loomio/loomio

View on GitHub
vue/src/shared/models/poll_model.js

Summary

Maintainability
D
3 days
Test Coverage
import BaseModel        from '@/shared/record_store/base_model';
import AppConfig        from '@/shared/services/app_config';
import Session          from '@/shared/services/session';
import HasDocuments     from '@/shared/mixins/has_documents';
import HasTranslations  from '@/shared/mixins/has_translations';
import EventBus         from '@/shared/services/event_bus';
import I18n             from '@/i18n';
import NullGroupModel   from '@/shared/models/null_group_model';
import { addDays, startOfHour, differenceInHours, addHours } from 'date-fns';
import { snakeCase, compact, head, orderBy, sortBy, map, flatten, slice, uniq, isEqual, shuffle } from 'lodash-es';

export default class PollModel extends BaseModel {
  static singular = 'poll';
  static plural = 'polls';
  static uniqueIndices = ['id', 'key'];
  static indices = ['discussionId', 'authorId', 'groupId'];

  constructor(...args) {
    super(...args);
    this.close = this.close.bind(this);
    this.reopen = this.reopen.bind(this);
    this.addToThread = this.addToThread.bind(this);
    this.addOption = this.addOption.bind(this);
    this.poll = this.poll.bind(this);
  }

  afterConstruction() {
    HasDocuments.apply(this, {showTitle: true});
    return HasTranslations.apply(this);
  }

  config() {
    return AppConfig.pollTypes[this.pollType];
  }

  i18n() {
    return AppConfig.pollTypes[this.pollType].i18n;
  }

  pollTypeKey() {
    return `poll_types.${this.pollType}`;
  }

  poll() { return this; }

  defaultValues() {
    return {
      discussionId: null,
      title: '',
      titlePlaceholder: null,
      closingAt: null,
      customize: false,
      details: '',
      detailsFormat: 'html',
      decidedVotersCount: 0,
      defaultDurationInDays: null,
      specifiedVotersOnly: false,
      pollOptionNames: [],
      pollType: 'proposal',
      chartColumn: null,
      chartType: null,
      minScore: null,
      maxScore: null,
      minimumStanceChoices: null,
      maximumStanceChoices: null,
      dotsPerPerson: null,
      canRespondMaybe: true,
      meetingDuration: null,
      limitReasonLength: true,
      stanceReasonRequired: 'optional',
      reasonPrompt: null,
      files: [],
      imageFiles: [],
      attachments: [],
      linkPreviews: [],
      notifyOnClosingSoon: 'undecided_voters',
      results: [],
      pollTemplateId: null,
      pollTempalteKey: null,
      pollOptionIds: [],
      pollOptionNameFormat: null,
      recipientMessage: null,
      recipientAudience: null,
      recipientUserIds: [],
      recipientChatbotIds: [],
      recipientEmails: [],
      notifyRecipients: true,
      shuffleOptions: false,
      tags: [],
      hideResults: 'off',
      stanceCounts: []
    };
  }

  pollTemplateKeyOrId() {
    return this.pollTemplateId || this.pollTemplateKey;
  }
  
  clonePoll() {
    const clone = this.clone();
    clone.id = null;
    clone.key = null;
    clone.sourceTemplateId = this.id;
    clone.authorId = Session.user().id;
    clone.groupId = null;
    clone.discussionId = null;

    clone.template = false;
    clone.closingAt = startOfHour(addDays(new Date(), this.defaultDurationInDays || 7));

    if (this.pollOptionsAttributes) {
      clone.pollOptionsAttributes = this.pollOptionsAttributes;
    } else {
      clone.pollOptionsAttributes = this.pollOptions().map(o => {
          return {
            name: o.name,
            meaning: o.meaning,
            prompt: o.prompt,
            icon: o.icon
          };
      });
    }

    clone.closedAt = null;
    clone.createdAt = null;
    clone.updatedAt = null;
    clone.decidedVotersCount = null;
    clone.undecidedVotersCount = null;
    return clone;
  }

  clonePollOptions() {
    return this.pollOptions().map(o => {
      return {
        id: o.id,
        name: o.name,
        meaning: o.meaning,
        prompt: o.prompt,
        icon: o.icon
      };
    });
  }

  defaulted(attr) {
    if (this[attr] === null) {
      return AppConfig.pollTypes[this.pollType].defaults[snakeCase(attr)];
    } else {
      return this[attr];
    }
  }

  audienceValues() {
    return {name: this.group().name};
  }

  relationships() {
    this.belongsTo('author', {from: 'users'});
    this.belongsTo('discussion');
    this.belongsTo('group');
    this.hasMany('stances');
    return this.hasMany('versions');
  }

  pieSlices() {
    let slices = [];
    if ((this.pollType === 'count') && this.results.find(r => r.icon === 'agree')) {
      const agree = this.results.find(r => r.icon === 'agree');
      if (agree.score < this.agreeTarget) {
        const pct = (parseFloat(agree.score) / parseFloat(this.agreeTarget)) * 100;
        slices.push({
          value: pct,
          color: agree.color
        });
        slices.push({
          value: 100 - pct,
          color: "#ddd"
        });
      } else {
        slices.push({
          value: 100,
          color: agree.color
        });
      }
    } else {
      slices = this.results.filter(r => r[this.chartColumn]).map(r => {
        return {
          value: r[this.chartColumn],
          color: r.color
        };
      });
    }
    return slices;
  }

  pollOptions() {
    const options = (this.recordStore.pollOptions.collection.chain().find({pollId: this.id, id: {$in: this.pollOptionIds}}).data());
    return orderBy(options, 'priority');
  }

  pollOptionsForVoting() {
    if (this.shuffleOptions) {
      return shuffle(this.pollOptions());
    } else {
      return this.pollOptions();
    }
  }

  bestNamedId() {
    return ((this.id && this) || (this.discusionId && this.discussion()) || (this.groupId && this.group()) || {namedId() {}}).namedId();
  }

  voters() {
    return this.latestStances().map(stance => stance.participant());
  }

  members() {
    return ((this.group() && this.group().members()) || []).concat(this.voters());
  }

  participantIds() {
    return compact(flatten(
      [this.authorId],
      map(this.stances(), 'participantId')
    )
    );
  }

  adminsInclude(user) {
    const stance = this.stanceFor(user);
    return ((this.authorId === user.id) && !this.groupId) ||
    ((this.authorId === user.id) && (this.groupId && this.group().membersInclude(user))) ||
    ((this.authorId === user.id) && (this.discussionId && this.discussion().membersInclude(user))) ||
    (stance && stance.admin) || 
    (this.discussionId && this.discussion().adminsInclude(user)) || 
    this.group().adminsInclude(user);
  }

  votersInclude(user) {
    if (specifiedVotersOnly) {
      return this.stanceFor(user);
    } else {
      return this.membersInclude(user);
    }
  }

  membersInclude(user) {
    return this.stanceFor(user) || (this.discussionId && this.discussion().membersInclude(user)) || this.group().membersInclude(user);
  }

  stanceFor(user) {
    if (user.id === AppConfig.currentUserId) {
      return this.myStance(); 
    } else {
      return head(orderBy(this.recordStore.stances.find({pollId: this.id, participantId: user.id, latest: true, revokedAt: null}), 'createdAt', 'desc'));
    }
  }

  myStance() {
    return this.recordStore.stances.find({id: this.myStanceId, revokedAt: null})[0];
  }

  iHaveVoted() {
    return this.myStanceId && this.myStance() && this.myStance().castAt;
  }

  showResults() {
    return !!this.closingAt &&
    (() => { switch (this.hideResults) {
      case "until_closed":
        return this.closedAt;
      case "until_vote":
        return this.closedAt || this.iHaveVoted();
      default:
        return true;
    } })();
  }

  optionsDiffer(options) {
    return !isEqual(sortBy(this.pollOptionNames), sortBy(map(options, 'name')));
  }

  iCanVote() {
    return this.isVotable() && (this.myStance() || (!this.specifiedVotersOnly && this.membersInclude(Session.user())));
  }

  isBlank() {
    return (this.details === '') || (this.details === null) || (this.details === '<p></p>');
  }

  authorName() {
    return this.author().nameWithTitle(this.group());
  }

  reactions() {
    return this.recordStore.reactions.find({reactableId: this.id, reactableType: "Poll"});
  }

  decidedVoterIds() {
    return uniq(flatten(this.results.map(o => o.voter_ids)));
  }

  // who's voted?
  decidedVoters() {
    return this.recordStore.users.find(this.decidedVoterIds());
  }

  outcome() {
    return this.recordStore.outcomes.find({pollId: this.id, latest: true})[0];
  }

  createdEvent() {
    return this.recordStore.events.find({eventableId: this.id, kind: 'poll_created'})[0];
  }

  latestStances(order, limit) {
    if (order == null) { order = '-createdAt'; }
    return slice(sortBy(this.recordStore.stances.find({pollId: this.id, latest: true, revokedAt: null}), order), 0, limit);
  }

  latestCastStances() {
    return this.recordStore.stances.collection.chain().find({
      pollId: this.id,
      latest: true,
      revokedAt: null,
      castAt: {$ne: null}
    }).data();
  }

  isVotable() {
    return !this.discardedAt && this.closingAt && (this.closedAt == null);
  }

  isClosed() {
    return (this.closedAt != null);
  }

  close() {
    this.processing = true;
    return this.remote.postMember(this.key, 'close')
    .finally(() => { return this.processing = false; });
  }

  reopen() {
    this.processing = true;
    return this.remote.postMember(this.key, 'reopen', {poll: {closing_at: this.closingAt}})
    .finally(() => { return this.processing = false; });
  }

  addToThread(discussionId) {
    this.processing = true;
    return this.remote.patchMember(this.keyOrId(), 'add_to_thread', { discussion_id: discussionId })
    .finally(() => { return this.processing = false; });
  }

  notifyAction() {
    if (this.isNew()) {
      return 'publish';
    } else {
      return 'edit';
    }
  }

  translatedPollType() {
    return I18n.t(`poll_types.${this.pollType}`);
  }

  translatedPollTypeCaps() {
    return I18n.t(`decision_tools_card.${this.pollType}_title`);
  }

  addOption(option) {
    if (this.pollOptionNames.includes(option) || !option) { return false; }
    this.pollOptionNames.push(option);
    if (this.pollType === "meeting") { this.pollOptionNames.sort(); }
    return option;
  }

  hasVariableScore() { 
    return this.defaulted('minScore') !== this.defaulted('maxScore');
  }

  singleChoice() {
    let middle;
    return this.defaulted('minimumStanceChoices') === (middle = this.defaulted('maximumStanceChoices')) && middle === 1;
  }

  hasOptionIcon() { 
    return this.config().has_option_icon;
  }

  datesAsOptions() {
    return this.pollOptionNameFormat === 'iso8601';
  }

  removeOrphanOptions() {
    return this.pollOptions().forEach(option => {
      if (!this.pollOptionNames.includes(option.name)) { return option.remove(); }
    });
  }
};