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/app/src/App.tsx b/packages/app/src/App.tsx index fb23a379..a9cd2980 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -17,6 +17,7 @@ import { configAtom } from './atoms/configAtoms'; import { queryParamAtom } from './atoms/queryParamAtom'; import { getMultinetSession } from './api/session'; +/** @jsxImportSource @emotion/react */ // eslint-disable-next-line @typescript-eslint/no-unused-vars const defaultVisibleSets = 6; diff --git a/packages/core/src/convertConfig.ts b/packages/core/src/convertConfig.ts index 6ab8dd9a..c03c8667 100644 --- a/packages/core/src/convertConfig.ts +++ b/packages/core/src/convertConfig.ts @@ -3,7 +3,7 @@ import { SortByOrder, SortVisibleBy, UpsetConfig, AttributePlots, ElementSelection, - AltText, + AltText, SetQuery, } from './types'; import { isUpsetConfig } from './typecheck'; import { DefaultConfig } from './defaultConfig'; @@ -60,6 +60,79 @@ type Version0_1_0 = { userAltText: AltText | null; } +type Version0_1_1 = { + plotInformation: PlotInformation; + horizontal: boolean; + firstAggregateBy: AggregateBy; + firstOverlapDegree: number; + secondAggregateBy: AggregateBy; + secondOverlapDegree: number; + sortVisibleBy: SortVisibleBy; + sortBy: string; + sortByOrder: SortByOrder; + filters: { + maxVisible: number; + minVisible: number; + hideEmpty: boolean; + hideNoSet: boolean; + }; + visibleSets: ColumnName[]; + visibleAttributes: ColumnName[]; + attributePlots: AttributePlots; + bookmarks: Bookmark[]; + collapsed: string[]; + plots: { + scatterplots: Scatterplot[]; + histograms: Histogram[]; + }; + allSets: Column[]; + selected: Row | null; + elementSelection: ElementSelection | null; + version: '0.1.1'; + useUserAlt: boolean; + userAltText: AltText | null; + intersectionSizeLabels: boolean; + setSizeLabels: boolean; + showHiddenSets: boolean; +} + +type Version0_1_2 = { + plotInformation: PlotInformation; + horizontal: boolean; + firstAggregateBy: AggregateBy; + firstOverlapDegree: number; + secondAggregateBy: AggregateBy; + secondOverlapDegree: number; + sortVisibleBy: SortVisibleBy; + sortBy: string; + sortByOrder: SortByOrder; + filters: { + maxVisible: number; + minVisible: number; + hideEmpty: boolean; + hideNoSet: boolean; + }; + visibleSets: ColumnName[]; + visibleAttributes: ColumnName[]; + attributePlots: AttributePlots; + bookmarks: Bookmark[]; + collapsed: string[]; + plots: { + scatterplots: Scatterplot[]; + histograms: Histogram[]; + }; + allSets: Column[]; + selected: Row | null; + elementSelection: ElementSelection | null; + version: '0.1.2'; + useUserAlt: boolean; + userAltText: AltText | null; + intersectionSizeLabels: boolean; + setSizeLabels: boolean; + showHiddenSets: boolean; + setQuery: SetQuery | null; +} + /** * Config type before versioning was implemented. */ @@ -98,14 +171,27 @@ type PreVersionConfig = { * @returns The converted config. */ // eslint-disable-next-line camelcase -function convert0_1_0(config: Version0_1_0): UpsetConfig { - (config as unknown as UpsetConfig).version = '0.1.1'; - (config as unknown as UpsetConfig).intersectionSizeLabels = DefaultConfig.intersectionSizeLabels; - (config as unknown as UpsetConfig).setSizeLabels = DefaultConfig.setSizeLabels; - (config as unknown as UpsetConfig).showHiddenSets = DefaultConfig.showHiddenSets; - return (config as unknown as UpsetConfig); +function convert0_1_0(config: Version0_1_0): Version0_1_1 { + (config as unknown as Version0_1_1).version = '0.1.1'; + (config as unknown as Version0_1_1).intersectionSizeLabels = DefaultConfig.intersectionSizeLabels; + (config as unknown as Version0_1_1).setSizeLabels = DefaultConfig.setSizeLabels; + (config as unknown as Version0_1_1).showHiddenSets = DefaultConfig.showHiddenSets; + return (config as unknown as Version0_1_1); } +/** + * Converts a configuration object from version 0.1.1 to version 0.1.2. + * + * @param config - The configuration object of version 0.1.1 to be converted. + * @returns The updated configuration object with version 0.1.2. + */ +function convert0_1_1(config: Version0_1_1): UpsetConfig { + (config as unknown as Version0_1_2).version = '0.1.2'; + (config as unknown as Version0_1_2).setQuery = DefaultConfig.setQuery; + return config as unknown as Version0_1_2; +} + + /** * Converts a pre-versioned config to the current version. * @param config The config to convert. @@ -144,18 +230,21 @@ 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); - break; + // @ts-expect-error: Fallthrough is intended behavior. + case '0.1.1': + convert0_1_1(config as Version0_1_1); default: void 0; + /* eslint-enable no-fallthrough */ } - /* eslint-enable no-fallthrough */ - /* eslint-enable no-void */ if (!isUpsetConfig(config)) { // eslint-disable-next-line no-console diff --git a/packages/core/src/defaultConfig.ts b/packages/core/src/defaultConfig.ts index 2eb87d35..174ac56b 100644 --- a/packages/core/src/defaultConfig.ts +++ b/packages/core/src/defaultConfig.ts @@ -37,8 +37,12 @@ export const DefaultConfig: UpsetConfig = { useUserAlt: false, userAltText: null, elementSelection: null, - version: '0.1.1', + version: '0.1.2', intersectionSizeLabels: true, setSizeLabels: true, showHiddenSets: true, + setQuery: { + name: '', + query: {}, + }, }; diff --git a/packages/core/src/render.ts b/packages/core/src/render.ts index 242fd4b4..204a17ab 100644 --- a/packages/core/src/render.ts +++ b/packages/core/src/render.ts @@ -2,9 +2,9 @@ import { firstAggregation, secondAggregation } from './aggregate'; import { filterRows } from './filter'; import { getSubsets } from './process'; import { sortRows } from './sort'; -import { areRowsAggregates, isRowAggregate } from './typeutils'; +import { areRowsAggregates, getBelongingSetsFromSetMembership, isPopulatedSetQuery, isRowAggregate } from './typeutils'; import { - Row, Rows, Sets, UpsetConfig, + Row, Rows, SetQueryMembership, Sets, UpsetConfig, } from './types'; /** @@ -12,6 +12,46 @@ import { */ export type RowMap = Record; +/** + * Represents a row to be rendered. + * + * @typedef {Object} RenderRow + * @property {string} id - The unique identifier for the row. + * @property {Row} row - The row data to be rendered. + */ +export type RenderRow = { + id: string; + row: Row; +}; + +/** + * Flattens a hierarchical structure of rows into a flat array of RenderRow objects. + * + * @param rows - The hierarchical structure of rows to flatten. + * @param flattenedRows - The array to store the flattened rows (optional, defaults to an empty array). + * @param idPrefix - The prefix to add to the IDs of the flattened rows (optional, defaults to an empty string). + * @returns The flattened array of RenderRow objects. + */ +export const flattenRows = ( + rows: Rows, + flattenedRows: RenderRow[] = [], + idPrefix: string = '', +): RenderRow[] => { + rows.order.forEach((rowId) => { + const row = rows.values[rowId]; + const prefix = idPrefix + row.id; + flattenedRows.push({ + id: prefix, + row, + }); + if (isRowAggregate(row)) { + flattenRows(row.items, flattenedRows, prefix); + } + }); + + return flattenedRows; +}; + /** * Calculates the first aggregation for the given data and state. * @param data - The data object containing items, sets, and attribute columns. @@ -40,11 +80,11 @@ const firstAggRR = (data: any, state: UpsetConfig) => { * @returns The second-level aggregation result. */ const secondAggRR = (data: any, state: UpsetConfig) => { - const rr = firstAggRR(data, state); + const renderRows = firstAggRR(data, state); - if (areRowsAggregates(rr)) { + if (areRowsAggregates(renderRows)) { const secondAgg = secondAggregation( - rr, + renderRows, state.secondAggregateBy, state.secondOverlapDegree, data.sets, @@ -55,23 +95,61 @@ const secondAggRR = (data: any, state: UpsetConfig) => { return secondAgg; } - return rr; + return renderRows; }; +/** + * Filters and returns rows based on the specified set membership query. + * + * @param rows - The rows to be filtered. + * @param membership - An object representing the set membership query. The keys are set names and the values are 'Yes' or 'No' indicating whether the row should belong to the set or not. + * @returns The filtered rows that match the set membership query. + */ +export function getQueryResult(rows: Rows, membership: SetQueryMembership): Rows { + const queryResults: Rows = { order: [], values: {} }; + flattenRows(rows).forEach((renderRow) => { + let match = true; + Object.entries(membership).forEach(([set, status]) => { + if (status === 'Yes' && !getBelongingSetsFromSetMembership(renderRow.row.setMembership).includes(set)) { + match = false; + } + if (status === 'No' && getBelongingSetsFromSetMembership(renderRow.row.setMembership).includes(set)) { + match = false; + } + }); + + if (match) { + queryResults.order.push(renderRow.id); + queryResults.values[renderRow.id] = renderRow.row; + } + }); + + return queryResults; +} + /** * Sorts the data by RR (Relative Risk) based on the provided state configuration. * * @param data - The data to be sorted. * @param state - The state configuration containing the visible sets and sorting options. + * @param ignoreQuery - Whether to ignore the query when sorting the data. Set this to true to get the sorted rows as if there was no query. * @returns The sorted rows based on the RR and the provided sorting options. */ -const sortByRR = (data: any, state: UpsetConfig) => { +const sortByRR = (data: any, state: UpsetConfig, ignoreQuery = false) => { if (!data || typeof data !== 'object' || !Object.hasOwn(data, 'sets')) return { order: [], values: {} }; const vSets: Sets = Object.fromEntries(Object.entries(data.sets as Sets).filter(([name, _set]) => state.visibleSets.includes(name))); - const rr = secondAggRR(data, state); - return sortRows(rr, state.sortBy, state.sortVisibleBy, vSets, state.sortByOrder); + let renderRows: Rows; + + if (!ignoreQuery && state.setQuery !== null && isPopulatedSetQuery(state.setQuery)) { + const subsets: Rows = getSubsets(data.items, data.sets, state.visibleSets, data.attributeColumns); + renderRows = getQueryResult(subsets, state.setQuery.query); + } else { + renderRows = secondAggRR(data, state); + } + + return sortRows(renderRows, state.sortBy, state.sortVisibleBy, vSets, state.sortByOrder); }; /** @@ -79,54 +157,23 @@ const sortByRR = (data: any, state: UpsetConfig) => { * * @param data - The data to be filtered. * @param state - The state object containing the Upset configuration. + * @param ignoreQuery - Whether to ignore the query when filtering the data. Set this to true to get the filtered rows as if there was no query. * @returns The filtered rows based on the RR algorithm and the provided filters. */ -const filterRR = (data: any, state: UpsetConfig) => { - const rr = sortByRR(data, state); +const filterRR = (data: any, state: UpsetConfig, ignoreQuery = false) => { + const renderRows = sortByRR(data, state, ignoreQuery); - return filterRows(rr, state.filters); + return filterRows(renderRows, state.filters); }; /** * Retrieves the rows of data based on the provided data and state. * @param data - The data to filter. * @param state - The state of the UpsetConfig. + * @param ignoreQuery - Whether to ignore the query when filtering the data. Set this to true to get the filtered rows as if there was no query. * @returns The filtered rows of data. */ -export const getRows = (data: any, state: UpsetConfig) => filterRR(data, state); - -export type RenderRow = { - id: string; - row: Row; -}; - -/** - * Flattens a hierarchical structure of rows into a flat array of RenderRow objects. - * - * @param rows - The hierarchical structure of rows to flatten. - * @param flattenedRows - The array to store the flattened rows (optional, defaults to an empty array). - * @param idPrefix - The prefix to add to the IDs of the flattened rows (optional, defaults to an empty string). - * @returns The flattened array of RenderRow objects. - */ -const flattenRows = ( - rows: Rows, - flattenedRows: RenderRow[] = [], - idPrefix: string = '', -): RenderRow[] => { - rows.order.forEach((rowId) => { - const row = rows.values[rowId]; - const prefix = idPrefix + row.id; - flattenedRows.push({ - id: prefix, - row, - }); - if (isRowAggregate(row)) { - flattenRows(row.items, flattenedRows, prefix); - } - }); - - return flattenedRows; -}; +export const getRows = (data: any, state: UpsetConfig, ignoreQuery = false) => filterRR(data, state, ignoreQuery); /** * Flattens the rows of data based on the provided state configuration. diff --git a/packages/core/src/typecheck.ts b/packages/core/src/typecheck.ts index 1fb1a8a2..075ebae1 100644 --- a/packages/core/src/typecheck.ts +++ b/packages/core/src/typecheck.ts @@ -4,6 +4,9 @@ import { ElementQueryType, ElementBookmark, ElementSelection, + SetQueryMembership, + SetQuery, + SetMembershipStatus, } from './types'; import { deepCopy } from './utils'; @@ -321,6 +324,54 @@ export function isPlotInformation(p: unknown): p is PlotInformation { && (typeof (p as PlotInformation).items === 'string' || (p as PlotInformation).items === null); } +/** + * Checks if the given value is a valid SetMembershipStatus. + * + * A valid SetMembershipStatus is one of the following strings: + * - 'Yes' + * - 'No' + * - 'May' + * + * @param s - The value to check. + * @returns True if the value is a valid SetMembershipStatus, false otherwise. + */ +export function isSetMembershipStatus(s: unknown): s is SetMembershipStatus { + return s === 'Yes' || s === 'No' || s === 'May'; +} + +/** + * Checks if the given value is a `SetQueryMembership` object. + * + * A `SetQueryMembership` object is considered valid if it is an object + * where all keys are strings and all values are valid `SetMembershipStatus`. + * + * @param s - The value to check. + * @returns `true` if the value is a `SetQueryMembership` object, otherwise `false`. + */ +export function isSetQueryMembership(s: unknown): s is SetQueryMembership { + return isObject(s) + && Object.entries(s).every( + ([k, v]) => typeof k === 'string' && isSetMembershipStatus(v)); +} + +/** + * Checks if the given value is a SetQuery object. + * + * A SetQuery object is expected to have the following properties: + * - `name`: a string representing the name of the query. + * - `query`: an object that satisfies the isSetQueryMembership check. + * + * @param s - The value to check. + * @returns `true` if the value is a SetQuery object, otherwise `false`. + */ +export function isSetQuery(s: unknown): s is SetQuery { + return isObject(s) + && Object.hasOwn(s, 'name') + && Object.hasOwn(s, 'query') + && typeof (s as SetQuery).name === 'string' + && isSetQueryMembership((s as SetQuery).query); +} + /** * Determines if the given object is a valid UpsetConfig using the CURRENT version. * @privateRemarks @@ -358,6 +409,7 @@ export function isUpsetConfig(config: unknown): config is UpsetConfig { && Object.hasOwn(config, 'intersectionSizeLabels') && Object.hasOwn(config, 'setSizeLabels') && Object.hasOwn(config, 'showHiddenSets') + && Object.hasOwn(config, 'setQuery') )) { console.warn('Upset config is missing required fields'); return false; @@ -368,7 +420,7 @@ export function isUpsetConfig(config: unknown): config is UpsetConfig { plotInformation, horizontal, firstAggregateBy, firstOverlapDegree, secondAggregateBy, secondOverlapDegree, sortVisibleBy, sortBy, sortByOrder, filters, visibleSets, visibleAttributes, attributePlots, bookmarks, collapsed, plots, allSets, selected, elementSelection, version, useUserAlt, userAltText, intersectionSizeLabels, setSizeLabels, - showHiddenSets, + showHiddenSets, setQuery, } = config as UpsetConfig; // Check that the fields are of the correct type @@ -553,7 +605,7 @@ export function isUpsetConfig(config: unknown): config is UpsetConfig { } // version - if (version !== '0.1.1') { + if (version !== '0.1.2') { console.warn('Upset config error: Invalid version'); return false; } @@ -594,6 +646,12 @@ export function isUpsetConfig(config: unknown): config is UpsetConfig { return false; } + // setQuery + if (setQuery !== null && !isSetQuery(setQuery)) { + console.warn('Upset config error: Set query is not an object'); + return false; + } + return true; /* eslint-enable no-console */ } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a52c4e6e..baa18841 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -275,7 +275,6 @@ export type Histogram = BasePlot & { }; export type Plot = Scatterplot | Histogram; - /** * Represents the different types of attribute plots. * Enum value is used here so that the values can be used as keys in upset package. @@ -354,6 +353,20 @@ export type ElementQuery = { */ export type NumericalQuery = {[attName: string] : [number, number]}; +/** + * Represents a query object where each key is a string and the value is a SetMembershipStatus. + * This type is used to define the membership status of elements in a set. + */ +export type SetQueryMembership = {[key: string]: SetMembershipStatus}; + +/** + * Represents a query for a set. + */ +export type SetQuery = { + name: string; + query: SetQueryMembership; +} + /** * Base representation of a bookmarkable type * @privateRemarks typechecked by isBookmark in typecheck.ts; changes here must be reflected there @@ -492,7 +505,7 @@ export type UpsetConfig = { * Selected elements (data points) in the Element View. */ elementSelection: ElementSelection | null; - version: '0.1.1'; + version: '0.1.2'; useUserAlt: boolean; userAltText: AltText | null; /** @@ -507,6 +520,10 @@ export type UpsetConfig = { * Whether to display the hidden sets & their sizes above the plot */ showHiddenSets: boolean; + /** + * Query by set query + */ + setQuery: SetQuery | null; }; export type AccessibleDataEntry = { diff --git a/packages/core/src/typeutils.ts b/packages/core/src/typeutils.ts index 9449a3e6..2313df2f 100644 --- a/packages/core/src/typeutils.ts +++ b/packages/core/src/typeutils.ts @@ -1,6 +1,6 @@ import { Aggregate, - Aggregates, Bookmark, BookmarkedIntersection, ElementBookmark, ElementQuery, NumericalBookmark, NumericalQuery, Row, Rows, SetMembershipStatus, Subset, Subsets, + Aggregates, Bookmark, BookmarkedIntersection, ElementBookmark, ElementQuery, NumericalBookmark, NumericalQuery, Row, Rows, SetMembershipStatus, SetQuery, Subset, Subsets, } from './types'; import { hashString } from './utils'; @@ -173,3 +173,13 @@ export function getBelongingSetsFromSetMembership(membership: { .filter((mem) => mem[1] === 'Yes') .map((mem) => mem[0]); } + +/** + * Checks if the given SetQuery is populated. + * + * @param query - The SetQuery object to check, or null. + * @returns True if the query is not null and contains at least one query value, otherwise false. + */ +export function isPopulatedSetQuery(query: SetQuery | null) { + return query !== null && Object.values(query.query).length > 0; +} diff --git a/packages/upset/src/atoms/config/queryBySetsAtoms.ts b/packages/upset/src/atoms/config/queryBySetsAtoms.ts new file mode 100644 index 00000000..dd8cef20 --- /dev/null +++ b/packages/upset/src/atoms/config/queryBySetsAtoms.ts @@ -0,0 +1,36 @@ +import { atom, selector } from 'recoil'; +import { SetQuery } from '@visdesignlab/upset2-core'; +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 + */ +export const queryBySetsInterfaceAtom = atom({ + key: 'query-by-sets-interface', + default: false, +}); + +/** + * A Recoil selector that retrieves the current set query from the upset configuration atom. + * + * @remarks + * This selector is used to get the `setQuery` property from the `upsetConfigAtom`. + * + * @returns {SetQuery} The current set query or null if not set. + * + * @example + * ```typescript + * const currentSetQuery = useRecoilValue(setQueryAtom); + * ``` + */ +export const setQueryAtom = selector({ + key: 'set-query', + get: ({ get }) => get(upsetConfigAtom).setQuery, +}); diff --git a/packages/upset/src/components/Body.tsx b/packages/upset/src/components/Body.tsx index 7ef2dbb9..c3dfe8c6 100644 --- a/packages/upset/src/components/Body.tsx +++ b/packages/upset/src/components/Body.tsx @@ -1,13 +1,19 @@ 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/config/queryBySetsAtoms'; export const Body = () => { const dimensions = useRecoilValue(dimensionsSelector); const rows = useRecoilValue(flattenedRowsSelector); + const queryBySetInterface = useRecoilValue(queryBySetsInterfaceAtom); + const setQuery = useRecoilValue(setQueryAtom); return ( @@ -26,7 +32,13 @@ export const Body = () => { > No intersections to display... : - } + ( + + { queryBySetInterface && } + { isPopulatedSetQuery(setQuery) ? : null } + + + )} ); }; diff --git a/packages/upset/src/components/Columns/Matrix/MembershipCircle.tsx b/packages/upset/src/components/Columns/Matrix/MembershipCircle.tsx index 962c3ac8..a8856b19 100644 --- a/packages/upset/src/components/Columns/Matrix/MembershipCircle.tsx +++ b/packages/upset/src/components/Columns/Matrix/MembershipCircle.tsx @@ -20,7 +20,7 @@ const MemberShipCircle: FC = (props) => { = (props) => { fill={ theme.matrix.member.yes } + strokeOpacity={0} r={3} />} diff --git a/packages/upset/src/components/Header/CollapseAllButton.tsx b/packages/upset/src/components/Header/CollapseAllButton.tsx index d6b36596..e9c09bd8 100644 --- a/packages/upset/src/components/Header/CollapseAllButton.tsx +++ b/packages/upset/src/components/Header/CollapseAllButton.tsx @@ -67,8 +67,8 @@ export const CollapseAllButton = () => { }, [allCollapsed, iconSize]); return ( -