Skip to content

Commit

Permalink
Merge pull request #333 from visdesignlab/trrack-selection
Browse files Browse the repository at this point in the history
Trrack selecting and deselecting intersections
  • Loading branch information
NateLanza authored Apr 5, 2024
2 parents 9943aa8 + d784e7a commit 9830e41
Show file tree
Hide file tree
Showing 11 changed files with 166 additions and 33 deletions.
81 changes: 81 additions & 0 deletions e2e-tests/provenance.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/* eslint-disable testing-library/prefer-screen-queries */
import { test, expect } from '@playwright/test';
import mockData from '../playwright/mock-data/simpsons/simpsons_data.json';
import mockAnnotations from '../playwright/mock-data/simpsons/simpsons_annotations.json';
import mockAltText from '../playwright/mock-data/simpsons/simpsons_alttxt.json';

test.beforeEach(async ({ page }) => {
await page.route('*/**/api/**', async (route) => {
const url = route.request().url();
let json;

if (url) {
if (url.includes('workspaces/Upset%20Examples/tables/simpsons/rows/?limit=9007199254740991')) {
json = mockData;
await route.fulfill({ json });
} else if (url.includes('workspaces/Upset%20Examples/tables/simpsons/annotations/')) {
json = mockAnnotations;
await route.fulfill({ json });
} else if (url.includes('alttxt')) {
json = mockAltText;
await route.fulfill({ json });
} else if (url.includes('workspaces/Upset%20Examples/sessions/table/193/state/')) {
await route.fulfill({ status: 200 });
} else {
await route.continue();
}
} else {
await route.abort();
}
});
});

/**
* Asserts that trrack history works for selecting and deselecting rows, provenance tree is displayed correctly,
* reverting to an earlier state works, elementView row deselection is trracked,
* and aggregate rows can be selected and deselected.
*/
test('Selection History', async ({ page }) => {
await page.goto('http://localhost:3000/?workspace=Upset+Examples&table=simpsons&sessionId=193');
await page.getByLabel('Open additional options menu').click();
await page.getByLabel('Open history tree sidebar').click();

// Testing history for a subset selection & deselection
await page.locator('g > circle').first().click();
await page.locator('g > circle').first().click();
await expect(page.locator('div').filter({ hasText: /^Select intersection "School & Male"$/ }).nth(2)).toBeVisible();
await expect(page.locator('div').filter({ hasText: /^Deselect intersection$/ }).nth(2)).toBeVisible();

// Testing history for an aggregate row selection & deselection
await page.getByRole('radio', { name: 'Degree' }).check();
await page.locator('g').filter({ hasText: /^Degree 3Degree 3$/ }).locator('rect').click();
await expect(page.locator('div').filter({ hasText: /^Select intersection "Degree 3"$/ }).nth(2)).toBeVisible();
await page.locator('g').filter({ hasText: /^Degree 3Degree 3$/ }).locator('rect').click();
await expect(page.getByText('Deselect intersection').nth(1)).toBeVisible();

// Check that selections are maintained after de-aggregation
await page.locator('g:nth-child(4) > .css-1kek4un-Y > g:nth-child(4) > rect').click();
await page.getByRole('radio', { name: 'None' }).check();
await page.locator('.css-zf6412').click();
await expect(page.getByText('Deselect intersection').nth(2)).toBeVisible();

// Check that selections can be reverted & start a new history tree branch
await page.locator('g:nth-child(10) > circle').click();
await page.locator('.css-zf6412').click();
await page.locator('g:nth-child(7) > .css-1kek4un-Y > g:nth-child(4) > rect').click();
await expect(page.getByText('Deselect intersection')).toBeVisible();
await expect(page.getByText('Select intersection "Duff Fan')).toBeVisible();

// Check that deselection triggered by element view unbookmarking is reflected in history tree.
// Also tests that the bookmarking & unbookmarking is trracked
await page.getByLabel('Open element view sidebar').click();
await page.locator('svg[data-testid="StarBorderIcon"]').click();
await page.locator('span.MuiChip-label+svg[data-testid="StarIcon"]').click();
await page.getByLabel('Open additional options menu').click();
await page.getByLabel('Open history tree sidebar').click();

await expect(page.getByText('Unbookmark Duff Fan & Male')).toBeVisible();
await expect(page.getByText('Deselect intersection').nth(1)).toBeVisible();
await expect(page.getByText('Bookmark Duff Fan & Male', { exact: true })).toBeVisible();
await expect(page.getByText('Select intersection "Duff Fan')).toBeVisible();
});
1 change: 1 addition & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ export type UpsetConfig = {
histograms: Histogram[];
};
allSets: Column[];
selected: Row;
};

export type AccessibleDataEntry = {
Expand Down
15 changes: 12 additions & 3 deletions packages/upset/src/atoms/config/currentIntersectionAtom.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import { Bookmark, Row } from '@visdesignlab/upset2-core';
import { atom, selector } from 'recoil';
import { selector } from 'recoil';

import { queryColorPalette } from '../../utils/styles';
import { upsetConfigAtom } from './upsetConfigAtoms';

export const currentIntersectionAtom = atom<Row | null>({

/**
* Represents the currently selected intersection,
* which is tracked by & pulled from the upsetConfigAtom.
* Defaults to undefined in some cases, which is equivalent to null,
* ie no intersection is selected.
* @returns {Row | null | undefined} The currently selected intersection.
*/
export const currentIntersectionSelector = selector<Row | null | undefined>({
key: 'current-intersection',
default: null,
get: ({ get }) => get(upsetConfigAtom).selected,
// No setter; this should be set by calling actions.setSelected(intersection)
});

export const bookmarkedIntersectionSelector = selector<Bookmark[]>({
Expand Down
10 changes: 5 additions & 5 deletions packages/upset/src/atoms/elementsSelectors.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Item, flattenedOnlyRows, getItems } from '@visdesignlab/upset2-core';
import { selectorFamily } from 'recoil';
import { bookmarkedColorPalette, currentIntersectionAtom, nextColorSelector } from './config/currentIntersectionAtom';
import { bookmarkedColorPalette, currentIntersectionSelector, nextColorSelector } from './config/currentIntersectionAtom';
import { itemsAtom } from './itemsAtoms';
import { dataAtom } from './dataAtom';
import { upsetConfigAtom } from './config/upsetConfigAtoms';
Expand All @@ -19,7 +19,7 @@ export const elementSelector = selectorFamily<
const intersections = flattenedOnlyRows(data, state);
const row = intersections[id];
const palette = get(bookmarkedColorPalette);
const currentIntersection = get(currentIntersectionAtom);
const currentIntersection = get(currentIntersectionSelector);

if (!row) return [];

Expand All @@ -32,7 +32,7 @@ export const elementSelector = selectorFamily<
color: palette[id] || get(nextColorSelector),
isCurrentSelected: !!currentIntersection,
isCurrent:
!!(currentIntersection && currentIntersection.id === id),
!!(currentIntersection?.id === id),
}));
},
});
Expand All @@ -59,10 +59,10 @@ export const intersectionCountSelector = selectorFamily<
export const elementItemMapSelector = selectorFamily<Item[], string[]>({
key: 'element-item-map',
get: (ids: string[]) => ({ get }) => {
const currentIntersection = get(currentIntersectionAtom);
const currentIntersection = get(currentIntersectionSelector);
const items: Item[] = [];

if (currentIntersection === null) return [];
if (!currentIntersection) return [];

if (!ids.includes(currentIntersection.id)) {
items.push(...get(elementSelector(currentIntersection.id)));
Expand Down
7 changes: 4 additions & 3 deletions packages/upset/src/components/Columns/SizeBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Row } from '@visdesignlab/upset2-core';
import { FC } from 'react';
import { useRecoilValue } from 'recoil';

import { bookmarkedColorPalette, bookmarkedIntersectionSelector, currentIntersectionAtom } from '../../atoms/config/currentIntersectionAtom';
import { bookmarkedColorPalette, bookmarkedIntersectionSelector, currentIntersectionSelector } from '../../atoms/config/currentIntersectionAtom';
import { dimensionsSelector } from '../../atoms/dimensionsAtom';
import { maxSize } from '../../atoms/maxSizeAtom';
import { useScale } from '../../hooks/useScale';
Expand All @@ -21,7 +21,7 @@ const highlightColors = ['rgb(116, 173, 209)', 'rgb(94, 102, 171)', 'rgb(29, 41,
export const SizeBar: FC<Props> = ({ row, size }) => {
const dimensions = useRecoilValue(dimensionsSelector);
const sizeDomain = useRecoilValue(maxSize);
const currentIntersection = useRecoilValue(currentIntersectionAtom);
const currentIntersection = useRecoilValue(currentIntersectionSelector);
const bookmarkedIntersections = useRecoilValue(bookmarkedIntersectionSelector);
const bookmarkedColorPallete = useRecoilValue(bookmarkedColorPalette);

Expand Down Expand Up @@ -56,7 +56,8 @@ export const SizeBar: FC<Props> = ({ row, size }) => {
return bookmarkedColorPallete[row.id];
}

if (row !== undefined && currentIntersection !== null && currentIntersection.id === row.id) { // if currently selected, use the highlight colors
// We don't want to evaluate this to true if both currentIntersection and row are undefined, hence the 1st condition
if (currentIntersection && currentIntersection?.id === row?.id) { // if currently selected, use the highlight colors
return highlightColors[index];
}
return colors[index];
Expand Down
19 changes: 13 additions & 6 deletions packages/upset/src/components/ElementView/ElementQueries.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,37 @@ import StarIcon from '@mui/icons-material/Star';
import StarBorderIcon from '@mui/icons-material/StarBorder';
import { Alert, Chip, Stack } from '@mui/material';
import { useContext } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useRecoilValue } from 'recoil';

import { flattenedOnlyRows } from '@visdesignlab/upset2-core';
import { Row, flattenedOnlyRows } from '@visdesignlab/upset2-core';
import {
bookmarkedColorPalette,
bookmarkedIntersectionSelector,
currentIntersectionAtom,
currentIntersectionSelector,
nextColorSelector,
} from '../../atoms/config/currentIntersectionAtom';
import { ProvenanceContext } from '../Root';
import { dataAtom } from '../../atoms/dataAtom';

export const ElementQueries = () => {
const { provenance, actions } = useContext(ProvenanceContext);
const [currentIntersection, setCurrentIntersection] = useRecoilState(
currentIntersectionAtom,
);
const currentIntersection = useRecoilValue(currentIntersectionSelector);
const colorPallete = useRecoilValue(bookmarkedColorPalette);
const nextColor = useRecoilValue(nextColorSelector);
const data = useRecoilValue(dataAtom);
const rows = flattenedOnlyRows(data, provenance.getState());
const bookmarked = useRecoilValue(bookmarkedIntersectionSelector);
const currentIntersectionDisplayName = currentIntersection?.elementName.replaceAll("~&~", " & ") || "";

/**
* Sets the currently selected intersection and fires
* a Trrack action to update the provenance graph.
* @param inter intersection to select
*/
function setCurrentIntersection(inter: Row | null) {
actions.setSelected(inter);
}

return (
<>
{!currentIntersection && bookmarked.length === 0 && (
Expand Down
4 changes: 2 additions & 2 deletions packages/upset/src/components/ElementView/ElementSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';

import { columnsAtom } from '../../atoms/columnAtom';
import { currentIntersectionAtom } from '../../atoms/config/currentIntersectionAtom';
import { currentIntersectionSelector } from '../../atoms/config/currentIntersectionAtom';
import { elementSelector, intersectionCountSelector } from '../../atoms/elementsSelectors';
import { ElementQueries } from './ElementQueries';
import { ElementTable } from './ElementTable';
Expand Down Expand Up @@ -56,7 +56,7 @@ function downloadElementsAsCSV(items: Item[], columns: string[], name: string) {
/** @jsxImportSource @emotion/react */
export const ElementSidebar = ({ open, close }: Props) => {
const [fullWidth, setFullWidth] = useState(false);
const currentIntersection = useRecoilValue(currentIntersectionAtom);
const currentIntersection = useRecoilValue(currentIntersectionSelector);
const [drawerWidth, setDrawerWidth] = useState(initialDrawerWidth);
const intersectionCounter = useRecoilValue(
intersectionCountSelector(currentIntersection?.id),
Expand Down
3 changes: 2 additions & 1 deletion packages/upset/src/components/ProvenanceVis.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,11 @@ export const ProvenanceVis = ({ open, close }: Props) => {
flexShrink: 0,
'& .MuiDrawer-paper': {
padding: '1em',
marginTop: '2em',
marginTop: '3em',
width: (open) ? initialDrawerWidth : 0,
boxSizing: 'border-box',
zIndex: 0,
maxHeight: 'calc(100% - 3em)',
},
}}
>
Expand Down
13 changes: 7 additions & 6 deletions packages/upset/src/components/Rows/AggregateRow.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { css } from '@emotion/react';
import { Aggregate } from '@visdesignlab/upset2-core';
import { FC, useContext } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useRecoilValue } from 'recoil';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import SvgIcon from '@mui/material/SvgIcon';

import { visibleSetSelector } from '../../atoms/config/visibleSetsAtoms';
import { dimensionsSelector } from '../../atoms/dimensionsAtom';
import { bookmarkedIntersectionSelector, currentIntersectionAtom } from '../../atoms/config/currentIntersectionAtom';
import { bookmarkedIntersectionSelector, currentIntersectionSelector } from '../../atoms/config/currentIntersectionAtom';
import translate from '../../utils/transform';
import { highlight, mousePointer } from '../../utils/styles';
import { SizeBar } from '../Columns/SizeBar';
Expand Down Expand Up @@ -46,8 +46,7 @@ const secondLevelXOffset = 15;
export const AggregateRow: FC<Props> = ({ aggregateRow }) => {
const visibleSets = useRecoilValue(visibleSetSelector);
const dimensions = useRecoilValue(dimensionsSelector);
const currentIntersection = useRecoilValue(currentIntersectionAtom);
const setCurrentIntersectionAtom = useSetRecoilState(currentIntersectionAtom);
const currentIntersection = useRecoilValue(currentIntersectionSelector);
const bookmarkedIntersections = useRecoilValue(bookmarkedIntersectionSelector);
const collapsedIds = useRecoilValue(collapsedSelector);
const { actions } = useContext(ProvenanceContext);
Expand All @@ -65,14 +64,16 @@ export const AggregateRow: FC<Props> = ({ aggregateRow }) => {

return (
<g
onClick={() => aggregateRow && (setCurrentIntersectionAtom(aggregateRow))}
onClick={() => aggregateRow &&
(currentIntersection?.id === aggregateRow.id ?
actions.setSelected(null) : actions.setSelected(aggregateRow))}
css={mousePointer}
>
<g transform={translate(aggregateRow.level === 2 ? secondLevelXOffset : 2, 0)}>
<rect
transform={translate(0, 2)}
css={
(currentIntersection !== null && currentIntersection.id === aggregateRow.id) &&
(currentIntersection?.id === aggregateRow.id) &&
highlight
}
height={(['Sets', 'Overlaps'].includes(aggregateRow.aggregateBy)) ? (dimensions.body.rowHeight - 4) * 2 : dimensions.body.rowHeight - 4}
Expand Down
30 changes: 24 additions & 6 deletions packages/upset/src/components/Rows/SubsetRow.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,50 @@
import { Subset, getBelongingSetsFromSetMembership, getDegreeFromSetMembership } from '@visdesignlab/upset2-core';
import React, { FC, useState } from 'react';
import { FC, useState, useContext } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';

import { visibleSetSelector } from '../../atoms/config/visibleSetsAtoms';
import { AttributeBars } from '../Columns/Attribute/AttributeBars';
import { SizeBar } from '../Columns/SizeBar';
import { DeviationBar } from '../Columns/DeviationBar';
import { Matrix } from '../Columns/Matrix/Matrix';
import { bookmarkedIntersectionSelector, currentIntersectionAtom } from '../../atoms/config/currentIntersectionAtom';
import { bookmarkedIntersectionSelector, currentIntersectionSelector } from '../../atoms/config/currentIntersectionAtom';
import { dimensionsSelector } from '../../atoms/dimensionsAtom';
import {
highlight, defaultBackground, mousePointer, hoverHighlight,
} from '../../utils/styles';
import { BookmarkStar } from '../Columns/BookmarkStar';
import { columnHoverAtom, columnSelectAtom } from '../../atoms/highlightAtom';
import { Degree } from '../Columns/Degree';
import { ProvenanceContext } from '../Root';
import { Row } from '@visdesignlab/upset2-core';

type Props = {
subset: Subset;
};

/**
* A row in the upset plot that represents a subset.
* @param subset The subset to display data for
*/
export const SubsetRow: FC<Props> = ({ subset }) => {
const visibleSets = useRecoilValue(visibleSetSelector);
const currentIntersection = useRecoilValue(currentIntersectionAtom);
const setCurrentIntersection = useSetRecoilState(currentIntersectionAtom);
const currentIntersection = useRecoilValue(currentIntersectionSelector);
const dimensions = useRecoilValue(dimensionsSelector);
const bookmarkedIntersections = useRecoilValue(bookmarkedIntersectionSelector);

// Use trrack action for current intersection
const { actions } = useContext(
ProvenanceContext,
);
/**
* Sets the currently selected intersection and fires
* a Trrack action to update the provenance graph.
* @param inter intersection to select
*/
function setCurrentIntersection(inter: Row | null) {
actions.setSelected(inter);
}

const setColumnHighlight = useSetRecoilState(columnHoverAtom);
const setColumnSelect = useSetRecoilState(columnSelectAtom);

Expand All @@ -36,7 +54,7 @@ export const SubsetRow: FC<Props> = ({ subset }) => {
<g
onClick={
() => {
if (currentIntersection !== null && currentIntersection.id === subset.id) { // if the row is already selected, deselect it
if (currentIntersection?.id === subset.id) { // if the row is already selected, deselect it
setCurrentIntersection(null);
setColumnSelect([]);
setHover(subset.id);
Expand All @@ -63,7 +81,7 @@ export const SubsetRow: FC<Props> = ({ subset }) => {
height={dimensions.body.rowHeight}
width={dimensions.body.rowWidth}
css={
currentIntersection !== null && currentIntersection.id === subset.id
currentIntersection?.id === subset.id
? highlight
: (hover === subset.id)
? hoverHighlight
Expand Down
Loading

0 comments on commit 9830e41

Please sign in to comment.