Compare commits

...

4 Commits

13 changed files with 280 additions and 54 deletions

View File

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

View File

@ -60,6 +60,7 @@ export default class Pipelines extends Component {
<Extensions.Renderer
extensionPoint="jenkins.pipeline.list.top"
store={this.context.store}
mobxStores={this.context.mobxStores}
/>
<Table
className="pipelines-table fixed"
@ -88,4 +89,5 @@ Pipelines.contextTypes = {
params: PropTypes.object,
pipelines: array,
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-core": "^6.7.6",
"babel-eslint": "^6.0.2",
"babel-plugin-transform-decorators-legacy": "1.3.4",
"babel-preset-es2015": "^6.6.0",
"babel-preset-react": "^6.5.0",
"babel-preset-stage-0": "^6.5.0",
@ -41,6 +42,8 @@
"immutable": "3.8.1",
"isomorphic-fetch": "2.2.1",
"keymirror": "0.1.1",
"mobx": "2.4.0",
"mobx-react": "3.5.1",
"moment": "2.13.0",
"moment-duration-format": "1.3.0",
"react": "15.1.0",

View File

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

View File

@ -0,0 +1,35 @@
/**
* Created by cmeyers on 7/26/16.
*/
import React, { Component, PropTypes } from 'react';
import FavoritesProvider from './FavoritesProvider';
import DashboardCards from './DashboardCards';
/**
* Restyled version of FavoritePipeline component.
*/
class FavoritesDashboard extends Component {
render() {
if (!this.props.mobxStores ||
!this.props.mobxStores.favoritesStore ||
!this.props.mobxStores.favoritesStore.favoritesList) {
return null;
}
const favoritesList = this.props.mobxStores.favoritesStore.favoritesList;
return (
<div>
<FavoritesProvider favoritesList={favoritesList} />
<DashboardCards favoritesList={favoritesList} />
</div>
);
}
}
FavoritesDashboard.propTypes = {
mobxStores: PropTypes.object,
};
export default FavoritesDashboard;

View File

@ -2,12 +2,7 @@
* 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';
import { observer } from 'mobx-react';
/**
* FavoritesProvider ensures that the current user's favorites
@ -16,6 +11,7 @@ import { actions } from '../redux/FavoritesActions';
* Components that require this data can simply wrap themselves in
* FavoritesProvider which will ensure the store is updated correctly.
*/
@observer
export class FavoritesProvider extends Component {
componentWillMount() {
@ -27,17 +23,17 @@ export class FavoritesProvider extends Component {
}
_initialize(props) {
const { user, favorites } = props;
const { favoritesList } = props;
const { user, favorites } = props.favoritesList;
const shouldFetchUser = !user;
const shouldFetchFavorites = user && !favorites;
if (shouldFetchUser) {
this.props.fetchUser();
}
if (shouldFetchFavorites) {
this.props.fetchFavorites(user);
favoritesList.fetchCurrentUser()
.then(() => {
favoritesList.fetchFavorites();
});
}
}
@ -50,15 +46,7 @@ export class FavoritesProvider extends Component {
FavoritesProvider.propTypes = {
children: PropTypes.node,
user: PropTypes.object,
favorites: PropTypes.instanceOf(List),
fetchUser: PropTypes.func,
fetchFavorites: PropTypes.func,
favoritesList: PropTypes.object,
};
const selectors = createSelector(
[userSelector, favoritesSelector],
(user, favorites) => ({ user, favorites })
);
export default connect(selectors, actions)(FavoritesProvider);
export default FavoritesProvider;

View File

@ -0,0 +1,30 @@
/**
* Created by cmeyers on 7/25/16.
*/
import React, { Component, PropTypes } from 'react';
import { observer } from 'mobx-react';
/**
*/
@observer class ViewFavoritesList extends Component {
onFetch() {
this.props.list.loadFavorites();
}
render() {
return (
<div>
<div>{this.props.list.count}</div>
<button onClick={() => this.onFetch()}>Fetch</button>
</div>
);
}
}
ViewFavoritesList.propTypes = {
list: PropTypes.object,
};
export default ViewFavoritesList;

View File

@ -2,9 +2,9 @@
# 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 :(
extensions:
- component: redux/FavoritesStore
extensionPoint: jenkins.main.stores
- component: components/DashboardCards
- component: model/PersonalizationStore
extensionPoint: jenkins.main.stores.mobx
- component: components/FavoritesDashboard
extensionPoint: jenkins.pipeline.list.top
- component: components/FavoritePipeline
extensionPoint: jenkins.pipeline.list.action

View File

@ -0,0 +1,126 @@
/**
* 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;
user = null;
constructor() {
this._init();
}
@action
_init() {
this.favorites = new List();
}
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);
}
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",
"immutable": "3.8.1",
"keymirror": "0.1.1",
"mobx": "2.4.0",
"mobx-react": "3.5.1",
"moment": "2.13.0",
"react": "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 { Router, Route, Link, useRouterHistory, IndexRedirect } from 'react-router';
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 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 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
history.listen(newLocation => {
const { dispatch, getState } = store;
@ -137,12 +153,19 @@ function startApp(routes, stores) {
// Start React
render(
<Provider store={store}>
<Router history={history}>{ makeRoutes(routes) }</Router>
</Provider>
, rootElement);
React.cloneElement(
<MobXProvider>
<ReduxProvider store={store}>
<Router history={history}>{ makeRoutes(routes) }</Router>
</ReduxProvider>
</MobXProvider>,
mobxMasterStore
),
rootElement);
}
Extensions.store.getExtensions(['jenkins.main.routes', 'jenkins.main.stores'], (routes = [], stores = []) => {
startApp(routes, stores);
Extensions.store.getExtensions(
['jenkins.main.routes', 'jenkins.main.stores', 'jenkins.main.stores.mobx'],
(routes = [], stores = [], mobxStores =[]) => {
startApp(routes, stores, mobxStores);
});