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.