[FIX JENKINS-35990] refactor js extensions (#291)
* Redux skeleton code for testResults * Render the test suites on the test tab * JENKINS-35990 - Refactoring JS extensions API * Refactor ExtensionStore and related to ES6 classes, add tests and docs * Bump js-extensions versions
This commit is contained in:
parent
8a8ea8e2aa
commit
0e57d411ab
|
@ -36,7 +36,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@jenkins-cd/design-language": "0.0.58",
|
||||
"@jenkins-cd/js-extensions": "0.0.15",
|
||||
"@jenkins-cd/js-extensions": "0.0.16",
|
||||
"@jenkins-cd/js-modules": "0.0.5",
|
||||
"@jenkins-cd/sse-gateway": "0.0.5",
|
||||
"immutable": "3.8.1",
|
||||
|
|
|
@ -4,7 +4,7 @@ import PipelineRowItem from './PipelineRowItem';
|
|||
import { PipelineRecord } from './records';
|
||||
|
||||
import { Page, PageHeader, Table, Title } from '@jenkins-cd/design-language';
|
||||
import { ExtensionPoint } from '@jenkins-cd/js-extensions';
|
||||
import Extensions from '@jenkins-cd/js-extensions';
|
||||
|
||||
const { array } = PropTypes;
|
||||
|
||||
|
@ -56,7 +56,7 @@ export default class Pipelines extends Component {
|
|||
</PageHeader>
|
||||
<main>
|
||||
<article>
|
||||
<ExtensionPoint name="jenkins.pipeline.list.top" />
|
||||
<Extensions.Renderer extensionPoint="jenkins.pipeline.list.top" />
|
||||
<Table
|
||||
className="pipelines-table fixed"
|
||||
headers={headers}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import { ExtensionPoint } from '@jenkins-cd/js-extensions';
|
||||
import Extensions from '@jenkins-cd/js-extensions';
|
||||
import LogConsole from './LogConsole';
|
||||
|
||||
import Steps from './Steps';
|
||||
|
@ -115,10 +115,10 @@ export class RunDetailsPipeline extends Component {
|
|||
}
|
||||
return (
|
||||
<div>
|
||||
{ nodes && nodes[nodeKey] && <ExtensionPoint
|
||||
{ nodes && nodes[nodeKey] && <Extensions.Renderer
|
||||
extensionPoint="jenkins.pipeline.run.result"
|
||||
router={router}
|
||||
location={location}
|
||||
name="jenkins.pipeline.run.result"
|
||||
nodes={nodes[nodeKey].model}
|
||||
pipelineName={name}
|
||||
branchName={isMultiBranch ? branch : undefined}
|
||||
|
|
|
@ -1,12 +1,41 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import { EmptyStateView } from '@jenkins-cd/design-language';
|
||||
import { actions as selectorActions, testResults as testResultsSelector,
|
||||
connect, createSelector } from '../redux';
|
||||
import Extensions from '@jenkins-cd/js-extensions';
|
||||
|
||||
const EmptyState = () => (
|
||||
<EmptyStateView tightSpacing>
|
||||
<p>
|
||||
There are no tests run for this build.
|
||||
</p>
|
||||
</EmptyStateView>
|
||||
);
|
||||
|
||||
const { object } = PropTypes;
|
||||
|
||||
/**
|
||||
* Displays a list of tests from the supplied build run property.
|
||||
*/
|
||||
export default class RunDetailsTests extends Component {
|
||||
export class RunDetailsTests extends Component {
|
||||
componentWillMount() {
|
||||
if (this.context.config) {
|
||||
this.props.fetchTestResults(
|
||||
this.context.config,
|
||||
{
|
||||
isMultiBranch: this.props.isMultiBranch,
|
||||
organization: this.props.params.organization,
|
||||
pipeline: this.props.params.pipeline,
|
||||
branch: this.props.params.branch,
|
||||
runId: this.props.params.runId,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.resetTestDetails();
|
||||
}
|
||||
|
||||
renderEmptyState() {
|
||||
return (
|
||||
<EmptyStateView tightSpacing>
|
||||
|
@ -16,17 +45,48 @@ export default class RunDetailsTests extends Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { result } = this.props;
|
||||
|
||||
if (!result) {
|
||||
const { testResults } = this.props;
|
||||
|
||||
if (!testResults) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: impl logic to display table of data
|
||||
return this.renderEmptyState();
|
||||
|
||||
if (!testResults.suites) {
|
||||
return <EmptyState />;
|
||||
}
|
||||
|
||||
const percentComplete = testResults.passCount /
|
||||
(testResults.passCount + testResults.failCount);
|
||||
|
||||
return (<div className="test-results-container">
|
||||
<div className="test=result-summary" style={{ display: 'none' }}>
|
||||
<div className={`test-result-bar ${percentComplete}%`}></div>
|
||||
<div className="test-result-passed">Passed {testResults.passCount}</div>
|
||||
<div className="test-result-failed">Failed {testResults.failCount}</div>
|
||||
<div className="test-result-skipped">Skipped {testResults.skipCount}</div>
|
||||
<div className="test-result-duration">Duration {testResults.duration}</div>
|
||||
</div>
|
||||
|
||||
<Extensions.Renderer extensionPoint="jenkins.test.result" dataType={testResults} testResults={testResults} />
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
|
||||
RunDetailsTests.propTypes = {
|
||||
result: object,
|
||||
params: PropTypes.object,
|
||||
isMultiBranch: PropTypes.bool,
|
||||
result: PropTypes.object,
|
||||
testResults: PropTypes.object,
|
||||
resetTestDetails: PropTypes.func,
|
||||
fetchTestResults: PropTypes.func,
|
||||
fetchTypeInfo: PropTypes.func,
|
||||
};
|
||||
|
||||
RunDetailsTests.contextTypes = {
|
||||
config: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
const selectors = createSelector([testResultsSelector],
|
||||
(testResults) => ({ testResults }));
|
||||
|
||||
export default connect(selectors, selectorActions)(RunDetailsTests);
|
||||
|
|
|
@ -89,4 +89,5 @@ export const State = Record({
|
|||
branches: null,
|
||||
steps: null,
|
||||
currentBranches: null,
|
||||
testResults: null,
|
||||
});
|
||||
|
|
|
@ -0,0 +1,126 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import { ResultItem, StatusIndicator } from '@jenkins-cd/design-language';
|
||||
import moment from 'moment';
|
||||
|
||||
/* eslint-disable max-len */
|
||||
|
||||
const TestCaseResultRow = (props) => {
|
||||
const t = props.testCase;
|
||||
const duration = moment.duration(Number(t.duration), 'milliseconds').humanize();
|
||||
const expandable = t.errorStackTrace;
|
||||
|
||||
let testDetails = !expandable ? null : (<div className="test-details">
|
||||
<div className="test-detail-text" style={{ display: 'none' }}>
|
||||
{duration}
|
||||
</div>
|
||||
<div className="test-console">
|
||||
<h4>Error</h4>
|
||||
<div className="error-message">
|
||||
{t.errorDetails}
|
||||
</div>
|
||||
<h4>Output</h4>
|
||||
<div className="stack-trace">
|
||||
{t.errorStackTrace}
|
||||
</div>
|
||||
</div>
|
||||
</div>);
|
||||
|
||||
let statusIndicator = null;
|
||||
switch (t.status) {
|
||||
case 'FAILED':
|
||||
statusIndicator = StatusIndicator.validResultValues.failure;
|
||||
break;
|
||||
case 'SKIPPED':
|
||||
statusIndicator = StatusIndicator.validResultValues.unstable;
|
||||
break;
|
||||
case 'PASSED':
|
||||
statusIndicator = StatusIndicator.validResultValues.success;
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
return (<ResultItem
|
||||
result={statusIndicator}
|
||||
expanded={false}
|
||||
label={`${t.name} - ${t.className}`}
|
||||
onExpand={null}
|
||||
extraInfo={duration}
|
||||
>
|
||||
{ testDetails }
|
||||
</ResultItem>);
|
||||
};
|
||||
|
||||
TestCaseResultRow.propTypes = {
|
||||
testCase: PropTypes.object,
|
||||
};
|
||||
|
||||
export default class TestResult extends Component {
|
||||
|
||||
render() {
|
||||
const testResults = this.props.testResults;
|
||||
const suites = this.props.testResults.suites;
|
||||
const tests = [].concat.apply([], suites.map(t => t.cases));
|
||||
|
||||
// possible statuses: PASSED, FAILED, SKIPPED
|
||||
const failures = tests.filter(t => t.status === 'FAILED');
|
||||
const skipped = tests.filter(t => t.status === 'SKIPPED');
|
||||
const newFailures = failures.filter(t => t.age === 1);
|
||||
const existingFailures = failures.filter(t => t.age > 1);
|
||||
|
||||
let passBlock = null;
|
||||
let newFailureBlock = null;
|
||||
let existingFailureBlock = null;
|
||||
let skippedBlock = null;
|
||||
|
||||
if (testResults.failCount === 0) {
|
||||
passBlock = [
|
||||
<h4>Passing - {testResults.passCount}</h4>,
|
||||
suites.map((t, i) => <TestCaseResultRow key={i} testCase={{
|
||||
className: `${t.cases.filter(c => c.status === 'PASSED').length} Passing`, // this shows second
|
||||
name: t.name,
|
||||
duration: t.duration,
|
||||
status: 'PASSED',
|
||||
}} />),
|
||||
];
|
||||
}
|
||||
|
||||
if (newFailures.length > 0 || existingFailures.length > 0) {
|
||||
if (newFailures.length === 0) {
|
||||
newFailureBlock = [
|
||||
<h4>New failing - {newFailures.length}</h4>,
|
||||
<div className="">No new failures</div>,
|
||||
];
|
||||
} else {
|
||||
newFailureBlock = [
|
||||
<h4>New failing - {newFailures.length}</h4>,
|
||||
newFailures.map((t, i) => <TestCaseResultRow key={i} testCase={t} />),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (existingFailures.length > 0) {
|
||||
existingFailureBlock = [
|
||||
<h4>Existing failures - {existingFailures.length}</h4>,
|
||||
existingFailures.map((t, i) => <TestCaseResultRow key={i} testCase={t} />),
|
||||
];
|
||||
}
|
||||
|
||||
if (skipped.length > 0) {
|
||||
skippedBlock = [
|
||||
<h4>Skipped - {skipped.length}</h4>,
|
||||
skipped.map((t, i) => <TestCaseResultRow key={i} testCase={t} />),
|
||||
];
|
||||
}
|
||||
|
||||
return (<div>
|
||||
{newFailureBlock}
|
||||
{existingFailureBlock}
|
||||
{skippedBlock}
|
||||
{passBlock}
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
|
||||
TestResult.propTypes = {
|
||||
testResults: PropTypes.object,
|
||||
};
|
|
@ -10,3 +10,6 @@ extensions:
|
|||
extensionPoint: jenkins.main.stores
|
||||
- component: components/PipelineRunGraph
|
||||
extensionPoint: jenkins.pipeline.run.result
|
||||
- component: components/testing/TestResults
|
||||
extensionPoint: jenkins.test.result
|
||||
type: hudson.tasks.test.TestResult
|
||||
|
|
|
@ -5,6 +5,8 @@ import { State } from '../components/records';
|
|||
|
||||
import { getNodesInformation } from '../util/logDisplayHelper';
|
||||
|
||||
import { buildUrl } from '../util/UrlUtils';
|
||||
|
||||
// helper functions
|
||||
|
||||
// helper to clean the path
|
||||
|
@ -78,6 +80,7 @@ export const ACTION_TYPES = keymirror({
|
|||
SET_BRANCHES_DATA: null,
|
||||
SET_CURRENT_BRANCHES_DATA: null,
|
||||
CLEAR_CURRENT_BRANCHES_DATA: null,
|
||||
SET_TEST_RESULTS: null,
|
||||
UPDATE_BRANCH_DATA: null,
|
||||
SET_STEPS: null,
|
||||
SET_NODE: null,
|
||||
|
@ -141,6 +144,9 @@ export const actionHandlers = {
|
|||
branches[id] = payload;
|
||||
return state.set('branches', branches);
|
||||
},
|
||||
[ACTION_TYPES.SET_TEST_RESULTS](state, { payload }): State {
|
||||
return state.set('testResults', payload === undefined ? {} : payload);
|
||||
},
|
||||
[ACTION_TYPES.SET_STEPS](state, { payload }): State {
|
||||
const steps = { ...state.steps } || {};
|
||||
steps[payload.nodesBaseUrl] = payload;
|
||||
|
@ -563,7 +569,7 @@ export const actions = {
|
|||
const url = `${config.getAppURLBase()}/rest/organizations/${branch.organization}` +
|
||||
`/pipelines/${event.blueocean_job_name}/branches/${branch.name}`;
|
||||
|
||||
const processBranchData = function (branchData) {
|
||||
const processBranchData = function processBranchData(branchData) {
|
||||
const { latestRun } = branchData;
|
||||
|
||||
// same issue as in 'updateRunData'; see comment above
|
||||
|
@ -582,7 +588,7 @@ export const actions = {
|
|||
};
|
||||
|
||||
exports.fetchJson(url, processBranchData, (error) => {
|
||||
console.log(error);
|
||||
console.log(error); // eslint-disable-line no-console
|
||||
});
|
||||
}
|
||||
};
|
||||
|
@ -683,6 +689,11 @@ export const actions = {
|
|||
payload: { type: 'ERROR', message: `${error.stack}` },
|
||||
type: ACTION_TYPES.UPDATE_MESSAGES,
|
||||
});
|
||||
// call again with no payload so actions handle missing data
|
||||
dispatch({
|
||||
...optional,
|
||||
type: actionType,
|
||||
});
|
||||
});
|
||||
},
|
||||
/*
|
||||
|
@ -733,7 +744,7 @@ export const actions = {
|
|||
|
||||
return getNodeAndSteps(information);
|
||||
},
|
||||
(error) => console.error('error', error)
|
||||
(error) => console.error('error', error) // eslint-disable-line no-console
|
||||
);
|
||||
}
|
||||
return getNodeAndSteps(data[nodesBaseUrl]);
|
||||
|
@ -772,16 +783,16 @@ export const actions = {
|
|||
const stepBaseUrl = calculateStepsBaseUrl(config);
|
||||
if (!data || !data[stepBaseUrl] || !data[stepBaseUrl]) {
|
||||
return exports.fetchJson(
|
||||
stepBaseUrl,
|
||||
(json) => {
|
||||
const information = getNodesInformation(json);
|
||||
information.nodesBaseUrl = stepBaseUrl;
|
||||
return dispatch({
|
||||
type: ACTION_TYPES.SET_STEPS,
|
||||
payload: information,
|
||||
});
|
||||
},
|
||||
(error) => console.error('error', error)
|
||||
stepBaseUrl,
|
||||
(json) => {
|
||||
const information = getNodesInformation(json);
|
||||
information.nodesBaseUrl = stepBaseUrl;
|
||||
return dispatch({
|
||||
type: ACTION_TYPES.SET_STEPS,
|
||||
payload: information,
|
||||
});
|
||||
},
|
||||
(error) => console.error('error', error) // eslint-disable-line no-console
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
@ -824,4 +835,31 @@ export const actions = {
|
|||
return null;
|
||||
};
|
||||
},
|
||||
|
||||
fetchTestResults(config, runDetails) {
|
||||
return (dispatch) => {
|
||||
const baseUrl = `${config.getAppURLBase()}/rest/organizations/`;
|
||||
let url;
|
||||
if (runDetails.isMultiBranch) {
|
||||
// eslint-disable-next-line max-len
|
||||
url = `${baseUrl}${buildUrl(runDetails.organization, 'pipelines', runDetails.pipeline, 'branches', runDetails.branch, 'runs', runDetails.runId)}/testReport/result`;
|
||||
} else {
|
||||
// eslint-disable-next-line max-len
|
||||
url = `${baseUrl}${buildUrl(runDetails.organization, 'pipelines', runDetails.branch, 'runs', runDetails.runId)}/testReport/result`;
|
||||
}
|
||||
|
||||
return dispatch(actions.generateData(
|
||||
url,
|
||||
ACTION_TYPES.SET_TEST_RESULTS
|
||||
));
|
||||
};
|
||||
},
|
||||
|
||||
resetTestDetails() {
|
||||
return (dispatch) =>
|
||||
dispatch({
|
||||
type: ACTION_TYPES.SET_TEST_RESULTS,
|
||||
payload: null,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
@ -14,6 +14,7 @@ export {
|
|||
branches,
|
||||
isMultiBranch,
|
||||
currentBranches,
|
||||
testResults,
|
||||
} from './reducer';
|
||||
export {
|
||||
ACTION_TYPES,
|
||||
|
|
|
@ -17,6 +17,7 @@ export const node = createSelector([adminStore], store => store.node);
|
|||
export const nodes = createSelector([adminStore], store => store.nodes);
|
||||
export const steps = createSelector([adminStore], store => store.steps);
|
||||
export const currentBranches = createSelector([adminStore], store => store.currentBranches);
|
||||
export const testResults = createSelector([adminStore], store => store.testResults);
|
||||
export const isMultiBranch = createSelector(
|
||||
[pipeline], (pipe) => {
|
||||
if (pipe && pipe.organization) {
|
||||
|
|
|
@ -24,3 +24,18 @@ export const buildRunDetailsUrl = (organization, pipeline, branch, runId, tabNam
|
|||
`${encodeURIComponent(branch)}/${encodeURIComponent(runId)}`;
|
||||
return tabName ? `${baseUrl}/${tabName}` : baseUrl;
|
||||
};
|
||||
|
||||
/**
|
||||
* Constructs an escaped url based on the arguments, with forward slashes between them
|
||||
* e.g. buildURL('organizations', orgName, 'runs', runId) => organizations/my%20org/runs/34
|
||||
*/
|
||||
export const buildUrl = (...args) => {
|
||||
let url = '';
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (i > 0) {
|
||||
url += '/';
|
||||
}
|
||||
url += encodeURIComponent(args[i]);
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
@import "variables";
|
||||
|
||||
@import "core";
|
||||
@import "run-pipeline";
|
||||
@import "testing";
|
||||
@import "run-pipeline";
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
.test-results-container {
|
||||
h4 {
|
||||
margin: 1em 0 .4em 0;
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
.test-console {
|
||||
padding: 1em;
|
||||
}
|
||||
.stack-trace {
|
||||
white-space: pre;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,203 @@
|
|||
/*
|
||||
* The MIT License
|
||||
*
|
||||
* Copyright (c) 2016, CloudBees, Inc.
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
package io.jenkins.blueocean.jsextensions;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.StringWriter;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Enumeration;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.kohsuke.accmod.Restricted;
|
||||
import org.kohsuke.accmod.restrictions.NoExternalUse;
|
||||
import org.kohsuke.stapler.HttpResponse;
|
||||
import org.kohsuke.stapler.WebMethod;
|
||||
import org.kohsuke.stapler.verb.GET;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import hudson.Extension;
|
||||
import hudson.PluginWrapper;
|
||||
import hudson.util.HttpResponses;
|
||||
import io.jenkins.blueocean.RootRoutable;
|
||||
import io.jenkins.blueocean.rest.model.BlueExtensionClass;
|
||||
import io.jenkins.blueocean.rest.model.BlueExtensionClassContainer;
|
||||
import jenkins.model.Jenkins;
|
||||
import net.sf.json.JSONArray;
|
||||
|
||||
/**
|
||||
* Utility class for gathering {@code jenkins-js-extension} data.
|
||||
*
|
||||
* @author <a href="mailto:tom.fennelly@gmail.com">tom.fennelly@gmail.com</a>
|
||||
*/
|
||||
@Extension
|
||||
@Restricted(NoExternalUse.class)
|
||||
@SuppressWarnings({"rawtypes","unchecked"})
|
||||
public class JenkinsJSExtensions implements RootRoutable {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(JenkinsJSExtensions.class);
|
||||
private static final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* The list of active pluginCache "as we know it". Used to determine if pluginCache have
|
||||
* been installed, uninstalled, deactivated etc.
|
||||
* <p>
|
||||
* We only do this because there's no jenkins mechanism for "listening" to
|
||||
* changes in the active plugin list.
|
||||
*/
|
||||
private static final List<PluginWrapper> pluginCache = new CopyOnWriteArrayList<>();
|
||||
|
||||
private static final Map<String, Object> jsExtensionCache = new ConcurrentHashMap<>();
|
||||
|
||||
public JenkinsJSExtensions() {
|
||||
}
|
||||
|
||||
/**
|
||||
* For the location in the API: /blue/js-extensions
|
||||
*/
|
||||
@Override
|
||||
public String getUrlName() {
|
||||
return "js-extensions";
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the actual data, from /js-extensions
|
||||
*/
|
||||
@WebMethod(name="") @GET
|
||||
public HttpResponse doData() {
|
||||
Object jsExtensionData = getJenkinsJSExtensionData();
|
||||
JSONArray jsExtensionDataJson = JSONArray.fromObject(jsExtensionData);
|
||||
return HttpResponses.okJSON(jsExtensionDataJson);
|
||||
}
|
||||
|
||||
/*protected*/ static Collection<Object> getJenkinsJSExtensionData() {
|
||||
refreshCacheIfNeeded();
|
||||
return jsExtensionCache.values();
|
||||
}
|
||||
|
||||
private static String getGav(Map ext){
|
||||
return ext.get("hpiPluginId") != null ? (String)ext.get("hpiPluginId") : null;
|
||||
}
|
||||
|
||||
private static void refreshCacheIfNeeded(){
|
||||
List<PluginWrapper> latestPlugins = Jenkins.getInstance().getPluginManager().getPlugins();
|
||||
if(!latestPlugins.equals(pluginCache)){
|
||||
refreshCache(latestPlugins);
|
||||
}
|
||||
}
|
||||
private synchronized static void refreshCache(List<PluginWrapper> latestPlugins){
|
||||
if(!latestPlugins.equals(pluginCache)) {
|
||||
pluginCache.clear();
|
||||
pluginCache.addAll(latestPlugins);
|
||||
refreshCache(pluginCache);
|
||||
}
|
||||
for (PluginWrapper pluginWrapper : pluginCache) {
|
||||
//skip probing plugin if already read
|
||||
if (jsExtensionCache.get(pluginWrapper.getLongName()) != null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
Enumeration<URL> dataResources = pluginWrapper.classLoader.getResources("jenkins-js-extension.json");
|
||||
while (dataResources.hasMoreElements()) {
|
||||
URL dataRes = dataResources.nextElement();
|
||||
StringWriter fileContentBuffer = new StringWriter();
|
||||
|
||||
LOGGER.debug("Reading 'jenkins-js-extension.json' from '{}'.", dataRes);
|
||||
|
||||
try {
|
||||
IOUtils.copy(dataRes.openStream(), fileContentBuffer, Charset.forName("UTF-8"));
|
||||
Map<?,List<Map>> extensionData = mapper.readValue(dataRes.openStream(), Map.class);
|
||||
List<Map> extensions = (List<Map>)extensionData.get("extensions");
|
||||
for (Map extension : extensions) {
|
||||
try {
|
||||
String type = (String)extension.get("type");
|
||||
if (type != null) {
|
||||
BlueExtensionClassContainer extensionClassContainer
|
||||
= Jenkins.getInstance().getExtensionList(BlueExtensionClassContainer.class).get(0);
|
||||
Map classInfo = (Map)mergeObjects(extensionClassContainer.get(type));
|
||||
List classInfoClasses = (List)classInfo.get("_classes");
|
||||
classInfoClasses.add(0, type);
|
||||
extension.put("_class", type);
|
||||
extension.put("_classes", classInfoClasses);
|
||||
}
|
||||
} catch(Exception e) {
|
||||
LOGGER.error("An error occurred when attempting to read type information from jenkins-js-extension.json from: " + dataRes, e);
|
||||
}
|
||||
}
|
||||
String pluginId = getGav(extensionData);
|
||||
if (pluginId != null) {
|
||||
jsExtensionCache.put(pluginId, mergeObjects(extensionData));
|
||||
} else {
|
||||
LOGGER.error(String.format("Plugin %s JS extension has missing hpiPluginId", pluginWrapper.getLongName()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Error reading 'jenkins-js-extension.json' from '" + dataRes + "'. Extensions defined in the host plugin will not be active.", e);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOGGER.error(String.format("Error locating jenkins-js-extension.json for plugin %s", pluginWrapper.getLongName()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Object mergeObjects(Object incoming) {
|
||||
if (incoming instanceof Map) {
|
||||
Map m = new HashMap();
|
||||
Map in = (Map)incoming;
|
||||
for (Object key : in.keySet()) {
|
||||
Object value = mergeObjects(in.get(key));
|
||||
m.put(key, value);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
if (incoming instanceof Collection) {
|
||||
List l = new ArrayList();
|
||||
for (Object i : (Collection)incoming) {
|
||||
i = mergeObjects(i);
|
||||
l.add(i);
|
||||
}
|
||||
return l;
|
||||
}
|
||||
if (incoming instanceof Class) {
|
||||
return ((Class) incoming).getName();
|
||||
}
|
||||
if (incoming instanceof BlueExtensionClass) {
|
||||
BlueExtensionClass in = (BlueExtensionClass)incoming;
|
||||
Map m = new HashMap();
|
||||
Object value = mergeObjects(in.getClasses());
|
||||
m.put("_classes", value);
|
||||
return m;
|
||||
}
|
||||
return incoming;
|
||||
}
|
||||
}
|
|
@ -23,6 +23,7 @@
|
|||
*/
|
||||
package io.jenkins.blueocean.jsextensions;
|
||||
|
||||
import io.jenkins.blueocean.jsextensions.JenkinsJSExtensions;
|
||||
import io.jenkins.blueocean.service.embedded.BaseTest;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
@ -35,19 +36,20 @@ import java.util.Map;
|
|||
*/
|
||||
public class JenkinsJSExtensionsTest extends BaseTest{
|
||||
|
||||
@SuppressWarnings({ "unchecked", "rawtypes" })
|
||||
@Test
|
||||
public void test() {
|
||||
// FIXME: This test relies on configuration in a separate project
|
||||
// Simple test of the rest endpoint. It should find the "blueocean-dashboard"
|
||||
// plugin ExtensionPoint contributions.
|
||||
List<Map> extensions = get("/javaScriptExtensionInfo", List.class);
|
||||
Map response = get("/js-extensions", Map.class);
|
||||
List<Map> extensions = (List)response.get("data");
|
||||
|
||||
Assert.assertEquals(1, extensions.size());
|
||||
Assert.assertEquals("blueocean-dashboard", extensions.get(0).get("hpiPluginId"));
|
||||
|
||||
List<Map> ext = (List<Map>) extensions.get(0).get("extensions");
|
||||
|
||||
Assert.assertEquals(4, ext.size());
|
||||
Assert.assertEquals(5, ext.size());
|
||||
Assert.assertEquals("AdminNavLink", ext.get(0).get("component"));
|
||||
Assert.assertEquals("jenkins.logo.top", ext.get(0).get("extensionPoint"));
|
||||
|
||||
|
@ -55,9 +57,9 @@ public class JenkinsJSExtensionsTest extends BaseTest{
|
|||
// result in the same object instance being returned because the list of plugin
|
||||
// has not changed i.e. we have a simple optimization in there where we only scan
|
||||
// the classpath if the active plugin lust has changed.
|
||||
Assert.assertArrayEquals(
|
||||
JenkinsJSExtensions.INSTANCE.getJenkinsJSExtensionData(),
|
||||
JenkinsJSExtensions.INSTANCE.getJenkinsJSExtensionData()
|
||||
Assert.assertEquals(
|
||||
JenkinsJSExtensions.getJenkinsJSExtensionData(),
|
||||
JenkinsJSExtensions.getJenkinsJSExtensionData()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@jenkins-cd/design-language": "0.0.58",
|
||||
"@jenkins-cd/js-extensions": "0.0.15",
|
||||
"@jenkins-cd/js-extensions": "0.0.16",
|
||||
"@jenkins-cd/js-modules": "0.0.5",
|
||||
"history": "2.0.2",
|
||||
"immutable": "3.8.1",
|
||||
|
|
|
@ -2,17 +2,7 @@ package io.jenkins.blueocean;
|
|||
|
||||
import hudson.ExtensionList;
|
||||
import hudson.model.UsageStatistics;
|
||||
import io.jenkins.blueocean.jsextensions.JenkinsJSExtensions;
|
||||
import jenkins.model.Jenkins;
|
||||
import org.kohsuke.accmod.Restricted;
|
||||
import org.kohsuke.accmod.restrictions.DoNotUse;
|
||||
import org.kohsuke.stapler.HttpResponse;
|
||||
import org.kohsuke.stapler.StaplerRequest;
|
||||
import org.kohsuke.stapler.StaplerResponse;
|
||||
import org.kohsuke.stapler.verb.GET;
|
||||
|
||||
import javax.servlet.ServletException;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Root of Blue Ocean UI
|
||||
|
@ -47,13 +37,6 @@ public class BlueOceanUI {
|
|||
return urlBase;
|
||||
}
|
||||
|
||||
// TODO: Look into using new Stapler stuff for doing this.
|
||||
@Restricted(DoNotUse.class)
|
||||
@GET
|
||||
public HttpResponse doJavaScriptExtensionInfo() {
|
||||
return new JsonResponse(JenkinsJSExtensions.INSTANCE.getJenkinsJSExtensionData());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return True if jenkins reports usage statistics.
|
||||
*/
|
||||
|
@ -68,19 +51,4 @@ public class BlueOceanUI {
|
|||
public String getPluginVersion() {
|
||||
return Jenkins.getInstance().getPlugin("blueocean-web").getWrapper().getVersion();
|
||||
}
|
||||
|
||||
private class JsonResponse implements HttpResponse {
|
||||
|
||||
private final byte[] data;
|
||||
|
||||
public JsonResponse(byte[] data) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void generateResponse(StaplerRequest staplerRequest, StaplerResponse staplerResponse, Object o) throws IOException, ServletException {
|
||||
staplerResponse.setContentType("application/json; charset=UTF-8");
|
||||
staplerResponse.getOutputStream().write(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,131 +0,0 @@
|
|||
/*
|
||||
* The MIT License
|
||||
*
|
||||
* Copyright (c) 2016, CloudBees, Inc.
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
package io.jenkins.blueocean.jsextensions;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import hudson.PluginWrapper;
|
||||
import jenkins.model.Jenkins;
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.kohsuke.accmod.Restricted;
|
||||
import org.kohsuke.accmod.restrictions.NoExternalUse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.StringWriter;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.Enumeration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
/**
|
||||
* Utility class for gathering {@code jenkins-js-extension} data.
|
||||
*
|
||||
* @author <a href="mailto:tom.fennelly@gmail.com">tom.fennelly@gmail.com</a>
|
||||
*/
|
||||
@Restricted(NoExternalUse.class)
|
||||
public class JenkinsJSExtensions {
|
||||
public static final JenkinsJSExtensions INSTANCE = new JenkinsJSExtensions();
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(JenkinsJSExtensions.class);
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* The list of active pluginCache "as we know it". Used to determine if pluginCache have
|
||||
* been installed, uninstalled, deactivated etc.
|
||||
* <p>
|
||||
* We only do this because there's no jenkins mechanism for "listening" to
|
||||
* changes in the active plugin list.
|
||||
*/
|
||||
private final List<PluginWrapper> pluginCache = new CopyOnWriteArrayList<>();
|
||||
|
||||
private JenkinsJSExtensions() {
|
||||
}
|
||||
|
||||
|
||||
private final Map<String, Map> jsExtensionCache = new ConcurrentHashMap<>();
|
||||
|
||||
|
||||
public byte[] getJenkinsJSExtensionData() {
|
||||
try {
|
||||
refreshCacheIfNeeded();
|
||||
return mapper.writeValueAsBytes(jsExtensionCache.values());
|
||||
} catch (JsonProcessingException e) {
|
||||
LOGGER.error("Failed to serialize to JSON: "+e.getMessage(), e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private String getGav(Map ext){
|
||||
return ext.get("hpiPluginId") != null ? (String)ext.get("hpiPluginId") : null;
|
||||
}
|
||||
|
||||
private void refreshCacheIfNeeded(){
|
||||
List<PluginWrapper> latestPlugins = Jenkins.getActiveInstance().getPluginManager().getPlugins();
|
||||
if(!latestPlugins.equals(pluginCache)){
|
||||
refreshCache(latestPlugins);
|
||||
}
|
||||
}
|
||||
private synchronized void refreshCache(List<PluginWrapper> latestPlugins){
|
||||
if(!latestPlugins.equals(pluginCache)) {
|
||||
pluginCache.clear();
|
||||
pluginCache.addAll(latestPlugins);
|
||||
refreshCache(pluginCache);
|
||||
}
|
||||
for (PluginWrapper pluginWrapper : pluginCache) {
|
||||
//skip probing plugin if already read
|
||||
if (jsExtensionCache.get(pluginWrapper.getLongName()) != null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
Enumeration<URL> dataResources = pluginWrapper.classLoader.getResources("jenkins-js-extension.json");
|
||||
while (dataResources.hasMoreElements()) {
|
||||
URL dataRes = dataResources.nextElement();
|
||||
StringWriter fileContentBuffer = new StringWriter();
|
||||
|
||||
LOGGER.debug("Reading 'jenkins-js-extension.json' from '{}'.", dataRes);
|
||||
|
||||
try {
|
||||
IOUtils.copy(dataRes.openStream(), fileContentBuffer, Charset.forName("UTF-8"));
|
||||
Map ext = mapper.readValue(dataRes.openStream(), Map.class);
|
||||
String pluginId = getGav(ext);
|
||||
if (pluginId != null) {
|
||||
jsExtensionCache.put(pluginId, ext);
|
||||
} else {
|
||||
LOGGER.error(String.format("Plugin %s JS extension has missing hpiPluginId", pluginWrapper.getLongName()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Error reading 'jenkins-js-extension.json' from '" + dataRes + "'. Extensions defined in the host plugin will not be active.", e);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOGGER.error(String.format("Error locating jenkins-js-extension.json for plugin %s", pluginWrapper.getLongName()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,5 +1,3 @@
|
|||
import AboutNavLink from './about/AboutNavLink.jsx';
|
||||
|
||||
const requestDone = 4; // Because Zombie is garbage
|
||||
|
||||
// Basically copied from AjaxHoc
|
||||
|
@ -42,8 +40,8 @@ exports.initialize = function (oncomplete) {
|
|||
// Create and export the shared js-extensions instance. This
|
||||
// will be accessible to bundles from plugins etc at runtime, allowing them to register
|
||||
// extension point impls that can be rendered via the ExtensionPoint class.
|
||||
const extensions = require('@jenkins-cd/js-extensions');
|
||||
jenkinsMods.export('jenkins-cd', 'js-extensions', extensions);
|
||||
const Extensions = require('@jenkins-cd/js-extensions');
|
||||
jenkinsMods.export('jenkins-cd', 'js-extensions', Extensions);
|
||||
|
||||
// Create and export a shared instance of the design
|
||||
// language React classes.
|
||||
|
@ -56,15 +54,12 @@ exports.initialize = function (oncomplete) {
|
|||
jenkinsMods.export('react', 'react', react);
|
||||
jenkinsMods.export('react', 'react-dom', reactDOM);
|
||||
|
||||
// Manually register extention points. TODO: we will be auto-registering these.
|
||||
extensions.store.addExtension('jenkins.topNavigation.menu', AboutNavLink);
|
||||
|
||||
// Get the extension list metadata from Jenkins.
|
||||
// Might want to do some flux fancy-pants stuff for this.
|
||||
const appRoot = document.getElementsByTagName("head")[0].getAttribute("data-appurl");
|
||||
const extensionsURL = `${appRoot}/javaScriptExtensionInfo`;
|
||||
getURL(extensionsURL, data => {
|
||||
extensions.store.setExtensionPointMetadata(data);
|
||||
oncomplete();
|
||||
Extensions.store.init({
|
||||
extensionDataProvider: cb => getURL(`${appRoot}/js-extensions`, rsp => cb(rsp.data)),
|
||||
typeInfoProvider: (type, cb) => getURL(`${appRoot}/rest/classes/${type}`, cb)
|
||||
});
|
||||
oncomplete();
|
||||
};
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
# Extensions in this plugin
|
||||
extensions:
|
||||
- component: AboutNavLink
|
||||
extensionPoint: jenkins.topNavigation.menu
|
|
@ -5,7 +5,7 @@ import { createHistory } from 'history';
|
|||
import { Provider, configureStore, combineReducers} from './redux';
|
||||
import { DevelopmentFooter } from './DevelopmentFooter';
|
||||
|
||||
import { ExtensionPoint } from '@jenkins-cd/js-extensions';
|
||||
import Extensions from '@jenkins-cd/js-extensions';
|
||||
import rootReducer, { ACTION_TYPES } from './redux/router';
|
||||
|
||||
import Config from './config';
|
||||
|
@ -26,7 +26,7 @@ class App extends Component {
|
|||
<div className="Site">
|
||||
<div id="outer">
|
||||
<header className="global-header">
|
||||
<ExtensionPoint name="jenkins.logo.top"/>
|
||||
<Extensions.Renderer extensionPoint="jenkins.logo.top"/>
|
||||
<nav>
|
||||
<Link to="/pipelines">Pipelines</Link>
|
||||
<a href="#">Administration</a>
|
||||
|
@ -57,10 +57,10 @@ class NotFound extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
function makeRoutes() {
|
||||
function makeRoutes(routes) {
|
||||
// Build up our list of top-level routes RR will ignore any non-route stuff put into this list.
|
||||
const appRoutes = [
|
||||
...ExtensionPoint.getExtensions("jenkins.main.routes"),
|
||||
...routes,
|
||||
// FIXME: Not sure best how to set this up without the hardcoded IndexRedirect :-/
|
||||
<IndexRedirect to="/pipelines" />,
|
||||
<Route path="*" component={NotFound}/>
|
||||
|
@ -75,7 +75,7 @@ function makeRoutes() {
|
|||
}
|
||||
|
||||
|
||||
function startApp() {
|
||||
function startApp(routes, stores) {
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
const headElement = document.getElementsByTagName("head")[0];
|
||||
|
@ -106,7 +106,6 @@ function startApp() {
|
|||
});
|
||||
|
||||
// get all ExtensionPoints related to redux-stores
|
||||
const stores = ExtensionPoint.getExtensions("jenkins.main.stores");
|
||||
let store;
|
||||
if (stores.length === 0) {
|
||||
// if we do not have any stores we only add the location store
|
||||
|
@ -139,11 +138,11 @@ function startApp() {
|
|||
// Start React
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<Router history={history}>{ makeRoutes() }</Router>
|
||||
<Router history={history}>{ makeRoutes(routes) }</Router>
|
||||
</Provider>
|
||||
, rootElement);
|
||||
}
|
||||
|
||||
ExtensionPoint.registerExtensionPoint("jenkins.main.routes", () => {
|
||||
startApp();
|
||||
Extensions.store.getExtensions(['jenkins.main.routes', 'jenkins.main.stores'], (routes = [], stores = []) => {
|
||||
startApp(routes, stores);
|
||||
});
|
||||
|
|
|
@ -21,13 +21,14 @@ describe('blueocean.js', () => {
|
|||
expect(browser.success).toBe(true);
|
||||
|
||||
// Check the requests are as expected.
|
||||
expect(loads.length).toBe(3);
|
||||
expect(loads.length).toBe(4);
|
||||
expect(loads[0]).toBe('http://localhost:18999/src/test/js/zombie-test-01.html');
|
||||
expect(loads[1]).toBe('http://localhost:18999/target/classes/io/jenkins/blueocean/no_imports/blueocean.js');
|
||||
|
||||
expect(loads[2]).toBe('http://localhost:18999/src/test/resources/blue/javaScriptExtensionInfo');
|
||||
expect(loads[2]).toBe('http://localhost:18999/src/test/resources/blue/js-extensions');
|
||||
//expect(loads[3]).toBe('http://localhost:18999/src/test/resources/mock-adjuncts/io/jenkins/blueocean-dashboard/jenkins-js-extension.js');
|
||||
|
||||
browser.dump(process.stderr);
|
||||
// Check for some of the elements. We know that the following should
|
||||
// be rendered by the React components.
|
||||
browser.assert.elements('header', 1);
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
<html>
|
||||
<head data-resurl="/src/test/resources"
|
||||
data-adjuncturl="/src/test/resources/mock-adjuncts"
|
||||
data-appurl="/src/test/resources/blue">
|
||||
data-appurl="/src/test/resources/blue"
|
||||
data-rooturl="/src/test/resources">
|
||||
<script type="text/javascript" src="../../../target/classes/io/jenkins/blueocean/no_imports/blueocean.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
[]
|
|
@ -0,0 +1,11 @@
|
|||
{"status":"ok",
|
||||
"data": [
|
||||
{
|
||||
"hpiPluginId":"blueocean-dashboard",
|
||||
"extensions":[
|
||||
{"extensionPoint":"jenkins.logo.top","component":"AdminNavLink"}
|
||||
],
|
||||
"extensionCSS":"org/jenkins/ui/jsmodules/blueocean_dashboard/extensions.css"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -123,11 +123,11 @@ function transformToJSX() {
|
|||
|
||||
// Add the js-modules import of the extensions and add the code to register all
|
||||
// of the extensions in the shared store.
|
||||
jsxFileContent += "require('@jenkins-cd/js-modules').import('jenkins-cd:js-extensions').onFulfilled(function(extensions) {\n";
|
||||
jsxFileContent += "require('@jenkins-cd/js-modules').import('jenkins-cd:js-extensions').onFulfilled(function(Extension) {\n";
|
||||
for (var i2 = 0; i2 < extensions.length; i2++) {
|
||||
var extension = extensions[i2];
|
||||
|
||||
jsxFileContent += " extensions.store.addExtension('" + extension.extensionPoint + "', " + extension.importAs + ");\n";
|
||||
jsxFileContent += " Extension.store._registerComponentInstance('" + extension.extensionPoint + "', '" + maven.getArtifactId() + "', '" + extension.component + "', " + extension.importAs + ");\n";
|
||||
}
|
||||
jsxFileContent += "});";
|
||||
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
# Jenkins JavaScript Extensions
|
||||
|
||||
Jenkins JavasScript Extensions (JSe) in BlueOcean are handled in the UI by the `@jenkins-cd/js-extensions` module.
|
||||
|
||||
JSe is based on the extensibility model already established by Jenkins, based on data and views, with the ability to inherit views based on parent data types.
|
||||
|
||||
JSe `@jenkins-cd/js-extensions` module exports 2 things:
|
||||
- `store` - the `ExtensionStore` instance, which must be initialized
|
||||
- `Renderer` - a React component to conveniently render extensions
|
||||
|
||||
### Store API
|
||||
|
||||
The `ExtensionStore` API is very simple, all public methods are asynchronous:
|
||||
|
||||
- `getExtensions(extensionPointName, [type,] onload)`
|
||||
This method will async load data and type information as needed, and call the onload handler with a list of extension exports, e.g. the React classes or otherwise exported references.
|
||||
|
||||
- `getTypeInfo(type, onload)`
|
||||
This will return a list of type information, from the [classes API](../blueocean-rest/README.md#classes_API), this method also handles caching results locally.
|
||||
|
||||
- `init()`
|
||||
Required to be called with `{ extensionDataProvider: ..., typeInfoProvider: }` see: [ExtensionStore.js](src/ExtensionStore.js#init) for details. This is currently done in [init.jsx](../blueocean-web/src/main/js/init.jsx), with methods to fetch extension data from `<jenkins-url>/blue/js-extensions/` and type information from `<jenkins-url>/blue/rest/classes/<class-name>`.
|
||||
|
||||
### Rendering extension points
|
||||
|
||||
The most common usage pattern is to use the exported `Renderer`, specifying the extension point name, any necessary contextual data, and optionally specifying a data type.
|
||||
|
||||
import Extensions from '@jenkins-cd/js-extensions';
|
||||
...
|
||||
<Extensions.Renderer extensionPoint="jenkins.navigation.top.menu" />
|
||||
|
||||
For example, rendering the test results for a build may be scoped to the specific type of test results in this manner:
|
||||
|
||||
<Extensions.Renderer extensionPoint="test-results-view" dataType={data._class} testResults={data} />
|
||||
|
||||
The `ExtensionRenderer` component optionally uses the [classes API](../blueocean-rest/README.md#classes_API) to look up an appropriate, specific set of views for the data being displayed. This should works seamlessly with other [capabilities](../blueocean-rest/README.md#capabilities).
|
||||
|
||||
|
||||
### Defining extension points
|
||||
|
||||
Extensions are defined in a `jenkins-js-extensions.yaml` file in the javascript source directory of a plugin by defining a list of extensions similar to this:
|
||||
|
||||
# Extensions in this plugin
|
||||
extensions:
|
||||
- component: AboutNavLink
|
||||
extensionPoint: jenkins.topNavigation.menu
|
||||
- component: components/tests/AbstractTestResult
|
||||
extensionPoint: jenkins.test.result
|
||||
type: hudson.tasks.test.AbstractTestResultAction
|
||||
|
||||
Properties are:
|
||||
- `component`: a module from which the default export will be used
|
||||
- `extensionPoint`: the extension point name
|
||||
- `type`: an optional data type this extension handles
|
||||
|
||||
For example, the `AboutNavLink` might be defined as a default export:
|
||||
|
||||
export default class NavLink extends React.Component {
|
||||
...
|
||||
}
|
||||
|
||||
Although extensions are not limited to React components, this is the typical usage so far.
|
|
@ -2,4 +2,31 @@
|
|||
// See https://github.com/jenkinsci/js-builder
|
||||
//
|
||||
var builder = require('@jenkins-cd/js-builder');
|
||||
builder.lint('none');
|
||||
builder.src(['src', 'js-extensions/@jenkins-cd', 'js-extensions/@jenkins-cd/subs']);
|
||||
builder.lang('es6');
|
||||
//builder.lint('none');
|
||||
//
|
||||
//Redefine the "test" task to use mocha and support es6.
|
||||
//We might build this into js-builder, but is ok here
|
||||
//for now.
|
||||
//
|
||||
builder.defineTask('test', function() {
|
||||
var mocha = require('gulp-mocha');
|
||||
var babel = require('babel-core/register');
|
||||
|
||||
// Allow running of a specific test
|
||||
// e.g. gulp test --test pipelines
|
||||
// will run the pipelines-spec.js
|
||||
var filter = builder.args.argvValue('--test', '*');
|
||||
|
||||
builder.gulp.src('spec/' + filter + '-spec.js')
|
||||
.pipe(mocha({
|
||||
compilers: { js: babel }
|
||||
})).on('error', function(e) {
|
||||
if (builder.isRetest()) {
|
||||
// ignore test failures if we are running retest.
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
|
||||
exports.store = require('./dist/store.js');
|
||||
exports.ExtensionPoint = require('./dist/ExtensionPoint.js');
|
||||
// Provide an ExtensionStore & ExtensionRenderer react component
|
||||
exports.store = require('./dist/ExtensionStore.js').instance;
|
||||
exports.Renderer = require('./dist/ExtensionRenderer.js').ExtensionRenderer;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@jenkins-cd/js-extensions",
|
||||
"version": "0.0.15",
|
||||
"version": "0.0.16",
|
||||
"description": "Jenkins Extension Store",
|
||||
"main": "index.js",
|
||||
"files": [
|
||||
|
@ -10,9 +10,10 @@
|
|||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && mkdir dist && jsx src/ExtensionPoint.jsx > dist/ExtensionPoint.js && cp src/*.js dist/",
|
||||
"build": "rm -rf dist && mkdir dist && babel --presets es2015,react src -d dist",
|
||||
"test": "gulp test",
|
||||
"prepublish": "npm run build"
|
||||
"prepublish": "npm run build",
|
||||
"lint": "gulp lint"
|
||||
},
|
||||
"author": "Tom Fennelly <tom.fennelly@gmail.com> (https://github.com/tfennelly)",
|
||||
"license": "MIT",
|
||||
|
@ -21,10 +22,20 @@
|
|||
"react-dom": "^0.14.7 || ^15.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jenkins-cd/eslint-config-jenkins": "0.0.2",
|
||||
"@jenkins-cd/js-builder": "0.0.34",
|
||||
"@jenkins-cd/js-test": "1.1.1",
|
||||
"babel-cli": "6.10.1",
|
||||
"babel-core": "^6.7.6",
|
||||
"babel-eslint": "^6.0.2",
|
||||
"babel-preset-es2015": "^6.6.0",
|
||||
"babel-preset-react": "^6.5.0",
|
||||
"babel-preset-stage-0": "^6.5.0",
|
||||
"eslint": "2.8.0",
|
||||
"eslint-plugin-react": "^5.0.1",
|
||||
"gulp": "^3.9.1",
|
||||
"react-tools": "^0.13.3"
|
||||
"gulp-mocha": "^2.2.0",
|
||||
"chai": "^3.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"js-yaml": "^3.6.0",
|
||||
|
|
|
@ -0,0 +1,231 @@
|
|||
var jsTest = require('@jenkins-cd/js-test');
|
||||
var expect = require('chai').expect;
|
||||
var ExtensionStore = require('../dist/ExtensionStore').ExtensionStore;
|
||||
var javaScriptExtensionInfo = require('./javaScriptExtensionInfo-01.json');
|
||||
|
||||
// js modules calling console.debug
|
||||
console.debug = function(msg) { console.log('DEBUG: ' + msg); };
|
||||
|
||||
var mockDataLoad = function(extensionStore, out) {
|
||||
var jsModules = require('@jenkins-cd/js-modules');
|
||||
out.plugins = {};
|
||||
out.loadCount = 0;
|
||||
|
||||
// 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];
|
||||
if (pluginMetadata.hpiPluginId === pluginId) {
|
||||
var extensions = pluginMetadata.extensions;
|
||||
for(var i2 = 0; i2 < extensions.length; i2++) {
|
||||
extensionStore._registerComponentInstance(extensions[i2].extensionPoint, pluginMetadata.hpiPluginId, extensions[i2].component, extensions[i2].component);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (out.plugins[pluginId] === undefined) {
|
||||
out.plugins[pluginId] = true;
|
||||
out.loadCount++;
|
||||
}
|
||||
|
||||
// fake the export of the bundle
|
||||
setTimeout(function() {
|
||||
// js modules calling console.debug
|
||||
var orig = console.debug;
|
||||
try {
|
||||
console.debug = function(msg) { };
|
||||
jsModules.export(pluginId, 'jenkins-js-extension', {});
|
||||
} finally {
|
||||
console.debug = orig;
|
||||
}
|
||||
}, 1);
|
||||
return theRealImport.call(theRealImport, bundleId);
|
||||
};
|
||||
};
|
||||
|
||||
describe("ExtensionStore.js", function () {
|
||||
|
||||
it("- fails if not initialized", function(done) {
|
||||
jsTest.onPage(function() {
|
||||
var extensionStore = new ExtensionStore();
|
||||
|
||||
var plugins = {};
|
||||
mockDataLoad(extensionStore, plugins);
|
||||
|
||||
try {
|
||||
extenstionStore.getExtensions('ext', function(ext) { });
|
||||
expect("Exception should be thrown").to.be.undefined;
|
||||
} catch(ex) {
|
||||
// expected
|
||||
}
|
||||
|
||||
extensionStore.init({
|
||||
extensionDataProvider: function(cb) { cb(javaScriptExtensionInfo); },
|
||||
typeInfoProvider: function(type, cb) { cb({}); }
|
||||
});
|
||||
|
||||
extensionStore.getExtensions('ep-1', function(extensions) {
|
||||
expect(extensions).to.not.be.undefined;
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("- test plugins loaded not duplicated", function (done) {
|
||||
jsTest.onPage(function() {
|
||||
var extensionStore = new ExtensionStore();
|
||||
|
||||
var plugins = {};
|
||||
mockDataLoad(extensionStore, plugins);
|
||||
|
||||
// Initialise the ExtensionStore with some extension point info. At runtime,
|
||||
// this info will be loaded from <jenkins>/blue/js-extensions/
|
||||
extensionStore.init({
|
||||
extensionDataProvider: function(cb) { cb(javaScriptExtensionInfo); },
|
||||
typeInfoProvider: function(type, cb) { cb({}); }
|
||||
});
|
||||
|
||||
// Call load for ExtensionPoint impls 'ep-1'. This should mimic
|
||||
// the ExtensionStore checking all plugins and loading the bundles for any
|
||||
// plugins that define an impl of 'ep-1' (if not already loaded).
|
||||
extensionStore.getExtensions('ep-1', function() {
|
||||
if (plugins.loadCount === 2) {
|
||||
expect(plugins.plugins['plugin-1']).to.not.be.undefined;
|
||||
expect(plugins.plugins['plugin-2']).to.not.be.undefined;
|
||||
|
||||
// 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).
|
||||
extensionStore.getExtensions('ep-1', function() {
|
||||
expect(plugins.loadCount).to.equal(2);
|
||||
|
||||
// Calling it yet again for different extension point, but
|
||||
// where the bundles for that extension point have already.
|
||||
extensionStore.getExtensions('ep-2', function() {
|
||||
expect(plugins.loadCount).to.equal(2);
|
||||
done();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("- handles types properly", function(done) {
|
||||
var extensionStore = new ExtensionStore();
|
||||
|
||||
var plugins = {};
|
||||
mockDataLoad(extensionStore, plugins);
|
||||
|
||||
var typeData = {};
|
||||
typeData['type-1'] = {
|
||||
"_class":"io.jenkins.blueocean.service.embedded.rest.ExtensionClassImpl",
|
||||
"_links":{
|
||||
"self":{"_class":"io.jenkins.blueocean.rest.hal.Link",
|
||||
"href":"/blue/rest/classes/hudson.tasks.junit.TestResultAction/"
|
||||
}
|
||||
},
|
||||
"classes":["supertype-1"]
|
||||
};
|
||||
typeData['type-2'] = {
|
||||
"_class":"io.jenkins.blueocean.service.embedded.rest.ExtensionClassImpl",
|
||||
"_links":{
|
||||
"self":{"_class":"io.jenkins.blueocean.rest.hal.Link",
|
||||
"href":"/blue/rest/classes/hudson.tasks.junit.TestResultAction/"
|
||||
}
|
||||
},
|
||||
"classes":["supertype-2"]
|
||||
};
|
||||
|
||||
extensionStore.init({
|
||||
extensionDataProvider: function(cb) { cb(javaScriptExtensionInfo); },
|
||||
typeInfoProvider: function(type, cb) { cb(typeData[type]); }
|
||||
});
|
||||
|
||||
extensionStore.getExtensions('ept-1', 'type-1', function(extensions) {
|
||||
expect(extensionStore.typeInfo).to.not.be.undefined;
|
||||
expect(extensions.length).to.equal(1);
|
||||
expect(extensions[0]).to.equal('typed-component-1.1');
|
||||
});
|
||||
|
||||
extensionStore.getExtensions('ept-2', 'type-2', function(extensions) {
|
||||
expect(extensions.length).to.equal(1);
|
||||
expect(extensions).to.include.members(["typed-component-1.2"]);
|
||||
});
|
||||
|
||||
done();
|
||||
});
|
||||
|
||||
it("- handles untyped extension points", function(done) {
|
||||
var extensionStore = new ExtensionStore();
|
||||
|
||||
var plugins = {};
|
||||
mockDataLoad(extensionStore, plugins);
|
||||
|
||||
var typeData = {};
|
||||
typeData['type-1'] = {
|
||||
"_class":"io.jenkins.blueocean.service.embedded.rest.ExtensionClassImpl",
|
||||
"_links":{
|
||||
"self":{"_class":"io.jenkins.blueocean.rest.hal.Link",
|
||||
"href":"/blue/rest/classes/hudson.tasks.junit.TestResultAction/"
|
||||
}
|
||||
},
|
||||
"classes":["supertype-1"]
|
||||
};
|
||||
typeData['type-2'] = {
|
||||
"_class":"io.jenkins.blueocean.service.embedded.rest.ExtensionClassImpl",
|
||||
"_links":{
|
||||
"self":{"_class":"io.jenkins.blueocean.rest.hal.Link",
|
||||
"href":"/blue/rest/classes/hudson.tasks.junit.TestResultAction/"
|
||||
}
|
||||
},
|
||||
"classes":["supertype-2"]
|
||||
};
|
||||
|
||||
extensionStore.init({
|
||||
extensionDataProvider: function(cb) { cb(javaScriptExtensionInfo); },
|
||||
typeInfoProvider: function(type, cb) { cb(typeData[type]); }
|
||||
});
|
||||
|
||||
extensionStore.getExtensions('ep-1', function(extensions) {
|
||||
expect(extensions.length).to.equal(3);
|
||||
expect(extensions).to.include.members(["component-1.1","component-1.2","component-2.1"]);
|
||||
});
|
||||
|
||||
extensionStore.getExtensions('ept-2', function(extensions) {
|
||||
expect(extensions.length).to.equal(0);
|
||||
expect(extensions).to.include.members([]);
|
||||
});
|
||||
|
||||
done();
|
||||
});
|
||||
|
||||
it("- handles multi-key requests", function(done) {
|
||||
var extensionStore = new ExtensionStore();
|
||||
|
||||
var plugins = {};
|
||||
mockDataLoad(extensionStore, plugins);
|
||||
|
||||
extensionStore.init({
|
||||
extensionDataProvider: function(cb) { cb(javaScriptExtensionInfo); },
|
||||
typeInfoProvider: function(type, cb) { cb({}); }
|
||||
});
|
||||
|
||||
extensionStore.getExtensions(['ep-1','ep-2'], function(ep1,ep2) {
|
||||
expect(ep1.length).to.equal(3);
|
||||
expect(ep1).to.include.members(["component-1.1","component-1.2","component-2.1"]);
|
||||
|
||||
expect(ep2.length).to.equal(3);
|
||||
expect(ep2).to.include.members(["component-1.3","component-2.2","component-2.3"]);
|
||||
});
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
|
@ -1,6 +1,9 @@
|
|||
var jsTest = require('@jenkins-cd/js-test');
|
||||
var expect = require('chai').expect;
|
||||
|
||||
describe("cssloadtracker.js", function () {
|
||||
var ResourceLoadTracker = require('../dist/ResourceLoadTracker').instance;
|
||||
|
||||
describe("ResourceLoadTracker.js", function () {
|
||||
|
||||
// Looking at './javaScriptExtensionInfo-02.json' you'll see that there are a few extension
|
||||
// points with impls spread across 2 plugins:
|
||||
|
@ -24,28 +27,27 @@ describe("cssloadtracker.js", function () {
|
|||
it("- test ep-1 loads css from both plugins", function (done) {
|
||||
jsTest.onPage(function() {
|
||||
var javaScriptExtensionInfo = require('./javaScriptExtensionInfo-02.json');
|
||||
var cssloadtracker = require('../src/cssloadtracker');
|
||||
|
||||
// Initialise the load tracker with plugin extension point info.
|
||||
cssloadtracker.setExtensionPointMetadata(javaScriptExtensionInfo);
|
||||
ResourceLoadTracker.setExtensionPointMetadata(javaScriptExtensionInfo);
|
||||
|
||||
// Verify that there's no link elements on the page.
|
||||
var cssElements = document.getElementsByTagName('link');
|
||||
expect(cssElements.length).toBe(0);
|
||||
expect(cssElements.length).to.equal(0);
|
||||
|
||||
// Mounting ep-1 should result in the CSS for both plugins being
|
||||
// loaded ...
|
||||
cssloadtracker.onMount('ep-1');
|
||||
ResourceLoadTracker.onMount('ep-1');
|
||||
cssElements = document.getElementsByTagName('link');
|
||||
expect(cssElements.length).toBe(2);
|
||||
expect(cssElements[0].getAttribute('href')).toBe('/adjuncts/908d75c1/org/jenkins/ui/jsmodules/plugin-1/extensions.css');
|
||||
expect(cssElements[1].getAttribute('href')).toBe('/adjuncts/908d75c1/org/jenkins/ui/jsmodules/plugin-2/extensions.css');
|
||||
expect(cssElements.length).to.equal(2);
|
||||
expect(cssElements[0].getAttribute('href')).to.equal('/adjuncts/908d75c1/org/jenkins/ui/jsmodules/plugin-1/extensions.css');
|
||||
expect(cssElements[1].getAttribute('href')).to.equal('/adjuncts/908d75c1/org/jenkins/ui/jsmodules/plugin-2/extensions.css');
|
||||
|
||||
// Unmounting ep-1 should result in the CSS for both plugins being
|
||||
// unloaded ...
|
||||
cssloadtracker.onUnmount('ep-1');
|
||||
ResourceLoadTracker.onUnmount('ep-1');
|
||||
cssElements = document.getElementsByTagName('link');
|
||||
expect(cssElements.length).toBe(0);
|
||||
expect(cssElements.length).to.equal(0);
|
||||
|
||||
done();
|
||||
});
|
||||
|
@ -54,26 +56,25 @@ describe("cssloadtracker.js", function () {
|
|||
it("- test ep-2 loads css from plugin-1 only", function (done) {
|
||||
jsTest.onPage(function() {
|
||||
var javaScriptExtensionInfo = require('./javaScriptExtensionInfo-02.json');
|
||||
var cssloadtracker = require('../src/cssloadtracker');
|
||||
|
||||
// Initialise the load tracker with plugin extension point info.
|
||||
cssloadtracker.setExtensionPointMetadata(javaScriptExtensionInfo);
|
||||
ResourceLoadTracker.setExtensionPointMetadata(javaScriptExtensionInfo);
|
||||
|
||||
// Verify that there's no link elements on the page.
|
||||
var cssElements = document.getElementsByTagName('link');
|
||||
expect(cssElements.length).toBe(0);
|
||||
expect(cssElements.length).to.equal(0);
|
||||
|
||||
// Mounting ep-2 should result in the CSS for plugin-1 only being
|
||||
// loaded ...
|
||||
cssloadtracker.onMount('ep-2');
|
||||
ResourceLoadTracker.onMount('ep-2');
|
||||
cssElements = document.getElementsByTagName('link');
|
||||
expect(cssElements.length).toBe(1);
|
||||
expect(cssElements[0].getAttribute('href')).toBe('/adjuncts/908d75c1/org/jenkins/ui/jsmodules/plugin-1/extensions.css');
|
||||
expect(cssElements.length).to.equal(1);
|
||||
expect(cssElements[0].getAttribute('href')).to.equal('/adjuncts/908d75c1/org/jenkins/ui/jsmodules/plugin-1/extensions.css');
|
||||
|
||||
// Unmounting ep-2 should result in no CSS on the page.
|
||||
cssloadtracker.onUnmount('ep-2');
|
||||
ResourceLoadTracker.onUnmount('ep-2');
|
||||
cssElements = document.getElementsByTagName('link');
|
||||
expect(cssElements.length).toBe(0);
|
||||
expect(cssElements.length).to.equal(0);
|
||||
|
||||
done();
|
||||
});
|
||||
|
@ -82,43 +83,42 @@ describe("cssloadtracker.js", function () {
|
|||
it("- test ep-1, ep-2 and ep-3 loads and unloads css in correct order", function (done) {
|
||||
jsTest.onPage(function() {
|
||||
var javaScriptExtensionInfo = require('./javaScriptExtensionInfo-02.json');
|
||||
var cssloadtracker = require('../src/cssloadtracker');
|
||||
|
||||
// Initialise the load tracker with plugin extension point info.
|
||||
cssloadtracker.setExtensionPointMetadata(javaScriptExtensionInfo);
|
||||
ResourceLoadTracker.setExtensionPointMetadata(javaScriptExtensionInfo);
|
||||
|
||||
// Verify that there's no link elements on the page.
|
||||
var cssElements = document.getElementsByTagName('link');
|
||||
expect(cssElements.length).toBe(0);
|
||||
expect(cssElements.length).to.equal(0);
|
||||
|
||||
// Mounting ep-* should result in the CSS for both plugins being
|
||||
// loaded ...
|
||||
cssloadtracker.onMount('ep-1');
|
||||
cssloadtracker.onMount('ep-2');
|
||||
cssloadtracker.onMount('ep-3');
|
||||
ResourceLoadTracker.onMount('ep-1');
|
||||
ResourceLoadTracker.onMount('ep-2');
|
||||
ResourceLoadTracker.onMount('ep-3');
|
||||
cssElements = document.getElementsByTagName('link');
|
||||
expect(cssElements.length).toBe(2);
|
||||
expect(cssElements[0].getAttribute('href')).toBe('/adjuncts/908d75c1/org/jenkins/ui/jsmodules/plugin-1/extensions.css');
|
||||
expect(cssElements[1].getAttribute('href')).toBe('/adjuncts/908d75c1/org/jenkins/ui/jsmodules/plugin-2/extensions.css');
|
||||
expect(cssElements.length).to.equal(2);
|
||||
expect(cssElements[0].getAttribute('href')).to.equal('/adjuncts/908d75c1/org/jenkins/ui/jsmodules/plugin-1/extensions.css');
|
||||
expect(cssElements[1].getAttribute('href')).to.equal('/adjuncts/908d75c1/org/jenkins/ui/jsmodules/plugin-2/extensions.css');
|
||||
|
||||
// Unmounting ep-1 should no change the page CSS because ep-2 and ep-3
|
||||
// are still mounted.
|
||||
cssloadtracker.onUnmount('ep-1');
|
||||
ResourceLoadTracker.onUnmount('ep-1');
|
||||
cssElements = document.getElementsByTagName('link');
|
||||
expect(cssElements.length).toBe(2);
|
||||
expect(cssElements[0].getAttribute('href')).toBe('/adjuncts/908d75c1/org/jenkins/ui/jsmodules/plugin-1/extensions.css');
|
||||
expect(cssElements[1].getAttribute('href')).toBe('/adjuncts/908d75c1/org/jenkins/ui/jsmodules/plugin-2/extensions.css');
|
||||
expect(cssElements.length).to.equal(2);
|
||||
expect(cssElements[0].getAttribute('href')).to.equal('/adjuncts/908d75c1/org/jenkins/ui/jsmodules/plugin-1/extensions.css');
|
||||
expect(cssElements[1].getAttribute('href')).to.equal('/adjuncts/908d75c1/org/jenkins/ui/jsmodules/plugin-2/extensions.css');
|
||||
|
||||
// Unmounting ep-3 should should result in plugin-2 CSS being unloaded from the page.
|
||||
cssloadtracker.onUnmount('ep-3');
|
||||
ResourceLoadTracker.onUnmount('ep-3');
|
||||
cssElements = document.getElementsByTagName('link');
|
||||
expect(cssElements.length).toBe(1);
|
||||
expect(cssElements[0].getAttribute('href')).toBe('/adjuncts/908d75c1/org/jenkins/ui/jsmodules/plugin-1/extensions.css');
|
||||
expect(cssElements.length).to.equal(1);
|
||||
expect(cssElements[0].getAttribute('href')).to.equal('/adjuncts/908d75c1/org/jenkins/ui/jsmodules/plugin-1/extensions.css');
|
||||
|
||||
// Unmounting ep-2 should should result in no CSS being on the page
|
||||
cssloadtracker.onUnmount('ep-2');
|
||||
ResourceLoadTracker.onUnmount('ep-2');
|
||||
cssElements = document.getElementsByTagName('link');
|
||||
expect(cssElements.length).toBe(0);
|
||||
expect(cssElements.length).to.equal(0);
|
||||
|
||||
done();
|
||||
});
|
|
@ -27,10 +27,36 @@
|
|||
"extensionPoint": "ep-2"
|
||||
},
|
||||
{
|
||||
"component": "component-1.3",
|
||||
"component": "component-2.3",
|
||||
"extensionPoint": "ep-2"
|
||||
}
|
||||
],
|
||||
"hpiPluginId": "plugin-2"
|
||||
},
|
||||
{
|
||||
"extensions": [
|
||||
{
|
||||
"component": "typed-component-1.1",
|
||||
"extensionPoint": "ept-1",
|
||||
"type": "supertype-1"
|
||||
},
|
||||
{
|
||||
"component": "typed-component-1.2",
|
||||
"extensionPoint": "ept-2",
|
||||
"type": "type-2"
|
||||
},
|
||||
{
|
||||
"component": "typed-component-1.2.1",
|
||||
"extensionPoint": "ept-2",
|
||||
"type": "subtype-2"
|
||||
},
|
||||
{
|
||||
"component": "typed-component-1.2.2",
|
||||
"extensionPoint": "ept-2",
|
||||
"type": "supertype-2"
|
||||
}
|
||||
],
|
||||
"hpiPluginId": "plugin-3",
|
||||
"extensionCSS": "org/jenkins/ui/jsmodules/plugin-3/extensions.css"
|
||||
}
|
||||
]
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
var expect = require('chai').expect;
|
||||
|
||||
describe("js-builder plugin test", function () {
|
||||
|
||||
it("- test readYAMLFile", function () {
|
||||
|
@ -17,9 +19,9 @@ describe("js-builder plugin test", function () {
|
|||
});
|
||||
|
||||
function assertSampleJSONOkay(asJSON) {
|
||||
expect(asJSON.id).toBe('com.example.my.plugin');
|
||||
expect(asJSON.artefacts.page.id).toBe('about-my-plugin');
|
||||
expect(asJSON.artefacts.components[0].id).toBe('MyNeatButton');
|
||||
expect(asJSON.artefacts.components[1].id).toBe('SuperList');
|
||||
expect(asJSON.id).to.equal('com.example.my.plugin');
|
||||
expect(asJSON.artefacts.page.id).to.equal('about-my-plugin');
|
||||
expect(asJSON.artefacts.components[0].id).to.equal('MyNeatButton');
|
||||
expect(asJSON.artefacts.components[1].id).to.equal('SuperList');
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,70 +0,0 @@
|
|||
var jsTest = require('@jenkins-cd/js-test');
|
||||
|
||||
describe("store.js", function () {
|
||||
|
||||
it("- test ", function (done) {
|
||||
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 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++;
|
||||
}
|
||||
|
||||
// fake the export of the bundle
|
||||
setTimeout(function() {
|
||||
jsModules.export(pluginId, 'jenkins-js-extension', {});
|
||||
}, 100);
|
||||
return theRealImport.call(theRealImport, bundleId);
|
||||
};
|
||||
|
||||
// Initialise the store with some extension point info. At runtime,
|
||||
// this info will be loaded from <jenkins>/blue/javaScriptExtensionInfo
|
||||
store.setExtensionPointMetadata(javaScriptExtensionInfo);
|
||||
|
||||
// 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();
|
||||
|
||||
// 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);
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,28 +1,26 @@
|
|||
var React = require('react');
|
||||
var ReactDOM = require('react-dom');
|
||||
var store = require('./store.js');
|
||||
var cssloadtracker = require('./cssloadtracker');
|
||||
|
||||
// TODO: Move this package to babel, and update this to ES6
|
||||
var ExtensionStore = require('./ExtensionStore.js');
|
||||
var ResourceLoadTracker = require('./ResourceLoadTracker').instance;
|
||||
|
||||
/**
|
||||
* An internal component that inserts things into the (separate) context of mounted extensions. We need this for our
|
||||
* configuration object, which helps resolve URLs for media, REST endpoints, etc, and we also need to bridge the
|
||||
* "router" context property in order for extensions to be able to use <Link> from react-router.
|
||||
*/
|
||||
var ContextBridge = React.createClass({
|
||||
class ContextBridge extends React.Component {
|
||||
|
||||
getChildContext: function() {
|
||||
getChildContext() {
|
||||
return {
|
||||
router: this.props.router,
|
||||
config: this.props.config
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
render: function() {
|
||||
render() {
|
||||
return this.props.children;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ContextBridge.childContextTypes = {
|
||||
router: React.PropTypes.object,
|
||||
|
@ -30,71 +28,71 @@ ContextBridge.childContextTypes = {
|
|||
};
|
||||
|
||||
ContextBridge.propTypes = {
|
||||
children: React.PropTypes.any,
|
||||
router: React.PropTypes.object,
|
||||
config: React.PropTypes.object
|
||||
};
|
||||
|
||||
/**
|
||||
* Implement an ExtensionPoint for which other plugins can provide an implementing Component.
|
||||
* Renderer for react component extensions for which other plugins can provide an implementing Component.
|
||||
*/
|
||||
var ExtensionPoint = React.createClass({
|
||||
|
||||
getInitialState: function () {
|
||||
export class ExtensionRenderer extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
// Initial state is empty. See the componentDidMount and render functions.
|
||||
return {};
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
cssloadtracker.onMount(this.props.name);
|
||||
var thisEp = this;
|
||||
ExtensionPoint.registerExtensionPoint(this.props.name, function(extensions) {
|
||||
thisEp.setState({
|
||||
extensions: extensions
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
componentDidUpdate: function() {
|
||||
this.state = { extensions: null };
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this._setExtensions();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
ResourceLoadTracker.onMount(this.props.extensionPoint);
|
||||
this._renderAllExtensions();
|
||||
},
|
||||
}
|
||||
|
||||
componentWillUnmount: function() {
|
||||
componentDidUpdate() {
|
||||
this._renderAllExtensions();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._unmountAllExtensions();
|
||||
cssloadtracker.onUnmount(this.props.name);
|
||||
},
|
||||
|
||||
ResourceLoadTracker.onUnmount(this.props.extensionPoint);
|
||||
}
|
||||
|
||||
_setExtensions() {
|
||||
ExtensionStore.instance.getExtensions(this.props.extensionPoint, this.props.dataType,
|
||||
extensions => this.setState({extensions: extensions})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method renders the "leaf node" container divs, one for each registered extension, that live in the same
|
||||
* react hierarchy as the <ExtensionPoint> instance itself. As far as our react is concerned, these are
|
||||
* react hierarchy as the <ExtensionRenderer> instance itself. As far as our react is concerned, these are
|
||||
* childless divs that are never updated. Actually rendering the extensions themselves is done by
|
||||
* _renderAllExtensions.
|
||||
*/
|
||||
render: function() {
|
||||
render() {
|
||||
var extensions = this.state.extensions;
|
||||
|
||||
if (!extensions) {
|
||||
// Initial state. componentDidMount will kick in and load the extensions.
|
||||
return null;
|
||||
} else if (extensions.length === 0) {
|
||||
console.warn('No "' + this.props.name + '" ExtensionPoint implementations were found across the installed plugin set. See ExtensionList:');
|
||||
console.log(store.getExtensionList());
|
||||
return null;
|
||||
} else {
|
||||
// Add a <div> for each of the extensions. See the __renderAllExtensions function.
|
||||
var extensionDivs = [];
|
||||
for (var i = 0; i < extensions.length; i++) {
|
||||
extensionDivs.push(<div key={i}/>);
|
||||
}
|
||||
return React.createElement(this.props.wrappingElement, null, extensionDivs);
|
||||
return null; // this is called before extension data is available
|
||||
}
|
||||
},
|
||||
|
||||
// Add a <div> for each of the extensions. See the __renderAllExtensions function.
|
||||
var extensionDivs = [];
|
||||
for (var i = 0; i < extensions.length; i++) {
|
||||
extensionDivs.push(<div key={i}/>);
|
||||
}
|
||||
return React.createElement(this.props.wrappingElement, null, extensionDivs);
|
||||
}
|
||||
|
||||
/**
|
||||
* For each extension, we have created a "leaf node" element in the DOM. This method creates a new react hierarchy
|
||||
* for each, and instructs it to render. From that point on we have a separation that keeps the main app insulated
|
||||
* from any plugin issues that may cause react to throw while updating. Inspired by Nylas N1.
|
||||
*/
|
||||
_renderAllExtensions: function() {
|
||||
_renderAllExtensions() {
|
||||
// NB: This needs to be a lot cleverer if the list of extensions for a specific point can change;
|
||||
// We will need to link each extension with its containing element, in some way that doesn't leak :) Easy in
|
||||
// browsers with WeakMap, less so otherwise.
|
||||
|
@ -102,12 +100,12 @@ var ExtensionPoint = React.createClass({
|
|||
if (el) {
|
||||
const children = el.children;
|
||||
if (children) {
|
||||
const extensions = store.getExtensions(this.props.name);
|
||||
const extensions = this.state.extensions;
|
||||
|
||||
// The number of children should be exactly the same as the number
|
||||
// of extensions. See the render function for where these are added.
|
||||
if (!extensions || extensions.length !== children.length) {
|
||||
console.error('Unexpected error in Jenkins ExtensionPoint rendering (' + this.props.name + '). Expecting a child DOM node for each extension point.');
|
||||
console.error('Unexpected error in Jenkins ExtensionRenderer rendering (' + this.props.extensionPoint + '). Expecting a child DOM node for each extension point.');
|
||||
return;
|
||||
}
|
||||
// render each extension on the allocated child node.
|
||||
|
@ -116,10 +114,10 @@ var ExtensionPoint = React.createClass({
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/** Actually render an individual extension */
|
||||
_renderExtension: function(element, extension) {
|
||||
_renderExtension(element, extension) {
|
||||
var component = React.createElement(extension, this.props);
|
||||
try {
|
||||
var contextValuesAsProps = {
|
||||
|
@ -134,13 +132,13 @@ var ExtensionPoint = React.createClass({
|
|||
var errorDiv = <div className="error alien">Error rendering {extension.name}: {e.toString()}</div>;
|
||||
ReactDOM.render(errorDiv, element);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up child extensions' react hierarchies. Necessary because they live in their own react hierarchies that
|
||||
* would otherwise not be notified when this is being unmounted.
|
||||
*/
|
||||
_unmountAllExtensions: function() {
|
||||
_unmountAllExtensions() {
|
||||
var thisNode = ReactDOM.findDOMNode(this);
|
||||
var children = thisNode ? thisNode.children : null;
|
||||
if (children && children.length) {
|
||||
|
@ -158,36 +156,19 @@ var ExtensionPoint = React.createClass({
|
|||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ExtensionPoint.defaultProps = {
|
||||
ExtensionRenderer.defaultProps = {
|
||||
wrappingElement: "div"
|
||||
};
|
||||
|
||||
ExtensionPoint.propTypes = {
|
||||
name: React.PropTypes.string.isRequired,
|
||||
ExtensionRenderer.propTypes = {
|
||||
extensionPoint: React.PropTypes.string.isRequired,
|
||||
dataType: React.PropTypes.any,
|
||||
wrappingElement: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.element])
|
||||
};
|
||||
|
||||
ExtensionPoint.contextTypes = {
|
||||
ExtensionRenderer.contextTypes = {
|
||||
router: React.PropTypes.object,
|
||||
config: React.PropTypes.object
|
||||
};
|
||||
|
||||
/**
|
||||
* Provide a static helper to avoid having to expose the store
|
||||
*/
|
||||
ExtensionPoint.getExtensions = function getExtensions(name) {
|
||||
return store.getExtensions(name);
|
||||
};
|
||||
|
||||
/**
|
||||
* Register the existence of an ExtensionPoint and load the extensions. onLoad is (extensions)=>{}
|
||||
*/
|
||||
ExtensionPoint.registerExtensionPoint = function registerExtensionPoint (name, onLoad) {
|
||||
store.loadExtensions(name, function (extensions) {
|
||||
if (typeof onLoad === "function") onLoad(extensions);
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = ExtensionPoint;
|
|
@ -0,0 +1,316 @@
|
|||
/**
|
||||
* ExtensionStore is responsible for maintaining extension metadata
|
||||
* including type/capability info
|
||||
*/
|
||||
export class ExtensionStore {
|
||||
/**
|
||||
* FIXME this is NOT a constructor, as there's no common way to
|
||||
* pass around a DI singleton at the moment across everything
|
||||
* that needs it (e.g. redux works for the app, not for other
|
||||
* things in this module)
|
||||
*
|
||||
* Needs:
|
||||
* args = {
|
||||
* extensionDataProvider: callback => {
|
||||
* ... // get the data
|
||||
* callback(extensionData); // array of extensions
|
||||
* },
|
||||
* typeInfoProvider: (type, callback) => {
|
||||
* ... // get the data based on 'type'
|
||||
* callback(typeInfo);
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
init(args) {
|
||||
// This data should come from <jenkins>/blue/js-extensions
|
||||
this.extensionDataProvider = args.extensionDataProvider;
|
||||
this.extensionPointList = undefined; // cache from extensionDataProvider...
|
||||
/**
|
||||
* The registered ExtensionPoint metadata + instance refs
|
||||
*/
|
||||
this.extensionPoints = {};
|
||||
/**
|
||||
* Type info cache
|
||||
*/
|
||||
this.typeInfo = {};
|
||||
/**
|
||||
* Used to fetch type information
|
||||
*/
|
||||
this.typeInfoProvider = args.typeInfoProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the extension script object
|
||||
*/
|
||||
_registerComponentInstance(extensionPointId, pluginId, component, instance) {
|
||||
var extensions = this.extensionPoints[extensionPointId];
|
||||
if (!extensions) {
|
||||
this._loadBundles(extensionPointId, () => this._registerComponentInstance(extensionPointId, pluginId, component, instance));
|
||||
return;
|
||||
}
|
||||
var extension = this._findPlugin(extensionPointId, pluginId, component);
|
||||
if (extension) {
|
||||
extension.instance = instance;
|
||||
return;
|
||||
}
|
||||
throw `Unable to locate plugin for ${extensionPointId} / ${pluginId} / ${component}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a plugin by extension point id, plugin id, component name
|
||||
*/
|
||||
_findPlugin(extensionPointId, pluginId, component) {
|
||||
var extensions = this.extensionPoints[extensionPointId];
|
||||
if (extensions) {
|
||||
for (var i = 0; i < extensions.length; i++) {
|
||||
var extension = extensions[i];
|
||||
if (extension.pluginId == pluginId && extension.component == component) {
|
||||
return extension;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The primary function to use in order to get extensions,
|
||||
* will call the onload callback with a list of exported extension
|
||||
* objects (e.g. React classes or otherwise).
|
||||
*/
|
||||
getExtensions(key, type, onload) {
|
||||
if (!this.extensionDataProvider) {
|
||||
throw "Must call ExtensionStore.init({ extensionDataProvider: (cb) => ..., typeInfoProvider: (type, cb) => ... }) first";
|
||||
}
|
||||
// Allow calls like: getExtensions('something', a => ...)
|
||||
if (arguments.length === 2 && typeof(type) === 'function') {
|
||||
onload = type;
|
||||
type = undefined;
|
||||
}
|
||||
// And calls like: getExtensions(['a','b'], (a,b) => ...)
|
||||
if (key instanceof Array) {
|
||||
var keys = key;
|
||||
var args = [];
|
||||
var nextArg = ext => {
|
||||
if(ext) args.push(ext);
|
||||
if (keys.length === 0) {
|
||||
onload(...args);
|
||||
} else {
|
||||
var arg = keys[0];
|
||||
keys = keys.slice(1);
|
||||
this.getExtensions(arg, null, nextArg);
|
||||
}
|
||||
};
|
||||
nextArg();
|
||||
return;
|
||||
}
|
||||
|
||||
this._loadBundles(key, extensions => this._filterExtensions(extensions, key, type, onload));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the type/capability info for the given data type
|
||||
*/
|
||||
getTypeInfo(type, onload) {
|
||||
var ti = this.typeInfo[type];
|
||||
if (ti) {
|
||||
return onload(ti);
|
||||
}
|
||||
this.typeInfoProvider(type, (data) => {
|
||||
ti = this.typeInfo[type] = JSON.parse(JSON.stringify(data));
|
||||
ti.classes = ti.classes || [];
|
||||
if (ti.classes.indexOf(type) < 0) {
|
||||
ti.classes = [type, ...ti.classes];
|
||||
}
|
||||
onload(ti);
|
||||
});
|
||||
}
|
||||
|
||||
_filterExtensions(extensions, key, currentDataType, onload) {
|
||||
if (currentDataType && typeof(currentDataType) === 'object'
|
||||
&& '_class' in currentDataType) { // handle the common API incoming data
|
||||
currentDataType = currentDataType._class;
|
||||
}
|
||||
if (extensions.length === 0) {
|
||||
onload(extensions); // no extensions for the given key
|
||||
return;
|
||||
}
|
||||
if (currentDataType) {
|
||||
var currentTypeInfo = this.typeInfo[currentDataType];
|
||||
if (!currentTypeInfo) {
|
||||
this.getTypeInfo(currentDataType, () => {
|
||||
this._filterExtensions(extensions, key, currentDataType, onload);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// prevent returning extensions for the given type
|
||||
// when a more specific extension is found
|
||||
var matchingExtensions = [];
|
||||
eachType: for (var typeIndex = 0; typeIndex < currentTypeInfo.classes.length; typeIndex++) {
|
||||
// currentTypeInfo.classes is ordered by java hierarchy, including
|
||||
// and beginning with the current data type
|
||||
var type = currentTypeInfo.classes[typeIndex];
|
||||
for (var i = 0; i < extensions.length; i++) {
|
||||
var extension = extensions[i];
|
||||
if (type === extension.type) {
|
||||
matchingExtensions.push(extension);
|
||||
}
|
||||
}
|
||||
// if we have this specific type handled, don't
|
||||
// proceed to parent types
|
||||
if (matchingExtensions.length > 0) {
|
||||
break eachType;
|
||||
}
|
||||
}
|
||||
extensions = matchingExtensions;
|
||||
} else {
|
||||
// exclude typed extensions when types not requested
|
||||
extensions = extensions.filter(m => !('type' in m));
|
||||
}
|
||||
onload(extensions.map(m => m.instance));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all the extension data
|
||||
*/
|
||||
_loadExtensionData(oncomplete) {
|
||||
if (this.extensionPointList) {
|
||||
onconplete(this.extensionPointList);
|
||||
return;
|
||||
}
|
||||
this.extensionDataProvider(data => {
|
||||
// We clone the data because we add to it.
|
||||
this.extensionPointList = JSON.parse(JSON.stringify(data));
|
||||
for(var i1 = 0; i1 < this.extensionPointList.length; i1++) {
|
||||
var pluginMetadata = this.extensionPointList[i1];
|
||||
var extensions = pluginMetadata.extensions || [];
|
||||
|
||||
for(var i2 = 0; i2 < extensions.length; i2++) {
|
||||
var extensionMetadata = extensions[i2];
|
||||
extensionMetadata.pluginId = pluginMetadata.hpiPluginId;
|
||||
var extensionPointMetadatas = this.extensionPoints[extensionMetadata.extensionPoint] = this.extensionPoints[extensionMetadata.extensionPoint] || [];
|
||||
extensionPointMetadatas.push(extensionMetadata);
|
||||
}
|
||||
}
|
||||
var ResourceLoadTracker = require('./ResourceLoadTracker').instance;
|
||||
ResourceLoadTracker.setExtensionPointMetadata(this.extensionPointList);
|
||||
if (oncomplete) oncomplete(this.extensionPointList);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the bundles for the given type
|
||||
*/
|
||||
_loadBundles(extensionPointId, onload) {
|
||||
// Make sure this has been initialized first
|
||||
if (!this.extensionPointList) {
|
||||
this._loadExtensionData(() => {
|
||||
this._loadBundles(extensionPointId, onload);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var extensionPointMetadatas = this.extensionPoints[extensionPointId];
|
||||
if (extensionPointMetadatas && extensionPointMetadatas.loaded) {
|
||||
onload(extensionPointMetadatas);
|
||||
return;
|
||||
}
|
||||
|
||||
extensionPointMetadatas = this.extensionPoints[extensionPointId] = this.extensionPoints[extensionPointId] || [];
|
||||
extensionPointMetadatas.loaded = true;
|
||||
|
||||
var jsModules = require('@jenkins-cd/js-modules');
|
||||
var loadCountMonitor = new LoadCountMonitor();
|
||||
|
||||
var loadPluginBundle = (pluginMetadata) => {
|
||||
loadCountMonitor.inc();
|
||||
|
||||
// The plugin bundle for this plugin may already be in the process of loading (async extension
|
||||
// point rendering). If it's not, pluginMetadata.loadCountMonitors will not be undefined,
|
||||
// which means we can go ahead with the async loading. If it is, pluginMetadata.loadCountMonitors
|
||||
// is defined, we just add "this" loadCountMonitor to pluginMetadata.loadCountMonitors.
|
||||
// It will get called as soon as the script loading is complete.
|
||||
if (!pluginMetadata.loadCountMonitors) {
|
||||
pluginMetadata.loadCountMonitors = [];
|
||||
pluginMetadata.loadCountMonitors.push(loadCountMonitor);
|
||||
jsModules.import(pluginMetadata.hpiPluginId + ':jenkins-js-extension')
|
||||
.onFulfilled(() => {
|
||||
pluginMetadata.bundleLoaded = true;
|
||||
for (var i = 0; i < pluginMetadata.loadCountMonitors.length; i++) {
|
||||
pluginMetadata.loadCountMonitors[i].dec();
|
||||
}
|
||||
delete pluginMetadata.loadCountMonitors;
|
||||
});
|
||||
} else {
|
||||
pluginMetadata.loadCountMonitors.push(loadCountMonitor);
|
||||
}
|
||||
};
|
||||
|
||||
var checkLoading = () => {
|
||||
if (loadCountMonitor.counter === 0) {
|
||||
onload(extensionPointMetadatas);
|
||||
}
|
||||
};
|
||||
|
||||
// Iterate over each plugin in extensionPointMetadata, async loading
|
||||
// the extension point .js bundle (if not already loaded) for each of the
|
||||
// plugins that implement the specified extensionPointId.
|
||||
for(var i1 = 0; i1 < this.extensionPointList.length; i1++) {
|
||||
|
||||
var pluginMetadata = this.extensionPointList[i1];
|
||||
var extensions = pluginMetadata.extensions || [];
|
||||
|
||||
for(var i2 = 0; i2 < extensions.length; i2++) {
|
||||
var extensionMetadata = extensions[i2];
|
||||
if (extensionMetadata.extensionPoint === extensionPointId) {
|
||||
// This plugin implements the ExtensionPoint.
|
||||
// If we haven't already loaded the extension point
|
||||
// bundle for this plugin, lets load it now.
|
||||
if (!pluginMetadata.bundleLoaded) {
|
||||
loadPluginBundle(pluginMetadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Listen to the inc/dec calls now that we've iterated
|
||||
// over all of the plugins.
|
||||
loadCountMonitor.onchange( () => {
|
||||
checkLoading();
|
||||
});
|
||||
|
||||
// Call checkLoading immediately in case all plugin
|
||||
// bundles have been loaded already.
|
||||
checkLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintains load counts for components
|
||||
*/
|
||||
class LoadCountMonitor {
|
||||
constructor() {
|
||||
this.counter = 0;
|
||||
this.callback = undefined;
|
||||
}
|
||||
|
||||
inc() {
|
||||
this.counter++;
|
||||
if (this.callback) {
|
||||
this.callback();
|
||||
}
|
||||
}
|
||||
|
||||
dec() {
|
||||
this.counter--;
|
||||
if (this.callback) {
|
||||
this.callback();
|
||||
}
|
||||
}
|
||||
|
||||
onchange(callback) {
|
||||
this.callback = callback;
|
||||
}
|
||||
}
|
||||
|
||||
// should figure out DI with singletons so we can move
|
||||
// required providers to other injection points, ideally
|
||||
export const instance = new ExtensionStore();
|
|
@ -0,0 +1,144 @@
|
|||
import jsModules from '@jenkins-cd/js-modules';
|
||||
|
||||
/**
|
||||
* CSS load tracker.
|
||||
* <p/>
|
||||
* Keeps track of page CSS, adding and removing CSS as ExtensionPoint components are
|
||||
* mounted and unmounted.
|
||||
*/
|
||||
export class ResourceLoadTracker {
|
||||
constructor() {
|
||||
// The CSS resources to be added for each Extension point.
|
||||
// Key: Extension point name.
|
||||
// Value: An array of CSS adjunct URLs that need to be activated when the extension point is rendered.
|
||||
this.pointCSSs = {};
|
||||
|
||||
// Active CSS.
|
||||
// Key: CSS URL.
|
||||
// Value: Counter of the number of mounted Extension Points that need the CSS to be active.
|
||||
// The onMount and onUnmount functions increment and decrement the counter. When the
|
||||
// counter gets back to zero, the CSS can be removed from the page.
|
||||
this.activeCSSs = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the loader with the extension point information.
|
||||
* @param extensionPointList The Extension point list. An array containing ExtensionPoint
|
||||
* metadata for all plugins that define such. It's an aggregation of
|
||||
* of the /jenkins-js-extension.json files found on the server classpath.
|
||||
*/
|
||||
setExtensionPointMetadata(extensionPointList) {
|
||||
// Reset - for testing.
|
||||
this.pointCSSs = {};
|
||||
this.activeCSSs = {};
|
||||
|
||||
// Iterate through each plugin /jenkins-js-extension.json
|
||||
for(var i1 = 0; i1 < extensionPointList.length; i1++) {
|
||||
var pluginMetadata = extensionPointList[i1];
|
||||
var extensions = pluginMetadata.extensions; // All the extensions defined on the plugin
|
||||
var pluginCSS = pluginMetadata.extensionCSS; // The plugin CSS URL (adjunct URL).
|
||||
|
||||
// Iterate through the ExtensionPoints defined in each plugin
|
||||
for (var i2 = 0; i2 < extensions.length; i2++) {
|
||||
var extensionPoint = extensions[i2].extensionPoint; // The extension point name.
|
||||
var pointCSS = this.pointCSSs[extensionPoint]; // The current list of CSS URLs for the named extension point.
|
||||
|
||||
if (!pointCSS) {
|
||||
pointCSS = [];
|
||||
this.pointCSSs[extensionPoint] = pointCSS;
|
||||
}
|
||||
|
||||
// Add the plugin CSS if it's not already in the list.
|
||||
if (pointCSS.indexOf(pluginCSS) === -1) {
|
||||
pointCSS.push(pluginCSS);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a Jenkins ExtensionPoint is mounted.
|
||||
* <p/>
|
||||
* If the extension point implementations use CSS (comes from plugins that define CSS)
|
||||
* then this method will use requireCSS, and then addCSS, for each CSS. addCSS only
|
||||
* gets called for a CSS by the first extension point to "require" that CSS.
|
||||
*
|
||||
* @param extensionPointName The extension point name.
|
||||
*/
|
||||
onMount(extensionPointName) {
|
||||
const pointCSS = this.pointCSSs[extensionPointName];
|
||||
if (pointCSS) {
|
||||
for (var i = 0; i < pointCSS.length; i++) {
|
||||
this._requireCSS(pointCSS[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a Jenkins ExtensionPoint is unmounted.
|
||||
* <p/>
|
||||
* If the extension point implementations use CSS (comes from plugins that define CSS)
|
||||
* then this method will use unrequireCSS, and then removeCSS, for each CSS. removeCSS only
|
||||
* gets called for a CSS by the last extension point to "unrequire" that CSS.
|
||||
*
|
||||
* @param extensionPointName The extension point name.
|
||||
*/
|
||||
onUnmount(extensionPointName) {
|
||||
const pointCSS = this.pointCSSs[extensionPointName];
|
||||
if (pointCSS) {
|
||||
for (var i = 0; i < pointCSS.length; i++) {
|
||||
this._unrequireCSS(pointCSS[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_requireCSS(url) {
|
||||
var activeCount = this.activeCSSs[url];
|
||||
|
||||
if (!activeCount) {
|
||||
activeCount = 0;
|
||||
this._addCSS(url);
|
||||
}
|
||||
activeCount++;
|
||||
this.activeCSSs[url] = activeCount;
|
||||
}
|
||||
|
||||
_unrequireCSS(url) {
|
||||
var activeCount = this.activeCSSs[url];
|
||||
|
||||
if (!activeCount) {
|
||||
// Huh?
|
||||
console.warn('Unexpected call to deactivate an inactive Jenkins Extension Point CSS: ' + url);
|
||||
// Does this mean that react calls unmount multiple times for a given component instance?
|
||||
// That would sound like a bug, no?
|
||||
} else {
|
||||
activeCount--;
|
||||
if (activeCount === 0) {
|
||||
// All extension points using this CSS have been unmounted.
|
||||
delete this.activeCSSs[url];
|
||||
this._removeCSS(url);
|
||||
} else {
|
||||
this.activeCSSs[url] = activeCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_addCSS(url) {
|
||||
const cssURLPrefix = jsModules.getAdjunctURL();
|
||||
jsModules.addCSSToPage(cssURLPrefix + '/' + url);
|
||||
}
|
||||
|
||||
_removeCSS(url) {
|
||||
const cssURLPrefix = jsModules.getAdjunctURL();
|
||||
const cssURL = cssURLPrefix + '/' + url;
|
||||
const linkElId = jsModules.toCSSId(cssURL);
|
||||
const linkEl = document.getElementById(linkElId);
|
||||
|
||||
if (linkEl) {
|
||||
linkEl.parentNode.removeChild(linkEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// in lieu of DI
|
||||
export const instance = new ResourceLoadTracker();
|
|
@ -1,138 +0,0 @@
|
|||
/**
|
||||
* CSS load tracker.
|
||||
* <p/>
|
||||
* Keeps track of page CSS, adding and removing CSS as ExtensionPoint components are
|
||||
* mounted and unmounted.
|
||||
*/
|
||||
|
||||
// The CSS resources to be added for each Extension point.
|
||||
// Key: Extension point name.
|
||||
// Value: An array of CSS adjunct URLs that need to be activated when the extension point is rendered.
|
||||
var pointCSSs = {};
|
||||
|
||||
// Active CSS.
|
||||
// Key: CSS URL.
|
||||
// Value: Counter of the number of mounted Extension Points that need the CSS to be active.
|
||||
// The onMount and onUnmount functions increment and decrement the counter. When the
|
||||
// counter gets back to zero, the CSS can be removed from the page.
|
||||
var activeCSSs = {};
|
||||
|
||||
const jsModules = require('@jenkins-cd/js-modules');
|
||||
|
||||
/**
|
||||
* Initialize the loader with the extension point information.
|
||||
* @param extensionPointList The Extension point list. An array containing ExtensionPoint
|
||||
* metadata for all plugins that define such. It's an aggregation of
|
||||
* of the /jenkins-js-extension.json files found on the server classpath.
|
||||
*/
|
||||
exports.setExtensionPointMetadata = function(extensionPointList) {
|
||||
// Reset - for testing.
|
||||
pointCSSs = {};
|
||||
activeCSSs = {};
|
||||
|
||||
// Iterate through each plugin /jenkins-js-extension.json
|
||||
for(var i1 = 0; i1 < extensionPointList.length; i1++) {
|
||||
var pluginMetadata = extensionPointList[i1];
|
||||
var extensions = pluginMetadata.extensions; // All the extensions defined on the plugin
|
||||
var pluginCSS = pluginMetadata.extensionCSS; // The plugin CSS URL (adjunct URL).
|
||||
|
||||
// Iterate through the ExtensionPoints defined in each plugin
|
||||
for (var i2 = 0; i2 < extensions.length; i2++) {
|
||||
var extensionPoint = extensions[i2].extensionPoint; // The extension point name.
|
||||
var pointCSS = pointCSSs[extensionPoint]; // The current list of CSS URLs for the named extension point.
|
||||
|
||||
if (!pointCSS) {
|
||||
pointCSS = [];
|
||||
pointCSSs[extensionPoint] = pointCSS;
|
||||
}
|
||||
|
||||
// Add the plugin CSS if it's not already in the list.
|
||||
if (pointCSS.indexOf(pluginCSS) === -1) {
|
||||
pointCSS.push(pluginCSS);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Called when a Jenkins ExtensionPoint is mounted.
|
||||
* <p/>
|
||||
* If the extension point implementations use CSS (comes from plugins that define CSS)
|
||||
* then this method will use requireCSS, and then addCSS, for each CSS. addCSS only
|
||||
* gets called for a CSS by the first extension point to "require" that CSS.
|
||||
*
|
||||
* @param extensionPointName The extension point name.
|
||||
*/
|
||||
exports.onMount = function(extensionPointName) {
|
||||
const pointCSS = pointCSSs[extensionPointName];
|
||||
if (pointCSS) {
|
||||
for (var i = 0; i < pointCSS.length; i++) {
|
||||
requireCSS(pointCSS[i]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Called when a Jenkins ExtensionPoint is unmounted.
|
||||
* <p/>
|
||||
* If the extension point implementations use CSS (comes from plugins that define CSS)
|
||||
* then this method will use unrequireCSS, and then removeCSS, for each CSS. removeCSS only
|
||||
* gets called for a CSS by the last extension point to "unrequire" that CSS.
|
||||
*
|
||||
* @param extensionPointName The extension point name.
|
||||
*/
|
||||
exports.onUnmount = function(extensionPointName) {
|
||||
const pointCSS = pointCSSs[extensionPointName];
|
||||
if (pointCSS) {
|
||||
for (var i = 0; i < pointCSS.length; i++) {
|
||||
unrequireCSS(pointCSS[i]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function requireCSS(url) {
|
||||
var activeCount = activeCSSs[url];
|
||||
|
||||
if (!activeCount) {
|
||||
activeCount = 0;
|
||||
addCSS(url);
|
||||
}
|
||||
activeCount++;
|
||||
activeCSSs[url] = activeCount;
|
||||
}
|
||||
|
||||
function unrequireCSS(url) {
|
||||
var activeCount = activeCSSs[url];
|
||||
|
||||
if (!activeCount) {
|
||||
// Huh?
|
||||
console.warn('Unexpected call to deactivate an inactive Jenkins Extension Point CSS: ' + url);
|
||||
// Does this mean that react calls unmount multiple times for a given component instance?
|
||||
// That would sound like a bug, no?
|
||||
} else {
|
||||
activeCount--;
|
||||
if (activeCount === 0) {
|
||||
// All extension points using this CSS have been unmounted.
|
||||
delete activeCSSs[url];
|
||||
removeCSS(url);
|
||||
} else {
|
||||
activeCSSs[url] = activeCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addCSS(url) {
|
||||
const cssURLPrefix = jsModules.getAdjunctURL();
|
||||
jsModules.addCSSToPage(cssURLPrefix + '/' + url);
|
||||
}
|
||||
|
||||
function removeCSS(url) {
|
||||
const cssURLPrefix = jsModules.getAdjunctURL();
|
||||
const cssURL = cssURLPrefix + '/' + url;
|
||||
const linkElId = jsModules.toCSSId(cssURL);
|
||||
const linkEl = document.getElementById(linkElId);
|
||||
|
||||
if (linkEl) {
|
||||
linkEl.parentNode.removeChild(linkEl);
|
||||
}
|
||||
}
|
|
@ -1,127 +0,0 @@
|
|||
/**
|
||||
* The registered ExtensionPoint instances.
|
||||
*/
|
||||
var points = {};
|
||||
/**
|
||||
* The ExtensionPoint metadata.
|
||||
*/
|
||||
var extensionPointList = [];
|
||||
|
||||
exports.setExtensionPointMetadata = function(data) {
|
||||
// This data should come from <jenkins>/blue/javaScriptExtensionInfo
|
||||
if (data) {
|
||||
// We clone the data because we add to it.
|
||||
extensionPointList = JSON.parse(JSON.stringify(data));
|
||||
var cssloadtracker = require('./cssloadtracker');
|
||||
cssloadtracker.setExtensionPointMetadata(extensionPointList);
|
||||
}
|
||||
};
|
||||
|
||||
exports.addExtensionPoint = function(key) {
|
||||
points[key] = points[key] || [];
|
||||
};
|
||||
|
||||
exports.addExtension = function (key, extension) {
|
||||
exports.addExtensionPoint(key);
|
||||
points[key].push(extension);
|
||||
};
|
||||
|
||||
exports.loadExtensions = function(key, onload) {
|
||||
loadBundles(key, function() {
|
||||
onload(exports.getExtensions(key));
|
||||
});
|
||||
};
|
||||
|
||||
exports.getExtensions = function(key) {
|
||||
return points[key] || [];
|
||||
};
|
||||
|
||||
exports.getExtensionList = function() {
|
||||
return extensionPointList;
|
||||
};
|
||||
|
||||
function LoadCountMonitor() {
|
||||
this.counter = 0;
|
||||
this.callback = undefined;
|
||||
}
|
||||
LoadCountMonitor.prototype.inc = function() {
|
||||
this.counter++;
|
||||
if (this.callback) {
|
||||
this.callback();
|
||||
}
|
||||
};
|
||||
LoadCountMonitor.prototype.dec = function() {
|
||||
this.counter--;
|
||||
if (this.callback) {
|
||||
this.callback();
|
||||
}
|
||||
};
|
||||
LoadCountMonitor.prototype.onchange = function(callback) {
|
||||
this.callback = callback;
|
||||
};
|
||||
|
||||
function loadBundles(extensionPointId, onBundlesLoaded) {
|
||||
|
||||
var jsModules = require('@jenkins-cd/js-modules');
|
||||
var loadCountMonitor = new LoadCountMonitor();
|
||||
|
||||
function loadPluginBundle(pluginMetadata) {
|
||||
loadCountMonitor.inc();
|
||||
|
||||
// The plugin bundle for this plugin may already be in the process of loading (async extension
|
||||
// point rendering). If it's not, pluginMetadata.loadCountMonitors will not be undefined,
|
||||
// which means we can go ahead with the async loading. If it is, pluginMetadata.loadCountMonitors
|
||||
// is defined, we just add "this" loadCountMonitor to pluginMetadata.loadCountMonitors.
|
||||
// It will get called as soon as the script loading is complete.
|
||||
if (!pluginMetadata.loadCountMonitors) {
|
||||
pluginMetadata.loadCountMonitors = [];
|
||||
pluginMetadata.loadCountMonitors.push(loadCountMonitor);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function checkLoading() {
|
||||
if (loadCountMonitor.counter === 0) {
|
||||
onBundlesLoaded();
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate over each plugin in extensionPointMetadata, async loading
|
||||
// the extension point .js bundle (if not already loaded) for each of the
|
||||
// plugins that implement the specified extensionPointId.
|
||||
for(var i1 = 0; i1 < extensionPointList.length; i1++) {
|
||||
|
||||
var pluginMetadata = extensionPointList[i1];
|
||||
var extensions = pluginMetadata.extensions;
|
||||
|
||||
for(var i2 = 0; i2 < extensions.length; i2++) {
|
||||
if (extensions[i2].extensionPoint === extensionPointId) {
|
||||
// This plugin implements the ExtensionPoint.
|
||||
// If we haven't already loaded the extension point
|
||||
// bundle for this plugin, lets load it now.
|
||||
if (!pluginMetadata.bundleLoaded) {
|
||||
loadPluginBundle(pluginMetadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Listen to the inc/dec calls now that we've iterated
|
||||
// over all of the plugins.
|
||||
loadCountMonitor.onchange(function() {
|
||||
checkLoading();
|
||||
});
|
||||
|
||||
// Call checkLoading immediately in case all plugin
|
||||
// bundles have been loaded already.
|
||||
checkLoading();
|
||||
}
|
Loading…
Reference in New Issue