From 22a16c465ab6c4d0e5b392effa5511097a323e79 Mon Sep 17 00:00:00 2001 From: Jake Wagoner Date: Thu, 13 Jun 2024 08:54:33 -0600 Subject: [PATCH 1/2] Add attribute mean and deviation to Intersections data table --- packages/app/src/components/DataTable.tsx | 489 ++++++++++++---------- 1 file changed, 265 insertions(+), 224 deletions(-) diff --git a/packages/app/src/components/DataTable.tsx b/packages/app/src/components/DataTable.tsx index 56b953af..ac158ee4 100644 --- a/packages/app/src/components/DataTable.tsx +++ b/packages/app/src/components/DataTable.tsx @@ -1,273 +1,314 @@ import { Backdrop, Box, Button, CircularProgress } from "@mui/material" -import { AccessibleDataEntry, CoreUpsetData } from "@visdesignlab/upset2-core"; +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 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.' + } +] + const getRowData = (row: AccessibleDataEntry) => { - return {id: row.id, elementName: `${(row.type === "Aggregate") ? "Aggregate: " : ""}${row.elementName.replaceAll("~&~", " & ")}`, size: row.size} + 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), + } + + for (const key in row.attributes) { + if (key === "deviation" || key === "degree") continue; + retVal[key] = (row.attributes[key] as SixNumberSummary).mean?.toFixed(2); + } + + return retVal; } const getAggRows = (row: AccessibleDataEntry) => { - const retVal: ReturnType[] = []; - if (row.items === undefined) return retVal; + const retVal: ReturnType[] = []; + if (row.items === undefined) return retVal; - Object.values(row.items).forEach((r: AccessibleDataEntry) => { - retVal.push(getRowData(r)); + Object.values(row.items).forEach((r: AccessibleDataEntry) => { + retVal.push(getRowData(r)); - if (r.type === "Aggregate") { - retVal.push(...getAggRows(r)); - } - }); + if (r.type === "Aggregate") { + retVal.push(...getAggRows(r)); + } + }); - return retVal; + return retVal; } const downloadCSS = { - m: "4px", - height: "40%", + m: "4px", + height: "40%", } const headerCSS = { - display: "flex", - justifyContent: "space-between", - alignItems: "center", - m: "2px" + display: "flex", + justifyContent: "space-between", + alignItems: "center", + m: "2px" } 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(); } type DownloadButtonProps = { - onClick: () => void; + onClick: () => void; } const DownloadButton = ({onClick}: DownloadButtonProps) => { - return ( - - ) + return ( + + ) } 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[]); - }) + 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); - }, []); - - // fetch subset data and create row objects with subset name and size - const tableRows: ReturnType[] = useMemo(() => { - if (rows === null) { - return []; - } + return; + } + setData(storedData as CoreUpsetData); + setRows(storedRows as ReturnType); + setVisibleSets(storedVisibleSets as string[]); + setHiddenSets(storedHiddenSets as string[]); + }) + setLoading(false); + }, []); - const retVal: ReturnType[] = []; - - Object.values(rows.values).forEach((r: AccessibleDataEntry) => { - retVal.push(getRowData(r)); - if (r.type === "Aggregate") { - retVal.push(...getAggRows(r)); - } - }); + 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)) { + 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; + // fetch subset data and create row objects + 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]); + + 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; + } + + 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]); + + const hiddenSetRows: {setName: string, size: number}[] = useMemo(() => { + if (hiddenSets === null || data === null) { + return []; + } + + return getSetRows(hiddenSets, data); + }, [hiddenSets, data]); + + if (data === null) { + return null; + } + + return ( + <> + { error ? +

Error fetching data...

: + + + + + +
+

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 From 9c55bbff29a09fd3daae8e0d7dd44b6a8174d4aa Mon Sep 17 00:00:00 2001 From: Jake Wagoner Date: Thu, 13 Jun 2024 09:49:16 -0600 Subject: [PATCH 2/2] Datatable: error handling and test update --- e2e-tests/datatable.spec.ts | 6 +- packages/app/src/components/DataTable.tsx | 146 +++++++++++++++++----- 2 files changed, 118 insertions(+), 34 deletions(-) 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 ac158ee4..5d5f5971 100644 --- a/packages/app/src/components/DataTable.tsx +++ b/packages/app/src/components/DataTable.tsx @@ -1,4 +1,4 @@ -import { Backdrop, Box, Button, CircularProgress } from "@mui/material" +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'; @@ -6,7 +6,25 @@ import { getAccessibleData } from "@visdesignlab/upset2-react"; import DownloadIcon from '@mui/icons-material/Download'; import localforage from "localforage"; +const downloadCSS = { + m: "4px", + height: "40%", +} + +const headerCSS = { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + m: "2px" +} + +/** + * Represents the columns configuration for the data table. + */ const setColumns: GridColDef[] = [ + /** + * Represents the column for the set name. + */ { field: 'setName', headerName: 'Set', @@ -14,6 +32,9 @@ const setColumns: GridColDef[] = [ editable: false, description: 'The name of the set.' }, + /** + * Represents the column for the set size. + */ { field: 'size', headerName: 'Size', @@ -23,6 +44,11 @@ const setColumns: GridColDef[] = [ } ] +/** + * 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, @@ -39,6 +65,11 @@ const getRowData = (row: AccessibleDataEntry) => { return retVal; } +/** + * 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; @@ -54,18 +85,13 @@ const getAggRows = (row: AccessibleDataEntry) => { return retVal; } -const downloadCSS = { - m: "4px", - height: "40%", -} - -const headerCSS = { - display: "flex", - justifyContent: "space-between", - alignItems: "center", - m: "2px" -} - +/** + * 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; @@ -95,10 +121,22 @@ function downloadElementsAsCSV(items: any[], columns: string[], name: string) { anchor.remove(); } +/** + * Props for the DownloadButton component. + */ type DownloadButtonProps = { + // 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 (