opf/openproject

View on GitHub
frontend/src/stimulus/controllers/dynamic/backlogs/model.js

Summary

Maintainability
F
3 days
Test Coverage
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2024 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
//
// See COPYRIGHT and LICENSE files for more details.
//++

/***************************************
  MODEL
  Common methods for sprint, work_package,
  story, task, and impediment
***************************************/

RB.Model = (function ($) {
  return RB.Object.create({

    initialize: function (el) {
      this.$ = $(el);
      this.el = el;
    },

    afterCreate: function (data, textStatus, xhr) {
      // Do nothing. Child objects may optionally override this
    },

    afterSave: function (data, textStatus, xhr) {
      var isNew, result;

      isNew = this.isNew();
      result = RB.Factory.initialize(RB.Model, data);

      this.unmarkSaving();
      this.refresh(result);

      if (isNew) {
        this.$.attr('id', result.$.attr('id'));
        this.afterCreate(data, textStatus, xhr);
      }
      else {
        this.afterUpdate(data, textStatus, xhr);
      }
    },

    afterUpdate: function (data, textStatus, xhr) {
      // Do nothing. Child objects may optionally override this
    },

    beforeSave: function () {
      // Do nothing. Child objects may or may not override this method
    },

    cancelEdit: function () {
      this.endEdit();
      if (this.isNew()) {
        this.$.hide('blind');
      }
    },

    close: function () {
      this.$.addClass('closed');
    },

    copyFromDialog: function () {
      var editors;

      if (this.$.find(".editors").length === 0) {
        editors = $("<div class='editors'></div>").appendTo(this.$);
      }
      else {
        editors = this.$.find(".editors").first();
      }
      editors.html("");
      editors.append($("#" + this.getType().toLowerCase() + "_editor").children(".editor"));
      this.saveEdits();
    },

    displayEditor: function (editor) {
      var self = this,
          baseClasses;

      baseClasses = 'ui-button ui-widget ui-state-default ui-corner-all';

      editor.dialog({
        buttons: [
        {
          text: 'OK',
          class: 'button -primary',
          click: function () {
            self.copyFromDialog();
            $(this).dialog("close");
          }
        },
        {
          text: 'Cancel',
          class: 'button',
          click: function () {
            self.cancelEdit();
            $(this).dialog("close");
          }
        },
        ],
        close: function (e, ui) {
          if (e.which === 1 || e.which === 27) {
            self.cancelEdit();
          }
        },
        dialogClass: this.getType().toLowerCase() + '_editor_dialog',
        modal:       true,
        position:    { my: 'center', at: 'center', of: window },
        resizable:   false,
        title:       (this.isNew() ? this.newDialogTitle() : this.editDialogTitle())
      });
      editor.find(".editor").first().focus();
      $('.button').removeClass(baseClasses);
      $('.ui-icon-closethick').prop('title', 'close');
    },

    edit: function () {
      var editor = this.getEditor(),
          self = this,
          maxTabIndex = 0;

      $('.stories .editors .editor').each(function (index) {
        var value;

        value = parseInt($(this).attr('tabindex'), 10);

        if (maxTabIndex < value) {
          maxTabIndex = value;
        }
      });

      if (!editor.hasClass('permanent')) {
        this.$.find('.editable').each(function (index) {
          const field = $(this);
          const fieldId = field.attr('field_id');
          const fieldName = field.attr('fieldname');
          const fieldLabel = field.attr('fieldlabel');
          const fieldOrder = parseInt(field.attr('fieldorder'), 10);
          const fieldEditable = field.attr('fieldeditable') || 'true';
          const fieldType = field.attr('fieldtype') || 'input';
          let typeId;
          let statusId;
          let input;

          if (fieldType === 'select') {
            // Special handling for status_id => they are dependent of type_id
            if (fieldName === 'status_id') {
              typeId = $.trim(self.$.find('.type_id .v').html());
              // when creating stories we need to query the select directly
              if (typeId === '') {
                typeId = $('#type_id_options').val();
              }
              statusId = $.trim(self.$.find('.status_id .v').html());
              input = self.findFactory(typeId, statusId, fieldName);
            } else if (fieldName === 'type_id') {
              input = $('#' + fieldName + '_options').clone(true);
              // if the type changes the status dropdown has to be modified
              input.change(function () {
                typeId = $(this).val();
                statusId = $.trim(self.$.find('.status_id .v').html());
                let newInput = self.findFactory(typeId, statusId, 'status_id');
                newInput = self.prepareInputFromFactory(newInput, fieldId, 'status_id', fieldOrder, maxTabIndex);
                newInput = self.replaceStatusForNewType(input, newInput, $(this).parent().find('.status_id').val(), editor);
              });
            } else {
              input = $('#' + fieldName + '_options').clone(true);
            }
          } else {
            input = $(document.createElement(fieldType));
          }

          input = self.prepareInputFromFactory(input, fieldId, fieldName, fieldOrder, maxTabIndex, fieldEditable);

          // Copy the value in the field to the input element
          input.val(fieldType === 'select' ? field.children('.v').first().text() : field.text());

          // Record in the model's root element which input field had the last focus. We will
          // use this information inside RB.Model.refresh() to determine where to return the
          // focus after the element has been refreshed with info from the server.
          input.focus(function () {
            self.$.data('focus', $(this).attr('name'));
          });

          input.blur(function () {
            self.$.data('focus', '');
          });

          $("<label />").attr({
            for: input.attr('id'),
          }).text(fieldLabel).appendTo(editor);
          input.appendTo(editor);
        });
      }

      this.displayEditor(editor);
      this.editorDisplayed(editor);
      return editor;
    },

    findFactory: function (typeId, statusId, fieldName){
      // Find a factory
      let newInput = $('#' + fieldName + '_options_' + typeId + '_' + statusId);
      if (newInput.length === 0) {
        // when no list found, only offer the default status
        // no list = combination is not valid / user has no rights -> workflow
        newInput = $('#status_id_options_default_' + statusId);
      }
      newInput = newInput.clone(true);
      return newInput;
    },

    prepareInputFromFactory: function (input, fieldId, fieldName, fieldOrder, maxTabIndex, fieldEditable) {
      input.attr('id', fieldName + '_' + fieldId);
      input.attr('name', fieldName);
      input.attr('tabindex', fieldOrder + maxTabIndex);
      if (fieldEditable !== 'true') {
        input.attr('disabled', true);
      }
      input.addClass(fieldName);
      input.addClass('editor');
      input.removeClass('template');
      input.removeClass('helper');
      return input;
    },

    replaceStatusForNewType: function (input,newInput, statusId, editor) {
      // Append an empty field and select it in case the old status is not available
      newInput.val(statusId); // try to set the status
      if (newInput.val() !== statusId){
          newInput.append(new Option('',''));
          newInput.val('');
      }
      newInput.focus(function () {
        self.$.data('focus', $(this).attr('name'));
      });

      newInput.blur(function () {
        self.$.data('focus', '');
      });
      // Find the old status dropdown and replace it with the new one
      input.parent().find('.status_id').replaceWith(newInput);
    },

    // Override this method to change the dialog title
    editDialogTitle: function () {
      return "Edit " + this.getType();
    },

    editorDisplayed: function (editor) {
      // Do nothing. Child objects may override this.
    },

    endEdit: function () {
      this.$.removeClass('editing');
    },

    error: function (xhr, textStatus, error) {
      this.markError();
      RB.Dialog.msg($(xhr.responseText).find('.errors').html());
      this.processError(xhr, textStatus, error);
    },

    getEditor: function () {
      var editorId, editor;
      // Create the model editor if it does not yet exist
      editorId = this.getType().toLowerCase() + "_editor";

      editor = $("#" + editorId).html("");

      if (editor.length === 0) {
        editor = $("<div id='" + editorId + "'></div>").appendTo("body");
      }
      return editor;
    },

    getID: function () {
      return this.$.children('.id').children('.v').text();
    },

    getType: function () {
      throw new Error("Child objects must override getType()");
    },

    handleClick: function (e) {
      const field = $(this);
      const model = field.parents('.model').first().data('this');
      const j = model.$;

      if (!j.hasClass('editing')
          && !j.hasClass('dragging')
          && !j.hasClass('prevent_edit')
          && !$(e.target).hasClass('prevent_edit')
          && e.target.closest('.editable').getAttribute('fieldeditable') !== 'false' ) {
        const editor = model.edit();
        var input = editor.find('.' + $(e.currentTarget).attr('fieldname') + '.editor');

        input.focus();
        input.click();
      }
    },

    handleSelect: function (e) {
      var j = $(this),
          self = j.data('this');

      if (!$(e.target).hasClass('editable') &&
          !$(e.target).hasClass('checkbox') &&
          !j.hasClass('editing') &&
          e.target.tagName !== 'A' &&
          !j.hasClass('dragging')) {

        self.setSelection(!self.isSelected());
      }
    },

    isClosed: function () {
      return this.$.hasClass('closed');
    },

    isNew: function () {
      return this.getID() === "";
    },

    markError: function () {
      this.$.addClass('error icon icon-bug');
    },

    markIfClosed: function () {
      throw new Error("Child objects must override markIfClosed()");
    },

    markSaving: function () {
      this.$.addClass('ajax-indicator');
    },

    // Override this method to change the dialog title
    newDialogTitle: function () {
      return "New " + this.getType();
    },

    open: function () {
      this.$.removeClass('closed');
    },

    processError: function (x, t, e) {
      // Override as needed
    },

    refresh: function (obj) {
      this.$.html(obj.$.html());

      if (obj.$.length > 1) {
        // execute script tags, that were attached to the sources
        obj.$.filter('script').each(function () {
          try {
            $.globalEval($(this).html());
          }
          catch (e) {
          }
        });
      }

      if (obj.isClosed()) {
        this.close();
      } else {
        this.open();
      }

      window.OpenProject.getPluginContext().then((pluginContext) => {
        pluginContext.bootstrap(this.$[0]);
      });

      this.refreshed();
    },

    refreshed: function () {
      // Override as needed
    },

    saveDirectives: function () {
      throw new Error("Child object must implement saveDirectives()");
    },

    saveEdits: function () {
      var j = this.$,
          self = this,
          editors = j.find('.editor'),
          saveDir;

      // Copy the values from the fields to the proper html elements
      editors.each(function (index) {
        const editor = $(this).find('input,select,textarea').addBack('input,select,textarea');
        const fieldName = editor.attr('name');
        const type = editor.attr('type');
        if (type && type.match(/select/)) {
          // if the user changes the type and that type does not offer the status
          // of the current story, the status field is set to blank
          // if the user saves this edit we will receive a validation error
          // the following 3 lines will prevent the override of the status id
          // otherwise we would loose the status id of the current ticket
          if (!(editor.val() === '' && fieldName === 'status_id')){
            j.children('div.' + fieldName).children('.v').text(editor.val());
          }

          j.children('div.' + fieldName).children('.t').text(editor.children(':selected').text());

        } else {
          j.children('div.' + fieldName).text(editor.val());
        }
      });

      // Mark the work_package as closed if so
      self.markIfClosed();

      // Get the save directives.
      saveDir = self.saveDirectives();

      self.beforeSave();

      self.unmarkError();
      self.markSaving();
      RB.ajax({
        type: "POST",
        url: saveDir.url,
        data: saveDir.data,
        success   : function (d, t, x) {
          self.afterSave(d, t, x);
        },
        error     : function (x, t, e) {
          self.error(x, t, e);
        }
      });
      self.endEdit();
    },

    unmarkError: function () {
      this.$.removeClass('error icon icon-bug');
    },

    unmarkSaving: function () {
      this.$.removeClass('ajax-indicator');
    }
  });
}(jQuery));