aboutsummaryrefslogtreecommitdiffstats
path: root/apps/workflowengine/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'apps/workflowengine/src/components')
-rw-r--r--apps/workflowengine/src/components/Check.vue229
-rw-r--r--apps/workflowengine/src/components/Checks/FileMimeType.vue172
-rw-r--r--apps/workflowengine/src/components/Checks/FileSystemTag.vue54
-rw-r--r--apps/workflowengine/src/components/Checks/RequestTime.vue139
-rw-r--r--apps/workflowengine/src/components/Checks/RequestURL.vue151
-rw-r--r--apps/workflowengine/src/components/Checks/RequestUserAgent.vue141
-rw-r--r--apps/workflowengine/src/components/Checks/RequestUserGroup.vue156
-rw-r--r--apps/workflowengine/src/components/Checks/file.js88
-rw-r--r--apps/workflowengine/src/components/Checks/index.js9
-rw-r--r--apps/workflowengine/src/components/Checks/request.js55
-rw-r--r--apps/workflowengine/src/components/Event.vue118
-rw-r--r--apps/workflowengine/src/components/Operation.vue44
-rw-r--r--apps/workflowengine/src/components/Rule.vue306
-rw-r--r--apps/workflowengine/src/components/Workflow.vue209
14 files changed, 1871 insertions, 0 deletions
diff --git a/apps/workflowengine/src/components/Check.vue b/apps/workflowengine/src/components/Check.vue
new file mode 100644
index 00000000000..136f6d21280
--- /dev/null
+++ b/apps/workflowengine/src/components/Check.vue
@@ -0,0 +1,229 @@
+<!--
+ - 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">
+ <NcSelect ref="checkSelector"
+ v-model="currentOption"
+ :options="options"
+ label="name"
+ :clearable="false"
+ :placeholder="t('workflowengine', 'Select a filter')"
+ @input="updateCheck" />
+ <NcSelect v-model="currentOperator"
+ :disabled="!currentOption"
+ :options="operators"
+ class="comparator"
+ label="name"
+ :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-else-if="currentOperator && currentComponent"
+ v-model="check.value"
+ :disabled="!currentOption"
+ :check="check"
+ class="option"
+ @input="updateCheck"
+ @valid="(valid=true) && validate()"
+ @invalid="!(valid=false) && validate()" />
+ <input v-else
+ v-model="check.value"
+ type="text"
+ :class="{ invalid: !valid }"
+ :disabled="!currentOption"
+ :placeholder="valuePlaceholder"
+ class="option"
+ @input="updateCheck">
+ <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 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: {
+ NcActionButton,
+ NcActions,
+ NcSelect,
+
+ // Icons
+ CloseIcon,
+ },
+ directives: {
+ ClickOutside,
+ },
+ props: {
+ check: {
+ type: Object,
+ required: true,
+ },
+ rule: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ deleteVisible: false,
+ currentOption: null,
+ currentOperator: null,
+ options: [],
+ valid: false,
+ }
+ },
+ computed: {
+ checks() {
+ return this.$store.getters.getChecksForEntity(this.rule.entity)
+ },
+ operators() {
+ if (!this.currentOption) { return [] }
+ const operators = this.checks[this.currentOption.class].operators
+ if (typeof operators === 'function') {
+ return operators(this.check)
+ }
+ 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
+ },
+ valuePlaceholder() {
+ if (this.currentOption && this.currentOption.placeholder) {
+ return this.currentOption.placeholder(this.check)
+ }
+ return ''
+ },
+ },
+ watch: {
+ 'check.operator'() {
+ this.validate()
+ },
+ },
+ mounted() {
+ this.options = Object.values(this.checks)
+ 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())
+ }
+ this.validate()
+ },
+ methods: {
+ showDelete() {
+ this.deleteVisible = true
+ },
+ hideDelete() {
+ this.deleteVisible = false
+ },
+ validate() {
+ this.valid = true
+ 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(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()
+
+ this.$emit('update', this.check)
+ },
+ },
+}
+</script>
+
+<style scoped lang="scss">
+ .check {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-start; // to not stretch components vertically
+ width: 100%;
+ padding-inline-end: 20px;
+
+ & > *:not(.close) {
+ width: 180px;
+ }
+ & > .comparator {
+ min-width: 200px;
+ width: 200px;
+ }
+ & > .option {
+ min-width: 260px;
+ width: 260px;
+ min-height: 48px;
+
+ & > input[type=text] {
+ min-height: 48px;
+ }
+ }
+ & > .v-select,
+ & > .button-vue,
+ & > input[type=text] {
+ margin-inline-end: 5px;
+ margin-bottom: 5px;
+ }
+ }
+
+ input[type=text] {
+ margin: 0;
+ }
+
+ .invalid {
+ 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
new file mode 100644
index 00000000000..6817b128e27
--- /dev/null
+++ b/apps/workflowengine/src/components/Checks/FileMimeType.vue
@@ -0,0 +1,172 @@
+<!--
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <div>
+ <NcSelect :model-value="currentValue"
+ :placeholder="t('workflowengine', 'Select a file type')"
+ label="label"
+ :options="options"
+ :clearable="false"
+ @input="setValue">
+ <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 #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>
+ </NcSelect>
+ <input v-if="!isPredefined"
+ :value="currentValue.id"
+ type="text"
+ :placeholder="t('workflowengine', 'e.g. httpd/unix-directory')"
+ @input="updateCustom">
+ </div>
+</template>
+
+<script>
+import NcEllipsisedOption from '@nextcloud/vue/components/NcEllipsisedOption'
+import NcSelect from '@nextcloud/vue/components/NcSelect'
+import { imagePath } from '@nextcloud/router'
+
+export default {
+ name: 'FileMimeType',
+ components: {
+ NcEllipsisedOption,
+ NcSelect,
+ },
+ props: {
+ modelValue: {
+ type: String,
+ default: '',
+ },
+ },
+
+ emits: ['update:model-value'],
+
+ data() {
+ return {
+ predefinedTypes: [
+ {
+ icon: 'icon-folder',
+ label: t('workflowengine', 'Folder'),
+ id: 'httpd/unix-directory',
+ },
+ {
+ icon: 'icon-picture',
+ label: t('workflowengine', 'Images'),
+ id: '/image\\/.*/',
+ },
+ {
+ iconUrl: imagePath('core', 'filetypes/x-office-document'),
+ label: t('workflowengine', 'Office documents'),
+ id: '/(vnd\\.(ms-|openxmlformats-|oasis\\.opendocument).*)$/',
+ },
+ {
+ iconUrl: imagePath('core', 'filetypes/application-pdf'),
+ label: t('workflowengine', 'PDF documents'),
+ id: 'application/pdf',
+ },
+ ],
+ newValue: '',
+ }
+ },
+ computed: {
+ options() {
+ return [...this.predefinedTypes, this.customValue]
+ },
+ isPredefined() {
+ const matchingPredefined = this.predefinedTypes.find((type) => this.newValue === type.id)
+ if (matchingPredefined) {
+ return true
+ }
+ return false
+ },
+ customValue() {
+ return {
+ icon: 'icon-settings-dark',
+ label: t('workflowengine', 'Custom MIME type'),
+ id: '',
+ }
+ },
+ currentValue() {
+ const matchingPredefined = this.predefinedTypes.find((type) => this.newValue === type.id)
+ if (matchingPredefined) {
+ return matchingPredefined
+ }
+ return {
+ icon: 'icon-settings-dark',
+ label: t('workflowengine', 'Custom mimetype'),
+ 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.id
+ this.$emit('update:model-value', this.newValue)
+ }
+ },
+ updateCustom(event) {
+ this.newValue = event.target.value || event.detail[0]
+ this.$emit('update:model-value', this.newValue)
+ },
+ },
+}
+</script>
+<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
new file mode 100644
index 00000000000..e71b0cd259a
--- /dev/null
+++ b/apps/workflowengine/src/components/Checks/FileSystemTag.vue
@@ -0,0 +1,54 @@
+<!--
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcSelectTags v-model="newValue"
+ :multiple="false"
+ @input="update" />
+</template>
+
+<script>
+import NcSelectTags from '@nextcloud/vue/components/NcSelectTags'
+
+export default {
+ name: 'FileSystemTag',
+ components: {
+ NcSelectTags,
+ },
+ props: {
+ modelValue: {
+ type: String,
+ default: '',
+ },
+ },
+
+ emits: ['update:model-value'],
+
+ data() {
+ return {
+ newValue: [],
+ }
+ },
+ watch: {
+ modelValue() {
+ this.updateValue()
+ },
+ },
+ beforeMount() {
+ this.updateValue()
+ },
+ methods: {
+ updateValue() {
+ if (this.modelValue !== '') {
+ this.newValue = parseInt(this.modelValue)
+ } else {
+ this.newValue = null
+ }
+ },
+ update() {
+ this.$emit('update:model-value', this.newValue || '')
+ },
+ },
+}
+</script>
diff --git a/apps/workflowengine/src/components/Checks/RequestTime.vue b/apps/workflowengine/src/components/Checks/RequestTime.vue
new file mode 100644
index 00000000000..5b1a4ef1cfa
--- /dev/null
+++ b/apps/workflowengine/src/components/Checks/RequestTime.vue
@@ -0,0 +1,139 @@
+<!--
+ - 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"
+ type="text"
+ class="timeslot--start"
+ placeholder="e.g. 08:00"
+ @input="update">
+ <input v-model="newValue.endTime"
+ type="text"
+ placeholder="e.g. 18:00"
+ @input="update">
+ <p v-if="!valid" class="invalid-hint">
+ {{ t('workflowengine', 'Please enter a valid time span') }}
+ </p>
+ <NcSelect v-show="valid"
+ v-model="newValue.timezone"
+ :clearable="false"
+ :options="timezones"
+ @input="update" />
+ </div>
+</template>
+
+<script>
+import NcSelect from '@nextcloud/vue/components/NcSelect'
+import moment from 'moment-timezone'
+
+const zones = moment.tz.names()
+export default {
+ name: 'RequestTime',
+ components: {
+ NcSelect,
+ },
+ props: {
+ modelValue: {
+ type: String,
+ default: '[]',
+ },
+ },
+ emits: ['update:model-value'],
+ data() {
+ return {
+ timezones: zones,
+ valid: false,
+ newValue: {
+ startTime: null,
+ endTime: null,
+ timezone: moment.tz.guess(),
+ },
+ stringifiedValue: '[]',
+ }
+ },
+ 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() {
+ try {
+ 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
+ }
+ },
+ validate() {
+ this.valid = this.newValue.startTime && this.newValue.startTime.match(/^(0[0-9]|1[0-9]|2[0-3]|[0-9]):[0-5][0-9]$/i) !== null
+ && this.newValue.endTime && this.newValue.endTime.match(/^(0[0-9]|1[0-9]|2[0-3]|[0-9]):[0-5][0-9]$/i) !== null
+ && moment.tz.zone(this.newValue.timezone) !== null
+ if (this.valid) {
+ this.$emit('valid')
+ } else {
+ this.$emit('invalid')
+ }
+ return this.valid
+ },
+ update() {
+ if (this.newValue.timezone === null) {
+ this.newValue.timezone = moment.tz.guess()
+ }
+ if (this.validate()) {
+ this.stringifiedValue = `["${this.newValue.startTime} ${this.newValue.timezone}","${this.newValue.endTime} ${this.newValue.timezone}"]`
+ this.$emit('update:model-value', this.stringifiedValue)
+ }
+ },
+ },
+}
+</script>
+
+<style scoped lang="scss">
+ .timeslot {
+ display: flex;
+ flex-grow: 1;
+ flex-wrap: wrap;
+ max-width: 180px;
+
+ .multiselect {
+ width: 100%;
+ margin-bottom: 5px;
+ }
+
+ .multiselect:deep(.multiselect__tags:not(:hover):not(:focus):not(:active)) {
+ border: 1px solid transparent;
+ }
+
+ input[type=text] {
+ width: 50%;
+ margin: 0;
+ margin-bottom: 5px;
+ min-height: 48px;
+
+ &.timeslot--start {
+ margin-inline-end: 5px;
+ width: calc(50% - 5px);
+ }
+ }
+
+ .invalid-hint {
+ color: var(--color-text-maxcontrast);
+ }
+ }
+</style>
diff --git a/apps/workflowengine/src/components/Checks/RequestURL.vue b/apps/workflowengine/src/components/Checks/RequestURL.vue
new file mode 100644
index 00000000000..21b3a9cacbe
--- /dev/null
+++ b/apps/workflowengine/src/components/Checks/RequestURL.vue
@@ -0,0 +1,151 @@
+<!--
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <div>
+ <NcSelect v-model="newValue"
+ :value="currentValue"
+ :placeholder="t('workflowengine', 'Select a request URL')"
+ label="label"
+ :clearable="false"
+ :options="options"
+ @input="setValue">
+ <template #option="option">
+ <span class="option__icon" :class="option.icon" />
+ <span class="option__title">
+ <NcEllipsisedOption :name="String(option.label)" />
+ </span>
+ </template>
+ <template #selected-option="selectedOption">
+ <span class="option__icon" :class="selectedOption.icon" />
+ <span class="option__title">
+ <NcEllipsisedOption :name="String(selectedOption.label)" />
+ </span>
+ </template>
+ </NcSelect>
+ <input v-if="!isPredefined"
+ type="text"
+ :value="currentValue.id"
+ :placeholder="placeholder"
+ @input="updateCustom">
+ </div>
+</template>
+
+<script>
+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: {
+ NcEllipsisedOption,
+ NcSelect,
+ },
+ mixins: [
+ valueMixin,
+ ],
+ props: {
+ modelValue: {
+ type: String,
+ default: '',
+ },
+ operator: {
+ type: String,
+ default: '',
+ },
+ },
+
+ emits: ['update:model-value'],
+
+ data() {
+ return {
+ newValue: '',
+ predefinedTypes: [
+ {
+ icon: 'icon-files-dark',
+ id: 'webdav',
+ label: t('workflowengine', 'Files WebDAV'),
+ },
+ ],
+ }
+ },
+ computed: {
+ options() {
+ return [...this.predefinedTypes, this.customValue]
+ },
+ placeholder() {
+ if (this.operator === 'matches' || this.operator === '!matches') {
+ return '/^https\\:\\/\\/localhost\\/index\\.php$/i'
+ }
+ return 'https://localhost/index.php'
+ },
+ matchingPredefined() {
+ return this.predefinedTypes
+ .find((type) => this.newValue === type.id)
+ },
+ isPredefined() {
+ return !!this.matchingPredefined
+ },
+ customValue() {
+ return {
+ icon: 'icon-settings-dark',
+ label: t('workflowengine', 'Custom URL'),
+ id: '',
+ }
+ },
+ currentValue() {
+ if (this.matchingPredefined) {
+ return this.matchingPredefined
+ }
+ return {
+ icon: 'icon-settings-dark',
+ label: t('workflowengine', 'Custom URL'),
+ id: this.newValue,
+ }
+ },
+ },
+ methods: {
+ validateRegex(string) {
+ const regexRegex = /^\/(.*)\/([gui]{0,3})$/
+ const result = regexRegex.exec(string)
+ return result !== null
+ },
+ setValue(value) {
+ // TODO: check if value requires a regex and set the check operator according to that
+ if (value !== null) {
+ this.newValue = value.id
+ this.$emit('update:model-value', this.newValue)
+ }
+ },
+ updateCustom(event) {
+ this.newValue = event.target.value
+ this.$emit('update:model-value', this.newValue)
+ },
+ },
+}
+</script>
+<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
new file mode 100644
index 00000000000..825a112f6fc
--- /dev/null
+++ b/apps/workflowengine/src/components/Checks/RequestUserAgent.vue
@@ -0,0 +1,141 @@
+<!--
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <div>
+ <NcSelect v-model="currentValue"
+ :placeholder="t('workflowengine', 'Select a user agent')"
+ label="label"
+ :options="options"
+ :clearable="false"
+ @input="setValue">
+ <template #option="option">
+ <span class="option__icon" :class="option.icon" />
+ <span class="option__title">
+ <NcEllipsisedOption :name="String(option.label)" />
+ </span>
+ </template>
+ <template #selected-option="selectedOption">
+ <span class="option__icon" :class="selectedOption.icon" />
+ <span class="option__title">
+ <NcEllipsisedOption :name="String(selectedOption.label)" />
+ </span>
+ </template>
+ </NcSelect>
+ <input v-if="!isPredefined"
+ v-model="newValue"
+ type="text"
+ @input="updateCustom">
+ </div>
+</template>
+
+<script>
+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: {
+ NcEllipsisedOption,
+ NcSelect,
+ },
+ mixins: [
+ valueMixin,
+ ],
+ props: {
+ modelValue: {
+ type: String,
+ default: '',
+ },
+ },
+ emits: ['update:model-value'],
+ data() {
+ return {
+ newValue: '',
+ predefinedTypes: [
+ { 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' },
+ ],
+ }
+ },
+ computed: {
+ options() {
+ return [...this.predefinedTypes, this.customValue]
+ },
+ matchingPredefined() {
+ return this.predefinedTypes
+ .find((type) => this.newValue === type.id)
+ },
+ isPredefined() {
+ return !!this.matchingPredefined
+ },
+ customValue() {
+ return {
+ icon: 'icon-settings-dark',
+ label: t('workflowengine', 'Custom user agent'),
+ id: '',
+ }
+ },
+ 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: {
+ validateRegex(string) {
+ const regexRegex = /^\/(.*)\/([gui]{0,3})$/
+ const result = regexRegex.exec(string)
+ return result !== null
+ },
+ setValue(value) {
+ // TODO: check if value requires a regex and set the check operator according to that
+ if (value !== null) {
+ this.newValue = value.id
+ this.$emit('update:model-value', this.newValue)
+ }
+ },
+ updateCustom() {
+ this.newValue = this.currentValue.id
+ this.$emit('update:model-value', this.newValue)
+ },
+ },
+}
+</script>
+<style scoped>
+ .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/RequestUserGroup.vue b/apps/workflowengine/src/components/Checks/RequestUserGroup.vue
new file mode 100644
index 00000000000..f9606b7ca26
--- /dev/null
+++ b/apps/workflowengine/src/components/Checks/RequestUserGroup.vue
@@ -0,0 +1,156 @@
+<!--
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <div>
+ <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"
+ :model-value="currentValue"
+ label="displayname"
+ @search="searchAsync"
+ @input="update" />
+ </div>
+</template>
+
+<script>
+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,
+}
+
+export default {
+ name: 'RequestUserGroup',
+ components: {
+ NcSelect,
+ },
+ props: {
+ modelValue: {
+ type: String,
+ default: '',
+ },
+ check: {
+ type: Object,
+ default: () => { return {} },
+ },
+ },
+ emits: ['update:model-value'],
+ data() {
+ return {
+ groups,
+ status,
+ wantedGroups,
+ newValue: '',
+ }
+ },
+ computed: {
+ 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 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/groups/details?limit=20&search={searchQuery}', { searchQuery })).then((response) => {
+ response.data.ocs.data.groups.forEach((group) => {
+ this.addGroup({
+ id: group.id,
+ displayname: group.displayname,
+ })
+ })
+ 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>
+.v-select {
+ width: 100%;
+}
+</style>
diff --git a/apps/workflowengine/src/components/Checks/file.js b/apps/workflowengine/src/components/Checks/file.js
new file mode 100644
index 00000000000..568efc81cd3
--- /dev/null
+++ b/apps/workflowengine/src/components/Checks/file.js
@@ -0,0 +1,88 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+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 [
+ { operator: 'matches', name: t('workflowengine', 'matches') },
+ { operator: '!matches', name: t('workflowengine', 'does not match') },
+ { operator: 'is', name: t('workflowengine', 'is') },
+ { operator: '!is', name: t('workflowengine', 'is not') },
+ ]
+}
+
+const FileChecks = [
+ {
+ class: 'OCA\\WorkflowEngine\\Check\\FileName',
+ name: t('workflowengine', 'File name'),
+ operators: stringOrRegexOperators,
+ placeholder: (check) => {
+ if (check.operator === 'matches' || check.operator === '!matches') {
+ return '/^dummy-.+$/i'
+ }
+ return 'filename.txt'
+ },
+ validate: stringValidator,
+ },
+
+ {
+ class: 'OCA\\WorkflowEngine\\Check\\FileMimeType',
+ name: t('workflowengine', 'File MIME type'),
+ operators: stringOrRegexOperators,
+ element: registerCustomElement(FileMimeType, 'oca-workflowengine-checks-file_mime_type'),
+ },
+
+ {
+ class: 'OCA\\WorkflowEngine\\Check\\FileSize',
+ name: t('workflowengine', 'File size (upload)'),
+ operators: [
+ { operator: 'less', name: t('workflowengine', 'less') },
+ { operator: '!greater', name: t('workflowengine', 'less or equals') },
+ { operator: '!less', name: t('workflowengine', 'greater or equals') },
+ { operator: 'greater', name: t('workflowengine', 'greater') },
+ ],
+ placeholder: (check) => '5 MB',
+ validate: (check) => check.value ? check.value.match(/^[0-9]+[ ]?[kmgt]?b$/i) !== null : false,
+ },
+
+ {
+ class: 'OCA\\WorkflowEngine\\Check\\RequestRemoteAddress',
+ name: t('workflowengine', 'Request remote address'),
+ operators: [
+ { operator: 'matchesIPv4', name: t('workflowengine', 'matches IPv4') },
+ { operator: '!matchesIPv4', name: t('workflowengine', 'does not match IPv4') },
+ { operator: 'matchesIPv6', name: t('workflowengine', 'matches IPv6') },
+ { operator: '!matchesIPv6', name: t('workflowengine', 'does not match IPv6') },
+ ],
+ placeholder: (check) => {
+ if (check.operator === 'matchesIPv6' || check.operator === '!matchesIPv6') {
+ return '::1/128'
+ }
+ return '127.0.0.1/32'
+ },
+ validate: (check) => {
+ if (check.operator === 'matchesIPv6' || check.operator === '!matchesIPv6') {
+ return validateIPv6(check.value)
+ }
+ return validateIPv4(check.value)
+ },
+ },
+
+ {
+ class: 'OCA\\WorkflowEngine\\Check\\FileSystemTags',
+ name: t('workflowengine', 'File system tag'),
+ operators: [
+ { operator: 'is', name: t('workflowengine', 'is tagged with') },
+ { operator: '!is', name: t('workflowengine', 'is not tagged with') },
+ ],
+ element: registerCustomElement(FileSystemTag, 'oca-workflowengine-file_system_tag'),
+ },
+]
+
+export default FileChecks
diff --git a/apps/workflowengine/src/components/Checks/index.js b/apps/workflowengine/src/components/Checks/index.js
new file mode 100644
index 00000000000..fc52f95f78a
--- /dev/null
+++ b/apps/workflowengine/src/components/Checks/index.js
@@ -0,0 +1,9 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+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
new file mode 100644
index 00000000000..b91f00baef0
--- /dev/null
+++ b/apps/workflowengine/src/components/Checks/request.js
@@ -0,0 +1,55 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+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 = [
+ {
+ class: 'OCA\\WorkflowEngine\\Check\\RequestURL',
+ name: t('workflowengine', 'Request URL'),
+ operators: [
+ { operator: 'is', name: t('workflowengine', 'is') },
+ { operator: '!is', name: t('workflowengine', 'is not') },
+ { operator: 'matches', name: t('workflowengine', 'matches') },
+ { operator: '!matches', name: t('workflowengine', 'does not match') },
+ ],
+ element: registerCustomElement(RequestURL, 'oca-workflowengine-checks-request_url'),
+ },
+ {
+ class: 'OCA\\WorkflowEngine\\Check\\RequestTime',
+ name: t('workflowengine', 'Request time'),
+ operators: [
+ { operator: 'in', name: t('workflowengine', 'between') },
+ { operator: '!in', name: t('workflowengine', 'not between') },
+ ],
+ element: registerCustomElement(RequestTime, 'oca-workflowengine-checks-request_time'),
+ },
+ {
+ class: 'OCA\\WorkflowEngine\\Check\\RequestUserAgent',
+ name: t('workflowengine', 'Request user agent'),
+ operators: [
+ { operator: 'is', name: t('workflowengine', 'is') },
+ { operator: '!is', name: t('workflowengine', 'is not') },
+ { operator: 'matches', name: t('workflowengine', 'matches') },
+ { operator: '!matches', name: t('workflowengine', 'does not match') },
+ ],
+ element: registerCustomElement(RequestUserAgent, 'oca-workflowengine-checks-request_user_agent'),
+ },
+ {
+ class: 'OCA\\WorkflowEngine\\Check\\UserGroupMembership',
+ name: t('workflowengine', 'Group membership'),
+ operators: [
+ { operator: 'is', name: t('workflowengine', 'is member of') },
+ { operator: '!is', name: t('workflowengine', 'is not member of') },
+ ],
+ element: registerCustomElement(RequestUserGroup, 'oca-workflowengine-checks-request_user_group'),
+ },
+]
+
+export default RequestChecks
diff --git a/apps/workflowengine/src/components/Event.vue b/apps/workflowengine/src/components/Event.vue
new file mode 100644
index 00000000000..f170101b4e9
--- /dev/null
+++ b/apps/workflowengine/src/components/Event.vue
@@ -0,0 +1,118 @@
+<!--
+ - 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" alt="">
+ <span class="option__title option__title_single">{{ operation.triggerHint }}</span>
+ </div>
+ <NcSelect v-else
+ :disabled="allEvents.length <= 1"
+ :multiple="true"
+ :options="allEvents"
+ :value="currentEvent"
+ :placeholder="placeholderString"
+ class="event__trigger"
+ label="displayName"
+ @input="updateEvent">
+ <template #option="option">
+ <img class="option__icon" :src="option.entity.icon" alt="">
+ <span class="option__title">{{ option.displayName }}</span>
+ </template>
+ <template #selected-option="option">
+ <img class="option__icon" :src="option.entity.icon" alt="">
+ <span class="option__title">{{ option.displayName }}</span>
+ </template>
+ </NcSelect>
+ </div>
+</template>
+
+<script>
+import NcSelect from '@nextcloud/vue/components/NcSelect'
+import { showWarning } from '@nextcloud/dialogs'
+
+export default {
+ name: 'Event',
+ components: {
+ NcSelect,
+ },
+ props: {
+ rule: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ entity() {
+ return this.$store.getters.getEntityForOperation(this.operation)
+ },
+ operation() {
+ return this.$store.getters.getOperationForRule(this.rule)
+ },
+ allEvents() {
+ return this.$store.getters.getEventsForOperation(this.operation)
+ },
+ 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
+ }
+ const existingEntity = this.rule.entity
+ const newEntities = events.map(event => event.entity.id).filter((value, index, self) => self.indexOf(value) === index)
+ let newEntity = null
+ if (newEntities.length > 1) {
+ newEntity = newEntities.filter(entity => entity !== existingEntity)[0]
+ } else {
+ newEntity = newEntities[0]
+ }
+
+ this.$set(this.rule, 'entity', newEntity)
+ this.$set(this.rule, 'events', events.filter(event => event.entity.id === newEntity).map(event => event.eventName))
+ this.$emit('update', this.rule)
+ },
+ },
+}
+</script>
+
+<style scoped lang="scss">
+ .event {
+ margin-bottom: 5px;
+
+ &__trigger {
+ max-width: 550px;
+ }
+ }
+
+ .isComplex {
+ img {
+ vertical-align: text-top;
+ }
+ span {
+ padding-top: 2px;
+ display: inline-block;
+ }
+ }
+
+ .option__title {
+ margin-inline-start: 5px;
+ color: var(--color-main-text);
+ }
+
+ .option__icon {
+ width: 16px;
+ height: 16px;
+ filter: var(--background-invert-if-dark);
+ }
+</style>
diff --git a/apps/workflowengine/src/components/Operation.vue b/apps/workflowengine/src/components/Operation.vue
new file mode 100644
index 00000000000..df0b78dad89
--- /dev/null
+++ b/apps/workflowengine/src/components/Operation.vue
@@ -0,0 +1,44 @@
+<!--
+ - 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>
+ <NcButton v-if="colored">
+ {{ t('workflowengine', 'Add new flow') }}
+ </NcButton>
+ </div>
+ <div class="actions__item_options">
+ <slot />
+ </div>
+ </div>
+</template>
+
+<script>
+import NcButton from '@nextcloud/vue/components/NcButton'
+
+export default {
+ name: 'Operation',
+ components: {
+ NcButton,
+ },
+ props: {
+ operation: {
+ type: Object,
+ required: true,
+ },
+ colored: {
+ type: Boolean,
+ default: true,
+ },
+ },
+}
+</script>
+
+<style scoped lang="scss">
+@use "./../styles/operation" as *;
+</style>
diff --git a/apps/workflowengine/src/components/Rule.vue b/apps/workflowengine/src/components/Rule.vue
new file mode 100644
index 00000000000..1c321fd014c
--- /dev/null
+++ b/apps/workflowengine/src/components/Rule.vue
@@ -0,0 +1,306 @@
+<!--
+ - 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">
+ <p>
+ <span>{{ t('workflowengine', 'When') }}</span>
+ <Event :rule="rule" @update="updateRule" />
+ </p>
+ <p v-for="(check, index) in rule.checks" :key="index">
+ <span>{{ t('workflowengine', 'and') }}</span>
+ <Check :check="check"
+ :rule="rule"
+ @update="updateRule"
+ @validate="validate"
+ @remove="removeCheck(check)" />
+ </p>
+ <p>
+ <span />
+ <input v-if="lastCheckComplete"
+ type="button"
+ class="check--add"
+ :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-else-if="operation.options"
+ v-model="rule.operation"
+ @input="updateOperation" />
+ </Operation>
+ <div class="buttons">
+ <NcButton v-if="rule.id < -1 || dirty" @click="cancelRule">
+ {{ t('workflowengine', 'Cancel') }}
+ </NcButton>
+ <NcButton v-else-if="!dirty" @click="deleteRule">
+ {{ t('workflowengine', 'Delete') }}
+ </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 }}
+ </p>
+ </div>
+ </div>
+</template>
+
+<script>
+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: {
+ Check,
+ Event,
+ NcActionButton,
+ NcActions,
+ NcButton,
+ Operation,
+ },
+ directives: {
+ Tooltip,
+ },
+ props: {
+ rule: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ editing: false,
+ checks: [],
+ error: null,
+ dirty: this.rule.id < 0,
+ originalRule: null,
+ element: null,
+ inputValue: '',
+ }
+ },
+ computed: {
+ /**
+ * @return {OperatorPlugin}
+ */
+ operation() {
+ return this.$store.getters.getOperationForRule(this.rule)
+ },
+ ruleStatus() {
+ 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'),
+ icon: IconClose,
+ type: 'warning',
+ tooltip: { placement: 'bottom', show: true, content: this.error },
+ }
+ }
+ if (!this.dirty) {
+ return { title: t('workflowengine', 'Active'), icon: IconCheckMark, type: 'success' }
+ }
+ return { title: t('workflowengine', 'Save'), icon: IconArrowRight, type: 'primary' }
+
+ },
+ lastCheckComplete() {
+ const lastCheck = this.rule.checks[this.rule.checks.length - 1]
+ return typeof lastCheck === 'undefined' || lastCheck.class !== null
+ },
+ },
+ 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)
+ this.updateRule()
+ },
+ 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)
+ },
+ updateRule() {
+ if (!this.dirty) {
+ this.dirty = true
+ }
+
+ this.error = null
+ this.$store.dispatch('updateRule', this.rule)
+ },
+ async saveRule() {
+ try {
+ await this.$store.dispatch('pushUpdateRule', this.rule)
+ this.dirty = false
+ this.error = null
+ this.originalRule = JSON.parse(JSON.stringify(this.rule))
+ } catch (e) {
+ console.error('Failed to save operation')
+ this.error = e.response.data.ocs.meta.message
+ }
+ },
+ async deleteRule() {
+ try {
+ await this.$store.dispatch('deleteRule', this.rule)
+ } catch (e) {
+ console.error('Failed to delete operation')
+ this.error = e.response.data.ocs.meta.message
+ }
+ },
+ cancelRule() {
+ 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) {
+ this.$delete(this.rule.checks, index)
+ }
+ 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">
+
+ .buttons {
+ display: flex;
+ justify-content: end;
+
+ button {
+ margin-inline-start: 5px;
+ }
+ button:last-child{
+ margin-inline-end: 10px;
+ }
+ }
+
+ .error-message {
+ float: right;
+ margin-inline-end: 10px;
+ }
+
+ .flow-icon {
+ width: 44px;
+ }
+
+ .rule {
+ display: flex;
+ flex-wrap: wrap;
+ border-inline-start: 5px solid var(--color-primary-element);
+
+ .trigger,
+ .action {
+ flex-grow: 1;
+ min-height: 100px;
+ max-width: 920px;
+ }
+ .action {
+ max-width: 400px;
+ position: relative;
+ }
+ .icon-confirm {
+ background-position: right 27px;
+ padding-inline-end: 20px;
+ margin-inline-end: 20px;
+ }
+ }
+
+ .trigger p, .action p {
+ min-height: 34px;
+ display: flex;
+
+ & > span {
+ min-width: 50px;
+ text-align: end;
+ color: var(--color-text-maxcontrast);
+ padding-inline-end: 10px;
+ padding-top: 6px;
+ }
+ .multiselect {
+ flex-grow: 1;
+ 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-inline-start: 6px;
+ margin: 0;
+ width: 180px;
+ border-radius: var(--border-radius);
+ color: var(--color-text-maxcontrast);
+ font-weight: normal;
+ text-align: start;
+ font-size: 1em;
+ }
+
+ @media (max-width:1400px) {
+ .rule {
+ &, .trigger, .action {
+ width: 100%;
+ max-width: 100%;
+ }
+ .flow-icon {
+ display: none;
+ }
+ }
+ }
+
+</style>
diff --git a/apps/workflowengine/src/components/Workflow.vue b/apps/workflowengine/src/components/Workflow.vue
new file mode 100644
index 00000000000..03ec2a79324
--- /dev/null
+++ b/apps/workflowengine/src/components/Workflow.vue
@@ -0,0 +1,209 @@
+<!--
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <div id="workflowengine">
+ <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>
+
+ <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"
+ :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>
+ </div>
+ </a>
+ </transition-group>
+
+ <div v-if="hasMoreOperations" class="actions__more">
+ <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') }}
+ </NcButton>
+ </div>
+ </NcSettingsSection>
+
+ <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.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,
+ },
+ data() {
+ return {
+ showMoreOperations: false,
+ appstoreUrl: generateUrl('settings/apps/workflow'),
+ workflowDocUrl: loadState('workflowengine', 'doc-url'),
+ WorkflowOffSvg,
+ }
+ },
+ computed: {
+ ...mapGetters({
+ rules: 'getRules',
+ }),
+ ...mapState({
+ appstoreEnabled: 'appstoreEnabled',
+ scope: 'scope',
+ operations: 'operations',
+ }),
+ hasMoreOperations() {
+ return Object.keys(this.operations).length > ACTION_LIMIT
+ },
+ mainOperations() {
+ if (this.showMoreOperations) {
+ return Object.values(this.operations)
+ }
+ return Object.values(this.operations).slice(0, ACTION_LIMIT)
+ },
+ showAppStoreHint() {
+ return this.appstoreEnabled && OC.isUserAdmin()
+ },
+ isUserAdmin() {
+ return OC.isUserAdmin()
+ },
+ isAdminScope() {
+ return this.scope === ADMIN_SCOPE
+ },
+ },
+ mounted() {
+ this.$store.dispatch('fetchRules')
+ },
+ methods: {
+ createNewRule(operation) {
+ this.$store.dispatch('createNewRule', operation)
+ },
+ },
+}
+</script>
+
+<style scoped lang="scss">
+ @use "./../styles/operation";
+
+ #workflowengine {
+ border-bottom: 1px solid var(--color-border);
+ }
+
+ .section {
+ max-width: 100vw;
+
+ h2.configured-flows {
+ margin-top: 50px;
+ margin-bottom: 0;
+ }
+ }
+
+ .actions {
+ display: flex;
+ flex-wrap: wrap;
+ max-width: 1200px;
+ .actions__item {
+ max-width: 280px;
+ flex-basis: 250px;
+ }
+ }
+
+ .actions__more {
+ margin-bottom: 10px;
+ }
+
+ .slide-enter-active {
+ -moz-transition-duration: 0.3s;
+ -webkit-transition-duration: 0.3s;
+ -o-transition-duration: 0.3s;
+ transition-duration: 0.3s;
+ -moz-transition-timing-function: ease-in;
+ -webkit-transition-timing-function: ease-in;
+ -o-transition-timing-function: ease-in;
+ transition-timing-function: ease-in;
+ }
+
+ .slide-leave-active {
+ -moz-transition-duration: 0.3s;
+ -webkit-transition-duration: 0.3s;
+ -o-transition-duration: 0.3s;
+ transition-duration: 0.3s;
+ -moz-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
+ -webkit-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
+ -o-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
+ transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
+ }
+
+ .slide-enter-to, .slide-leave {
+ max-height: 500px;
+ overflow: hidden;
+ }
+
+ .slide-enter, .slide-leave-to {
+ overflow: hidden;
+ max-height: 0;
+ padding-top: 0;
+ padding-bottom: 0;
+ }
+
+ .actions__item.more {
+ background-color: var(--color-background-dark);
+ }
+</style>