File: D:/web/matomo.vdk/plugins/SegmentEditor/vue/src/SegmentGenerator/SegmentGenerator.vue
<!--
Matomo - free/libre analytics platform
@link https://matomo.org
@license https://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)"
/>
<div class="segment-loading">
<MatomoLoader v-show="conditionValuesLoading[orCondition.id]" />
</div>
<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="onSegmentSelection($event, orCondition)"
: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
:value="orCondition.value"
@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,
Matomo,
MatomoLoader,
} 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.slice(0, minPos);
newMetric.matches = metric.slice(minPos, minPos + 1);
newMetric.value = decodeURIComponent(metric.slice(minPos + 1));
} else {
newMetric.segment = metric.slice(0, minPos);
newMetric.matches = metric.slice(minPos, minPos + 2);
newMetric.value = decodeURIComponent(metric.slice(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: {
type: [String, Number],
default: () => Matomo.idSite,
},
modelValue: {
type: String,
default: '',
},
},
components: {
ActivityIndicator,
Field,
MatomoLoader,
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) {
if (!this.firstSegment) {
return; // skip till list of segments is available
}
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);
});
},
onSegmentSelection(event: string, orCondition: SegmentOrCondition) {
orCondition.segment = event;
this.updateAutocomplete(orCondition);
this.computeSegmentDefinition();
this.focusValueInput(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,
idSite: this.idsite,
},
{
createErrorNotification: false, // don't show errors returned from the API in UI
},
).then((response) => {
this.conditionValuesLoading[orCondition.id!] = false;
resolved = true;
let autocompleteValues = response;
if (Array.isArray(autocompleteValues)) {
autocompleteValues = autocompleteValues.map((v) => `${v}`);
}
const inputElement = $(`.orCondId${orCondition.id} .metricValueBlock input`)
.autocomplete({
source: autocompleteValues,
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|null) {
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: [] };
if (!this.firstSegment) {
return; // skip till list of segments is available
}
this.addAndCondition(condition);
this.addNewOrCondition(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;
},
focusValueInput(orCondition: SegmentOrCondition) {
const $input = $(`.orCondId${orCondition.id} .metricValueBlock input`);
$input.focus();
if ($input.val()) {
$input.select();
}
},
},
computed: {
firstSegment() {
return this.queriedSegments[0]?.segment || null;
},
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>