Merge pull request #415 from jnugh/ux-manage-servers

Ux manage servers
This commit is contained in:
Yuya Ochiai 2017-02-10 20:19:27 +09:00 committed by GitHub
commit c76797e68e
13 changed files with 519 additions and 35 deletions

View file

@ -11,6 +11,7 @@ from the final changelog of the release.
Release date: TBD
### Improvements
- Added a new team button next to the team tabs
#### All Platforms
- Suppress white screen which is displayed for a moment on startup

View file

@ -10,6 +10,8 @@ const MattermostView = require('./MattermostView.jsx');
const TabBar = require('./TabBar.jsx');
const HoveringURL = require('./HoveringURL.jsx');
const NewTeamModal = require('./NewTeamModal.jsx');
// Todo: Need to consider better way to apply styles
const styles = {
hoveringURL: {
@ -36,7 +38,8 @@ const MainPage = React.createClass({
propTypes: {
disablewebsecurity: React.PropTypes.bool.isRequired,
onUnreadCountChange: React.PropTypes.func.isRequired,
teams: React.PropTypes.array.isRequired
teams: React.PropTypes.array.isRequired,
onTeamConfigChange: React.PropTypes.func.isRequired
},
getInitialState() {
@ -127,6 +130,10 @@ const MainPage = React.createClass({
mattermost.goForward();
}
});
ipcRenderer.on('add-server', () => {
this.addServer();
});
},
componentDidUpdate(prevProps, prevState) {
if (prevState.key !== this.state.key) { // i.e. When tab has been changed
@ -237,6 +244,11 @@ const MainPage = React.createClass({
this.setState({targetURL});
}
},
addServer() {
this.setState({
showNewTeamModal: true
});
},
render() {
var self = this;
@ -253,6 +265,7 @@ const MainPage = React.createClass({
mentionAtActiveCounts={this.state.mentionAtActiveCounts}
activeKey={this.state.key}
onSelect={this.handleSelect}
onAddServer={this.addServer}
/>
</Row>
);
@ -296,6 +309,25 @@ const MainPage = React.createClass({
authServerURL = `${tmpURL.protocol}//${tmpURL.host}`;
authInfo = this.state.loginQueue[0].authInfo;
}
var modal = (
<NewTeamModal
show={this.state.showNewTeamModal}
onClose={() => {
this.setState({
showNewTeamModal: false
});
}}
onSave={(newTeam) => {
this.props.teams.push(newTeam);
this.setState({
showNewTeamModal: false,
key: this.props.teams.length - 1
});
this.render();
this.props.onTeamConfigChange(this.props.teams);
}}
/>
);
return (
<div>
<LoginModal
@ -323,6 +355,9 @@ const MainPage = React.createClass({
targetURL={this.state.targetURL}
/> }
</ReactCSSTransitionGroup>
<div>
{ modal }
</div>
</div>
);
}

View file

@ -0,0 +1,209 @@
const React = require('react');
const {Modal, Button, FormGroup, FormControl, ControlLabel, HelpBlock} = require('react-bootstrap');
class NewTeamModal extends React.Component {
constructor() {
super();
this.wasShown = false;
this.state = {
teamName: '',
teamUrl: '',
saveStarted: false
};
}
componentWillMount() {
this.initializeOnShow();
}
initializeOnShow() {
this.state = {
teamName: this.props.team ? this.props.team.name : '',
teamUrl: this.props.team ? this.props.team.url : '',
teamIndex: this.props.team ? this.props.team.index : false,
saveStarted: false
};
}
getTeamNameValidationError() {
if (!this.state.saveStarted) {
return null;
}
return this.state.teamName.length > 0 ? null : 'Name is required.';
}
getTeamNameValidationState() {
return this.getTeamNameValidationError() === null ? null : 'error';
}
handleTeamNameChange(e) {
this.setState({
teamName: e.target.value
});
}
getTeamUrlValidationError() {
if (!this.state.saveStarted) {
return null;
}
if (this.state.teamUrl.length === 0) {
return 'URL is required.';
}
if (!(/^https?:\/\/.*/).test(this.state.teamUrl.trim())) {
return 'URL should start with http:// or https://.';
}
return null;
}
getTeamUrlValidationState() {
return this.getTeamUrlValidationError() === null ? null : 'error';
}
handleTeamUrlChange(e) {
this.setState({
teamUrl: e.target.value
});
}
getError() {
return this.getTeamNameValidationError() || this.getTeamUrlValidationError();
}
validateForm() {
return this.getTeamNameValidationState() === null &&
this.getTeamUrlValidationState() === null;
}
save() {
this.setState({
saveStarted: true
}, () => {
if (this.validateForm()) {
this.props.onSave({
url: this.state.teamUrl,
name: this.state.teamName,
index: this.state.teamIndex
});
}
});
}
getSaveButtonLabel() {
if (this.props.editMode) {
return 'Save';
}
return 'Add';
}
getModalTitle() {
if (this.props.editMode) {
return 'Edit Server';
}
return 'Add Server';
}
render() {
const noBottomSpaceing = {
'padding-bottom': 0,
'margin-bottom': 0
};
if (this.wasShown !== this.props.show && this.props.show) {
this.initializeOnShow();
}
this.wasShown = this.props.show;
return (
<Modal
show={this.props.show}
id='newServerModal'
onHide={this.props.onClose}
onKeyDown={(e) => {
switch (e.key) {
case 'Enter':
this.save();
// The add button from behind this might still be focused
e.preventDefault();
e.stopPropagation();
break;
case 'Escape':
this.props.onClose();
break;
}
}}
>
<Modal.Header>
<Modal.Title>{this.getModalTitle()}</Modal.Title>
</Modal.Header>
<Modal.Body>
<form>
<FormGroup
validationState={this.getTeamNameValidationState()}
>
<ControlLabel>{'Server Display Name'}</ControlLabel>
<FormControl
id='teamNameInput'
type='text'
value={this.state.teamName}
placeholder='Server Name'
onChange={this.handleTeamNameChange.bind(this)}
/>
<FormControl.Feedback/>
<HelpBlock>{'The name of the server displayed on your desktop app tab bar.'}</HelpBlock>
</FormGroup>
<FormGroup
validationState={this.getTeamUrlValidationState()}
style={noBottomSpaceing}
>
<ControlLabel>{'Server URL'}</ControlLabel>
<FormControl
id='teamUrlInput'
type='text'
value={this.state.teamUrl}
placeholder='https://example.com'
onChange={this.handleTeamUrlChange.bind(this)}
/>
<FormControl.Feedback/>
<HelpBlock
style={noBottomSpaceing}
>{'The URL of your Mattermost server. Must start with http:// or https://.'}</HelpBlock>
</FormGroup>
</form>
</Modal.Body>
<Modal.Footer>
<div
className='pull-left modal-error'
>
{this.getError()}
</div>
<Button
id='cancelNewServerModal'
onClick={this.props.onClose}
>{'Cancel'}</Button>
<Button
id='saveNewServerModal'
onClick={this.save.bind(this)}
disabled={!this.validateForm()}
bsStyle='primary'
>{this.getSaveButtonLabel()}</Button>
</Modal.Footer>
</Modal>
);
}
}
NewTeamModal.propTypes = {
onClose: React.PropTypes.func,
onSave: React.PropTypes.func,
team: React.PropTypes.object,
editMode: React.PropTypes.boolean,
show: React.PropTypes.boolean
};
module.exports = NewTeamModal;

View file

@ -46,6 +46,11 @@ const SettingsPage = React.createClass({
});
});
}
ipcRenderer.on('add-server', () => {
this.setState({
showAddTeamForm: true
});
});
},
handleTeamsChange(teams) {
this.setState({
@ -141,6 +146,11 @@ const SettingsPage = React.createClass({
showAddTeamForm: !this.state.showAddTeamForm
});
},
setShowTeamFormVisibility(val) {
this.setState({
showAddTeamForm: val
});
},
handleFlashWindow() {
this.setState({
notifications: {
@ -153,6 +163,23 @@ const SettingsPage = React.createClass({
showUnreadBadge: !this.refs.showUnreadBadge.props.checked
});
},
updateTeam(index, newData) {
var teams = this.state.teams;
teams[index] = newData;
this.setState({
teams
});
},
addServer(team) {
var teams = this.state.teams;
teams.push(team);
this.setState({
teams
});
},
render() {
var teamsRow = (
<Row>
@ -160,7 +187,11 @@ const SettingsPage = React.createClass({
<TeamList
teams={this.state.teams}
showAddTeamForm={this.state.showAddTeamForm}
toggleAddTeamForm={this.toggleShowTeamForm}
setAddTeamFormVisibility={this.setShowTeamFormVisibility}
onTeamsChange={this.handleTeamsChange}
updateTeam={this.updateTeam}
addServer={this.addServer}
/>
</Col>
</Row>
@ -354,6 +385,7 @@ const SettingsPage = React.createClass({
<p className='text-right'>
<a
style={settingsPage.sectionHeadingLink}
id='addNewServer'
href='#'
onClick={this.toggleShowTeamForm}
>{'⊞ Add new server'}</a>

View file

@ -1,5 +1,5 @@
const React = require('react');
const {Nav, NavItem} = require('react-bootstrap');
const {Nav, NavItem, Button} = require('react-bootstrap');
class TabBar extends React.Component {
render() {
@ -75,16 +75,30 @@ class TabBar extends React.Component {
onSelect={this.props.onSelect}
>
{ tabs }
{ this.renderAddTeamButton() }
</Nav>
);
}
renderAddTeamButton() {
return (
<Button
id='tabBarAddNewTeam'
onClick={this.props.onAddServer}
bsStyle='tabButton'
>
{'+'}
</Button>
);
}
}
TabBar.propTypes = {
activeKey: React.PropTypes.number,
id: React.PropTypes.string,
onSelect: React.PropTypes.func,
teams: React.PropTypes.array
teams: React.PropTypes.array,
onAddServer: React.PropTypes.func
};
module.exports = TabBar;

View file

@ -1,19 +1,23 @@
const React = require('react');
const {ListGroup} = require('react-bootstrap');
const TeamListItem = require('./TeamListItem.jsx');
const TeamListItemNew = require('./TeamListItemNew.jsx');
const NewTeamModal = require('./NewTeamModal.jsx');
const RemoveServerModal = require('./RemoveServerModal.jsx');
const TeamList = React.createClass({
propTypes: {
onTeamsChange: React.PropTypes.func,
showAddTeamForm: React.PropTypes.bool,
teams: React.PropTypes.array
teams: React.PropTypes.array,
addServer: React.PropTypes.func,
updateTeam: React.PropTypes.func,
toggleAddTeamForm: React.PropTypes.func,
setAddTeamFormVisibility: React.PropTypes.func
},
getInitialState() {
return {
showTeamListItemNew: false,
showEditTeamForm: false,
indexToRemoveServer: -1,
team: {
url: '',
@ -40,7 +44,7 @@ const TeamList = React.createClass({
}
this.setState({
showTeamListItemNew: false,
showEditTeamForm: false,
team: {
url: '',
name: '',
@ -52,7 +56,7 @@ const TeamList = React.createClass({
},
handleTeamEditing(teamName, teamUrl, teamIndex) {
this.setState({
showTeamListItemNew: true,
showEditTeamForm: true,
team: {
url: teamUrl,
name: teamName,
@ -92,19 +96,45 @@ const TeamList = React.createClass({
);
});
var addTeamForm;
if (this.props.showAddTeamForm || this.state.showTeamListItemNew) {
addTeamForm = (
<TeamListItemNew
key={this.state.team.index}
onTeamAdd={this.handleTeamAdd}
teamIndex={this.state.team.index}
teamName={this.state.team.name}
teamUrl={this.state.team.url}
/>);
} else {
addTeamForm = '';
}
var addServerForm = (
<NewTeamModal
show={this.props.showAddTeamForm || this.state.showEditTeamForm}
editMode={this.state.showEditTeamForm}
onClose={() => {
this.setState({
showEditTeamForm: false,
team: {
name: '',
url: '',
index: false
}
});
this.props.setAddTeamFormVisibility(false);
}}
onSave={(newTeam) => {
var teamData = {
name: newTeam.name,
url: newTeam.url
};
if (this.props.showAddTeamForm) {
this.props.addServer(teamData);
} else {
this.props.updateTeam(newTeam.index, teamData);
}
this.setState({
showNewTeamModal: false,
showEditTeamForm: false,
team: {
name: '',
url: '',
index: false
}
});
this.render();
this.props.setAddTeamFormVisibility(false);
}}
team={this.state.team}
/>);
const removeServer = this.props.teams[this.state.indexToRemoveServer];
const removeServerModal = (
@ -123,7 +153,7 @@ const TeamList = React.createClass({
return (
<ListGroup className='teamList'>
{ teamNodes }
{ addTeamForm }
{ addServerForm }
{ removeServerModal}
</ListGroup>
);

View file

@ -0,0 +1,22 @@
const settings = require('../../common/settings');
const {remote} = require('electron');
class AppConfig {
constructor(file) {
this.fileName = file;
try {
this.data = settings.readFileSync(file);
} catch (e) {
this.data = {
teams: []
};
}
}
set(key, value) {
this.data[key] = value;
settings.writeFileSync(this.fileName, this.data);
}
}
module.exports = new AppConfig(remote.app.getPath('userData') + '/config.json');

View file

@ -16,3 +16,25 @@
opacity: 0.01;
transition: opacity 500ms ease-in-out;
}
.btn-tabButton {
margin-top: 3px;
color: #333;
background-color: #fff;
border-color: #ccc;
}
.btn-tabButton:hover {
color: #333;
background-color: #e6e6e6;
border-color: #adadad;
}
.has-error .control-label,
.has-error .help-block {
color: #333;
}
.modal-error {
color: #a94442;
}

View file

@ -9,19 +9,12 @@ const ReactDOM = require('react-dom');
const {remote, ipcRenderer} = require('electron');
const MainPage = require('./components/MainPage.jsx');
const settings = require('../common/settings');
const AppConfig = require('./config/AppConfig.js');
const badge = require('./js/badge');
remote.getCurrentWindow().removeAllListeners('focus');
var config;
try {
const configFile = remote.app.getPath('userData') + '/config.json';
config = settings.readFileSync(configFile);
} catch (e) {
window.location = 'settings.html';
}
if (config.teams.length === 0) {
if (AppConfig.data.teams.length === 0) {
window.location = 'settings.html';
}
@ -40,7 +33,7 @@ function showUnreadBadgeWindows(unreadCount, mentionCount) {
if (mentionCount > 0) {
const dataURL = badge.createDataURL(mentionCount.toString());
sendBadge(dataURL, 'You have unread mentions (' + mentionCount + ')');
} else if (unreadCount > 0 && config.showUnreadBadge) {
} else if (unreadCount > 0 && AppConfig.data.showUnreadBadge) {
const dataURL = badge.createDataURL('•');
sendBadge(dataURL, 'You have unread channels (' + unreadCount + ')');
} else {
@ -51,7 +44,7 @@ function showUnreadBadgeWindows(unreadCount, mentionCount) {
function showUnreadBadgeOSX(unreadCount, mentionCount) {
if (mentionCount > 0) {
remote.app.dock.setBadge(mentionCount.toString());
} else if (unreadCount > 0 && config.showUnreadBadge) {
} else if (unreadCount > 0 && AppConfig.data.showUnreadBadge) {
remote.app.dock.setBadge('•');
} else {
remote.app.dock.setBadge('');
@ -89,11 +82,16 @@ function showUnreadBadge(unreadCount, mentionCount) {
}
}
function teamConfigChange(teams) {
AppConfig.set('teams', teams);
}
ReactDOM.render(
<MainPage
disablewebsecurity={config.disablewebsecurity}
teams={config.teams}
disablewebsecurity={AppConfig.data.disablewebsecurity}
teams={AppConfig.data.teams}
onUnreadCountChange={showUnreadBadge}
onTeamConfigChange={teamConfigChange}
/>,
document.getElementById('content')
);

View file

@ -5,6 +5,7 @@
<meta charset="UTF-8">
<title>Settings</title>
<link rel="stylesheet" href="modules/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" href="css/index.css">
</head>
<body>

View file

@ -27,6 +27,11 @@ function createTemplate(mainWindow, config) {
click() {
mainWindow.loadURL('file://' + __dirname + '/browser/settings.html');
}
}, {
label: 'Sign in to Another Server',
click() {
mainWindow.webContents.send('add-server');
}
}, separatorItem, {
role: 'hide'
}, {
@ -41,6 +46,11 @@ function createTemplate(mainWindow, config) {
click() {
mainWindow.loadURL('file://' + __dirname + '/browser/settings.html');
}
}, {
label: 'Sign in to Another Server',
click() {
mainWindow.webContents.send('add-server');
}
}, separatorItem, {
role: 'quit',
accelerator: 'CmdOrCtrl+Q',

View file

@ -171,4 +171,11 @@ describe('browser/index.html', function desc() {
browserWindow.getTitle().should.eventually.equal('Title 1');
});
});
it('should open the new server prompt after clicking the add button', () => {
// See settings_test for specs that cover the actual prompt
return this.app.client.waitUntilWindowLoaded().
click('#tabBarAddNewTeam').
isExisting('#newServerModal').should.eventually.be.true;
});
});

View file

@ -245,4 +245,107 @@ describe('browser/settings.html', function desc() {
isExisting(modalTitleSelector).should.eventually.false;
});
});
describe('NewTeamModal', () => {
beforeEach(() => {
env.addClientCommands(this.app.client);
return this.app.client.
loadSettingsPage().
click('#addNewServer');
});
it('should open the new server modal', () => {
return this.app.client.isExisting('#newServerModal').should.eventually.equal(true);
});
it('should close the window after clicking cancel', () => {
return this.app.client.
click('#cancelNewServerModal').
pause(1000). // Animation
isExisting('#newServerModal').should.eventually.equal(false);
});
it('should not be valid if no team name has been set', () => {
return this.app.client.
click('#saveNewServerModal').
isExisting('.has-error #teamNameInput').should.eventually.equal(true);
});
it('should not be valid if no server address has been set', () => {
return this.app.client.
click('#saveNewServerModal').
isExisting('.has-error #teamUrlInput').should.eventually.equal(true);
});
describe('Valid server name', () => {
beforeEach(() => {
return this.app.client.
setValue('#teamNameInput', 'TestTeam').
click('#saveNewServerModal');
});
it('should not be marked invalid', () => {
return this.app.client.
isExisting('.has-error #teamNameInput').should.eventually.equal(false);
});
it('should not be possible to click save', () => {
return this.app.client.
getAttribute('#saveNewServerModal', 'disabled').should.eventually.equal('true');
});
});
describe('Valid server url', () => {
beforeEach(() => {
return this.app.client.
setValue('#teamUrlInput', 'http://example.org').
click('#saveNewServerModal');
});
it('should be valid', () => {
return this.app.client.
isExisting('.has-error #teamUrlInput').should.eventually.equal(false);
});
it('should not be possible to click save', () => {
return this.app.client.
getAttribute('#saveNewServerModal', 'disabled').should.eventually.equal('true');
});
});
it('should not be valid if an invalid server address has been set', () => {
return this.app.client.
setValue('#teamUrlInput', 'superInvalid url').
click('#saveNewServerModal').
isExisting('.has-error #teamUrlInput').should.eventually.equal(true);
});
describe('Valid Team Settings', () => {
beforeEach(() => {
return this.app.client.
setValue('#teamUrlInput', 'http://example.org').
setValue('#teamNameInput', 'TestTeam');
});
it('should be possible to click add', () => {
return this.app.client.
getAttribute('#saveNewServerModal', 'disabled').should.eventually.equal(null);
});
it('should add the team to the config file', (done) => {
this.app.client.
click('#saveNewServerModal').
pause(1000). // Animation
click('#btnSave').
pause(1000).then(() => {
const savedConfig = JSON.parse(fs.readFileSync(env.configFilePath, 'utf8'));
savedConfig.teams.should.contain({
name: 'TestTeam',
url: 'http://example.org'
});
return done();
});
});
});
});
});