oglimmer/linky

View on GitHub
src/redux/actions/links.js

Summary

Maintainability
C
7 hrs
Test Coverage

import { actions } from 'react-redux-form';
import { push } from 'react-router-redux';
import assert from 'assert';
import BlueBirdPromise from 'bluebird';

import fetch from '../../util/fetch';
import { diff } from '../../util/ArrayUtil';

import { CLICK_LINK, CHANGE_SORTING_LINKS, SELECT_TAG, ADD_LINK, UPDATE_LINK,
  DEL_LINK, RSS_UPDATES, RSS_UPDATES_DETAILS, RSS_SET_DETAILS_ID, SET_LINKS,
  RENAME_TAG_LINKLIST, REMOVE_TAG_FROM_LINKS, TOGGLE_COLUMN_VIEW } from '../actionTypes';

import { fetchTagHierarchy, manipulateTagCounter } from './tagHierarchy';
import { setErrorMessage, setTempMessage, setInfoMessage } from './feedback';

const RSS_UPDATE_FREQUENCY = 1000 * 60 * 5;


export function clickLink(id) {
  return { type: CLICK_LINK, id };
}

export function changeSortingLink(byColumn) {
  return { type: CHANGE_SORTING_LINKS, byColumn };
}

export function toggleColumnView(columnName) {
  return { type: TOGGLE_COLUMN_VIEW, columnName };
}

export function selectTag(tag) {
  return { type: SELECT_TAG, tag };
}

function addLinkPost(attr) {
  return Object.assign({}, attr, { type: ADD_LINK });
}

function updateLinkPost(id, linkUrl, tags, rssUrl, pageTitle, notes) {
  return { type: UPDATE_LINK, id, linkUrl, tags, rssUrl, pageTitle, notes };
}

function delLinkPost(id) {
  return { type: DEL_LINK, id };
}

function setRssUpdates(linkId, newUpdates) {
  return { type: RSS_UPDATES, linkId, newUpdates };
}

function setRssUpdatesDetails(linkId, newDetails) {
  return { type: RSS_UPDATES_DETAILS, linkId, newDetails };
}

function setRssDetailsId(id) {
  return { type: RSS_SET_DETAILS_ID, id };
}

function setLinks(linkList) {
  return { type: SET_LINKS, linkList };
}

export function removeTagFromLinks(tagName) {
  return { type: REMOVE_TAG_FROM_LINKS, tagName };
}

export function renameTagInLinks(oldTagName, newTagName) {
  return (dispatch, getState) => {
    if (getState().mainData.linkList) {
      return { type: RENAME_TAG_LINKLIST, oldTagName, newTagName };
    }
    return Promise.resolve();
  };
}

export function resetAddLinkFields() {
  return (dispatch) => {
    dispatch(actions.reset('addUrl.url'));
    dispatch(actions.reset('addUrl.tags'));
    dispatch(actions.reset('addUrl.id'));
    dispatch(actions.reset('addUrl.rssUrl'));
    dispatch(actions.reset('addUrl.pageTitle'));
    dispatch(actions.reset('addUrl.notes'));
  };
}


export function fetchRssUpdatesDetails(id) {
  return async (dispatch, getState) => {
    if (getState().mainData.selectedLinkForDetails === id) {
      dispatch(setRssDetailsId(null));
      return;
    }
    try {
      const json = await fetch.get(`/rest/links/${id}/rssDetails`, getState().auth.token);
      dispatch(setRssUpdates(id, json.result));
      dispatch(setRssUpdatesDetails(id, json.display));
      dispatch(setRssDetailsId(id));
    } catch (err) {
      dispatch(setErrorMessage(err));
    }
  };
}

const lastUpdates = {};
export function fetchRssUpdates(forceUpdate = false) {
  return async (dispatch, getState) => {
    const { linkList } = getState().mainData;
    const totals = await BlueBirdPromise.map(linkList.filter(e => e.rssUrl),
      async (linkElement) => {
        const lastFetch = lastUpdates[linkElement.id];
        if (forceUpdate || !lastFetch || lastFetch < Date.now() - RSS_UPDATE_FREQUENCY) {
          lastUpdates[linkElement.id] = Date.now();
          const json = await fetch.get(`/rest/links/${linkElement.id}/rss`, getState().auth.token);
          dispatch(setRssUpdates(linkElement.id, json.result));
          return json.result;
        }
        return 0;
      }, { concurrency: 5 },
    );
    const totalNewUpdates = totals.reduce((sum, value) => sum + value, 0);
    if (totalNewUpdates > 0) {
      if (Notification.permission !== 'granted') {
        Notification.requestPermission();
      } else {
        /* eslint-disable no-new */
        new Notification(`${totalNewUpdates} unread RSS feeds found!`, {
          icon: 'https://linky1.com/favicon.ico',
        });
        /* eslint-enable no-new */
      }
    }
  };
}


export function fetchLinks(tag) {
  return async (dispatch, getState) => {
    const selectedTag = tag || getState().mainData.selectedTag;
    try {
      const linkList = await fetch.get(`/rest/links/${selectedTag}`, getState().auth.token);
      dispatch(setLinks(linkList));
    } catch (err) {
      if (err.message.indexOf('Invalid auth token') !== -1) {
        dispatch(setErrorMessage('Session token expired. Reload this page!'));
      } else {
        dispatch(setErrorMessage(err));
      }
    }
  };
}


export function changeTag(tag) {
  return dispatch => dispatch(push(tag));
}

function handlingLinkListChange(linkId, link, selectedTag) {
  return (dispatch) => {
    if (!linkId) {
      // a new item: add to current list if it has the selectedTag
      if (link.tags.find(e => e === selectedTag)) {
        dispatch(addLinkPost(link));
      }
    } else if (!link.tags.find(e => e === selectedTag)) {
      // not new, so delete if it doesn't have the selected tag anymore
      dispatch(delLinkPost(link.id));
    } else {
      // not new and not deleted from the selectedTag, so update
      dispatch(updateLinkPost(link.id, link.linkUrl, link.tags, link.rssUrl,
        link.pageTitle, link.notes));
    }
  };
}

function handlingTagListChange(newLink, oldTags) {
  return (dispatch) => {
    // in old but not in new => deleted
    const toBeRemoved = diff(oldTags, newLink.tags);
    toBeRemoved.forEach(tagName => dispatch(manipulateTagCounter(tagName, -1)));
    // in new but not in old => add
    const toBeAdded = diff(newLink.tags, oldTags);
    toBeAdded.forEach(tagName => dispatch(manipulateTagCounter(tagName, 1)));
  };
}

const updateAfterPersistLink = (responseUpdates, linkList, selectedTag, dispatch, successMsg) => {
  if (responseUpdates.message) {
    dispatch(setErrorMessage(responseUpdates.message));
  } else {
    const updateLink = (link) => {
      const oldElement = linkList.find(e => e.id === link.id);
      dispatch(handlingLinkListChange(oldElement ? oldElement.id : null, link, selectedTag));
      dispatch(handlingTagListChange(link, oldElement ? oldElement.tags : []));
    };
    const newLink = responseUpdates.primary;
    if (successMsg) {
      dispatch(setInfoMessage(successMsg));
    } else {
      const additionalInfo = responseUpdates.collateral && responseUpdates.collateral.length > 0 ? ` Also updated ${responseUpdates.collateral.length} other link(s).` : '';
      dispatch(setInfoMessage(`${newLink.linkUrl} with tags = ${newLink.tags} successfully saved. ${additionalInfo}`));
    }
    updateLink(newLink);
    if (responseUpdates.collateral) {
      responseUpdates.collateral.forEach(updateLink);
    }
  }
};

export function persistLink(linkId, url, tags, rssUrl, pageTitle, notes) {
  return async (dispatch, getState) => {
    dispatch(setTempMessage('sending data to server ...'));
    const { linkList, selectedTag } = getState().mainData;
    try {
      const response = await (linkId ?
        fetch.put(`/rest/links/${linkId}`, {
          url,
          tags,
          rssUrl,
          pageTitle,
          notes,
        }, getState().auth.token) :
        fetch.post('/rest/links', { url, tags, rssUrl, pageTitle, notes }, getState().auth.token));
      const responseUpdates = await response.json();
      updateAfterPersistLink(responseUpdates, linkList, selectedTag, dispatch);
    } catch (err) {
      dispatch(setErrorMessage(err));
    }
  };
}

export function delLink(id) {
  return async (dispatch, getState) => {
    dispatch(setTempMessage('sending data to server ...'));
    const linkToDelete = getState().mainData.linkList.find(e => e.id === id);
    assert(linkToDelete && linkToDelete.id && linkToDelete.tags);
    try {
      await fetch.delete(`/rest/links/${id}`, getState().auth.token);
      dispatch(delLinkPost(id));
      linkToDelete.tags.forEach(tagName => dispatch(manipulateTagCounter(tagName, -1)));
      dispatch(setInfoMessage(`${linkToDelete.linkUrl} successfully deleted.`));
    } catch (err) {
      dispatch(setErrorMessage(err));
    }
  };
}

export function editLink(id, url, tags, rssUrl, pageTitle, notes) {
  return (dispatch) => {
    dispatch(actions.change('addUrl.id', id));
    dispatch(actions.change('addUrl.url', url));
    dispatch(actions.change('addUrl.tags', tags));
    dispatch(actions.change('addUrl.rssUrl', rssUrl || ''));
    dispatch(actions.change('addUrl.pageTitle', pageTitle || ''));
    dispatch(actions.change('addUrl.notes', notes || ''));
  };
}

export function initialLoadLinks(tag) {
  return (dispatch, getState) => {
    const shouldInitTags = !getState().tagHierarchyData.tagHierarchy;
    dispatch(selectTag(tag));
    const promises = [
      dispatch(fetchLinks(tag)),
    ];
    if (shouldInitTags) {
      promises.push(dispatch(fetchTagHierarchy()));
    }
    return Promise.all(promises);
  };
}

export function completeChangeTag(tag) {
  return async (dispatch) => {
    await dispatch(initialLoadLinks(tag));
    dispatch(fetchRssUpdates());
  };
}

// client-side only
export function startRssUpdates() {
  return (dispatch, getState) => {
    if (getState().auth.token) {
      dispatch(fetchRssUpdates());
      setTimeout(() => {
        dispatch(startRssUpdates());
      }, RSS_UPDATE_FREQUENCY);
    }
  };
}

export function sendSearch(searchString) {
  const currentTime = new Date();
  return async (dispatch, getState) => {
    try {
      const json = await fetch.get(`/rest/search/links?q=${encodeURIComponent(searchString)}`, getState().auth.token);
      dispatch(setLinks(json));
      dispatch(actions.change('searchBar.serverSide', true));
      console.log(`Search took ${new Date().getTime() - currentTime.getTime()} millis`);
    } catch (err) {
      dispatch(setErrorMessage(err));
    }
  };
}

export function createArchive(id) {
  return async (dispatch, getState) => {
    dispatch(setTempMessage('sending data to server ...'));
    const { linkList, selectedTag } = getState().mainData;
    try {
      const response = await fetch.post(`/rest/archive/${id}`, {}, getState().auth.token);
      const responseUpdates = await response.json();
      updateAfterPersistLink(responseUpdates, linkList, selectedTag, dispatch, 'Archive successfully created.');
    } catch (err) {
      dispatch(setErrorMessage(err));
    }
  };
}