diff options
Diffstat (limited to 'apps/workflowengine/src/components')
17 files changed, 658 insertions, 835 deletions
diff --git a/apps/workflowengine/src/components/Check.vue b/apps/workflowengine/src/components/Check.vue index 2ea267878fa..136f6d21280 100644 --- a/apps/workflowengine/src/components/Check.vue +++ b/apps/workflowengine/src/components/Check.vue @@ -1,24 +1,36 @@ +<!-- + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div v-click-outside="hideDelete" class="check" @click="showDelete"> - <Multiselect ref="checkSelector" + <NcSelect ref="checkSelector" v-model="currentOption" :options="options" label="name" - track-by="class" - :allow-empty="false" + :clearable="false" :placeholder="t('workflowengine', 'Select a filter')" @input="updateCheck" /> - <Multiselect v-model="currentOperator" + <NcSelect v-model="currentOperator" :disabled="!currentOption" :options="operators" class="comparator" label="name" - track-by="operator" - :allow-empty="false" + :clearable="false" :placeholder="t('workflowengine', 'Select a comparator')" @input="updateCheck" /> + <component :is="currentElement" + v-if="currentElement" + ref="checkComponent" + :disabled="!currentOption" + :operator="check.operator" + :model-value="check.value" + class="option" + @update:model-value="updateCheck" + @valid="(valid=true) && validate()" + @invalid="!(valid=false) && validate()" /> <component :is="currentOption.component" - v-if="currentOperator && currentComponent" + v-else-if="currentOperator && currentComponent" v-model="check.value" :disabled="!currentOption" :check="check" @@ -34,24 +46,33 @@ :placeholder="valuePlaceholder" class="option" @input="updateCheck"> - <Actions v-if="deleteVisible || !currentOption"> - <ActionButton icon="icon-close" @click="$emit('remove')" /> - </Actions> + <NcActions v-if="deleteVisible || !currentOption"> + <NcActionButton :title="t('workflowengine', 'Remove filter')" @click="$emit('remove')"> + <template #icon> + <CloseIcon :size="20" /> + </template> + </NcActionButton> + </NcActions> </div> </template> <script> -import Multiselect from '@nextcloud/vue/dist/Components/Multiselect' -import Actions from '@nextcloud/vue/dist/Components/Actions' -import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcSelect from '@nextcloud/vue/components/NcSelect' + +import CloseIcon from 'vue-material-design-icons/Close.vue' import ClickOutside from 'vue-click-outside' export default { name: 'Check', components: { - ActionButton, - Actions, - Multiselect, + NcActionButton, + NcActions, + NcSelect, + + // Icons + CloseIcon, }, directives: { ClickOutside, @@ -87,6 +108,12 @@ export default { } return operators }, + currentElement() { + if (!this.check.class) { + return false + } + return this.checks[this.check.class].element + }, currentComponent() { if (!this.currentOption) { return [] } return this.checks[this.currentOption.class].component @@ -108,6 +135,15 @@ export default { this.currentOption = this.checks[this.check.class] this.currentOperator = this.operators.find((operator) => operator.operator === this.check.operator) + if (this.currentElement) { + // If we do not set it, the check`s value would remain empty. Unsure why Vue behaves this way. + this.$refs.checkComponent.modelValue = undefined + } else if (this.currentOption?.component) { + // keeping this in an else for apps that try to be backwards compatible and may ship both + // to be removed in 03/2028 + console.warn('Developer warning: `CheckPlugin.options` is deprecated. Use `CheckPlugin.element` instead.') + } + if (this.check.class === null) { this.$nextTick(() => this.$refs.checkSelector.$el.focus()) } @@ -125,15 +161,22 @@ export default { if (this.currentOption && this.currentOption.validate) { this.valid = !!this.currentOption.validate(this.check) } + // eslint-disable-next-line vue/no-mutating-props this.check.invalid = !this.valid this.$emit('validate', this.valid) }, - updateCheck() { - const matchingOperator = this.operators.findIndex((operator) => this.check.operator === operator.operator) + updateCheck(event) { + const selectedOperator = event?.operator || this.currentOperator?.operator || this.check.operator + const matchingOperator = this.operators.findIndex((operator) => selectedOperator === operator.operator) if (this.check.class !== this.currentOption.class || matchingOperator === -1) { this.currentOperator = this.operators[0] } + if (event?.detail) { + this.check.value = event.detail[0] + } + // eslint-disable-next-line vue/no-mutating-props this.check.class = this.currentOption.class + // eslint-disable-next-line vue/no-mutating-props this.check.operator = this.currentOperator.operator this.validate() @@ -148,46 +191,39 @@ export default { .check { display: flex; flex-wrap: wrap; + align-items: flex-start; // to not stretch components vertically width: 100%; - padding-right: 20px; + padding-inline-end: 20px; + & > *:not(.close) { width: 180px; } & > .comparator { - min-width: 130px; - width: 130px; + min-width: 200px; + width: 200px; } & > .option { - min-width: 230px; - width: 230px; + min-width: 260px; + width: 260px; + min-height: 48px; + + & > input[type=text] { + min-height: 48px; + } } - & > .multiselect, + & > .v-select, + & > .button-vue, & > input[type=text] { - margin-right: 5px; + margin-inline-end: 5px; margin-bottom: 5px; } - - .multiselect::v-deep .multiselect__content-wrapper li>span, - .multiselect::v-deep .multiselect__single { - display: block; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } } + input[type=text] { margin: 0; } - ::placeholder { - font-size: 10px; - } - button.action-item.action-item--single.icon-close { - height: 44px; - width: 44px; - margin-top: -5px; - margin-bottom: -5px; - } + .invalid { - border: 1px solid var(--color-error) !important; + border-color: var(--color-error) !important; } </style> diff --git a/apps/workflowengine/src/components/Checks/FileMimeType.vue b/apps/workflowengine/src/components/Checks/FileMimeType.vue index 501b7a598a0..6817b128e27 100644 --- a/apps/workflowengine/src/components/Checks/FileMimeType.vue +++ b/apps/workflowengine/src/components/Checks/FileMimeType.vue @@ -1,92 +1,87 @@ <!-- - - @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> - - - - @author Julius Härtl <jus@bitgrid.net> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> - + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div> - <Multiselect - :value="currentValue" + <NcSelect :model-value="currentValue" :placeholder="t('workflowengine', 'Select a file type')" label="label" - track-by="pattern" :options="options" - :multiple="false" - :tagging="false" + :clearable="false" @input="setValue"> - <template slot="singleLabel" slot-scope="props"> - <span v-if="props.option.icon" class="option__icon" :class="props.option.icon" /> - <img v-else :src="props.option.iconUrl"> - <span class="option__title option__title_single">{{ props.option.label }}</span> + <template #option="option"> + <span v-if="option.icon" class="option__icon" :class="option.icon" /> + <span v-else class="option__icon-img"> + <img :src="option.iconUrl" alt=""> + </span> + <span class="option__title"> + <NcEllipsisedOption :name="String(option.label)" /> + </span> </template> - <template slot="option" slot-scope="props"> - <span v-if="props.option.icon" class="option__icon" :class="props.option.icon" /> - <img v-else :src="props.option.iconUrl"> - <span class="option__title">{{ props.option.label }}</span> + <template #selected-option="selectedOption"> + <span v-if="selectedOption.icon" class="option__icon" :class="selectedOption.icon" /> + <span v-else class="option__icon-img"> + <img :src="selectedOption.iconUrl" alt=""> + </span> + <span class="option__title"> + <NcEllipsisedOption :name="String(selectedOption.label)" /> + </span> </template> - </Multiselect> + </NcSelect> <input v-if="!isPredefined" + :value="currentValue.id" type="text" - :value="currentValue.pattern" :placeholder="t('workflowengine', 'e.g. httpd/unix-directory')" @input="updateCustom"> </div> </template> <script> -import Multiselect from '@nextcloud/vue/dist/Components/Multiselect' -import valueMixin from './../../mixins/valueMixin' +import NcEllipsisedOption from '@nextcloud/vue/components/NcEllipsisedOption' +import NcSelect from '@nextcloud/vue/components/NcSelect' import { imagePath } from '@nextcloud/router' export default { name: 'FileMimeType', components: { - Multiselect, + NcEllipsisedOption, + NcSelect, + }, + props: { + modelValue: { + type: String, + default: '', + }, }, - mixins: [ - valueMixin, - ], + + emits: ['update:model-value'], + data() { return { predefinedTypes: [ { icon: 'icon-folder', label: t('workflowengine', 'Folder'), - pattern: 'httpd/unix-directory', + id: 'httpd/unix-directory', }, { icon: 'icon-picture', label: t('workflowengine', 'Images'), - pattern: '/image\\/.*/', + id: '/image\\/.*/', }, { iconUrl: imagePath('core', 'filetypes/x-office-document'), label: t('workflowengine', 'Office documents'), - pattern: '/(vnd\\.(ms-|openxmlformats-|oasis\\.opendocument).*)$/', + id: '/(vnd\\.(ms-|openxmlformats-|oasis\\.opendocument).*)$/', }, { iconUrl: imagePath('core', 'filetypes/application-pdf'), label: t('workflowengine', 'PDF documents'), - pattern: 'application/pdf', + id: 'application/pdf', }, ], + newValue: '', } }, computed: { @@ -94,7 +89,7 @@ export default { return [...this.predefinedTypes, this.customValue] }, isPredefined() { - const matchingPredefined = this.predefinedTypes.find((type) => this.newValue === type.pattern) + const matchingPredefined = this.predefinedTypes.find((type) => this.newValue === type.id) if (matchingPredefined) { return true } @@ -103,50 +98,75 @@ export default { customValue() { return { icon: 'icon-settings-dark', - label: t('workflowengine', 'Custom mimetype'), - pattern: '', + label: t('workflowengine', 'Custom MIME type'), + id: '', } }, currentValue() { - const matchingPredefined = this.predefinedTypes.find((type) => this.newValue === type.pattern) + const matchingPredefined = this.predefinedTypes.find((type) => this.newValue === type.id) if (matchingPredefined) { return matchingPredefined } return { icon: 'icon-settings-dark', label: t('workflowengine', 'Custom mimetype'), - pattern: this.newValue, + id: this.newValue, } }, }, + watch: { + modelValue() { + this.updateInternalValue() + }, + }, + methods: { validateRegex(string) { const regexRegex = /^\/(.*)\/([gui]{0,3})$/ const result = regexRegex.exec(string) return result !== null }, + updateInternalValue() { + this.newValue = this.modelValue + }, setValue(value) { if (value !== null) { - this.newValue = value.pattern - this.$emit('input', this.newValue) + this.newValue = value.id + this.$emit('update:model-value', this.newValue) } }, updateCustom(event) { - this.newValue = event.target.value - this.$emit('input', this.newValue) + this.newValue = event.target.value || event.detail[0] + this.$emit('update:model-value', this.newValue) }, }, } </script> -<style scoped> - .multiselect, input[type='text'] { - width: 100%; - } - .multiselect >>> .multiselect__content-wrapper li>span, - .multiselect >>> .multiselect__single { - display: flex; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } +<style scoped lang="scss"> +.v-select, +input[type='text'] { + width: 100%; +} + +input[type=text] { + min-height: 48px; +} + +.option__icon, +.option__icon-img { + display: inline-block; + min-width: 30px; + background-position: center; + vertical-align: middle; +} + +.option__icon-img { + text-align: center; +} + +.option__title { + display: inline-flex; + width: calc(100% - 36px); + vertical-align: middle; +} </style> diff --git a/apps/workflowengine/src/components/Checks/FileSystemTag.vue b/apps/workflowengine/src/components/Checks/FileSystemTag.vue index 828423736a4..e71b0cd259a 100644 --- a/apps/workflowengine/src/components/Checks/FileSystemTag.vue +++ b/apps/workflowengine/src/components/Checks/FileSystemTag.vue @@ -1,53 +1,37 @@ <!-- - - @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> - - - - @author Julius Härtl <jus@bitgrid.net> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> - + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <MultiselectTag v-model="newValue" + <NcSelectTags v-model="newValue" :multiple="false" - :label="t('workflowengine', 'Select a tag')" @input="update" /> </template> <script> -import { MultiselectTag } from './MultiselectTag' +import NcSelectTags from '@nextcloud/vue/components/NcSelectTags' export default { name: 'FileSystemTag', components: { - MultiselectTag, + NcSelectTags, }, props: { - value: { + modelValue: { type: String, default: '', }, }, + + emits: ['update:model-value'], + data() { return { newValue: [], } }, watch: { - value() { + modelValue() { this.updateValue() }, }, @@ -56,19 +40,15 @@ export default { }, methods: { updateValue() { - if (this.value !== '') { - this.newValue = this.value + if (this.modelValue !== '') { + this.newValue = parseInt(this.modelValue) } else { this.newValue = null } }, update() { - this.$emit('input', this.newValue || '') + this.$emit('update:model-value', this.newValue || '') }, }, } </script> - -<style scoped> - -</style> diff --git a/apps/workflowengine/src/components/Checks/MultiselectTag/MultiselectTag.vue b/apps/workflowengine/src/components/Checks/MultiselectTag/MultiselectTag.vue deleted file mode 100644 index c5419f69c3f..00000000000 --- a/apps/workflowengine/src/components/Checks/MultiselectTag/MultiselectTag.vue +++ /dev/null @@ -1,130 +0,0 @@ -<!-- - - @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> - - - - @author Julius Härtl <jus@bitgrid.net> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> - -<template> - <Multiselect v-model="inputValObjects" - :options="tags" - :options-limit="5" - :placeholder="label" - track-by="id" - :custom-label="tagLabel" - class="multiselect-vue" - :multiple="multiple" - :close-on-select="false" - :tag-width="60" - :disabled="disabled" - @input="update"> - <span slot="noResult">{{ t('core', 'No results') }}</span> - <template #option="scope"> - {{ tagLabel(scope.option) }} - </template> - </multiselect> -</template> - -<script> -import Multiselect from '@nextcloud/vue/dist/Components/Multiselect' -import { searchTags } from './api' - -let uuid = 0 -export default { - name: 'MultiselectTag', - components: { - Multiselect, - }, - props: { - label: { - type: String, - required: true, - }, - value: { - type: [String, Array], - default: null, - }, - disabled: { - type: Boolean, - default: false, - }, - multiple: { - type: Boolean, - default: false, - }, - }, - data() { - return { - inputValObjects: [], - tags: [], - } - }, - computed: { - id() { - return 'settings-input-text-' + this.uuid - }, - }, - watch: { - value(newVal) { - this.inputValObjects = this.getValueObject() - }, - }, - beforeCreate() { - this.uuid = uuid.toString() - uuid += 1 - searchTags().then((result) => { - this.tags = result - this.inputValObjects = this.getValueObject() - }).catch(console.error.bind(this)) - }, - methods: { - getValueObject() { - if (this.tags.length === 0) { - return [] - } - if (this.multiple) { - return this.value.filter((tag) => tag !== '').map( - (id) => this.tags.find((tag2) => tag2.id === id) - ) - } else { - return this.tags.find((tag) => tag.id === this.value) - } - }, - update() { - if (this.multiple) { - this.$emit('input', this.inputValObjects.map((element) => element.id)) - } else { - if (this.inputValObjects === null) { - this.$emit('input', '') - } else { - this.$emit('input', this.inputValObjects.id) - } - } - }, - tagLabel({ displayName, userVisible, userAssignable }) { - if (userVisible === false) { - return t('systemtags', '%s (invisible)').replace('%s', displayName) - } - if (userAssignable === false) { - return t('systemtags', '%s (restricted)').replace('%s', displayName) - } - return displayName - }, - }, -} -</script> diff --git a/apps/workflowengine/src/components/Checks/MultiselectTag/api.js b/apps/workflowengine/src/components/Checks/MultiselectTag/api.js deleted file mode 100644 index d5295a5b6da..00000000000 --- a/apps/workflowengine/src/components/Checks/MultiselectTag/api.js +++ /dev/null @@ -1,112 +0,0 @@ -/** - * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> - * - * @author Julius Härtl <jus@bitgrid.net> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -import axios from '@nextcloud/axios' -import { generateRemoteUrl } from '@nextcloud/router' - -const xmlToJson = (xml) => { - let obj = {} - - if (xml.nodeType === 1) { - if (xml.attributes.length > 0) { - obj['@attributes'] = {} - for (let j = 0; j < xml.attributes.length; j++) { - const attribute = xml.attributes.item(j) - obj['@attributes'][attribute.nodeName] = attribute.nodeValue - } - } - } else if (xml.nodeType === 3) { - obj = xml.nodeValue - } - - if (xml.hasChildNodes()) { - for (let i = 0; i < xml.childNodes.length; i++) { - const item = xml.childNodes.item(i) - const nodeName = item.nodeName - if (typeof (obj[nodeName]) === 'undefined') { - obj[nodeName] = xmlToJson(item) - } else { - if (typeof obj[nodeName].push === 'undefined') { - const old = obj[nodeName] - obj[nodeName] = [] - obj[nodeName].push(old) - } - obj[nodeName].push(xmlToJson(item)) - } - } - } - return obj -} - -const parseXml = (xml) => { - let dom = null - try { - dom = (new DOMParser()).parseFromString(xml, 'text/xml') - } catch (e) { - console.error('Failed to parse xml document', e) - } - return dom -} - -const xmlToTagList = (xml) => { - const json = xmlToJson(parseXml(xml)) - const list = json['d:multistatus']['d:response'] - const result = [] - for (const index in list) { - const tag = list[index]['d:propstat'] - - if (tag['d:status']['#text'] !== 'HTTP/1.1 200 OK') { - continue - } - result.push({ - id: tag['d:prop']['oc:id']['#text'], - displayName: tag['d:prop']['oc:display-name']['#text'], - canAssign: tag['d:prop']['oc:can-assign']['#text'] === 'true', - userAssignable: tag['d:prop']['oc:user-assignable']['#text'] === 'true', - userVisible: tag['d:prop']['oc:user-visible']['#text'] === 'true', - }) - } - return result -} - -const searchTags = function() { - return axios({ - method: 'PROPFIND', - url: generateRemoteUrl('dav') + '/systemtags/', - data: `<?xml version="1.0"?> - <d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns"> - <d:prop> - <oc:id /> - <oc:display-name /> - <oc:user-visible /> - <oc:user-assignable /> - <oc:can-assign /> - </d:prop> - </d:propfind>`, - }).then((response) => { - return xmlToTagList(response.data) - }) -} - -export { - searchTags, -} diff --git a/apps/workflowengine/src/components/Checks/MultiselectTag/index.js b/apps/workflowengine/src/components/Checks/MultiselectTag/index.js deleted file mode 100644 index 99628cf145a..00000000000 --- a/apps/workflowengine/src/components/Checks/MultiselectTag/index.js +++ /dev/null @@ -1,26 +0,0 @@ -/** - * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> - * - * @author Julius Härtl <jus@bitgrid.net> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -import MultiselectTag from './MultiselectTag' - -export default MultiselectTag -export { MultiselectTag } diff --git a/apps/workflowengine/src/components/Checks/RequestTime.vue b/apps/workflowengine/src/components/Checks/RequestTime.vue index 6723ba52f93..5b1a4ef1cfa 100644 --- a/apps/workflowengine/src/components/Checks/RequestTime.vue +++ b/apps/workflowengine/src/components/Checks/RequestTime.vue @@ -1,3 +1,7 @@ +<!-- + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div class="timeslot"> <input v-model="newValue.startTime" @@ -12,33 +16,31 @@ <p v-if="!valid" class="invalid-hint"> {{ t('workflowengine', 'Please enter a valid time span') }} </p> - <Multiselect v-show="valid" + <NcSelect v-show="valid" v-model="newValue.timezone" + :clearable="false" :options="timezones" @input="update" /> </div> </template> <script> -import Multiselect from '@nextcloud/vue/dist/Components/Multiselect' +import NcSelect from '@nextcloud/vue/components/NcSelect' import moment from 'moment-timezone' -import valueMixin from '../../mixins/valueMixin' const zones = moment.tz.names() export default { name: 'RequestTime', components: { - Multiselect, + NcSelect, }, - mixins: [ - valueMixin, - ], props: { - value: { + modelValue: { type: String, - default: '', + default: '[]', }, }, + emits: ['update:model-value'], data() { return { timezones: zones, @@ -48,21 +50,31 @@ export default { endTime: null, timezone: moment.tz.guess(), }, + stringifiedValue: '[]', } }, - mounted() { - this.validate() + watch: { + modelValue() { + this.updateInternalValue() + }, + }, + beforeMount() { + // this is necessary to keep so the value is re-applied when a different + // check is being removed. + this.updateInternalValue() }, methods: { - updateInternalValue(value) { + updateInternalValue() { try { - const data = JSON.parse(value) + const data = JSON.parse(this.modelValue) if (data.length === 2) { this.newValue = { startTime: data[0].split(' ', 2)[0], endTime: data[1].split(' ', 2)[0], timezone: data[0].split(' ', 2)[1], } + this.stringifiedValue = `["${this.newValue.startTime} ${this.newValue.timezone}","${this.newValue.endTime} ${this.newValue.timezone}"]` + this.validate() } } catch (e) { // ignore invalid values @@ -84,8 +96,8 @@ export default { this.newValue.timezone = moment.tz.guess() } if (this.validate()) { - const output = `["${this.newValue.startTime} ${this.newValue.timezone}","${this.newValue.endTime} ${this.newValue.timezone}"]` - this.$emit('input', output) + this.stringifiedValue = `["${this.newValue.startTime} ${this.newValue.timezone}","${this.newValue.endTime} ${this.newValue.timezone}"]` + this.$emit('update:model-value', this.stringifiedValue) } }, }, @@ -104,7 +116,7 @@ export default { margin-bottom: 5px; } - .multiselect::v-deep .multiselect__tags:not(:hover):not(:focus):not(:active) { + .multiselect:deep(.multiselect__tags:not(:hover):not(:focus):not(:active)) { border: 1px solid transparent; } @@ -112,9 +124,10 @@ export default { width: 50%; margin: 0; margin-bottom: 5px; + min-height: 48px; &.timeslot--start { - margin-right: 5px; + margin-inline-end: 5px; width: calc(50% - 5px); } } diff --git a/apps/workflowengine/src/components/Checks/RequestURL.vue b/apps/workflowengine/src/components/Checks/RequestURL.vue index 06ea8ea3bfb..21b3a9cacbe 100644 --- a/apps/workflowengine/src/components/Checks/RequestURL.vue +++ b/apps/workflowengine/src/components/Checks/RequestURL.vue @@ -1,76 +1,72 @@ <!-- - - @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> - - - - @author Julius Härtl <jus@bitgrid.net> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> - + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div> - <Multiselect + <NcSelect v-model="newValue" :value="currentValue" :placeholder="t('workflowengine', 'Select a request URL')" label="label" - track-by="pattern" - group-values="children" - group-label="label" + :clearable="false" :options="options" - :multiple="false" - :tagging="false" @input="setValue"> - <template slot="singleLabel" slot-scope="props"> - <span class="option__icon" :class="props.option.icon" /> - <span class="option__title option__title_single">{{ props.option.label }}</span> + <template #option="option"> + <span class="option__icon" :class="option.icon" /> + <span class="option__title"> + <NcEllipsisedOption :name="String(option.label)" /> + </span> </template> - <template slot="option" slot-scope="props"> - <span class="option__icon" :class="props.option.icon" /> - <span class="option__title">{{ props.option.label }} {{ props.option.$groupLabel }}</span> + <template #selected-option="selectedOption"> + <span class="option__icon" :class="selectedOption.icon" /> + <span class="option__title"> + <NcEllipsisedOption :name="String(selectedOption.label)" /> + </span> </template> - </Multiselect> + </NcSelect> <input v-if="!isPredefined" type="text" - :value="currentValue.pattern" + :value="currentValue.id" :placeholder="placeholder" @input="updateCustom"> </div> </template> <script> -import Multiselect from '@nextcloud/vue/dist/Components/Multiselect' -import valueMixin from '../../mixins/valueMixin' +import NcEllipsisedOption from '@nextcloud/vue/components/NcEllipsisedOption' +import NcSelect from '@nextcloud/vue/components/NcSelect' +import valueMixin from '../../mixins/valueMixin.js' export default { name: 'RequestURL', components: { - Multiselect, + NcEllipsisedOption, + NcSelect, }, mixins: [ valueMixin, ], + props: { + modelValue: { + type: String, + default: '', + }, + operator: { + type: String, + default: '', + }, + }, + + emits: ['update:model-value'], + data() { return { newValue: '', predefinedTypes: [ { - label: t('workflowengine', 'Predefined URLs'), - children: [ - { pattern: 'webdav', label: t('workflowengine', 'Files WebDAV') }, - ], + icon: 'icon-files-dark', + id: 'webdav', + label: t('workflowengine', 'Files WebDAV'), }, ], } @@ -80,30 +76,23 @@ export default { return [...this.predefinedTypes, this.customValue] }, placeholder() { - if (this.check.operator === 'matches' || this.check.operator === '!matches') { + if (this.operator === 'matches' || this.operator === '!matches') { return '/^https\\:\\/\\/localhost\\/index\\.php$/i' } return 'https://localhost/index.php' }, matchingPredefined() { return this.predefinedTypes - .map(groups => groups.children) - .flat() - .find((type) => this.newValue === type.pattern) + .find((type) => this.newValue === type.id) }, isPredefined() { return !!this.matchingPredefined }, customValue() { return { - label: t('workflowengine', 'Others'), - children: [ - { - icon: 'icon-settings-dark', - label: t('workflowengine', 'Custom URL'), - pattern: '', - }, - ], + icon: 'icon-settings-dark', + label: t('workflowengine', 'Custom URL'), + id: '', } }, currentValue() { @@ -113,7 +102,7 @@ export default { return { icon: 'icon-settings-dark', label: t('workflowengine', 'Custom URL'), - pattern: this.newValue, + id: this.newValue, } }, }, @@ -126,19 +115,37 @@ export default { setValue(value) { // TODO: check if value requires a regex and set the check operator according to that if (value !== null) { - this.newValue = value.pattern - this.$emit('input', this.newValue) + this.newValue = value.id + this.$emit('update:model-value', this.newValue) } }, updateCustom(event) { this.newValue = event.target.value - this.$emit('input', this.newValue) + this.$emit('update:model-value', this.newValue) }, }, } </script> -<style scoped> - .multiselect, input[type='text'] { +<style scoped lang="scss"> + .v-select, + input[type='text'] { width: 100%; } + + input[type='text'] { + min-height: 48px; + } + + .option__icon { + display: inline-block; + min-width: 30px; + background-position: center; + vertical-align: middle; + } + + .option__title { + display: inline-flex; + width: calc(100% - 36px); + vertical-align: middle; + } </style> diff --git a/apps/workflowengine/src/components/Checks/RequestUserAgent.vue b/apps/workflowengine/src/components/Checks/RequestUserAgent.vue index ffee4acb9a9..825a112f6fc 100644 --- a/apps/workflowengine/src/components/Checks/RequestUserAgent.vue +++ b/apps/workflowengine/src/components/Checks/RequestUserAgent.vue @@ -1,77 +1,64 @@ <!-- - - @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> - - - - @author Julius Härtl <jus@bitgrid.net> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> - + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div> - <Multiselect - :value="currentValue" + <NcSelect v-model="currentValue" :placeholder="t('workflowengine', 'Select a user agent')" label="label" - track-by="pattern" :options="options" - :multiple="false" - :tagging="false" + :clearable="false" @input="setValue"> - <template slot="singleLabel" slot-scope="props"> - <span class="option__icon" :class="props.option.icon" /> - <!-- v-html can be used here as t() always passes our translated strings though DOMPurify.sanitize --> - <!-- eslint-disable-next-line vue/no-v-html --> - <span class="option__title option__title_single" v-html="props.option.label" /> + <template #option="option"> + <span class="option__icon" :class="option.icon" /> + <span class="option__title"> + <NcEllipsisedOption :name="String(option.label)" /> + </span> </template> - <template slot="option" slot-scope="props"> - <span class="option__icon" :class="props.option.icon" /> - <!-- eslint-disable-next-line vue/no-v-html --> - <span v-if="props.option.$groupLabel" class="option__title" v-html="props.option.$groupLabel" /> - <!-- eslint-disable-next-line vue/no-v-html --> - <span v-else class="option__title" v-html="props.option.label" /> + <template #selected-option="selectedOption"> + <span class="option__icon" :class="selectedOption.icon" /> + <span class="option__title"> + <NcEllipsisedOption :name="String(selectedOption.label)" /> + </span> </template> - </Multiselect> + </NcSelect> <input v-if="!isPredefined" + v-model="newValue" type="text" - :value="currentValue.pattern" @input="updateCustom"> </div> </template> <script> -import Multiselect from '@nextcloud/vue/dist/Components/Multiselect' -import valueMixin from '../../mixins/valueMixin' +import NcEllipsisedOption from '@nextcloud/vue/components/NcEllipsisedOption' +import NcSelect from '@nextcloud/vue/components/NcSelect' +import valueMixin from '../../mixins/valueMixin.js' export default { name: 'RequestUserAgent', components: { - Multiselect, + NcEllipsisedOption, + NcSelect, }, mixins: [ valueMixin, ], + props: { + modelValue: { + type: String, + default: '', + }, + }, + emits: ['update:model-value'], data() { return { newValue: '', predefinedTypes: [ - { pattern: 'android', label: t('workflowengine', 'Android client'), icon: 'icon-phone' }, - { pattern: 'ios', label: t('workflowengine', 'iOS client'), icon: 'icon-phone' }, - { pattern: 'desktop', label: t('workflowengine', 'Desktop client'), icon: 'icon-desktop' }, - { pattern: 'mail', label: t('workflowengine', 'Thunderbird & Outlook addons'), icon: 'icon-mail' }, + { id: 'android', label: t('workflowengine', 'Android client'), icon: 'icon-phone' }, + { id: 'ios', label: t('workflowengine', 'iOS client'), icon: 'icon-phone' }, + { id: 'desktop', label: t('workflowengine', 'Desktop client'), icon: 'icon-desktop' }, + { id: 'mail', label: t('workflowengine', 'Thunderbird & Outlook addons'), icon: 'icon-mail' }, ], } }, @@ -81,7 +68,7 @@ export default { }, matchingPredefined() { return this.predefinedTypes - .find((type) => this.newValue === type.pattern) + .find((type) => this.newValue === type.id) }, isPredefined() { return !!this.matchingPredefined @@ -90,18 +77,23 @@ export default { return { icon: 'icon-settings-dark', label: t('workflowengine', 'Custom user agent'), - pattern: '', + id: '', } }, - currentValue() { - if (this.matchingPredefined) { - return this.matchingPredefined - } - return { - icon: 'icon-settings-dark', - label: t('workflowengine', 'Custom user agent'), - pattern: this.newValue, - } + currentValue: { + get() { + if (this.matchingPredefined) { + return this.matchingPredefined + } + return { + icon: 'icon-settings-dark', + label: t('workflowengine', 'Custom user agent'), + id: this.newValue, + } + }, + set(value) { + this.newValue = value + }, }, }, methods: { @@ -113,43 +105,37 @@ export default { setValue(value) { // TODO: check if value requires a regex and set the check operator according to that if (value !== null) { - this.newValue = value.pattern - this.$emit('input', this.newValue) + this.newValue = value.id + this.$emit('update:model-value', this.newValue) } }, - updateCustom(event) { - this.newValue = event.target.value - this.$emit('input', this.newValue) + updateCustom() { + this.newValue = this.currentValue.id + this.$emit('update:model-value', this.newValue) }, }, } </script> <style scoped> - .multiselect, input[type='text'] { + .v-select, + input[type='text'] { width: 100%; } - .multiselect .multiselect__content-wrapper li>span { - display: flex; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - .multiselect::v-deep .multiselect__single { - width: 100%; - display: flex; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + input[type='text'] { + min-height: 48px; } + .option__icon { display: inline-block; min-width: 30px; - background-position: left; + background-position: center; + vertical-align: middle; } + .option__title { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + display: inline-flex; + width: calc(100% - 36px); + vertical-align: middle; } </style> diff --git a/apps/workflowengine/src/components/Checks/RequestUserGroup.vue b/apps/workflowengine/src/components/Checks/RequestUserGroup.vue index f9f6226b4e4..f9606b7ca26 100644 --- a/apps/workflowengine/src/components/Checks/RequestUserGroup.vue +++ b/apps/workflowengine/src/components/Checks/RequestUserGroup.vue @@ -1,44 +1,31 @@ <!-- - - @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> - - - - @author Julius Härtl <jus@bitgrid.net> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> - + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div> - <Multiselect :value="currentValue" + <NcSelect :aria-label-combobox="t('workflowengine', 'Select groups')" + :aria-label-listbox="t('workflowengine', 'Groups')" + :clearable="false" :loading="status.isLoading && groups.length === 0" + :placeholder="t('workflowengine', 'Type to search for group …')" :options="groups" - :multiple="false" + :model-value="currentValue" label="displayname" - track-by="id" - @search-change="searchAsync" - @input="(value) => $emit('input', value.id)" /> + @search="searchAsync" + @input="update" /> </div> </template> <script> -import Multiselect from '@nextcloud/vue/dist/Components/Multiselect' -import axios from '@nextcloud/axios' +import { translate as t } from '@nextcloud/l10n' import { generateOcsUrl } from '@nextcloud/router' +import axios from '@nextcloud/axios' +import NcSelect from '@nextcloud/vue/components/NcSelect' + const groups = [] +const wantedGroups = [] const status = { isLoading: false, } @@ -46,10 +33,10 @@ const status = { export default { name: 'RequestUserGroup', components: { - Multiselect, + NcSelect, }, props: { - value: { + modelValue: { type: String, default: '', }, @@ -58,33 +45,57 @@ export default { default: () => { return {} }, }, }, + emits: ['update:model-value'], data() { return { groups, status, + wantedGroups, + newValue: '', } }, computed: { - currentValue() { - return this.groups.find(group => group.id === this.value) || null + currentValue: { + get() { + return this.groups.find(group => group.id === this.newValue) || null + }, + set(value) { + this.newValue = value + }, + }, + }, + watch: { + modelValue() { + this.updateInternalValue() }, }, async mounted() { + // If empty, load first chunk of groups if (this.groups.length === 0) { await this.searchAsync('') } - if (this.currentValue === null) { - await this.searchAsync(this.value) + // If a current group is set but not in our list of groups then search for that group + if (this.currentValue === null && this.newValue) { + await this.searchAsync(this.newValue) } }, methods: { + t, + searchAsync(searchQuery) { if (this.status.isLoading) { + if (searchQuery) { + // The first 20 groups are loaded up front (indicated by an + // empty searchQuery parameter), afterwards we may load + // groups that have not been fetched yet, but are used + // in existing rules. + this.enqueueWantedGroup(searchQuery) + } return } this.status.isLoading = true - return axios.get(generateOcsUrl('cloud', 2) + 'groups/details?limit=20&search=' + encodeURI(searchQuery)).then((response) => { + return axios.get(generateOcsUrl('cloud/groups/details?limit=20&search={searchQuery}', { searchQuery })).then((response) => { response.data.ocs.data.groups.forEach((group) => { this.addGroup({ id: group.id, @@ -92,21 +103,54 @@ export default { }) }) this.status.isLoading = false + this.findGroupByQueue() }, (error) => { console.error('Error while loading group list', error.response) }) }, + async updateInternalValue() { + if (!this.newValue) { + await this.searchAsync(this.modelValue) + } + this.newValue = this.modelValue + }, addGroup(group) { const index = this.groups.findIndex((item) => item.id === group.id) if (index === -1) { this.groups.push(group) } }, + hasGroup(group) { + const index = this.groups.findIndex((item) => item.id === group) + return index > -1 + }, + update(value) { + this.newValue = value.id + this.$emit('update:model-value', this.newValue) + }, + enqueueWantedGroup(expectedGroupId) { + const index = this.wantedGroups.findIndex((groupId) => groupId === expectedGroupId) + if (index === -1) { + this.wantedGroups.push(expectedGroupId) + } + }, + async findGroupByQueue() { + let nextQuery + do { + nextQuery = this.wantedGroups.shift() + if (this.hasGroup(nextQuery)) { + nextQuery = undefined + } + } while (!nextQuery && this.wantedGroups.length > 0) + if (nextQuery) { + await this.searchAsync(nextQuery) + } + }, }, } </script> <style scoped> - .multiselect { - width: 100%; - } +.v-select { + width: 100%; +} </style> diff --git a/apps/workflowengine/src/components/Checks/file.js b/apps/workflowengine/src/components/Checks/file.js index 26c246be591..568efc81cd3 100644 --- a/apps/workflowengine/src/components/Checks/file.js +++ b/apps/workflowengine/src/components/Checks/file.js @@ -1,28 +1,12 @@ /** - * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> - * - * @author Julius Härtl <jus@bitgrid.net> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { stringValidator, validateIPv4, validateIPv6 } from '../../helpers/validators' -import FileMimeType from './FileMimeType' -import FileSystemTag from './FileSystemTag' +import { stringValidator, validateIPv4, validateIPv6 } from '../../helpers/validators.js' +import { registerCustomElement } from '../../helpers/window.js' +import FileMimeType from './FileMimeType.vue' +import FileSystemTag from './FileSystemTag.vue' const stringOrRegexOperators = () => { return [ @@ -51,7 +35,7 @@ const FileChecks = [ class: 'OCA\\WorkflowEngine\\Check\\FileMimeType', name: t('workflowengine', 'File MIME type'), operators: stringOrRegexOperators, - component: FileMimeType, + element: registerCustomElement(FileMimeType, 'oca-workflowengine-checks-file_mime_type'), }, { @@ -97,7 +81,7 @@ const FileChecks = [ { operator: 'is', name: t('workflowengine', 'is tagged with') }, { operator: '!is', name: t('workflowengine', 'is not tagged with') }, ], - component: FileSystemTag, + element: registerCustomElement(FileSystemTag, 'oca-workflowengine-file_system_tag'), }, ] diff --git a/apps/workflowengine/src/components/Checks/index.js b/apps/workflowengine/src/components/Checks/index.js index 4f68f5e074c..fc52f95f78a 100644 --- a/apps/workflowengine/src/components/Checks/index.js +++ b/apps/workflowengine/src/components/Checks/index.js @@ -1,26 +1,9 @@ /** - * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> - * - * @author Julius Härtl <jus@bitgrid.net> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import FileChecks from './file' -import RequestChecks from './request' +import FileChecks from './file.js' +import RequestChecks from './request.js' export default [...FileChecks, ...RequestChecks] diff --git a/apps/workflowengine/src/components/Checks/request.js b/apps/workflowengine/src/components/Checks/request.js index 22710315c2e..b91f00baef0 100644 --- a/apps/workflowengine/src/components/Checks/request.js +++ b/apps/workflowengine/src/components/Checks/request.js @@ -1,29 +1,13 @@ /** - * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> - * - * @author Julius Härtl <jus@bitgrid.net> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import RequestUserAgent from './RequestUserAgent' -import RequestTime from './RequestTime' -import RequestURL from './RequestURL' -import RequestUserGroup from './RequestUserGroup' +import { registerCustomElement } from '../../helpers/window.js' +import RequestUserAgent from './RequestUserAgent.vue' +import RequestTime from './RequestTime.vue' +import RequestURL from './RequestURL.vue' +import RequestUserGroup from './RequestUserGroup.vue' const RequestChecks = [ { @@ -35,7 +19,7 @@ const RequestChecks = [ { operator: 'matches', name: t('workflowengine', 'matches') }, { operator: '!matches', name: t('workflowengine', 'does not match') }, ], - component: RequestURL, + element: registerCustomElement(RequestURL, 'oca-workflowengine-checks-request_url'), }, { class: 'OCA\\WorkflowEngine\\Check\\RequestTime', @@ -44,7 +28,7 @@ const RequestChecks = [ { operator: 'in', name: t('workflowengine', 'between') }, { operator: '!in', name: t('workflowengine', 'not between') }, ], - component: RequestTime, + element: registerCustomElement(RequestTime, 'oca-workflowengine-checks-request_time'), }, { class: 'OCA\\WorkflowEngine\\Check\\RequestUserAgent', @@ -55,16 +39,16 @@ const RequestChecks = [ { operator: 'matches', name: t('workflowengine', 'matches') }, { operator: '!matches', name: t('workflowengine', 'does not match') }, ], - component: RequestUserAgent, + element: registerCustomElement(RequestUserAgent, 'oca-workflowengine-checks-request_user_agent'), }, { class: 'OCA\\WorkflowEngine\\Check\\UserGroupMembership', - name: t('workflowengine', 'User group membership'), + name: t('workflowengine', 'Group membership'), operators: [ { operator: 'is', name: t('workflowengine', 'is member of') }, { operator: '!is', name: t('workflowengine', 'is not member of') }, ], - component: RequestUserGroup, + element: registerCustomElement(RequestUserGroup, 'oca-workflowengine-checks-request_user_group'), }, ] diff --git a/apps/workflowengine/src/components/Event.vue b/apps/workflowengine/src/components/Event.vue index a00777d25e6..f170101b4e9 100644 --- a/apps/workflowengine/src/components/Event.vue +++ b/apps/workflowengine/src/components/Event.vue @@ -1,39 +1,42 @@ +<!-- + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div class="event"> <div v-if="operation.isComplex && operation.fixedEntity !== ''" class="isComplex"> - <img class="option__icon" :src="entity.icon"> + <img class="option__icon" :src="entity.icon" alt=""> <span class="option__title option__title_single">{{ operation.triggerHint }}</span> </div> - <Multiselect v-else - :value="currentEvent" - :options="allEvents" - track-by="id" - :multiple="true" - :auto-limit="false" + <NcSelect v-else :disabled="allEvents.length <= 1" + :multiple="true" + :options="allEvents" + :value="currentEvent" + :placeholder="placeholderString" + class="event__trigger" + label="displayName" @input="updateEvent"> - <template slot="selection" slot-scope="{ values, search, isOpen }"> - <div v-if="values.length && !isOpen" class="eventlist"> - <img class="option__icon" :src="values[0].entity.icon"> - <span v-for="(value, index) in values" :key="value.id" class="text option__title option__title_single">{{ value.displayName }} <span v-if="index+1 < values.length">, </span></span> - </div> + <template #option="option"> + <img class="option__icon" :src="option.entity.icon" alt=""> + <span class="option__title">{{ option.displayName }}</span> </template> - <template slot="option" slot-scope="props"> - <img class="option__icon" :src="props.option.entity.icon"> - <span class="option__title">{{ props.option.displayName }}</span> + <template #selected-option="option"> + <img class="option__icon" :src="option.entity.icon" alt=""> + <span class="option__title">{{ option.displayName }}</span> </template> - </Multiselect> + </NcSelect> </div> </template> <script> -import Multiselect from '@nextcloud/vue/dist/Components/Multiselect' +import NcSelect from '@nextcloud/vue/components/NcSelect' import { showWarning } from '@nextcloud/dialogs' export default { name: 'Event', components: { - Multiselect, + NcSelect, }, props: { rule: { @@ -54,10 +57,15 @@ export default { currentEvent() { return this.allEvents.filter(event => event.entity.id === this.rule.entity && this.rule.events.indexOf(event.eventName) !== -1) }, + placeholderString() { + // TRANSLATORS: Users should select a trigger for a workflow action + return t('workflowengine', 'Select a trigger') + }, }, methods: { updateEvent(events) { if (events.length === 0) { + // TRANSLATORS: Users must select an event as of "happening" or "incident" which triggers an action showWarning(t('workflowengine', 'At least one event must be selected')) return } @@ -81,7 +89,12 @@ export default { <style scoped lang="scss"> .event { margin-bottom: 5px; + + &__trigger { + max-width: 550px; + } } + .isComplex { img { vertical-align: text-top; @@ -91,50 +104,15 @@ export default { display: inline-block; } } - .multiselect { - width: 100%; - max-width: 550px; - margin-top: 4px; - } - .multiselect::v-deep .multiselect__single { - display: flex; - } - .multiselect:not(.multiselect--active)::v-deep .multiselect__tags { - background-color: var(--color-main-background) !important; - border: 1px solid transparent; - } - - .multiselect::v-deep .multiselect__tags { - background-color: var(--color-main-background) !important; - height: auto; - min-height: 34px; - } - - .multiselect:not(.multiselect--disabled)::v-deep .multiselect__tags .multiselect__single { - background-image: var(--icon-triangle-s-000); - background-repeat: no-repeat; - background-position: right center; - } - - input { - border: 1px solid transparent; - } .option__title { - margin-left: 5px; + margin-inline-start: 5px; color: var(--color-main-text); } - .option__title_single { - font-weight: 900; - } .option__icon { width: 16px; height: 16px; - } - - .eventlist img, - .eventlist .text { - vertical-align: middle; + filter: var(--background-invert-if-dark); } </style> diff --git a/apps/workflowengine/src/components/Operation.vue b/apps/workflowengine/src/components/Operation.vue index 47a40eca950..df0b78dad89 100644 --- a/apps/workflowengine/src/components/Operation.vue +++ b/apps/workflowengine/src/components/Operation.vue @@ -1,14 +1,16 @@ +<!-- + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div class="actions__item" :class="{'colored': colored}" :style="{ backgroundColor: colored ? operation.color : 'transparent' }"> <div class="icon" :class="operation.iconClass" :style="{ backgroundImage: operation.iconClass ? '' : `url(${operation.icon})` }" /> <div class="actions__item__description"> <h3>{{ operation.name }}</h3> <small>{{ operation.description }}</small> - <div> - <button v-if="colored"> - {{ t('workflowengine', 'Add new flow') }} - </button> - </div> + <NcButton v-if="colored"> + {{ t('workflowengine', 'Add new flow') }} + </NcButton> </div> <div class="actions__item_options"> <slot /> @@ -17,8 +19,13 @@ </template> <script> +import NcButton from '@nextcloud/vue/components/NcButton' + export default { name: 'Operation', + components: { + NcButton, + }, props: { operation: { type: Object, @@ -33,5 +40,5 @@ export default { </script> <style scoped lang="scss"> - @import "./../styles/operation"; +@use "./../styles/operation" as *; </style> diff --git a/apps/workflowengine/src/components/Rule.vue b/apps/workflowengine/src/components/Rule.vue index c59c4568f19..1c321fd014c 100644 --- a/apps/workflowengine/src/components/Rule.vue +++ b/apps/workflowengine/src/components/Rule.vue @@ -1,3 +1,7 @@ +<!-- + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div v-if="operation" class="section rule" :style="{ borderLeftColor: operation.color || '' }"> <div class="trigger"> @@ -18,30 +22,36 @@ <input v-if="lastCheckComplete" type="button" class="check--add" - value="Add a new filter" - @click="rule.checks.push({class: null, operator: null, value: ''})"> + :value="t('workflowengine', 'Add a new filter')" + @click="onAddFilter"> </p> </div> <div class="flow-icon icon-confirm" /> <div class="action"> <Operation :operation="operation" :colored="false"> + <component :is="operation.element" + v-if="operation.element" + :model-value="inputValue" + @update:model-value="updateOperationByEvent" /> <component :is="operation.options" - v-if="operation.options" + v-else-if="operation.options" v-model="rule.operation" @input="updateOperation" /> </Operation> <div class="buttons"> - <button class="status-button icon" - :class="ruleStatus.class" - @click="saveRule"> - {{ ruleStatus.title }} - </button> - <button v-if="rule.id < -1 || dirty" @click="cancelRule"> + <NcButton v-if="rule.id < -1 || dirty" @click="cancelRule"> {{ t('workflowengine', 'Cancel') }} - </button> - <button v-else-if="!dirty" @click="deleteRule"> + </NcButton> + <NcButton v-else-if="!dirty" @click="deleteRule"> {{ t('workflowengine', 'Delete') }} - </button> + </NcButton> + <NcButton :type="ruleStatus.type" + @click="saveRule"> + <template #icon> + <component :is="ruleStatus.icon" :size="20" /> + </template> + {{ ruleStatus.title }} + </NcButton> </div> <p v-if="error" class="error-message"> {{ error }} @@ -51,17 +61,27 @@ </template> <script> -import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip' -import Actions from '@nextcloud/vue/dist/Components/Actions' -import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' -import Event from './Event' -import Check from './Check' -import Operation from './Operation' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcButton from '@nextcloud/vue/components/NcButton' +import Tooltip from '@nextcloud/vue/directives/Tooltip' +import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue' +import IconCheckMark from 'vue-material-design-icons/Check.vue' +import IconClose from 'vue-material-design-icons/Close.vue' + +import Event from './Event.vue' +import Check from './Check.vue' +import Operation from './Operation.vue' export default { name: 'Rule', components: { - Operation, Check, Event, Actions, ActionButton, + Check, + Event, + NcActionButton, + NcActions, + NcButton, + Operation, }, directives: { Tooltip, @@ -79,9 +99,14 @@ export default { error: null, dirty: this.rule.id < 0, originalRule: null, + element: null, + inputValue: '', } }, computed: { + /** + * @return {OperatorPlugin} + */ operation() { return this.$store.getters.getOperationForRule(this.rule) }, @@ -89,14 +114,15 @@ export default { if (this.error || !this.rule.valid || this.rule.checks.length === 0 || this.rule.checks.some((check) => check.invalid === true)) { return { title: t('workflowengine', 'The configuration is invalid'), - class: 'icon-close-white invalid', + icon: IconClose, + type: 'warning', tooltip: { placement: 'bottom', show: true, content: this.error }, } } if (!this.dirty) { - return { title: t('workflowengine', 'Active'), class: 'icon icon-checkmark' } + return { title: t('workflowengine', 'Active'), icon: IconCheckMark, type: 'success' } } - return { title: t('workflowengine', 'Save'), class: 'icon-confirm-white primary' } + return { title: t('workflowengine', 'Save'), icon: IconArrowRight, type: 'primary' } }, lastCheckComplete() { @@ -106,13 +132,25 @@ export default { }, mounted() { this.originalRule = JSON.parse(JSON.stringify(this.rule)) + if (this.operation?.element) { + this.inputValue = this.rule.operation + } else if (this.operation?.options) { + // keeping this in an else for apps that try to be backwards compatible and may ship both + // to be removed in 03/2028 + console.warn('Developer warning: `OperatorPlugin.options` is deprecated. Use `OperatorPlugin.element` instead.') + } }, methods: { async updateOperation(operation) { this.$set(this.rule, 'operation', operation) - await this.updateRule() + this.updateRule() }, - validate(state) { + async updateOperationByEvent(event) { + this.inputValue = event.detail[0] + this.$set(this.rule, 'operation', event.detail[0]) + this.updateRule() + }, + validate(/* state */) { this.error = null this.$store.dispatch('updateRule', this.rule) }, @@ -147,11 +185,13 @@ export default { if (this.rule.id < 0) { this.$store.dispatch('removeRule', this.rule) } else { + this.inputValue = this.originalRule.operation this.$store.dispatch('updateRule', this.originalRule) this.originalRule = JSON.parse(JSON.stringify(this.rule)) this.dirty = false } }, + async removeCheck(check) { const index = this.rule.checks.findIndex(item => item === check) if (index > -1) { @@ -159,50 +199,32 @@ export default { } this.$store.dispatch('updateRule', this.rule) }, + + onAddFilter() { + // eslint-disable-next-line vue/no-mutating-props + this.rule.checks.push({ class: null, operator: null, value: '' }) + }, }, } </script> <style scoped lang="scss"> - button.icon { - padding-left: 32px; - background-position: 10px center; - } .buttons { - display: block; - overflow: hidden; + display: flex; + justify-content: end; button { - float: right; - height: 34px; + margin-inline-start: 5px; + } + button:last-child{ + margin-inline-end: 10px; } } .error-message { float: right; - margin-right: 10px; - } - - .status-button { - transition: 0.5s ease all; - display: block; - margin: 3px 10px 3px auto; - } - .status-button.primary { - padding-left: 32px; - background-position: 10px center; - } - .status-button:not(.primary) { - background-color: var(--color-main-background); - } - .status-button.invalid { - background-color: var(--color-warning); - color: #fff; - border: none; - } - .status-button.icon-checkmark { - border: 1px solid var(--color-success); + margin-inline-end: 10px; } .flow-icon { @@ -212,12 +234,13 @@ export default { .rule { display: flex; flex-wrap: wrap; - border-left: 5px solid var(--color-primary-element); + border-inline-start: 5px solid var(--color-primary-element); - .trigger, .action { + .trigger, + .action { flex-grow: 1; min-height: 100px; - max-width: 700px; + max-width: 920px; } .action { max-width: 400px; @@ -225,19 +248,20 @@ export default { } .icon-confirm { background-position: right 27px; - padding-right: 20px; - margin-right: 20px; + padding-inline-end: 20px; + margin-inline-end: 20px; } } + .trigger p, .action p { min-height: 34px; display: flex; & > span { min-width: 50px; - text-align: right; + text-align: end; color: var(--color-text-maxcontrast); - padding-right: 10px; + padding-inline-end: 10px; padding-top: 6px; } .multiselect { @@ -245,20 +269,25 @@ export default { max-width: 300px; } } + .trigger p:first-child span { padding-top: 3px; } + .trigger p:last-child { + padding-top: 8px; + } + .check--add { background-position: 7px center; background-color: transparent; - padding-left: 6px; + padding-inline-start: 6px; margin: 0; width: 180px; border-radius: var(--border-radius); color: var(--color-text-maxcontrast); font-weight: normal; - text-align: left; + text-align: start; font-size: 1em; } diff --git a/apps/workflowengine/src/components/Workflow.vue b/apps/workflowengine/src/components/Workflow.vue index 121e517e964..03ec2a79324 100644 --- a/apps/workflowengine/src/components/Workflow.vue +++ b/apps/workflowengine/src/components/Workflow.vue @@ -1,63 +1,94 @@ +<!-- + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div id="workflowengine"> - <div class="section"> - <h2>{{ t('workflowengine', 'Available flows') }}</h2> - - <p v-if="scope === 0" class="settings-hint"> + <NcSettingsSection :name="t('workflowengine', 'Available flows')" + :doc-url="workflowDocUrl"> + <p v-if="isAdminScope" class="settings-hint"> <a href="https://nextcloud.com/developer/">{{ t('workflowengine', 'For details on how to write your own flow, check out the development documentation.') }}</a> </p> - <transition-group name="slide" tag="div" class="actions"> - <Operation v-for="operation in getMainOperations" + <NcEmptyContent v-if="!isUserAdmin && mainOperations.length === 0" + :name="t('workflowengine', 'No flows installed')" + :description="!isUserAdmin ? t('workflowengine', 'Ask your administrator to install new flows.') : undefined"> + <template #icon> + <NcIconSvgWrapper :svg="WorkflowOffSvg" :size="20" /> + </template> + </NcEmptyContent> + <transition-group v-else + name="slide" + tag="div" + class="actions"> + <Operation v-for="operation in mainOperations" :key="operation.id" :operation="operation" @click.native="createNewRule(operation)" /> - <a v-if="showAppStoreHint" - :key="'add'" + key="add" :href="appstoreUrl" class="actions__item colored more"> <div class="icon icon-add" /> <div class="actions__item__description"> <h3>{{ t('workflowengine', 'More flows') }}</h3> - <small>{{ t('workflowengine', 'Browse the app store') }}</small> + <small>{{ t('workflowengine', 'Browse the App Store') }}</small> </div> </a> </transition-group> <div v-if="hasMoreOperations" class="actions__more"> - <button class="icon" - :class="showMoreOperations ? 'icon-triangle-n' : 'icon-triangle-s'" - @click="showMoreOperations=!showMoreOperations"> + <NcButton @click="showMoreOperations = !showMoreOperations"> + <template #icon> + <MenuUp v-if="showMoreOperations" :size="20" /> + <MenuDown v-else :size="20" /> + </template> {{ showMoreOperations ? t('workflowengine', 'Show less') : t('workflowengine', 'Show more') }} - </button> + </NcButton> </div> + </NcSettingsSection> - <h2 v-if="scope === 0" class="configured-flows"> - {{ t('workflowengine', 'Configured flows') }} - </h2> - <h2 v-else class="configured-flows"> - {{ t('workflowengine', 'Your flows') }} - </h2> - </div> - - <transition-group v-if="rules.length > 0" name="slide"> - <Rule v-for="rule in rules" :key="rule.id" :rule="rule" /> - </transition-group> + <NcSettingsSection v-if="mainOperations.length > 0" + :name="isAdminScope ? t('workflowengine', 'Configured flows') : t('workflowengine', 'Your flows')"> + <transition-group v-if="rules.length > 0" name="slide"> + <Rule v-for="rule in rules" :key="rule.id" :rule="rule" /> + </transition-group> + <NcEmptyContent v-else :name="t('workflowengine', 'No flows configured')"> + <template #icon> + <NcIconSvgWrapper :svg="WorkflowOffSvg" :size="20" /> + </template> + </NcEmptyContent> + </NcSettingsSection> </div> </template> <script> -import Rule from './Rule' -import Operation from './Operation' +import Rule from './Rule.vue' +import Operation from './Operation.vue' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection' import { mapGetters, mapState } from 'vuex' import { generateUrl } from '@nextcloud/router' +import { loadState } from '@nextcloud/initial-state' +import MenuUp from 'vue-material-design-icons/MenuUp.vue' +import MenuDown from 'vue-material-design-icons/MenuDown.vue' +import WorkflowOffSvg from '../../img/workflow-off.svg?raw' const ACTION_LIMIT = 3 +const ADMIN_SCOPE = 0 +// const PERSONAL_SCOPE = 1 export default { name: 'Workflow', components: { + MenuDown, + MenuUp, + NcButton, + NcEmptyContent, + NcIconSvgWrapper, + NcSettingsSection, Operation, Rule, }, @@ -65,6 +96,8 @@ export default { return { showMoreOperations: false, appstoreUrl: generateUrl('settings/apps/workflow'), + workflowDocUrl: loadState('workflowengine', 'doc-url'), + WorkflowOffSvg, } }, computed: { @@ -79,14 +112,20 @@ export default { hasMoreOperations() { return Object.keys(this.operations).length > ACTION_LIMIT }, - getMainOperations() { + mainOperations() { if (this.showMoreOperations) { return Object.values(this.operations) } return Object.values(this.operations).slice(0, ACTION_LIMIT) }, showAppStoreHint() { - return this.scope === 0 && this.appstoreEnabled && OC.isUserAdmin() + return this.appstoreEnabled && OC.isUserAdmin() + }, + isUserAdmin() { + return OC.isUserAdmin() + }, + isAdminScope() { + return this.scope === ADMIN_SCOPE }, }, mounted() { @@ -101,9 +140,12 @@ export default { </script> <style scoped lang="scss"> + @use "./../styles/operation"; + #workflowengine { border-bottom: 1px solid var(--color-border); } + .section { max-width: 100vw; @@ -112,6 +154,7 @@ export default { margin-bottom: 0; } } + .actions { display: flex; flex-wrap: wrap; @@ -122,9 +165,8 @@ export default { } } - button.icon { - padding-left: 32px; - background-position: 10px center; + .actions__more { + margin-bottom: 10px; } .slide-enter-active { @@ -161,8 +203,6 @@ export default { padding-bottom: 0; } - @import "./../styles/operation"; - .actions__item.more { background-color: var(--color-background-dark); } |