diff options
author | dizzy <diosmosis@users.noreply.github.com> | 2022-03-31 16:20:54 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-03-31 16:20:54 +0300 |
commit | 19489d103c6b4e91a745492e7ccffd957c3dcb4f (patch) | |
tree | 5bfcf0bbb6d2725876a01935f35500e7380e1595 /plugins/SegmentEditor/vue/src | |
parent | 09e1412dcd0e142c760eb5447af254a817b7b056 (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')
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[]; +} |