diff options
author | Ben <ben.burgess@innocraft.com> | 2022-05-25 04:25:04 +0300 |
---|---|---|
committer | Ben <ben.burgess@innocraft.com> | 2022-05-25 04:25:04 +0300 |
commit | 7a3865b02d6c4511db639d61f6de7e9c216fa675 (patch) | |
tree | f73b884c3ce969fba97b391d5652ea0a308d6b7d /plugins/CorePluginsAdmin/vue/src/FormField | |
parent | 276ed587372cd76f44e51f9a98462c45f1eb8f5c (diff) | |
parent | 53c00a78caf96d24dd8f7f74dc8fd74268b312b1 (diff) |
Merge branch '4.x-dev' into lockaltlockalt
Diffstat (limited to 'plugins/CorePluginsAdmin/vue/src/FormField')
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; +} |