Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 107 additions & 8 deletions src/components/filteredEventsListPanel/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
Skeleton,
Typography,
Select,
Checkbox,
} from "antd";
import * as d3 from "d3";
import { roleColorMap, transitionStyle } from "../../helpers/utility";
Expand All @@ -29,7 +30,7 @@ import { buildColumnsFromSettings } from "./columnBuilders";

const { Text } = Typography;

const { selectFilteredEvent } = filteredEventsActions;
const { selectFilteredEvent, setSelectedEventUids, toggleEventUidSelection } = filteredEventsActions;

const EVENT_TYPES = ["all", "snv", "cna", "fusion", "complexsv"];

Expand Down Expand Up @@ -62,6 +63,70 @@ class FilteredEventsListPanel extends Component {
selectedColumnKeys: defaultKeys,
});
};

handleCheckboxChange = (record, checked) => {
const { toggleEventUidSelection } = this.props;
toggleEventUidSelection(record.uid, checked);
};

handleHeaderCheckboxChange = (records) => {
const { selectedEventUids, setSelectedEventUids } = this.props;

// Get all tier 1 and 2 records from current view
const tier1And2Records = records.filter(
(r) => r.tier && (+r.tier === 1 || +r.tier === 2)
);
const tier1And2Uids = tier1And2Records.map((r) => r.uid);

// Check current state
const selectedTier1And2 = tier1And2Uids.filter((uid) =>
selectedEventUids.includes(uid)
);
const allSelected = selectedTier1And2.length === tier1And2Uids.length && tier1And2Uids.length > 0;

if (allSelected) {
// Deselect all tier 1 and 2
const newUids = selectedEventUids.filter(
(uid) => !tier1And2Uids.includes(uid)
);
setSelectedEventUids(newUids);
} else {
// Select all tier 1 and 2
const newSelectedUids = [...new Set([...selectedEventUids, ...tier1And2Uids])];
setSelectedEventUids(newSelectedUids);
}
};

getHeaderCheckboxState = (records) => {
const { selectedEventUids } = this.props;

// Get all tier 1 and 2 records from current view
const tier1And2Records = records.filter(
(r) => r.tier && (+r.tier === 1 || +r.tier === 2)
);
const tier1And2Uids = tier1And2Records.map((r) => r.uid);

if (tier1And2Uids.length === 0) {
return { checked: false, indeterminate: false };
}

const selectedTier1And2 = tier1And2Uids.filter((uid) =>
selectedEventUids.includes(uid)
);

if (selectedTier1And2.length === 0) {
return { checked: false, indeterminate: false };
} else if (selectedTier1And2.length === tier1And2Uids.length) {
return { checked: true, indeterminate: false };
} else {
return { checked: false, indeterminate: true };
}
};

isEventSelected = (record) => {
const { selectedEventUids } = this.props;
return selectedEventUids.includes(record.uid);
};
state = {
eventType: "all",
tierFilters: [1, 2], // start with tiers 1 & 2 checked
Expand All @@ -71,14 +136,13 @@ class FilteredEventsListPanel extends Component {
variantFilters: [],
geneFilters: [],
tierCountsMap: {},
geneVariantsWithTierChanges: null, // Set of gene-variant keys that have tier changes
selectedColumnKeys: [],
};

// Track if a fetch is in progress to prevent concurrent calls
_isFetchingTierCounts = false;

// add as a class field

getDefaultColumnKeys = () => {
const { data: settingsData, dataset } = this.props;

Expand Down Expand Up @@ -201,7 +265,7 @@ class FilteredEventsListPanel extends Component {

// If no gene-variants have tier changes, nothing to fetch
if (geneVariantsWithTiers.size === 0) {
this.setState({ tierCountsMap: {} });
this.setState({ tierCountsMap: {}, geneVariantsWithTierChanges: geneVariantsWithTiers });
return;
}

Expand All @@ -221,7 +285,7 @@ class FilteredEventsListPanel extends Component {

// Guard: nothing to fetch after filtering
if (uniqueRecords.length === 0) {
this.setState({ tierCountsMap: {} });
this.setState({ tierCountsMap: {}, geneVariantsWithTierChanges: geneVariantsWithTiers });
return;
}

Expand All @@ -248,15 +312,22 @@ class FilteredEventsListPanel extends Component {
await Promise.all(batchPromises);
}

this.setState({ tierCountsMap: map });
this.setState({ tierCountsMap: map, geneVariantsWithTierChanges: geneVariantsWithTiers });
} finally {
this._isFetchingTierCounts = false;
}
};

getTierTooltipContent = (record) => {
const key = `${record.gene}-${record.type}`;
const tierCounts = this.state.tierCountsMap[key];
const { tierCountsMap, geneVariantsWithTierChanges } = this.state;

// Check if this gene-variant has no tier changes
if (geneVariantsWithTierChanges && !geneVariantsWithTierChanges.has(key)) {
return "No tier change";
}

const tierCounts = tierCountsMap[key];
if (!tierCounts) return "Loading tier distribution...";
const total =
(tierCounts[1] || 0) + (tierCounts[2] || 0) + (tierCounts[3] || 0);
Expand Down Expand Up @@ -341,6 +412,28 @@ class FilteredEventsListPanel extends Component {
filterValues
);

// Checkbox column for selecting events
const headerCheckboxState = this.getHeaderCheckboxState(records);
const checkboxColumn = {
title: (
<Checkbox
checked={headerCheckboxState.checked}
indeterminate={headerCheckboxState.indeterminate}
onChange={() => this.handleHeaderCheckboxChange(records)}
/>
),
key: "select",
width: 50,
fixed: "left",
align: "center",
render: (_, record) => (
<Checkbox
checked={this.isEventSelected(record)}
onChange={(e) => this.handleCheckboxChange(record, e.target.checked)}
/>
),
};

return (
<Wrapper>
{error ? (
Expand Down Expand Up @@ -459,9 +552,10 @@ class FilteredEventsListPanel extends Component {
<Skeleton active loading={loading}>
<Table
columns={[
checkboxColumn,
...(additionalColumns || []),
...columns,
].filter((col) => selectedColumnKeys.includes(col.key))}
].filter((col) => col.key === "select" || selectedColumnKeys.includes(col.key))}
dataSource={records}
pagination={{ pageSize: 50 }}
showSorterTooltip={false}
Expand Down Expand Up @@ -621,6 +715,10 @@ FilteredEventsListPanel.defaultProps = {};
const mapDispatchToProps = (dispatch) => ({
selectFilteredEvent: (filteredEvent, viewMode) =>
dispatch(selectFilteredEvent(filteredEvent, viewMode)),
setSelectedEventUids: (uids) =>
dispatch(setSelectedEventUids(uids)),
toggleEventUidSelection: (uid, selected) =>
dispatch(toggleEventUidSelection(uid, selected)),
});
const mapStateToProps = (state) => {
const mergedEvents = selectMergedEvents(state);
Expand All @@ -630,6 +728,7 @@ const mapStateToProps = (state) => {
filteredEvents: mergedEvents.filteredEvents,
originalFilteredEvents: state.FilteredEvents.originalFilteredEvents,
selectedFilteredEvent: mergedEvents.selectedFilteredEvent,
selectedEventUids: state.FilteredEvents.selectedEventUids || [],
viewMode: state.FilteredEvents.viewMode,
error: state.FilteredEvents.error,
id: state.CaseReport.id,
Expand Down
9 changes: 5 additions & 4 deletions src/components/reportButtonsPanel/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ class ReportButtonsPanel extends Component {
};

handleExportNotes = async () => {
const { mergedEvents } = this.props;
const { mergedEvents, selectedEventUids } = this.props;
try {
this.setState({ exporting: true });
const state = this.props;
await exportReport(state, mergedEvents);
await exportReport(state, mergedEvents, selectedEventUids);
} catch (err) {
console.error("Report export failed:", err);
} finally {
Expand All @@ -44,11 +44,11 @@ class ReportButtonsPanel extends Component {
};

handlePreviewReport = async () => {
const { mergedEvents } = this.props;
const { mergedEvents, selectedEventUids } = this.props;
try {
this.setState({ previewLoading: true, previewVisible: true });
const state = this.props;
const html = await previewReport(state, mergedEvents);
const html = await previewReport(state, mergedEvents, selectedEventUids);
this.setState({ previewHtml: html });
} catch (err) {
console.error("Report preview failed:", err);
Expand Down Expand Up @@ -202,6 +202,7 @@ const mapStateToProps = (state) => ({
CaseReport: state.CaseReport,
Interpretations: state.Interpretations,
mergedEvents: require("../../redux/interpretations/selectors").selectMergedEvents(state),
selectedEventUids: state.FilteredEvents.selectedEventUids || [],
});

export default connect(
Expand Down
19 changes: 13 additions & 6 deletions src/helpers/reportExporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import { getUser } from './userAuth';
* Builds a report structure from Redux state with merged interpretations
* @param {Object} state - Redux state
* @param {Object} mergedEvents - Events merged with interpretations from selectMergedEvents selector
* @param {Array} selectedEventUids - Array of UIDs for events to include in report (if empty, no alterations included)
* @returns {Object} Report structure suitable for HtmlRenderer
*/
function buildReportFromMergedState(state, mergedEvents) {
function buildReportFromMergedState(state, mergedEvents, selectedEventUids = []) {
const ce = state?.CaseReport || {};
const m = ce?.metadata || {};

Expand All @@ -25,7 +26,11 @@ function buildReportFromMergedState(state, mergedEvents) {
// Use merged events which already have interpretations applied
const alterationsRaw = Array.isArray(mergedEvents?.filteredEvents) ? mergedEvents.filteredEvents : [];
const alterationsMapped = alterationsRaw.map(mapEvent);
const alterations = alterationsMapped.filter((a) => a.tier === '1' || a.tier === '2');

// Filter alterations: only include those that are selected via checkboxes
// If no UIDs selected, no alterations are included (empty report)
const selectedUidsSet = new Set(selectedEventUids || []);
const alterations = alterationsMapped.filter((a) => selectedUidsSet.has(a.id));

// Get global notes from interpretations
const globalNotesInterp = state?.Interpretations?.selected?.['GLOBAL_NOTES'];
Expand Down Expand Up @@ -106,14 +111,15 @@ function buildTherapiesFromAlterations(alterations) {
* Generates the HTML report without downloading
* @param {Object} state - Redux state
* @param {Object} mergedEvents - Events merged with interpretations
* @param {Array} selectedEventUids - Array of UIDs for events to include in report
* @returns {Promise<string>} The generated HTML string
*/
export async function previewReport(state, mergedEvents) {
export async function previewReport(state, mergedEvents, selectedEventUids = []) {
try {
const gos_user = getUser();
const caseId = String(state?.CaseReport?.id || '');
const interpretationsFiltered = Object.values(state.Interpretations?.byId || {}).filter(i => i.authorId === gos_user?.userId && i.caseId === caseId);
const report = buildReportFromMergedState(state, mergedEvents);
const report = buildReportFromMergedState(state, mergedEvents, selectedEventUids);
report.author = gos_user ? gos_user.displayName : 'Unknown Author';
report.interpretations = interpretationsFiltered;
const renderer = new HtmlRenderer();
Expand All @@ -130,14 +136,15 @@ export async function previewReport(state, mergedEvents) {
* Exports the clinical report as a static HTML file
* @param {Object} state - Redux state
* @param {Object} mergedEvents - Events merged with interpretations
* @param {Array} selectedEventUids - Array of UIDs for events to include in report
* @returns {Promise<void>}
*/
export async function exportReport(state, mergedEvents) {
export async function exportReport(state, mergedEvents, selectedEventUids = []) {
try {
const gos_user = getUser();
const caseId = String(state?.CaseReport?.id || '');
const interpretationsFiltered = Object.values(state.Interpretations?.byId || {}).filter(i => i.authorId === gos_user?.userId && i.caseId === caseId);
const report = buildReportFromMergedState(state, mergedEvents);
const report = buildReportFromMergedState(state, mergedEvents, selectedEventUids);
report.author = gos_user ? gos_user.displayName : 'Unknown Author';
report.interpretations = interpretationsFiltered;
const renderer = new HtmlRenderer();
Expand Down
12 changes: 12 additions & 0 deletions src/redux/filteredEvents/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ const actions = {
RESET_TIER_OVERRIDES: "RESET_TIER_OVERRIDES",
REVERT_FILTERED_EVENT: "REVERT_FILTERED_EVENT",

SET_SELECTED_EVENT_UIDS: "SET_SELECTED_EVENT_UIDS",
TOGGLE_EVENT_UID_SELECTION: "TOGGLE_EVENT_UID_SELECTION",

fetchFilteredEvents: () => ({
type: actions.FETCH_FILTERED_EVENTS_REQUEST,
}),
Expand All @@ -24,6 +27,15 @@ const actions = {
alterationId,
originalEvent,
}),
setSelectedEventUids: (uids) => ({
type: actions.SET_SELECTED_EVENT_UIDS,
uids,
}),
toggleEventUidSelection: (uid, selected) => ({
type: actions.TOGGLE_EVENT_UID_SELECTION,
uid,
selected,
}),
};

export default actions;
26 changes: 26 additions & 0 deletions src/redux/filteredEvents/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const initState = {
selectedFilteredEvent: null,
viewMode: "tracks",
error: null,
selectedEventUids: [],
};

export default function appReducer(state = initState, action) {
Expand Down Expand Up @@ -89,6 +90,31 @@ export default function appReducer(state = initState, action) {
selectedFilteredEvent: nextSelected,
};
}
case actions.SET_SELECTED_EVENT_UIDS: {
return {
...state,
selectedEventUids: action.uids || [],
};
}
case actions.TOGGLE_EVENT_UID_SELECTION: {
const { uid, selected } = action;
const currentUids = state.selectedEventUids || [];

if (selected) {
if (!currentUids.includes(uid)) {
return {
...state,
selectedEventUids: [...currentUids, uid],
};
}
} else {
return {
...state,
selectedEventUids: currentUids.filter((u) => u !== uid),
};
}
return state;
}
default:
return state;
}
Expand Down
Loading