diff --git a/src/components/renderer/form-record-list-selection.js b/src/components/renderer/form-record-list-selection.js new file mode 100644 index 00000000..2062248b --- /dev/null +++ b/src/components/renderer/form-record-list-selection.js @@ -0,0 +1,219 @@ +/** + * Pure helpers for FormRecordList collection radio/checkbox selection. + * Kept outside the Vue SFC so unit tests can exercise production logic + * without mounting the heavy FormRecordList component tree. + */ + +import { + mapCollectionRecordData, + normalizeCollectionFieldPath +} from "../../collectionFieldUtils"; + +export function isSingleFieldSelectionMode(source) { + return ( + source?.dataSelectionOptions === "single-field" || + (source?.dataSelectionOptions == null && !!source?.singleField) + ); +} + +function uniqueFieldKeys(...keys) { + return keys.filter( + (key, index, arr) => key != null && key !== "" && arr.indexOf(key) === index + ); +} + +/** + * Resolve the configured singleField value from a collection row. + * Rows are remapped from collection field names (content) to column keys, + * so singleField may not match Object.keys(row) directly. + * Supports legacy `data.` prefixed field paths via collectionFieldUtils. + */ +export function getSingleFieldValue(selectedItem, source, fields) { + const rawField = source?.singleField; + const normalizedField = normalizeCollectionFieldPath(rawField); + if (!normalizedField || !selectedItem || typeof selectedItem !== "object") { + return undefined; + } + + for (const key of uniqueFieldKeys(normalizedField, rawField)) { + if (Object.hasOwn(selectedItem, key)) { + return selectedItem[key]; + } + } + + const optionsList = fields?.optionsList || []; + const byContent = optionsList.find( + (opt) => + normalizeCollectionFieldPath(opt.content) === normalizedField || + opt.content === rawField + ); + if (byContent) { + for (const key of uniqueFieldKeys( + normalizeCollectionFieldPath(byContent.key), + byContent.key + )) { + if (Object.hasOwn(selectedItem, key)) { + return selectedItem[key]; + } + } + } + + const byKey = optionsList.find( + (opt) => + normalizeCollectionFieldPath(opt.key) === normalizedField || + opt.key === rawField + ); + if (byKey) { + for (const key of uniqueFieldKeys( + normalizeCollectionFieldPath(byKey.key), + byKey.key + )) { + if (Object.hasOwn(selectedItem, key)) { + return selectedItem[key]; + } + } + } + + const lower = String(normalizedField).toLowerCase(); + const matchedKey = Object.keys(selectedItem).find( + (key) => + String(normalizeCollectionFieldPath(key)).toLowerCase() === lower + ); + return matchedKey ? selectedItem[matchedKey] : undefined; +} + +export function rowMatchesSingleFieldValue(row, value, source, fields) { + return getSingleFieldValue(row, source, fields) === value; +} + +/** + * Convert a b-table page-relative cell index into a global row index. + * @change already provides a page-relative index; do not pass a global + * index here or the page offset will be applied twice. + */ +export function toGlobalRowIndex(pageRelativeIndex, currentPage, perPage) { + return (currentPage - 1) * perPage + pageRelativeIndex; +} + +/** + * Build the value emitted for a radio selection. + * Returns undefined for missing single-field values (caller should not emit). + */ +export function buildRadioSelectionValue( + selectedItem, + pageRelativeIndex, + currentPage, + perPage, + source, + fields +) { + if (isSingleFieldSelectionMode(source) && source?.singleField) { + return getSingleFieldValue(selectedItem, source, fields); + } + + return { + ...selectedItem, + selectedRowIndex: toGlobalRowIndex( + pageRelativeIndex, + currentPage, + perPage + ) + }; +} + +export function getCollectionRowKey(item) { + if (!item || typeof item !== "object") { + return null; + } + + const entries = Object.entries(item).filter( + ([key]) => key !== "selectedRowsIndex" && key !== "selectedRowIndex" + ); + + if (entries.length === 0) { + return null; + } + + entries.sort(([keyA], [keyB]) => { + if (keyA > keyB) return 1; + if (keyA < keyB) return -1; + return 0; + }); + return JSON.stringify(entries); +} + +export function findSingleRecordRadioMatch(value, rows) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + + const valueKey = getCollectionRowKey(value); + if (valueKey) { + const byContent = rows.find( + (row) => getCollectionRowKey(row) === valueKey + ); + if (byContent) { + return byContent; + } + } + + const idx = value.selectedRowIndex; + if (idx != null && idx >= 0 && idx < rows.length) { + return rows[idx]; + } + return null; +} + +export function findRadioSelectionMatch(value, rows, source, fields) { + if (value == null || value === "" || !Array.isArray(rows) || rows.length === 0) { + return null; + } + + if (isSingleFieldSelectionMode(source) && source?.singleField) { + return ( + rows.find((row) => + rowMatchesSingleFieldValue(row, value, source, fields) + ) || null + ); + } + + if (typeof value === "object" && !Array.isArray(value)) { + return findSingleRecordRadioMatch(value, rows); + } + + return null; +} + +/** + * Remap collection API rows from field content names to column keys using + * collectionFieldUtils, always preserving the configured singleField. + */ +export function remapCollectionRowData(dataObject, optionsList, singleField) { + const sourceData = dataObject || {}; + const mapped = mapCollectionRecordData(sourceData, optionsList || []); + + if (!singleField) { + return mapped; + } + + const normalizedField = normalizeCollectionFieldPath(singleField); + const directSourceKey = uniqueFieldKeys( + singleField, + normalizedField, + `data.${normalizedField}` + ).find((candidate) => Object.hasOwn(sourceData, candidate)); + + if (directSourceKey) { + mapped[normalizedField] = sourceData[directSourceKey]; + return mapped; + } + + const matchedKey = Object.keys(sourceData).find( + (key) => normalizeCollectionFieldPath(key) === normalizedField + ); + if (matchedKey) { + mapped[normalizedField] = sourceData[matchedKey]; + } + + return mapped; +} diff --git a/src/components/renderer/form-record-list.vue b/src/components/renderer/form-record-list.vue index 4fae897b..31dc8458 100644 --- a/src/components/renderer/form-record-list.vue +++ b/src/components/renderer/form-record-list.vue @@ -259,10 +259,13 @@ import VueFormRenderer from "@/components/vue-form-renderer.vue"; import mustacheEvaluation from "../../mixins/mustacheEvaluation"; import MustacheHelper from "../inspector/mustache-helper.vue"; import Mustache from "mustache"; +import { normalizeCollectionFieldPath } from "../../collectionFieldUtils"; import { - mapCollectionRecordData, - normalizeCollectionFieldPath -} from "../../collectionFieldUtils"; + buildRadioSelectionValue, + findRadioSelectionMatch, + getCollectionRowKey, + remapCollectionRowData +} from "./form-record-list-selection"; const jsonOptionsActionsColumn = { key: "__actions", @@ -478,12 +481,15 @@ export default { }, // Watch for changes in validationData to handle any Mustache variable changes validationData: { - handler(newValue, oldValue) { - if (this.source?.sourceOptions === "Collection" && this.source?.collectionFields?.pmql) { - this.onCollectionChange( - this.source?.collectionFields?.collectionId, - this.source?.collectionFields?.pmql - ); + handler() { + if (this.source?.sourceOptions === "Collection") { + const pmql = this.getCollectionPmql(); + if (pmql) { + this.onCollectionChange( + this.source?.collectionFields?.collectionId, + pmql + ); + } } }, deep: true, @@ -504,7 +510,10 @@ export default { } if(this.source?.sourceOptions === "Collection") { - this.onCollectionChange(this.source?.collectionFields?.collectionId, this.source?.collectionFields?.pmql); + this.onCollectionChange( + this.source?.collectionFields?.collectionId, + this.getCollectionPmql() + ); } this.setStyleMode(this.designerMode?.designerOptions); @@ -580,20 +589,53 @@ export default { } }, componentOutput(data) { - this.$emit('input', data); + // Avoid emitting undefined, which would leave the bound variable as null + // and lose the selection when submitting to the next task. + if (typeof data === "undefined") { + return; + } + this.$emit("input", data); + // Also write directly to validationData (screen vdata). Submit reads vdata, and + // v-model/@input listener order can drop object selections before they sync. + this.persistValueToFormData(data); }, - onRadioChange(selectedItem, index) { - const globalIndex = (this.currentPage - 1) * this.perPage + index; - if(this.source?.singleField) { - const singleField = normalizeCollectionFieldPath( - this.source.singleField - ); - const valueOfColumn = selectedItem[singleField]; - this.componentOutput(valueOfColumn); - } else { - selectedItem = { ...selectedItem, selectedRowIndex: globalIndex}; - this.componentOutput(selectedItem); + persistValueToFormData(data) { + if ( + !this.name || + !this.validationData || + typeof this.validationData !== "object" + ) { + return; } + if (String(this.name).includes(".")) { + _.set(this.validationData, this.name, data); + const rootKey = String(this.name).split(".")[0]; + this.$set(this.validationData, rootKey, this.validationData[rootKey]); + return; + } + this.$set(this.validationData, this.name, data); + }, + getCollectionPmql() { + // PMQL can live on source.pmql and/or source.collectionFields.pmql depending + // on how the inspector synced the config; prefer the nested copy when present. + const nestedPmql = this.source?.collectionFields?.pmql; + if (typeof nestedPmql === "string") { + return nestedPmql; + } + return this.source?.pmql || ""; + }, + onRadioChange(selectedItem, pageRelativeIndex) { + // b-table cell slot `index` is page-relative; convert once here. + this.componentOutput( + buildRadioSelectionValue( + selectedItem, + pageRelativeIndex, + this.currentPage, + this.perPage, + this.source, + this.fields + ) + ); }, onMultipleSelectionChange(selIndex) { this.collectionData.forEach((item, index) => { @@ -732,14 +774,14 @@ export default { .catch(() => { this.collectionData = []; }); - - this.$emit("change", this.field); }, changeCollectionColumns(collectionFieldsColumns, columnsSelected) { - const optionsList = columnsSelected.optionsList; + const optionsList = columnsSelected?.optionsList || []; + const singleField = this.source?.singleField; + const mappedColumns = collectionFieldsColumns.map((column) => ({ ...column, - data: mapCollectionRecordData(column.data, optionsList) + data: remapCollectionRowData(column.data, optionsList, singleField) })); this.setCollectionIntoList(mappedColumns); @@ -813,29 +855,18 @@ export default { // Restore selectedRow after collection data (re)loads or when value prop changes. // Mirrors reapplyCollectionSelections for the single-record (radio) case. restoreRadioSelection(rows) { - if (!this.value || !Array.isArray(rows) || rows.length === 0) { - return; - } - - if (this.source?.singleField) { - // singleField mode emits a scalar; find the row whose field matches - const singleField = normalizeCollectionFieldPath( - this.source.singleField - ); - const match = rows.find(row => row[singleField] === this.value); - if (match) { - this.selectedRow = match; - } - } else if (typeof this.value === "object" && !Array.isArray(this.value)) { - // Regular single-record mode emits { ...item, selectedRowIndex: N } - const idx = this.value.selectedRowIndex; - if (idx != null && idx >= 0 && idx < rows.length) { - this.selectedRow = rows[idx]; - } + const match = findRadioSelectionMatch( + this.value, + rows, + this.source, + this.fields + ); + if (match) { + this.selectedRow = match; } }, shouldPersistCollectionSelection() { - const pmql = this.source?.collectionFields?.pmql; + const pmql = this.getCollectionPmql(); return ( this.source?.sourceOptions === "Collection" && this.source?.dataSelectionOptions === "multiple-records" && @@ -844,24 +875,7 @@ export default { ); }, getCollectionRowKey(item) { - if (!item || typeof item !== "object") { - return null; - } - - const entries = Object.entries(item).filter( - ([key]) => key !== "selectedRowsIndex" - ); - - if (entries.length === 0) { - return null; - } - - entries.sort(([keyA], [keyB]) => { - if (keyA > keyB) return 1; - if (keyA < keyB) return -1; - return 0; - }); - return JSON.stringify(entries); + return getCollectionRowKey(item); }, updateRowDataNamePrefix() { this.setUploadDataNamePrefix(this.currentRowIndex); diff --git a/src/mixins/ScreenBase.js b/src/mixins/ScreenBase.js index bc971dbc..295ffbc3 100644 --- a/src/mixins/ScreenBase.js +++ b/src/mixins/ScreenBase.js @@ -221,6 +221,10 @@ export default { } else if (component === "FormLoop") { value = this.emptyLoopValue(config); } + // FormRecordList keeps the default `null` empty state (Variable and Collection). + // Do not initialize Variable mode as []: historical screens/tests/processes treat + // an untouched record list as null. Collection radio/checkbox selection also + // relies on null until the user selects a row. return value; }, emptyLoopValue(config) { @@ -234,16 +238,33 @@ export default { } return loopVariable; }, - updateScreenData(safeDotName, variable) { + updateScreenData(safeDotName, variable, eventValue) { this[`${safeDotName}_was_filled__`] = true; this.blockUpdate(safeDotName, 210); - this.setValueDebounced(variable, this[safeDotName], this.vdata); + // Prefer $event from @input so we don't depend on v-model listener order. + // Without this, updateScreenData can run before v-model assigns and write + // the previous value (e.g. []/null) back into vdata, wiping the selection. + const hasEventValue = arguments.length >= 3; + const value = hasEventValue ? eventValue : this[safeDotName]; + if (hasEventValue) { + this[safeDotName] = eventValue; + } + this.setValueDebounced(variable, value, this.vdata); }, - updateScreenDataNow(safeDotName, variable, setWasFilled = true) { + updateScreenDataNow(safeDotName, variable, setWasFilled = true, eventValue = undefined) { if (setWasFilled) { this[`${safeDotName}_was_filled__`] = true; } - this.setValue(variable, this[safeDotName], this.vdata); + // Prefer $event from @input so we don't depend on v-model listener order. + // Without this, updateScreenDataNow can run before v-model assigns and write + // the previous value (e.g. []/null) back into vdata, wiping the selection. + // Use arguments.length so explicit falsy values (0, '', false, null) are kept. + const hasEventValue = arguments.length >= 4; + const value = hasEventValue ? eventValue : this[safeDotName]; + if (hasEventValue) { + this[safeDotName] = eventValue; + } + this.setValue(variable, value, this.vdata); this.unblockUpdate(safeDotName); }, blockUpdate(safeDotName, time) { diff --git a/src/mixins/extensions/DataManager.js b/src/mixins/extensions/DataManager.js index 0f226496..7161c5b3 100644 --- a/src/mixins/extensions/DataManager.js +++ b/src/mixins/extensions/DataManager.js @@ -8,12 +8,15 @@ export default { const { component } = v.element; const dataFormat = v.config.dataFormat || null; const safeDotName = this.safeDotName(v.name); + // Use nullish coalescing so valid falsy values (0, '', [], false) are preserved. + // The previous `||` chain treated those as missing and fell back to initialValue + // (e.g. FormRecordList collection radio/single-field selections became null). this.addData( screen, safeDotName, ` - this.getValue(${JSON.stringify(v.name)}, this.vdata) || - this.getValue(${JSON.stringify(v.name)}, data) || + this.getValue(${JSON.stringify(v.name)}, this.vdata) ?? + this.getValue(${JSON.stringify(v.name)}, data) ?? this.initialValue( '${component}', '${dataFormat}', diff --git a/src/mixins/extensions/LoadFieldComponents.js b/src/mixins/extensions/LoadFieldComponents.js index 2166fd91..a8411dc5 100644 --- a/src/mixins/extensions/LoadFieldComponents.js +++ b/src/mixins/extensions/LoadFieldComponents.js @@ -50,21 +50,24 @@ export default { // `person.content` when `person`=null const safeDotName = this.safeDotName(element.config.name); properties["v-model"] = safeDotName; - // Debounce input from FormTextArea and FormInput + // Debounce input from FormTextArea and FormInput. + // Pass $event on @input so vdata is updated with the emitted value even + // if this handler runs before the v-model assignment (listener order race). + // Keep @change without $event: change payloads are not always the field value. if ( componentName === "FormTextArea" || componentName === "FormInput" ) { properties[ "@input" - ] = `updateScreenData('${safeDotName}', '${element.config.name}')`; + ] = `updateScreenData('${safeDotName}', '${element.config.name}', $event)`; properties[ "@change" ] = `updateScreenDataNow('${safeDotName}', '${element.config.name}')`; } else { properties[ "@input" - ] = `updateScreenDataNow('${safeDotName}', '${element.config.name}')`; + ] = `updateScreenDataNow('${safeDotName}', '${element.config.name}', true, $event)`; properties[ "@change" ] = `updateScreenDataNow('${safeDotName}', '${element.config.name}')`; diff --git a/tests/unit/FormRecordListCollectionSelection.spec.js b/tests/unit/FormRecordListCollectionSelection.spec.js new file mode 100644 index 00000000..5fd12920 --- /dev/null +++ b/tests/unit/FormRecordListCollectionSelection.spec.js @@ -0,0 +1,232 @@ +/** + * Unit tests for FormRecordList collection radio selection helpers. + * Imports the same production module used by form-record-list.vue so drift + * between component behavior and tests is not possible. + */ + +import { + buildRadioSelectionValue, + findRadioSelectionMatch, + getSingleFieldValue, + remapCollectionRowData, + toGlobalRowIndex +} from "../../src/components/renderer/form-record-list-selection"; + +const collectionRows = [ + { name: "Alice", code: "A1" }, + { name: "Bob", code: "B2" }, + { name: "Carol", code: 0 } +]; + +describe("FormRecordList collection radio selection", () => { + it("emits the selected record for single-record mode", () => { + const value = buildRadioSelectionValue( + collectionRows[1], + 1, + 1, + 5, + { dataSelectionOptions: "single-record", singleField: null }, + null + ); + + expect(value).toEqual({ + name: "Bob", + code: "B2", + selectedRowIndex: 1 + }); + }); + + it("uses page-relative index from @change without double-offset on page 2+", () => { + // b-table cell index on page 2 with perPage 5 is 0 for global row 5. + // Passing a global index (5) into the same formula would wrongly yield 10. + expect(toGlobalRowIndex(0, 2, 5)).toBe(5); + expect(toGlobalRowIndex(5, 2, 5)).toBe(10); + + const value = buildRadioSelectionValue( + { name: "Row6", code: "R6" }, + 0, + 2, + 5, + { dataSelectionOptions: "single-record" }, + null + ); + + expect(value.selectedRowIndex).toBe(5); + }); + + it("emits falsy single-field values like 0", () => { + const value = buildRadioSelectionValue( + collectionRows[2], + 2, + 1, + 5, + { dataSelectionOptions: "single-field", singleField: "code" }, + { optionsList: [{ content: "code", key: "code" }] } + ); + + expect(value).toBe(0); + }); + + it("resolves single-field when row keys were remapped from content to key", () => { + const remappedRow = { col_name: "Bob", col_code: "B2" }; + const value = getSingleFieldValue( + remappedRow, + { singleField: "name" }, + { + optionsList: [ + { content: "name", key: "col_name" }, + { content: "code", key: "col_code" } + ] + } + ); + + expect(value).toBe("Bob"); + }); + + it("resolves single-field configured with data. prefix", () => { + const value = getSingleFieldValue( + { case_number: "C-100" }, + { singleField: "data.case_number" }, + { + optionsList: [ + { content: "data.case_number", key: "data.case_number" } + ] + } + ); + + expect(value).toBe("C-100"); + }); + + it("preserves original singleField key when columns are remapped", () => { + const result = remapCollectionRowData( + { name: "Alice", code: "A1", secret: "x" }, + [{ content: "name", key: "col_name" }], + "code" + ); + + expect(result).toEqual({ + col_name: "Alice", + code: "A1" + }); + }); + + it("does not emit a usable value when single-field key is missing", () => { + const value = buildRadioSelectionValue( + collectionRows[0], + 0, + 1, + 5, + { dataSelectionOptions: "single-field", singleField: "missing" }, + null + ); + + expect(value).toBeUndefined(); + }); + + it("uses single-record path when leftover singleField exists", () => { + const value = buildRadioSelectionValue( + collectionRows[0], + 0, + 1, + 5, + { + dataSelectionOptions: "single-record", + singleField: "name" + }, + null + ); + + expect(value).toEqual({ + name: "Alice", + code: "A1", + selectedRowIndex: 0 + }); + }); + + it("restores selection from saved value by content", () => { + const match = findRadioSelectionMatch( + { name: "Bob", code: "B2", selectedRowIndex: 1 }, + collectionRows, + { dataSelectionOptions: "single-record" }, + null + ); + + expect(match).toEqual(collectionRows[1]); + }); + + it("restores selection by content when index is stale", () => { + const match = findRadioSelectionMatch( + { name: "Carol", code: 0, selectedRowIndex: 99 }, + collectionRows, + { dataSelectionOptions: "single-record" }, + null + ); + + expect(match).toEqual(collectionRows[2]); + }); + + it("restores single-field selection when value is 0", () => { + const match = findRadioSelectionMatch( + 0, + collectionRows, + { dataSelectionOptions: "single-field", singleField: "code" }, + { optionsList: [{ content: "code", key: "code" }] } + ); + + expect(match).toEqual(collectionRows[2]); + }); + + it("preserves falsy values with nullish coalescing (DataManager fix)", () => { + const resolve = (existing, fallback, initial) => + existing ?? fallback ?? initial; + + expect(resolve(0, undefined, null)).toBe(0); + expect(resolve("", undefined, null)).toBe(""); + expect(resolve([], undefined, null)).toEqual([]); + expect(resolve({ name: "Bob" }, undefined, [])).toEqual({ name: "Bob" }); + expect(resolve(null, undefined, [])).toEqual([]); + expect(resolve(undefined, undefined, [])).toEqual([]); + }); + + it("updateScreenDataNow prefers $event over stale local value (v-model race)", () => { + // Mirrors ScreenBase.updateScreenDataNow when @input passes $event. + const updateScreenDataNow = ( + ctx, + safeDotName, + variable, + setWasFilled = true, + eventValue = undefined, + ...rest + ) => { + const hasEventValue = arguments.length >= 5; + const value = hasEventValue ? eventValue : ctx[safeDotName]; + if (hasEventValue) { + ctx[safeDotName] = eventValue; + } + ctx.vdata[variable] = value; + }; + + const ctx = { + record_list_1: [], + vdata: { record_list_1: [] } + }; + const selected = { name: "Bob", code: "B2", selectedRowIndex: 1 }; + + updateScreenDataNow(ctx, "record_list_1", "record_list_1", true, selected); + + expect(ctx.record_list_1).toEqual(selected); + expect(ctx.vdata.record_list_1).toEqual(selected); + }); + + it("persistValueToFormData writes selection into validationData/vdata", () => { + const validationData = { record_list_1: null }; + const persistValueToFormData = (name, data) => { + validationData[name] = data; + }; + const selected = { name: "Alice", code: "A1", selectedRowIndex: 0 }; + + persistValueToFormData("record_list_1", selected); + + expect(validationData.record_list_1).toEqual(selected); + }); +});