opf/openproject

View on GitHub
frontend/src/app/shared/helpers/drag-and-drop/reorder-delta-builder.ts

Summary

Maintainability
C
1 day
Test Coverage
import { debugLog } from 'core-app/shared/helpers/debug_output';
import { QueryOrder } from 'core-app/core/apiv3/endpoints/queries/apiv3-query-order';

// min allowed position
export const MIN_ORDER = -2147483647;
// max postgres 4-byte integer position
export const MAX_ORDER = 2147483647;
// default position to insert
export const DEFAULT_ORDER = 0;
// The distance to keep between each element
export const ORDER_DISTANCE = 16384;

/**
 * Return either the delta position or the previous persisted position,
 * in that order.
 *
 * @param wpId
 */
function livePosition(
  delta:QueryOrder,
  positions:QueryOrder,
  wpId:string,
):number|undefined {
  // Explicitly check for undefined here as the delta might be 0 which is falsey.
  return delta[wpId] === undefined ? positions[wpId] : delta[wpId];
}

/**
 * Return the position number for the given index
 */
function positionFor(
  delta:QueryOrder,
  order:string[],
  positions:QueryOrder,
  index:number,
):number|undefined {
  const wpId = order[index];
  return livePosition(delta, positions, wpId);
}

/**
 * Ensure +order+ is already ascending with the exception of +index+,
 * or otherwise reorder the positions starting from the first element.
 */
function isAscendingOrder(
  order:string[],
  positions:QueryOrder,
  index:number,
):boolean {
  let current:number|undefined;

  for (let i = 0, l = order.length; i < l; i += 1) {
    const id = order[i];
    const position = positions[id];

    // Skip our insertion point
    if (i === index) {
      continue;
    }

    // If neither position is set
    if (current === undefined || position === undefined) {
      current = position;
      continue;
    }

    // If the next position is not larger, rebuild positions
    if (position < current) {
      return false;
    }
  }

  return true;
}

/**
 * Since from and to index or only one apart,
 * we can swap the positions.
 *
 * TODO: This should not modify in-place and then return an unrelated value
 */
function positionSwap(
  delta:QueryOrder,
  order:string[],
  positions:QueryOrder,
  index:number,
  fromIndex:number|null,
  wpId:string,
):QueryOrder {
  if (fromIndex === null) {
    return delta;
  }

  const myPosition = positionFor(delta, order, positions, index);
  const neighbor = order[fromIndex];
  const neighborPosition = positionFor(delta, order, positions, fromIndex);

  // If either the neighbor or wpid have no position yet,
  // go through the regular update flow
  if (myPosition === undefined || neighborPosition === undefined) {
    return delta;
  }

  return {
    ...delta,
    [`${wpId}`]: neighborPosition,
    [`${neighbor}`]: myPosition,
  };
}

/**
 * Insert wpId as the first element
 */
function insertAsFirst(
  delta:QueryOrder,
  order:string[],
  positions:QueryOrder,
  index:number,
  wpId:string,
) {
  // Get the actual successor position, it might vary wildly from the optimal position
  const successorPosition = positionFor(delta, order, positions, index + 1);

  // If the successor also has no position yet, simply assign the default
  if (successorPosition === undefined) {
    return {
      ...delta,
      [wpId]: DEFAULT_ORDER,
    };
  }
  return {
    ...delta,
    [wpId]: successorPosition - (ORDER_DISTANCE / 2),
  };
}

/**
 * Builds any previous unset position from 0 .. index
 * so we can properly insert the wpId @ index.
 */
function buildUpPredecessorPosition(
  delta:QueryOrder,
  order:string[],
  positions:QueryOrder,
  index:number,
) {
  let predecessorPosition:number = DEFAULT_ORDER - ORDER_DISTANCE;
  const newDelta = { ...delta };
  for (let i = 0; i < index; i += 1) {
    const id = order[i];
    const position = positions[id];

    // If this current ID has no position yet, assign the current one
    if (position === undefined) {
      newDelta[id] = predecessorPosition + ORDER_DISTANCE;
      predecessorPosition = newDelta[id];
    } else {
      predecessorPosition = position;
    }
  }

  return {
    predecessorPosition,
    delta: newDelta,
  };
}

/**
 * Returns the minimal position assigned currently
 */
function firstPosition(
  delta:QueryOrder,
  order:string[],
  positions:QueryOrder,
):number {
  const wpId = order[0] || '';
  return livePosition(delta, positions, wpId) || 0;
}

/**
 * Returns the maximum position assigned currently.
 * Note that a list can be unpositioned at the beginning, so this may return undefined
 */
function lastPosition(
  delta:QueryOrder,
  order:string[],
  positions:QueryOrder,
):number {
  for (let i = order.length - 1; i >= 0; i -= 1) {
    const wpId = order[i];
    const position = livePosition(delta, positions, wpId);

    // Return the first set position.
    if (position !== undefined) {
      return position;
    }
  }

  return 0;
}

/**
 * Distribute the items over a given min/max
 */
function redistribute(
  delta:QueryOrder,
  order:string[],
  minIndex:number,
  maxIndex:number,
) {
  const itemsToDistribute = order.length;

  let min = minIndex;
  let max = maxIndex;

  // We can keep min and max orders if distance/(items to distribute) >= 1
  let space = Math.floor((max - min) / (itemsToDistribute - 1));

  // If no space is left, first try to add to the max item
  // Or subtract from the min item
  if (space < 1) {
    if ((max + itemsToDistribute) <= MAX_ORDER) {
      max += itemsToDistribute;
    } else if ((min - itemsToDistribute) >= MIN_ORDER) {
      min -= itemsToDistribute;
    } else {
      // This should not happen in a 4-byte integer with our frontend
      throw new Error('Elements cannot be moved further and no space is left. Too many elements');
    }

    // Rebuild space
    space = Math.floor((max - min) / (itemsToDistribute - 1));
  }

  // Assign positions for all values in between min/max
  const newDelta = { ...delta };
  for (let i = 0; i < itemsToDistribute; i += 1) {
    const wpId = order[i];
    newDelta[wpId] = min + (i * space);
  }

  return newDelta;
}

/**
 * There was no space left at the desired insert position,
 * we're going to evenly distribute all items again
 */
function reorderedInsert(
  delta:QueryOrder,
  order:string[],
  positions:QueryOrder,
) {
  // Get the current distance between orders
  // Both must be set by now due to +buildUpPredecessorPosition+ having run.
  const min = firstPosition(delta, order, positions);
  const max = lastPosition(delta, order, positions);

  return redistribute(delta, order, min, max);
}

/**
 * Insert +wpId+ at +index+ in a position that is determined either
 * by its neighbors, one of them in case both do not yet have a position
 */
function buildInsertPosition(
  order:string[],
  positions:QueryOrder,
  wpId:string,
  index:number,
  fromIndex:number|null,
):QueryOrder {
  const delta = {};
  // Special case, order is empty or only contains wpId
  // Then simply insert as the default position unless it already has a position
  if (order.length <= 1 && positions[wpId] === undefined) {
    return {
      ...delta,
      [wpId]: DEFAULT_ORDER,
    };
  }

  // Special case, shifted movement by one
  const newDelta = positionSwap(delta, order, positions, index, fromIndex, wpId);
  if (fromIndex !== null
    && Math.abs(fromIndex - index) === 1
    && delta !== newDelta
  ) {
    return newDelta;
  }

  // Special case, index is 0
  if (index === 0) {
    return insertAsFirst(newDelta, order, positions, index, wpId);
  }

  // Ensure previous positions exist so we can insert wpId @ index
  const { delta: rebuiltDelta, predecessorPosition } = buildUpPredecessorPosition(newDelta, order, positions, index);

  // Ensure we reorder when predecessor is at max already
  if (predecessorPosition >= MAX_ORDER) {
    debugLog('Predecessor position is at max order, need to reorder');
    return reorderedInsert(rebuiltDelta, order, positions);
  }

  // Get the actual successor position, it might vary wildly from the optimal position
  const successorPosition = positionFor(rebuiltDelta, order, positions, index + 1);

  if (successorPosition === undefined) {
    // Successor does not have a position yet (is NULL), any position will work
    // so let's use the optimal one which is halfway to a potential successor
    return {
      ...rebuiltDelta,
      [wpId]: predecessorPosition + (ORDER_DISTANCE / 2),
    };
  }

  // Ensure we reorder when successor is at max already
  if (successorPosition >= MAX_ORDER) {
    debugLog('Successor position is at max order, need to reorder');
    return reorderedInsert(rebuiltDelta, order, positions);
  }

  // successor exists and has a position
  // We will want to insert at the half way from predecessorPosition ... successorPosition
  const distance = Math.floor((successorPosition - predecessorPosition) / 2);

  // If there is no space to insert, we're going to optimize the available space
  if (distance < 1) {
    debugLog('Cannot insert at optimal position, no space left. Need to reorder');
    return reorderedInsert(rebuiltDelta, order, positions);
  }

  return {
    ...rebuiltDelta,
    [wpId]: predecessorPosition + distance,
  };
}

/**
 * Get the absolute minimum and maximum positions
 * currently assigned in the slot.
 *
 * If there is at least two positions assigned, returns the maximum
 * between them.
 *
 * Otherwise, returns the optimum min max for the given order length.
 */
function minMaxPositions(
  delta:QueryOrder,
  order:string[],
  positions:QueryOrder,
):[number, number] {
  let min:number = MAX_ORDER;
  let max:number = MIN_ORDER;
  let any = false;

  for (let i = order.length - 1; i >= 0; i -= 1) {
    const wpId = order[i];
    const position = livePosition(delta, positions, wpId);

    if (position !== undefined) {
      min = Math.min(position, min);
      max = Math.max(position, max);
      any = true;
    }
  }

  if (any && min !== max) {
    return [min, max];
  }
  return [DEFAULT_ORDER, order.length * ORDER_DISTANCE];
}

/**
 * Reassign mixed positions so that they are strictly ascending again,
 * but try to keep relative positions alive
 */
function rebuildPositions(
  order:string[],
  positions:QueryOrder,
) {
  const delta:QueryOrder = {};
  const [min, max] = minMaxPositions(delta, order, positions);
  return redistribute(delta, order, min, max);
}

/**
 * Build a delta
 * Computes the delta of positions for a given operation and order
 *
 * @param order The current order of work packages that contains the user movement
 * @param positions The current positions as loaded from backend / persisted from previous calls
 * @param wpId The work package that got moved
 * @param index The index a work package got moved into
 * @param fromIndex If moved within the order, the previous index used for movement optimzation
 */
export function buildDelta(
  order:string[],
  positions:QueryOrder,
  wpId:string,
  index:number,
  fromIndex:number|null,
):QueryOrder {
  if (!isAscendingOrder(order, positions, index)) {
    return rebuildPositions(order, positions);
  }

  // Insert only the new element
  return buildInsertPosition(order, positions, wpId, index, fromIndex);
}