Added initial backstage components and InstalledIntegrations page

This commit is contained in:
Harrison Healey
2016-03-17 10:30:49 -04:00
parent 9c36210edd
commit c417fdc152
14 changed files with 1045 additions and 8 deletions

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import {Link} from 'react-router';
export default class BackstageCategory extends React.Component {
static get propTypes() {
return {
name: React.PropTypes.string.isRequired,
title: React.PropTypes.node.isRequired,
icon: React.PropTypes.string.isRequired,
parentLink: React.PropTypes.string,
children: React.PropTypes.arrayOf(React.PropTypes.element)
};
}
static get defaultProps() {
return {
parentLink: '',
children: []
};
}
static get contextTypes() {
return {
router: React.PropTypes.object.isRequired
};
}
render() {
const {name, title, icon, parentLink, children} = this.props;
const link = parentLink + '/' + name;
let clonedChildren = null;
if (children.length > 0 && this.context.router.isActive(link)) {
clonedChildren = (
<ul className='sections'>
{
React.Children.map(children, (child) => {
return React.cloneElement(child, {
parentLink: link
});
})
}
</ul>
);
}
return (
<li className='backstage__sidebar__category'>
<Link
to={link}
className='category-title'
activeClassName='category-title--active'
>
<i className={'fa ' + icon}/>
<span className='category-title__text'>
{title}
</span>
</Link>
{clonedChildren}
</li>
);
}
}

View File

@@ -0,0 +1,62 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import TeamStore from 'stores/team_store.jsx';
import {FormattedMessage} from 'react-intl';
import {Link} from 'react-router';
export default class BackstageNavbar extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.state = {
team: TeamStore.getCurrent()
};
}
componentDidMount() {
TeamStore.addChangeListener(this.handleChange);
}
componentWillUnmount() {
TeamStore.removeChangeListener(this.handleChange);
}
handleChange() {
this.setState({
team: TeamStore.getCurrent()
});
}
render() {
if (!this.state.team) {
return null;
}
return (
<div className='backstage__navbar row'>
<Link
className='backstage__navbar__back'
to={`/${this.state.team.display_name}/channels/town-square`}
>
<i className='fa fa-angle-left'/>
<span>
<FormattedMessage
id='backstage.back_to_mattermost'
defaultMessage='Back to {siteName}'
values={{
siteName: global.window.mm_config.SiteName
}}
/>
</span>
</Link>
<span style={{float: 'right'}}>{'TODO: Switch Teams'}</span>
</div>
);
}
}

View File

@@ -0,0 +1,122 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import {Link} from 'react-router';
export default class BackstageSection extends React.Component {
static get propTypes() {
return {
name: React.PropTypes.string.isRequired,
title: React.PropTypes.node.isRequired,
parentLink: React.PropTypes.string,
subsection: React.PropTypes.bool,
children: React.PropTypes.arrayOf(React.PropTypes.element)
};
}
static get defaultProps() {
return {
parentLink: '',
subsection: false,
children: []
};
}
static get contextTypes() {
return {
router: React.PropTypes.object.isRequired
};
}
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
this.state = {
expanded: true
};
}
getLink() {
return this.props.parentLink + '/' + this.props.name;
}
isActive() {
const link = this.getLink();
return this.context.router.isActive(link);
}
handleClick(e) {
if (this.isActive()) {
// we're already on this page so just toggle the link
e.preventDefault();
this.setState({
expanded: !this.state.expanded
});
}
// otherwise, just follow the link
}
render() {
const {title, subsection, children} = this.props;
const link = this.getLink();
const active = this.isActive();
// act like docs.mattermost.com and only expand if this link is active
const expanded = active && this.state.expanded;
let toggle = null;
if (children.length > 0) {
if (expanded) {
toggle = <i className='fa fa-minus-square-o'/>;
} else {
toggle = <i className='fa fa-plus-square-o'/>;
}
}
let clonedChildren = null;
if (children.length > 0 && expanded) {
clonedChildren = (
<ul className='subsections'>
{
React.Children.map(children, (child) => {
return React.cloneElement(child, {
parentLink: link,
subsection: true
});
})
}
</ul>
);
}
let className = 'section';
if (subsection) {
className = 'subsection';
}
return (
<li className={className}>
<Link
className={`${className}-title`}
activeClassName={`${className}-title--active`}
onClick={this.handleClick}
to={link}
>
{toggle}
<span className={`${className}-title__text`}>
{title}
</span>
</Link>
{clonedChildren}
</li>
);
}
}

View File

@@ -0,0 +1,113 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import TeamStore from 'stores/team_store.jsx';
import BackstageCategory from './backstage_category.jsx';
import BackstageSection from './backstage_section.jsx';
import {FormattedMessage} from 'react-intl';
export default class BackstageSidebar extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.state = {
team: TeamStore.getCurrent()
};
}
componentDidMount() {
TeamStore.addChangeListener(this.handleChange);
}
componentWillUnmount() {
TeamStore.removeChangeListener(this.handleChange);
}
handleChange() {
this.setState({
team: TeamStore.getCurrent()
});
}
render() {
const team = TeamStore.getCurrent();
if (!team) {
return null;
}
return (
<div className='backstage__sidebar'>
<ul>
<BackstageCategory
name='team_settings'
parentLink={`/${team.name}`}
icon='fa-users'
title={
<FormattedMessage
id='backstage.team_settings'
defaultMessage='Team Settings'
/>
}
/>
<BackstageCategory
name='integrations'
parentLink={`/${team.name}`}
icon='fa-link'
title={
<FormattedMessage
id='backstage.integrations'
defaultMessage='Integrations'
/>
}
>
<BackstageSection
name='installed'
title={(
<FormattedMessage
id='backstage.integrations.installed'
defaultMessage='Installed Integrations'
/>
)}
/>
<BackstageSection
name='add'
title={(
<FormattedMessage
id='backstage.integrations.add'
defaultMessage='Add Integration'
/>
)}
collapsible={true}
>
<BackstageSection
name='incoming_webhook'
title={(
<FormattedMessage
id='backstage.integrations.add.incomingWebhook'
defaultMessage='Incoming Webhook'
/>
)}
/>
<BackstageSection
name='outgoing_webhook'
title={(
<FormattedMessage
id='backstage.integrations.add.outgoingWebhook'
defaultMessage='Outgoing Webhook'
/>
)}
/>
</BackstageSection>
</BackstageCategory>
</ul>
</div>
);
}
}

View File

@@ -0,0 +1,304 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import * as AsyncClient from 'utils/async_client.jsx';
import ChannelStore from 'stores/channel_store.jsx';
import IntegrationStore from 'stores/integration_store.jsx';
import * as Utils from 'utils/utils.jsx';
import {FormattedMessage} from 'react-intl';
import {Link} from 'react-router';
export default class InstalledIntegrations extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.setFilter = this.setFilter.bind(this);
this.state = {
incomingWebhooks: [],
outgoingWebhooks: [],
filter: ''
};
}
componentWillMount() {
IntegrationStore.addChangeListener(this.handleChange);
if (window.mm_config.EnableIncomingWebhooks === 'true') {
if (IntegrationStore.hasReceivedIncomingWebhooks()) {
this.setState({
incomingWebhooks: IntegrationStore.getIncomingWebhooks()
});
} else {
AsyncClient.listIncomingHooks();
}
}
if (window.mm_config.EnableOutgoingWebhooks === 'true') {
if (IntegrationStore.hasReceivedOutgoingWebhooks()) {
this.setState({
outgoingWebhooks: IntegrationStore.getOutgoingWebhooks()
});
} else {
AsyncClient.listOutgoingHooks();
}
}
}
componentWillUnmount() {
IntegrationStore.removeChangeListener(this.handleChange);
}
handleChange() {
this.setState({
incomingWebhooks: IntegrationStore.getIncomingWebhooks(),
outgoingWebhooks: IntegrationStore.getOutgoingWebhooks()
});
}
setFilter(e, filter) {
e.preventDefault();
this.setState({
filter
});
}
renderTypeFilters(incomingWebhooks, outgoingWebhooks) {
const fields = [];
if (incomingWebhooks.length > 0 || outgoingWebhooks.length > 0) {
let filterClassName = 'type-filter';
if (this.state.filter === '') {
filterClassName += ' type-filter--selected';
}
fields.push(
<a
key='allFilter'
className={filterClassName}
href='#'
onClick={(e) => this.setFilter(e, '')}
>
<FormattedMessage
id='installed_integrations.allFilter'
defaultMessage='All ({count})'
values={{
count: incomingWebhooks.length + outgoingWebhooks.length
}}
/>
</a>
);
}
if (incomingWebhooks.length > 0) {
fields.push(
<span
key='incomingWebhooksDivider'
className='divider'
>
{'|'}
</span>
);
let filterClassName = 'type-filter';
if (this.state.filter === 'incomingWebhooks') {
filterClassName += ' type-filter--selected';
}
fields.push(
<a
key='incomingWebhooksFilter'
className={filterClassName}
href='#'
onClick={(e) => this.setFilter(e, 'incomingWebhooks')}
>
<FormattedMessage
id='installed_integrations.incomingWebhooksFilter'
defaultMessage='Incoming Webhooks ({count})'
values={{
count: incomingWebhooks.length
}}
/>
</a>
);
}
if (outgoingWebhooks.length > 0) {
fields.push(
<span
key='outgoingWebhooksDivider'
className='divider'
>
{'|'}
</span>
);
let filterClassName = 'type-filter';
if (this.state.filter === 'outgoingWebhooks') {
filterClassName += ' type-filter--selected';
}
fields.push(
<a
key='outgoingWebhooksFilter'
className={filterClassName}
href='#'
onClick={(e) => this.setFilter(e, 'outgoingWebhooks')}
>
<FormattedMessage
id='installed_integrations.outgoingWebhooksFilter'
defaultMessage='Outgoing Webhooks ({count})'
values={{
count: outgoingWebhooks.length
}}
/>
</a>
);
}
return (
<div className='type-filters'>
{fields}
</div>
);
}
render() {
const incomingWebhooks = this.state.incomingWebhooks;
const outgoingWebhooks = this.state.outgoingWebhooks;
const integrations = [];
if (!this.state.filter || this.state.filter === 'incomingWebhooks') {
for (const incomingWebhook of incomingWebhooks) {
integrations.push(
<IncomingWebhook
key={incomingWebhook.id}
incomingWebhook={incomingWebhook}
/>
);
}
}
if (!this.state.filter || this.state.filter === 'outgoingWebhooks') {
for (const outgoingWebhook of outgoingWebhooks) {
integrations.push(
<OutgoingWebhook
key={outgoingWebhook.id}
outgoingWebhook={outgoingWebhook}
/>
);
}
}
return (
<div className='backstage row'>
<div className='installed-integrations'>
<div className='installed-integrations__header'>
<h1 className='text'>
<FormattedMessage
id='installed_integrations.header'
defaultMessage='Installed Integrations'
/>
</h1>
<Link
className='add-integrations-link'
to={'/yourteamhere/integrations/add'}
>
<button
type='button'
className='btn btn-primary'
>
<span>
<FormattedMessage
id='installed_integrations.add'
defaultMessage='Add Integration'
/>
</span>
</button>
</Link>
</div>
<div className='installed-integrations__filters'>
{this.renderTypeFilters(this.state.incomingWebhooks, this.state.outgoingWebhooks)}
<input
type='search'
placeholder={Utils.localizeMessage('installed_integrations.search', 'Search Integrations')}
style={{flexGrow: 0, flexShrink: 0}}
/>
</div>
<div className='installed-integrations__list'>
{integrations}
</div>
</div>
</div>
);
}
}
function IncomingWebhook({incomingWebhook}) {
const channel = ChannelStore.get(incomingWebhook.channel_id);
const channelName = channel ? channel.display_name : 'cannot find channel';
return (
<div className='installed-integrations__item installed-integrations__incoming-webhook'>
<div className='details'>
<div className='details-row'>
<span className='name'>
{channelName}
</span>
<span className='type'>
<FormattedMessage
id='installed_integrations.incomingWebhookType'
defaultMessage='(Incoming Webhook)'
/>
</span>
</div>
<div className='details-row'>
<span className='description'>
{Utils.getWindowLocationOrigin() + '/hooks/' + incomingWebhook.id}
</span>
</div>
</div>
</div>
);
}
IncomingWebhook.propTypes = {
incomingWebhook: React.PropTypes.object.isRequired
};
function OutgoingWebhook({outgoingWebhook}) {
const channel = ChannelStore.get(outgoingWebhook.channel_id);
const channelName = channel ? channel.display_name : 'cannot find channel';
return (
<div className='installed-integrations__item installed-integrations__outgoing-webhook'>
<div className='details'>
<div className='details-row'>
<span className='name'>
{channelName}
</span>
<span className='type'>
<FormattedMessage
id='installed_integrations.outgoingWebhookType'
defaultMessage='(Outgoing Webhook)'
/>
</span>
</div>
<div className='details-row'>
<span className='description'>
{Utils.getWindowLocationOrigin() + '/hooks/' + outgoingWebhook.id}
</span>
</div>
</div>
</div>
);
}
OutgoingWebhook.propTypes = {
outgoingWebhook: React.PropTypes.object.isRequired
};

View File

@@ -199,6 +199,9 @@ export default class LoggedIn extends React.Component {
if (this.props.children) {
content = this.props.children;
} else {
content.push(
this.props.navbar
);
content.push(
this.props.sidebar
);
@@ -247,8 +250,9 @@ LoggedIn.defaultProps = {
};
LoggedIn.propTypes = {
children: React.PropTypes.object,
sidebar: React.PropTypes.object,
center: React.PropTypes.object,
children: React.PropTypes.arrayOf(React.PropTypes.element),
navbar: React.PropTypes.element,
sidebar: React.PropTypes.element,
center: React.PropTypes.element,
params: React.PropTypes.object
};

View File

@@ -36,6 +36,9 @@ import ShouldVerifyEmail from 'components/should_verify_email.jsx';
import DoVerifyEmail from 'components/do_verify_email.jsx';
import AdminConsole from 'components/admin_console/admin_controller.jsx';
import TutorialView from 'components/tutorial/tutorial_view.jsx';
import BackstageNavbar from 'components/backstage/backstage_navbar.jsx';
import BackstageSidebar from 'components/backstage/backstage_sidebar.jsx';
import InstalledIntegrations from 'components/backstage/installed_integrations.jsx';
import SignupTeamComplete from 'components/signup_team_complete/components/signup_team_complete.jsx';
import WelcomePage from 'components/signup_team_complete/components/team_signup_welcome_page.jsx';
@@ -241,6 +244,51 @@ function renderRootComponent() {
path=':team/logout'
onEnter={onLoggedOut}
/>
<Route
path=':team/team_settings'
components={{
navbar: BackstageNavbar,
sidebar: BackstageSidebar,
center: null
}}
/>
<Route path=':team/integrations'>
<IndexRedirect to='installed'/>
<Route
path='installed'
components={{
navbar: BackstageNavbar,
sidebar: BackstageSidebar,
center: InstalledIntegrations
}}
/>
<Route path='add'>
<IndexRoute
components={{
navbar: BackstageNavbar,
sidebar: BackstageSidebar,
center: null
}}
/>
<Route
path='incoming_webhook'
components={{
navbar: BackstageNavbar,
sidebar: BackstageSidebar,
center: null
}}
/>
<Route
path='outgoing_webhook'
components={{
navbar: BackstageNavbar,
sidebar: BackstageSidebar,
center: null
}}
/>
</Route>
</Route>
>>>>>>> Added initial backstage components and InstalledIntegrations page
<Route
path='admin_console'
component={AdminConsole}

View File

@@ -0,0 +1,182 @@
.backstage {
background-color: #f2f2f2;
height: 100%;
padding-left: 260px;
padding-top: 45px;
}
.backstage__navbar {
background: white;
border-bottom: lightgray 1px solid;
margin: 0 -15px;
padding: 10px;
z-index: 10;
}
.backstage__navbar__back {
color: black;
.fa {
font-weight: bold;
margin-right: 5px;
}
}
.backstage__sidebar {
position: absolute;
left: 0;
width: 260px;
height: 100%;
background-color: #f2f2f2;
padding-bottom: 20px;
padding-left: 20px;
padding-right: 20px;
padding-top: 45px;
z-index: 5;
ul {
padding: 0px;
list-style: none;
}
}
.backstage__sidebar__category {
border: lightgray 1px solid;
.category-title {
color: gray;
display: block;
padding: 5px 10px;
position: relative;
}
.category-title--active {
color: black;
}
.category-title__text {
position: absolute;
left: 2em;
}
.sections {
background: white;
border-top: lightgray 1px solid;
}
.section-title {
display: block;
padding-left: 2em;
}
.subsection {
}
.subsection-title {
display: block;
padding-left: 3em;
}
.section-title--active, .subsection-title--active {
background-color:#2388d6;
color: white;
}
}
.backstage__sidebar__category + .backstage__sidebar__category {
border-top-width: 0px;
}
.installed-integrations {
height: 100%;
max-width: 958px;
}
.installed-integrations__header {
margin-bottom: 20px;
width: 100%;
.text {
display: inline;
}
.add-integrations-link {
float: right;
}
}
.installed-integrations__filters {
display: flex;
flex-direction: row;
margin-bottom: 8px;
width: 100%;
.type-filters {
flex-grow: 1;
flex-shrink: 0;
.type-filter {
&--selected {
color: black;
cursor: default;
font-weight: bold;
}
}
.divider {
margin-left: 8px;
margin-right: 8px;
}
}
.filter-box {
flex-grow: 0;
flex-shrink: 0;
}
}
.installed-integrations__list {
background-color: white;
border: lightgray 1px solid;
padding-bottom: 30px;
padding-left: 30px;
padding-right: 30px;
padding-top: 10px;
}
.installed-integrations__item {
border-bottom: lightgray 1px solid;
display: flex;
padding: 20px;
.details {
flex-grow: 1;
flex-shrink: 1;
overflow: hidden;
text-overflow: ellipsis;
.details-row + .details-row {
margin-top: 15px;
}
.name {
font-weight: bold;
margin-bottom: 1em;
}
.type {
margin-left: 6px;
}
.description {
color: gray;
margin-bottom: 1em;
}
}
.actions {
flex-grow: 0;
flex-shrink: 0;
padding-left: 20px;
}
}

View File

@@ -2,6 +2,7 @@
@import 'access-history';
@import 'activity-log';
@import 'admin-console';
@import 'backstage';
@import 'docs';
@import 'error-page';
@import 'loading';

View File

@@ -13,11 +13,6 @@ class FileStore extends EventEmitter {
constructor() {
super();
this.addChangeListener = this.addChangeListener.bind(this);
this.removeChangeListener = this.removeChangeListener.bind(this);
this.emitChange = this.emitChange.bind(this);
this.handleEventPayload = this.handleEventPayload.bind(this);
this.dispatchToken = AppDispatcher.register(this.handleEventPayload);
this.fileInfo = new Map();

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import AppDispatcher from '../dispatcher/app_dispatcher.jsx';
import Constants from 'utils/constants.jsx';
import EventEmitter from 'events';
import * as Utils from 'utils/utils.jsx';
const ActionTypes = Constants.ActionTypes;
const CHANGE_EVENT = 'changed';
class IntegrationStore extends EventEmitter {
constructor() {
super();
this.dispatchToken = AppDispatcher.register(this.handleEventPayload.bind(this));
this.incomingWebhooks = [];
this.receivedIncomingWebhooks = false;
this.outgoingWebhooks = [];
this.receivedOutgoingWebhooks = false;
}
addChangeListener(callback) {
this.on(CHANGE_EVENT, callback);
}
removeChangeListener(callback) {
this.removeListener(CHANGE_EVENT, callback);
}
emitChange() {
this.emit(CHANGE_EVENT);
}
hasReceivedIncomingWebhooks() {
return this.receivedIncomingWebhooks;
}
getIncomingWebhooks() {
return this.incomingWebhooks;
}
setIncomingWebhooks(incomingWebhooks) {
this.incomingWebhooks = Utils.freezeArray(incomingWebhooks);
this.receivedIncomingWebhooks = true;
}
hasReceivedOutgoingWebhooks() {
return this.receivedIncomingWebhooks;
}
getOutgoingWebhooks() {
return this.outgoingWebhooks;
}
setOutgoingWebhooks(outgoingWebhooks) {
this.outgoingWebhooks = Utils.freezeArray(outgoingWebhooks);
this.receivedOutgoingWebhooks = true;
}
handleEventPayload(payload) {
const action = payload.action;
switch (action.type) {
case ActionTypes.RECEIVED_INCOMING_WEBHOOKS:
this.setIncomingWebhooks(action.incomingWebhooks);
this.emitChange();
break;
case ActionTypes.RECEIVED_OUTGOING_WEBHOOKS:
this.setOutgoingWebhooks(action.outgoingWebhooks);
this.emitChange();
break;
}
}
}
export default new IntegrationStore();

View File

@@ -1121,3 +1121,49 @@ export function getRecentAndNewUsersAnalytics(teamId) {
}
);
}
export function listIncomingHooks() {
if (isCallInProgress('listIncomingHooks')) {
return;
}
callTracker.listIncomingHooks = utils.getTimestamp();
client.listIncomingHooks(
(data) => {
callTracker.listIncomingHooks = 0;
AppDispatcher.handleServerAction({
type: ActionTypes.RECEIVED_INCOMING_WEBHOOKS,
incomingWebhooks: data
});
},
(err) => {
callTracker.listIncomingHooks = 0;
dispatchError(err, 'getIncomingHooks');
}
);
}
export function listOutgoingHooks() {
if (isCallInProgress('listOutgoingHooks')) {
return;
}
callTracker.listOutgoingHooks = utils.getTimestamp();
client.listOutgoingHooks(
(data) => {
callTracker.listOutgoingHooks = 0;
AppDispatcher.handleServerAction({
type: ActionTypes.RECEIVED_OUTGOING_WEBHOOKS,
outgoingWebhooks: data
});
},
(err) => {
callTracker.listOutgoingHooks = 0;
dispatchError(err, 'getOutgoingHooks');
}
);
}

View File

@@ -68,6 +68,8 @@ export default {
RECEIVED_PREFERENCE: null,
RECEIVED_PREFERENCES: null,
RECEIVED_FILE_INFO: null,
RECEIVED_INCOMING_WEBHOOKS: null,
RECEIVED_OUTGOING_WEBHOOKS: null,
RECEIVED_MSG: null,

View File

@@ -1398,3 +1398,13 @@ export function localizeMessage(id, defaultMessage) {
return id;
}
export function freezeArray(arr) {
for (const obj of arr) {
Object.freeze(obj);
}
Object.freeze(arr);
return arr;
}