Skip to content

Commit 12bea74

Browse files
authored
Context Builder manual selection mode (#452)
1 parent e1e9134 commit 12bea74

File tree

32 files changed

+740
-232
lines changed

32 files changed

+740
-232
lines changed
Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,25 @@
11
:global {
2-
#confirmation-modal-container,
3-
#prompt-modal-container {
4-
& ~ div[role="dialog"] {
5-
// Detect when the modal has a sibling later in the DOM. That suggests it
6-
// has launched another modal that should take precedence.
7-
&:has(~ div[role="dialog"]) {
8-
.modal-content {
9-
display: none;
10-
}
11-
}
12-
13-
// Hide the backdrop of that later sibling so there is only one backdrop
14-
// at a time.
15-
& ~ div[role="dialog"] {
16-
.modal-backdrop {
17-
display: none;
18-
}
19-
}
2+
// A parent modal that launches a confirmation modal
3+
// should not be fully hidden, just darkened.
4+
// This overrides the behavior specified here:
5+
// https://github.com/broadinstitute/depmap-portal/blob/e1e9134/frontend/packages/@depmap/data-explorer-2/src/styles/ContextBuilderV2.scss#L16-L36
6+
div[role="dialog"]:has(~ div[role="dialog"] .stackableConfirmationModal) {
7+
.modal-content {
8+
display: block !important;
9+
filter: brightness(0.5);
10+
transition: filter 0.3s ease;
2011
}
2112
}
2213
}
14+
15+
.dontShowThisAgain {
16+
label {
17+
font-weight: normal;
18+
cursor: pointer;
19+
}
20+
21+
span {
22+
margin: 5px;
23+
vertical-align: top;
24+
}
25+
}

frontend/packages/@depmap/common-components/src/utils/getConfirmation.tsx

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from "react";
22
import ReactDOM from "react-dom";
33
import { Button, Modal } from "react-bootstrap";
4-
import "../styles/modals.scss";
4+
import styles from "../styles/modals.scss";
55

66
interface ConfirmationOptions {
77
title?: string | null;
@@ -10,12 +10,24 @@ interface ConfirmationOptions {
1010
message: React.ReactNode;
1111
showModalBackdrop?: boolean | null;
1212
yesButtonBsStyle?: string | null | undefined;
13+
dontShowAgainLocalStorageKey?: string;
1314
}
1415

1516
const launchModal = (
1617
options: ConfirmationOptions,
1718
resolve: (ok: boolean) => void
1819
) => {
20+
if (options.dontShowAgainLocalStorageKey) {
21+
const skip =
22+
window.localStorage.getItem(options.dontShowAgainLocalStorageKey!) ===
23+
"true";
24+
25+
if (skip) {
26+
resolve(true);
27+
return;
28+
}
29+
}
30+
1931
const container = document.createElement("div");
2032
container.id = "confirmation-modal-container";
2133
document.body.append(container);
@@ -28,6 +40,7 @@ const launchModal = (
2840
ReactDOM.render(
2941
<Modal
3042
show
43+
dialogClassName="stackableConfirmationModal"
3144
backdrop={
3245
typeof options.showModalBackdrop === "boolean"
3346
? options.showModalBackdrop
@@ -43,6 +56,29 @@ const launchModal = (
4356
</Modal.Header>
4457
<Modal.Body>
4558
<section>{options.message}</section>
59+
{options.dontShowAgainLocalStorageKey && (
60+
<section className={styles.dontShowThisAgain}>
61+
<label>
62+
<input
63+
type="checkbox"
64+
defaultChecked={false}
65+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
66+
if (e.target.checked) {
67+
window.localStorage.setItem(
68+
options.dontShowAgainLocalStorageKey!,
69+
"true"
70+
);
71+
} else {
72+
window.localStorage.removeItem(
73+
options.dontShowAgainLocalStorageKey!
74+
);
75+
}
76+
}}
77+
/>
78+
<span>Don’t show this again</span>
79+
</label>
80+
</section>
81+
)}
4682
</Modal.Body>
4783
<Modal.Footer>
4884
<Button

frontend/packages/@depmap/common-components/src/utils/promptForValue.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ function launchModal<T>(
6666
{(value, onChange, acceptText, setAcceptText) => (
6767
<Modal
6868
backdrop="static"
69+
dialogClassName="stackableConfirmationModal"
6970
{...options.modalProps}
7071
show
7172
onHide={() => {

frontend/packages/@depmap/common-components/src/utils/showInfoModal.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export default function showInfoModal(
3737
ReactDOM.render(
3838
<Modal
3939
backdrop="static"
40+
dialogClassName="stackableConfirmationModal"
4041
{...options.modalProps}
4142
show
4243
onHide={() => {

frontend/packages/@depmap/data-explorer-2/src/components/AnnotationSelect/AnnotationSourceSelect.tsx

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import React, { useMemo } from "react";
22
import { Dataset, TabularDataset } from "@depmap/types";
33
import PlotConfigSelect from "../PlotConfigSelect";
44
import showAnnotationDetailsModal from "./showAnnotationDetailsModal";
5-
import styles from "../../styles/DimensionSelect.scss";
65

76
interface Props {
87
axis: "sample" | "feature" | undefined;
@@ -51,21 +50,18 @@ function AnnotationSourceSelect({
5150
return (
5251
<PlotConfigSelect
5352
show
54-
label={
55-
<span className={styles.labelWithDetailsButton}>
56-
Annotation Source
57-
<button
58-
type="button"
59-
className={styles.detailsButton}
60-
disabled={!value}
61-
onClick={() => {
62-
showAnnotationDetailsModal(value!);
63-
}}
64-
>
65-
details
66-
</button>
67-
</span>
68-
}
53+
label="Annotation Source"
54+
renderDetailsButton={() => (
55+
<button
56+
type="button"
57+
disabled={!value}
58+
onClick={() => {
59+
showAnnotationDetailsModal(value!);
60+
}}
61+
>
62+
details
63+
</button>
64+
)}
6965
value={value}
7066
enable={!isLoadingAnnotationDatasets}
7167
isLoading={isLoadingAnnotationDatasets}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import React from "react";
2+
import { getConfirmation } from "@depmap/common-components";
3+
4+
export default function confirmManualSelectMode() {
5+
return getConfirmation({
6+
title: "Switch to manual selection?",
7+
message: (
8+
<p>
9+
This will discard your existing rules and create a fixed list of
10+
selected rows.
11+
</p>
12+
),
13+
yesText: "Switch to Manual Mode",
14+
noText: "Cancel",
15+
yesButtonBsStyle: "primary",
16+
dontShowAgainLocalStorageKey:
17+
"suppress-context-builder-manual-mode-warning",
18+
});
19+
}

frontend/packages/@depmap/data-explorer-2/src/components/ContextBuilderV2/ContextBuilderBody/ContextBuilderTableView/index.tsx

Lines changed: 90 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,64 @@
1-
import React, { useCallback, useEffect, useMemo, useRef } from "react";
1+
import React, {
2+
useCallback,
3+
useEffect,
4+
useMemo,
5+
useRef,
6+
useState,
7+
} from "react";
28
import { Button } from "react-bootstrap";
9+
import { Spinner } from "@depmap/common-components";
310
import SliceTable from "@depmap/slice-table";
411
import { areSliceQueriesEqual, SliceQuery } from "@depmap/types";
12+
import { isCompleteExpression } from "../../../../utils/misc";
513
import { useContextBuilderState } from "../../state/ContextBuilderState";
614
import NumberOfMatches from "../Expression/NumberOfMatches";
715
import useMatches from "../../hooks/useMatches";
16+
import confirmManualSelectMode from "./confirmManualSelectMode";
817
import styles from "../../../../styles/ContextBuilderV2.scss";
918

1019
function ContextBuilderTableView() {
1120
const {
1221
dimension_type,
22+
isManualSelectMode,
1323
mainExpr,
14-
setShowTableView,
15-
uniqueVariableSlices,
16-
tableOnlySlices,
17-
setTableOnlySlices,
1824
name,
25+
replaceExprWithSimpleList,
1926
setIsReadyToSave,
27+
setShowTableView,
28+
setTableOnlySlices,
29+
tableOnlySlices,
30+
undoManualSelectionMode,
31+
uniqueVariableSlices,
2032
} = useContextBuilderState();
2133

2234
const { isLoading, matchingIds } = useMatches(mainExpr);
35+
const [isViewInitialized, setIsViewInitialized] = useState(() => {
36+
// Edge case: we were dropped right into table mode before a valid
37+
// expression was created. We can skip initialization.
38+
if (!isCompleteExpression(mainExpr)) {
39+
return true;
40+
}
41+
42+
return false;
43+
});
44+
const wasLoading = useRef(false);
45+
46+
const viewOnlySlices = useRef<Set<SliceQuery>>(new Set(uniqueVariableSlices));
47+
const initialSlices = useRef([...viewOnlySlices.current, ...tableOnlySlices]);
2348

2449
useEffect(() => {
2550
if (isLoading) {
2651
setIsReadyToSave(false);
2752
} else {
2853
setIsReadyToSave(matchingIds.length > 0);
54+
55+
if (wasLoading.current) {
56+
setIsViewInitialized(true);
57+
}
2958
}
30-
}, [isLoading, matchingIds, setIsReadyToSave]);
3159

32-
const viewOnlySlices = useRef<Set<SliceQuery>>(new Set(uniqueVariableSlices));
33-
const initialSlices = useRef([...viewOnlySlices.current, ...tableOnlySlices]);
60+
wasLoading.current = isLoading;
61+
}, [isLoading, matchingIds, setIsReadyToSave]);
3462

3563
const initialRowSelection = useMemo(() => {
3664
const rowSelection: Record<string, boolean> = {};
@@ -39,7 +67,9 @@ function ContextBuilderTableView() {
3967
});
4068

4169
return rowSelection;
42-
}, [matchingIds]);
70+
// We only want to set the initialRowSelection on initialization.
71+
// eslint-disable-next-line react-hooks/exhaustive-deps
72+
}, [isViewInitialized]);
4373

4474
const handleChangeSlices = useCallback(
4575
(updatedSlices: SliceQuery[]) => {
@@ -54,8 +84,39 @@ function ContextBuilderTableView() {
5484
[uniqueVariableSlices, setTableOnlySlices]
5585
);
5686

57-
if (isLoading) {
58-
return <div>Loading...</div>;
87+
const shouldConfirmRowSelection = useRef(false);
88+
89+
const handleChangeRowSelection = useCallback(
90+
(nextRowSelection: Record<string, boolean>) => {
91+
const selectedIds = Object.entries(nextRowSelection)
92+
.filter(([, included]) => included)
93+
.map(([id]) => id);
94+
95+
replaceExprWithSimpleList(selectedIds);
96+
shouldConfirmRowSelection.current = true;
97+
},
98+
[replaceExprWithSimpleList]
99+
);
100+
101+
useEffect(() => {
102+
if (isManualSelectMode && shouldConfirmRowSelection.current) {
103+
shouldConfirmRowSelection.current = false;
104+
105+
confirmManualSelectMode().then((confirmed) => {
106+
if (!confirmed) {
107+
setIsViewInitialized(false);
108+
undoManualSelectionMode();
109+
}
110+
});
111+
}
112+
}, [isManualSelectMode, undoManualSelectionMode]);
113+
114+
if (!isViewInitialized) {
115+
return (
116+
<div className={styles.ContextBuilderTableView}>
117+
<Spinner position="absolute" left="calc(50vw - 100px)" />
118+
</div>
119+
);
59120
}
60121

61122
return (
@@ -66,7 +127,25 @@ function ContextBuilderTableView() {
66127
initialSlices={initialSlices.current}
67128
onChangeSlices={handleChangeSlices}
68129
viewOnlySlices={viewOnlySlices.current}
130+
enableRowSelection
131+
onChangeRowSelection={handleChangeRowSelection}
69132
initialRowSelection={initialRowSelection}
133+
renderCustomControls={() => {
134+
return isManualSelectMode ? (
135+
<div className={styles.manualSelectionControls}>
136+
You’re now in manual selection mode.{" "}
137+
<Button
138+
bsStyle="info"
139+
onClick={() => {
140+
setIsViewInitialized(false);
141+
undoManualSelectionMode();
142+
}}
143+
>
144+
Restore previous rules
145+
</Button>
146+
</div>
147+
) : null;
148+
}}
70149
renderCustomActions={() => {
71150
return (
72151
<div className={styles.customTableControls}>

frontend/packages/@depmap/data-explorer-2/src/components/ContextBuilderV2/ContextBuilderBody/Expression/RelationalExpression/RightHandSide/NumberInput.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,16 @@ interface Props {
1111
path: (string | number)[];
1212
domain: { min: number; max: number; isAllIntegers: boolean } | null;
1313
isLoading: boolean;
14+
onClickShowDistribution: () => void;
1415
}
1516

16-
function NumberInput({ expr, path, domain, isLoading }: Props) {
17+
function NumberInput({
18+
expr,
19+
path,
20+
domain,
21+
isLoading,
22+
onClickShowDistribution,
23+
}: Props) {
1724
const [value, setValue] = useState<number | null>(expr);
1825
const { dispatch, shouldShowValidation } = useContextBuilderState();
1926

@@ -40,7 +47,16 @@ function NumberInput({ expr, path, domain, isLoading }: Props) {
4047

4148
return (
4249
<div className={styles.NumberInput}>
43-
<label htmlFor={`number-input-${path}`}>Value</label>
50+
<div>
51+
<label htmlFor={`number-input-${path}`}>Value</label>
52+
<button
53+
type="button"
54+
className={styles.detailsButton}
55+
onClick={onClickShowDistribution}
56+
>
57+
details
58+
</button>
59+
</div>
4460
<FormControl
4561
className={cx({
4662
[styles.invalidNumber]:

0 commit comments

Comments
 (0)