diff --git a/e2e-tests/queryBySets.spec.ts b/e2e-tests/queryBySets.spec.ts new file mode 100644 index 00000000..192e6035 --- /dev/null +++ b/e2e-tests/queryBySets.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from '@playwright/test'; +import { beforeTest } from './common'; + +test.beforeEach(beforeTest); + +test('Query by Sets', async ({ page }) => { + await page.goto('http://localhost:3000/?workspace=Upset+Examples&table=simpsons&sessionId=193'); + + // open Query by sets interface + await page.getByTestId('AddIcon').locator('path').click(); + // await page.getByLabel('Query By Sets').locator('rect').click(); + + // select first two sets as 'No', third as 'Yes' + await page.locator('g:nth-child(2) > g > circle').first().click(); + await page.locator('g:nth-child(2) > g > circle:nth-child(3)').click(); + await page.locator('g:nth-child(4) > g > circle:nth-child(4)').click(); + +// TODO: Add a test for changing the name. As is, playwright struggles to handle web dialog inputs + + // Ensure that the text is correct + await page.getByText('intersections of set [Evil]').click(); + + // Add the query + await page.getByLabel('Add query').locator('rect').click(); + + // This specific query size is 5 + await page.locator('text').filter({ hasText: /^5$/ }).click(); + + // Remove the query + await page.getByLabel('Remove query').locator('rect').click(); + +}); \ No newline at end of file diff --git a/packages/core/src/convertConfig.ts b/packages/core/src/convertConfig.ts index fd571bc3..3ab13694 100644 --- a/packages/core/src/convertConfig.ts +++ b/packages/core/src/convertConfig.ts @@ -272,18 +272,22 @@ export function convertConfig(config: unknown): UpsetConfig { if (!Object.hasOwn(config, 'version')) preVersionConversion(config as PreVersionConfig); /* eslint-disable no-void */ - /* eslint-disable no-fallthrough */ // Switch case is designed to fallthrough to the next version's conversion function // so that all versions are converted cumulatively. + switch ((config as {version: string}).version) { + /* eslint-disable no-fallthrough */ + // @ts-expect-error: Fallthrough is intended behavior. This is needed because Typescript build is not parsing eslint flags case '0.1.0': convert0_1_0(config as Version0_1_0); + // @ts-expect-error: Fallthrough is intended behavior. case '0.1.1': convert0_1_1(config as Version0_1_1); case '0.1.2': convert0_1_2(config as Version0_1_2); default: void 0; + /* eslint-enable no-fallthrough */ } if (!isUpsetConfig(config)) { diff --git a/packages/core/src/defaultConfig.ts b/packages/core/src/defaultConfig.ts index 633bbc62..588e024f 100644 --- a/packages/core/src/defaultConfig.ts +++ b/packages/core/src/defaultConfig.ts @@ -10,7 +10,7 @@ export const DefaultConfig: UpsetConfig = { title: null, }, horizontal: false, - firstAggregateBy: 'Degree', + firstAggregateBy: 'None', firstOverlapDegree: 2, secondAggregateBy: 'None', secondOverlapDegree: 2, diff --git a/packages/upset/src/atoms/queryBySetsAtoms.ts b/packages/upset/src/atoms/config/queryBySetsAtoms.ts similarity index 93% rename from packages/upset/src/atoms/queryBySetsAtoms.ts rename to packages/upset/src/atoms/config/queryBySetsAtoms.ts index 8c8f2f91..dd8cef20 100644 --- a/packages/upset/src/atoms/queryBySetsAtoms.ts +++ b/packages/upset/src/atoms/config/queryBySetsAtoms.ts @@ -1,13 +1,13 @@ import { atom, selector } from 'recoil'; import { SetQuery } from '@visdesignlab/upset2-core'; -import { upsetConfigAtom } from './config/upsetConfigAtoms'; +import { upsetConfigAtom } from './upsetConfigAtoms'; /** * Atom to manage the state of the query-by-sets interface. - * + * * This atom holds a boolean value indicating whether the query-by-sets * interface is enabled or not. The default value is `false`. - * + * * @constant * @type {boolean} * @default false diff --git a/packages/upset/src/components/Body.tsx b/packages/upset/src/components/Body.tsx index 2b06af2d..c3dfe8c6 100644 --- a/packages/upset/src/components/Body.tsx +++ b/packages/upset/src/components/Body.tsx @@ -1,13 +1,13 @@ import { useRecoilValue } from 'recoil'; +import { isPopulatedSetQuery } from '@visdesignlab/upset2-core'; import { dimensionsSelector } from '../atoms/dimensionsAtom'; import translate from '../utils/transform'; import { MatrixRows } from './Rows/MatrixRows'; import { flattenedRowsSelector } from '../atoms/renderRowsAtom'; import { QueryBySetInterface } from './custom/QueryBySet/QueryBySetInterface'; import { SetQueryRow } from './custom/QueryBySet/SetQueryRow'; -import { queryBySetsInterfaceAtom, setQueryAtom } from '../atoms/queryBySetsAtoms'; -import { isPopulatedSetQuery } from '@visdesignlab/upset2-core'; +import { queryBySetsInterfaceAtom, setQueryAtom } from '../atoms/config/queryBySetsAtoms'; export const Body = () => { const dimensions = useRecoilValue(dimensionsSelector); diff --git a/packages/upset/src/components/Header/Header.tsx b/packages/upset/src/components/Header/Header.tsx index fec5ae73..9e0ffffe 100644 --- a/packages/upset/src/components/Header/Header.tsx +++ b/packages/upset/src/components/Header/Header.tsx @@ -5,7 +5,7 @@ import { SizeHeader } from './SizeHeader'; import { MatrixHeader } from './MatrixHeader'; import { CollapseAllButton } from './CollapseAllButton'; import { QueryButton } from './QueryButton'; -import { setQueryAtom } from '../../atoms/queryBySetsAtoms'; +import { setQueryAtom } from '../../atoms/config/queryBySetsAtoms'; export const Header = () => { const setQuery = useRecoilValue(setQueryAtom); diff --git a/packages/upset/src/components/Header/QueryButton.tsx b/packages/upset/src/components/Header/QueryButton.tsx index 8f995655..62a80fba 100644 --- a/packages/upset/src/components/Header/QueryButton.tsx +++ b/packages/upset/src/components/Header/QueryButton.tsx @@ -1,11 +1,11 @@ import { SvgIcon, Tooltip } from '@mui/material'; import { Add, Remove } from '@mui/icons-material'; import { useRecoilState, useRecoilValue } from 'recoil'; -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import Group from '../custom/Group'; import { mousePointer } from '../../utils/styles'; import { dimensionsSelector } from '../../atoms/dimensionsAtom'; -import { queryBySetsInterfaceAtom } from '../../atoms/queryBySetsAtoms'; +import { queryBySetsInterfaceAtom } from '../../atoms/config/queryBySetsAtoms'; /** * The size of the icon in pixels. @@ -22,13 +22,9 @@ export const QueryButton = () => { /** * Toggles the query by set interface. */ - const toggleQueryBySetsInterface = () => { - if (queryBySetsInterface) { - setQueryBySetsInterface(false); - } else { - setQueryBySetsInterface(true); - } - }; + const toggleQueryBySetsInterface = useCallback(() => { + setQueryBySetsInterface((prev) => !prev); + }, [setQueryBySetsInterface]); // for whatever reason this needed to be memoized for the icon to update... // some weird recoil thing I expect diff --git a/packages/upset/src/components/Rows/AggregateRow.tsx b/packages/upset/src/components/Rows/AggregateRow.tsx index a000b4bc..eebf43eb 100644 --- a/packages/upset/src/components/Rows/AggregateRow.tsx +++ b/packages/upset/src/components/Rows/AggregateRow.tsx @@ -7,9 +7,9 @@ import SvgIcon from '@mui/material/SvgIcon'; import { visibleSetSelector } from '../../atoms/config/visibleSetsAtoms'; import { dimensionsSelector } from '../../atoms/dimensionsAtom'; -import { currentIntersectionSelector } from '../../atoms/config/currentIntersectionAtom'; +import { currentIntersectionSelector } from '../../atoms/config/currentIntersectionAtom'; import translate from '../../utils/transform'; -import { highlight, mousePointer } from '../../utils/styles'; +import { highlight, mousePointer, DEFAULT_ROW_BACKGROUND_COLOR, ROW_BORDER_STROKE_COLOR, ROW_BORDER_STROKE_WIDTH, DEFAULT_ROW_BACKGROUND_OPACITY } from '../../utils/styles'; import { SizeBar } from '../Columns/SizeBar'; import { Matrix } from '../Columns/Matrix/Matrix'; import { BookmarkStar } from '../Columns/BookmarkStar'; @@ -117,10 +117,10 @@ export const AggregateRow: FC = ({ aggregateRow }) => { width={width} rx={5} ry={10} - fill="#cccccc" - opacity="0.3" - stroke="#555555" - strokeWidth="1px" + fill={DEFAULT_ROW_BACKGROUND_COLOR} + opacity={DEFAULT_ROW_BACKGROUND_OPACITY} + stroke={ROW_BORDER_STROKE_COLOR} + strokeWidth={ROW_BORDER_STROKE_WIDTH} /> {collapsedIds.includes(aggregateRow.id) ? collapsed : expanded} diff --git a/packages/upset/src/components/Rows/MatrixRows.tsx b/packages/upset/src/components/Rows/MatrixRows.tsx index 2fb88ff8..7b0496e6 100644 --- a/packages/upset/src/components/Rows/MatrixRows.tsx +++ b/packages/upset/src/components/Rows/MatrixRows.tsx @@ -8,7 +8,7 @@ import translate from '../../utils/transform'; import { AggregateRow } from './AggregateRow'; import { SubsetRow } from './SubsetRow'; import { collapsedSelector } from '../../atoms/collapsedAtom'; -import { queryBySetsInterfaceAtom, setQueryAtom } from '../../atoms/queryBySetsAtoms'; +import { queryBySetsInterfaceAtom, setQueryAtom } from '../../atoms/config/queryBySetsAtoms'; type Props = { rows: RenderRow[]; diff --git a/packages/upset/src/components/SvgBase.tsx b/packages/upset/src/components/SvgBase.tsx index 074bcc46..0163dd0f 100644 --- a/packages/upset/src/components/SvgBase.tsx +++ b/packages/upset/src/components/SvgBase.tsx @@ -7,7 +7,7 @@ import { dimensionsSelector } from '../atoms/dimensionsAtom'; import { ProvenanceContext } from './Root'; import { currentIntersectionSelector } from '../atoms/config/currentIntersectionAtom'; import { calculateDimensions } from '../dimensions'; -import { queryBySetsInterfaceAtom } from '../atoms/queryBySetsAtoms'; +import { queryBySetsInterfaceAtom } from '../atoms/config/queryBySetsAtoms'; export const SvgBase: FC = ({ children }) => { const dimensions = useRecoilValue(dimensionsSelector); diff --git a/packages/upset/src/components/custom/QueryBySet/QueryBySetInterface.tsx b/packages/upset/src/components/custom/QueryBySet/QueryBySetInterface.tsx index 22e3cf77..4059885f 100644 --- a/packages/upset/src/components/custom/QueryBySet/QueryBySetInterface.tsx +++ b/packages/upset/src/components/custom/QueryBySet/QueryBySetInterface.tsx @@ -13,13 +13,13 @@ import { } from '@visdesignlab/upset2-core'; import { dimensionsSelector } from '../../../atoms/dimensionsAtom'; import translate from '../../../utils/transform'; -import { mousePointer } from '../../../utils/styles'; +import { mousePointer, DEFAULT_ROW_BACKGROUND_COLOR, ROW_BORDER_STROKE_COLOR, ROW_BORDER_STROKE_WIDTH, DEFAULT_ROW_BACKGROUND_OPACITY } from '../../../utils/styles'; import { SetMembershipRow } from './SetMembershipRow'; import { visibleSetSelector } from '../../../atoms/config/visibleSetsAtoms'; import { SizeBar } from '../../Columns/SizeBar'; import { dataAtom } from '../../../atoms/dataAtom'; import { ProvenanceContext } from '../../Root'; -import { queryBySetsInterfaceAtom } from '../../../atoms/queryBySetsAtoms'; +import { queryBySetsInterfaceAtom } from '../../../atoms/config/queryBySetsAtoms'; // edit icon size const EDIT_ICON_SIZE = 14; @@ -68,8 +68,9 @@ export const QueryBySetInterface = () => { const queryResults = Object.values(queryResult.values); queryResults.forEach((row) => { - if (!isRowAggregate(row)) + if (!isRowAggregate(row)) { size += row.size; + } }); return size; @@ -96,7 +97,7 @@ export const QueryBySetInterface = () => { } // base string. All results must begin with this - let queryResultString = 'intersections of '; + let queryString = 'intersections of '; // 'May' sets have no string representation and so are ignored const yesSets = Object.entries(membership).filter(([_, status]) => status === 'Yes'); @@ -107,16 +108,16 @@ export const QueryBySetInterface = () => { */ if (yesSets.length > 0) { if (yesSets.length === 1) { - queryResultString += 'set '; + queryString += 'set '; } else { - queryResultString += 'sets '; + queryString += 'sets '; } } yesSets.forEach(([set], index) => { - queryResultString += `[${set.replace('Set_', '')}]`; + queryString += `[${set.replace('Set_', '')}]`; if (index < yesSets.length - 1) { - queryResultString += ' and '; + queryString += ' and '; } }); @@ -125,23 +126,23 @@ export const QueryBySetInterface = () => { */ if (noSets.length > 0) { if (yesSets.length > 0) { - queryResultString += ' but excluding set'; + queryString += ' but excluding set'; } else { - queryResultString += 'excluding set'; + queryString += 'excluding set'; } if (noSets.length > 1) { - queryResultString += 's'; + queryString += 's'; } } noSets.forEach(([set], index) => { - queryResultString += ` [${set.replace('Set_', '')}]`; + queryString += ` [${set.replace('Set_', '')}]`; if (index < noSets.length - 1) { - queryResultString += ' and '; + queryString += ' and '; } }); - return queryResultString; + return queryString; }, [membership, queryResult]); /** @@ -179,8 +180,8 @@ export const QueryBySetInterface = () => { width={dimensions.setQuery.width} opacity="0.2" fill="transparent" - stroke="#555555" - strokeWidth="1px" + stroke={ROW_BORDER_STROKE_COLOR} + strokeWidth={ROW_BORDER_STROKE_WIDTH} /> {/* Query Header */} @@ -188,10 +189,10 @@ export const QueryBySetInterface = () => { transform={translate(0, 0)} height={dimensions.body.rowHeight} width={dimensions.setQuery.width} - fill="#cccccc" - opacity="0.3" - stroke="#555555" - strokeWidth="1px" + fill={DEFAULT_ROW_BACKGROUND_COLOR} + opacity={DEFAULT_ROW_BACKGROUND_OPACITY} + stroke={ROW_BORDER_STROKE_COLOR} + strokeWidth={ROW_BORDER_STROKE_WIDTH} /> { y1={dimensions.body.rowHeight / 2} x2={dimensions.matrixColumn.visibleSetsWidth - 10} y2={dimensions.body.rowHeight / 2} - stroke="#555555" opacity="0.4" - strokeWidth="1px" + stroke={ROW_BORDER_STROKE_COLOR} + strokeWidth={ROW_BORDER_STROKE_WIDTH} /> @@ -266,7 +267,7 @@ export const QueryBySetInterface = () => { height={CHECK_ICON_SIZE} width={CHECK_ICON_SIZE} fill="transparent" - onClick={() => addQuery()} + onClick={addQuery} /> diff --git a/packages/upset/src/components/custom/QueryBySet/SetMembershipRow.tsx b/packages/upset/src/components/custom/QueryBySet/SetMembershipRow.tsx index 75c1388d..c944623b 100644 --- a/packages/upset/src/components/custom/QueryBySet/SetMembershipRow.tsx +++ b/packages/upset/src/components/custom/QueryBySet/SetMembershipRow.tsx @@ -1,4 +1,4 @@ -import { Dispatch, FC, SetStateAction } from 'react'; +import { Dispatch, FC, SetStateAction, useCallback } from 'react'; import { useRecoilValue } from 'recoil'; import { SetMembershipStatus, SetQueryMembership } from '@visdesignlab/upset2-core'; import { css } from '@emotion/react'; @@ -17,7 +17,7 @@ type Props = { */ setMembers: Dispatch>; /** - * Membership type for the row, can only be 'not', 'maybe', or 'must'. + * Membership type for the row, can only be 'Not', 'Maybe', or 'Must'. */ membershipType?: SetMembershipStatus /** @@ -43,11 +43,11 @@ export const SetMembershipRow: FC = ({ const visibleSets = useRecoilValue(visibleSetSelector); /** - * Retrieves the membership status for a given set. + * Retrieves the membership status for a given set within the current query interface selections. If the row is combined, it returns the membership status for the combined row. * @param set - The name of the set. * @returns The membership status for the set. */ - function getMembershipStatus(set: string): SetMembershipStatus { + function getMembershipStatusInQuery(set: string): SetMembershipStatus { if (combined || !membershipType) { return members[set]; } @@ -59,13 +59,13 @@ export const SetMembershipRow: FC = ({ * * @param set - The name of the set. */ - function selectMembershipCircle(set: string) { + const selectMembershipCircle = useCallback((set: string) => { if (combined || !membershipType) return; const newMembers = { ...members }; newMembers[set] = membershipType; setMembers(newMembers); - } + }, [combined, membershipType, members, setMembers]); return ( @@ -81,9 +81,9 @@ export const SetMembershipRow: FC = ({ selectMembershipCircle(set)} transform={translate(((dimensions.set.width / 2) + dimensions.gap / 2) * index, 0)} - membershipStatus={getMembershipStatus(set)} + membershipStatus={getMembershipStatusInQuery(set)} showoutline - css={(!combined && getMembershipStatus(set) === members[set]) && highlightedSetMemberCircle} + css={(!combined && getMembershipStatusInQuery(set) === members[set]) && highlightedSetMemberCircle} /> ))} diff --git a/packages/upset/src/components/custom/QueryBySet/SetQueryRow.tsx b/packages/upset/src/components/custom/QueryBySet/SetQueryRow.tsx index 142911a7..7743e32b 100644 --- a/packages/upset/src/components/custom/QueryBySet/SetQueryRow.tsx +++ b/packages/upset/src/components/custom/QueryBySet/SetQueryRow.tsx @@ -1,4 +1,4 @@ -import { FC, useCallback, useContext } from 'react'; +import { FC, useContext, useMemo } from 'react'; import { useRecoilValue } from 'recoil'; import { SetMembershipStatus } from '@visdesignlab/upset2-core'; @@ -9,13 +9,24 @@ import { dimensionsSelector } from '../../../atoms/dimensionsAtom'; import translate from '../../../utils/transform'; import { visibleSetSelector } from '../../../atoms/config/visibleSetsAtoms'; import MemberShipCircle from '../../Columns/Matrix/MembershipCircle'; -import { setQueryAtom } from '../../../atoms/queryBySetsAtoms'; +import { setQueryAtom } from '../../../atoms/config/queryBySetsAtoms'; import { ProvenanceContext } from '../../Root'; import { SizeBar } from '../../Columns/SizeBar'; import { flattenedRowsSelector } from '../../../atoms/renderRowsAtom'; +import { DEFAULT_ROW_BACKGROUND_COLOR, DEFAULT_ROW_BACKGROUND_OPACITY, ROW_BORDER_STROKE_COLOR, ROW_BORDER_STROKE_WIDTH } from '../../../utils/styles'; const REMOVE_ICON_SIZE = 16; +/** + * Component representing a row in the set query. + * + * This component displays a row with information about a set query, including + * the set query name, membership status circles for visible sets, and a size bar + * indicating the total size of the query. It also includes a button to remove the set query. + * + * @component + * @returns {JSX.Element} The rendered SetQueryRow component. + */ export const SetQueryRow: FC = () => { const { actions } = useContext(ProvenanceContext); const dimensions = useRecoilValue(dimensionsSelector); @@ -24,27 +35,29 @@ export const SetQueryRow: FC = () => { const rows = useRecoilValue(flattenedRowsSelector); /** - * Retrieves the membership status for a given set. + * Retrieves the membership status for a given set from the set query sets. * @param set - The name of the set. * @returns The membership status for the set. */ - function getMembershipStatus(set: string): SetMembershipStatus { + function getMembershipStatusFromQuery(set: string): SetMembershipStatus { return setQuery?.query?.[set] ?? 'No'; } /** - * Removes the current set query from the list of set queries. - * - * This function calls the `removeSetQuery` action from the `actions` object, - * passing the current `setQuery` as an argument to remove it from the list. + * Remove the current (and only) set query. * * @returns {void} This function does not return a value. */ function removeSetQuery(): void { - actions.removeSetQuery(setQuery); + actions.removeSetQuery(); } - const totalQuerySize = useCallback(() => { + /** + * Calculates the total size of all rows in the query. + * + * @returns {number} The total size of all rows. + */ + const totalQuerySize = useMemo(() => { let total = 0; rows.forEach((rr) => { total += rr.row.size; @@ -61,10 +74,10 @@ export const SetQueryRow: FC = () => { width={dimensions.body.rowWidth} rx={5} ry={10} - fill="#cccccc" - opacity="0.3" - stroke="#555555" - strokeWidth="1px" + fill={DEFAULT_ROW_BACKGROUND_COLOR} + opacity={DEFAULT_ROW_BACKGROUND_OPACITY} + stroke={ROW_BORDER_STROKE_COLOR} + strokeWidth={ROW_BORDER_STROKE_WIDTH} /> {/* Remove query button */} @@ -74,7 +87,7 @@ export const SetQueryRow: FC = () => { css={css` cursor: pointer; `} - onClick={() => removeSetQuery()} + onClick={removeSetQuery} > { {visibleSets.map((set, index) => ( ))} - + diff --git a/packages/upset/src/provenance/index.ts b/packages/upset/src/provenance/index.ts index cd6e51ed..10c8f051 100644 --- a/packages/upset/src/provenance/index.ts +++ b/packages/upset/src/provenance/index.ts @@ -410,9 +410,9 @@ const addSetQueryAction = register( * @param state - The current state of the Upset configuration. * @returns The updated state with the `setQuery` property set to `null`. */ -const removeSetQueryAction = register( +const removeSetQueryAction = register( 'remove-set-query', - (state: UpsetConfig, query: SetQuery) => { + (state: UpsetConfig) => { // filter out the query to remove state.setQuery = null; return state; @@ -536,9 +536,9 @@ export function getActions(provenance: UpsetProvenance) { `Query ${query.name}: ${queryString}`, addSetQueryAction(query), ), - removeSetQuery: (query: SetQuery) => provenance.apply( - `Remove Query: ${query.name}`, - removeSetQueryAction(query), + removeSetQuery: () => provenance.apply( + `Remove Query`, + removeSetQueryAction(), ), }; } diff --git a/packages/upset/src/utils/styles.ts b/packages/upset/src/utils/styles.ts index 1ae2fd5c..137580ac 100644 --- a/packages/upset/src/utils/styles.ts +++ b/packages/upset/src/utils/styles.ts @@ -48,4 +48,13 @@ export const arrowIconCSS = { height: '16px', width: '16px', marginLeft: '6px', }; +// Stroke color and width for queryBySets Interface and query rows +export const ROW_BORDER_STROKE_COLOR = '#555555'; +export const ROW_BORDER_STROKE_WIDTH = '1px'; + +// Default background color for attribute plots export const ATTRIBUTE_DEFAULT_COLOR = '#d3d3d3'; + +// Default background color for aggregate and query rows +export const DEFAULT_ROW_BACKGROUND_COLOR = '#cccccc'; +export const DEFAULT_ROW_BACKGROUND_OPACITY = '0.3';