Home Reference Source

src/sagas/pages.js

import { delay } from 'redux-saga';
import {
  all,
  call,
  put,
  race,
  select,
  take,
  takeEvery,
  throttle,
  fork,
  actionChannel,
  cancelled,
} from 'redux-saga/effects';
import { matchPath } from 'react-router';
import { batchActions } from 'redux-batched-actions';
import {
  compact,
  each,
  head,
  isEmpty,
  isObject,
  map,
  clone,
  isEqual,
  has,
  findLast,
  get,
  set,
  reduce,
  pickBy,
} from 'lodash';
import {
  getAction,
  getLocation,
  LOCATION_CHANGE,
  replace,
} from 'connected-react-router';
import queryString from 'query-string';
import {
  SET,
  COPY,
  SYNC,
  REMOVE,
  UPDATE,
  UPDATE_MAP,
} from '../constants/models';
import { MAP_URL, METADATA_REQUEST, RESET } from '../constants/pages';
import { metadataFail, metadataSuccess, setStatus } from '../actions/pages';
import { combineModels, updateModel } from '../actions/models';
import { changeRootPage } from '../actions/global';
import { rootPageSelector } from '../selectors/global';
import { makePageRoutesByIdSelector } from '../selectors/pages';
import { modelsSelector } from '../selectors/models';
import fetchSaga from './fetch.js';
import { FETCH_PAGE_METADATA } from '../core/api.js';
import compileUrl from '../utils/compileUrl';
import linkResolver from '../utils/linkResolver';

function autoDetectBasePath(pathPattern, pathname) {
  const match = matchPath(pathname, {
    path: pathPattern,
    exact: false,
    strict: false,
  });
  return match && match.url;
}

function applyPlaceholders(key, obj, placeholders) {
  const newObj = {};
  each(obj, (v, k) => {
    if (isObject(v)) {
      newObj[k] = applyPlaceholders(key, v, placeholders);
    } else if (v === '::self') {
      newObj[k] = placeholders[key];
    } else if (placeholders[v.substr(1)]) {
      newObj[k] = placeholders[v.substr(1)];
    } else {
      newObj[k] = obj[k];
    }
  });
  return newObj;
}

function* pathMapping(location, routes) {
  const parsedPath = head(
    compact(map(routes.list, route => matchPath(location.pathname, route)))
  );
  if (parsedPath && !isEmpty(parsedPath.params)) {
    yield put(
      batchActions(
        map(parsedPath.params, (value, key) => ({
          ...routes.pathMapping[key],
          ...applyPlaceholders(key, routes.pathMapping[key], parsedPath.params),
        }))
      )
    );
  }
}

function* queryMapping(location, routes) {
  const parsedQuery = queryString.parse(location.search);
  if (!isEmpty(parsedQuery)) {
    yield put(
      batchActions(
        compact(
          map(parsedQuery, (value, key) => {
            return (
              routes.queryMapping[key] && {
                ...routes.queryMapping[key].get,
                ...applyPlaceholders(
                  key,
                  routes.queryMapping[key].get,
                  parsedQuery
                ),
              }
            );
          })
        )
      )
    );
  }
}

function* mappingUrlToRedux(routes) {
  const location = yield select(getLocation);
  if (routes) {
    yield all([
      call(pathMapping, location, routes),
      call(queryMapping, location, routes),
    ]);
  }
  // TODO: исправить сброс роутинга до базового уровня
  // try {
  //   const firstRoute = (routes && routes.list && routes.list[0]) || {};
  //   const basePath = autoDetectBasePath(firstRoute.path, location.pathname);
  //   if (!firstRoute.isOtherPage && location.pathname !== basePath) {
  //     yield put(
  //       replace({
  //         pathname: basePath,
  //         search: location.search,
  //         state: { silent: true },
  //       })
  //     );
  //   }
  // } catch (e) {
  //   console.error(`Ошибка автоматического определения базового роута.`);
  //   console.error(e);
  // }
}

function* processUrl() {
  try {
    const location = yield select(getLocation);
    const pageId = yield select(rootPageSelector);
    const routes = yield select(makePageRoutesByIdSelector(pageId));
    const routerAction = yield select(getAction);
    if (routerAction !== 'POP' && !(location.state && location.state.silent)) {
      yield call(mappingUrlToRedux, routes);
    }
  } catch (err) {
    console.error(err);
  }
}

/**
 * сага, фетчит метадату
 * @param action
 */
function* getMetadata(action) {
  let { pageId, rootPage, pageUrl, mapping } = action.payload;
  try {
    const { search } = yield select(getLocation);
    if (!isEmpty(mapping)) {
      const state = yield select();
      const extraQueryParams = rootPage && queryString.parse(search);
      pageUrl = compileUrl(pageUrl, mapping, state, { extraQueryParams });
    } else if (rootPage) {
      pageUrl = pageUrl + search;
    }
    const metadata = yield call(fetchSaga, FETCH_PAGE_METADATA, { pageUrl });

    yield call(mappingUrlToRedux, metadata.routes);
    if (rootPage) {
      yield put(changeRootPage(metadata.id));
    }
    yield fork(watcherDefaultModels, metadata.models);
    yield put(metadataSuccess(metadata.id, metadata));
    yield put(setStatus(metadata.id, 200));
  } catch (err) {
    if (err && err.status) {
      yield put(setStatus(pageId, err.status));
    }

    if (rootPage) {
      yield put(changeRootPage(pageId));
    }
    yield put(
      metadataFail(
        pageId,
        {
          label: err.status ? err.status : 'Ошибка',
          text: err.message,
          closeButton: false,
          severity: 'danger',
        },
        err.json && err.json.meta ? err.json.meta : {}
      )
    );
  }
}

/**
 * Дополнительная функция для observeModels.
 * резолвит и сравнивает модели из стейта и резолв модели.
 * @param models - модели для резолва
 * @param stateModels - модели из стейта
 * @returns {*}
 */
export function compareAndResolve(models, stateModels) {
  return reduce(
    models,
    (acc, value, path) => {
      const resolveValue = linkResolver(stateModels, value);
      const stateValue = get(stateModels, path);

      if (!isEqual(stateValue, resolveValue)) {
        return set(clone(acc), path, resolveValue);
      }
      return acc;
    },
    {}
  );
}

/**
 * Для закрытия канала используем race
 * c экшеном сброса (обнуление) метаданных страницы
 * @param config - конфиг для моделей по умолчанию, который прокидывается в сагу
 */
export function* watcherDefaultModels(config) {
  yield race([call(flowDefaultModels, config), take(RESET)]);
}

/**
 * Сага для первоначальной установки моделей по умолчанию
 * и подписка на изменения через канал
 * @param config - конфиг для моделей по умолчанию
 * @returns {boolean}
 */
export function* flowDefaultModels(config) {
  if (isEmpty(config)) return false;
  const state = yield select();
  const initialModels = yield call(compareAndResolve, config, state);
  if (!isEmpty(initialModels)) {
    yield put(combineModels(initialModels));
  }
  const observableModels = pickBy(
    config,
    item => !!item.observe && !!item.link
  );
  if (!isEmpty(observableModels)) {
    const modelsChan = yield actionChannel([
      SET,
      COPY,
      SYNC,
      REMOVE,
      UPDATE,
      UPDATE_MAP,
    ]);
    try {
      while (true) {
        const oldState = yield select();
        yield take(modelsChan);
        const newState = yield select();
        let changedModels = pickBy(
          observableModels,
          cfg => !isEqual(get(oldState, cfg.link), get(newState, cfg.link))
        );
        const newModels = yield call(
          compareAndResolve,
          changedModels,
          newState
        );
        if (!isEmpty(newModels)) {
          yield put(combineModels(newModels));
        }
      }
    } finally {
      if (yield cancelled()) {
        modelsChan.close();
      }
    }
  }
}

/**
 * Сайд-эффекты для page редюсера
 * @ignore
 */
export const pagesSagas = [
  takeEvery(METADATA_REQUEST, getMetadata),
  throttle(500, MAP_URL, processUrl),
];