All files / ee/app/assets/javascripts/analytics/cycle_analytics/components/create_value_stream_form utils.js

100% Statements 65/65
81.48% Branches 44/54
100% Functions 20/20
100% Lines 59/59

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262                                                                37x                         5x 37x 37x                                     5x 6x           6x                                         5x 47x   47x 45x 1x   45x 2x     2x     47x 43x 1x     4x   47x                   5x 21x 21x 1x   21x 4x   21x                       5x 21x 41x 41x       5x   41x   41x                           5x 41x 40x 34x                     5x 12x               6x                             5x   11x 11x 10x 10x                 5x     10x 11x 7x                     5x 10x   10x   10x 10x     17x       17x 16x         1x        
import { isEqual, pick } from 'lodash';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import { isStartEvent, getAllowedEndEvents, eventToOption, eventsByIdentifier } from '../../utils';
import {
  i18n,
  ERRORS,
  defaultErrors,
  defaultFields,
  NAME_MAX_LENGTH,
  formFieldKeys,
  editableFormFieldKeys,
} from './constants';
 
/**
 * @typedef {Object} CustomStageEvents
 * @property {String} canBeStartEvent - Title of the metric measured
 * @property {String} name - Friendly name for the event
 * @property {String} identifier - snakeized name for the event
 *
 * @typedef {Object} DropdownData
 * @property {String} text - Friendly name for the event
 * @property {String} value - Value to be submitted for the dropdown
 */
 
/**
 * Takes an array of custom stage events to return only the
 * events where `canBeStartEvent` is true and converts them
 * to { value, text } pairs for use in dropdowns
 *
 * @param {CustomStageEvents[]} events
 * @returns {DropdownData[]} array of start events formatted for dropdowns
 */
export const startEventOptions = (eventsList) => [
  { value: null, text: i18n.SELECT_START_EVENT },
  ...eventsList.filter(isStartEvent).map(eventToOption),
];
 
/**
 * Takes an array of custom stage events to return only the
 * events where `canBeStartEvent` is false and converts them
 * to { value, text } pairs for use in dropdowns
 *
 * @param {CustomStageEvents[]} events
 * @returns {DropdownData[]} array end events formatted for dropdowns
 */
export const endEventOptions = (eventsList, startEventIdentifier) => {
  const endEvents = getAllowedEndEvents(eventsList, startEventIdentifier);
  return [
    { value: null, text: i18n.SELECT_END_EVENT },
    ...eventsByIdentifier(eventsList, endEvents).map(eventToOption),
  ];
};
 
/**
 * @typedef {Object} CustomStageFormData
 * @property {Object.<String, String>} fields - form field values
 * @property {Object.<String, Array>} errors - form field errors
 */
 
/**
 * Initializes the fields and errors for the custom stages form
 * providing defaults for any missing keys
 *
 * @param {CustomStageFormData} data
 * @returns {CustomStageFormData} the updated initial data with all defaults
 */
export const initializeFormData = ({ fields, errors }) => {
  const initErrors = fields?.endEventIdentifier
    ? defaultErrors
    : {
        ...defaultErrors,
        endEventIdentifier: !fields?.startEventIdentifier ? [ERRORS.START_EVENT_REQUIRED] : [],
      };
  return {
    fields: {
      ...defaultFields,
      ...fields,
    },
    errors: {
      ...initErrors,
      ...errors,
    },
  };
};
 
/**
 * Validates the form fields for the custom stages form
 * Any errors will be returned in a object where the key is
 * the name of the field.g
 *
 * @param {Object} fields key value pair of form field values
 * @param {Object} defaultStageNames array of lower case default value stream names
 * @returns {Object} key value pair of form fields with an array of errors
 */
export const validateStage = (fields, defaultStageNames = []) => {
  const newErrors = {};
 
  if (fields?.name) {
    if (fields.name.length > NAME_MAX_LENGTH) {
      newErrors.name = [ERRORS.MAX_LENGTH];
    }
    if (fields?.custom && defaultStageNames.includes(fields.name.toLowerCase())) {
      newErrors.name = [ERRORS.STAGE_NAME_EXISTS];
    }
  } else {
    newErrors.name = [ERRORS.STAGE_NAME_MIN_LENGTH];
  }
 
  if (fields?.startEventIdentifier) {
    if (!fields?.endEventIdentifier) {
      newErrors.endEventIdentifier = [ERRORS.END_EVENT_REQUIRED];
    }
  } else {
    newErrors.endEventIdentifier = [ERRORS.START_EVENT_REQUIRED];
  }
  return newErrors;
};
 
/**
 * Validates the name of a value stream Any errors will be
 * returned as an array in a object with key`name`
 *
 * @param {Object} fields key value pair of form field values
 * @returns {Array} an array of errors
 */
export const validateValueStreamName = ({ name = '' }) => {
  const errors = [];
  if (name.length > NAME_MAX_LENGTH) {
    errors.push(ERRORS.MAX_LENGTH);
  }
  if (!name.length) {
    errors.push(ERRORS.VALUE_STREAM_NAME_MIN_LENGTH);
  }
  return errors;
};
 
/**
 * Formats the value stream stages for submission, ensures that the
 * 'custom' property is set when we are editing, we include the `id` if its
 * set and all fields are converted to snake case
 *
 * @param {Array} stages array of value stream stages
 * @param {Boolean} isEditing flag to indicate if we are editing a value stream or creating
 * @returns {Array} the array prepared to be submitted for persistence
 */
export const formatStageDataForSubmission = (stages, isEditing = false) => {
  return stages.map(({ id = null, custom = false, name, ...rest }) => {
    let editProps = { custom };
    if (isEditing) {
      // We can add a new stage to the value stream when either creating, or editing
      // If a new stage has been added then at this point, the `id` won't exist
      // The new stage is still `custom` but wont have an id until the form submits and its persisted to the DB
      editProps = id ? { id, custom: true } : { custom: true };
    }
    const editableFields = pick(rest, editableFormFieldKeys);
    // While we work on https://gitlab.com/gitlab-org/gitlab/-/issues/321959 we should not allow editing default
    return custom
      ? convertObjectPropsToSnakeCase({ ...editableFields, ...editProps, name })
      : convertObjectPropsToSnakeCase({ ...editProps, name, custom: false });
  });
};
 
/**
 * Checks an array of value stream stages to see if there are
 * any differences in the values they contain
 *
 * @param {Array} stages array of value stream stages
 * @param {Array} stages array of value stream stages
 * @returns {Boolean} returns true if there is a difference in the 2 arrays
 */
export const hasDirtyStage = (currentStages, originalStages) => {
  const cs = currentStages.map((s) => pick(s, formFieldKeys));
  const os = originalStages.map((s) => pick(s, formFieldKeys));
  return !isEqual(cs, os);
};
 
/**
 * Checks if the target name matches the name of any of the value
 * stream stages passed in
 *
 * @param {Array} stages array of value stream stages
 * @param {String} targetName name we are searching for
 * @returns {Object} returns the found object or null
 */
const findStageByName = (stages, targetName = '') =>
  stages.find(({ name }) => name.toLowerCase().trim() === targetName.toLowerCase().trim());
 
/**
 * Returns a valid custom value stream stage
 *
 * @param {Object} stage a raw value stream stage retrieved from the vuex store
 * @returns {Object} the same stage with fields adjusted for the value stream form
 */
const prepareCustomStage = ({ startEventLabel = {}, endEventLabel = {}, ...rest }) => ({
  ...rest,
  startEventLabel,
  endEventLabel,
  startEventLabelId: startEventLabel?.id || null,
  endEventLabelId: endEventLabel?.id || null,
  isDefault: false,
});
 
/**
 * Returns a valid default value stream stage
 *
 * @param {Object} stage a raw value stream stage retrieved from the vuex store
 * @returns {Object} the same stage with fields adjusted for the value stream form
 */
const prepareDefaultStage = (defaultStageConfig, { name, ...rest }) => {
  // default stages currently dont have any label based events
  const stage = findStageByName(defaultStageConfig, name) || null;
  if (!stage) return {};
  const { startEventIdentifier = null, endEventIdentifier = null } = stage;
  return {
    ...rest,
    name,
    startEventIdentifier,
    endEventIdentifier,
    isDefault: true,
  };
};
 
const generateHiddenDefaultStages = (defaultStageConfig, stageNames) => {
  // We use the stage name to check for any default stages that might be hidden
  // Currently the default stages can't be renamed
  return defaultStageConfig
    .filter(({ name }) => !stageNames.includes(name.toLowerCase()))
    .map((data) => ({ ...data, hidden: true }));
};
 
/**
 * Returns a valid array of value stream stages for
 * use in the value stream form
 *
 * @param {Array} defaultStageConfig an array of the default value stream stages retrieved from the backend
 * @param {Array} selectedValueStreamStages an array of raw value stream stages retrieved from the vuex store
 * @returns {Object} the same stage with fields adjusted for the value stream form
 */
export const generateInitialStageData = (defaultStageConfig, selectedValueStreamStages) => {
  const hiddenDefaultStages = generateHiddenDefaultStages(
    defaultStageConfig,
    selectedValueStreamStages.map((s) => s.name.toLowerCase()),
  );
  const combinedStages = [...selectedValueStreamStages, ...hiddenDefaultStages];
  return combinedStages.map(
    ({ startEventIdentifier = null, endEventIdentifier = null, custom = false, ...rest }) => {
      const stageData =
        custom && startEventIdentifier && endEventIdentifier
          ? prepareCustomStage({ ...rest, startEventIdentifier, endEventIdentifier })
          : prepareDefaultStage(defaultStageConfig, rest);
 
      if (stageData?.name) {
        return {
          ...stageData,
          custom,
        };
      }
      return {};
    },
  );
};