Source: management-system/src/frontend/components/scripting-ide/ScriptingIde.vue

<template>
  <div style="padding: 0px" class="ide-container">
    <v-card color="grey lighten-4" class="mb-2" style="border-top: 1px solid black">
      <v-app-bar dense>
        <v-toolbar-title>Script Editor</v-toolbar-title>
        <v-spacer />
        <v-btn @click="openScriptApi">Open Script Task API</v-btn>
        <v-btn color="primary" @click="saveAndClose()">
          Ok
          <v-icon class="ml-2">mdi-check</v-icon>
        </v-btn>
      </v-app-bar>
    </v-card>
    <v-row no-gutters style="height: 100%">
      <script-tasks-list
        :menu-filtered="menuFiltered"
        :open-element-id="openElementId"
        :selected-element="selectedElement"
        @open="open"
      />
      <v-col sm="10" class="editor-tab">
        <script-editor
          :process-definitions-id="processDefinitionsId"
          :open-element-id="openElementId"
          :reload-flag="openElementId"
          :value="openElementValue"
          :readonly="openElementReadonly"
          @input="updateOpenElementValue($event)"
        />
      </v-col>
    </v-row>
  </div>
</template>

<script>
import * as R from 'ramda';
import ScriptEditor from '@/frontend/components/scripting-ide/ScriptEditor.vue';
import ScriptTasksList from '@/frontend/components/scripting-ide/ScriptTasksList.vue';
import { eventHandler } from '@/frontend/backend-api/index.js';

/**
 * Scripting IDE component.
 */
export default {
  components: { ScriptEditor, ScriptTasksList },

  props: {
    // if the editor is currently open
    isOpen: Boolean,

    // The Id of the currently selected BPMN element.
    selectedElement: Object,

    // The Id of the currently open process.
    processDefinitionsId: String,
  },

  computed: {
    // Returns the configuration of the MS
    config() {
      return this.$store.getters['configStore/config'];
    },
    // Returns the id of the currently open element or null.
    openElementId() {
      return this.openElement ? this.openElement.id : null;
    },

    // Returns whether the current element is readonly.
    openElementReadonly() {
      return this.openElement ? this.openElement.readonly : true;
    },

    /**
     * Returns the very object which holds the value for the currently open element.
     * The value can be accessed and changed using the "data" property.
     */
    openElementValueObject() {
      if (!this.openElement) {
        return null;
      }

      // library objects don't have any child nodes to filter
      if (this.openElement.id.includes('LIBRARY')) {
        return this.openElement.xmlObject;
      }

      let scriptElement = null;

      // otherwise find data object
      if (this.openElement.type === 'scriptTask') {
        scriptElement = R.find(R.propEq('nodeName', 'script'))(
          this.openElement.xmlObject.childNodes
        );
        // if there is no script element, add one (including a cdata section) and return it
        if (!scriptElement) {
          scriptElement = this.processDiagram.createElement('script');
          this.openElement.xmlObject.appendChild(scriptElement);
        }
      }
      return scriptElement;
    },

    /**
     * Returns the script value of the selected element.
     */
    openElementValue() {
      if (this.openElementValueObject) {
        const separatorFilter = /\/\*{14} (SCRIPT|LIBRARY) (BEGINS|ENDS) \*{14}\//g;
        const blankLineFilter = /^\s*[\r\n]/gm;
        let script = this.openElementValueObject.textContent;
        return script.replace(blankLineFilter, '').trim();
      }
      return '';
    },

    /**
     * Returns the XML of the currently edited process.
     */
    xml() {
      return this.$store.getters['processEditorStore/xml'];
    },

    /**
     * Returns the object of the currently open process.
     */
    process() {
      return this.$store.getters['processStore/processById'](this.processDefinitionsId);
    },

    /**
     * Returns the traversable parser element of the currently edited process.
     */
    processDiagram() {
      return new DOMParser().parseFromString(this.xml, 'application/xml');
    },

    /**
     * Returns an iterable parser element of all script tasks or sequence flows that are coming from a XOR/OR Gateway of the currently edited process.
     */
    processElements() {
      return this.processDiagram.getElementsByTagName('scriptTask');
    },

    /**
     * Returns all processes APART from the currently open one (=> all other).
     */
    processes() {
      return R.reject(R.propEq('id', this.processDefinitionsId))(
        this.$store.getters['processStore/processes']
      );
    },

    /**
     * Returns the library for the currently open process.
     */
    processLibrary() {
      return this.$store.getters['processEditorStore/library'];
    },

    /**
     * Build a menu tree from both this processes' elements and all other processes' tasks,
     * which are not writeable.
     */
    menuTree() {
      if (!this.selectedElement) {
        return [];
      }

      return [
        {
          name: this.process.name,
          id: this.process.id,
          library: this.process.library || {
            data: '',
            capabilities: [],
          },
          readonly: false,
          elements: this.processElements,
        },
      ]
        .concat(this.otherElements)
        .map((item) => {
          // map all objects to useful menu items,
          // store original XML object under xmlObject
          const xmlElements = item.elements;
          const menuElements = [
            // {
            //   name: 'Library',
            //   id: `LIBRARY-${item.id}`,
            //   title: 'Library',
            //   readonly: item.readonly,
            //   xmlObject: item.library,
            // },
          ];

          let acceptedElements = 'scriptTask';

          for (let i = 0; i < xmlElements.length; i += 1) {
            const element = xmlElements[i];

            // only show elements from other processes of similar type as selected element
            if (element.tagName === acceptedElements) {
              const nameElement = R.find(R.propEq('nodeName', 'name'))(element.attributes);
              const idElement = R.find(R.propEq('nodeName', 'id'))(element.attributes);

              const name = nameElement ? nameElement.nodeValue : null;
              const id = idElement ? idElement.nodeValue : null;

              menuElements.push({
                name,
                id,
                processDefinitionsId: item.id,
                type: acceptedElements,
                title: name || id,
                readonly: this.selectedElement.id != id,
                xmlObject: element,
              });
            }
          }

          // return menu item object
          return {
            active: true, // open list group by default
            name: item.name,
            id: item.id,
            readonly: item.readonly,
            elements: menuElements,
          };
        });
    },
    menuFiltered() {
      const self = this;
      return this.menuTree.filter((process) => process.name.includes(self.elementFilter));
    },
  },

  data() {
    return {
      /**
       * Model for the filter field.
       */
      elementFilter: '',
      openElement: null,
      elementsFiltered: [],
      autoSaveTimeout: null,
      otherElements: [],
      timeout: null,
      scriptEventCallback: null,
    };
  },

  methods: {
    openScriptApi() {
      window.open('https://docs.proceed-labs.org/concepts/bpmn/bpmn-script-task/');
    },
    /**
     * close editor automatically 5 minutes after last change
     */
    autoClose() {
      if (!process.env.IS_ELECTRON) {
        this.timeout = setTimeout(() => {
          this.saveAndClose();
        }, this.config.closeOpenEditorsInMs || 300000);
      }
    },
    async saveChanges() {
      // emit to save changes in modeler
      this.$emit('updated', {
        elementId: this.openElementId,
        script: this.openElementValueObject.textContent,
      });
    },
    saveAndClose() {
      this.$emit('close');
    },
    /**
     * Opens an element.
     *
     * @param element
     * @returns void
     */
    open(element) {
      this.openElement = element;
    },

    /**
     * Updates the code value of the currently open element.
     *
     * @param value
     */
    updateOpenElementValue(element) {
      if (
        this.openElementValueObject &&
        !this.openElementReadonly &&
        this.openElementValueObject.textContent !== element.code
      ) {
        this.openElementValueObject.textContent = element.code;
        clearTimeout(this.timeout);
        this.autoClose();
        this.$store.dispatch('processEditorStore/setScriptOfElement', {
          script: element.code,
          elId: this.openElementId,
          elType: this.selectedElement.type,
          change: element.change,
        });
        this.saveChanges();
      }
    },
    /**
     * Gets all sequenceFlows and scriptTasks from other processes
     */
    async loadOtherElements() {
      const promises = this.processes.map(async (process) => {
        const bpmn = await this.$store.getters['processStore/xmlById'](process.id);

        const xmlDom = new DOMParser().parseFromString(bpmn, 'application/xml');

        const scriptTasks = Array.from(xmlDom.getElementsByTagName('scriptTask'));

        return {
          name: process.name,
          id: process.id,
          library: process.library || {
            data: '',
            capabilities: [],
          },
          readonly: true, // elements of other processes cannot be written in this IDE
          elements: scriptTasks,
        };
      });

      this.otherElements = await Promise.all(promises);
    },
  },
  watch: {
    /**
     * When the user selects a different element in the BPMN diagram,
     * if it is a script task, open its code in the editor.
     */
    isOpen(isOpen) {
      // all tasks available for open are in the
      // first slot of the menuTree, since that's
      // where the currently open process resides.
      if (isOpen) {
        if (this.selectedElement.type === 'bpmn:ScriptTask') {
          this.autoClose();
          const availableTasks = this.menuTree[0].elements;
          // find menu item by id
          const taskToSelect = R.find(R.propEq('id', this.selectedElement.id))(availableTasks);

          // if exists, open it
          if (taskToSelect) {
            this.openElement = taskToSelect;
          }
        }

        this.scriptEventCallback = ({ processDefinitionsId, elId, elType, script, change }) => {
          // update the code visible in the editor if the currently selected element is updated but not editable (editable ones are handled in a subcomponent)
          if (this.isOpen && elId === this.openElementId && this.openElementReadonly) {
            this.openElementValueObject.textContent = script;
            // trigger a reload of the modeler content
            const tmp = this.openElement;
            this.openElement = null;
            this.$nextTick(() => {
              this.openElement = tmp;
            });
          }
        };

        // add the callback to the eventHandler when the script editor is opened
        eventHandler.on('processScriptChanged', this.scriptEventCallback);
      } else {
        if (this.scriptEventCallback) {
          // remove callback from eventHandler when script editor is closed
          eventHandler.off('processScriptChanged', this.scriptEventCallback);
          this.scriptEventCallback = null;
        }
      }
    },
    async processes() {
      this.loadOtherElements();
    },
  },
  mounted() {
    this.loadOtherElements();
  },
};
</script>

<style>
.ide-container {
  height: 100%;
  overflow: hidden;
  background-color: #ffffff;
}

.v-list__tile--active {
  background-color: rgba(0, 0, 0, 0.04);
}
</style>