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:
authorBen <ben.burgess@innocraft.com>2022-05-25 04:25:04 +0300
committerBen <ben.burgess@innocraft.com>2022-05-25 04:25:04 +0300
commit7a3865b02d6c4511db639d61f6de7e9c216fa675 (patch)
treef73b884c3ce969fba97b391d5652ea0a308d6b7d /plugins/CorePluginsAdmin/vue/src/FormField
parent276ed587372cd76f44e51f9a98462c45f1eb8f5c (diff)
parent53c00a78caf96d24dd8f7f74dc8fd74268b312b1 (diff)
Merge branch '4.x-dev' into lockaltlockalt
Diffstat (limited to 'plugins/CorePluginsAdmin/vue/src/FormField')
-rw-r--r--plugins/CorePluginsAdmin/vue/src/FormField/FieldAngularJsTemplate.vue104
-rw-r--r--plugins/CorePluginsAdmin/vue/src/FormField/FieldCheckbox.vue55
-rw-r--r--plugins/CorePluginsAdmin/vue/src/FormField/FieldCheckboxArray.vue89
-rw-r--r--plugins/CorePluginsAdmin/vue/src/FormField/FieldExpandableSelect.less88
-rw-r--r--plugins/CorePluginsAdmin/vue/src/FormField/FieldExpandableSelect.vue190
-rw-r--r--plugins/CorePluginsAdmin/vue/src/FormField/FieldFieldArray.vue42
-rw-r--r--plugins/CorePluginsAdmin/vue/src/FormField/FieldFile.vue60
-rw-r--r--plugins/CorePluginsAdmin/vue/src/FormField/FieldHidden.vue35
-rw-r--r--plugins/CorePluginsAdmin/vue/src/FormField/FieldMultituple.less3
-rw-r--r--plugins/CorePluginsAdmin/vue/src/FormField/FieldMultituple.vue45
-rw-r--r--plugins/CorePluginsAdmin/vue/src/FormField/FieldNumber.vue56
-rw-r--r--plugins/CorePluginsAdmin/vue/src/FormField/FieldRadio.vue69
-rw-r--r--plugins/CorePluginsAdmin/vue/src/FormField/FieldSelect.less3
-rw-r--r--plugins/CorePluginsAdmin/vue/src/FormField/FieldSelect.vue315
-rw-r--r--plugins/CorePluginsAdmin/vue/src/FormField/FieldSite.vue47
-rw-r--r--plugins/CorePluginsAdmin/vue/src/FormField/FieldText.vue74
-rw-r--r--plugins/CorePluginsAdmin/vue/src/FormField/FieldTextArray.vue62
-rw-r--r--plugins/CorePluginsAdmin/vue/src/FormField/FieldTextarea.vue58
-rw-r--r--plugins/CorePluginsAdmin/vue/src/FormField/FieldTextareaArray.vue82
-rw-r--r--plugins/CorePluginsAdmin/vue/src/FormField/FormField.adapter.ts150
-rw-r--r--plugins/CorePluginsAdmin/vue/src/FormField/FormField.vue412
-rw-r--r--plugins/CorePluginsAdmin/vue/src/FormField/utilities.ts37
22 files changed, 2076 insertions, 0 deletions
diff --git a/plugins/CorePluginsAdmin/vue/src/FormField/FieldAngularJsTemplate.vue b/plugins/CorePluginsAdmin/vue/src/FormField/FieldAngularJsTemplate.vue
new file mode 100644
index 0000000000..c608e33682
--- /dev/null
+++ b/plugins/CorePluginsAdmin/vue/src/FormField/FieldAngularJsTemplate.vue
@@ -0,0 +1,104 @@
+<!--
+ 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 ref="root"/>
+</template>
+
+<script lang="ts">
+import {
+ defineComponent,
+ ref,
+ watch,
+ onMounted,
+} from 'vue';
+import { Matomo } from 'CoreHome';
+
+function clone<T>(obj?: T): T|undefined {
+ if (typeof obj === 'undefined') {
+ return undefined;
+ }
+
+ return JSON.parse(JSON.stringify(obj));
+}
+
+export default defineComponent({
+ props: {
+ modelValue: null,
+ formField: {
+ type: null,
+ required: true,
+ },
+ templateFile: {
+ type: String,
+ required: true,
+ },
+ },
+ emits: ['update:modelValue'],
+ inheritAttrs: false,
+ setup(props, context) {
+ const root = ref(null);
+
+ const $element = window.$(
+ `<div ng-include="'${props.templateFile}?cb=${Matomo.cacheBuster}'"></div>`,
+ );
+
+ const $timeout = Matomo.helper.getAngularDependency('$timeout');
+ const $rootScope = Matomo.helper.getAngularDependency('$rootScope');
+
+ const scope = $rootScope.$new();
+ scope.formField = {
+ ...clone(props.formField),
+ value: clone(props.modelValue),
+ };
+
+ scope.$watch('formField.value', (newValue: unknown, oldValue: unknown) => {
+ if (newValue !== oldValue
+ && JSON.stringify(newValue) !== JSON.stringify(props.modelValue)
+ ) {
+ context.emit('update:modelValue', clone(newValue));
+ }
+ });
+
+ watch(() => props.modelValue, (newValue) => {
+ if (JSON.stringify(newValue) !== JSON.stringify(scope.formField.value)) {
+ $timeout(() => {
+ scope.formField.value = newValue;
+ });
+ }
+ });
+
+ watch(() => props.formField, (newValue) => {
+ $timeout(() => {
+ const currentValue = scope.formField.value;
+ scope.formField = {
+ ...clone(newValue),
+ value: currentValue,
+ };
+ });
+ }, { deep: true });
+
+ // append on mount
+ onMounted(() => {
+ window.$(root.value! as HTMLElement).append($element);
+
+ Matomo.helper.compileAngularComponents($element, {
+ scope,
+ params: {
+ formField: {
+ ...clone(props.formField),
+ value: props.modelValue,
+ },
+ },
+ });
+ });
+
+ return {
+ root,
+ };
+ },
+});
+</script>
diff --git a/plugins/CorePluginsAdmin/vue/src/FormField/FieldCheckbox.vue b/plugins/CorePluginsAdmin/vue/src/FormField/FieldCheckbox.vue
new file mode 100644
index 0000000000..3c242e2627
--- /dev/null
+++ b/plugins/CorePluginsAdmin/vue/src/FormField/FieldCheckbox.vue
@@ -0,0 +1,55 @@
+<!--
+ 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="checkbox">
+ <label>
+ <input
+ @change="onChange($event)"
+ v-bind="uiControlAttributes"
+ :value="1"
+ :checked="isChecked"
+ type="checkbox"
+ :id="name"
+ :name="name"
+ />
+
+ <span v-html="$sanitize(title)"/>
+ </label>
+ </div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ modelValue: [Boolean, Number, String],
+ uiControlAttributes: Object,
+ name: String,
+ title: String,
+ },
+ inheritAttrs: false,
+ emits: ['update:modelValue'],
+ methods: {
+ onChange(event: Event) {
+ const newValue = (event.target as HTMLInputElement).checked;
+ if (this.modelValue !== newValue) {
+ // undo checked change since we want the parent component to decide if it should go
+ // through
+ (event.target as HTMLInputElement).checked = !newValue;
+
+ this.$emit('update:modelValue', newValue);
+ }
+ },
+ },
+ computed: {
+ isChecked() {
+ return !!this.modelValue && this.modelValue !== '0';
+ },
+ },
+});
+</script>
diff --git a/plugins/CorePluginsAdmin/vue/src/FormField/FieldCheckboxArray.vue b/plugins/CorePluginsAdmin/vue/src/FormField/FieldCheckboxArray.vue
new file mode 100644
index 0000000000..b9e5c7036e
--- /dev/null
+++ b/plugins/CorePluginsAdmin/vue/src/FormField/FieldCheckboxArray.vue
@@ -0,0 +1,89 @@
+<!--
+ 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 ref="root">
+ <label class="fieldRadioTitle" v-show="title">{{ title }}</label>
+ <p
+ v-for="(checkboxModel, $index) in availableOptions"
+ :key="$index"
+ class="checkbox"
+ >
+ <label>
+ <input
+ :value="checkboxModel.key"
+ :checked="!!checkboxStates[$index]"
+ @change="onChange($index)"
+ v-bind="uiControlAttributes"
+ type="checkbox"
+ :id="`${name}${checkboxModel.key}`"
+ :name="checkboxModel.name"
+ />
+ <span>{{ checkboxModel.value }}</span>
+
+ <span class="form-description" v-show="checkboxModel.description">
+ {{ checkboxModel.description }}
+ </span>
+ </label>
+ </p>
+ </div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+interface Option {
+ key: unknown;
+}
+
+function getCheckboxStates(availableOptions?: Option[], modelValue?: unknown[]) {
+ return (availableOptions || []).map((o) => modelValue && modelValue.indexOf(o.key) !== -1);
+}
+
+export default defineComponent({
+ props: {
+ modelValue: Array,
+ name: String,
+ title: String,
+ availableOptions: Array,
+ uiControlAttributes: Object,
+ type: String,
+ },
+ inheritAttrs: false,
+ emits: ['update:modelValue'],
+ computed: {
+ checkboxStates() {
+ return getCheckboxStates(this.availableOptions as Option[], this.modelValue);
+ },
+ },
+ mounted() {
+ window.Materialize.updateTextFields();
+ },
+ methods: {
+ onChange(changedIndex: number) {
+ const checkboxStates = [...this.checkboxStates];
+ checkboxStates[changedIndex] = !checkboxStates[changedIndex];
+
+ const availableOptions = (this.availableOptions || {}) as Record<string, Option>;
+
+ const newValue: unknown[] = [];
+ Object.values(availableOptions).forEach((option: Option, index: number) => {
+ if (checkboxStates[index]) {
+ newValue.push(option.key);
+ }
+ });
+
+ // undo checked changes since we want the parent component to decide if it should go
+ // through
+ (this.$refs.root as HTMLElement).querySelectorAll('input').forEach((inp: HTMLInputElement) => {
+ inp.checked = !inp.checked;
+ });
+
+ this.$emit('update:modelValue', newValue);
+ },
+ },
+});
+</script>
diff --git a/plugins/CorePluginsAdmin/vue/src/FormField/FieldExpandableSelect.less b/plugins/CorePluginsAdmin/vue/src/FormField/FieldExpandableSelect.less
new file mode 100644
index 0000000000..75a8c2428c
--- /dev/null
+++ b/plugins/CorePluginsAdmin/vue/src/FormField/FieldExpandableSelect.less
@@ -0,0 +1,88 @@
+.expandableSelector {
+
+ position: relative;
+
+ .secondary-content {
+ font-size: 16px;
+ margin-top: -3px;
+ color: @theme-color-link;
+ cursor: help;
+ }
+
+ ul {
+ min-width: 250px;
+
+ &.collection.firstLevel {
+ border-top: 0;
+ margin-top: 0;
+ margin-bottom: 0;
+ font-size: 12px;
+
+ > li {
+ padding: 0 !important;
+ }
+ }
+
+ .expandableListCategory {
+ padding: 10px 20px;
+ color: @theme-color-link;
+ }
+
+ li {
+ &:hover {
+ background: #f2f2f2 !important;
+ }
+
+ &.collection-item {
+ cursor: pointer;
+ }
+ }
+
+ ul {
+ margin-top: 0;
+ margin-bottom: 0;
+
+ .primary-content {
+ width: 100%;
+ }
+ .secondary-content {
+ margin-top: 3px;
+ }
+
+ li {
+ padding-top: 6px !important;
+ padding-bottom: 6px !important;
+ padding-left: 30px !important;
+ min-width: 200px;
+
+ &:hover {
+ background: #f2f2f2 !important;
+ }
+ }
+ }
+ }
+
+ .searchContainer {
+ padding: 5px;
+ border-left: 1px solid #e0e0e0;
+ border-right: 1px solid #e0e0e0;
+ border-top: 1px solid #e0e0e0;
+ }
+
+ .expandableSearch {
+ vertical-align: top;
+ padding: 7px 6px !important;
+ border: 1px solid #d0d0d0 !important;
+ background: #fff !important;
+ font-size: 11px !important;
+ color: #454545 !important;
+ width: 100% !important;
+ }
+
+ .expandableList {
+ position: absolute;
+ z-index: 9999;
+ margin-top: -48px;
+ background: #fff;
+ }
+} \ No newline at end of file
diff --git a/plugins/CorePluginsAdmin/vue/src/FormField/FieldExpandableSelect.vue b/plugins/CorePluginsAdmin/vue/src/FormField/FieldExpandableSelect.vue
new file mode 100644
index 0000000000..a91b91c377
--- /dev/null
+++ b/plugins/CorePluginsAdmin/vue/src/FormField/FieldExpandableSelect.vue
@@ -0,0 +1,190 @@
+<!--
+ 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="expandableSelector" v-focus-anywhere-but-here="{ blur: onBlur }">
+ <div @click="showSelect = !showSelect" class="select-wrapper">
+ <svg class="caret" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
+ <path d="M7 10l5 5 5-5z"></path><path d="M0 0h24v24H0z" fill="none"></path>
+ </svg>
+ <input type="text" class="select-dropdown" readonly="readonly" :value="modelValueText"/>
+ </div>
+
+ <div v-show="showSelect" class="expandableList z-depth-2">
+
+ <div class="searchContainer">
+ <input
+ type="text"
+ placeholder="Search"
+ v-model="searchTerm"
+ class="expandableSearch browser-default"
+ v-focus-if="showSelect"
+ />
+ </div>
+ <ul class="collection firstLevel">
+ <li
+ v-for="(options, index) in availableOptions"
+ class="collection-item"
+ v-show="options.values.filter(x =>
+ x.value.toLowerCase().indexOf(searchTerm.toLowerCase()) !== -1).length"
+ :key="index"
+ >
+ <h4
+ class="expandableListCategory"
+ @click="onCategoryClicked(options)"
+ >
+ {{ options.group }}
+ <span
+ class="secondary-content"
+ :class='{
+ "icon-arrow-right": showCategory !== options.group,
+ "icon-arrow-bottom": showCategory === options.group
+ }'
+ />
+ </h4>
+
+ <ul v-show="showCategory === options.group || searchTerm" class="collection secondLevel">
+ <li
+ class="expandableListItem collection-item valign-wrapper"
+ v-for="children in options.values.filter(x =>
+ x.value.toLowerCase().indexOf(searchTerm.toLowerCase()) !== -1)"
+ :key="children.key"
+ @click="onValueClicked(children)"
+ >
+ <span class="primary-content">{{ children.value }}</span>
+ <span
+ v-show="children.tooltip"
+ :title="children.tooltip"
+ class="secondary-content icon-help"
+ ></span>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </div>
+ </div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { FocusAnywhereButHere, FocusIf } from 'CoreHome';
+
+interface SelectValueInfo {
+ key: unknown;
+}
+
+interface AvailableOptions {
+ group: string;
+ key: string|number;
+ value: unknown;
+ tooltip?: string;
+}
+
+interface Option {
+ key: string|number;
+ value: unknown;
+ tooltip?: string;
+}
+
+interface OptionGroup {
+ group: string;
+ values: Option[];
+}
+
+export function getAvailableOptions(
+ availableValues: Record<string, unknown>|null,
+): OptionGroup[] {
+ const flatValues: OptionGroup[] = [];
+
+ if (!availableValues) {
+ return flatValues;
+ }
+
+ const groups: Record<string, OptionGroup> = {};
+ Object.values(availableValues).forEach((uncastedValue) => {
+ const value = uncastedValue as AvailableOptions;
+ const group = value.group || '';
+
+ if (!(group in groups) || !groups[group]) {
+ groups[group] = { values: [], group };
+ }
+
+ const formatted: Option = { key: value.key, value: value.value };
+
+ if ('tooltip' in value && value.tooltip) {
+ formatted.tooltip = value.tooltip;
+ }
+
+ groups[group].values.push(formatted);
+ });
+
+ Object.values(groups).forEach((group) => {
+ if (group.values.length) {
+ flatValues.push(group);
+ }
+ });
+
+ return flatValues;
+}
+
+export default defineComponent({
+ props: {
+ modelValue: [Number, String],
+ availableOptions: Array,
+ title: String,
+ },
+ directives: {
+ FocusAnywhereButHere,
+ FocusIf,
+ },
+ inheritAttrs: false,
+ emits: ['update:modelValue'],
+ data() {
+ return {
+ showSelect: false,
+ searchTerm: '',
+ showCategory: '',
+ };
+ },
+ computed: {
+ modelValueText() {
+ if (this.title) {
+ return this.title;
+ }
+
+ const key = this.modelValue;
+ const availableOptions = (this.availableOptions || []) as OptionGroup[];
+
+ let keyItem!: { key: string|number, value: unknown }|undefined;
+ availableOptions.some((option) => {
+ keyItem = option.values.find((item) => item.key === key);
+ return keyItem; // stop iterating if found
+ });
+
+ if (keyItem) {
+ return keyItem.value ? `${keyItem.value}` : '';
+ }
+ return key ? `${key}` : '';
+ },
+ },
+ methods: {
+ onBlur() {
+ this.showSelect = false;
+ },
+ onCategoryClicked(options: AvailableOptions) {
+ if (this.showCategory === options.group) {
+ this.showCategory = '';
+ } else {
+ this.showCategory = options.group;
+ }
+ },
+ onValueClicked(selectedValue: SelectValueInfo) {
+ this.$emit('update:modelValue', selectedValue.key);
+ this.showSelect = false;
+ },
+ },
+});
+</script>
diff --git a/plugins/CorePluginsAdmin/vue/src/FormField/FieldFieldArray.vue b/plugins/CorePluginsAdmin/vue/src/FormField/FieldFieldArray.vue
new file mode 100644
index 0000000000..0ea559cd2d
--- /dev/null
+++ b/plugins/CorePluginsAdmin/vue/src/FormField/FieldFieldArray.vue
@@ -0,0 +1,42 @@
+<!--
+ 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>
+ <label :for="name" v-html="$sanitize(title)"></label>
+
+ <FieldArray
+ :name="name"
+ :model-value="modelValue"
+ @update:modelValue="onValueUpdate($event)"
+ :field="uiControlAttributes.field"
+ />
+ </div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { FieldArray } from 'CoreHome';
+
+export default defineComponent({
+ components: {
+ FieldArray,
+ },
+ props: {
+ name: String,
+ title: String,
+ modelValue: null,
+ uiControlAttributes: Object,
+ },
+ inheritAttrs: false,
+ emits: ['update:modelValue'],
+ methods: {
+ onValueUpdate(newValue: unknown) {
+ this.$emit('update:modelValue', newValue);
+ },
+ },
+});
+</script>
diff --git a/plugins/CorePluginsAdmin/vue/src/FormField/FieldFile.vue b/plugins/CorePluginsAdmin/vue/src/FormField/FieldFile.vue
new file mode 100644
index 0000000000..bbdb7367d0
--- /dev/null
+++ b/plugins/CorePluginsAdmin/vue/src/FormField/FieldFile.vue
@@ -0,0 +1,60 @@
+<!--
+ 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>
+ <div class="btn">
+ <span :for="name" v-html="$sanitize(title)"></span>
+ <input ref="fileInput" :name="name" type="file" :id="name" @change="onChange($event)" />
+ </div>
+
+ <div class="file-path-wrapper">
+ <input class="file-path validate" :value="filePath" type="text"/>
+ </div>
+ </div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ name: String,
+ title: String,
+ modelValue: [String, File],
+ },
+ inheritAttrs: false,
+ emits: ['update:modelValue'],
+ watch: {
+ modelValue(v: string|File) {
+ if (!v || v === '') {
+ const fileInputElement = this.$refs.fileInput as HTMLInputElement;
+ fileInputElement!.value = '';
+ }
+ },
+ },
+ methods: {
+ onChange(event: Event) {
+ const { files } = event.target as HTMLInputElement;
+ if (!files) {
+ return;
+ }
+
+ const file = files.item(0);
+ this.$emit('update:modelValue', file);
+ },
+ },
+ computed: {
+ filePath() {
+ if (this.modelValue instanceof File) {
+ return (this.$refs.fileInput as HTMLInputElement).value;
+ }
+
+ return undefined;
+ },
+ },
+});
+</script>
diff --git a/plugins/CorePluginsAdmin/vue/src/FormField/FieldHidden.vue b/plugins/CorePluginsAdmin/vue/src/FormField/FieldHidden.vue
new file mode 100644
index 0000000000..38c5ee248a
--- /dev/null
+++ b/plugins/CorePluginsAdmin/vue/src/FormField/FieldHidden.vue
@@ -0,0 +1,35 @@
+<!--
+ 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>
+ <input
+ :type="uiControl"
+ :name="name"
+ :value="modelValue"
+ @change="onChange($event)"
+ />
+ </div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ modelValue: null,
+ uiControl: String,
+ name: String,
+ },
+ inheritAttrs: false,
+ emits: ['update:modelValue'],
+ methods: {
+ onChange(event: Event) {
+ this.$emit('update:modelValue', (event.target as HTMLInputElement).value);
+ },
+ },
+});
+</script>
diff --git a/plugins/CorePluginsAdmin/vue/src/FormField/FieldMultituple.less b/plugins/CorePluginsAdmin/vue/src/FormField/FieldMultituple.less
new file mode 100644
index 0000000000..d887465f94
--- /dev/null
+++ b/plugins/CorePluginsAdmin/vue/src/FormField/FieldMultituple.less
@@ -0,0 +1,3 @@
+.fieldMultiTuple {
+ font-size: 1rem;
+} \ No newline at end of file
diff --git a/plugins/CorePluginsAdmin/vue/src/FormField/FieldMultituple.vue b/plugins/CorePluginsAdmin/vue/src/FormField/FieldMultituple.vue
new file mode 100644
index 0000000000..8a9213befd
--- /dev/null
+++ b/plugins/CorePluginsAdmin/vue/src/FormField/FieldMultituple.vue
@@ -0,0 +1,45 @@
+<!--
+ 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="fieldMultiTuple">
+ <label :for="name" v-html="$sanitize(title)"></label>
+ <MultiPairField
+ :name="name"
+ :model-value="modelValue"
+ @update:modelValue="onUpdateValue"
+ :field1="uiControlAttributes.field1"
+ :field2="uiControlAttributes.field2"
+ :field3="uiControlAttributes.field3"
+ :field4="uiControlAttributes.field4"
+ >
+ </MultiPairField>
+ </div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { MultiPairField } from 'CoreHome';
+
+export default defineComponent({
+ props: {
+ name: String,
+ title: String,
+ modelValue: null,
+ uiControlAttributes: Object,
+ },
+ inheritAttrs: false,
+ components: {
+ MultiPairField,
+ },
+ emits: ['update:modelValue'],
+ methods: {
+ onUpdateValue(newValue: unknown) {
+ this.$emit('update:modelValue', newValue);
+ },
+ },
+});
+</script>
diff --git a/plugins/CorePluginsAdmin/vue/src/FormField/FieldNumber.vue b/plugins/CorePluginsAdmin/vue/src/FormField/FieldNumber.vue
new file mode 100644
index 0000000000..9b209d298d
--- /dev/null
+++ b/plugins/CorePluginsAdmin/vue/src/FormField/FieldNumber.vue
@@ -0,0 +1,56 @@
+<!--
+ Matomo - free/libre analytics platform
+ @link https://matomo.org
+ @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+-->
+
+<template>
+ <!-- note: @change is used in case the change event is programmatically triggered -->
+ <input
+ :class="`control_${uiControl}`"
+ :type="uiControl"
+ :id="name"
+ :name="name"
+ :value="(modelValue || '').toString()"
+ @keydown="onChange($event)"
+ @change="onChange($event)"
+ v-bind="uiControlAttributes"
+ />
+ <label :for="name" v-html="$sanitize(title)"></label>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { debounce } from 'CoreHome';
+
+export default defineComponent({
+ props: {
+ uiControl: String,
+ name: String,
+ title: String,
+ modelValue: [Number, String],
+ uiControlAttributes: Object,
+ },
+ inheritAttrs: false,
+ emits: ['update:modelValue'],
+ created() {
+ this.onChange = debounce(this.onChange.bind(this), 50);
+ },
+ methods: {
+ onChange(event: Event) {
+ const value = parseFloat((event.target as HTMLInputElement).value);
+ this.$emit('update:modelValue', value);
+ },
+ },
+ mounted() {
+ window.Materialize.updateTextFields();
+ },
+ watch: {
+ modelValue() {
+ setTimeout(() => {
+ window.Materialize.updateTextFields();
+ });
+ },
+ },
+});
+</script>
diff --git a/plugins/CorePluginsAdmin/vue/src/FormField/FieldRadio.vue b/plugins/CorePluginsAdmin/vue/src/FormField/FieldRadio.vue
new file mode 100644
index 0000000000..9231ca3e80
--- /dev/null
+++ b/plugins/CorePluginsAdmin/vue/src/FormField/FieldRadio.vue
@@ -0,0 +1,69 @@
+<!--
+ 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 ref="root">
+ <label class="fieldRadioTitle" v-show="title">{{ title }}</label>
+
+ <p
+ v-for="radioModel in (availableOptions || [])"
+ :key="radioModel.key"
+ class="radio"
+ >
+ <label>
+ <input
+ :value="radioModel.key"
+ @change="onChange($event)"
+ type="radio"
+ :id="`${name}${radioModel.key}`"
+ :name="name"
+ :disabled="radioModel.disabled || disabled"
+ v-bind="uiControlAttributes"
+ :checked="modelValue === radioModel.key || `${modelValue}` === radioModel.key"
+ />
+
+ <span>
+ {{ radioModel.value }}
+
+ <span class="form-description" v-show="radioModel.description">
+ {{ radioModel.description }}
+ </span>
+ </span>
+ </label>
+ </p>
+ </div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ title: String,
+ availableOptions: Array,
+ name: String,
+ disabled: Boolean,
+ uiControlAttributes: Object,
+ modelValue: [String, Number],
+ },
+ inheritAttrs: false,
+ emits: ['update:modelValue'],
+ methods: {
+ onChange(event: Event) {
+ (this.$refs.root as HTMLElement).querySelectorAll('input').forEach((inp, i) => {
+ if (!this.availableOptions?.[i]) {
+ return;
+ }
+
+ const { key } = (this.availableOptions as { key: string }[])[i];
+ (inp as HTMLInputElement).checked = this.modelValue === key || `${this.modelValue}` === key;
+ });
+
+ this.$emit('update:modelValue', (event.target as HTMLInputElement).value);
+ },
+ },
+});
+</script>
diff --git a/plugins/CorePluginsAdmin/vue/src/FormField/FieldSelect.less b/plugins/CorePluginsAdmin/vue/src/FormField/FieldSelect.less
new file mode 100644
index 0000000000..f65b95a637
--- /dev/null
+++ b/plugins/CorePluginsAdmin/vue/src/FormField/FieldSelect.less
@@ -0,0 +1,3 @@
+.matomo-field-select label {
+ top: -14px; // compensate for extra div added in vue
+} \ No newline at end of file
diff --git a/plugins/CorePluginsAdmin/vue/src/FormField/FieldSelect.vue b/plugins/CorePluginsAdmin/vue/src/FormField/FieldSelect.vue
new file mode 100644
index 0000000000..36b9a4451b
--- /dev/null
+++ b/plugins/CorePluginsAdmin/vue/src/FormField/FieldSelect.vue
@@ -0,0 +1,315 @@
+<!--
+ 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 v-if="groupedOptions" class="matomo-field-select">
+ <select
+ ref="select"
+ class="grouped"
+ :multiple="multiple"
+ :name="name"
+ @change="onChange($event)"
+ v-bind="uiControlAttributes"
+ >
+ <optgroup
+ v-for="[group, options] in groupedOptions"
+ :key="group"
+ :label="group"
+ >
+ <option
+ v-for="option in options"
+ :key="option.key"
+ :value="`string:${option.key}`"
+ :selected="multiple
+ ? modelValue && modelValue.indexOf(option.key) !== -1
+ : modelValue === option.key"
+ :disabled="option.disabled"
+ >
+ {{ option.value }}
+ </option>
+ </optgroup>
+ </select>
+ <label :for="name" v-html="$sanitize(title)"></label>
+ </div>
+ <div v-if="!groupedOptions && options" class="matomo-field-select">
+ <select
+ class="ungrouped"
+ ref="select"
+ :multiple="multiple"
+ :name="name"
+ @change="onChange($event)"
+ v-bind="uiControlAttributes"
+ >
+ <option
+ v-for="option in options"
+ :key="option.key"
+ :value="`string:${option.key}`"
+ :selected="multiple
+ ? modelValue && modelValue.indexOf(option.key) !== -1
+ : modelValue === option.key"
+ :disabled="option.disabled"
+ >
+ {{ option.value }}
+ </option>
+ </select>
+ <label :for="name" v-html="$sanitize(title)"></label>
+ </div>
+</template>
+
+<script lang="ts">
+import { defineComponent, nextTick } from 'vue';
+
+interface OptionGroup {
+ group?: string;
+ key: string|number;
+ value: unknown;
+ disabled?: boolean;
+}
+
+function initMaterialSelect(
+ select: HTMLSelectElement|undefined|null,
+ modelValue: string|number|string[],
+ placeholder: string|undefined,
+ uiControlOptions = {},
+ multiple: boolean,
+) {
+ if (!select) {
+ return;
+ }
+
+ const $select = window.$(select);
+
+ // reset selected since materialize removes them
+ Array.from(select.options).forEach((opt) => {
+ if (multiple) {
+ opt.selected = !!modelValue
+ && (modelValue as unknown[]).indexOf(opt.value.replace(/^string:/, '')) !== -1;
+ } else {
+ opt.selected = `string:${modelValue}` === opt.value;
+ }
+ });
+
+ $select.formSelect(uiControlOptions);
+
+ // add placeholder to input
+ if (placeholder) {
+ const $materialInput = $select.closest('.select-wrapper').find('input');
+ $materialInput.attr('placeholder', placeholder);
+ }
+}
+
+function hasGroupedValues(availableValues: unknown) {
+ if (Array.isArray(availableValues)
+ || !(typeof availableValues === 'object')
+ ) {
+ return false;
+ }
+
+ return Object.values(availableValues as Record<string, unknown>).some(
+ (v) => typeof v === 'object',
+ );
+}
+
+function hasOption(flatValues: OptionGroup[], key: string) {
+ return flatValues.some((f) => f.key === key);
+}
+
+export function getAvailableOptions(
+ givenAvailableValues: Record<string, unknown>|null,
+ type: string,
+ uiControlAttributes?: Record<string, unknown>,
+): OptionGroup[] {
+ if (!givenAvailableValues) {
+ return [];
+ }
+
+ let hasGroups = true;
+
+ let availableValues = givenAvailableValues as Record<string, Record<string|number, unknown>>;
+ if (!hasGroupedValues(availableValues)) {
+ availableValues = { '': givenAvailableValues };
+ hasGroups = false;
+ }
+
+ const flatValues: OptionGroup[] = [];
+ Object.entries(availableValues).forEach(([group, values]) => {
+ Object.entries(values).forEach(([valueObjKey, value]) => {
+ if (value && typeof value === 'object' && typeof (value as OptionGroup).key !== 'undefined') {
+ flatValues.push(value as OptionGroup);
+ return;
+ }
+
+ let key: number|string = valueObjKey;
+ if (type === 'integer' && typeof valueObjKey === 'string') {
+ key = parseInt(valueObjKey, 10);
+ }
+
+ flatValues.push({ group: hasGroups ? group : undefined, key, value });
+ });
+ });
+
+ // for selects w/ a placeholder, add an option to unset the select
+ if (uiControlAttributes?.placeholder
+ && !hasOption(flatValues, '')
+ ) {
+ return [{ key: '', value: '' }, ...flatValues];
+ }
+
+ return flatValues;
+}
+
+function handleOldAngularJsValues<T>(value: T): T {
+ if (typeof value === 'string') {
+ return value.replace(/^string:/, '') as unknown as T;
+ }
+ return value;
+}
+
+export default defineComponent({
+ props: {
+ modelValue: null,
+ multiple: Boolean,
+ name: String,
+ title: String,
+ availableOptions: Array,
+ uiControlAttributes: Object,
+ uiControlOptions: Object,
+ },
+ inheritAttrs: false,
+ emits: ['update:modelValue'],
+ computed: {
+ options(): OptionGroup[]|undefined {
+ // if modelValue is empty, but there is no empty value allowed in availableOptions,
+ // add one temporarily until something is set
+ const availableOptions = this.availableOptions as OptionGroup[]|undefined;
+ if (availableOptions
+ && !hasOption(availableOptions, '')
+ && (typeof this.modelValue === 'undefined'
+ || this.modelValue === null
+ || this.modelValue === '')
+ ) {
+ return [
+ { key: '', value: this.modelValue, group: this.hasGroups ? '' : undefined },
+ ...availableOptions,
+ ];
+ }
+ return availableOptions;
+ },
+ hasGroups() {
+ const availableOptions = this.availableOptions as OptionGroup[]|undefined;
+ return availableOptions && availableOptions[0] && typeof availableOptions[0].group !== 'undefined';
+ },
+ groupedOptions() {
+ const { options } = this;
+
+ if (!this.hasGroups || !options) {
+ return null;
+ }
+
+ const groups: Record<string, OptionGroup[]> = {};
+ (options as OptionGroup[]).forEach((entry) => {
+ const group = entry.group!;
+ groups[group] = groups[group] || [];
+ groups[group].push(entry);
+ });
+
+ const result = Object.entries(groups);
+ result.sort((lhs, rhs) => {
+ if (lhs[0] < rhs[0]) {
+ return -1;
+ }
+
+ if (lhs[0] > rhs[0]) {
+ return 1;
+ }
+
+ return 0;
+ });
+ return result;
+ },
+ },
+ methods: {
+ onChange(event: Event) {
+ const element = event.target as HTMLSelectElement;
+
+ let newValue: string|number|(string|number)[];
+ if (this.multiple) {
+ newValue = Array.from(element.options).filter((e) => e.selected).map((e) => e.value);
+ newValue = newValue.map((x) => handleOldAngularJsValues(x));
+ } else {
+ newValue = element.value;
+ newValue = handleOldAngularJsValues(newValue);
+ }
+
+ this.$emit('update:modelValue', newValue);
+
+ // if modelValue does not change, select will still have the changed value, but we
+ // want it to have the value determined by modelValue. so we force an update.
+ nextTick(() => {
+ if (this.modelValue !== newValue) {
+ this.onModelValueChange(this.modelValue);
+ }
+ });
+ },
+ onModelValueChange(newVal: string|number|string[]) {
+ window.$(this.$refs.select as HTMLSelectElement).val(newVal);
+ setTimeout(() => {
+ initMaterialSelect(
+ this.$refs.select as HTMLSelectElement,
+ newVal,
+ this.uiControlAttributes?.placeholder,
+ this.uiControlOptions,
+ this.multiple,
+ );
+ });
+ },
+ },
+ watch: {
+ modelValue(newVal: string|number|string[]) {
+ this.onModelValueChange(newVal);
+ },
+ 'uiControlAttributes.disabled': {
+ handler(newVal?: boolean, oldVal?: boolean) {
+ setTimeout(() => {
+ if (newVal !== oldVal) {
+ initMaterialSelect(
+ this.$refs.select as HTMLSelectElement,
+ this.modelValue,
+ this.uiControlAttributes?.placeholder,
+ this.uiControlOptions,
+ this.multiple,
+ );
+ }
+ });
+ },
+ },
+ availableOptions(newVal?: OptionGroup[], oldVal?: OptionGroup[]) {
+ if (newVal !== oldVal) {
+ setTimeout(() => {
+ initMaterialSelect(
+ this.$refs.select as HTMLSelectElement,
+ this.modelValue,
+ this.uiControlAttributes?.placeholder,
+ this.uiControlOptions,
+ this.multiple,
+ );
+ });
+ }
+ },
+ },
+ mounted() {
+ setTimeout(() => {
+ initMaterialSelect(
+ this.$refs.select as HTMLSelectElement,
+ this.modelValue,
+ this.uiControlAttributes?.placeholder,
+ this.uiControlOptions,
+ this.multiple,
+ );
+ });
+ },
+});
+</script>
diff --git a/plugins/CorePluginsAdmin/vue/src/FormField/FieldSite.vue b/plugins/CorePluginsAdmin/vue/src/FormField/FieldSite.vue
new file mode 100644
index 0000000000..dedd60eba9
--- /dev/null
+++ b/plugins/CorePluginsAdmin/vue/src/FormField/FieldSite.vue
@@ -0,0 +1,47 @@
+<!--
+ 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>
+ <label :for="name" class="siteSelectorLabel" v-html="$sanitize(title)"></label>
+ <div class="sites_autocomplete">
+ <SiteSelector
+ :model-value="modelValue"
+ @update:modelValue="onChange($event)"
+ :id="name"
+ :show-all-sites-item="uiControlAttributes.showAllSitesItem || false"
+ :switch-site-on-select="false"
+ :show-selected-site="true"
+ :only-sites-with-admin-access="uiControlAttributes.onlySitesWithAdminAccess || false"
+ v-bind="uiControlAttributes"
+ />
+ </div>
+ </div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { SiteSelector, SiteRef } from 'CoreHome';
+
+export default defineComponent({
+ props: {
+ name: String,
+ title: String,
+ modelValue: Object,
+ uiControlAttributes: Object,
+ },
+ inheritAttrs: false,
+ components: {
+ SiteSelector,
+ },
+ emits: ['update:modelValue'],
+ methods: {
+ onChange(newValue: SiteRef) {
+ this.$emit('update:modelValue', newValue);
+ },
+ },
+});
+</script>
diff --git a/plugins/CorePluginsAdmin/vue/src/FormField/FieldText.vue b/plugins/CorePluginsAdmin/vue/src/FormField/FieldText.vue
new file mode 100644
index 0000000000..c240b36611
--- /dev/null
+++ b/plugins/CorePluginsAdmin/vue/src/FormField/FieldText.vue
@@ -0,0 +1,74 @@
+<!--
+ Matomo - free/libre analytics platform
+ @link https://matomo.org
+ @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+-->
+
+<template>
+ <!-- note: @change is used in case the change event is programmatically triggered -->
+ <input
+ :class="`control_${uiControl}`"
+ :type="uiControl"
+ :id="name"
+ :name="name"
+ :value="modelValueText"
+ @keydown="onKeydown($event)"
+ @change="onKeydown($event)"
+ v-bind="uiControlAttributes"
+ />
+ <label
+ :for="name"
+ v-html="$sanitize(title)"
+ />
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { debounce } from 'CoreHome';
+
+export default defineComponent({
+ props: {
+ title: String,
+ name: String,
+ uiControlAttributes: Object,
+ modelValue: [String, Number],
+ uiControl: String,
+ },
+ inheritAttrs: false,
+ emits: ['update:modelValue'],
+ computed: {
+ modelValueText() {
+ if (typeof this.modelValue === 'undefined' || this.modelValue === null) {
+ return '';
+ }
+
+ return this.modelValue.toString();
+ },
+ },
+ created() {
+ // debounce because puppeteer types reeaally fast
+ this.onKeydown = debounce(this.onKeydown.bind(this), 50);
+ },
+ mounted() {
+ setTimeout(() => {
+ window.Materialize.updateTextFields();
+ });
+ },
+ watch: {
+ modelValue() {
+ setTimeout(() => {
+ window.Materialize.updateTextFields();
+ });
+ },
+ },
+ methods: {
+ onKeydown(event: Event) {
+ const newValue = (event.target as HTMLInputElement).value;
+ if (this.modelValue !== newValue) {
+ this.$emit('update:modelValue', newValue);
+ }
+ },
+ },
+});
+
+</script>
diff --git a/plugins/CorePluginsAdmin/vue/src/FormField/FieldTextArray.vue b/plugins/CorePluginsAdmin/vue/src/FormField/FieldTextArray.vue
new file mode 100644
index 0000000000..4290e910b6
--- /dev/null
+++ b/plugins/CorePluginsAdmin/vue/src/FormField/FieldTextArray.vue
@@ -0,0 +1,62 @@
+<!--
+ Matomo - free/libre analytics platform
+ @link https://matomo.org
+ @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+-->
+
+<template>
+ <!-- note: @change is used in case the change event is programmatically triggered -->
+ <div>
+ <label
+ :for="name"
+ v-html="$sanitize(title)"
+ />
+ <input
+ :class="`control_${ uiControl }`"
+ :type="uiControl"
+ :name="name"
+ @keydown="onKeydown($event)"
+ @change="onKeydown($event)"
+ :value="concattedValues"
+ v-bind="uiControlAttributes"
+ />
+ </div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { debounce } from 'CoreHome';
+
+export default defineComponent({
+ props: {
+ name: String,
+ title: String,
+ uiControl: String,
+ modelValue: Array,
+ uiControlAttributes: Object,
+ },
+ inheritAttrs: false,
+ computed: {
+ concattedValues() {
+ if (typeof this.modelValue === 'string') {
+ return this.modelValue;
+ }
+
+ return (this.modelValue || []).join(', ');
+ },
+ },
+ emits: ['update:modelValue'],
+ created() {
+ // debounce because puppeteer types reeaally fast
+ this.onKeydown = debounce(this.onKeydown.bind(this), 50);
+ },
+ methods: {
+ onKeydown(event: Event) {
+ const values = (event.target as HTMLInputElement).value.split(',').map((v) => v.trim());
+ if (values.join(', ') !== this.concattedValues) {
+ this.$emit('update:modelValue', values);
+ }
+ },
+ },
+});
+</script>
diff --git a/plugins/CorePluginsAdmin/vue/src/FormField/FieldTextarea.vue b/plugins/CorePluginsAdmin/vue/src/FormField/FieldTextarea.vue
new file mode 100644
index 0000000000..2529663f92
--- /dev/null
+++ b/plugins/CorePluginsAdmin/vue/src/FormField/FieldTextarea.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>
+ <!-- note: @change is used in case the change event is programmatically triggered -->
+ <textarea
+ :name="name"
+ v-bind="uiControlAttributes"
+ :id="name"
+ :value="modelValue"
+ @keydown="onKeydown($event)"
+ @change="onKeydown($event)"
+ class="materialize-textarea"
+ ref="textarea"
+ ></textarea>
+ <label :for="name" v-html="$sanitize(title)"></label>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { debounce } from 'CoreHome';
+
+export default defineComponent({
+ props: {
+ name: String,
+ uiControlAttributes: Object,
+ modelValue: String,
+ title: String,
+ },
+ inheritAttrs: false,
+ emits: ['update:modelValue'],
+ created() {
+ this.onKeydown = debounce(this.onKeydown.bind(this), 50);
+ },
+ methods: {
+ onKeydown(event: Event) {
+ this.$emit('update:modelValue', (event.target as HTMLTextAreaElement).value);
+ },
+ },
+ watch: {
+ modelValue() {
+ setTimeout(() => {
+ window.Materialize.textareaAutoResize(this.$refs.textarea);
+ window.Materialize.updateTextFields();
+ });
+ },
+ },
+ mounted() {
+ setTimeout(() => {
+ window.Materialize.textareaAutoResize(this.$refs.textarea);
+ window.Materialize.updateTextFields();
+ });
+ },
+});
+</script>
diff --git a/plugins/CorePluginsAdmin/vue/src/FormField/FieldTextareaArray.vue b/plugins/CorePluginsAdmin/vue/src/FormField/FieldTextareaArray.vue
new file mode 100644
index 0000000000..bb1d684e75
--- /dev/null
+++ b/plugins/CorePluginsAdmin/vue/src/FormField/FieldTextareaArray.vue
@@ -0,0 +1,82 @@
+<!--
+ Matomo - free/libre analytics platform
+ @link https://matomo.org
+ @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+-->
+
+<template>
+ <!-- note: @change is used in case the change event is programmatically triggered -->
+ <div>
+ <label
+ :for="name"
+ v-html="$sanitize(title)"
+ ></label>
+ <textarea
+ ref="textarea"
+ :name="name"
+ v-bind="uiControlAttributes"
+ :value="concattedValue"
+ @keydown="onKeydown($event)"
+ @change="onKeydown($event)"
+ class="materialize-textarea"
+ ></textarea>
+ </div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { debounce } from 'CoreHome';
+
+const SEPARATOR = '\n';
+
+export default defineComponent({
+ props: {
+ name: String,
+ title: String,
+ uiControlAttributes: Object,
+ modelValue: [Array, String],
+ },
+ inheritAttrs: false,
+ emits: ['update:modelValue'],
+ computed: {
+ concattedValue() {
+ if (typeof this.modelValue === 'string') {
+ return this.modelValue;
+ }
+
+ return (this.modelValue || []).join(SEPARATOR);
+ },
+ },
+ created() {
+ this.onKeydown = debounce(this.onKeydown.bind(this), 50);
+ },
+ methods: {
+ onKeydown(event: KeyboardEvent) {
+ const value = (event.target as HTMLTextAreaElement).value.split(SEPARATOR);
+ if (value.join(SEPARATOR) !== this.concattedValue) {
+ this.$emit('update:modelValue', value);
+ }
+ },
+ },
+ watch: {
+ modelValue(newVal, oldVal) {
+ if (newVal !== oldVal) {
+ setTimeout(() => {
+ if (this.$refs.textarea) {
+ window.Materialize.textareaAutoResize(this.$refs.textarea);
+ }
+ window.Materialize.updateTextFields();
+ });
+ }
+ },
+ },
+ mounted() {
+ setTimeout(() => {
+ if (this.$refs.textarea) {
+ window.Materialize.textareaAutoResize(this.$refs.textarea);
+ }
+ window.Materialize.updateTextFields();
+ });
+ },
+});
+</script>
diff --git a/plugins/CorePluginsAdmin/vue/src/FormField/FormField.adapter.ts b/plugins/CorePluginsAdmin/vue/src/FormField/FormField.adapter.ts
new file mode 100644
index 0000000000..fad8f05cf9
--- /dev/null
+++ b/plugins/CorePluginsAdmin/vue/src/FormField/FormField.adapter.ts
@@ -0,0 +1,150 @@
+/*!
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+import { IScope, ITimeoutService } from 'angular';
+import {
+ createAngularJsAdapter,
+ transformAngularJsBoolAttr,
+ transformAngularJsIntAttr,
+ useExternalPluginComponent,
+} from 'CoreHome';
+import { markRaw } from 'vue';
+import FormField from './FormField.vue';
+import FieldAngularJsTemplate from './FieldAngularJsTemplate.vue';
+
+function transformVueComponentRef(value?: Record<string, string>) {
+ if (!value) {
+ return undefined;
+ }
+
+ const { plugin, name } = value;
+ if (!plugin || !name) {
+ throw new Error('Invalid component property given to piwik-field directive, must be '
+ + '{plugin: \'...\',name: \'...\'}');
+ }
+
+ return useExternalPluginComponent(plugin, name);
+}
+
+interface Setting {
+ name: string;
+ value: unknown;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function conditionFn(scope: any, condition: string) {
+ const values: Record<string, unknown> = {};
+ Object.values((scope.allSettings || {}) as Record<string, Setting>).forEach((setting) => {
+ if (setting.value === '0') {
+ values[setting.name] = 0;
+ } else {
+ values[setting.name] = setting.value;
+ }
+ });
+
+ return scope.$eval(condition, values);
+}
+
+export default createAngularJsAdapter<[ITimeoutService]>({
+ component: FormField,
+ scope: {
+ modelValue: {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ default(scope: any) {
+ const field = scope.piwikFormField;
+
+ // vue components expect object data as input, so we parse JSON data
+ // for angularjs directives that use JSON.
+ if (typeof field.value === 'string'
+ && field.value
+ && (field.type === 'array'
+ || field.uiControl === 'multituple'
+ || field.uiControl === 'field-array'
+ || field.uiControl === 'multiselect'
+ || field.uiControl === 'site')
+ ) {
+ field.value = JSON.parse(field.value);
+ }
+
+ if (field.uiControl === 'checkbox') {
+ return transformAngularJsBoolAttr(field.value);
+ }
+ return field.value;
+ },
+ },
+ piwikFormField: {
+ vue: 'formField',
+ angularJsBind: '=',
+ transform(v: unknown, vm: unknown, scope: IScope) {
+ const value = v as Record<string, unknown>;
+
+ function getComponent() {
+ if (value.templateFile) {
+ return markRaw(FieldAngularJsTemplate);
+ }
+
+ const comp = transformVueComponentRef(value.component as Record<string, string>);
+ if (!comp) {
+ return undefined;
+ }
+
+ return markRaw(comp);
+ }
+
+ return {
+ ...value,
+ condition: value.condition
+ ? conditionFn.bind(null, scope, value.condition as string)
+ : value.condition,
+ disabled: transformAngularJsBoolAttr(value.disabled),
+ autocomplete: transformAngularJsBoolAttr(value.autocomplete),
+ autofocus: transformAngularJsBoolAttr(value.autofocus),
+ tabindex: transformAngularJsIntAttr(value.tabindex),
+ fullWidth: transformAngularJsBoolAttr(value.fullWidth),
+ maxlength: transformAngularJsIntAttr(value.maxlength),
+ required: transformAngularJsBoolAttr(value.required),
+ rows: transformAngularJsIntAttr(value.rows),
+ min: transformAngularJsIntAttr(value.min),
+ max: transformAngularJsIntAttr(value.max),
+ component: getComponent(),
+ };
+ },
+ },
+ allSettings: {
+ angularJsBind: '=',
+ },
+ },
+ directiveName: 'piwikFormField',
+ events: {
+ 'update:modelValue': (newValue, vm, scope, element, attrs, controller, $timeout) => {
+ if (newValue !== scope.piwikFormField.value) {
+ $timeout(() => {
+ scope.piwikFormField.value = newValue;
+ });
+ }
+ },
+ },
+ $inject: ['$timeout'],
+ postCreate(vm, scope) {
+ scope.$watch('piwikFormField.value', (newVal: unknown, oldVal: unknown) => {
+ if (newVal !== oldVal) {
+ vm.modelValue = newVal;
+ }
+ });
+
+ // deep watch for all settings, on change trigger change in formfield property
+ // so condition is re-applied
+ scope.$watch('allSettings', () => {
+ vm.formField = {
+ ...vm.formField,
+ condition: scope.piwikFormField.condition
+ ? conditionFn.bind(null, scope, scope.piwikFormField.condition as string)
+ : scope.piwikFormField.condition,
+ };
+ }, true);
+ },
+});
diff --git a/plugins/CorePluginsAdmin/vue/src/FormField/FormField.vue b/plugins/CorePluginsAdmin/vue/src/FormField/FormField.vue
new file mode 100644
index 0000000000..57186f0d9e
--- /dev/null
+++ b/plugins/CorePluginsAdmin/vue/src/FormField/FormField.vue
@@ -0,0 +1,412 @@
+<!--
+ 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="form-group row matomo-form-field"
+ v-show="showField"
+ >
+ <h3
+ v-if="formField.introduction"
+ class="col s12"
+ >
+ {{ formField.introduction }}
+ </h3>
+ <div
+ class="col s12"
+ :class="{
+ 'input-field': formField.uiControl !== 'checkbox' && formField.uiControl !== 'radio',
+ 'file-field': formField.uiControl === 'file',
+ 'm6': !formField.fullWidth,
+ }"
+ >
+ <component
+ :is="childComponent"
+ v-bind="{
+ formField,
+ ...formField,
+ modelValue: processedModelValue,
+ availableOptions,
+ ...extraChildComponentParams,
+ }"
+ @update:modelValue="onChange($event)"
+ >
+ </component>
+ </div>
+ <div
+ class="col s12"
+ :class="{ 'm6': !formField.fullWidth }"
+ >
+ <div
+ v-if="showFormHelp"
+ class="form-help"
+ >
+ <div
+ v-show="formField.description"
+ class="form-description"
+ >
+ {{ formField.description }}
+ </div>
+ <span
+ class="inline-help"
+ ref="inlineHelp"
+ v-if="formField.inlineHelp || hasInlineHelpSlot"
+ >
+ <component
+ v-if="inlineHelpComponent"
+ :is="inlineHelpComponent"
+ v-bind="inlineHelpBind"
+ />
+
+ <slot name="inline-help"></slot>
+ </span>
+ <span v-show="showDefaultValue">
+ <br />
+ {{ translate('General_Default') }}:
+ <span>{{ defaultValuePrettyTruncated }}</span>
+ </span>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script lang="ts">
+import {
+ defineComponent,
+ onMounted,
+ ref,
+ watch,
+ Component,
+ markRaw,
+} from 'vue';
+import { useExternalPluginComponent } from 'CoreHome';
+import FieldCheckbox from './FieldCheckbox.vue';
+import FieldCheckboxArray from './FieldCheckboxArray.vue';
+import FieldExpandableSelect, {
+ getAvailableOptions as getExpandableSelectAvailableOptions,
+} from './FieldExpandableSelect.vue';
+import FieldFieldArray from './FieldFieldArray.vue';
+import FieldFile from './FieldFile.vue';
+import FieldHidden from './FieldHidden.vue';
+import FieldMultituple from './FieldMultituple.vue';
+import FieldNumber from './FieldNumber.vue';
+import FieldRadio from './FieldRadio.vue';
+import FieldSelect, {
+ getAvailableOptions as getSelectAvailableOptions,
+} from './FieldSelect.vue';
+import FieldSite from './FieldSite.vue';
+import FieldText from './FieldText.vue';
+import FieldTextArray from './FieldTextArray.vue';
+import FieldTextarea from './FieldTextarea.vue';
+import FieldTextareaArray from './FieldTextareaArray.vue';
+import { processCheckboxAndRadioAvailableValues } from './utilities';
+import FieldAngularJsTemplate from './FieldAngularJsTemplate.vue';
+
+const TEXT_CONTROLS = ['password', 'url', 'search', 'email'];
+const CONTROLS_SUPPORTING_ARRAY = ['textarea', 'checkbox', 'text'];
+const CONTROL_TO_COMPONENT_MAP: Record<string, string> = {
+ checkbox: 'FieldCheckbox',
+ 'expandable-select': 'FieldExpandableSelect',
+ 'field-array': 'FieldFieldArray',
+ file: 'FieldFile',
+ hidden: 'FieldHidden',
+ multiselect: 'FieldSelect',
+ multituple: 'FieldMultituple',
+ number: 'FieldNumber',
+ radio: 'FieldRadio',
+ select: 'FieldSelect',
+ site: 'FieldSite',
+ text: 'FieldText',
+ textarea: 'FieldTextarea',
+};
+
+type ProcessAvailableOptionsFn = (
+ availableValues: Record<string, unknown>|null,
+ type: string,
+ uiControlAttributes?: Record<string, unknown>,
+) => unknown[];
+
+const CONTROL_TO_AVAILABLE_OPTION_PROCESSOR: Record<string, ProcessAvailableOptionsFn> = {
+ FieldSelect: getSelectAvailableOptions,
+ FieldCheckboxArray: processCheckboxAndRadioAvailableValues,
+ FieldRadio: processCheckboxAndRadioAvailableValues,
+ FieldExpandableSelect: getExpandableSelectAvailableOptions,
+};
+
+interface ComponentReference {
+ plugin: string;
+ name: string;
+}
+
+interface FormField {
+ availableValues: Record<string, unknown>;
+ type: string;
+ uiControlAttributes?: Record<string, unknown>;
+ defaultValue: unknown;
+ uiControl: string;
+ component: Component | ComponentReference;
+ inlineHelp?: string;
+ inlineHelpBind?: unknown;
+ templateFile?: string;
+}
+
+interface OptionLike {
+ key?: string|number;
+ value?: unknown;
+}
+
+export default defineComponent({
+ props: {
+ modelValue: null,
+ formField: {
+ type: Object,
+ required: true,
+ },
+ },
+ emits: ['update:modelValue'],
+ components: {
+ FieldCheckbox,
+ FieldCheckboxArray,
+ FieldExpandableSelect,
+ FieldFieldArray,
+ FieldFile,
+ FieldHidden,
+ FieldMultituple,
+ FieldNumber,
+ FieldRadio,
+ FieldSelect,
+ FieldSite,
+ FieldText,
+ FieldTextArray,
+ FieldTextarea,
+ FieldTextareaArray,
+ },
+ setup(props) {
+ const inlineHelpNode = ref<HTMLElement|null>(null);
+
+ const setInlineHelp = (newVal?: string|HTMLElement|JQuery) => {
+ let toAppend: HTMLElement|JQuery|string;
+
+ if (!newVal
+ || !inlineHelpNode.value
+ || typeof (newVal as unknown as Record<string, unknown>).render === 'function'
+ ) {
+ return;
+ }
+
+ if (typeof newVal === 'string') {
+ if (newVal.indexOf('#') === 0) {
+ toAppend = window.$(newVal);
+ } else {
+ toAppend = window.vueSanitize(newVal);
+ }
+ } else {
+ toAppend = newVal;
+ }
+
+ window.$(inlineHelpNode.value).html('').append(toAppend);
+ };
+
+ watch(() => (props.formField as FormField).inlineHelp, setInlineHelp);
+
+ onMounted(() => {
+ setInlineHelp((props.formField as FormField).inlineHelp);
+ });
+
+ return {
+ inlineHelp: inlineHelpNode,
+ };
+ },
+ computed: {
+ inlineHelpComponent() {
+ const formField = this.formField as FormField;
+
+ const inlineHelpRecord = formField.inlineHelp as unknown as Record<string, unknown>;
+ if (inlineHelpRecord && typeof inlineHelpRecord.render === 'function') {
+ return formField.inlineHelp as Component;
+ }
+ return undefined;
+ },
+ inlineHelpBind() {
+ return this.inlineHelpComponent ? this.formField.inlineHelpBind : undefined;
+ },
+ childComponent(): string|Component {
+ const formField = this.formField as FormField;
+
+ if (formField.component) {
+ let component = formField.component as Component;
+
+ if ((formField.component as ComponentReference).plugin) {
+ const { plugin, name } = formField.component as ComponentReference;
+ if (!plugin || !name) {
+ throw new Error('Invalid component property given to piwik-field directive, must be '
+ + '{plugin: \'...\',name: \'...\'}');
+ }
+
+ component = useExternalPluginComponent(plugin, name);
+ }
+
+ return markRaw(component);
+ }
+
+ // backwards compatibility w/ settings that use templateFile property
+ if (formField.templateFile) {
+ return markRaw(FieldAngularJsTemplate);
+ }
+
+ const { uiControl } = formField;
+
+ let control = CONTROL_TO_COMPONENT_MAP[uiControl];
+ if (TEXT_CONTROLS.indexOf(uiControl) !== -1) {
+ control = 'FieldText'; // we use same template for text and password both
+ }
+
+ if (this.formField.type === 'array' && CONTROLS_SUPPORTING_ARRAY.indexOf(uiControl) !== -1) {
+ control = `${control}Array`;
+ }
+
+ return control;
+ },
+ extraChildComponentParams() {
+ if (this.formField.uiControl === 'multiselect') {
+ return { multiple: true };
+ }
+ return {};
+ },
+ showFormHelp() {
+ return this.formField.description
+ || this.formField.inlineHelp
+ || this.showDefaultValue
+ || this.hasInlineHelpSlot;
+ },
+ showDefaultValue() {
+ return this.defaultValuePretty
+ && this.formField.uiControl !== 'checkbox'
+ && this.formField.uiControl !== 'radio';
+ },
+ /**
+ * @deprecated here for angularjs BC support. shouldn't be used directly, instead use
+ * GroupedSetting.vue.
+ */
+ showField() {
+ if (!this.formField
+ || !this.formField.condition
+ || !(this.formField.condition instanceof Function)
+ ) {
+ return true;
+ }
+
+ return this.formField.condition();
+ },
+ processedModelValue() {
+ const field = this.formField as FormField;
+
+ // convert boolean values since angular 1.6 uses strict equals when determining if a model
+ // value matches the ng-value of an input.
+ if (field.type === 'boolean') {
+ const valueIsTruthy = this.modelValue && this.modelValue > 0 && this.modelValue !== '0';
+
+ // for checkboxes, the value MUST be either true or false
+ if (field.uiControl === 'checkbox') {
+ return valueIsTruthy;
+ }
+
+ if (field.uiControl === 'radio') {
+ return valueIsTruthy ? '1' : '0';
+ }
+ }
+
+ return this.modelValue;
+ },
+ defaultValue(): string {
+ const { defaultValue } = this.formField as FormField;
+ if (Array.isArray(defaultValue)) {
+ return (defaultValue as unknown[]).join(',');
+ }
+ return defaultValue as string;
+ },
+ availableOptions() {
+ const { childComponent } = this;
+ if (typeof childComponent !== 'string') {
+ return null;
+ }
+
+ const formField = this.formField as FormField;
+
+ if (!formField.availableValues
+ || !CONTROL_TO_AVAILABLE_OPTION_PROCESSOR[childComponent]
+ ) {
+ return null;
+ }
+
+ return CONTROL_TO_AVAILABLE_OPTION_PROCESSOR[childComponent](
+ formField.availableValues,
+ formField.type,
+ formField.uiControlAttributes,
+ );
+ },
+ defaultValuePretty() {
+ const formField = this.formField as FormField;
+ let { defaultValue } = formField;
+ const { availableOptions } = this;
+
+ if (typeof defaultValue === 'string' && defaultValue) {
+ // eg default value for multi tuple
+ let defaultParsed = null;
+ try {
+ defaultParsed = JSON.parse(defaultValue);
+ } catch (e) {
+ // invalid JSON
+ }
+
+ if (defaultParsed !== null && typeof defaultParsed === 'object') {
+ return '';
+ }
+ }
+
+ if (!Array.isArray(availableOptions)) {
+ if (Array.isArray(defaultValue)) {
+ return '';
+ }
+
+ return defaultValue ? `${defaultValue}` : '';
+ }
+
+ const prettyValues: unknown[] = [];
+
+ if (!Array.isArray(defaultValue)) {
+ defaultValue = [defaultValue];
+ }
+
+ (availableOptions || []).forEach((value) => {
+ if (typeof (value as OptionLike).value !== 'undefined'
+ && (defaultValue as unknown[]).indexOf((value as OptionLike).key) !== -1
+ ) {
+ prettyValues.push((value as OptionLike).value);
+ }
+ });
+
+ return prettyValues.join(', ');
+ },
+ defaultValuePrettyTruncated() {
+ return this.defaultValuePretty.substring(0, 50);
+ },
+ hasInlineHelpSlot() {
+ if (!this.$slots['inline-help']) {
+ return false;
+ }
+
+ const inlineHelpSlot = this.$slots['inline-help']();
+ return !!inlineHelpSlot?.[0]?.children?.length;
+ },
+ },
+ methods: {
+ onChange(newValue: unknown) {
+ this.$emit('update:modelValue', newValue);
+ },
+ },
+});
+</script>
diff --git a/plugins/CorePluginsAdmin/vue/src/FormField/utilities.ts b/plugins/CorePluginsAdmin/vue/src/FormField/utilities.ts
new file mode 100644
index 0000000000..9a3a3864cd
--- /dev/null
+++ b/plugins/CorePluginsAdmin/vue/src/FormField/utilities.ts
@@ -0,0 +1,37 @@
+/*!
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+interface Option {
+ key: string|number;
+ value: unknown;
+}
+
+export function processCheckboxAndRadioAvailableValues(
+ availableValues: Record<string, unknown>|null,
+ type: string,
+): Option[] {
+ if (!availableValues) {
+ return [];
+ }
+
+ const flatValues: Option[] = [];
+ Object.entries(availableValues).forEach(([valueObjKey, value]) => {
+ if (value && typeof value === 'object' && typeof (value as Option).key !== 'undefined') {
+ flatValues.push(value as Option);
+ return;
+ }
+
+ let key: number|string = valueObjKey;
+ if (type === 'integer' && typeof valueObjKey === 'string') {
+ key = parseInt(key, 10);
+ }
+
+ flatValues.push({ key, value });
+ });
+
+ return flatValues;
+}