providence-analytics
Version:
Providence is the 'All Seeing Eye' that measures effectivity and popularity of software. Release management will become highly efficient due to an accurate impact analysis of (breaking) changes
456 lines (413 loc) • 14.3 kB
JavaScript
/* eslint-disable lit-a11y/no-invalid-change-handler */
/* eslint-disable max-classes-per-file */
// eslint-disable-next-line import/no-extraneous-dependencies
import { LitElement, html, css } from 'lit-element';
import { tooltip as tooltipStyles } from './styles/tooltip.css.js';
import { global as globalStyles } from './styles/global.css.js';
import { utils as utilsStyles } from './styles/utils.css.js';
import { tableDecoration } from './styles/tableDecoration.css.js';
import { GlobalDecorator } from './utils/GlobalDecorator.js';
import { DecorateMixin } from './utils/DecorateMixin.js';
import { downloadFile } from './utils/downloadFile.js';
import { PTable } from './components/p-table/PTable.js';
// Decorate third party component styles
GlobalDecorator.decorateStyles(globalStyles, { prepend: true });
PTable.decorateStyles(tableDecoration);
customElements.define('p-table', PTable);
/**
*
* @param {{ project:string, filePath:string, name:string }} specifierRes
* @param {{ categoryConfig:object }} metaConfig
* @returns {string[]}
*/
function getCategoriesForMatchedSpecifier(specifierRes, { metaConfig }) {
const resultCats = [];
if (metaConfig && metaConfig.categoryConfig) {
const { project, filePath, name } = specifierRes.exportSpecifier;
// First of all, do we have a matching project?
// TODO: we should allow different configs for different (major) versions
const match = metaConfig.categoryConfig.find(cat => cat.project === project);
if (match) {
Object.entries(match.categories).forEach(([categoryName, matchFn]) => {
if (matchFn(filePath, name)) {
resultCats.push(categoryName);
}
});
}
}
return resultCats;
}
function checkedValues(checkboxOrNodeList) {
if (!checkboxOrNodeList.length) {
return checkboxOrNodeList.checked && checkboxOrNodeList.value;
}
return Array.from(checkboxOrNodeList)
.filter(r => r.checked)
.map(r => r.value);
}
class PBoard extends DecorateMixin(LitElement) {
static get properties() {
return {
// Transformed data from fetch
tableData: Object,
__resultFiles: Array,
__menuData: Object,
};
}
static get styles() {
return [
...super.styles,
utilsStyles,
tooltipStyles,
css`
p-table {
border: 1px solid gray;
display: block;
margin: 2px;
}
.heading {
font-size: 1.5em;
letter-spacing: 0.1em;
}
.heading__part {
color: var(--primary-color);
}
.menu-group {
display: flex;
flex-wrap: wrap;
flex-direction: column;
}
`,
];
}
/**
* @param {object} referenceCollections references defined in providence.conf.js Includes reference projects
* @param {object} searchTargetCollections programs defined in providence.conf.js. Includes search-target projects
* @param {object[]} projDeps deps retrieved by running providence, read from search-target-deps-file.json
*/
_selectionMenuTemplate(result) {
if (!result) {
return html``;
}
const { referenceCollections, searchTargetDeps } = result;
return html`
<test-table></test-table>
<form class="u-c-mv2" id="selection-menu-form" action="" @change="${this._aggregateResults}">
<fieldset>
<legend>References (grouped by collection)</legend>
${Object.keys(referenceCollections).map(
colName => html`
<div role="separator">${colName}</div>
${referenceCollections[colName].map(
refName => html`
<label
><input
type="checkbox"
name="references"
.checked=${colName === 'lion-based-ui'}
.value="${refName}"
/>${refName}</label
>
`,
)}
`,
)}
</fieldset>
<fieldset>
<legend>Repositories (grouped by search target)</legend>
${Object.keys(searchTargetDeps).map(
rootProjName => html`
<details>
<summary>
<span class="u-bold">${rootProjName}</span>
<input
aria-label="check all"
type="checkbox"
checked
@change="${({ target }) => {
// TODO: of course, logic depending on dom is never a good idea
const groupBoxes =
target.parentElement.nextElementSibling.querySelectorAll(
'input[type=checkbox]',
);
const { checked } = target;
Array.from(groupBoxes).forEach(box => {
// eslint-disable-next-line no-param-reassign
box.checked = checked;
});
}}"
/>
</summary>
<div class="menu-group">
${searchTargetDeps[rootProjName].map(
dep => html`
<label
><input
type="checkbox"
name="repos"
.checked="${dep}"
.value="${dep}"
/>${dep}</label
>
`,
)}
</div>
<hr />
</details>
`,
)}
</fieldset>
</form>
`;
}
_activeAnalyzerSelectTemplate() {
return html`
<select id="active-analyzer" @change="${this._onActiveAnalyzerChanged}">
${Object.keys(this.__resultFiles).map(
analyzerName => html` <option value="${analyzerName}">${analyzerName}</option> `,
)}
</select>
`;
}
_onActiveAnalyzerChanged() {
this._aggregateResults();
}
get _selectionMenuFormNode() {
return this.shadowRoot.getElementById('selection-menu-form');
}
get _activeAnalyzerNode() {
return this.shadowRoot.getElementById('active-analyzer');
}
get _tableNode() {
return this.shadowRoot.querySelector('p-table');
}
_createCsv(headers = this._tableNode._viewDataHeaders, data = this._tableNode._viewData) {
let result = 'sep=;\n';
result += `${headers.join(';')}\n`;
data.forEach(row => {
result += `${Object.values(row)
.map(v => {
if (Array.isArray(v)) {
const res = [];
v.forEach(vv => {
// TODO: make recursive
if (typeof vv === 'string') {
res.push(vv);
} else {
// typeof v === 'object'
res.push(JSON.stringify(vv));
}
});
return res.join(', ');
}
if (typeof v === 'object') {
// This has knowledge about specifier.
// TODO make more generic and add toString() to this obj in generation pahse
return v.name;
}
return v;
})
.join(';')}\n`;
});
return result;
}
render() {
return html`
<div style="display:flex; align-items: baseline;">
<h1 class="heading">providence <span class="heading__part">dashboard</span> (alpha)</h1>
<div class="u-ml2">
${this._activeAnalyzerSelectTemplate()}
<button @click="${() => downloadFile('data.csv', this._createCsv())}">get csv</button>
</div>
</div>
${this._selectionMenuTemplate(this.__menuData)}
<p-table .data="${this.tableData}" class="u-mt3"></p-table>
`;
}
constructor() {
super();
this.__resultFiles = [];
this.__menuData = null;
}
firstUpdated(...args) {
super.firstUpdated(...args);
this._tableNode.renderCellContent = this._renderCellContent.bind(this);
this.__init();
}
async __init() {
await this.__fetchMenuData();
await this.__fetchResults();
await this.__fetchProvidenceConf();
this._enrichMenuData();
}
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has('__menuData')) {
this._aggregateResults();
}
}
/**
* Gets all selection menu data and creates an aggregated
* '_viewData' result.
*/
async _aggregateResults() {
if (!this.__menuData) {
return;
}
// await this.__fetchResults();
const elements = Array.from(this._selectionMenuFormNode.elements);
const repos = elements.filter(n => n.name === 'repos');
const references = elements.filter(n => n.name === 'references');
const activeRefs = [...new Set(checkedValues(references))];
const activeRepos = [...new Set(checkedValues(repos))];
const activeAnalyzer = this._activeAnalyzerNode.value;
const totalQueryOutput = this.__aggregateResultData(activeRefs, activeRepos, activeAnalyzer);
// Prepare viewData
const dataResult = [];
// When we support more analyzers than match-imports and match-subclasses, make a switch
// here
totalQueryOutput.forEach((specifierRes, i) => {
dataResult[i] = {};
dataResult[i].specifier = specifierRes.exportSpecifier;
dataResult[i].sourceProject = specifierRes.exportSpecifier.project;
dataResult[i].categories = getCategoriesForMatchedSpecifier(
specifierRes,
this.__providenceConf,
);
dataResult[i].type = specifierRes.exportSpecifier.name === '[file]' ? 'file' : 'specifier';
// dedupe, because outputs genarted with older versions might have dedupe problems
dataResult[i].count = Array.from(new Set(specifierRes.matchesPerProject))
.map(mpp => mpp.files)
.flat(Infinity).length;
dataResult[i].matchedProjects = specifierRes.matchesPerProject;
});
this.tableData = dataResult;
}
__aggregateResultData(activeRefs, activeRepos, activeAnalyzer) {
const jsonResultsActiveFilter = [];
activeRefs.forEach(ref => {
const refSearch = `_${ref.replace('#', '_')}_`;
activeRepos.forEach(dep => {
const depSearch = `_${dep.replace('#', '_')}_`;
const found = this.__resultFiles[activeAnalyzer].find(
({ fileName }) =>
fileName.includes(encodeURIComponent(refSearch)) &&
fileName.includes(encodeURIComponent(depSearch)),
);
if (found) {
jsonResultsActiveFilter.push(found.content);
} else {
// eslint-disable-next-line no-console
console.info(`No result output json for ${refSearch} and ${depSearch}`);
}
});
});
let totalQueryOutput = [];
jsonResultsActiveFilter.forEach(json => {
if (!Array.isArray(json.queryOutput)) {
// can be a string like [no-mactched-dependency]
return;
}
// Start by adding the first entry of totalQueryOutput
if (!totalQueryOutput) {
totalQueryOutput = json.queryOutput;
return;
}
json.queryOutput.forEach(currentRec => {
// Json queryOutput
// Now, look if we already have an "exportSpecifier".
const totalRecFound = totalQueryOutput.find(
totalRec => currentRec.exportSpecifier.id === totalRec.exportSpecifier.id,
);
// If so, concatenate the "matchesPerProject" array to the existing one
if (totalRecFound) {
// TODO: merge smth?
totalRecFound.matchesPerProject = totalRecFound.matchesPerProject.concat(
currentRec.matchesPerProject,
);
}
// If not, just add a new one to the array.
else {
totalQueryOutput.push(currentRec);
}
});
});
return totalQueryOutput;
}
_enrichMenuData() {
const menuData = this.__initialMenuData;
// Object.keys(menuData.searchTargetDeps).forEach((groupName) => {
// menuData.searchTargetDeps[groupName] = menuData.searchTargetDeps[groupName].map(project => (
// { project, checked: true } // check whether we have results, also for active references
// ));
// });
this.__menuData = menuData;
}
/**
* @override
* @param {*} content
*/
// eslint-disable-next-line class-methods-use-this
_renderSpecifier(content) {
let display;
if (content.name === '[file]') {
display = content.filePath;
} else {
display = content.name;
}
const tooltip = content.filePath;
return html`
<div>
<span class="c-tooltip c-tooltip--right" data-tooltip="${tooltip}"> ${display} </span>
</div>
`;
}
/**
* @override
* @param {*} content
* @param {*} header
*/
// eslint-disable-next-line class-methods-use-this
_renderCellContent(content, header) {
if (header === 'specifier') {
return this._renderSpecifier(content);
}
if (header === 'matchedProjects') {
return html`${content
.sort((a, b) => b.files.length - a.files.length)
.map(
mpp => html`
<details>
<summary>
<span style="font-weight:bold;">${mpp.project}</span>
(${mpp.files.length})
</summary>
<ul>
${mpp.files.map(
f => html`<li>${typeof f === 'object' ? JSON.stringify(f) : f}</li>`,
)}
</ul>
</details>
`,
)}`;
}
if (content instanceof Array) {
return content.join(', ');
}
return content;
}
async __fetchMenuData() {
// Derived from providence.conf.js, generated in server.mjs
this.__initialMenuData = await fetch('/menu-data.json').then(response => response.json());
}
async __fetchProvidenceConf() {
// Gets the providence conf as defined by the end user in providence-conf.(m)js
// @ts-ignore
// eslint-disable-next-line import/no-absolute-path
this.__providenceConf = (await import('/providence-conf.js')).default;
}
async __fetchResults() {
this.__resultFiles = await fetch('/results.json').then(response => response.json());
}
}
customElements.define('p-board', PBoard);