[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:
Keith Zantow 2016-06-28 21:28:16 -04:00 committed by GitHub
parent 8a8ea8e2aa
commit 0e57d411ab
40 changed files with 1465 additions and 688 deletions

View File

@ -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",

View File

@ -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}

View File

@ -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}

View File

@ -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);

View File

@ -89,4 +89,5 @@ export const State = Record({
branches: null,
steps: null,
currentBranches: null,
testResults: null,
});

View File

@ -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,
};

View File

@ -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

View File

@ -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,
});
},
};

View File

@ -14,6 +14,7 @@ export {
branches,
isMultiBranch,
currentBranches,
testResults,
} from './reducer';
export {
ACTION_TYPES,

View File

@ -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) {

View File

@ -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;
};

View File

@ -1,4 +1,5 @@
@import "variables";
@import "core";
@import "run-pipeline";
@import "testing";
@import "run-pipeline";

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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()
);
}

View File

@ -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",

View File

@ -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);
}
}
}

View File

@ -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()));
}
}
}
}

View File

@ -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();
};

View File

@ -0,0 +1,4 @@
# Extensions in this plugin
extensions:
- component: AboutNavLink
extensionPoint: jenkins.topNavigation.menu

View File

@ -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);
});

View File

@ -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);

View File

@ -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>

View File

@ -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"
}
]
}

View File

@ -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 += "});";

View File

@ -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.

View File

@ -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;
});
});

View File

@ -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;

View File

@ -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",

View File

@ -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();
});
});

View File

@ -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();
});

View File

@ -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"
}
]

View File

@ -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');
}
});

View File

@ -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();
});
});
}
});
});
});
});

View File

@ -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 &lt;Link&gt; 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 &lt;ExtensionPoint&gt; instance itself. As far as our react is concerned, these are
* react hierarchy as the &lt;ExtensionRenderer&gt; 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;

View File

@ -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();

View File

@ -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();

View File

@ -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);
}
}

View File

@ -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();
}