Files
freeCodeCamp/common/app/Panes/redux/index.js
Berkeley Martinez dbecdc5618 feat: prep for modern challenges (#15781)
* feat(seed): Add modern challenge

* chore(react): Use prop-types package

* feat: Initial refactor to redux-first-router

BREAKING CHANGE: Everything is different!

* feat: First rendering

* feat(routes): Challenges view render but failing

* fix(Challenges): Remove contain HOC

* fix(RFR): Add params selector

* fix(RFR): :en should be :lang

* fix: Update berks utils for redux

* fix(Map): Challenge link to arg

* fix(Map): Add trailing slash to map page

* fix(RFR): Use FCC Link

Use fcc Link to get around issue of lang being undefined

* fix(Router): Link to is required

* fix(app): Rely on RFR state for app lang

* chore(RFR): Remove unused RFR Link

* fix(RFR): Hydrate initial challenge using RFR and RO

* fix: Casing issue

* fix(RFR): Undefined links

* fix(RFR): Use onRoute<name> convention for route types

* feat(server/react): Add helpful redux logging/throwing

* fix(server/react): Strip out nonjson from state

This prevents thunks in routesMap from breaking serialization

* fix(RFR/Link): Should accept any renderable

* fix(RFR): Get redirects working

* fix(RFR): Redirects and not found's

* fix(Map): Move challenge onClick handler

* fix(Map): Allow Router.link to handle clicks after onClick

* fix(routes): Remove react-router-redux

* feat(Router): Add lang to all route actions by default

* fix(entities): Only fetch challenge if not already loaded

* fix(Files): Move files to own feature

* chore(Challenges): Remove vestigial hints logic

* fix(RFR): Update challenges on route challenges

* fix(code-storage): Should use events instead of commands

* fix(Map): ClickOnMap should not hold on to event

* chore(lint): Use eslint-config-freecodecamp

Closes #15938

* feat(Panes): Update panes on route instead of render

* fix(Panes): Store panesmap and update on fetchchallenges

* fix(Panes): Normalize panesmaps

* fix(Panes): Remove filter from createpanemap

* fix(Panes): Middleware on location meta object

* feat(Panes): Filter preview on nonhtml challenges

* build(babel): Add lodash babel plugin

* chore(lint): Lint js files

* fix(server/user-stats): Remove use of lodash chain

this interferes with babel-plugin-lodash

* feat(dev): Add remote redux devtools for ssr

* fix(Panes): Dispatch mount action

this is needed to trigger window/divider epics

* fix(Panes): Getpane to use new panesmap format

* fix(Panes): Always update panes after state

this lets the panes logic be affected by changes in state
2017-11-09 19:10:30 -06:00

312 lines
9.0 KiB
JavaScript

import { isLocationAction } from 'redux-first-router';
import _ from 'lodash';
import {
composeReducers,
createAction,
createTypes,
handleActions
} from 'berkeleys-redux-utils';
import ns from '../ns.json';
import windowEpic from './window-epic.js';
import dividerEpic from './divider-epic.js';
import { challengeMetaSelector } from '../../routes/Challenges/redux';
import { types as app } from '../../redux';
const isDev = process.env.NODE_ENV !== 'production';
export const epics = [
windowEpic,
dividerEpic
];
export const types = createTypes([
'panesUpdatedThroughFetch',
'panesMounted',
'panesUpdated',
'panesWillMount',
'panesWillUnmount',
'updateSize',
'dividerClicked',
'dividerMoved',
'mouseReleased',
'windowResized',
// commands
'updateNavHeight',
'hidePane'
], ns);
export const panesUpdatedThroughFetch = createAction(
types.panesUpdatedThroughFetch,
null,
panesView => ({ panesView })
);
export const panesMounted = createAction(types.panesMounted);
export const panesUpdated = createAction(types.panesUpdated);
export const panesWillMount = createAction(types.panesWillMount);
export const panesWillUnmount = createAction(types.panesWillUnmount);
export const dividerClicked = createAction(types.dividerClicked);
export const dividerMoved = createAction(types.dividerMoved);
export const mouseReleased = createAction(types.mouseReleased);
export const windowResized = createAction(types.windowResized);
// commands
export const updateNavHeight = createAction(types.updateNavHeight);
export const hidePane = createAction(types.hidePane);
const initialState = {
height: 600,
width: 800,
navHeight: 50,
panes: [],
panesByName: {},
pressedDivider: null,
nameToType: {}
};
export const getNS = state => state[ns];
export const heightSelector = state => {
const { navHeight, height } = getNS(state);
return height - navHeight;
};
export const panesSelector = state => getNS(state).panes;
export const panesByNameSelector = state => getNS(state).panesByName;
export const pressedDividerSelector =
state => getNS(state).pressedDivider;
export const widthSelector = state => getNS(state).width;
export const nameToTypeSelector = state => getNS(state).nameToType;
function isPanesAction({ type } = {}, typeToName) {
return !!typeToName[type];
}
function getDividerLeft(numOfPanes, index) {
let dividerLeft = null;
if (numOfPanes > 1 && numOfPanes !== index + 1) {
dividerLeft = (100 / numOfPanes) * (index + 1);
}
return dividerLeft;
}
function forEachConfig(config, cb) {
return _.forEach(config, (val, key) => {
// val is a sub config
if (_.isObject(val) && !val.name) {
return forEachConfig(val, cb);
}
return cb(config, key);
});
}
function reduceConfig(config, cb, acc = {}) {
return _.reduce(config, (acc, val, key) => {
if (_.isObject(val) && !val.name) {
return reduceConfig(val, cb, acc);
}
return cb(acc, val, key);
}, acc);
}
const getPaneName = (panes, index) => (panes[index] || {}).name || '';
export const createPaneMap = (ns, getPanesMap) => {
const panesMap = _.reduce(getPanesMap(), (map, val, key) => {
let paneConfig = val;
if (typeof val === 'string') {
paneConfig = {
name: val
};
}
map[key] = paneConfig;
return map;
}, {});
return Object.defineProperty(panesMap, 'toString', { value: () => ns });
};
export default function createPanesAspects(config) {
if (isDev) {
forEachConfig(config, (typeToName, actionType) => {
if (actionType === 'undefined') {
throw new Error(
`action type for ${typeToName[actionType]} is undefined`
);
}
});
}
const typeToName = reduceConfig(config, (acc, val, type) => {
const name = _.isObject(val) ? val.name : val;
acc[type] = name;
return acc;
});
function middleware({ getState }) {
const filterPanes = panesMap => _.reduce(panesMap, (panes, pane, type) => {
if (typeof pane.filter !== 'function' || pane.filter(getState())) {
panes[type] = pane;
}
return panes;
}, {});
// we cache the previous map so that we can attach it to the fetchChallenge
let previousMap;
// show panes on challenge route
// select panes map on viewType (this is state dependent)
// filter panes out on state
return next => action => {
let finalAction = action;
if (isPanesAction(action, typeToName)) {
finalAction = {
...action,
meta: {
...action.meta,
isPaneAction: true,
paneName: typeToName[action.type]
}
};
}
const result = next(finalAction);
if (isLocationAction(action)) {
// location matches a panes route
if (config[action.type]) {
const paneMap = previousMap = config[action.type];
const meta = challengeMetaSelector(getState());
const viewMap = paneMap[meta.viewType] || {};
next(panesUpdatedThroughFetch(filterPanes(viewMap)));
} else {
next(panesUpdatedThroughFetch({}));
}
}
if (action.type === app.fetchChallenge.complete) {
const meta = challengeMetaSelector(getState());
const viewMap = previousMap[meta.viewType] || {};
next(panesUpdatedThroughFetch(filterPanes(viewMap)));
}
return result;
};
}
const reducer = composeReducers(
ns,
handleActions(
() => ({
[types.dividerClicked]: (state, { payload: name }) => ({
...state,
pressedDivider: name
}),
[types.dividerMoved]: (state, { payload: clientX }) => {
const { width, pressedDivider: paneName } = state;
const dividerBuffer = (200 / width) * 100;
const paneIndex =
_.findIndex(state.panes, ({ name }) => paneName === name);
const currentPane = state.panesByName[paneName];
const rightPane =
state.panesByName[getPaneName(state.panes, paneIndex + 1)] || {};
const leftPane =
state.panesByName[getPaneName(state.panes, paneIndex - 1)] || {};
const rightBound = (rightPane.dividerLeft || 100) - dividerBuffer;
const leftBound = (leftPane.dividerLeft || 0) + dividerBuffer;
const newPosition = _.clamp(
(clientX / width) * 100,
leftBound,
rightBound
);
return {
...state,
panesByName: {
...state.panesByName,
[currentPane.name]: {
...currentPane,
dividerLeft: newPosition
}
}
};
},
[types.mouseReleased]: state => ({ ...state, pressedDivider: null }),
[types.windowResized]: (state, { payload: { height, width } }) => ({
...state,
height,
width
}),
// used to clear bin buttons
[types.panesWillUnmount]: state => ({
...state,
panes: [],
panesByName: {},
pressedDivider: null
}),
[types.updateNavHeight]: (state, { payload: navHeight }) => ({
...state,
navHeight
})
}),
initialState,
),
function metaReducer(state = initialState, action) {
if (action.meta && action.meta.panesView) {
const panesView = action.meta.panesView;
const panes = _.map(panesView, ({ name }, type) => ({ name, type }));
const numOfPanes = Object.keys(panes).length;
return {
...state,
panes,
panesByName: panes.reduce((panes, { name }, index) => {
const dividerLeft = getDividerLeft(numOfPanes, index);
panes[name] = {
name,
dividerLeft,
isHidden: false
};
return panes;
}, {})
};
}
if (action.meta && action.meta.isPaneAction) {
const name = action.meta.paneName;
const oldPane = state.panesByName[name];
const pane = {
...oldPane,
isHidden: !oldPane.isHidden
};
const panesByName = {
...state.panesByName,
[name]: pane
};
const numOfPanes = state.panes.reduce((sum, { name }) => {
return panesByName[name].isHidden ? sum : sum + 1;
}, 0);
let numOfHidden = 0;
return {
...state,
panesByName: state.panes.reduce(
(panesByName, { name }, index) => {
if (!panesByName[name].isHidden) {
const dividerLeft = getDividerLeft(
numOfPanes,
index - numOfHidden
);
panesByName[name] = {
...panesByName[name],
dividerLeft
};
} else {
numOfHidden = numOfHidden + 1;
}
return panesByName;
},
panesByName
)
};
}
return state;
}
);
return {
reducer,
middleware
};
}