Skip to content

Commit

Permalink
wip: Upgrade to Quasar 2 / Vue 3 - event workflows (#393)
Browse files Browse the repository at this point in the history
claustres committed Jul 21, 2023
1 parent 3db0571 commit bf3b20e
Showing 14 changed files with 111 additions and 204 deletions.
33 changes: 9 additions & 24 deletions api/src/hooks/hooks.event-logs.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import makeDebug from 'debug'
import _ from 'lodash'
import commonHooks from 'feathers-hooks-common'
import { sendPushNotifications } from '../utils.js'

const { getItems } = commonHooks
const debug = makeDebug('aktnmap:event-logs:hooks')
@@ -84,45 +85,29 @@ export async function updatePreviousLog (hook) {
return hook
}

export async function sendStateNotifications (hook) {
export async function sendEventLogPushNotifications (hook) {
if (hook.type !== 'after') {
throw new Error('The \'sendStateNotifications\' hook should only be used as a \'after\' hook.')
throw new Error('The \'sendEventLogPushNotifications\' hook should only be used as a \'after\' hook.')
}

// A notification occur only when we record the interaction of a given workflow step
// from the coordinator toward the participant
const interaction = _.get(hook, 'result.properties.interaction')
const stakeholder = _.get(hook, 'result.stakeholder')
if (interaction && (stakeholder === 'coordinator')) {
const pusherService = hook.app.getService('pusher')
if (!pusherService) return hook
const participant = hook.result.participant
let event = hook.result.event
if (participant && event) {
// We need the event first to get its title
// We need the event first to get its title as we only have the id
const eventsService = hook.app.getService('events', hook.service.context)
event = await eventsService.get(event.toString())
// We'd like to be tolerant here because the participants might have be removed from the system while the event is still alive
try {
await pusherService.create({
action: 'message',
// The notification contains the event title with recorded interaction
// FIXME add dates for correct stacking
message: {
title: event.name,
body: interaction.value,
// To make the log appear right in the event time line use the event creation as reference
// and make the log appear like an event update
createdAt: event.createdAt,
updatedAt: hook.result.createdAt,
// Custom vibration pattern
vibration: [500, 1000, 500, 500, 500, 500],
sound: 'default'
},
pushObject: participant.toString(),
pushObjectService: 'users'
})
debug('Published event state notifications for participant ' + participant.toString() + ' on event ' + event._id.toString())
const notification = {
title: event.name,
body: interaction.value
}
await sendPushNotifications(hook.app, [participant.toString()], notification)
} catch (error) {
hook.app.logger.error(error.message, error)
}
4 changes: 2 additions & 2 deletions api/src/services/event-logs/event-logs.hooks.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import commonHooks from 'feathers-hooks-common'
import { hooks as coreHooks } from '@kalisio/kdk/core.api.js'
import { addLogDefaults, sendStateNotifications, linkWithPreviousLog, updatePreviousLog, archive } from '../../hooks/index.js'
import { addLogDefaults, sendEventLogPushNotifications, linkWithPreviousLog, updatePreviousLog, archive } from '../../hooks/index.js'

const populatePreviousLog = commonHooks.populate({
schema: hook => {
@@ -44,7 +44,7 @@ const hooks = {
all: [],
find: [populatePreviousLog, populateParticipant],
get: [],
create: [updatePreviousLog, sendStateNotifications, populatePreviousLog, populateParticipant],
create: [updatePreviousLog, sendEventLogPushNotifications, populatePreviousLog, populateParticipant],
update: [],
patch: [],
remove: []
3 changes: 3 additions & 0 deletions api/src/utils.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import _ from 'lodash'
import { createObjectID } from '@kalisio/kdk/core.api.js'

// Send a notification to participants that can be users, groups or tags
export async function sendPushNotifications (app, participants, notification) {
@@ -18,6 +19,8 @@ export async function sendPushNotifications (app, participants, notification) {
// Define participants
const [usersId, groupsId, tagsId] = Array(3).fill([])
_.forEach(participants, participant => {
// Check for ObjectID
if (typeof participant === 'string') usersId.push(participant)
if (participant.service === 'members') usersId.push(participant._id)
if (participant.service === 'groups') groupsId.push(participant._id)
if (participant.service === 'tags') tagsId.push(participant._id)
10 changes: 5 additions & 5 deletions src/components/AlertEditor.vue
Original file line number Diff line number Diff line change
@@ -41,6 +41,11 @@ export default {
default: () => null
}
},
data () {
return {
applyInProgress: false
}
},
computed: {
buttons () {
return [
@@ -49,11 +54,6 @@ export default {
]
}
},
data () {
return {
applyInProgress: false
}
},
methods: {
onFormReferenceCreated (reference) {
if (reference) {
4 changes: 2 additions & 2 deletions src/components/EventActivityPanel.vue
Original file line number Diff line number Diff line change
@@ -7,9 +7,9 @@
<q-tooltip v-if="participant.step" content-class="bg-primary" >{{ getUserState(participant) }}</q-tooltip>
<q-tooltip v-if="participant.step" :offset="[0, 48]">{{ $t('EventActivityPanel.FILTER_PARTICIPANTS') }}</q-tooltip>
</q-btn>
<span>{{ getUserName(participant) }}&nbsp;&nbsp;</span>
<span v-if="getUserComment(participant)">{{ getUserName(participant) }}: {{ getUserComment(participant) }}</span>
<span v-else>{{ getUserName(participant) }}</span>
</div>
<k-text-area class="self-center text-italic" :text="getUserComment(participant)" :length="30"/>
<div class="col-auto self-center">
<q-btn v-if="!archived && canFollowUpUser(participant)" flat round small color="primary" @click="doUserFollowUp(participant._id)">
<q-icon name="las la-sms" color="red" />
8 changes: 2 additions & 6 deletions src/components/EventCard.vue
Original file line number Diff line number Diff line change
@@ -330,9 +330,7 @@ export default {
return this.$api.getService('event-logs', this.contextId)
},
async loadSchema () {
// Load layer schema if any first
await this.loadLayerSchema(this.event.layer)
this.schema = await this.generateSchemaForStep(this.participantStep)
this.schema = this.generateSchemaForStep(this.participantStep)
return this.schema
},
configureActions () {
@@ -419,9 +417,7 @@ export default {
this.loadRefs()
])
await this.$refs.form.build()
const properties = await this.loadFeatureProperties(this.event.feature)
if (properties) this.$refs.form.fill(properties)
else this.$refs.form.clear()
this.$refs.form.clear()
} else if (this.isCoordinator) {
this.$router.push({
name: 'event-activity',
8 changes: 2 additions & 6 deletions src/components/EventLogEditor.vue
Original file line number Diff line number Diff line change
@@ -53,7 +53,7 @@ export default {
return this.$api.getService('event-logs', this.contextId)
},
async loadSchema () {
this.schema = await this.generateSchemaForStep(this.step, this.event.layer)
this.schema = this.generateSchemaForStep(this.step)
return this.schema
},
async refresh () {
@@ -65,9 +65,7 @@ export default {
this.loadRefs()
])
await this.$refs.form.build()
const properties = await this.loadFeatureProperties(this.event.feature)
if (properties) this.$refs.form.fill(properties)
else this.$refs.form.clear()
this.$refs.form.clear()
}
},
async logCoordinatorState () {
@@ -79,8 +77,6 @@ export default {
// Retrieve source log/event
this.state = await this.getService().get(this.logId)
this.event = await this.$api.getService('events', this.contextId).get(this.objectId)
// Load layer schema if any
await this.loadLayerSchema(this.event.layer)
this.step = this.getWorkflowStep(this.state)
this.refresh()
}
14 changes: 5 additions & 9 deletions src/components/EventLogItem.vue
Original file line number Diff line number Diff line change
@@ -14,15 +14,11 @@
Item content
-->
<template v-slot:item-content>
<q-item-section>
<q-item-label>{{ getUserName(item) }}</q-item-label>
<q-item-label v-if="itemStep" caption>{{ getUserComment(item) }}</q-item-label>
</q-item-section>
<q-item-section>
<q-item-label v-if="itemStep">{{ getUserState(item) }}</q-item-label>
<q-item-label v-if="!itemStep">{{ $t('EventLogItem.READ') }}</q-item-label>
<q-item-label v-if="createdAt" caption>{{ createdAt.toLocaleString() }}</q-item-label>
</q-item-section>
<q-item-label>{{ getUserName(item) }}</q-item-label>
<q-item-label v-if="itemStep" caption>{{ getUserComment(item) }}</q-item-label>
<q-item-label v-if="itemStep">{{ getUserState(item) }}</q-item-label>
<q-item-label v-if="!itemStep">{{ $t('EventLogItem.READ') }}</q-item-label>
<q-item-label v-if="createdAt" caption>{{ createdAt.toLocaleString() }}</q-item-label>
</template>
</KItem>
</template>
8 changes: 2 additions & 6 deletions src/components/EventLogsList.vue
Original file line number Diff line number Diff line change
@@ -154,9 +154,7 @@ export default {
return this.$api.getService(this.getServiceName())
},
async loadSchema () {
// Load layer schema if any first
await this.loadLayerSchema(this.event.layer)
this.schema = await this.generateSchemaForStep(this.selectedParticipantStep)
this.schema = this.generateSchemaForStep(this.selectedParticipantStep)
return this.schema
},
onItemToggled (item, toggled) {
@@ -193,9 +191,7 @@ export default {
this.loadRefs()
])
await this.$refs.form.build()
const properties = await this.loadFeatureProperties(this.event.feature)
if (properties) this.$refs.form.fill(properties)
else this.$refs.form.clear()
this.$refs.form.clear()
},
async logParticipantStates () {
// Check for multi-selection
14 changes: 14 additions & 0 deletions src/components/EventTemplateWorkflowEditor.vue
Original file line number Diff line number Diff line change
@@ -7,6 +7,7 @@
<div class="column xs-gutter">
<EventTemplateWorkflowForm
:ref="onFormReferenceCreated"
:class="{ 'light-dimmed': applyInProgress }"
:schema="schema"
@form-ready="onFormReady"
/>
@@ -29,6 +30,11 @@ export default {
kdkCoreMixins.schemaProxy,
kdkCoreMixins.baseEditor
],
data () {
return {
applyInProgress: false
}
},
computed: {
buttons () {
return [
@@ -38,6 +44,14 @@ export default {
}
},
methods: {
async apply () {
this.applyInProgress = true
if (await kdkCoreMixins.baseEditor.methods.apply.call(this)) {
this.applyInProgress = false
this.closeModal()
}
this.applyInProgress = false
},
openModal (maximized = false) {
this.refresh()
kdkCoreMixins.baseModal.methods.openModal.call(this, maximized)
127 changes: 48 additions & 79 deletions src/components/EventTemplateWorkflowForm.vue
Original file line number Diff line number Diff line change
@@ -2,7 +2,8 @@
<q-stepper id="workflow" header-nav animated ref="stepper" v-model="currentStep" @input="onStepSelected">
<q-step v-for="(step, index) in steps" :key="step.name + '_' + index" :name="step.name"
:title="step.title" :icon="getStepIcon(step)">
<KForm ref="stepForm" v-show="!preview" :schema="schema" @form-ready="fillStepForm" @field-changed="onStepFieldChanged" />
<KForm ref="stepForm" v-show="!preview" :schema="stepSchema" :values='stepValues'
@form-ready="onFormReady" @field-changed="onStepFieldChanged" />
<div v-show="preview">
<KForm ref="previewForm" :schema="previewSchema" />
</div>
@@ -72,8 +73,23 @@ export default {
return {
steps: [],
currentStep: '',
preview: false,
previewSchema: null
stepValues: {},
preview: false
}
},
computed: {
stepSchema () {
if (!this.schema) return null
// Start from base schema
const schema = Object.assign({}, this.schema)
// Add required end field options
const options = _.get(this.stepValues, 'interaction', [])
_.set(schema, 'properties.end.field.options',
options.map(option => Object.assign({ label: option.value }, option)))
return schema
},
previewSchema () {
return this.generateSchemaForStep(this.getCurrentStep())
}
},
methods: {
@@ -110,7 +126,7 @@ export default {
this.steps.push(step)
this.currentStep = this.steps[this.steps.length - 1].name
}
this.restoreStep()
this.refreshStep()
},
onRemoveStep () {
const name = this.currentStep
@@ -120,21 +136,21 @@ export default {
if (this.currentStep !== this.steps[0].name) {
this.currentStep = this.steps[index - 1].name
} else {
// when removing first step the second will replace it
// When removing first step the second will replace it
this.currentStep = this.steps[1].name
}
// Can't use splice because Vue does not detect the change
this.steps = this.steps.filter((step) => step.name !== name)
// Use splice so that Vue detects the change
this.steps.splice(index, 1)
// Restore step form when editing
this.restoreStep()
this.refreshStep()
},
onPreviousStep () {
// Apply current form changes when editing
// If not possible the current form is invalid so do nothing
if (!this.applyStepChanges()) return
const index = this.getCurrentStepIndex()
this.currentStep = this.steps[index - 1].name
this.restoreStep()
this.refreshStep()
},
onNextStep () {
// Apply current form changes when editing
@@ -143,7 +159,7 @@ export default {
const index = this.getCurrentStepIndex()
this.currentStep = this.steps[index + 1].name
// Restore step form when editing
this.restoreStep()
this.refreshStep()
},
onStepSelected (step) {
// Apply current form changes when editing
@@ -153,7 +169,7 @@ export default {
// For now we don't validate
// if (!this.applyStepChanges()) return
// Restore step form when editing
this.restoreStep()
this.refreshStep()
},
onPreviewOrEdit () {
// Apply current form changes before previewing
@@ -163,7 +179,17 @@ export default {
}
this.preview = !this.preview
// Restore step form when editing
this.restoreStep()
this.refreshStep()
},
onStepFieldChanged () {
const form = this.getForm('stepForm')
if (form) {
_.assign(this.stepValues, form.values())
}
},
onFormReady () {
// Now internal form is ready we are as well
this.$emit('form-ready', this)
},
applyStepChanges () {
if (this.preview) return true
@@ -173,83 +199,25 @@ export default {
}
return form.isValid
},
async restoreStep () {
// For preview we need to update the underlying schema to reflect step values
if (this.preview) {
this.previewSchema = await this.generateSchemaForStep(this.getCurrentStep(), this.layer)
// We need to force a refresh so that the schema is correctly transfered to child component by Vuejs
await this.$nextTick()
// Force form refresh to default values
const form = this.getForm('previewForm')
await form.build()
form.clear()
} else {
refreshStep () {
// For preview the underlying schema should be updated automatically, thus reset the form
if (!this.preview) {
// Otherwise simply fill the step form
this.fillStepForm()
}
},
async loadPreviewSchema () {
try {
this.previewSchema = await this.generateSchemaForStep(this.getCurrentStep(), this.layer)
return this.previewSchema
} catch (error) {
this.$events.emit('error', error)
throw error
}
},
onStepFieldChanged (field, value) {
// Setup workflow ending values selector depending on interaction field state
if (field === 'interaction') {
this.setupEndField()
}
},
fillStepForm () {
const form = this.getForm('stepForm')
form.fill(this.getCurrentStep())
this.setupFeatureInteractionField()
this.setupEndField()
},
setupFeatureInteractionField () {
if (!this.layerSchema) return
const form = this.getForm('stepForm')
const interactionField = form.getField('featureInteraction')
// Add required label field
// FIXME: [Vue warn] Set operation on key "xxx" failed: target is readonly.
//_.set(interactionField, 'field.options',
// _.toPairs(this.layerSchema.properties).map(([key, value]) => ({ value: key, label: value.field.helper })))
},
setupEndField () {
const form = this.getForm('stepForm')
const interactionField = form.getField('interaction')
const endField = form.getField('end')
// Add required label field
// FIXME: [Vue warn] Set operation on key "xxx" failed: target is readonly.
//_.set(endField, 'field.options',
// interactionField.reference.model.map(option => Object.assign({ label: option.value }, option)))
},
async build () {
// Because our step form is under a v-if caused by the Quasar stepper
// it is destroyed/recreated by Vue so that we need to restore the refs each time it is build
this.setRefs(['stepForm'])
// Build the internal form
await Promise.all([
this.loadPreviewSchema(),
this.loadRefs()
])
return Promise.all([
this.getForm('stepForm').build(),
this.getForm('previewForm').build()
])
this.stepValues = this.getCurrentStep()
},
async fill (workflow) {
// If no workflow given this will use default one
// If no workflow given we will use default one
if (workflow && workflow.length > 0) {
this.steps = workflow
this.currentStep = this.steps[0].name
// Reset current step if not available
if (!this.getCurrentStep()) this.currentStep = this.steps[0].name
}
// Restore step form when editing
this.restoreStep()
this.refreshStep()
},
clear () {
this.fill([this.generateStep()])
@@ -266,21 +234,22 @@ export default {
object.workflow = this.steps
},
submitted (object) {
// Nothing to do
}
},
created () {
this.defaultStep = {
title: '',
icon: { name: 'fas fa-check', color: 'grey' },
description: '',
featureInteraction: [],
interaction: [],
end: [],
stakeholder: 'participant'
}
// Initialize step data on creation so that local ref to form can be resolved
this.steps = [this.generateStep()]
this.currentStep = this.steps[0].name
this.refreshStep()
}
}
</script>
70 changes: 16 additions & 54 deletions src/mixins/mixin.events.js
Original file line number Diff line number Diff line change
@@ -24,10 +24,12 @@ const eventsMixin = {
return utils.getLocationAsFeature(this.event)
},
hasLocation () {
return _.has(this.getLocationAsFeature(), 'geometry')
const feature = this.getLocationAsFeature()
return feature && _.has(feature, 'geometry')
},
hasLocationGeometry () {
return (_.get(this.getLocationAsFeature(), 'geometry.type') !== 'Point')
const feature = this.getLocationAsFeature()
return feature && _.has(feature, 'geometry') && (_.get(feature, 'geometry.type') !== 'Point')
},
hasAnyLocation () {
return this.hasLocation() || this.hasLocationGeometry()
@@ -58,28 +60,18 @@ const eventsMixin = {
if (_.isEmpty(step)) return false
else return !_.isEmpty(step.interaction)
},
// Check if there is a defined feature interaction on target step
hasStepFeatureInteraction (step) {
if (_.isEmpty(step)) return false
else return !_.isEmpty(step.featureInteraction)
},
// Check if there is a defined interaction on target step
hasStepInteraction (step) {
return this.hasStepUserInteraction(step) || this.hasStepFeatureInteraction(step)
return this.hasStepUserInteraction(step)
},
// Check if there is a recorded user interaction on target state
hasStateUserInteraction (state) {
if (_.isEmpty(state)) return false
else return !_.isEmpty(_.get(state, 'properties.interaction'))
},
// Check if there is a recorded feature interaction on target state
hasStateFeatureInteraction (state) {
if (_.isEmpty(state)) return false
else return !_.isEmpty(state.properties)
},
// Check if there is a recorded interaction on target state
hasStateInteraction (state) {
return this.hasStateUserInteraction(state) || this.hasStateFeatureInteraction(state)
return this.hasStateUserInteraction(state)
},
// Check if we wait for an interaction at current state based on target step/stakeholder
waitingInteraction (step, state, stakeholder) {
@@ -114,8 +106,6 @@ const eventsMixin = {
getUserIcon (state = {}, step = {}) {
// When last step was an interaction use it as icon
if (this.hasStateUserInteraction(state)) return _.get(state, 'properties.interaction.icon')
// When last step was a feature interaction use a specific icon
if (this.hasStateFeatureInteraction(state)) return { name: 'fa-edit', color: 'blue' }
// If we wait for an interaction use previous state icon
if (this.hasStateUserInteraction(state.previous)) return this.getUserIcon(state.previous, step)
// Otherwise use workflow icon for current step
@@ -204,39 +194,19 @@ const eventsMixin = {
return currentStep
}
},
async loadLayerSchema (layerId) {
this.layerSchema = null
if (!layerId) return
const layer = await this.$api.getService('catalog', this.contextId).get(layerId)
if (layer.schema) this.layerSchema = layer.schema.content
},
async loadFeatureProperties (featureId) {
if (!featureId) return null
const feature = await this.$api.getService('features', this.contextId).get(featureId)
return (!_.isEmpty(feature.properties) ? feature.properties : null)
},
async loadFeatureGeometry (featureId) {
if (!featureId) return null
const feature = await this.$api.getService('features', this.contextId).get(featureId)
return feature.geometry
},
async generateSchemaForStep (step) {
generateSchemaForStep (step) {
// Start from schema template and clone it because modifications
// will be shared by all caller otherwise
if (!this.baseLogSchema) {
this.baseLogSchema = await kdkCoreUtils.loadSchema('event-logs.create')
// FIXME: not yet sure why this is now required, might be related to
// https://forum.vuejs.org/t/solved-using-standalone-version-but-getting-failed-to-mount-component-template-or-render-function-not-defined/19569/2
if (this.baseLogSchema.default) this.baseLogSchema = this.baseLogSchema.default
}
const schema = _.cloneDeep(this.baseLogSchema)
// Then add step interactions
if (this.hasStepFeatureInteraction(step)) {
if (this.layerSchema) {
schema.properties = _.pickBy(this.layerSchema.properties, (value, property) => step.featureInteraction.includes(property))
schema.required = _.filter(this.layerSchema.required, (property) => step.featureInteraction.includes(property))
}
const schema = {
$schema: 'http://json-schema.org/draft-07/schema#',
$id: 'http://www.kalisio.xyz/schemas/event-logs.create.json#',
title: 'Event Log Creation',
description: 'Event log creation schema',
type: 'object',
properties: {},
required: []
}
// Then add step interaction
if (this.hasStepUserInteraction(step)) {
const options = step.interaction.map(option => { return { label: option.value, value: option } })
schema.properties.interaction = {
@@ -303,14 +273,6 @@ const eventsMixin = {
// Directly store as GeoJson objects
const log = await this.createParticipantLog(step, state)
_.merge(log.properties, result.values)
if (this.hasStateFeatureInteraction(log) && this.event.feature) {
// Use feature geometry instead of user position in this case
const geometry = await this.loadFeatureGeometry(this.event.feature)
if (geometry) log.geometry = geometry
// Update feature properties
this.$api.getService('features', this.contextId).patch(this.event.feature,
_.mapKeys(result.values, (value, key) => `properties.${key}`))
}
// Then create interaction log
return this.getService().create(log)
} else {
10 changes: 0 additions & 10 deletions src/schemas/event-logs.create.json

This file was deleted.

2 changes: 1 addition & 1 deletion src/schemas/event-templates.update-workflow.json
Original file line number Diff line number Diff line change
@@ -55,10 +55,10 @@
"end": {
"type": "array",
"uniqueItems": true,
"multiselect": true,
"field": {
"component": "form/KSelectField",
"label": "schemas.EVENT_TEMPLATES_WORKFLOW_END_FIELD_LABEL",
"multiple": true,
"chips": true,
"options": []
}

0 comments on commit bf3b20e

Please sign in to comment.