Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/matomo-org/matomo.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordizzy <diosmosis@users.noreply.github.com>2022-03-31 16:20:54 +0300
committerGitHub <noreply@github.com>2022-03-31 16:20:54 +0300
commit19489d103c6b4e91a745492e7ccffd957c3dcb4f (patch)
tree5bfcf0bbb6d2725876a01935f35500e7380e1595 /plugins/SegmentEditor/vue/src
parent09e1412dcd0e142c760eb5447af254a817b7b056 (diff)
[Vue] migrate segment generator directive to Vue (#18993)
* start migrating segment generator directive * get to build * remove some TODO * rebuilt * get UI tests to pass * fix ng-model handling * remote todo * built vue files
Diffstat (limited to 'plugins/SegmentEditor/vue/src')
-rw-r--r--plugins/SegmentEditor/vue/src/SegmentGenerator/SegmentGenerator.adapter.ts91
-rw-r--r--plugins/SegmentEditor/vue/src/SegmentGenerator/SegmentGenerator.less213
-rw-r--r--plugins/SegmentEditor/vue/src/SegmentGenerator/SegmentGenerator.store.ts94
-rw-r--r--plugins/SegmentEditor/vue/src/SegmentGenerator/SegmentGenerator.vue523
-rw-r--r--plugins/SegmentEditor/vue/src/SegmentGenerator/ValueInput.vue38
-rw-r--r--plugins/SegmentEditor/vue/src/index.ts12
-rw-r--r--plugins/SegmentEditor/vue/src/types.ts30
7 files changed, 1001 insertions, 0 deletions
diff --git a/plugins/SegmentEditor/vue/src/SegmentGenerator/SegmentGenerator.adapter.ts b/plugins/SegmentEditor/vue/src/SegmentGenerator/SegmentGenerator.adapter.ts
new file mode 100644
index 0000000000..49642797f3
--- /dev/null
+++ b/plugins/SegmentEditor/vue/src/SegmentGenerator/SegmentGenerator.adapter.ts
@@ -0,0 +1,91 @@
+/*!
+ * 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 { nextTick } from 'vue';
+import {
+ createAngularJsAdapter,
+ removeAngularJsSpecificProperties,
+ transformAngularJsBoolAttr,
+} from 'CoreHome';
+import SegmentGenerator from './SegmentGenerator.vue';
+
+export default createAngularJsAdapter<[ITimeoutService]>({
+ component: SegmentGenerator,
+ require: '?ngModel',
+ scope: {
+ segmentDefinition: {
+ angularJsBind: '@',
+ vue: 'modelValue',
+ },
+ addInitialCondition: {
+ angularJsBind: '=',
+ transform: transformAngularJsBoolAttr,
+ },
+ visitSegmentsOnly: {
+ angularJsBind: '=',
+ transform: transformAngularJsBoolAttr,
+ },
+ idsite: {
+ angularJsBind: '=',
+ },
+ },
+ directiveName: 'piwikSegmentGenerator',
+ $inject: ['$timeout'],
+ events: {
+ 'update:modelValue': (newValue, vm, scope, element, attrs, ngModel, $timeout) => {
+ const currentValue = ngModel ? ngModel.$viewValue : scope.segmentDefinition;
+ if (newValue !== currentValue) {
+ $timeout(() => {
+ if (!ngModel) {
+ scope.segmentDefinition = newValue;
+ return;
+ }
+
+ // ngModel being used
+ (ngModel as INgModelController).$setViewValue(newValue);
+ (ngModel as INgModelController).$render(); // not detected by the watch for some reason
+ });
+ }
+ },
+ },
+ postCreate(vm, scope, element, attrs, controller) {
+ // methods to forward for BC
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (element.scope() as any).segmentGenerator = {
+ getSegmentString(): string {
+ return vm.modelValue;
+ },
+ };
+
+ const ngModel = controller as INgModelController;
+ if (!ngModel) {
+ scope.$watch('segmentDefinition', (newVal: unknown) => {
+ if (newVal !== vm.modelValue) {
+ nextTick(() => {
+ vm.modelValue = newVal;
+ });
+ }
+ });
+
+ return;
+ }
+
+ // ngModel being used
+ ngModel.$render = () => {
+ nextTick(() => {
+ vm.modelValue = removeAngularJsSpecificProperties(ngModel.$viewValue);
+ });
+ };
+
+ if (typeof scope.segmentDefinition !== 'undefined') {
+ (ngModel as INgModelController).$setViewValue(scope.segmentDefinition);
+ } else {
+ ngModel.$setViewValue(vm.modelValue);
+ }
+ },
+});
diff --git a/plugins/SegmentEditor/vue/src/SegmentGenerator/SegmentGenerator.less b/plugins/SegmentEditor/vue/src/SegmentGenerator/SegmentGenerator.less
new file mode 100644
index 0000000000..778021f446
--- /dev/null
+++ b/plugins/SegmentEditor/vue/src/SegmentGenerator/SegmentGenerator.less
@@ -0,0 +1,213 @@
+.segment-generator {
+ width: 930px;
+
+ @media screen and (max-width: 940px) {
+ width: 100%;
+ }
+
+ .segment-row-inputs {
+ .form-group {
+ margin-top: 0;
+ margin-bottom: 0;
+
+ .input-field {
+ margin-top: 0;
+ }
+ }
+ }
+
+ .segment-input input {
+ display: block;
+ width: 96%;
+ padding: 8px 2%;
+ }
+ .segment-input label {
+ display: block;
+ margin: 0 0 5px 0;
+ font-size: 11px;
+ color: #505050;
+ }
+ .segment-input {
+ float: left;
+ padding: 6px 0 5px 3px;
+ border: 2px dashed #EFEFEB;
+ margin-right: 3px;
+ }
+
+ .segment-rows {
+ padding: 4px;
+ margin: 0 3px 0 0;
+ border: 1px solid #a9a399;
+ border-radius: 3px 3px 3px 3px;
+ position: relative;
+ box-shadow: 0 12px 6px -10px rgba(0, 0, 0, 0.42);
+ }
+
+ .segment-add-row,
+ .segment-add-or {
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: bold;
+ background: @theme-color-background-contrast;
+ color: #b9b9b9;
+ text-align: center;
+ position: relative;
+ .border-radius(5px);
+ }
+
+ .segment-add-row > div,
+ .segment-add-or > div {
+ border-radius: 4px;
+ border: 2px dashed #fff;
+ padding: 10px 0;
+ }
+
+ .segment-add-row > div a,
+ .segment-add-or > div a {
+ color: #b9b9b9;
+ text-decoration: none;
+ }
+
+ .segment-input {
+ select, input {
+ .font-default(12px, 14px);
+ color: @theme-color-text;
+ font-weight: 600;
+ margin: 0;
+ height: 32px;
+ }
+ }
+
+ .segment-add-row > div a span,
+ .segment-add-or > div a span {
+ color: @theme-color-brand;
+ text-shadow: none;
+ }
+
+ .segment-add-row {
+ margin: 0 3px 0 0;
+ padding: 0 12px;
+ border: 1px solid #a9a399;
+ border-radius: 3px 3px 3px 3px;
+ box-shadow: 0 12px 6px -10px rgba(0, 0, 0, 0.42);
+ }
+
+ .segment-add-or {
+ text-shadow: 0 1px 0 #fff;
+ display: inline-block;
+ width: 100%;
+ padding: 0 1%;
+ background: #efefeb;
+ border-radius: 3px 3px 3px 3px;
+ }
+
+ .segment-add-or > div {
+ border: 2px dashed #EFEFEB;
+ background-color: #efefeb;
+ }
+
+ .segment-row {
+ border-radius: 3px;
+ display: inline-block;
+ position: relative;
+ width: 100%;
+ padding: 12px 1%;
+ background: #efefeb;
+ padding: 0 5px 0 5px;
+
+ @media screen and (max-width: 749px) {
+ width: 100%;
+ }
+
+ }
+
+ .segment-row .segment-close {
+ top: 15px;
+ right: 6px;
+ position: absolute;
+ width: 15px;
+ height: 15px;
+ background: url(plugins/SegmentEditor/images/segment-close.png) 0 0 no-repeat;
+ z-index: 9999;
+ }
+
+ .segment-row .segment-loading {
+ top: 25px;
+ right: 30px;
+ position: absolute;
+ width: 15px;
+ height: 15px;
+ background: url(plugins/Morpheus/images/loading-blue.gif) 0 0 no-repeat;
+ }
+
+ .segment-or {
+ display: inline-block;
+ margin: 0 0 0 6%;
+ position: relative;
+ background: #efefeb;
+ padding: 5px 28px;
+ color: #4f4f4f;
+ font-weight: bold;
+ font-size: 14px;
+ text-shadow: 0 1px 0 #fff;
+ }
+
+ .segment-or:before,
+ .segment-or:after {
+ content: '';
+ position: absolute;
+ background: @theme-color-background-base;
+ border: 1px solid #efefeb;
+ width: 10px;
+ top: -1px;
+ bottom: -1px;
+ }
+
+ .segment-or:before {
+ border-left: none;
+ left: 0;
+ border-radius: 0 5px 5px 0;
+ }
+
+ .segment-or:after {
+ border-right: none;
+ right: 0;
+ border-radius: 5px 0 0 5px;
+ }
+
+ .segment-and {
+ display: inline-block;
+ margin: -1px 0 -1px 6%;
+ z-index: 1;
+ position: relative;
+ background: @theme-color-background-contrast;
+ padding: 5px 35px;
+ color: #4f4f4f;
+ font-size: 14px;
+ font-weight: bold;
+ text-shadow: 0 1px 0 #fff;
+ }
+
+ .segment-and:before,
+ .segment-and:after {
+ content: '';
+ position: absolute;
+ background: url(plugins/SegmentEditor/images/bg-inverted-corners.png);
+ border: 1px solid #a9a399;
+ width: 10px;
+ top: 0;
+ bottom: 0;
+ }
+
+ .segment-and:before {
+ border-left: none;
+ left: 0;
+ border-radius: 0 5px 5px 0;
+ }
+
+ .segment-and:after {
+ border-right: none;
+ right: 0;
+ border-radius: 5px 0 0 5px;
+ }
+}
diff --git a/plugins/SegmentEditor/vue/src/SegmentGenerator/SegmentGenerator.store.ts b/plugins/SegmentEditor/vue/src/SegmentGenerator/SegmentGenerator.store.ts
new file mode 100644
index 0000000000..46d68274d4
--- /dev/null
+++ b/plugins/SegmentEditor/vue/src/SegmentGenerator/SegmentGenerator.store.ts
@@ -0,0 +1,94 @@
+/*!
+ * 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,
+ DeepReadonly,
+} from 'vue';
+import { AjaxHelper } from 'CoreHome';
+import { SegmentMetadata } from '../types';
+
+interface SegmentGeneratorStoreState {
+ isLoading: boolean;
+ segments: SegmentMetadata[];
+}
+
+class SegmentGeneratorStore {
+ private privateState: SegmentGeneratorStoreState = reactive<SegmentGeneratorStoreState>({
+ isLoading: false,
+ segments: [],
+ });
+
+ readonly state = computed(() => readonly(this.privateState));
+
+ private loadSegmentsAbort?: AbortController;
+
+ private loadSegmentsPromise?: Promise<SegmentMetadata[]>;
+
+ private fetchedSiteId?: string|number;
+
+ loadSegments(
+ siteId?: string|number,
+ visitSegmentsOnly?: boolean,
+ ): Promise<DeepReadonly<SegmentMetadata[]>> {
+ if (this.loadSegmentsAbort) {
+ this.loadSegmentsAbort.abort();
+ this.loadSegmentsAbort = undefined;
+ }
+
+ this.privateState.isLoading = true;
+
+ if (this.fetchedSiteId !== siteId) {
+ this.loadSegmentsAbort = undefined;
+ this.fetchedSiteId = siteId;
+ }
+
+ if (!this.loadSegmentsPromise) {
+ let idSites: string|number|undefined = undefined;
+ let idSite: string|number|undefined = undefined;
+
+ if (siteId === 'all' || !siteId) {
+ idSites = 'all';
+ idSite = 'all';
+ } else if (siteId) {
+ idSites = siteId;
+ idSite = siteId;
+ }
+
+ this.loadSegmentsAbort = new AbortController();
+ this.loadSegmentsPromise = AjaxHelper.fetch<SegmentMetadata[]>({
+ method: 'API.getSegmentsMetadata',
+ filter_limit: '-1',
+ _hideImplementationData: 0,
+ idSites,
+ idSite,
+ });
+ }
+
+ return this.loadSegmentsPromise.then((response) => {
+ this.privateState.isLoading = false;
+
+ if (response) {
+ if (visitSegmentsOnly) {
+ this.privateState.segments = response.filter(
+ (s) => s.sqlSegment && s.sqlSegment.match(/log_visit\./),
+ );
+ } else {
+ this.privateState.segments = response;
+ }
+ }
+
+ return this.state.value.segments;
+ }).finally(() => {
+ this.privateState.isLoading = false;
+ });
+ }
+}
+
+export default new SegmentGeneratorStore();
diff --git a/plugins/SegmentEditor/vue/src/SegmentGenerator/SegmentGenerator.vue b/plugins/SegmentEditor/vue/src/SegmentGenerator/SegmentGenerator.vue
new file mode 100644
index 0000000000..7fd9b5db91
--- /dev/null
+++ b/plugins/SegmentEditor/vue/src/SegmentGenerator/SegmentGenerator.vue
@@ -0,0 +1,523 @@
+<!--
+ 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="segment-generator" ref="root">
+ <ActivityIndicator :loading="isLoading" />
+ <div
+ :class="`segmentRow${conditionIndex}`"
+ v-for="(condition, conditionIndex) in conditions"
+ :key="conditionIndex"
+ >
+ <div class="segment-rows">
+ <div
+ :class="`orCondId${orCondition.id}`"
+ v-for="(orCondition, orConditionIndex) in condition.orConditions"
+ :key="orConditionIndex"
+ >
+ <div class="segment-row">
+ <a
+ class="segment-close"
+ @click="removeOrCondition(condition, orCondition)"
+ />
+ <a
+ href="#"
+ class="segment-loading"
+ v-show="conditionValuesLoading[orCondition.id]"
+ />
+ <div class="segment-row-inputs valign-wrapper">
+ <div class="segment-input metricListBlock valign-wrapper">
+ <div style="width: 100%;">
+ <Field
+ uicontrol="expandable-select"
+ name="segments"
+ :model-value="orCondition.segment"
+ @update:model-value="orCondition.segment = $event;
+ updateAutocomplete(orCondition); computeSegmentDefinition();"
+ :title="segments[orCondition.segment]?.name"
+ :full-width="true"
+ :options="segmentList"
+ >
+ </Field>
+ </div>
+ </div>
+ <div class="segment-input metricMatchBlock valign-wrapper">
+ <div style="display: inline-block">
+ <Field
+ uicontrol="select"
+ name="matchType"
+ :model-value="orCondition.matches"
+ @update:model-value="orCondition.matches = $event; computeSegmentDefinition();"
+ :full-width="true"
+ :options="matches[segments[orCondition.segment]?.type]"
+ >
+ </Field>
+ </div>
+ </div>
+ <div class="segment-input metricValueBlock valign-wrapper">
+ <div
+ class="form-group row"
+ style="width: 100%;"
+ >
+ <div class="input-field col s12">
+ <span
+ role="status"
+ aria-live="polite"
+ class="ui-helper-hidden-accessible"
+ />
+ <ValueInput
+ :or="orCondition"
+ @update="orCondition.value = $event;
+ // deep watch doesn't catch this change
+ this.computeSegmentDefinition();"
+ />
+ </div>
+ </div>
+ </div>
+ <div class="clear" />
+ </div>
+ </div>
+ <div class="segment-or">{{ translate('SegmentEditor_OperatorOR') }}</div>
+ </div>
+ <div
+ class="segment-add-or"
+ @click="addNewOrCondition(condition)"
+ >
+ <div>
+ <a v-html="$sanitize(addNewOrConditionLinkText)" />
+ </div>
+ </div>
+ </div>
+ <div class="segment-and">{{ translate('SegmentEditor_OperatorAND') }}</div>
+ </div>
+ <div
+ class="segment-add-row initial"
+ @click="addNewAndCondition()"
+ >
+ <div>
+ <a v-html="$sanitize(addNewAndConditionLinkText)" />
+ </div>
+ </div>
+ </div>
+</template>
+
+<script lang="ts">
+import { DeepReadonly, defineComponent, nextTick } from 'vue';
+import {
+ translate,
+ AjaxHelper,
+ ActivityIndicator,
+} from 'CoreHome';
+import { Field } from 'CorePluginsAdmin';
+import SegmentGeneratorStore from './SegmentGenerator.store';
+import { SegmentAndCondition, SegmentMetadata, SegmentOrCondition } from '../types';
+import ValueInput from './ValueInput.vue';
+
+interface SegmentGeneratorState {
+ conditions: SegmentAndCondition[];
+ matches: Record<string, { key: string, value: string }[]>;
+ queriedSegments: DeepReadonly<SegmentMetadata[]>;
+ conditionValuesLoading: Record<string, boolean>;
+ segmentDefinition: string;
+}
+
+function initialMatches() {
+ return {
+ metric: [
+ {
+ key: '==',
+ value: translate('General_OperationEquals'),
+ },
+ {
+ key: '!=',
+ value: translate('General_OperationNotEquals'),
+ },
+ {
+ key: '<=',
+ value: translate('General_OperationAtMost'),
+ },
+ {
+ key: '>=',
+ value: translate('General_OperationAtLeast'),
+ },
+ {
+ key: '<',
+ value: translate('General_OperationLessThan'),
+ },
+ {
+ key: '>',
+ value: translate('General_OperationGreaterThan'),
+ },
+ ],
+ dimension: [
+ {
+ key: '==',
+ value: translate('General_OperationIs'),
+ },
+ {
+ key: '!=',
+ value: translate('General_OperationIsNot'),
+ },
+ {
+ key: '=@',
+ value: translate('General_OperationContains'),
+ },
+ {
+ key: '!@',
+ value: translate('General_OperationDoesNotContain'),
+ },
+ {
+ key: '=^',
+ value: translate('General_OperationStartsWith'),
+ },
+ {
+ key: '=$',
+ value: translate('General_OperationEndsWith'),
+ },
+ ],
+ };
+}
+
+function generateUniqueId() {
+ let id = '';
+ const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
+
+ for (let i = 1; i <= 10; i += 1) {
+ id += chars.charAt(Math.floor(Math.random() * chars.length));
+ }
+
+ return id;
+}
+
+function findAndExplodeByMatch(metric: string) {
+ const matches = ['==', '!=', '<=', '>=', '=@', '!@', '<', '>', '=^', '=$'];
+ const newMetric: SegmentOrCondition = {} as unknown as SegmentOrCondition;
+ let minPos = metric.length;
+ let match;
+ let index: number;
+ let singleChar = false;
+
+ for (let key = 0; key < matches.length; key += 1) {
+ match = matches[key];
+ index = metric.indexOf(match);
+ if (index !== -1) {
+ if (index < minPos) {
+ minPos = index;
+ if (match.length === 1) {
+ singleChar = true;
+ }
+ }
+ }
+ }
+
+ if (minPos < metric.length) {
+ // sth found - explode
+ if (singleChar === true) {
+ newMetric.segment = metric.substr(0, minPos);
+ newMetric.matches = metric.substr(minPos, 1);
+ newMetric.value = decodeURIComponent(metric.substr(minPos + 1));
+ } else {
+ newMetric.segment = metric.substr(0, minPos);
+ newMetric.matches = metric.substr(minPos, 2);
+ newMetric.value = decodeURIComponent(metric.substr(minPos + 2));
+ }
+
+ // if value is only '' -> change to empty string
+ if (newMetric.value === '""') {
+ newMetric.value = '';
+ }
+ }
+
+ try {
+ // Decode again to deal with double-encoded segments in database
+ newMetric.value = decodeURIComponent(newMetric.value);
+ } catch (e) {
+ // Expected if the segment was not double-encoded
+ }
+
+ return newMetric;
+}
+
+function stripTags(text?: unknown) {
+ return text ? `${text}`.replace(/(<([^>]+)>)/ig, '') : text;
+}
+
+const { $ } = window;
+
+export default defineComponent({
+ props: {
+ addInitialCondition: Boolean,
+ visitSegmentsOnly: Boolean,
+ idsite: [String, Number],
+ modelValue: {
+ type: String,
+ default: '',
+ },
+ },
+ components: {
+ ActivityIndicator,
+ Field,
+ ValueInput,
+ },
+ data(): SegmentGeneratorState {
+ return {
+ conditions: [],
+ queriedSegments: [],
+ matches: initialMatches(),
+ conditionValuesLoading: {},
+ segmentDefinition: '',
+ };
+ },
+ emits: ['update:modelValue'],
+ watch: {
+ modelValue(newVal) {
+ if (newVal !== this.segmentDefinition) {
+ this.setSegmentString(newVal);
+ }
+ },
+ conditions: {
+ deep: true,
+ handler() {
+ this.computeSegmentDefinition();
+ },
+ },
+ segmentDefinition(newVal) {
+ if (newVal !== this.modelValue) {
+ this.$emit('update:modelValue', newVal);
+ }
+ },
+ idsite(newVal) {
+ this.reloadSegments(newVal, this.visitSegmentsOnly);
+ },
+ },
+ created() {
+ this.matches[''] = this.matches.dimension;
+ this.setSegmentString(this.modelValue);
+ this.segmentDefinition = this.modelValue;
+
+ this.reloadSegments(this.idsite, this.visitSegmentsOnly);
+ },
+ methods: {
+ reloadSegments(idsite?: string|number, visitSegmentsOnly?: boolean) {
+ SegmentGeneratorStore.loadSegments(idsite, visitSegmentsOnly).then((segments) => {
+ this.queriedSegments = segments.map((s) => ({
+ ...s,
+ category: s.category || 'Others',
+ }));
+
+ if (this.addInitialCondition && this.conditions.length === 0) {
+ this.addNewAndCondition();
+ }
+ });
+ },
+ addAndCondition(condition: SegmentAndCondition) {
+ this.conditions.push(condition);
+ },
+ addNewOrCondition(condition: SegmentAndCondition) {
+ const orCondition = {
+ segment: this.firstSegment,
+ matches: this.firstMatch!,
+ value: '',
+ };
+
+ this.addOrCondition(condition, orCondition);
+ },
+ addOrCondition(condition: SegmentAndCondition, orCondition: SegmentOrCondition) {
+ this.conditionValuesLoading[orCondition.id!] = false;
+ orCondition.id = generateUniqueId();
+
+ condition.orConditions.push(orCondition);
+
+ nextTick(() => {
+ this.updateAutocomplete(orCondition);
+ });
+ },
+ updateAutocomplete(orCondition: SegmentOrCondition) {
+ this.conditionValuesLoading[orCondition.id!] = true;
+
+ $(`.orCondId${orCondition.id} .metricValueBlock input`, this.$refs.root as HTMLElement)
+ .autocomplete({
+ source: [],
+ minLength: 0,
+ });
+
+ const abortController = new AbortController();
+
+ let resolved = false;
+ AjaxHelper.fetch<string[]>(
+ {
+ module: 'API',
+ format: 'json',
+ method: 'API.getSuggestedValuesForSegment',
+ segmentName: orCondition.segment,
+ },
+ ).then((response) => {
+ this.conditionValuesLoading[orCondition.id!] = false;
+ resolved = true;
+
+ const inputElement = $(`.orCondId${orCondition.id} .metricValueBlock input`)
+ .autocomplete({
+ source: response,
+ minLength: 0,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ select: (event: Event, ui: any) => {
+ event.preventDefault();
+
+ orCondition.value = ui.item.value;
+ this.computeSegmentDefinition(); // deep watch doesn't catch this change
+ this.$forceUpdate();
+ },
+ })
+ .off('click')
+ .click(() => {
+ $(inputElement).autocomplete('search', orCondition.value);
+ });
+ }).catch(() => {
+ resolved = true;
+
+ this.conditionValuesLoading[orCondition.id!] = false;
+
+ $(`.orCondId${orCondition.id} .metricValueBlock input`)
+ .autocomplete({
+ source: [],
+ minLength: 0,
+ })
+ .autocomplete('search', orCondition.value);
+ });
+
+ setTimeout(() => {
+ if (!resolved) {
+ abortController.abort();
+ }
+ }, 20000);
+ },
+ removeOrCondition(condition: SegmentAndCondition, orCondition: SegmentOrCondition) {
+ const index = condition.orConditions.indexOf(orCondition);
+
+ if (index > -1) {
+ condition.orConditions.splice(index, 1);
+ }
+
+ if (condition.orConditions.length === 0) {
+ const andCondIndex = this.conditions.indexOf(condition);
+
+ if (index > -1) {
+ this.conditions.splice(andCondIndex, 1);
+ }
+ }
+ },
+ setSegmentString(segmentStr: string) {
+ this.conditions = [];
+
+ if (!segmentStr) {
+ return;
+ }
+
+ const blocks = segmentStr.split(';').map((b) => b.split(','));
+ this.conditions = blocks.map((block) => {
+ const condition: SegmentAndCondition = { orConditions: [] };
+
+ block.forEach((innerBlock) => {
+ const orCondition: SegmentOrCondition = findAndExplodeByMatch(innerBlock);
+ this.addOrCondition(condition, orCondition);
+ });
+
+ return condition;
+ });
+ },
+ addNewAndCondition() {
+ const condition = { orConditions: [] };
+
+ this.addAndCondition(condition);
+ this.addNewOrCondition(condition);
+
+ return condition;
+ },
+ // NOTE: can't use a computed property since we need to recompute on changes inside the
+ // structure. don't have to if we don't do in-place changes, but with nested structures,
+ // that's complicated.
+ computeSegmentDefinition() {
+ let segmentStr = '';
+
+ this.conditions.forEach((condition) => {
+ if (!condition.orConditions.length) {
+ return;
+ }
+
+ let subSegmentStr = '';
+ condition.orConditions.forEach((orCondition) => {
+ if (!orCondition.value && !orCondition.segment && !orCondition.matches) {
+ return;
+ }
+
+ if (subSegmentStr !== '') {
+ subSegmentStr += ','; // OR operator
+ }
+
+ // one encode for urldecode on value, one encode for urldecode on condition
+ const value = encodeURIComponent(encodeURIComponent(orCondition.value));
+ subSegmentStr += `${orCondition.segment}${orCondition.matches}${value}`;
+ });
+
+ if (segmentStr !== '') {
+ segmentStr += ';'; // add AND operator between segment blocks
+ }
+
+ segmentStr += subSegmentStr;
+ });
+
+ this.segmentDefinition = segmentStr;
+ },
+ },
+ computed: {
+ firstSegment() {
+ return this.queriedSegments[0].segment;
+ },
+ firstMatch() {
+ const segment = this.queriedSegments[0];
+ if (!segment) {
+ return null;
+ }
+
+ if (segment.type && this.matches[segment.type]) {
+ return this.matches[segment.type][0].key;
+ }
+
+ return this.matches[''][0].key;
+ },
+ segments() {
+ const result: Record<string, SegmentMetadata> = {};
+ this.queriedSegments.forEach((s) => {
+ result[s.segment] = s;
+ });
+ return result;
+ },
+ segmentList() {
+ return this.queriedSegments.map((s) => ({
+ group: s.category,
+ key: s.segment,
+ value: s.name,
+ tooltip: s.acceptedValues ? stripTags(s.acceptedValues) : undefined,
+ }));
+ },
+ addNewOrConditionLinkText() {
+ return `+${translate(
+ 'SegmentEditor_AddANDorORCondition',
+ `<span>${translate('SegmentEditor_OperatorOR')}</span>`,
+ )}`;
+ },
+ andConditionLabel() {
+ return this.conditions.length ? translate('SegmentEditor_OperatorAND') : '';
+ },
+ addNewAndConditionLinkText() {
+ return `+${translate('SegmentEditor_AddANDorORCondition', `<span>${this.andConditionLabel}</span>`)}`;
+ },
+ isLoading() {
+ return SegmentGeneratorStore.state.value.isLoading;
+ },
+ },
+});
+</script>
diff --git a/plugins/SegmentEditor/vue/src/SegmentGenerator/ValueInput.vue b/plugins/SegmentEditor/vue/src/SegmentGenerator/ValueInput.vue
new file mode 100644
index 0000000000..15880bb0db
--- /dev/null
+++ b/plugins/SegmentEditor/vue/src/SegmentGenerator/ValueInput.vue
@@ -0,0 +1,38 @@
+<!--
+ Matomo - free/libre analytics platform
+ @link https://matomo.org
+ @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+-->
+
+<template>
+ <input
+ :placeholder="translate('General_Value')"
+ type="text"
+ class="autocomplete"
+ :title="translate('General_Value')"
+ autocomplete="off"
+ :value="or.value"
+ @keydown="onKeydownOrConditionValue($event)"
+ @change="onKeydownOrConditionValue($event)"
+ />
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { debounce } from 'CoreHome';
+
+export default defineComponent({
+ props: {
+ or: Object,
+ },
+ created() {
+ this.onKeydownOrConditionValue = debounce(this.onKeydownOrConditionValue, 50);
+ },
+ emits: ['update'],
+ methods: {
+ onKeydownOrConditionValue(event: Event) {
+ this.$emit('update', (event.target as HTMLInputElement).value);
+ },
+ },
+});
+</script>
diff --git a/plugins/SegmentEditor/vue/src/index.ts b/plugins/SegmentEditor/vue/src/index.ts
new file mode 100644
index 0000000000..5258822104
--- /dev/null
+++ b/plugins/SegmentEditor/vue/src/index.ts
@@ -0,0 +1,12 @@
+/*!
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+import './SegmentGenerator/SegmentGenerator.adapter';
+
+export * from './types';
+export { default as SegmentGeneratorStore } from './SegmentGenerator/SegmentGenerator.store';
+export { default as SegmentGenerator } from './SegmentGenerator/SegmentGenerator.vue';
diff --git a/plugins/SegmentEditor/vue/src/types.ts b/plugins/SegmentEditor/vue/src/types.ts
new file mode 100644
index 0000000000..62aab9d1f5
--- /dev/null
+++ b/plugins/SegmentEditor/vue/src/types.ts
@@ -0,0 +1,30 @@
+/*!
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+export interface SegmentMetadata {
+ acceptedValues: string;
+ category: string;
+ name: string;
+ needsMostFrequentValues: boolean;
+ segment: string;
+ sqlFilterValue: unknown;
+ sqlSegment: string;
+ type: string;
+}
+
+export interface SegmentOrCondition {
+ segment: string;
+ matches: string;
+ value: string;
+
+ id?: string;
+ isLoading?: boolean;
+}
+
+export interface SegmentAndCondition {
+ orConditions: SegmentOrCondition[];
+}