Merge remote-tracking branch 'origin/master' into feature/UX-159

This commit is contained in:
Ivan Meredith 2016-04-11 10:30:09 +12:00
commit 25c675e470
51 changed files with 1149 additions and 455 deletions

View File

@ -1,4 +1,5 @@
This is BlueOcean repo. It is a multi-module maven project. Each sub-directory at the root of the repo is jenkins extension.
This is the BlueOcean repo. It is a multi-module maven project. Each sub-directory at the root of the repo is jenkins extension.
Blue Ocean is the new UI project for Jenkins.

View File

@ -0,0 +1,6 @@
{
"extends": "@jenkins-cd/jenkins/react",
"rules": {
"no-unused-vars": [2, {"varsIgnorePattern": "^React$"}]
}
}

View File

@ -0,0 +1,7 @@
import { configure } from '@kadira/storybook';
function loadStories() {
require('../src/main/js/components/stories/index');
}
configure(loadStories, module);

View File

@ -1,11 +1,127 @@
# Example plugin
# Admin plugin
This plugin is an example of a few extensions
The extension points are defined in `blueocean-web`
This plugin has started as an example of a few extensions.
The extension points are defined in `blueocean-web`.
However it had become the main app for all client side screens.
## Running this
1) Go into `blueocean-plugin` and run `mvn hpi:run` in a terminal. (mvn clean install from the root of the project is always a good idea regularly!)
2) From this directory, run `gulp rebundle` to watch for JS changes and reload them.
3) Open browser to http://localhost:8080/jenkins/blue/ to see this
4) hack away. Refreshing the browser will pick up changes. If you add a new extension point or export a new extension you may need to restart the `mvn hpi:run` process.
### With mvn
1. Go into `blueocean-plugin` and run `mvn hpi:run` in a terminal. (mvn clean install from the root of the project is always a good idea regularly!)
2. From this directory, run `gulp bundle:watch` to watch for JS changes and reload them.
3. Open browser to http://localhost:8080/jenkins/blue/ to see this
4. hack away. Refreshing the browser will pick up changes. If you add a new extension point or export a new extension you may need to restart the `mvn hpi:run` process.
### With npm/storybook
We are supporting React Storybook https://voice.kadira.io/introducing-react-storybook-ec27f28de1e2#.8zsjledjp
```javascript
npm run storybook
```
Then itll start a webserver on port 9001. Further on any change it will
refresh the page for you on the browser. The design is not the same as
in blueocean yet but you can fully develop the components without a
running jenkins,
#### Writing Stories
Basically, a story is a single view of a component. It's like a test case, but you can preview it (live) from the Storybook UI.
You can write your stories anywhere you want. But keeping them close to your components is a pretty good idea.
Let's write some stories:
```javascript
// src/main/js/components/stories/button.js
import React from 'react';
import { storiesOf, action } from '@kadira/storybook';
storiesOf('Button', module)
.add('with a text', () => (
<button onClick={action('clicked')}>My First Button</button>
))
.add('with no text', () => (
<button></button>
));
```
Here, we simply have two stories for the built-in `button` component. But, you can import any of your components and write stories for them instead.
#### Configurations for storybook
Now you need to tell Storybook where it should load the stories from. For that, you need to add the new story to the configuration file `.storybook/config.js`:
```javascript
// .storybook/config.js
import { configure } from '@kadira/storybook';
function loadStories() {
require('../src/main/js/components/stories/index');
require('../components/stories/button'); // add this line
}
configure(loadStories, module);
```
or to the `src/main/js/components/stories/index.js` (this is the preferred way):
```javascript
// src/main/js/components/stories/index.js
require('./pipelines');
require('./status');
require('./button'); // add this line
```
That's it. Now simply run “npm run storybook” and start developing your components.
### Testing
We have created different test environments that you can us during development.
To run the test once:
```
npm run test
```
#### TDD support via watch
```
npm run test:watch
```
Can be used for TDD with a rapid feedback loop (as soon you save all tests will run)
### Linting with npm
ESLint with React linting options have been enabled.
```
npm run lint
```
#### lint:fix
You can use the command lint:fix and it will try to fix all
offenses, however there maybe some more that you need to fix manually.
```
npm run lint:fix
```
#### lint:watch
You can use the command lint:watch and it will give rapid feedback
(as soon you save, lint will run) while you try to fix all offenses.
```
gulp lint:watch --continueOnLint
```

View File

@ -23,3 +23,6 @@ builder.defineTask('test', function() {
throw e;
});
});
builder.gulp.task('lint:watch', function () {
builder.gulp.watch(['src/main/js/**/*.js', 'src/main/js/**/*.jsx'], ['lint']);
});

View File

@ -2,16 +2,20 @@
"name": "blueocean-admin",
"version": "0.0.1",
"scripts": {
"storybook": "start-storybook -p 9001",
"lint": "gulp lint --silent",
"lint:fix": "gulp lint --fixLint --silent",
"lint:watch": "gulp lint:watch",
"test": "gulp test --silent",
"retest": "gulp retest --silent",
"test:watch": "gulp test:watch --silent",
"bundle": "gulp bundle --silent",
"rebundle": "gulp rebundle"
"bundle:watch": "gulp bundle:watch"
},
"devDependencies": {
"@jenkins-cd/js-builder": "0.0.17",
"@jenkins-cd/js-test": "^1.x",
"@jenkins-cd/eslint-config-jenkins": "0.0.2",
"@jenkins-cd/js-builder": "0.0.23",
"@jenkins-cd/js-test": "1.1.1",
"@kadira/storybook": "^1.3.0",
"babel": "^6.5.2",
"babel-core": "^6.6.5",
"babel-eslint": "^6.0.0",
@ -28,9 +32,9 @@
"skin-deep": "^0.14.0"
},
"dependencies": {
"@jenkins-cd/design-language": "^0.x",
"@jenkins-cd/js-extensions": "^0.x",
"@jenkins-cd/js-modules": "^0.x",
"@jenkins-cd/design-language": "0.0.7",
"@jenkins-cd/js-extensions": "0.0.10",
"@jenkins-cd/js-modules": "0.0.2",
"immutable": "^3.7.6",
"moment": "^2.12.0",
"moment-duration-format": "^1.3.0",
@ -38,5 +42,8 @@
"react-dom": "^0.14.5",
"react-router": "^2.0.1",
"window-handle": "^1.0.0"
},
"jenkinscd" : {
"extDependencies": ["immutable", "react-router"]
}
}

View File

@ -1,13 +1,15 @@
import React, { Component, PropTypes } from 'react';
import Immutable from 'immutable';
function placeholder(...ignored) {
const requestDone = 4; // Because Zombie is garbage
function placeholder() {
return null;
}
// FIXME: We should rename this to something clearer and lose the capital A if we're going to keep it.
export default function AjaxHoc(ComposedComponent, getURLFromProps = placeholder) {
// FIXME: We should rename this to something clearer and
// lose the capital A if we're going to keep it.
export default function ajaxHoc(ComposedComponent, getURLFromProps = placeholder) {
class Wrapped extends Component {
constructor(props) {
super(props);
@ -15,19 +17,10 @@ export default function AjaxHoc(ComposedComponent, getURLFromProps = placeholder
const data = null;
const url = null;
this.state = {data, url};
this.state = { data, url };
this._lastUrl = null; // Keeping this out of the react state so we can set it any time
}
checkUrl(props) {
const config = this.context.config;
const url = getURLFromProps(props, config);
this.setState({url}, () => this.maybeLoad());
}
componentWillReceiveProps(nextProps) {
this.checkUrl(nextProps);
}
componentWillMount() {
this.checkUrl(this.props);
@ -37,21 +30,27 @@ export default function AjaxHoc(ComposedComponent, getURLFromProps = placeholder
this.maybeLoad();
}
componentWillReceiveProps(nextProps) {
this.checkUrl(nextProps);
}
maybeLoad() {
const {url} = this.state;
if (url && url != this._lastUrl) {
const { url } = this.state;
if (url && url !== this._lastUrl) {
this._lastUrl = url;
this.fetchPipelineData(data => {
// eslint-disable-next-line
this.setState({
data: Immutable.fromJS(data)
data: Immutable.fromJS(data),
});
});
}
}
render() {
return <ComposedComponent {...this.props} data={ this.state.data }/>;
checkUrl(props) {
const config = this.context.config;
const url = getURLFromProps(props, config);
this.setState({ url }, () => this.maybeLoad());
}
/** FIXME: Ghetto ajax loading of pipeline data for an org @store*/
@ -65,7 +64,7 @@ export default function AjaxHoc(ComposedComponent, getURLFromProps = placeholder
}
xmlhttp.onreadystatechange = () => {
if (xmlhttp.readyState === XMLHttpRequest.DONE) {
if (xmlhttp.readyState === requestDone) {
if (xmlhttp.status === 200) {
let data = null;
try {
@ -85,10 +84,15 @@ export default function AjaxHoc(ComposedComponent, getURLFromProps = placeholder
xmlhttp.open('GET', url, true);
xmlhttp.send();
}
render() {
return <ComposedComponent {...this.props} data={ this.state.data } />;
}
}
Wrapped.contextTypes = {
config: PropTypes.object
config: PropTypes.object,
};
return Wrapped;

View File

@ -10,9 +10,9 @@ class OrganisationPipelines extends Component {
// The specific pipeline we may be focused on
let pipeline;
if(pipelines && this.props.params && this.props.params.pipeline) {
if (pipelines && this.props.params && this.props.params.pipeline) {
const name = this.props.params.pipeline;
pipeline = pipelines.find(aPipeLine => aPipeLine.get("name") == name);
pipeline = pipelines.find(aPipeLine => aPipeLine.get('name') === name);
// FIXME: This foo.get("bar") syntax is not ideal ^^^
// Convert back to a real JS object
@ -30,12 +30,12 @@ class OrganisationPipelines extends Component {
OrganisationPipelines.propTypes = {
data: PropTypes.object, // From Ajax wrapper
params: PropTypes.object, // From react-router
children: PropTypes.node // From react-router
children: PropTypes.node, // From react-router
};
OrganisationPipelines.childContextTypes = {
pipelines: PropTypes.object,
pipeline: PropTypes.object
pipeline: PropTypes.object,
};
// eslint-disable-next-line

View File

@ -1,16 +1,18 @@
import { Route, IndexRoute } from 'react-router';
import React from 'react';
import OrganisationPipelines from './OrganisationPipelines';
import { Pipelines, MultiBranch, Activity, PullRequests } from './components';
import { Pipelines, MultiBranch, Activity, PullRequests, PipelinePage } from './components';
// Config has some globals in it for path / routes
import { rootRoutePath } from './config';
export default (
<Route path={rootRoutePath} component={OrganisationPipelines}>
<IndexRoute component={Pipelines}/>
<Route path=":pipeline/branches" component={MultiBranch}/>
<Route path=":pipeline/activity" component={Activity}/>
<Route path=":pipeline/pr" component={PullRequests}/>
<IndexRoute component={Pipelines} />
<Route component={PipelinePage}>
<Route path=":pipeline/branches" component={MultiBranch} />
<Route path=":pipeline/activity" component={Activity} />
<Route path=":pipeline/pr" component={PullRequests} />
</Route>
</Route>
);
);

View File

@ -1,16 +1,11 @@
import React, { Component, PropTypes } from 'react';
import AjaxHoc from '../AjaxHoc';
import ajaxHoc from '../AjaxHoc';
import Table from './Table';
import Runs from './Runs';
import { Link } from 'react-router';
import { urlPrefix } from '../config';
import { ActivityRecord, ChangeSetRecord } from './records';
import { Page, PageHeader, Title, WeatherIcon } from '@jenkins-cd/design-language';
import pipelinePropProvider from './pipelinePropProvider';
export class Activity extends Component {
render() {
const { pipeline, data } = this.props;
@ -18,48 +13,36 @@ export class Activity extends Component {
if (!data || !pipeline) {
return null;
}
const
{
name,
weatherScore,
} = pipeline;
const headers = ['Status', 'Build', 'Commit', 'Branch', 'Message', 'Duration', 'Completed'];
let latestRecord = {};
return (<Page>
<PageHeader>
<Title><WeatherIcon score={weatherScore} /> <h1>CloudBees / {name}</h1></Title>
</PageHeader>
<main>
<article>
<Table headers={headers}>
{ data.map((run, index) => {
let
return (<main>
<article>
<Table headers={headers}>
{ data.map((run, index) => {
let
changeset = run.get('changeSet');
if (changeset.size > 0) {
changeset = changeset.toJS();
latestRecord = new ChangeSetRecord(
changeset[Object.keys(changeset)[0]]);
}
return (<Runs
key={index}
changeset={latestRecord}
data={new ActivityRecord(run)}
/>);
})}
if (changeset.size > 0) {
changeset = changeset.toJS();
latestRecord = new ChangeSetRecord(
changeset[Object.keys(changeset)[0]]);
}
return (<Runs
key={index}
changeset={latestRecord}
data={new ActivityRecord(run)}
/>);
})}
<tr>
<td colSpan={headers.length}>
<Link className="btn" to={urlPrefix}>Dashboard</Link>
</td>
</tr>
</Table>
</article>
</main>
</Page>);
<tr>
<td colSpan={headers.length}>
<Link className="btn" to={urlPrefix}>Dashboard</Link>
</td>
</tr>
</Table>
</article>
</main>);
}
}
@ -69,8 +52,8 @@ Activity.propTypes = {
};
// Decorated for ajax as well as getting pipeline from context
export default pipelinePropProvider(AjaxHoc(Activity,(props, config) => {
export default ajaxHoc(Activity, (props, config) => {
if (!props.pipeline) return null;
return `${config.getAppURLBase()}/rest/organizations/jenkins` +
`/pipelines/${props.pipeline.name}/runs`;
}));
});

View File

@ -1,6 +1,6 @@
import React, { Component, PropTypes } from 'react';
import moment from 'moment';
import { WeatherIcon } from '@jenkins-cd/design-language';
import { WeatherIcon, StatusIndicator } from '@jenkins-cd/design-language';
export default class Branches extends Component {
@ -10,14 +10,13 @@ export default class Branches extends Component {
if (!data) {
return null;
}
const { latestRun, weatherScore, name} = data;
const { result, endTime, changeSet} = latestRun;
const {commitId, msg } = changeSet[0] || {};
const { latestRun, weatherScore, name } = data;
const { result, endTime, changeSet, state } = latestRun;
const { commitId, msg } = changeSet[0] || {};
return (<tr key={name}>
<td><WeatherIcon score={weatherScore} /></td>
<td>{result}</td>
<td><StatusIndicator result={result === 'UNKNOWN' ? state : result} /></td>
<td>{decodeURIComponent(name)}</td>
<td>{commitId || '-'}</td>
<td>{msg || '-'}</td>

View File

@ -1,12 +1,10 @@
import React, { Component, PropTypes } from 'react';
import { Link } from 'react-router';
import Table from './Table';
import AjaxHoc from '../AjaxHoc';
import ajaxHoc from '../AjaxHoc';
import Branches from './Branches';
import { WeatherIcon, Page, PageHeader, Title } from '@jenkins-cd/design-language';
import { RunsRecord } from './records';
import { urlPrefix } from '../config';
import pipelinePropProvider from './pipelinePropProvider';
export class MultiBranch extends Component {
render() {
@ -16,38 +14,29 @@ export class MultiBranch extends Component {
return null;
}
const {
name,
weatherScore,
} = pipeline;
const headers =
['Health', 'Status', 'Branch', 'Last commit', 'Latest message', 'Completed'];
return (
<Page>
<PageHeader>
<Title><WeatherIcon score={weatherScore} /> <h1>CloudBees / {name}</h1></Title>
</PageHeader>
<main>
<article>
<Table className="multiBranch"
headers={headers}
>
{data.map((run, index) => {
const result = new RunsRecord(run.toJS());
return <Branches key={index} data={result} />;
})
}
<tr>
<td colSpan={headers.length}>
<Link className="btn" to={urlPrefix}>Dashboard</Link>
</td>
</tr>
</Table>
</article>
</main>
</Page>);
<main>
<article>
<Table className="multiBranch"
headers={headers}
>
{data.map((run, index) => {
const result = new RunsRecord(run.toJS());
return <Branches key={index} data={result} />;
})
}
<tr>
<td colSpan={headers.length}>
<Link className="btn" to={urlPrefix}>Dashboard</Link>
</td>
</tr>
</Table>
</article>
</main>
);
}
}
@ -57,8 +46,8 @@ MultiBranch.propTypes = {
};
// Decorated for ajax as well as getting pipeline from context
export default pipelinePropProvider(AjaxHoc(MultiBranch, (props, config) => {
export default ajaxHoc(MultiBranch, (props, config) => {
if (!props.pipeline) return null;
return `${config.getAppURLBase()}/rest/organizations/jenkins` +
`/pipelines/${props.pipeline.name}/branches`;
}));
});

View File

@ -0,0 +1,39 @@
import React, { Component, PropTypes } from 'react';
import { Page, PageHeader, Title, PageTabs, TabLink, WeatherIcon }
from '@jenkins-cd/design-language';
import { urlPrefix } from '../config';
export default class PipelinePage extends Component {
render() {
const { pipeline } = this.context;
if (!pipeline) {
return null; // Loading...
}
return (
<Page>
<PageHeader>
<Title>
<WeatherIcon score={pipeline.weatherScore} />
<h1>CloudBees / {pipeline.name}</h1>
</Title>
<PageTabs base={`${urlPrefix}/${pipeline.name}`}>
<TabLink to="/activity">Activity</TabLink>
<TabLink to="/branches">Branches</TabLink>
<TabLink to="/pr">Pull Requests</TabLink>
</PageTabs>
</PageHeader>
{React.cloneElement(this.props.children, { pipeline })}
</Page>
);
}
}
PipelinePage.propTypes = {
children: PropTypes.object,
};
PipelinePage.contextTypes = {
pipeline: PropTypes.object,
};

View File

@ -4,20 +4,24 @@ import { WeatherIcon } from '@jenkins-cd/design-language';
import { urlPrefix } from '../config';
export default class Pipeline extends Component {
export default class PipelineRowItem extends Component {
calculateResponse(passing, failing) {
let restponse = '-';
if (failing > 0) {
return (`${failing} failing`);
restponse = (`${failing} failing`);
} else if (passing > 0) {
return (`${passing} passing`);
} else {
return '-';
restponse = (`${passing} passing`);
}
return restponse;
}
render() {
const { pipeline } = this.props;
// Early out
if (!pipeline) {
return null;
}
const simple = !pipeline.branchNames;
const {
name,
@ -28,32 +32,36 @@ export default class Pipeline extends Component {
numberOfFailingPullRequests,
} = pipeline;
const hasPullRequests = !simple && (numberOfSuccessfulPullRequests || numberOfFailingPullRequests);
const hasPullRequests = !simple && (
numberOfSuccessfulPullRequests || numberOfFailingPullRequests);
const multiBranchURL = `${urlPrefix}/${name}/branches`;
const pullRequestsURL = `${urlPrefix}/${name}/pr`;
const activitiesURL = `${urlPrefix}/${name}/activity`;
let multiBranchLabel = " - ";
let multiPrLabel = " - ";
let multiBranchLabel = ' - ';
let multiPrLabel = ' - ';
let multiBranchLink = null;
let pullRequestsLink = null;
if (!simple) {
multiBranchLabel = this.calculateResponse(numberOfSuccessfulBranches, numberOfFailingBranches);
multiPrLabel = this.calculateResponse(numberOfSuccessfulPullRequests, numberOfFailingPullRequests);
multiBranchLabel = this.calculateResponse(
numberOfSuccessfulBranches, numberOfFailingBranches);
multiPrLabel = this.calculateResponse(
numberOfSuccessfulPullRequests, numberOfFailingPullRequests);
multiBranchLink = <Link className="btn" to={multiBranchURL}>multiBranch</Link>;
if (hasPullRequests)
if (hasPullRequests) {
pullRequestsLink = <Link className="btn" to={pullRequestsURL}>pr</Link>;
}
}
// FIXME: Visual alignment of the last column
return (
<tr>
<td>{name}</td>
<td><WeatherIcon score={weatherScore}/></td>
<td><WeatherIcon score={weatherScore} /></td>
<td>{multiBranchLabel}</td>
<td>{multiPrLabel}</td>
<td>
@ -67,6 +75,6 @@ export default class Pipeline extends Component {
}
}
Pipeline.propTypes = {
pipeline: PropTypes.object.isRequired
PipelineRowItem.propTypes = {
pipeline: PropTypes.object.isRequired,
};

View File

@ -1,5 +1,5 @@
import React, { Component, PropTypes } from 'react';
import Pipeline from './Pipeline';
import PipelineRowItem from './PipelineRowItem';
import { PipelineRecord } from './records';
import Table from './Table';
@ -24,14 +24,25 @@ export default class Pipelines extends Component {
<PageHeader>
<Title>
<h1>CloudBees</h1>
<a target="_blank" className="btn-primary" href="/jenkins/view/All/newJob">New Pipeline</a>
<a
target="_blank"
className="btn-primary"
href="/jenkins/view/All/newJob"
>
New Pipeline
</a>
</Title>
</PageHeader>
<main>
<article>
<Table className="multiBranch" headers={['Name', 'Status', 'Branches', 'Pull Requests', '']}>
<Table
className="multiBranch"
headers={['Name', 'Status', 'Branches', 'Pull Requests', '']}
>
{ pipelineRecords
.map(pipeline => <Pipeline key={pipeline.name} pipeline={pipeline}/>)
.map(pipeline => <PipelineRowItem
key={pipeline.name} pipeline={pipeline}
/>)
.toArray() }
</Table>
</article>
@ -41,5 +52,5 @@ export default class Pipelines extends Component {
}
Pipelines.contextTypes = {
pipelines: PropTypes.object
pipelines: PropTypes.object,
};

View File

@ -1,25 +1,27 @@
import React, { Component, PropTypes } from 'react';
import moment from 'moment';
import { StatusIndicator } from '@jenkins-cd/design-language';
export default class PullRequest extends Component {
render() {
const { pr } = this.props;
if (!pr) {
return null;
render() {
const { pr } = this.props;
if (!pr) {
return null;
}
const { latestRun, pullRequest } = pr;
if (!latestRun || !pullRequest) {
return null;
}
const result = latestRun.result === 'UNKNOWN' ? latestRun.state : latestRun.result;
return (<tr key={latestRun.id}>
<td><StatusIndicator result={result} /></td>
<td>{latestRun.id}</td>
<td>{pullRequest.title || '-'}</td>
<td>{pullRequest.author || '-'}</td>
<td>{moment(latestRun.endTime).fromNow()}</td>
</tr>);
}
const { latestRun, pullRequest } = pr;
if (!latestRun || !pullRequest) {
return null;
}
return (<tr key={latestRun.id}>
<td>{latestRun.result}</td>
<td>{latestRun.id}</td>
<td>{pullRequest.title || '-'}</td>
<td>{pullRequest.author || '-'}</td>
<td>{moment(latestRun.endTime).fromNow()}</td>
</tr>);
}
}
PullRequest.propTypes = {

View File

@ -1,13 +1,10 @@
import React, { Component, PropTypes } from 'react';
import { Link } from 'react-router';
import AjaxHoc from '../AjaxHoc';
import ajaxHoc from '../AjaxHoc';
import Table from './Table';
import PullRequest from './PullRequest';
import { RunsRecord } from './records';
import { urlPrefix } from '../config';
import pipelinePropProvider from './pipelinePropProvider';
import { Page, PageHeader, Title, WeatherIcon } from '@jenkins-cd/design-language';
export class PullRequests extends Component {
render() {
@ -16,28 +13,17 @@ export class PullRequests extends Component {
if (!data || !pipeline) {
return null;
}
const
{
name,
weatherScore,
} = pipeline;
const headers = ['Status', 'Latest Build', 'Summary', 'Author', 'Completed'];
return (<Page>
<PageHeader>
<Title><WeatherIcon score={weatherScore}/> <h1>CloudBees / {name}</h1></Title>
</PageHeader>
return (
<main>
<article>
<Table headers={headers}>
{ data.filter((run) => run.get('pullRequest')).map((run, index) => {
const result = new RunsRecord(run.toJS());
return (<PullRequest
key={index}
pr={result}
key={index}
pr={result}
/>);
})}
@ -49,7 +35,7 @@ export class PullRequests extends Component {
</Table>
</article>
</main>
</Page>);
);
}
}
@ -59,8 +45,8 @@ PullRequests.propTypes = {
};
// Decorated for ajax as well as getting pipeline from context
export default pipelinePropProvider(AjaxHoc(PullRequests, (props, config) => {
export default ajaxHoc(PullRequests, (props, config) => {
if (!props.pipeline) return null;
return `${config.getAppURLBase()}/rest/organizations/jenkins` +
`/pipelines/${props.pipeline.name}/branches`;
}));
});

View File

@ -0,0 +1,51 @@
import React, { Component, PropTypes } from 'react';
import { StatusIndicator } from '@jenkins-cd/design-language';
const { number } = PropTypes;
/*
DEMO of running state with raising percentages
*/
export default class RunningIndicator extends Component {
constructor(props) {
super(props);
const initialPercentage = props.percentage || 12.5;
this.state = { percentage: initialPercentage };
this.tick = () => { // FIXME: remove this.tick code when ux-206 is fixed
if (this.state.percentage === 100 - initialPercentage) {
this.setState({ percentage: 100 });
clearInterval(this.timer);
} else {
this.setState({ percentage: this.state.percentage + initialPercentage });
}
};
}
/* FIXME:
remove all interval related code when ux-206 is fixed
start
*/
componentDidMount() {
this.timer = setInterval(this.tick, 500);
}
componentWillUnmount() {
clearInterval(this.timer);
}
/* FIXME:
remove all interval related code when ux-206 is fixed
stop
*/
render() {
const props = {
result: 'running',
title: 'running',
percentage: this.state.percentage,
};
return (<StatusIndicator
{...Object.assign({}, this.props, props)}
/>);
}
}
RunningIndicator.propTypes = {
percentage: number,
};

View File

@ -1,6 +1,6 @@
import React, { Component, PropTypes } from 'react';
import moment from 'moment';
import { ModalView, ModalBody } from '@jenkins-cd/design-language';
import { ModalView, ModalBody, StatusIndicator } from '@jenkins-cd/design-language';
require('moment-duration-format');
@ -10,7 +10,7 @@ require('moment-duration-format');
export default class Runs extends Component {
constructor(props) {
super(props);
this.state = {isVisible: false};
this.state = { isVisible: false };
}
render() {
const { data, changeset } = this.props;
@ -22,30 +22,39 @@ export default class Runs extends Component {
duration = moment.duration(
Number(data.durationInMillis), 'milliseconds').format('hh:mm:ss');
const
durationArray = duration.split(':'),
name = decodeURIComponent(data.pipeline)
;
const durationArray = duration.split(':');
const name = decodeURIComponent(data.pipeline);
if (durationArray.length === 1) {
duration = `00:${duration}`;
}
const afterClose = () => this.setState({ isVisible: false });
const open = () => this.setState({ isVisible: true });
const result = data.result === 'UNKNOWN' ? data.state : data.result;
return (<tr key={data.id}>
<td>
{
this.state.isVisible && <ModalView hideOnOverlayClicked
title={`Branch ${name}`}
isVisible={this.state.isVisible}
afterClose={() => this.setState({isVisible: false})}>
title={`Branch ${name}`}
isVisible={this.state.isVisible}
afterClose={afterClose}
>
<ModalBody>
<dl>
<dt>Status</dt>
<dd>{data.result}</dd>
<dd>
<StatusIndicator result={result} />
</dd>
<dt>Build</dt>
<dd>{data.id}</dd>
<dt>Commit</dt>
<dd>{changeset && changeset.commitId && changeset.commitId.substring(0, 8) || '-'}</dd>
<dd>
{changeset
&& changeset.commitId
&& changeset.commitId.substring(0, 8) || '-'
}
</dd>
<dt>Branch</dt>
<dd>{name}</dd>
<dt>Message</dt>
@ -58,12 +67,12 @@ export default class Runs extends Component {
</ModalBody>
</ModalView>
}
<a onClick={() => this.setState({isVisible: true})}>
{data.result}
<a onClick={open}>
<StatusIndicator result={result} />
</a>
</td>
<td>{data.id}</td>
<td>{changeset && changeset.commitId && changeset.commitId.substring(0, 8) || '-'}</td>
<td>{changeset && changeset.commitId && changeset.commitId.substring(0, 8) || '-'}</td>
<td>{name}</td>
<td>{changeset && changeset.comment || '-'}</td>
<td>

View File

@ -2,10 +2,12 @@ import Pipelines from './Pipelines';
import MultiBranch from './MultiBranch';
import Activity from './Activity';
import PullRequests from './PullRequests';
import PipelinePage from './PipelinePage';
export {
Pipelines,
MultiBranch,
Activity,
PullRequests
};
PullRequests,
PipelinePage,
};

View File

@ -1,24 +0,0 @@
import React, { Component, PropTypes } from 'react';
/**
* Wraps a component, for the purpose of providing a "selected pipeline" from the react context (as a prop on wrapped
* component). This is a transformer, not a constructor.
*
* @param WrappedComponent a react component class/constructor
* @return {Wrapper}
*/
export default function pipelinePropProvider(WrappedComponent) {
class Wrapper extends Component {
render() {
const { pipeline } = this.context;
return <WrappedComponent {...this.props} pipeline={pipeline}/>;
}
}
Wrapper.contextTypes = {
pipeline: PropTypes.object
};
return Wrapper;
}

View File

@ -1,6 +1,9 @@
import Immutable from 'immutable';
export const PipelineRecord = Immutable.Record({ // eslint-disable-line new-cap
/* We cannot extend Record
since we would return a function, */
/* eslint new-cap: [0] */
const { Record } = Immutable;
export const PipelineRecord = Record({
displayName: '',
name: '',
organization: '',
@ -14,23 +17,7 @@ export const PipelineRecord = Immutable.Record({ // eslint-disable-line new-cap
totalNumberOfPullRequests: 0,
});
export const ActivityRecord = Immutable.Record({// eslint-disable-line
changeSet: ChangeSetRecord,
durationInMillis: null,
enQueueTime: null,
endTime: null,
id: null,
organization: null,
pipeline: null,
result: null,
runSummary: null,
startTime: null,
state: null,
type: null,
commitId: null,
});
export const ChangeSetRecord = Immutable.Record({// eslint-disable-line
export const ChangeSetRecord = Record({
author: {
email: null,
fullName: null,
@ -46,19 +33,36 @@ export const ChangeSetRecord = Immutable.Record({// eslint-disable-line
timestamp: null,
});
export const RunsRecord = Immutable.Record({
latestRun: ActivityRecord,
name: null,
weatherScore: 0,
pullRequest: PullRequestRecord,
}
);
export const ActivityRecord = Record({
changeSet: ChangeSetRecord,
durationInMillis: null,
enQueueTime: null,
endTime: null,
id: null,
organization: null,
pipeline: null,
result: null,
runSummary: null,
startTime: null,
state: null,
type: null,
commitId: null,
});
export const PullRequestRecord = Immutable.Record({
export const PullRequestRecord = Record({
pullRequest: {
author: null,
id: null,
title: null,
url: null,
}
},
});
export const RunsRecord = Record({
latestRun: ActivityRecord,
name: null,
weatherScore: 0,
pullRequest: PullRequestRecord,
}
);

View File

@ -0,0 +1,2 @@
require('./pipelines');
require('./status');

View File

@ -0,0 +1,51 @@
import React from 'react';
import { storiesOf } from '@kadira/storybook';
import PipelineRowItem from '../PipelineRowItem.jsx';
import { PipelineRecord } from '../records.jsx';
import Table from '../Table.jsx';
/*
First example of using storybook
*/
storiesOf('pipelines', module)
.add('with a pipeline', () => (
<Table
className="multiBranch"
headers={['Name', 'Status', 'Branches', 'Pull Requests', '']}
>
<PipelineRowItem
pipeline={new PipelineRecord({
displayName: 'moreBeersSuccess',
name: 'morebeersSuccess',
organization: 'jenkins',
weatherScore: 0,
branchNames: ['master'],
numberOfFailingBranches: 0,
numberOfFailingPullRequests: 0,
numberOfSuccessfulBranches: 3,
numberOfSuccessfulPullRequests: 3,
totalNumberOfBranches: 3,
totalNumberOfPullRequests: 3,
})}
/>
<PipelineRowItem
pipeline={new PipelineRecord({
displayName: 'moreBeers',
name: 'morebeers',
organization: 'jenkins',
weatherScore: 0,
branchNames: ['master'],
numberOfFailingBranches: 1,
numberOfFailingPullRequests: 0,
numberOfSuccessfulBranches: 0,
numberOfSuccessfulPullRequests: 0,
totalNumberOfBranches: 1,
totalNumberOfPullRequests: 0,
})}
/>
</Table>
))
.add('no pipeline should return null', () => (
<PipelineRowItem />
))
;

View File

@ -0,0 +1,84 @@
import React from 'react';
import { storiesOf } from '@kadira/storybook';
import { StatusIndicator } from '@jenkins-cd/design-language';
import RunningIndicator from '../RunningIndicator.jsx';
const props = {
width: '640px',
height: '640px',
};
const smaller = {
width: '320px',
height: '320px',
};
storiesOf('StatusIndicators', module)
.add('success', () => (
<StatusIndicator
{...Object.assign({
result: 'SUCCESS',
}, props)}
/>
))
.add('failure', () => (
<StatusIndicator
{...Object.assign({
result: 'FAILURE',
}, props)}
/>
))
.add('queued', () => (
<div>
<div>This will be animated
by css and will turn
</div>
<StatusIndicator
{...Object.assign({
result: 'QUEUED',
}, props)}
/>
</div>
))
.add('running', () => (
<div>
<div>This shows 50%</div>
<StatusIndicator
{...Object.assign({
result: 'RUNNING',
percentage: 50,
}, props)}
/>
</div>
))
.add('running animated', () => (
<div>
<div>
This shows demo where % is raised
and stops at 100%
</div>
<RunningIndicator {...props} />
</div>
))
.add('all', () => (
<div>
<StatusIndicator
{...smaller}
result="SUCCESS"
/>
<StatusIndicator
{...smaller}
result="FAILURE"
/>
<StatusIndicator
{...smaller}
result="QUEUED"
/>
<StatusIndicator
{...smaller}
result="RUNNING"
percentage={50}
/>
</div>
))
;

View File

@ -1,3 +1,3 @@
// Shared consts.
export const rootRoutePath = "pipelines";
export const rootRoutePath = 'pipelines';
export const urlPrefix = `/${rootRoutePath}`;

View File

@ -1,5 +1,4 @@
import React from 'react';
import {createRenderer} from 'react-addons-test-utils';
import { assert} from 'chai';
import sd from 'skin-deep';
import Immutable from 'immutable';
@ -154,10 +153,6 @@ describe("Activity should render", () => {
});
it("does renders the Activity with data", () => {
// does WeatherIcon renders the value from the pipeline?
const weatherIcon = tree.subTree('WeatherIcon').getRenderOutput();
assert.isNotNull(weatherIcon);
assert.isNotNull(weatherIcon.props.score);
// does data renders?
const runs = tree.subTree('Runs').getRenderOutput();
assert.isNotNull(runs.props.changeset)

View File

@ -3,8 +3,8 @@ import {createRenderer} from 'react-addons-test-utils';
import { assert} from 'chai';
import sd from 'skin-deep';
import Pipeline from '../../main/js/components/Pipeline.jsx';
import PipelineRowItem from '../../main/js/components/PipelineRowItem.jsx';
import { PipelineRecord } from '../../main/js/components/records.jsx';
const
hack={
@ -44,21 +44,28 @@ const
'organization': 'jenkins',
'weatherScore': 0
},
testElementSimple = (<Pipeline
testElementSimple = (<PipelineRowItem
hack={hack}
pipeline={pipelineSimple}
simple={true}/>
),
testElementMultiSuccess = (<Pipeline
testElementMultiSuccess = (<PipelineRowItem
hack={hack}
pipeline={pipelineMultiSuccess}
/>
),
testElementMulti = (<Pipeline
testElementMulti = (<PipelineRowItem
hack={hack}
pipeline={pipelineMulti}/>
);
describe("PipelineRecord can be created ", () => {
it("without error", () => {
const pipeRecord = new PipelineRecord(pipelineMultiSuccess);
console.log(pipeRecord)
})
});
describe("pipeline component simple rendering", () => {
const
renderer = createRenderer();
@ -77,7 +84,6 @@ describe("pipeline component simple rendering", () => {
assert.isObject(children[2].props);
assert.equal(children[2].props.children, ' - ');
});
});
describe("pipeline component multiBranch rendering", () => {

View File

@ -1,88 +0,0 @@
import React, {Component} from 'react';
import sd from 'skin-deep';
import { assert} from 'chai';
import pipelinePropProvider from '../../main/js/components/pipelinePropProvider';
class DummyChild extends Component {
render() {
return <div>Pipeline is '{String(this.props.pipeline)}'</div>;
}
}
describe("pipelinePropProvider", () => {
const pipelineValue = {
foo: "bar",
baz: "quux",
toString() {return "pipeline value"}
};
const otherValue = "Rubber baby buggy bumpers";
const WrappedDummy = pipelinePropProvider(DummyChild);
let props = {};
// Using a provider function turns on Skin-deep's context support for some reason
const providerFn = () => React.createElement(WrappedDummy, props);
let tree, instance, vdom; // Rendered results
let context = {};
beforeEach(() => {
context = {};
props = {};
});
describe("with value in context", () => {
beforeEach(() => {
context.pipeline = pipelineValue;
props.otherProp = otherValue;
tree = sd.shallowRender(providerFn, context);
instance = tree.getMountedInstance();
vdom = tree.getRenderOutput();
});
it("should render", () => {
assert.isNotNull(tree);
assert.isNotNull(instance);
assert.isNotNull(vdom);
});
it("should extract the correct value", () => {
assert.equal(vdom.props.pipeline, pipelineValue, "pipeline property on vdom");
});
it("should pass on other props", () => {
assert.equal(vdom.props.otherProp, otherValue, "otherProp property on vdom");
});
});
describe("with no value in context", () => {
beforeEach(() => {
props.otherProp = otherValue;
tree = sd.shallowRender(providerFn, context);
instance = tree.getMountedInstance();
vdom = tree.getRenderOutput();
});
it("should render", () => {
assert.isNotNull(tree);
assert.isNotNull(instance);
assert.isNotNull(vdom);
});
it("should extract nothing from context", () => {
assert.equal(vdom.props.pipeline, null, "pipeline property on vdom");
});
it("should pass on other props", () => {
assert.equal(vdom.props.otherProp, otherValue, "otherProp property on vdom");
});
});
});

View File

@ -48,7 +48,7 @@ describe("pipelines", () => {
it("renders pipelines - check rows number to be as expected", () => {
const
row = tree.everySubTree('Pipeline')
row = tree.everySubTree('PipelineRowItem')
;
assert.equal(row.length, 2);
});

View File

@ -34,10 +34,6 @@ describe("PullRequests should render", () => {
});
it("does renders the PullRequests with data", () => {
// does WeatherIcon renders the value from the pipeline?
const weatherIcon = tree.subTree('WeatherIcon').getRenderOutput();
assert.isNotNull(weatherIcon);
assert.isNotNull(weatherIcon.props.score);
// does data renders?
const runs = tree.subTree('PullRequest').getRenderOutput();
assert.isNotNull(runs.props.changeset)

View File

@ -0,0 +1,74 @@
//FIXME: should be part of the jdl, but test are broken there
import React from 'react';
import { assert} from 'chai';
import sd from 'skin-deep';
import Immutable from 'immutable';
import { StatusIndicator, SvgStatus, SvgSpinner }
from '@jenkins-cd/design-language';
const props = {
width: '640px',
height: '640px',
};
const results = {
failure: {
fill: '#d54c53',
stroke: '#cf3a41',
},
};
describe("StatusIndicator should render", () => {
let tree = null;
beforeEach(() => {
tree = sd.shallowRender(<StatusIndicator
result="SUCCESS"
{...props}
/>);
});
it("does render success", () => {
const statusindicator = tree.getRenderOutput();
assert.isNotNull(statusindicator);
assert.equal(statusindicator.props.result, 'success');
assert.equal(statusindicator.props.width, props.width);
});
});
describe("SvgStatus should render", () => {
let tree = null;
beforeEach(() => {
tree = sd.shallowRender(<SvgStatus
result="FAILURE"
/>);
});
it("does render FAILURE", () => {
const circle = tree.subTree('circle').getRenderOutput();
assert.isNotNull(circle);
assert.equal(circle.props.fill, results.failure.fill);
assert.equal(circle.props.stroke, results.failure.stroke);
});
});
describe("SvgSpinner should render", () => {
let tree = null;
beforeEach(() => {
tree = sd.shallowRender(<SvgSpinner
result="QUEUED"
/>);
});
it("does render FAILURE", () => {
const path = tree.subTree('path').getRenderOutput();
assert.isNotNull(path);
assert.equal(path.props.fill, 'none');
assert.equal(path.props.stroke, '#4a90e2');
});
});

View File

@ -66,12 +66,12 @@ public class AbstractRunImpl<T extends Run> extends BlueRun {
@Override
public BlueRunState getStateObj() {
if(!run.hasntStartedYet() && run.isBuilding()) {
if(!run.hasntStartedYet() && run.isLogUpdated()) {
return BlueRunState.RUNNING;
} else if(!run.isLogUpdated()){
return BlueRunState.FINISHED;
} else {
return BlueRunState.FINISHED;
return BlueRunState.QUEUED;
}
}

View File

@ -156,7 +156,7 @@ public abstract class BlueRun extends Resource {
public abstract Object getLog();
public enum BlueRunState {
NOT_STARTED,
QUEUED,
RUNNING,
FINISHED
}

View File

@ -48,7 +48,7 @@ If you wish to make changes to blueocean.js, then you will need to install gulp
```
$ cd blueocean-web
$ gulp rebundle
$ gulp bundle:watch
```
(or run gulp, after each change) in the blueocean-web directory. This will pick up any changes.

View File

@ -13,8 +13,6 @@ builder.src(['src/main/js', 'src/main/less', 'node_modules/@jenkins-cd/design-la
// generateNoImportsBundle makes it easier to test with zombie.
//
builder.bundle('src/main/js/blueocean.js')
.withExternalModuleMapping('jquery-detached', 'jquery-detached:jquery2')
.inDir('target/classes/io/jenkins/blueocean')
.less('src/main/less/blueocean.less')
.generateNoImportsBundle();

View File

@ -10,13 +10,13 @@
"lint": "gulp lint --silent",
"lint:fix": "gulp lint --fixLint --silent",
"test": "gulp test --silent",
"retest": "gulp retest",
"test:watch": "gulp test:watch",
"bundle": "gulp bundle",
"rebundle": "gulp rebundle"
"bundle:watch": "gulp bundle:watch"
},
"devDependencies": {
"@jenkins-cd/js-builder": "0.0.17",
"@jenkins-cd/js-test": "^1.x",
"@jenkins-cd/js-builder": "0.0.23",
"@jenkins-cd/js-test": "1.1.1",
"babel-eslint": "^6.0.0",
"babel-preset-es2015": "^6.5.0",
"babel-preset-react": "^6.5.0",
@ -26,11 +26,10 @@
"zombie": "^4.2.1"
},
"dependencies": {
"@jenkins-cd/design-language": "^0.x",
"@jenkins-cd/js-extensions": "^0.x",
"@jenkins-cd/js-modules": "^0.x",
"@jenkins-cd/design-language": "0.0.7",
"@jenkins-cd/js-extensions": "0.0.10",
"@jenkins-cd/js-modules": "0.0.2",
"history": "^2.0.1",
"jquery-detached": "^2.1.4-v4",
"react": "^0.14.7",
"react-dom": "^0.14.7",
"react-router": "^2.0.1",

View File

@ -1,5 +1,38 @@
import AboutNavLink from './about/AboutNavLink.jsx';
const requestDone = 4; // Because Zombie is garbage
// Basically copied from AjaxHoc
function getURL(url, onLoad) {
const xmlhttp = new XMLHttpRequest();
if (!url) {
onLoad(null);
return;
}
xmlhttp.onreadystatechange = () => {
if (xmlhttp.readyState === requestDone) {
if (xmlhttp.status === 200) {
let data = null;
try {
data = JSON.parse(xmlhttp.responseText);
} catch (e) {
// eslint-disable-next-line
console.log('Loading', url,
'Expecting JSON, instead got', xmlhttp.responseText);
}
onLoad(data);
} else {
// eslint-disable-next-line
console.log('Loading', url, 'expected 200, got', xmlhttp.status, xmlhttp.responseText);
}
}
};
xmlhttp.open('GET', url, true);
xmlhttp.send();
}
exports.initialize = function (oncomplete) {
//
// Application initialization.
@ -28,9 +61,9 @@ exports.initialize = function (oncomplete) {
// Get the extension list metadata from Jenkins.
// Might want to do some flux fancy-pants stuff for this.
const $ = require('jquery-detached').getJQuery();
const appRoot = $("head").data("appurl");
$.getJSON(`${appRoot}/javaScriptExtensionInfo`, (data) => {
const appRoot = document.getElementsByTagName("head")[0].getAttribute("data-appurl");
const extensionsURL = `${appRoot}/javaScriptExtensionInfo`;
getURL(extensionsURL, data => {
extensions.store.setExtensionPointMetadata(data);
oncomplete();
});

View File

@ -26,6 +26,7 @@ const config = {
},
less: {
sources: "less/theme.less",
watch: "less/**/*.less", // Watch includes as well as main
dest: "dist/assets/css"
},
copy: {
@ -61,6 +62,13 @@ const config = {
clean: ["dist", "licenses"]
};
// Watch
gulp.task("watch", ["default"], () => {
gulp.watch(config.react.sources, ["compile-react"]);
gulp.watch(config.less.watch, ["less"]);
});
// Default to clean and build
gulp.task("default", () =>

View File

@ -554,18 +554,43 @@ code.inline {
color: @text-color;
display: inline-block;
padding: 0.6rem 1rem 0;
height: 30px;
border-bottom: solid 3px rgba(0, 0, 0, 0);
height: 33px;
font-family: @font-family-nav;
}
.page-tabs a.selected {
border-bottom: solid 3px @brand-primary;
}
.page-tabs a.selected:hover,
.page-tabs a.selected:active {
text-decoration: none;
// For transforming underline
position: relative;
overflow: hidden;
transform: translateZ(0);
margin-bottom: -3px;
}
.page-tabs a:before {
// For transforming underline
content: "";
position: absolute;
z-index: -1;
left: 0;
right: 0;
bottom: 0;
background: @brand-primary;
height: 3px;
transition-timing-function: ease-out;
transition-duration: 0.2s;
transform: translateY(3px);
transition-property: transform;
}
.page-tabs a.selected:before,
.page-tabs:hover a.selected:hover:before,
.page-tabs a:hover:before {
// For transforming underline
transform: translateY(0);
}
.page-tabs:hover a.selected:before {
// For transforming underline
transform: translateY(3px);
}
main article {
@ -639,3 +664,44 @@ footer {
right: 20px;
top: 5px
}
.spin {
-webkit-animation-name: spin;
-webkit-animation-duration: 4000ms;
-webkit-animation-iteration-count: infinite;
-webkit-animation-timing-function: linear;
-moz-animation-name: spin;
-moz-animation-duration: 4000ms;
-moz-animation-iteration-count: infinite;
-moz-animation-timing-function: linear;
-ms-animation-name: spin;
-ms-animation-duration: 4000ms;
-ms-animation-iteration-count: infinite;
-ms-animation-timing-function: linear;
animation-name: spin;
animation-duration: 4000ms;
animation-iteration-count: infinite;
animation-timing-function: linear;
}
@-ms-keyframes spin {
from { -ms-transform: rotate(0deg); }
to { -ms-transform: rotate(360deg); }
}
@-moz-keyframes spin {
from { -moz-transform: rotate(0deg); }
to { -moz-transform: rotate(360deg); }
}
@-webkit-keyframes spin {
from { -webkit-transform: rotate(0deg); }
to { -webkit-transform: rotate(360deg); }
}
@keyframes spin {
from { transform:rotate(0deg); }
to { transform:rotate(360deg); }
}
circle.success {
fill: @brand-success;
}
circle.failure {
fill: @brand-danger;
}

View File

@ -1,7 +1,7 @@
{
"name": "@jenkins-cd/design-language",
"jdlName": "jenkins-design-language",
"version": "0.0.5",
"version": "0.0.7",
"description": "Styles, assets, and React classes for Jenkins Design Language",
"main": "dist/js/components/index.js",
"scripts": {
@ -34,6 +34,7 @@
"normalize.css": "^3.0.3",
"octicons": "^3.5.0",
"react": "^0.14.7",
"react-router": "^2.0.1",
"run-sequence": "^1.1.5"
},
"files": [

View File

@ -1,6 +1,16 @@
export {ModalView, ModalBody, ModalStyles, ModalHeader} from './modal/modalview';
export {
ModalView,
ModalBody,
ModalStyles,
ModalHeader
} from './modal/modalview';
export {WeatherIcon} from './weather-icon';
export {Page} from './page';
export {GlobalHeader, GlobalNav} from './global-header';
export {PageHeader, Title, PageTabs} from './page-header';
export {PageHeader, Title, PageTabs, TabLink} from './page-header';
export {Table} from './table';
export {
StatusIndicator,
SvgSpinner,
SvgStatus,
} from './status/StatusIndicator';

View File

@ -1,4 +1,5 @@
import React, {Component} from 'react';
import {Link} from 'react-router';
export class PageHeader extends Component {
render() {
@ -20,6 +21,33 @@ export class Title extends Component {
export class PageTabs extends Component {
render() {
return <nav className="page-tabs">{this.props.children}</nav>;
const base = this.props.base;
return (
<nav className="page-tabs">
{React.Children.map(this.props.children, child => React.cloneElement(child, {base}))}
</nav>
);
}
}
PageTabs.propTypes = {
base: React.PropTypes.string
};
export class TabLink extends Component {
render() {
const base = this.props.base || "";
const routeUrl = base + this.props.to;
const linkClassName = this.context.router.isActive(routeUrl) ? "selected" : undefined;
return <Link to={routeUrl} className={linkClassName}>{this.props.children}</Link>;
}
}
TabLink.propTypes = {
base: React.PropTypes.string,
to: React.PropTypes.string.isRequired
};
TabLink.contextTypes = {
router: React.PropTypes.object
};

View File

@ -0,0 +1,46 @@
import React, { Component, PropTypes } from 'react';
import SvgSpinner from './SvgSpinner';
import SvgStatus from './SvgStatus';
const { number, string } = PropTypes;
class StatusIndicator extends Component {
render() {
const {
result,
percentage,
width = '32px',
height = '32px',
} = this.props;
// early out
if (!result && !result.toLowerCase) {
return null;
}
const resultClean = result.toLowerCase();
const props = {
percentage,
height,
width,
result: resultClean,
title: resultClean,
};
return (resultClean === 'running' || resultClean === 'queued' ? <SvgSpinner
{...props}
/> : <SvgStatus
{...props}
/>);
}
}
StatusIndicator.propTypes = {
result: string.isRequired,
percentage: number,
width: string,
height: string,
};
export {
StatusIndicator,
SvgSpinner,
SvgStatus,
};

View File

@ -0,0 +1,95 @@
import React, { Component, PropTypes } from 'react';
const { string, object, number } = PropTypes;
function polarToCartesian(centerX, centerY, radius, angleInDegrees) {
const angleInRadians = (angleInDegrees - 90) * Math.PI / 180.0;
return {
x: centerX + (radius * Math.cos(angleInRadians)),
y: centerY + (radius * Math.sin(angleInRadians)),
};
}
function describeArc(x, y, radius, startAngle, endAngle) {
const start = polarToCartesian(x, y, radius, endAngle);
const end = polarToCartesian(x, y, radius, startAngle);
const arcSweep = endAngle - startAngle <= 180 ? '0' : '1';
const d = [
'M', start.x, start.y,
'A', radius, radius, 0, arcSweep, 0, end.x, end.y,
].join(' ');
return d;
}
export default class SvgSpinner extends Component {
render() {
const {
result = 'failure',
percentage = 12.5,
title = 'No title',
width = '32px',
height = '32px',
colors = {
backgrounds: {
box: 'none',
outer: 'none',
},
strokes: {
outer: '#a9c6e6',
path: '#4a90e2',
},
},
} = this.props;
const rotate = percentage / 100 * 360;
const d = describeArc(50, 50, 40, 0, rotate);
return (<svg xmlns="http://www.w3.org/2000/svg"
className={result === 'queued' ? 'spin' : ''}
width={width}
height={height}
viewBox="0 0 100 100"
preserveAspectRatio="xMidYMid"
>
<title>{title}</title>
<rect
x="0"
y="0"
width="100"
height="100"
fill={colors.backgrounds.box}
className="bk"
/>
<circle
cx="50"
cy="50"
r="40"
stroke={rotate === 360 ? colors.strokes.path : colors.strokes.outer}
fill={colors.backgrounds.outer}
strokeWidth="10"
strokeLinecap="round"
/>
<path
className={result}
fill="none"
stroke={colors.strokes.path}
strokeWidth="10"
d={d}
/>
</svg>);
}
}
SvgSpinner.propTypes = {
title: string,
width: string,
result: string,
height: string,
percentage: number,
colors: object,
};

View File

@ -0,0 +1,71 @@
import React, { Component, PropTypes } from 'react';
const { string, object } = PropTypes;
const results = {
success: {
fill: '#8cc04f',
stroke: '#7cb445',
},
failure: {
fill: '#d54c53',
stroke: '#cf3a41',
}
};
export default class SvgStatus extends Component {
render() {
const {
result = 'failure',
title = 'No title',
width = '32px',
height = '32px',
colors = {
backgrounds: {
box: 'none',
inner: results[result.toLowerCase()] ? results[result.toLowerCase()].fill : 'none',
},
strokes: {
inner: results[result.toLowerCase()] ? results[result.toLowerCase()].stroke : 'none',
},
},
} = this.props;
return (<svg xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
viewBox="0 0 100 100"
preserveAspectRatio="xMidYMid"
>
<title>{title}</title>
<rect
x="0"
y="0"
width="100"
height="100"
fill={colors.backgrounds.box}
className="bk"
/>
<circle
className={result}
cx="50"
cy="50"
r="40"
stroke={colors.strokes.inner}
fill={colors.backgrounds.inner}
strokeWidth="2"
strokeLinecap="round"
/>
</svg>);
}
}
SvgStatus.propTypes = {
title: string,
result: string,
width: string,
height: string,
colors: object,
};

View File

@ -1,4 +1,5 @@
//
// See https://github.com/jenkinsci/js-builder
//
require('@jenkins-cd/js-builder');
var builder = require('@jenkins-cd/js-builder');
builder.lint('none');

View File

@ -1,12 +1,12 @@
{
"name": "@jenkins-cd/js-extensions",
"version": "0.0.8",
"version": "0.0.10",
"description": "Jenkins Extension Store",
"main": "index.js",
"files": ["index.js","dist","README.md"],
"scripts": {
"build": "jsx src/ExtensionPoint.jsx > dist/ExtensionPoint.js && cp src/store.js dist/",
"test": "echo \"Error: no test specified\" && exit 1",
"build": "rm -rf dist && mkdir dist && jsx src/ExtensionPoint.jsx > dist/ExtensionPoint.js && cp src/store.js dist/",
"test": "gulp test",
"prepublish": "npm run build"
},
"author": "Tom Fennelly <tom.fennelly@gmail.com> (https://github.com/tfennelly)",
@ -17,11 +17,11 @@
},
"devDependencies": {
"gulp": "^3.9.1",
"@jenkins-cd/js-builder": "^0.x",
"@jenkins-cd/js-test": "^1.x",
"@jenkins-cd/js-builder": "0.0.22",
"@jenkins-cd/js-test": "1.1.1",
"react-tools": "^0.13.3"
},
"dependencies": {
"@jenkins-cd/js-modules": "^0.x"
"@jenkins-cd/js-modules": "0.0.2"
}
}

View File

@ -1,56 +1,70 @@
var jsTest = require('@jenkins-cd/js-test');
describe("store.js", function () {
it("- test ", function (done) {
var javaScriptExtensionInfo = require('./javaScriptExtensionInfo-01.json');
var store = require('../store');
var jsModules = require('@jenkins-cd/js-modules');
var pluginsLoaded = {};
var loaded = 0;
jsTest.onPage(function() {
var javaScriptExtensionInfo = require('./javaScriptExtensionInfo-01.json');
var store = require('../src/store');
var jsModules = require('@jenkins-cd/js-modules');
var pluginsLoaded = {};
var loaded = 0;
// Mock the calls to addScript
jsModules.addScript = function(scriptUrl, options) {
expect(scriptUrl).toBe('io/jenkins/' + options.hpiPluginId + '/jenkins-js-extension.js');
// mimic registering of those extensions
for(var i1 = 0; i1 < javaScriptExtensionInfo.length; i1++) {
var pluginMetadata = javaScriptExtensionInfo[i1];
var extensions = pluginMetadata.extensions;
for(var i2 = 0; i2 < extensions.length; i2++) {
store.addExtension(extensions[i2].component, extensions[i2].extensionPoint);
// Mock the calls to import
var theRealImport = jsModules.import;
jsModules.import = function(bundleId) {
var internal = require('@jenkins-cd/js-modules/js/internal');
var bundleModuleSpec = internal.parseResourceQName(bundleId);
var pluginId = bundleModuleSpec.namespace;
// mimic registering of those extensions
for(var i1 = 0; i1 < javaScriptExtensionInfo.length; i1++) {
var pluginMetadata = javaScriptExtensionInfo[i1];
var extensions = pluginMetadata.extensions;
for(var i2 = 0; i2 < extensions.length; i2++) {
store.addExtension(extensions[i2].component, extensions[i2].extensionPoint);
}
}
if (pluginsLoaded[pluginId] === undefined) {
pluginsLoaded[pluginId] = true;
loaded++;
}
}
if (pluginsLoaded[options.hpiPluginId] === undefined) {
pluginsLoaded[options.hpiPluginId] = true;
loaded++;
}
options.success();
};
// Initialise the store with some extension point info. At runtime,
// this info will be loaded from <jenkins>/blue/javaScriptExtensionInfo
store.setExtensionPointMetadata(javaScriptExtensionInfo);
// fake the export of the bundle
setTimeout(function() {
jsModules.export(pluginId, 'jenkins-js-extension', {});
}, 100);
return theRealImport.call(theRealImport, bundleId);
};
// Call load for ExtensionPoint impls 'ep-1'. This should mimic
// the store checking all plugins and loading the bundles for any
// plugins that define an impl of 'ep-1' (if not already loaded).
store.loadExtensions('ep-1', function() {
if (loaded === 2) {
expect(pluginsLoaded['plugin-1']).toBeDefined();
expect(pluginsLoaded['plugin-2']).toBeDefined();
// Initialise the store with some extension point info. At runtime,
// this info will be loaded from <jenkins>/blue/javaScriptExtensionInfo
store.setExtensionPointMetadata(javaScriptExtensionInfo);
// if we call load again, nothing should happen as
// all plugin bundles have been loaded i.e. loaded
// should still be 2 (i.e. unchanged).
store.loadExtensions('ep-1', function() {
expect(loaded, 2);
// Call load for ExtensionPoint impls 'ep-1'. This should mimic
// the store checking all plugins and loading the bundles for any
// plugins that define an impl of 'ep-1' (if not already loaded).
store.loadExtensions('ep-1', function() {
if (loaded === 2) {
expect(pluginsLoaded['plugin-1']).toBeDefined();
expect(pluginsLoaded['plugin-2']).toBeDefined();
// Calling it yet again for different extension point, but
// where the bundles for that extension point have already.
store.loadExtensions('ep-2', function() {
// if we call load again, nothing should happen as
// all plugin bundles have been loaded i.e. loaded
// should still be 2 (i.e. unchanged).
store.loadExtensions('ep-1', function() {
expect(loaded, 2);
done();
// Calling it yet again for different extension point, but
// where the bundles for that extension point have already.
store.loadExtensions('ep-2', function() {
expect(loaded, 2);
done();
});
});
});
}
}
});
});
});
});

View File

@ -101,17 +101,20 @@ var ExtensionPoint = React.createClass({
* would otherwise not be notified when this is being unmounted.
*/
_unmountAllExtensions: function() {
var children = ReactDOM.findDOMNode(this).children;
for (var i = 0; i < children.length; i++) {
var child = children[i];
try {
if (child) {
ReactDOM.unmountComponentAtNode(child);
var thisNode = ReactDOM.findDOMNode(this);
var children = thisNode ? thisNode.children : null;
if (children && children.length) {
for (var i = 0; i < children.length; i++) {
var child = children[i];
try {
if (child) {
ReactDOM.unmountComponentAtNode(child);
}
}
catch (err) {
// Log and continue, don't want to stop unmounting children
console.log("Error unmounting component", child, err);
}
}
catch (err) {
// Log and continue, don't want to stop unmounting children
console.log("Error unmounting component", child, err);
}
}
}

View File

@ -64,7 +64,6 @@ function loadBundles(extensionPointId, onBundlesLoaded) {
var loadCountMonitor = new LoadCountMonitor();
function loadPluginBundle(pluginMetadata) {
var scriptUrl = 'io/jenkins/' + pluginMetadata.hpiPluginId + '/jenkins-js-extension.js';
loadCountMonitor.inc();
// The plugin bundle for this plugin may already be in the process of loading (async extension
@ -75,17 +74,14 @@ function loadBundles(extensionPointId, onBundlesLoaded) {
if (!pluginMetadata.loadCountMonitors) {
pluginMetadata.loadCountMonitors = [];
pluginMetadata.loadCountMonitors.push(loadCountMonitor);
jsModules.addScript(scriptUrl, {
scriptSrcBase: '@adjunct',
hpiPluginId: pluginMetadata.hpiPluginId, // Used for testing
success: function () {
jsModules.import(pluginMetadata.hpiPluginId + ':jenkins-js-extension')
.onFulfilled(function() {
pluginMetadata.bundleLoaded = true;
for (var i = 0; i < pluginMetadata.loadCountMonitors.length; i++) {
pluginMetadata.loadCountMonitors[i].dec();
}
delete pluginMetadata.loadCountMonitors;
}
});
});
} else {
pluginMetadata.loadCountMonitors.push(loadCountMonitor);
}