streetmix/streetmix

View on GitHub
assets/scripts/streets/xhr.js

Summary

Maintainability
C
7 hrs
Test Coverage
import clone from 'just-clone'
import { showError, ERRORS } from '../app/errors'
import {
  checkIfEverythingIsLoaded,
  setServerContacted
} from '../app/initialization'
import { formatMessage } from '../locales/locale'
import { MODES, processMode, getMode, setMode } from '../app/mode'
import { goNewStreet } from '../app/routing'
import { infoBubble } from '../info_bubble/info_bubble'
import { app } from '../preinit/app_settings'
import { segmentsChanged } from '../segments/view'
import { getSignInData, isSignedIn } from '../users/authentication'
import {
  deleteStreet,
  getStreet,
  getStreetWithParams,
  postStreet,
  putStreet
} from '../util/api'
import {
  isblockingAjaxRequestInProgress,
  newBlockingAjaxRequest
} from '../util/fetch_blocking'
import store from '../store'
import { updateSettings } from '../store/slices/settings'
import {
  saveStreetId,
  saveOriginalStreetId,
  updateEditCount,
  updateStreetData
} from '../store/slices/street'
import { addToast } from '../store/slices/toasts'
import { resetUndoStack } from '../store/slices/history'
import { makeDefaultStreet } from './creation'
import { NEW_STREET_EMPTY } from './constants'
import {
  prepareEmptyStreet,
  prepareDefaultStreet,
  trimStreetData,
  updateEverything,
  addAltVariantObject,
  setStreetCreatorId,
  setUpdateTimeToNow,
  setLastStreet,
  setIgnoreStreetChanges
} from './data_model'
import {
  getRemixOnFirstEdit,
  setRemixOnFirstEdit,
  remixStreet,
  addRemixSuffixToName
} from './remix'
import { unifyUndoStack } from './undo_stack'
import { deleteStreetThumbnail } from './image'

const SAVE_STREET_DELAY = 500

let saveStreetTimerId = -1
let saveStreetIncomplete = false

export function getSaveStreetIncomplete () {
  return saveStreetIncomplete
}

export function setSaveStreetIncomplete (value) {
  saveStreetIncomplete = value
}

let latestRequestId

export async function createNewStreetOnServer () {
  const settings = store.getState().settings

  if (settings.newStreetPreference === NEW_STREET_EMPTY) {
    prepareEmptyStreet()
  } else {
    prepareDefaultStreet()
  }

  const transmission = packServerStreetDataRaw()

  postStreet(transmission).then(receiveNewStreet).catch(errorReceiveNewStreet)
}

function receiveNewStreet ({ data }) {
  setStreetId(data.id, data.namespacedId)
  saveStreetToServer(true)
}

function errorReceiveNewStreet ({ response }) {
  if (response.status === 401) {
    showError(ERRORS.AUTH_EXPIRED, true)
  } else {
    showError(ERRORS.NEW_STREET_SERVER_FAILURE, true)
  }
}

export async function fetchStreetFromServer () {
  const street = store.getState().street

  try {
    const response = await getStreetWithParams(
      street.creatorId,
      street.namespacedId
    )
    receiveStreet(response.data)
  } catch (error) {
    errorReceiveStreet(error)
  }
}

function errorReceiveStreet (error) {
  const data = error.response.data
  const mode = getMode()
  if (
    mode === MODES.CONTINUE ||
    mode === MODES.USER_GALLERY ||
    mode === MODES.GLOBAL_GALLERY
  ) {
    goNewStreet()
  } else {
    if (data.status === 404 || data.status === 410) {
      if (store.getState().street.creatorId) {
        if (data.status === 410) {
          setMode(MODES.STREET_410_BUT_LINK_TO_USER)
        } else {
          setMode(MODES.STREET_404_BUT_LINK_TO_USER)
        }
      } else {
        setMode(MODES.STREET_404)
      }
      // TODO swap for showError (here and elsewhere)
      processMode()
    } else {
      showError(ERRORS.NEW_STREET_SERVER_FAILURE, true)
    }
  }
}

export function saveStreetToServer (initial) {
  if (app.readOnly) {
    return
  }

  const transmission = packServerStreetDataRaw()
  const street = store.getState().street

  putStreet(street.id, transmission).then(() => {
    setSaveStreetIncomplete(false)

    if (initial) {
      confirmSaveStreetToServerInitial()
    }
  })
}

function confirmSaveStreetToServerInitial () {
  setServerContacted(true)
  checkIfEverythingIsLoaded()
}

function clearScheduledSavingStreetToServer () {
  window.clearTimeout(saveStreetTimerId)
}

export async function fetchStreetForVerification () {
  // Don’t do it with any network services pending
  // NOTE: this used to check for all nonblocking requests,
  // but this system is getting refactored away -- so we're
  // not really checking against all pending requests anymore.
  if (
    isblockingAjaxRequestInProgress() ||
    saveStreetIncomplete ||
    store.getState().errors.abortEverything ||
    getRemixOnFirstEdit()
  ) {
    return
  }

  latestRequestId = Date.now()
  const streetId = store.getState().street.id

  try {
    const response = await getStreet(streetId, {
      headers: { 'x-streetmix-request-id': latestRequestId }
    })

    // Response headers are lower-case via Axios
    const requestId = response.headers['x-streetmix-request-id']
    // Throw an error if response is stale
    if (latestRequestId !== Number.parseInt(requestId, 10)) {
      throw new Error('1')
    }

    // Handle response data
    receiveStreetForVerification(response.data)
  } catch (error) {
    // Silently throw away stale responses
    if (error.message === '1') return

    // Otherwise, handle the error
    console.log(error)
    errorReceiveStreetForVerification(error.response)
  }
}

/**
 * Compare the `clientUpdatedAt` value of local data to server data.
 * We don't use `updatedAt` which is only updated on the server. We use
 * `clientUpdatedAt` which is only used to validate the age of local data.
 * If local data is outdated, then we replace it with the updated data.
 *
 * @param {Object} transmission - server data
 */
function receiveStreetForVerification (transmission) {
  const localUpdatedAt = new Date(store.getState().street.clientUpdatedAt)
  const serverUpdatedAt = new Date(transmission.clientUpdatedAt)

  if (serverUpdatedAt && localUpdatedAt && serverUpdatedAt > localUpdatedAt) {
    store.dispatch(
      addToast({
        method: 'warning',
        message: formatMessage(
          'toast.reloaded',
          'Your street was reloaded from the server as it was modified elsewhere.'
        )
      })
    )

    infoBubble.suppress()

    unpackServerStreetData(transmission, null, null, false)

    // Update everything, but don't re-save the street to the server,
    // which will re-invalidate the local copy.
    updateEverything(true, false)
  }
}

function errorReceiveStreetForVerification (data) {
  // 404 should never happen here, since 410 designates streets that have
  // been deleted (but remain hidden on the server)

  if (isSignedIn() && (data.status === 404 || data.status === 410)) {
    showError(ERRORS.STREET_DELETED_ELSEWHERE, true)
  }
}

function receiveStreet (transmission) {
  unpackServerStreetData(transmission, null, null, true)

  setServerContacted(true)

  // Legacy - remove once everything is Promise-based.
  checkIfEverythingIsLoaded()
}

function unpackStreetDataFromServerTransmission (transmission) {
  // Catch a data error where a user's street might be retrieved
  // without any data in it (so-called error 9B)
  if (!transmission.data) {
    showError(ERRORS.STREET_DATA_FAILURE)
    return
  }

  const street = clone(transmission.data.street)
  street.creatorId = transmission.creatorId ?? null
  street.originalStreetId = transmission.originalStreetId ?? null
  street.updatedAt = transmission.updatedAt ?? null
  street.clientUpdatedAt = transmission.clientUpdatedAt ?? null
  street.name = transmission.name ?? null
  street.location = transmission.data.street.location ?? null
  street.editCount = transmission.data.street.editCount ?? 0

  return street
}

export function unpackServerStreetData (
  transmission,
  id,
  namespacedId,
  checkIfNeedsToBeRemixed
) {
  const street = unpackStreetDataFromServerTransmission(transmission)
  addAltVariantObject(street)

  store.dispatch(updateStreetData(street))
  store.dispatch(resetUndoStack())

  if (id) {
    setStreetId(id, namespacedId)
  } else {
    setStreetId(transmission.id, transmission.namespacedId)
  }

  if (checkIfNeedsToBeRemixed) {
    if (!isSignedIn() || street.creatorId !== getSignInData().userId) {
      setRemixOnFirstEdit(true)
    } else {
      setRemixOnFirstEdit(false)
    }

    if (!getRemixOnFirstEdit()) {
      saveStreetToServer()
    }
  }
}

export function packServerStreetDataRaw () {
  const data = {}
  data.street = trimStreetData(store.getState().street)

  // Those go above data in the structure, so they need to be cleared here
  delete data.street.name
  delete data.street.originalStreetId
  delete data.street.updatedAt
  delete data.street.clientUpdatedAt

  // This will be implied through authorization header
  delete data.street.creatorId

  if (store.getState().flags.SAVE_UNDO.value === true) {
    data.undoStack = clone(store.getState().undo.stack)
    data.undoPosition = store.getState().undo.position
  }

  const street = store.getState().street
  const transmission = {
    name: street.name,
    originalStreetId: street.originalStreetId,
    data,
    clientUpdatedAt: street.clientUpdatedAt
  }

  return transmission
}

// Legacy: converts raw JS objects to JSON.
// axios-based requests do this automatically.
export function packServerStreetData () {
  const transmission = packServerStreetDataRaw()
  return JSON.stringify(transmission)
}

export function setStreetId (newId, newNamespacedId) {
  store.dispatch(saveStreetId(newId, newNamespacedId))

  unifyUndoStack()
  updateLastStreetInfo()
}

export function updateLastStreetInfo () {
  const street = store.getState().street
  store.dispatch(
    updateSettings({
      lastStreetId: street.id,
      lastStreetNamespacedId: street.namespacedId,
      lastStreetCreatorId: street.creatorId
    })
  )
}

export function scheduleSavingStreetToServer () {
  saveStreetIncomplete = true

  clearScheduledSavingStreetToServer()

  if (getRemixOnFirstEdit()) {
    remixStreet()
  } else {
    saveStreetTimerId = window.setTimeout(function () {
      saveStreetToServer(false)
    }, SAVE_STREET_DELAY)
  }
}

export function fetchLastStreet () {
  const streetId = store.getState().app.priorLastStreetId

  newBlockingAjaxRequest(
    'load',
    {
      // TODO const
      url: '/api/v1/streets/' + streetId,
      method: 'GET'
    },
    receiveLastStreet,
    cancelReceiveLastStreet
  )
}

function cancelReceiveLastStreet () {
  makeDefaultStreet()
}

function receiveLastStreet (transmission) {
  setIgnoreStreetChanges(true)
  const street = store.getState().street
  unpackServerStreetData(transmission, street.id, street.namespacedId, false)
  const priorLastStreetId = store.getState().app.priorLastStreetId
  store.dispatch(saveOriginalStreetId(priorLastStreetId))
  addRemixSuffixToName()

  if (isSignedIn()) {
    setStreetCreatorId(getSignInData().userId)
  } else {
    setStreetCreatorId(null)
  }
  setUpdateTimeToNow()
  store.dispatch(updateEditCount(0))
  // console.log('editCount = 0 on last street!')

  // COMMENT - update street state to change originalStreetId above;
  // now have to update again to change edit count - how to fix?
  unifyUndoStack()

  segmentsChanged()

  setIgnoreStreetChanges(false)
  setLastStreet()

  saveStreetToServer(false)
}

export function sendDeleteStreetToServer (id) {
  deleteStreet(id)

  // Delete street thumbnail from Cloudinary.
  // TODO: handle this from the backend!
  deleteStreetThumbnail(id)

  // Prevents new street submenu from showing the last street
  const settings = store.getState().settings

  if (settings.lastStreetId === id) {
    store.dispatch(
      updateSettings({
        lastStreetId: null,
        lastStreetCreatorId: null,
        lastStreetNamespacedId: null
      })
    )
  }
}