* Fixes #541 - Moderation page

* code cleanup

* changed how moderation page shows and works by default

* code improvement

* thumbnails on moderation page should not be hyperlinks & show moderation navlink

* extracted ModerationPanel and Details to their own files

* changed border style

* thinner border
This commit is contained in:
Mavis Ou 2017-07-11 15:24:10 -07:00 коммит произвёл GitHub
Родитель 909dc101cf
Коммит 9d6cd8cdbc
19 изменённых файлов: 427 добавлений и 104 удалений

Просмотреть файл

@ -52,7 +52,7 @@ The port number you are using. e.g., `PORT=3000`
Default: `https://network-pulse-api-staging.herokuapp.com/`
URL to your local Pulse API instance (if you have one set up). e.g., `PULSE_API=http://test.example.com:8000`
URL to your local Pulse API instance (if you have one set up). e.g., `PULSE_API=http://test.example.com:8000/api/pulse`
To set up a local instance of Pulse API, follow instructions on https://github.com/mozilla/network-pulse-api/blob/master/README.md.

31
components/details.jsx Normal file
Просмотреть файл

@ -0,0 +1,31 @@
import React from 'react';
import ReactGA from 'react-ga';
import PropTypes from 'prop-types';
class Details extends React.Component {
handleVisitBtnClick() {
ReactGA.event(this.props.createGaEventConfig(`Visit button`, `Clicked`, `beacon`));
}
handleGetInvolvedLinkClick() {
ReactGA.event(this.props.createGaEventConfig(`Get involved`, `Clicked`, `beacon`));
}
render() {
let props = this.props;
let getInvolvedText = props.getInvolved ? props.getInvolved : null;
let getInvolvedLink = props.getInvolvedUrl ? ( <a href={props.getInvolvedUrl} target="_blank" onClick={this.handleGetInvolvedLinkClick}>Get Involved</a>) : null;
return props.onDetailView || props.onModerationMode ?
(<div>
{ props.interest ? <p className="interest">{props.interest}</p> : null }
{ getInvolvedText || getInvolvedLink ? <p className="get-involved">{getInvolvedText} {getInvolvedLink}</p> : null }
{ props.contentUrl ? <a href={props.contentUrl} target="_blank" className="btn btn-block btn-outline-info mb-3" onClick={this.handleVisitBtnClick}>Visit</a> : null }
</div>) : null;
}
}
Details.propTypes = {
createGaEventConfig: PropTypes.func.isRequired
};
export default Details;

Просмотреть файл

@ -0,0 +1,85 @@
import React from 'react';
import Select from 'react-select';
import Service from '../js/service.js';
class ModerationPanel extends React.Component {
constructor(props) {
super(props);
this.state = {
moderationState: this.props.moderationState
};
}
getModerationStates(input, callback) {
Service.moderationStates
.get()
.then((data) => {
let options = data.map((option) => {
return { value: option.id, label: option.name };
});
callback(null, {options});
})
.catch((reason) => {
console.error(reason);
});
}
getNonce(callback) {
Service.nonce()
.then((nonce) => {
callback(false, nonce);
})
.catch((reason) => {
callback(new Error(`Could not retrieve data from /nonce. Reason: ${reason}`));
});
}
handleModerationStateChange(selected) {
this.getNonce((error, nonce) => {
if (error) {
console.error(error);
return;
}
let formattedNonce = {
nonce: nonce.nonce,
csrfmiddlewaretoken: nonce.csrf_token
};
Service.entry
.put.moderationState(this.props.id, selected.value, formattedNonce)
.then(() => {
this.setState({ moderationState: selected });
})
.catch(reason => {
this.setState({
serverError: true
});
console.error(reason);
});
});
}
render() {
return <div className="moderation-panel p-3">
<Select.Async
name="form-field-name"
value={this.state.moderationState}
className="d-block text-left"
searchable={false}
loadOptions={(input, callback) => this.getModerationStates(input, callback)}
onChange={(selected) => this.handleModerationStateChange(selected)}
clearable={false}
/>
</div>;
}
}
ModerationPanel.defaultProps = {
id: ``,
moderationState: ``
};
export default ModerationPanel;

Просмотреть файл

@ -1,6 +1,7 @@
import React from 'react';
import { IndexLink } from 'react-router';
import NavLink from '../nav-link/nav-link.jsx';
import user from '../../js/app-user';
class NavListItem extends React.Component {
render() {
@ -11,13 +12,39 @@ class NavListItem extends React.Component {
}
class NavBar extends React.Component {
constructor(props) {
super(props);
this.state = { user };
}
componentDidMount() {
user.addListener(this);
user.verify();
}
componentWillUnmount() {
user.removeListener(this);
}
updateUser(event) {
// this updateUser method is called by "user" after changes in the user state happened
if (event === `verified` ) {
this.setState({ user });
}
}
renderModeratorLink() {
if (!user.moderator) return null;
return <NavListItem><NavLink to="/moderation" className="text-nav-link bookmarks">Moderation</NavLink></NavListItem>;
}
render() {
// We have renamed all non user facing "favorites" related variables and text (e.g., favs, faved, etc) to "bookmarks".
// This is because we want client side code to match what Pulse API uses (i.e., bookmarks)
// For user facing bits like UI labels and URL path we want them to stay as "favorites".
// That's why a link like <NavLink to="/favs" className="text-nav-link bookmarks">Favs</NavLink> is seen here.
// For more info see: https://github.com/mozilla/network-pulse/issues/326
return (
<div className="navbar">
<div className="container">
@ -30,6 +57,7 @@ class NavBar extends React.Component {
<NavListItem><NavLink to="/latest" className="text-nav-link">Latest</NavLink></NavListItem>
<NavListItem><NavLink to="/issues" className="text-nav-link">Issues</NavLink></NavListItem>
<NavListItem><NavLink to="/favs" className="text-nav-link bookmarks">Favs</NavLink></NavListItem>
{ this.renderModeratorLink() }
<NavListItem><NavLink to="/search" className="btn-search"><i className="fa fa-search"/><span className="sr-only">Search</span></NavLink></NavListItem>
<NavListItem><NavLink to="/add" className="btn-add"><img src="/assets/svg/icon-plus.svg" /></NavLink></NavListItem>
</ul>

Просмотреть файл

@ -4,35 +4,11 @@ import { Link } from 'react-router';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import moment from 'moment';
import Details from '../details.jsx';
import ModerationPanel from '../moderation-panel.jsx';
import { getBookmarks, saveBookmarks } from '../../js/bookmarks-manager';
import Utility from '../../js/utility.js';
class Details extends React.Component {
handleVisitBtnClick() {
ReactGA.event(this.props.createGaEventConfig(`Visit button`, `Clicked`, `beacon`));
}
handleGetInvolvedLinkClick() {
ReactGA.event(this.props.createGaEventConfig(`Get involved`, `Clicked`, `beacon`));
}
render() {
let props = this.props;
let getInvolvedText = props.getInvolved ? props.getInvolved : null;
let getInvolvedLink = props.getInvolvedUrl ? ( <a href={props.getInvolvedUrl} target="_blank" onClick={this.handleGetInvolvedLinkClick}>Get Involved</a>) : null;
return props.onDetailView ?
(<div>
{ props.interest ? <p className="interest">{props.interest}</p> : null }
{ getInvolvedText || getInvolvedLink ? <p className="get-involved">{getInvolvedText} {getInvolvedLink}</p> : null }
{ props.contentUrl ? <a href={props.contentUrl} target="_blank" className="btn btn-block btn-outline-info mb-3" onClick={this.handleVisitBtnClick}>Visit</a> : null }
</div>) : null;
}
}
Details.propTypes = {
createGaEventConfig: PropTypes.func.isRequired
};
class ProjectCard extends React.Component {
constructor(props) {
super(props);
@ -145,7 +121,7 @@ class ProjectCard extends React.Component {
renderTitle(detailViewLink) {
let title = this.props.title;
if (!this.props.onDetailView) {
if (!this.props.onDetailView && !this.props.onModerationMode) {
title = <Link to={detailViewLink} onClick={this.handleTitleClick}>{title}</Link>;
}
@ -153,15 +129,18 @@ class ProjectCard extends React.Component {
}
renderThumbnail(detailViewLink) {
let thumbnailSource = this.props.thumbnail;
if (!this.props.thumbnail) return null;
if (!thumbnailSource) return null;
let classnames = `thumbnail`;
let thumbnail = <div className="img-container"><img src={this.props.thumbnail} /></div>;
if (this.props.onDetailView) {
return <div className={classnames}>{thumbnail}</div>;
}
return (
<Link to={detailViewLink} onClick={this.handleThumbnailClick} className="thumbnail">
<div className="img-container">
<img src={thumbnailSource} />
</div>
<Link to={detailViewLink} onClick={this.handleThumbnailClick} className={classnames}>
{thumbnail}
</Link>
);
}
@ -169,14 +148,14 @@ class ProjectCard extends React.Component {
renderActionPanel(detailViewLink) {
let actionPanel = null;
if (this.props.onDetailView) {
if (this.props.onDetailView || this.props.onModerationMode) {
let twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(this.props.title)}&url=${encodeURIComponent(window.location.href)}`;
actionPanel = (
<div className="d-flex share">
<a href={twitterUrl} onClick={evt => this.handleTwitterShareClick(evt) } className="btn twitter-share d-inline-block align-self-center mr-3"></a>
<div className="reveal-url">
<a className="btn" onClick={evt => this.handleShareBtnClick(evt)} ref={(btn) => { this.shareBtn = btn; }}></a>
<input creadOnly type="text" ref={(input) => { this.urlToShare = input; }} className="form-control px-2" />
<input readOnly type="text" ref={(input) => { this.urlToShare = input; }} className="form-control px-2" />
</div>
</div>
);
@ -217,11 +196,15 @@ class ProjectCard extends React.Component {
}
renderDescription() {
return this.props.description.split(`\n`).map(paragraph => <p>{paragraph}</p>);
return this.props.description.split(`\n`).map((paragraph) => {
if (!paragraph) return null;
return <p key={paragraph}>{paragraph}</p>;
});
}
renderIssuesAndTags() {
if (!this.props.onDetailView) return null;
if (!this.props.onDetailView && !this.props.onModerationMode) return null;
let issues = this.props.issues.map(issue => {
return <Link to={`/issues/${Utility.getUriPathFromIssueName(issue)}`} className="btn btn-xs btn-tag" key={issue}>{issue}</Link>;
@ -234,6 +217,12 @@ class ProjectCard extends React.Component {
return <div>{issues}{tags}</div>;
}
renderModerationPanel() {
if (!this.props.onModerationMode) return null;
return <ModerationPanel id={this.props.id} moderationState={this.props.moderationState} />;
}
render() {
let wrapperClassnames = classNames({
"col-md-6": !this.props.onDetailView,
@ -244,6 +233,7 @@ class ProjectCard extends React.Component {
let classnames = classNames({
"project-card": true,
"detail-view": this.props.onDetailView,
"moderation-mode": this.props.onModerationMode,
"bookmarked": this.state.bookmarked
});
@ -252,6 +242,7 @@ class ProjectCard extends React.Component {
return (
<div className={wrapperClassnames}>
<div className={classnames}>
{ this.renderModerationPanel() }
<div className="main-content">
{this.renderThumbnail(detailViewLink)}
<div className="content m-3">
@ -294,7 +285,8 @@ ProjectCard.propTypes = {
};
ProjectCard.defaultProps = {
onDetailView: false
onDetailView: false,
moderationState: undefined // id of the moderation
};
export default ProjectCard;

Просмотреть файл

@ -8,13 +8,9 @@
&.detail-view {
margin: 0 auto;
h2 {
color: $body-color;
}
}
&:not(.detail-view) {
&:not(.detail-view):not(.moderation-mode) {
.main-content {
position: relative;
height: 350px;
@ -31,6 +27,7 @@
}
h2 {
color: $body-color;
font-size: 30px;
line-height: 1;
margin: 0 0 10px;
@ -150,6 +147,10 @@
transform: scale(1);
}
}
.moderation-panel {
background: $gray-lightest;
}
}
// >= 768px

Просмотреть файл

@ -31,7 +31,7 @@ class ProjectList extends React.Component {
renderProjectCards() {
return this.props.entries.map(project => {
return <ProjectCard key={project.id} {...Utility.processEntryData(project)} />;
return <ProjectCard key={project.id} onModerationMode={this.props.onModerationMode} {...Utility.processEntryData(project)} />;
});
}
@ -68,9 +68,9 @@ ProjectList.propTypes = {
entries: PropTypes.array.isRequired,
loadingData: PropTypes.bool.isRequired,
moreEntriesToFetch: PropTypes.bool.isRequired,
fetchData: PropTypes.func.isRequired
fetchData: PropTypes.func.isRequired,
};
ProjectCard.defaultProps = {
ProjectList.defaultProps = {
entries: []
};

Просмотреть файл

@ -37,6 +37,7 @@ export default React.createClass({
let combinedParams = Object.assign({},params);
if (combinedParams.ids) {
// The `ids` query param is only used on the bookmarks(favs) page
// We want to display bookmarked projects by the time they were bookmarked.
// There are a few steps to make this happen:
// 1) first we fetch projects from Pulse API in a batch of size PROJECT_BATCH_SIZE.
@ -54,6 +55,13 @@ export default React.createClass({
return Object.assign(combinedParams, { page: 1 });
}
if (combinedParams.moderationState) {
// "moderationstate" is the query param the API understands (case sensitive)
// and its value should just be the name of the moderation state
combinedParams.moderationstate = combinedParams.moderationState.label;
delete combinedParams.moderationState;
}
return Object.assign(combinedParams, { page: this.state.nextBatchIndex });
},
fetchData(params = this.props) {
@ -120,34 +128,27 @@ export default React.createClass({
return <div><p>Discover & collaborate on projects for a healthy internet. <a href="https://www.mozillapulse.org/entry/120">Learn more</a>.</p></div>;
},
renderSearchResult() {
if (!this.props.search || this.state.loadingData) return null;
let total = this.state.totalMatched,
plural = (total === 0 || total > 1), // because "0 results"
term = this.props.search,
searchResultNotice = `${total} result${plural ? `s` : ``} found for ${term}`;
return <p>{searchResultNotice}</p>;
},
renderEntryCounter() {
if (this.state.loadingData) return null;
if (!this.props.issue && !this.props.tag) return null;
if (!this.props.search && !this.props.moderationState && !this.props.issue && !this.props.tag) return null;
return <p>{this.state.totalMatched} result{this.state.totalMatched > 0 ? `s` : ``} found</p>;
let counterText = `${this.state.totalMatched} result${this.state.totalMatched > 0 ? `s` : ``} found`;
let searchKeyword = this.props.search;
return <p>{`${counterText}${searchKeyword ? ` for ${searchKeyword}` : ``}`}</p>;
},
render() {
return (
<div>
{ this.renderTagHeader() }
{ this.renderLearnMoreNotice()}
{ this.renderSearchResult() }
{ this.renderEntryCounter() }
<ProjectList entries={this.state.entries}
loadingData={this.state.loadingData}
moreEntriesToFetch={this.state.moreEntriesToFetch}
fetchData={this.fetchData}
restoreScrollPosition={pageSettings.shouldRestore} />
restoreScrollPosition={pageSettings.shouldRestore}
onModerationMode={!!this.props.moderationState} />
</div>
);
}

Просмотреть файл

@ -1,2 +1,2 @@
HOST=https://pulse-react.herokuapp.com
PULSE_API=https://network-pulse-api-staging.herokuapp.com
PULSE_API=https://network-pulse-api-staging.herokuapp.com/api/pulse

Просмотреть файл

@ -39,7 +39,7 @@ const Login = {
// verify user's logged in status with Pulse API
Service.userstatus()
.then(response => {
setUserData(false, response.username);
setUserData(false, response.username, response.moderator);
})
.catch(reason => {
console.error(reason);
@ -83,6 +83,7 @@ class User {
resetUser() {
this.username = undefined;
this.loggedin = false;
this.moderator = false;
this.failedLogin = false;
// We do not touch the "attemptingLogin" value in localStorage.
// It is up to the login/verify/logout/update functions to
@ -119,8 +120,8 @@ class User {
}
verify(location) {
Login.isLoggedIn(location, (error, username) => {
this.update(error, username);
Login.isLoggedIn(location, (error, username, moderator) => {
this.update(error, username, moderator);
});
}
@ -135,7 +136,7 @@ class User {
});
}
update(error, username=false) {
update(error, username=false, moderator=false) {
if (error) {
console.log(`login error:`, error);
}
@ -150,6 +151,7 @@ class User {
// bind the user values
this.loggedin = !!username;
this.moderator = !!moderator;
this.username = username;
// notify listeners that this user logged in state has been verified

Просмотреть файл

@ -1,3 +1,4 @@
import url from 'url';
import env from "../config/env.generated.json";
export default {
@ -28,7 +29,7 @@ export default {
],
connectSrc: [
`'self'`,
env.PULSE_API || `https://network-pulse-api-staging.herokuapp.com/`
url.parse(env.PULSE_API).host || `https://network-pulse-api-staging.herokuapp.com/`
],
childSrc: [
`'none'`

Просмотреть файл

@ -121,21 +121,24 @@ function callURL(route) {
}
/**
* Make an POST XHR request and return a promise to resolve it.
* Make a POST or PUT XHR request and return a promise to resolve it.
* @param {Object} params A key-value object to be posted
* @returns {Promise} A promise to resolve an XHR request
*/
function postEntry(entryData) {
function updateEntry(requestType = ``, endpointRoute, entryData) {
let dataToSend = requestType === `POST` ? JSON.stringify(entryData) : ``;
let request = new XMLHttpRequest();
return new Promise((resolve, reject) => {
request.open(`POST`, `${pulseAPI}/entries/`, true);
request.open(requestType, endpointRoute, true);
request.withCredentials = true;
request.onload = (event) => {
let result = event.currentTarget;
if (result.status >= 200 && result.status < 400) {
if (result.response.length === 0) resolve();
let data;
try {
@ -157,10 +160,11 @@ function postEntry(entryData) {
request.setRequestHeader(`X-CSRFToken`, entryData.csrfmiddlewaretoken);
request.setRequestHeader(`Content-Type`,`application/json`);
request.send(JSON.stringify(entryData));
request.send(dataToSend);
});
}
export default {
entries: {
get: function(params,token) {
@ -171,12 +175,22 @@ export default {
return getDataFromURL(`${pulseAPI}/entries/`, Object.assign(params, defaultParams), token);
},
post: function(entryData) {
return postEntry(entryData);
return updateEntry(`POST`,`${pulseAPI}/entries/`, entryData);
}
},
entry: {
get: function(entryId) {
return getDataFromURL(`${pulseAPI}/entries/${entryId}/`);
},
put: {
moderationState: function(entryId, stateId, nonce) {
return updateEntry(`PUT`,`${pulseAPI}/entries/${entryId}/moderate/${stateId}`, nonce);
}
}
},
moderationStates: {
get: function() {
return getDataFromURL(`${pulseAPI}/entries/moderation-states/`);
}
},
issues: {

10
package-lock.json сгенерированный
Просмотреть файл

@ -3550,11 +3550,21 @@
"resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-5.1.3.tgz",
"integrity": "sha1-zUBiZZOinuz2hLbTjXEfRMSBiK8="
},
"react-input-autosize": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/react-input-autosize/-/react-input-autosize-1.1.4.tgz",
"integrity": "sha1-y8RQctQITdxXgG2447NOZEuDZqw="
},
"react-router": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-3.0.5.tgz",
"integrity": "sha1-w7eHN1gEWou8lWKu9P9LyMznwTY="
},
"react-select": {
"version": "1.0.0-rc.5",
"resolved": "https://registry.npmjs.org/react-select/-/react-select-1.0.0-rc.5.tgz",
"integrity": "sha1-nTFvJSsa3Dct21zfHxGca3z9tdY="
},
"react-side-effect": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-1.1.3.tgz",

Просмотреть файл

@ -9,11 +9,11 @@
"build": "run-s bootstrap build:**",
"build:config": "node js/build-env-config.js > config/env.generated.json",
"build:client": "run-s build-client:**",
"build-client:js": "webpack --config webpack.config.js --optimize-minimize --display-error-details --colors",
"build-client:js": "webpack --config webpack.config.js --display-error-details --colors",
"build-client:scss": "node-sass scss/main.scss dist/css/main.css",
"build-client:prefix:main": "postcss --use autoprefixer -o dist/css/main.css dist/css/main.css",
"build-client:static": "run-s copy:**",
"build:server": "webpack --config webpack.server.config.js --optimize-minimize --display-error-details --colors",
"build:server": "webpack --config webpack.server.config.js --display-error-details --colors",
"copy:styling-dependency": "run-s bootstrap:clean bootstrap:fontawesome bootstrap:prefix",
"bootstrap:clean": "shx cp -r node_modules/mofo-bootstrap/dest/css dist",
"bootstrap:fontawesome": "shx cp -r node_modules/font-awesome/css/font-awesome.min.css dist/css && shx cp -r node_modules/font-awesome/fonts dist",
@ -91,6 +91,7 @@
"react-ga": "^2.1.2",
"react-helmet": "^5.0.3",
"react-router": "^3.0.0",
"react-select": "^1.0.0-rc.5",
"react-tag-autocomplete": "^5.4.0",
"shx": "^0.2.1",
"superagent": "^3.3.0",

37
pages/moderation.jsx Normal file
Просмотреть файл

@ -0,0 +1,37 @@
import React from 'react';
import Search from './search/search.jsx';
import NotFound from './not-found.jsx';
import user from '../js/app-user';
class Moderation extends React.Component {
constructor(props) {
super(props);
this.state = {
user
};
}
componentDidMount() {
user.addListener(this);
user.verify(this.props.router.location);
}
componentWillUnmount() {
user.removeListener(this);
}
updateUser(event) {
// this updateUser method is called by "user" after changes in the user state happened
if (event === `verified` ) {
this.setState({ user });
}
}
render() {
if (!user.moderator) return <NotFound />;
return <Search moderation={true} {...this.props} />;
}
}
export default Moderation;

Просмотреть файл

@ -3,40 +3,59 @@ import { browserHistory } from 'react-router';
import { Helmet } from "react-helmet";
import classNames from 'classnames';
import DebounceInput from 'react-debounce-input';
import Select from 'react-select';
import ReactGA from 'react-ga';
import Service from '../../js/service.js';
import ProjectLoader from '../../components/project-loader/project-loader.jsx';
export default React.createClass({
getInitialState() {
return {
keywordSearched: ``
};
},
const DEFALUT_MODERATION_FILTER = `Pending`;
class Search extends React.Component {
constructor(props) {
super(props);
this.state = this.getSearchCriteria(props);
}
componentWillReceiveProps(nextProps) {
// when window.history.back() or windows.history.forward() is triggered
// (e.g., clicking on browser's back / forward button)
// we want to make sure search result gets updated accordingly
this.setState({ keywordSearched: nextProps.location.query.keyword });
},
this.setState(this.getSearchCriteria(nextProps));
}
componentDidMount() {
this.setState({ keywordSearched: this.props.location.query.keyword });
// The focus() function of <input /> isn't exposed by <DebounceInput />
// Ticket filed on the 'react-debounce-input' repo https://github.com/nkbt/react-debounce-input/issues/65
// In the meanwhile, we have to rely on document.querySelector(`#search-box`) to trigger input's focus() function
document.querySelector(`#search-box`).focus();
},
}
getSearchCriteria(theProps) {
return {
keywordSearched: theProps.location.query.keyword,
moderationState: { value: ``, label: theProps.location.query.moderationstate || DEFALUT_MODERATION_FILTER }
};
}
updateBrowserHistory() {
let keywordSearched = this.state.keywordSearched;
let location = {
pathname: this.props.router.location.pathname
};
let moderationState = this.state.moderationState;
let location = { pathname: this.props.router.location.pathname };
let query = {};
if ( keywordSearched ) {
location[`query`] = { keyword: keywordSearched };
query.keyword = keywordSearched;
}
if ( moderationState ) {
query.moderationstate = moderationState.label;
}
location.query = query;
browserHistory.push(location);
},
}
handleInputChange(event) {
let keywordsEntered = event.target.value;
@ -49,7 +68,8 @@ export default React.createClass({
this.setState({ keywordSearched: keywordsEntered }, () => {
this.updateBrowserHistory();
});
},
}
handleDismissBtnClick() {
this.setState({ keywordSearched: `` }, () => {
this.updateBrowserHistory();
@ -58,22 +78,103 @@ export default React.createClass({
// In the meanwhile, we have to rely on document.querySelector(`#search-box`) to trigger input's focus() function
document.querySelector(`#search-box`).focus();
});
},
}
renderSearchBar() {
let classnames = classNames({activated: true, 'search-bar': true, 'w-100': true, 'mb-0': true});
let label;
if (this.props.moderation) {
label = <div className="mr-2">Keywords:</div>;
}
return <div className="d-flex align-items-center">
{label}
<div className={classnames}>
<DebounceInput id="search-box"
value={this.state.keywordSearched}
debounceTimeout={300}
type="search"
onChange={(event) => this.handleInputChange(event)}
placeholder="Search keywords, people, tags..." />
<button className="btn dismiss" onClick={() => this.handleDismissBtnClick()}>&times;</button>
</div>
</div>;
}
getModerationStates(input, callback) {
Service.moderationStates
.get()
.then((mStates) => {
let options = mStates.map((mState) => {
return { value: mState.id, label: mState.name };
});
callback(null, {options});
})
.catch((reason) => {
console.error(reason);
});
}
handleFilterChange(selected) {
this.setState({ moderationState: selected }, () => {
this.updateBrowserHistory();
});
}
renderStateFilter() {
return <div className="d-flex align-items-center mb-3">
<div className="mr-2">Filter by moderation state:</div>
<Select.Async
name="form-field-name"
value={this.state.moderationState}
className="state-filter d-inline-block text-left"
searchable={false}
clearable={false}
cache={false}
placeholder="Moderation state"
loadOptions={(input, callback) => this.getModerationStates(input, callback)}
onChange={(selected) => this.handleFilterChange(selected)}
/>
</div>;
}
renderSearchControls() {
if (this.props.moderation) {
return <div className="moderation-search-controls mb-4 pb-4">
<h2>Moderation</h2>
<div>
{ this.renderStateFilter() }
{ this.renderSearchBar() }
</div>
</div>;
}
return <div>{ this.renderSearchBar() }</div>;
}
renderProjects() {
if (!this.state.keywordSearched && !this.props.moderation) return null;
if (this.props.moderation) {
return <ProjectLoader search={this.state.keywordSearched} moderationState={this.state.moderationState} />;
}
if (this.state.keywordSearched) {
return <ProjectLoader search={this.state.keywordSearched} />;
}
}
render() {
return (
<div className="search-page">
<Helmet><title>{this.state.keywordSearched}</title></Helmet>
<div className={classNames({activated: true, 'search-bar': true})}>
<DebounceInput id="search-box"
value={this.state.keywordSearched}
debounceTimeout={300}
type="search"
onChange={this.handleInputChange}
placeholder="Search keywords, people, tags..." />
<button className="btn dismiss" onClick={this.handleDismissBtnClick}>&times;</button>
</div>
{ this.state.keywordSearched ? <ProjectLoader search={this.state.keywordSearched} /> : null }
{ this.renderSearchControls() }
{ this.renderProjects() }
</div>
);
}
});
}
export default Search;

Просмотреть файл

@ -1,3 +1,7 @@
.moderation-search-controls {
border-bottom: 3px solid $gray-lighter;
}
.search-bar {
position: relative;
@ -40,3 +44,16 @@
box-sizing: border-box;
}
}
.state-filter {
width: 100%;
@media screen and (min-width: $bp-md) {
width: 130px;
}
.Select-control {
border: 0;
border-radius: 0;
}
}

Просмотреть файл

@ -1,7 +1,6 @@
import React from 'react';
import { Route, IndexRoute, IndexRedirect } from 'react-router';
import { Helmet } from "react-helmet";
import localstorage from './js/localstorage.js';
import pageSettings from './js/app-page-settings';
import ProjectLoader from './components/project-loader/project-loader.jsx';
@ -12,6 +11,7 @@ import Entry from './pages/entry.jsx';
import Add from './pages/add/add.jsx';
import Submitted from './pages/add/submitted.jsx';
import Search from './pages/search/search.jsx';
import Moderation from './pages/moderation.jsx';
import NotFound from './pages/not-found.jsx';
import Navbar from './components/navbar/navbar.jsx';
@ -83,6 +83,7 @@ module.exports = (
<IndexRedirect to="/latest" />
<Route path=":tag" component={Tag} onEnter={evt => pageSettings.setCurrentPathname(evt.location.pathname)} />
</Route>
<Route path="moderation" component={Moderation} onEnter={evt => pageSettings.setCurrentPathname(evt.location.pathname)} />
<Route path="*" component={NotFound}/>
</Route>
);

Просмотреть файл

@ -8,6 +8,7 @@
@import '../node_modules/mofo-bootstrap/src/scss/overrides/mixins.scss';
@import '../node_modules/bootstrap/scss/buttons.scss';
@import '../node_modules/bootstrap/scss/forms.scss';
@import '../node_modules/react-select/scss/default.scss';
// mixins