Browse Source

Unified workflow management

Signed-off-by: Julius Härtl <jus@bitgrid.net>
tags/v18.0.0beta1
Julius Härtl 4 years ago
parent
commit
ad976c66fd
No account linked to committer's email address

+ 1
- 1
apps/workflowengine/src/admin.js View File

@@ -382,4 +382,4 @@ import OperationsTemplate from './templates/operations.handlebars';
this.collection.each(this.renderOperation, this);
}
});
})();
})();

+ 104
- 0
apps/workflowengine/src/components/Check.vue View File

@@ -0,0 +1,104 @@
<template>
<div class="check" @click="showDelete" v-click-outside="hideDelete">
<Multiselect v-model="currentOption" :options="options" label="name"
track-by="class" :allow-empty="false" :placeholder="t('workflowengine', 'Select a filter')"
@input="updateCheck" ref="checkSelector"></Multiselect>
<Multiselect v-if="currentOption" v-model="currentOperator" @input="updateCheck"
:options="operators" label="name" track-by="operator"
:allow-empty="false" :placeholder="t('workflowengine', 'Select a comparator')"></Multiselect>
<component v-if="currentOperator && currentComponent" :is="currentOption.component()" v-model="check.value" />
<input v-else-if="currentOperator" type="text" v-model="check.value" @input="updateCheck" />
<Actions>
<ActionButton icon="icon-delete" v-if="deleteVisible || !currentOption" @click="$emit('remove')" />
</Actions>
</div>
</template>

<script>
import { Multiselect, Actions, ActionButton } from 'nextcloud-vue'
import ClickOutside from 'vue-click-outside';

export default {
name: 'Check',
components: {
ActionButton,
Actions,
Multiselect
},
directives: {
ClickOutside
},
props: {
check: {
type: Object,
required: true
}
},
data() {
return {
deleteVisible: false,
currentOption: null,
currentOperator: null,
options: [],
}
},
mounted() {
this.options = Object.values(OCA.WorkflowEngine.Plugins).map((plugin) => {
if (plugin.component) {
return {...plugin.getCheck(), component: plugin.component}
}
return plugin.getCheck()
})
this.currentOption = this.options.find((option) => option.class === this.check.class)
this.currentOperator = this.operators.find((operator) => operator.operator === this.check.operator)
this.$nextTick(() => {
this.$refs.checkSelector.$el.focus()
})
},
computed: {
operators() {
if (!this.currentOption)
return []
return this.options.find((item) => item.class === this.currentOption.class).operators
},
currentComponent() {
if (!this.currentOption)
return []
let currentComponent = this.options.find((item) => item.class === this.currentOption.class).component
return currentComponent && currentComponent()
}
},
methods: {
showDelete() {
this.deleteVisible = true
},
hideDelete() {
this.deleteVisible = false
},
updateCheck() {
if (this.check.class !== this.currentOption.class) {
this.currentOperator = this.operators[0]
}
this.check.class = this.currentOption.class
this.check.operator = this.currentOperator.operator
this.$emit('update', this.check)
}
}
}
</script>

<style scoped lang="scss">
.check {
display: flex;
& > .multiselect,
& > input[type=text] {
margin-right: 5px;
}
}
input[type=text] {
margin: 0;
}
::placeholder {
font-size: 10px;
}
</style>

+ 84
- 0
apps/workflowengine/src/components/Event.vue View File

@@ -0,0 +1,84 @@
<template>
<div>
<Multiselect :value="currentEvent" :options="allEvents" label="name" track-by="id" :allow-empty="false" :disabled="allEvents.length <= 1" @input="updateEvent">
<template slot="singleLabel" slot-scope="props">
<span class="option__icon" :class="props.option.icon"></span>
<span class="option__title option__title_single">{{ props.option.name }}</span>
</template>
<template slot="option" slot-scope="props">
<span class="option__icon" :class="props.option.icon"></span>
<span class="option__title">{{ props.option.name }}</span>
</template>
</Multiselect>
</div>
</template>

<script>
import { Multiselect } from 'nextcloud-vue'
import { eventService, operationService } from '../services/Operation'

export default {
name: "Event",
components: {
Multiselect
},
props: {
rule: {
type: Object,
required: true
}
},
computed: {
currentEvent() {
if (typeof this.rule.event === 'undefined') {
return this.allEvents.length > 0 ? this.allEvents[0] : null
}
return this.allEvents.find(event => event.id === this.rule.event)
},
allEvents() {
return this.operation.events.map((eventName) => eventService.get(eventName))
},
operation() {
return operationService.get(this.rule.class)
}
},
methods: {
updateEvent(event) {
this.$set(this.rule, 'event', event.id)
this.$emit('update', this.rule)
}
}
}
</script>

<style scoped>
.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 .multiselect__single {
background-color: var(--color-main-background) !important;
}

.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;
color: var(--color-main-text);
}
.option__title_single {
font-weight: 900;
}
</style>

+ 83
- 0
apps/workflowengine/src/components/Operation.vue View File

@@ -0,0 +1,83 @@
<template>
<div class="actions__item" :class="{'colored': !!color}" :style="{ backgroundColor: color }">
<div class="icon" :class="icon"></div>
<h3>{{ title }}</h3>
<small>{{ description }}</small>
<slot />
</div>
</template>

<script>
export default {
name: "Operation",
props: {
icon: {
type: String,
required: true
},
title: {
type: String,
required: true
},
description: {
type: String,
required: true
},
color: {
type: String,
required: false
},
}
}
</script>

<style scoped lang="scss">
.actions__item {
display: flex;
flex-direction: column;
flex-grow: 1;
margin-left: -1px;
padding: 10px;
border-radius: var(--border-radius-large);
max-width: 230px;
margin-right: 20px;
}
.icon {
display: block;
width: 100%;
height: 50px;
background-size: 50px 50px;
background-position: center center;
margin-top: 10px;
margin-bottom: 20px;
}
h3, small {
padding: 6px;
text-align: center;
display: block;
}
h3 {
margin: 0;
padding: 0;
font-weight: 500;
}
small {
font-size: 10pt;
}
.icon-block {
background-image: url(/apps-extra/files_accesscontrol/img/app-dark.svg);
}

.icon-convert-pdf {
background-image: url(/apps-extra/workflow_pdf_converter/img/app-dark.svg);
}

.colored {
.icon {
filter: invert(1);
}
* {
color: var(--color-primary-text)
}
}
</style>

+ 45
- 0
apps/workflowengine/src/components/Operations/ConvertToPdf.vue View File

@@ -0,0 +1,45 @@
<template>
<multiselect :options="options" track-by="id" label="text" v-model="value"></multiselect>
</template>

<script>
const pdfConvertOptions = [
{
id: 'keep;preserve',
text: t('workflow_pdf_converter', 'Keep original, preserve existing PDFs'),
},
{
id: 'keep;overwrite',
text: t('workflow_pdf_converter', 'Keep original, overwrite existing PDF'),
},
{
id: 'delete;preserve',
text: t('workflow_pdf_converter', 'Delete original, preserve existing PDFs'),
},
{
id: 'delete;overwrite',
text: t('workflow_pdf_converter', 'Delete original, overwrite existing PDF'),
},
]

import { Multiselect } from 'nextcloud-vue'
export default {
name: "ConvertToPdf",
components: {Multiselect},
data() {
return {
options: pdfConvertOptions,
value: pdfConvertOptions[0]
}
}
}
</script>

<style scoped>
.multiselect {
width: 100%;
max-width: 300px;
margin: auto;
text-align: center;
}
</style>

+ 29
- 0
apps/workflowengine/src/components/Operations/Tag.vue View File

@@ -0,0 +1,29 @@
<template>
<multiselect :options="options" v-model="value" track-by="id" label="title" :multiple="true" :tagging="true" @input="$emit('input', value.map(item => item.id))"></multiselect>
</template>

<script>
// TODO: fetch tags from endpoint
const tags = [{id: 3, title: 'foo'}, {id: 4, title: 'bar'}]

import { Multiselect } from 'nextcloud-vue'
export default {
name: "Tag",
components: {Multiselect},
data() {
return {
options: tags,
value: null
}
}
}
</script>

<style scoped>
.multiselect {
width: 100%;
max-width: 300px;
margin: auto;
text-align: center;
}
</style>

+ 209
- 0
apps/workflowengine/src/components/Rule.vue View File

@@ -0,0 +1,209 @@
<template>
<div class="section rule">
<!-- TODO: icon-confirm -->
<div class="trigger icon-confirm">
<p>
<span>{{ t('workflowengine', 'When') }}</span>
<Event :rule="rule" @update="updateRule"></Event>
</p>
<p v-for="check in rule.checks">
<span>{{ t('workflowengine', 'and') }}</span>
<Check :check="check" @update="updateRule" @remove="removeCheck(check)"></Check>
</p>
<p>
<span> </span>
<input v-if="lastCheckComplete" type="button" class="check--add" @click="rule.checks.push({class: null, operator: null, value: null})" value="Add a new filter"/>
</p>
</div>
<div class="action">
<div class="buttons">
<button class="status-button icon" :class="ruleStatus.class" v-tooltip="ruleStatus.tooltip" @click="saveRule">{{ ruleStatus.title }}</button>
<Actions>
<ActionButton v-if="rule.id === -1" icon="icon-close" @click="$emit('cancel')">Cancel</ActionButton>
<ActionButton v-else icon="icon-delete" @click="deleteRule">Delete</ActionButton>
</Actions>
</div>
<Operation :icon="operation.icon" :title="operation.title" :description="operation.description">
<component v-if="operation.options" :is="operation.options" v-model="operation.operation" @input="updateOperation" />
</Operation>
</div>
</div>
</template>

<script>
import { Actions, ActionButton, Tooltip } from 'nextcloud-vue'
import Event from './Event'
import Check from './Check'
import Operation from './Operation'
import { operationService } from '../services/Operation'
import axios from 'nextcloud-axios'
import confirmPassword from 'nextcloud-password-confirmation'

export default {
name: 'Rule',
components: {
Operation, Check, Event, Actions, ActionButton
},
directives: {
Tooltip
},
props: {
rule: {
type: Object,
required: true,
}
},
data () {
return {
editing: false,
operationService,
checks: [],
error: null,
dirty: this.rule.id === -1,
checking: false
}
},
computed: {
operation() {
return this.operationService.get(this.rule.class)
},
ruleStatus() {
if (this.error) {
return { title: 'Invalid', class: 'icon-close-white invalid', tooltip: { placement: 'bottom', show: true, content: escapeHTML(this.error.data) } }
}
if (!this.dirty || this.checking) {
return { title: 'Active', class: 'icon icon-checkmark' }
}
return { title: 'Save', class: 'icon-confirm-white primary' }


},
lastCheckComplete() {
const lastCheck = this.rule.checks[this.rule.checks.length-1]
return typeof lastCheck === 'undefined' || lastCheck.class !== null
}
},
methods: {
updateOperation(operation) {
this.$set(this.rule, 'operation', operation)
},
async updateRule() {
this.checking = true
if (!this.dirty) {
this.dirty = true
}
try {
let result = await axios.post(OC.generateUrl(`/apps/workflowengine/operations/test`), this.rule)
this.error = null
this.checking = false
} catch (e) {
console.error('Failed to update operation')
this.error = e.response
this.checking = false
}
},
async saveRule() {
try {
await confirmPassword()
let result
if (this.rule.id === -1) {
result = await axios.post(OC.generateUrl(`/apps/workflowengine/operations`), this.rule)
this.rule.id = result.id
} else {
result = await axios.put(OC.generateUrl(`/apps/workflowengine/operations/${this.rule.id}`), this.rule)
}
this.$emit('update', result.data)
this.dirty = false
this.error = null
} catch (e) {
console.error('Failed to update operation')
this.error = e.response
}
},
async deleteRule() {
try {
await confirmPassword()
await axios.delete(OC.generateUrl(`/apps/workflowengine/operations/${this.rule.id}`))
this.$emit('delete')
} catch (e) {
console.error('Failed to delete operation')
this.error = e.response
}
},
removeCheck(check) {
const index = this.rule.checks.findIndex(item => item === check)
if (index !== -1) {
this.rule.checks.splice(index, 1)
}
}
}
}
</script>

<style scoped lang="scss">
button.icon {
padding-left: 32px;
background-position: 10px center;
}

.status-button {
transition: 0.5s ease all;
}
.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;
}

.rule {
display: flex;
.trigger, .action {
flex-grow: 1;
min-height: 100px;
width: 50%;
}
.action {
position: relative;
.buttons {
position: absolute;
right: 0;
}
}
.icon-confirm {
background-position: right center;
}
}
.trigger p, .action p {
display: flex;
align-items: center;
margin-bottom: 5px;

& > span {
min-width: 50px;
text-align: right;
color: var(--color-text-light);
padding-right: 5px;
}
.multiselect {
flex-grow: 1;
max-width: 300px;
}
}

.check--add {
background-position: 7px center;
background-color: transparent;
padding-left: 6px;
width: 160px;
border-radius: var(--border-radius);
font-weight: normal;
text-align: left;
font-size: 1em;
}
</style>

+ 39
- 0
apps/workflowengine/src/components/Values/FileMimeType.vue View File

@@ -0,0 +1,39 @@
<template>
<input type="text" />
</template>

<script>
import { Multiselect } from 'nextcloud-vue'

export default {
name: 'SizeValue',
components: {
Multiselect
},
data() {
return {
predefinedTypes: [
{
icon: 'icon-picture',
label: 'Images',
pattern: '/image\\/.*/'
},
{
icon: 'icon-category-office',
label: 'Office documents',
pattern: '/(vnd\\.(ms-|openxmlformats-).*))$/'
},
{
icon: 'icon-filetype-file',
label: 'PDF documents',
pattern: 'application/pdf'
}
]
}
}
}
</script>

<style scoped>

</style>

+ 28
- 0
apps/workflowengine/src/components/Values/SizeValue.vue View File

@@ -0,0 +1,28 @@
<template>
<input type="text" placeholder="1 MB" @input="$emit('input', newValue)" v-model="newValue">
</template>

<script>
export default {
name: "SizeValue",
props: {
value: {
type: String
}
},
data() {
return {
newValue: this.value
}
},
watch: {
value() {
this.newValue = this.value
}
}
}
</script>

<style scoped>

</style>

+ 146
- 0
apps/workflowengine/src/components/Workflow.vue View File

@@ -0,0 +1,146 @@
<template>
<div>
<div class="section">
<h2>{{ t('workflowengine', 'Workflows') }}</h2>

<div class="actions" v-if="!hasUnsavedRule">
<Operation v-for="operation in getMainOperations" :key="operation.class"
:icon="operation.icon" :title="operation.title" :description="operation.description" :color="operation.color"
@click.native="createNewRule(operation)"></Operation>
</div>
<div class="actions__more" v-if="!hasUnsavedRule && hasMoreOperations">
<button class="icon" :class="showMoreOperations ? 'icon-triangle-n' : 'icon-triangle-s'"
@click="showMoreOperations=!showMoreOperations">
{{ showMoreOperations ? t('workflowengine', 'Show less') : t('workflowengine', 'Show more') }}
</button>
</div>
<transition name="slide">
<div class="actions" v-if="!hasUnsavedRule && showMoreOperations && hasMoreOperations">
<Operation v-for="operation in getMoreOperations" :key="operation.class" :icon="operation.icon" :title="operation.title" :description="operation.description"
@click.native="createNewRule(operation)"></Operation>
</div>
</transition>
</div>

<transition-group name="slide">
<Rule v-for="rule in rules" :key="rule.id" :rule="rule" @delete="removeRule(rule)" @cancel="removeRule(rule)" @update="updateRule"></Rule>
</transition-group>

</div>
</template>

<script>
import Rule from './Rule'
import Operation from './Operation'
import { operationService } from '../services/Operation'
import axios from 'nextcloud-axios'

const ACTION_LIMIT = 3

export default {
name: 'Workflow',
components: {
Operation,
Rule
},
async mounted() {
const { data } = await axios.get(OC.generateUrl('/apps/workflowengine/operations'))
this.rules = data
},
data() {
return {
operations: operationService,
showMoreOperations: false,
rules: []
}
},
computed: {
hasMoreOperations() {
return Object.keys(this.operations.getAll()).length > ACTION_LIMIT
},
getMainOperations() {
return Object.values(this.operations.getAll()).slice(0, ACTION_LIMIT)
},
getMoreOperations() {
return Object.values(this.operations.getAll()).slice(ACTION_LIMIT)

},
hasUnsavedRule() {
return !!this.rules.find((rule) => rule.id === -1)
}
},
methods: {
updateRule(rule) {
let index = this.rules.findIndex((item) => rule === item)
this.$set(this.rules, index, rule)
},
removeRule(rule) {
let index = this.rules.findIndex((item) => rule === item)
this.rules.splice(index, 1)
},
showAllOperations() {

},
createNewRule(operation) {
this.rules.unshift({
id: -1,
class: operation.class,
name: '', // unused in the new ui, there for legacy reasons
checks: [],
operation: operation.operation || ''
})
}
}
}
</script>

<style scoped lang="scss">
.actions {
display: flex;
.action__item {
border: 1px solid var(--color-border);
}
}

.actions__more {
text-align: center;
}

button.icon {
padding-left: 32px;
background-position: 10px center;
}


.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;
}
</style>

+ 5
- 0
apps/workflowengine/src/filemimetypeplugin.js View File

@@ -17,6 +17,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import FileMimeType from './components/Values/FileMimeType'

(function() {

@@ -65,6 +66,10 @@
var regexRegex = /^\/(.*)\/([gui]{0,3})$/,
result = regexRegex.exec(string);
return result !== null;
},

component: function () {
return FileMimeType
}
};
})();

+ 4
- 1
apps/workflowengine/src/filesizeplugin.js View File

@@ -17,7 +17,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import SizeValue from './components/Values/SizeValue'
(function() {

OCA.WorkflowEngine = OCA.WorkflowEngine || {};
@@ -49,6 +49,9 @@
.tooltip({
placement: 'bottom'
});
},
component: function () {
return SizeValue
}
};
})();

+ 141
- 0
apps/workflowengine/src/services/Operation.js View File

@@ -0,0 +1,141 @@
import ConvertToPdf from './../components/Operations/ConvertToPdf'
import Tag from './../components/Operations/Tag'
class OperationService {

constructor() {
this.operations = {}
}
registerOperation (operation) {
this.operations[operation.class] = Object.assign({
color: 'var(--color-primary)'
}, operation)
}

getAll() {
return this.operations
}

get(className) {
return this.operations[className]
}

}

class EventService {

constructor() {
this.events = {}
}
registerEvent(event) {
this.events[event.id] = event
}

getAll() {
return this.events
}

get(id) {
return this.events[id]
}

}

const operationService = new OperationService()
const eventService = new EventService()


const ALL_CHECKS = [
'OCA\\WorkflowEngine\\Check\\FileMimeType',
'OCA\\WorkflowEngine\\Check\\FileName',
'OCA\\WorkflowEngine\\Check\\FileSize',
'OCA\\WorkflowEngine\\Check\\FileSystemTags',
'OCA\\WorkflowEngine\\Check\\RequestRemoteAddress',
'OCA\\WorkflowEngine\\Check\\RequestTime',
'OCA\\WorkflowEngine\\Check\\RequestURL',
'OCA\\WorkflowEngine\\Check\\RequestUserAgent',
'OCA\\WorkflowEngine\\Check\\UserGroupMembership'
]

/**
* TODO: move to separate apps
* TODO: fetch from initial state api
**/
const EVENT_FILE_ACCESS = 'EVENT_FILE_ACCESS'
const EVENT_FILE_CHANGED = 'EVENT_FILE_CHANGED'
const EVENT_FILE_TAGGED = 'EVENT_FILE_TAGGED'

eventService.registerEvent({
id: EVENT_FILE_ACCESS,
name: 'File is accessed',
icon: 'icon-desktop',
checks: ALL_CHECKS,
})

eventService.registerEvent({
id: EVENT_FILE_CHANGED,
name: 'File was updated',
icon: 'icon-folder',
checks: ALL_CHECKS,
})


eventService.registerEvent({
id: EVENT_FILE_TAGGED,
name: 'File was tagged',
icon: 'icon-tag',
checks: ALL_CHECKS,
})

operationService.registerOperation({
class: 'OCA\\FilesAccessControl\\Operation',
title: 'Block access',
description: 'todo',
icon: 'icon-block',
color: 'var(--color-error)',
events: [
EVENT_FILE_ACCESS
],
operation: 'deny'
})

operationService.registerOperation({
class: 'OCA\\FilesAutomatedTagging\\Operation',
title: 'Tag a file',
description: 'todo',
icon: 'icon-tag',
events: [
EVENT_FILE_CHANGED,
EVENT_FILE_TAGGED
],
options: Tag

})

operationService.registerOperation({
class: 'OCA\\WorkflowPDFConverter\\Operation',
title: 'Convert to PDF',
description: 'todo',
color: '#dc5047',
icon: 'icon-convert-pdf',
events: [
EVENT_FILE_CHANGED,
//EVENT_FILE_TAGGED
],
options: ConvertToPdf
})


const legacyChecks = Object.values(OCA.WorkflowEngine.Plugins).map((plugin) => {
if (plugin.component) {
return {...plugin.getCheck(), component: plugin.component}
}
return plugin.getCheck()
}).reduce((obj, item) => {
obj[item.class] = item
return obj
}, {})

export {
eventService,
operationService
}

+ 8
- 1
apps/workflowengine/src/workflowengine.js View File

@@ -1,4 +1,3 @@
import './admin'
import './filemimetypeplugin'
import './filenameplugin'
import './filesizeplugin'
@@ -10,3 +9,11 @@ import './requestuseragentplugin'
import './usergroupmembershipplugin'

window.OCA.WorkflowEngine = OCA.WorkflowEngine

import Vue from 'vue';

Vue.prototype.t = t;

import Settings from './components/Workflow';
const View = Vue.extend(Settings)
new View({}).$mount('#workflowengine')

Loading…
Cancel
Save