diff options
Diffstat (limited to 'web/react/components')
27 files changed, 1357 insertions, 1753 deletions
diff --git a/web/react/components/admin_console/admin_controller.jsx b/web/react/components/admin_console/admin_controller.jsx index de0b085bc..32ed70a99 100644 --- a/web/react/components/admin_console/admin_controller.jsx +++ b/web/react/components/admin_console/admin_controller.jsx @@ -21,10 +21,10 @@ import TeamSettingsTab from './team_settings.jsx'; import ServiceSettingsTab from './service_settings.jsx'; import LegalAndSupportSettingsTab from './legal_and_support_settings.jsx'; import TeamUsersTab from './team_users.jsx'; -import TeamAnalyticsTab from './team_analytics.jsx'; +import TeamAnalyticsTab from '../analytics/team_analytics.jsx'; import LdapSettingsTab from './ldap_settings.jsx'; import LicenseSettingsTab from './license_settings.jsx'; -import SystemAnalyticsTab from './system_analytics.jsx'; +import SystemAnalyticsTab from '../analytics/system_analytics.jsx'; export default class AdminController extends React.Component { constructor(props) { diff --git a/web/react/components/admin_console/analytics.jsx b/web/react/components/admin_console/analytics.jsx deleted file mode 100644 index ec9ad4da0..000000000 --- a/web/react/components/admin_console/analytics.jsx +++ /dev/null @@ -1,489 +0,0 @@ -// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import Constants from '../../utils/constants.jsx'; -import LineChart from './line_chart.jsx'; -import DoughnutChart from './doughnut_chart.jsx'; -import StatisticCount from './statistic_count.jsx'; - -var Tooltip = ReactBootstrap.Tooltip; -var OverlayTrigger = ReactBootstrap.OverlayTrigger; - -import {injectIntl, intlShape, defineMessages, FormattedMessage, FormattedDate} from 'mm-intl'; - -const holders = defineMessages({ - analyticsTotalUsers: { - id: 'admin.analytics.totalUsers', - defaultMessage: 'Total Users' - }, - analyticsPublicChannels: { - id: 'admin.analytics.publicChannels', - defaultMessage: 'Public Channels' - }, - analyticsPrivateGroups: { - id: 'admin.analytics.privateGroups', - defaultMessage: 'Private Groups' - }, - analyticsTotalPosts: { - id: 'admin.analytics.totalPosts', - defaultMessage: 'Total Posts' - }, - analyticsFilePosts: { - id: 'admin.analytics.totalFilePosts', - defaultMessage: 'Posts with Files' - }, - analyticsHashtagPosts: { - id: 'admin.analytics.totalHashtagPosts', - defaultMessage: 'Posts with Hashtags' - }, - analyticsIncomingHooks: { - id: 'admin.analytics.totalIncomingWebhooks', - defaultMessage: 'Incoming Webhooks' - }, - analyticsOutgoingHooks: { - id: 'admin.analytics.totalOutgoingWebhooks', - defaultMessage: 'Outgoing Webhooks' - }, - analyticsChannelTypes: { - id: 'admin.analytics.channelTypes', - defaultMessage: 'Channel Types' - }, - analyticsTextPosts: { - id: 'admin.analytics.textPosts', - defaultMessage: 'Posts with Text-only' - }, - analyticsPostTypes: { - id: 'admin.analytics.postTypes', - defaultMessage: 'Posts, Files and Hashtags' - } -}); - -export default class Analytics extends React.Component { - constructor(props) { - super(props); - - this.state = {}; - } - - render() { // in the future, break down these into smaller components - const {formatMessage} = this.props.intl; - - var serverError = ''; - if (this.props.serverError) { - serverError = <div className='form-group has-error'><label className='control-label'>{this.props.serverError}</label></div>; - } - - let loading = ( - <h5> - <FormattedMessage - id='admin.analytics.loading' - defaultMessage='Loading...' - /> - </h5> - ); - - let firstRow; - let extraGraphs; - if (this.props.showAdvanced) { - firstRow = ( - <div className='row'> - <StatisticCount - title={formatMessage(holders.analyticsTotalUsers)} - icon='fa-users' - count={this.props.uniqueUserCount} - /> - <StatisticCount - title={formatMessage(holders.analyticsTotalPosts)} - icon='fa-comment' - count={this.props.postCount} - /> - <StatisticCount - title={formatMessage(holders.analyticsIncomingHooks)} - icon='fa-arrow-down' - count={this.props.incomingWebhookCount} - /> - <StatisticCount - title={formatMessage(holders.analyticsOutgoingHooks)} - icon='fa-arrow-up' - count={this.props.outgoingWebhookCount} - /> - </div> - ); - - const channelTypeData = [ - { - value: this.props.channelOpenCount, - color: '#46BFBD', - highlight: '#5AD3D1', - label: formatMessage(holders.analyticsPublicChannels) - }, - { - value: this.props.channelPrivateCount, - color: '#FDB45C', - highlight: '#FFC870', - label: formatMessage(holders.analyticsPrivateGroups) - } - ]; - - const postTypeData = [ - { - value: this.props.filePostCount, - color: '#46BFBD', - highlight: '#5AD3D1', - label: formatMessage(holders.analyticsFilePosts) - }, - { - value: this.props.filePostCount, - color: '#F7464A', - highlight: '#FF5A5E', - label: formatMessage(holders.analyticsHashtagPosts) - }, - { - value: this.props.postCount - this.props.filePostCount - this.props.hashtagPostCount, - color: '#FDB45C', - highlight: '#FFC870', - label: formatMessage(holders.analyticsTextPosts) - } - ]; - - extraGraphs = ( - <div className='row'> - <DoughnutChart - title={formatMessage(holders.analyticsChannelTypes)} - data={channelTypeData} - width='300' - height='225' - /> - <DoughnutChart - title={formatMessage(holders.analyticsPostTypes)} - data={postTypeData} - width='300' - height='225' - /> - </div> - ); - } else { - firstRow = ( - <div className='row'> - <StatisticCount - title={formatMessage(holders.analyticsTotalUsers)} - icon='fa-users' - count={this.props.uniqueUserCount} - /> - <StatisticCount - title={formatMessage(holders.analyticsPublicChannels)} - icon='fa-globe' - count={this.props.channelOpenCount} - /> - <StatisticCount - title={formatMessage(holders.analyticsPrivateGroups)} - icon='fa-lock' - count={this.props.channelPrivateCount} - /> - <StatisticCount - title={formatMessage(holders.analyticsTotalPosts)} - icon='fa-comment' - count={this.props.postCount} - /> - </div> - ); - } - - let postCountsByDay; - if (this.props.postCountsDay == null) { - postCountsByDay = ( - <div className='col-sm-12'> - <div className='total-count by-day'> - <div className='title'> - <FormattedMessage - id='admin.analytics.totalPosts' - defaultMessage='Total Posts' - /> - </div> - <div className='content'>{loading}</div> - </div> - </div> - ); - } else { - let content; - if (this.props.postCountsDay.labels.length === 0) { - content = ( - <h5> - <FormattedMessage - id='admin.analytics.meaningful' - defaultMessage='Not enough data for a meaningful representation.' - /> - </h5> - ); - } else { - content = ( - <LineChart - data={this.props.postCountsDay} - width='740' - height='225' - /> - ); - } - postCountsByDay = ( - <div className='col-sm-12'> - <div className='total-count by-day'> - <div className='title'> - <FormattedMessage - id='admin.analytics.totalPosts' - defaultMessage='Total Posts' - /> - </div> - <div className='content'> - {content} - </div> - </div> - </div> - ); - } - - let usersWithPostsByDay; - if (this.props.userCountsWithPostsDay == null) { - usersWithPostsByDay = ( - <div className='col-sm-12'> - <div className='total-count by-day'> - <div className='title'> - <FormattedMessage - id='admin.analytics.activeUsers' - defaultMessage='Active Users With Posts' - /> - </div> - <div className='content'>{loading}</div> - </div> - </div> - ); - } else { - let content; - if (this.props.userCountsWithPostsDay.labels.length === 0) { - content = ( - <h5> - <FormattedMessage - id='admin.analytics.meaningful' - defaultMessage='Not enough data for a meaningful representation.' - /> - </h5> - ); - } else { - content = ( - <LineChart - data={this.props.userCountsWithPostsDay} - width='740' - height='225' - /> - ); - } - usersWithPostsByDay = ( - <div className='col-sm-12'> - <div className='total-count by-day'> - <div className='title'> - <FormattedMessage - id='admin.analytics.activeUsers' - defaultMessage='Active Users With Posts' - /> - </div> - <div className='content'> - {content} - </div> - </div> - </div> - ); - } - - let recentActiveUser; - if (this.props.recentActiveUsers != null) { - let content; - if (this.props.recentActiveUsers.length === 0) { - content = loading; - } else { - content = ( - <table> - <tbody> - { - this.props.recentActiveUsers.map((user) => { - const tooltip = ( - <Tooltip id={'recent-user-email-tooltip-' + user.id}> - {user.email} - </Tooltip> - ); - - return ( - <tr key={'recent-user-table-entry-' + user.id}> - <td> - <OverlayTrigger - delayShow={Constants.OVERLAY_TIME_DELAY} - placement='top' - overlay={tooltip} - > - <time> - {user.username} - </time> - </OverlayTrigger> - </td> - <td> - <FormattedDate - value={user.last_activity_at} - day='numeric' - month='long' - year='numeric' - hour12={true} - hour='2-digit' - minute='2-digit' - /> - </td> - </tr> - ); - }) - } - </tbody> - </table> - ); - } - recentActiveUser = ( - <div className='col-sm-6'> - <div className='total-count recent-active-users'> - <div className='title'> - <FormattedMessage - id='admin.analytics.recentActive' - defaultMessage='Recent Active Users' - /> - </div> - <div className='content'> - {content} - </div> - </div> - </div> - ); - } - - let newUsers; - if (this.props.newlyCreatedUsers != null) { - let content; - if (this.props.newlyCreatedUsers.length === 0) { - content = loading; - } else { - content = ( - <table> - <tbody> - { - this.props.newlyCreatedUsers.map((user) => { - const tooltip = ( - <Tooltip id={'new-user-email-tooltip-' + user.id}> - {user.email} - </Tooltip> - ); - - return ( - <tr key={'new-user-table-entry-' + user.id}> - <td> - <OverlayTrigger - delayShow={Constants.OVERLAY_TIME_DELAY} - placement='top' - overlay={tooltip} - > - <time> - {user.username} - </time> - </OverlayTrigger> - </td> - <td> - <FormattedDate - value={user.create_at} - day='numeric' - month='long' - year='numeric' - hour12={true} - hour='2-digit' - minute='2-digit' - /> - </td> - </tr> - ); - }) - } - </tbody> - </table> - ); - } - newUsers = ( - <div className='col-sm-6'> - <div className='total-count recent-active-users'> - <div className='title'> - <FormattedMessage - id='admin.analytics.newlyCreated' - defaultMessage='Newly Created Users' - /> - </div> - <div className='content'> - {content} - </div> - </div> - </div> - ); - } - - return ( - <div className='wrapper--fixed team_statistics'> - <h3> - <FormattedMessage - id='admin.analytics.title' - defaultMessage='Statistics for {title}' - values={{ - title: this.props.title - }} - /> - </h3> - {serverError} - {firstRow} - {extraGraphs} - <div className='row'> - {postCountsByDay} - </div> - <div className='row'> - {usersWithPostsByDay} - </div> - <div className='row'> - {recentActiveUser} - {newUsers} - </div> - </div> - ); - } -} - -Analytics.defaultProps = { - title: null, - channelOpenCount: null, - channelPrivateCount: null, - postCount: null, - postCountsDay: null, - userCountsWithPostsDay: null, - recentActiveUsers: null, - newlyCreatedUsers: null, - uniqueUserCount: null, - serverError: null -}; - -Analytics.propTypes = { - intl: intlShape.isRequired, - title: React.PropTypes.string, - channelOpenCount: React.PropTypes.number, - channelPrivateCount: React.PropTypes.number, - postCount: React.PropTypes.number, - showAdvanced: React.PropTypes.bool, - filePostCount: React.PropTypes.number, - hashtagPostCount: React.PropTypes.number, - incomingWebhookCount: React.PropTypes.number, - outgoingWebhookCount: React.PropTypes.number, - postCountsDay: React.PropTypes.object, - userCountsWithPostsDay: React.PropTypes.object, - recentActiveUsers: React.PropTypes.array, - newlyCreatedUsers: React.PropTypes.array, - uniqueUserCount: React.PropTypes.number, - serverError: React.PropTypes.string -}; - -export default injectIntl(Analytics); diff --git a/web/react/components/admin_console/line_chart.jsx b/web/react/components/admin_console/line_chart.jsx deleted file mode 100644 index 7e2f95c84..000000000 --- a/web/react/components/admin_console/line_chart.jsx +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -export default class LineChart extends React.Component { - constructor(props) { - super(props); - - this.initChart = this.initChart.bind(this); - this.chart = null; - } - - componentDidMount() { - this.initChart(this.props); - } - - componentWillReceiveProps(nextProps) { - if (this.chart) { - this.chart.destroy(); - this.initChart(nextProps); - } - } - - componentWillUnmount() { - if (this.chart) { - this.chart.destroy(); - } - } - - initChart(props) { - var el = ReactDOM.findDOMNode(this); - var ctx = el.getContext('2d'); - this.chart = new Chart(ctx).Line(props.data, props.options || {}); //eslint-disable-line new-cap - } - - render() { - return ( - <canvas - width={this.props.width} - height={this.props.height} - /> - ); - } -} - -LineChart.propTypes = { - width: React.PropTypes.string, - height: React.PropTypes.string, - data: React.PropTypes.object, - options: React.PropTypes.object -}; diff --git a/web/react/components/admin_console/service_settings.jsx b/web/react/components/admin_console/service_settings.jsx index 047c7eb8d..9ed81b6a3 100644 --- a/web/react/components/admin_console/service_settings.jsx +++ b/web/react/components/admin_console/service_settings.jsx @@ -31,6 +31,10 @@ var holders = defineMessages({ id: 'admin.service.sessionDaysEx', defaultMessage: 'Ex "30"' }, + corsExample: { + id: 'admin.service.corsEx', + defaultMessage: 'http://example.com' + }, saving: { id: 'admin.service.saving', defaultMessage: 'Saving Config...' @@ -131,6 +135,8 @@ class ServiceSettings extends React.Component { config.ServiceSettings.SessionCacheInMinutes = SessionCacheInMinutes; ReactDOM.findDOMNode(this.refs.SessionCacheInMinutes).value = SessionCacheInMinutes; + config.ServiceSettings.AllowCorsFrom = ReactDOM.findDOMNode(this.refs.AllowCorsFrom).value.trim(); + Client.saveConfig( config, () => { @@ -766,6 +772,35 @@ class ServiceSettings extends React.Component { <div className='form-group'> <label className='control-label col-sm-4' + htmlFor='AllowCorsFrom' + > + <FormattedMessage + id='admin.service.corsTitle' + defaultMessage='Allow Cross-origin Requests from:' + /> + </label> + <div className='col-sm-8'> + <input + type='text' + className='form-control' + id='AllowCorsFrom' + ref='AllowCorsFrom' + placeholder={formatMessage(holders.corsExample)} + defaultValue={this.props.config.ServiceSettings.AllowCorsFrom} + onChange={this.handleChange} + /> + <p className='help-text'> + <FormattedMessage + id='admin.service.corsDescription' + defaultMessage='Enable HTTP Cross origin request from a specific domain. Use "*" if you want to allow CORS from any domain or leave it blank to disable it.' + /> + </p> + </div> + </div> + + <div className='form-group'> + <label + className='control-label col-sm-4' htmlFor='SessionLengthWebInDays' > <FormattedMessage diff --git a/web/react/components/admin_console/system_analytics.jsx b/web/react/components/admin_console/system_analytics.jsx deleted file mode 100644 index f983db177..000000000 --- a/web/react/components/admin_console/system_analytics.jsx +++ /dev/null @@ -1,216 +0,0 @@ -// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import Analytics from './analytics.jsx'; -import * as Client from '../../utils/client.jsx'; - -import {injectIntl, intlShape, defineMessages} from 'mm-intl'; - -const labels = defineMessages({ - totalPosts: { - id: 'admin.system_analytics.totalPosts', - defaultMessage: 'Total Posts' - }, - activeUsers: { - id: 'admin.system_analytics.activeUsers', - defaultMessage: 'Active Users With Posts' - }, - title: { - id: 'admin.system_analytics.title', - defaultMessage: 'the System' - } -}); - -class SystemAnalytics extends React.Component { - constructor(props) { - super(props); - - this.getData = this.getData.bind(this); - - this.state = { // most of this state should be from a store in the future - users: null, - serverError: null, - channel_open_count: null, - channel_private_count: null, - post_count: null, - post_counts_day: null, - user_counts_with_posts_day: null, - recent_active_users: null, - newly_created_users: null, - unique_user_count: null - }; - } - - componentDidMount() { - this.getData(); - } - - getData() { // should be moved to an action creator eventually - const {formatMessage} = this.props.intl; - Client.getSystemAnalytics( - 'standard', - (data) => { - for (var index in data) { - if (data[index].name === 'channel_open_count') { - this.setState({channel_open_count: data[index].value}); - } - - if (data[index].name === 'channel_private_count') { - this.setState({channel_private_count: data[index].value}); - } - - if (data[index].name === 'post_count') { - this.setState({post_count: data[index].value}); - } - - if (data[index].name === 'unique_user_count') { - this.setState({unique_user_count: data[index].value}); - } - } - }, - (err) => { - this.setState({serverError: err.message}); - } - ); - - Client.getSystemAnalytics( - 'post_counts_day', - (data) => { - data.reverse(); - - var chartData = { - labels: [], - datasets: [{ - label: formatMessage(labels.totalPosts), - fillColor: 'rgba(151,187,205,0.2)', - strokeColor: 'rgba(151,187,205,1)', - pointColor: 'rgba(151,187,205,1)', - pointStrokeColor: '#fff', - pointHighlightFill: '#fff', - pointHighlightStroke: 'rgba(151,187,205,1)', - data: [] - }] - }; - - for (var index in data) { - if (data[index]) { - var row = data[index]; - chartData.labels.push(row.name); - chartData.datasets[0].data.push(row.value); - } - } - - this.setState({post_counts_day: chartData}); - }, - (err) => { - this.setState({serverError: err.message}); - } - ); - - Client.getSystemAnalytics( - 'user_counts_with_posts_day', - (data) => { - data.reverse(); - - var chartData = { - labels: [], - datasets: [{ - label: formatMessage(labels.activeUsers), - fillColor: 'rgba(151,187,205,0.2)', - strokeColor: 'rgba(151,187,205,1)', - pointColor: 'rgba(151,187,205,1)', - pointStrokeColor: '#fff', - pointHighlightFill: '#fff', - pointHighlightStroke: 'rgba(151,187,205,1)', - data: [] - }] - }; - - for (var index in data) { - if (data[index]) { - var row = data[index]; - chartData.labels.push(row.name); - chartData.datasets[0].data.push(row.value); - } - } - - this.setState({user_counts_with_posts_day: chartData}); - }, - (err) => { - this.setState({serverError: err.message}); - } - ); - - if (global.window.mm_license.IsLicensed === 'true') { - Client.getSystemAnalytics( - 'extra_counts', - (data) => { - for (var index in data) { - if (data[index].name === 'file_post_count') { - this.setState({file_post_count: data[index].value}); - } - - if (data[index].name === 'hashtag_post_count') { - this.setState({hashtag_post_count: data[index].value}); - } - - if (data[index].name === 'incoming_webhook_count') { - this.setState({incoming_webhook_count: data[index].value}); - } - - if (data[index].name === 'outgoing_webhook_count') { - this.setState({outgoing_webhook_count: data[index].value}); - } - } - }, - (err) => { - this.setState({serverError: err.message}); - } - ); - } - } - - componentWillReceiveProps() { - this.setState({ - serverError: null, - channel_open_count: null, - channel_private_count: null, - post_count: null, - post_counts_day: null, - user_counts_with_posts_day: null, - unique_user_count: null - }); - - this.getData(); - } - - render() { - return ( - <div> - <Analytics - intl={this.props.intl} - title={this.props.intl.formatMessage(labels.title)} - channelOpenCount={this.state.channel_open_count} - channelPrivateCount={this.state.channel_private_count} - postCount={this.state.post_count} - showAdvanced={global.window.mm_license.IsLicensed === 'true'} - filePostCount={this.state.file_post_count} - hashtagPostCount={this.state.hashtag_post_count} - incomingWebhookCount={this.state.incoming_webhook_count} - outgoingWebhookCount={this.state.outgoing_webhook_count} - postCountsDay={this.state.post_counts_day} - userCountsWithPostsDay={this.state.user_counts_with_posts_day} - uniqueUserCount={this.state.unique_user_count} - serverError={this.state.serverError} - /> - </div> - ); - } -} - -SystemAnalytics.propTypes = { - intl: intlShape.isRequired, - team: React.PropTypes.object -}; - -export default injectIntl(SystemAnalytics); diff --git a/web/react/components/admin_console/team_analytics.jsx b/web/react/components/admin_console/team_analytics.jsx deleted file mode 100644 index 808d8046d..000000000 --- a/web/react/components/admin_console/team_analytics.jsx +++ /dev/null @@ -1,253 +0,0 @@ -// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import Analytics from './analytics.jsx'; -import * as Client from '../../utils/client.jsx'; - -import {injectIntl, intlShape, defineMessages} from 'mm-intl'; - -const labels = defineMessages({ - totalPosts: { - id: 'admin.team_analytics.totalPosts', - defaultMessage: 'Total Posts' - }, - activeUsers: { - id: 'admin.team_analytics.activeUsers', - defaultMessage: 'Active Users With Posts' - } -}); - -class TeamAnalytics extends React.Component { - constructor(props) { - super(props); - - this.getData = this.getData.bind(this); - - this.state = { // most of this state should be from a store in the future - users: null, - serverError: null, - channel_open_count: null, - channel_private_count: null, - post_count: null, - post_counts_day: null, - user_counts_with_posts_day: null, - recent_active_users: null, - newly_created_users: null, - unique_user_count: null - }; - } - - componentDidMount() { - this.getData(this.props.team.id); - } - - getData(teamId) { // should be moved to an action creator eventually - const {formatMessage} = this.props.intl; - Client.getTeamAnalytics( - teamId, - 'standard', - (data) => { - for (var index in data) { - if (data[index].name === 'channel_open_count') { - this.setState({channel_open_count: data[index].value}); - } - - if (data[index].name === 'channel_private_count') { - this.setState({channel_private_count: data[index].value}); - } - - if (data[index].name === 'post_count') { - this.setState({post_count: data[index].value}); - } - - if (data[index].name === 'unique_user_count') { - this.setState({unique_user_count: data[index].value}); - } - } - }, - (err) => { - this.setState({serverError: err.message}); - } - ); - - Client.getTeamAnalytics( - teamId, - 'post_counts_day', - (data) => { - data.reverse(); - - var chartData = { - labels: [], - datasets: [{ - label: formatMessage(labels.totalPosts), - fillColor: 'rgba(151,187,205,0.2)', - strokeColor: 'rgba(151,187,205,1)', - pointColor: 'rgba(151,187,205,1)', - pointStrokeColor: '#fff', - pointHighlightFill: '#fff', - pointHighlightStroke: 'rgba(151,187,205,1)', - data: [] - }] - }; - - for (var index in data) { - if (data[index]) { - var row = data[index]; - chartData.labels.push(row.name); - chartData.datasets[0].data.push(row.value); - } - } - - this.setState({post_counts_day: chartData}); - }, - (err) => { - this.setState({serverError: err.message}); - } - ); - - Client.getTeamAnalytics( - teamId, - 'user_counts_with_posts_day', - (data) => { - data.reverse(); - - var chartData = { - labels: [], - datasets: [{ - label: formatMessage(labels.activeUsers), - fillColor: 'rgba(151,187,205,0.2)', - strokeColor: 'rgba(151,187,205,1)', - pointColor: 'rgba(151,187,205,1)', - pointStrokeColor: '#fff', - pointHighlightFill: '#fff', - pointHighlightStroke: 'rgba(151,187,205,1)', - data: [] - }] - }; - - for (var index in data) { - if (data[index]) { - var row = data[index]; - chartData.labels.push(row.name); - chartData.datasets[0].data.push(row.value); - } - } - - this.setState({user_counts_with_posts_day: chartData}); - }, - (err) => { - this.setState({serverError: err.message}); - } - ); - - Client.getProfilesForTeam( - teamId, - (users) => { - this.setState({users}); - - var usersList = []; - for (var id in users) { - if (users.hasOwnProperty(id)) { - usersList.push(users[id]); - } - } - - usersList.sort((a, b) => { - if (a.last_activity_at < b.last_activity_at) { - return 1; - } - - if (a.last_activity_at > b.last_activity_at) { - return -1; - } - - return 0; - }); - - var recentActive = []; - for (let i = 0; i < usersList.length; i++) { - if (usersList[i].last_activity_at == null) { - continue; - } - - recentActive.push(usersList[i]); - if (i > 19) { - break; - } - } - - this.setState({recent_active_users: recentActive}); - - usersList.sort((a, b) => { - if (a.create_at < b.create_at) { - return 1; - } - - if (a.create_at > b.create_at) { - return -1; - } - - return 0; - }); - - var newlyCreated = []; - for (let i = 0; i < usersList.length; i++) { - newlyCreated.push(usersList[i]); - if (i > 19) { - break; - } - } - - this.setState({newly_created_users: newlyCreated}); - }, - (err) => { - this.setState({serverError: err.message}); - } - ); - } - - componentWillReceiveProps(newProps) { - this.setState({ - users: null, - serverError: null, - channel_open_count: null, - channel_private_count: null, - post_count: null, - post_counts_day: null, - user_counts_with_posts_day: null, - recent_active_users: null, - newly_created_users: null, - unique_user_count: null - }); - - this.getData(newProps.team.id); - } - - render() { - return ( - <div> - <Analytics - intl={this.props.intl} - title={this.props.team.name} - users={this.state.users} - channelOpenCount={this.state.channel_open_count} - channelPrivateCount={this.state.channel_private_count} - postCount={this.state.post_count} - postCountsDay={this.state.post_counts_day} - userCountsWithPostsDay={this.state.user_counts_with_posts_day} - recentActiveUsers={this.state.recent_active_users} - newlyCreatedUsers={this.state.newly_created_users} - uniqueUserCount={this.state.unique_user_count} - serverError={this.state.serverError} - /> - </div> - ); - } -} - -TeamAnalytics.propTypes = { - intl: intlShape.isRequired, - team: React.PropTypes.object -}; - -export default injectIntl(TeamAnalytics); diff --git a/web/react/components/admin_console/user_item.jsx b/web/react/components/admin_console/user_item.jsx index 009a9f004..4af350bcd 100644 --- a/web/react/components/admin_console/user_item.jsx +++ b/web/react/components/admin_console/user_item.jsx @@ -7,26 +7,7 @@ import UserStore from '../../stores/user_store.jsx'; import ConfirmModal from '../confirm_modal.jsx'; import TeamStore from '../../stores/team_store.jsx'; -import {injectIntl, intlShape, defineMessages, FormattedMessage} from 'mm-intl'; - -var holders = defineMessages({ - confirmDemoteRoleTitle: { - id: 'admin.user_item.confirmDemoteRoleTitle', - defaultMessage: 'Confirm demotion from System Admin role' - }, - confirmDemotion: { - id: 'admin.user_item.confirmDemotion', - defaultMessage: 'Confirm Demotion' - }, - confirmDemoteDescription: { - id: 'admin.user_item.confirmDemoteDescription', - defaultMessage: 'If you demote yourself from the System Admin role and there is not another user with System Admin privileges, you\'ll need to re-assign a System Admin by accessing the Mattermost server through a terminal and running the following command.' - }, - confirmDemotionCmd: { - id: 'admin.user_item.confirmDemotionCmd', - defaultMessage: 'platform -assign_role -team_name="yourteam" -email="name@yourcompany.com" -role="system_admin"' - } -}); +import {FormattedMessage} from 'mm-intl'; export default class UserItem extends React.Component { constructor(props) { @@ -336,15 +317,44 @@ export default class UserItem extends React.Component { ); } const me = UserStore.getCurrentUser(); - const {formatMessage} = this.props.intl; let makeDemoteModal = null; if (this.props.user.id === me.id) { + const title = ( + <FormattedMessage + id='admin.user_item.confirmDemoteRoleTitle' + defaultMessage='Confirm demotion from System Admin role' + /> + ); + + const message = ( + <div> + <FormattedMessage + id='admin.user_item.confirmDemoteDescription' + defaultMessage="If you demote yourself from the System Admin role and there is not another user with System Admin privileges, you\'ll need to re-assign a System Admin by accessing the Mattermost server through a terminal and running the following command." + /> + <br/> + <br/> + <FormattedMessage + id='admin.user_item.confirmDemotionCmd' + defaultMessage='platform -assign_role -team_name="yourteam" -email="name@yourcompany.com" -role="system_admin"' + /> + {serverError} + </div> + ); + + const confirmButton = ( + <FormattedMessage + id='admin.user_item.confirmDemotion' + defaultMessage='Confirm Demotion' + /> + ); + makeDemoteModal = ( <ConfirmModal show={this.state.showDemoteModal} - title={formatMessage(holders.confirmDemoteRoleTitle)} - message={[formatMessage(holders.confirmDemoteDescription), React.createElement('br'), React.createElement('br'), formatMessage(holders.confirmDemotionCmd), serverError]} - confirm_button={formatMessage(holders.confirmDemotion)} + title={title} + message={message} + confirmButton={confirmButton} onConfirm={this.handleDemoteSubmit} onCancel={this.handleDemoteCancel} /> @@ -405,10 +415,7 @@ export default class UserItem extends React.Component { } UserItem.propTypes = { - intl: intlShape.isRequired, user: React.PropTypes.object.isRequired, refreshProfiles: React.PropTypes.func.isRequired, doPasswordReset: React.PropTypes.func.isRequired }; - -export default injectIntl(UserItem); diff --git a/web/react/components/admin_console/doughnut_chart.jsx b/web/react/components/analytics/doughnut_chart.jsx index e2dc01528..00bb66f0a 100644 --- a/web/react/components/admin_console/doughnut_chart.jsx +++ b/web/react/components/analytics/doughnut_chart.jsx @@ -39,7 +39,7 @@ export default class DoughnutChart extends React.Component { if (this.props.data == null) { content = ( <FormattedMessage - id='admin.analytics.loading' + id='analytics.chart.loading' defaultMessage='Loading...' /> ); @@ -69,7 +69,7 @@ export default class DoughnutChart extends React.Component { } DoughnutChart.propTypes = { - title: React.PropTypes.string, + title: React.PropTypes.node, width: React.PropTypes.string, height: React.PropTypes.string, data: React.PropTypes.array, diff --git a/web/react/components/analytics/line_chart.jsx b/web/react/components/analytics/line_chart.jsx new file mode 100644 index 000000000..d1bb6b9cb --- /dev/null +++ b/web/react/components/analytics/line_chart.jsx @@ -0,0 +1,90 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {FormattedMessage} from 'mm-intl'; + +export default class LineChart extends React.Component { + constructor(props) { + super(props); + + this.initChart = this.initChart.bind(this); + this.chart = null; + } + + componentDidMount() { + this.initChart(); + } + + componentDidUpdate() { + if (this.chart) { + this.chart.destroy(); + } + this.initChart(); + } + + componentWillUnmount() { + if (this.chart) { + this.chart.destroy(); + } + } + + initChart() { + if (!this.refs.canvas) { + return; + } + var el = ReactDOM.findDOMNode(this.refs.canvas); + var ctx = el.getContext('2d'); + this.chart = new Chart(ctx).Line(this.props.data, this.props.options || {}); //eslint-disable-line new-cap + } + + render() { + let content; + if (this.props.data == null) { + content = ( + <FormattedMessage + id='analytics.chart.loading' + defaultMessage='Loading...' + /> + ); + } else if (this.props.data.labels.length === 0) { + content = ( + <h5> + <FormattedMessage + id='analytics.chart.meaningful' + defaultMessage='Not enough data for a meaningful representation.' + /> + </h5> + ); + } else { + content = ( + <canvas + ref='canvas' + width={this.props.width} + height={this.props.height} + /> + ); + } + + return ( + <div className='col-sm-12'> + <div className='total-count by-day'> + <div className='title'> + {this.props.title} + </div> + <div className='content'> + {content} + </div> + </div> + </div> + ); + } +} + +LineChart.propTypes = { + title: React.PropTypes.node.isRequired, + width: React.PropTypes.string.isRequired, + height: React.PropTypes.string.isRequired, + data: React.PropTypes.object, + options: React.PropTypes.object +}; + diff --git a/web/react/components/admin_console/statistic_count.jsx b/web/react/components/analytics/statistic_count.jsx index 118a0ad31..cf457310f 100644 --- a/web/react/components/admin_console/statistic_count.jsx +++ b/web/react/components/analytics/statistic_count.jsx @@ -7,7 +7,7 @@ export default class StatisticCount extends React.Component { render() { let loading = ( <FormattedMessage - id='admin.analytics.loading' + id='analytics.chart.loading' defaultMessage='Loading...' /> ); @@ -27,7 +27,7 @@ export default class StatisticCount extends React.Component { } StatisticCount.propTypes = { - title: React.PropTypes.string.isRequired, + title: React.PropTypes.node.isRequired, icon: React.PropTypes.string.isRequired, count: React.PropTypes.number }; diff --git a/web/react/components/analytics/system_analytics.jsx b/web/react/components/analytics/system_analytics.jsx new file mode 100644 index 000000000..a2b783a79 --- /dev/null +++ b/web/react/components/analytics/system_analytics.jsx @@ -0,0 +1,346 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import LineChart from './line_chart.jsx'; +import DoughnutChart from './doughnut_chart.jsx'; +import StatisticCount from './statistic_count.jsx'; + +import AnalyticsStore from '../../stores/analytics_store.jsx'; + +import * as Utils from '../../utils/utils.jsx'; +import * as AsyncClient from '../../utils/async_client.jsx'; +import Constants from '../../utils/constants.jsx'; +const StatTypes = Constants.StatTypes; + +import {injectIntl, intlShape, defineMessages, FormattedMessage} from 'mm-intl'; + +const holders = defineMessages({ + analyticsPublicChannels: { + id: 'analytics.system.publicChannels', + defaultMessage: 'Public Channels' + }, + analyticsPrivateGroups: { + id: 'analytics.system.privateGroups', + defaultMessage: 'Private Groups' + }, + analyticsFilePosts: { + id: 'analytics.system.totalFilePosts', + defaultMessage: 'Posts with Files' + }, + analyticsHashtagPosts: { + id: 'analytics.system.totalHashtagPosts', + defaultMessage: 'Posts with Hashtags' + }, + analyticsTextPosts: { + id: 'analytics.system.textPosts', + defaultMessage: 'Posts with Text-only' + } +}); + +class SystemAnalytics extends React.Component { + constructor(props) { + super(props); + + this.onChange = this.onChange.bind(this); + + this.state = {stats: AnalyticsStore.getAllSystem()}; + } + + componentDidMount() { + AnalyticsStore.addChangeListener(this.onChange); + + AsyncClient.getStandardAnalytics(); + AsyncClient.getPostsPerDayAnalytics(); + AsyncClient.getUsersPerDayAnalytics(); + + if (global.window.mm_license.IsLicensed === 'true') { + AsyncClient.getAdvancedAnalytics(); + } + } + + componentWillUnmount() { + AnalyticsStore.removeChangeListener(this.onChange); + } + + shouldComponentUpdate(nextProps, nextState) { + if (!Utils.areObjectsEqual(nextState.stats, this.state.stats)) { + return true; + } + + return false; + } + + onChange() { + this.setState({stats: AnalyticsStore.getAllSystem()}); + } + + render() { + const stats = this.state.stats; + + let advancedCounts; + let advancedGraphs; + if (global.window.mm_license.IsLicensed === 'true') { + advancedCounts = ( + <div className='row'> + <StatisticCount + title={ + <FormattedMessage + id='analytics.system.totalSessions' + defaultMessage='Total Sessions' + /> + } + icon='fa-signal' + count={stats[StatTypes.TOTAL_SESSIONS]} + /> + <StatisticCount + title={ + <FormattedMessage + id='analytics.system.totalCommands' + defaultMessage='Total Commands' + /> + } + icon='fa-terminal' + count={stats[StatTypes.TOTAL_COMMANDS]} + /> + <StatisticCount + title={ + <FormattedMessage + id='analytics.system.totalIncomingWebhooks' + defaultMessage='Incoming Webhooks' + /> + } + icon='fa-arrow-down' + count={stats[StatTypes.TOTAL_IHOOKS]} + /> + <StatisticCount + title={ + <FormattedMessage + id='analytics.system.totalOutgoingWebhooks' + defaultMessage='Outgoing Webhooks' + /> + } + icon='fa-arrow-up' + count={stats[StatTypes.TOTAL_OHOOKS]} + /> + </div> + ); + + const channelTypeData = formatChannelDoughtnutData(stats[StatTypes.TOTAL_PUBLIC_CHANNELS], stats[StatTypes.TOTAL_PRIVATE_GROUPS], this.props.intl); + const postTypeData = formatPostDoughtnutData(stats[StatTypes.TOTAL_FILE_POSTS], stats[StatTypes.TOTAL_HASHTAG_POSTS], stats[StatTypes.TOTAL_POSTS], this.props.intl); + + advancedGraphs = ( + <div className='row'> + <DoughnutChart + title={ + <FormattedMessage + id='analytics.system.channelTypes' + defaultMessage='Channel Types' + /> + } + data={channelTypeData} + width='300' + height='225' + /> + <DoughnutChart + title={ + <FormattedMessage + id='analytics.system.postTypes' + defaultMessage='Posts, Files and Hashtags' + /> + } + data={postTypeData} + width='300' + height='225' + /> + </div> + ); + } + + const postCountsDay = formatPostsPerDayData(stats[StatTypes.POST_PER_DAY]); + const userCountsWithPostsDay = formatUsersWithPostsPerDayData(stats[StatTypes.USERS_WITH_POSTS_PER_DAY]); + + return ( + <div className='wrapper--fixed team_statistics'> + <h3> + <FormattedMessage + id='analytics.system.title' + defaultMessage='System Statistics' + /> + </h3> + <div className='row'> + <StatisticCount + title={ + <FormattedMessage + id='analytics.system.totalUsers' + defaultMessage='Total Users' + /> + } + icon='fa-user' + count={stats[StatTypes.TOTAL_USERS]} + /> + <StatisticCount + title={ + <FormattedMessage + id='analytics.system.totalTeams' + defaultMessage='Total Teams' + /> + } + icon='fa-users' + count={stats[StatTypes.TOTAL_TEAMS]} + /> + <StatisticCount + title={ + <FormattedMessage + id='analytics.system.totalPosts' + defaultMessage='Total Posts' + /> + } + icon='fa-comment' + count={stats[StatTypes.TOTAL_POSTS]} + /> + <StatisticCount + title={ + <FormattedMessage + id='analytics.system.totalChannels' + defaultMessage='Total Channels' + /> + } + icon='fa-globe' + count={stats[StatTypes.TOTAL_PUBLIC_CHANNELS] + stats[StatTypes.TOTAL_PRIVATE_GROUPS]} + /> + </div> + {advancedCounts} + {advancedGraphs} + <div className='row'> + <LineChart + title={ + <FormattedMessage + id='analytics.system.totalPosts' + defaultMessage='Total Posts' + /> + } + data={postCountsDay} + width='740' + height='225' + /> + </div> + <div className='row'> + <LineChart + title={ + <FormattedMessage + id='analytics.system.activeUsers' + defaultMessage='Active Users With Posts' + /> + } + data={userCountsWithPostsDay} + width='740' + height='225' + /> + </div> + </div> + ); + } +} + +SystemAnalytics.propTypes = { + intl: intlShape.isRequired, + team: React.PropTypes.object +}; + +export default injectIntl(SystemAnalytics); + +export function formatChannelDoughtnutData(totalPublic, totalPrivate, intl) { + const {formatMessage} = intl; + const channelTypeData = [ + { + value: totalPublic, + color: '#46BFBD', + highlight: '#5AD3D1', + label: formatMessage(holders.analyticsPublicChannels) + }, + { + value: totalPrivate, + color: '#FDB45C', + highlight: '#FFC870', + label: formatMessage(holders.analyticsPrivateGroups) + } + ]; + + return channelTypeData; +} + +export function formatPostDoughtnutData(filePosts, hashtagPosts, totalPosts, intl) { + const {formatMessage} = intl; + const postTypeData = [ + { + value: filePosts, + color: '#46BFBD', + highlight: '#5AD3D1', + label: formatMessage(holders.analyticsFilePosts) + }, + { + value: hashtagPosts, + color: '#F7464A', + highlight: '#FF5A5E', + label: formatMessage(holders.analyticsHashtagPosts) + }, + { + value: totalPosts - filePosts - hashtagPosts, + color: '#FDB45C', + highlight: '#FFC870', + label: formatMessage(holders.analyticsTextPosts) + } + ]; + + return postTypeData; +} + +export function formatPostsPerDayData(data) { + var chartData = { + labels: [], + datasets: [{ + fillColor: 'rgba(151,187,205,0.2)', + strokeColor: 'rgba(151,187,205,1)', + pointColor: 'rgba(151,187,205,1)', + pointStrokeColor: '#fff', + pointHighlightFill: '#fff', + pointHighlightStroke: 'rgba(151,187,205,1)', + data: [] + }] + }; + + for (var index in data) { + if (data[index]) { + var row = data[index]; + chartData.labels.push(row.name); + chartData.datasets[0].data.push(row.value); + } + } + + return chartData; +} + +export function formatUsersWithPostsPerDayData(data) { + var chartData = { + labels: [], + datasets: [{ + fillColor: 'rgba(151,187,205,0.2)', + strokeColor: 'rgba(151,187,205,1)', + pointColor: 'rgba(151,187,205,1)', + pointStrokeColor: '#fff', + pointHighlightFill: '#fff', + pointHighlightStroke: 'rgba(151,187,205,1)', + data: [] + }] + }; + + for (var index in data) { + if (data[index]) { + var row = data[index]; + chartData.labels.push(row.name); + chartData.datasets[0].data.push(row.value); + } + } + + return chartData; +} diff --git a/web/react/components/analytics/table_chart.jsx b/web/react/components/analytics/table_chart.jsx new file mode 100644 index 000000000..c94fa300b --- /dev/null +++ b/web/react/components/analytics/table_chart.jsx @@ -0,0 +1,60 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import Constants from '../../utils/constants.jsx'; + +const Tooltip = ReactBootstrap.Tooltip; +const OverlayTrigger = ReactBootstrap.OverlayTrigger; + +export default class TableChart extends React.Component { + render() { + return ( + <div className='col-sm-6'> + <div className='total-count recent-active-users'> + <div className='title'> + {this.props.title} + </div> + <div className='content'> + <table> + <tbody> + { + this.props.data.map((item) => { + const tooltip = ( + <Tooltip id={'tip-table-entry-' + item.name}> + {item.tip} + </Tooltip> + ); + + return ( + <tr key={'table-entry-' + item.name}> + <td> + <OverlayTrigger + delayShow={Constants.OVERLAY_TIME_DELAY} + placement='top' + overlay={tooltip} + > + <time> + {item.name} + </time> + </OverlayTrigger> + </td> + <td> + {item.value} + </td> + </tr> + ); + }) + } + </tbody> + </table> + </div> + </div> + </div> + ); + } +} + +TableChart.propTypes = { + title: React.PropTypes.node, + data: React.PropTypes.array +}; diff --git a/web/react/components/analytics/team_analytics.jsx b/web/react/components/analytics/team_analytics.jsx new file mode 100644 index 000000000..1236c070b --- /dev/null +++ b/web/react/components/analytics/team_analytics.jsx @@ -0,0 +1,235 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import LineChart from './line_chart.jsx'; +import StatisticCount from './statistic_count.jsx'; +import TableChart from './table_chart.jsx'; + +import AnalyticsStore from '../../stores/analytics_store.jsx'; + +import * as Utils from '../../utils/utils.jsx'; +import * as AsyncClient from '../../utils/async_client.jsx'; +import Constants from '../../utils/constants.jsx'; +const StatTypes = Constants.StatTypes; + +import {formatPostsPerDayData, formatUsersWithPostsPerDayData} from './system_analytics.jsx'; +import {injectIntl, intlShape, FormattedMessage, FormattedDate} from 'mm-intl'; + +class TeamAnalytics extends React.Component { + constructor(props) { + super(props); + + this.onChange = this.onChange.bind(this); + + this.state = {stats: AnalyticsStore.getAllTeam(this.props.team.id)}; + } + + componentDidMount() { + AnalyticsStore.addChangeListener(this.onChange); + + this.getData(this.props.team.id); + } + + getData(id) { + AsyncClient.getStandardAnalytics(id); + AsyncClient.getPostsPerDayAnalytics(id); + AsyncClient.getUsersPerDayAnalytics(id); + AsyncClient.getRecentAndNewUsersAnalytics(id); + } + + componentWillUnmount() { + AnalyticsStore.removeChangeListener(this.onChange); + } + + componentWillReceiveProps(nextProps) { + this.getData(nextProps.team.id); + this.setState({stats: AnalyticsStore.getAllTeam(nextProps.team.id)}); + } + + shouldComponentUpdate(nextProps, nextState) { + if (!Utils.areObjectsEqual(nextState.stats, this.state.stats)) { + return true; + } + + if (!Utils.areObjectsEqual(nextProps.team, this.props.team)) { + return true; + } + + return false; + } + + onChange() { + this.setState({stats: AnalyticsStore.getAllTeam(this.props.team.id)}); + } + + render() { + const stats = this.state.stats; + const postCountsDay = formatPostsPerDayData(stats[StatTypes.POST_PER_DAY]); + const userCountsWithPostsDay = formatUsersWithPostsPerDayData(stats[StatTypes.USERS_WITH_POSTS_PER_DAY]); + const recentActiveUsers = formatRecentUsersData(stats[StatTypes.RECENTLY_ACTIVE_USERS]); + const newlyCreatedUsers = formatNewUsersData(stats[StatTypes.NEWLY_CREATED_USERS]); + + return ( + <div className='wrapper--fixed team_statistics'> + <h3> + <FormattedMessage + id='analytics.team.title' + defaultMessage='Team Statistics for {team}' + values={{ + team: this.props.team.name + }} + /> + </h3> + <div className='row'> + <StatisticCount + title={ + <FormattedMessage + id='analytics.team.totalUsers' + defaultMessage='Total Users' + /> + } + icon='fa-user' + count={stats[StatTypes.TOTAL_USERS]} + /> + <StatisticCount + title={ + <FormattedMessage + id='analytics.team.publicChannels' + defaultMessage='Public Channels' + /> + } + icon='fa-users' + count={stats[StatTypes.TOTAL_PUBLIC_CHANNELS]} + /> + <StatisticCount + title={ + <FormattedMessage + id='analytics.team.privateGroups' + defaultMessage='Private Groups' + /> + } + icon='fa-globe' + count={stats[StatTypes.TOTAL_PRIVATE_GROUPS]} + /> + <StatisticCount + title={ + <FormattedMessage + id='analytics.team.totalPosts' + defaultMessage='Total Posts' + /> + } + icon='fa-comment' + count={stats[StatTypes.TOTAL_POSTS]} + /> + </div> + <div className='row'> + <LineChart + title={ + <FormattedMessage + id='analytics.team.totalPosts' + defaultMessage='Total Posts' + /> + } + data={postCountsDay} + width='740' + height='225' + /> + </div> + <div className='row'> + <LineChart + title={ + <FormattedMessage + id='analytics.team.activeUsers' + defaultMessage='Active Users With Posts' + /> + } + data={userCountsWithPostsDay} + width='740' + height='225' + /> + </div> + <div className='row'> + <TableChart + title={ + <FormattedMessage + id='analytics.team.activeUsers' + defaultMessage='Recent Active Users' + /> + } + data={recentActiveUsers} + /> + <TableChart + title={ + <FormattedMessage + id='analytics.team.newlyCreated' + defaultMessage='Newly Created Users' + /> + } + data={newlyCreatedUsers} + /> + </div> + </div> + ); + } +} + +TeamAnalytics.propTypes = { + intl: intlShape.isRequired, + team: React.PropTypes.object.isRequired +}; + +export default injectIntl(TeamAnalytics); + +export function formatRecentUsersData(data) { + if (data == null) { + return []; + } + + const formattedData = data.map((user) => { + const item = {}; + item.name = user.username; + item.value = ( + <FormattedDate + value={user.last_activity_at} + day='numeric' + month='long' + year='numeric' + hour12={true} + hour='2-digit' + minute='2-digit' + /> + ); + item.tip = user.email; + + return item; + }); + + return formattedData; +} + +export function formatNewUsersData(data) { + if (data == null) { + return []; + } + + const formattedData = data.map((user) => { + const item = {}; + item.name = user.username; + item.value = ( + <FormattedDate + value={user.create_at} + day='numeric' + month='long' + year='numeric' + hour12={true} + hour='2-digit' + minute='2-digit' + /> + ); + item.tip = user.email; + + return item; + }); + + return formattedData; +} diff --git a/web/react/components/channel_invite_modal.jsx b/web/react/components/channel_invite_modal.jsx index c9fe871d0..6c8d51abb 100644 --- a/web/react/components/channel_invite_modal.jsx +++ b/web/react/components/channel_invite_modal.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import MemberList from './member_list.jsx'; +import FilteredUserList from './filtered_user_list.jsx'; import LoadingScreen from './loading_screen.jsx'; import UserStore from '../stores/user_store.jsx'; @@ -22,8 +22,12 @@ export default class ChannelInviteModal extends React.Component { this.onListenerChange = this.onListenerChange.bind(this); this.handleInvite = this.handleInvite.bind(this); + this.createInviteButton = this.createInviteButton.bind(this); + // the state gets populated when the modal is shown - this.state = {}; + this.state = { + loading: true + }; } shouldComponentUpdate(nextProps, nextState) { if (!this.props.show && !nextProps.show) { @@ -77,19 +81,6 @@ export default class ChannelInviteModal extends React.Component { loading: false }; } - onShow() { - if ($(window).width() > 768) { - $(ReactDOM.findDOMNode(this.refs.modalBody)).perfectScrollbar(); - $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 200); - } else { - $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 150); - } - } - componentDidUpdate(prevProps) { - if (this.props.show && !prevProps.show) { - this.onShow(); - } - } componentWillReceiveProps(nextProps) { if (!this.props.show && nextProps.show) { ChannelStore.addExtraInfoChangeListener(this.onListenerChange); @@ -108,9 +99,10 @@ export default class ChannelInviteModal extends React.Component { this.setState(newState); } } - handleInvite(userId) { - var data = {}; - data.user_id = userId; + handleInvite(user) { + const data = { + user_id: user.id + }; Client.addChannelMember( this.props.channel.id, @@ -124,27 +116,40 @@ export default class ChannelInviteModal extends React.Component { } ); } + createInviteButton({user}) { + return ( + <a + onClick={this.handleInvite.bind(this, user)} + className='btn btn-sm btn-primary' + > + <i className='glyphicon glyphicon-envelope'/> + <FormattedMessage + id='channel_invite.add' + defaultMessage=' Add' + /> + </a> + ); + } render() { var inviteError = null; if (this.state.inviteError) { inviteError = (<label className='has-error control-label'>{this.state.inviteError}</label>); } - var currentMember = ChannelStore.getCurrentMember(); - var isAdmin = false; - if (currentMember) { - isAdmin = Utils.isAdmin(currentMember.roles) || Utils.isAdmin(UserStore.getCurrentUser().roles); - } - var content; if (this.state.loading) { content = (<LoadingScreen/>); } else { + let maxHeight = 1000; + if (Utils.windowHeight() <= 1200) { + maxHeight = Utils.windowHeight() - 300; + } + content = ( - <MemberList - memberList={this.state.nonmembers} - isAdmin={isAdmin} - handleInvite={this.handleInvite} + <FilteredUserList + style={{maxHeight}} + users={this.state.nonmembers} + actions={[this.createInviteButton]} /> ); } @@ -164,9 +169,7 @@ export default class ChannelInviteModal extends React.Component { <span className='name'>{this.props.channel.display_name}</span> </Modal.Title> </Modal.Header> - <Modal.Body - ref='modalBody' - > + <Modal.Body> {inviteError} {content} </Modal.Body> diff --git a/web/react/components/channel_members_modal.jsx b/web/react/components/channel_members_modal.jsx index fd452f206..688ab7dd2 100644 --- a/web/react/components/channel_members_modal.jsx +++ b/web/react/components/channel_members_modal.jsx @@ -1,8 +1,8 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. +import FilteredUserList from './filtered_user_list.jsx'; import LoadingScreen from './loading_screen.jsx'; -import MemberList from './member_list.jsx'; import ChannelInviteModal from './channel_invite_modal.jsx'; import UserStore from '../stores/user_store.jsx'; @@ -24,6 +24,8 @@ export default class ChannelMembersModal extends React.Component { this.onChange = this.onChange.bind(this); this.handleRemove = this.handleRemove.bind(this); + this.createRemoveMemberButton = this.createRemoveMemberButton.bind(this); + // the rest of the state gets populated when the modal is shown this.state = { showInviteModal: false @@ -51,24 +53,10 @@ export default class ChannelMembersModal extends React.Component { }; } - const users = UserStore.getActiveOnlyProfiles(); - const memberList = extraInfo.members; - - const nonmemberList = []; - for (const id in users) { - if (users.hasOwnProperty(id)) { - let found = false; - for (let i = 0; i < memberList.length; i++) { - if (memberList[i].id === id) { - found = true; - break; - } - } - if (!found) { - nonmemberList.push(users[id]); - } - } - } + // clone the member list since we mutate it later on + const memberList = extraInfo.members.map((member) => { + return Object.assign({}, member); + }); function compareByUsername(a, b) { if (a.username < b.username) { @@ -81,24 +69,12 @@ export default class ChannelMembersModal extends React.Component { } memberList.sort(compareByUsername); - nonmemberList.sort(compareByUsername); return { - nonmemberList, memberList, loading: false }; } - onShow() { - if ($(window).width() > 768) { - $(ReactDOM.findDOMNode(this.refs.modalBody)).perfectScrollbar(); - } - } - componentDidUpdate(prevProps) { - if (this.props.show && !prevProps.show) { - this.onShow(); - } - } componentWillReceiveProps(nextProps) { if (!this.props.show && nextProps.show) { ChannelStore.addExtraInfoChangeListener(this.onChange); @@ -116,41 +92,25 @@ export default class ChannelMembersModal extends React.Component { this.setState(newState); } } - handleRemove(userId) { - // Make sure the user is a member of the channel - const memberList = this.state.memberList; - let found = false; - for (let i = 0; i < memberList.length; i++) { - if (memberList[i].id === userId) { - found = true; - break; - } - } - - if (!found) { - return; - } + handleRemove(user) { + const userId = user.id; const data = {}; data.user_id = userId; - Client.removeChannelMember(ChannelStore.getCurrentId(), data, + Client.removeChannelMember( + ChannelStore.getCurrentId(), + data, () => { - let oldMember; + const memberList = this.state.memberList.slice(); for (let i = 0; i < memberList.length; i++) { if (userId === memberList[i].id) { - oldMember = memberList[i]; memberList.splice(i, 1); break; } } - const nonmemberList = this.state.nonmemberList; - if (oldMember) { - nonmemberList.push(oldMember); - } - - this.setState({memberList, nonmemberList}); + this.setState({memberList}); AsyncClient.getChannelExtraInfo(); }, (err) => { @@ -158,30 +118,40 @@ export default class ChannelMembersModal extends React.Component { } ); } - render() { - var maxHeight = 1000; - if (Utils.windowHeight() <= 1200) { - maxHeight = Utils.windowHeight() - 300; - } - - const currentMember = ChannelStore.getCurrentMember(); - let isAdmin = false; - if (currentMember) { - isAdmin = Utils.isAdmin(currentMember.roles) || Utils.isAdmin(UserStore.getCurrentUser().roles); + createRemoveMemberButton({user}) { + if (user.id === UserStore.getCurrentId()) { + return null; } + return ( + <button + type='button' + className='btn btn-primary btn-message' + onClick={this.handleRemove.bind(this, user)} + > + <FormattedMessage + id='channel_members_modal.removeMember' + defaultMessage='Remove Member' + /> + </button> + ); + } + render() { let content; if (this.state.loading) { content = (<LoadingScreen/>); } else { + let maxHeight = 1000; + if (Utils.windowHeight() <= 1200) { + maxHeight = Utils.windowHeight() - 300; + } + content = ( - <div className='team-member-list'> - <MemberList - memberList={this.state.memberList} - isAdmin={isAdmin} - handleRemove={this.handleRemove} - /> - </div> + <FilteredUserList + style={{maxHeight}} + users={this.state.memberList} + actions={[this.createRemoveMemberButton]} + /> ); } @@ -217,7 +187,6 @@ export default class ChannelMembersModal extends React.Component { </Modal.Header> <Modal.Body ref='modalBody' - style={{maxHeight}} > {content} </Modal.Body> diff --git a/web/react/components/confirm_modal.jsx b/web/react/components/confirm_modal.jsx index 987649f38..bb3576684 100644 --- a/web/react/components/confirm_modal.jsx +++ b/web/react/components/confirm_modal.jsx @@ -44,7 +44,7 @@ export default class ConfirmModal extends React.Component { className='btn btn-primary' onClick={this.props.onConfirm} > - {this.props.confirm_button} + {this.props.confirmButton} </button> </Modal.Footer> </Modal> @@ -55,13 +55,13 @@ export default class ConfirmModal extends React.Component { ConfirmModal.defaultProps = { title: '', message: '', - confirm_button: '' + confirmButton: '' }; ConfirmModal.propTypes = { show: React.PropTypes.bool.isRequired, - title: React.PropTypes.string, - message: React.PropTypes.string, - confirm_button: React.PropTypes.string, + title: React.PropTypes.node, + message: React.PropTypes.node, + confirmButton: React.PropTypes.node, onConfirm: React.PropTypes.func.isRequired, onCancel: React.PropTypes.func.isRequired }; diff --git a/web/react/components/filtered_user_list.jsx b/web/react/components/filtered_user_list.jsx new file mode 100644 index 000000000..ffd6ebf53 --- /dev/null +++ b/web/react/components/filtered_user_list.jsx @@ -0,0 +1,136 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import UserList from './user_list.jsx'; + +import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'mm-intl'; + +const holders = defineMessages({ + member: { + id: 'filtered_user_list.member', + defaultMessage: 'Member' + }, + search: { + id: 'filtered_user_list.search', + defaultMessage: 'Search members' + } +}); + +class FilteredUserList extends React.Component { + constructor(props) { + super(props); + + this.handleFilterChange = this.handleFilterChange.bind(this); + + this.state = { + filter: '' + }; + } + + componentDidMount() { + $(ReactDOM.findDOMNode(this.refs.userList)).perfectScrollbar(); + } + + componentDidUpdate(prevProps, prevState) { + if (prevState.filter !== this.state.filter) { + $(ReactDOM.findDOMNode(this.refs.userList)).scrollTop(0); + } + } + + handleFilterChange(e) { + this.setState({ + filter: e.target.value + }); + } + + render() { + const {formatMessage} = this.props.intl; + + let users = this.props.users; + + if (this.state.filter) { + const filter = this.state.filter.toLowerCase(); + + users = users.filter((user) => { + return user.username.toLowerCase().indexOf(filter) !== -1 || + (user.first_name && user.first_name.toLowerCase().indexOf(filter) !== -1) || + (user.last_name && user.last_name.toLowerCase().indexOf(filter) !== -1) || + (user.nickname && user.nickname.toLowerCase().indexOf(filter) !== -1); + }); + } + + let count; + if (users.length === this.props.users.length) { + count = ( + <FormattedMessage + id='filtered_user_list.count' + defaultMessage='{count} {count, plural, + one {member} + other {members} + }' + values={{ + count: users.length + }} + /> + ); + } else { + count = ( + <FormattedMessage + id='filtered_user_list.countTotal' + defaultMessage='{count} {count, plural, + one {member} + other {members} + } of {total} Total' + values={{ + count: users.length, + total: this.props.users.length + }} + /> + ); + } + + return ( + <div + className='filtered-user-list' + style={this.props.style} + > + <div className='filter-row'> + <div className='col-sm-6'> + <input + ref='filter' + className='form-control filter-textbox' + placeholder={formatMessage(holders.search)} + onInput={this.handleFilterChange} + /> + </div> + <div className='col-sm-6'> + <span className='member-count'>{count}</span> + </div> + </div> + <div + ref='userList' + className='user-list' + > + <UserList + users={users} + actions={this.props.actions} + /> + </div> + </div> + ); + } +} + +FilteredUserList.defaultProps = { + users: [], + actions: [] +}; + +FilteredUserList.propTypes = { + intl: intlShape.isRequired, + users: React.PropTypes.arrayOf(React.PropTypes.object), + actions: React.PropTypes.arrayOf(React.PropTypes.func), + style: React.PropTypes.object +}; + +export default injectIntl(FilteredUserList); diff --git a/web/react/components/member_list.jsx b/web/react/components/member_list.jsx deleted file mode 100644 index 62d600279..000000000 --- a/web/react/components/member_list.jsx +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import MemberListItem from './member_list_item.jsx'; - -import {FormattedMessage} from 'mm-intl'; - -export default class MemberList extends React.Component { - render() { - var members = []; - - if (this.props.memberList !== null) { - members = this.props.memberList; - } - - var message = null; - if (members.length === 0) { - message = ( - <tr><td> - <FormattedMessage - id='member_list.noUsersAdd' - defaultMessage='No users to add.' - /> - </td></tr> - ); - } - - return ( - <table className='table more-table member-list-holder'> - <tbody> - {members.map(function mymembers(member) { - return ( - <MemberListItem - key={member.id} - member={member} - isAdmin={this.props.isAdmin} - handleInvite={this.props.handleInvite} - handleRemove={this.props.handleRemove} - handleMakeAdmin={this.props.handleMakeAdmin} - /> - ); - }, this)} - {message} - </tbody> - </table> - ); - } -} - -MemberList.propTypes = { - memberList: React.PropTypes.array, - isAdmin: React.PropTypes.bool, - handleInvite: React.PropTypes.func, - handleRemove: React.PropTypes.func, - handleMakeAdmin: React.PropTypes.func -}; diff --git a/web/react/components/member_list_item.jsx b/web/react/components/member_list_item.jsx deleted file mode 100644 index 88b98738d..000000000 --- a/web/react/components/member_list_item.jsx +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import UserStore from '../stores/user_store.jsx'; -import * as Utils from '../utils/utils.jsx'; - -import {FormattedMessage} from 'mm-intl'; - -export default class MemberListItem extends React.Component { - constructor(props) { - super(props); - - this.handleInvite = this.handleInvite.bind(this); - this.handleRemove = this.handleRemove.bind(this); - this.handleMakeAdmin = this.handleMakeAdmin.bind(this); - } - handleInvite(e) { - e.preventDefault(); - this.props.handleInvite(this.props.member.id); - } - handleRemove(e) { - e.preventDefault(); - this.props.handleRemove(this.props.member.id); - } - handleMakeAdmin(e) { - e.preventDefault(); - this.props.handleMakeAdmin(this.props.member.id); - } - render() { - var member = this.props.member; - var isAdmin = this.props.isAdmin; - var isMemberAdmin = Utils.isAdmin(member.roles); - var timestamp = UserStore.getCurrentUser().update_at; - - var invite; - if (this.props.handleInvite) { - invite = ( - <a - onClick={this.handleInvite} - className='btn btn-sm btn-primary' - > - <i className='glyphicon glyphicon-envelope'/> - <FormattedMessage - id='member_item.add' - defaultMessage=' Add' - /> - </a> - ); - } else if (isAdmin && !isMemberAdmin && (member.id !== UserStore.getCurrentId())) { - var self = this; - - let makeAdminOption = null; - if (this.props.handleMakeAdmin) { - makeAdminOption = ( - <li role='presentation'> - <a - href='' - role='menuitem' - onClick={self.handleMakeAdmin} - > - <FormattedMessage - id='member_item.makeAdmin' - defaultMessage='Make Admin' - /> - </a> - </li>); - } - - let handleRemoveOption = null; - if (this.props.handleRemove) { - handleRemoveOption = ( - <li role='presentation'> - <a - href='' - role='menuitem' - onClick={self.handleRemove} - > - <FormattedMessage - id='member_item.removeMember' - defaultMessage='Remove Member' - /> - </a> - </li>); - } - - invite = ( - <div className='dropdown member-drop'> - <a - href='#' - className='dropdown-toggle theme' - type='button' - data-toggle='dropdown' - aria-expanded='true' - > - <span className='fa fa-pencil'></span> - <span className='text-capitalize'> - {member.roles || - <FormattedMessage - id='member_item.member' - defaultMessage='Member' - /> - } - </span> - </a> - <ul - className='dropdown-menu member-menu' - role='menu' - > - {makeAdminOption} - {handleRemoveOption} - </ul> - </div> - ); - } else { - invite = (<div className='member-role text-capitalize'><span className='fa fa-pencil hidden'></span>{member.roles || <FormattedMessage id='member_item.member'/>}</div>); - } - - return ( - <tr> - <td className='direct-channel'> - <img - className='profile-img pull-left' - src={'/api/v1/users/' + member.id + '/image?time=' + timestamp + '&' + Utils.getSessionIndex()} - height='36' - width='36' - /> - <div className='more-name'>{Utils.displayUsername(member.id)}</div> - <div className='more-description'>{member.email}</div> - </td> - <td className='td--action lg'>{invite}</td> - </tr> - ); - } -} - -MemberListItem.propTypes = { - handleInvite: React.PropTypes.func, - handleRemove: React.PropTypes.func, - handleMakeAdmin: React.PropTypes.func, - member: React.PropTypes.object, - isAdmin: React.PropTypes.bool -}; diff --git a/web/react/components/member_list_team.jsx b/web/react/components/member_list_team.jsx index f1c31131f..cfd5359b7 100644 --- a/web/react/components/member_list_team.jsx +++ b/web/react/components/member_list_team.jsx @@ -1,7 +1,8 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import MemberListTeamItem from './member_list_team_item.jsx'; +import FilteredUserList from './filtered_user_list.jsx'; +import TeamMembersDropdown from './team_members_dropdown.jsx'; import UserStore from '../stores/user_store.jsx'; export default class MemberListTeam extends React.Component { @@ -44,21 +45,16 @@ export default class MemberListTeam extends React.Component { } render() { - const memberList = this.state.users.map((user) => { - return ( - <MemberListTeamItem - key={user.id} - user={user} - /> - ); - }); - return ( - <table className='table more-table member-list-holder'> - <tbody> - {memberList} - </tbody> - </table> + <FilteredUserList + style={this.props.style} + users={this.state.users} + actions={[TeamMembersDropdown]} + /> ); } } + +MemberListTeam.propTypes = { + style: React.PropTypes.object +}; diff --git a/web/react/components/more_direct_channels.jsx b/web/react/components/more_direct_channels.jsx index 3b72b251c..0814ac1b3 100644 --- a/web/react/components/more_direct_channels.jsx +++ b/web/react/components/more_direct_channels.jsx @@ -2,36 +2,24 @@ // See License.txt for license information. const Modal = ReactBootstrap.Modal; +import FilteredUserList from './filtered_user_list.jsx'; import UserStore from '../stores/user_store.jsx'; import * as Utils from '../utils/utils.jsx'; -import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'mm-intl'; +import {FormattedMessage} from 'mm-intl'; -const holders = defineMessages({ - member: { - id: 'more_direct_channels.member', - defaultMessage: 'Member' - }, - search: { - id: 'more_direct_channels.search', - defaultMessage: 'Search members' - } -}); - -class MoreDirectChannels extends React.Component { +export default class MoreDirectChannels extends React.Component { constructor(props) { super(props); - this.handleFilterChange = this.handleFilterChange.bind(this); this.handleHide = this.handleHide.bind(this); this.handleShowDirectChannel = this.handleShowDirectChannel.bind(this); this.handleUserChange = this.handleUserChange.bind(this); - this.createRowForUser = this.createRowForUser.bind(this); + this.createJoinDirectChannelButton = this.createJoinDirectChannelButton.bind(this); this.state = { users: this.getUsersFromStore(), - filter: '', loadingDMChannel: -1 }; } @@ -60,39 +48,10 @@ class MoreDirectChannels extends React.Component { UserStore.removeChangeListener(this.handleUserChange); } - componentDidUpdate(prevProps) { - if (!prevProps.show && this.props.show) { - this.onShow(); - } - } - - onShow() { - if (Utils.isMobile()) { - $(ReactDOM.findDOMNode(this.refs.userList)).css('max-height', $(window).height() - 250); - } else { - $(ReactDOM.findDOMNode(this.refs.userList)).perfectScrollbar(); - $(ReactDOM.findDOMNode(this.refs.userList)).css('max-height', $(window).height() - 300); - } - } - - handleFilterChange() { - const filter = ReactDOM.findDOMNode(this.refs.filter).value; - - if ($(window).width() > 768) { - $(ReactDOM.findDOMNode(this.refs.userList)).scrollTop(0); - } - - if (filter !== this.state.filter) { - this.setState({filter}); - } - } - handleHide() { if (this.props.onModalDismissed) { this.props.onModalDismissed(); } - - this.setState({filter: ''}); } handleShowDirectChannel(teammate, e) { @@ -120,145 +79,39 @@ class MoreDirectChannels extends React.Component { this.setState({users: this.getUsersFromStore()}); } - createRowForUser(user) { - const details = []; - - const fullName = Utils.getFullName(user); - if (fullName) { - details.push( - <span - key={`${user.id}__full-name`} - className='full-name' - > - {fullName} - </span> - ); - } - - if (user.nickname) { - const separator = fullName ? ' - ' : ''; - details.push( - <span - key={`${user.nickname}__nickname`} - > - {separator + user.nickname} - </span> - ); - } - - let joinButton; + createJoinDirectChannelButton({user}) { if (this.state.loadingDMChannel === user.id) { - joinButton = ( + return ( <img className='channel-loading-gif' src='/static/images/load.gif' /> ); - } else { - joinButton = ( - <button - type='button' - className='btn btn-primary btn-message' - onClick={this.handleShowDirectChannel.bind(this, user)} - > - <FormattedMessage - id='more_direct_channels.message' - defaultMessage='Message' - /> - </button> - ); } return ( - <tr key={'direct-channel-row-user' + user.id}> - <td - key={user.id} - className='direct-channel' - > - <img - className='profile-img pull-left' - width='38' - height='38' - src={`/api/v1/users/${user.id}/image?time=${user.update_at}&${Utils.getSessionIndex()}`} - /> - <div className='more-name'> - {user.username} - </div> - <div className='more-description'> - {details} - </div> - </td> - <td className='td--action lg'> - {joinButton} - </td> - </tr> + <button + type='button' + className='btn btn-primary btn-message' + onClick={this.handleShowDirectChannel.bind(this, user)} + > + <FormattedMessage + id='more_direct_channels.message' + defaultMessage='Message' + /> + </button> ); } render() { - const {formatMessage} = this.props.intl; - if (!this.props.show) { - return null; - } - - let users = this.state.users; - if (this.state.filter) { - const filter = this.state.filter.toLowerCase(); - - users = users.filter((user) => { - return user.username.toLowerCase().indexOf(filter) !== -1 || - user.first_name.toLowerCase().indexOf(filter) !== -1 || - user.last_name.toLowerCase().indexOf(filter) !== -1 || - user.nickname.toLowerCase().indexOf(filter) !== -1; - }); - } - - const userEntries = users.map(this.createRowForUser); - - if (userEntries.length === 0) { - userEntries.push( - <tr key='no-users-found'><td> - <FormattedMessage - id='more_direct_channels.notFound' - defaultMessage='No users found :(' - /> - </td></tr>); - } - - let memberString = formatMessage(holders.member); - if (users.length !== 1) { - memberString += 's'; - } - - let count; - if (users.length === this.state.users.length) { - count = ( - <FormattedMessage - id='more_direct_channels.count' - defaultMessage='{count} {member}' - values={{ - count: users.length, - member: memberString - }} - /> - ); - } else { - count = ( - <FormattedMessage - id='more_direct_channels.countTotal' - defaultMessage='{count} {member} of {total} Total' - values={{ - count: users.length, - member: memberString, - total: this.state.users.length - }} - /> - ); + let maxHeight = 1000; + if (Utils.windowHeight() <= 1200) { + maxHeight = Utils.windowHeight() - 300; } return ( <Modal - dialogClassName='more-modal' + dialogClassName='more-modal more-direct-channels' show={this.props.show} onHide={this.handleHide} > @@ -270,30 +123,12 @@ class MoreDirectChannels extends React.Component { /> </Modal.Title> </Modal.Header> - <Modal.Body ref='modalBody'> - <div className='filter-row'> - <div className='col-sm-6'> - <input - ref='filter' - className='form-control filter-textbox' - placeholder={formatMessage(holders.search)} - onInput={this.handleFilterChange} - /> - </div> - <div className='col-sm-6'> - <span className='member-count'>{count}</span> - </div> - </div> - <div - ref='userList' - className='user-list' - > - <table className='more-table table'> - <tbody> - {userEntries} - </tbody> - </table> - </div> + <Modal.Body> + <FilteredUserList + style={{maxHeight}} + users={this.state.users} + actions={[this.createJoinDirectChannelButton]} + /> </Modal.Body> <Modal.Footer> <button @@ -313,9 +148,6 @@ class MoreDirectChannels extends React.Component { } MoreDirectChannels.propTypes = { - intl: intlShape.isRequired, show: React.PropTypes.bool.isRequired, onModalDismissed: React.PropTypes.func }; - -export default injectIntl(MoreDirectChannels); diff --git a/web/react/components/member_list_team_item.jsx b/web/react/components/team_members_dropdown.jsx index 23bc10781..8aacba8ca 100644 --- a/web/react/components/member_list_team_item.jsx +++ b/web/react/components/team_members_dropdown.jsx @@ -9,28 +9,9 @@ import * as Utils from '../utils/utils.jsx'; import ConfirmModal from './confirm_modal.jsx'; import TeamStore from '../stores/team_store.jsx'; -import {injectIntl, intlShape, defineMessages, FormattedMessage} from 'mm-intl'; +import {FormattedMessage} from 'mm-intl'; -var holders = defineMessages({ - confirmDemoteRoleTitle: { - id: 'member_team_item.confirmDemoteRoleTitle', - defaultMessage: 'Confirm demotion from System Admin role' - }, - confirmDemotion: { - id: 'member_team_item.confirmDemotion', - defaultMessage: 'Confirm Demotion' - }, - confirmDemoteDescription: { - id: 'member_team_item.confirmDemoteDescription', - defaultMessage: 'If you demote yourself from the System Admin role and there is not another user with System Admin privileges, you\'ll need to re-assign a System Admin by accessing the Mattermost server through a terminal and running the following command.' - }, - confirmDemotionCmd: { - id: 'member_team_item.confirmDemotionCmd', - defaultMessage: 'platform -assign_role -team_name="yourteam" -email="name@yourcompany.com" -role="system_admin"' - } -}); - -export default class MemberListTeamItem extends React.Component { +export default class TeamMembersDropdown extends React.Component { constructor(props) { super(props); @@ -159,24 +140,23 @@ export default class MemberListTeamItem extends React.Component { const user = this.props.user; let currentRoles = ( <FormattedMessage - id='member_team_item.member' + id='team_members_dropdown.member' defaultMessage='Member' /> ); - const timestamp = UserStore.getCurrentUser().update_at; if (user.roles.length > 0) { if (Utils.isSystemAdmin(user.roles)) { currentRoles = ( <FormattedMessage - id='member_team_item.systemAdmin' + id='team_members_dropdown.systemAdmin' defaultMessage='System Admin' /> ); } else if (Utils.isAdmin(user.roles)) { currentRoles = ( <FormattedMessage - id='member_team_item.teamAdmin' + id='team_members_dropdown.teamAdmin' defaultMessage='Team Admin' /> ); @@ -185,7 +165,6 @@ export default class MemberListTeamItem extends React.Component { } } - const email = user.email; let showMakeMember = user.roles === 'admin' || user.roles === 'system_admin'; let showMakeAdmin = user.roles === '' || user.roles === 'system_admin'; let showMakeActive = false; @@ -194,7 +173,7 @@ export default class MemberListTeamItem extends React.Component { if (user.delete_at > 0) { currentRoles = ( <FormattedMessage - id='member_team_item.inactive' + id='team_members_dropdown.inactive' defaultMessage='Inactive' /> ); @@ -214,7 +193,7 @@ export default class MemberListTeamItem extends React.Component { onClick={this.handleMakeAdmin} > <FormattedMessage - id='member_team_item.makeAdmin' + id='team_members_dropdown.makeAdmin' defaultMessage='Make Team Admin' /> </a> @@ -232,7 +211,7 @@ export default class MemberListTeamItem extends React.Component { onClick={this.handleMakeMember} > <FormattedMessage - id='member_team_item.makeMember' + id='team_members_dropdown.makeMember' defaultMessage='Make Member' /> </a> @@ -250,7 +229,7 @@ export default class MemberListTeamItem extends React.Component { onClick={this.handleMakeActive} > <FormattedMessage - id='member_team_item.makeActive' + id='team_members_dropdown.makeActive' defaultMessage='Make Active' /> </a> @@ -268,7 +247,7 @@ export default class MemberListTeamItem extends React.Component { onClick={this.handleMakeNotActive} > <FormattedMessage - id='member_team_item.makeInactive' + id='team_members_dropdown.makeInactive' defaultMessage='Make Inactive' /> </a> @@ -276,15 +255,44 @@ export default class MemberListTeamItem extends React.Component { ); } const me = UserStore.getCurrentUser(); - const {formatMessage} = this.props.intl; let makeDemoteModal = null; if (this.props.user.id === me.id) { + const title = ( + <FormattedMessage + id='team_members_dropdown.confirmDemoteRoleTitle' + defaultMessage='Confirm demotion from System Admin role' + /> + ); + + const message = ( + <div> + <FormattedMessage + id='team_members_dropdown.confirmDemoteDescription' + defaultMessage="If you demote yourself from the System Admin role and there is not another user with System Admin privileges, you'll need to re-assign a System Admin by accessing the Mattermost server through a terminal and running the following command." + /> + <br/> + <br/> + <FormattedMessage + id='team_members_dropdown.confirmDemotionCmd' + defaultMessage='platform -assign_role -team_name="yourteam" -email="name@yourcompany.com" -role="system_admin"' + /> + {serverError} + </div> + ); + + const confirmButton = ( + <FormattedMessage + id='team_members_dropdown.confirmDemotion' + defaultMessage='Confirm Demotion' + /> + ); + makeDemoteModal = ( <ConfirmModal show={this.state.showDemoteModal} - title={formatMessage(holders.confirmDemoteRoleTitle)} - message={[formatMessage(holders.confirmDemoteDescription), React.createElement('br'), React.createElement('br'), formatMessage(holders.confirmDemotionCmd), serverError]} - confirm_button={formatMessage(holders.confirmDemotion)} + title={title} + message={message} + confirmButton={confirmButton} onConfirm={this.handleDemoteSubmit} onCancel={this.handleDemoteCancel} /> @@ -292,48 +300,33 @@ export default class MemberListTeamItem extends React.Component { } return ( - <tr> - <td className='row member-div'> - <img - className='post-profile-img pull-left' - src={`/api/v1/users/${user.id}/image?time=${timestamp}&${Utils.getSessionIndex()}`} - height='36' - width='36' - /> - <span className='more-name'>{Utils.displayUsername(user.id)}</span> - <span className='more-description'>{email}</span> - <div className='dropdown member-drop'> - <a - href='#' - className='dropdown-toggle theme' - type='button' - data-toggle='dropdown' - aria-expanded='true' - > - <span className='fa fa-pencil'></span> - <span>{currentRoles} </span> - </a> - <ul - className='dropdown-menu member-menu' - role='menu' - > - {makeAdmin} - {makeMember} - {makeActive} - {makeNotActive} - </ul> - </div> - {makeDemoteModal} - {serverError} - </td> - </tr> + <div className='dropdown member-drop'> + <a + href='#' + className='dropdown-toggle theme' + type='button' + data-toggle='dropdown' + aria-expanded='true' + > + <span className='fa fa-pencil'></span> + <span>{currentRoles} </span> + </a> + <ul + className='dropdown-menu member-menu' + role='menu' + > + {makeAdmin} + {makeMember} + {makeActive} + {makeNotActive} + </ul> + {makeDemoteModal} + {serverError} + </div> ); } } -MemberListTeamItem.propTypes = { - intl: intlShape.isRequired, +TeamMembersDropdown.propTypes = { user: React.PropTypes.object.isRequired }; - -export default injectIntl(MemberListTeamItem); diff --git a/web/react/components/team_members_modal.jsx b/web/react/components/team_members_modal.jsx index 8ac435742..9bdb16438 100644 --- a/web/react/components/team_members_modal.jsx +++ b/web/react/components/team_members_modal.jsx @@ -3,45 +3,24 @@ import MemberListTeam from './member_list_team.jsx'; import TeamStore from '../stores/team_store.jsx'; +import * as Utils from '../utils/utils.jsx'; import {FormattedMessage} from 'mm-intl'; const Modal = ReactBootstrap.Modal; export default class TeamMembersModal extends React.Component { - constructor(props) { - super(props); - - this.onShow = this.onShow.bind(this); - } - - componentDidMount() { - if (this.props.show) { - this.onShow(); - } - } - - componentDidUpdate(prevProps) { - if (this.props.show && !prevProps.show) { - this.onShow(); - } - } - - onShow() { - if ($(window).width() > 768) { - $(ReactDOM.findDOMNode(this.refs.modalBody)).perfectScrollbar(); - $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 200); - } else { - $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 150); - } - } - render() { const team = TeamStore.getCurrent(); + let maxHeight = 1000; + if (Utils.windowHeight() <= 1200) { + maxHeight = Utils.windowHeight() - 300; + } + return ( <Modal - dialogClassName='team-members-modal' + dialogClassName='more-modal' show={this.props.show} onHide={this.props.onHide} > @@ -54,10 +33,8 @@ export default class TeamMembersModal extends React.Component { }} /> </Modal.Header> - <Modal.Body ref='modalBody'> - <div className='team-member-list'> - <MemberListTeam/> - </div> + <Modal.Body> + <MemberListTeam style={{maxHeight}}/> </Modal.Body> <Modal.Footer> <button diff --git a/web/react/components/user_list.jsx b/web/react/components/user_list.jsx new file mode 100644 index 000000000..39453a827 --- /dev/null +++ b/web/react/components/user_list.jsx @@ -0,0 +1,53 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {FormattedMessage} from 'mm-intl'; +import UserListRow from './user_list_row.jsx'; + +export default class UserList extends React.Component { + render() { + const users = this.props.users; + + let content; + if (users.length > 0) { + content = users.map((user) => { + return ( + <UserListRow + key={user.id} + user={user} + actions={this.props.actions} + /> + ); + }); + } else { + content = ( + <tr key='no-users-found'> + <td> + <FormattedMessage + id='user_list.notFound' + defaultMessage='No users found :(' + /> + </td> + </tr> + ); + } + + return ( + <table className='more-table table'> + <tbody> + {content} + </tbody> + </table> + ); + } +} + +UserList.defaultProps = { + users: [], + actions: [] +}; + +UserList.propTypes = { + users: React.PropTypes.arrayOf(React.PropTypes.object), + actions: React.PropTypes.arrayOf(React.PropTypes.func) +}; diff --git a/web/react/components/user_list_row.jsx b/web/react/components/user_list_row.jsx new file mode 100644 index 000000000..2aeca7d47 --- /dev/null +++ b/web/react/components/user_list_row.jsx @@ -0,0 +1,65 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import Constants from '../utils/constants.jsx'; +import PreferenceStore from '../stores/preference_store.jsx'; +import * as Utils from '../utils/utils.jsx'; + +export default function UserListRow({user, actions}) { + const nameFormat = PreferenceStore.get(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', ''); + + let name = user.username; + if (user.nickname && nameFormat === Constants.Preferences.DISPLAY_PREFER_NICKNAME) { + name = `${user.nickname} (${user.username})`; + } else if ((user.first_name || user.last_name) && (nameFormat === Constants.Preferences.DISPLAY_PREFER_NICKNAME || nameFormat === Constants.Preferences.DISPLAY_PREFER_FULL_NAME)) { + name = `${Utils.getFullName(user)} (${user.username})`; + } + + const buttons = actions.map((Action, index) => { + return ( + <Action + key={index.toString()} + user={user} + /> + ); + }); + + return ( + <tr> + <td + key={user.id} + className='direct-channel' + style={{display: 'flex'}} + > + <img + className='profile-img' + src={`/api/v1/users/${user.id}/image?time=${user.update_at}&${Utils.getSessionIndex()}`} + /> + <div + className='user-list-item__details' + > + <div className='more-name'> + {name} + </div> + <div className='more-description'> + {user.email} + </div> + </div> + <div + className='user-list-item__actions' + > + {buttons} + </div> + </td> + </tr> + ); +} + +UserListRow.defaultProps = { + actions: [] +}; + +UserListRow.propTypes = { + user: React.PropTypes.object.isRequired, + actions: React.PropTypes.arrayOf(React.PropTypes.func) +}; diff --git a/web/react/components/user_settings/manage_languages.jsx b/web/react/components/user_settings/manage_languages.jsx index fee6d9da2..2d1c74717 100644 --- a/web/react/components/user_settings/manage_languages.jsx +++ b/web/react/components/user_settings/manage_languages.jsx @@ -1,6 +1,8 @@ // Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. // See License.txt for license information. +import SettingItemMax from '../setting_item_max.jsx'; + import * as Client from '../../utils/client.jsx'; import * as Utils from '../../utils/utils.jsx'; @@ -69,7 +71,7 @@ export default class ManageLanguage extends React.Component { </option>); }); - return ( + const input = ( <div key='changeLanguage'> <br/> <label className='control-label'> @@ -88,24 +90,28 @@ export default class ManageLanguage extends React.Component { {options} </select> {serverError} - <div className='padding-top'> - <a - className={'btn btn-sm btn-primary'} - href='#' - onClick={this.changeLanguage} - > - <FormattedMessage - id='user.settings.languages' - defaultMessage='Set language' - /> - </a> - </div> </div> </div> ); + + return ( + <SettingItemMax + title={ + <FormattedMessage + id='user.settings.display.language' + defaultMessage='Language' + /> + } + width='medium' + submit={this.changeLanguage} + inputs={[input]} + updateSection={this.props.updateSection} + /> + ); } } ManageLanguage.propTypes = { - user: React.PropTypes.object -};
\ No newline at end of file + user: React.PropTypes.object.isRequired, + updateSection: React.PropTypes.func.isRequired +}; diff --git a/web/react/components/user_settings/user_settings_display.jsx b/web/react/components/user_settings/user_settings_display.jsx index 5f23a8995..3e468e08f 100644 --- a/web/react/components/user_settings/user_settings_display.jsx +++ b/web/react/components/user_settings/user_settings_display.jsx @@ -12,46 +12,7 @@ import * as Utils from '../../utils/utils.jsx'; import Constants from '../../utils/constants.jsx'; import {savePreferences} from '../../utils/client.jsx'; -import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'mm-intl'; - -const holders = defineMessages({ - normalClock: { - id: 'user.settings.display.normalClock', - defaultMessage: '12-hour clock (example: 4:00 PM)' - }, - militaryClock: { - id: 'user.settings.display.militaryClock', - defaultMessage: '24-hour clock (example: 16:00)' - }, - clockDisplay: { - id: 'user.settings.display.clockDisplay', - defaultMessage: 'Clock Display' - }, - teammateDisplay: { - id: 'user.settings.display.teammateDisplay', - defaultMessage: 'Teammate Name Display' - }, - showNickname: { - id: 'user.settings.display.showNickname', - defaultMessage: 'Show nickname if one exists, otherwise show first and last name' - }, - showUsername: { - id: 'user.settings.display.showUsername', - defaultMessage: 'Show username (team default)' - }, - showFullname: { - id: 'user.settings.display.showFullname', - defaultMessage: 'Show first and last name' - }, - fontTitle: { - id: 'user.settings.display.fontTitle', - defaultMessage: 'Display Font' - }, - language: { - id: 'user.settings.display.language', - defaultMessage: 'Language' - } -}); +import {FormattedMessage} from 'mm-intl'; function getDisplayStateFromStores() { const militaryTime = PreferenceStore.getPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', {value: 'false'}); @@ -65,7 +26,7 @@ function getDisplayStateFromStores() { }; } -class UserSettingsDisplay extends React.Component { +export default class UserSettingsDisplay extends React.Component { constructor(props) { super(props); @@ -119,7 +80,6 @@ class UserSettingsDisplay extends React.Component { this.updateState(); } render() { - const {formatMessage} = this.props.intl; const serverError = this.state.serverError || null; let clockSection; let nameFormatSection; @@ -181,7 +141,12 @@ class UserSettingsDisplay extends React.Component { clockSection = ( <SettingItemMax - title={formatMessage(holders.clockDisplay)} + title={ + <FormattedMessage + id='user.settings.display.clockDisplay' + defaultMessage='Clock Display' + /> + } inputs={inputs} submit={this.handleSubmit} server_error={serverError} @@ -189,11 +154,21 @@ class UserSettingsDisplay extends React.Component { /> ); } else { - let describe = ''; + let describe; if (this.state.militaryTime === 'true') { - describe = formatMessage(holders.militaryClock); + describe = ( + <FormattedMessage + id='user.settings.display.militaryClock' + defaultMessage='24-hour clock (example: 16:00)' + /> + ); } else { - describe = formatMessage(holders.normalClock); + describe = ( + <FormattedMessage + id='user.settings.display.normalClock' + defaultMessage='12-hour clock (example: 4:00 PM)' + /> + ); } const handleUpdateClockSection = () => { @@ -202,7 +177,12 @@ class UserSettingsDisplay extends React.Component { clockSection = ( <SettingItemMin - title={formatMessage(holders.clockDisplay)} + title={ + <FormattedMessage + id='user.settings.display.clockDisplay' + defaultMessage='Clock Display' + /> + } describe={describe} updateSection={handleUpdateClockSection} /> @@ -284,7 +264,12 @@ class UserSettingsDisplay extends React.Component { nameFormatSection = ( <SettingItemMax - title={formatMessage(holders.teammateDisplay)} + title={ + <FormattedMessage + id='user.settings.display.teammateDisplay' + defaultMessage='Teammate Name Display' + /> + } inputs={inputs} submit={this.handleSubmit} server_error={serverError} @@ -295,18 +280,38 @@ class UserSettingsDisplay extends React.Component { /> ); } else { - let describe = ''; + let describe; if (this.state.nameFormat === 'username') { - describe = formatMessage(holders.showUsername); + describe = ( + <FormattedMessage + id='user.settings.display.showUsername' + defaultMessage='Show username (team default)' + /> + ); } else if (this.state.nameFormat === 'full_name') { - describe = formatMessage(holders.showFullname); + describe = ( + <FormattedMessage + id='user.settings.display.showFullname' + defaultMessage='Show first and last name' + /> + ); } else { - describe = formatMessage(holders.showNickname); + describe = ( + <FormattedMessage + id='user.settings.display.showNickname' + defaultMessage='Show nickname if one exists, otherwise show first and last name' + /> + ); } nameFormatSection = ( <SettingItemMin - title={formatMessage(holders.teammateDisplay)} + title={ + <FormattedMessage + id='user.settings.display.teammateDisplay' + defaultMessage='Teammate Name Display' + /> + } describe={describe} updateSection={() => { this.props.updateSection('name_format'); @@ -356,7 +361,12 @@ class UserSettingsDisplay extends React.Component { fontSection = ( <SettingItemMax - title={formatMessage(holders.fontTitle)} + title={ + <FormattedMessage + id='user.settings.display.fontTitle' + defaultMessage='Display Font' + /> + } inputs={inputs} submit={this.handleSubmit} server_error={serverError} @@ -369,7 +379,12 @@ class UserSettingsDisplay extends React.Component { } else { fontSection = ( <SettingItemMin - title={formatMessage(holders.fontTitle)} + title={ + <FormattedMessage + id='user.settings.display.fontTitle' + defaultMessage='Display Font' + /> + } describe={this.state.selectedFont} updateSection={() => { this.props.updateSection('font'); @@ -379,19 +394,9 @@ class UserSettingsDisplay extends React.Component { } if (this.props.activeSection === 'languages') { - var inputs = []; - inputs.push( + languagesSection = ( <ManageLanguages user={this.props.user} - key='languages-ui' - /> - ); - - languagesSection = ( - <SettingItemMax - title={formatMessage(holders.language)} - width='medium' - inputs={inputs} updateSection={(e) => { this.updateSection(''); e.preventDefault(); @@ -408,7 +413,12 @@ class UserSettingsDisplay extends React.Component { languagesSection = ( <SettingItemMin - title={formatMessage(holders.language)} + title={ + <FormattedMessage + id='user.settings.display.language' + defaultMessage='Language' + /> + } width='medium' describe={locale} updateSection={() => { @@ -452,12 +462,12 @@ class UserSettingsDisplay extends React.Component { /> </h3> <div className='divider-dark first'/> - <ThemeSetting - selected={this.props.activeSection === 'theme'} - updateSection={this.updateSection} - setRequireConfirm={this.props.setRequireConfirm} - setEnforceFocus={this.props.setEnforceFocus} - /> + <ThemeSetting + selected={this.props.activeSection === 'theme'} + updateSection={this.updateSection} + setRequireConfirm={this.props.setRequireConfirm} + setEnforceFocus={this.props.setEnforceFocus} + /> <div className='divider-dark'/> {fontSection} <div className='divider-dark'/> @@ -473,7 +483,6 @@ class UserSettingsDisplay extends React.Component { } UserSettingsDisplay.propTypes = { - intl: intlShape.isRequired, user: React.PropTypes.object, updateSection: React.PropTypes.func, updateTab: React.PropTypes.func, @@ -483,5 +492,3 @@ UserSettingsDisplay.propTypes = { setRequireConfirm: React.PropTypes.func.isRequired, setEnforceFocus: React.PropTypes.func.isRequired }; - -export default injectIntl(UserSettingsDisplay); |