Codeminer42/cm42-central

View on GitHub
app/assets/javascripts/models/beta/story.js

Summary

Maintainability
C
1 day
Test Coverage
import { status, storyTypes, storyScopes } from 'libs/beta/constants';
import httpService from '../../services/httpService';
import changeCase from 'change-object-case';
import * as Label from './label';
import PropTypes from 'prop-types';
import StoryPropTypesShape, {
  storyPropTypes,
} from '../../components/shapes/story';
import moment from 'moment';
import { has } from 'underscore';
import * as History from './history';

const compareValues = (a, b) => {
  if (a > b) return 1;

  if (a < b) return -1;

  return 0;
};

export const needConfirmation = story =>
  story.state === status.ACCEPTED ||
  story.state === status.REJECTED ||
  story.state === status.RELEASE;

export const comparePosition = (a, b) => {
  const positionA = Number(a.newPosition);
  const positionB = Number(b.newPosition);

  return compareValues(positionA, positionB);
};

export const compareAcceptedAt = (a, b) => {
  return compareValues(a.acceptedAt, b.acceptedAt);
};

export const compareDeliveredAt = (a, b) => {
  return compareValues(a.deliveredAt, b.deliveredAt);
};

export const compareStartedAt = (a, b) => {
  return compareValues(a.startedAt, b.startedAt);
};

export const isUnestimatedFeature = story =>
  !hasEstimate(story) && isFeature(story);

export const isInProgress = story =>
  !isUnscheduled(story) && !isUnstarted(story);

export const isFeature = story => {
  return story.storyType === storyTypes.FEATURE;
};

export const isUnscheduled = story => {
  return story.state === status.UNSCHEDULED;
};

export const isUnstarted = story => {
  return story.state === status.UNSTARTED;
};

export const isAccepted = story => {
  return story.state === status.ACCEPTED;
};

export const totalPoints = stories =>
  stories.reduce((total, current) => total + getPoints(current), 0);

export const isHighlighted = story => Boolean(story.highlighted);

export const getPoints = story =>
  isFeature(story) ? Number(story.estimate) : 0;

export const getCompletedPoints = story =>
  isFeature(story) && isAccepted(story) ? story.estimate : 0;

export const isSameState = (story1, story2) => story1.state === story2.state;

export const isStoryNotEstimated = (storyType, estimate) =>
  storyType === 'feature' && !estimate;

export const isRelease = story => story.storyType === storyTypes.RELEASE;

export const hasHistory = story => !isRelease(story);

export const releaseIsLate = story => {
  if (!isRelease(story)) {
    return false;
  }

  const today = moment();
  const releaseDate = moment(story.releaseDate, ['YYYY-MM-DD']).endOf('day');

  return today > releaseDate;
};

export const possibleStatesFor = story =>
  isUnestimatedFeature(story._editing) ? [states[0]] : states;

export const types = ['feature', 'bug', 'release', 'chore'];

export const states = [
  'unscheduled',
  'unstarted',
  'started',
  'finished',
  'delivered',
  'accepted',
  'rejected',
];

export const findById = (stories, id) => {
  return stories.find(story => story.id === id);
};

export const updatePosition = async story => {
  const serializedStory = serialize(story);

  const { data } = await httpService.post(
    `/beta/stories/${story.id}/position`,
    { story: serializedStory }
  );

  const deserializedData = data.map(item => deserialize(item.story));

  return deserializedData;
};

export const update = async (story, projectId, options) => {
  const serializedStory = serialize(story);

  const { data } = await httpService.put(
    `/projects/${projectId}/stories/${story.id}`,
    { story: serializedStory }
  );

  return deserialize(data.story, options);
};

export const getHighestNewPosition = stories => {
  if (stories.length === 1) {
    return 1;
  }
  const storiesNewPosition = stories.map(story => story.newPosition);
  const highestPositionValue = Math.max(...storiesNewPosition);

  return highestPositionValue + 1;
};

export const post = async (story, projectId) => {
  const serializedStory = serialize(story);

  const { data } = await httpService.post(`/projects/${projectId}/stories`, {
    story: serializedStory,
  });

  return deserialize(data.story);
};

export const deleteStory = (storyId, projectId) =>
  httpService.delete(`/projects/${projectId}/stories/${storyId}`);

export const addNewAttributes = (current, newAttributes) => {
  return { ...current, ...newAttributes };
};

export const search = async (queryParam, projectId) => {
  const { data } = await httpService.get(
    `/projects/${projectId}/stories?q=${queryParam}`,
    {
      timeout: 1500,
    }
  );
  return data.map(item => deserialize(item.story));
};

export const getByLabel = async (label, projectId) => {
  const { data } = await httpService.get(
    `/projects/${projectId}/stories?label=${label}`,
    {
      timeout: 1500,
    }
  );
  return data.map(item => deserialize(item.story));
};

export const getHistory = async (storyId, projectId, users) => {
  const { data } = await httpService.get(
    `/projects/${projectId}/stories/${storyId}/activities`
  );
  return History.deserializeHistory(data, users);
};

export const storyFailure = (story, error) => ({
  ...story,
  _editing: setLoadingValue(story._editing, false),
  errors: error,
  needsToSave: false,
});

export const toggleStory = story => {
  const editing = story.collapsed
    ? { ...story, _isDirty: false, loading: false }
    : null;

  return {
    ...story,
    _editing: editing,
    collapsed: !story.collapsed,
  };
};

const isUnscheduledState = (story, newAttributes) =>
  isFeature(story._editing) &&
  (isChangingWithoutEstimate(story, newAttributes) ||
    hasNilProp(newAttributes, 'estimate') ||
    isChangingToUnscheduled(story, newAttributes) ||
    isUnscheduled(newAttributes));

const hasNilProp = (story, prop) => has(story, prop) && !story[prop];

const isChangingToUnscheduled = (story, newAttributes) =>
  isUnscheduled(story._editing) &&
  !has(newAttributes, 'state') &&
  !hasEstimate(newAttributes);

const isChangingWithoutEstimate = (story, newAttributes) =>
  !hasEstimate(story._editing) && !hasEstimate(newAttributes);

export const stateFor = (story, newAttributes, newStory) =>
  isUnscheduledState(story, newAttributes)
    ? status.UNSCHEDULED
    : newStory.state;

export const estimateFor = (story, newAttributes, newStory) => {
  if (isNoEstimated(story, newAttributes)) return '';
  if (isFeature(newAttributes) && !isUnscheduled(story._editing)) return 1;

  return newStory.estimate;
};

const isEstimable = isFeature;

const isNoEstimated = (story, newAttributes) =>
  (!isEstimable(story._editing) && !has(newAttributes, 'storyType')) ||
  (!isEstimable(newAttributes) && has(newAttributes, 'storyType'));

const hasEstimate = story => Boolean(story.estimate);

export const editStory = (story, newAttributes) => {
  const newStory = {
    ...story._editing,
    ...newAttributes,
  };

  newStory.state = stateFor(story, newAttributes, newStory);
  newStory.estimate = estimateFor(story, newAttributes, newStory);
  newStory.labels = Label.uniqueLabels(newStory.labels);

  return {
    ...story,
    _editing: {
      ...newStory,
      _isDirty: true,
    },
  };
};

export const serialize = story => {
  const data = !isRelease(story)
    ? {
        ...story,
        labels: Label.joinLabels(story.labels),
      }
    : {
        ...story,
        estimate: '',
        ownedById: null,
        labels: '',
        ownedByName: null,
        ownedByInitials: null,
        notes: [],
        tasks: [],
      };

  return changeCase.snakeKeys(data, {
    recursive: true,
    arrayRecursive: true,
  });
};

export const deserialize = (data, options) => {
  const story = changeCase.camelKeys(data, {
    recursive: true,
    arrayRecursive: true,
  });

  options = options || {};
  const collapsed = options.collapse === undefined ? true : options.collapse;

  return {
    ...story,
    labels: Label.splitLabels(story.labels),
    estimate: story.estimate || '',
    collapsed,
  };
};

export const setLoadingStory = story => ({
  ...story,
  _editing: setLoadingValue(story._editing, true),
});

export const setLoadingValue = (story, loading) => ({
  ...story,
  loading,
});

export const cloneStory = story => {
  const clonedStory = {
    ...story,
    id: null,
    state: status.UNSCHEDULED,
    _isDirty: true,
    collapsed: false,
    tasks: [],
    notes: [],
    _editing: null,
  };

  return {
    ...clonedStory,
    _editing: clonedStory,
  };
};

export const createNewStory = (stories, storyAttributes) => {
  const story = stories.find(isNew);

  if (story) {
    editStory(story, storyAttributes);
  }

  const newId = createTemporaryId();

  const newStory = {
    ...emptyStory,
    ...storyAttributes,
    collapsed: false,
    id: newId,
  };

  return {
    ...newStory,
    _editing: newStory,
  };
};

export const createTemporaryId = () => Symbol(`new-${Date.now()}`);

export const withScope = (stories, from) =>
  Boolean(from) ? stories[from] : stories[storyScopes.ALL];

export const isSearch = from => from === storyScopes.SEARCH;

export const isEpic = from => from === storyScopes.EPIC;

export const haveHighlightButton = (stories, story, from) =>
  (isEpic(from) || isSearch(from)) && haveStory(story, stories);

export const haveSearch = stories =>
  Boolean(stories[storyScopes.SEARCH].length);

export const haveStory = (story, stories) =>
  stories.some(item => item.id === story.id);

export const isNew = story => {
  return typeof story.id === 'symbol';
};

export const canSave = story =>
  !isAccepted(story) && story._editing.title !== '';

export const canDelete = story => !isAccepted(story) && !isNew(story);

export const canEdit = story => !isAccepted(story);

export const withoutNewStory = (stories, id) =>
  stories.filter(story => story.id !== id);

export const replaceOrAddNewStory = (stories, newStory, id) => {
  if (id) {
    return stories.map(story => (story.id === id ? newStory : story));
  }

  return [newStory, ...stories];
};

const emptyStory = {
  title: '',
  description: null,
  estimate: '',
  storyType: 'feature',
  state: 'unscheduled',
  acceptedAt: null,
  requestedById: null,
  ownedById: null,
  projectId: null,
  createdAt: '',
  updatedAt: '',
  position: '',
  newPosition: null,
  labels: [],
  requestedByName: '',
  ownedByName: null,
  ownedByInitials: null,
  releaseDate: null,
  deliveredAt: null,
  errors: {},
  notes: [],
  tasks: [],
};

export const storyTransitions = {
  START: 'start',
  FINISH: 'finish',
  DELIVER: 'deliver',
  ACCEPT: 'accept',
  REJECT: 'reject',
  RESTART: 'restart',
  RELEASE: 'release',
};

const stateTransitions = {
  [status.UNSCHEDULED]: {
    [storyTransitions.START]: status.STARTED,
  },
  [status.UNSTARTED]: {
    [storyTransitions.START]: status.STARTED,
  },
  [status.STARTED]: {
    [storyTransitions.FINISH]: status.FINISHED,
  },
  [status.FINISHED]: {
    [storyTransitions.DELIVER]: status.DELIVERED,
  },
  [status.DELIVERED]: {
    [storyTransitions.ACCEPT]: status.ACCEPTED,
    [storyTransitions.REJECT]: status.REJECTED,
  },
  [status.REJECTED]: {
    [storyTransitions.RESTART]: status.STARTED,
  },
  [status.ACCEPTED]: {},
};

export const getNextState = (currentState, transition) => {
  if (transition === storyTransitions.RELEASE) {
    return status.ACCEPTED;
  }

  const nextState = stateTransitions[currentState][transition];

  if (nextState) {
    return nextState;
  }

  return currentState;
};

export const editingStoryPropTypesShape = PropTypes.shape({
  ...storyPropTypes,
  _editing: StoryPropTypesShape,
});

export const donePoints = stories =>
  stories.reduce((points, story) => getCompletedPoints(story) + points, 0);

export const remainingPoints = stories =>
  totalPoints(stories) - donePoints(stories);

export const sortOptimistically = (stories, newStory) => {
  const isChillyBinStory = isUnscheduled(newStory);
  const targetStories = stories.filter(
    story => isUnscheduled(story) === isChillyBinStory
  );

  const storiesToUpdate = targetStories.filter(
    story =>
      story.newPosition >= newStory.newPosition && story.id !== newStory.id
  );
  const updatedPositions = storiesToUpdate.map(story => ({
    ...story,
    newPosition: story.newPosition + 1,
  }));

  return [...updatedPositions, newStory];
};