Merge pull request #10200 from BerkeleyTrue/fix/code-uri

Fix(challenges): code storage/uri
This commit is contained in:
Quincy Larson
2016-08-31 16:18:50 -07:00
committed by GitHub
18 changed files with 383 additions and 87 deletions

View File

@ -1,51 +1,148 @@
import { Observable } from 'rx';
import store from 'store'; import store from 'store';
import { removeCodeUri, getCodeUri } from '../utils/code-uri';
import { ofType } from '../../common/utils/get-actions-of-type';
import { updateContents } 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 types from '../../common/app/routes/challenges/redux/types';
import { import {
savedCodeFound savedCodeFound,
updateMain,
lockUntrustedCode
} from '../../common/app/routes/challenges/redux/actions'; } from '../../common/app/routes/challenges/redux/actions';
const legecyPrefixes = [ const legacyPrefixes = [
'Bonfire: ', 'Bonfire: ',
'Waypoint: ', 'Waypoint: ',
'Zipline: ', 'Zipline: ',
'Basejump: ', 'Basejump: ',
'Checkpoint: ' 'Checkpoint: '
]; ];
const legacyPostfix = 'Val';
function getCode(id, legacy) { function getCode(id) {
if (store.has(id)) { if (store.has(id)) {
return store.get(id); return store.get(id);
} }
if (store.has(legacy)) { return null;
const code = '' + store.get(legacy); }
store.remove(legacy);
function getLegacyCode(legacy) {
const key = legacy + legacyPostfix;
let code = null;
if (store.has(key)) {
code = '' + store.get(key);
store.remove(key);
return code; return code;
} }
return legecyPrefixes.reduce((code, prefix) => { return legacyPrefixes.reduce((code, prefix) => {
if (code) { if (code) {
return code; return code;
} }
return store.get(prefix + legacy + 'Val'); return store.get(prefix + key);
}, null); }, null);
} }
export default function codeStorageSaga(actions$, getState) { function legacyToFile(code, files, key) {
return actions$ return { [key]: updateContents(code, files[key]) };
.filter(({ type }) => ( }
type === types.saveCode ||
type === types.loadCode export function clearCodeSaga(actions, getState) {
)) return actions
.map(({ type }) => { ::ofType(types.clearSavedCode)
const { id = '', files = {}, legacyKey = '' } = getState().challengesApp; .map(() => {
if (type === types.saveCode) { const { challengesApp: { id = '' } } = getState();
store.set(id, files); store.remove(id);
return null;
}
const codeFound = getCode(id, legacyKey);
if (codeFound) {
return savedCodeFound(codeFound);
}
return null; return null;
}); });
} }
export function saveCodeSaga(actions, getState) {
return actions
::ofType(types.saveCode)
// do not save challenge if code is locked
.filter(() => !getState().challengesApp.isCodeLocked)
.map(() => {
const { challengesApp: { id = '', files = {} } } = getState();
store.set(id, files);
return null;
});
}
export function loadCodeSaga(actions$, getState, { window, location }) {
return actions$
::ofType(types.loadCode)
.flatMap(() => {
let finalFiles;
const state = getState();
const { user } = userSelector(state);
const {
challengesApp: {
id = '',
files = {},
legacyKey = '',
key
}
} = state;
const codeUriFound = getCodeUri(
location,
window.decodeURIComponent
);
if (codeUriFound) {
finalFiles = legacyToFile(codeUriFound, files, key);
removeCodeUri(location, window.history);
return Observable.of(
lockUntrustedCode(),
makeToast({
message: 'I found code in the URI. Loading now.'
}),
savedCodeFound(finalFiles)
);
}
const codeFound = getCode(id);
if (codeFound) {
finalFiles = codeFound;
} else {
const legacyCode = getLegacyCode(legacyKey);
if (legacyCode) {
finalFiles = legacyToFile(legacyCode, files, key);
}
}
if (finalFiles) {
return Observable.of(
makeToast({
message: 'I found some saved work. Loading now.'
}),
savedCodeFound(finalFiles),
updateMain()
);
}
if (user.challengeMap && user.challengeMap[id]) {
const userChallenge = user.challengeMap[id];
if (userChallenge.files) {
finalFiles = userChallenge.files;
} else if (userChallenge.solution) {
finalFiles = legacyToFile(userChallenge.solution, files, key);
}
if (finalFiles) {
return Observable.of(
makeToast({
message: 'I found a previous solved solution. Loading now.'
}),
savedCodeFound(finalFiles),
updateMain()
);
}
}
return Observable.empty();
});
}
export default combineSagas(saveCodeSaga, loadCodeSaga, clearCodeSaga);

View File

@ -91,6 +91,8 @@ export default function executeChallengeSaga(action$, getState) {
type === types.executeChallenge || type === types.executeChallenge ||
type === types.updateMain type === types.updateMain
)) ))
// if isCodeLockedTrue do not run challenges
.filter(() => !getState().challengesApp.isCodeLocked)
.debounce(750) .debounce(750)
.flatMapLatest(({ type }) => { .flatMapLatest(({ type }) => {
const state = getState(); const state = getState();

View File

@ -2,6 +2,7 @@ import Rx, { Observable, Subject } from 'rx';
/* eslint-disable import/no-unresolved */ /* eslint-disable import/no-unresolved */
import loopProtect from 'loop-protect'; import loopProtect from 'loop-protect';
/* eslint-enable import/no-unresolved */ /* 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 from '../../common/app/routes/challenges/redux/types';
import { import {
updateOutput, updateOutput,
@ -89,12 +90,13 @@ export default function frameSaga(actions$, getState, { window, document }) {
const proxyLogger$ = new Subject(); const proxyLogger$ = new Subject();
const runTests$ = window.__common[testId + 'Ready$'] = const runTests$ = window.__common[testId + 'Ready$'] =
new Subject(); new Subject();
const result$ = actions$ const result$ = actions$::ofType(
.filter(({ type }) => ( types.frameMain,
type === types.frameMain || types.frameTests,
type === types.frameTests || types.frameOutput
type === types.frameOutput )
)) // if isCodeLocked is true do not frame user code
.filter(() => !getState().challengesApp.isCodeLocked)
.map(action => { .map(action => {
if (action.type === types.frameMain) { if (action.type === types.frameMain) {
return frameMain(action.payload, document, proxyLogger$); return frameMain(action.payload, document, proxyLogger$);

79
client/utils/code-uri.js Normal file
View File

@ -0,0 +1,79 @@
import flow from 'lodash/flow';
import { decodeFcc } from '../../common/utils/encode-decode';
const queryRegex = /^(\?|#\?)/;
export function legacyIsInQuery(query, decode) {
let decoded;
try {
decoded = decode(query);
} catch (err) {
return false;
}
if (!decoded || typeof decoded.split !== 'function') {
return false;
}
return decoded
.replace(queryRegex, '')
.split('&')
.reduce(function(found, param) {
var key = param.split('=')[0];
if (key === 'solution') {
return true;
}
return found;
}, false);
}
export function getKeyInQuery(query, keyToFind = '') {
return query
.split('&')
.reduce((oldValue, param) => {
const key = param.split('=')[0];
const value = param
.split('=')
.slice(1)
.join('=');
if (key === keyToFind) {
return value;
}
return oldValue;
}, null);
}
export function getLegacySolutionFromQuery(query = '', decode) {
return flow(
getKeyInQuery,
decode,
decodeFcc
)(query, 'solution');
}
export function getCodeUri(location, decodeURIComponent) {
let query;
if (
location.search &&
legacyIsInQuery(location.search, decodeURIComponent)
) {
query = location.search.replace(/^\?/, '');
} else {
return null;
}
return getLegacySolutionFromQuery(query, decodeURIComponent);
}
export function removeCodeUri(location, history) {
if (
typeof location.href.split !== 'function' ||
typeof history.replaceState !== 'function'
) {
return false;
}
history.replaceState(
history.state,
null,
location.href.split('?')[0]
);
return true;
}

View File

@ -7,8 +7,8 @@ export default React.createClass({
'aria-controls': React.PropTypes.string, 'aria-controls': React.PropTypes.string,
className: React.PropTypes.string, className: React.PropTypes.string,
href: React.PropTypes.string, href: React.PropTypes.string,
onClick: React.PropTypes.func.isRequired, onClick: React.PropTypes.func,
points: React.PropTypes.func, points: React.PropTypes.number,
title: React.PropTypes.node title: React.PropTypes.node
}, },

View File

@ -13,7 +13,7 @@ import {
} from './selectors'; } from './selectors';
import { updateCurrentChallenge } from '../routes/challenges/redux/actions'; import { updateCurrentChallenge } from '../routes/challenges/redux/actions';
import getActionsOfType from '../../utils/get-actions-of-type'; import getActionsOfType from '../../utils/get-actions-of-type';
import combineSagas from '../utils/combine-sagas'; import combineSagas from '../../utils/combine-sagas';
import { postJSON$ } from '../../utils/ajax-stream'; import { postJSON$ } from '../../utils/ajax-stream';
const log = debug('fcc:app/redux/load-current-challenge-saga'); const log = debug('fcc:app/redux/load-current-challenge-saga');

View File

@ -11,22 +11,24 @@ import BugModal from '../Bug-Modal.jsx';
import { challengeSelector } from '../../redux/selectors'; import { challengeSelector } from '../../redux/selectors';
import { import {
executeChallenge, executeChallenge,
updateMain,
updateFile, updateFile,
loadCode loadCode
} from '../../redux/actions'; } from '../../redux/actions';
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
challengeSelector, challengeSelector,
state => state.challengesApp.id,
state => state.challengesApp.tests, state => state.challengesApp.tests,
state => state.challengesApp.files, state => state.challengesApp.files,
state => state.challengesApp.key, state => state.challengesApp.key,
( (
{ showPreview, mode }, { showPreview, mode },
id,
tests, tests,
files = {}, files = {},
key = '' key = ''
) => ({ ) => ({
id,
content: files[key] && files[key].contents || '', content: files[key] && files[key].contents || '',
file: files[key], file: files[key],
showPreview, showPreview,
@ -38,7 +40,6 @@ const mapStateToProps = createSelector(
const bindableActions = { const bindableActions = {
executeChallenge, executeChallenge,
updateFile, updateFile,
updateMain,
loadCode loadCode
}; };
@ -46,18 +47,23 @@ export class Challenge extends PureComponent {
static displayName = 'Challenge'; static displayName = 'Challenge';
static propTypes = { static propTypes = {
id: PropTypes.string,
showPreview: PropTypes.bool, showPreview: PropTypes.bool,
content: PropTypes.string, content: PropTypes.string,
mode: PropTypes.string, mode: PropTypes.string,
updateFile: PropTypes.func, updateFile: PropTypes.func,
executeChallenge: PropTypes.func, executeChallenge: PropTypes.func,
updateMain: PropTypes.func,
loadCode: PropTypes.func loadCode: PropTypes.func
}; };
componentDidMount() { componentDidMount() {
this.props.loadCode(); this.props.loadCode();
this.props.updateMain(); }
componentWillReceiveProps(nextProps) {
if (this.props.id !== nextProps.id) {
this.props.loadCode();
}
} }
renderPreview(showPreview) { renderPreview(showPreview) {

View File

@ -12,7 +12,8 @@ import { challengeSelector } from '../../redux/selectors';
import { import {
openBugModal, openBugModal,
updateHint, updateHint,
executeChallenge executeChallenge,
unlockUntrustedCode
} from '../../redux/actions'; } from '../../redux/actions';
import { makeToast } from '../../../../toasts/redux/actions'; import { makeToast } from '../../../../toasts/redux/actions';
import { toggleHelpChat } from '../../../../redux/actions'; import { toggleHelpChat } from '../../../../redux/actions';
@ -22,7 +23,8 @@ const bindableActions = {
executeChallenge, executeChallenge,
updateHint, updateHint,
toggleHelpChat, toggleHelpChat,
openBugModal openBugModal,
unlockUntrustedCode
}; };
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
challengeSelector, challengeSelector,
@ -31,20 +33,23 @@ const mapStateToProps = createSelector(
state => state.challengesApp.tests, state => state.challengesApp.tests,
state => state.challengesApp.output, state => state.challengesApp.output,
state => state.challengesApp.hintIndex, state => state.challengesApp.hintIndex,
state => state.challengesApp.isCodeLocked,
( (
{ challenge: { title, description, hints = [] } = {} }, { challenge: { title, description, hints = [] } = {} },
windowHeight, windowHeight,
navHeight, navHeight,
tests, tests,
output, output,
hintIndex hintIndex,
isCodeLocked
) => ({ ) => ({
title, title,
description, description,
height: windowHeight - navHeight - 20, height: windowHeight - navHeight - 20,
tests, tests,
output, output,
hint: hints[hintIndex] hint: hints[hintIndex],
isCodeLocked
}) })
); );
@ -65,7 +70,8 @@ export class SidePanel extends PureComponent {
updateHint: PropTypes.func, updateHint: PropTypes.func,
makeToast: PropTypes.func, makeToast: PropTypes.func,
toggleHelpChat: PropTypes.func, toggleHelpChat: PropTypes.func,
openBugModal: PropTypes.func openBugModal: PropTypes.func,
unlockUntrustedCode: PropTypes.func
}; };
renderDescription(description = [ 'Happy Coding!' ], descriptionRegex) { renderDescription(description = [ 'Happy Coding!' ], descriptionRegex) {
@ -106,7 +112,9 @@ export class SidePanel extends PureComponent {
updateHint, updateHint,
makeToast, makeToast,
toggleHelpChat, toggleHelpChat,
openBugModal openBugModal,
isCodeLocked,
unlockUntrustedCode
} = this.props; } = this.props;
const style = {}; const style = {};
if (height) { if (height) {
@ -135,9 +143,11 @@ export class SidePanel extends PureComponent {
<ToolPanel <ToolPanel
executeChallenge={ executeChallenge } executeChallenge={ executeChallenge }
hint={ hint } hint={ hint }
isCodeLocked={ isCodeLocked }
makeToast={ makeToast } makeToast={ makeToast }
openBugModal={ openBugModal } openBugModal={ openBugModal }
toggleHelpChat={ toggleHelpChat } toggleHelpChat={ toggleHelpChat }
unlockUntrustedCode={ unlockUntrustedCode }
updateHint={ updateHint } updateHint={ updateHint }
/> />
<Output output={ output }/> <Output output={ output }/>

View File

@ -1,7 +1,14 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { Button, ButtonGroup } from 'react-bootstrap'; import { Button, ButtonGroup, Tooltip, OverlayTrigger } from 'react-bootstrap';
import PureComponent from 'react-pure-render/component'; import PureComponent from 'react-pure-render/component';
const unlockWarning = (
<Tooltip id='tooltip'>
<h4>
<strong>Careful!</strong> Only run code you trust
</h4>
</Tooltip>
);
export default class ToolPanel extends PureComponent { export default class ToolPanel extends PureComponent {
constructor(...props) { constructor(...props) {
super(...props); super(...props);
@ -14,8 +21,10 @@ export default class ToolPanel extends PureComponent {
executeChallenge: PropTypes.func, executeChallenge: PropTypes.func,
updateHint: PropTypes.func, updateHint: PropTypes.func,
hint: PropTypes.string, hint: PropTypes.string,
isCodeLocked: PropTypes.bool,
toggleHelpChat: PropTypes.func, toggleHelpChat: PropTypes.func,
openBugModal: PropTypes.func openBugModal: PropTypes.func,
unlockUntrustedCode: PropTypes.func.isRequired
}; };
makeHint() { makeHint() {
@ -51,24 +60,55 @@ export default class ToolPanel extends PureComponent {
); );
} }
renderExecute(isCodeLocked, executeChallenge, unlockUntrustedCode) {
if (isCodeLocked) {
return (
<OverlayTrigger
overlay={ unlockWarning }
placement='right'
>
<Button
block={ true }
bsStyle='primary'
className='btn-big'
onClick={ unlockUntrustedCode }
>
Code Locked. Unlock?
</Button>
</OverlayTrigger>
);
}
return (
<Button
block={ true }
bsStyle='primary'
className='btn-big'
onClick={ executeChallenge }
>
Run tests (ctrl + enter)
</Button>
);
}
render() { render() {
const { const {
hint, hint,
isCodeLocked,
executeChallenge, executeChallenge,
toggleHelpChat, toggleHelpChat,
openBugModal openBugModal,
unlockUntrustedCode
} = this.props; } = this.props;
return ( return (
<div> <div>
{ this.renderHint(hint, this.makeHint) } { this.renderHint(hint, this.makeHint) }
<Button {
block={ true } this.renderExecute(
bsStyle='primary' isCodeLocked,
className='btn-big' executeChallenge,
onClick={ executeChallenge } unlockUntrustedCode
> )
Run tests (ctrl + enter) }
</Button>
<div className='button-spacer' /> <div className='button-spacer' />
<ButtonGroup <ButtonGroup
className='input-group' className='input-group'

View File

@ -22,6 +22,11 @@ export const fetchChallengeCompleted = createAction(
); );
export const resetUi = createAction(types.resetUi); export const resetUi = createAction(types.resetUi);
export const updateHint = createAction(types.updateHint); export const updateHint = createAction(types.updateHint);
export const lockUntrustedCode = createAction(types.lockUntrustedCode);
export const unlockUntrustedCode = createAction(
types.unlockUntrustedCode,
() => null
);
export const fetchChallenges = createAction(types.fetchChallenges); export const fetchChallenges = createAction(types.fetchChallenges);
export const fetchChallengesCompleted = createAction( export const fetchChallengesCompleted = createAction(
@ -85,6 +90,7 @@ export const moveToNextChallenge = createAction(types.moveToNextChallenge);
export const saveCode = createAction(types.saveCode); export const saveCode = createAction(types.saveCode);
export const loadCode = createAction(types.loadCode); export const loadCode = createAction(types.loadCode);
export const savedCodeFound = createAction(types.savedCodeFound); export const savedCodeFound = createAction(types.savedCodeFound);
export const clearSavedCode = createAction(types.clearSavedCode);
// video challenges // video challenges

View File

@ -1,7 +1,10 @@
import { Observable } from 'rx'; import { Observable } from 'rx';
import types from './types'; import types from './types';
import { moveToNextChallenge } from './actions'; import {
moveToNextChallenge,
clearSavedCode
} from './actions';
import { challengeSelector } from './selectors'; import { challengeSelector } from './selectors';
import { randomCompliment } from '../../../utils/get-words'; import { randomCompliment } from '../../../utils/get-words';
@ -24,7 +27,8 @@ function postChallenge(url, username, _csrf, challengeInfo) {
updateUserChallenge( updateUserChallenge(
username, username,
{ ...challengeInfo, lastUpdated, completedDate } { ...challengeInfo, lastUpdated, completedDate }
) ),
clearSavedCode()
); );
}) })
.catch(createErrorObservable); .catch(createErrorObservable);

View File

@ -45,6 +45,7 @@ const initialUiState = {
shouldShowQuestions: false shouldShowQuestions: false
}; };
const initialState = { const initialState = {
isCodeLocked: false,
id: '', id: '',
challenge: '', challenge: '',
helpChatRoom: 'Help', helpChatRoom: 'Help',
@ -88,6 +89,14 @@ const mainReducer = handleActions(
0 : 0 :
state.hintIndex + 1 state.hintIndex + 1
}), }),
[types.lockUntrustedCode]: state => ({
...state,
isCodeLocked: true
}),
[types.unlockUntrustedCode]: state => ({
...state,
isCodeLocked: false
}),
[types.executeChallenge]: state => ({ [types.executeChallenge]: state => ({
...state, ...state,
tests: state.tests.map(test => ({ ...test, err: false, pass: false })) tests: state.tests.map(test => ({ ...test, err: false, pass: false }))

View File

@ -17,6 +17,8 @@ export default createTypes([
'replaceChallenge', 'replaceChallenge',
'resetUi', 'resetUi',
'updateHint', 'updateHint',
'lockUntrustedCode',
'unlockUntrustedCode',
// map // map
'updateFilter', 'updateFilter',
@ -49,6 +51,7 @@ export default createTypes([
'saveCode', 'saveCode',
'loadCode', 'loadCode',
'savedCodeFound', 'savedCodeFound',
'clearSavedCode',
// video challenges // video challenges
'toggleQuestionView', 'toggleQuestionView',

View File

@ -1,42 +1,17 @@
import { compose } from 'redux'; import flow from 'lodash/flow';
import { bonfire, html, js } from '../../utils/challengeTypes'; import { bonfire, html, js } from '../../utils/challengeTypes';
import { decodeScriptTags } from '../../../utils/encode-decode';
import protect from '../../utils/empty-protector'; import protect from '../../utils/empty-protector';
export function encodeScriptTags(value) {
return value
.replace(/<script>/gi, 'fccss')
.replace(/<\/script>/gi, 'fcces');
}
export function decodeSafeTags(value) {
return value
.replace(/fccss/gi, '<script>')
.replace(/fcces/gi, '</script>');
}
export function encodeFormAction(value) {
return value.replace(
/<form[^>]*>/,
val => val.replace(/action(\s*?)=/, 'fccfaa$1=')
);
}
export function decodeFccfaaAttr(value) {
return value.replace(
/<form[^>]*>/,
val => val.replace(/fccfaa(\s*?)=/, 'action$1=')
);
}
export function arrayToString(seedData = ['']) { export function arrayToString(seedData = ['']) {
seedData = Array.isArray(seedData) ? seedData : [seedData]; seedData = Array.isArray(seedData) ? seedData : [seedData];
return seedData.reduce((seed, line) => '' + seed + line + '\n', '\n'); return seedData.reduce((seed, line) => '' + seed + line + '\n', '\n');
} }
export function buildSeed({ challengeSeed = [] } = {}) { export function buildSeed({ challengeSeed = [] } = {}) {
return compose( return flow(
decodeSafeTags, arrayToString,
arrayToString decodeScriptTags
)(challengeSeed); )(challengeSeed);
} }

View File

@ -2,7 +2,6 @@ import { Observable } from 'rx';
import { push } from 'react-router-redux'; import { push } from 'react-router-redux';
import { types } from './actions'; import { types } from './actions';
import combineSagas from '../../../utils/combine-sagas';
import { makeToast } from '../../../toasts/redux/actions'; import { makeToast } from '../../../toasts/redux/actions';
import { fetchChallenges } from '../../challenges/redux/actions'; import { fetchChallenges } from '../../challenges/redux/actions';
import { import {
@ -14,6 +13,7 @@ import {
import { userSelector } from '../../../redux/selectors'; import { userSelector } from '../../../redux/selectors';
import { postJSON$ } from '../../../../utils/ajax-stream'; import { postJSON$ } from '../../../../utils/ajax-stream';
import langs from '../../../../utils/supported-languages'; import langs from '../../../../utils/supported-languages';
import combineSagas from '../../../../utils/combine-sagas';
const urlMap = { const urlMap = {
isLocked: 'lockdown', isLocked: 'lockdown',

View File

@ -0,0 +1,46 @@
import flow from 'lodash/flow';
// we don't store loop protect disable key
export function removeNoprotect(val) {
return val.replace(/noprotect/gi, '');
}
export function encodeScriptTags(val) {
return val
.replace(/<script>/gi, 'fccss')
.replace(/<\/script>/gi, 'fcces');
}
export function decodeScriptTags(val) {
return val
.replace(/fccss/gi, '<script>')
.replace(/fcces/gi, '</script>');
}
export function encodeFormAction(val) {
return val.replace(
// look for attributes in a form
/<form[^>]*>/,
// val is the string within the opening form tag
// look for an `action` attribute, replace it with a fcc tag
val => val.replace(/action(\s*?)=/, 'fccfaa$1=')
);
}
export function decodeFormAction(val) {
return val.replace(
/<form[^>]*>/,
val => val.replace(/fccfaa(\s*?)=/, 'action$1=')
);
}
export const encodeFcc = flow([
removeNoprotect,
encodeFormAction,
encodeScriptTags
]);
export const decodeFcc = flow([
decodeFormAction,
decodeScriptTags
]);

View File

@ -1,3 +1,20 @@
// redux-observable compatible operator
export function ofType(...keys) {
return this.filter(({ type }) => {
const len = keys.length;
if (len === 1) {
return type === keys[0];
} else {
for (let i = 0; i < len; i++) {
if (keys[i] === type) {
return true;
}
}
}
return false;
});
}
export default function getActionsOfType(actions, ...types) { export default function getActionsOfType(actions, ...types) {
const length = types.length; const length = types.length;
return actions return actions