Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
257 changes: 185 additions & 72 deletions modules/dataquery/jsx/viewdata.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a> tags
const urls = String(props.value).split(';');
display = (
urls.map((url, i) => (
<span key={i}>
<a href={url.trim()}>{url.trim()}</a>
{i < urls.length - 1 && '; '}
</span>
))
);
break;
default:
display = (
<a href={props.value}>
{display}
</a>
);
}
display = (
<a href={props.value}>
{display}
</a>
);
}
return display;
}
Expand Down Expand Up @@ -355,6 +340,7 @@ function ViewData(props: {
props.fulldictionary
);
const [emptyVisits, setEmptyVisits] = useState<boolean>(true);
const [emptyCandidates, setEmptyCandidates] = useState<boolean>(true);

let queryTable;
if (queryData.loading) {
Expand All @@ -380,40 +366,159 @@ function ViewData(props: {
break;
case 'done':
try {
queryTable = <DataTable
rowNumLabel={t('Row Number')}
fields={organizedData.headers.map(
(val: string) => {
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 = (
<div className='alert alert-info no-result-found-panel'>
<strong>{t('No result found.', {ns: 'loris'})}</strong>
</div>
);
} else {
queryTable = <DataTable
rowNumLabel={t('Row Number')}
fields={organizedData.headers.map(
(val: string) => {
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
Expand All @@ -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 ? (
<CheckboxElement
name="emptyvisits"
value={emptyVisits}
Expand All @@ -435,7 +544,19 @@ function ViewData(props: {
setEmptyVisits(value)
}
/>
: <div />);
) : null;

const emptyCandidatesCheckbox = (
<CheckboxElement
name="emptycandidates"
value={emptyCandidates}
label={t('Show candidates with no data', {ns: 'dataquery'})}
onUserInput={
(name: string, value: boolean) =>
setEmptyCandidates(value)
}
/>
);
return <div>
<SelectElement
name='headerdisplay'
Expand Down Expand Up @@ -495,7 +616,8 @@ function ViewData(props: {
}
sortByValue={false}
/>
{emptyCheckbox}
{emptyVisitsCheckbox}
{emptyCandidatesCheckbox}
{queryTable}
</div>;
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
*/
Expand All @@ -841,6 +953,7 @@ function organizedFormatter(
fields: APIQueryField[],
dict: FullDictionary,
displayEmptyVisits: boolean,
displayEmptyCandidates: boolean,
enumDisplay: EnumDisplayTypes,
t: any,
) {
Expand Down
3 changes: 3 additions & 0 deletions modules/dataquery/locale/dataquery.pot
Original file line number Diff line number Diff line change
Expand Up @@ -535,3 +535,6 @@ msgstr ""

msgid "or"
msgstr ""

msgid "Show candidates with no data"
msgstr ""
3 changes: 3 additions & 0 deletions modules/dataquery/locale/fr/LC_MESSAGES/dataquery.po
Original file line number Diff line number Diff line change
Expand Up @@ -769,3 +769,6 @@ msgstr "Onglet invalide"

msgid "or"
msgstr "ou"

msgid "Show candidates with no data"
msgstr "Afficher les candidats sans données"
3 changes: 3 additions & 0 deletions modules/dataquery/locale/hi/LC_MESSAGES/dataquery.po
Original file line number Diff line number Diff line change
Expand Up @@ -531,3 +531,6 @@ msgstr "अमान्य उत्तर"

msgid "Invalid tab"
msgstr "अमान्य टैब"

msgid "Show candidates with no data"
msgstr "बिना डेटा वाले उम्मीदवार दिखाएँ"
3 changes: 3 additions & 0 deletions modules/dataquery/locale/ja/LC_MESSAGES/dataquery.po
Original file line number Diff line number Diff line change
Expand Up @@ -533,3 +533,6 @@ msgstr "無効なタブ"

msgid "or"
msgstr "または"

msgid "Show candidates with no data"
msgstr "データのない候補者を表示"
3 changes: 3 additions & 0 deletions modules/dataquery/locale/zh/LC_MESSAGES/dataquery.po
Original file line number Diff line number Diff line change
Expand Up @@ -533,3 +533,6 @@ msgstr "无效标签"

msgid "or"
msgstr "或者"

msgid "Show candidates with no data"
msgstr "显示无数据的受试者"
Loading