From 6dd45a4ab40cf817f92386c842474ca2a185f2f2 Mon Sep 17 00:00:00 2001 From: Henri Rabalais Date: Mon, 27 Jul 2026 06:38:00 -0400 Subject: [PATCH] [dataquery] add toggle to hide candidates with no data Co-authored-by: arnav-makkar --- modules/dataquery/jsx/viewdata.tsx | 257 +++++++++++++----- modules/dataquery/locale/dataquery.pot | 3 + .../locale/fr/LC_MESSAGES/dataquery.po | 3 + .../locale/hi/LC_MESSAGES/dataquery.po | 3 + .../locale/ja/LC_MESSAGES/dataquery.po | 3 + .../locale/zh/LC_MESSAGES/dataquery.po | 3 + 6 files changed, 200 insertions(+), 72 deletions(-) diff --git a/modules/dataquery/jsx/viewdata.tsx b/modules/dataquery/jsx/viewdata.tsx index ac1d1ba856..ada6f53ae2 100644 --- a/modules/dataquery/jsx/viewdata.tsx +++ b/modules/dataquery/jsx/viewdata.tsx @@ -94,26 +94,11 @@ function DisplayValue(props: { } if (props.dictionary.type == 'URI') { - switch (props.dictionary.cardinality) { - case 'many': - // Split the string and map to multiple tags - const urls = String(props.value).split(';'); - display = ( - urls.map((url, i) => ( - - {url.trim()} - {i < urls.length - 1 && '; '} - - )) - ); - break; - default: - display = ( - - {display} - - ); - } + display = ( + + {display} + + ); } return display; } @@ -355,6 +340,7 @@ function ViewData(props: { props.fulldictionary ); const [emptyVisits, setEmptyVisits] = useState(true); + const [emptyCandidates, setEmptyCandidates] = useState(true); let queryTable; if (queryData.loading) { @@ -380,40 +366,159 @@ function ViewData(props: { break; case 'done': try { - queryTable = { - return {show: true, label: val}; - }) - } - data={organizedData.data} - getMappedCell={ - organizedMapper( - visitOrganization, - props.fields, - props.fulldictionary, - ) - } - getFormattedCell={ - organizedFormatter( - queryData.data, - visitOrganization, - props.fields, - props.fulldictionary, - emptyVisits, - enumDisplay, - props.t - ) - } - hide={ - { - rowsPerPage: false, - defaultColumn: true, - downloadCSV: visitOrganization == 'inline', + /** + * Helper function to check if a row has any instrument data + * (not just candidate metadata like PSCID) + * + * @param {TableRow} row - The row to check + * @returns {boolean} - true if row has data + */ + const rowHasData = (row: TableRow): boolean => { + // Cross-sectional mode prepends a 'Visit' column, so field indices are offset by 1 + const offset = visitOrganization === 'crosssection' ? 1 : 0; + + for (let i = 0; i < props.fields.length; i++) { + const field = props.fields[i]; + const dict = getDictionary(field, props.fulldictionary); + + // Skip candidate metadata fields (use scope instead of hardcoded names) + if (dict && dict.scope === 'candidate') { + continue; + } + + // Check if this field has any data (accounting for offset) + const rowIndex = i + offset; + if (rowIndex < row.length) { + const cellValue = row[rowIndex]; + + // Basic null/empty check + if (cellValue === null || cellValue === '') { + continue; + } + + // For session-scoped fields in longitudinal/inline/raw modes, + // the data is JSON-encoded. Need to parse and check if it contains actual visit data + if (dict && dict.scope === 'session' && + (visitOrganization === 'longitudinal' + || visitOrganization === 'inline' + || visitOrganization === 'raw')) { + try { + const parsed = JSON.parse(cellValue); + // Iterate over the parsed object values (session cells) + const hasData = Object.values(parsed).some((cell: any) => { + // unexpected types (like verify that it is an object) + if (typeof cell !== 'object' || cell === null) return false; + + // Check for standard 'value' property + if (cell.value !== undefined + && cell.value !== null + && cell.value !== '') { + return true; + } + + // Check for 'values' property (cardinality: many) + if (cell.values !== undefined && cell.values !== null) { + const valueKeys = Object.keys(cell.values); + if (valueKeys.length > 0) { + return true; + } + } + + return false; + }); + + if (hasData) { + return true; // Has actual session data + } + } catch (e) { + // If parsing fails, treat as no data + continue; + } + } else { + // For non-session fields or cross-sectional/raw modes, non-null/non-empty means has data + return true; + } } } - />; + return false; + }; + + // Filter out empty candidates if toggle is off + let filteredData = emptyCandidates + ? organizedData.data + : organizedData.data.filter(rowHasData); + + // For Cross-sectional mode, also filter out empty visits if toggle is off + // In this mode, each visit is a separate row with Visit column at index 0 + if (visitOrganization === 'crosssection' && !emptyVisits) { + filteredData = filteredData.filter((row) => { + // In Cross-sectional, each row represents ONE visit + // Check if this specific visit row has any session data + // Start at index 1 to skip the Visit column itself + for (let i = 1; i < row.length; i++) { + if (row[i] !== null && row[i] !== '') { + // This column has data - verify it's a session field + const fieldIndex = i - 1; // Adjust for Visit column offset + if (fieldIndex >= 0 && fieldIndex < props.fields.length) { + const field = props.fields[fieldIndex]; + const dict = getDictionary(field, props.fulldictionary); + + // Only count session-scoped fields + if (dict && dict.scope === 'session') { + return true; // This visit has session data + } + } + } + } + return false; // No session data for this visit + }); + } + + // If all data is filtered out, show "No result found" message directly + // This avoids potential React reconciliation errors in DataTable when switching from populated table to empty + if (filteredData.length === 0) { + queryTable = ( +
+ {t('No result found.', {ns: 'loris'})} +
+ ); + } else { + queryTable = { + return {show: true, label: val}; + }) + } + data={filteredData} + getMappedCell={ + organizedMapper( + visitOrganization, + props.fields, + props.fulldictionary, + ) + } + getFormattedCell={ + organizedFormatter( + queryData.data, + visitOrganization, + props.fields, + props.fulldictionary, + emptyVisits, + emptyCandidates, + enumDisplay, + props.t + ) + } + hide={ + { + rowsPerPage: false, + defaultColumn: true, + downloadCSV: visitOrganization == 'inline', + } + } + />; + } } catch (e) { // OrganizedMapper/Formatter can throw an error // before the loading is complete @@ -425,7 +530,11 @@ function ViewData(props: { } } - const emptyCheckbox = (visitOrganization === 'inline' ? + // Only show empty visits toggle for modes where it works + // (Inline only, as Cross-sectional is handled by 'empty candidates') + const showEmptyVisitsToggle = ['inline'].includes(visitOrganization); + + const emptyVisitsCheckbox = showEmptyVisitsToggle ? ( - :
); + ) : null; + + const emptyCandidatesCheckbox = ( + + setEmptyCandidates(value) + } + /> + ); return
- {emptyCheckbox} + {emptyVisitsCheckbox} + {emptyCandidatesCheckbox} {queryTable}
; } @@ -603,14 +725,9 @@ function organizeData( dataRow.push(null); } else { const mappedVals = Object.keys(thevalues) - .map((key) => { - // If it's a URI, don't prepend the key/label - if (dictionary.type === 'URI') { - return thevalues[key]; - } - // Otherwise, concatenate key and value - return key + '=' + thevalues[key]; - }) + .map( + (key) => key + '=' + thevalues[key] + ) .join(';'); dataRow.push(mappedVals); } @@ -792,14 +909,7 @@ function expandLongitudinalCells( } const thevalues = thissession.values; return {value: Object.keys(thevalues) - .map( (key) => { - // If it's a URI, don't prepend the key/label - if (fielddict.type === 'URI') { - return thevalues[key]; - } - // Otherwise concatenate key with value. - return key + '=' + thevalues[key]; - }) + .map( (key) => key + '=' + thevalues[key]) .join(';'), dictionary: fielddict}; default: if (thissession.value !== undefined) { @@ -828,10 +938,12 @@ function expandLongitudinalCells( * @param {array} dict - The full dictionary * @param {boolean} displayEmptyVisits - Whether visits with * no data should be displayed + * @param {boolean} displayEmptyCandidates - Whether candidates with + * no data should be displayed * @param {EnumDisplayTypes} enumDisplay - The format to display * enum values * @param {any} t - useTranslation - + * * @returns {function} - the appropriate column formatter for this data organization */ @@ -841,6 +953,7 @@ function organizedFormatter( fields: APIQueryField[], dict: FullDictionary, displayEmptyVisits: boolean, + displayEmptyCandidates: boolean, enumDisplay: EnumDisplayTypes, t: any, ) { diff --git a/modules/dataquery/locale/dataquery.pot b/modules/dataquery/locale/dataquery.pot index 46f10901b9..945238b135 100644 --- a/modules/dataquery/locale/dataquery.pot +++ b/modules/dataquery/locale/dataquery.pot @@ -535,3 +535,6 @@ msgstr "" msgid "or" msgstr "" + +msgid "Show candidates with no data" +msgstr "" diff --git a/modules/dataquery/locale/fr/LC_MESSAGES/dataquery.po b/modules/dataquery/locale/fr/LC_MESSAGES/dataquery.po index 46c60addf0..23061baf8f 100644 --- a/modules/dataquery/locale/fr/LC_MESSAGES/dataquery.po +++ b/modules/dataquery/locale/fr/LC_MESSAGES/dataquery.po @@ -769,3 +769,6 @@ msgstr "Onglet invalide" msgid "or" msgstr "ou" + +msgid "Show candidates with no data" +msgstr "Afficher les candidats sans données" diff --git a/modules/dataquery/locale/hi/LC_MESSAGES/dataquery.po b/modules/dataquery/locale/hi/LC_MESSAGES/dataquery.po index b0a9f46d8e..d1e361c8ca 100644 --- a/modules/dataquery/locale/hi/LC_MESSAGES/dataquery.po +++ b/modules/dataquery/locale/hi/LC_MESSAGES/dataquery.po @@ -531,3 +531,6 @@ msgstr "अमान्य उत्तर" msgid "Invalid tab" msgstr "अमान्य टैब" + +msgid "Show candidates with no data" +msgstr "बिना डेटा वाले उम्मीदवार दिखाएँ" diff --git a/modules/dataquery/locale/ja/LC_MESSAGES/dataquery.po b/modules/dataquery/locale/ja/LC_MESSAGES/dataquery.po index 50b511ce9d..8fbb0f74a8 100644 --- a/modules/dataquery/locale/ja/LC_MESSAGES/dataquery.po +++ b/modules/dataquery/locale/ja/LC_MESSAGES/dataquery.po @@ -533,3 +533,6 @@ msgstr "無効なタブ" msgid "or" msgstr "または" + +msgid "Show candidates with no data" +msgstr "データのない候補者を表示" diff --git a/modules/dataquery/locale/zh/LC_MESSAGES/dataquery.po b/modules/dataquery/locale/zh/LC_MESSAGES/dataquery.po index d7ff1352d5..854abe42c1 100644 --- a/modules/dataquery/locale/zh/LC_MESSAGES/dataquery.po +++ b/modules/dataquery/locale/zh/LC_MESSAGES/dataquery.po @@ -533,3 +533,6 @@ msgstr "无效标签" msgid "or" msgstr "或者" + +msgid "Show candidates with no data" +msgstr "显示无数据的受试者"