From 419f0e2bfd7e5c9c9d6058274d39f42a42864883 Mon Sep 17 00:00:00 2001 From: Leszek Date: Wed, 8 Jul 2026 09:49:16 +0200 Subject: [PATCH 01/10] fix repeat groups answers displaying --- .../DataTableCell/RepeatGroupCell.tsx | 2 +- .../submissions/DataTableCell/index.tsx | 8 +- .../submissions/submissionUtils.tests.ts | 198 +++++++++++++++++- .../submissions/submissionUtils.tsx | 57 ++++- jsapp/js/components/submissions/table.tsx | 13 +- .../submissions/tableUtils.tests.ts | 67 +++++- jsapp/js/components/submissions/tableUtils.ts | 46 ++++ 7 files changed, 364 insertions(+), 27 deletions(-) diff --git a/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.tsx b/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.tsx index 52b832b77e..e4d53f5326 100644 --- a/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.tsx +++ b/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.tsx @@ -12,7 +12,7 @@ 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 (
diff --git a/jsapp/js/components/submissions/DataTableCell/index.tsx b/jsapp/js/components/submissions/DataTableCell/index.tsx index d8e20381c4..c9e8666ec0 100644 --- a/jsapp/js/components/submissions/DataTableCell/index.tsx +++ b/jsapp/js/components/submissions/DataTableCell/index.tsx @@ -47,7 +47,13 @@ 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' || + (props.reactTableRow.value === undefined && props.columnKey.includes('/'))) + ) { return } diff --git a/jsapp/js/components/submissions/submissionUtils.tests.ts b/jsapp/js/components/submissions/submissionUtils.tests.ts index 8e0f94ad2a..482f2536c9 100644 --- a/jsapp/js/components/submissions/submissionUtils.tests.ts +++ b/jsapp/js/components/submissions/submissionUtils.tests.ts @@ -1,6 +1,7 @@ import assetDataFactory from '#/endpoints/assetData.factory' import { getMediaAttachment, + getRepeatGroupAnswers, getSubmissionDisplayData, getSupplementalDetailsContent, hasUnacceptedAutomaticContent, @@ -57,7 +58,7 @@ chai.use(chaiExclude) // After a recent chai / deep-eql update, tests relying on this behavior would // fail. Hence, use this looser comparison function. import chaiDeepEqualIgnoreUndefined from 'chai-deep-equal-ignore-undefined' -import type { SubmissionSupplementalDetails } from '#/dataInterface' +import type { SubmissionResponse, SubmissionSupplementalDetails } from '#/dataInterface' chai.use(chaiDeepEqualIgnoreUndefined) describe('getSubmissionDisplayData', () => { @@ -144,6 +145,201 @@ describe('getMediaAttachment', () => { }) }) +describe('getRepeatGroupAnswers', () => { + it('should return values for a repeat group in root', () => { + const rootRepeatSubmission = { + _attachments: [], + children: [ + { + 'children/name': 'Ada', + }, + { + 'children/name': 'Grace', + }, + ], + } as unknown as SubmissionResponse + + 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 = { + _attachments: [], + family: { + 'family/children': [ + { + 'family/children/name': 'Ada', + }, + { + 'family/children/name': 'Grace', + }, + ], + }, + } as unknown as SubmissionResponse + + 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 = { + _attachments: [], + other_group: { + 'other_group/name': 'Nope', + }, + } as unknown as SubmissionResponse + + const test = getRepeatGroupAnswers(submissionWithoutRepeat, 'children/name') + + chai.expect(test).to.deep.equal([]) + }) + + it('should skip unanswered iterations and keep answered values', () => { + const partiallyAnsweredRepeatSubmission = { + _attachments: [], + children: [ + { + 'children/name': 'Ada', + }, + { + 'children/age': '11', + }, + { + 'children/name': 'Grace', + }, + ], + } as unknown as SubmissionResponse + + const test = getRepeatGroupAnswers(partiallyAnsweredRepeatSubmission, 'children/name') + + chai.expect(test).to.deep.equal(['Ada', 'Grace']) + }) + + it('should ignore invalid repeat items and return values from valid objects', () => { + const mixedRepeatSubmission = { + _attachments: [], + children: [ + { + 'children/name': 'Ada', + }, + null, + 'unexpected-string-item', + { + 'children/name': 'Grace', + }, + ], + } as unknown as SubmissionResponse + + 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 = { + _attachments: [], + 'regular_group/nested_repeat': [ + { + 'regular_group/nested_repeat/nested_text': 'c', + }, + { + 'regular_group/nested_repeat/nested_text': 'd', + }, + ], + } as unknown as SubmissionResponse + + const test = getRepeatGroupAnswers(rootLevelGroupedRepeatSubmission, 'regular_group/nested_repeat/nested_text') + + chai.expect(test).to.deep.equal(['c', 'd']) + }) + + it('should group nested repeat answers by outer repeat iteration for readability', () => { + const nestedRepeatForTableDisplay = { + _attachments: [], + 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_name': 'h', + }, + ], + }, + ], + } as unknown as SubmissionResponse + + const test = getRepeatGroupAnswers(nestedRepeatForTableDisplay, 'outer_repeat/inner_repeat/item_name') + + chai.expect(test).to.deep.equal(['(e, f)', '(g, h)']) + }) + + it('should return values from repeat groups nested 4 levels deep', () => { + const fourLevelNestedRepeatSubmission = { + _attachments: [], + 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', + }, + ], + }, + ], + }, + ], + }, + ], + } as unknown as SubmissionResponse + + const test = getRepeatGroupAnswers( + fourLevelNestedRepeatSubmission, + 'level1_repeat/level2_repeat/level3_repeat/level4_repeat/deep_value', + ) + + chai.expect(test).to.deep.equal(['alpha', 'beta']) + }) +}) + describe('getSupplementalDetailsContent', () => { it('should return transcript value properly', () => { const test = getSupplementalDetailsContent( diff --git a/jsapp/js/components/submissions/submissionUtils.tsx b/jsapp/js/components/submissions/submissionUtils.tsx index 502754303b..29aaf933a4 100644 --- a/jsapp/js/components/submissions/submissionUtils.tsx +++ b/jsapp/js/components/submissions/submissionUtils.tsx @@ -529,6 +529,9 @@ export function getRepeatGroupAnswers( /** Full (nested) path to a response, e.g. group_person/group_pets/group_pet/pet_name. */ fullPath: string, ): React.ReactNode[] { + const pathSegments = fullPath.split('/') + const lastPathSegmentIndex = pathSegments.length - 1 + // 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. @@ -537,17 +540,29 @@ export function getRepeatGroupAnswers( 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 [] + // Some submissions keep grouped-repeat keys under a deeper full prefix directly on parent object, + // so we may need to jump over missing path levels. + 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 = Object.prototype.hasOwnProperty.call(data, resolvedSegment) + + if (hasResolvedPath || hasResolvedSegment) { + submissionResponseValue = hasResolvedPath ? data[resolvedPath] : data[resolvedSegment] + break + } - if (currentPath === fullPath) { + resolvedDepth += 1 + } + + if (typeof submissionResponseValue === 'undefined') return [] + + if (resolvedDepth === lastPathSegmentIndex) { // 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. @@ -558,7 +573,7 @@ export function getRepeatGroupAnswers( // 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 levelParentKey = fullPath.split('/').slice(0, resolvedDepth).join('/') const attachmentPath = appendTextToPathAtLevel(fullPath, levelParentKey, `[${responseNumber}]`) const attachment = getMediaAttachment(responseData, String(submissionResponseValue), attachmentPath) @@ -573,12 +588,32 @@ export function getRepeatGroupAnswers( } else { // Here we go recursively into each item of the array, looking for answers. + if (isSubmissionResponseValueObject(submissionResponseValue)) { + return lookForAnswers(submissionResponseValue, resolvedDepth + 1, responseIndex) + } + // 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), + const answersByBranch = submissionResponseValue.map((item: SubmissionResponseValue, itemIndex: number) => + lookForAnswers(item, resolvedDepth + 1, itemIndex), ) + + const shouldGroupByBranch = + resolvedDepth + 2 <= lastPathSegmentIndex && + answersByBranch.some((branch) => branch.length > 1) && + answersByBranch.every((branch) => branch.every((answer) => typeof answer === 'string')) + + if (shouldGroupByBranch) { + return answersByBranch.flatMap((branch) => { + if (branch.length <= 0) return [] + if (branch.length === 1) return branch + + return [`(${branch.join(', ')})`] + }) + } + + return answersByBranch.flat() } } 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..989719f2c3 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,66 @@ 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..3d8dbab658 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 (key in row) { + 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 (parentPath in row) { + 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 && rootParentGroup in row) { + 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. From 009391e1978aa14099ce1748ede487dd3c84eb1e Mon Sep 17 00:00:00 2001 From: Leszek Date: Wed, 8 Jul 2026 16:18:43 +0200 Subject: [PATCH 02/10] improve repeat group handling in data table --- .../DataTableCell/RepeatGroupCell.module.scss | 20 - .../DataTableCell/RepeatGroupCell.tsx | 63 ++- .../DataTableCell/TextModalCell.tsx | 3 +- .../submissions/DataTableCell/index.tsx | 10 +- .../submissions/repeatGroupUtils.tests.ts | 424 ++++++++++++++++++ .../submissions/repeatGroupUtils.tsx | 216 +++++++++ .../submissions/submissionMediaUtils.ts | 43 ++ .../submissions/submissionUtils.tests.ts | 198 +------- .../submissions/submissionUtils.tsx | 184 +------- .../submissions/tableUtils.tests.ts | 5 +- 10 files changed, 758 insertions(+), 408 deletions(-) delete mode 100644 jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.module.scss create mode 100644 jsapp/js/components/submissions/repeatGroupUtils.tests.ts create mode 100644 jsapp/js/components/submissions/repeatGroupUtils.tsx create mode 100644 jsapp/js/components/submissions/submissionMediaUtils.ts diff --git a/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.module.scss b/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.module.scss deleted file mode 100644 index 0e3ec3d681..0000000000 --- a/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.module.scss +++ /dev/null @@ -1,20 +0,0 @@ -@use 'scss/mixins'; - -.repeatGroupCell { - @include mixins.textEllipsis; - - width: 100%; - height: 100%; - align-content: center; - - > * { - display: inline; - } -} - -@media print { - .repeatGroupCell { - // 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 e4d53f5326..9b2d0867a9 100644 --- a/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.tsx +++ b/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.tsx @@ -1,27 +1,68 @@ +import { List } from '@mantine/core' import type { SubmissionResponse } from '#/dataInterface' -import { getRepeatGroupAnswers } from '../submissionUtils' -import styles from './RepeatGroupCell.module.scss' +import { + type RepeatGroupAnswerTreeNode, + formatRepeatGroupAnswerValueToText, + getRepeatGroupAnswerTree, +} from '../repeatGroupUtils' +import TextModalCell from './TextModalCell' interface RepeatGroupCellProps { submissionData: SubmissionResponse rowName: string + columnName: string + submissionIndex: number + submissionTotal: number } /** * Displays a list of answers from a repeat group question. */ export default function RepeatGroupCell(props: RepeatGroupCellProps) { - const repeatGroupAnswers = getRepeatGroupAnswers(props.submissionData, props.rowName) + const repeatGroupAnswers = getRepeatGroupAnswerTree(props.submissionData, props.rowName) if (!repeatGroupAnswers || repeatGroupAnswers.length <= 0) return null - return ( -
- {repeatGroupAnswers.map((answer, i) => ( - - {i > 0 && ', '} - {answer} - + const formatIndexPath = (indexPath: number[]): string => `${indexPath.join('.')}.` + + const formatInlineAnswer = (answerNode: RepeatGroupAnswerTreeNode, indexPath: number[]): string => { + if (!Array.isArray(answerNode)) { + return `${formatIndexPath(indexPath)} ${formatRepeatGroupAnswerValueToText(answerNode)}` + } + + return answerNode + .map((childNode, childIndex) => formatInlineAnswer(childNode, [...indexPath, childIndex + 1])) + .join('; ') + } + + const collectIndexedLeafAnswers = (answerNode: RepeatGroupAnswerTreeNode, indexPath: number[]): string[] => { + if (!Array.isArray(answerNode)) { + return [`${formatIndexPath(indexPath)} ${formatRepeatGroupAnswerValueToText(answerNode)}`] + } + + return answerNode.flatMap((childNode, childIndex) => + collectIndexedLeafAnswers(childNode, [...indexPath, childIndex + 1]), + ) + } + + const inlineText = repeatGroupAnswers.map((answer, i) => formatInlineAnswer(answer, [i + 1])).join('; ') + + const modalRows = repeatGroupAnswers.flatMap((answer, i) => collectIndexedLeafAnswers(answer, [i + 1])) + + const modalContent = ( + + {modalRows.map((rowText, i) => ( + {rowText} ))} -
+ + ) + + return ( + ) } 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 c9e8666ec0..be1fe3a105 100644 --- a/jsapp/js/components/submissions/DataTableCell/index.tsx +++ b/jsapp/js/components/submissions/DataTableCell/index.tsx @@ -54,7 +54,15 @@ export default function DataTableCell(props: DataTableCellProps) { (typeof props.reactTableRow.value === 'object' || (props.reactTableRow.value === undefined && props.columnKey.includes('/'))) ) { - return + return ( + + ) } if (props.question && props.question.type && props.reactTableRow.value) { diff --git a/jsapp/js/components/submissions/repeatGroupUtils.tests.ts b/jsapp/js/components/submissions/repeatGroupUtils.tests.ts new file mode 100644 index 0000000000..e448ff7f67 --- /dev/null +++ b/jsapp/js/components/submissions/repeatGroupUtils.tests.ts @@ -0,0 +1,424 @@ +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 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 group nested repeat answers by outer repeat iteration for readability', () => { + 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 keep nested grouping and show placeholder 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..cb28cfc999 --- /dev/null +++ b/jsapp/js/components/submissions/repeatGroupUtils.tsx @@ -0,0 +1,216 @@ +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[] + +function isRepeatGroupAnswerValueText(answerValue: RepeatGroupAnswerValue): answerValue is RepeatGroupAnswerValueText { + return answerValue.kind === 'text' +} + +export function formatRepeatGroupAnswerValueToText(answerValue: RepeatGroupAnswerValue): string { + if (answerValue.kind === 'deleted-attachment') { + return t('Deleted attachment') + } + + return answerValue.value +} + +function isRepeatGroupAnswerValueTextNode( + answerNode: RepeatGroupAnswerTreeNode, +): answerNode is RepeatGroupAnswerValueText { + return !Array.isArray(answerNode) && isRepeatGroupAnswerValueText(answerNode) +} + +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. Nested + * repeat groups are still grouped into strings like `(a, b)` so the caller can + * tell which nested answers came from the same parent iteration. + */ +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)] + } + + const textChildren = answerNode.filter(isRepeatGroupAnswerValueTextNode) + + if (textChildren.length === answerNode.length) { + if (textChildren.length === 1) { + return [textChildren[0].value] + } + + return [`(${textChildren.map((childNode) => childNode.value).join(', ')})`] + } + + 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, + ): 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 = 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) + } + + // 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), + ) + + 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.tests.ts b/jsapp/js/components/submissions/submissionUtils.tests.ts index 482f2536c9..8e0f94ad2a 100644 --- a/jsapp/js/components/submissions/submissionUtils.tests.ts +++ b/jsapp/js/components/submissions/submissionUtils.tests.ts @@ -1,7 +1,6 @@ import assetDataFactory from '#/endpoints/assetData.factory' import { getMediaAttachment, - getRepeatGroupAnswers, getSubmissionDisplayData, getSupplementalDetailsContent, hasUnacceptedAutomaticContent, @@ -58,7 +57,7 @@ chai.use(chaiExclude) // After a recent chai / deep-eql update, tests relying on this behavior would // fail. Hence, use this looser comparison function. import chaiDeepEqualIgnoreUndefined from 'chai-deep-equal-ignore-undefined' -import type { SubmissionResponse, SubmissionSupplementalDetails } from '#/dataInterface' +import type { SubmissionSupplementalDetails } from '#/dataInterface' chai.use(chaiDeepEqualIgnoreUndefined) describe('getSubmissionDisplayData', () => { @@ -145,201 +144,6 @@ describe('getMediaAttachment', () => { }) }) -describe('getRepeatGroupAnswers', () => { - it('should return values for a repeat group in root', () => { - const rootRepeatSubmission = { - _attachments: [], - children: [ - { - 'children/name': 'Ada', - }, - { - 'children/name': 'Grace', - }, - ], - } as unknown as SubmissionResponse - - 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 = { - _attachments: [], - family: { - 'family/children': [ - { - 'family/children/name': 'Ada', - }, - { - 'family/children/name': 'Grace', - }, - ], - }, - } as unknown as SubmissionResponse - - 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 = { - _attachments: [], - other_group: { - 'other_group/name': 'Nope', - }, - } as unknown as SubmissionResponse - - const test = getRepeatGroupAnswers(submissionWithoutRepeat, 'children/name') - - chai.expect(test).to.deep.equal([]) - }) - - it('should skip unanswered iterations and keep answered values', () => { - const partiallyAnsweredRepeatSubmission = { - _attachments: [], - children: [ - { - 'children/name': 'Ada', - }, - { - 'children/age': '11', - }, - { - 'children/name': 'Grace', - }, - ], - } as unknown as SubmissionResponse - - const test = getRepeatGroupAnswers(partiallyAnsweredRepeatSubmission, 'children/name') - - chai.expect(test).to.deep.equal(['Ada', 'Grace']) - }) - - it('should ignore invalid repeat items and return values from valid objects', () => { - const mixedRepeatSubmission = { - _attachments: [], - children: [ - { - 'children/name': 'Ada', - }, - null, - 'unexpected-string-item', - { - 'children/name': 'Grace', - }, - ], - } as unknown as SubmissionResponse - - 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 = { - _attachments: [], - 'regular_group/nested_repeat': [ - { - 'regular_group/nested_repeat/nested_text': 'c', - }, - { - 'regular_group/nested_repeat/nested_text': 'd', - }, - ], - } as unknown as SubmissionResponse - - const test = getRepeatGroupAnswers(rootLevelGroupedRepeatSubmission, 'regular_group/nested_repeat/nested_text') - - chai.expect(test).to.deep.equal(['c', 'd']) - }) - - it('should group nested repeat answers by outer repeat iteration for readability', () => { - const nestedRepeatForTableDisplay = { - _attachments: [], - 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_name': 'h', - }, - ], - }, - ], - } as unknown as SubmissionResponse - - const test = getRepeatGroupAnswers(nestedRepeatForTableDisplay, 'outer_repeat/inner_repeat/item_name') - - chai.expect(test).to.deep.equal(['(e, f)', '(g, h)']) - }) - - it('should return values from repeat groups nested 4 levels deep', () => { - const fourLevelNestedRepeatSubmission = { - _attachments: [], - 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', - }, - ], - }, - ], - }, - ], - }, - ], - } as unknown as SubmissionResponse - - const test = getRepeatGroupAnswers( - fourLevelNestedRepeatSubmission, - 'level1_repeat/level2_repeat/level3_repeat/level4_repeat/deep_value', - ) - - chai.expect(test).to.deep.equal(['alpha', 'beta']) - }) -}) - describe('getSupplementalDetailsContent', () => { it('should return transcript value properly', () => { const test = getSupplementalDetailsContent( diff --git a/jsapp/js/components/submissions/submissionUtils.tsx b/jsapp/js/components/submissions/submissionUtils.tsx index 29aaf933a4..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,120 +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[] { - const pathSegments = fullPath.split('/') - const lastPathSegmentIndex = pathSegments.length - 1 - - // 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 => { - if (!isSubmissionResponseValueObject(data)) return [] - - // Some submissions keep grouped-repeat keys under a deeper full prefix directly on parent object, - // so we may need to jump over missing path levels. - 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 = 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) { - // 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, resolvedDepth).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. - - if (isSubmissionResponseValueObject(submissionResponseValue)) { - return lookForAnswers(submissionResponseValue, resolvedDepth + 1, responseIndex) - } - - // Gracefully skip if form has changed over time in a specific way leading to a key-collision. - if (!Array.isArray(submissionResponseValue)) return [] - - const answersByBranch = submissionResponseValue.map((item: SubmissionResponseValue, itemIndex: number) => - lookForAnswers(item, resolvedDepth + 1, itemIndex), - ) - - const shouldGroupByBranch = - resolvedDepth + 2 <= lastPathSegmentIndex && - answersByBranch.some((branch) => branch.length > 1) && - answersByBranch.every((branch) => branch.every((answer) => typeof answer === 'string')) - - if (shouldGroupByBranch) { - return answersByBranch.flatMap((branch) => { - if (branch.length <= 0) return [] - if (branch.length === 1) return branch - - return [`(${branch.join(', ')})`] - }) - } - - return answersByBranch.flat() - } - } - - return lookForAnswers(responseData) -} - /** * Filters data for items inside the group */ @@ -658,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 @@ -811,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/tableUtils.tests.ts b/jsapp/js/components/submissions/tableUtils.tests.ts index 989719f2c3..777ba157c2 100644 --- a/jsapp/js/components/submissions/tableUtils.tests.ts +++ b/jsapp/js/components/submissions/tableUtils.tests.ts @@ -104,10 +104,7 @@ describe('tableUtils', () => { }) 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 parentContainer = [{ 'group_a/group_b/question': 'a' }, { 'group_a/group_b/question': 'b' }] const row = { 'group_a/group_b': parentContainer, } as unknown as SubmissionResponse From e0e0cd1af02d9e7385d1e4d752f2b0d8e463fb43 Mon Sep 17 00:00:00 2001 From: Leszek Date: Wed, 8 Jul 2026 17:06:54 +0200 Subject: [PATCH 03/10] cr fixes --- .../submissions/DataTableCell/index.tsx | 19 +++++++++++++++++-- .../submissions/repeatGroupUtils.tests.ts | 10 ++++++++++ .../submissions/repeatGroupUtils.tsx | 7 ++++--- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/jsapp/js/components/submissions/DataTableCell/index.tsx b/jsapp/js/components/submissions/DataTableCell/index.tsx index be1fe3a105..77fa5cf4d6 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 && @@ -51,8 +67,7 @@ export default function DataTableCell(props: DataTableCellProps) { // undefined even though repeat data exists in the submission payload. if ( !props.columnKey.startsWith(SUPPLEMENTAL_DETAILS_PROP) && - (typeof props.reactTableRow.value === 'object' || - (props.reactTableRow.value === undefined && props.columnKey.includes('/'))) + (typeof props.reactTableRow.value === 'object' || shouldRenderUndefinedNestedKeyAsRepeat) ) { return ( { 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', diff --git a/jsapp/js/components/submissions/repeatGroupUtils.tsx b/jsapp/js/components/submissions/repeatGroupUtils.tsx index cb28cfc999..3a4949ffde 100644 --- a/jsapp/js/components/submissions/repeatGroupUtils.tsx +++ b/jsapp/js/components/submissions/repeatGroupUtils.tsx @@ -102,6 +102,7 @@ export function getRepeatGroupAnswerTree( data: DataResponse | SubmissionResponse | SubmissionResponseValue, currentDepth = 0, responseIndex?: number, + allowBareSegmentFallback = false, ): RepeatGroupAnswerTreeNode[] => { if (!isSubmissionResponseValueObject(data)) return [] @@ -115,7 +116,7 @@ export function getRepeatGroupAnswerTree( const resolvedPath = pathSegments.slice(0, resolvedDepth + 1).join('/') const resolvedSegment = pathSegments[resolvedDepth] const hasResolvedPath = Object.prototype.hasOwnProperty.call(data, resolvedPath) - const hasResolvedSegment = Object.prototype.hasOwnProperty.call(data, resolvedSegment) + const hasResolvedSegment = allowBareSegmentFallback && Object.prototype.hasOwnProperty.call(data, resolvedSegment) if (hasResolvedPath || hasResolvedSegment) { submissionResponseValue = hasResolvedPath ? data[resolvedPath] : data[resolvedSegment] @@ -149,7 +150,7 @@ export function getRepeatGroupAnswerTree( } if (isSubmissionResponseValueObject(submissionResponseValue)) { - return lookForAnswers(submissionResponseValue, resolvedDepth + 1, responseIndex) + return lookForAnswers(submissionResponseValue, resolvedDepth + 1, responseIndex, true) } // If this branch is not an object or an array, then the saved submission @@ -157,7 +158,7 @@ export function getRepeatGroupAnswerTree( if (!Array.isArray(submissionResponseValue)) return [] const answersByBranch: RepeatGroupAnswerTreeNode[][] = submissionResponseValue.map( - (item: SubmissionResponseValue, itemIndex: number) => lookForAnswers(item, resolvedDepth + 1, itemIndex), + (item: SubmissionResponseValue, itemIndex: number) => lookForAnswers(item, resolvedDepth + 1, itemIndex, true), ) const normalizedAnswersByBranch: RepeatGroupAnswerTreeNode[][] = answersByBranch.map( From 0da88f2817635809d8101b67fea41cf937c5e4f8 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Wed, 8 Jul 2026 16:08:37 +0100 Subject: [PATCH 04/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- jsapp/js/components/submissions/tableUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsapp/js/components/submissions/tableUtils.ts b/jsapp/js/components/submissions/tableUtils.ts index 3d8dbab658..81a6aa9c24 100644 --- a/jsapp/js/components/submissions/tableUtils.ts +++ b/jsapp/js/components/submissions/tableUtils.ts @@ -180,7 +180,7 @@ export function selectNestedRow(row: SubmissionResponse, key: string, rootParent } // Prefer exact key lookup whenever available. - if (key in row) { + if (Object.prototype.hasOwnProperty.call(row, key)) { return row[key] } From c75bf0135f1990a64b23e729670186644fe3e20b Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Wed, 8 Jul 2026 16:08:47 +0100 Subject: [PATCH 05/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- jsapp/js/components/submissions/tableUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsapp/js/components/submissions/tableUtils.ts b/jsapp/js/components/submissions/tableUtils.ts index 81a6aa9c24..a7d7d5a8a5 100644 --- a/jsapp/js/components/submissions/tableUtils.ts +++ b/jsapp/js/components/submissions/tableUtils.ts @@ -189,7 +189,7 @@ export function selectNestedRow(row: SubmissionResponse, key: string, rootParent const keyPathSegments = key.split('/') for (let i = keyPathSegments.length - 1; i >= 1; i--) { const parentPath = keyPathSegments.slice(0, i).join('/') - if (parentPath in row) { + if (Object.prototype.hasOwnProperty.call(row, parentPath)) { const parentValue = row[parentPath] // Parent fallback is only valid for container-like values (repeat/group payloads). From ee98951b6884790d7c22c047888f2dd990b57e0a Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Wed, 8 Jul 2026 16:08:55 +0100 Subject: [PATCH 06/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- jsapp/js/components/submissions/tableUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsapp/js/components/submissions/tableUtils.ts b/jsapp/js/components/submissions/tableUtils.ts index a7d7d5a8a5..d449b37b8f 100644 --- a/jsapp/js/components/submissions/tableUtils.ts +++ b/jsapp/js/components/submissions/tableUtils.ts @@ -201,7 +201,7 @@ export function selectNestedRow(row: SubmissionResponse, key: string, rootParent } // Backward-compatible fallback for root-level repeat groups. - if (rootParentGroup && rootParentGroup in row) { + if (rootParentGroup && Object.prototype.hasOwnProperty.call(row, rootParentGroup)) { const rootParentValue = row[rootParentGroup] if (Array.isArray(rootParentValue) || (typeof rootParentValue === 'object' && rootParentValue !== null)) { return rootParentValue From 4b1f0157092e280cfacf6b8a7996d17b8972c089 Mon Sep 17 00:00:00 2001 From: Leszek Date: Wed, 8 Jul 2026 17:09:37 +0200 Subject: [PATCH 07/10] cr fix --- jsapp/js/components/submissions/repeatGroupUtils.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/jsapp/js/components/submissions/repeatGroupUtils.tsx b/jsapp/js/components/submissions/repeatGroupUtils.tsx index 3a4949ffde..bd243c52d8 100644 --- a/jsapp/js/components/submissions/repeatGroupUtils.tsx +++ b/jsapp/js/components/submissions/repeatGroupUtils.tsx @@ -1,3 +1,4 @@ +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' From 6b215a8b6f8b2dbe3d3a4560a5be873eb80ef4f0 Mon Sep 17 00:00:00 2001 From: Leszek Date: Wed, 8 Jul 2026 17:10:41 +0200 Subject: [PATCH 08/10] cr fix --- .../DataTableCell/RepeatGroupCell.tsx | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.tsx b/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.tsx index 9b2d0867a9..3a03e0b5dd 100644 --- a/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.tsx +++ b/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.tsx @@ -15,34 +15,36 @@ interface RepeatGroupCellProps { submissionTotal: number } -/** - * Displays a list of answers from a repeat group question. - */ -export default function RepeatGroupCell(props: RepeatGroupCellProps) { - const repeatGroupAnswers = getRepeatGroupAnswerTree(props.submissionData, props.rowName) - if (!repeatGroupAnswers || repeatGroupAnswers.length <= 0) return null +function formatIndexPath(indexPath: number[]): string { + return `${indexPath.join('.')}.` +} - const formatIndexPath = (indexPath: number[]): string => `${indexPath.join('.')}.` +function formatInlineAnswer(answerNode: RepeatGroupAnswerTreeNode, indexPath: number[]): string { + if (!Array.isArray(answerNode)) { + return `${formatIndexPath(indexPath)} ${formatRepeatGroupAnswerValueToText(answerNode)}` + } - const formatInlineAnswer = (answerNode: RepeatGroupAnswerTreeNode, indexPath: number[]): string => { - if (!Array.isArray(answerNode)) { - return `${formatIndexPath(indexPath)} ${formatRepeatGroupAnswerValueToText(answerNode)}` - } + return answerNode + .map((childNode, childIndex) => formatInlineAnswer(childNode, [...indexPath, childIndex + 1])) + .join('; ') +} - return answerNode - .map((childNode, childIndex) => formatInlineAnswer(childNode, [...indexPath, childIndex + 1])) - .join('; ') +function collectIndexedLeafAnswers(answerNode: RepeatGroupAnswerTreeNode, indexPath: number[]): string[] { + if (!Array.isArray(answerNode)) { + return [`${formatIndexPath(indexPath)} ${formatRepeatGroupAnswerValueToText(answerNode)}`] } - const collectIndexedLeafAnswers = (answerNode: RepeatGroupAnswerTreeNode, indexPath: number[]): string[] => { - if (!Array.isArray(answerNode)) { - return [`${formatIndexPath(indexPath)} ${formatRepeatGroupAnswerValueToText(answerNode)}`] - } + return answerNode.flatMap((childNode, childIndex) => + collectIndexedLeafAnswers(childNode, [...indexPath, childIndex + 1]), + ) +} - return answerNode.flatMap((childNode, childIndex) => - collectIndexedLeafAnswers(childNode, [...indexPath, childIndex + 1]), - ) - } +/** + * Displays a list of answers from a repeat group question. + */ +export default function RepeatGroupCell(props: RepeatGroupCellProps) { + const repeatGroupAnswers = getRepeatGroupAnswerTree(props.submissionData, props.rowName) + if (!repeatGroupAnswers || repeatGroupAnswers.length <= 0) return null const inlineText = repeatGroupAnswers.map((answer, i) => formatInlineAnswer(answer, [i + 1])).join('; ') From 1a3da20f8f68eb459298b66da07c08ecefb69af8 Mon Sep 17 00:00:00 2001 From: Leszek Date: Wed, 8 Jul 2026 21:31:32 +0200 Subject: [PATCH 09/10] undo UI improvements --- .../DataTableCell/RepeatGroupCell.tsx | 72 +++++-------------- .../submissions/DataTableCell/index.tsx | 10 +-- .../submissions/repeatGroupUtils.tests.ts | 10 +-- .../submissions/repeatGroupUtils.tsx | 24 +------ 4 files changed, 24 insertions(+), 92 deletions(-) diff --git a/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.tsx b/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.tsx index 3a03e0b5dd..a94a28c454 100644 --- a/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.tsx +++ b/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.tsx @@ -1,70 +1,32 @@ -import { List } from '@mantine/core' import type { SubmissionResponse } from '#/dataInterface' -import { - type RepeatGroupAnswerTreeNode, - formatRepeatGroupAnswerValueToText, - getRepeatGroupAnswerTree, -} from '../repeatGroupUtils' -import TextModalCell from './TextModalCell' +import { getRepeatGroupAnswers } from '../repeatGroupUtils' interface RepeatGroupCellProps { submissionData: SubmissionResponse rowName: string - columnName: string - submissionIndex: number - submissionTotal: number -} - -function formatIndexPath(indexPath: number[]): string { - return `${indexPath.join('.')}.` -} - -function formatInlineAnswer(answerNode: RepeatGroupAnswerTreeNode, indexPath: number[]): string { - if (!Array.isArray(answerNode)) { - return `${formatIndexPath(indexPath)} ${formatRepeatGroupAnswerValueToText(answerNode)}` - } - - return answerNode - .map((childNode, childIndex) => formatInlineAnswer(childNode, [...indexPath, childIndex + 1])) - .join('; ') -} - -function collectIndexedLeafAnswers(answerNode: RepeatGroupAnswerTreeNode, indexPath: number[]): string[] { - if (!Array.isArray(answerNode)) { - return [`${formatIndexPath(indexPath)} ${formatRepeatGroupAnswerValueToText(answerNode)}`] - } - - return answerNode.flatMap((childNode, childIndex) => - collectIndexedLeafAnswers(childNode, [...indexPath, childIndex + 1]), - ) } /** * Displays a list of answers from a repeat group question. */ export default function RepeatGroupCell(props: RepeatGroupCellProps) { - const repeatGroupAnswers = getRepeatGroupAnswerTree(props.submissionData, props.rowName) + const repeatGroupAnswers = getRepeatGroupAnswers(props.submissionData, props.rowName) if (!repeatGroupAnswers || repeatGroupAnswers.length <= 0) return null - - const inlineText = repeatGroupAnswers.map((answer, i) => formatInlineAnswer(answer, [i + 1])).join('; ') - - const modalRows = repeatGroupAnswers.flatMap((answer, i) => collectIndexedLeafAnswers(answer, [i + 1])) - - const modalContent = ( - - {modalRows.map((rowText, i) => ( - {rowText} - ))} - - ) - return ( - +
+ {repeatGroupAnswers.map((answer, i) => ( + + {i > 0 && ', '} + {answer} + + ))} +
) } diff --git a/jsapp/js/components/submissions/DataTableCell/index.tsx b/jsapp/js/components/submissions/DataTableCell/index.tsx index 77fa5cf4d6..f292caac1d 100644 --- a/jsapp/js/components/submissions/DataTableCell/index.tsx +++ b/jsapp/js/components/submissions/DataTableCell/index.tsx @@ -69,15 +69,7 @@ export default function DataTableCell(props: DataTableCellProps) { !props.columnKey.startsWith(SUPPLEMENTAL_DETAILS_PROP) && (typeof props.reactTableRow.value === 'object' || shouldRenderUndefinedNestedKeyAsRepeat) ) { - return ( - - ) + return } if (props.question && props.question.type && props.reactTableRow.value) { diff --git a/jsapp/js/components/submissions/repeatGroupUtils.tests.ts b/jsapp/js/components/submissions/repeatGroupUtils.tests.ts index ae8936d0e1..649629dfdc 100644 --- a/jsapp/js/components/submissions/repeatGroupUtils.tests.ts +++ b/jsapp/js/components/submissions/repeatGroupUtils.tests.ts @@ -150,7 +150,7 @@ describe('getRepeatGroupAnswers', () => { 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']) + 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', () => { @@ -170,7 +170,7 @@ describe('getRepeatGroupAnswers', () => { chai.expect(test).to.deep.equal(['c', 'd']) }) - it('should group nested repeat answers by outer repeat iteration for readability', () => { + it('should flatten nested repeat answers into a single list', () => { const nestedRepeatForTableDisplay = makeNestedRepeatSubmission([ ['e', 'f'], ['g', 'h'], @@ -178,10 +178,10 @@ describe('getRepeatGroupAnswers', () => { const test = getRepeatGroupAnswers(nestedRepeatForTableDisplay, 'outer_repeat/inner_repeat/item_name') - chai.expect(test).to.deep.equal(['(e, f)', '(g, h)']) + chai.expect(test).to.deep.equal(['e', 'f', 'g', 'h']) }) - it('should keep nested grouping and show placeholder for unanswered nested repeat values', () => { + it('should flatten nested answers and include placeholders for unanswered nested repeat values', () => { const nestedRepeatWithMissingValues = makeSubmission({ outer_repeat: [ { @@ -212,7 +212,7 @@ describe('getRepeatGroupAnswers', () => { unansweredPlaceholder: '-', }) - chai.expect(test).to.deep.equal(['(e, f)', '(g, -)']) + chai.expect(test).to.deep.equal(['e', 'f', 'g', '-']) }) it('should return deleted attachment component when repeat answer points to deleted media', () => { diff --git a/jsapp/js/components/submissions/repeatGroupUtils.tsx b/jsapp/js/components/submissions/repeatGroupUtils.tsx index bd243c52d8..35eb98584d 100644 --- a/jsapp/js/components/submissions/repeatGroupUtils.tsx +++ b/jsapp/js/components/submissions/repeatGroupUtils.tsx @@ -25,10 +25,6 @@ export type RepeatGroupAnswerValue = RepeatGroupAnswerValueText | RepeatGroupAns export type RepeatGroupAnswerTreeNode = RepeatGroupAnswerValue | RepeatGroupAnswerTreeNode[] -function isRepeatGroupAnswerValueText(answerValue: RepeatGroupAnswerValue): answerValue is RepeatGroupAnswerValueText { - return answerValue.kind === 'text' -} - export function formatRepeatGroupAnswerValueToText(answerValue: RepeatGroupAnswerValue): string { if (answerValue.kind === 'deleted-attachment') { return t('Deleted attachment') @@ -37,12 +33,6 @@ export function formatRepeatGroupAnswerValueToText(answerValue: RepeatGroupAnswe return answerValue.value } -function isRepeatGroupAnswerValueTextNode( - answerNode: RepeatGroupAnswerTreeNode, -): answerNode is RepeatGroupAnswerValueText { - return !Array.isArray(answerNode) && isRepeatGroupAnswerValueText(answerNode) -} - const isSubmissionResponseValueObject = (data: any): data is SubmissionResponseValueObject => { if (data === null) return false if (typeof data !== 'object') return false @@ -55,9 +45,7 @@ const isSubmissionResponseValueObject = (data: any): data is SubmissionResponseV /** * Returns answers for one repeat-group question as a flat list. * - * This helper exists for older callers that expect a simple flat array. Nested - * repeat groups are still grouped into strings like `(a, b)` so the caller can - * tell which nested answers came from the same parent iteration. + * This helper exists for older callers that expect a simple flat array. */ export function getRepeatGroupAnswers( responseData: DataResponse | SubmissionResponse, @@ -72,16 +60,6 @@ export function getRepeatGroupAnswers( return [renderRepeatGroupAnswerValue(answerNode)] } - const textChildren = answerNode.filter(isRepeatGroupAnswerValueTextNode) - - if (textChildren.length === answerNode.length) { - if (textChildren.length === 1) { - return [textChildren[0].value] - } - - return [`(${textChildren.map((childNode) => childNode.value).join(', ')})`] - } - return answerNode.flatMap((childNode) => formatAnswerNode(childNode)) } From 43c14e6e0d2a2a1302d41abd55c0d0647bea109e Mon Sep 17 00:00:00 2001 From: Leszek Date: Wed, 8 Jul 2026 22:04:00 +0200 Subject: [PATCH 10/10] bring back print styles --- .../DataTableCell/RepeatGroupCell.module.scss | 20 +++++++++++++++++++ .../DataTableCell/RepeatGroupCell.tsx | 10 ++-------- 2 files changed, 22 insertions(+), 8 deletions(-) create mode 100644 jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.module.scss diff --git a/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.module.scss b/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.module.scss new file mode 100644 index 0000000000..79c18ad245 --- /dev/null +++ b/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.module.scss @@ -0,0 +1,20 @@ +@use 'scss/mixins'; + +.cell { + @include mixins.textEllipsis; + + width: 100%; + height: 100%; + align-content: center; + + > * { + display: inline; + } +} + +@media print { + .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 a94a28c454..ca8dd15648 100644 --- a/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.tsx +++ b/jsapp/js/components/submissions/DataTableCell/RepeatGroupCell.tsx @@ -1,5 +1,6 @@ import type { SubmissionResponse } from '#/dataInterface' import { getRepeatGroupAnswers } from '../repeatGroupUtils' +import styles from './RepeatGroupCell.module.scss' interface RepeatGroupCellProps { submissionData: SubmissionResponse @@ -13,14 +14,7 @@ export default function RepeatGroupCell(props: RepeatGroupCellProps) { const repeatGroupAnswers = getRepeatGroupAnswers(props.submissionData, props.rowName) if (!repeatGroupAnswers || repeatGroupAnswers.length <= 0) return null return ( -
+
{repeatGroupAnswers.map((answer, i) => ( {i > 0 && ', '}