Skip to content

Commit

Permalink
feat(app): new layout (freeCodeCamp#14707)
Browse files Browse the repository at this point in the history
* feat(app): Restructure app to be more flexible and redux idiomatic

BREAKING CHANGE: Lots of breaking changes

* refactor(challenges): Redux to started file structure

* fix(app): lint issues due to refactor

* fix(settings): Refactor settings to use folder structure

* refactor(challenges): Move step redux stuff into step folder

* fix(challenges): Remove fetchchallenges actions

* refactor(challenges): Move project redux logic into project view subdirectory

* refactor(app): %s/sagas/epics/g

* refactor(redux): Use new redux-epic with combineEpic and ofType

* refactor(app): Move challenge selector to app level

* fix(app): Move loading challenge info into challenge route

This moves a lot of the logic needed to load challenge info into the challenge app. This decouples
the main app from the challenge route

* refactor(map): Map is now decoupled from challenges

* refactor(challenges): Use selectors everywhere instead of guessing state shape

* refactor(client): refactor client epics to use selectors

* refactor(app): Refactor userSelector to return user object instead of object.user

* refactor(entities): Move entities logic into it's own file

* fix(redux): combineTypes should be combineActions

* fix(app): reducer namespacing and import

* fix(Map): Fix undefined type and update redux-action

* fix(redux): Refactor fetchUser to be more declarative

Use rxjs methods instead of imperative if/else. Also prevent non-actions from being emitted

* fix(redux): toString multi phase action types

* fix(redux): typecast multiphase type, fix typo in reducer

toString multiphase types in fetch challenge epic. Add epic to epics lists. Fix type in fetch
challenge complete handler

* fix(redux): updateCurrentChallengelogic should be centerlized

Move route changes to one location.

* fix(Nav): Prevent event object from hanging around

closeDropDown/openDropDown where handing on to the event object. This was causing issues with react
since event objects are recycled in React.

* fix(Map.Challenge): decouple map selector

* fix(Map): Decouple panel selectors from props

Panel Selectors no longer need to know the shape of a components props. Refactored component
selectors to decouple them entities state shape

* fix(Map.redux): Add select challenge epic and connect map epics

* fix(redux.analytics): Fix meta creator and nav/map events

* fix(redux): Update current challenge ajax

* fix(challenges): ssr fetch challenge should update challenge ui

Was using an epic to update challenge ui on fetch complete, but this was not working on ssr due to
the way ssr disables epics to wait for completion. This commit fixes this by causing the complete to
directly update state in the challenge ui

* fix(challenges): wrong import of types, refactor epic name

* fix(redux): Prevent fetch challenge epic from emitting null to dispatch

* fix(redux): prevent executechallenge from emitting null

* fix(challenges.redux): testsSelector returns just tests

* fix(challenges.redux): Prevent completion challenge from emitting null

* refactor(Challenges.Step): Refactor step challenge to release event object

* fix(redux): wrap reducers in factories
reducers exported from features need to be factories
this helps avoid cyclic requires messing up reducer creation
We end up with exports from files being undefined as node tries
to resolve cyclic dependencies.
This prevents that by wrapping the `handleActions` call so that the ref
to types imported from parent features are closures and can be resolved
by node before we need them.

* fix(Map): createUi not working correctly

map utils should receive just map ui state, createMapUi needs to add title to challenge

* feat(Challenges): Adds Panes and panes backend challenge

* fix: Create child container to wrap children

Create a ChildContainer comp' to wrap all children that represent the view for the current route.
This let's the child route define if they want a full width view or if they want the standard
max-width view.

* feat(Panes): panes now render dividers

* feat(Panes): Get divider to move currectly

* fix(Nav): Add top margin to contained childs

Move margin-bottom from nav to child container as margin top. This let's the jsbin style views fit
snug with navbar

* fix(Panes): Should be contained within their borders

* feat(Panes): Update navbar height of pane on app mount

* feat(Panes): Toggle map on map nav btn click

* fix(gulpfile): Ensure nodemon exits on restart

On process exit, wait for nodemon to shutdown before process.exit

* feat(Panes): Make Panes redux first

* fix(Panes): Fix divider positioning

* fix(Panes): Update divider moved handler

dividerMoved action now uses new panesByName structure

* feat(Panes): Pane nav button will hide panes

* chore(package-lock): Update package lock

* feat(Panes.redux): Recaculate dividers on pane toggle

* fix(Challenges): Update challenge on dashedName change

This fixes backwards navigation not updating the redux state current
challenge

* feat(Panes.redux): Clear panes on unmount

Clearing panes on unmount will clear bin buttons in nav

* refactor(Map): Colocate styles

* feat(Map): New map layout

* fix(Map): No longer has it's own page

* fix: FetchChallenges on appMounted

* feat: Normalize fetchChallenge(s) results

This allows superblocks to be sent with both fetchChallenge and
fetchChallenges so the map is always populated on first load

* feat(Map): Show blocks on first load

* fix(less): Remove old css

* feat(Nav): Reduce nav height

* fix(Nav): Render nav after content

Render nav after content and use css to reverse again on screen. We do
this so the panes can render first and update redux panes state which
will then update the nav ui state before nav has a chance to render

* fix(Panes): Add container

This adds a Panes Container that will allow it to udpate redux state so
Panes Component will have redux state ready to actually render panes

* feat(Challenges.Classic): Add panes

* fix(Challenge.Classic): Editor onchange should not need to know about file

* fix(Panes): Index on panes hide should account for hidden pane

* fix(Challanges.Classic): Fix panes types

* fix(Challenges): Add completion modal to all challenges

Change classic modal to completion modal

* fix(Panes): Dividers live on top of planes

* fix(Challenges): Remove codemirror theme

Remove codemirror theme and remove borders from preview frame

* fix(Challenges.Classic): Remove old component

* feat(Challenges.Step): Add panes to step challenge

* feat(Challenges.Project): Add panes to projects

* fix(Challenges.Projects): Remove row

* fix(Modals): Move modal text color to challenge less

This text color is dependent on the actual header color

* fix(Map): Use Superblock title for ui

* fix(Map): Reduce panel header height

* fix(app): Capitalize Toasts folder

Feature folders should be campitalized

* chore(Map): Remove unused epic file

* fix(Step): Fix tests

* test(Map): Update createMapUi tests input
  • Loading branch information
BerkeleyTrue authored and QuincyLarson committed Aug 1, 2017
1 parent 7a75e25 commit 42bfa2e
Show file tree
Hide file tree
Showing 170 changed files with 5,613 additions and 5,822 deletions.
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Observable } from 'rx';
import { createErrorObservable } from '../../common/app/redux/actions';
import { createErrorObservable } from '../../common/app/redux';
import capitalize from 'lodash/capitalize';

// analytics types
Expand Down Expand Up @@ -27,7 +27,7 @@ function formatFields({ type, ...fields }) {
}, { type });
}

export default function analyticsSaga(actions, getState, { window }) {
export default function analyticsSaga(actions, { getState }, { window }) {
const { ga } = window;
if (typeof ga !== 'function') {
console.log('GA not found');
Expand All @@ -39,5 +39,6 @@ export default function analyticsSaga(actions, getState, { window }) {
.filter(Boolean)
// ga always returns undefined
.map(({ type, ...fields }) => ga('send', type, fields))
.ignoreElements()
.catch(createErrorObservable);
}
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
import { Observable } from 'rx';
import { combineEpics, ofType } from 'redux-epic';
import store from 'store';

import { removeCodeUri, getCodeUri } from '../utils/code-uri';
import { ofType } from '../../common/utils/get-actions-of-type';

import { setContent } from '../../common/utils/polyvinyl';
import combineSagas from '../../common/utils/combine-sagas';

import { userSelector } from '../../common/app/redux/selectors';
import { makeToast } from '../../common/app/toasts/redux/actions';
import types from '../../common/app/routes/challenges/redux/types';
import {
userSelector,
challengeSelector
} from '../../common/app/redux';
import { makeToast } from '../../common/app/Toasts/redux';
import {
types,
savedCodeFound,
updateMain,
lockUntrustedCode
} from '../../common/app/routes/challenges/redux/actions';
import {
challengeSelector
} from '../../common/app/routes/challenges/redux/selectors';
lockUntrustedCode,

keySelector,
filesSelector,
codeLockedSelector
} from '../../common/app/routes/challenges/redux';

const legacyPrefixes = [
'Bonfire: ',
Expand Down Expand Up @@ -54,43 +58,39 @@ function legacyToFile(code, files, key) {
return { [key]: setContent(code, files[key]) };
}

export function clearCodeSaga(actions, getState) {
return actions
::ofType(types.clearSavedCode)
export function clearCodeEpic(actions, { getState }) {
return actions::ofType(types.clearSavedCode)
.map(() => {
const { challengesApp: { id = '' } } = getState();
const { id } = challengeSelector(getState());
store.remove(id);
return null;
});
})
.ignoreElements();
}
export function saveCodeSaga(actions, getState) {
return actions
::ofType(types.saveCode)
export function saveCodeEpic(actions, { getState }) {
return actions::ofType(types.saveCode)
// do not save challenge if code is locked
.filter(() => !getState().challengesApp.isCodeLocked)
.filter(() => !codeLockedSelector(getState()))
.map(() => {
const { challengesApp: { id = '', files = {} } } = getState();
const { id } = challengeSelector(getState());
const files = filesSelector(getState());
store.set(id, files);
return null;
});
})
.ignoreElements();
}

export function loadCodeSaga(actions, getState, { window, location }) {
return actions
::ofType(types.loadCode)
export function loadCodeEpic(actions, { getState }, { window, location }) {
return actions::ofType(types.loadCode)
.flatMap(() => {
let finalFiles;
const state = getState();
const { user } = userSelector(state);
const { challenge } = challengeSelector(state);
const user = userSelector(state);
const challenge = challengeSelector(state);
const key = keySelector(state);
const files = filesSelector(state);
const {
challengesApp: {
id = '',
files = {},
legacyKey = '',
key
}
} = state;
id,
name: legacyKey
} = challenge;
const codeUriFound = getCodeUri(
location,
window.decodeURIComponent
Expand Down Expand Up @@ -149,4 +149,4 @@ export function loadCodeSaga(actions, getState, { window, location }) {
});
}

export default combineSagas(saveCodeSaga, loadCodeSaga, clearCodeSaga);
export default combineEpics(saveCodeEpic, loadCodeEpic, clearCodeEpic);
7 changes: 3 additions & 4 deletions client/sagas/err-saga.js → client/epics/err-epic.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { makeToast } from '../../common/app/toasts/redux/actions';
import { makeToast } from '../../common/app/Toasts/redux';

export default function errorSaga(action$) {
return action$
.filter(({ error }) => !!error)
export default function errorSaga(actions) {
return actions.filter(({ error }) => !!error)
.map(({ error }) => error)
.doOnNext(error => console.error(error))
.map(() => makeToast({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,37 +1,40 @@
import { Scheduler, Observable } from 'rx';

import { ofType } from 'redux-epic';

import {
buildClassic,
buildBackendChallenge
} from '../utils/build.js';
import { ofType } from '../../common/utils/get-actions-of-type.js';
import {
createErrorObservable,

challengeSelector
} from '../../common/app/routes/challenges/redux/selectors';
import types from '../../common/app/routes/challenges/redux/types';
import { createErrorObservable } from '../../common/app/redux/actions';
} from '../../common/app/redux';
import {
types,

frameMain,
frameTests,
initOutput,
saveCode
} from '../../common/app/routes/challenges/redux/actions';
saveCode,

filesSelector,
codeLockedSelector
} from '../../common/app/routes/challenges/redux';

export default function buildChallengeEpic(actions, getState) {
return actions
::ofType(types.executeChallenge, types.updateMain)
export default function executeChallengeEpic(actions, { getState }) {
return actions::ofType(types.executeChallenge, types.updateMain)
// if isCodeLocked do not run challenges
.filter(() => !getState().challengesApp.isCodeLocked)
.filter(() => !codeLockedSelector(getState()))
.debounce(750)
.flatMapLatest(({ type }) => {
const shouldProxyConsole = type === types.updateMain;
const state = getState();
const { files } = state.challengesApp;
const files = filesSelector(state);
const {
challenge: {
required = [],
type: challengeType
}
required = [],
type: challengeType
} = challengeSelector(state);
if (challengeType === 'backend') {
return buildBackendChallenge(state)
Expand All @@ -53,6 +56,7 @@ export default function buildChallengeEpic(actions, getState) {
initOutput('// running test') :
null
))
.filter(Boolean)
.catch(createErrorObservable);
});
}
21 changes: 12 additions & 9 deletions client/sagas/frame-epic.js → client/epics/frame-epic.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import Rx, { Observable, Subject } from 'rx';
import { ofType } from 'redux-epic';
/* eslint-disable import/no-unresolved */
import loopProtect from 'loop-protect';
/* eslint-enable import/no-unresolved */
import { ofType } from '../../common/utils/get-actions-of-type';
import types from '../../common/app/routes/challenges/redux/types';
import {
types,

updateOutput,
checkChallenge,
updateTests
} from '../../common/app/routes/challenges/redux/actions';
updateTests,

codeLockedSelector,
testsSelector
} from '../../common/app/routes/challenges/redux';

// we use two different frames to make them all essentially pure functions
// main iframe is responsible rendering the preview and is where we proxy the
Expand Down Expand Up @@ -86,7 +90,7 @@ function frameTests({ build, sources, checkChallengePayload } = {}, document) {
tests.close();
}

export default function frameEpic(actions, getState, { window, document }) {
export default function frameEpic(actions, { getState }, { window, document }) {
// we attach a common place for the iframes to pull in functions from
// the main process
window.__common = {};
Expand All @@ -95,10 +99,9 @@ export default function frameEpic(actions, getState, { window, document }) {
const proxyLogger = new Subject();
// frameReady will let us know when the test iframe is ready to run
const frameReady = window.__common[testId + 'Ready'] = new Subject();
const result = actions
::ofType(types.frameMain, types.frameTests)
const result = actions::ofType(types.frameMain, types.frameTests)
// if isCodeLocked is true do not frame user code
.filter(() => !getState().challengesApp.isCodeLocked)
.filter(() => !codeLockedSelector(getState()))
.map(action => {
if (action.type === types.frameMain) {
return frameMain(action.payload, document, proxyLogger);
Expand All @@ -111,7 +114,7 @@ export default function frameEpic(actions, getState, { window, document }) {
proxyLogger.map(updateOutput),
frameReady.flatMap(({ checkChallengePayload }) => {
const { frame } = getFrameDocument(document, testId);
const { tests } = getState().challengesApp;
const tests = testsSelector(getState());
const postTests = Observable.of(
updateOutput('// tests completed'),
checkChallenge(checkChallengePayload)
Expand Down
10 changes: 10 additions & 0 deletions client/epics/hard-go-to-epic.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { types } from '../../common/app/redux';
import { ofType } from 'redux-epic';

export default function hardGoToSaga(actions, { getState }, { history }) {
return actions::ofType(types.hardGoTo)
.map(({ payload = '/settings' }) => {
history.pushState(history.state, null, payload);
return null;
});
}
21 changes: 21 additions & 0 deletions client/epics/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import analyticsEpic from './analytics-epic.js';
import codeStorageEpic from './code-storage-epic.js';
import errEpic from './err-epic.js';
import executeChallengeEpic from './execute-challenge-epic.js';
import frameEpic from './frame-epic.js';
import hardGoToEpic from './hard-go-to-epic.js';
import mouseTrapEpic from './mouse-trap-epic.js';
import nightModeEpic from './night-mode-epic.js';
import titleEpic from './title-epic.js';

export default [
analyticsEpic,
codeStorageEpic,
errEpic,
executeChallengeEpic,
frameEpic,
hardGoToEpic,
mouseTrapEpic,
nightModeEpic,
titleEpic
];
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { push } from 'react-router-redux';
import {
toggleNightMode,
hardGoTo
} from '../../common/app/redux/actions';
} from '../../common/app/redux';

function bindKey(key, actionCreator) {
return Observable.fromEventPattern(
Expand All @@ -21,8 +21,8 @@ const softRedirects = {
'g n o': '/settings'
};

export default function mouseTrapSaga(actions$) {
const traps$ = [
export default function mouseTrapSaga(actions) {
const traps = [
...Object.keys(softRedirects)
.map(key => bindKey(key, () => push(softRedirects[key]))),
bindKey(
Expand All @@ -39,5 +39,5 @@ export default function mouseTrapSaga(actions$) {
),
bindKey('g t n', toggleNightMode)
];
return Observable.merge(traps$).takeUntil(actions$.last());
return Observable.merge(traps).takeUntil(actions.last());
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,25 @@ import { Observable } from 'rx';
import store from 'store';

import { postJSON$ } from '../../common/utils/ajax-stream';
import types from '../../common/app/redux/types';
import {
types,

addThemeToBody,
updateTheme,
createErrorObservable
} from '../../common/app/redux/actions';

createErrorObservable,

themeSelector,
csrfSelector
} from '../../common/app/redux';

function persistTheme(theme) {
store.set('fcc-theme', theme);
}

export default function nightModeSaga(
actions,
getState,
{ getState },
{ document: { body } }
) {
const toggleBodyClass = actions
Expand All @@ -35,7 +40,7 @@ export default function nightModeSaga(

const optimistic = toggle
.flatMap(() => {
const { app: { theme } } = getState();
const { theme } = themeSelector(getState());
const newTheme = !theme || theme === 'default' ? 'night' : 'default';
persistTheme(newTheme);
return Observable.of(
Expand All @@ -47,7 +52,8 @@ export default function nightModeSaga(
const ajax = toggle
.debounce(250)
.flatMapLatest(() => {
const { app: { theme, csrfToken: _csrf } } = getState();
const _csrf = csrfSelector(getState());
const theme = themeSelector(getState());
return postJSON$('/update-my-theme', { _csrf, theme })
.catch(createErrorObservable);
});
Expand Down
10 changes: 10 additions & 0 deletions client/epics/title-epic.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ofType } from 'redux-epic';
import { types, titleSelector } from '../../common/app/redux';

export default function titleSage(actions, { getState }, { document }) {
return actions::ofType(types.updateTitle)
.do(() => {
document.title = titleSelector(getState());
})
.ignoreElements();
}
Loading

0 comments on commit 42bfa2e

Please sign in to comment.