diff --git a/.nsprc b/.nsprc index 071431eff..583560bdd 100644 --- a/.nsprc +++ b/.nsprc @@ -1,5 +1,6 @@ { "exceptions": [ - "https://nodesecurity.io/advisories/531" + "https://nodesecurity.io/advisories/531", + "https://nodesecurity.io/advisories/532" ] -} \ No newline at end of file +} diff --git a/bin/cli-jobs b/bin/cli-jobs index c5aefc518..6048a69db 100755 --- a/bin/cli-jobs +++ b/bin/cli-jobs @@ -12,7 +12,7 @@ const mongoose = require('../services/mongoose'); const kue = require('../services/kue'); util.onshutdown([ - () => mongoose.disconnect() + () => mongoose.disconnect(), ]); /** @@ -20,17 +20,17 @@ util.onshutdown([ */ function processJobs() { - // Start the scraper processor. - scraper.process(); - - // Start the mail processor. - mailer.process(); - // The scraper only needs to shutdown when the scraper has actually been // started. util.onshutdown([ () => kue.Task.shutdown() ]); + + // Start the scraper processor. + scraper.process(); + + // Start the mail processor. + mailer.process(); } /** @@ -48,22 +48,13 @@ function removeJob(job) { })); } -/** - * Removes the jobs passed in and returns a promise. - * @param {Array} jobs array of jobs - * @return {Promise} - */ -function removeJobs(jobs) { - return Promise.all(jobs.map(removeJob)); -} - /** * Get the top n jobs with a specific state. * @param {String} [state='complete'] state to list jobs by * @param {Number} limit limit of jobs to load * @return {Promise} */ -function rangeJobsByState(state = 'complete', limit) { +function rangeJobsByState(state, limit) { return new Promise((resolve, reject) => { kue.Job.rangeByState(state, 0, limit, 'asc', (err, jobs) => { if (err) { @@ -75,22 +66,52 @@ function rangeJobsByState(state = 'complete', limit) { }); } +async function getJobBatch(n, includeStuck) { + let jobs = []; + + jobs = await rangeJobsByState('complete', n); + + if (includeStuck) { + jobs = jobs.concat(await rangeJobsByState('failed', n)); + } + + return jobs; +} + /** * Cleans up the jobs that are in the queue. */ async function cleanupJobs(options) { + + // The scraper only needs to shutdown when the scraper has actually been + // started. + util.onshutdown([ + () => kue.Task.shutdown() + ]); + const n = 100; try { - const joblists = await Promise.all([ - rangeJobsByState('complete', n), - options.stuck ? rangeJobsByState('failed', n) : false - ]); - await joblists.filter((jobs) => jobs).map(removeJobs); + // Connect to redis by establishing a queue. + kue.Task.connect(); + + let jobCount = 0; + let jobs = await getJobBatch(n, options.stuck); + + while (jobs.length > 0) { + + // Remove all the jobs. + await Promise.all(jobs.map((job) => removeJob(job))); + + jobCount += jobs.length; + + // Get the next batch of jobs. + jobs = await getJobBatch(n, options.stuck); + } util.shutdown(); - console.log('Removed old jobs'); + console.log(`Removed ${jobCount} jobs`); } catch (err) { console.error(err); util.shutdown(1); diff --git a/bin/cli-users b/bin/cli-users index 89f53a67d..89bf667a7 100755 --- a/bin/cli-users +++ b/bin/cli-users @@ -8,10 +8,13 @@ const program = require('./commander'); const inquirer = require('inquirer'); const UsersService = require('../services/users'); const UserModel = require('../models/user'); +const CommentModel = require('../models/comment'); +const ActionModel = require('../models/action'); const USER_ROLES = require('../models/enum/user_roles'); const mongoose = require('../services/mongoose'); const util = require('./util'); const Table = require('cli-table'); +const databaseVerifications = require('./verifications/database'); const validateRequired = (msg = 'Field is required', len = 1) => (input) => { if (input && input.length >= len) { @@ -122,26 +125,48 @@ async function createUser(options) { } catch (err) { console.error(err); - util.shutdown(); + util.shutdown(1); } } /** * Deletes a user. */ -function deleteUser(userID) { - UserModel - .findOneAndRemove({ - id: userID - }) - .then(() => { - console.log('Deleted user'); - util.shutdown(); - }) - .catch((err) => { - console.error(err); - util.shutdown(); - }); +async function deleteUser(userID) { + + try { + + // Find the user we're removing. + const user = await UserModel.findOne({id: userID}); + if (!user) { + throw new Error(`user with id ${userID} not found`); + } + + // Remove all the user's actions. + await ActionModel + .where({user_id: user.id}) + .setOptions({multi: true}) + .remove(); + + // Remove all the user's comments. + await CommentModel + .where({author_id: user.id}) + .setOptions({multi: true}) + .remove(); + + // Update the counts that might have changed. + for (const verification of databaseVerifications) { + await verification({fix: true, limit: Infinity, batch: 1000}); + } + + // Remove the user. + await user.remove(); + + util.shutdown(); + } catch (err) { + console.error(err); + util.shutdown(1); + } } /** diff --git a/client/coral-admin/src/components/ui/Header.js b/client/coral-admin/src/components/ui/Header.js index bd7fbcf0c..e199d5ded 100644 --- a/client/coral-admin/src/components/ui/Header.js +++ b/client/coral-admin/src/components/ui/Header.js @@ -87,7 +87,7 @@ const CoralHeader = ({ - + Report a bug or give feedback diff --git a/client/coral-admin/src/graphql/index.js b/client/coral-admin/src/graphql/index.js index d7656d9a5..42e96f382 100644 --- a/client/coral-admin/src/graphql/index.js +++ b/client/coral-admin/src/graphql/index.js @@ -1,15 +1,5 @@ import update from 'immutability-helper'; -import mapValues from 'lodash/mapValues'; - -// Map nested object leaves. Array objects are considered leaves. -function mapLeaves(o, mapper) { - return mapValues(o, (val) => { - if (typeof val === 'object' && !Array.isArray(val)) { - return mapLeaves(val, mapper); - } - return mapper(val); - }); -} +import {mapLeaves} from 'coral-framework/utils'; export default { mutations: { diff --git a/client/coral-admin/src/routes/Configure/components/ModerationSettings.js b/client/coral-admin/src/routes/Configure/components/ModerationSettings.js index f5db719bb..94aa7d923 100644 --- a/client/coral-admin/src/routes/Configure/components/ModerationSettings.js +++ b/client/coral-admin/src/routes/Configure/components/ModerationSettings.js @@ -39,7 +39,7 @@ class ModerationSettings extends React.Component { }; render() { - const {settings, data, root} = this.props; + const {settings, data, root, updatePending, errors} = this.props; return ( ); @@ -82,6 +84,7 @@ 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, diff --git a/client/coral-admin/src/routes/Configure/components/StreamSettings.css b/client/coral-admin/src/routes/Configure/components/StreamSettings.css index 5ddd46bd2..628192de6 100644 --- a/client/coral-admin/src/routes/Configure/components/StreamSettings.css +++ b/client/coral-admin/src/routes/Configure/components/StreamSettings.css @@ -53,3 +53,6 @@ +.autoCloseWrapper { + display: flex; +} diff --git a/client/coral-admin/src/routes/Configure/components/StreamSettings.js b/client/coral-admin/src/routes/Configure/components/StreamSettings.js index 9c05711cb..82441632d 100644 --- a/client/coral-admin/src/routes/Configure/components/StreamSettings.js +++ b/client/coral-admin/src/routes/Configure/components/StreamSettings.js @@ -100,7 +100,7 @@ class StreamSettings extends React.Component { }; render() { - const {settings, data, root, errors} = this.props; + const {settings, data, root, errors, updatePending} = this.props; return ( - -
- - - - - +
+ +
+ + + + + +
{/* the above card should be the last one if at all possible because of z-index issues with the selects */} @@ -203,6 +205,8 @@ class StreamSettings extends React.Component { fill="adminStreamSettings" data={data} queryData={{root, settings}} + updatePending={updatePending} + errors={errors} /> ); diff --git a/client/coral-admin/src/routes/Configure/components/TechSettings.js b/client/coral-admin/src/routes/Configure/components/TechSettings.js index 7f16b6064..1b783c9ec 100644 --- a/client/coral-admin/src/routes/Configure/components/TechSettings.js +++ b/client/coral-admin/src/routes/Configure/components/TechSettings.js @@ -31,7 +31,7 @@ class TechSettings extends React.Component { }; render() { - const {settings, data, root} = this.props; + const {settings, data, root, errors, updatePending} = this.props; return ( ); @@ -59,6 +61,7 @@ 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, diff --git a/client/coral-admin/src/routes/Configure/containers/Configure.js b/client/coral-admin/src/routes/Configure/containers/Configure.js index 8f80a3e24..dfa8df876 100644 --- a/client/coral-admin/src/routes/Configure/containers/Configure.js +++ b/client/coral-admin/src/routes/Configure/containers/Configure.js @@ -2,38 +2,20 @@ import React, {Component} from 'react'; import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; import {compose, gql} from 'react-apollo'; -import withQuery from 'coral-framework/hocs/withQuery'; +import {withQuery, withMergedSettings} from 'coral-framework/hocs'; import {Spinner} from 'coral-ui'; import {notify} from 'coral-framework/actions/notification'; import PropTypes from 'prop-types'; -import assignWith from 'lodash/assignWith'; import {withUpdateSettings} from 'coral-framework/graphql/mutations'; import {getErrorMessages, getDefinitionName} from 'coral-framework/utils'; import StreamSettings from './StreamSettings'; import TechSettings from './TechSettings'; import ModerationSettings from './ModerationSettings'; import {clearPending, setActiveSection} from '../../../actions/configure'; - import Configure from '../components/Configure'; -// Like lodash merge but does not recurse into arrays. -const mergeExcludingArrays = (objValue, srcValue) => { - if (typeof srcValue === 'object' && !Array.isArray(srcValue)) { - return assignWith({}, objValue, srcValue, mergeExcludingArrays); - } - return srcValue; -}; - class ConfigureContainer extends Component { - // Merge current settings with pending settings. - getMergedSettings = (props = this.props) => { - return assignWith({}, props.root.settings, props.pending, mergeExcludingArrays); - } - - // Cached merged settings. - mergedSettings = this.getMergedSettings(); - savePending = async () => { try { await this.props.updateSettings(this.props.pending); @@ -44,14 +26,6 @@ class ConfigureContainer extends Component { } }; - componentWillReceiveProps(nextProps) { - - // Recalculate merged settings when necessary. - if (this.props.root.settings !== nextProps.root.settings || this.props.pending !== nextProps.pending) { - this.mergedSettings = this.getMergedSettings(nextProps); - } - } - render () { if(this.props.data.loading) { return ; @@ -62,7 +36,7 @@ class ConfigureContainer extends Component { auth={this.props.auth} data={this.props.data} root={this.props.root} - settings={this.mergedSettings} + settings={this.props.mergedSettings} canSave={this.props.canSave} savePending={this.savePending} setActiveSection={this.props.setActiveSection} @@ -112,6 +86,7 @@ export default compose( withUpdateSettings, withConfigureQuery, connect(mapStateToProps, mapDispatchToProps), + withMergedSettings('root.settings', 'pending', 'mergedSettings'), )(ConfigureContainer); ConfigureContainer.propTypes = { @@ -124,5 +99,6 @@ ConfigureContainer.propTypes = { root: PropTypes.object.isRequired, canSave: PropTypes.bool.isRequired, pending: PropTypes.object.isRequired, + mergedSettings: PropTypes.object.isRequired, activeSection: PropTypes.string.isRequired, }; diff --git a/client/coral-admin/src/routes/Configure/containers/ModerationSettings.js b/client/coral-admin/src/routes/Configure/containers/ModerationSettings.js index 0583378a9..61538f4c0 100644 --- a/client/coral-admin/src/routes/Configure/containers/ModerationSettings.js +++ b/client/coral-admin/src/routes/Configure/containers/ModerationSettings.js @@ -10,6 +10,10 @@ const slots = [ 'adminModerationSettings', ]; +const mapStateToProps = (state) => ({ + errors: state.configure.errors, +}); + const mapDispatchToProps = (dispatch) => bindActionCreators({ updatePending, @@ -36,5 +40,5 @@ export default compose( } ` }), - connect(null, mapDispatchToProps), + connect(mapStateToProps, mapDispatchToProps), )(ModerationSettings); diff --git a/client/coral-admin/src/routes/Configure/containers/TechSettings.js b/client/coral-admin/src/routes/Configure/containers/TechSettings.js index df33a3f9a..1f5ac20b8 100644 --- a/client/coral-admin/src/routes/Configure/containers/TechSettings.js +++ b/client/coral-admin/src/routes/Configure/containers/TechSettings.js @@ -10,6 +10,10 @@ const slots = [ 'adminTechSettings', ]; +const mapStateToProps = (state) => ({ + errors: state.configure.errors, +}); + const mapDispatchToProps = (dispatch) => bindActionCreators({ updatePending, @@ -33,5 +37,5 @@ export default compose( } ` }), - connect(null, mapDispatchToProps), + connect(mapStateToProps, mapDispatchToProps), )(TechSettings); diff --git a/client/coral-admin/src/routes/Stories/components/Stories.js b/client/coral-admin/src/routes/Stories/components/Stories.js index 866defde4..933237ab9 100644 --- a/client/coral-admin/src/routes/Stories/components/Stories.js +++ b/client/coral-admin/src/routes/Stories/components/Stories.js @@ -48,7 +48,7 @@ class Stories extends Component {
{t('streams.filter_streams')}
{t('streams.stream_status')}
{t('streams.sort_by')}
( - status === 'open' ? ( -
-

- {t('configure.open_stream_configuration')} -

- -
- ) : ( -
-

- {t('configure.close_stream_configuration')} -

- -
- ) -); - -CloseCommentsInfo.propTypes = { - status: PropTypes.string, - onClick: PropTypes.func, -}; - -export default CloseCommentsInfo; \ No newline at end of file diff --git a/client/coral-configure/components/ConfigureCommentStream.js b/client/coral-configure/components/ConfigureCommentStream.js deleted file mode 100644 index 20250ee86..000000000 --- a/client/coral-configure/components/ConfigureCommentStream.js +++ /dev/null @@ -1,71 +0,0 @@ -import React from 'react'; -import {Button, Checkbox} from 'coral-ui'; -import QuestionBoxBuilder from './QuestionBoxBuilder'; -import cn from 'classnames'; - -import styles from './ConfigureCommentStream.css'; - -import t from 'coral-framework/services/i18n'; - -export default ({handleChange, handleApply, changed, ...props}) => ( -
-
-
-

{t('configure.title')}

- -

{t('configure.description')}

-
-
    -
  • - -
  • -
  • - -
  • -
  • - - { - props.questionBoxEnable && - } -
  • -
-
-
-); diff --git a/client/coral-configure/components/Markdown.js b/client/coral-configure/components/Markdown.js deleted file mode 100644 index d9d1bc4a7..000000000 --- a/client/coral-configure/components/Markdown.js +++ /dev/null @@ -1,24 +0,0 @@ -import React, {PureComponent} from 'react'; -import PropTypes from 'prop-types'; -import marked from 'marked'; - -const renderer = new marked.Renderer(); - -// Set link target to `_parent` to work properly in an embed. -renderer.link = (href, title, text) => - `${text}`; - -marked.setOptions({renderer}); - -export default class Markdown extends PureComponent { - render() { - const {content, ...rest} = this.props; - const __html = marked(content); - return
; - } -} - -Markdown.propTypes = { - content: PropTypes.string, -}; - diff --git a/client/coral-configure/components/QuestionBoxBuilder.css b/client/coral-configure/components/QuestionBoxBuilder.css deleted file mode 100644 index 758f350b9..000000000 --- a/client/coral-configure/components/QuestionBoxBuilder.css +++ /dev/null @@ -1,45 +0,0 @@ -.qbBuilder { - margin-left: 50px; -} - -.qbItemIconList { - padding: 0; - margin: 10px 0; -} - -.qbItemIcon { - background: #F0F0F0; - width: 45px; - height: 45px; - font-size: 24px; - text-align: center; - line-height: 45px; - color: #252525; - border-radius: 3px; - display: inline-block; - overflow: hidden; - margin-right: 10px; - position: relative; - border: solid 2px #F0F0F0; - transition: border 0.3s cubic-bezier(.4,0,.2,1); -} - -.qbItemIcon:hover { - cursor: pointer; -} - -.qbItemIconActive { - border: solid 2px #00796B; -} - -.defaultIcon { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; -} - -.qb { - margin: 10px 0; -} \ No newline at end of file diff --git a/client/coral-configure/components/QuestionBoxBuilder.js b/client/coral-configure/components/QuestionBoxBuilder.js deleted file mode 100644 index 28dfc30b3..000000000 --- a/client/coral-configure/components/QuestionBoxBuilder.js +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react'; -import QuestionBox from 'talk-plugin-questionbox/QuestionBox'; -import {Icon, Spinner} from 'coral-ui'; -import DefaultIcon from './DefaultIcon'; -import cn from 'classnames'; -import styles from './QuestionBoxBuilder.css'; - -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 {handleChange, questionBoxIcon, questionBoxContent} = this.props; - const {loading, MarkdownEditor} = this.state; - - if (loading) { - return ; - } - - return ( -
-

Include an Icon

- -
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
- - - - handleChange({}, {questionBoxContent: value})} - /> - -
- ); - } -} - -export default QuestionBoxBuilder; diff --git a/client/coral-configure/containers/ConfigureStreamContainer.js b/client/coral-configure/containers/ConfigureStreamContainer.js deleted file mode 100644 index 0ab71c1d7..000000000 --- a/client/coral-configure/containers/ConfigureStreamContainer.js +++ /dev/null @@ -1,145 +0,0 @@ -import React, {Component} from 'react'; -import {connect} from 'react-redux'; -import {compose} from 'react-apollo'; -import PropTypes from 'prop-types'; -import {updateOpenStatus, updateConfiguration} from 'coral-embed-stream/src/actions/asset'; -import CloseCommentsInfo from '../components/CloseCommentsInfo'; -import ConfigureCommentStream from '../components/ConfigureCommentStream'; -import t, {timeago} from 'coral-framework/services/i18n'; - -class ConfigureStreamContainer extends Component { - constructor (props) { - super(props); - - this.state = { - changed: false, - dirtySettings: {...props.asset.settings}, - closedAt: !props.asset.isClosed ? 'open' : 'closed' - }; - - this.toggleStatus = this.toggleStatus.bind(this); - this.handleChange = this.handleChange.bind(this); - this.handleApply = this.handleApply.bind(this); - } - - handleApply (e) { - e.preventDefault(); - const {elements} = e.target; - const {questionBoxIcon, questionBoxContent} = this.state.dirtySettings; - const premod = elements.premod.checked; - const questionBoxEnable = elements.qboxenable.checked; - const premodLinksEnable = elements.plinksenable.checked; - const {changed} = this.state; - - const newConfig = { - moderation: premod ? 'PRE' : 'POST', - questionBoxEnable, - questionBoxContent, - questionBoxIcon, - premodLinksEnable - }; - - if (changed) { - this.props.updateConfiguration(newConfig); - setTimeout(() => { - this.setState({ - changed: false - }); - }, 300); - } - } - - handleChange (e, newChanges) { - let changes = {}; - - if (changes) { - changes = {...newChanges}; - } - - if (e.target && e.target.id === 'qboxenable') { - changes.questionBoxEnable = e.target.checked; - } - if (e.currentTarget && e.currentTarget.id === 'qboxicon') { - changes.questionBoxIcon = e.currentTarget.dataset.icon; - } - if (e.target && e.target.id === 'plinksenable') { - changes.premodLinksEnable = e.target.value; - } - - this.setState({ - changed: true, - dirtySettings: { - ...this.state.dirtySettings, - ...changes, - }, - }); - } - - toggleStatus () { - - // update the closedAt status for the asset - this.props.updateStatus( - this.state.closedAt === 'open' ? 'closed' : 'open' - ); - this.setState({ - closedAt: (this.state.closedAt === 'open' ? 'closed' : 'open') - }); - } - - getClosedIn () { - const {closedTimeout} = this.props.asset.settings; - const {created_at} = this.props.asset; - - return timeago(new Date(created_at).getTime() + (1000 * closedTimeout)); - } - - render () { - const {dirtySettings} = this.state; - const premod = dirtySettings.moderation === 'PRE'; - const {closedAt} = this.state; - const closedTimeout = dirtySettings.closedTimeout; - - return ( -
- -
-

{closedAt === 'open' ? t('configure.close') : t('configure.open')} {t('configure.comment_stream')}

- {(closedAt === 'open' && closedTimeout) ?

{t('configure.comment_stream_will_close')} {this.getClosedIn()}.

: ''} - -
- ); - } -} - -const mapStateToProps = (state) => ({ - asset: state.asset -}); - -const mapDispatchToProps = (dispatch) => ({ - updateStatus: (status) => dispatch(updateOpenStatus(status)), - updateConfiguration: (newConfig) => dispatch(updateConfiguration(newConfig)), -}); - -ConfigureStreamContainer.propTypes = { - updateStatus: PropTypes.func, - closedTimeout: PropTypes.string, - created_at: PropTypes.string, - updateConfiguration: PropTypes.func, - asset: PropTypes.object, -}; - -export default compose( - connect(mapStateToProps, mapDispatchToProps) -)(ConfigureStreamContainer); diff --git a/client/coral-embed-stream/src/actions/configure.js b/client/coral-embed-stream/src/actions/configure.js new file mode 100644 index 000000000..bfc738fe0 --- /dev/null +++ b/client/coral-embed-stream/src/actions/configure.js @@ -0,0 +1,9 @@ +import * as actions from '../constants/configure'; + +export const updatePending = ({updater, errorUpdater}) => { + return {type: actions.UPDATE_PENDING, updater, errorUpdater}; +}; + +export const clearPending = () => { + return {type: actions.CLEAR_PENDING}; +}; diff --git a/client/coral-configure/components/DefaultIcon.css b/client/coral-embed-stream/src/components/DefaultQuestionBoxIcon.css similarity index 59% rename from client/coral-configure/components/DefaultIcon.css rename to client/coral-embed-stream/src/components/DefaultQuestionBoxIcon.css index 6125d2f6a..414a48e7a 100644 --- a/client/coral-configure/components/DefaultIcon.css +++ b/client/coral-embed-stream/src/components/DefaultQuestionBoxIcon.css @@ -16,12 +16,5 @@ color: #262626; } -.qbIconContainer { - position: relative; - border: 0; - color: white; - display: inline-block; - padding: 5px 20px; - vertical-align: middle; - width: 10px; -} \ No newline at end of file +.root { +} diff --git a/client/coral-configure/components/DefaultIcon.js b/client/coral-embed-stream/src/components/DefaultQuestionBoxIcon.js similarity index 54% rename from client/coral-configure/components/DefaultIcon.js rename to client/coral-embed-stream/src/components/DefaultQuestionBoxIcon.js index 5f6f4876c..acb3f2e64 100644 --- a/client/coral-configure/components/DefaultIcon.js +++ b/client/coral-embed-stream/src/components/DefaultQuestionBoxIcon.js @@ -1,13 +1,13 @@ import React from 'react'; import cn from 'classnames'; -import styles from './DefaultIcon.css'; +import styles from './DefaultQuestionBoxIcon.css'; import {Icon} from 'coral-ui'; -const DefaultIcon = ({className}) => ( -
+const DefaultQuestionBoxIcon = ({className}) => ( +
); -export default DefaultIcon; +export default DefaultQuestionBoxIcon; diff --git a/client/coral-embed-stream/src/components/Embed.js b/client/coral-embed-stream/src/components/Embed.js index 5ce7021fb..7445bf300 100644 --- a/client/coral-embed-stream/src/components/Embed.js +++ b/client/coral-embed-stream/src/components/Embed.js @@ -1,16 +1,17 @@ import React from 'react'; import PropTypes from 'prop-types'; -import Stream from '../containers/Stream'; +import Stream from '../tabs/stream/containers/Stream'; +import Configure from '../tabs/configure/containers/Configure'; import Slot from 'coral-framework/components/Slot'; import {can} from 'coral-framework/services/perms'; import t from 'coral-framework/services/i18n'; +import AutomaticAssetClosure from '../containers/AutomaticAssetClosure'; -import {TabBar, Tab, TabContent, TabPane} from 'coral-ui'; +import ExtendableTabPanel from '../containers/ExtendableTabPanel'; +import {Tab, TabPane} from 'coral-ui'; import ProfileContainer from 'coral-settings/containers/ProfileContainer'; import Popup from 'coral-framework/components/Popup'; import IfSlotIsNotEmpty from 'coral-framework/components/IfSlotIsNotEmpty'; -import ConfigureStreamContainer - from 'coral-configure/containers/ConfigureStreamContainer'; import cn from 'classnames'; export default class Embed extends React.Component { @@ -21,20 +22,37 @@ export default class Embed extends React.Component { case 'profile': this.props.data.refetch(); break; - case 'config': - this.props.data.refetch(); - break; } this.props.setActiveTab(tab); }; - render() { - const {activeTab, commentId, root, data, auth: {showSignInDialog, signInDialogFocus}, blurSignInDialog, focusSignInDialog, hideSignInDialog, router: {location: {query: {parentUrl}}}} = this.props; + getTabs() { const {user} = this.props.auth; + const tabs = [ + + {t('embed_comments_tab')} + , + + {t('framework.my_profile')} + , + ]; + if (can(user, 'UPDATE_CONFIG')) { + tabs.push( + + {t('framework.configure_stream')} + + ); + } + return tabs; + } + + render() { + const {activeTab, commentId, root, root: {asset}, data, auth: {showSignInDialog, signInDialogFocus}, blurSignInDialog, focusSignInDialog, hideSignInDialog, router: {location: {query: {parentUrl}}}} = this.props; const hasHighlightedComment = !!commentId; return (
+ - - - {t('embed_comments_tab')} - - - {t('framework.my_profile')} - - {can(user, 'UPDATE_CONFIG') && - - {t('framework.configure_stream')} - - } - + - - - - - - - - - - - + setActiveTab={this.changeTab} + fallbackTab='stream' + tabSlot='embedStreamTabs' + tabSlotPrepend='embedStreamTabsPrepend' + tabPaneSlot='embedStreamTabPanes' + slotProps={{data}} + queryData={{root}} + tabs={this.getTabs()} + tabPanes={[ + + + , + + + , + + + , + ]} + />
); } diff --git a/client/coral-embed-stream/src/components/ExtendableTab.js b/client/coral-embed-stream/src/components/ExtendableTab.js new file mode 100644 index 000000000..75c6739a4 --- /dev/null +++ b/client/coral-embed-stream/src/components/ExtendableTab.js @@ -0,0 +1,35 @@ +import React from 'react'; +import {Tab} from 'coral-ui'; +import PropTypes from 'prop-types'; + +/** + * ExtendableTab adds a hover property to its children, because + * Tab is rendered as a button and under Firefox its children do + * not support mouse events. + */ +class ExtendableTab extends React.Component { + state = { + hover: false, + } + + handleMouseEnter = () => this.setState({hover: true}); + handleMouseLeave = () => this.setState({hover: false}); + + render() { + return ( + + {React.cloneElement(this.props.children, {hover: this.state.hover})} + + ); + } +} + +ExtendableTab.propTypes = { + children: PropTypes.node, +}; + +export default ExtendableTab; diff --git a/client/coral-embed-stream/src/components/StreamTabPanel.css b/client/coral-embed-stream/src/components/ExtendableTabPanel.css similarity index 100% rename from client/coral-embed-stream/src/components/StreamTabPanel.css rename to client/coral-embed-stream/src/components/ExtendableTabPanel.css diff --git a/client/coral-embed-stream/src/components/StreamTabPanel.js b/client/coral-embed-stream/src/components/ExtendableTabPanel.js similarity index 85% rename from client/coral-embed-stream/src/components/StreamTabPanel.js rename to client/coral-embed-stream/src/components/ExtendableTabPanel.js index 35c10d5d9..ec582eb84 100644 --- a/client/coral-embed-stream/src/components/StreamTabPanel.js +++ b/client/coral-embed-stream/src/components/ExtendableTabPanel.js @@ -1,9 +1,9 @@ import React from 'react'; import {Spinner, TabBar, TabContent} from 'coral-ui'; import PropTypes from 'prop-types'; -import styles from './StreamTabPanel.css'; +import styles from './ExtendableTabPanel.css'; -class StreamTabPanel extends React.Component { +class ExtendableTabPanel extends React.Component { render() { const {activeTab, setActiveTab, tabs, tabPanes, sub, loading, ...rest} = this.props; @@ -23,7 +23,7 @@ class StreamTabPanel extends React.Component { } } -StreamTabPanel.propTypes = { +ExtendableTabPanel.propTypes = { activeTab: PropTypes.string.isRequired, setActiveTab: PropTypes.func.isRequired, loading: PropTypes.bool, @@ -39,4 +39,4 @@ StreamTabPanel.propTypes = { sub: PropTypes.bool, }; -export default StreamTabPanel; +export default ExtendableTabPanel; diff --git a/client/talk-plugin-questionbox/QuestionBox.css b/client/coral-embed-stream/src/components/QuestionBox.css similarity index 94% rename from client/talk-plugin-questionbox/QuestionBox.css rename to client/coral-embed-stream/src/components/QuestionBox.css index cb9902ade..f8c8e0eb2 100644 --- a/client/talk-plugin-questionbox/QuestionBox.css +++ b/client/coral-embed-stream/src/components/QuestionBox.css @@ -13,6 +13,8 @@ min-height: 50px; display: flex; border-radius: 3px; + padding-right: 40px; + box-sizing: border-box; } .icon { @@ -63,8 +65,3 @@ justify-content: center; font-weight: 400; } - -.hidden { - visibility: hidden; - display: none; -} \ No newline at end of file diff --git a/client/talk-plugin-questionbox/QuestionBox.js b/client/coral-embed-stream/src/components/QuestionBox.js similarity index 59% rename from client/talk-plugin-questionbox/QuestionBox.js rename to client/coral-embed-stream/src/components/QuestionBox.js index f15549b7c..1f34afcd7 100644 --- a/client/talk-plugin-questionbox/QuestionBox.js +++ b/client/coral-embed-stream/src/components/QuestionBox.js @@ -3,16 +3,14 @@ import cn from 'classnames'; import styles from './QuestionBox.css'; import {Icon} from 'coral-ui'; import Markdown from 'coral-framework/components/Markdown'; +import DefaultQuestionBoxIcon from './DefaultQuestionBoxIcon'; -import Slot from 'coral-framework/components/Slot'; - -const QuestionBox = ({content, enable, icon = '', className = ''}) => ( -
+const QuestionBox = ({content, icon, className, children}) => ( +
{ icon === 'default' ? (
- - +
) : (
@@ -23,8 +21,7 @@ const QuestionBox = ({content, enable, icon = '', className = ''}) => (
- - + {children}
); diff --git a/client/coral-embed-stream/src/constants/configure.js b/client/coral-embed-stream/src/constants/configure.js new file mode 100644 index 000000000..b29cf6280 --- /dev/null +++ b/client/coral-embed-stream/src/constants/configure.js @@ -0,0 +1,4 @@ +const prefix = 'TALK_EMBED_STREAM_CONFIGURE'; + +export const UPDATE_PENDING = `${prefix}_UPDATE_PENDING`; +export const CLEAR_PENDING = `${prefix}_CLEAR_PENDING`; diff --git a/client/coral-embed-stream/src/containers/AutomaticAssetClosure.js b/client/coral-embed-stream/src/containers/AutomaticAssetClosure.js index 487e927a8..f6ade1de0 100644 --- a/client/coral-embed-stream/src/containers/AutomaticAssetClosure.js +++ b/client/coral-embed-stream/src/containers/AutomaticAssetClosure.js @@ -1,10 +1,10 @@ import React from 'react'; import PropTypes from 'prop-types'; -import {gql} from 'react-apollo'; +import {gql, compose} from 'react-apollo'; +import {withFragments} from 'coral-framework/hocs'; const FRAGMENT = gql` fragment CoralEmbedStream_AutomaticAssetClosure_Fragment on Asset { - id isClosed } `; @@ -26,15 +26,15 @@ class AutomaticAssetClosure extends React.Component { timer = null; componentWillMount() { - this.setupTimer(this.props.assetId, this.props.closedAt); + this.setupTimer(this.props.asset.id, this.props.asset.closedAt); } componentWillReceiveProps(next) { if ( - this.props.assetId !== next.assetId || - this.props.closedAt !== next.closedAt + this.props.asset.id !== next.asset.id || + this.props.asset.closedAt !== next.asset.closedAt ) { - this.setupTimer(next.assetId, next.closedAt); + this.setupTimer(next.asset.id, next.asset.closedAt); } } @@ -43,6 +43,7 @@ class AutomaticAssetClosure extends React.Component { fragment: FRAGMENT, id: getFragmentId(assetId), data: { + __typename: 'Asset', isClosed: true, }, }); @@ -74,9 +75,21 @@ class AutomaticAssetClosure extends React.Component { } } -AutomaticAssetClosure.PropTypes = { - assetId: PropTypes.string, - closedAt: PropTypes.string, +AutomaticAssetClosure.propTypes = { + asset: PropTypes.object.isRequired, }; -export default AutomaticAssetClosure; +const withAutomaticAssetClosureFragments = withFragments({ + asset: gql` + fragment CoralEmbedStream_AutomaticAssetClosure_asset on Asset { + id + closedAt + } + `, +}); + +const enhance = compose( + withAutomaticAssetClosureFragments, +); + +export default enhance(AutomaticAssetClosure); diff --git a/client/coral-embed-stream/src/containers/Embed.js b/client/coral-embed-stream/src/containers/Embed.js index 132e7f5e7..7a8df6e7c 100644 --- a/client/coral-embed-stream/src/containers/Embed.js +++ b/client/coral-embed-stream/src/containers/Embed.js @@ -13,7 +13,9 @@ import * as assetActions from '../actions/asset'; import {getDefinitionName, getSlotFragmentSpreads} from 'coral-framework/utils'; import {withQuery} from 'coral-framework/hocs'; import Embed from '../components/Embed'; -import Stream from './Stream'; +import Stream from '../tabs/stream/containers/Stream'; +import AutomaticAssetClosure from './AutomaticAssetClosure'; +import Configure from '../tabs/configure/containers/Configure'; import {notify} from 'coral-framework/actions/notification'; import t from 'coral-framework/services/i18n'; import PropTypes from 'prop-types'; @@ -151,6 +153,9 @@ const USERNAME_REJECTED_SUBSCRIPTION = gql` const slots = [ 'embed', + 'embedStreamTabs', + 'embedStreamTabsPrepend', + 'embedStreamTabPanes', ]; const EMBED_QUERY = gql` @@ -167,10 +172,20 @@ const EMBED_QUERY = gql` id status } + asset(id: $assetId, url: $assetUrl) { + ...${getDefinitionName(Configure.fragments.asset)} + ...${getDefinitionName(Stream.fragments.asset)} + ...${getDefinitionName(AutomaticAssetClosure.fragments.asset)} + } ${getSlotFragmentSpreads(slots, 'root')} ...${getDefinitionName(Stream.fragments.root)} + ...${getDefinitionName(Configure.fragments.root)} } ${Stream.fragments.root} + ${Stream.fragments.asset} + ${Configure.fragments.root} + ${Configure.fragments.asset} + ${AutomaticAssetClosure.fragments.asset} `; export const withEmbedQuery = withQuery(EMBED_QUERY, { diff --git a/client/coral-embed-stream/src/containers/StreamTabPanel.js b/client/coral-embed-stream/src/containers/ExtendableTabPanel.js similarity index 61% rename from client/coral-embed-stream/src/containers/StreamTabPanel.js rename to client/coral-embed-stream/src/containers/ExtendableTabPanel.js index d3dadb5d2..3fca664e7 100644 --- a/client/coral-embed-stream/src/containers/StreamTabPanel.js +++ b/client/coral-embed-stream/src/containers/ExtendableTabPanel.js @@ -1,22 +1,23 @@ import React from 'react'; -import StreamTabPanel from '../components/StreamTabPanel'; +import ExtendableTabPanel from '../components/ExtendableTabPanel'; import {connect} from 'react-redux'; -import {Tab, TabPane} from 'coral-ui'; +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'; -class StreamTabPanelContainer extends React.Component { +class ExtendableTabPanelContainer extends React.Component { static contextTypes = { plugins: PropTypes.object, }; componentDidMount() { - this.fallbackAllTab(); + this.handleFallback(); } componentWillReceiveProps(next) { - this.fallbackAllTab(next); + this.handleFallback(next); } shouldComponentUpdate(next) { @@ -34,30 +35,50 @@ class StreamTabPanelContainer extends React.Component { return changes.length !== 0; } - fallbackAllTab(props = this.props) { - if (props.activeTab !== props.fallbackTab) { - const slotPlugins = this.getSlotElements(props.tabSlot, props).map((el) => el.type.talkPluginName); - if (slotPlugins.indexOf(props.activeTab) === -1) { - props.setActiveTab(props.fallbackTab); - } + handleFallback(props = this.props) { + if (this.getTabNames(props).indexOf(props.activeTab) === -1) { + props.setActiveTab(props.fallbackTab); } } + getTabNames(props = this.props) { + 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.getSlotElements(props.tabSlot).map((el) => { + return this.getSlotTabElements(props.tabSlot); + } + + getPluginTabElementsPrepend(props = this.props) { + return this.getSlotTabElements(props.tabSlotPrepend); + } + + getSlotTabElements(slot) { + return this.getSlotElements(slot).map((el) => { return ( - + {React.cloneElement(el, {active: this.props.activeTab === el.type.talkPluginName})} - + ); }); } + getTabElements(props = this.props) { + const elements = [...this.getPluginTabElementsPrepend(props)]; + if (Array.isArray(props.tabs)) { + elements.push(...props.tabs); + } else { + elements.push(props.tabs); + } + elements.push(...this.getPluginTabElements(props)); + return elements; + } + getPluginTabPaneElements(props = this.props) { return this.getSlotElements(props.tabPaneSlot).map((el) => { return ( @@ -70,12 +91,12 @@ class StreamTabPanelContainer extends React.Component { render() { return ( - @@ -83,19 +104,20 @@ class StreamTabPanelContainer extends React.Component { } } -StreamTabPanelContainer.propTypes = { +ExtendableTabPanelContainer.propTypes = { activeTab: PropTypes.string.isRequired, setActiveTab: PropTypes.func.isRequired, - appendTabs: PropTypes.oneOfType([ + tabs: PropTypes.oneOfType([ PropTypes.element, PropTypes.arrayOf(PropTypes.element) ]), - appendTabPanes: PropTypes.oneOfType([ + tabPanes: PropTypes.oneOfType([ PropTypes.element, PropTypes.arrayOf(PropTypes.element) ]), fallbackTab: PropTypes.string.isRequired, tabSlot: PropTypes.string.isRequired, + tabSlotPrepend: PropTypes.string.isRequired, tabPaneSlot: PropTypes.string.isRequired, slotProps: PropTypes.object.isRequired, queryData: PropTypes.object, @@ -108,4 +130,4 @@ const mapStateToProps = (state) => ({ reduxState: state, }); -export default connect(mapStateToProps, null)(StreamTabPanelContainer); +export default connect(mapStateToProps, null)(ExtendableTabPanelContainer); diff --git a/client/coral-embed-stream/src/graphql/index.js b/client/coral-embed-stream/src/graphql/index.js index d18cdc435..d77e6356a 100644 --- a/client/coral-embed-stream/src/graphql/index.js +++ b/client/coral-embed-stream/src/graphql/index.js @@ -2,6 +2,7 @@ import {gql} from 'react-apollo'; import update from 'immutability-helper'; import uuid from 'uuid/v4'; import {insertCommentIntoEmbedQuery, removeCommentFromEmbedQuery} from './utils'; +import {mapLeaves} from 'coral-framework/utils'; export default { fragments: { @@ -218,6 +219,18 @@ export default { }, }, }), + UpdateAssetSettings: ({variables: {input}}) => ({ + updateQueries: { + CoralEmbedStream_Embed: (prev) => { + const updated = update(prev, { + asset: { + settings: mapLeaves(input, (leaf) => ({$set: leaf})), + }, + }); + return updated; + } + } + }), }, }; diff --git a/client/coral-embed-stream/src/reducers/configure.js b/client/coral-embed-stream/src/reducers/configure.js new file mode 100644 index 000000000..0a94d5a54 --- /dev/null +++ b/client/coral-embed-stream/src/reducers/configure.js @@ -0,0 +1,42 @@ +import * as actions from '../constants/configure'; +import isEmpty from 'lodash/isEmpty'; +import update from 'immutability-helper'; + +const initialState = { + canSave: false, + pending: {}, + errors: {}, +}; + +export default function config(state = initialState, action) { + switch (action.type) { + case actions.UPDATE_PENDING: { + let next = state; + if (action.updater) { + next = update(next, { + pending: action.updater, + }); + } + if (action.errorUpdater) { + next = update(next, { + errors: action.errorUpdater, + }); + } + const noErrors = Object.keys(next.errors).reduce((res, error) => res && !next.errors[error], true); + const canSave = !isEmpty(next.pending) && noErrors; + next = update(next, { + canSave: {$set: canSave}, + }); + + return next; + } + case actions.CLEAR_PENDING: + return { + ...state, + pending: {}, + canSave: false, + }; + default: + return state; + } +} diff --git a/client/coral-embed-stream/src/reducers/index.js b/client/coral-embed-stream/src/reducers/index.js index 5c553b04f..d6d0c7693 100644 --- a/client/coral-embed-stream/src/reducers/index.js +++ b/client/coral-embed-stream/src/reducers/index.js @@ -2,6 +2,7 @@ import auth from './auth'; import asset from './asset'; import embed from './embed'; import config from './config'; +import configure from './configure'; import stream from './stream'; import {reducer as commentBox} from '../../../talk-plugin-commentbox'; @@ -11,5 +12,6 @@ export default { commentBox, embed, config, + configure, stream, }; diff --git a/client/coral-embed-stream/src/tabs/configure/components/AssetStatusInfo.css b/client/coral-embed-stream/src/tabs/configure/components/AssetStatusInfo.css new file mode 100644 index 000000000..3dfd98ccc --- /dev/null +++ b/client/coral-embed-stream/src/tabs/configure/components/AssetStatusInfo.css @@ -0,0 +1,10 @@ +.wrapper { + display: flex; + justify-content: space-between; + align-items: center; +} + +.button { + width: 300px; + margin-left: 20px; +} diff --git a/client/coral-embed-stream/src/tabs/configure/components/AssetStatusInfo.js b/client/coral-embed-stream/src/tabs/configure/components/AssetStatusInfo.js new file mode 100644 index 000000000..d3c2f14d7 --- /dev/null +++ b/client/coral-embed-stream/src/tabs/configure/components/AssetStatusInfo.js @@ -0,0 +1,72 @@ +import React from 'react'; +import {Button} from 'coral-ui'; +import PropTypes from 'prop-types'; +import t, {timeago} from 'coral-framework/services/i18n'; +import cn from 'classnames'; +import styles from './AssetStatusInfo.css'; + +class AssetStatusInfo extends React.Component { + + timer = null; + + constructor(props) { + super(props); + this.setupTimer(props); + } + + componentWillReceiveProps(nextProps) { + this.setupTimer(nextProps); + } + + // Rerendering interval. If remaining time > 1min, rerender every minute, otherwise evey second. + interval(closedAt) { + const diff = new Date(closedAt).getTime() - new Date().getTime(); + return diff > 60000 ? 60000 : 1000; + } + + // Timer that counts down the remaining time. + setupTimer({closedAt, isClosed} = this.props) { + if (this.timer && (isClosed || !closedAt)) { + clearTimeout(this.timer); + this.timer = null; + } + if (isClosed || !closedAt) { + this.timer = null; + return; + } + if (!this.timer) { + this.timer = setTimeout(() => { + this.timer = null; + this.forceUpdate(); + this.setupTimer(); + }, this.interval(closedAt)); + } + } + + render() { + const {isClosed, closedAt, onClose, onOpen} = this.props; + return ( +
+

{!isClosed ? t('configure.close') : t('configure.open')} {t('configure.comment_stream')}

+ {(!isClosed && closedAt) ?

{t('configure.comment_stream_will_close')} {timeago(new Date(closedAt))}.

: ''} +
+

+ {!isClosed ? t('configure.open_stream_configuration') : t('configure.close_stream_configuration')} +

+ +
+
+ ); + } +} + +AssetStatusInfo.propTypes = { + isClosed: PropTypes.bool.isRequired, + closedAt: PropTypes.string, + onClose: PropTypes.func.isRequired, + onOpen: PropTypes.func.isRequired, +}; + +export default AssetStatusInfo; diff --git a/client/coral-embed-stream/src/tabs/configure/components/Configure.js b/client/coral-embed-stream/src/tabs/configure/components/Configure.js new file mode 100644 index 000000000..a7000864e --- /dev/null +++ b/client/coral-embed-stream/src/tabs/configure/components/Configure.js @@ -0,0 +1,30 @@ +import React from 'react'; +import AssetStatusInfo from '../containers/AssetStatusInfo'; +import Settings from '../containers/Settings'; +import PropTypes from 'prop-types'; + +class Configure extends React.Component { + render() { + return ( +
+ +
+ +
+ ); + } +} + +Configure.propTypes = { + data: PropTypes.object.isRequired, + root: PropTypes.object.isRequired, + asset: PropTypes.object.isRequired, +}; + +export default Configure; diff --git a/client/coral-embed-stream/src/tabs/configure/components/QuestionBoxBuilder.css b/client/coral-embed-stream/src/tabs/configure/components/QuestionBoxBuilder.css new file mode 100644 index 000000000..8dc6091fa --- /dev/null +++ b/client/coral-embed-stream/src/tabs/configure/components/QuestionBoxBuilder.css @@ -0,0 +1,59 @@ +.root { +} + +.iconList { + display: flex; + padding: 0; + margin: 10px 0; +} + +.item { + list-style-type: none; +} + +.button { + composes: buttonReset from "coral-framework/styles/reset.css"; + + display: flex; + align-items: center; + justify-content: center; + text-align: center; + background: #F0F0F0; + width: 48px; + height: 48px; + font-size: 24px; + text-align: center; + line-height: 48px; + color: #252525; + border-radius: 3px; + overflow: hidden; + margin-right: 10px; + position: relative; + border: solid 2px #F0F0F0; + outline: 0; + + &:hover { + cursor: pointer; + } +} + +.button:focus { + border: solid 2px #00c96B; +} + +.buttonActive { + border: solid 2px #00796B; +} + +.defaultIcon { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.questionBox { + margin: 10px 0; +} + diff --git a/client/coral-embed-stream/src/tabs/configure/components/QuestionBoxBuilder.js b/client/coral-embed-stream/src/tabs/configure/components/QuestionBoxBuilder.js new file mode 100644 index 000000000..0d44b4a3a --- /dev/null +++ b/client/coral-embed-stream/src/tabs/configure/components/QuestionBoxBuilder.js @@ -0,0 +1,93 @@ +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'; + +const 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, questionBoxContent, onContentChange, onIconChange} = this.props; + const {loading, MarkdownEditor} = this.state; + + if (loading) { + return ; + } + + return ( +
+

Include an Icon

+ +
    + {icons.map((item) => { + const name = typeof item === 'object' ? Object.keys(item)[0] : item; + const icon = typeof item === 'object' ? item[name] : item; + return ( +
  • + +
  • + ); + })} +
+ + + + + +
+ ); + } +} + +export default QuestionBoxBuilder; diff --git a/client/coral-configure/components/ConfigureCommentStream.css b/client/coral-embed-stream/src/tabs/configure/components/Settings.css similarity index 59% rename from client/coral-configure/components/ConfigureCommentStream.css rename to client/coral-embed-stream/src/tabs/configure/components/Settings.css index 5575e073b..1a97c3fc1 100644 --- a/client/coral-configure/components/ConfigureCommentStream.css +++ b/client/coral-embed-stream/src/tabs/configure/components/Settings.css @@ -7,9 +7,8 @@ margin: 0 10px; } -.wrapper ul { - list-style: none; - padding: 0; +.description { + max-width: 380px; } .checkbox { @@ -17,19 +16,14 @@ margin: 12px 12px 12px 0; } -.wrapper h4 { - font-size: 14px; - margin-bottom: 5px; -} - -.wrapper p { - max-width: 380px; +.list { + margin-top: 26px; } .wrapper { margin-bottom: 20px; } -.hidden { - display: none; -} \ No newline at end of file +.questionBoxContainer { + margin-top: 24px; +} diff --git a/client/coral-embed-stream/src/tabs/configure/components/Settings.js b/client/coral-embed-stream/src/tabs/configure/components/Settings.js new file mode 100644 index 000000000..4e89172d2 --- /dev/null +++ b/client/coral-embed-stream/src/tabs/configure/components/Settings.js @@ -0,0 +1,102 @@ +import React from 'react'; +import {Button} from 'coral-ui'; +import PropTypes from 'prop-types'; +import t from 'coral-framework/services/i18n'; +import cn from 'classnames'; +import styles from './Settings.css'; +import Configuration from 'coral-framework/components/StreamConfiguration'; +import QuestionBoxBuilder from './QuestionBoxBuilder'; +import Slot from 'coral-framework/components/Slot'; + +class Settings extends React.Component { + render() { + const { + settings: { + moderation, + premodLinksEnable, + questionBoxEnable, + questionBoxContent, + questionBoxIcon, + }, + onToggleModeration, + onTogglePremodLinks, + onToggleQuestionBox, + onQuestionBoxIconChange, + onQuestionBoxContentChange, + canSave, + onApply, + slotProps, + queryData, + } = this.props; + return ( +
+
+

{t('configure.title')}

+ +

{t('configure.description')}

+
+
+ + + + { + questionBoxEnable && +
+ +
+ } +
+ +
+
+ ); + } +} + +Settings.propTypes = { + queryData: PropTypes.object.isRequired, + slotProps: PropTypes.object.isRequired, + settings: PropTypes.object.isRequired, + canSave: PropTypes.bool.isRequired, + onToggleModeration: PropTypes.func.isRequired, + onTogglePremodLinks: PropTypes.func.isRequired, + onToggleQuestionBox: PropTypes.func.isRequired, + onQuestionBoxContentChange: PropTypes.func.isRequired, + onQuestionBoxIconChange: PropTypes.func.isRequired, + onApply: PropTypes.func.isRequired, +}; + +export default Settings; diff --git a/client/coral-embed-stream/src/tabs/configure/containers/AssetStatusInfo.js b/client/coral-embed-stream/src/tabs/configure/containers/AssetStatusInfo.js new file mode 100644 index 000000000..3d4248e24 --- /dev/null +++ b/client/coral-embed-stream/src/tabs/configure/containers/AssetStatusInfo.js @@ -0,0 +1,44 @@ +import React from 'react'; +import {gql, compose} from 'react-apollo'; +import {withFragments} from 'coral-framework/hocs'; +import AssetStatusInfo from '../components/AssetStatusInfo'; +import PropTypes from 'prop-types'; +import {withUpdateAssetStatus} from 'coral-framework/graphql/mutations'; + +class AssetStatusInfoContainer extends React.Component { + + openAsset = () => this.props.updateAssetStatus(this.props.asset.id, {closedAt: null}); + closeAsset = () => this.props.updateAssetStatus(this.props.asset.id, {closedAt: new Date().toISOString()}); + + render() { + return ; + } +} + +AssetStatusInfoContainer.propTypes = { + asset: PropTypes.object.isRequired, + updateAssetStatus: PropTypes.func.isRequired, +}; + +const withAssetStatusInfoFragments = withFragments({ + asset: gql` + fragment CoralEmbedStream_AssetStatusInfo_asset on Asset { + id + closedAt + isClosed + } + `, +}); + +const enhance = compose( + withAssetStatusInfoFragments, + withUpdateAssetStatus, +); + +export default enhance(AssetStatusInfoContainer); diff --git a/client/coral-embed-stream/src/tabs/configure/containers/Configure.js b/client/coral-embed-stream/src/tabs/configure/containers/Configure.js new file mode 100644 index 000000000..b88306262 --- /dev/null +++ b/client/coral-embed-stream/src/tabs/configure/containers/Configure.js @@ -0,0 +1,50 @@ +import React from 'react'; +import {gql, compose} from 'react-apollo'; +import {withFragments} from 'coral-framework/hocs'; +import Configure from '../components/Configure'; +import AssetStatusInfo from './AssetStatusInfo'; +import Settings from './Settings'; +import PropTypes from 'prop-types'; +import {getDefinitionName} from 'coral-framework/utils'; + +class ConfigureContainer extends React.Component { + + render() { + return ; + } +} + +ConfigureContainer.propTypes = { + data: PropTypes.object.isRequired, + root: PropTypes.object.isRequired, + asset: PropTypes.object.isRequired, +}; + +const withConfigureFragments = withFragments({ + root: gql` + fragment CoralEmbedStream_Configure_root on RootQuery { + __typename + ...${getDefinitionName(Settings.fragments.root)} + } + ${Settings.fragments.root} + `, + asset: gql` + fragment CoralEmbedStream_Configure_asset on Asset { + __typename + ...${getDefinitionName(AssetStatusInfo.fragments.asset)} + ...${getDefinitionName(Settings.fragments.asset)} + } + ${AssetStatusInfo.fragments.asset} + ${Settings.fragments.asset} + `, +}); + +const enhance = compose( + withConfigureFragments, +); + +export default enhance(ConfigureContainer); diff --git a/client/coral-embed-stream/src/tabs/configure/containers/Settings.js b/client/coral-embed-stream/src/tabs/configure/containers/Settings.js new file mode 100644 index 000000000..afad3482d --- /dev/null +++ b/client/coral-embed-stream/src/tabs/configure/containers/Settings.js @@ -0,0 +1,129 @@ +import React from 'react'; +import {gql, compose} from 'react-apollo'; +import {withFragments, withMergedSettings} from 'coral-framework/hocs'; +import {getErrorMessages, getSlotFragmentSpreads} from 'coral-framework/utils'; +import Settings from '../components/Settings.js'; +import PropTypes from 'prop-types'; +import {withUpdateAssetSettings} from 'coral-framework/graphql/mutations'; +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import {notify} from 'coral-framework/actions/notification'; +import {clearPending, updatePending} from '../../../actions/configure'; + +const slots = [ + 'streamSettings', +]; + +class SettingsContainer extends React.Component { + + toggleModeration = () => { + const updater = {moderation: {$set: this.props.mergedSettings.moderation === 'PRE' ? 'POST' : 'PRE'}}; + this.props.updatePending({updater}); + }; + + togglePremodLinks = () => { + const updater = {premodLinksEnable: {$set: !this.props.mergedSettings.premodLinksEnable}}; + this.props.updatePending({updater}); + }; + + toggleQuestionBox = () => { + const updater = {questionBoxEnable: {$set: !this.props.mergedSettings.questionBoxEnable}}; + this.props.updatePending({updater}); + }; + + setQuestionBoxIcon = (icon) => { + const updater = {questionBoxIcon: {$set: icon}}; + this.props.updatePending({updater}); + }; + + setQuestionBoxContent = (content) => { + const updater = {questionBoxContent: {$set: content}}; + this.props.updatePending({updater}); + }; + + savePending = async () => { + try { + await this.props.updateAssetSettings(this.props.asset.id, this.props.pending); + this.props.clearPending(); + } + catch(err) { + this.props.notify('error', getErrorMessages(err)); + } + }; + + render() { + const {mergedSettings, canSave, data, root, asset, errors, updatePending} = this.props; + return ; + } +} + +SettingsContainer.propTypes = { + data: PropTypes.object.isRequired, + root: PropTypes.object.isRequired, + asset: PropTypes.object.isRequired, + pending: PropTypes.object.isRequired, + errors: PropTypes.object.isRequired, + mergedSettings: PropTypes.object.isRequired, + updateAssetSettings: PropTypes.func.isRequired, + clearPending: PropTypes.func.isRequired, + notify: PropTypes.func.isRequired, + updatePending: PropTypes.func.isRequired, + canSave: PropTypes.bool.isRequired, +}; + +const withSettingsFragments = withFragments({ + root: gql` + fragment CoralEmbedStream_Settings_root on RootQuery { + __typename + ${getSlotFragmentSpreads(slots, 'root')} + } + `, + asset: gql` + fragment CoralEmbedStream_Settings_asset on Asset { + id + settings { + moderation + premodLinksEnable + questionBoxEnable + questionBoxIcon + questionBoxContent + ${getSlotFragmentSpreads(slots, 'settings')} + } + ${getSlotFragmentSpreads(slots, 'asset')} + } + `, +}); + +const mapStateToProps = (state) => ({ + pending: state.configure.pending, + canSave: state.configure.canSave, + errors: state.configure.errors, +}); + +const mapDispatchToProps = (dispatch) => + bindActionCreators({ + notify, + clearPending, + updatePending, + }, dispatch); + +const enhance = compose( + withSettingsFragments, + withUpdateAssetSettings, + connect(mapStateToProps, mapDispatchToProps), + withMergedSettings('asset.settings', 'pending', 'mergedSettings'), +); + +export default enhance(SettingsContainer); diff --git a/client/coral-embed-stream/src/components/AllCommentsPane.js b/client/coral-embed-stream/src/tabs/stream/components/AllCommentsPane.js similarity index 98% rename from client/coral-embed-stream/src/components/AllCommentsPane.js rename to client/coral-embed-stream/src/tabs/stream/components/AllCommentsPane.js index 2967b9972..3a64820c7 100644 --- a/client/coral-embed-stream/src/components/AllCommentsPane.js +++ b/client/coral-embed-stream/src/tabs/stream/components/AllCommentsPane.js @@ -14,7 +14,7 @@ const hasComment = (nodes, id) => nodes.some((node) => node.id === id); // comments to show. The spare cursor functions as a backup in case one // of the comments gets deleted. function resetCursors(state, props) { - const comments = props.root.asset.comments; + const comments = props.asset.comments; if (comments && comments.nodes.length) { const idCursors = [comments.nodes[0].id]; if (comments.nodes[1]) { @@ -30,7 +30,7 @@ function resetCursors(state, props) { // using the help of the backup cursor. function invalidateCursor(invalidated, state, props) { const alt = invalidated === 1 ? 0 : 1; - const comments = props.root.asset.comments; + const comments = props.asset.comments; const idCursors = []; if (state.idCursors[alt]) { idCursors.push(state.idCursors[alt]); diff --git a/client/coral-embed-stream/src/components/Comment.css b/client/coral-embed-stream/src/tabs/stream/components/Comment.css similarity index 100% rename from client/coral-embed-stream/src/components/Comment.css rename to client/coral-embed-stream/src/tabs/stream/components/Comment.css diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/tabs/stream/components/Comment.js similarity index 99% rename from client/coral-embed-stream/src/components/Comment.js rename to client/coral-embed-stream/src/tabs/stream/components/Comment.js index 7ae36b5fa..3da59afdc 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/tabs/stream/components/Comment.js @@ -9,13 +9,13 @@ import {can} from 'coral-framework/services/perms'; import {TransitionGroup} from 'react-transition-group'; import cn from 'classnames'; import styles from './Comment.css'; -import {THREADING_LEVEL} from '../constants/stream'; +import {THREADING_LEVEL} from '../../../constants/stream'; import merge from 'lodash/merge'; import mapValues from 'lodash/mapValues'; import LoadMore from './LoadMore'; import {getEditableUntilDate} from './util'; -import {findCommentWithId} from '../graphql/utils'; +import {findCommentWithId} from '../../../graphql/utils'; import CommentContent from 'coral-framework/components/CommentContent'; import Slot from 'coral-framework/components/Slot'; import CommentTombstone from './CommentTombstone'; diff --git a/client/coral-embed-stream/src/components/CommentTombstone.css b/client/coral-embed-stream/src/tabs/stream/components/CommentTombstone.css similarity index 100% rename from client/coral-embed-stream/src/components/CommentTombstone.css rename to client/coral-embed-stream/src/tabs/stream/components/CommentTombstone.css diff --git a/client/coral-embed-stream/src/components/CommentTombstone.js b/client/coral-embed-stream/src/tabs/stream/components/CommentTombstone.js similarity index 100% rename from client/coral-embed-stream/src/components/CommentTombstone.js rename to client/coral-embed-stream/src/tabs/stream/components/CommentTombstone.js diff --git a/client/coral-embed-stream/src/components/CountdownSeconds.js b/client/coral-embed-stream/src/tabs/stream/components/CountdownSeconds.js similarity index 100% rename from client/coral-embed-stream/src/components/CountdownSeconds.js rename to client/coral-embed-stream/src/tabs/stream/components/CountdownSeconds.js diff --git a/client/coral-embed-stream/src/components/EditableCommentContent.js b/client/coral-embed-stream/src/tabs/stream/components/EditableCommentContent.js similarity index 100% rename from client/coral-embed-stream/src/components/EditableCommentContent.js rename to client/coral-embed-stream/src/tabs/stream/components/EditableCommentContent.js diff --git a/client/coral-embed-stream/src/components/IgnoreUserWizard.js b/client/coral-embed-stream/src/tabs/stream/components/IgnoreUserWizard.js similarity index 100% rename from client/coral-embed-stream/src/components/IgnoreUserWizard.js rename to client/coral-embed-stream/src/tabs/stream/components/IgnoreUserWizard.js diff --git a/client/coral-embed-stream/src/components/InactiveCommentLabel.css b/client/coral-embed-stream/src/tabs/stream/components/InactiveCommentLabel.css similarity index 100% rename from client/coral-embed-stream/src/components/InactiveCommentLabel.css rename to client/coral-embed-stream/src/tabs/stream/components/InactiveCommentLabel.css diff --git a/client/coral-embed-stream/src/components/InactiveCommentLabel.js b/client/coral-embed-stream/src/tabs/stream/components/InactiveCommentLabel.js similarity index 100% rename from client/coral-embed-stream/src/components/InactiveCommentLabel.js rename to client/coral-embed-stream/src/tabs/stream/components/InactiveCommentLabel.js diff --git a/client/coral-embed-stream/src/components/LoadMore.js b/client/coral-embed-stream/src/tabs/stream/components/LoadMore.js similarity index 100% rename from client/coral-embed-stream/src/components/LoadMore.js rename to client/coral-embed-stream/src/tabs/stream/components/LoadMore.js diff --git a/client/coral-embed-stream/src/components/NewCount.js b/client/coral-embed-stream/src/tabs/stream/components/NewCount.js similarity index 100% rename from client/coral-embed-stream/src/components/NewCount.js rename to client/coral-embed-stream/src/tabs/stream/components/NewCount.js diff --git a/client/coral-embed-stream/src/components/NoComments.css b/client/coral-embed-stream/src/tabs/stream/components/NoComments.css similarity index 100% rename from client/coral-embed-stream/src/components/NoComments.css rename to client/coral-embed-stream/src/tabs/stream/components/NoComments.css diff --git a/client/coral-embed-stream/src/components/NoComments.js b/client/coral-embed-stream/src/tabs/stream/components/NoComments.js similarity index 100% rename from client/coral-embed-stream/src/components/NoComments.js rename to client/coral-embed-stream/src/tabs/stream/components/NoComments.js diff --git a/client/coral-embed-stream/src/components/Stream.css b/client/coral-embed-stream/src/tabs/stream/components/Stream.css similarity index 54% rename from client/coral-embed-stream/src/components/Stream.css rename to client/coral-embed-stream/src/tabs/stream/components/Stream.css index 23f620157..ac014e75d 100644 --- a/client/coral-embed-stream/src/components/Stream.css +++ b/client/coral-embed-stream/src/tabs/stream/components/Stream.css @@ -2,9 +2,22 @@ margin-top: 6px; } -.viewAllButton { +.viewAllButtonContainer { position: absolute; - right: 0px; + width: 100%; + display: flex; + justify-content: center; + margin-top: -11px; + z-index: 10; +} + +.viewAllButton { + composes: buttonReset from "coral-framework/styles/reset.css"; + + background-color: #4D8FCC; + color: white; + padding: 4px 8px; + border-radius: 2px; } .tabPanel { @@ -26,4 +39,4 @@ margin-top: 28px; padding-bottom: 50px; min-height: 600px; -} \ No newline at end of file +} diff --git a/client/coral-embed-stream/src/components/Stream.js b/client/coral-embed-stream/src/tabs/stream/components/Stream.js similarity index 85% rename from client/coral-embed-stream/src/components/Stream.js rename to client/coral-embed-stream/src/tabs/stream/components/Stream.js index 4c2d10a99..046b0f1df 100644 --- a/client/coral-embed-stream/src/components/Stream.js +++ b/client/coral-embed-stream/src/tabs/stream/components/Stream.js @@ -11,15 +11,14 @@ import RestrictedMessageBox from 'coral-framework/components/RestrictedMessageBox'; import t, {timeago} from 'coral-framework/services/i18n'; import CommentBox from 'talk-plugin-commentbox/CommentBox'; -import QuestionBox from 'talk-plugin-questionbox/QuestionBox'; +import QuestionBox from '../../../components/QuestionBox'; import {isCommentActive} from 'coral-framework/utils'; -import {Button, Tab, TabCount, TabPane} from 'coral-ui'; +import {Tab, TabCount, TabPane, Button} from 'coral-ui'; import cn from 'classnames'; -import {getTopLevelParent, attachCommentToParent} from '../graphql/utils'; +import {getTopLevelParent, attachCommentToParent} from '../../../graphql/utils'; import AllCommentsPane from './AllCommentsPane'; -import AutomaticAssetClosure from '../containers/AutomaticAssetClosure'; -import StreamTabPanel from '../containers/StreamTabPanel'; +import ExtendableTabPanel from '../../../containers/ExtendableTabPanel'; import styles from './Stream.css'; @@ -47,7 +46,8 @@ class Stream extends React.Component { activeReplyBox, setActiveReplyBox, commentClassNames, - root: {asset, asset: {comment}}, + asset, + asset: {comment}, postComment, notify, editComment, @@ -58,6 +58,7 @@ class Stream extends React.Component { loadNewReplies, auth: {user}, emit, + viewAllComments, } = this.props; // even though the permalinked comment is the highlighted one, we're displaying its parent + replies @@ -76,6 +77,14 @@ class Stream extends React.Component { return (
+
+ +
@@ -148,26 +158,28 @@ class Stream extends React.Component { {...slotProps} />
- {t('stream.all_comments')} {totalCommentCount} } - appendTabPanes={ - + tabPanes={ + new Date(); - const showCommentBox = loggedIn && ((!banned && !pending & !temporarilySuspended && !highlightedComment) || keepCommentBox); + const showCommentBox = loggedIn && ((!banned && !temporarilySuspended && !highlightedComment) || keepCommentBox); const slotProps = {data}; const slotQueryData = {root, asset}; @@ -247,33 +262,29 @@ class Stream extends React.Component { return (
- - {highlightedComment && - } - {open ?
- + {questionBoxEnable && ( + + + + )} {!banned && temporarilySuspended && @@ -320,7 +331,7 @@ class Stream extends React.Component { {highlightedComment ? this.renderHighlightedComment() - : this.renderTabPanel() + : this.renderExtendableTabPanel() }
); diff --git a/client/coral-embed-stream/src/components/StreamError.css b/client/coral-embed-stream/src/tabs/stream/components/StreamError.css similarity index 100% rename from client/coral-embed-stream/src/components/StreamError.css rename to client/coral-embed-stream/src/tabs/stream/components/StreamError.css diff --git a/client/coral-embed-stream/src/components/StreamError.js b/client/coral-embed-stream/src/tabs/stream/components/StreamError.js similarity index 100% rename from client/coral-embed-stream/src/components/StreamError.js rename to client/coral-embed-stream/src/tabs/stream/components/StreamError.js diff --git a/client/coral-embed-stream/src/components/SuspendAccount.css b/client/coral-embed-stream/src/tabs/stream/components/SuspendedAccount.css similarity index 100% rename from client/coral-embed-stream/src/components/SuspendAccount.css rename to client/coral-embed-stream/src/tabs/stream/components/SuspendedAccount.css diff --git a/client/coral-embed-stream/src/components/SuspendedAccount.js b/client/coral-embed-stream/src/tabs/stream/components/SuspendedAccount.js similarity index 98% rename from client/coral-embed-stream/src/components/SuspendedAccount.js rename to client/coral-embed-stream/src/tabs/stream/components/SuspendedAccount.js index ce9a1c23c..0369ef559 100644 --- a/client/coral-embed-stream/src/components/SuspendedAccount.js +++ b/client/coral-embed-stream/src/tabs/stream/components/SuspendedAccount.js @@ -2,7 +2,7 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; import cn from 'classnames'; import t from 'coral-framework/services/i18n'; -import styles from './SuspendAccount.css'; +import styles from './SuspendedAccount.css'; import {Button} from 'coral-ui'; import validate from 'coral-framework/helpers/validate'; import RestrictedMessageBox from 'coral-framework/components/RestrictedMessageBox'; diff --git a/client/coral-embed-stream/src/components/util.js b/client/coral-embed-stream/src/tabs/stream/components/util.js similarity index 100% rename from client/coral-embed-stream/src/components/util.js rename to client/coral-embed-stream/src/tabs/stream/components/util.js diff --git a/client/coral-embed-stream/src/containers/Comment.js b/client/coral-embed-stream/src/tabs/stream/containers/Comment.js similarity index 96% rename from client/coral-embed-stream/src/containers/Comment.js rename to client/coral-embed-stream/src/tabs/stream/containers/Comment.js index 26fc872d5..eb27517f7 100644 --- a/client/coral-embed-stream/src/containers/Comment.js +++ b/client/coral-embed-stream/src/tabs/stream/containers/Comment.js @@ -4,9 +4,9 @@ import Comment from '../components/Comment'; import {withFragments} from 'coral-framework/hocs'; import {getSlotFragmentSpreads} from 'coral-framework/utils'; import {withSetCommentStatus} from 'coral-framework/graphql/mutations'; -import {THREADING_LEVEL} from '../constants/stream'; +import {THREADING_LEVEL} from '../../../constants/stream'; import hoistStatics from 'recompose/hoistStatics'; -import {nest} from '../graphql/utils'; +import {nest} from '../../../graphql/utils'; const slots = [ 'streamQuestionArea', diff --git a/client/coral-embed-stream/src/containers/Stream.js b/client/coral-embed-stream/src/tabs/stream/containers/Stream.js similarity index 84% rename from client/coral-embed-stream/src/containers/Stream.js rename to client/coral-embed-stream/src/tabs/stream/containers/Stream.js index 6c3d7b02c..dad69b960 100644 --- a/client/coral-embed-stream/src/containers/Stream.js +++ b/client/coral-embed-stream/src/tabs/stream/containers/Stream.js @@ -2,7 +2,7 @@ import React from 'react'; import {gql, compose} from 'react-apollo'; import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; -import {ADDTL_COMMENTS_ON_LOAD_MORE, THREADING_LEVEL} from '../constants/stream'; +import {ADDTL_COMMENTS_ON_LOAD_MORE, THREADING_LEVEL} from '../../../constants/stream'; import { withPostComment, withPostFlag, withPostDontAgree, withDeleteAction, withEditComment @@ -10,7 +10,7 @@ import { import * as authActions from 'coral-embed-stream/src/actions/auth'; import * as notificationActions from 'coral-framework/actions/notification'; -import {setActiveReplyBox, setActiveTab, viewAllComments} from '../actions/stream'; +import {setActiveReplyBox, setActiveTab, viewAllComments} from '../../../actions/stream'; import Stream from '../components/Stream'; import Comment from './Comment'; import {withFragments, withEmit} from 'coral-framework/hocs'; @@ -23,7 +23,7 @@ import { removeCommentFromEmbedQuery, insertFetchedCommentsIntoEmbedQuery, nest, -} from '../graphql/utils'; +} from '../../../graphql/utils'; const {showSignInDialog, editName} = authActions; const {notify} = notificationActions; @@ -36,7 +36,7 @@ class StreamContainer extends React.Component { this.commentsEditedSubscription = this.props.data.subscribeToMore({ document: COMMENTS_EDITED_SUBSCRIPTION, variables: { - assetId: this.props.root.asset.id, + assetId: this.props.asset.id, }, updateQuery: (prev, {subscriptionData: {data: {commentEdited}}}) => { @@ -62,7 +62,7 @@ class StreamContainer extends React.Component { this.commentsAddedSubscription = this.props.data.subscribeToMore({ document: COMMENTS_ADDED_SUBSCRIPTION, variables: { - assetId: this.props.root.asset.id, + assetId: this.props.asset.id, }, updateQuery: (prev, {subscriptionData: {data: {commentAdded}}}) => { @@ -116,7 +116,7 @@ class StreamContainer extends React.Component { limit: parent_id ? 999999 : ADDTL_COMMENTS_ON_LOAD_MORE, cursor: comment.replies.endCursor, parent_id, - asset_id: this.props.root.asset.id, + asset_id: this.props.asset.id, sortOrder: 'ASC', excludeIgnored: this.props.data.variables.excludeIgnored, }, @@ -131,9 +131,9 @@ class StreamContainer extends React.Component { query: LOAD_MORE_QUERY, variables: { limit: ADDTL_COMMENTS_ON_LOAD_MORE, - cursor: this.props.root.asset.comments.endCursor, + cursor: this.props.asset.comments.endCursor, parent_id: null, - asset_id: this.props.root.asset.id, + asset_id: this.props.asset.id, sortOrder: this.props.data.variables.sortOrder, sortBy: this.props.data.variables.sortBy, excludeIgnored: this.props.data.variables.excludeIgnored, @@ -177,9 +177,9 @@ class StreamContainer extends React.Component { } render() { - if (!this.props.root.asset - || this.props.root.asset.comment === undefined - && !this.props.root.asset.comments + if (!this.props.asset + || this.props.asset.comment === undefined + && !this.props.asset.comments ) { return ; } @@ -271,6 +271,7 @@ const LOAD_MORE_QUERY = gql` const slots = [ 'streamTabs', + 'streamTabsPrepend', 'streamTabPanes', 'streamFilter', ]; @@ -278,49 +279,6 @@ const slots = [ const fragments = { root: gql` fragment CoralEmbedStream_Stream_root on RootQuery { - asset(id: $assetId, url: $assetUrl) { - comment(id: $commentId) @include(if: $hasComment) { - ...CoralEmbedStream_Stream_comment - ${nest(` - parent { - ...CoralEmbedStream_Stream_comment - ...nest - } - `, THREADING_LEVEL)} - } - id - title - url - closedAt - isClosed - created_at - settings { - moderation - infoBoxEnable - infoBoxContent - premodLinksEnable - questionBoxEnable - questionBoxContent - questionBoxIcon - closedTimeout - closedMessage - charCountEnable - charCount - requireEmailConfirmation - } - commentCount @skip(if: $hasComment) - totalCommentCount @skip(if: $hasComment) - comments(query: {limit: 10, excludeIgnored: $excludeIgnored, sortOrder: $sortOrder, sortBy: $sortBy}) @skip(if: $hasComment) { - nodes { - ...CoralEmbedStream_Stream_comment - } - hasNextPage - startCursor - endCursor - } - ${getSlotFragmentSpreads(slots, 'asset')} - ...${getDefinitionName(Comment.fragments.asset)} - } me { status ignoredUsers { @@ -333,8 +291,52 @@ const fragments = { ${getSlotFragmentSpreads(slots, 'root')} ...${getDefinitionName(Comment.fragments.root)} } - ${Comment.fragments.asset} ${Comment.fragments.root} + `, + asset: gql` + fragment CoralEmbedStream_Stream_asset on Asset { + comment(id: $commentId) @include(if: $hasComment) { + ...CoralEmbedStream_Stream_comment + ${nest(` + parent { + ...CoralEmbedStream_Stream_comment + ...nest + } + `, THREADING_LEVEL)} + } + id + title + url + isClosed + created_at + settings { + moderation + infoBoxEnable + infoBoxContent + premodLinksEnable + questionBoxEnable + questionBoxContent + questionBoxIcon + closedTimeout + closedMessage + charCountEnable + charCount + requireEmailConfirmation + } + commentCount @skip(if: $hasComment) + totalCommentCount @skip(if: $hasComment) + comments(query: {limit: 10, excludeIgnored: $excludeIgnored, sortOrder: $sortOrder, sortBy: $sortBy}) @skip(if: $hasComment) { + nodes { + ...CoralEmbedStream_Stream_comment + } + hasNextPage + startCursor + endCursor + } + ${getSlotFragmentSpreads(slots, 'asset')} + ...${getDefinitionName(Comment.fragments.asset)} + } + ${Comment.fragments.asset} ${commentFragment} `, }; diff --git a/client/coral-embed-stream/style/default.css b/client/coral-embed-stream/style/default.css index 0dcbf856e..63f1edf67 100644 --- a/client/coral-embed-stream/style/default.css +++ b/client/coral-embed-stream/style/default.css @@ -275,22 +275,6 @@ button.comment__action-button[disabled], /* Close comments */ -.close-comments-intro-wrapper { - display: flex; - justify-content: space-between; - align-items: center; -} - -.close-comments-intro-wrapper button { - width: 300px; - margin-left: 20px; -} - -.close-comments-intro-wrapper button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - .close-comments-message { box-sizing: border-box; width: 100%; diff --git a/client/coral-framework/components/MarkdownEditor.js b/client/coral-framework/components/MarkdownEditor.js index 65f603b0c..451a06019 100644 --- a/client/coral-framework/components/MarkdownEditor.js +++ b/client/coral-framework/components/MarkdownEditor.js @@ -107,6 +107,11 @@ export default class MarkdownEditor extends Component { ...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); } diff --git a/client/coral-framework/components/StreamConfiguration.css b/client/coral-framework/components/StreamConfiguration.css new file mode 100644 index 000000000..c6863fc6a --- /dev/null +++ b/client/coral-framework/components/StreamConfiguration.css @@ -0,0 +1,33 @@ +.root { + position: relative; + margin: 12px 12px 24px 0; +} + +.action { + display: inline-block; + position: absolute; + top: 0; + left: 0; + padding-left: 4px; +} + +.title { + display: block; + font-size: 14px; + margin-bottom: 5px; + font-weight: bold; + cursor: pointer; + padding-right: 50px; +} + +.description{ + padding-right: 50px; +} + +.content { + display: inline-block; + box-sizing: border-box; + padding-left: 50px; +} + + diff --git a/client/coral-framework/components/StreamConfiguration.js b/client/coral-framework/components/StreamConfiguration.js new file mode 100644 index 000000000..6331e8230 --- /dev/null +++ b/client/coral-framework/components/StreamConfiguration.js @@ -0,0 +1,48 @@ +import React from 'react'; +import Checkbox from 'coral-ui/components/Checkbox'; +import PropTypes from 'prop-types'; +import cn from 'classnames'; +import styles from './StreamConfiguration.css'; +import uuid from 'uuid/v4'; + +class Configuration extends React.Component { + + id = uuid(); + + render() { + const {title, description, children, className, onCheckbox, checked, ...rest} = this.props; + return ( +
+ {checked !== undefined && +
+ +
+ } +
+ +
{description}
+
+ {children} +
+
+
+ ); + } +} + +Configuration.propTypes = { + title: PropTypes.string.isRequired, + description: PropTypes.string, + className: PropTypes.string, + onCheckbox: PropTypes.func, + checked: PropTypes.bool, + children: PropTypes.node, +}; + +export default Configuration; diff --git a/client/coral-framework/graphql/fragments.js b/client/coral-framework/graphql/fragments.js index c2a03131b..8b4c19e6f 100644 --- a/client/coral-framework/graphql/fragments.js +++ b/client/coral-framework/graphql/fragments.js @@ -17,6 +17,8 @@ export default { 'IgnoreUserResponse', 'StopIgnoringUserResponse', 'UpdateSettingsResponse', + 'UpdateAssetSettingsResponse', + 'UpdateAssetStatusResponse', ) }; diff --git a/client/coral-framework/graphql/mutations.js b/client/coral-framework/graphql/mutations.js index 3a543a738..15d24a9ce 100644 --- a/client/coral-framework/graphql/mutations.js +++ b/client/coral-framework/graphql/mutations.js @@ -88,7 +88,7 @@ export const withRemoveTag = withMutation( asset_id: assetId, item_type: itemType, }, - optimisticResponse: { + o3timisticResponse: { removeTag: { __typename: 'ModifyTagResponse', errors: null, @@ -363,3 +363,70 @@ export const withUpdateSettings = withMutation( }); }}), }); + +export const withUpdateAssetSettings = withMutation( + gql` + mutation UpdateAssetSettings($id: ID!, $input: AssetSettingsInput!) { + updateAssetSettings(id: $id, input: $input) { + ...UpdateAssetSettingsResponse + } + } + `, { + props: ({mutate}) => ({ + updateAssetSettings: (id, input) => { + return mutate({ + variables: { + id, + input, + }, + optimisticResponse: { + updateAssetSettings: { + __typename: 'UpdateAssetSettingsResponse', + errors: null, + } + }, + }); + }}), + }); + +export const withUpdateAssetStatus = withMutation( + gql` + mutation UpdateAssetStatus($id: ID!, $input: UpdateAssetStatusInput!) { + updateAssetStatus(id: $id, input: $input) { + ...UpdateAssetStatusResponse + } + } + `, { + props: ({mutate}) => ({ + updateAssetStatus: (id, input) => { + return mutate({ + variables: { + id, + input, + }, + optimisticResponse: { + updateAssetStatus: { + __typename: 'UpdateAssetStatusResponse', + errors: null, + } + }, + update: (proxy) => { + if (input.closedAt !== undefined) { + const fragment = gql` + fragment Talk_UpdateAssetStatusResponse on Asset { + closedAt + isClosed + }`; + + const fragmentId = `Asset_${id}`; + const data = { + __typename: 'Asset', + closedAt: input.closedAt, + isClosed: !!input.closedAt && new Date(input.closedAt).getTime() <= new Date().getTime(), + }; + proxy.writeFragment({fragment, id: fragmentId, data}); + } + } + }); + }}), + }); diff --git a/client/coral-framework/hocs/index.js b/client/coral-framework/hocs/index.js index 67b47482e..b3c0219e3 100644 --- a/client/coral-framework/hocs/index.js +++ b/client/coral-framework/hocs/index.js @@ -5,3 +5,4 @@ export {default as withCopyToClipboard} from './withCopyToClipboard'; export {default as withEmit} from './withEmit'; export {default as excludeIf} from './excludeIf'; export {default as connect} from './connect'; +export {default as withMergedSettings} from './withMergedSettings'; diff --git a/client/coral-framework/hocs/withMergedSettings.js b/client/coral-framework/hocs/withMergedSettings.js new file mode 100644 index 000000000..c90e360bc --- /dev/null +++ b/client/coral-framework/hocs/withMergedSettings.js @@ -0,0 +1,25 @@ +import {mergeExcludingArrays} from 'coral-framework/utils'; +import assignWith from 'lodash/assignWith'; +import get from 'lodash/get'; +import {withPropsOnChange} from 'recompose'; + +/** + * Exports a HOC that applies props at `pending` to + * props at `settings` and writes into `result` prop name. + * `Settings`, and `pending` can have a dotnotation like + * "asset.settings". + * + * Example: + * withMergedSettings('asset.settings', 'pending', 'mergedSettings') + */ +const withMergedSettings = (settings, pending, result) => + withPropsOnChange( + (props, nextProps) => + get(props, settings) !== get(nextProps, settings) || + get(props, pending) !== get(nextProps, pending), + (props) => ({ + [result]: assignWith({}, get(props, settings), get(props, pending), mergeExcludingArrays) + }) + ); + +export default withMergedSettings; diff --git a/client/coral-framework/utils/index.js b/client/coral-framework/utils/index.js index 037c1b99f..86e9e1915 100644 --- a/client/coral-framework/utils/index.js +++ b/client/coral-framework/utils/index.js @@ -2,6 +2,8 @@ import {gql} from 'react-apollo'; import t from 'coral-framework/services/i18n'; import union from 'lodash/union'; import {capitalize} from 'coral-framework/helpers/strings'; +import assignWith from 'lodash/assignWith'; +import mapValues from 'lodash/mapValues'; export * from 'coral-framework/helpers/strings'; export const getTotalActionCount = (type, comment) => { @@ -197,3 +199,21 @@ export function getTotalReactionsCount(actionSummaries) { .filter(({__typename}) => !NOT_REACTION_TYPES.includes(__typename)) .reduce((total, {count}) => total + count, 0); } + +// Like lodash merge but does not recurse into arrays. +export function mergeExcludingArrays(objValue, srcValue) { + if (typeof srcValue === 'object' && !Array.isArray(srcValue)) { + return assignWith({}, objValue, srcValue, mergeExcludingArrays); + } + return srcValue; +} + +// Map nested object leaves. Array objects are considered leaves. +export function mapLeaves(o, mapper) { + return mapValues(o, (val) => { + if (typeof val === 'object' && !Array.isArray(val)) { + return mapLeaves(val, mapper); + } + return mapper(val); + }); +} diff --git a/client/coral-ui/components/Checkbox.css b/client/coral-ui/components/Checkbox.css index d20886689..a25ea150f 100644 --- a/client/coral-ui/components/Checkbox.css +++ b/client/coral-ui/components/Checkbox.css @@ -1,10 +1,9 @@ -.label { +.root { position: relative; display: inline-block; } -.label input { - visibility: hidden; +.input { position: absolute; left: 7px; bottom: 7px; @@ -12,6 +11,7 @@ padding: 0; outline: none; cursor: pointer; + pointer-events: none; opacity: 0; } @@ -19,21 +19,9 @@ cursor: pointer; } -.label input[type="checkbox"]:checked + .checkbox:before { - content: "\e834"; -} - -.label input[type="checkbox"] + .checkbox:before { +.checkbox:before { content: "\e835"; color: #717171; -} - -.label.type--green input[type="checkbox"] + .checkbox:before { - color: #00a291; -} - -.label input[type="checkbox"] + .checkbox:before { - position: absolute; left: 4px; top: 0px; width: 18px; @@ -49,22 +37,23 @@ white-space: nowrap; direction: ltr; vertical-align: -6px; - -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; - -moz-osx-font-smoothing: grayscale; - -webkit-font-feature-settings: 'liga'; font-feature-settings: 'liga'; - -webkit-transition: all .2s ease; transition: all .2s ease; z-index: 1; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } -.checkboxInfo { - display: inline-block; - max-width: 360px; - margin-left: 50px; +.checkboxChecked:before { + content: "\e834"; + color: #00a291; } -.checkboxInfo h4 { - margin: 0 0 5px; +.input:focus + .checkbox:before { + color: #00a291; +} + +.input:focus + .checkboxChecked:before { + color: #00e291; } diff --git a/client/coral-ui/components/Checkbox.js b/client/coral-ui/components/Checkbox.js index f80666c65..98804fe60 100644 --- a/client/coral-ui/components/Checkbox.js +++ b/client/coral-ui/components/Checkbox.js @@ -1,16 +1,28 @@ import React from 'react'; import styles from './Checkbox.css'; +import cn from 'classnames'; +import PropTypes from 'prop-types'; -export default ({name, cStyle = 'base', onChange, label, className, info, ...attrs}) => ( -