diff --git a/e2e-tests/datatable.spec.ts b/e2e-tests/datatable.spec.ts index e1365970..efad7a39 100644 --- a/e2e-tests/datatable.spec.ts +++ b/e2e-tests/datatable.spec.ts @@ -42,11 +42,11 @@ test('Datatable', async ({ page }) => { // Test downloads // ////////////////// const page1 = await page1Promise; - const heading1 = await page1.getByRole('heading', { name: 'UpSet Data Table' }); + const heading1 = await page1.getByRole('heading', { name: 'Intersection Data' }); await expect(heading1).toBeVisible(); const downloadPromise = page1.waitForEvent('download'); - const downloadButton = await page1.locator('div').filter({ hasText: /^UpSet Data TableDownload$/ }).getByRole('button'); + const downloadButton = await page1.locator('div').filter({ hasText: /^Intersection DataDownload$/ }).getByRole('button'); await expect(downloadButton).toBeVisible(); await downloadButton.click(); const download = await downloadPromise; @@ -71,7 +71,7 @@ test('Datatable', async ({ page }) => { // ////////////////// // Test that the tables exist // ////////////////// - const datatable = await page1.getByText('IntersectionSizeSchool & Male3Unincluded3Male3Duff Fan & Male & Power Plant3Evil & Male2Evil & Male & Power Plant2Duff Fan & Male2Blue Hair2School1School & Evil & Male1Rows per page:101–10 of'); + const datatable = await page1.getByText('IntersectionSizeDeviationAgeSchool & Male35.199.33Unincluded35.1930.33Male3-9.4257.33Duff Fan & Male & Power Plant310.5838.33Evil & Male21.0343.50Evil & Male & Power Plant26.4161.50Duff Fan & Male21.0340.00Blue Hair27.2956.00School11.738.00School & Evil & Male11.7311.00Rows per page:101–10 of'); await expect(datatable).toBeVisible(); const visibleSets = await page1.getByText('SetSizeSchool6Blue Hair3Duff Fan6Evil6Male18Power Plant5Rows per page:101–6 of'); await expect(visibleSets).toBeVisible(); diff --git a/packages/app/src/components/DataTable.tsx b/packages/app/src/components/DataTable.tsx index 56b953af..5d5f5971 100644 --- a/packages/app/src/components/DataTable.tsx +++ b/packages/app/src/components/DataTable.tsx @@ -1,273 +1,398 @@ -import { Backdrop, Box, Button, CircularProgress } from "@mui/material" -import { AccessibleDataEntry, CoreUpsetData } from "@visdesignlab/upset2-core"; +import { Backdrop, Box, Button, CircularProgress, Dialog } from "@mui/material" +import { AccessibleDataEntry, CoreUpsetData, SixNumberSummary } from "@visdesignlab/upset2-core"; import { useEffect, useMemo, useState } from "react"; import { DataGrid, GridColDef } from '@mui/x-data-grid'; import { getAccessibleData } from "@visdesignlab/upset2-react"; import DownloadIcon from '@mui/icons-material/Download'; import localforage from "localforage"; -const getRowData = (row: AccessibleDataEntry) => { - return {id: row.id, elementName: `${(row.type === "Aggregate") ? "Aggregate: " : ""}${row.elementName.replaceAll("~&~", " & ")}`, size: row.size} +const downloadCSS = { + m: "4px", + height: "40%", } -const getAggRows = (row: AccessibleDataEntry) => { - const retVal: ReturnType[] = []; - if (row.items === undefined) return retVal; +const headerCSS = { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + m: "2px" +} - Object.values(row.items).forEach((r: AccessibleDataEntry) => { - retVal.push(getRowData(r)); +/** + * Represents the columns configuration for the data table. + */ +const setColumns: GridColDef[] = [ + /** + * Represents the column for the set name. + */ + { + field: 'setName', + headerName: 'Set', + width: 250, + editable: false, + description: 'The name of the set.' + }, + /** + * Represents the column for the set size. + */ + { + field: 'size', + headerName: 'Size', + width: 250, + editable: false, + description: 'The number of elements within the set.' + } +] - if (r.type === "Aggregate") { - retVal.push(...getAggRows(r)); - } - }); +/** + * Converts an AccessibleDataEntry object into a row data object. + * @param row - The AccessibleDataEntry object to convert. + * @returns The converted row data object. + */ +const getRowData = (row: AccessibleDataEntry) => { + const retVal: { [key: string]: any } = { + id: row.id, + elementName: `${(row.type === "Aggregate") ? "Aggregate: " : ""}${row.elementName.replaceAll("~&~", " & ")}`, + size: row.size, + deviation: row.attributes?.deviation.toFixed(2), + } - return retVal; -} + for (const key in row.attributes) { + if (key === "deviation" || key === "degree") continue; + retVal[key] = (row.attributes[key] as SixNumberSummary).mean?.toFixed(2); + } -const downloadCSS = { - m: "4px", - height: "40%", + return retVal; } -const headerCSS = { - display: "flex", - justifyContent: "space-between", - alignItems: "center", - m: "2px" +/** + * Retrieves the aggregated rows from the given row of accessible data. + * @param row - The row of accessible data. + * @returns An array of aggregated row data. + */ +const getAggRows = (row: AccessibleDataEntry) => { + const retVal: ReturnType[] = []; + if (row.items === undefined) return retVal; + + Object.values(row.items).forEach((r: AccessibleDataEntry) => { + retVal.push(getRowData(r)); + + if (r.type === "Aggregate") { + retVal.push(...getAggRows(r)); + } + }); + + return retVal; } +/** + * Generates a CSV file and downloads it. + * + * @param items - The array of items to be downloaded. + * @param columns - The array of column names. + * @param name - The name of the CSV file. + */ function downloadElementsAsCSV(items: any[], columns: string[], name: string) { - if (items.length < 1 || columns.length < 1) return; - - const saveText: string[] = []; - - saveText.push(columns.map(h => (h.includes(',') ? `"${h}"` : h)).join(',')); - - items.forEach(item => { - const row: string[] = []; - - columns.forEach(col => { - row.push(item[col]?.toString() || '-'); - }); - - saveText.push(row.map(r => (r.includes(',') ? `"${r}"` : r)).join(',')); + if (items.length < 1 || columns.length < 1) return; + + const saveText: string[] = []; + + saveText.push(columns.map(h => (h.includes(',') ? `"${h}"` : h)).join(',')); + + items.forEach(item => { + const row: string[] = []; + + columns.forEach(col => { + row.push(item[col]?.toString() || '-'); }); + + saveText.push(row.map(r => (r.includes(',') ? `"${r}"` : r)).join(',')); + }); - const blob = new Blob([saveText.join('\n')], { type: 'text/csv' }); - const blobUrl = URL.createObjectURL(blob); - - const anchor: any = document.createElement('a'); - anchor.style = 'display: none'; - document.body.appendChild(anchor); - anchor.href = blobUrl; - anchor.download = `${name}_${Date.now()}.csv`; - anchor.click(); - anchor.remove(); + const blob = new Blob([saveText.join('\n')], { type: 'text/csv' }); + const blobUrl = URL.createObjectURL(blob); + + const anchor: any = document.createElement('a'); + anchor.style = 'display: none'; + document.body.appendChild(anchor); + anchor.href = blobUrl; + anchor.download = `${name}_${Date.now()}.csv`; + anchor.click(); + anchor.remove(); } +/** + * Props for the DownloadButton component. + */ type DownloadButtonProps = { - onClick: () => void; + // The click event handler function for the button. + onClick: () => void; } +/** + * DownloadButton component. + * + * @component + * @param {Object} props - The component props. + * @param {Function} props.onClick - The click event handler for the button. + * @returns {JSX.Element} The DownloadButton component. + */ const DownloadButton = ({onClick}: DownloadButtonProps) => { - return ( - - ) + return ( + + ) } +/** + * Renders a data table component that displays intersection data, visible sets, and hidden sets. + * The component fetches data from local storage and populates the table with the retrieved data. + * If there is an error fetching the data, an error message is displayed. + * + * @returns The DataTable component. + */ export const DataTable = () => { - const [data , setData] = useState(null); - const [rows, setRows] = useState | null>(null); - const [visibleSets, setVisibleSets] = useState(null); - const [hiddenSets, setHiddenSets] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(false); - - useEffect(() => { - setLoading(true); - Promise.all([ - localforage.getItem("data"), - localforage.getItem("rows"), - localforage.getItem("visibleSets"), - localforage.getItem("hiddenSets") - ]).then(([storedData, storedRows, storedVisibleSets, storedHiddenSets]) => { - if (storedData === null || storedRows === null || storedVisibleSets === null || storedHiddenSets === null) { - setError(true); - setLoading(false); - return; - } - setData(storedData as CoreUpsetData); - setRows(storedRows as ReturnType); - setVisibleSets(storedVisibleSets as string[]); - setHiddenSets(storedHiddenSets as string[]); - }) - setLoading(false); - }, []); - - // fetch subset data and create row objects with subset name and size - const tableRows: ReturnType[] = useMemo(() => { - if (rows === null) { - return []; - } + const [data , setData] = useState(null); + const [rows, setRows] = useState | null>(null); + const [visibleSets, setVisibleSets] = useState(null); + const [hiddenSets, setHiddenSets] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); - const retVal: ReturnType[] = []; - - Object.values(rows.values).forEach((r: AccessibleDataEntry) => { - retVal.push(getRowData(r)); + /** + * Fetches data from local storage and sets the state variables. + * If the data is not found in local storage, an error message is displayed. + */ + useEffect(() => { + setLoading(true); + Promise.all([ + localforage.getItem("data"), + localforage.getItem("rows"), + localforage.getItem("visibleSets"), + localforage.getItem("hiddenSets") + ]).then(([storedData, storedRows, storedVisibleSets, storedHiddenSets]) => { + if (storedData === null || storedRows === null || storedVisibleSets === null || storedHiddenSets === null) { + console.error("Data not found in local storage") + setError("Error: Data not found in local storage"); + } else { + setData(storedData as CoreUpsetData); + setRows(storedRows as ReturnType); + setVisibleSets(storedVisibleSets as string[]); + setHiddenSets(storedHiddenSets as string[]); + } + }).catch((e) => { + setError(`Error: ${e}`); + }); + setLoading(false); + }, []); - if (r.type === "Aggregate") { - retVal.push(...getAggRows(r)); - } - }); + /** + * Generates an array of data columns for the data table. + * Each column object contains information such as field name, header name, width, and description. + * It iterates through the rows and adds any missing attribute columns to the array. + * + * @param rows - The rows of data for the table. + * @returns An array of data columns for the data table. + */ + const dataColumns: GridColDef[] = useMemo(() => { + const cols = [ + { + field: 'elementName', + headerName: 'Intersection', + width: 350, + editable: false, + description: 'The name of the intersection of sets.', + }, + { + field: 'size', + headerName: 'Size', + width: 150, + editable: false, + description: 'The number of intersections within the subset or aggregate.' + }, + { + field: 'deviation', + headerName: 'Deviation', + width: 150, + editable: false, + description: 'The deviation of the intersection from the expected value.' + } + ]; - return retVal; - - }, [rows]); + // add the attributes to the dataColumns object + if (rows) { + Object.values(rows.values).forEach((r: AccessibleDataEntry) => { + for (const key in r.attributes) { + if (!cols.find((m) => m.field === key)) { + // skip deviation and degree. Deviation is added above and degree is not needed in the table + if (key === "deviation" || key === "degree") continue; + cols.push({ + field: key, + headerName: key, + width: 150, + editable: false, + description: `Attribute: ${key}` + }); + } + } + }) + } - const getSetRows = (sets: string[], data: CoreUpsetData) => { - const retVal: {setName: string, size: number}[] = []; - retVal.push(...sets.map((s: string) => { - return {id: s, setName: s.replace('Set_', ''), size: data.sets[s].size}; - })); + return cols; + }, [rows]); - return retVal; + /** + * Returns an array of table rows based on the provided data. + * If the rows are null, an empty array is returned. + * For each row, it calls the `getRowData` function to get the row data. + * If a row has a type of "Aggregate", it also adds additional rows using the `getAggRows` function. + * + * @param rows - The data rows to generate the table rows from. + * @returns An array of table rows. + */ + const tableRows: ReturnType[] = useMemo(() => { + if (rows === null) { + return []; } - const visibleSetRows: {setName: string, size: number}[] = useMemo(() => { - if (visibleSets === null || data === null) { - return []; - } + const retVal: ReturnType[] = []; - return getSetRows(visibleSets, data); - }, [visibleSets, data]); + Object.values(rows.values).forEach((r: AccessibleDataEntry) => { + retVal.push(getRowData(r)); - const hiddenSetRows: {setName: string, size: number}[] = useMemo(() => { - if (hiddenSets === null || data === null) { - return []; - } + if (r.type === "Aggregate") { + retVal.push(...getAggRows(r)); + } + }); - return getSetRows(hiddenSets, data); - }, [hiddenSets, data]); - - const dataColumns: GridColDef[] = [ - { - field: 'elementName', - headerName: 'Intersection', - width: 350, - editable: false, - description: 'The name of the intersection of sets.', - }, - { - field: 'size', - headerName: 'Size', - width: 150, - editable: false, - description: 'The number of intersections within the subset or aggregate.' - }, - ] - - const setColumns: GridColDef[] = [ - { - field: 'setName', - headerName: 'Set', - width: 250, - editable: false, - description: 'The name of the set.' - }, - { - field: 'size', - headerName: 'Size', - width: 250, - editable: false, - description: 'The number of elements within the set.' - } - ] + return retVal; + }, [rows]); + + /** + * Retrieves an array of objects containing information about the sets. + * Each object includes the set name and its corresponding size. + * + * @param sets - An array of set names. + * @param data - An object containing the data for the sets. + * @returns An array of objects with the set name and size. + */ + const getSetRows = (sets: string[], data: CoreUpsetData) => { + const retVal: { setName: string, size: number }[] = []; + retVal.push(...sets.map((s: string) => { + return { id: s, setName: s.replace('Set_', ''), size: data.sets[s].size }; + })); - if (data === null) { - return null; + return retVal; + } + + /** + * Returns an array of visible set rows. + * + * @param visibleSets - The array of visible sets. + * @param data - The data used to generate the set rows. + * @returns An array of objects representing the visible set rows. + */ + const visibleSetRows: {setName: string, size: number}[] = useMemo(() => { + if (visibleSets === null || data === null) { + return []; } - return ( - <> - { error ? -

Error fetching data...

: - - - - - -
-

UpSet Data Table

- downloadElementsAsCSV(tableRows, ["elementName", "size"], "upset2_datatable")} /> -
- -
- -
-

Visible Sets

- downloadElementsAsCSV(visibleSetRows, ["setName", "size"], "upset2_visiblesets_table")} /> -
- -
- -
-

Hidden Sets

- downloadElementsAsCSV(hiddenSetRows, ["setName", "size"], "upset2_hiddensets_table")} /> -
- -
-
- } - - ) + return getSetRows(visibleSets, data); + }, [visibleSets, data]); + + /** + * Calculates the hidden set rows based on the provided hidden sets and data. + * @param hiddenSets - The hidden sets. + * @param data - The data. + * @returns An array of objects containing the set name and size. + */ + const hiddenSetRows: {setName: string, size: number}[] = useMemo(() => { + if (hiddenSets === null || data === null) { + return []; + } + + return getSetRows(hiddenSets, data); + }, [hiddenSets, data]); + + return ( + <> + { error ? +

{error}

: + + + + + +
+

Intersection Data

+ downloadElementsAsCSV(tableRows, dataColumns.map((m) => m.field), "upset2_intersection_data")} /> +
+ +
+ +
+

Visible Sets

+ downloadElementsAsCSV(visibleSetRows, ["setName", "size"], "upset2_visiblesets_table")} /> +
+ +
+ +
+

Hidden Sets

+ downloadElementsAsCSV(hiddenSetRows, ["setName", "size"], "upset2_hiddensets_table")} /> +
+ +
+
+ } + + ) } \ No newline at end of file