Merge pull request #367 from jenkinsci/feature/JENKINS-35840-35781-more-favoriting

Feature/jenkins 35840 35781 more favoriting
This commit is contained in:
Cliff Meyers 2016-07-27 21:18:20 -04:00 committed by GitHub
commit e84f3204e0
18 changed files with 351 additions and 177 deletions

View File

@ -3,6 +3,7 @@ import { CommitHash, ReadableDate } from '@jenkins-cd/design-language';
import { LiveStatusIndicator, WeatherIcon } from '@jenkins-cd/design-language';
import Extensions from '@jenkins-cd/js-extensions';
import RunPipeline from './RunPipeline.jsx';
import { StopPropagation } from './StopPropagation';
import { buildRunDetailsUrl } from '../util/UrlUtils';
const { object } = PropTypes;
@ -43,22 +44,30 @@ export default class Branches extends Component {
};
const { msg } = changeSet[0] || {};
return (<tr key={cleanBranchName} onClick={open} id={`${cleanBranchName}-${id}`} >
<td><WeatherIcon score={weatherScore} /></td>
<td onClick={open}>
<LiveStatusIndicator result={result === 'UNKNOWN' ? state : result}
startTime={startTime} estimatedDuration={estimatedDurationInMillis}
/>
</td>
<td>{cleanBranchName}</td>
<td><CommitHash commitId={commitId} /></td>
<td>{msg || '-'}</td>
<td><ReadableDate date={endTime} liveUpdate /></td>
<td>
<RunPipeline organization={organization} pipeline={fullName} branch={encodeURIComponent(branchName)} />
<Extensions.Renderer extensionPoint="jenkins.pipeline.branches.list.action" />
</td>
</tr>);
return (
<tr key={cleanBranchName} onClick={open} id={`${cleanBranchName}-${id}`} >
<td><WeatherIcon score={weatherScore} /></td>
<td onClick={open}>
<LiveStatusIndicator result={result === 'UNKNOWN' ? state : result}
startTime={startTime} estimatedDuration={estimatedDurationInMillis}
/>
</td>
<td>{cleanBranchName}</td>
<td><CommitHash commitId={commitId} /></td>
<td>{msg || '-'}</td>
<td><ReadableDate date={endTime} liveUpdate /></td>
<td>
<StopPropagation className="actions">
<RunPipeline organization={organization} pipeline={fullName} branch={encodeURIComponent(branchName)} />
<Extensions.Renderer
extensionPoint="jenkins.pipeline.branches.list.action"
pipeline={data}
store={this.context.store}
/>
</StopPropagation>
</td>
</tr>
);
}
}
@ -66,8 +75,8 @@ Branches.propTypes = {
data: object.isRequired,
};
Branches.contextTypes = {
store: object,
pipeline: object,
router: object.isRequired, // From react-router
location: object,

View File

@ -1,5 +1,6 @@
import React, { Component, PropTypes } from 'react';
import { Link } from 'react-router';
import Extensions from '@jenkins-cd/js-extensions';
import { isFailure, isPending } from '../util/FetchStatus';
import NotFound from './NotFound';
import {
@ -9,7 +10,6 @@ import {
PageTabs,
TabLink,
WeatherIcon,
Favorite,
} from '@jenkins-cd/design-language';
import { buildOrganizationUrl, buildPipelineUrl } from '../util/UrlUtils';
@ -27,7 +27,7 @@ export default class PipelinePage extends Component {
if (isPending(pipeline)) {
return null;
}
if (isFailure(pipeline)) {
return <NotFound />;
}
@ -44,7 +44,11 @@ export default class PipelinePage extends Component {
<span> / </span>
<Link to={activityUrl}>{name}</Link>
</h1>
<Favorite className="dark-yellow" />
<Extensions.Renderer
extensionPoint="jenkins.pipeline.detail.header.action"
store={this.context.store}
pipeline={this.context.pipeline}
/>
</Title>
<PageTabs base={baseUrl}>
<TabLink to="/activity">Activity</TabLink>
@ -65,4 +69,5 @@ PipelinePage.propTypes = {
PipelinePage.contextTypes = {
location: PropTypes.object,
pipeline: PropTypes.object,
store: PropTypes.object,
};

View File

@ -91,6 +91,12 @@ class RunDetails extends Component {
decodeURIComponent(run.pipeline) === branch;
})[0];
// deep-linking across RunDetails for different pipelines yields 'runs' data for the wrong pipeline
// during initial render. when runs are refetched the screen will render again with 'currentRun' correctly set
if (!currentRun) {
return null;
}
currentRun.name = name;
const status = currentRun.result === 'UNKNOWN' ? currentRun.state : currentRun.result;

View File

@ -0,0 +1,40 @@
/**
* Created by cmeyers on 7/27/16.
*/
import React, { Component, PropTypes } from 'react';
/**
* Stops propagation of click events inside this container.
* Useful for areas in UI where children should always handle the event, no matter what parent listeners are bound.
*
* This is a workaround for the following scenario:
* 1. Parent DOM element has a click listener,
* 2. Child DOM element added via an extension point calls event.stopPropagation() in its own click listener.
*
* This fails to work, even when calling stopProp and stopImmediateProp on the native event,
* probably beacuse there are two React trees each with their own document listener.
*
* see: http://stackoverflow.com/questions/24415631/reactjs-syntheticevent-stoppropagation-only-works-with-react-events
*/
export class StopPropagation extends Component {
_suppressEvent(event) {
event.stopPropagation();
}
render() {
return (
<div
className={this.props.className}
onClick={(event) => this._suppressEvent(event)}
>
{this.props.children}
</div>
);
}
}
StopPropagation.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
};

View File

@ -72,6 +72,8 @@ export const PullRequestRecord = Record({
});
export const RunsRecord = Record({
_class: null,
_links: null,
latestRun: ActivityRecord,
name: null,
weatherScore: 0,

View File

@ -48,115 +48,121 @@
}
.pipelines-table {
th {
width: 10%;
min-width: 100px;
}
th {
width: 10%;
min-width: 100px;
}
.name {
width: auto;
}
.name {
width: auto;
}
.favorite {
width: 30px;
}
.favorite {
width: 30px;
}
}
.activity-table {
th {
width: 75px;
}
th {
width: 75px;
}
.branch {
width: 175px;
}
.branch {
width: 175px;
}
.message {
width: 50%;
}
.message {
width: 50%;
}
.duration, .completed {
width: 125px;
}
.duration, .completed {
width: 125px;
}
.status-link {
cursor: pointer;
}
.status-link {
cursor: pointer;
}
}
.multibranch-table {
th {
width: 75px;
}
th {
width: 75px;
}
.branch {
width: 200px;
}
.branch {
width: 200px;
}
.message {
width: 50%;
}
.message {
width: 50%;
}
.lastcommit, .completed {
width: 125px;
}
.lastcommit, .completed {
width: 125px;
}
.actions {
display: flex;
align-items: center;
}
}
.pr-table {
th {
width: 75px;
}
th {
width: 75px;
}
.summary {
width: 100%;
}
.summary {
width: 100%;
}
.build, .completed {
width: 125px;
}
.build, .completed {
width: 125px;
}
}
.changeset-table {
th {
width: 100px;
}
th {
width: 100px;
}
.author {
width: 150px;
}
.author {
width: 150px;
}
.message {
width: 100%;
}
.message {
width: 100%;
}
.date {
width: 125px;
}
.date {
width: 125px;
}
}
.artifacts-table {
th {
width: 100px;
}
th {
width: 100px;
}
.name {
width: 100%;
}
.name {
width: 100%;
}
.download {
text-align: right;
}
.download {
text-align: right;
}
}
.nodes {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px;
}
.nodes__section {
display: flex;
align-items: center;
display: flex;
align-items: center;
}
.logConsole .result-item-children {

View File

@ -35,7 +35,7 @@
},
"dependencies": {
"@jenkins-cd/design-language": "0.0.63",
"@jenkins-cd/js-extensions": "0.0.17-beta-1",
"@jenkins-cd/js-extensions": "0.0.19",
"@jenkins-cd/js-modules": "0.0.5",
"@jenkins-cd/sse-gateway": "0.0.5",
"immutable": "3.8.1",

View File

@ -6,9 +6,10 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { List } from 'immutable';
import { userSelector, favoritesSelector } from '../redux/FavoritesStore';
import { favoritesSelector } from '../redux/FavoritesStore';
import { actions } from '../redux/FavoritesActions';
import FavoritesProvider from './FavoritesProvider';
import { PipelineCard } from './PipelineCard';
// the order the cards should be displayed based on their result/state (aka 'status')
@ -90,54 +91,11 @@ const extractPath = (path, begin, end) => {
*/
export class DashboardCards extends Component {
constructor(props) {
super(props);
this.fetchUserInProgress = false;
this.fetchFavoritesInProgress = false;
}
componentWillMount() {
this._initialize(this.props);
}
componentWillReceiveProps(props) {
this._initialize(props);
}
_initialize(props) {
const config = this.context.config;
const { user, favorites } = props;
if (user) {
this.fetchUserInProgress = false;
}
if (favorites) {
this.fetchFavoritesInProgress = false;
}
if (config) {
const shouldFetchUser = !user && !this.fetchUserInProgress;
const shouldFetchFavorites = user && !favorites && !this.fetchFavoritesInProgress;
if (shouldFetchUser) {
this.fetchUserInProgress = true;
this.props.fetchUser(config);
}
if (shouldFetchFavorites) {
this.fetchFavoritesInProgress = true;
this.props.fetchFavorites(config, user);
}
}
}
_onFavoriteToggle(isFavorite, favorite) {
this.props.toggleFavorite(isFavorite, favorite.item);
this.props.toggleFavorite(isFavorite, favorite.item, favorite);
}
render() {
_renderCardStack() {
if (!this.props.favorites) {
return null;
}
@ -168,6 +126,7 @@ export class DashboardCards extends Component {
let startTime = null;
let estimatedDuration = null;
let commitId = null;
let runId = null;
if (latestRun) {
if (latestRun.result) {
@ -177,6 +136,7 @@ export class DashboardCards extends Component {
startTime = latestRun.startTime;
estimatedDuration = latestRun.estimatedDurationInMillis;
commitId = latestRun.commitId;
runId = latestRun.id;
}
if (latestRun && latestRun.result) {
@ -194,6 +154,7 @@ export class DashboardCards extends Component {
pipeline={pipelineName}
branch={branchName}
commitId={commitId}
runId={runId}
favorite
onFavoriteToggle={(isFavorite) => this._onFavoriteToggle(isFavorite, favorite)}
/>
@ -207,23 +168,25 @@ export class DashboardCards extends Component {
</div>
);
}
render() {
return (
<FavoritesProvider store={this.props.store}>
{ this._renderCardStack() }
</FavoritesProvider>
);
}
}
DashboardCards.propTypes = {
user: PropTypes.object,
store: PropTypes.object,
favorites: PropTypes.instanceOf(List),
fetchUser: PropTypes.func,
fetchFavorites: PropTypes.func,
toggleFavorite: PropTypes.func,
};
DashboardCards.contextTypes = {
config: PropTypes.object,
};
const selectors = createSelector(
[userSelector, favoritesSelector],
(user, favorites) => ({ user, favorites })
[favoritesSelector],
(favorites) => ({ favorites })
);
export default connect(selectors, actions)(DashboardCards);

View File

@ -11,8 +11,12 @@ import { Favorite } from '@jenkins-cd/design-language';
import { favoritesSelector } from '../redux/FavoritesStore';
import { actions } from '../redux/FavoritesActions';
import { checkMatchingFavoriteUrls } from '../util/FavoriteUtils';
import FavoritesProvider from './FavoritesProvider';
/**
* 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
* and toggling favorited state on the server.
*/
export class FavoritePipeline extends Component {
@ -34,18 +38,21 @@ export class FavoritePipeline extends Component {
}
}
_findMatchingFavorite(pipeline, favorites) {
if (!pipeline || !favorites) {
return null;
}
return favorites.find((fav) => {
const favUrl = fav.item._links.self.href;
const pipelineUrl = pipeline._links.self.href;
return checkMatchingFavoriteUrls(favUrl, pipelineUrl);
});
}
_updateState(props) {
const { pipeline } = props;
let favorite = null;
if (props.favorites) {
favorite = props.favorites.find((fav) => {
const favUrl = fav.item._links.self.href;
const pipelineUrl = pipeline._links.self.href;
return checkMatchingFavoriteUrls(favUrl, pipelineUrl);
});
}
const favorite = this._findMatchingFavorite(pipeline, props.favorites);
this.setState({
favorite: !!favorite,
@ -58,16 +65,20 @@ export class FavoritePipeline extends Component {
favorite: isFavorite,
});
const favorite = this._findMatchingFavorite(this.props.pipeline, this.props.favorites);
if (this.props.toggleFavorite) {
this.props.toggleFavorite(isFavorite, this.props.pipeline);
this.props.toggleFavorite(isFavorite, this.props.pipeline, favorite);
}
}
render() {
return (
<Favorite checked={this.state.favorite} className={this.props.className}
onToggle={() => this._onFavoriteToggle()}
/>
<FavoritesProvider store={this.props.store}>
<Favorite checked={this.state.favorite} className={this.props.className}
onToggle={() => this._onFavoriteToggle()}
/>
</FavoritesProvider>
);
}
}
@ -77,6 +88,7 @@ FavoritePipeline.propTypes = {
pipeline: PropTypes.object,
favorites: PropTypes.instanceOf(List),
toggleFavorite: PropTypes.func,
store: PropTypes.object,
};
FavoritePipeline.defaultProps = {

View File

@ -0,0 +1,18 @@
import React, { Component } from 'react';
import FavoritePipeline from './FavoritePipeline';
/**
* Restyled version of FavoritePipeline component.
*
* Created by cmeyers on 7/20/16.
*/
export class FavoritePipelineHeader extends Component {
render() {
return (
<FavoritePipeline { ...this.props } className="dark-yellow" />
);
}
}
export default FavoritePipelineHeader;

View File

@ -0,0 +1,64 @@
/**
* 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,6 +2,7 @@
* Created by cmeyers on 6/28/16.
*/
import React, { Component, PropTypes } from 'react';
import { Link } from 'react-router';
import { Icon } from 'react-material-icons-blue';
import { Favorite, LiveStatusIndicator } from '@jenkins-cd/design-language';
@ -75,6 +76,10 @@ export class PipelineCard extends Component {
const showRun = status && (status.toLowerCase() === 'failure' || status.toLowerCase() === 'aborted');
const commitText = commitId ? commitId.substr(0, 7) : '';
const runUrl = `/organizations/${encodeURIComponent(this.props.organization)}/` +
`${encodeURIComponent(this.props.fullName)}/detail/` +
`${encodeURIComponent(this.props.branch || this.props.pipeline)}/${encodeURIComponent(this.props.runId)}/pipeline`;
return (
<div className={`pipeline-card ${bgClass}`}>
<LiveStatusIndicator
@ -83,7 +88,9 @@ export class PipelineCard extends Component {
/>
<span className="name">
{this.props.organization} / <span title={this.props.fullName}>{this.props.pipeline}</span>
<Link to={runUrl}>
{this.props.organization} / <span title={this.props.fullName}>{this.props.pipeline}</span>
</Link>
</span>
{ this.props.branch ?
@ -129,6 +136,7 @@ PipelineCard.propTypes = {
pipeline: PropTypes.string,
branch: PropTypes.string,
commitId: PropTypes.string,
runId: PropTypes.string,
favorite: PropTypes.bool,
onRunClick: PropTypes.func,
onFavoriteToggle: PropTypes.func,

View File

@ -8,3 +8,7 @@ extensions:
extensionPoint: jenkins.pipeline.list.top
- component: components/FavoritePipeline
extensionPoint: jenkins.pipeline.list.action
- component: components/FavoritePipelineHeader
extensionPoint: jenkins.pipeline.detail.header.action
- component: components/FavoritePipeline
extensionPoint: jenkins.pipeline.branches.list.action

View File

@ -33,13 +33,24 @@ function parseJSON(response) {
});
}
const fetchFlags = {
[ACTION_TYPES.SET_USER]: false,
[ACTION_TYPES.SET_FAVORITES]: false,
};
export const actions = {
fetchUser(config) {
fetchUser() {
return (dispatch) => {
const baseUrl = config.getAppURLBase();
const baseUrl = urlConfig.blueoceanAppURL;
const url = `${baseUrl}/rest/organizations/jenkins/user/`;
const fetchOptions = { ...defaultFetchOptions };
if (fetchFlags[ACTION_TYPES.SET_USER]) {
return null;
}
fetchFlags[ACTION_TYPES.SET_USER] = true;
return dispatch(actions.generateData(
{ url, fetchOptions },
ACTION_TYPES.SET_USER
@ -47,13 +58,19 @@ export const actions = {
};
},
fetchFavorites(config, user) {
fetchFavorites(user) {
return (dispatch) => {
const baseUrl = config.getAppURLBase();
const baseUrl = urlConfig.blueoceanAppURL;
const username = user.id;
const url = `${baseUrl}/rest/users/${username}/favorites/`;
const fetchOptions = { ...defaultFetchOptions };
if (fetchFlags[ACTION_TYPES.SET_FAVORITES]) {
return null;
}
fetchFlags[ACTION_TYPES.SET_FAVORITES] = true;
return dispatch(actions.generateData(
{ url, fetchOptions },
ACTION_TYPES.SET_FAVORITES
@ -61,10 +78,14 @@ export const actions = {
};
},
toggleFavorite(addFavorite, branch) {
toggleFavorite(addFavorite, branch, favoriteToRemove) {
return (dispatch) => {
const baseUrl = urlConfig.jenkinsRootURL;
const url = `${baseUrl}${branch._links.self.href}/favorite`;
const url = addFavorite ?
`${baseUrl}${branch._links.self.href}/favorite` :
`${baseUrl}${favoriteToRemove._links.self.href}`;
const fetchOptions = {
...defaultFetchOptions,
method: 'PUT',
@ -89,12 +110,16 @@ export const actions = {
return (dispatch) => fetch(url, fetchOptions)
.then(checkStatus)
.then(parseJSON)
.then(json => dispatch({
...optional,
type: actionType,
payload: json,
}))
.then((json) => {
fetchFlags[actionType] = false;
return dispatch({
...optional,
type: actionType,
payload: json,
});
})
.catch((error) => {
fetchFlags[actionType] = false;
console.error(error); // eslint-disable-line no-console
// call again with no payload so actions handle missing data
dispatch({

View File

@ -6,6 +6,10 @@
min-width: 400px;
padding: 15px;
a {
color: white;
}
.name, .branch, .commit {
margin-left: 10px;
}

View File

@ -1,5 +1,9 @@
@import 'components/pipeline-card';
.multibranch-table .actions .checkbox {
margin-top: 3px;
}
.favorites-card-stack {
margin-bottom: 40px;

View File

@ -26,7 +26,7 @@ describe('PipelineCard', () => {
assert.equal(wrapper.find('LiveStatusIndicator').length, 1);
assert.equal(wrapper.find('.name').length, 1);
assert.equal(wrapper.find('.name').text(), 'Jenkins / blueocean');
assert.equal(wrapper.find('.name').text(), '<Link />');
assert.equal(wrapper.find('.branch').length, 1);
assert.equal(wrapper.find('.branchText').text(), 'feature/JENKINS-123');
assert.equal(wrapper.find('.commit').length, 1);

View File

@ -54,13 +54,17 @@ public class JenkinsJSExtensionsTest extends BaseTest {
Assert.assertEquals("AdminNavLink", extensionPoints.get(0).get("component"));
Assert.assertEquals("jenkins.logo.top", extensionPoints.get(0).get("extensionPoint"));
} else if ("blueocean-personalization".equals(pluginId)) {
Assert.assertEquals(3, extensionPoints.size());
Assert.assertEquals(5, extensionPoints.size());
Assert.assertEquals("redux/FavoritesStore", extensionPoints.get(0).get("component"));
Assert.assertEquals("jenkins.main.stores", extensionPoints.get(0).get("extensionPoint"));
Assert.assertEquals("components/DashboardCards", extensionPoints.get(1).get("component"));
Assert.assertEquals("jenkins.pipeline.list.top", extensionPoints.get(1).get("extensionPoint"));
Assert.assertEquals("components/FavoritePipeline", extensionPoints.get(2).get("component"));
Assert.assertEquals("jenkins.pipeline.list.action", extensionPoints.get(2).get("extensionPoint"));
Assert.assertEquals("components/FavoritePipelineHeader", extensionPoints.get(3).get("component"));
Assert.assertEquals("jenkins.pipeline.detail.header.action", extensionPoints.get(3).get("extensionPoint"));
Assert.assertEquals("components/FavoritePipeline", extensionPoints.get(4).get("component"));
Assert.assertEquals("jenkins.pipeline.branches.list.action", extensionPoints.get(4).get("extensionPoint"));
} else {
Assert.fail("Found extensions from unknown pluginId: " + pluginId);
}