Merge branch 'master' into subscribe-mod-actions

Conflicts:
	client/coral-admin/src/routes/Moderation/components/Moderation.js
	client/coral-admin/src/routes/Moderation/components/styles.css
	client/coral-admin/src/routes/Moderation/containers/Moderation.js
This commit is contained in:
Chi Vinh Le
2017-06-19 23:52:10 +07:00
24 changed files with 522 additions and 86 deletions
@@ -51,3 +51,16 @@ export const toggleSelectCommentInUserDetail = (id, active) => {
id
};
};
export const toggleStorySearch = (active) => ({
type: active ? actions.SHOW_STORY_SEARCH : actions.HIDE_STORY_SEARCH
});
export const storySearchChange = (value) => ({
type: actions.STORY_SEARCH_CHANGE_VALUE,
value
});
export const clearState = () => ({
type: actions.MODERATION_CLEAR_STATE
});
@@ -1,6 +1,5 @@
.layout {
max-width: 1280px;
margin: 0 auto;
overflow: hidden;
background-color: #FAFAFA;
}
@@ -10,7 +10,7 @@ const Layout = ({
toggleShortcutModal,
restricted = false,
...props}) => (
<LayoutMDL fixedDrawer>
<LayoutMDL className={styles.layout} fixedDrawer>
<Header
handleLogout={handleLogout}
showShortcuts={toggleShortcutModal}
@@ -12,3 +12,7 @@ export const CHANGE_USER_DETAIL_STATUSES = 'CHANGE_USER_DETAIL_STATUSES';
export const SELECT_USER_DETAIL_COMMENT = 'SELECT_USER_DETAIL_COMMENT';
export const UNSELECT_USER_DETAIL_COMMENT = 'UNSELECT_USER_DETAIL_COMMENT';
export const CLEAR_USER_DETAIL_SELECTIONS = 'CLEAR_USER_DETAIL_SELECTIONS';
export const SHOW_STORY_SEARCH = 'SHOW_STORY_SEARCH';
export const HIDE_STORY_SEARCH = 'HIDE_STORY_SEARCH';
export const STORY_SEARCH_CHANGE_VALUE = 'STORY_SEARCH_CHANGE_VALUE';
export const MODERATION_CLEAR_STATE = 'MODERATION_CLEAR_STATE';
@@ -12,6 +12,8 @@ const initialState = fromJS({
userDetailStatuses: ['NONE', 'ACCEPTED', 'REJECTED', 'PREMOD'],
userDetailSelectedIds: new Set(),
banDialog: false,
storySearchVisible: false,
storySearchString: '',
shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show',
sortOrder: 'REVERSE_CHRONOLOGICAL',
suspendUserDialog: {
@@ -25,6 +27,8 @@ const initialState = fromJS({
export default function moderation (state = initialState, action) {
switch (action.type) {
case actions.MODERATION_CLEAR_STATE:
return initialState;
case actions.HIDE_BANUSER_DIALOG:
return state
.set('banDialog', false)
@@ -80,6 +84,12 @@ export default function moderation (state = initialState, action) {
return state.update('userDetailSelectedIds', (set) => set.add(action.id));
case actions.UNSELECT_USER_DETAIL_COMMENT:
return state.update('userDetailSelectedIds', (set) => set.delete(action.id));
case actions.SHOW_STORY_SEARCH:
return state.set('storySearchVisible', true);
case actions.HIDE_STORY_SEARCH:
return state.set('storySearchVisible', false);
case actions.STORY_SEARCH_CHANGE_VALUE:
return state.set('storySearchString', action.value);
case actions.SET_SORT_ORDER:
return state.set('sortOrder', action.order);
default :
@@ -91,6 +91,7 @@ span {
.moderateAsset {
a {
text-align: center;
-webkit-box-flex: 1;
-ms-flex: 1;
flex: 1;
@@ -103,21 +104,9 @@ span {
opacity: 1;
&:hover {
opacity: .8;
opacity: .8;
cursor: pointer;
}
&:first-child {
text-align: left;
}
&:nth-child(2) {
text-align: center;
}
&:last-child {
text-align: right;
}
}
}
}
@@ -10,6 +10,8 @@ import ModerationHeader from './ModerationHeader';
import NotFoundAsset from './NotFoundAsset';
import ModerationKeysModal from '../../../components/ModerationKeysModal';
import UserDetail from '../containers/UserDetail';
import StorySearch from '../containers/StorySearch';
import {Spinner} from 'coral-ui';
export default class Moderation extends Component {
state = {
@@ -32,6 +34,15 @@ export default class Moderation extends Component {
this.toggleModal(false);
}
closeSearch = () => {
const {toggleStorySearch} = this.props;
toggleStorySearch(false);
}
openSearch = () => {
this.props.toggleStorySearch(true);
}
moderate = (accept) => () => {
const {acceptComment, rejectComment} = this.props;
const {selectedIndex} = this.state;
@@ -92,17 +103,22 @@ export default class Moderation extends Component {
}
render () {
const {root, moderation, settings, assets, viewUserDetail, hideUserDetail, activeTab, ...props} = this.props;
const providedAssetId = this.props.params.id;
let asset;
const {root, moderation, settings, viewUserDetail, hideUserDetail, activeTab, ...props} = this.props;
const providedAssetId = this.props.params.id;
const {asset} = root;
if (providedAssetId) {
asset = assets.find((asset) => asset.id === this.props.params.id);
if (asset === null) {
if (!asset) {
// Not found.
return <NotFoundAsset assetId={providedAssetId} />;
}
if (asset === undefined || asset.id !== providedAssetId) {
// Still loading.
return <Spinner />;
}
}
const comments = root[activeTab];
@@ -127,7 +143,12 @@ export default class Moderation extends Component {
return (
<div>
<ModerationHeader asset={asset} />
<ModerationHeader
searchVisible={this.props.moderation.storySearchVisible}
openSearch={this.openSearch}
closeSearch={this.closeSearch}
asset={asset}
/>
<ModerationMenu
asset={asset}
allCount={root.allCount}
@@ -194,6 +215,11 @@ export default class Moderation extends Component {
acceptComment={props.acceptComment}
rejectComment={props.rejectComment} />
)}
<StorySearch
moderation={this.props.moderation}
closeSearch={this.closeSearch}
storySearchChange={this.props.storySearchChange}
/>
</div>
);
}
@@ -1,30 +1,35 @@
import React from 'react';
import {Link} from 'react-router';
import React, {PropTypes} from 'react';
import {Icon} from 'coral-ui';
import styles from './styles.css';
import t from 'coral-framework/services/i18n';
const ModerationHeader = (props) => (
const ModerationHeader = ({asset, searchVisible, openSearch, closeSearch}) => {
const trigger = searchVisible ? closeSearch : openSearch;
const searchTriggerIcon = <Icon className={styles.searchTrigger} name={searchVisible ? 'arrow_drop_up' : 'arrow_drop_down'} />;
const title = asset
? <a onClick={trigger} className="mdl-tabs__tab">{asset.title} {searchTriggerIcon}</a>
: <a onClick={trigger} className="mdl-tabs__tab">{t('modqueue.all_streams')} {searchTriggerIcon}</a>;
return (
<div className=''>
<div className={`mdl-tabs ${styles.header}`}>
{
props.asset ?
<div className={`mdl-tabs__tab-bar ${styles.moderateAsset}`}>
<Link className="mdl-tabs__tab" to="/admin/moderate">{t('modqueue.all_streams')}</Link>
<a className="mdl-tabs__tab" href={props.asset.url} target="_blank">
<span>{props.asset.title}</span>
<Icon className={styles.settingsButton} name="open_in_new"/>
</a>
<Link className="mdl-tabs__tab" to="/admin/stories">Select Stream</Link>
</div>
:
<div className={`mdl-tabs__tab-bar ${styles.moderateAsset}`}>
<a className="mdl-tabs__tab" />
<a className="mdl-tabs__tab">{t('modqueue.all_streams')}</a>
<Link className="mdl-tabs__tab" to="/admin/stories">{t('modqueue.select_stream')}</Link>
</div>
}
<div className={`mdl-tabs__tab-bar ${styles.moderateAsset}`}>
{title}
</div>
</div>
</div>
);
);
};
ModerationHeader.propTypes = {
asset: PropTypes.shape({
title: PropTypes.string,
id: PropTypes.string
}),
openSearch: PropTypes.func.isRequired,
closeSearch: PropTypes.func.isRequired
};
export default ModerationHeader;
@@ -0,0 +1,30 @@
import React, {PropTypes} from 'react';
import styles from './StorySearch.css';
const formatDate = (date) => {
const d = new Date(date);
return `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`;
};
const Story = ({author, title, createdAt, open, id, goToStory}) => {
return (
<li className={styles.story} onClick={() => goToStory(id)}>
<span className={styles.title}>{title}</span>
<div className={styles.meta}>
<span className={styles.author}>By {author}</span>
<span className={styles.createdAt}>{formatDate(createdAt)}</span>
<span className={styles.status}>{open ? 'Open' : 'Closed'}</span>
</div>
</li>
);
};
Story.propTypes = {
id: PropTypes.string.isRequired,
author: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
createdAt: PropTypes.string.isRequired,
open: PropTypes.bool.isRequired
};
export default Story;
@@ -0,0 +1,145 @@
.overlay {
position: absolute;
top: 58px;
left: 0;
right: 0;
bottom: 0;
width: 100%;
z-index: 99;
opacity: 0;
}
.container {
position: absolute;
background-color: white;
top: 106px;
left: 0;
right: 0;
margin-left: auto;
margin-right: auto;
width: 50%;
box-shadow: 0px 3px 5px 0px rgba(0,0,0,0.15);
z-index: 100;
max-width: 820px;
}
.positionShim {
left: -50%;
}
.headInput {
background-color: #efefef;
padding: 10px 27px;
box-sizing: border-box;
}
.searchInput {
width: calc(100% - 100px);
padding: 8px;
height: 100%;
font-size: 16px;
margin-right: 5px;
position: relative;
top: 3px;
box-sizing: border-box;
border-radius: 3px;
border: solid 1px #dfdfdf;
max-height: 45px;
max-width: 600px;
font-weight: 300;
}
.cta {
letter-spacing: 1px;
font-weight: bold;
font-size: 15px;
margin: 0;
height: 50px;
box-sizing: border-box;
font-size: 15px;
font-weight: 500;
padding: 12px 30px;
letter-spacing: 0.25px;
}
/*.storyList {
border-top: 1px solid #ddd;
}*/
.story {
padding: 7px 50px;
border-bottom: 1px solid #ddd;
cursor: pointer;
display: block;
text-decoration: none;
height: 50px;
box-sizing: border-box;
transition: background-color 400ms;
&:hover {
background-color: #efefef;
}
&:last-child {
border-bottom: none;
}
}
.title, .meta {
margin: 0;
color: black;
font-size: 15px;
}
.author, .createdAt, .status {
font-size: 17px;
display: inline-block;
font-size: .8em;
}
.createdAt {
text-align: center;
}
.author {
display: inline-block;
width: 200px;
color: #aaa;
}
.createdAt {
display: inline-block;
width: 200px;
color: #aaa;
}
.searchButton {
width: 90px;
height: 35px;
}
.searchResults {
padding: 7px 27px;
background: #F5F5F5;
}
.searchResults i {
font-size: 16px;
vertical-align: middle;
}
.accessStories {
font-size: 18px;
}
.headlineRecent {
font-size: 15px;
font-weight: 500;
letter-spacing: 0.25px;
vertical-align: middle;
margin-left: 8px;
}
.noResults {
padding: 10px 24px 15px 49px;
}
@@ -0,0 +1,93 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './StorySearch.css';
import {Button, Spinner, Icon} from 'coral-ui';
import Story from './Story';
const StorySearch = (props) => {
const {
root: {
assets = []
},
data: {loading}
} = props;
if (!props.moderation.storySearchVisible) {
return null;
}
return (
<div>
<div className={styles.container} role='alertdialog' onKeyDown={props.handleEsc}>
<div className={styles.positionShim}>
<div className={styles.headInput}>
<input
className={styles.searchInput}
onChange={props.handleSearchChange}
onKeyDown={props.handleEnter}
value={props.searchValue}
autoFocus
/>
<Button
cStyle='blue'
className={styles.searchButton}
onClick={props.search}
raised >
Search
</Button>
</div>
<div className={styles.results}>
<p className={styles.cta}>Moderate comments on All Stories</p>
<div className={styles.storyList}>
{
props.moderation.storySearchString ? (
<div className={styles.searchResults}>
<Icon name="search" />
<span className={styles.headlineRecent}>Search Results</span>
</div>
) : (
<div className={styles.searchResults}>
<Icon name="access_time" />
<span className={styles.headlineRecent}>Most Recent Stories</span>
</div>
)
}
{
loading
? <Spinner />
: assets.map((story, i) => {
const storyOpen = story.closedAt === null || new Date(story.closedAt) > new Date();
return <Story
key={i}
id={story.id}
title={story.title}
createdAt={new Date(story.created_at).toISOString()}
open={storyOpen}
author={story.author}
goToStory={props.goToStory}
/>;
})
}
{assets.length === 0 && <div className={styles.noResults}>No results</div>}
</div>
</div>
</div>
</div>
<div className={styles.overlay} onClick={props.closeSearch} />
</div>
);
};
StorySearch.propTypes = {
search: PropTypes.func.isRequired,
goToStory: PropTypes.func.isRequired,
closeSearch: PropTypes.func.isRequired,
moderation: PropTypes.object.isRequired,
handleSearchChange: PropTypes.func.isRequired
};
export default StorySearch;
@@ -116,29 +116,20 @@ span {
transition: background-color 200ms;
opacity: 1;
&:first-child {
text-align: left;
&:hover {
cursor: pointer;
background-color: #212121;
}
span {
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
max-width: 344px;
display: inline-block;
vertical-align: top;
}
&:nth-child(2) {
&:hover {
cursor: pointer;
background-color: #212121;
}
span {
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
max-width: 344px;
display: inline-block;
vertical-align: top;
}
}
&:last-child {
text-align: right;
}
}
}
}
@@ -534,3 +525,8 @@ span {
line-height: 1px;
font-weight: 300;
}
.searchTrigger {
position: relative;
top: .3em;
}
@@ -2,7 +2,6 @@ import React, {Component} from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {compose, gql} from 'react-apollo';
import isEqual from 'lodash/isEqual';
import withQuery from 'coral-framework/hocs/withQuery';
import {getDefinitionName} from 'coral-framework/utils';
import * as notification from 'coral-admin/src/services/notification';
@@ -14,7 +13,6 @@ import {withSetUserStatus, withSuspendUser, withSetCommentStatus} from 'coral-fr
import {handleCommentChange} from '../../../graphql/utils';
import {fetchSettings} from 'actions/settings';
import {updateAssets} from 'actions/assets';
import {
toggleModal,
singleView,
@@ -23,9 +21,12 @@ import {
showSuspendUserDialog,
hideSuspendUserDialog,
hideShortcutsNote,
toggleStorySearch,
viewUserDetail,
hideUserDetail,
setSortOrder,
storySearchChange,
clearState
} from 'actions/moderation';
import {Spinner} from 'coral-ui';
@@ -123,6 +124,7 @@ class ModerationContainer extends Component {
}
componentWillMount() {
this.props.clearState();
this.props.fetchSettings();
this.subscribeToUpdates();
}
@@ -132,11 +134,6 @@ class ModerationContainer extends Component {
}
componentWillReceiveProps(nextProps) {
const {updateAssets} = this.props;
if(!isEqual(nextProps.root.assets, this.props.root.assets)) {
updateAssets(nextProps.root.assets);
}
// Resubscribe when we change between assets.
if(this.props.data.variables.asset_id !== nextProps.data.variables.asset_id) {
this.resubscribe();
@@ -325,7 +322,7 @@ const commentConnectionFragment = gql`
`;
const withModQueueQuery = withQuery(gql`
query CoralAdmin_Moderation($asset_id: ID, $sort: SORT_ORDER) {
query CoralAdmin_Moderation($asset_id: ID, $sort: SORT_ORDER, $allAssets: Boolean!) {
all: comments(query: {
statuses: [NONE, PREMOD, ACCEPTED, REJECTED],
asset_id: $asset_id,
@@ -362,7 +359,7 @@ const withModQueueQuery = withQuery(gql`
}) {
...CoralAdmin_Moderation_CommentConnection
}
assets: assets {
asset(id: $asset_id) @skip(if: $allAssets) {
id
title
url
@@ -398,6 +395,7 @@ const withModQueueQuery = withQuery(gql`
variables: {
asset_id: id,
sort: sortOrder,
allAssets: id === null
}
};
},
@@ -441,23 +439,24 @@ const mapStateToProps = (state) => ({
moderation: state.moderation.toJS(),
settings: state.settings.toJS(),
auth: state.auth.toJS(),
assets: state.assets.get('assets')
});
const mapDispatchToProps = (dispatch) => ({
...bindActionCreators({
toggleModal,
singleView,
updateAssets,
fetchSettings,
showBanUserDialog,
hideBanUserDialog,
hideShortcutsNote,
toggleStorySearch,
showSuspendUserDialog,
hideSuspendUserDialog,
viewUserDetail,
hideUserDetail,
setSortOrder,
storySearchChange,
clearState
}, dispatch),
});
@@ -0,0 +1,87 @@
import React from 'react';
import {compose, gql} from 'react-apollo';
import StorySearch from '../components/StorySearch';
import {withRouter} from 'react-router';
import withQuery from 'coral-framework/hocs/withQuery';
class StorySearchContainer extends React.Component {
constructor(props) {
super(props);
this.state = {
searchValue: props.moderation.storySearchString
};
}
handleSearchChange = (e) => {
const {value} = e.target;
this.setState({
searchValue: value
});
}
handleEsc = (e) => {
if (e.key === 'Escape') {
e.preventDefault();
this.props.closeSearch();
}
}
handleEnter = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.search();
}
}
search = () => {
const {searchValue} = this.state;
this.props.storySearchChange(searchValue);
}
goToStory = (id) => {
const {router, closeSearch} = this.props;
router.push(`/admin/moderate/${id}`);
closeSearch();
}
render () {
return (
<StorySearch
search={this.search}
goToStory={this.goToStory}
handleEsc={this.handleEsc}
handleEnter={this.handleEnter}
searchValue={this.state.searchValue}
handleSearchChange={this.handleSearchChange}
{...this.props}
/>
);
}
}
export const withAssetSearchQuery = withQuery(gql`
query SearchStories($value: String = "") {
assets(query: {value: $value, limit: 10}) {
id
title
url
created_at
closedAt
author
}
}
`, {
options: ({moderation: {storySearchString = ''}}) => {
return {
variables: {
value: storySearchString
}
};
}
});
export default compose(
withRouter,
withAssetSearchQuery
)(StorySearchContainer);
+10
View File
@@ -84,6 +84,16 @@
background: #4f5c67;
}
.type--blue {
color: white;
background: #083b97;
}
.type--blue:hover {
color: white;
background: #083b97;
}
.type--darkGrey {
color: white;
background: #616161;
+11
View File
@@ -19,6 +19,16 @@ const genAssetsByID = (context, ids) => AssetModel.find({
}
}).then(util.singleJoinBy(ids, 'id'));
/**
* [getAssetsByQuery description]
* @param {Object} context the context of the request
* @param {Object} query the query
* @return {Promise} resolves the assets
*/
const getAssetsByQuery = (context, query) => {
return AssetsService.search(query);
};
/**
* This endpoint find or creates an asset at the given url when it is loaded.
* @param {Object} context the context of the request
@@ -65,6 +75,7 @@ module.exports = (context) => ({
// this operation create a new asset if one isn't found.
getByURL: (url) => findOrCreateAssetByURL(context, url),
search: (query) => getAssetsByQuery(context, query),
getByID: new DataLoader((ids) => genAssetsByID(context, ids)),
getForMetrics: () => getAssetsForMetrics(context),
getAll: new util.SingletonResolver(() => AssetModel.find({}))
+2 -2
View File
@@ -6,12 +6,12 @@ const {
} = require('../../perms/constants');
const RootQuery = {
assets(_, args, {loaders: {Assets}, user}) {
assets(_, {query}, {loaders: {Assets}, user}) {
if (user == null || !user.can(SEARCH_ASSETS)) {
return null;
}
return Assets.getAll.load();
return Assets.search(query);
},
asset(_, query, {loaders: {Assets}}) {
if (query.id) {
+9 -1
View File
@@ -104,7 +104,15 @@ input UsersQuery {
sort: SORT_ORDER = REVERSE_CHRONOLOGICAL
}
# AssetsQuery allows teh ability to query assets by specific fields
input AssetsQuery {
# a search string to match against titles, authors, urls, etc.
value: String = ""
# Limit the number of results to be returned
limit: Int = 10
}
################################################################################
## Tags
################################################################################
@@ -635,7 +643,7 @@ type RootQuery {
comment(id: ID!): Comment
# All assets. Requires the `ADMIN` role.
assets: [Asset]
assets(query: AssetsQuery): [Asset]
# Find or create an asset by url, or just find with the ID.
asset(id: ID, url: String): Asset
+1 -1
View File
@@ -106,7 +106,7 @@
"passport": "^0.3.2",
"passport-jwt": "^2.2.1",
"passport-local": "^1.0.0",
"prop-types": "^15.5.8",
"prop-types": "^15.5.10",
"react-apollo": "^1.1.0",
"react-recaptcha": "^2.2.6",
"react-toastify": "^1.5.0",
+2 -2
View File
@@ -48,13 +48,13 @@ router.get('/', (req, res, next) => {
Promise.all([
// Find the actuall assets.
FilterOpenAssets(AssetsService.search(search), filter)
FilterOpenAssets(AssetsService.search({value: search}), filter)
.sort({[field]: (sort === 'asc') ? 1 : -1})
.skip(parseInt(skip))
.limit(parseInt(limit)),
// Get the count of actual assets.
FilterOpenAssets(AssetsService.search(search), filter)
FilterOpenAssets(AssetsService.search({value: search}), filter)
.count()
])
.then(([result, count]) => {
+3 -4
View File
@@ -104,13 +104,12 @@ module.exports = class AssetsService {
}
/**
* Finds assets matching keywords on the model. If `value` is an empty string,
* then it will not even perform a text search query.
* Finds assets matching keywords on the model.
* @param {String} value string to search by.
* @return {Promise}
*/
static search(value = '', skip = null, limit = null) {
if (value.length === 0) {
static search({value, skip, limit} = {}) {
if (!value) {
return AssetsService.all(skip, limit);
} else {
return AssetModel
+1 -1
View File
@@ -43,7 +43,7 @@ describe('/api/v1/assets', () => {
.set(passport.inject({roles: ['ADMIN']}))
.then((res) => {
const body = res.body;
expect(body).to.have.property('count', 2);
expect(body).to.have.property('result');
+5
View File
@@ -29,6 +29,11 @@
background-color: #FAFAFA;
font-family: 'Roboto', sans-serif;
}
#root > div {
height: 100%;
}
/* putting this here until I can get webpack to behave */
.react-tagsinput {
background-color: #fff;
+8 -1
View File
@@ -5064,7 +5064,7 @@ longest@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097"
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0:
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.3.1.tgz#d1a8ad33fa9ce0e713d65fdd0ac8b748d478c848"
dependencies:
@@ -6579,6 +6579,13 @@ prop-types@^15.5.0, prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.5.7,
dependencies:
fbjs "^0.8.9"
prop-types@^15.5.10:
version "15.5.10"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.10.tgz#2797dfc3126182e3a95e3dfbb2e893ddd7456154"
dependencies:
fbjs "^0.8.9"
loose-envify "^1.3.1"
protocols@^1.1.0, protocols@^1.4.0:
version "1.4.5"
resolved "https://registry.yarnpkg.com/protocols/-/protocols-1.4.5.tgz#21de1f441c4ef7094408ed9f1c94f7a114b87557"