diff --git a/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.module.scss b/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.module.scss index 0e3ec3d681..79c18ad245 100644 --- a/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.module.scss +++ b/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.module.scss @@ -1,6 +1,6 @@ @use 'scss/mixins'; -.repeatGroupCell { +.cell { @include mixins.textEllipsis; width: 100%; @@ -13,7 +13,7 @@ } @media print { - .repeatGroupCell { + .cell { // Avoid trimming data from cells @include mixins.undoTextEllipsis; } diff --git a/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.tsx b/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.tsx index 52b832b77e..ca8dd15648 100644 --- a/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.tsx +++ b/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.tsx @@ -1,5 +1,5 @@ import type { SubmissionResponse } from '#/dataInterface' -import { getRepeatGroupAnswers } from '../submissionUtils' +import { getRepeatGroupAnswers } from '../repeatGroupUtils' import styles from './RepeatGroupCell.module.scss' interface RepeatGroupCellProps { @@ -12,10 +12,9 @@ interface RepeatGroupCellProps { */ export default function RepeatGroupCell(props: RepeatGroupCellProps) { const repeatGroupAnswers = getRepeatGroupAnswers(props.submissionData, props.rowName) - if (!repeatGroupAnswers) return null - + if (!repeatGroupAnswers || repeatGroupAnswers.length <= 0) return null return ( -
+
{repeatGroupAnswers.map((answer, i) => ( {i > 0 && ', '} diff --git a/jsapp/js/components/submissions/DataTableCell/TextModalCell.tsx b/jsapp/js/components/submissions/DataTableCell/TextModalCell.tsx index 3502ed8522..e77c36068a 100644 --- a/jsapp/js/components/submissions/DataTableCell/TextModalCell.tsx +++ b/jsapp/js/components/submissions/DataTableCell/TextModalCell.tsx @@ -16,6 +16,7 @@ interface TextModalCellProps { columnName: string submissionIndex: number submissionTotal: number + modalContent?: React.ReactNode } /** @@ -69,7 +70,7 @@ export default function TextModalCell(props: TextModalCellProps) {
- {props.text} + {props.modalContent ?? props.text}
diff --git a/jsapp/js/components/submissions/DataTableCell/index.tsx b/jsapp/js/components/submissions/DataTableCell/index.tsx index d8e20381c4..f292caac1d 100644 --- a/jsapp/js/components/submissions/DataTableCell/index.tsx +++ b/jsapp/js/components/submissions/DataTableCell/index.tsx @@ -35,6 +35,22 @@ export default function DataTableCell(props: DataTableCellProps) { const submissionIndex = props.reactTableRow.index + 1 const columnName = getColumnLabel(props.asset, props.columnKey, props.showGroupName, props.translationIndex) + const shouldRenderUndefinedNestedKeyAsRepeat = (() => { + if (props.reactTableRow.value !== undefined || !props.columnKey.includes('/')) { + return false + } + + const keyPathSegments = props.columnKey.split('/') + for (let i = keyPathSegments.length - 1; i >= 1; i--) { + const parentPath = keyPathSegments.slice(0, i).join('/') + if (Array.isArray(submission[parentPath])) { + return true + } + } + + return false + })() + if ( props.isBulkProcessingInProgress && props.reactTableRow.value === undefined && @@ -47,7 +63,12 @@ export default function DataTableCell(props: DataTableCellProps) { ) } - if (typeof props.reactTableRow.value === 'object' && !props.columnKey.startsWith(SUPPLEMENTAL_DETAILS_PROP)) { + // Some repeat answers are stored under related nested keys, so a direct lookup for this column key can be + // undefined even though repeat data exists in the submission payload. + if ( + !props.columnKey.startsWith(SUPPLEMENTAL_DETAILS_PROP) && + (typeof props.reactTableRow.value === 'object' || shouldRenderUndefinedNestedKeyAsRepeat) + ) { return } diff --git a/jsapp/js/components/submissions/repeatGroupUtils.tests.ts b/jsapp/js/components/submissions/repeatGroupUtils.tests.ts new file mode 100644 index 0000000000..649629dfdc --- /dev/null +++ b/jsapp/js/components/submissions/repeatGroupUtils.tests.ts @@ -0,0 +1,434 @@ +import chai from 'chai' +import React from 'react' +import DeletedAttachment from '#/attachments/deletedAttachment.component' +import type { SubmissionResponse } from '#/dataInterface' +import assetDataFactory from '#/endpoints/assetData.factory' +import { getRepeatGroupAnswerTree, getRepeatGroupAnswers } from './repeatGroupUtils' +import { nestedRepeatSurveySubmission } from './submissionUtils.mocks' + +function makeSubmission(overrides: Record): SubmissionResponse { + return assetDataFactory(1, { + _attachments: [], + ...(overrides as Partial), + }) +} + +function makeRepeatItem(path: string, value: string) { + return { + [path]: value, + } +} + +function makeRepeatSubmission( + groupName: string, + questionName: string, + values: Array | null>, +) { + return makeSubmission({ + [groupName]: values.map((value) => { + if (typeof value === 'string') { + return makeRepeatItem(`${groupName}/${questionName}`, value) + } + + return value + }), + }) +} + +function makeNestedRepeatSubmission(branches: string[][]) { + return makeSubmission({ + outer_repeat: branches.map((branch) => { + return { + 'outer_repeat/inner_repeat': branch.map((value) => + makeRepeatItem('outer_repeat/inner_repeat/item_name', value), + ), + } + }), + }) +} + +function textAnswer(value: string) { + return { kind: 'text' as const, value } +} + +function deletedAttachmentAnswer() { + return { kind: 'deleted-attachment' as const } +} + +describe('getRepeatGroupAnswers', () => { + it('should return values for a repeat group in root', () => { + const rootRepeatSubmission = makeRepeatSubmission('children', 'name', ['Ada', 'Grace']) + + const test = getRepeatGroupAnswers(rootRepeatSubmission, 'children/name') + + chai.expect(test).to.deep.equal(['Ada', 'Grace']) + }) + + it('should return values for a repeat group nested inside a regular group', () => { + const groupedRepeatSubmission = makeSubmission({ + family: { + 'family/children': [ + { + 'family/children/name': 'Ada', + }, + { + 'family/children/name': 'Grace', + }, + ], + }, + }) + + const test = getRepeatGroupAnswers(groupedRepeatSubmission, 'family/children/name') + + chai.expect(test).to.deep.equal(['Ada', 'Grace']) + }) + + it('should return no values when repeat group does not exist in submission', () => { + const submissionWithoutRepeat = makeSubmission({ + other_group: { + 'other_group/name': 'Nope', + }, + }) + + const test = getRepeatGroupAnswers(submissionWithoutRepeat, 'children/name') + + chai.expect(test).to.deep.equal([]) + }) + + it('should not match unrelated root-level segment when full repeat path is missing', () => { + const submissionWithUnrelatedRootName = makeSubmission({ + name: 'Alice', + }) + + const test = getRepeatGroupAnswers(submissionWithUnrelatedRootName, 'group_a/name') + + chai.expect(test).to.deep.equal([]) + }) + + it('should skip unanswered iterations and keep answered values', () => { + const partiallyAnsweredRepeatSubmission = makeRepeatSubmission('children', 'name', [ + 'Ada', + { 'children/age': '11' }, + 'Grace', + ]) + + const test = getRepeatGroupAnswers(partiallyAnsweredRepeatSubmission, 'children/name') + + chai.expect(test).to.deep.equal(['Ada', 'Grace']) + }) + + it('should preserve unanswered repeat iterations when includeUnanswered is enabled', () => { + const partiallyAnsweredRepeatSubmission = makeRepeatSubmission('children', 'name', [ + 'Ada', + { 'children/age': '11' }, + 'Grace', + ]) + + const test = getRepeatGroupAnswers(partiallyAnsweredRepeatSubmission, 'children/name', { + includeUnanswered: true, + unansweredPlaceholder: '-', + }) + + chai.expect(test).to.deep.equal(['Ada', '-', 'Grace']) + }) + + it('should ignore invalid repeat items and return values from valid objects', () => { + const mixedRepeatSubmission = makeSubmission({ + children: [ + makeRepeatItem('children/name', 'Ada'), + null, + 'unexpected-string-item', + makeRepeatItem('children/name', 'Grace'), + ], + }) + + const test = getRepeatGroupAnswers(mixedRepeatSubmission, 'children/name') + + chai.expect(test).to.deep.equal(['Ada', 'Grace']) + }) + + it('should return values for a repeat group nested inside another repeat group', () => { + const test = getRepeatGroupAnswers(nestedRepeatSurveySubmission, 'group_people/group_items/Item_name') + + chai.expect(test).to.deep.equal(['Notebook', 'Pen', 'Shoe', 'Computer']) + }) + + it('should return values for repeat inside regular group when repeat key is flat at root level', () => { + const rootLevelGroupedRepeatSubmission = makeSubmission({ + 'regular_group/nested_repeat': [ + { + 'regular_group/nested_repeat/nested_text': 'c', + }, + { + 'regular_group/nested_repeat/nested_text': 'd', + }, + ], + }) + + const test = getRepeatGroupAnswers(rootLevelGroupedRepeatSubmission, 'regular_group/nested_repeat/nested_text') + + chai.expect(test).to.deep.equal(['c', 'd']) + }) + + it('should flatten nested repeat answers into a single list', () => { + const nestedRepeatForTableDisplay = makeNestedRepeatSubmission([ + ['e', 'f'], + ['g', 'h'], + ]) + + const test = getRepeatGroupAnswers(nestedRepeatForTableDisplay, 'outer_repeat/inner_repeat/item_name') + + chai.expect(test).to.deep.equal(['e', 'f', 'g', 'h']) + }) + + it('should flatten nested answers and include placeholders for unanswered nested repeat values', () => { + const nestedRepeatWithMissingValues = makeSubmission({ + outer_repeat: [ + { + 'outer_repeat/inner_repeat': [ + { + 'outer_repeat/inner_repeat/item_name': 'e', + }, + { + 'outer_repeat/inner_repeat/item_name': 'f', + }, + ], + }, + { + 'outer_repeat/inner_repeat': [ + { + 'outer_repeat/inner_repeat/item_name': 'g', + }, + { + 'outer_repeat/inner_repeat/item_cost': '100', + }, + ], + }, + ], + }) + + const test = getRepeatGroupAnswers(nestedRepeatWithMissingValues, 'outer_repeat/inner_repeat/item_name', { + includeUnanswered: true, + unansweredPlaceholder: '-', + }) + + chai.expect(test).to.deep.equal(['e', 'f', 'g', '-']) + }) + + it('should return deleted attachment component when repeat answer points to deleted media', () => { + const submissionWithDeletedRepeatAttachment = makeSubmission({ + _attachments: [ + { + download_url: 'https://example.test/file.jpg', + filename: 'file.jpg', + media_file_basename: 'file.jpg', + mimetype: 'image/jpeg', + question_xpath: 'children[1]/photo', + uid: 'attachment-1', + is_deleted: true, + }, + ], + children: [ + { + 'children/photo': 'file.jpg', + }, + ], + }) + + const test = getRepeatGroupAnswers(submissionWithDeletedRepeatAttachment, 'children/photo') + + chai.expect(test).to.have.length(1) + chai.expect(React.isValidElement(test[0])).to.equal(true) + chai.expect((test[0] as React.ReactElement).type).to.equal(DeletedAttachment) + }) + + it('should return values from repeat groups nested 4 levels deep', () => { + const fourLevelNestedRepeatSubmission = makeSubmission({ + level1_repeat: [ + { + 'level1_repeat/level2_repeat': [ + { + 'level1_repeat/level2_repeat/level3_repeat': [ + { + 'level1_repeat/level2_repeat/level3_repeat/level4_repeat': [ + { + 'level1_repeat/level2_repeat/level3_repeat/level4_repeat/deep_value': 'alpha', + }, + ], + }, + ], + }, + ], + }, + { + 'level1_repeat/level2_repeat': [ + { + 'level1_repeat/level2_repeat/level3_repeat': [ + { + 'level1_repeat/level2_repeat/level3_repeat/level4_repeat': [ + { + 'level1_repeat/level2_repeat/level3_repeat/level4_repeat/deep_value': 'beta', + }, + ], + }, + ], + }, + ], + }, + ], + }) + + const test = getRepeatGroupAnswers( + fourLevelNestedRepeatSubmission, + 'level1_repeat/level2_repeat/level3_repeat/level4_repeat/deep_value', + ) + + chai.expect(test).to.deep.equal(['alpha', 'beta']) + }) +}) + +describe('getRepeatGroupAnswerTree', () => { + it('should preserve top-level nested repeat branches without string parentheses', () => { + const nestedRepeatForTableDisplay = makeNestedRepeatSubmission([ + ['e', 'f'], + ['g', 'h'], + ]) + + const test = getRepeatGroupAnswerTree(nestedRepeatForTableDisplay, 'outer_repeat/inner_repeat/item_name') + + chai.expect(test).to.deep.equal([ + [textAnswer('e'), textAnswer('f')], + [textAnswer('g'), textAnswer('h')], + ]) + }) + + it('should keep placeholders inside nested branches when includeUnanswered is enabled', () => { + const nestedRepeatWithMissingValues = makeSubmission({ + outer_repeat: [ + { + 'outer_repeat/inner_repeat': [ + { + 'outer_repeat/inner_repeat/item_name': 'e', + }, + { + 'outer_repeat/inner_repeat/item_name': 'f', + }, + ], + }, + { + 'outer_repeat/inner_repeat': [ + { + 'outer_repeat/inner_repeat/item_name': 'g', + }, + { + 'outer_repeat/inner_repeat/item_cost': '100', + }, + ], + }, + ], + }) + + const test = getRepeatGroupAnswerTree(nestedRepeatWithMissingValues, 'outer_repeat/inner_repeat/item_name', { + includeUnanswered: true, + unansweredPlaceholder: '-', + }) + + chai.expect(test).to.deep.equal([ + [textAnswer('e'), textAnswer('f')], + [textAnswer('g'), textAnswer('-')], + ]) + }) + + it('should preserve deeper nested repeat hierarchy for modal rendering', () => { + const multiLevelNestedRepeatSubmission = makeSubmission({ + level1_repeat: [ + { + 'level1_repeat/level2_repeat': [ + { + 'level1_repeat/level2_repeat/level3_repeat': [ + { + 'level1_repeat/level2_repeat/level3_repeat/value': 'alpha', + }, + { + 'level1_repeat/level2_repeat/level3_repeat/value': 'beta', + }, + ], + }, + { + 'level1_repeat/level2_repeat/level3_repeat': [ + { + 'level1_repeat/level2_repeat/level3_repeat/value': 'gamma', + }, + ], + }, + ], + }, + ], + }) + + const test = getRepeatGroupAnswerTree( + multiLevelNestedRepeatSubmission, + 'level1_repeat/level2_repeat/level3_repeat/value', + ) + + chai.expect(test).to.deep.equal([[[textAnswer('alpha'), textAnswer('beta')], [textAnswer('gamma')]]]) + }) + + it('should preserve single-branch depth by default', () => { + const oneBranchPerLevelSubmission = makeSubmission({ + countries: [ + { + 'countries/cities': [ + { + 'countries/cities/streets': [ + { + 'countries/cities/streets/houses': [ + { + 'countries/cities/streets/houses/inhabitants': [ + { + 'countries/cities/streets/houses/inhabitants/name': 'Karl', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }) + + const test = getRepeatGroupAnswerTree( + oneBranchPerLevelSubmission, + 'countries/cities/streets/houses/inhabitants/name', + ) + + chai.expect(test).to.deep.equal([[[[[textAnswer('Karl')]]]]]) + }) + + it('should mark deleted attachment leaves without leaking JSX into the tree', () => { + const submissionWithDeletedRepeatAttachment = makeSubmission({ + _attachments: [ + { + download_url: 'https://example.test/file.jpg', + filename: 'file.jpg', + media_file_basename: 'file.jpg', + mimetype: 'image/jpeg', + question_xpath: 'children[1]/photo', + uid: 'attachment-1', + is_deleted: true, + }, + ], + children: [ + { + 'children/photo': 'file.jpg', + }, + ], + }) + + const test = getRepeatGroupAnswerTree(submissionWithDeletedRepeatAttachment, 'children/photo') + + chai.expect(test).to.deep.equal([deletedAttachmentAnswer()]) + }) +}) diff --git a/jsapp/js/components/submissions/repeatGroupUtils.tsx b/jsapp/js/components/submissions/repeatGroupUtils.tsx new file mode 100644 index 0000000000..35eb98584d --- /dev/null +++ b/jsapp/js/components/submissions/repeatGroupUtils.tsx @@ -0,0 +1,196 @@ +import type * as React from 'react' +import type { DataResponse } from '#/api/models/dataResponse' +import DeletedAttachment from '#/attachments/deletedAttachment.component' +import type { SubmissionResponse, SubmissionResponseValue, SubmissionResponseValueObject } from '#/dataInterface' +import { recordKeys } from '#/utils' +import { getMediaAttachment } from './submissionMediaUtils' + +export interface RepeatGroupAnswerOptions { + /** Keep unanswered repeat iterations visible using a placeholder value. */ + includeUnanswered?: boolean + /** Placeholder used for unanswered repeat iterations. */ + unansweredPlaceholder?: string +} + +export interface RepeatGroupAnswerValueText { + kind: 'text' + value: string +} + +export interface RepeatGroupAnswerValueDeletedAttachment { + kind: 'deleted-attachment' +} + +export type RepeatGroupAnswerValue = RepeatGroupAnswerValueText | RepeatGroupAnswerValueDeletedAttachment + +export type RepeatGroupAnswerTreeNode = RepeatGroupAnswerValue | RepeatGroupAnswerTreeNode[] + +export function formatRepeatGroupAnswerValueToText(answerValue: RepeatGroupAnswerValue): string { + if (answerValue.kind === 'deleted-attachment') { + return t('Deleted attachment') + } + + return answerValue.value +} + +const isSubmissionResponseValueObject = (data: any): data is SubmissionResponseValueObject => { + if (data === null) return false + if (typeof data !== 'object') return false + if (Array.isArray(data)) return false + if (recordKeys(data).length === 0) return false + + return true +} + +/** + * Returns answers for one repeat-group question as a flat list. + * + * This helper exists for older callers that expect a simple flat array. + */ +export function getRepeatGroupAnswers( + responseData: DataResponse | SubmissionResponse, + /** Full (nested) path to a response, e.g. group_person/group_pets/group_pet/pet_name. */ + fullPath: string, + options?: RepeatGroupAnswerOptions, +): React.ReactNode[] { + const answerTree = getRepeatGroupAnswerTree(responseData, fullPath, options) + + const formatAnswerNode = (answerNode: RepeatGroupAnswerTreeNode): React.ReactNode[] => { + if (!Array.isArray(answerNode)) { + return [renderRepeatGroupAnswerValue(answerNode)] + } + + return answerNode.flatMap((childNode) => formatAnswerNode(childNode)) + } + + return answerTree.flatMap((answerNode) => formatAnswerNode(answerNode)) +} + +export function getRepeatGroupAnswerTree( + responseData: DataResponse | SubmissionResponse, + /** Full (nested) path to a response, e.g. group_person/group_pets/group_pet/pet_name. */ + fullPath: string, + options?: RepeatGroupAnswerOptions, +): RepeatGroupAnswerTreeNode[] { + const pathSegments = fullPath.split('/') + const lastPathSegmentIndex = pathSegments.length - 1 + const shouldIncludeUnanswered = options?.includeUnanswered === true + const unansweredPlaceholder = options?.unansweredPlaceholder || '-' + + const lookForAnswers = ( + data: DataResponse | SubmissionResponse | SubmissionResponseValue, + currentDepth = 0, + responseIndex?: number, + allowBareSegmentFallback = false, + ): RepeatGroupAnswerTreeNode[] => { + if (!isSubmissionResponseValueObject(data)) return [] + + // Some submissions store the child repeat key directly on the current + // object, even when part of the path is missing as its own nested object. + // We keep walking forward through the path until we find the first key that + // actually exists in this branch. + let resolvedDepth = currentDepth + let submissionResponseValue: SubmissionResponseValue | undefined + while (resolvedDepth <= lastPathSegmentIndex) { + const resolvedPath = pathSegments.slice(0, resolvedDepth + 1).join('/') + const resolvedSegment = pathSegments[resolvedDepth] + const hasResolvedPath = Object.prototype.hasOwnProperty.call(data, resolvedPath) + const hasResolvedSegment = allowBareSegmentFallback && Object.prototype.hasOwnProperty.call(data, resolvedSegment) + + if (hasResolvedPath || hasResolvedSegment) { + submissionResponseValue = hasResolvedPath ? data[resolvedPath] : data[resolvedSegment] + break + } + + resolvedDepth += 1 + } + + if (typeof submissionResponseValue === 'undefined') return [] + + if (resolvedDepth === lastPathSegmentIndex) { + // Once we reach the last path segment, the value should be the answer for + // this question. If it is an array instead, the saved submission shape no + // longer matches the current form shape, so we skip it instead of + // guessing. + if (Array.isArray(submissionResponseValue)) return [] + + // Attachments inside repeat groups are keyed by a path that includes the + // repeat index, for example `band_member[3]/portrait_photo`. + const responseNumber = responseIndex !== undefined ? responseIndex + 1 : undefined + const levelParentKey = fullPath.split('/').slice(0, resolvedDepth).join('/') + const attachmentPath = appendTextToPathAtLevel(fullPath, levelParentKey, `[${responseNumber}]`) + const attachment = getMediaAttachment(responseData, String(submissionResponseValue), attachmentPath) + + if (typeof attachment === 'object' && attachment?.is_deleted) { + return [{ kind: 'deleted-attachment' }] + } + + return [{ kind: 'text', value: String(submissionResponseValue) }] + } + + if (isSubmissionResponseValueObject(submissionResponseValue)) { + return lookForAnswers(submissionResponseValue, resolvedDepth + 1, responseIndex, true) + } + + // If this branch is not an object or an array, then the saved submission + // shape no longer matches the current form shape. + if (!Array.isArray(submissionResponseValue)) return [] + + const answersByBranch: RepeatGroupAnswerTreeNode[][] = submissionResponseValue.map( + (item: SubmissionResponseValue, itemIndex: number) => lookForAnswers(item, resolvedDepth + 1, itemIndex, true), + ) + + const normalizedAnswersByBranch: RepeatGroupAnswerTreeNode[][] = answersByBranch.map( + (branchAnswers, branchIndex) => { + if (!shouldIncludeUnanswered || branchAnswers.length > 0) { + return branchAnswers + } + + const branchItem = submissionResponseValue[branchIndex] + if (!isSubmissionResponseValueObject(branchItem)) { + return [] + } + + return [{ kind: 'text', value: unansweredPlaceholder }] + }, + ) + + if (resolvedDepth + 2 <= lastPathSegmentIndex) { + const groupedAnswers: RepeatGroupAnswerTreeNode[] = [] + + for (const branch of normalizedAnswersByBranch) { + if (branch.length <= 0) { + continue + } + + groupedAnswers.push(branch) + } + + return groupedAnswers + } + + return normalizedAnswersByBranch.reduce((flattenedAnswers, branch) => { + flattenedAnswers.push(...branch) + return flattenedAnswers + }, []) + } + + return lookForAnswers(responseData) +} + +function appendTextToPathAtLevel(path: string, level: string, stringToAdd: string): string { + const parts = path.split('/') + const index = parts.indexOf(level) + if (index !== -1) { + parts[index] = `${parts[index]}${stringToAdd}` + } + return parts.join('/') +} + +function renderRepeatGroupAnswerValue(answerValue: RepeatGroupAnswerValue): React.ReactNode { + if (answerValue.kind === 'deleted-attachment') { + return + } + + return answerValue.value +} diff --git a/jsapp/js/components/submissions/submissionMediaUtils.ts b/jsapp/js/components/submissions/submissionMediaUtils.ts new file mode 100644 index 0000000000..0d9db67b56 --- /dev/null +++ b/jsapp/js/components/submissions/submissionMediaUtils.ts @@ -0,0 +1,43 @@ +import type { _DataResponseAttachmentsItem } from '#/api/models/_dataResponseAttachmentsItem' +import type { DataResponse } from '#/api/models/dataResponse' +import type { SubmissionAttachment, SubmissionResponse } from '#/dataInterface' + +/** + * Returns an attachment object or an error message. + */ +export function getMediaAttachment( + submission: DataResponse | SubmissionResponse, + fileName: string, + questionXPath: string, +): string | SubmissionAttachment { + let mediaAttachment: string | _DataResponseAttachmentsItem = t('Could not find ##fileName##').replace( + '##fileName##', + fileName, + ) + submission._attachments.forEach((attachment) => { + if (attachment.question_xpath === questionXPath) { + // Check if the audio filetype is of type not supported by player and send it to format to mp3 + if ( + attachment.mimetype!.includes('audio/') && + !attachment.mimetype!.includes('/mp3') && + !attachment.mimetype!.includes('mpeg') && + !attachment.mimetype!.includes('/wav') && + !attachment.mimetype!.includes('ogg') + ) { + const newAudioURL = attachment.download_url + '?format=mp3' + const newAttachment = { + ...attachment, + download_url: newAudioURL, + download_large_url: newAudioURL, + download_medium_url: newAudioURL, + download_small_url: newAudioURL, + mimetype: 'audio/mp3', + } + mediaAttachment = newAttachment + } else { + mediaAttachment = attachment + } + } + }) + return mediaAttachment +} diff --git a/jsapp/js/components/submissions/submissionUtils.tsx b/jsapp/js/components/submissions/submissionUtils.tsx index 502754303b..9b8b0e8a58 100644 --- a/jsapp/js/components/submissions/submissionUtils.tsx +++ b/jsapp/js/components/submissions/submissionUtils.tsx @@ -1,16 +1,13 @@ import clonedeep from 'lodash.clonedeep' import get from 'lodash.get' -import type { _DataResponseAttachmentsItem } from '#/api/models/_dataResponseAttachmentsItem' import type { DataResponse } from '#/api/models/dataResponse' import { getRowName, getSurveyFlatPaths, getTranslatedRowLabel, isRowSpecialLabelHolder } from '#/assetUtils' -import DeletedAttachment from '#/attachments/deletedAttachment.component' import { QUAL_NOTE_TYPE, type SubmissionAnalysisResponse, } from '#/components/processing/SingleProcessingContent/TabAnalysis/common/constants' import { getSupplementalPathParts } from '#/components/processing/processingUtils' -import { getColumnLabel } from '#/components/submissions/tableUtils' -import { getBackgroundAudioQuestionName } from '#/components/submissions/tableUtils' +import { getBackgroundAudioQuestionName, getColumnLabel } from '#/components/submissions/tableUtils' import { CHOICE_LISTS, GROUP_TYPES_BEGIN, @@ -28,12 +25,20 @@ import type { SubmissionAttachment, SubmissionResponse, SubmissionResponseValue, - SubmissionResponseValueObject, SubmissionSupplementalDetails, SurveyChoice, SurveyRow, } from '#/dataInterface' import { recordEntries, recordKeys } from '#/utils' +import { getRepeatGroupAnswers } from './repeatGroupUtils' +import { getMediaAttachment } from './submissionMediaUtils' +export { + getRepeatGroupAnswerTree, + getRepeatGroupAnswers, + type RepeatGroupAnswerOptions, + type RepeatGroupAnswerTreeNode, +} from './repeatGroupUtils' +export { getMediaAttachment } from './submissionMediaUtils' export enum DisplayGroupTypeName { group_root = 'group_root', @@ -506,85 +511,6 @@ function isRowFromCurrentGroupLevel( } } -const isSubmissionResponseValueObject = (data: any): data is SubmissionResponseValueObject => { - if (data === null) return false - if (typeof data !== 'object') return false - if (Array.isArray(data)) return false - if (recordKeys(data).length === 0) return false - - return true -} - -/** - * Returns an array of answers. Will return empty array if no answers found. - * - * Note: this function doesn't include unresponded questions from repeat groups - i.e. if your repeat group `person` has - * `your_name` question, and user submitted 10 `person`s, but only 3 of them have `your_name` answered, this function - * will return an array of 3 items (e.g. `['Joe', 'Moe', 'Zoe']`) rather than an array of 10 items with empty strings - * for unresponded questions (e.g. `['', 'Joe', '', '', '', '', 'Moe', 'Zoe', '', '']`). - * TODO: we might want to change this in future, when we will improve the Data Table UI for repeat groups. - */ -export function getRepeatGroupAnswers( - responseData: DataResponse | SubmissionResponse, - /** Full (nested) path to a response, e.g. group_person/group_pets/group_pet/pet_name. */ - fullPath: string, -): React.ReactNode[] { - // This function is a recursive detective. It goes through nested groups from given path (`targetKey`), looking for - // answers. We are traversing `DataResponse | SubmissionResponse` and it's values (might be nested arrays), going one path level and - // one `responseData` level at a time - verifying if response exist and if needed (nested) going deeper. - const lookForAnswers = ( - data: DataResponse | SubmissionResponse | SubmissionResponseValue, - currentDepth = 0, - responseIndex?: number, - ): Array => { - const currentPath = fullPath - .split('/') - .slice(0, currentDepth + 1) - .join('/') - - if (!isSubmissionResponseValueObject(data)) return [] - - const submissionResponseValue = data[currentPath] - if (!submissionResponseValue) return [] - - if (currentPath === fullPath) { - // At full path `submissionResponseValue` should be an actual response to a repeat group question. - - // Gracefully skip if form has changed over time in a specific way leading to a key-collision. - if (Array.isArray(submissionResponseValue)) return [] - - // To find the attachment, we need to build a question path that includes response number in it. For example, if - // we have repeat group `band_member` with `image` type question `portrait_photo`, then the attachment for third - // member would use `band_member[3]/portrait_photo` path. There might be more complex groups, so let's hope it - // works for them too :fingers_crossed:. - const responseNumber = responseIndex !== undefined ? responseIndex + 1 : undefined - const levelParentKey = fullPath.split('/').slice(0, currentDepth).join('/') - const attachmentPath = appendTextToPathAtLevel(fullPath, levelParentKey, `[${responseNumber}]`) - const attachment = getMediaAttachment(responseData, String(submissionResponseValue), attachmentPath) - - if (typeof attachment === 'object' && attachment?.is_deleted) { - // If we've found the attachment, and it is deleted, we don't want to display it… - return [] - } else { - // …otherwise we are displaying raw data - // TODO: In future we could render something similar to `MediaCell` for each response/attachment here - return [String(submissionResponseValue)] - } - } else { - // Here we go recursively into each item of the array, looking for answers. - - // Gracefully skip if form has changed over time in a specific way leading to a key-collision. - if (!Array.isArray(submissionResponseValue)) return [] - - return submissionResponseValue.flatMap((item: SubmissionResponseValue, itemIndex: number) => - lookForAnswers(item, currentDepth + 1, itemIndex), - ) - } - } - - return lookForAnswers(responseData) -} - /** * Filters data for items inside the group */ @@ -623,46 +549,6 @@ function getRowListName(row: SurveyRow | undefined): string | undefined { return undefined } -/** - * Returns an attachment object or an error message. - */ -export function getMediaAttachment( - submission: DataResponse | SubmissionResponse, - fileName: string, - questionXPath: string, -): string | SubmissionAttachment { - let mediaAttachment: string | _DataResponseAttachmentsItem = t('Could not find ##fileName##').replace( - '##fileName##', - fileName, - ) - submission._attachments.forEach((attachment) => { - if (attachment.question_xpath === questionXPath) { - // Check if the audio filetype is of type not supported by player and send it to format to mp3 - if ( - attachment.mimetype!.includes('audio/') && - !attachment.mimetype!.includes('/mp3') && - !attachment.mimetype!.includes('mpeg') && - !attachment.mimetype!.includes('/wav') && - !attachment.mimetype!.includes('ogg') - ) { - const newAudioURL = attachment.download_url + '?format=mp3' - const newAttachment = { - ...attachment, - download_url: newAudioURL, - download_large_url: newAudioURL, - download_medium_url: newAudioURL, - download_small_url: newAudioURL, - mimetype: 'audio/mp3', - } - mediaAttachment = newAttachment - } else { - mediaAttachment = attachment - } - } - }) - return mediaAttachment -} - /** * Returns supplemental details for given path, * e.g. `_supplementalDetails/question_name/transcript_pl` or @@ -776,21 +662,6 @@ export function getQuestionXPath(surveyRows: SurveyRow[], rowName: string) { return flatPaths[rowName] } -/** - * Inserts given string immediately after the specified level in the path. - * @param path - The original path string. - * @param level - The level after which `stringToAdd` should be inserted. - * @returns The updated path string. - */ -function appendTextToPathAtLevel(path: string, level: string, stringToAdd: string): string { - const parts = path.split('/') - const index = parts.indexOf(level) - if (index !== -1) { - parts[index] = `${parts[index]}${stringToAdd}` - } - return parts.join('/') -} - /** * In given submission data, it finds provided attachment, sets its `is_deleted` * flag to `true` and then returns the updated submission data. diff --git a/jsapp/js/components/submissions/table.tsx b/jsapp/js/components/submissions/table.tsx index 293c42277b..504d24570b 100644 --- a/jsapp/js/components/submissions/table.tsx +++ b/jsapp/js/components/submissions/table.tsx @@ -57,6 +57,7 @@ import { getColumnLabel, isTableColumnFilterableByDropdown, isTableColumnFilterableByTextInput, + selectNestedRow, } from '#/components/submissions/tableUtils' import type { ValidationStatusOption, @@ -566,16 +567,6 @@ export class DataTable extends React.Component { this.openBulkProcessingModal(fieldId, 'approve') } - // We need to distinguish between repeated groups with nested values - // and other question types that use a flat nested key (i.e. with '/'). - // If submission response contains the parent key, we should use that. - _selectNestedRow(row: SubmissionResponse, key: string, rootParentGroup: string | undefined) { - if (rootParentGroup && rootParentGroup in row && !key.startsWith(SUPPLEMENTAL_DETAILS_PROP)) { - return row[rootParentGroup] - } - return row[key] - } - _getColumnWidth(columnId: AnyRowTypeName | string | undefined) { if (!columnId) { return DEFAULT_DATA_CELL_WIDTH @@ -1012,7 +1003,7 @@ export class DataTable extends React.Component { ) }, id: key, - accessor: (row) => this._selectNestedRow(row, key, rootParentGroup), + accessor: (row) => selectNestedRow(row, key, rootParentGroup), index: index, question: q, // This (and the Filter itself) will be set below (we do it separately, diff --git a/jsapp/js/components/submissions/tableUtils.tests.ts b/jsapp/js/components/submissions/tableUtils.tests.ts index 9d4db79614..777ba157c2 100644 --- a/jsapp/js/components/submissions/tableUtils.tests.ts +++ b/jsapp/js/components/submissions/tableUtils.tests.ts @@ -1,5 +1,6 @@ -import { QuestionTypeName } from '#/constants' -import { getColumnLabel, isTableColumnFilterableByTextInput } from './tableUtils' +import { QuestionTypeName, SUPPLEMENTAL_DETAILS_PROP } from '#/constants' +import type { SubmissionResponse } from '#/dataInterface' +import { getColumnLabel, isTableColumnFilterableByTextInput, selectNestedRow } from './tableUtils' import { assetWithBgAudioAndNLP, assetWithNestedGroupsAndNLP } from './tableUtils.mocks' describe('tableUtils', () => { @@ -76,4 +77,63 @@ describe('tableUtils', () => { chai.expect(test).to.equal(false) }) }) + + describe('selectNestedRow', () => { + it('should return exact key value when present', () => { + const row = { + 'group_a/group_b/question': 'value-from-exact-key', + } as unknown as SubmissionResponse + + const test = selectNestedRow(row, 'group_a/group_b/question', 'group_a') + + chai.expect(test).to.equal('value-from-exact-key') + }) + + it('should always use exact key for supplemental details', () => { + const supplementalKey = `${SUPPLEMENTAL_DETAILS_PROP}/audio/transcript_en` + const row = { + [supplementalKey]: 'bonjour', + audio: { + transcript_en: 'should-not-be-used', + }, + } as unknown as SubmissionResponse + + const test = selectNestedRow(row, supplementalKey, 'audio') + + chai.expect(test).to.equal('bonjour') + }) + + it('should return nearest parent container when exact nested key is missing', () => { + const parentContainer = [{ 'group_a/group_b/question': 'a' }, { 'group_a/group_b/question': 'b' }] + const row = { + 'group_a/group_b': parentContainer, + } as unknown as SubmissionResponse + + const test = selectNestedRow(row, 'group_a/group_b/question', 'group_a') + + chai.expect(test).to.equal(parentContainer) + }) + + it('should ignore scalar parent fallback and keep searching for valid container', () => { + const rootContainer = [{ 'group_a/question': 'a' }] + const row = { + 'group_a/group_b': 'scalar-value', + group_a: rootContainer, + } as unknown as SubmissionResponse + + const test = selectNestedRow(row, 'group_a/group_b/question', 'group_a') + + chai.expect(test).to.equal(rootContainer) + }) + + it('should return undefined when neither exact key nor valid parent containers exist', () => { + const row = { + group_a: 'not-a-container', + } as unknown as SubmissionResponse + + const test = selectNestedRow(row, 'group_a/group_b/question', 'group_a') + + chai.expect(test).to.equal(undefined) + }) + }) }) diff --git a/jsapp/js/components/submissions/tableUtils.ts b/jsapp/js/components/submissions/tableUtils.ts index d8055903bc..d449b37b8f 100644 --- a/jsapp/js/components/submissions/tableUtils.ts +++ b/jsapp/js/components/submissions/tableUtils.ts @@ -165,6 +165,52 @@ export function getBackgroundAudioQuestionName(asset: AssetResponse): string | n return asset?.content?.survey?.find((item) => item.type === QUESTION_TYPES['background-audio'].id)?.name || null } +/** + * Selects a value for a Data Table column key from submission data. + * + * For repeat/group data, some submissions store answers under a parent path + * (e.g. `group_a/group_b`) rather than the full question key + * (`group_a/group_b/question`). This helper returns the closest matching + * container payload in such cases. + */ +export function selectNestedRow(row: SubmissionResponse, key: string, rootParentGroup: string | undefined) { + // Supplemental details should always use exact keys. + if (key.startsWith(SUPPLEMENTAL_DETAILS_PROP)) { + return row[key] + } + + // Prefer exact key lookup whenever available. + if (Object.prototype.hasOwnProperty.call(row, key)) { + return row[key] + } + + // If exact key is missing, find the closest existing parent path. + // Example: key `group_a/group_b/question` with data stored at `group_a/group_b`. + const keyPathSegments = key.split('/') + for (let i = keyPathSegments.length - 1; i >= 1; i--) { + const parentPath = keyPathSegments.slice(0, i).join('/') + if (Object.prototype.hasOwnProperty.call(row, parentPath)) { + const parentValue = row[parentPath] + + // Parent fallback is only valid for container-like values (repeat/group payloads). + // This avoids accidentally targeting an unrelated scalar from a shorter matching path. + if (Array.isArray(parentValue) || (typeof parentValue === 'object' && parentValue !== null)) { + return parentValue + } + } + } + + // Backward-compatible fallback for root-level repeat groups. + if (rootParentGroup && Object.prototype.hasOwnProperty.call(row, rootParentGroup)) { + const rootParentValue = row[rootParentGroup] + if (Array.isArray(rootParentValue) || (typeof rootParentValue === 'object' && rootParentValue !== null)) { + return rootParentValue + } + } + + return row[key] +} + /** * Returns a complete and unique list of columns (keys) that contain displayable * data that is useful for users.