mirror of
https://github.com/wassname/talk.git
synced 2026-08-10 12:41:02 +08:00
Merge master
This commit is contained in:
@@ -2,10 +2,15 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Router, Route, IndexRedirect, IndexRoute } from 'react-router';
|
||||
|
||||
import Configure from 'routes/Configure';
|
||||
import Install from 'routes/Install';
|
||||
import Stories from 'routes/Stories';
|
||||
import Community from 'routes/Community';
|
||||
|
||||
import Configure from 'routes/Configure';
|
||||
import StreamSettings from './routes/Configure/containers/StreamSettings';
|
||||
import ModerationSettings from './routes/Configure/containers/ModerationSettings';
|
||||
import TechSettings from './routes/Configure/containers/TechSettings';
|
||||
|
||||
import { ModerationLayout, Moderation } from 'routes/Moderation';
|
||||
|
||||
import Layout from 'containers/Layout';
|
||||
@@ -15,7 +20,14 @@ const routes = (
|
||||
<Route exact path="/admin/install" component={Install} />
|
||||
<Route path="/admin" component={Layout}>
|
||||
<IndexRedirect to="/admin/moderate" />
|
||||
<Route path="configure" component={Configure} />
|
||||
|
||||
<Route path="configure" component={Configure}>
|
||||
<Route path="stream" component={StreamSettings} />
|
||||
<Route path="moderation" component={ModerationSettings} />
|
||||
<Route path="tech" component={TechSettings} />
|
||||
<IndexRedirect to="stream" />
|
||||
</Route>
|
||||
|
||||
<Route path="stories" component={Stories} />
|
||||
|
||||
{/* Community Routes */}
|
||||
|
||||
@@ -8,6 +8,10 @@ export const clearPending = () => {
|
||||
return { type: actions.CLEAR_PENDING };
|
||||
};
|
||||
|
||||
export const setActiveSection = section => {
|
||||
return { type: actions.SET_ACTIVE_SECTION, section };
|
||||
export const showSaveDialog = () => {
|
||||
return { type: actions.SHOW_SAVE_DIALOG };
|
||||
};
|
||||
|
||||
export const hideSaveDialog = () => {
|
||||
return { type: actions.HIDE_SAVE_DIALOG };
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ const buildUserHistory = (userState = {}) => {
|
||||
return orderBy(
|
||||
flatten(
|
||||
Object.keys(userState.status)
|
||||
.filter(k => k !== '__typename')
|
||||
.filter(k => !k.startsWith('__'))
|
||||
.map(k => userState.status[k].history)
|
||||
),
|
||||
'created_at',
|
||||
|
||||
@@ -25,37 +25,29 @@ class CommentDetails extends Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { data, root, comment, clearHeightCache } = this.props;
|
||||
const { root, comment, clearHeightCache } = this.props;
|
||||
const { showDetail } = this.state;
|
||||
const queryData = {
|
||||
|
||||
const slotPassthrough = {
|
||||
clearHeightCache,
|
||||
root,
|
||||
comment,
|
||||
more: showDetail,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<IfSlotIsNotEmpty
|
||||
queryData={queryData}
|
||||
slot={['adminCommentMoreDetails', 'adminCommentMoreFlagDetails']}
|
||||
passthrough={slotPassthrough}
|
||||
>
|
||||
<a onClick={this.toggleDetail} className={styles.moreDetail}>
|
||||
{showDetail ? t('modqueue.less_detail') : t('modqueue.more_detail')}
|
||||
</a>
|
||||
</IfSlotIsNotEmpty>
|
||||
<Slot
|
||||
fill="adminCommentDetailArea"
|
||||
data={data}
|
||||
clearHeightCache={clearHeightCache}
|
||||
queryData={queryData}
|
||||
more={showDetail}
|
||||
/>
|
||||
<Slot fill="adminCommentDetailArea" passthrough={slotPassthrough} />
|
||||
{showDetail && (
|
||||
<Slot
|
||||
fill="adminCommentMoreDetails"
|
||||
data={data}
|
||||
clearHeightCache={clearHeightCache}
|
||||
queryData={queryData}
|
||||
/>
|
||||
<Slot fill="adminCommentMoreDetails" passthrough={slotPassthrough} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -63,7 +55,6 @@ class CommentDetails extends Component {
|
||||
}
|
||||
|
||||
CommentDetails.propTypes = {
|
||||
data: PropTypes.object.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
comment: PropTypes.object.isRequired,
|
||||
clearHeightCache: PropTypes.func,
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { matchLinks } from '../utils';
|
||||
import memoize from 'lodash/memoize';
|
||||
|
||||
function escapeRegExp(string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||
}
|
||||
|
||||
// generate a regulare expression that catches the `phrases`.
|
||||
function generateRegExp(phrases) {
|
||||
const inner = phrases
|
||||
.map(phrase =>
|
||||
phrase
|
||||
.split(/\s+/)
|
||||
.map(word => escapeRegExp(word))
|
||||
.join('[\\s"?!.]+')
|
||||
)
|
||||
.join('|');
|
||||
|
||||
const pattern = `(^|[^\\w])(${inner})(?=[^\\w]|$)`;
|
||||
try {
|
||||
return new RegExp(pattern, 'iu');
|
||||
} catch (_err) {
|
||||
// IE does not support unicode support, so we'll create one without.
|
||||
return new RegExp(pattern, 'i');
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a regular expression detecting `suspectWords` and `bannedWords` phrases.
|
||||
function getPhrasesRegexp(suspectWords, bannedWords) {
|
||||
return generateRegExp([...suspectWords, ...bannedWords]);
|
||||
}
|
||||
|
||||
// Memoized version as arguments rarely change.
|
||||
const getPhrasesRegexpMemoized = memoize(getPhrasesRegexp);
|
||||
|
||||
// markPhrases looks for `supsectWords` and `bannedWords` inside `body` and highlights them by returning
|
||||
// an array of React Elements.
|
||||
function markPhrases(body, suspectWords, bannedWords, keyPrefix) {
|
||||
const regexp = getPhrasesRegexpMemoized(suspectWords, bannedWords);
|
||||
const tokens = body.split(regexp);
|
||||
return tokens.map(
|
||||
(token, i) =>
|
||||
i % 3 === 2 ? <mark key={`${keyPrefix}_${i}`}>{token}</mark> : token
|
||||
);
|
||||
}
|
||||
|
||||
// markLinks looks for links inside `body` and highlights them by returning
|
||||
// an array of React Elements.
|
||||
function markLinks(body) {
|
||||
const matches = matchLinks(body);
|
||||
const content = [];
|
||||
let index = 0;
|
||||
if (matches) {
|
||||
matches.forEach((match, i) => {
|
||||
content.push(body.substring(index, match.index));
|
||||
content.push(<mark key={i}>{match.text}</mark>);
|
||||
index = match.lastIndex;
|
||||
});
|
||||
}
|
||||
content.push(body.substring(index));
|
||||
return content;
|
||||
}
|
||||
|
||||
const CommentFormatter = ({
|
||||
body,
|
||||
suspectWords,
|
||||
bannedWords,
|
||||
className = 'comment',
|
||||
...rest
|
||||
}) => {
|
||||
// Breaking the body by line break
|
||||
const textbreaks = body.split('\n');
|
||||
|
||||
return (
|
||||
<span className={`${className}-text`} {...rest}>
|
||||
{textbreaks.map((line, i) => {
|
||||
const content = markLinks(line).map((element, index) => {
|
||||
// Keep highlighted links.
|
||||
if (typeof element !== 'string') {
|
||||
return element;
|
||||
}
|
||||
|
||||
// Highlight suspect and banned phrase inside this part of text.
|
||||
return markPhrases(element, suspectWords, bannedWords, index);
|
||||
});
|
||||
|
||||
return (
|
||||
<span key={i} className={`${className}-line`}>
|
||||
{content}
|
||||
{i !== textbreaks.length - 1 && (
|
||||
<br className={`${className}-linebreak`} />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
CommentFormatter.propTypes = {
|
||||
className: PropTypes.string,
|
||||
bannedWords: PropTypes.array,
|
||||
suspectWords: PropTypes.array,
|
||||
body: PropTypes.string,
|
||||
};
|
||||
|
||||
export default CommentFormatter;
|
||||
@@ -43,6 +43,9 @@ const CommentLabels = ({
|
||||
comment,
|
||||
comment: { className, status, actions, hasParent },
|
||||
}) => {
|
||||
const slotPassthrough = {
|
||||
comment,
|
||||
};
|
||||
return (
|
||||
<div className={cn(className, styles.root)}>
|
||||
<div className={styles.coreLabels}>
|
||||
@@ -69,7 +72,7 @@ const CommentLabels = ({
|
||||
<Slot
|
||||
className={styles.slot}
|
||||
fill="adminCommentLabels"
|
||||
queryData={{ comment }}
|
||||
passthrough={slotPassthrough}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.copy {
|
||||
padding: 20px 0;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import styles from './Forbidden.css';
|
||||
|
||||
const Forbidden = () => (
|
||||
<div className={styles.container}>
|
||||
<p className={styles.copy}>
|
||||
This page is for team use only. Please contact an administrator if you
|
||||
want to join this team.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Forbidden;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { matchLinks } from '../utils';
|
||||
import matchLinks from 'coral-framework/utils/matchLinks';
|
||||
|
||||
export default ({ text, children }) => {
|
||||
const hasLinks = !!matchLinks(text);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
.layout {
|
||||
margin: 0 auto;
|
||||
background-color: #FAFAFA;
|
||||
}
|
||||
height: inherit;
|
||||
min-height: calc(100vh - 58px);
|
||||
}
|
||||
@@ -98,7 +98,6 @@ class UserDetail extends React.Component {
|
||||
|
||||
renderLoaded() {
|
||||
const {
|
||||
data,
|
||||
root,
|
||||
root: { me, user, totalComments, rejectedComments },
|
||||
activeTab,
|
||||
@@ -123,6 +122,11 @@ class UserDetail extends React.Component {
|
||||
const banned = isBanned(user);
|
||||
const suspended = isSuspended(user);
|
||||
|
||||
const slotPassthrough = {
|
||||
root,
|
||||
user,
|
||||
};
|
||||
|
||||
return (
|
||||
<ClickOutside onClickOutside={modal ? null : hideUserDetail}>
|
||||
<Drawer
|
||||
@@ -244,11 +248,7 @@ class UserDetail extends React.Component {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Slot
|
||||
fill="userProfile"
|
||||
data={this.props.data}
|
||||
queryData={{ root, user }}
|
||||
/>
|
||||
<Slot fill="userProfile" passthrough={slotPassthrough} />
|
||||
|
||||
<hr />
|
||||
|
||||
@@ -301,7 +301,6 @@ class UserDetail extends React.Component {
|
||||
<UserDetailCommentList
|
||||
user={user}
|
||||
root={root}
|
||||
data={data}
|
||||
loadMore={loadMore}
|
||||
toggleSelect={toggleSelect}
|
||||
viewUserDetail={viewUserDetail}
|
||||
@@ -320,7 +319,6 @@ class UserDetail extends React.Component {
|
||||
<UserDetailCommentList
|
||||
user={user}
|
||||
root={root}
|
||||
data={data}
|
||||
loadMore={loadMore}
|
||||
toggleSelect={toggleSelect}
|
||||
viewUserDetail={viewUserDetail}
|
||||
@@ -368,9 +366,7 @@ UserDetail.propTypes = {
|
||||
bulkReject: PropTypes.func.isRequired,
|
||||
toggleSelectAll: PropTypes.func.isRequired,
|
||||
loading: PropTypes.bool.isRequired,
|
||||
data: PropTypes.shape({
|
||||
refetch: PropTypes.func.isRequired,
|
||||
}),
|
||||
data: PropTypes.object,
|
||||
activeTab: PropTypes.string.isRequired,
|
||||
selectedCommentIds: PropTypes.array.isRequired,
|
||||
viewUserDetail: PropTypes.any.isRequired,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Link } from 'react-router';
|
||||
import { Icon } from 'coral-ui';
|
||||
import CommentDetails from './CommentDetails';
|
||||
import styles from './UserDetailComment.css';
|
||||
import CommentFormatter from 'coral-admin/src/components/CommentFormatter';
|
||||
import AdminCommentContent from 'coral-framework/components/AdminCommentContent';
|
||||
import IfHasLink from 'coral-admin/src/components/IfHasLink';
|
||||
import cn from 'classnames';
|
||||
import CommentAnimatedEdit from './CommentAnimatedEdit';
|
||||
@@ -32,13 +32,12 @@ class UserDetailComment extends React.Component {
|
||||
selected,
|
||||
toggleSelect,
|
||||
className,
|
||||
data,
|
||||
root: { settings },
|
||||
} = this.props;
|
||||
|
||||
const queryData = { root, comment };
|
||||
|
||||
const formatterSettings = {
|
||||
const slotPassthrough = {
|
||||
root,
|
||||
comment,
|
||||
suspectWords: settings.wordlist.suspect,
|
||||
bannedWords: settings.wordlist.banned,
|
||||
body: comment.body,
|
||||
@@ -76,7 +75,8 @@ class UserDetailComment extends React.Component {
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.story}>
|
||||
Story: {comment.asset.title}
|
||||
{t('common.story')}:{' '}
|
||||
{comment.asset.title ? comment.asset.title : comment.asset.url}
|
||||
{
|
||||
<Link to={`/admin/moderate/${comment.asset.id}`}>
|
||||
{t('modqueue.moderate')}
|
||||
@@ -88,15 +88,13 @@ class UserDetailComment extends React.Component {
|
||||
<div className={styles.body}>
|
||||
<Slot
|
||||
fill="userDetailCommentContent"
|
||||
data={data}
|
||||
className={cn(
|
||||
styles.commentContent,
|
||||
'talk-admin-user-detail-comment'
|
||||
)}
|
||||
queryData={queryData}
|
||||
slotSize={1}
|
||||
defaultComponent={CommentFormatter}
|
||||
{...formatterSettings}
|
||||
size={1}
|
||||
defaultComponent={AdminCommentContent}
|
||||
passthrough={slotPassthrough}
|
||||
/>
|
||||
<a
|
||||
className={styles.external}
|
||||
@@ -109,6 +107,7 @@ class UserDetailComment extends React.Component {
|
||||
<div className={styles.sideActions}>
|
||||
<IfHasLink text={comment.body}>
|
||||
<span className={styles.hasLinks}>
|
||||
{/* TODO: translate string */}
|
||||
<Icon name="error_outline" /> Contains Link
|
||||
</span>
|
||||
</IfHasLink>
|
||||
@@ -128,7 +127,7 @@ class UserDetailComment extends React.Component {
|
||||
</div>
|
||||
</CommentAnimatedEdit>
|
||||
</div>
|
||||
<CommentDetails data={data} root={root} comment={comment} />
|
||||
<CommentDetails root={root} comment={comment} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -136,7 +135,6 @@ class UserDetailComment extends React.Component {
|
||||
|
||||
UserDetailComment.propTypes = {
|
||||
selected: PropTypes.bool,
|
||||
data: PropTypes.object,
|
||||
user: PropTypes.object.isRequired,
|
||||
viewUserDetail: PropTypes.func.isRequired,
|
||||
acceptComment: PropTypes.func.isRequired,
|
||||
|
||||
@@ -9,7 +9,6 @@ import ApproveButton from './ApproveButton';
|
||||
|
||||
const UserDetailCommentList = props => {
|
||||
const {
|
||||
data,
|
||||
root,
|
||||
root: { user, comments: { nodes, hasNextPage } },
|
||||
acceptComment,
|
||||
@@ -70,7 +69,6 @@ const UserDetailCommentList = props => {
|
||||
key={comment.id}
|
||||
user={user}
|
||||
root={root}
|
||||
data={data}
|
||||
comment={comment}
|
||||
acceptComment={acceptComment}
|
||||
rejectComment={rejectComment}
|
||||
@@ -93,7 +91,6 @@ UserDetailCommentList.propTypes = {
|
||||
root: PropTypes.object.isRequired,
|
||||
acceptComment: PropTypes.func.isRequired,
|
||||
rejectComment: PropTypes.func.isRequired,
|
||||
data: PropTypes.object.isRequired,
|
||||
selectedCommentIds: PropTypes.array.isRequired,
|
||||
viewUserDetail: PropTypes.any.isRequired,
|
||||
loadMore: PropTypes.any.isRequired,
|
||||
|
||||
@@ -2,4 +2,6 @@ const prefix = 'TALK_ADMIN_CONFIGURE';
|
||||
|
||||
export const UPDATE_PENDING = `${prefix}_UPDATE_PENDING`;
|
||||
export const CLEAR_PENDING = `${prefix}_CLEAR_PENDING`;
|
||||
export const SET_ACTIVE_SECTION = `${prefix}_SET_ACTIVE_SECTION`;
|
||||
|
||||
export const SHOW_SAVE_DIALOG = `${prefix}_SHOW_SAVE_DIALOG`;
|
||||
export const HIDE_SAVE_DIALOG = `${prefix}_HIDE_SAVE_DIALOG`;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { logout } from 'coral-framework/actions/auth';
|
||||
import { can } from 'coral-framework/services/perms';
|
||||
import UserDetail from 'coral-admin/src/containers/UserDetail';
|
||||
import PropTypes from 'prop-types';
|
||||
import Forbidden from '../components/Forbidden';
|
||||
|
||||
class LayoutContainer extends React.Component {
|
||||
render() {
|
||||
@@ -47,10 +48,7 @@ class LayoutContainer extends React.Component {
|
||||
} else {
|
||||
return (
|
||||
<Layout {...this.props} handleLogout={logout}>
|
||||
<p>
|
||||
This page is for team use only. Please contact an administrator if
|
||||
you want to join this team.
|
||||
</p>
|
||||
<Forbidden />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,23 @@ const initialState = {
|
||||
canSave: false,
|
||||
pending: {},
|
||||
errors: {},
|
||||
activeSection: 'stream',
|
||||
saveDialog: false,
|
||||
};
|
||||
|
||||
export default function configure(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case actions.SHOW_SAVE_DIALOG: {
|
||||
return {
|
||||
...state,
|
||||
saveDialog: true,
|
||||
};
|
||||
}
|
||||
case actions.HIDE_SAVE_DIALOG: {
|
||||
return {
|
||||
...state,
|
||||
saveDialog: false,
|
||||
};
|
||||
}
|
||||
case actions.UPDATE_PENDING: {
|
||||
let next = state;
|
||||
if (action.updater) {
|
||||
@@ -40,11 +52,8 @@ export default function configure(state = initialState, action) {
|
||||
pending: {},
|
||||
canSave: false,
|
||||
};
|
||||
case actions.SET_ACTIVE_SECTION:
|
||||
return {
|
||||
...state,
|
||||
activeSection: action.section,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -130,3 +130,12 @@ th.header:nth-child(2), th.header:nth-child(3) {
|
||||
.loadMore {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.roleDropdown {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.roleOption {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
|
||||
@@ -200,24 +200,31 @@ class People extends React.Component {
|
||||
</td>
|
||||
<td className="mdl-data-table__cell--non-numeric">
|
||||
<Dropdown
|
||||
containerClassName="talk-admin-community-people-dd-role"
|
||||
className={cn(
|
||||
'talk-admin-community-people-dd-role',
|
||||
styles.roleDropdown
|
||||
)}
|
||||
value={user.role}
|
||||
placeholder={t('community.role')}
|
||||
onChange={role => setUserRole(user.id, role)}
|
||||
>
|
||||
<Option
|
||||
className={styles.roleOption}
|
||||
value={COMMENTER}
|
||||
label={t('community.commenter')}
|
||||
/>
|
||||
<Option
|
||||
className={styles.roleOption}
|
||||
value={STAFF}
|
||||
label={t('community.staff')}
|
||||
/>
|
||||
<Option
|
||||
className={styles.roleOption}
|
||||
value={MODERATOR}
|
||||
label={t('community.moderator')}
|
||||
/>
|
||||
<Option
|
||||
className={styles.roleOption}
|
||||
value={ADMIN}
|
||||
label={t('community.admin')}
|
||||
/>
|
||||
|
||||
@@ -1,50 +1,37 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { Button, List, Item } from 'coral-ui';
|
||||
import styles from './Configure.css';
|
||||
import StreamSettings from '../containers/StreamSettings';
|
||||
import ModerationSettings from '../containers/ModerationSettings';
|
||||
import TechSettings from '../containers/TechSettings';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import { can } from 'coral-framework/services/perms';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import { Button, List, Item } from 'coral-ui';
|
||||
import { can } from 'coral-framework/services/perms';
|
||||
import styles from './Configure.css';
|
||||
import SaveChangesDialog from './SaveChangesDialog';
|
||||
|
||||
export default class Configure extends Component {
|
||||
getSectionComponent(section) {
|
||||
switch (section) {
|
||||
case 'stream':
|
||||
return StreamSettings;
|
||||
case 'moderation':
|
||||
return ModerationSettings;
|
||||
case 'tech':
|
||||
return TechSettings;
|
||||
}
|
||||
throw new Error(`Unknown section ${section}`);
|
||||
}
|
||||
|
||||
class Configure extends React.Component {
|
||||
render() {
|
||||
const {
|
||||
currentUser,
|
||||
canSave,
|
||||
savePending,
|
||||
setActiveSection,
|
||||
activeSection,
|
||||
} = this.props;
|
||||
const SectionComponent = this.getSectionComponent(activeSection);
|
||||
const { canSave, currentUser, root, savePending, settings } = this.props;
|
||||
|
||||
if (!can(currentUser, 'UPDATE_CONFIG')) {
|
||||
return (
|
||||
<p>
|
||||
You must be an administrator to access config settings. Please find
|
||||
the nearest Admin and ask them to level you up!
|
||||
</p>
|
||||
);
|
||||
return <p>{t('configure.access_message')}</p>;
|
||||
}
|
||||
|
||||
const passProps = {
|
||||
root,
|
||||
settings,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<SaveChangesDialog
|
||||
saveDialog={this.props.saveDialog}
|
||||
hideSaveDialog={this.props.hideSaveDialog}
|
||||
saveChanges={this.props.saveChanges}
|
||||
discardChanges={this.props.discardChanges}
|
||||
/>
|
||||
<div className={styles.leftColumn}>
|
||||
<List onChange={setActiveSection} activeItem={activeSection}>
|
||||
<List
|
||||
onChange={this.props.handleSectionChange}
|
||||
activeItem={this.props.activeSection}
|
||||
>
|
||||
<Item itemId="stream" icon="speaker_notes">
|
||||
{t('configure.stream_settings')}
|
||||
</Item>
|
||||
@@ -74,11 +61,7 @@ export default class Configure extends Component {
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.mainContent}>
|
||||
<SectionComponent
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
settings={this.props.settings}
|
||||
/>
|
||||
{React.cloneElement(this.props.children, passProps)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -87,11 +70,17 @@ export default class Configure extends Component {
|
||||
|
||||
Configure.propTypes = {
|
||||
savePending: PropTypes.func.isRequired,
|
||||
saveChanges: PropTypes.func.isRequired,
|
||||
discardChanges: PropTypes.func.isRequired,
|
||||
currentUser: PropTypes.object.isRequired,
|
||||
data: PropTypes.object.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
settings: PropTypes.object.isRequired,
|
||||
canSave: PropTypes.bool.isRequired,
|
||||
setActiveSection: PropTypes.func.isRequired,
|
||||
handleSectionChange: PropTypes.func.isRequired,
|
||||
activeSection: PropTypes.string.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
saveDialog: PropTypes.bool,
|
||||
hideSaveDialog: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Configure;
|
||||
|
||||
@@ -52,7 +52,7 @@ class ModerationSettings extends React.Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { settings, data, root, updatePending, errors } = this.props;
|
||||
const { settings, slotPassthrough } = this.props;
|
||||
|
||||
return (
|
||||
<ConfigurePage title={t('configure.moderation_settings')}>
|
||||
@@ -82,13 +82,7 @@ class ModerationSettings extends React.Component {
|
||||
suspectWords={settings.wordlist.suspect}
|
||||
onChangeWordlist={this.updateWordlist}
|
||||
/>
|
||||
<Slot
|
||||
fill="adminModerationSettings"
|
||||
data={data}
|
||||
queryData={{ root, settings }}
|
||||
updatePending={updatePending}
|
||||
errors={errors}
|
||||
/>
|
||||
<Slot fill="adminModerationSettings" passthrough={slotPassthrough} />
|
||||
</ConfigurePage>
|
||||
);
|
||||
}
|
||||
@@ -97,9 +91,8 @@ class ModerationSettings extends React.Component {
|
||||
ModerationSettings.propTypes = {
|
||||
updatePending: PropTypes.func.isRequired,
|
||||
errors: PropTypes.object.isRequired,
|
||||
data: PropTypes.object.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
settings: PropTypes.object.isRequired,
|
||||
slotPassthrough: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default ModerationSettings;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
.buttonActions {
|
||||
padding-top: 15px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
padding: 25px;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.close {
|
||||
font-size: 20px;
|
||||
line-height: 14px;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
position: absolute;
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
color: #363636;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.cancel {
|
||||
color: #363636;
|
||||
margin-right: 15px;
|
||||
display: inline-block;
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
margin-left: 5px;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import cn from 'classnames';
|
||||
import { Button, Dialog } from 'coral-ui';
|
||||
import styles from './SaveChangesDialog.css';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const SaveChangesDialog = ({
|
||||
saveDialog,
|
||||
hideSaveDialog,
|
||||
saveChanges,
|
||||
discardChanges,
|
||||
}) => (
|
||||
<Dialog
|
||||
className={cn(styles.dialog, 'talk-admin-configure-save-dialog')}
|
||||
id="saveDialog"
|
||||
open={saveDialog}
|
||||
onCancel={hideSaveDialog}
|
||||
>
|
||||
<span className={styles.close} onClick={hideSaveDialog}>
|
||||
×
|
||||
</span>
|
||||
<div className={styles.title}>
|
||||
{t('configure.save_changes_dialog.unsaved_changes')}
|
||||
</div>
|
||||
{t('configure.save_changes_dialog.copy')}
|
||||
<div
|
||||
className={cn(
|
||||
styles.buttonActions,
|
||||
'talk-admin-configure-save-dialog-button-actions'
|
||||
)}
|
||||
>
|
||||
<a className={styles.cancel} onClick={hideSaveDialog}>
|
||||
Cancel
|
||||
</a>
|
||||
<Button onClick={discardChanges} className={styles.button}>
|
||||
{t('configure.save_changes_dialog.discard')}
|
||||
</Button>
|
||||
<Button onClick={saveChanges} cStyle="green" className={styles.button}>
|
||||
{t('configure.save_changes_dialog.save_settings')}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
SaveChangesDialog.propTypes = {
|
||||
saveDialog: PropTypes.bool.isRequired,
|
||||
hideSaveDialog: PropTypes.func.isRequired,
|
||||
saveChanges: PropTypes.func.isRequired,
|
||||
discardChanges: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default SaveChangesDialog;
|
||||
@@ -107,7 +107,7 @@ class StreamSettings extends React.Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { settings, data, root, errors, updatePending } = this.props;
|
||||
const { settings, slotPassthrough, errors } = this.props;
|
||||
|
||||
return (
|
||||
<ConfigurePage title={t('configure.stream_settings')}>
|
||||
@@ -220,13 +220,7 @@ class StreamSettings extends React.Component {
|
||||
</div>
|
||||
</ConfigureCard>
|
||||
{/* the above card should be the last one if at all possible because of z-index issues with the selects */}
|
||||
<Slot
|
||||
fill="adminStreamSettings"
|
||||
data={data}
|
||||
queryData={{ root, settings }}
|
||||
updatePending={updatePending}
|
||||
errors={errors}
|
||||
/>
|
||||
<Slot fill="adminStreamSettings" passthrough={slotPassthrough} />
|
||||
</ConfigurePage>
|
||||
);
|
||||
}
|
||||
@@ -235,9 +229,8 @@ class StreamSettings extends React.Component {
|
||||
StreamSettings.propTypes = {
|
||||
updatePending: PropTypes.func.isRequired,
|
||||
errors: PropTypes.object.isRequired,
|
||||
data: PropTypes.object.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
settings: PropTypes.object.isRequired,
|
||||
slotPassthrough: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default StreamSettings;
|
||||
|
||||
@@ -34,7 +34,7 @@ class TechSettings extends React.Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { settings, data, root, errors, updatePending } = this.props;
|
||||
const { settings, slotPassthrough } = this.props;
|
||||
return (
|
||||
<ConfigurePage title={t('configure.tech_settings')}>
|
||||
<Domainlist
|
||||
@@ -50,13 +50,7 @@ class TechSettings extends React.Component {
|
||||
onChange={this.updateCustomCssUrl}
|
||||
/>
|
||||
</ConfigureCard>
|
||||
<Slot
|
||||
fill="adminTechSettings"
|
||||
data={data}
|
||||
queryData={{ root, settings }}
|
||||
updatePending={updatePending}
|
||||
errors={errors}
|
||||
/>
|
||||
<Slot fill="adminTechSettings" passthrough={slotPassthrough} />
|
||||
</ConfigurePage>
|
||||
);
|
||||
}
|
||||
@@ -64,10 +58,9 @@ class TechSettings extends React.Component {
|
||||
|
||||
TechSettings.propTypes = {
|
||||
updatePending: PropTypes.func.isRequired,
|
||||
errors: PropTypes.object.isRequired,
|
||||
data: PropTypes.object.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
settings: PropTypes.object.isRequired,
|
||||
slotPassthrough: PropTypes.object.isRequired,
|
||||
errors: PropTypes.object,
|
||||
};
|
||||
|
||||
export default TechSettings;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { Component } from 'react';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { compose, gql } from 'react-apollo';
|
||||
@@ -10,15 +10,70 @@ import { getDefinitionName } from 'coral-framework/utils';
|
||||
import StreamSettings from './StreamSettings';
|
||||
import TechSettings from './TechSettings';
|
||||
import ModerationSettings from './ModerationSettings';
|
||||
import { clearPending, setActiveSection } from '../../../actions/configure';
|
||||
import {
|
||||
clearPending,
|
||||
showSaveDialog,
|
||||
hideSaveDialog,
|
||||
} from '../../../actions/configure';
|
||||
import Configure from '../components/Configure';
|
||||
import { withRouter } from 'react-router';
|
||||
|
||||
class ConfigureContainer extends React.Component {
|
||||
state = { nextRoute: '' };
|
||||
|
||||
class ConfigureContainer extends Component {
|
||||
savePending = async () => {
|
||||
await this.props.updateSettings(this.props.pending);
|
||||
this.props.clearPending();
|
||||
};
|
||||
|
||||
saveChanges = async () => {
|
||||
await this.savePending();
|
||||
this.props.hideSaveDialog();
|
||||
this.gotoNextRoute();
|
||||
};
|
||||
|
||||
discardChanges = async () => {
|
||||
await this.props.clearPending();
|
||||
this.props.hideSaveDialog();
|
||||
this.gotoNextRoute();
|
||||
};
|
||||
|
||||
gotoNextRoute = () => {
|
||||
const { nextRoute } = this.state;
|
||||
if (nextRoute) {
|
||||
this.props.router.push(nextRoute);
|
||||
this.setState({ nextRoute: '' });
|
||||
}
|
||||
};
|
||||
|
||||
handleSectionChange = async section => {
|
||||
const nextRoute = `/admin/configure/${section}`;
|
||||
|
||||
if (this.shouldShowSaveDialog()) {
|
||||
await this.setState({ nextRoute });
|
||||
this.props.showSaveDialog();
|
||||
} else {
|
||||
// Just go to the section
|
||||
this.props.router.push(nextRoute);
|
||||
}
|
||||
};
|
||||
|
||||
shouldShowSaveDialog = () => {
|
||||
return !!Object.keys(this.props.pending).length;
|
||||
};
|
||||
|
||||
routeLeave = ({ pathname }) => {
|
||||
if (this.shouldShowSaveDialog()) {
|
||||
this.setState({ nextRoute: pathname });
|
||||
this.props.showSaveDialog();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.router.setRouteLeaveHook(this.props.route, this.routeLeave);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.data.error) {
|
||||
return <div>{this.props.data.error.message}</div>;
|
||||
@@ -30,15 +85,20 @@ class ConfigureContainer extends Component {
|
||||
|
||||
return (
|
||||
<Configure
|
||||
saveChanges={this.saveChanges}
|
||||
discardChanges={this.discardChanges}
|
||||
saveDialog={this.props.saveDialog}
|
||||
activeSection={this.props.routes[3].path}
|
||||
hideSaveDialog={this.props.hideSaveDialog}
|
||||
canSave={this.props.canSave}
|
||||
currentUser={this.props.currentUser}
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
settings={this.props.mergedSettings}
|
||||
canSave={this.props.canSave}
|
||||
handleSectionChange={this.handleSectionChange}
|
||||
savePending={this.savePending}
|
||||
setActiveSection={this.props.setActiveSection}
|
||||
activeSection={this.props.activeSection}
|
||||
/>
|
||||
>
|
||||
{this.props.children}
|
||||
</Configure>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -75,18 +135,21 @@ const mapStateToProps = state => ({
|
||||
pending: state.configure.pending,
|
||||
canSave: state.configure.canSave,
|
||||
activeSection: state.configure.activeSection,
|
||||
saveDialog: state.configure.saveDialog,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch =>
|
||||
bindActionCreators(
|
||||
{
|
||||
clearPending,
|
||||
setActiveSection,
|
||||
showSaveDialog,
|
||||
hideSaveDialog,
|
||||
},
|
||||
dispatch
|
||||
);
|
||||
|
||||
export default compose(
|
||||
withRouter,
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
withUpdateSettings,
|
||||
withConfigureQuery,
|
||||
@@ -94,14 +157,20 @@ export default compose(
|
||||
)(ConfigureContainer);
|
||||
|
||||
ConfigureContainer.propTypes = {
|
||||
activeSection: PropTypes.string,
|
||||
updateSettings: PropTypes.func.isRequired,
|
||||
clearPending: PropTypes.func.isRequired,
|
||||
setActiveSection: PropTypes.func.isRequired,
|
||||
showSaveDialog: PropTypes.func.isRequired,
|
||||
hideSaveDialog: PropTypes.func.isRequired,
|
||||
saveDialog: PropTypes.bool.isRequired,
|
||||
currentUser: PropTypes.object.isRequired,
|
||||
data: PropTypes.object.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
canSave: PropTypes.bool.isRequired,
|
||||
pending: PropTypes.object.isRequired,
|
||||
mergedSettings: PropTypes.object.isRequired,
|
||||
activeSection: PropTypes.string.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
router: PropTypes.object,
|
||||
route: PropTypes.object,
|
||||
routes: PropTypes.array,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import ModerationSettings from '../components/ModerationSettings';
|
||||
import withFragments from 'coral-framework/hocs/withFragments';
|
||||
import { getSlotFragmentSpreads } from 'coral-framework/utils';
|
||||
import { updatePending } from '../../../actions/configure';
|
||||
import { mapProps } from 'recompose';
|
||||
|
||||
const slots = ['adminModerationSettings'];
|
||||
|
||||
@@ -41,5 +42,17 @@ export default compose(
|
||||
}
|
||||
`,
|
||||
}),
|
||||
connect(mapStateToProps, mapDispatchToProps)
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
mapProps(({ root, settings, updatePending, errors, ...rest }) => ({
|
||||
slotPassthrough: {
|
||||
root,
|
||||
settings,
|
||||
updatePending,
|
||||
errors,
|
||||
},
|
||||
updatePending,
|
||||
settings,
|
||||
errors,
|
||||
...rest,
|
||||
}))
|
||||
)(ModerationSettings);
|
||||
|
||||
@@ -5,6 +5,7 @@ import StreamSettings from '../components/StreamSettings';
|
||||
import withFragments from 'coral-framework/hocs/withFragments';
|
||||
import { getSlotFragmentSpreads } from 'coral-framework/utils';
|
||||
import { updatePending } from '../../../actions/configure';
|
||||
import { mapProps } from 'recompose';
|
||||
|
||||
const slots = ['adminStreamSettings'];
|
||||
|
||||
@@ -42,5 +43,17 @@ export default compose(
|
||||
}
|
||||
`,
|
||||
}),
|
||||
connect(mapStateToProps, mapDispatchToProps)
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
mapProps(({ root, settings, updatePending, errors, ...rest }) => ({
|
||||
slotPassthrough: {
|
||||
root,
|
||||
settings,
|
||||
updatePending,
|
||||
errors,
|
||||
},
|
||||
updatePending,
|
||||
settings,
|
||||
errors,
|
||||
...rest,
|
||||
}))
|
||||
)(StreamSettings);
|
||||
|
||||
@@ -5,6 +5,7 @@ import TechSettings from '../components/TechSettings';
|
||||
import withFragments from 'coral-framework/hocs/withFragments';
|
||||
import { getSlotFragmentSpreads } from 'coral-framework/utils';
|
||||
import { updatePending } from '../../../actions/configure';
|
||||
import { mapProps } from 'recompose';
|
||||
|
||||
const slots = ['adminTechSettings'];
|
||||
|
||||
@@ -38,5 +39,17 @@ export default compose(
|
||||
}
|
||||
`,
|
||||
}),
|
||||
connect(mapStateToProps, mapDispatchToProps)
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
mapProps(({ root, settings, updatePending, errors, ...rest }) => ({
|
||||
slotPassthrough: {
|
||||
root,
|
||||
settings,
|
||||
updatePending,
|
||||
errors,
|
||||
},
|
||||
updatePending,
|
||||
settings,
|
||||
errors,
|
||||
...rest,
|
||||
}))
|
||||
)(TechSettings);
|
||||
|
||||
@@ -10,12 +10,14 @@ const InitialStep = () => {
|
||||
return (
|
||||
<div className={cn(styles.step, styles.finalStep, 'talk-install-step-5')}>
|
||||
<p>{t('install.final.description')}</p>
|
||||
<Button raised>
|
||||
<Link to="/admin">{t('install.final.launch')}</Link>
|
||||
</Button>
|
||||
<Button cStyle="black" raised>
|
||||
<a href="http://coralproject.net">{t('install.final.close')}</a>
|
||||
</Button>
|
||||
<Link to="/admin">
|
||||
<Button raised>{t('install.final.launch')}</Button>
|
||||
</Link>
|
||||
<a href="http://coralproject.net">
|
||||
<Button cStyle="black" raised>
|
||||
{t('install.final.close')}
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import styles from './Comment.css';
|
||||
import CommentLabels from 'coral-admin/src/components/CommentLabels';
|
||||
import CommentAnimatedEdit from 'coral-admin/src/components/CommentAnimatedEdit';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
import CommentFormatter from 'coral-admin/src/components/CommentFormatter';
|
||||
import AdminCommentContent from 'coral-framework/components/AdminCommentContent';
|
||||
import IfHasLink from 'coral-admin/src/components/IfHasLink';
|
||||
import cn from 'classnames';
|
||||
import ApproveButton from 'coral-admin/src/components/ApproveButton';
|
||||
@@ -53,7 +53,6 @@ class Comment extends React.Component {
|
||||
comment,
|
||||
selected,
|
||||
className,
|
||||
data,
|
||||
root,
|
||||
root: { settings },
|
||||
currentAsset,
|
||||
@@ -62,7 +61,6 @@ class Comment extends React.Component {
|
||||
} = this.props;
|
||||
|
||||
const selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
|
||||
const queryData = { root, comment, asset: comment.asset };
|
||||
|
||||
const formatterSettings = {
|
||||
suspectWords: settings.wordlist.suspect,
|
||||
@@ -70,6 +68,13 @@ class Comment extends React.Component {
|
||||
body: comment.body,
|
||||
};
|
||||
|
||||
const slotPassthrough = {
|
||||
clearHeightCache,
|
||||
root,
|
||||
comment,
|
||||
asset: comment.asset,
|
||||
};
|
||||
|
||||
return (
|
||||
<li
|
||||
tabIndex={0}
|
||||
@@ -113,16 +118,15 @@ class Comment extends React.Component {
|
||||
<CommentLabels comment={comment} />
|
||||
<Slot
|
||||
fill="adminCommentInfoBar"
|
||||
data={data}
|
||||
clearHeightCache={clearHeightCache}
|
||||
queryData={queryData}
|
||||
passthrough={slotPassthrough}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.moderateArticle}>
|
||||
Story: {comment.asset.title}
|
||||
{t('common.story')}:{' '}
|
||||
{comment.asset.title ? comment.asset.title : comment.asset.url}
|
||||
{!currentAsset && (
|
||||
<Link to={`/admin/moderate/${comment.asset.id}`}>
|
||||
{t('modqueue.moderate')}
|
||||
@@ -134,13 +138,10 @@ class Comment extends React.Component {
|
||||
<div className={styles.body}>
|
||||
<Slot
|
||||
fill="adminCommentContent"
|
||||
data={data}
|
||||
className={cn(styles.commentContent, 'talk-admin-comment')}
|
||||
clearHeightCache={clearHeightCache}
|
||||
queryData={queryData}
|
||||
slotSize={1}
|
||||
defaultComponent={CommentFormatter}
|
||||
{...formatterSettings}
|
||||
size={1}
|
||||
defaultComponent={AdminCommentContent}
|
||||
passthrough={{ ...slotPassthrough, ...formatterSettings }}
|
||||
/>
|
||||
<div className={styles.commentContentFooter}>
|
||||
<a
|
||||
@@ -156,6 +157,7 @@ class Comment extends React.Component {
|
||||
<div className={styles.sideActions}>
|
||||
<IfHasLink text={comment.body}>
|
||||
<span className={styles.hasLinks}>
|
||||
{/* TODO: translate string */}
|
||||
<Icon name="error_outline" /> Contains Link
|
||||
</span>
|
||||
</IfHasLink>
|
||||
@@ -169,18 +171,12 @@ class Comment extends React.Component {
|
||||
onClick={this.reject}
|
||||
/>
|
||||
</div>
|
||||
<Slot
|
||||
fill="adminSideActions"
|
||||
data={data}
|
||||
clearHeightCache={clearHeightCache}
|
||||
queryData={queryData}
|
||||
/>
|
||||
<Slot fill="adminSideActions" passthrough={slotPassthrough} />
|
||||
</div>
|
||||
</div>
|
||||
</CommentAnimatedEdit>
|
||||
</div>
|
||||
<CommentDetails
|
||||
data={data}
|
||||
root={root}
|
||||
comment={comment}
|
||||
clearHeightCache={clearHeightCache}
|
||||
@@ -218,7 +214,6 @@ Comment.propTypes = {
|
||||
id: PropTypes.string,
|
||||
}),
|
||||
}),
|
||||
data: PropTypes.object.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
selected: PropTypes.bool,
|
||||
};
|
||||
|
||||
@@ -126,7 +126,6 @@ class Moderation extends Component {
|
||||
render() {
|
||||
const {
|
||||
root,
|
||||
data,
|
||||
moderation,
|
||||
viewUserDetail,
|
||||
activeTab,
|
||||
@@ -148,6 +147,13 @@ class Moderation extends Component {
|
||||
count: root[`${queue}Count`],
|
||||
}));
|
||||
|
||||
const slotPassthrough = {
|
||||
root,
|
||||
asset,
|
||||
activeTab,
|
||||
handleCommentChange,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ModerationHeader
|
||||
@@ -171,7 +177,6 @@ class Moderation extends Component {
|
||||
/>
|
||||
<ModerationQueue
|
||||
key={`${activeTab}_${this.props.moderation.sortOrder}`}
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
currentAsset={asset}
|
||||
comments={comments.nodes}
|
||||
@@ -204,13 +209,7 @@ class Moderation extends Component {
|
||||
closeSearch={this.closeSearch}
|
||||
storySearchChange={this.props.storySearchChange}
|
||||
/>
|
||||
<Slot
|
||||
data={data}
|
||||
queryData={{ root, asset }}
|
||||
activeTab={activeTab}
|
||||
handleCommentChange={handleCommentChange}
|
||||
fill="adminModeration"
|
||||
/>
|
||||
<Slot fill="adminModeration" passthrough={slotPassthrough} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,10 +6,6 @@
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
:global(html) {
|
||||
height: inherit;
|
||||
}
|
||||
|
||||
.list {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -344,7 +344,6 @@ class ModerationQueue extends React.Component {
|
||||
child = (
|
||||
<div style={style}>
|
||||
<Comment
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
comment={comment}
|
||||
dangling={
|
||||
@@ -408,7 +407,6 @@ class ModerationQueue extends React.Component {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<Comment
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
@@ -476,7 +474,6 @@ ModerationQueue.propTypes = {
|
||||
hasNextPage: PropTypes.bool,
|
||||
comments: PropTypes.array,
|
||||
activeTab: PropTypes.string.isRequired,
|
||||
data: PropTypes.object.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
currentUserId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
@@ -111,14 +111,14 @@ class IndicatorContainer extends Component {
|
||||
return null;
|
||||
}
|
||||
|
||||
const slotPassthrough = {
|
||||
handleCommentChange: this.handleCommentChange,
|
||||
};
|
||||
|
||||
return (
|
||||
<span>
|
||||
<Indicator />
|
||||
<Slot
|
||||
data={this.props.data}
|
||||
handleCommentChange={this.handleCommentChange}
|
||||
fill="adminModerationIndicator"
|
||||
/>
|
||||
<Slot fill="adminModerationIndicator" passthrough={slotPassthrough} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,3 +89,12 @@
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.statusDropdown {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.statusDropdownOption {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,11 +22,20 @@ class Stories extends Component {
|
||||
const closed = !!(closedAt && new Date(closedAt).getTime() < Date.now());
|
||||
return (
|
||||
<Dropdown
|
||||
className={styles.statusDropdown}
|
||||
value={closed}
|
||||
onChange={value => this.props.onStatusChange(value, id)}
|
||||
>
|
||||
<Option value={false} label={t('streams.open')} />
|
||||
<Option value={true} label={t('streams.closed')} />
|
||||
<Option
|
||||
value={false}
|
||||
label={t('streams.open')}
|
||||
className={styles.statusDropdownOption}
|
||||
/>
|
||||
<Option
|
||||
value={true}
|
||||
label={t('streams.closed')}
|
||||
className={styles.statusDropdownOption}
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
import LinkifyIt from 'linkify-it';
|
||||
import tlds from 'tlds';
|
||||
const linkify = new LinkifyIt();
|
||||
linkify.tlds(tlds);
|
||||
|
||||
export function matchLinks(text) {
|
||||
return linkify.match(text);
|
||||
}
|
||||
|
||||
export const isPremod = mod => mod === 'PRE';
|
||||
|
||||
export const getModPath = (type = 'all', assetId) =>
|
||||
|
||||
@@ -71,16 +71,19 @@ export const setActiveTab = tab => dispatch => {
|
||||
dispatch({ type: actions.SET_ACTIVE_TAB, tab });
|
||||
};
|
||||
|
||||
// @Deprecated
|
||||
export const addCommentBoxTag = tag => ({
|
||||
type: actions.ADD_COMMENT_BOX_TAG,
|
||||
tag,
|
||||
});
|
||||
|
||||
// @Deprecated
|
||||
export const removeCommentBoxTag = idx => ({
|
||||
type: actions.REMOVE_COMMENT_BOX_TAG,
|
||||
idx,
|
||||
});
|
||||
|
||||
// @Deprecated
|
||||
export const clearCommentBoxTags = () => ({
|
||||
type: actions.CLEAR_COMMENT_BOX_TAGS,
|
||||
});
|
||||
|
||||
@@ -63,6 +63,7 @@ export default class Embed extends React.Component {
|
||||
} = this.props;
|
||||
const hasHighlightedComment = !!commentId;
|
||||
const popupUrl = `login?parentUrl=${encodeURIComponent(parentUrl)}`;
|
||||
const slotPassthrough = { root };
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -84,7 +85,7 @@ export default class Embed extends React.Component {
|
||||
/>
|
||||
</IfSlotIsNotEmpty>
|
||||
|
||||
<Slot data={data} queryData={{ root }} fill="embed" />
|
||||
<Slot passthrough={slotPassthrough} fill="embed" />
|
||||
|
||||
<ExtendableTabPanel
|
||||
className="talk-embed-stream-tab-bar"
|
||||
@@ -94,8 +95,7 @@ export default class Embed extends React.Component {
|
||||
tabSlot="embedStreamTabs"
|
||||
tabSlotPrepend="embedStreamTabsPrepend"
|
||||
tabPaneSlot="embedStreamTabPanes"
|
||||
slotProps={{ data }}
|
||||
queryData={{ root }}
|
||||
slotPassthrough={slotPassthrough}
|
||||
tabs={this.getTabs()}
|
||||
tabPanes={[
|
||||
<TabPane
|
||||
@@ -138,9 +138,5 @@ Embed.propTypes = {
|
||||
commentId: PropTypes.string,
|
||||
root: PropTypes.object,
|
||||
activeTab: PropTypes.string,
|
||||
data: PropTypes.shape({
|
||||
loading: PropTypes.bool,
|
||||
error: PropTypes.object,
|
||||
refetch: PropTypes.func,
|
||||
}).isRequired,
|
||||
data: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import React from 'react';
|
||||
import ExtendableTabPanel from '../components/ExtendableTabPanel';
|
||||
import { connect } from 'react-redux';
|
||||
import { TabPane } from 'coral-ui';
|
||||
import ExtendableTab from '../components/ExtendableTab';
|
||||
import { getShallowChanges } from 'coral-framework/utils';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withSlotElements } from 'coral-framework/hocs';
|
||||
import { compose } from 'recompose';
|
||||
|
||||
class ExtendableTabPanelContainer extends React.Component {
|
||||
static contextTypes = {
|
||||
plugins: PropTypes.object,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.handleFallback();
|
||||
}
|
||||
@@ -20,24 +15,6 @@ class ExtendableTabPanelContainer extends React.Component {
|
||||
this.handleFallback(next);
|
||||
}
|
||||
|
||||
shouldComponentUpdate(next) {
|
||||
// Prevent Slot from rerendering when only reduxState has changed and
|
||||
// it does not result in a change of slot children.
|
||||
const changes = getShallowChanges(this.props, next);
|
||||
if (changes.length === 1 && changes[0] === 'reduxState') {
|
||||
const prevKeys = this.getSlotElements(this.props.tabSlot, this.props).map(
|
||||
el => el.key
|
||||
);
|
||||
const nextKeys = this.getSlotElements(next.tabSlot, next).map(
|
||||
el => el.key
|
||||
);
|
||||
return !isEqual(prevKeys, nextKeys);
|
||||
}
|
||||
|
||||
// Prevent Slot from rerendering when no props has shallowly changed.
|
||||
return changes.length !== 0;
|
||||
}
|
||||
|
||||
handleFallback(props = this.props) {
|
||||
if (this.getTabNames(props).indexOf(props.activeTab) === -1) {
|
||||
props.setActiveTab(props.fallbackTab);
|
||||
@@ -48,38 +25,23 @@ class ExtendableTabPanelContainer extends React.Component {
|
||||
return this.getTabElements(props).map(el => el.props.tabId);
|
||||
}
|
||||
|
||||
getSlotElements(slot, props = this.props) {
|
||||
const { plugins } = this.context;
|
||||
return plugins.getSlotElements(
|
||||
slot,
|
||||
props.reduxState,
|
||||
props.slotProps,
|
||||
props.queryData
|
||||
);
|
||||
}
|
||||
|
||||
getPluginTabElements(props = this.props) {
|
||||
return this.getSlotTabElements(props.tabSlot);
|
||||
return props.slotElements[0].map(this.createPluginTabFactory(props));
|
||||
}
|
||||
|
||||
getPluginTabElementsPrepend(props = this.props) {
|
||||
return this.getSlotTabElements(props.tabSlotPrepend);
|
||||
return props.slotElements[1].map(this.createPluginTabFactory(props));
|
||||
}
|
||||
|
||||
getSlotTabElements(slot) {
|
||||
return this.getSlotElements(slot).map(el => {
|
||||
return (
|
||||
<ExtendableTab
|
||||
tabId={el.type.talkPluginName}
|
||||
key={el.type.talkPluginName}
|
||||
>
|
||||
{React.cloneElement(el, {
|
||||
active: this.props.activeTab === el.type.talkPluginName,
|
||||
})}
|
||||
</ExtendableTab>
|
||||
);
|
||||
});
|
||||
}
|
||||
createPluginTabFactory = (props = this.props) => el => {
|
||||
return (
|
||||
<ExtendableTab tabId={el.key} key={el.key}>
|
||||
{React.cloneElement(el, {
|
||||
active: props.activeTab === el.key,
|
||||
})}
|
||||
</ExtendableTab>
|
||||
);
|
||||
};
|
||||
|
||||
getTabElements(props = this.props) {
|
||||
const elements = [...this.getPluginTabElementsPrepend(props)];
|
||||
@@ -92,14 +54,16 @@ class ExtendableTabPanelContainer extends React.Component {
|
||||
return elements;
|
||||
}
|
||||
|
||||
createPluginTabPane(el) {
|
||||
return (
|
||||
<TabPane tabId={el.key} key={el.key}>
|
||||
{el}
|
||||
</TabPane>
|
||||
);
|
||||
}
|
||||
|
||||
getPluginTabPaneElements(props = this.props) {
|
||||
return this.getSlotElements(props.tabPaneSlot).map(el => {
|
||||
return (
|
||||
<TabPane tabId={el.type.talkPluginName} key={el.type.talkPluginName}>
|
||||
{el}
|
||||
</TabPane>
|
||||
);
|
||||
});
|
||||
return props.slotElements[2].map(this.createPluginTabPane);
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -132,15 +96,15 @@ ExtendableTabPanelContainer.propTypes = {
|
||||
tabSlot: PropTypes.string.isRequired,
|
||||
tabSlotPrepend: PropTypes.string.isRequired,
|
||||
tabPaneSlot: PropTypes.string.isRequired,
|
||||
slotProps: PropTypes.object.isRequired,
|
||||
queryData: PropTypes.object,
|
||||
slotPassthrough: PropTypes.object,
|
||||
className: PropTypes.string,
|
||||
sub: PropTypes.bool,
|
||||
loading: PropTypes.bool,
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
reduxState: state,
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, null)(ExtendableTabPanelContainer);
|
||||
export default compose(
|
||||
withSlotElements({
|
||||
slot: props => [props.tabSlot, props.tabSlotPrepend, props.tabPaneSlot],
|
||||
passthroughPropName: 'slotPassthrough',
|
||||
})
|
||||
)(ExtendableTabPanelContainer);
|
||||
|
||||
@@ -8,7 +8,7 @@ const initialState = {
|
||||
errors: {},
|
||||
};
|
||||
|
||||
export default function config(state = initialState, action) {
|
||||
export default function configure(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case actions.UPDATE_PENDING: {
|
||||
let next = state;
|
||||
|
||||
@@ -7,11 +7,7 @@ class Configure extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div className="talk-embed-stream-configuration-container">
|
||||
<Settings
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
asset={this.props.asset}
|
||||
/>
|
||||
<Settings root={this.props.root} asset={this.props.asset} />
|
||||
<hr />
|
||||
<AssetStatusInfo asset={this.props.asset} />
|
||||
</div>
|
||||
@@ -20,7 +16,6 @@ class Configure extends React.Component {
|
||||
}
|
||||
|
||||
Configure.propTypes = {
|
||||
data: PropTypes.object.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
asset: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
@@ -1,38 +1,15 @@
|
||||
import React from 'react';
|
||||
import QuestionBox from '../../../components/QuestionBox';
|
||||
import { Icon, Spinner } from 'coral-ui';
|
||||
import DefaultQuestionBoxIcon from '../../../components/DefaultQuestionBoxIcon';
|
||||
import cn from 'classnames';
|
||||
import styles from './QuestionBoxBuilder.css';
|
||||
import { Icon } from 'coral-ui';
|
||||
import MarkdownEditor from 'coral-framework/components/MarkdownEditor';
|
||||
|
||||
const DefaultIcon = <DefaultQuestionBoxIcon className={styles.defaultIcon} />;
|
||||
|
||||
const icons = [{ default: DefaultIcon }, 'forum', 'build', 'format_quote'];
|
||||
|
||||
class QuestionBoxBuilder extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.state = {
|
||||
loading: true,
|
||||
};
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.loadEditor();
|
||||
}
|
||||
|
||||
async loadEditor() {
|
||||
const {
|
||||
default: MarkdownEditor,
|
||||
} = await import('coral-framework/components/MarkdownEditor');
|
||||
|
||||
return this.setState({
|
||||
loading: false,
|
||||
MarkdownEditor,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
questionBoxIcon,
|
||||
@@ -40,11 +17,6 @@ class QuestionBoxBuilder extends React.Component {
|
||||
onContentChange,
|
||||
onIconChange,
|
||||
} = this.props;
|
||||
const { loading, MarkdownEditor } = this.state;
|
||||
|
||||
if (loading) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
|
||||
@@ -25,9 +25,9 @@ class Settings extends React.Component {
|
||||
onQuestionBoxContentChange,
|
||||
canSave,
|
||||
onApply,
|
||||
slotProps,
|
||||
queryData,
|
||||
slotPassthrough,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.container}>
|
||||
@@ -77,7 +77,7 @@ class Settings extends React.Component {
|
||||
</div>
|
||||
)}
|
||||
</Configuration>
|
||||
<Slot fill="streamSettings" queryData={queryData} {...slotProps} />
|
||||
<Slot fill="streamSettings" passthrough={slotPassthrough} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -85,8 +85,7 @@ class Settings extends React.Component {
|
||||
}
|
||||
|
||||
Settings.propTypes = {
|
||||
queryData: PropTypes.object.isRequired,
|
||||
slotProps: PropTypes.object.isRequired,
|
||||
slotPassthrough: PropTypes.object.isRequired,
|
||||
settings: PropTypes.object.isRequired,
|
||||
canSave: PropTypes.bool.isRequired,
|
||||
onToggleModeration: PropTypes.func.isRequired,
|
||||
|
||||
@@ -13,13 +13,7 @@ class ConfigureContainer extends React.Component {
|
||||
return <div>{this.props.data.error.message}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Configure
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
asset={this.props.asset}
|
||||
/>
|
||||
);
|
||||
return <Configure root={this.props.root} asset={this.props.asset} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,17 +57,24 @@ class SettingsContainer extends React.Component {
|
||||
const {
|
||||
mergedSettings,
|
||||
canSave,
|
||||
data,
|
||||
root,
|
||||
asset,
|
||||
errors,
|
||||
updatePending,
|
||||
} = this.props;
|
||||
|
||||
const slotPassthrough = {
|
||||
root,
|
||||
asset,
|
||||
settings: mergedSettings,
|
||||
updatePending,
|
||||
errors,
|
||||
};
|
||||
|
||||
return (
|
||||
<Settings
|
||||
settings={mergedSettings}
|
||||
queryData={{ root, asset, settings: mergedSettings }}
|
||||
slotProps={{ data, updatePending, errors }}
|
||||
slotPassthrough={slotPassthrough}
|
||||
savePending={this.savePending}
|
||||
onToggleModeration={this.toggleModeration}
|
||||
onTogglePremodLinks={this.togglePremodLinks}
|
||||
@@ -82,7 +89,6 @@ class SettingsContainer extends React.Component {
|
||||
}
|
||||
|
||||
SettingsContainer.propTypes = {
|
||||
data: PropTypes.object.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
asset: PropTypes.object.isRequired,
|
||||
pending: PropTypes.object.isRequired,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
.root {
|
||||
text-align: center;
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 64px;
|
||||
color: #3a4a52;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.info {
|
||||
margin-top: 0px;
|
||||
font-size: 14px;
|
||||
color: #7d8285;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import styles from './BlankCommentHistory.css';
|
||||
import { Icon } from 'coral-ui';
|
||||
import cn from 'classnames';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
export default () => (
|
||||
<section className={cn(styles.root, 'talk-my-profile-comment-history-blank')}>
|
||||
<Icon name="chat" className={styles.icon} />
|
||||
<h1 className={styles.title}>{t('comment_history_blank.title')}</h1>
|
||||
<p className={styles.info}>{t('comment_history_blank.info')}</p>
|
||||
</section>
|
||||
);
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
.main {
|
||||
min-width: 70%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
|
||||
@@ -22,9 +22,9 @@ class Comment extends React.Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { comment, data, root } = this.props;
|
||||
const { comment, root } = this.props;
|
||||
const reactionCount = getTotalReactionsCount(comment.action_summaries);
|
||||
const queryData = { root, comment, asset: comment.asset };
|
||||
const slotPassthrough = { root, comment, asset: comment.asset };
|
||||
|
||||
return (
|
||||
<div className={styles.myComment}>
|
||||
@@ -33,8 +33,8 @@ class Comment extends React.Component {
|
||||
fill="commentContent"
|
||||
defaultComponent={CommentContent}
|
||||
className={cn(styles.commentBody, 'my-comment-body')}
|
||||
data={data}
|
||||
queryData={queryData}
|
||||
passthrough={slotPassthrough}
|
||||
size={1}
|
||||
/>
|
||||
<div className={cn(styles.commentSummary, 'comment-summary')}>
|
||||
<span
|
||||
@@ -98,9 +98,10 @@ class Comment extends React.Component {
|
||||
fill="historyCommentTimestamp"
|
||||
defaultComponent={CommentTimestamp}
|
||||
className={'talk-history-comment-published-date'}
|
||||
created_at={comment.created_at}
|
||||
data={data}
|
||||
queryData={queryData}
|
||||
passthrough={{
|
||||
created_at: comment.created_at,
|
||||
...slotPassthrough,
|
||||
}}
|
||||
inline
|
||||
/>
|
||||
</li>
|
||||
@@ -114,7 +115,6 @@ class Comment extends React.Component {
|
||||
Comment.propTypes = {
|
||||
comment: PropTypes.object.isRequired,
|
||||
navigate: PropTypes.func.isRequired,
|
||||
data: PropTypes.object.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Comment from '../containers/Comment';
|
||||
import LoadMore from './LoadMore';
|
||||
import BlankCommentHistory from './BlankCommentHistory';
|
||||
|
||||
class CommentHistory extends React.Component {
|
||||
state = {
|
||||
@@ -21,7 +22,10 @@ class CommentHistory extends React.Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { navigate, comments, data, root } = this.props;
|
||||
const { navigate, comments, root } = this.props;
|
||||
if (!comments.nodes.length) {
|
||||
return <BlankCommentHistory />;
|
||||
}
|
||||
return (
|
||||
<div className="talk-my-profile-comment-history">
|
||||
<div className="commentHistory__list">
|
||||
@@ -29,7 +33,6 @@ class CommentHistory extends React.Component {
|
||||
return (
|
||||
<Comment
|
||||
key={i}
|
||||
data={data}
|
||||
root={root}
|
||||
comment={comment}
|
||||
navigate={navigate}
|
||||
@@ -52,7 +55,6 @@ CommentHistory.propTypes = {
|
||||
comments: PropTypes.object.isRequired,
|
||||
loadMore: PropTypes.func,
|
||||
navigate: PropTypes.func,
|
||||
data: PropTypes.object,
|
||||
root: PropTypes.object,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,57 +1,27 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
import CommentHistory from '../containers/CommentHistory';
|
||||
import ExtendableTabPanel from '../../../containers/ExtendableTabPanel';
|
||||
import { Tab, TabPane } from 'coral-ui';
|
||||
import styles from './Profile.css';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import TabPanel from '../containers/TabPanel';
|
||||
|
||||
const Profile = ({
|
||||
username,
|
||||
emailAddress,
|
||||
data,
|
||||
root,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
}) => (
|
||||
<div className="talk-my-profile talk-profile-container">
|
||||
<div className={styles.userInfo}>
|
||||
<h2 className={styles.username}>{username}</h2>
|
||||
{emailAddress ? <p className={styles.email}>{emailAddress}</p> : null}
|
||||
const Profile = ({ username, emailAddress, root, slotPassthrough }) => {
|
||||
return (
|
||||
<div className="talk-my-profile talk-profile-container">
|
||||
<div className={styles.userInfo}>
|
||||
<h2 className={styles.username}>{username}</h2>
|
||||
{emailAddress ? <p className={styles.email}>{emailAddress}</p> : null}
|
||||
</div>
|
||||
<Slot fill="profileSections" passthrough={slotPassthrough} />
|
||||
<TabPanel root={root} slotPassthrough={slotPassthrough} />
|
||||
</div>
|
||||
<Slot fill="profileSections" data={data} queryData={{ root }} />
|
||||
<ExtendableTabPanel
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
fallbackTab="comments"
|
||||
tabSlot="profileTabs"
|
||||
tabSlotPrepend="profileTabsPrepend"
|
||||
tabPaneSlot="profileTabPanes"
|
||||
slotProps={{ data }}
|
||||
queryData={{ root }}
|
||||
tabs={[
|
||||
<Tab key="comments" tabId="comments">
|
||||
{t('framework.my_comments')}
|
||||
</Tab>,
|
||||
]}
|
||||
tabPanes={[
|
||||
<TabPane key="comments" tabId="comments">
|
||||
<CommentHistory data={data} root={root} />
|
||||
</TabPane>,
|
||||
]}
|
||||
sub
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
Profile.propTypes = {
|
||||
username: PropTypes.string,
|
||||
emailAddress: PropTypes.string,
|
||||
data: PropTypes.object,
|
||||
root: PropTypes.object,
|
||||
activeTab: PropTypes.string.isRequired,
|
||||
setActiveTab: PropTypes.func.isRequired,
|
||||
slotPassthrough: PropTypes.object,
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Slot } from 'coral-framework/components';
|
||||
|
||||
class Settings extends React.Component {
|
||||
render() {
|
||||
const { root } = this.props;
|
||||
const slotPassthrough = { root };
|
||||
return (
|
||||
<div>
|
||||
<Slot fill="profileSettings" passthrough={slotPassthrough} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Settings.propTypes = {
|
||||
root: PropTypes.object,
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import CommentHistory from '../containers/CommentHistory';
|
||||
import ExtendableTabPanel from '../../../containers/ExtendableTabPanel';
|
||||
import { Tab, TabPane } from 'coral-ui';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import Settings from '../containers/Settings';
|
||||
|
||||
const TabPanel = ({
|
||||
root,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
showSettingsTab,
|
||||
slotPassthrough,
|
||||
}) => {
|
||||
const tabs = [
|
||||
<Tab key="comments" tabId="comments">
|
||||
{t('framework.my_comments')}
|
||||
</Tab>,
|
||||
];
|
||||
|
||||
if (showSettingsTab) {
|
||||
tabs.push(
|
||||
<Tab key="settings" tabId="settings">
|
||||
{t('profile_settings')}
|
||||
</Tab>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ExtendableTabPanel
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
fallbackTab="comments"
|
||||
tabSlot="profileTabs"
|
||||
tabSlotPrepend="profileTabsPrepend"
|
||||
tabPaneSlot="profileTabPanes"
|
||||
slotPassthrough={slotPassthrough}
|
||||
tabs={tabs}
|
||||
tabPanes={[
|
||||
<TabPane key="comments" tabId="comments">
|
||||
<CommentHistory root={root} />
|
||||
</TabPane>,
|
||||
<TabPane key="settings" tabId="settings">
|
||||
<Settings root={root} />
|
||||
</TabPane>,
|
||||
]}
|
||||
sub
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
TabPanel.propTypes = {
|
||||
root: PropTypes.object,
|
||||
slotPassthrough: PropTypes.object,
|
||||
activeTab: PropTypes.string.isRequired,
|
||||
setActiveTab: PropTypes.func.isRequired,
|
||||
showSettingsTab: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default TabPanel;
|
||||
@@ -4,7 +4,7 @@ import { connect } from 'react-redux';
|
||||
import { compose, gql } from 'react-apollo';
|
||||
import CommentHistory from '../components/CommentHistory';
|
||||
import Comment from './Comment';
|
||||
import { withFragments } from 'coral-framework/hocs';
|
||||
import { withFragments, withFetchMore } from 'coral-framework/hocs';
|
||||
|
||||
import { appendNewNodes } from 'plugin-api/beta/client/utils';
|
||||
import update from 'immutability-helper';
|
||||
@@ -16,7 +16,7 @@ class CommentHistoryContainer extends Component {
|
||||
};
|
||||
|
||||
loadMore = () => {
|
||||
return this.props.data.fetchMore({
|
||||
return this.props.fetchMore({
|
||||
query: LOAD_MORE_QUERY,
|
||||
variables: {
|
||||
limit: 5,
|
||||
@@ -43,7 +43,6 @@ class CommentHistoryContainer extends Component {
|
||||
return (
|
||||
<CommentHistory
|
||||
comments={this.props.root.me.comments}
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
loadMore={this.loadMore}
|
||||
navigate={this.navigate}
|
||||
@@ -57,8 +56,8 @@ CommentHistoryContainer.contextTypes = {
|
||||
};
|
||||
|
||||
CommentHistoryContainer.propTypes = {
|
||||
data: PropTypes.object,
|
||||
root: PropTypes.object,
|
||||
fetchMore: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const LOAD_MORE_QUERY = gql`
|
||||
@@ -99,5 +98,6 @@ const mapStateToProps = state => ({
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps, null),
|
||||
withCommentHistoryFragments
|
||||
withCommentHistoryFragments,
|
||||
withFetchMore
|
||||
)(CommentHistoryContainer);
|
||||
|
||||
@@ -7,11 +7,10 @@ import { withQuery } from 'coral-framework/hocs';
|
||||
import NotLoggedIn from '../components/NotLoggedIn';
|
||||
import { Spinner } from 'coral-ui';
|
||||
import Profile from '../components/Profile';
|
||||
import CommentHistory from './CommentHistory';
|
||||
import TabPanel from './TabPanel';
|
||||
import { getDefinitionName } from 'coral-framework/utils';
|
||||
|
||||
import { showSignInDialog } from 'coral-embed-stream/src/actions/login';
|
||||
import { setActiveTab } from '../../../actions/profile';
|
||||
import { getSlotFragmentSpreads } from 'coral-framework/utils';
|
||||
|
||||
class ProfileContainer extends Component {
|
||||
@@ -23,7 +22,7 @@ class ProfileContainer extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { currentUser, showSignInDialog, root, data } = this.props;
|
||||
const { currentUser, showSignInDialog, root } = this.props;
|
||||
const { me } = this.props.root;
|
||||
const loading = this.props.data.loading;
|
||||
|
||||
@@ -41,15 +40,14 @@ class ProfileContainer extends Component {
|
||||
|
||||
const localProfile = currentUser.profiles.find(p => p.provider === 'local');
|
||||
const emailAddress = localProfile && localProfile.id;
|
||||
const slotPassthrough = { root };
|
||||
|
||||
return (
|
||||
<Profile
|
||||
username={me.username}
|
||||
emailAddress={emailAddress}
|
||||
data={data}
|
||||
root={root}
|
||||
activeTab={this.props.activeTab}
|
||||
setActiveTab={this.props.setActiveTab}
|
||||
slotPassthrough={slotPassthrough}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -60,16 +58,9 @@ ProfileContainer.propTypes = {
|
||||
root: PropTypes.object,
|
||||
currentUser: PropTypes.object,
|
||||
showSignInDialog: PropTypes.func,
|
||||
activeTab: PropTypes.string.isRequired,
|
||||
setActiveTab: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const slots = [
|
||||
'profileSections',
|
||||
'profileTabs',
|
||||
'profileTabsPrepend',
|
||||
'profileTabPanes',
|
||||
];
|
||||
const slots = ['profileSections'];
|
||||
|
||||
const withProfileQuery = withQuery(
|
||||
gql`
|
||||
@@ -78,10 +69,10 @@ const withProfileQuery = withQuery(
|
||||
id
|
||||
username
|
||||
}
|
||||
...${getDefinitionName(CommentHistory.fragments.root)}
|
||||
...${getDefinitionName(TabPanel.fragments.root)}
|
||||
${getSlotFragmentSpreads(slots, 'root')}
|
||||
}
|
||||
${CommentHistory.fragments.root}
|
||||
${TabPanel.fragments.root}
|
||||
`,
|
||||
{
|
||||
options: {
|
||||
@@ -92,11 +83,10 @@ const withProfileQuery = withQuery(
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
currentUser: state.auth.user,
|
||||
activeTab: state.profile.activeTab,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch =>
|
||||
bindActionCreators({ showSignInDialog, setActiveTab }, dispatch);
|
||||
bindActionCreators({ showSignInDialog }, dispatch);
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { compose, gql } from 'react-apollo';
|
||||
import Settings from '../components/Settings';
|
||||
import { withFragments } from 'coral-framework/hocs';
|
||||
import { getSlotFragmentSpreads } from 'coral-framework/utils';
|
||||
|
||||
const slots = ['profileSettings'];
|
||||
|
||||
class SettingsContainer extends React.Component {
|
||||
render() {
|
||||
return <Settings {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
const enhance = compose(
|
||||
withFragments({
|
||||
root: gql`
|
||||
fragment TalkEmbedStream_ProfileSettings_root on RootQuery {
|
||||
__typename
|
||||
${getSlotFragmentSpreads(slots, 'root')}
|
||||
}
|
||||
`,
|
||||
})
|
||||
);
|
||||
|
||||
export default enhance(SettingsContainer);
|
||||
@@ -0,0 +1,64 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { compose, gql } from 'react-apollo';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { withSlotElements, withFragments } from 'coral-framework/hocs';
|
||||
import Settings from './Settings';
|
||||
import CommentHistory from './CommentHistory';
|
||||
import { getDefinitionName } from 'coral-framework/utils';
|
||||
import TabPanel from '../components/TabPanel';
|
||||
import { setActiveTab } from '../../../actions/profile';
|
||||
import { getSlotFragmentSpreads } from 'coral-framework/utils';
|
||||
|
||||
class TabPanelContainer extends Component {
|
||||
render() {
|
||||
return (
|
||||
<TabPanel
|
||||
root={this.props.root}
|
||||
slotPassthrough={this.props.slotPassthrough}
|
||||
activeTab={this.props.activeTab}
|
||||
setActiveTab={this.props.setActiveTab}
|
||||
showSettingsTab={this.props.profileSettingsSlotElements.length > 0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TabPanelContainer.propTypes = {
|
||||
root: PropTypes.object,
|
||||
slotPassthrough: PropTypes.object,
|
||||
activeTab: PropTypes.string.isRequired,
|
||||
setActiveTab: PropTypes.func.isRequired,
|
||||
profileSettingsSlotElements: PropTypes.array.isRequired,
|
||||
};
|
||||
|
||||
const slots = ['profileTabs', 'profileTabsPrepend', 'profileTabPanes'];
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
activeTab: state.profile.activeTab,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch =>
|
||||
bindActionCreators({ setActiveTab }, dispatch);
|
||||
|
||||
export default compose(
|
||||
withFragments({
|
||||
root: gql`
|
||||
fragment TalkEmbedStream_ProfileTabPanel_root on RootQuery {
|
||||
__typename
|
||||
...${getDefinitionName(CommentHistory.fragments.root)}
|
||||
...${getDefinitionName(Settings.fragments.root)}
|
||||
${getSlotFragmentSpreads(slots, 'root')}
|
||||
}
|
||||
${CommentHistory.fragments.root}
|
||||
${Settings.fragments.root}
|
||||
`,
|
||||
}),
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
withSlotElements({
|
||||
slot: 'profileSettings',
|
||||
propName: 'profileSettingsSlotElements',
|
||||
passthroughPropName: 'slotPassthrough',
|
||||
})
|
||||
)(TabPanelContainer);
|
||||
@@ -126,7 +126,6 @@ class AllCommentsPane extends React.Component {
|
||||
|
||||
render() {
|
||||
const {
|
||||
data,
|
||||
root,
|
||||
comments,
|
||||
commentClassNames,
|
||||
@@ -164,7 +163,6 @@ class AllCommentsPane extends React.Component {
|
||||
return (
|
||||
<Comment
|
||||
commentClassNames={commentClassNames}
|
||||
data={data}
|
||||
root={root}
|
||||
disableReply={disableReply}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
@@ -205,7 +203,6 @@ class AllCommentsPane extends React.Component {
|
||||
}
|
||||
|
||||
AllCommentsPane.propTypes = {
|
||||
data: PropTypes.object,
|
||||
root: PropTypes.object,
|
||||
comments: PropTypes.object,
|
||||
commentClassNames: PropTypes.array,
|
||||
|
||||
@@ -16,7 +16,7 @@ import mapValues from 'lodash/mapValues';
|
||||
import get from 'lodash/get';
|
||||
|
||||
import LoadMore from './LoadMore';
|
||||
import { getEditableUntilDate } from './util';
|
||||
import { getEditableUntilDate } from '../util';
|
||||
import { findCommentWithId } from '../../../graphql/utils';
|
||||
import CommentContent from 'coral-framework/components/CommentContent';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
@@ -181,7 +181,6 @@ export default class Comment extends React.Component {
|
||||
}),
|
||||
charCountEnable: PropTypes.bool.isRequired,
|
||||
maxCharCount: PropTypes.number,
|
||||
data: PropTypes.object,
|
||||
root: PropTypes.object,
|
||||
loadMore: PropTypes.func,
|
||||
postDontAgree: PropTypes.func,
|
||||
@@ -417,7 +416,6 @@ export default class Comment extends React.Component {
|
||||
{view.map(reply => {
|
||||
return (
|
||||
<CommentContainer
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
disableReply={disableReply}
|
||||
@@ -487,7 +485,6 @@ export default class Comment extends React.Component {
|
||||
renderComment() {
|
||||
const {
|
||||
asset,
|
||||
data,
|
||||
root,
|
||||
depth,
|
||||
comment,
|
||||
@@ -535,12 +532,8 @@ export default class Comment extends React.Component {
|
||||
);
|
||||
|
||||
// props that are passed down the slots.
|
||||
const slotProps = {
|
||||
data,
|
||||
const slotPassthrough = {
|
||||
depth,
|
||||
};
|
||||
|
||||
const queryData = {
|
||||
root,
|
||||
asset,
|
||||
comment,
|
||||
@@ -551,8 +544,7 @@ export default class Comment extends React.Component {
|
||||
<Slot
|
||||
className={cn(styles.commentAvatar, 'talk-stream-comment-avatar')}
|
||||
fill="commentAvatar"
|
||||
{...slotProps}
|
||||
queryData={queryData}
|
||||
passthrough={slotPassthrough}
|
||||
inline
|
||||
/>
|
||||
|
||||
@@ -573,8 +565,7 @@ export default class Comment extends React.Component {
|
||||
className={cn(styles.username, 'talk-stream-comment-user-name')}
|
||||
fill="commentAuthorName"
|
||||
defaultComponent={CommentAuthorName}
|
||||
queryData={queryData}
|
||||
{...slotProps}
|
||||
passthrough={slotPassthrough}
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -591,8 +582,7 @@ export default class Comment extends React.Component {
|
||||
'talk-stream-comment-author-tags'
|
||||
)}
|
||||
fill="commentAuthorTags"
|
||||
queryData={queryData}
|
||||
{...slotProps}
|
||||
passthrough={slotPassthrough}
|
||||
inline
|
||||
/>
|
||||
</div>
|
||||
@@ -607,9 +597,10 @@ export default class Comment extends React.Component {
|
||||
fill="commentTimestamp"
|
||||
defaultComponent={CommentTimestamp}
|
||||
className={'talk-stream-comment-published-date'}
|
||||
created_at={comment.created_at}
|
||||
queryData={queryData}
|
||||
{...slotProps}
|
||||
passthrough={{
|
||||
created_at: comment.created_at,
|
||||
...slotPassthrough,
|
||||
}}
|
||||
/>
|
||||
{comment.editing && comment.editing.edited ? (
|
||||
<span>
|
||||
@@ -624,8 +615,7 @@ export default class Comment extends React.Component {
|
||||
<Slot
|
||||
className={styles.commentInfoBar}
|
||||
fill="commentInfoBar"
|
||||
{...slotProps}
|
||||
queryData={queryData}
|
||||
passthrough={slotPassthrough}
|
||||
/>
|
||||
|
||||
{isActive &&
|
||||
@@ -665,9 +655,8 @@ export default class Comment extends React.Component {
|
||||
fill="commentContent"
|
||||
className="talk-stream-comment-content"
|
||||
defaultComponent={CommentContent}
|
||||
{...slotProps}
|
||||
queryData={queryData}
|
||||
slotSize={1}
|
||||
size={1}
|
||||
passthrough={slotPassthrough}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -678,8 +667,7 @@ export default class Comment extends React.Component {
|
||||
<div className="talk-embed-stream-comment-actions-container-left commentActionsLeft comment__action-container">
|
||||
<Slot
|
||||
fill="commentReactions"
|
||||
{...slotProps}
|
||||
queryData={queryData}
|
||||
passthrough={slotPassthrough}
|
||||
inline
|
||||
/>
|
||||
|
||||
@@ -696,9 +684,7 @@ export default class Comment extends React.Component {
|
||||
<div className="talk-embed-stream-comment-actions-container-right commentActionsRight comment__action-container">
|
||||
<Slot
|
||||
fill="commentActions"
|
||||
wrapperComponent={ActionButton}
|
||||
{...slotProps}
|
||||
queryData={queryData}
|
||||
passthrough={slotPassthrough}
|
||||
inline
|
||||
/>
|
||||
<ActionButton>
|
||||
|
||||
@@ -18,14 +18,8 @@ class CommentForm extends React.Component {
|
||||
charCountEnable: PropTypes.bool.isRequired,
|
||||
maxCharCount: PropTypes.number,
|
||||
|
||||
// DOM ID for form input that edits comment body
|
||||
bodyInputId: PropTypes.string,
|
||||
|
||||
// screen reader label for input that edits comment body
|
||||
bodyLabel: PropTypes.string,
|
||||
|
||||
// Placeholder for input that edits comment body
|
||||
bodyPlaceholder: PropTypes.string,
|
||||
// Unique identifier for this form
|
||||
id: PropTypes.string,
|
||||
|
||||
// render at start of button container (useful for extra buttons)
|
||||
buttonContainerStart: PropTypes.node,
|
||||
@@ -37,15 +31,15 @@ class CommentForm extends React.Component {
|
||||
submitButtonCStyle: PropTypes.string,
|
||||
|
||||
// return whether the submit button should be enabled for the provided
|
||||
// comment ({ body }) (for reasons other than charCount)
|
||||
// input (for reasons other than charCount)
|
||||
submitEnabled: PropTypes.func,
|
||||
|
||||
// className to add to buttons
|
||||
submitButtonClassName: PropTypes.string,
|
||||
cancelButtonClassName: PropTypes.string,
|
||||
|
||||
body: PropTypes.string.isRequired,
|
||||
onBodyChange: PropTypes.func.isRequired,
|
||||
input: PropTypes.object.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func,
|
||||
state: PropTypes.string,
|
||||
@@ -53,13 +47,12 @@ class CommentForm extends React.Component {
|
||||
registerHook: PropTypes.func,
|
||||
unregisterHook: PropTypes.func,
|
||||
isReply: PropTypes.bool,
|
||||
isEdit: PropTypes.bool,
|
||||
root: PropTypes.object.isRequired,
|
||||
comment: PropTypes.object,
|
||||
};
|
||||
static get defaultProps() {
|
||||
return {
|
||||
bodyLabel: t('comment_box.comment'),
|
||||
bodyPlaceholder: t('comment_box.comment'),
|
||||
submitText: t('comment_box.post'),
|
||||
submitButtonCStyle: 'darkGrey',
|
||||
submitEnabled: () => true,
|
||||
@@ -90,20 +83,20 @@ class CommentForm extends React.Component {
|
||||
cancelButtonClassName,
|
||||
submitButtonClassName,
|
||||
charCountEnable,
|
||||
body,
|
||||
input,
|
||||
loadingState,
|
||||
comment,
|
||||
root,
|
||||
} = this.props;
|
||||
|
||||
const length = body.length;
|
||||
const length = input.body.length;
|
||||
const isRespectingMaxCount = length =>
|
||||
charCountEnable && maxCharCount && length > maxCharCount;
|
||||
const disableSubmitButton =
|
||||
!length ||
|
||||
body.trim().length === 0 ||
|
||||
input.body.trim().length === 0 ||
|
||||
isRespectingMaxCount(length) ||
|
||||
!submitEnabled({ body }) ||
|
||||
!submitEnabled(input) ||
|
||||
loadingState === 'loading';
|
||||
const disableCancelButton = loadingState === 'loading';
|
||||
const disableTextArea = loadingState === 'loading';
|
||||
@@ -113,17 +106,16 @@ class CommentForm extends React.Component {
|
||||
<DraftArea
|
||||
root={root}
|
||||
comment={comment}
|
||||
id={this.props.bodyInputId}
|
||||
label={this.props.bodyLabel}
|
||||
value={body}
|
||||
placeholder={this.props.bodyPlaceholder}
|
||||
onChange={this.props.onBodyChange}
|
||||
id={this.props.id}
|
||||
input={input}
|
||||
onInputChange={this.props.onInputChange}
|
||||
disabled={disableTextArea}
|
||||
charCountEnable={this.props.charCountEnable}
|
||||
maxCharCount={this.props.maxCharCount}
|
||||
registerHook={this.props.registerHook}
|
||||
unregisterHook={this.props.unregisterHook}
|
||||
isReply={this.props.isReply}
|
||||
isEdit={this.props.isEdit}
|
||||
/>
|
||||
<div className={cn(styles.buttonContainer, `${name}-button-container`)}>
|
||||
{this.props.buttonContainerStart}
|
||||
|
||||
@@ -13,17 +13,17 @@ import styles from './DraftArea.css';
|
||||
*/
|
||||
export default class DraftArea extends React.Component {
|
||||
renderCharCount() {
|
||||
const { value, maxCharCount } = this.props;
|
||||
const { input, maxCharCount } = this.props;
|
||||
|
||||
const className = cn(
|
||||
styles.charCount,
|
||||
'talk-plugin-commentbox-char-count',
|
||||
{
|
||||
[`${styles.charMax} talk-plugin-commentbox-char-max`]:
|
||||
value.length > maxCharCount,
|
||||
input.body.length > maxCharCount,
|
||||
}
|
||||
);
|
||||
const remaining = maxCharCount - value.length;
|
||||
const remaining = maxCharCount - input.body.length;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
@@ -32,48 +32,61 @@ export default class DraftArea extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
getLabel() {
|
||||
if (this.props.isEdit) {
|
||||
return t('edit_comment.body_input_label');
|
||||
}
|
||||
return this.props.isReply ? t('comment_box.reply') : t('comment.comment');
|
||||
}
|
||||
|
||||
getPlaceholder() {
|
||||
if (this.props.isEdit) {
|
||||
return '';
|
||||
}
|
||||
return this.getLabel();
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
value,
|
||||
placeholder,
|
||||
input,
|
||||
id,
|
||||
disabled,
|
||||
rows,
|
||||
label,
|
||||
charCountEnable,
|
||||
maxCharCount,
|
||||
onChange,
|
||||
queryData,
|
||||
onInputChange,
|
||||
isReply,
|
||||
isEdit,
|
||||
registerHook,
|
||||
unregisterHook,
|
||||
root,
|
||||
comment,
|
||||
} = this.props;
|
||||
|
||||
const tASettings = {
|
||||
value,
|
||||
placeholder,
|
||||
id,
|
||||
onChange,
|
||||
rows,
|
||||
disabled,
|
||||
isReply,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div id={id}>
|
||||
<div
|
||||
className={cn(styles.container, 'talk-plugin-commentbox-container')}
|
||||
>
|
||||
<label htmlFor={id} className="screen-reader-text" aria-hidden={true}>
|
||||
{label}
|
||||
</label>
|
||||
<Slot
|
||||
fill="draftArea"
|
||||
defaultComponent={DraftAreaContent}
|
||||
className={styles.content}
|
||||
queryData={queryData}
|
||||
registerHook={this.props.registerHook}
|
||||
unregisterHook={this.props.unregisterHook}
|
||||
{...tASettings}
|
||||
passthrough={{
|
||||
id,
|
||||
root,
|
||||
comment,
|
||||
registerHook,
|
||||
unregisterHook,
|
||||
input,
|
||||
onInputChange,
|
||||
disabled,
|
||||
isReply,
|
||||
isEdit,
|
||||
placeholder: this.getPlaceholder(),
|
||||
label: this.getLabel(),
|
||||
}}
|
||||
/>
|
||||
{/* Is this slot here legitimate? (kiwi) */}
|
||||
<Slot fill="commentInputArea" />
|
||||
</div>
|
||||
{charCountEnable && maxCharCount > 0 && this.renderCharCount()}
|
||||
@@ -82,22 +95,17 @@ export default class DraftArea extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
DraftArea.defaultProps = {
|
||||
rows: 3,
|
||||
};
|
||||
|
||||
DraftArea.propTypes = {
|
||||
charCountEnable: PropTypes.bool,
|
||||
maxCharCount: PropTypes.number,
|
||||
id: PropTypes.string,
|
||||
value: PropTypes.string,
|
||||
placeholder: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
input: PropTypes.object,
|
||||
onInputChange: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
rows: PropTypes.number,
|
||||
queryData: PropTypes.object.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
comment: PropTypes.object,
|
||||
registerHook: PropTypes.func,
|
||||
unregisterHook: PropTypes.func,
|
||||
isReply: PropTypes.bool,
|
||||
isEdit: PropTypes.bool,
|
||||
};
|
||||
|
||||
@@ -3,36 +3,49 @@ import PropTypes from 'prop-types';
|
||||
import cn from 'classnames';
|
||||
import styles from './DraftAreaContent.css';
|
||||
|
||||
const DraftAreaContent = ({
|
||||
value,
|
||||
placeholder,
|
||||
id,
|
||||
onChange,
|
||||
rows,
|
||||
disabled,
|
||||
}) => (
|
||||
<textarea
|
||||
className={cn(styles.content, 'talk-plugin-commentbox-textarea')}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
id={id}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
rows={rows}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
DraftAreaContent.defaultProps = {
|
||||
rows: 3,
|
||||
};
|
||||
class DraftAreaContent extends React.Component {
|
||||
render() {
|
||||
const {
|
||||
input,
|
||||
id,
|
||||
onInputChange,
|
||||
disabled,
|
||||
label,
|
||||
placeholder,
|
||||
} = this.props;
|
||||
const inputId = `${id}-textarea`;
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="screen-reader-text"
|
||||
aria-hidden={true}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<textarea
|
||||
id={inputId}
|
||||
className={cn(styles.content, 'talk-plugin-commentbox-textarea')}
|
||||
value={input.body}
|
||||
placeholder={placeholder}
|
||||
onChange={e => onInputChange({ body: e.target.value })}
|
||||
rows={3}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DraftAreaContent.propTypes = {
|
||||
id: PropTypes.string,
|
||||
value: PropTypes.string,
|
||||
input: PropTypes.object,
|
||||
placeholder: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
label: PropTypes.string,
|
||||
onInputChange: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
rows: PropTypes.number,
|
||||
isEdit: PropTypes.bool,
|
||||
isReply: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default DraftAreaContent;
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { notifyForNewCommentStatus } from '../helpers';
|
||||
import CommentForm from '../containers/CommentForm';
|
||||
import styles from './Comment.css';
|
||||
import { CountdownSeconds } from './CountdownSeconds';
|
||||
import { getEditableUntilDate } from './util';
|
||||
import { can } from 'coral-framework/services/perms';
|
||||
|
||||
import { Icon } from 'coral-ui';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
@@ -15,187 +12,83 @@ import t from 'coral-framework/services/i18n';
|
||||
* Renders a Comment's body in such a way that the end-user can edit it and save changes
|
||||
*/
|
||||
class EditableCommentContent extends React.Component {
|
||||
static propTypes = {
|
||||
// show notification to the user (e.g. for errors)
|
||||
notify: PropTypes.func.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
// comment that is being edited
|
||||
comment: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
body: PropTypes.string,
|
||||
editing: PropTypes.shape({
|
||||
edited: PropTypes.bool,
|
||||
|
||||
// ISO8601
|
||||
editableUntil: PropTypes.string,
|
||||
}),
|
||||
}).isRequired,
|
||||
|
||||
// logged in user
|
||||
currentUser: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
}),
|
||||
charCountEnable: PropTypes.bool,
|
||||
maxCharCount: PropTypes.number,
|
||||
|
||||
// edit a comment, passed {{ body }}
|
||||
editComment: PropTypes.func,
|
||||
|
||||
// called when editing should be stopped
|
||||
stopEditing: PropTypes.func,
|
||||
};
|
||||
|
||||
unmounted = false;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.editWindowExpiryTimeout = null;
|
||||
this.state = {
|
||||
body: props.comment.body,
|
||||
loadingState: '',
|
||||
// data: {@object} contains data that might be useful for plugins, metadata, etc
|
||||
data: {},
|
||||
};
|
||||
}
|
||||
componentDidMount() {
|
||||
const editableUntil = getEditableUntilDate(this.props.comment);
|
||||
const now = new Date();
|
||||
const editWindowRemainingMs = editableUntil && editableUntil - now;
|
||||
if (editWindowRemainingMs > 0) {
|
||||
this.editWindowExpiryTimeout = setTimeout(() => {
|
||||
this.forceUpdate();
|
||||
}, editWindowRemainingMs);
|
||||
}
|
||||
}
|
||||
componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
if (this.editWindowExpiryTimeout) {
|
||||
this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
handleBodyChange = (body, data) => {
|
||||
this.setState(state => ({
|
||||
body,
|
||||
data: {
|
||||
...state.data,
|
||||
...data,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
handleSubmit = async () => {
|
||||
if (!can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) {
|
||||
this.props.notify('error', t('error.NOT_AUTHORIZED'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loadingState: 'loading' });
|
||||
|
||||
const { editComment, stopEditing } = this.props;
|
||||
if (typeof editComment !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
let input = {
|
||||
body: this.state.body,
|
||||
...this.state.data,
|
||||
};
|
||||
|
||||
let response;
|
||||
|
||||
try {
|
||||
response = await editComment(input);
|
||||
if (!this.unmounted) {
|
||||
this.setState({ loadingState: 'success' });
|
||||
}
|
||||
const status = response.data.editComment.comment.status;
|
||||
notifyForNewCommentStatus(this.props.notify, status);
|
||||
if (typeof stopEditing === 'function') {
|
||||
stopEditing();
|
||||
}
|
||||
} catch (error) {
|
||||
this.setState({ loadingState: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
getEditableUntil = (props = this.props) => {
|
||||
return getEditableUntilDate(props.comment);
|
||||
};
|
||||
|
||||
isEditWindowExpired = (props = this.props) => {
|
||||
return this.getEditableUntil(props) - new Date() < 0;
|
||||
};
|
||||
|
||||
isSubmitEnabled = comment => {
|
||||
// should be disabled if user hasn't actually changed their
|
||||
// original comment
|
||||
renderButtonContainerStart() {
|
||||
return (
|
||||
comment.body !== this.props.comment.body && !this.isEditWindowExpired()
|
||||
<div className={styles.buttonContainerLeft}>
|
||||
<span className={styles.editWindowRemaining}>
|
||||
{this.props.editWindowExpired ? (
|
||||
<span>
|
||||
{t('edit_comment.edit_window_expired')}
|
||||
<span>
|
||||
<a className={styles.link} onClick={this.props.onCancel}>
|
||||
{t('edit_comment.edit_window_expired_close')}
|
||||
</a>
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
<Icon name="timer" className={styles.timerIcon} />{' '}
|
||||
{t('edit_comment.edit_window_timer_prefix')}
|
||||
<CountdownSeconds
|
||||
until={this.props.editableUntil}
|
||||
classNameForMsRemaining={remainingMs =>
|
||||
remainingMs <= 10 * 1000 ? styles.editWindowAlmostOver : ''
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const id = `edit-draft_${this.props.comment.id}`;
|
||||
return (
|
||||
<div className={styles.editCommentForm}>
|
||||
<CommentForm
|
||||
isEdit
|
||||
root={this.props.root}
|
||||
comment={this.props.comment}
|
||||
defaultValue={this.props.comment.body}
|
||||
bodyInputId={id}
|
||||
charCountEnable={this.props.charCountEnable}
|
||||
maxCharCount={this.props.maxCharCount}
|
||||
submitEnabled={this.isSubmitEnabled}
|
||||
body={this.state.body}
|
||||
onBodyChange={this.handleBodyChange}
|
||||
onSubmit={this.handleSubmit}
|
||||
submitEnabled={this.props.submitEnabled}
|
||||
input={this.props.input}
|
||||
onInputChange={this.props.onInputChange}
|
||||
onSubmit={this.props.onSubmit}
|
||||
onCancel={this.props.onCancel}
|
||||
loadingState={this.props.loadingState}
|
||||
registerHook={this.props.registerHook}
|
||||
unregisterHook={this.props.unregisterHook}
|
||||
buttonContainerStart={this.renderButtonContainerStart()}
|
||||
submitButtonClassName={styles.button}
|
||||
cancelButtonClassName={styles.button}
|
||||
bodyLabel={t('edit_comment.body_input_label')}
|
||||
bodyPlaceholder=""
|
||||
submitText={<span>{t('edit_comment.save_button')}</span>}
|
||||
submitButtonCStyle="green"
|
||||
onCancel={this.props.stopEditing}
|
||||
submitButtonClassName={styles.button}
|
||||
cancelButtonClassName={styles.button}
|
||||
loadingState={this.state.loadingState}
|
||||
buttonContainerStart={
|
||||
<div className={styles.buttonContainerLeft}>
|
||||
<span className={styles.editWindowRemaining}>
|
||||
{this.isEditWindowExpired() ? (
|
||||
<span>
|
||||
{t('edit_comment.edit_window_expired')}
|
||||
{typeof this.props.stopEditing === 'function' ? (
|
||||
<span>
|
||||
<a
|
||||
className={styles.link}
|
||||
onClick={this.props.stopEditing}
|
||||
>
|
||||
{t('edit_comment.edit_window_expired_close')}
|
||||
</a>
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
<Icon name="timer" className={styles.timerIcon} />{' '}
|
||||
{t('edit_comment.edit_window_timer_prefix')}
|
||||
<CountdownSeconds
|
||||
until={this.getEditableUntil()}
|
||||
classNameForMsRemaining={remainingMs =>
|
||||
remainingMs <= 10 * 1000
|
||||
? styles.editWindowAlmostOver
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
id={id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditableCommentContent.propTypes = {
|
||||
charCountEnable: PropTypes.bool,
|
||||
submitEnabled: PropTypes.func,
|
||||
maxCharCount: PropTypes.number,
|
||||
root: PropTypes.object.isRequired,
|
||||
comment: PropTypes.object.isRequired,
|
||||
input: PropTypes.object.isRequired,
|
||||
registerHook: PropTypes.func.isRequired,
|
||||
unregisterHook: PropTypes.func.isRequired,
|
||||
onInputChange: PropTypes.func,
|
||||
onSubmit: PropTypes.func,
|
||||
onCancel: PropTypes.func,
|
||||
loadingState: PropTypes.string,
|
||||
editWindowExpired: PropTypes.bool,
|
||||
editableUntil: PropTypes.object,
|
||||
};
|
||||
|
||||
export default EditableCommentContent;
|
||||
|
||||
@@ -15,14 +15,13 @@ import QuestionBox from '../../../components/QuestionBox';
|
||||
import { Tab, TabCount, TabPane } from 'coral-ui';
|
||||
import cn from 'classnames';
|
||||
import get from 'lodash/get';
|
||||
|
||||
import { reverseCommentParentTree } from '../../../graphql/utils';
|
||||
import AllCommentsPane from './AllCommentsPane';
|
||||
import ExtendableTabPanel from '../../../containers/ExtendableTabPanel';
|
||||
import ChangedUsername from './ChangedUsername';
|
||||
import CommentNotFound from '../containers/CommentNotFound';
|
||||
|
||||
import styles from './Stream.css';
|
||||
import ChangedUsername from './ChangedUsername';
|
||||
|
||||
class Stream extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -40,7 +39,6 @@ class Stream extends React.Component {
|
||||
|
||||
renderHighlightedComment() {
|
||||
const {
|
||||
data,
|
||||
root,
|
||||
activeReplyBox,
|
||||
setActiveReplyBox,
|
||||
@@ -91,7 +89,6 @@ class Stream extends React.Component {
|
||||
</div>
|
||||
|
||||
<Comment
|
||||
data={data}
|
||||
root={root}
|
||||
commentClassNames={commentClassNames}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
@@ -122,7 +119,6 @@ class Stream extends React.Component {
|
||||
|
||||
renderExtendableTabPanel() {
|
||||
const {
|
||||
data,
|
||||
root,
|
||||
activeReplyBox,
|
||||
setActiveReplyBox,
|
||||
@@ -147,15 +143,14 @@ class Stream extends React.Component {
|
||||
loading,
|
||||
} = this.props;
|
||||
|
||||
const slotProps = { data };
|
||||
const slotQueryData = { root, asset };
|
||||
const slotPassthrough = { root, asset };
|
||||
|
||||
// `key` of `ExtendableTabPanel` depends on sorting so that we always reset
|
||||
// the state when changing sorting.
|
||||
return (
|
||||
<div className={cn('talk-stream-tab-container', styles.tabContainer)}>
|
||||
<div className={cn('talk-stream-filter-wrapper', styles.filterWrapper)}>
|
||||
<Slot fill="streamFilter" queryData={slotQueryData} {...slotProps} />
|
||||
<Slot fill="streamFilter" passthrough={slotPassthrough} />
|
||||
</div>
|
||||
|
||||
<ExtendableTabPanel
|
||||
@@ -166,8 +161,7 @@ class Stream extends React.Component {
|
||||
tabSlot="streamTabs"
|
||||
tabSlotPrepend="streamTabsPrepend"
|
||||
tabPaneSlot="streamTabPanes"
|
||||
slotProps={slotProps}
|
||||
queryData={slotQueryData}
|
||||
slotPassthrough={slotPassthrough}
|
||||
loading={loading}
|
||||
tabs={
|
||||
<Tab tabId={'all'} key="all">
|
||||
@@ -180,7 +174,6 @@ class Stream extends React.Component {
|
||||
tabPanes={
|
||||
<TabPane tabId="all" key="all">
|
||||
<AllCommentsPane
|
||||
data={data}
|
||||
root={root}
|
||||
asset={asset}
|
||||
comments={comments}
|
||||
@@ -212,7 +205,6 @@ class Stream extends React.Component {
|
||||
|
||||
render() {
|
||||
const {
|
||||
data,
|
||||
root,
|
||||
appendItemArray,
|
||||
asset,
|
||||
@@ -243,13 +235,17 @@ class Stream extends React.Component {
|
||||
!changedUsername &&
|
||||
!highlightedComment) ||
|
||||
keepCommentBox);
|
||||
const slotProps = { data };
|
||||
const slotQueryData = { root, asset };
|
||||
|
||||
if (highlightedComment === null) {
|
||||
return <StreamError>{t('stream.comment_not_found')}</StreamError>;
|
||||
return (
|
||||
<StreamError>
|
||||
<CommentNotFound />
|
||||
</StreamError>
|
||||
);
|
||||
}
|
||||
|
||||
const slotPassthrough = { root, asset };
|
||||
|
||||
return (
|
||||
<div id="stream" className={styles.root}>
|
||||
{open ? (
|
||||
@@ -263,11 +259,7 @@ class Stream extends React.Component {
|
||||
content={asset.settings.questionBoxContent}
|
||||
icon={asset.settings.questionBoxIcon}
|
||||
>
|
||||
<Slot
|
||||
fill="streamQuestionArea"
|
||||
queryData={slotQueryData}
|
||||
{...slotProps}
|
||||
/>
|
||||
<Slot fill="streamQuestionArea" passthrough={slotPassthrough} />
|
||||
</QuestionBox>
|
||||
)}
|
||||
{!banned &&
|
||||
@@ -304,7 +296,7 @@ class Stream extends React.Component {
|
||||
<p>{asset.settings.closedMessage}</p>
|
||||
)}
|
||||
|
||||
<Slot fill="stream" queryData={slotQueryData} {...slotProps} />
|
||||
<Slot fill="stream" passthrough={slotPassthrough} />
|
||||
|
||||
{currentUser && (
|
||||
<ModerationLink
|
||||
@@ -324,7 +316,6 @@ class Stream extends React.Component {
|
||||
Stream.propTypes = {
|
||||
asset: PropTypes.object,
|
||||
activeStreamTab: PropTypes.string,
|
||||
data: PropTypes.object,
|
||||
root: PropTypes.object,
|
||||
activeReplyBox: PropTypes.string,
|
||||
setActiveReplyBox: PropTypes.func,
|
||||
|
||||
@@ -16,7 +16,6 @@ import { nest } from '../../../graphql/utils';
|
||||
|
||||
const slots = [
|
||||
'streamQuestionArea',
|
||||
'commentInputArea',
|
||||
'commentInputDetailArea',
|
||||
'commentInfoBar',
|
||||
'commentActions',
|
||||
|
||||
@@ -7,10 +7,24 @@ import Slot from 'coral-framework/components/Slot';
|
||||
import { connect } from 'react-redux';
|
||||
import CommentForm from '../containers/CommentForm';
|
||||
import { notifyForNewCommentStatus } from '../helpers';
|
||||
import withHooks from '../hocs/withHooks';
|
||||
import { compose } from 'recompose';
|
||||
import once from 'lodash/once';
|
||||
|
||||
// TODO: (kiwi) Need to adapt CSS classes post refactor to match the rest.
|
||||
export const name = 'talk-plugin-commentbox';
|
||||
|
||||
// @Deprecated
|
||||
const showOldTagsWarningOnce = once(() => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn(
|
||||
'Using `addTags` and `removeTags` is deprecated. Please switch to `onInputChange` and `input` instead'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const initialInput = { body: '', tags: [] };
|
||||
|
||||
/**
|
||||
* Container for posting a new Comment
|
||||
*/
|
||||
@@ -19,14 +33,8 @@ class CommentBox extends React.Component {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
body: '',
|
||||
loadingState: '',
|
||||
// data: {@object} contains data that might be useful for plugins
|
||||
data: {},
|
||||
hooks: {
|
||||
preSubmit: [],
|
||||
postSubmit: [],
|
||||
},
|
||||
input: initialInput,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,29 +64,38 @@ class CommentBox extends React.Component {
|
||||
return;
|
||||
}
|
||||
|
||||
// @Deprecated
|
||||
const deprecatedTags = this.props.tags || [];
|
||||
if (deprecatedTags.length) {
|
||||
showOldTagsWarningOnce();
|
||||
}
|
||||
const tags = this.state.input.tags || [];
|
||||
|
||||
let input = {
|
||||
asset_id: assetId,
|
||||
parent_id: parentId,
|
||||
body: this.state.body,
|
||||
tags: this.props.tags,
|
||||
...this.state.data,
|
||||
...this.state.input,
|
||||
tags: [...deprecatedTags, ...tags],
|
||||
};
|
||||
|
||||
// Execute preSubmit Hooks
|
||||
this.state.hooks.preSubmit.forEach(hook =>
|
||||
hook(input, this.handleBodyChange)
|
||||
);
|
||||
this.props.forEachHook('preSubmit', hook => {
|
||||
const result = hook(input);
|
||||
if (result) {
|
||||
input = result;
|
||||
}
|
||||
});
|
||||
this.setState({ loadingState: 'loading' });
|
||||
|
||||
postComment(input, 'comments')
|
||||
.then(({ data }) => {
|
||||
this.setState({ loadingState: 'success', body: '' });
|
||||
this.setState({ loadingState: 'success', input: initialInput });
|
||||
const postedComment = data.createComment.comment;
|
||||
const actions = data.createComment.actions;
|
||||
|
||||
// Execute postSubmit Hooks
|
||||
this.state.hooks.postSubmit.forEach(hook =>
|
||||
hook(data, this.handleBodyChange)
|
||||
this.props.forEachHook('postSubmit', hook =>
|
||||
hook(data, this.handleInputChange)
|
||||
);
|
||||
|
||||
notifyForNewCommentStatus(notify, postedComment.status, actions);
|
||||
@@ -92,62 +109,32 @@ class CommentBox extends React.Component {
|
||||
});
|
||||
};
|
||||
|
||||
handleBodyChange = (body, data) => {
|
||||
handleInputChange = input => {
|
||||
this.setState(state => ({
|
||||
body,
|
||||
data: {
|
||||
...state.data,
|
||||
...data,
|
||||
input: {
|
||||
...state.input,
|
||||
...input,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
registerHook = (hookType = '', hook = () => {}) => {
|
||||
if (typeof hook !== 'function') {
|
||||
return console.warn(
|
||||
`Hooks must be functions. Please check your ${hookType} hooks`
|
||||
);
|
||||
} else if (typeof hookType === 'string') {
|
||||
this.setState(state => ({
|
||||
hooks: {
|
||||
...state.hooks,
|
||||
[hookType]: [...state.hooks[hookType], hook],
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
hookType,
|
||||
hook,
|
||||
};
|
||||
} else {
|
||||
return console.warn(
|
||||
'hookTypes must be a string. Please check your hooks'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
unregisterHook = hookData => {
|
||||
const { hookType, hook } = hookData;
|
||||
|
||||
this.setState(state => {
|
||||
let newHooks = state.hooks[newHooks];
|
||||
const idx = state.hooks[hookType].indexOf(hook);
|
||||
|
||||
if (idx !== -1) {
|
||||
newHooks = [
|
||||
...state.hooks[hookType].slice(0, idx),
|
||||
...state.hooks[hookType].slice(idx + 1),
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
hooks: {
|
||||
...state.hooks,
|
||||
[hookType]: newHooks,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
renderButtonContainerStart() {
|
||||
const { root, isReply, registerHook, unregisterHook } = this.props;
|
||||
return (
|
||||
<Slot
|
||||
fill="commentInputDetailArea"
|
||||
passthrough={{
|
||||
root,
|
||||
registerHook: registerHook,
|
||||
unregisterHook: unregisterHook,
|
||||
isReply,
|
||||
input: this.state.input,
|
||||
onInputChange: this.handleInputChange,
|
||||
}}
|
||||
inline
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
@@ -157,6 +144,8 @@ class CommentBox extends React.Component {
|
||||
parentId,
|
||||
comment,
|
||||
root,
|
||||
registerHook,
|
||||
unregisterHook,
|
||||
} = this.props;
|
||||
let { onCancel } = this.props;
|
||||
|
||||
@@ -178,25 +167,15 @@ class CommentBox extends React.Component {
|
||||
root={root}
|
||||
comment={comment}
|
||||
defaultValue={this.props.defaultValue}
|
||||
bodyLabel={isReply ? t('comment_box.reply') : t('comment.comment')}
|
||||
maxCharCount={maxCharCount}
|
||||
charCountEnable={this.props.charCountEnable}
|
||||
bodyPlaceholder={t('comment.comment')}
|
||||
bodyInputId={id}
|
||||
body={this.state.body}
|
||||
registerHook={this.registerHook}
|
||||
unregisterHook={this.unregisterHook}
|
||||
id={id}
|
||||
input={this.state.input}
|
||||
registerHook={registerHook}
|
||||
unregisterHook={unregisterHook}
|
||||
isReply={isReply}
|
||||
buttonContainerStart={
|
||||
<Slot
|
||||
fill="commentInputDetailArea"
|
||||
registerHook={this.registerHook}
|
||||
unregisterHook={this.unregisterHook}
|
||||
isReply={isReply}
|
||||
inline
|
||||
/>
|
||||
}
|
||||
onBodyChange={this.handleBodyChange}
|
||||
buttonContainerStart={this.renderButtonContainerStart()}
|
||||
onInputChange={this.handleInputChange}
|
||||
loadingState={this.state.loadingState}
|
||||
onCancel={onCancel}
|
||||
onSubmit={this.handleSubmit}
|
||||
@@ -223,6 +202,9 @@ CommentBox.propTypes = {
|
||||
tags: PropTypes.array,
|
||||
root: PropTypes.object.isRequired,
|
||||
comment: PropTypes.object,
|
||||
registerHook: PropTypes.func.isRequired,
|
||||
unregisterHook: PropTypes.func.isRequired,
|
||||
forEachHook: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
CommentBox.fragments = CommentForm.fragments;
|
||||
@@ -231,4 +213,9 @@ const mapStateToProps = state => ({
|
||||
tags: state.stream.commentBoxTags,
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, null)(CommentBox);
|
||||
const enhance = compose(
|
||||
withHooks(['preSubmit', 'postSubmit']),
|
||||
connect(mapStateToProps, null)
|
||||
);
|
||||
|
||||
export default enhance(CommentBox);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button } from 'coral-ui';
|
||||
import { setActiveTab } from '../../../actions/embed';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
class CommentNotFound extends React.Component {
|
||||
showAllTab = () => {
|
||||
this.props.setActiveTab('all');
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<p>{t('stream.comment_not_found')}</p>
|
||||
<Button onClick={this.showAllTab}>Show all comments</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CommentNotFound.propTypes = {
|
||||
setActiveTab: PropTypes.func,
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch =>
|
||||
bindActionCreators(
|
||||
{
|
||||
setActiveTab,
|
||||
},
|
||||
dispatch
|
||||
);
|
||||
|
||||
export default connect(null, mapDispatchToProps)(CommentNotFound);
|
||||
@@ -17,9 +17,17 @@ class DraftAreaContainer extends React.Component {
|
||||
}
|
||||
|
||||
async initValue() {
|
||||
const value = await this.context.pymSessionStorage.getItem(this.getPath());
|
||||
if (value && this.props.onChange) {
|
||||
this.props.onChange(value);
|
||||
const input = await this.context.pymSessionStorage.getItem(this.getPath());
|
||||
if (input && this.props.onInputChange) {
|
||||
let parsed = '';
|
||||
|
||||
// Older version saved a normal string, catch those and ignore them.
|
||||
try {
|
||||
parsed = JSON.parse(input);
|
||||
} catch (_e) {}
|
||||
if (typeof parsed === 'object') {
|
||||
this.props.onInputChange(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,14 +35,13 @@ class DraftAreaContainer extends React.Component {
|
||||
return `${STORAGE_PATH}_${this.props.id}`;
|
||||
};
|
||||
|
||||
onChange = (body, data) => {
|
||||
this.props.onChange && this.props.onChange(body, data);
|
||||
};
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (this.props.value !== nextProps.value) {
|
||||
if (nextProps.value) {
|
||||
this.context.pymSessionStorage.setItem(this.getPath(), nextProps.value);
|
||||
if (this.props.input !== nextProps.input) {
|
||||
if (nextProps.input) {
|
||||
this.context.pymSessionStorage.setItem(
|
||||
this.getPath(),
|
||||
JSON.stringify(nextProps.input)
|
||||
);
|
||||
} else {
|
||||
this.context.pymSessionStorage.removeItem(this.getPath());
|
||||
}
|
||||
@@ -42,23 +49,20 @@ class DraftAreaContainer extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const queryData = { comment: this.props.comment, root: this.props.root };
|
||||
|
||||
return (
|
||||
<DraftArea
|
||||
queryData={queryData}
|
||||
value={this.props.value}
|
||||
placeholder={this.props.placeholder}
|
||||
root={this.props.root}
|
||||
comment={this.props.comment}
|
||||
input={this.props.input}
|
||||
id={this.props.id}
|
||||
onChange={this.onChange}
|
||||
rows={this.props.rows}
|
||||
onInputChange={this.props.onInputChange}
|
||||
disabled={this.props.disabled}
|
||||
charCountEnable={this.props.charCountEnable}
|
||||
maxCharCount={this.props.maxCharCount}
|
||||
label={this.props.label}
|
||||
registerHook={this.props.registerHook}
|
||||
unregisterHook={this.props.unregisterHook}
|
||||
isReply={this.props.isReply}
|
||||
isEdit={this.props.isEdit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -74,20 +78,18 @@ DraftAreaContainer.propTypes = {
|
||||
charCountEnable: PropTypes.bool,
|
||||
maxCharCount: PropTypes.number,
|
||||
id: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
placeholder: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
input: PropTypes.object.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
rows: PropTypes.number,
|
||||
label: PropTypes.string.isRequired,
|
||||
registerHook: PropTypes.func,
|
||||
unregisterHook: PropTypes.func,
|
||||
isReply: PropTypes.bool,
|
||||
isEdit: PropTypes.bool,
|
||||
root: PropTypes.object.isRequired,
|
||||
comment: PropTypes.object,
|
||||
};
|
||||
|
||||
const slots = ['draftArea'];
|
||||
const slots = ['draftArea', 'commentInputArea'];
|
||||
|
||||
export default withFragments({
|
||||
root: gql`
|
||||
|
||||
@@ -1,11 +1,150 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { notifyForNewCommentStatus } from '../helpers';
|
||||
import { getEditableUntilDate } from '../util';
|
||||
import { can } from 'coral-framework/services/perms';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
import EditableCommentContent from '../components/EditableCommentContent';
|
||||
import CommentForm from './CommentForm';
|
||||
import withHooks from '../hocs/withHooks';
|
||||
import { compose } from 'recompose';
|
||||
|
||||
const EditableCommentContentContainer = props => (
|
||||
<EditableCommentContent {...props} />
|
||||
);
|
||||
/**
|
||||
* Renders a Comment's body in such a way that the end-user can edit it and save changes
|
||||
*/
|
||||
class EditableCommentContentContainer extends React.Component {
|
||||
unmounted = false;
|
||||
editWindowExpiryTimeout = null;
|
||||
state = {
|
||||
loadingState: '',
|
||||
submitEnabled: false,
|
||||
input: {
|
||||
body: this.props.comment.body,
|
||||
},
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const editableUntil = getEditableUntilDate(this.props.comment);
|
||||
const now = new Date();
|
||||
const editWindowRemainingMs = editableUntil && editableUntil - now;
|
||||
if (editWindowRemainingMs > 0) {
|
||||
this.editWindowExpiryTimeout = setTimeout(() => {
|
||||
this.forceUpdate();
|
||||
}, editWindowRemainingMs);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
if (this.editWindowExpiryTimeout) {
|
||||
this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
handleInputChange = input => {
|
||||
this.setState(state => ({
|
||||
submitEnabled: true,
|
||||
input: {
|
||||
...state.input,
|
||||
...input,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
handleSubmit = async () => {
|
||||
if (!can(this.props.currentUser, 'INTERACT_WITH_COMMUNITY')) {
|
||||
this.props.notify('error', t('error.NOT_AUTHORIZED'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loadingState: 'loading' });
|
||||
|
||||
const { editComment, stopEditing } = this.props;
|
||||
if (typeof editComment !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
let input = this.state.input;
|
||||
|
||||
// Execute preSubmit Hooks
|
||||
this.props.forEachHook('preSubmit', hook => {
|
||||
const result = hook(input);
|
||||
if (result) {
|
||||
input = result;
|
||||
}
|
||||
});
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await editComment(input);
|
||||
// Execute postSubmit Hooks
|
||||
this.props.forEachHook('postSubmit', hook =>
|
||||
hook(response, this.handleInputChange)
|
||||
);
|
||||
|
||||
if (!this.unmounted) {
|
||||
this.setState({ loadingState: 'success' });
|
||||
}
|
||||
const status = response.data.editComment.comment.status;
|
||||
notifyForNewCommentStatus(this.props.notify, status);
|
||||
if (typeof stopEditing === 'function') {
|
||||
stopEditing();
|
||||
}
|
||||
} catch (error) {
|
||||
this.setState({ loadingState: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
getEditableUntil = (props = this.props) => {
|
||||
return getEditableUntilDate(props.comment);
|
||||
};
|
||||
|
||||
isEditWindowExpired = (props = this.props) => {
|
||||
return this.getEditableUntil(props) - new Date() < 0;
|
||||
};
|
||||
|
||||
isSubmitEnabled = () => this.state.submitEnabled;
|
||||
|
||||
render() {
|
||||
return (
|
||||
<EditableCommentContent
|
||||
charCountEnable={this.props.charCountEnable}
|
||||
submitEnabled={this.isSubmitEnabled}
|
||||
maxCharCount={this.props.maxCharCount}
|
||||
root={this.props.root}
|
||||
comment={this.props.comment}
|
||||
input={this.state.input}
|
||||
onInputChange={this.handleInputChange}
|
||||
onSubmit={this.handleSubmit}
|
||||
onCancel={this.props.stopEditing}
|
||||
loadingState={this.state.loadingState}
|
||||
editWindowExpired={this.isEditWindowExpired()}
|
||||
editableUntil={this.getEditableUntil()}
|
||||
registerHook={this.props.registerHook}
|
||||
unregisterHook={this.props.unregisterHook}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditableCommentContentContainer.propTypes = {
|
||||
notify: PropTypes.func.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
comment: PropTypes.object.isRequired,
|
||||
currentUser: PropTypes.object,
|
||||
charCountEnable: PropTypes.bool,
|
||||
maxCharCount: PropTypes.number,
|
||||
editComment: PropTypes.func,
|
||||
stopEditing: PropTypes.func,
|
||||
registerHook: PropTypes.func.isRequired,
|
||||
unregisterHook: PropTypes.func.isRequired,
|
||||
forEachHook: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
EditableCommentContentContainer.fragments = CommentForm.fragments;
|
||||
|
||||
export default EditableCommentContentContainer;
|
||||
const enhance = compose(withHooks(['preSubmit', 'postSubmit']));
|
||||
|
||||
export default enhance(EditableCommentContentContainer);
|
||||
|
||||
@@ -367,6 +367,7 @@ const LOAD_MORE_QUERY = gql`
|
||||
`;
|
||||
|
||||
const slots = [
|
||||
'commentInputDetailArea',
|
||||
'streamTabs',
|
||||
'streamTabsPrepend',
|
||||
'streamTabPanes',
|
||||
@@ -465,7 +466,6 @@ const mapStateToProps = state => ({
|
||||
activeStreamTab: state.stream.activeTab,
|
||||
previousStreamTab: state.stream.previousTab,
|
||||
commentClassNames: state.stream.commentClassNames,
|
||||
pluginConfig: state.config.plugin_config,
|
||||
sortOrder: state.stream.sortOrder,
|
||||
sortBy: state.stream.sortBy,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import hoistStatics from 'recompose/hoistStatics';
|
||||
|
||||
/**
|
||||
* WithHooks provides a property `hooks` to the wrapped component.
|
||||
*/
|
||||
export default hooks =>
|
||||
hoistStatics(WrappedComponent => {
|
||||
class WithHooks extends React.Component {
|
||||
hooks = hooks.reduce((map, key) => {
|
||||
map[key] = [];
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
registerHook = (hookType = '', hook) => {
|
||||
if (typeof hook !== 'function') {
|
||||
return console.warn(
|
||||
`Hooks must be functions. Please check your ${hookType} hooks`
|
||||
);
|
||||
}
|
||||
if (!hooks.includes(hookType)) {
|
||||
throw new Error(`Unknown hookType ${hookType}`);
|
||||
}
|
||||
|
||||
this.hooks[hookType].push(hook);
|
||||
return {
|
||||
hookType,
|
||||
hook,
|
||||
};
|
||||
};
|
||||
|
||||
unregisterHook = hookData => {
|
||||
const { hookType, hook } = hookData;
|
||||
const idx = this.hooks[hookType].indexOf(hook);
|
||||
if (idx !== -1) {
|
||||
this.hooks[hookType].splice(idx, 1);
|
||||
}
|
||||
};
|
||||
|
||||
forEachHook = (hookType, callback) => {
|
||||
this.hooks[hookType].forEach(callback);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<WrappedComponent
|
||||
{...this.props}
|
||||
registerHook={this.registerHook}
|
||||
unregisterHook={this.unregisterHook}
|
||||
forEachHook={this.forEachHook}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return WithHooks;
|
||||
});
|
||||
@@ -161,6 +161,14 @@ export default class Stream {
|
||||
);
|
||||
}
|
||||
|
||||
enablePluginsDebug() {
|
||||
this.pym.sendMessage('enablePluginsDebug');
|
||||
}
|
||||
|
||||
disablePluginsDebug() {
|
||||
this.pym.sendMessage('disablePluginsDebug');
|
||||
}
|
||||
|
||||
login(token) {
|
||||
this.pym.sendMessage('login', token);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ export default class StreamInterface {
|
||||
return this._stream.emitter.on(eventName, callback);
|
||||
}
|
||||
|
||||
off(eventName, callback) {
|
||||
return this._stream.emitter.off(eventName, callback);
|
||||
}
|
||||
|
||||
login(token) {
|
||||
return this._stream.login(token);
|
||||
}
|
||||
@@ -18,4 +22,12 @@ export default class StreamInterface {
|
||||
remove() {
|
||||
return this._stream.remove();
|
||||
}
|
||||
|
||||
enablePluginsDebug() {
|
||||
return this._stream.enablePluginsDebug();
|
||||
}
|
||||
|
||||
disablePluginsDebug() {
|
||||
return this._stream.disablePluginsDebug();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@ export const setAuthToken = token => (dispatch, _, { localStorage }) => {
|
||||
localStorage.setItem('token', token);
|
||||
}
|
||||
|
||||
// Dispatch the set auth token action. For some browsers and situations, we
|
||||
// may not be able to persist the auth token any other way. Keep it in redux!
|
||||
dispatch({ type: actions.SET_AUTH_TOKEN, token });
|
||||
|
||||
dispatch(checkLogin());
|
||||
};
|
||||
|
||||
@@ -87,6 +91,7 @@ export const handleSuccessfulLogin = (user, token) => (
|
||||
dispatch({
|
||||
type: actions.HANDLE_SUCCESSFUL_LOGIN,
|
||||
user,
|
||||
token,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { MERGE_CONFIG } from '../constants/config';
|
||||
import {
|
||||
MERGE_CONFIG,
|
||||
ENABLE_PLUGINS_DEBUG,
|
||||
DISABLE_PLUGINS_DEBUG,
|
||||
} from '../constants/config';
|
||||
|
||||
export const mergeConfig = config => ({
|
||||
type: MERGE_CONFIG,
|
||||
config,
|
||||
});
|
||||
|
||||
export const enablePluginsDebug = () => ({
|
||||
type: ENABLE_PLUGINS_DEBUG,
|
||||
});
|
||||
|
||||
export const disablePluginsDebug = () => ({
|
||||
type: DISABLE_PLUGINS_DEBUG,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.content {
|
||||
a {
|
||||
color: #063b9a;
|
||||
text-decoration: underline;
|
||||
font-weight: 300;
|
||||
background-color: #f4ff81;
|
||||
}
|
||||
|
||||
mark {
|
||||
background-color: #f4ff81;
|
||||
}
|
||||
|
||||
b, strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import matchLinks from '../utils/matchLinks';
|
||||
import memoize from 'lodash/memoize';
|
||||
import cn from 'classnames';
|
||||
import styles from './AdminCommentContent.css';
|
||||
|
||||
function escapeHTML(unsafe) {
|
||||
return unsafe
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function escapeRegExp(string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||
}
|
||||
|
||||
// generate a regulare expression that catches the `phrases`.
|
||||
function generateRegExp(phrases) {
|
||||
const inner = phrases
|
||||
.map(phrase =>
|
||||
phrase
|
||||
.split(/\s+/)
|
||||
.map(word => escapeRegExp(word))
|
||||
.join('[\\s"?!.]+')
|
||||
)
|
||||
.join('|');
|
||||
|
||||
const pattern = `(^|[^\\w])(${inner})(?=[^\\w]|$)`;
|
||||
try {
|
||||
return new RegExp(pattern, 'iu');
|
||||
} catch (_err) {
|
||||
// IE does not support unicode support, so we'll create one without.
|
||||
return new RegExp(pattern, 'i');
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a regular expression detecting `suspectWords` and `bannedWords` phrases.
|
||||
function getPhrasesRegexp(suspectWords, bannedWords) {
|
||||
return generateRegExp([...suspectWords, ...bannedWords]);
|
||||
}
|
||||
|
||||
// Memoized version as arguments rarely change.
|
||||
const getPhrasesRegexpMemoized = memoize(getPhrasesRegexp);
|
||||
|
||||
function nl2br(body, keyPrefix) {
|
||||
const tokens = body.split('\n').reduce((tokens, t, i) => {
|
||||
if (i !== 0) {
|
||||
tokens.push(<br key={`${keyPrefix}_${i}`} />);
|
||||
}
|
||||
tokens.push(t);
|
||||
return tokens;
|
||||
}, []);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// markPhrases looks for `supsectWords` and `bannedWords` inside `body` and highlights them by returning
|
||||
// an array of React Elements.
|
||||
function markPhrases(body, suspectWords, bannedWords, keyPrefix) {
|
||||
const regexp = getPhrasesRegexpMemoized(suspectWords, bannedWords);
|
||||
const tokens = body.split(regexp);
|
||||
return tokens.map(
|
||||
(token, i) =>
|
||||
i % 3 === 2 ? <mark key={`${keyPrefix}_${i}`}>{token}</mark> : token
|
||||
);
|
||||
}
|
||||
|
||||
// markLinks looks for links inside `body` and highlights them by returning
|
||||
// an array of React Elements.
|
||||
function markLinks(body, keyPrefix) {
|
||||
const matches = matchLinks(body);
|
||||
const content = [];
|
||||
let index = 0;
|
||||
if (matches) {
|
||||
matches.forEach((match, i) => {
|
||||
content.push(body.substring(index, match.index));
|
||||
content.push(
|
||||
<a key={`${keyPrefix}_${i}`} href={match.url} target="_blank">
|
||||
{match.text}
|
||||
</a>
|
||||
);
|
||||
index = match.lastIndex;
|
||||
});
|
||||
}
|
||||
content.push(body.substring(index));
|
||||
return content;
|
||||
}
|
||||
|
||||
// markPhrasesHTML looks for `supsectWords` and `bannedWords` inside `text` and highlights them by returning
|
||||
// a HTML string.
|
||||
function markPhrasesHTML(text, suspectWords, bannedWords) {
|
||||
const regexp = getPhrasesRegexpMemoized(suspectWords, bannedWords);
|
||||
const tokens = text.split(regexp);
|
||||
if (tokens.length === 1) {
|
||||
return text;
|
||||
}
|
||||
return tokens
|
||||
.map(
|
||||
(token, i) =>
|
||||
i % 3 === 2 ? `<mark>${escapeHTML(token)}</mark>` : escapeHTML(token)
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
// markHTMLNode manipulates the node by looking for #text nodes and adding markers
|
||||
// for `supsectWords` and `bannedWords`.
|
||||
function markHTMLNode(parentNode, suspectWords, bannedWords) {
|
||||
parentNode.childNodes.forEach(node => {
|
||||
if (node.nodeName === '#text') {
|
||||
const newContent = markPhrasesHTML(
|
||||
node.textContent,
|
||||
suspectWords,
|
||||
bannedWords
|
||||
);
|
||||
if (newContent !== node.textContent) {
|
||||
const newNode = document.createElement('span');
|
||||
newNode.innerHTML = newContent;
|
||||
parentNode.replaceChild(newNode, node);
|
||||
}
|
||||
} else {
|
||||
markHTMLNode(node, suspectWords, bannedWords);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// renderText performs all the marking of a text body and returns an array of React Elements.
|
||||
function renderText(body, suspectWords, bannedWords) {
|
||||
return nl2br(body).map((element, index) => {
|
||||
// Skip br tags.
|
||||
if (typeof element !== 'string') {
|
||||
return element;
|
||||
}
|
||||
return markLinks(element, index).map((element, index) => {
|
||||
// Keep highlighted links.
|
||||
if (typeof element !== 'string') {
|
||||
return element;
|
||||
}
|
||||
|
||||
// Highlight suspect and banned phrase inside this part of text.
|
||||
return markPhrases(element, suspectWords, bannedWords, index);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const commonPropTypes = {
|
||||
className: PropTypes.string,
|
||||
bannedWords: PropTypes.array.isRequired,
|
||||
suspectWords: PropTypes.array.isRequired,
|
||||
body: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
const AdminCommentContentText = ({
|
||||
body,
|
||||
className,
|
||||
suspectWords,
|
||||
bannedWords,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn(className, styles.content)}>
|
||||
{renderText(body, suspectWords, bannedWords)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
AdminCommentContentText.propTypes = commonPropTypes;
|
||||
|
||||
const AdminCommentContentHTML = ({
|
||||
body,
|
||||
className,
|
||||
suspectWords,
|
||||
bannedWords,
|
||||
}) => {
|
||||
// We create a Shadow DOM Tree with the HTML body content and
|
||||
// use it as a parser.
|
||||
const node = document.createElement('div');
|
||||
node.innerHTML = body;
|
||||
|
||||
// Then we traverse it recursively and manipulate it to highlight suspect words
|
||||
// and banned words.
|
||||
markHTMLNode(node, suspectWords, bannedWords);
|
||||
|
||||
// Finally we render the content of the Shadow DOM Tree
|
||||
return (
|
||||
<div
|
||||
className={cn(className, styles.content)}
|
||||
dangerouslySetInnerHTML={{ __html: node.innerHTML }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
AdminCommentContentHTML.propTypes = commonPropTypes;
|
||||
|
||||
const AdminCommentContent = ({
|
||||
className,
|
||||
body,
|
||||
suspectWords,
|
||||
bannedWords,
|
||||
html,
|
||||
}) => {
|
||||
const Component = html ? AdminCommentContentHTML : AdminCommentContentText;
|
||||
return (
|
||||
<Component
|
||||
className={className}
|
||||
body={body}
|
||||
suspectWords={suspectWords}
|
||||
bannedWords={bannedWords}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
AdminCommentContent.propTypes = {
|
||||
...commonPropTypes,
|
||||
html: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default AdminCommentContent;
|
||||
@@ -1,39 +1,14 @@
|
||||
import React, { Children } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getShallowChanges } from 'coral-framework/utils';
|
||||
import { withSlotElements, withCompatPassthrough } from '../hocs';
|
||||
import { compose } from 'recompose';
|
||||
|
||||
class IfSlotIsEmpty extends React.Component {
|
||||
static contextTypes = {
|
||||
plugins: PropTypes.object,
|
||||
};
|
||||
|
||||
shouldComponentUpdate(next) {
|
||||
// Prevent Slot from rerendering when only reduxState has changed and
|
||||
// it does not result in a change.
|
||||
const changes = getShallowChanges(this.props, next);
|
||||
if (changes.length === 1 && changes[0] === 'reduxState') {
|
||||
return this.isSlotEmpty(this.props) !== this.isSlotEmpty(next);
|
||||
}
|
||||
|
||||
// Prevent Slot from rerendering when no props has shallowly changed.
|
||||
return changes.length !== 0;
|
||||
}
|
||||
|
||||
isSlotEmpty(props = this.props) {
|
||||
const {
|
||||
slot,
|
||||
className: _a,
|
||||
reduxState,
|
||||
component: _b = 'div',
|
||||
children: _c,
|
||||
queryData,
|
||||
...rest
|
||||
} = props;
|
||||
const slots = Array.isArray(slot) ? slot : [slot];
|
||||
return slots.every(slot =>
|
||||
this.context.plugins.isSlotEmpty(slot, reduxState, rest, queryData)
|
||||
);
|
||||
const { slotElements } = props;
|
||||
return slotElements.length === 0
|
||||
? false
|
||||
: slotElements.every(elements => elements.length === 0);
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -44,10 +19,15 @@ class IfSlotIsEmpty extends React.Component {
|
||||
|
||||
IfSlotIsEmpty.propTypes = {
|
||||
slot: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
|
||||
children: PropTypes.node.isRequired,
|
||||
passthrough: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
reduxState: state,
|
||||
});
|
||||
const omitProps = ['slot', 'children'];
|
||||
|
||||
export default connect(mapStateToProps, null)(IfSlotIsEmpty);
|
||||
export default compose(
|
||||
withCompatPassthrough(omitProps),
|
||||
withSlotElements({
|
||||
slot: props => props.slot,
|
||||
})
|
||||
)(IfSlotIsEmpty);
|
||||
|
||||
@@ -1,39 +1,14 @@
|
||||
import React, { Children } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getShallowChanges } from 'coral-framework/utils';
|
||||
import { withSlotElements, withCompatPassthrough } from '../hocs';
|
||||
import { compose } from 'recompose';
|
||||
|
||||
class IfSlotIsNotEmpty extends React.Component {
|
||||
static contextTypes = {
|
||||
plugins: PropTypes.object,
|
||||
};
|
||||
|
||||
shouldComponentUpdate(next) {
|
||||
// Prevent Slot from rerendering when only reduxState has changed and
|
||||
// it does not result in a change.
|
||||
const changes = getShallowChanges(this.props, next);
|
||||
if (changes.length === 1 && changes[0] === 'reduxState') {
|
||||
return this.isSlotEmpty(this.props) !== this.isSlotEmpty(next);
|
||||
}
|
||||
|
||||
// Prevent Slot from rerendering when no props has shallowly changed.
|
||||
return changes.length !== 0;
|
||||
}
|
||||
|
||||
isSlotEmpty(props = this.props) {
|
||||
const {
|
||||
slot,
|
||||
className: _a,
|
||||
reduxState,
|
||||
component: _b = 'div',
|
||||
children: _c,
|
||||
queryData,
|
||||
...rest
|
||||
} = props;
|
||||
const slots = Array.isArray(slot) ? slot : [slot];
|
||||
return slots.every(slot =>
|
||||
this.context.plugins.isSlotEmpty(slot, reduxState, rest, queryData)
|
||||
);
|
||||
const { slotElements } = props;
|
||||
return slotElements.length === 0
|
||||
? true
|
||||
: slotElements.every(elements => elements.length === 0);
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -44,10 +19,16 @@ class IfSlotIsNotEmpty extends React.Component {
|
||||
|
||||
IfSlotIsNotEmpty.propTypes = {
|
||||
slot: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
|
||||
slotElements: PropTypes.array.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
passthrough: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
reduxState: state,
|
||||
});
|
||||
const omitProps = ['slot', 'children'];
|
||||
|
||||
export default connect(mapStateToProps, null)(IfSlotIsNotEmpty);
|
||||
export default compose(
|
||||
withCompatPassthrough(omitProps),
|
||||
withSlotElements({
|
||||
slot: props => props.slot,
|
||||
})
|
||||
)(IfSlotIsNotEmpty);
|
||||
|
||||
@@ -1,154 +1,11 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import SimpleMDE from 'simplemde';
|
||||
import cn from 'classnames';
|
||||
import noop from 'lodash/noop';
|
||||
import styles from './MarkdownEditor.css';
|
||||
import { Spinner } from 'coral-ui';
|
||||
import Loadable from 'react-loadable';
|
||||
|
||||
const config = {
|
||||
status: false,
|
||||
const MarkdownEditor = Loadable({
|
||||
loader: () =>
|
||||
import(/* webpackChunkName: "markdownEditor" */
|
||||
'./loadable/MarkdownEditor'),
|
||||
loading: Spinner,
|
||||
});
|
||||
|
||||
// Do not download fontAwesome icons as we replace them with
|
||||
// material icons.
|
||||
autoDownloadFontAwesome: false,
|
||||
|
||||
// Disable built-in spell checker as it is very rudimentary.
|
||||
spellChecker: false,
|
||||
|
||||
toolbar: [
|
||||
{
|
||||
name: 'bold',
|
||||
action: SimpleMDE.toggleBold,
|
||||
className: styles.iconBold,
|
||||
title: 'Bold',
|
||||
},
|
||||
{
|
||||
name: 'italic',
|
||||
action: SimpleMDE.toggleItalic,
|
||||
className: styles.iconItalic,
|
||||
title: 'Italic',
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
action: SimpleMDE.toggleHeadingSmaller,
|
||||
className: styles.iconTitle,
|
||||
title: 'Title, Subtitle, Heading',
|
||||
},
|
||||
'|',
|
||||
{
|
||||
name: 'quote',
|
||||
action: SimpleMDE.toggleBlockquote,
|
||||
className: styles.iconQuote,
|
||||
title: 'Quote',
|
||||
},
|
||||
{
|
||||
name: 'unordered-list',
|
||||
action: SimpleMDE.toggleUnorderedList,
|
||||
className: styles.iconUnorderedList,
|
||||
title: 'Generic List',
|
||||
},
|
||||
{
|
||||
name: 'ordered-list',
|
||||
action: SimpleMDE.toggleOrderedList,
|
||||
className: styles.iconOrderedList,
|
||||
title: 'Numbered List',
|
||||
},
|
||||
'|',
|
||||
{
|
||||
name: 'link',
|
||||
action: SimpleMDE.drawLink,
|
||||
className: styles.iconLink,
|
||||
title: 'Create Link',
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
action: SimpleMDE.drawImage,
|
||||
className: styles.iconImage,
|
||||
title: 'Insert Image',
|
||||
},
|
||||
'|',
|
||||
{
|
||||
name: 'preview',
|
||||
action: SimpleMDE.togglePreview,
|
||||
className: cn(styles.iconPreview, 'no-disable'),
|
||||
title: 'Toggle Preview',
|
||||
},
|
||||
{
|
||||
name: 'side-by-side',
|
||||
action: SimpleMDE.toggleSideBySide,
|
||||
className: cn(styles.iconSideBySide, 'no-disable'),
|
||||
title: 'Toggle Side by Side',
|
||||
},
|
||||
{
|
||||
name: 'fullscreen',
|
||||
action: SimpleMDE.toggleFullScreen,
|
||||
className: cn(styles.iconFullscreen, 'no-disable'),
|
||||
title: 'Toggle Fullscreen',
|
||||
},
|
||||
'|',
|
||||
{
|
||||
name: 'guide',
|
||||
action: 'https://simplemde.com/markdown-guide',
|
||||
className: styles.iconGuide,
|
||||
title: 'Markdown Guide',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default class MarkdownEditor extends Component {
|
||||
textarea = null;
|
||||
editor = null;
|
||||
|
||||
onRef = ref => (this.textarea = ref);
|
||||
|
||||
componentDidMount() {
|
||||
this.editor = new SimpleMDE({
|
||||
...config,
|
||||
element: this.textarea,
|
||||
});
|
||||
|
||||
// Don't trap the key, to stay accessible.
|
||||
this.editor.codemirror.options.extraKeys['Tab'] = false;
|
||||
this.editor.codemirror.options.extraKeys['Shift-Tab'] = false;
|
||||
|
||||
this.editor.codemirror.on('change', this.onChange);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (
|
||||
this.props.value !== nextProps.value &&
|
||||
nextProps.value !== this.editor.value()
|
||||
) {
|
||||
this.editor.value(nextProps.value);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
// Workaround empty render issue.
|
||||
// https://github.com/NextStepWebs/simplemde-markdown-editor/issues/313
|
||||
this.editor.codemirror.refresh();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.editor.toTextArea();
|
||||
}
|
||||
|
||||
onChange = () => {
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(this.editor.value());
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<textarea ref={this.onRef} {...this.props} onChange={noop} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MarkdownEditor.propTypes = {
|
||||
onChange: PropTypes.func,
|
||||
value: PropTypes.string,
|
||||
};
|
||||
export default MarkdownEditor;
|
||||
|
||||
@@ -3,5 +3,36 @@
|
||||
}
|
||||
|
||||
.debug {
|
||||
background-color: coral;
|
||||
background-color: #e2e2e2;
|
||||
border-style: dotted solid;
|
||||
border-width: 2px;
|
||||
border: dotted 2px coral;
|
||||
padding: 2px;
|
||||
margin: 1px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.debug::before {
|
||||
content: attr(data-slot-name);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
|
||||
background: #000;
|
||||
color: #FFF;
|
||||
padding: 5px;
|
||||
border-radius: 5px;
|
||||
opacity: 0;
|
||||
transition: 0.3s;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 999!important;
|
||||
white-space: pre-wrap;
|
||||
min-height: 16px;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.debug:hover::before {
|
||||
opacity: 1;
|
||||
top: 100%;
|
||||
}
|
||||
@@ -4,86 +4,21 @@ import styles from './Slot.css';
|
||||
import { connect } from 'react-redux';
|
||||
import kebabCase from 'lodash/kebabCase';
|
||||
import PropTypes from 'prop-types';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import get from 'lodash/get';
|
||||
import { getShallowChanges } from 'coral-framework/utils';
|
||||
import omit from 'lodash/omit';
|
||||
|
||||
const emptyConfig = {};
|
||||
import { withSlotElements, withCompatPassthrough } from '../hocs';
|
||||
import { compose } from 'recompose';
|
||||
|
||||
class Slot extends React.Component {
|
||||
static contextTypes = {
|
||||
plugins: PropTypes.object,
|
||||
};
|
||||
|
||||
shouldComponentUpdate(next) {
|
||||
// Prevent Slot from rerendering when only reduxState has changed and
|
||||
// it does not result in a change of slot children.
|
||||
const changes = getShallowChanges(this.props, next);
|
||||
if (changes.length === 1 && changes[0] === 'reduxState') {
|
||||
const prevChildrenKeys = this.getChildren(this.props).map(
|
||||
child => child.key
|
||||
);
|
||||
const nextChildrenKeys = this.getChildren(next).map(child => child.key);
|
||||
return !isEqual(prevChildrenKeys, nextChildrenKeys);
|
||||
}
|
||||
|
||||
// Prevent Slot from rerendering when no props has shallowly changed.
|
||||
return changes.length !== 0;
|
||||
}
|
||||
|
||||
getSlotProps(props = this.props) {
|
||||
return omit(props, [
|
||||
'fill',
|
||||
'inline',
|
||||
'className',
|
||||
'reduxState',
|
||||
'slotSize',
|
||||
'defaultComponent',
|
||||
'queryData',
|
||||
'childFactory',
|
||||
'component',
|
||||
]);
|
||||
}
|
||||
|
||||
getChildren(props = this.props) {
|
||||
const { slotSize = 0 } = props;
|
||||
const { plugins } = this.context;
|
||||
|
||||
return plugins.getSlotElements(
|
||||
props.fill,
|
||||
props.reduxState,
|
||||
this.getSlotProps(props),
|
||||
props.queryData,
|
||||
{ slotSize }
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
inline = false,
|
||||
className,
|
||||
reduxState,
|
||||
debug,
|
||||
component: Component,
|
||||
childFactory,
|
||||
defaultComponent: DefaultComponent,
|
||||
queryData,
|
||||
fill,
|
||||
} = this.props;
|
||||
const { plugins } = this.context;
|
||||
let children = this.getChildren();
|
||||
const pluginConfig =
|
||||
get(reduxState, 'config.plugins_config') || emptyConfig;
|
||||
if (children.length === 0 && DefaultComponent) {
|
||||
const props = plugins.getSlotComponentProps(
|
||||
DefaultComponent,
|
||||
reduxState,
|
||||
this.getSlotProps(this.props),
|
||||
queryData
|
||||
);
|
||||
children = <DefaultComponent {...props} />;
|
||||
}
|
||||
|
||||
let children = this.props.slotElements;
|
||||
if (childFactory) {
|
||||
children = children.map(childFactory);
|
||||
}
|
||||
@@ -91,10 +26,11 @@ class Slot extends React.Component {
|
||||
return (
|
||||
<Component
|
||||
className={cn(
|
||||
{ [styles.inline]: inline, [styles.debug]: pluginConfig.debug },
|
||||
{ [styles.inline]: inline, [styles.debug]: debug },
|
||||
className,
|
||||
`talk-slot-${kebabCase(fill)}`
|
||||
)}
|
||||
data-slot-name={fill}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
@@ -110,13 +46,14 @@ Slot.propTypes = {
|
||||
fill: PropTypes.string.isRequired,
|
||||
inline: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
reduxState: PropTypes.object,
|
||||
debug: PropTypes.bool,
|
||||
slotElements: PropTypes.arrayOf(PropTypes.element).isRequired,
|
||||
defaultComponent: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
|
||||
|
||||
/**
|
||||
* Specifies the number of children that can fill the slot.
|
||||
*/
|
||||
slotSize: PropTypes.number,
|
||||
size: PropTypes.number,
|
||||
|
||||
/**
|
||||
* You may specify the component to use as the root wrapper.
|
||||
@@ -125,8 +62,12 @@ Slot.propTypes = {
|
||||
component: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
|
||||
|
||||
// props coming from graphql must be passed through this property.
|
||||
// @Deprecated
|
||||
queryData: PropTypes.object,
|
||||
|
||||
// props that are passed to all Slot Components
|
||||
passthrough: PropTypes.object,
|
||||
|
||||
/**
|
||||
* You may need to apply reactive updates to a child as it is exiting.
|
||||
* This is generally done by using `cloneElement` however in the case of an exiting
|
||||
@@ -140,8 +81,27 @@ Slot.propTypes = {
|
||||
childFactory: PropTypes.func,
|
||||
};
|
||||
|
||||
const omitProps = [
|
||||
'fill',
|
||||
'inline',
|
||||
'className',
|
||||
'size',
|
||||
'defaultComponent',
|
||||
'queryData',
|
||||
'childFactory',
|
||||
'component',
|
||||
];
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
reduxState: state,
|
||||
debug: get(state, 'config.plugins_config.debug'),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, null)(Slot);
|
||||
export default compose(
|
||||
withCompatPassthrough(omitProps),
|
||||
withSlotElements({
|
||||
slot: props => props.fill,
|
||||
size: props => props.size,
|
||||
defaultComponent: props => props.defaultComponent,
|
||||
}),
|
||||
connect(mapStateToProps, null)
|
||||
)(Slot);
|
||||
|
||||
@@ -10,6 +10,7 @@ class TalkProvider extends React.Component {
|
||||
plugins: this.props.plugins,
|
||||
rest: this.props.rest,
|
||||
graphql: this.props.graphql,
|
||||
introspection: this.props.introspection,
|
||||
notification: this.props.notification,
|
||||
localStorage: this.props.localStorage,
|
||||
sessionStorage: this.props.sessionStorage,
|
||||
@@ -29,6 +30,7 @@ class TalkProvider extends React.Component {
|
||||
|
||||
TalkProvider.childContextTypes = {
|
||||
pym: PropTypes.object,
|
||||
introspection: PropTypes.object,
|
||||
eventEmitter: PropTypes.object,
|
||||
plugins: PropTypes.object,
|
||||
rest: PropTypes.func,
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import SimpleMDE from 'simplemde';
|
||||
import cn from 'classnames';
|
||||
import noop from 'lodash/noop';
|
||||
import styles from './MarkdownEditor.css';
|
||||
|
||||
const config = {
|
||||
status: false,
|
||||
|
||||
// Do not download fontAwesome icons as we replace them with
|
||||
// material icons.
|
||||
autoDownloadFontAwesome: false,
|
||||
|
||||
// Disable built-in spell checker as it is very rudimentary.
|
||||
spellChecker: false,
|
||||
|
||||
toolbar: [
|
||||
{
|
||||
name: 'bold',
|
||||
action: SimpleMDE.toggleBold,
|
||||
className: styles.iconBold,
|
||||
title: 'Bold',
|
||||
},
|
||||
{
|
||||
name: 'italic',
|
||||
action: SimpleMDE.toggleItalic,
|
||||
className: styles.iconItalic,
|
||||
title: 'Italic',
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
action: SimpleMDE.toggleHeadingSmaller,
|
||||
className: styles.iconTitle,
|
||||
title: 'Title, Subtitle, Heading',
|
||||
},
|
||||
'|',
|
||||
{
|
||||
name: 'quote',
|
||||
action: SimpleMDE.toggleBlockquote,
|
||||
className: styles.iconQuote,
|
||||
title: 'Quote',
|
||||
},
|
||||
{
|
||||
name: 'unordered-list',
|
||||
action: SimpleMDE.toggleUnorderedList,
|
||||
className: styles.iconUnorderedList,
|
||||
title: 'Generic List',
|
||||
},
|
||||
{
|
||||
name: 'ordered-list',
|
||||
action: SimpleMDE.toggleOrderedList,
|
||||
className: styles.iconOrderedList,
|
||||
title: 'Numbered List',
|
||||
},
|
||||
'|',
|
||||
{
|
||||
name: 'link',
|
||||
action: SimpleMDE.drawLink,
|
||||
className: styles.iconLink,
|
||||
title: 'Create Link',
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
action: SimpleMDE.drawImage,
|
||||
className: styles.iconImage,
|
||||
title: 'Insert Image',
|
||||
},
|
||||
'|',
|
||||
{
|
||||
name: 'preview',
|
||||
action: SimpleMDE.togglePreview,
|
||||
className: cn(styles.iconPreview, 'no-disable'),
|
||||
title: 'Toggle Preview',
|
||||
},
|
||||
{
|
||||
name: 'side-by-side',
|
||||
action: SimpleMDE.toggleSideBySide,
|
||||
className: cn(styles.iconSideBySide, 'no-disable'),
|
||||
title: 'Toggle Side by Side',
|
||||
},
|
||||
{
|
||||
name: 'fullscreen',
|
||||
action: SimpleMDE.toggleFullScreen,
|
||||
className: cn(styles.iconFullscreen, 'no-disable'),
|
||||
title: 'Toggle Fullscreen',
|
||||
},
|
||||
'|',
|
||||
{
|
||||
name: 'guide',
|
||||
action: 'https://simplemde.com/markdown-guide',
|
||||
className: styles.iconGuide,
|
||||
title: 'Markdown Guide',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default class MarkdownEditor extends Component {
|
||||
textarea = null;
|
||||
editor = null;
|
||||
|
||||
onRef = ref => (this.textarea = ref);
|
||||
|
||||
componentDidMount() {
|
||||
this.editor = new SimpleMDE({
|
||||
...config,
|
||||
element: this.textarea,
|
||||
});
|
||||
|
||||
// Don't trap the key, to stay accessible.
|
||||
this.editor.codemirror.options.extraKeys['Tab'] = false;
|
||||
this.editor.codemirror.options.extraKeys['Shift-Tab'] = false;
|
||||
|
||||
this.editor.codemirror.on('change', this.onChange);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (
|
||||
this.props.value !== nextProps.value &&
|
||||
nextProps.value !== this.editor.value()
|
||||
) {
|
||||
this.editor.value(nextProps.value);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
// Workaround empty render issue.
|
||||
// https://github.com/NextStepWebs/simplemde-markdown-editor/issues/313
|
||||
this.editor.codemirror.refresh();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.editor.toTextArea();
|
||||
}
|
||||
|
||||
onChange = () => {
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(this.editor.value());
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<textarea ref={this.onRef} {...this.props} onChange={noop} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MarkdownEditor.propTypes = {
|
||||
onChange: PropTypes.func,
|
||||
value: PropTypes.string,
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
const prefix = `TALK_FRAMEWORK`;
|
||||
|
||||
export const SET_AUTH_TOKEN = `${prefix}_SET_AUTH_TOKEN`;
|
||||
|
||||
export const CHECK_LOGIN_REQUEST = `${prefix}_CHECK_LOGIN_REQUEST`;
|
||||
export const CHECK_LOGIN_SUCCESS = `${prefix}_CHECK_LOGIN_SUCCESS`;
|
||||
export const CHECK_LOGIN_FAILURE = `${prefix}_CHECK_LOGIN_FAILURE`;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const prefix = `TALK_FRAMEWORK`;
|
||||
|
||||
export const MERGE_CONFIG = `${prefix}_MERGE_CONFIG`;
|
||||
export const ENABLE_PLUGINS_DEBUG = `${prefix}_ENABLE_PLUGINS_DEBUG`;
|
||||
export const DISABLE_PLUGINS_DEBUG = `${prefix}_DISABLE_PLUGINS_DEBUG`;
|
||||
|
||||
@@ -11,6 +11,14 @@ export { default as withSignUp } from './withSignUp';
|
||||
export { default as withForgotPassword } from './withForgotPassword';
|
||||
export { default as withSetUsername } from './withSetUsername';
|
||||
export { default as withPopupAuthHandler } from './withPopupAuthHandler';
|
||||
export { default as withEnumValues } from './withEnumValues';
|
||||
export { default as withCompatPassthrough } from './withCompatPassthrough';
|
||||
export { default as withSlotElements } from './withSlotElements';
|
||||
export { default as withVariables } from './withVariables';
|
||||
export { default as WithRefetch } from './withRefetch';
|
||||
export { default as withFetchMore } from './withFetchMore';
|
||||
export { default as withSubscribeToMore } from './withSubscribeToMore';
|
||||
export { default as withGraphQLExtension } from './withGraphQLExtension';
|
||||
export {
|
||||
default as withResendEmailConfirmation,
|
||||
} from './withResendEmailConfirmation';
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import withProps from 'recompose/withProps';
|
||||
import omit from 'lodash/omit';
|
||||
|
||||
function getPassthrough(props, omitProps) {
|
||||
const slotProps = omit(props, [...omitProps, 'passthrough']);
|
||||
|
||||
// @Deprecated
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if (Object.keys(slotProps).length) {
|
||||
/* eslint-disable no-console */
|
||||
console.warn(
|
||||
`Slot '${
|
||||
props.fill
|
||||
}' passing through unknown props is deprecated, please use 'passthrough' instead`,
|
||||
slotProps
|
||||
);
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
}
|
||||
|
||||
if (props.passthrough) {
|
||||
return props.passthrough;
|
||||
}
|
||||
|
||||
if (props.queryData) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
/* eslint-disable no-console */
|
||||
console.warn(
|
||||
`Slot '${
|
||||
props.fill
|
||||
}' property 'queryData' is deprecated, please use 'passthrough' instead`
|
||||
);
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
return {
|
||||
...props.queryData,
|
||||
...slotProps,
|
||||
};
|
||||
}
|
||||
|
||||
return slotProps;
|
||||
}
|
||||
|
||||
/**
|
||||
* @Deprecated
|
||||
* withCompatPassthrough is a compatibility HOC that supports our old
|
||||
* API which puts unknown props and `queryData` to `passhtrough` to be
|
||||
* used with HOC `withSlotElements`.
|
||||
*/
|
||||
export default omitProps =>
|
||||
withProps(props => ({
|
||||
passthrough: getPassthrough(props, omitProps),
|
||||
}));
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import hoistStatics from 'recompose/hoistStatics';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* WithEnumValues inject given property name for given enum.
|
||||
*/
|
||||
export default (enumName, propName) =>
|
||||
hoistStatics(WrappedComponent => {
|
||||
class WithEnumValues extends React.Component {
|
||||
static contextTypes = {
|
||||
introspection: PropTypes.object,
|
||||
};
|
||||
|
||||
render() {
|
||||
const inject = {
|
||||
[propName]: this.context.introspection.getEnumValues(enumName),
|
||||
};
|
||||
return <WrappedComponent {...this.props} {...inject} />;
|
||||
}
|
||||
}
|
||||
|
||||
return WithEnumValues;
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import hoistStatics from 'recompose/hoistStatics';
|
||||
import { Subscriber } from 'react-broadcast';
|
||||
import get from 'lodash/get';
|
||||
|
||||
/**
|
||||
* WithFetchMore provides a property `fetchMore` to the wrapped component.
|
||||
* Calling `fetchMore` is the same as calling `data.fetchMore` from the
|
||||
* Apollo React API.
|
||||
*/
|
||||
export default hoistStatics(WrappedComponent => {
|
||||
class WithFetchMore extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<Subscriber channel="queryData">
|
||||
{data => (
|
||||
<WrappedComponent
|
||||
{...this.props}
|
||||
fetchMore={get(data, 'fetchMore')}
|
||||
/>
|
||||
)}
|
||||
</Subscriber>
|
||||
);
|
||||
}
|
||||
}
|
||||
return WithFetchMore;
|
||||
});
|
||||
@@ -6,27 +6,21 @@ import { getShallowChanges } from 'coral-framework/utils';
|
||||
import PropTypes from 'prop-types';
|
||||
import union from 'lodash/union';
|
||||
|
||||
// TODO: Should not depend on `props.data`
|
||||
// Currently necessary because of this https://github.com/apollographql/graphql-anywhere/issues/38
|
||||
function filter(doc, data, variables) {
|
||||
function filter(doc, data) {
|
||||
const resolver = (fieldName, root, args, context, info) => {
|
||||
return root[info.resultKey];
|
||||
};
|
||||
|
||||
return graphql(resolver, doc, data, null, variables);
|
||||
return graphql(resolver, doc, data, null, null, { includeAll: true });
|
||||
}
|
||||
|
||||
// filterProps returns only the property as defined in the fragments.
|
||||
// TODO: Should not depend on `props.data`
|
||||
function filterProps(props, fragments) {
|
||||
const filtered = {};
|
||||
Object.keys(fragments).forEach(key => {
|
||||
if (!(key in props)) {
|
||||
if (!(key in props) || props[key] === undefined) {
|
||||
return;
|
||||
}
|
||||
filtered[key] = props.data
|
||||
? filter(fragments[key], props[key], props.data.variables)
|
||||
: props[key];
|
||||
filtered[key] = filter(fragments[key], props[key]);
|
||||
});
|
||||
return filtered;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import hoistStatics from 'recompose/hoistStatics';
|
||||
|
||||
/**
|
||||
* WithGraphQLExtension adds graphql configuration to the
|
||||
* GraphQL registry, only works on Components used
|
||||
* directly in a Slot.
|
||||
*/
|
||||
export default extension =>
|
||||
hoistStatics(WrappedComponent => {
|
||||
class WithGraphQLExtension extends React.Component {
|
||||
render() {
|
||||
return <WrappedComponent {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
WrappedComponent.graphqlExtension = extension;
|
||||
return WithGraphQLExtension;
|
||||
});
|
||||
@@ -11,6 +11,8 @@ import { getOperationName } from 'apollo-client/queries/getFromAST';
|
||||
import throttle from 'lodash/throttle';
|
||||
import get from 'lodash/get';
|
||||
import { notify } from 'coral-framework/actions/notification';
|
||||
import { Broadcast } from 'react-broadcast';
|
||||
import { compose } from 'recompose';
|
||||
|
||||
const withSkipOnErrors = reducer => (prev, action, ...rest) => {
|
||||
if (
|
||||
@@ -43,6 +45,15 @@ function networkStatusToString(networkStatus) {
|
||||
}
|
||||
}
|
||||
|
||||
// When wrapped broadcast all data changes to channel "queryData".
|
||||
const withBroadcaster = WrappedComponent => props => (
|
||||
/* eslint-disable react/prop-types */
|
||||
<Broadcast channel="queryData" value={props.data}>
|
||||
<WrappedComponent {...props} />
|
||||
</Broadcast>
|
||||
/* eslint-enable react/prop-types */
|
||||
);
|
||||
|
||||
const createHOC = (document, config, { notifyOnError = true }) =>
|
||||
hoistStatics(WrappedComponent => {
|
||||
return class WithQuery extends React.Component {
|
||||
@@ -283,6 +294,7 @@ const createHOC = (document, config, { notifyOnError = true }) =>
|
||||
props: args => {
|
||||
const nextData = this.nextData(args.data);
|
||||
const { root } = separateDataAndRoot(args.data);
|
||||
|
||||
if (config.props) {
|
||||
// Custom props, in this case we just pass the wrapped args to it.
|
||||
return config.props({
|
||||
@@ -323,10 +335,13 @@ const createHOC = (document, config, { notifyOnError = true }) =>
|
||||
if (!this.memoized) {
|
||||
this.resolvedDocument = this.resolveDocument(document);
|
||||
this.name = getDefinitionName(this.resolvedDocument);
|
||||
this.memoized = graphql(this.resolvedDocument, {
|
||||
...this.wrappedConfig,
|
||||
options: this.wrappedOptions,
|
||||
})(WrappedComponent);
|
||||
this.memoized = compose(
|
||||
graphql(this.resolvedDocument, {
|
||||
...this.wrappedConfig,
|
||||
options: this.wrappedOptions,
|
||||
}),
|
||||
withBroadcaster
|
||||
)(WrappedComponent);
|
||||
}
|
||||
return this.memoized;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import hoistStatics from 'recompose/hoistStatics';
|
||||
import { Subscriber } from 'react-broadcast';
|
||||
import get from 'lodash/get';
|
||||
|
||||
/**
|
||||
* WithRefetch provides a property `refetch` to the wrapped component.
|
||||
* Calling refetch will perform a refetch of the parent Query.
|
||||
*/
|
||||
export default hoistStatics(WrappedComponent => {
|
||||
class WithRefetch extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<Subscriber channel="queryData">
|
||||
{data => (
|
||||
<WrappedComponent {...this.props} refetch={get(data, 'refetch')} />
|
||||
)}
|
||||
</Subscriber>
|
||||
);
|
||||
}
|
||||
}
|
||||
return WithRefetch;
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { getShallowChanges } from 'coral-framework/utils';
|
||||
import { compose } from 'recompose';
|
||||
import hoistStatics from 'recompose/hoistStatics';
|
||||
import isFunction from 'lodash/isFunction';
|
||||
|
||||
function resolvePrimitiveOrFunction(primitiveOrFunction, props) {
|
||||
if (isFunction(primitiveOrFunction)) {
|
||||
return primitiveOrFunction(props);
|
||||
}
|
||||
return primitiveOrFunction;
|
||||
}
|
||||
|
||||
const createHOC = ({
|
||||
slot,
|
||||
defaultComponent = null,
|
||||
passthroughPropName = 'passthrough',
|
||||
size = null,
|
||||
propName = 'slotElements',
|
||||
}) =>
|
||||
hoistStatics(WrappedComponent => {
|
||||
return class withSlotElements extends React.Component {
|
||||
static contextTypes = {
|
||||
plugins: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
reduxState: PropTypes.object,
|
||||
};
|
||||
|
||||
getSlots(props = this.props) {
|
||||
const tmp = resolvePrimitiveOrFunction(slot, props);
|
||||
if (Array.isArray(tmp)) {
|
||||
return tmp;
|
||||
}
|
||||
return [tmp];
|
||||
}
|
||||
|
||||
getDefaultComponents(props = this.props, fill = 1) {
|
||||
const tmp = resolvePrimitiveOrFunction(defaultComponent, props);
|
||||
if (Array.isArray(tmp)) {
|
||||
return tmp;
|
||||
}
|
||||
return new Array(fill).fill(tmp);
|
||||
}
|
||||
|
||||
getSizes(props = this.props, fill = 1) {
|
||||
const tmp = resolvePrimitiveOrFunction(size, props);
|
||||
if (Array.isArray(tmp)) {
|
||||
return tmp;
|
||||
}
|
||||
return new Array(fill).fill(tmp);
|
||||
}
|
||||
|
||||
getPassthrough(props = this.props) {
|
||||
return passthroughPropName ? props[passthroughPropName] : null;
|
||||
}
|
||||
|
||||
shouldComponentUpdate(next) {
|
||||
// Prevent Slot from rerendering when only reduxState has changed and
|
||||
// it does not result in a change of slot children.
|
||||
const changes = getShallowChanges(this.props, next);
|
||||
|
||||
// Handle special `passthrough` props.
|
||||
if (passthroughPropName) {
|
||||
const passthroughIndex = changes.indexOf(passthroughPropName);
|
||||
if (passthroughIndex !== -1) {
|
||||
if (!this.props[passthroughPropName] || next[passthroughPropName]) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
getShallowChanges(
|
||||
this.props[passthroughPropName],
|
||||
next[passthroughPropName]
|
||||
).lenght === 0
|
||||
) {
|
||||
changes.splice(passthroughIndex, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changes.length === 1 && changes[0] === 'reduxState') {
|
||||
// If config changed, we'll have to rerender everything.
|
||||
// Should only happen during development as this is
|
||||
// usually static.
|
||||
if (this.props.reduxState.config !== next.reduxState.config) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const prevChildrenKeys = this.getSlotElements(this.props).map(
|
||||
child => child.key
|
||||
);
|
||||
const nextChildrenKeys = this.getSlotElements(next).map(
|
||||
child => child.key
|
||||
);
|
||||
return !isEqual(prevChildrenKeys, nextChildrenKeys);
|
||||
}
|
||||
|
||||
// Prevent Slot from rerendering when no props has shallowly changed.
|
||||
return changes.length !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns slot elements for configured slots. If only one slot is given
|
||||
* slot elements are returned directly. If more than one slot is specified
|
||||
* returns an array of slot elements.
|
||||
*/
|
||||
getSlotElements(props = this.props) {
|
||||
const { plugins } = this.context;
|
||||
const slots = this.getSlots(props);
|
||||
const sizes = this.getSizes(props, slots.length);
|
||||
const defaultComponents = this.getDefaultComponents(
|
||||
props,
|
||||
slots.length
|
||||
);
|
||||
const slotPassthrough = this.getPassthrough(props);
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if (slotPassthrough.data && slotPassthrough.data.refetch) {
|
||||
/* eslint-disable no-console */
|
||||
console.warn(
|
||||
'Slots no longer need `data` property.',
|
||||
'Plugins can use the new HOCs `withRefetch`,',
|
||||
'`withFetchMore`, `withSubscribeToMore` and `withVariables` instead.',
|
||||
'Affected slots: ',
|
||||
slots
|
||||
);
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
}
|
||||
|
||||
const elements = [];
|
||||
slots.forEach((s, i) => {
|
||||
const DefaultComponent = defaultComponents[i];
|
||||
const size = sizes[i];
|
||||
const slotElements = plugins.getSlotElements(
|
||||
s,
|
||||
props.reduxState,
|
||||
slotPassthrough,
|
||||
{ size }
|
||||
);
|
||||
|
||||
if (slotElements.length === 0 && DefaultComponent) {
|
||||
const p = plugins.getSlotComponentProps(
|
||||
DefaultComponent,
|
||||
props.reduxState,
|
||||
slotPassthrough
|
||||
);
|
||||
slotElements.push(<DefaultComponent key="default" {...p} />);
|
||||
}
|
||||
|
||||
elements.push(slotElements);
|
||||
});
|
||||
|
||||
if (elements.length === 1) {
|
||||
return elements[0];
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { reduxState: _a, ...rest } = this.props;
|
||||
const slotElements = this.getSlotElements();
|
||||
const props = {
|
||||
[propName]: slotElements,
|
||||
...rest,
|
||||
};
|
||||
return <WrappedComponent {...props} />;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
reduxState: state,
|
||||
});
|
||||
|
||||
/**
|
||||
* Exports a HOC that provides a property `slotElements`.
|
||||
* @param {Object} [options] configuration
|
||||
* @param {string|array} [options.slot] Slot name or Array of Slot Names
|
||||
* @param {element|array} [options.defaultComponent] Default Components or Array of such
|
||||
* @param {number|array} [options.size] Slot size or an Array of slot size
|
||||
* @param {string} [options.passthroughPropName] The property to find the passthrough prop
|
||||
* @param {string} [options.propName] New property name, defaults to `slotElements`
|
||||
*
|
||||
* @return {func} Returns a HOC that per default provides the property `slotElements` with an
|
||||
* Array of Slot Elements or in case of multiple slots, an Array of Slot Element
|
||||
* Arrays.
|
||||
*
|
||||
* Example:
|
||||
* withSlotElements({
|
||||
* slot: 'awesomeSlot',
|
||||
* size: 1,
|
||||
* })(MyComponent);
|
||||
*/
|
||||
export default settings => {
|
||||
return compose(connect(mapStateToProps, null), createHOC(settings));
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import hoistStatics from 'recompose/hoistStatics';
|
||||
import { Subscriber } from 'react-broadcast';
|
||||
import get from 'lodash/get';
|
||||
|
||||
/**
|
||||
* WithSubscribeToMore provides a property `subscribeToMore` to the wrapped component.
|
||||
* Calling `subscribeToMore` is the same as calling `data.subscribeToMore` from the
|
||||
* Apollo React API.
|
||||
*/
|
||||
export default hoistStatics(WrappedComponent => {
|
||||
class WithSubscribeToMore extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<Subscriber channel="queryData">
|
||||
{data => (
|
||||
<WrappedComponent
|
||||
{...this.props}
|
||||
subscribeToMore={get(data, 'subscribeToMoreThrottled')}
|
||||
/>
|
||||
)}
|
||||
</Subscriber>
|
||||
);
|
||||
}
|
||||
}
|
||||
return WithSubscribeToMore;
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import hoistStatics from 'recompose/hoistStatics';
|
||||
import { Subscriber } from 'react-broadcast';
|
||||
import get from 'lodash/get';
|
||||
|
||||
/**
|
||||
* WithVariables provides a property `variables` to the wrapped component.
|
||||
* These are the variables of the parent Query.
|
||||
*/
|
||||
export default hoistStatics(WrappedComponent => {
|
||||
class WithVariables extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<Subscriber channel="queryData">
|
||||
{data => (
|
||||
<WrappedComponent
|
||||
{...this.props}
|
||||
variables={get(data, 'variables')}
|
||||
/>
|
||||
)}
|
||||
</Subscriber>
|
||||
);
|
||||
}
|
||||
}
|
||||
return WithVariables;
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user