feature(map): Add collapse to block level

This commit is contained in:
Berkeley Martinez
2016-06-22 14:55:35 -07:00
parent b8434edd51
commit 1f02e31894
7 changed files with 241 additions and 214 deletions

View File

@ -1,97 +1,76 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { Link } from 'react-router';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import FA from 'react-fontawesome'; import FA from 'react-fontawesome';
import PureComponent from 'react-pure-render/component'; import PureComponent from 'react-pure-render/component';
import { Panel } from 'react-bootstrap'; import { Panel } from 'react-bootstrap';
import classnames from 'classnames';
import { updateCurrentChallenge } from '../../redux/actions'; import Challenge from './Challenge.jsx';
import { toggleThisPanel } from '../../redux/actions';
const dispatchActions = { updateCurrentChallenge }; const dispatchActions = { toggleThisPanel };
const mapStateToProps = createSelector(
(_, props) => props.dashedName,
state => state.entities.block,
state => state.entities.challenge,
(state, props) => state.challengesApp.mapUi[props.dashedName],
(dashedName, blockMap, challengeMap, isOpen) => {
const block = blockMap[dashedName];
return {
isOpen,
dashedName,
title: block.title,
time: block.time,
challenges: block.challenges
};
}
);
export class Block extends PureComponent { export class Block extends PureComponent {
constructor(...props) {
super(...props);
this.handleSelect = this.handleSelect.bind(this);
}
static displayName = 'Block'; static displayName = 'Block';
static propTypes = { static propTypes = {
title: PropTypes.string, title: PropTypes.string,
dashedName: PropTypes.string, dashedName: PropTypes.string,
time: PropTypes.string, time: PropTypes.string,
isOpen: PropTypes.bool,
challenges: PropTypes.array, challenges: PropTypes.array,
updateCurrentChallenge: PropTypes.func toggleThisPanel: PropTypes.func
}; };
renderChallenges(blockName, challenges, updateCurrentChallenge) { handleSelect(eventKey, e) {
e.preventDefault();
this.props.toggleThisPanel(eventKey);
}
renderChallenges(challenges) {
if (!Array.isArray(challenges) || !challenges.length) { if (!Array.isArray(challenges) || !challenges.length) {
return <div>No Challenges Found</div>; return <div>No Challenges Found</div>;
} }
return challenges.map(challenge => { return challenges.map(dashedName => (
const { <Challenge
title, dashedName={ dashedName }
dashedName, key={ dashedName }
isLocked, />
isRequired, ));
isCompleted
} = challenge;
const challengeClassName = classnames({
'text-primary': true,
'padded-ionic-icon': true,
'negative-15': true,
'challenge-title': true,
'ion-checkmark-circled faded': !isLocked && isCompleted,
'ion-ios-circle-outline': !isLocked && !isCompleted,
'ion-locked': isLocked,
disabled: isLocked
});
if (isLocked) {
return (
<p
className={ challengeClassName }
key={ title }
>
{ title }
{
isRequired ?
<span className='text-primary'><strong>*</strong></span> :
''
}
</p>
);
}
return (
<p
className={ challengeClassName }
key={ title }
>
<Link to={ `/challenges/${blockName}/${dashedName}` }>
<span
onClick={ () => updateCurrentChallenge(challenge) }
>
{ title }
<span className='sr-only'>complete</span>
{
isRequired ?
<span className='text-primary'><strong>*</strong></span> :
''
}
</span>
</Link>
</p>
);
});
} }
render() { render() {
const { const {
title, title,
time, time,
challenges, dashedName,
updateCurrentChallenge, isOpen,
dashedName challenges
} = this.props; } = this.props;
return ( return (
<Panel <Panel
bsClass='map-accordion-panel-nested' bsClass='map-accordion-panel-nested'
collapsible={ true } collapsible={ true }
expanded={ false } eventKey={ dashedName || title }
expanded={ isOpen }
header={ header={
<div> <div>
<h3><FA name='caret-right' />{ title }</h3> <h3><FA name='caret-right' />{ title }</h3>
@ -100,13 +79,12 @@ export class Block extends PureComponent {
} }
id={ title } id={ title }
key={ title } key={ title }
onSelect={ this.handleSelect }
> >
{ { this.renderChallenges(challenges) }
this.renderChallenges(dashedName, challenges, updateCurrentChallenge)
}
</Panel> </Panel>
); );
} }
} }
export default connect(null, dispatchActions)(Block); export default connect(mapStateToProps, dispatchActions)(Block);

View File

@ -0,0 +1,110 @@
import React, { PropTypes } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { Link } from 'react-router';
import PureComponent from 'react-pure-render/component';
import classnames from 'classnames';
import { updateCurrentChallenge } from '../../redux/actions';
const bindableActions = { updateCurrentChallenge };
const mapStateToProps = createSelector(
(_, props) => props.dashedName,
state => state.entities.challenge,
(dashedName, challengeMap) => {
const challenge = challengeMap[dashedName] || {};
return {
dashedName,
challenge,
title: challenge.title,
block: challenge.block,
isLocked: challenge.isLocked,
isRequired: challenge.isRequired,
isCompleted: challenge.isCompleted
};
}
);
export class Challenge extends PureComponent {
constructor(...args) {
super(...args);
}
static displayName = 'Challenge';
static propTypes = {
title: PropTypes.string,
dashedName: PropTypes.string,
block: PropTypes.string,
isLocked: PropTypes.bool,
isRequired: PropTypes.bool,
isCompleted: PropTypes.bool,
challenge: PropTypes.object,
updateCurrentChallenge: PropTypes.func
};
renderCompleted(isCompleted, isLocked) {
if (isLocked || !isCompleted) {
return null;
}
return <span className='sr-only'>completed</span>;
}
renderRequired(isRequired) {
if (!isRequired) {
return '';
}
return <span className='text-primary'><strong>*</strong></span>;
}
renderLocked(title, isRequired, className) {
return (
<p
className={ className }
key={ title }
>
{ title }
{ this.renderRequired(isRequired) }
</p>
);
}
render() {
const {
title,
dashedName,
block,
isLocked,
isRequired,
isCompleted,
challenge,
updateCurrentChallenge
} = this.props;
const challengeClassName = classnames({
'text-primary': true,
'padded-ionic-icon': true,
'negative-15': true,
'challenge-title': true,
'ion-checkmark-circled faded': !isLocked && isCompleted,
'ion-ios-circle-outline': !isLocked && !isCompleted,
'ion-locked': isLocked,
disabled: isLocked
});
if (isLocked) {
return this.renderLocked(title, isRequired, challengeClassName);
}
return (
<p
className={ challengeClassName }
key={ title }
>
<Link to={ `/challenges/${block}/${dashedName}` }>
<span onClick={ () => updateCurrentChallenge(challenge) }>
{ title }
{ this.renderCompleted(isCompleted, isLocked) }
{ this.renderRequired(isRequired) }
</span>
</Link>
</p>
);
}
}
export default connect(mapStateToProps, bindableActions)(Challenge);

View File

@ -15,26 +15,31 @@ const nonprofitProjects = {
challenges: [ challenges: [
{ {
title: 'Greenfield Nonprofit Project #1', title: 'Greenfield Nonprofit Project #1',
dashedName: 'greenfield-1',
isLocked: true, isLocked: true,
isRequired: true isRequired: true
}, },
{ {
title: 'Greenfield Nonprofit Project #2', title: 'Greenfield Nonprofit Project #2',
dashedName: 'greenfield-2',
isLocked: true, isLocked: true,
isRequired: true isRequired: true
}, },
{ {
title: 'Legacy Code Nonprofit Project #1', title: 'Legacy Code Nonprofit Project #1',
dashedName: 'legacy-1',
isLocked: true, isLocked: true,
isRequired: true isRequired: true
}, },
{ {
title: 'Legacy Code Nonprofit Project #2', title: 'Legacy Code Nonprofit Project #2',
dashedName: 'legacy-2',
isLocked: true, isLocked: true,
isRequired: true isRequired: true
}, },
{ {
title: 'Claim your Full Stack Development Certification', title: 'Claim your Full Stack Development Certification',
dashedName: 'claim-full-stack',
isLocked: true isLocked: true
} }
] ]

View File

@ -1,20 +1,24 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { connect } from 'react-redux';
import PureComponent from 'react-pure-render/component'; import PureComponent from 'react-pure-render/component';
import { InputGroup, FormControl, Button, Row } from 'react-bootstrap'; import { InputGroup, FormControl, Button, Row } from 'react-bootstrap';
import classnames from 'classnames'; import classnames from 'classnames';
import { clearFilter, updateFilter } from '../../redux/actions';
const ESC = 27;
const clearIcon = <i className='fa fa-times' />; const clearIcon = <i className='fa fa-times' />;
const searchIcon = <i className='fa fa-search' />; const searchIcon = <i className='fa fa-search' />;
const ESC = 27; const bindableActions = { clearFilter, updateFilter };
export default class Header extends PureComponent { const mapStateToProps = state => ({ filter: state.challengesApp.filter });
export class Header extends PureComponent {
constructor(...props) { constructor(...props) {
super(...props); super(...props);
this.handleKeyDown = this.handleKeyDown.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this);
} }
static displayName = 'MapHeader'; static displayName = 'MapHeader';
static propTypes = { static propTypes = {
filter: PropTypes.string,
clearFilter: PropTypes.func, clearFilter: PropTypes.func,
filter: PropTypes.string,
updateFilter: PropTypes.func updateFilter: PropTypes.func
}; };
@ -79,3 +83,4 @@ export default class Header extends PureComponent {
); );
} }
} }
export default connect(mapStateToProps, bindableActions)(Header);

View File

@ -2,141 +2,45 @@ import React, { PropTypes } from 'react';
import { compose } from 'redux'; import { compose } from 'redux';
import { contain } from 'redux-epic'; import { contain } from 'redux-epic';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import PureComponent from 'react-pure-render/component'; import PureComponent from 'react-pure-render/component';
import MapHeader from './Header.jsx'; import MapHeader from './Header.jsx';
import SuperBlock from './Super-Block.jsx'; import SuperBlock from './Super-Block.jsx';
import FullStack from './Full-Stack.jsx'; import { fetchChallenges } from '../../redux/actions';
import CodingPrep from './Coding-Prep.jsx';
import {
clearFilter,
fetchChallenges,
toggleThisPanel,
updateFilter
} from '../../redux/actions';
const bindableActions = {
clearFilter,
fetchChallenges,
toggleThisPanel,
updateFilter
};
const superBlocksSelector = createSelector(
state => state.challengesApp.superBlocks,
state => state.entities.superBlock,
state => state.entities.block,
state => state.entities.challenge,
(superBlocks, superBlockMap, blockMap, challengeMap) => {
if (!superBlockMap || !blockMap || !challengeMap) {
return {
superBlocks: []
};
}
return {
superBlocks: superBlocks
.map(superBlockName => superBlockMap[superBlockName])
.map(superBlock => ({
...superBlock,
blocks: superBlock.blocks
.map(blockName => blockMap[blockName])
.map(block => ({
...block,
challenges: block.challenges
.map(dashedName => challengeMap[dashedName])
}))
}))
};
}
);
const mapStateToProps = createSelector(
superBlocksSelector,
state => state.challengesApp.filter,
state => state.challengesApp.map,
({ superBlocks }, filter, mapUi) => {
return {
superBlocks,
filter,
mapUi
};
}
);
const bindableActions = { fetchChallenges };
const mapStateToProps = state => ({
superBlocks: state.challengesApp.superBlocks
});
const fetchOptions = { const fetchOptions = {
fetchAction: 'fetchChallenges', fetchAction: 'fetchChallenges',
isPrimed({ superBlocks }) { isPrimed({ superBlocks }) {
return Array.isArray(superBlocks) && superBlocks.length > 1; return Array.isArray(superBlocks) && superBlocks.length > 1;
} }
}; };
export class ShowMap extends PureComponent { export class ShowMap extends PureComponent {
static displayName = 'Map'; static displayName = 'Map';
static propTypes = { static propTypes = { superBlocks: PropTypes.array };
clearFilter: PropTypes.func,
filter: PropTypes.string,
superBlocks: PropTypes.array,
updateFilter: PropTypes.func,
mapUi: PropTypes.object
};
renderSuperBlocks( renderSuperBlocks(superBlocks) {
superBlocks,
updateCurrentChallenge,
mapUi,
toggleThisPanel
) {
if (!Array.isArray(superBlocks) || !superBlocks.length) { if (!Array.isArray(superBlocks) || !superBlocks.length) {
return <div>No Super Blocks</div>; return <div>No Super Blocks</div>;
} }
return superBlocks return superBlocks.map(dashedName => (
.map((superBlock) => { <SuperBlock
return ( dashedName={ dashedName }
<SuperBlock key={ dashedName }
key={ superBlock.title } />
mapUi={ mapUi } ));
toggleThisPanel={ toggleThisPanel }
updateCurrentChallenge={ updateCurrentChallenge }
{ ...superBlock }
/>
);
});
} }
render() { render() {
const { const { superBlocks } = this.props;
updateCurrentChallenge,
superBlocks,
updateFilter,
clearFilter,
filter,
mapUi,
toggleThisPanel
} = this.props;
return ( return (
<div> <div>
<MapHeader <MapHeader />
clearFilter={ clearFilter }
filter={ filter }
updateFilter={ updateFilter }
/>
<div className='map-accordion'> <div className='map-accordion'>
{ { this.renderSuperBlocks(superBlocks) }
this.renderSuperBlocks(
superBlocks,
updateCurrentChallenge,
mapUi,
toggleThisPanel
)
}
<FullStack
mapUi={ mapUi }
toggleThisPanel={ toggleThisPanel }
/>
<CodingPrep
mapUi={ mapUi }
toggleThisPanel={ toggleThisPanel }
/>
<div className='spacer' /> <div className='spacer' />
</div> </div>
</div> </div>

View File

@ -1,22 +1,36 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import PureComponent from 'react-pure-render/component'; import PureComponent from 'react-pure-render/component';
import FA from 'react-fontawesome'; import FA from 'react-fontawesome';
import { Panel } from 'react-bootstrap'; import { Panel } from 'react-bootstrap';
import Block from './Block.jsx'; import Block from './Block.jsx';
import { toggleThisPanel } from '../../redux/actions';
export default class SuperBlock extends PureComponent { const dispatchActions = { toggleThisPanel };
constructor(...props) { const mapStateToProps = createSelector(
super(...props); (_, props) => props.dashedName,
this.handleSelect = this.handleSelect.bind(this); state => state.entities.superBlock,
(state, props) => state.challengesApp.mapUi[props.dashedName],
(dashedName, superBlockMap, isOpen) => ({
isOpen,
title: superBlockMap[dashedName].title,
blocks: superBlockMap[dashedName].blocks
})
);
export class SuperBlock extends PureComponent {
constructor(...props) {
super(...props);
this.handleSelect = this.handleSelect.bind(this);
} }
static displayName = 'SuperBlock'; static displayName = 'SuperBlock';
static propTypes = { static propTypes = {
title: PropTypes.string, title: PropTypes.string,
dashedName: PropTypes.string, dashedName: PropTypes.string,
message: PropTypes.string,
blocks: PropTypes.array, blocks: PropTypes.array,
mapUi: PropTypes.object, isOpen: PropTypes.bool,
message: PropTypes.string,
toggleThisPanl: PropTypes.func toggleThisPanl: PropTypes.func
}; };
@ -29,36 +43,45 @@ export default class SuperBlock extends PureComponent {
if (!Array.isArray(blocks) || !blocks.length) { if (!Array.isArray(blocks) || !blocks.length) {
return <div>No Blocks Found</div>; return <div>No Blocks Found</div>;
} }
return blocks.map(block => { return blocks.map(dashedName => (
return ( <Block
<Block dashedName={ dashedName }
key={ block.title } key={ dashedName }
{ ...block } />
/> ));
); }
});
renderMessage(message) {
if (!message) {
return null;
}
return (
<div className='challenge-block-description'>
{ message }
</div>
);
} }
render() { render() {
const { title, dashedName, blocks, message, mapUi = {} } = this.props; const {
title,
dashedName,
blocks,
message,
isOpen
} = this.props;
return ( return (
<Panel <Panel
bsClass='map-accordion-panel' bsClass='map-accordion-panel'
collapsible={ true } collapsible={ true }
eventKey={ dashedName || title } eventKey={ dashedName || title }
expanded={ dashedName ? mapUi[dashedName] : true } expanded={ isOpen }
header={ <h2><FA name='caret-right' />{ title }</h2> } header={ <h2><FA name='caret-right' />{ title }</h2> }
id={ title } id={ title }
key={ dashedName || title } key={ dashedName || title }
onSelect={ this.handleSelect } onSelect={ this.handleSelect }
> >
{ { this.renderMessage(message) }
message ?
<div className='challenge-block-description'>
{ message }
</div> :
''
}
<div <div
className='map-accordion-block' className='map-accordion-block'
> >
@ -68,3 +91,8 @@ export default class SuperBlock extends PureComponent {
); );
} }
} }
export default connect(
mapStateToProps,
dispatchActions
)(SuperBlock);

View File

@ -46,10 +46,7 @@ const initialState = {
legacyKey: '', legacyKey: '',
files: {}, files: {},
// map // map
map: { mapUi: {},
'full-stack': true,
'coding-prep': true
},
filter: '', filter: '',
superBlocks: [], superBlocks: [],
// misc // misc
@ -251,7 +248,7 @@ const mapReducer = handleActions(
[payload]: !state[payload] [payload]: !state[payload]
}) })
}, },
initialState.map initialState.mapUi
); );
export default function challengeReducers(state, action) { export default function challengeReducers(state, action) {
@ -261,9 +258,9 @@ export default function challengeReducers(state, action) {
return { ...newState, files }; return { ...newState, files };
} }
// map actions only effect this reducer; // map actions only effect this reducer;
const map = mapReducer(state && state.map || {}, action); const mapUi = mapReducer(state && state.mapUi || {}, action);
if (newState.map !== map) { if (newState.mapUi !== mapUi) {
return { ...newState, map }; return { ...newState, mapUi };
} }
return newState; return newState;
} }