Compare commits

...

6 Commits

13 changed files with 259 additions and 128 deletions

View File

@ -89,6 +89,7 @@ export default class PipelineRowItem extends Component {
<Extensions.Renderer <Extensions.Renderer
extensionPoint="jenkins.pipeline.list.action" extensionPoint="jenkins.pipeline.list.action"
store={this.context.store} store={this.context.store}
mobxStores={this.context.mobxStores}
pipeline={this.props.pipeline} pipeline={this.props.pipeline}
/> />
</td> </td>
@ -104,5 +105,5 @@ PipelineRowItem.propTypes = {
PipelineRowItem.contextTypes = { PipelineRowItem.contextTypes = {
location: PropTypes.object, location: PropTypes.object,
store: PropTypes.object, mobxStores: PropTypes.object,
}; };

View File

@ -60,6 +60,7 @@ export default class Pipelines extends Component {
<Extensions.Renderer <Extensions.Renderer
extensionPoint="jenkins.pipeline.list.top" extensionPoint="jenkins.pipeline.list.top"
store={this.context.store} store={this.context.store}
mobxStores={this.context.mobxStores}
/> />
<Table <Table
className="pipelines-table fixed" className="pipelines-table fixed"
@ -88,4 +89,5 @@ Pipelines.contextTypes = {
params: PropTypes.object, params: PropTypes.object,
pipelines: array, pipelines: array,
store: PropTypes.object, store: PropTypes.object,
mobxStores: PropTypes.object,
}; };

View File

@ -1,3 +1,10 @@
{ {
"presets": ["es2015", "stage-0", "react"] "presets": [
"react",
"es2015",
"stage-0"
],
"plugins": [
"transform-decorators-legacy"
]
} }

View File

@ -19,6 +19,7 @@
"babel": "^6.5.2", "babel": "^6.5.2",
"babel-core": "^6.7.6", "babel-core": "^6.7.6",
"babel-eslint": "^6.0.2", "babel-eslint": "^6.0.2",
"babel-plugin-transform-decorators-legacy": "1.3.4",
"babel-preset-es2015": "^6.6.0", "babel-preset-es2015": "^6.6.0",
"babel-preset-react": "^6.5.0", "babel-preset-react": "^6.5.0",
"babel-preset-stage-0": "^6.5.0", "babel-preset-stage-0": "^6.5.0",
@ -41,6 +42,8 @@
"immutable": "3.8.1", "immutable": "3.8.1",
"isomorphic-fetch": "2.2.1", "isomorphic-fetch": "2.2.1",
"keymirror": "0.1.1", "keymirror": "0.1.1",
"mobx": "2.4.0",
"mobx-react": "3.5.1",
"moment": "2.13.0", "moment": "2.13.0",
"moment-duration-format": "1.3.0", "moment-duration-format": "1.3.0",
"react": "15.1.0", "react": "15.1.0",

View File

@ -2,14 +2,8 @@
* Created by cmeyers on 7/6/16. * Created by cmeyers on 7/6/16.
*/ */
import React, { Component, PropTypes } from 'react'; import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux'; import { observer } from 'mobx-react';
import { createSelector } from 'reselect';
import { List } from 'immutable';
import { favoritesSelector } from '../redux/FavoritesStore';
import { actions } from '../redux/FavoritesActions';
import FavoritesProvider from './FavoritesProvider';
import { PipelineCard } from './PipelineCard'; import { PipelineCard } from './PipelineCard';
// the order the cards should be displayed based on their result/state (aka 'status') // the order the cards should be displayed based on their result/state (aka 'status')
@ -89,18 +83,19 @@ const extractPath = (path, begin, end) => {
/** /**
*/ */
@observer
export class DashboardCards extends Component { export class DashboardCards extends Component {
_onFavoriteToggle(isFavorite, favorite) { _onFavoriteToggle(isFavorite, favorite) {
this.props.toggleFavorite(isFavorite, favorite.item); this.props.favoritesList.toggleFavorite(isFavorite, favorite.item);
} }
_renderCardStack() { _renderCardStack() {
if (!this.props.favorites) { if (!this.props.favoritesList.favorites) {
return null; return null;
} }
const sortedFavorites = this.props.favorites.sort(sortComparator); const sortedFavorites = this.props.favoritesList.favorites.sort(sortComparator);
const favoriteCards = sortedFavorites.map(favorite => { const favoriteCards = sortedFavorites.map(favorite => {
const pipeline = favorite.item; const pipeline = favorite.item;
@ -168,22 +163,15 @@ export class DashboardCards extends Component {
render() { render() {
return ( return (
<FavoritesProvider store={this.props.store}> <div>
{ this._renderCardStack() } { this._renderCardStack() }
</FavoritesProvider> </div>
); );
} }
} }
DashboardCards.propTypes = { DashboardCards.propTypes = {
store: PropTypes.object, favoritesList: PropTypes.object,
favorites: PropTypes.instanceOf(List),
toggleFavorite: PropTypes.func,
}; };
const selectors = createSelector( export default DashboardCards;
[favoritesSelector],
(favorites) => ({ favorites })
);
export default connect(selectors, actions)(DashboardCards);

View File

@ -2,22 +2,20 @@
* Created by cmeyers on 7/8/16. * Created by cmeyers on 7/8/16.
*/ */
import React, { Component, PropTypes } from 'react'; import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux'; import { autorun } from 'mobx';
import { createSelector } from 'reselect'; import { observer } from 'mobx-react';
import { List } from 'immutable';
import { Favorite } from '@jenkins-cd/design-language'; import { Favorite } from '@jenkins-cd/design-language';
import { favoritesSelector } from '../redux/FavoritesStore'; import PersonalizationStore from '../model/PersonalizationStore';
import { actions } from '../redux/FavoritesActions';
import { checkMatchingFavoriteUrls } from '../util/FavoriteUtils'; import { checkMatchingFavoriteUrls } from '../util/FavoriteUtils';
import FavoritesProvider from './FavoritesProvider';
/** /**
* A toggle button to favorite or unfavorite the provided item (pipeline or branch) * A toggle button to favorite or unfavorite the provided item (pipeline or branch)
* Contains all logic for rendering the current favorite status of that item * Contains all logic for rendering the current favorite status of that item
* and toggling favorited state on the server. * and toggling favorited state on the server.
*/ */
@observer
export class FavoritePipeline extends Component { export class FavoritePipeline extends Component {
constructor(props) { constructor(props) {
@ -29,21 +27,23 @@ export class FavoritePipeline extends Component {
} }
componentWillMount() { componentWillMount() {
this._updateState(this.props); this.favoritesList = PersonalizationStore.favoritesStore.favoritesList;
this.favoritesList.initialize();
this._unsubscribe = autorun(() => this.updateFavorite());
} }
componentWillReceiveProps(nextProps) { componentWillUnmount() {
if (this.props.favorites !== nextProps.favorites) { if (this._unsubscribe) {
this._updateState(nextProps); this._unsubscribe();
} }
} }
_updateState(props) { updateFavorite() {
const { pipeline } = props; const { pipeline } = this.props;
let favorite = null; let favorite = null;
if (props.favorites) { if (this.favoritesList.favorites) {
favorite = props.favorites.find((fav) => { favorite = this.favoritesList.favorites.find((fav) => {
const favUrl = fav.item._links.self.href; const favUrl = fav.item._links.self.href;
const pipelineUrl = pipeline._links.self.href; const pipelineUrl = pipeline._links.self.href;
@ -62,18 +62,18 @@ export class FavoritePipeline extends Component {
favorite: isFavorite, favorite: isFavorite,
}); });
if (this.props.toggleFavorite) { debugger;
this.props.toggleFavorite(isFavorite, this.props.pipeline);
if (this.favoritesList.toggleFavorite) {
this.favoritesList.toggleFavorite(isFavorite, this.props.pipeline);
} }
} }
render() { render() {
return ( return (
<FavoritesProvider store={this.props.store}> <Favorite checked={this.state.favorite} className={this.props.className}
<Favorite checked={this.state.favorite} className={this.props.className} onToggle={() => this._onFavoriteToggle()}
onToggle={() => this._onFavoriteToggle()} />
/>
</FavoritesProvider>
); );
} }
} }
@ -81,18 +81,10 @@ export class FavoritePipeline extends Component {
FavoritePipeline.propTypes = { FavoritePipeline.propTypes = {
className: PropTypes.string, className: PropTypes.string,
pipeline: PropTypes.object, pipeline: PropTypes.object,
favorites: PropTypes.instanceOf(List),
toggleFavorite: PropTypes.func,
store: PropTypes.object,
}; };
FavoritePipeline.defaultProps = { FavoritePipeline.defaultProps = {
favorite: false, favorite: false,
}; };
const selectors = createSelector( export default FavoritePipeline;
[favoritesSelector],
(favorites) => ({ favorites })
);
export default connect(selectors, actions)(FavoritePipeline);

View File

@ -0,0 +1,24 @@
/**
* Created by cmeyers on 7/26/16.
*/
import React, { Component, PropTypes } from 'react';
import DashboardCards from './DashboardCards';
import PersonalizationStore from '../model/PersonalizationStore';
/**
* Top-level component bound to extension point that passes down store to child components.
*/
class FavoritesDashboard extends Component {
render() {
const favoritesList = PersonalizationStore.favoritesStore.favoritesList;
favoritesList.initialize();
return (
<div>
<DashboardCards favoritesList={favoritesList} />
</div>
);
}
}
export default FavoritesDashboard;

View File

@ -1,64 +0,0 @@
/**
* Created by cmeyers on 7/20/16.
*/
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { List } from 'immutable';
import { userSelector, favoritesSelector } from '../redux/FavoritesStore';
import { actions } from '../redux/FavoritesActions';
/**
* FavoritesProvider ensures that the current user's favorites
* are loaded for any components which may need it.
*
* Components that require this data can simply wrap themselves in
* FavoritesProvider which will ensure the store is updated correctly.
*/
export class FavoritesProvider extends Component {
componentWillMount() {
this._initialize(this.props);
}
componentWillReceiveProps(props) {
this._initialize(props);
}
_initialize(props) {
const { user, favorites } = props;
const shouldFetchUser = !user;
const shouldFetchFavorites = user && !favorites;
if (shouldFetchUser) {
this.props.fetchUser();
}
if (shouldFetchFavorites) {
this.props.fetchFavorites(user);
}
}
render() {
return this.props.children ?
React.cloneElement(this.props.children, { ...this.props }) :
null;
}
}
FavoritesProvider.propTypes = {
children: PropTypes.node,
user: PropTypes.object,
favorites: PropTypes.instanceOf(List),
fetchUser: PropTypes.func,
fetchFavorites: PropTypes.func,
};
const selectors = createSelector(
[userSelector, favoritesSelector],
(user, favorites) => ({ user, favorites })
);
export default connect(selectors, actions)(FavoritesProvider);

View File

@ -2,9 +2,9 @@
# NB: "component" currently maps to modules, not "symbols" so make sure to "export default" # NB: "component" currently maps to modules, not "symbols" so make sure to "export default"
# WARNING: If you change this you'll have to change io.jenkins.blueocean.jsextensions.JenkinsJSExtensionsTest as well :( # WARNING: If you change this you'll have to change io.jenkins.blueocean.jsextensions.JenkinsJSExtensionsTest as well :(
extensions: extensions:
- component: redux/FavoritesStore - component: model/PersonalizationStore
extensionPoint: jenkins.main.stores extensionPoint: jenkins.main.stores.mobx
- component: components/DashboardCards - component: components/FavoritesDashboard
extensionPoint: jenkins.pipeline.list.top extensionPoint: jenkins.pipeline.list.top
- component: components/FavoritePipeline - component: components/FavoritePipeline
extensionPoint: jenkins.pipeline.list.action extensionPoint: jenkins.pipeline.list.action

View File

@ -0,0 +1,135 @@
/**
* Created by cmeyers on 7/25/16.
*/
import Immutable from 'immutable';
import fetch from 'isomorphic-fetch';
import { action, computed, observable, useStrict } from 'mobx';
useStrict(true);
import urlConfig from '../config';
urlConfig.loadConfig();
import { User } from '../model/User';
import { checkMatchingFavoriteUrls } from '../util/FavoriteUtils';
const { List } = Immutable;
const defaultFetchOptions = {
credentials: 'same-origin',
};
function checkStatus(response) {
if (response.status >= 300 || response.status < 200) {
const error = new Error(response.statusText);
error.response = response;
throw error;
}
return response;
}
function parseJSON(response) {
return response.json()
// FIXME: workaround for status=200 w/ empty response body that causes error in Chrome
// server should probably return HTTP 204 instead
.catch((error) => {
if (error.message === 'Unexpected end of JSON input') {
return {};
}
throw error;
});
}
function execFetch(url, options) {
const fetchOptions = options || { ... defaultFetchOptions };
return fetch(url, fetchOptions)
.then(checkStatus)
.then(parseJSON);
}
export class FavoritesList {
@observable favorites = new List();
user = null;
_initializing = false;
initialize() {
const shouldFetchUser = !this.user;
console.log('intialize()?', this._initializing);
if (shouldFetchUser && !this._initializing) {
this._initializing = true;
this.fetchCurrentUser()
.then(() => {
this.fetchFavorites()
.then(() => {
this._initializing = false;
});
});
}
}
fetchCurrentUser() {
const baseUrl = urlConfig.blueoceanAppURL;
const url = `${baseUrl}/rest/organizations/jenkins/user/`;
return execFetch(url)
.then((data) => {
this.user = new User(data);
});
}
fetchFavorites() {
const baseUrl = urlConfig.blueoceanAppURL;
const username = this.user.id;
const url = `${baseUrl}/rest/users/${username}/favorites/`;
return execFetch(url)
.then(action((data) => {
this.favorites = new List(data);
}));
}
toggleFavorite(addFavorite, pipelineOrBranch) {
const baseUrl = urlConfig.jenkinsRootURL;
const url = `${baseUrl}${pipelineOrBranch._links.self.href}/favorite`;
const fetchOptions = {
...defaultFetchOptions,
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(
{ favorite: addFavorite }
),
};
return execFetch(url, fetchOptions)
.then((favoritePayload) => {
this._updateToggledFavorite(addFavorite, pipelineOrBranch, favoritePayload);
});
}
@action
_updateToggledFavorite(addFavorite, pipelineOrBranch, favoritePayload) {
if (addFavorite) {
this.favorites = this.favorites.push(favoritePayload);
} else {
const toggledBranchHref = pipelineOrBranch._links.self.href;
// filter the list so that only favorites which didn't match the branch's href are returned
this.favorites = this.favorites.filter(fav => {
const favoritedBranch = fav.item;
return !checkMatchingFavoriteUrls(
favoritedBranch._links.self.href,
toggledBranchHref,
);
});
}
}
@computed get count() {
return this.favorites && this.favorites.length || 0;
}
}

View File

@ -0,0 +1,18 @@
/**
* Created by cmeyers on 7/25/16.
*/
import { FavoritesList } from './FavoritesList';
class PersonalizationStore {
favoritesList = null;
constructor() {
this.favoritesList = new FavoritesList();
}
}
export default {
favoritesStore: new PersonalizationStore(),
};

View File

@ -31,6 +31,8 @@
"history": "2.0.2", "history": "2.0.2",
"immutable": "3.8.1", "immutable": "3.8.1",
"keymirror": "0.1.1", "keymirror": "0.1.1",
"mobx": "2.4.0",
"mobx-react": "3.5.1",
"moment": "2.13.0", "moment": "2.13.0",
"react": "15.1.0", "react": "15.1.0",
"react-addons-css-transition-group": "15.1.0", "react-addons-css-transition-group": "15.1.0",

View File

@ -2,7 +2,8 @@ import React, { Component, PropTypes } from 'react';
import { render } from 'react-dom'; import { render } from 'react-dom';
import { Router, Route, Link, useRouterHistory, IndexRedirect } from 'react-router'; import { Router, Route, Link, useRouterHistory, IndexRedirect } from 'react-router';
import { createHistory } from 'history'; import { createHistory } from 'history';
import { Provider, configureStore, combineReducers} from './redux'; import { Provider as ReduxProvider, configureStore, combineReducers} from './redux';
import { Provider as MobXProvider } from 'mobx-react';
import { DevelopmentFooter } from './DevelopmentFooter'; import { DevelopmentFooter } from './DevelopmentFooter';
import Extensions from '@jenkins-cd/js-extensions'; import Extensions from '@jenkins-cd/js-extensions';
@ -75,7 +76,7 @@ function makeRoutes(routes) {
} }
function startApp(routes, stores) { function startApp(routes, stores, mobxStores) {
const rootElement = document.getElementById("root"); const rootElement = document.getElementById("root");
const headElement = document.getElementsByTagName("head")[0]; const headElement = document.getElementsByTagName("head")[0];
@ -117,6 +118,21 @@ function startApp(routes, stores) {
); );
} }
// build up a 'master store' of all registered component stores
let mobxMasterStore = {};
if (mobxStores.length > 0) {
/*
TODO: watch for store name collisions and warn
for (const store of mobxStores) {
for (const storeName in store) {
console.log(storeName);
}
}
*/
mobxMasterStore = Object.assign(mobxMasterStore, ...mobxStores);
}
// on each change of the url we need to update the location object // on each change of the url we need to update the location object
history.listen(newLocation => { history.listen(newLocation => {
const { dispatch, getState } = store; const { dispatch, getState } = store;
@ -137,12 +153,19 @@ function startApp(routes, stores) {
// Start React // Start React
render( render(
<Provider store={store}> React.cloneElement(
<Router history={history}>{ makeRoutes(routes) }</Router> <MobXProvider>
</Provider> <ReduxProvider store={store}>
, rootElement); <Router history={history}>{ makeRoutes(routes) }</Router>
</ReduxProvider>
</MobXProvider>,
mobxMasterStore
),
rootElement);
} }
Extensions.store.getExtensions(['jenkins.main.routes', 'jenkins.main.stores'], (routes = [], stores = []) => { Extensions.store.getExtensions(
startApp(routes, stores); ['jenkins.main.routes', 'jenkins.main.stores', 'jenkins.main.stores.mobx'],
(routes = [], stores = [], mobxStores =[]) => {
startApp(routes, stores, mobxStores);
}); });