Codeminer42/cm42-central

View on GitHub
app/assets/javascripts/views/story_view.jsx

Summary

Maintainability
F
1 wk
Test Coverage
import React from 'react';
import ReactDOM from 'react-dom';
import StoryControls from 'components/story/StoryControls';
import StoryDescription from 'components/story/StoryDescription';
import StoryHistoryLocation from 'components/story/StoryHistoryLocation';
import StorySelect from 'components/story/StorySelect';
import StoryDatePicker from 'components/story/StoryDatePicker';
import StoryNotes from 'components/story/StoryNotes';
import NoteForm from 'components/notes/NoteForm';
import StoryLabels from 'components/story/StoryLabels';
import StoryTasks from 'components/story/StoryTasks';
import TaskForm from 'components/tasks/TaskForm';
import StoryStateButtons from 'components/story/StoryStateButtons';
import StoryEstimateButtons from 'components/story/StoryEstimateButtons';
import Clipboard from 'clipboard';
import FormView from './form_view';
import EpicView from './epic_view';
import storyTemplate from 'templates/story.ejs';
import alertTemplate from 'templates/alert.ejs';
import storyHoverTemplate from 'templates/story_hover.ejs';
import noteTemplate from 'templates/note.ejs';
import StoryCopyIdClipboard from '../components/story/StoryCopyIdClipboard';

const LOCAL_STORY_REGEXP = /(?!\s|\b)(#\d+)(?!\w)/g;

const StoryView = FormView.extend({
  template: storyTemplate,
  alert: alertTemplate,

  tagName: 'div',
  linkedStories: {},

  initialize: function (options) {
    _.extend(this, _.pick(options, 'isSearchResult'));

    _.bindAll(
      this,
      'render',
      'highlight',
      'moveColumn',
      'setClassName',
      'transition',
      'estimate',
      'disableForm',
      'renderNotes',
      'hoverBox',
      'renderTasks',
      'handleNoteDelete',
      'handleSaveError',
      'addEmptyTask',
      'handleNoteSubmit',
      'renderTaskForm',
      'handleTaskSubmit',
      'clickSave',
      'handleTaskUpdate',
      'handleTaskSaveSuccess',
      'handleTaskDelete',
      'toggleControlButtons'
    );

    // Rerender on any relevant change to the views story
    this.model.on('change', this.render);

    this.model.on('change:title', this.highlight);
    this.model.on('change:description', this.highlight);
    this.model.on('change:column', this.highlight);
    this.model.on('change:state', this.highlight);
    this.model.on('change:position', this.highlight);
    this.model.on('change:estimate', this.highlight);
    this.model.on('change:story_type', this.highlight);

    this.model.on('change:column', this.moveColumn);

    this.model.on('change:estimate', this.setClassName);
    this.model.on('change:state', this.setClassName);

    this.model.on('change:tasks', this.addEmptyTask);
    this.model.on('change:tasks', this.renderTasksCollection);

    this.model.on('render', this.hoverBox);
    // Supply the model with a reference to it's own view object, so it can
    // remove itself from the page when destroy() gets called.
    this.model.views.push(this);

    if (this.model.id) {
      this.id = this.el.id =
        (this.isSearchResult ? 'search-result-' : '') + this.model.id;
      this.$el.attr('id', 'story-' + this.id);
      this.$el.data('story-id', this.id);
    }

    // Set up CSS classes for the view
    this.setClassName();
  },

  isReadonly: function () {
    return this.model.isReadonly;
  },

  events: {
    click: 'startEdit',
    'click .epic-link': 'openEpic',
    'click .cancel': 'cancelEdit',
    'click .clone-story': 'cloneStory',
    'click .transition': 'transition',
    'change select.story_type': 'render',
    'click .destroy': 'clear',
    'click .description': 'editDescription',
    'click .edit-description': 'editDescription',
    'click .toggle-history': 'showHistory',
    sortupdate: 'sortUpdate',
    'click #locate': 'highlightSearchedStory',
  },

  // Triggered whenever a story is dropped to a new position
  sortUpdate: function (ev, ui) {
    // The target element, i.e. the StoryView.el element
    var target = $(ev.target);

    // Initially, try and get the id's of the previous and / or next stories
    // by just searching up above and below in the DOM of the column position
    // the story was dropped on.  The case where the column is empty is
    // handled below.
    var previous_story_id = target.prev('.story').data('story-id');
    var next_story_id = target.next('.story').data('story-id');

    // Set the story state if drop column is chilly_bin or backlog
    var column = target.parent().attr('id');
    if (
      column === 'backlog' ||
      (column === 'in_progress' && this.model.get('state') === 'unscheduled')
    ) {
      this.model.set({ state: 'unstarted' });
    } else if (column === 'chilly_bin') {
      this.model.set({ state: 'unscheduled' });
      [previous_story_id, next_story_id] = [next_story_id, previous_story_id];
    }

    // If both of these are unset, the story has been dropped on an empty
    // column, which will be either the backlog or the chilly bin as these
    // are the only columns that can receive drops from other columns.
    if (_.isUndefined(previous_story_id) && _.isUndefined(next_story_id)) {
      var beforeSearchColumns = this.model.collection.project.columnsBefore(
        '#' + column
      );
      var afterSearchColumns = this.model.collection.project.columnsAfter(
        '#' + column
      );

      var previousStory = _.last(
        this.model.collection.columns(beforeSearchColumns)
      );
      var nextStory = _.first(
        this.model.collection.columns(afterSearchColumns)
      );

      if (typeof previousStory !== 'undefined') {
        previous_story_id = previousStory.id;
      }
      if (typeof nextStory !== 'undefined') {
        next_story_id = nextStory.id;
      }
    }

    if (!_.isUndefined(previous_story_id)) {
      this.model.moveAfter(previous_story_id);
    } else if (!_.isUndefined(next_story_id)) {
      this.model.moveBefore(next_story_id);
    } else {
      // The only possible scenario that we should reach this point under
      // is if there is only one story in the collection, so there is no
      // previous or next story.  If this is not the case then something
      // has gone wrong.
      if (this.model.collection.length !== 1) {
        throw 'Unable to determine previous or next story id for dropped story';
      }
    }
    this.model.save();
  },

  transition: function (ev) {
    // The name of the function that needs to be called on the model is the
    // value of the form button that was clicked.
    var transitionEvent = ev.target.value;
    _.each(I18n.t('story.events'), function (value, key) {
      if (value === transitionEvent) transitionEvent = key;
    });

    if (transitionEvent === 'accept' || transitionEvent === 'reject') {
      var confirmed = confirm(
        I18n.t('story.definitive_sure', { action: transitionEvent })
      );

      if (!confirmed) return;
    }

    this.saveInProgress = true;
    this.render();

    this.model[transitionEvent]({ silent: true });

    var that = this;
    return this.model.save(null, {
      success: function (model, response) {
        that.saveInProgress = false;
        that.render();
      },
      error: function (model, response) {
        var json = $.parseJSON(response.responseText);
        window.projectView.notice({
          title: I18n.t('save error'),
          text: model.errorMessages(),
        });
        that.saveInProgress = false;
        that.render();
      },
    });
  },

  estimate: function (points) {
    this.saveInProgress = true;
    this.render();
    this.model.set({ estimate: points });

    var that = this;
    return this.model.save(null, {
      success: function (model, response) {
        that.saveInProgress = false;
        that.render();
      },
      error: function (model, response) {
        var json = $.parseJSON(response.responseText);
        window.projectView.notice({
          title: I18n.t('save error'),
          text: model.errorMessages(),
        });
        that.saveInProgress = false;
        that.render();
      },
    });
  },

  canEdit: function () {
    var isEditable = this.model.get('editing');
    var isSearchResultContainer = this.$el.hasClass('searchResult');
    var clickFromSearchResult = this.model.get('clickFromSearchResult');
    if (_.isUndefined(isEditable)) isEditable = false;
    if (_.isUndefined(clickFromSearchResult)) clickFromSearchResult = false;
    if (clickFromSearchResult && isSearchResultContainer) {
      return isEditable;
    } else if (!clickFromSearchResult && !isSearchResultContainer) {
      return isEditable;
    } else {
      return false;
    }
  },

  // Move the story to a new column
  moveColumn: function () {
    this.$el.appendTo(this.model.get('column'));
  },

  startEdit: function (e) {
    if (this.eventShouldExpandStory(e)) {
      this.model.set({
        editing: true,
        editingDescription: false,
        clickFromSearchResult: this.$el.hasClass('searchResult'),
      });
      this.removeHoverbox();
    }
  },

  openEpic: function (e) {
    e.stopPropagation();
    var label = $(e.target).text();
    new EpicView({ model: this.model.collection.project, label: label });
  },

  // When a story is clicked, this method is used to check whether the
  // corresponding click event should expand the story into its form view.
  eventShouldExpandStory: function (e) {
    // Shouldn't expand if it's already expanded.
    if (this.canEdit()) {
      return false;
    }
    // Should expand if the click wasn't on one of the buttons.
    if ($(e.target).is('input')) return false;
    if ($(e.target).is('.input')) return false;
    if ($(e.target).is('button')) return false;
    if ($(e.target).parent().is('button')) return false;
    return true;
  },

  cancelEdit: function () {
    this.model.set({ editing: false });

    // If the model was edited, but the edits were deemed invalid by the
    // server, the local copy of the model will still be invalid and have
    // errors set on it after cancel.  So, reload it from the server, which
    // will return the attributes to their true state.
    if (this.model.hasErrors()) {
      this.model.unset('errors');
      this.model.fetch();
    }

    // If this is a new story and cancel is clicked, the story and view
    // should be removed.
    if (this.model.isNew()) {
      this.model.clear();
    }
  },

  saveEdit: function (event, editMode) {
    this.disableForm();

    // Call this here to ensure the story gets it's accepted_at date set
    // before the story collection callbacks are run if required.  The
    // collection callbacks need this to be set to know which iteration to
    // put an accepted story in.
    this.model.setAcceptedAt();

    var that = this;

    return this.model.save(null, {
      success: function (model, response) {
        that.enableForm();
        that.model.set({ editing: editMode });
        that.toggleControlButtons(false);
        that.highlightStory();
      },
      error: function (model, response) {
        var json = $.parseJSON(response.responseText);
        model.set({ editing: true, errors: json.story.errors });

        window.projectView.notice({
          title: I18n.t('save error'),
          text: model.errorMessages(),
        });

        that.enableForm();
      },
    });
  },

  // Delete the story and remove it's view element
  clear: function () {
    if (confirm('Are you sure you want to destroy this story?'))
      this.model.clear();
  },

  editDescription: function (ev) {
    const $target = $(ev.target);
    if ($target.hasClass('story-link') || $target.hasClass('story-link-icon'))
      return;

    this.model.set({ editingDescription: true });
    this.render();
  },

  // Visually highlight the story if an external change happens
  highlight: function () {
    if (!this.model.get('editing')) {
      // Workaround for http://bugs.jqueryui.com/ticket/5506
      if (this.$el.is(':visible')) {
        this.$el.effect('highlight', {}, 3000);
      }
    }
  },

  cloneStory: function () {
    this.cancelEdit();
    const clonedStory = this.model.clone();
    this.resetAttributes(clonedStory);

    if (clonedStory.isNew()) {
      this.model.collection.add(clonedStory);
      clonedStory.save(null, {
        success: (model, response) => {
          _.last(clonedStory.views).highlight();
        },
        error: (model, response) => {
          window.projectView.notice({
            title: I18n.t('save error'),
            text: model.errorMessages(),
          });
        },
      });
    }
  },

  resetAttributes: function (story) {
    story.set({
      id: null,
      created_at: null,
      updated_at: null,
      position: null,
      state: 'unscheduled',
    });
    return story;
  },

  highlightSearchedStory: function () {
    this.scrollToStory();
    this.highlightStory();
  },

  scrollToStory: function () {
    const storyElement = this.storyElement();
    $('.content-wrapper').animate(
      {
        scrollTop: storyElement.offset().top - 15,
      },
      'fast'
    );
  },

  highlightStory: function () {
    const element = this.storyElement();
    element.effect('highlight', { color: 'lightgreen' }, 1500);
  },

  storyElement: function () {
    return $(`#story-${this.model.get('id')}`);
  },

  render: function () {
    const storyControlsContainer = this.$('[data-story-controls]').get(0);
    if (storyControlsContainer) {
      ReactDOM.unmountComponentAtNode(storyControlsContainer);
    }

    var isGuest =
      this.model.collection !== undefined &&
      this.model.collection.project.current_user !== undefined &&
      this.model.collection.project.current_user.get('guest?');

    if (this.canEdit()) {
      this.renderExpanded(isGuest);
    } else {
      this.renderCollapsed(isGuest);
    }

    this.hoverBox();
    this.handleBackLoggedRelease();
    return this;
  },

  renderExpanded: function (isGuest) {
    this.$el.empty();
    this.$el.addClass('editing');

    const $storyControls = $('<div data-story-controls></div>');
    this.$el.append($storyControls);
    this.appendHistoryLocation();
    this.appendTitle();
    this.appendEstimateTypeState();
    this.appendRequestedAndOwnedBy();
    this.appendTags();
    this.appendDescription();
    this.$el.append($('<div data-story-tasks></div>'));
    this.$el.append($('<div data-story-task-form></div>'));
    this.$el.append($('<div data-story-notes></div>'));
    this.$el.append($('<div data-story-note-form></div>'));
    if (this.model.get('story_type') === 'release') {
      this.$el.empty();
      this.$el.append($storyControls);
      this.renderReleaseStory();
    }
    this.renderReactComponents();

    if (isGuest) {
      this.toggleControlButtons(true, false);
    }
  },

  appendHistoryLocation: function () {
    if (this.id !== undefined) {
      const $storyHistoryLocation = $(
        '<div data-story-history-location></div>'
      );
      this.$el.append($storyHistoryLocation);
    }
  },

  appendTitle: function () {
    this.$el.append(this.makeFormControl(this.makeTitle()));
  },

  appendEstimateTypeState: function () {
    this.$el.append(
      this.makeFormControl(function (div) {
        $(div).addClass('form-inline');

        const $storyEstimate = $(
          '<div class="form-group" data-story-estimate></div>'
        );
        $(div).append($storyEstimate);

        const $storyType = $('<div class="form-group" data-story-type></div>');
        $(div).append($storyType);

        const $storyState = $(
          '<div class="form-group" data-story-state></div>'
        );
        $(div).append($storyState);
      })
    );
  },

  appendRequestedAndOwnedBy: function () {
    this.$el.append(
      this.makeFormControl(function (div) {
        $(div).addClass('form-inline');

        const $storyRequestedBy = $(
          '<div class="form-group" data-requested-by></div>'
        );
        $(div).append($storyRequestedBy);

        const $storyOwnedBy = $('<div class="form-group" data-owned-by></div>');
        $(div).append($storyOwnedBy);
      })
    );
  },

  appendTags: function () {
    this.$el.append(
      this.makeFormControl(function (div) {
        const $storyTags = $('<div class="form-group" data-tags></div>');
        $(div).append($storyTags);
      })
    );
  },

  appendDescription: function () {
    this.$el.append(
      this.makeFormControl(function (div) {
        var $storyDescription = $('<div class="story-description"><div>');
        $(div).append($storyDescription);
      })
    );
  },

  appendAttachments: function () {
    this.$el.append(
      this.makeFormControl(function (div) {
        const $storyAttachments = $('<div class="story-attachments"></div>');
        $(div).append($storyAttachments);

        if (process.env.NODE_ENV !== 'test') {
          clearTimeout(window.executeAttachinaryTimeout);
          window.executeAttachinaryTimeout = setTimeout(
            ExecuteAttachinary,
            1000
          );
        }
      })
    );
  },

  renderCollapsed: function (isGuest) {
    this.$el.removeClass('editing');
    this.$el.html(this.template({ story: this.model, view: this }));
    this.$el.toggleClass(
      'collapsed-iteration',
      !this.model.get('isVisible') && !this.isSearchResult
    );

    const stateButtons = this.$('[data-story-state-buttons]').get(0);
    if (stateButtons) {
      ReactDOM.render(
        <StoryStateButtons events={this.model.events()} />,
        stateButtons
      );
    }

    const estimateButtons = this.$('[data-story-estimate-buttons]').get(0);
    if (estimateButtons) {
      ReactDOM.render(
        <StoryEstimateButtons
          points={this.model.point_values()}
          onClick={this.estimate}
        />,
        estimateButtons
      );
    }

    const copyStoryIdClipboardLink = this.$(
      '[data-story-id-copy-clipboard]'
    ).get(0);
    if (copyStoryIdClipboardLink) {
      ReactDOM.render(
        <StoryCopyIdClipboard id={this.id} />,
        copyStoryIdClipboardLink
      );
    }

    if (isGuest) {
      this.$el
        .find('.state-actions')
        .find('.transition')
        .prop('disabled', true);
    }
  },

  renderReactComponents: function () {
    this.renderControls();
    this.renderHistoryLocationContainer();
    this.renderDescription();
    this.renderTagsInput();
    this.renderSelects();
    this.renderTasks();
    this.renderNotes();
  },

  renderControls: function () {
    ReactDOM.render(
      <StoryControls
        onClickSave={this.clickSave}
        onClickCancel={this.cancelEdit}
        disableChanges={this.disabledChanges()}
      />,
      this.$('[data-story-controls]').get(0)
    );
  },

  renderHistoryLocationContainer: function () {
    const historyLocationContainer = this.$(
      '[data-story-history-location]'
    ).get(0);
    if (historyLocationContainer) {
      ReactDOM.render(
        <StoryHistoryLocation
          id={this.id}
          url={`${this.getLocation()}#story-${this.id}`}
        />,
        historyLocationContainer
      );
      new Clipboard('.btn-clipboard');
    }
  },

  renderDescription: function () {
    const description = this.$('.story-description')[0];
    if (description) {
      ReactDOM.render(
        <StoryDescription
          name="description"
          linkedStories={this.linkedStories}
          isReadonly={this.isReadonly()}
          description={this.parseDescription()}
          usernames={window.projectView.usernames()}
          isNew={this.model.isNew()}
          editingDescription={this.model.get('editingDescription')}
          value={this.model.get('description')}
          fileuploadprogressall={this.uploadProgressBar}
          onChange={event =>
            this.onChangeModel(event.target.value, 'description')
          }
          onClick={this.editDescription}
        />,
        description
      );
    }
  },

  renderTagsInput: function () {
    const tagsInput = this.$('[data-tags]')[0];
    if (tagsInput) {
      ReactDOM.render(
        <StoryLabels
          name="labels"
          className="labels"
          value={this.model.get('labels')}
          availableLabels={this.model.collection.labels}
          onChange={event => this.onChangeModel(event.target.value, 'labels')}
          disabled={this.isReadonly()}
        />,
        tagsInput
      );
    }
  },

  renderSelects: function () {
    const $storyEstimateSelect = this.$('[data-story-estimate]');
    if ($storyEstimateSelect.length) {
      const storyEstimateOptions = this.model
        .point_values()
        .map(this.createStoryEstimateOptions);
      ReactDOM.render(
        <StorySelect
          name="estimate"
          className="story_estimate"
          blank={I18n.t('story.no_estimate')}
          options={storyEstimateOptions}
          selected={this.model.get('estimate')}
          disabled={this.model.notEstimable() || this.isReadonly()}
        />,
        $storyEstimateSelect.get(0)
      );

      this.bindElementToAttribute(
        $storyEstimateSelect.find('select[name="estimate"]'),
        'estimate'
      );
    }

    const $storyTypeSelect = this.$('[data-story-type]');
    if ($storyTypeSelect.length) {
      const typeOptions = ['feature', 'chore', 'bug', 'release'];
      const storyTypeOptions = typeOptions.map(this.createStoryTypeOptions);
      ReactDOM.render(
        <StorySelect
          className="story_type"
          options={storyTypeOptions}
          name="story_type"
          selected={this.model.get('story_type')}
          disabled={this.isReadonly()}
        />,
        $storyTypeSelect.get(0)
      );

      this.bindElementToAttribute(
        $storyTypeSelect.find('select[name="story_type"]'),
        'story_type'
      );
    }

    const $storyStateSelect = this.$('[data-story-state]');
    if ($storyStateSelect.length) {
      const stateOptions = [
        'unscheduled',
        'unstarted',
        'started',
        'finished',
        'delivered',
        'accepted',
        'rejected',
      ];
      const storyStateOptions = stateOptions.map(this.createStoryStateOptions);
      ReactDOM.render(
        <StorySelect
          name="state"
          className="story_state"
          options={storyStateOptions}
          selected={this.model.get('state')}
          disabled={this.isReadonly()}
        />,
        $storyStateSelect.get(0)
      );

      this.bindElementToAttribute(
        $storyStateSelect.find('select[name="state"]'),
        'state'
      );
    }

    const $storyRequestedBySelect = this.$('[data-requested-by]');
    if ($storyRequestedBySelect.length) {
      const storyRequestedByOptions =
        this.model.collection.project.users.forSelect();
      ReactDOM.render(
        <StorySelect
          name="requested_by"
          blank="---"
          className="requested_by_id"
          options={storyRequestedByOptions}
          selected={this.model.get('requested_by_id')}
          disabled={this.isReadonly()}
        />,
        $storyRequestedBySelect.get(0)
      );

      this.bindElementToAttribute(
        $storyRequestedBySelect.find('select[name="requested_by"]'),
        'requested_by_id'
      );
    }

    const $storyOwnedBySelect = this.$('[data-owned-by]');
    if ($storyOwnedBySelect.length) {
      const storyOwnedByOptions =
        this.model.collection.project.users.forSelect();
      ReactDOM.render(
        <StorySelect
          name="owned_by"
          className="owned_by_id"
          options={storyOwnedByOptions}
          blank="---"
          selected={this.model.get('owned_by_id')}
          disabled={this.isReadonly()}
        />,
        $storyOwnedBySelect.get(0)
      );

      this.bindElementToAttribute(
        $storyOwnedBySelect.find('select[name="owned_by"]'),
        'owned_by_id'
      );
    }
  },

  renderNotes: function () {
    const $storyNotes = this.$('[data-story-notes]');
    if ($storyNotes.length && !this.model.isNew()) {
      const isReadonly = this.isReadonly();
      const notes = this.model.notes;

      ReactDOM.render(
        <StoryNotes
          notes={isReadonly ? notes : notes.slice(0, -1)}
          disabled={isReadonly}
          onDelete={this.handleNoteDelete}
        />,
        $storyNotes.get(0)
      );

      if (!isReadonly) {
        this.renderNoteForm();
      }
    }
  },

  renderNoteForm: function () {
    const $noteForm = this.$('[data-story-note-form]');
    if ($noteForm.length) {
      this.addEmptyNote();

      ReactDOM.render(
        <NoteForm
          note={this.model.notes.last()}
          onSubmit={this.handleNoteSubmit}
        />,
        $noteForm.get(0)
      );
      const addNoteButton = $noteForm.find('button');
      const noteTextArea = $noteForm.find('textarea');

      addNoteButton.attr('disabled', 'disabled');

      noteTextArea.atwho({
        at: '@',
        data: window.projectView.usernames(),
      });

      noteTextArea.keyup(function () {
        if ($.trim(noteTextArea.val())) {
          addNoteButton.removeAttr('disabled');
        } else {
          addNoteButton.attr('disabled', 'disabled');
        }
      });
    }
  },

  handleNoteDelete: function (note) {
    note.destroy();
    this.renderNotes();
  },

  handleNoteSubmit: function ({ note, newValue }) {
    note.set({ note: newValue });
    return note.save(null, {
      success: () => window.projectView.model.fetch(),
      error: this.handleSaveError,
    });
  },

  renderTasks: function () {
    const $storyTasks = this.$('[data-story-tasks]');
    if ($storyTasks.length && !this.model.isNew()) {
      const isReadonly = this.isReadonly();
      const tasks = this.model.tasks;

      ReactDOM.render(
        <StoryTasks
          tasks={isReadonly ? tasks : tasks.slice(0, -1)}
          disabled={isReadonly}
          handleUpdate={this.handleTaskUpdate}
          handleDelete={this.handleTaskDelete}
        />,
        $storyTasks.get(0)
      );

      if (!isReadonly) {
        this.renderTaskForm();
      }
    }
  },

  renderTaskForm: function () {
    const $taskForm = this.$('[data-story-task-form]');
    if ($taskForm.length) {
      this.addEmptyTask();

      ReactDOM.render(
        <TaskForm
          onSubmit={this.handleTaskSubmit}
          task={this.model.tasks.last()}
        />,
        $taskForm.get(0)
      );
      const addTaskButton = $taskForm.find('button');
      const taskTextArea = $taskForm.find('input');

      addTaskButton.attr('disabled', 'disabled');

      taskTextArea.keyup(function () {
        if ($.trim(taskTextArea.val())) {
          addTaskButton.removeAttr('disabled');
        } else {
          addTaskButton.attr('disabled', 'disabled');
        }
      });
    }
  },

  handleTaskSubmit: function ({ task, taskName }) {
    task.set('name', taskName);
    return task.save(null, {
      dataType: 'text',
      success: this.handleTaskSaveSuccess,
      error: this.handleSaveError,
    });
  },

  handleTaskDelete: function (task) {
    task.destroy();
    this.renderTasks();
  },

  handleTaskSaveSuccess: function () {
    window.projectView.model.fetch();
    this.renderTasks();
  },

  handleTaskUpdate: function ({ task, done }) {
    task.set('done', done);
    task.save(null, {
      dataType: 'text',
      success: this.handleTaskSaveSuccess,
      error: this.handleSaveError,
    });
  },

  handleSaveError: function (model, response) {
    const json = JSON.parse(response.responseText);
    model.set({ errors: json[model.name].errors });
    window.projectView.noticeSaveError(model);
  },

  createStoryEstimateOptions: function (option) {
    return [option, option];
  },

  createStoryTypeOptions: function (option) {
    return [I18n.t('story.type.' + option), option];
  },

  createStoryStateOptions: function (option) {
    return [I18n.t('story.state.' + option), option];
  },

  makeTitle: function () {
    return function (div) {
      $(div).append(
        this.label('title', I18n.t('activerecord.attributes.story.title'))
      );
      $(div).append(
        this.textField('title', {
          class: 'title form-control input-sm',
          placeholder: I18n.t('story title'),
          maxlength: 255,
          disabled: this.isReadonly(),
        })
      );
    };
  },

  renderReleaseStory: function () {
    this.$el.append(this.makeFormControl(this.makeTitle()));

    if (this.model.get('editing')) {
      this.$el.append(
        this.makeFormControl(function (div) {
          const $storyType = $(
            '<div class="form-group" data-story-type></div>'
          );
          $(div).append($storyType);
        })
      );
    }

    const $storyDate = $(
      '<div class="form-group" data-story-datepicker></div>'
    );
    this.$el.append($storyDate);

    ReactDOM.render(
      <StoryDatePicker
        releaseDate={this.model.get('release_date')}
        onChangeCallback={function () {
          $('input[name=release_date]').trigger('change');
        }}
      />,
      $storyDate.get(0)
    );

    const dateInput = this.$('input[name=release_date]');
    this.bindElementToAttribute(dateInput, 'release_date');

    this.$el.append(
      this.makeFormControl(function (div) {
        var $description = $('<div class="story-description"><div>');
        $(div).append($description);
      })
    );
  },

  parseDescription: function () {
    const description = this.model.get('description') || '';
    var id, story;
    return description.replace(LOCAL_STORY_REGEXP, story_id => {
      id = story_id.substring(1);
      story = this.model.collection.get(id);
      this.linkedStories[id] = story;
      return story ? `<p data-story-id='${id}'></p>` : story_id;
    });
  },

  setClassName: function () {
    var className = [
      'story',
      this.model.get('story_type'),
      this.model.get('state'),
    ].join(' ');
    if (this.model.estimable() && !this.model.estimated()) {
      className += ' unestimated';
    }
    if (this.isSearchResult) {
      className += ' searchResult';
    }
    this.className = this.el.className = className;
    return this;
  },

  saveInProgress: false,

  disableForm: function () {
    this.$el.find('input,select,textarea').attr('disabled', 'disabled');
    this.$el
      .find('a.collapse,a.expand')
      .removeClass(/icons-/)
      .addClass('icons-throbber');
  },

  enableForm: function () {
    this.$el.find('input,select,textarea').removeAttr('disabled');
    this.$el
      .find('a.collapse')
      .removeClass(/icons-/)
      .addClass('icons-collapse');
  },

  onChangeModel: function (value, element) {
    this.model.set({ [element]: value }, { silent: true });
  },

  addEmptyTask: function () {
    if (this.model.isNew()) {
      return;
    }

    var task = this.model.tasks.last();
    if (task && task.isNew()) {
      return;
    }

    this.model.tasks.add({});
  },

  addEmptyNote: function () {
    // Don't add an empty note if the story is unsaved.
    if (this.model.isNew()) {
      return;
    }

    // Don't add an empty note if the notes collection already has a trailing
    // new Note.
    var last = this.model.notes.last();
    if (last && last.isNew()) {
      return;
    }

    // Add a new unsaved note to the collection.  This will be rendered
    // as a form which will allow the user to add a new note to the story.
    this.model.notes.add({});
    this.$el
      .find('a.collapse,a.expand')
      .removeClass(/icons-/)
      .addClass('icons-throbber');
  },

  // FIXME Move to separate view
  hoverBox: function () {
    if (!this.model.isNew()) {
      this.$el.find('.popover-activate').popover({
        delay: 200, // A small delay to stop the popovers triggering whenever the mouse is moving around
        html: true,
        trigger: 'hover',
        title: () => this.model.get('title'),
        content: () =>
          storyHoverTemplate({
            story: this.model,
            noteTemplate: noteTemplate,
          }),
      });
    }
  },

  removeHoverbox: function () {
    $('.popover').remove();
  },

  backLoggedRelease: function () {
    var backlogged = false;
    const { collection, attributes } = this.model;
    if (collection.project.iterations) {
      collection.project.iterations.forEach(iteration => {
        iteration.stories().forEach(story => {
          if (story.id === attributes.id && attributes.release_date) {
            var iteration_date = new Date(iteration.startDate());
            var release_date = new Date(attributes.release_date);
            backlogged = iteration_date > release_date;
          }
        });
      });
      return backlogged;
    }
  },

  handleBackLoggedRelease: function () {
    this.$el.toggleClass('backlogged-release', this.backLoggedRelease());
    if (this.backLoggedRelease()) {
      this.$el.attr('title', I18n.t('story.warnings.backlogged_release'));
    }
  },

  setFocus: function () {
    if (this.model.get('editing') === true) {
      this.$('input.title').first().focus();
    }
  },

  makeFormControl: function (content) {
    var div = this.make('div', {
      class: 'form-group',
    });
    if (typeof content === 'function') {
      content.call(this, div);
    } else if (typeof content === 'object') {
      var $div = $(div);
      if (content.label) {
        $div.append(this.label(content.name));
        $div.append('<br/>');
      }
      $div.append(content.control);
    }
    return div;
  },

  clickSave: function (event) {
    return this.saveEdit(event, false);
  },

  toggleControlButtons: function (isDisabled, changeCancel) {
    var $storyControls = this.$el.find('.story-controls');
    $storyControls.find('.submit, .destroy').prop('disabled', isDisabled);

    if (changeCancel === undefined) {
      changeCancel = true;
    }
    if (changeCancel) {
      $storyControls.find('.cancel').prop('disabled', isDisabled);
    }
  },

  getLocation: function () {
    var location = window.location.href;
    var hashIndex = location.indexOf('#');
    var endIndex = hashIndex > 0 ? hashIndex : location.length;
    return location.substring(0, endIndex);
  },

  showHistory: function () {
    this.model.showHistory();
  },

  isLoaded: function () {
    const projectStories = this.model.collection.project.projectBoard.stories;
    const isLoaded =
      !!projectStories.get(this.model.get('id')) || !this.model.get('id');
    return isLoaded;
  },

  disabledChanges: function () {
    const disabledChanges =
      !this.isLoaded() || this.model.get('state') === 'accepted';
    return disabledChanges;
  },

  isLoadedSearchResult: function () {
    return this.isSearchResult && this.isLoaded();
  },
});

export default StoryView;