Skip to content

Commit

Permalink
Merge branch 'develop' into TASK-7099
Browse files Browse the repository at this point in the history
  • Loading branch information
jmjuanes committed Oct 28, 2024
2 parents a5237cc + 79e433c commit 9968380
Show file tree
Hide file tree
Showing 53 changed files with 1,220 additions and 1,010 deletions.
13 changes: 12 additions & 1 deletion cypress/e2e/iva/genome-browser.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ context("GenomeBrowser", () => {

cy.get("@karyotype")
.find(`div[data-cy="gb-karyotype-toggle"]`)
.trigger("click");
.trigger("click", {force: true});

cy.get("@karyotypeContent")
.invoke("css", "display")
Expand Down Expand Up @@ -763,6 +763,17 @@ context("GenomeBrowser", () => {
.find("polyline")
.should("exist");
});

it("should display a tooltip when hovering the coverage", () => {
// eslint-disable-next-line cypress/no-force
cy.get("@coverage")
.find(`rect[data-cy="gb-coverage-tooltip-mask"]`)
.trigger("mouseenter", {force: true});

cy.get("@coverage")
.find(`text[data-cy="gb-coverage-tooltip-text"]`)
.should("not.have.css", "display", "none");
});
});

context("alignments", () => {
Expand Down
32 changes: 32 additions & 0 deletions cypress/e2e/iva/variant-browser-grid.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,38 @@ context("Variant Browser Grid", () => {
});
});

context("clinical info", () => {
context("cosmic column", () => {
const columnIndex = 18;
it("should display an 'x' icon if no cosmic information is available", () => {
cy.get("@variantBrowser")
.find(`tbody > tr[data-uniqueid="14:91649938:A:G"] > td`)
.eq(columnIndex)
.find("i")
.should("have.class", "fa-times");
});

it("should display the number of entries and total trait associations", () => {
cy.get("@variantBrowser")
.find("tbody tr:first > td")
.eq(columnIndex)
.should("contain.text", "1 entry (1)");
});

it("should display a tooltip with a link to cosmic", () => {
cy.get("@variantBrowser")
.find("tbody tr:first > td")
.eq(columnIndex)
.find("a")
.trigger("mouseover");

cy.get(`div[class="qtip-content"]`)
.find(`a[href^="https://cancer.sanger.ac.uk/cosmic/search?q="]`)
.should("exist");
});
});
});

context("actions", () => {
const variant = "14:91649858:C:T";
beforeEach(() => {
Expand Down
119 changes: 77 additions & 42 deletions src/core/clients/opencga/opencga-catalog-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,58 +53,93 @@ export default class OpencgaCatalogUtils {
}

// Check if the user has the right the permissions in the study.
static checkPermissions(study, user, permissions) {
if (!study || !user || !permissions) {
console.error(`No valid parameters, study: ${study}, user: ${user}, permissions: ${permissions}`);
static getStudyEffectivePermission(study, userId, permission, simplifyPermissions = false) {
// Caution 1 20240916 Vero:
// As discussed and agreed, this method is considering the VIEW, WRITE, DELETE permissions of all catalog entities in addition to the EXECUTE_JOBS.
// The rest of permissions described in the following link are not currently needed in IVA for now:
// https://github.com/opencb/opencga/blob/develop/docs/manual/data-management/sharing-and-permissions/permissions.md
// Caution w 20240916 Vero:
// As discussed and agreed, the optimization parameter simplifyPermissions is set as false by default according to the default value in OpenCGA.

// Get the resource from the provided permission, that has the structure '{OPERATION}_{RESOURCE}'. E.g:
// "WRITE_SAMPLES" --> "SAMPLES"
// "VIEW_CLINICAL_ANALYSIS" --> "CLINICAL_ANALYSIS"
const resource = permission.split("_").slice(1).join("_");

// VALIDATION
if (!study || !userId || !permission || !resource) {
console.error(`No valid parameters, study: ${study}, user: ${userId}, permission: ${permission}, catalogEntity: ${resource}`);
return false;
}
// Check if user is a Study admin, belongs to @admins group
const admins = study.groups.find(group => group.id === "@admins");
if (admins.userIds.includes(user)) {
return true;
const permissionLevel = {};
permissionLevel["NONE"] = 1;
if (permission !== "EXECUTE_JOBS") {
permissionLevel[`VIEW_${resource}`] = 2;
permissionLevel[`WRITE_${resource}`] = 3;
permissionLevel[`DELETE_${resource}`] = 4;
} else {
// Check if user is in acl
const aclUserIds = study.groups
.filter(group => group.userIds.includes(user))
.map(group => group.id);
aclUserIds.push(user);
for (const aclId of aclUserIds) {
// Find the permissions for this user
const userPermissions = study?.acl
?.find(acl => acl.member === user)?.groups
?.find(group => group.id === aclId)?.permissions || [];
if (Array.isArray(permissions)) {
for (const permission of permissions) {
if (userPermissions?.includes(permission)) {
return true;
}
}
permissionLevel[permission] = 2;
}

const getPermissionLevel = permissionList => {
const levels = permissionList
.map(p => permissionLevel[p])
.filter(p => typeof p === "number");
return levels.length > 0 ? Math.max(...levels) : 0;
};

const getEffectivePermission = (userPermission, groupPermissions) => {
// It is possible to simplify permissions.
if (!simplifyPermissions) {
// First, find permission level at user level
const userPermissionLevel = getPermissionLevel(userPermission);
if (userPermissionLevel) {
// If the permission level at user level is greater than 0, return this permission level because it has priority over groups.
return userPermissionLevel;
} else {
if (userPermissions?.includes(permissions)) {
return true;
}
// Check permission level at groups level. No hierarchy defined here. Example:
// If a user belongs to two groups:
// - groupA - Has permission VIEW_SAMPLES
// - groupB - Has permission WRITE_SAMPLES
// The dominant permission will be the highest, i.e. WRITE_SAMPLES
return Math.max(0, ...groupPermissions.map(g => getPermissionLevel(g)));
}
} else {
// If "simplifyPermissions = true" permissions become more flexible.
// As long as the user has the necessary permission at the user or group level it'll be able to perform the action.
// I.e., there's no hierarchy where user-level permissions override group-level ones
groupPermissions.push(userPermission);
return Math.max(0, ...groupPermissions.map(g => getPermissionLevel(g)));
}
}
return false;
}
};

// Check if the provided user is admin in the organization
static isOrganizationAdmin(organization, userId) {
if (!organization || !userId) {
return false;
// ALGORITHM
// 1. If userId is the installation admin grant permission
if (userId === "opencga") {
return true;
}
// 1. Check if user is the organization admin
if (organization?.owner === userId) {
// 2. If userId is a Study admin, belongs to @admins group. Grant permission
const admins = study.groups.find(group => group.id === "@admins");
if (admins.userIds.includes(userId)) {
return true;
} else {
// Check if user is an admin of the organization
if (organization?.admins?.includes?.(userId)) {
return true;
}
}
// Other case, user is not admin of the organization
return false;
// 3. Permissions for member
const userPermissionsStudy = study?.acl
?.find(acl => acl.member === userId)
?.permissions || [];

// 4. Permissions for groups where the member belongs to
const groupIds = study.groups
.filter(group => group.userIds.includes(userId))
.map(group => group.id);

const groupPermissions = groupIds.map(groupId => study?.acl
?.find(acl => acl.member === userId)?.groups
?.find(group => group.id === groupId)?.permissions || []);

// If the effective permission retrieved is greater or equal than the permission level requested, grant permission.
// If not, deny permission
return getEffectivePermission(userPermissionsStudy, groupPermissions) >= permissionLevel[permission];
}

// Check if the user has the right the permissions in the study.
Expand Down
2 changes: 1 addition & 1 deletion src/genome-browser/genome-browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export default class GenomeBrowser {
// Generate GB template
const template = UtilsNew.renderHTML(`
<div id="${this.prefix}" data-cy="gb-parent" class="card text-bg-light mb-3">
<div id="${this.prefix}Navigation" data-cy="gb-navigation" class="card-header"></div>
<div id="${this.prefix}Navigation" data-cy="gb-navigation" class="card-header bg-light sticky-top"></div>
<ul class="list-group rounded-0">
<li id="${this.prefix}Karyotype" data-cy="gb-karyotype" class="list-group-item" style="display:none;"></li>
<li id="${this.prefix}Chromosome" data-cy="gb-chromosome" class="list-group-item" style="display:none;"></li>
Expand Down
52 changes: 50 additions & 2 deletions src/genome-browser/renderers/alignment-renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ export default class AlignmentRenderer extends Renderer {
// Define the height of the coverage track
const regionSize = options.requestedRegion.end - options.requestedRegion.start + 1;
const parentHeight = options.svgCanvasFeatures.parentElement.clientHeight;
const coverageHeight = regionSize < this.config.alignmentsMaxRegionSize ? 50 : parentHeight;
// const coverageHeight = regionSize < this.config.alignmentsMaxRegionSize ? 50 : parentHeight;
const coverageHeight = 75;

const coverageParent = SVG.addChild(options.svgCanvasFeatures, "g", {
"data-cy": "gb-coverage",
Expand Down Expand Up @@ -201,7 +202,7 @@ export default class AlignmentRenderer extends Renderer {
points.push(`${startPoint},${height}`);

if (maximumValue > 0) {
const maxValueRatio = height / maximumValue;
const maxValueRatio = (height - 25) / maximumValue;
let prevCoverage = -1;
let prevPosition = -1;

Expand Down Expand Up @@ -235,6 +236,53 @@ export default class AlignmentRenderer extends Renderer {
width: pixelWidth,
height: height,
cursor: "pointer",
style: "opacity:0.6;",
});

const coverageValueRect = SVG.addChild(group, "path", {
"d": "M0 0L15 0C20 0 20 5 20 5L20 15C20 15 20 20 15 20L5 20L0 25L-5 20L-15 20C-20 20-20 15-20 15L-20 5C-20 5-20 0-15 0L0 0Z",
"fill": "#000",
"style": "display:none;transform:translateX(0px);",
"data-cy": "gb-coverage-tooltip-tip",
});
const coverageValueText = SVG.addChild(group, "text", {
"x": 0,
"y": 10,
"fill": "#fff",
"dominant-baseline": "middle",
"text-anchor": "middle",
"style": "font-size:10px;font-weight:bold;",
"data-cy": "gb-coverage-tooltip-text",
});
const coverageMask = SVG.addChild(group, "rect", {
"x": startPoint,
"y": 0,
"width": Math.abs(endPoint - startPoint),
"height": height,
"fill": "transparent",
"stroke": "none",
"data-cy": "gb-coverage-tooltip-mask",
});

const visibleStart = parseInt(GenomeBrowserUtils.getFeatureX(options.region.start, options) - (options.pixelBase / 2));
const initialCanvasStart = parseInt(options.svgCanvasFeatures.getAttribute("x"));

coverageMask.addEventListener("mousemove", e => {
const deltaCanvas = initialCanvasStart - parseInt(options.svgCanvasFeatures.getAttribute("x"));
const position = visibleStart + deltaCanvas + e.offsetX;
const index = Math.floor(((position - startPoint) / options.pixelBase) / coverage.windowSize);

coverageValueRect.style.transform = `translateX(${position}px)`;
coverageValueText.setAttribute("x", position + "px");
coverageValueText.textContent = Math.ceil(coverage.values[index] || 0);
});
coverageMask.addEventListener("mouseleave", () => {
coverageValueRect.style.display = "none";
coverageValueText.style.display = "none";
});
coverageMask.addEventListener("mouseenter", () => {
coverageValueRect.style.display = "";
coverageValueText.style.display = "";
});
}

Expand Down
33 changes: 28 additions & 5 deletions src/genome-browser/renderers/variant-renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,21 @@ export default class VariantRenderer extends Renderer {
const lollipopStickHeight = this.config.lollipopHeight - this.config.lollipopFocusWidth - this.config.lollipopMaxWidth / 2;
let lollipopStickStart = this.config.lollipopFocusWidth + this.config.lollipopMaxWidth / 2;
let lollipopPositions = [];
let lollipopIndex = 0;
let topPosition = this.config.lollipopVisible ? this.config.lollipopHeight : this.config.headerHeight;

// We have to filter features and consider only the ones that are in the current region
// this is important because when we request for breakends to OpenCGA it returns both variants of the breakend
const region = options.requestedRegion;
const featuresToDisplay = (features || []).filter(feature => {
return feature.chromosome === region.chromosome && feature.start <= region.end && region.start <= feature.end;
});

if (this.config.lollipopVisible) {
lollipopPositions = LollipopLayout.fromFeaturesList(features || [], options.requestedRegion, lollipopRegionWidth, {
const featuresForLollipops = featuresToDisplay.filter(feature => {
return this.config.lollipopVariantTypes.includes(feature.type);
});
lollipopPositions = LollipopLayout.fromFeaturesList(featuresForLollipops, options.requestedRegion, lollipopRegionWidth, {
minSeparation: this.config.lollipopMaxWidth,
});
}
Expand All @@ -60,7 +71,16 @@ export default class VariantRenderer extends Renderer {
lollipopStickStart = lollipopStickStart + this.config.highlightHeight;
}

(features || []).forEach((feature, featureIndex) => {
featuresToDisplay.forEach((feature, featureIndex) => {
// Check if this variant has been previously rendered
if (options?.renderedFeatures && feature?.id) {
if (options.renderedFeatures.has(feature.id)) {
return;
}
// Prevent rendering this variant in next calls of this renderer
options.renderedFeatures.add(feature.id);
}

const group = SVG.addChild(options.svgCanvasFeatures, "g", {
"data-cy": "gb-variant",
"data-id": feature.id || "-",
Expand All @@ -86,9 +106,9 @@ export default class VariantRenderer extends Renderer {

let variantElement = null;

// Check if lollipops are visible
if (this.config.lollipopVisible) {
const lollipopX = lollipopStartX + lollipopPositions[featureIndex];
// Check if lollipops are visible and the feature type is one of the allowed types for lollipops
if (this.config.lollipopVisible && this.config.lollipopVariantTypes?.includes?.(feature?.type)) {
const lollipopX = lollipopStartX + lollipopPositions[lollipopIndex];
const lollipopWidth = Math.min(1, Math.max(0, this.getValueFromConfig("lollipopWidth", [feature])));
const lollipopPath = [
`M ${lollipopX},${lollipopStickStart}`,
Expand Down Expand Up @@ -130,6 +150,8 @@ export default class VariantRenderer extends Renderer {
variantElement.setAttribute("stroke-width", 0);
});
}
// increment lollipop index
lollipopIndex = lollipopIndex + 1;
} else {
variantElement = SVG.addChild(group, "rect", {
"data-cy": "gb-variant-lollipop-shape",
Expand Down Expand Up @@ -296,6 +318,7 @@ export default class VariantRenderer extends Renderer {
lollipopMaxWidth: 15,
lollipopShape: GenomeBrowserUtils.lollipopShapeFormatter,
lollipopWidth: GenomeBrowserUtils.lollipopWidthFormatter,
lollipopVariantTypes: ["SNV", "INDEL", "BREAKEND"],
// Lollipop focus
lollipopFocusEnabled: true,
lollipopFocusWidth: 2,
Expand Down
9 changes: 5 additions & 4 deletions src/genome-browser/tracks/feature-track.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,17 +229,17 @@ export default class FeatureTrack {

hideContent() {
this.contentVisible = false;
this.content.classList.add("hidden");
this.resize.classList.add("hidden");
this.content.classList.add("d-none");
this.resize.classList.add("d-none");

this.titleToggleIcon.classList.remove("fa-minus");
this.titleToggleIcon.classList.add("fa-plus");
}

showContent() {
this.contentVisible = true;
this.content.classList.remove("hidden");
this.resize.classList.remove("hidden");
this.content.classList.remove("d-none");
this.resize.classList.remove("d-none");

this.titleToggleIcon.classList.remove("fa-plus");
this.titleToggleIcon.classList.add("fa-minus");
Expand Down Expand Up @@ -475,6 +475,7 @@ export default class FeatureTrack {
renderedArea: this.renderedArea,
renderedFeatures: this.renderedFeatures,
pixelBase: this.pixelBase,
region: this.region,
position: this.region.center(),
regionSize: this.region.length(),
labelMaxRegionSize: this.config.labelMaxRegionSize,
Expand Down
Loading

0 comments on commit 9968380

Please sign in to comment.