diff --git a/corehq/apps/app_manager/detail_screen.py b/corehq/apps/app_manager/detail_screen.py index fddbe010d567..019895dabdeb 100644 --- a/corehq/apps/app_manager/detail_screen.py +++ b/corehq/apps/app_manager/detail_screen.py @@ -608,6 +608,26 @@ class AddressPopup(HideShortColumn): template_form = 'address-popup' +@register_format_type('geo-boundary') +class GeoBoundary(HideShortColumn): + template_form = 'geo_boundary' + + +@register_format_type('geo-boundary-color') +class GeoBoundaryColor(HideShortColumn): + template_form = 'geo_boundary_color_hex' + + +@register_format_type('geo-points') +class GeoPoints(HideShortColumn): + template_form = 'geo_points' + + +@register_format_type('geo-points-colors') +class GeoPointsColors(HideShortColumn): + template_form = 'geo_points_colors_hex' + + @register_format_type('picture') class Picture(FormattedDetailColumn): template_form = 'image' diff --git a/corehq/apps/app_manager/static/app_manager/js/details/bootstrap3/column.js b/corehq/apps/app_manager/static/app_manager/js/details/bootstrap3/column.js index 35f73d82080a..70fa517d938b 100644 --- a/corehq/apps/app_manager/static/app_manager/js/details/bootstrap3/column.js +++ b/corehq/apps/app_manager/static/app_manager/js/details/bootstrap3/column.js @@ -15,6 +15,7 @@ import ko from "knockout"; import _ from "underscore"; import initialPageData from "hqwebapp/js/initial_page_data"; import main from "hqwebapp/js/bootstrap3/main"; +import alertUser from "hqwebapp/js/bootstrap3/alert_user"; import Utils from "app_manager/js/details/utils"; import uiElementInput from "hqwebapp/js/ui_elements/bootstrap3/ui-element-input"; import uiElementKeyValueMapping from "hqwebapp/js/ui_elements/bootstrap3/ui-element-key-val-mapping"; @@ -304,33 +305,84 @@ export default function (col, screen) { return false; }, self); - // Add the graphing option if self is a graph so self we can set the value to graph - let menuOptions = Utils.getFieldFormats(); - if (self.original.format === "graph") { - menuOptions = menuOptions.concat([{ - value: "graph", - label: "", - }]); - } + const filterFormats = function (menuOptions, currentFormatValue) { + let filteredOptions = menuOptions; + // Add the graphing option if self is a graph so self we can set the value to graph + if (currentFormatValue === "graph") { + filteredOptions = filteredOptions.concat([{ + value: "graph", + label: "", + }]); + } - if (self.useXpathExpression) { - const menuOptionsToRemove = ['picture', 'audio']; - for (let i = 0; i < menuOptionsToRemove.length; i++) { - for (let j = 0; j < menuOptions.length; j++) { - if ( - menuOptions[j].value !== self.original.format - && menuOptions[j].value === menuOptionsToRemove[i] - ) { - menuOptions.splice(j, 1); + if (self.useXpathExpression) { + const menuOptionsToRemove = ['picture', 'audio']; + for (let i = 0; i < menuOptionsToRemove.length; i++) { + for (let j = 0; j < filteredOptions.length; j++) { + if ( + filteredOptions[j].value !== self.original.format + && filteredOptions[j].value === menuOptionsToRemove[i] + ) { + filteredOptions.splice(j, 1); + } } } + } else { + // Restrict Translatable Text usage to Calculated Properties only + const index = filteredOptions.findIndex(f => f.value.includes('translatable-enum')); + if (index !== -1) { + filteredOptions.splice(index, 1); + } } - } else { - // Restrict Translatable Text usage to Calculated Properties only - menuOptions.splice(-1); - } + // Filter formats based on screen type (short=case list, long=case detail) + const formatDeps = Utils.dynamicFormats.COLUMN_FORMAT_DEPENDENCIES; + filteredOptions = filteredOptions.filter(option => { + const config = formatDeps[option.value]; + if (!config || option.value === currentFormatValue) { + return true; + } + if (config.display === 'short') { + return screen.columnKey === 'short'; + } else if (config.display === 'long') { + return screen.columnKey === 'long'; + } + return true; + }); + + return filteredOptions; + }; + + let menuOptions = filterFormats(Utils.getFieldFormats(), self.original.format); self.format = uiElementSelect.new(menuOptions).val(self.original.format || null); + self.previousFormat = self.format.val(); + + self.updateFormatOptions = function (dynamicFormats, formatsToInclude) { + let updateMenuOptions = Utils.getFieldFormats(); + updateMenuOptions = updateMenuOptions.filter(function (option) { + if (!dynamicFormats.includes(option.value)) { + return true; + } + return formatsToInclude.includes(option.value); + }); + + const currentFormatValue = self.format && self.format.val ? self.format.val() : null; + updateMenuOptions = filterFormats(updateMenuOptions, currentFormatValue); + + self.format.setOptions(updateMenuOptions); + + const shouldClearSelection = currentFormatValue && dynamicFormats.includes(currentFormatValue) && + !formatsToInclude.includes(currentFormatValue); + if (shouldClearSelection) { + self.format.val('plain'); + self.format.ui.find('select').val('plain'); + const message = Utils.dynamicFormats.getDependencyAlertMessage(currentFormatValue); + alertUser.alert_user(message, 'warning', false, true); + } else if (currentFormatValue !== null) { + self.format.val(currentFormatValue); + } + }; + self.supportsOptimizations = ko.observable(false); self.setSupportOptimizations = function () { let optimizationsSupported = ( diff --git a/corehq/apps/app_manager/static/app_manager/js/details/bootstrap3/screen.js b/corehq/apps/app_manager/static/app_manager/js/details/bootstrap3/screen.js index b0f94f0a6321..f8cd2c009d28 100644 --- a/corehq/apps/app_manager/static/app_manager/js/details/bootstrap3/screen.js +++ b/corehq/apps/app_manager/static/app_manager/js/details/bootstrap3/screen.js @@ -354,6 +354,37 @@ export default function (spec, config, options) { self.fire('change'); }; + const COLUMN_FORMAT_DEPENDENCIES = Utils.dynamicFormats.COLUMN_FORMAT_DEPENDENCIES; + const uniqueDependencies = Array.from( + new Set(_.flatten( + Object.values(COLUMN_FORMAT_DEPENDENCIES).map(config => config.dependencies) + )) + ); + + const calculateDynamicFormatsToInclude = function () { + const formatSet = new Set( + self.columns() + .map(col => col.format?.val?.()) + .filter(Boolean) + ); + + return Object.entries(COLUMN_FORMAT_DEPENDENCIES) + .filter(([, config]) => + config.dependencies.every(dep => formatSet.has(dep)) + ) + .map(([formatName]) => formatName); + }; + + const updateAllColumnFormats = function () { + const formatsToInclude = calculateDynamicFormatsToInclude(); + const dynamicFormats = Object.keys(COLUMN_FORMAT_DEPENDENCIES); + _.each(self.columns(), function (col) { + if (!col.isTab) { + col.updateFormatOptions(dynamicFormats, formatsToInclude); + } + }); + }; + self.initColumnAsColumn = function (column) { column.model.setEdit(false); column.field.setEdit(true); @@ -395,6 +426,16 @@ export default function (spec, config, options) { }]); } }); + column.format.on('change', function () { + const newFormat = column.format.val(); + const dependencyChanged = + uniqueDependencies.includes(column.previousFormat) || + uniqueDependencies.includes(newFormat); + if (dependencyChanged) { + updateAllColumnFormats(); + } + column.previousFormat = newFormat; + }); return column; }; @@ -436,6 +477,9 @@ export default function (spec, config, options) { self.initColumnAsColumn(self.columns()[i]); } + // Update all column formats on page load to account for dynamic formats + updateAllColumnFormats(); + self.caseTileRowMax = ko.computed(() => _.max([self.columns().length + 1, 7])); self.caseTileRowMax.subscribe(function (newValue) { self.updateTileRowMaxForColumns(newValue); @@ -492,6 +536,7 @@ export default function (spec, config, options) { } else if (change.status === 'deleted') { move = 1; affectedColumns = self.columns.slice(change.index); + updateAllColumnFormats(); } if (affectedColumns) { affectedColumns.forEach(c => { @@ -509,7 +554,12 @@ export default function (spec, config, options) { // Only save if property names are valid var errors = [], containsTab = false, - imageColumnCount = 0; + imageColumnCount = 0, + geoBoundaryCount = 0, + geoBoundaryColorCount = 0, + geoPointsCount = 0, + geoPointsColorsCount = 0; + _.each(self.columns(), function (column) { column.saveAttempted(true); if (column.isTab) { @@ -521,12 +571,32 @@ export default function (spec, config, options) { errors.push(gettext("There is an error in your property name: ") + column.field.value); } else if (column.format.value === 'image') { imageColumnCount += 1; + } else if (column.format.value === 'geo-boundary') { + geoBoundaryCount += 1; + } else if (column.format.value === 'geo-boundary-color') { + geoBoundaryColorCount += 1; + } else if (column.format.value === 'geo-points') { + geoPointsCount += 1; + } else if (column.format.value === 'geo-points-colors') { + geoPointsColorsCount += 1; } }); if (imageColumnCount > 1) { errors.push(gettext("You can only have one property with the 'Image' format")); } + if (geoBoundaryCount > 1) { + errors.push(gettext("You can only have one property with the 'Geo Boundary' format")); + } + if (geoBoundaryColorCount > 1) { + errors.push(gettext("You can only have one property with the 'Geo Boundary Color' format")); + } + if (geoPointsCount > 1) { + errors.push(gettext("You can only have one property with the 'Geo Points' format")); + } + if (geoPointsColorsCount > 1) { + errors.push(gettext("You can only have one property with the 'Geo Points Colors' format")); + } if (containsTab) { if (!self.columns()[0].isTab) { errors.push(gettext("All properties must be below a tab.")); @@ -678,6 +748,12 @@ export default function (spec, config, options) { self.columns.splice(index, 0, column); } column.useXpathExpression = !!columnConfiguration.useXpathExpression; + + const formatsToInclude = calculateDynamicFormatsToInclude(); + const dynamicFormats = Object.keys(COLUMN_FORMAT_DEPENDENCIES); + if (!column.isTab) { + column.updateFormatOptions(dynamicFormats, formatsToInclude); + } }; self.pasteCallback = function (data, index) { try { diff --git a/corehq/apps/app_manager/static/app_manager/js/details/bootstrap5/column.js b/corehq/apps/app_manager/static/app_manager/js/details/bootstrap5/column.js index ff217fff8610..c13b6254f9f5 100644 --- a/corehq/apps/app_manager/static/app_manager/js/details/bootstrap5/column.js +++ b/corehq/apps/app_manager/static/app_manager/js/details/bootstrap5/column.js @@ -15,6 +15,7 @@ import ko from "knockout"; import _ from "underscore"; import initialPageData from "hqwebapp/js/initial_page_data"; import main from "hqwebapp/js/bootstrap5/main"; +import alertUser from "hqwebapp/js/bootstrap5/alert_user"; import Utils from "app_manager/js/details/utils"; import uiElementInput from "hqwebapp/js/ui_elements/bootstrap5/ui-element-input"; import uiElementKeyValueMapping from "hqwebapp/js/ui_elements/bootstrap5/ui-element-key-val-mapping"; @@ -304,33 +305,84 @@ export default function (col, screen) { return false; }, self); - // Add the graphing option if self is a graph so self we can set the value to graph - let menuOptions = Utils.getFieldFormats(); - if (self.original.format === "graph") { - menuOptions = menuOptions.concat([{ - value: "graph", - label: "", - }]); - } + const filterFormats = function (menuOptions, currentFormatValue) { + let filteredOptions = menuOptions; + // Add the graphing option if self is a graph so self we can set the value to graph + if (currentFormatValue === "graph") { + filteredOptions = filteredOptions.concat([{ + value: "graph", + label: "", + }]); + } - if (self.useXpathExpression) { - const menuOptionsToRemove = ['picture', 'audio']; - for (let i = 0; i < menuOptionsToRemove.length; i++) { - for (let j = 0; j < menuOptions.length; j++) { - if ( - menuOptions[j].value !== self.original.format - && menuOptions[j].value === menuOptionsToRemove[i] - ) { - menuOptions.splice(j, 1); + if (self.useXpathExpression) { + const menuOptionsToRemove = ['picture', 'audio']; + for (let i = 0; i < menuOptionsToRemove.length; i++) { + for (let j = 0; j < filteredOptions.length; j++) { + if ( + filteredOptions[j].value !== self.original.format + && filteredOptions[j].value === menuOptionsToRemove[i] + ) { + filteredOptions.splice(j, 1); + } } } + } else { + // Restrict Translatable Text usage to Calculated Properties only + const index = filteredOptions.findIndex(f => f.value.includes('translatable-enum')); + if (index !== -1) { + filteredOptions.splice(index, 1); + } } - } else { - // Restrict Translatable Text usage to Calculated Properties only - menuOptions.splice(-1); - } + // Filter formats based on screen type (short=case list, long=case detail) + const formatDeps = Utils.dynamicFormats.COLUMN_FORMAT_DEPENDENCIES; + filteredOptions = filteredOptions.filter(option => { + const config = formatDeps[option.value]; + if (!config || option.value === currentFormatValue) { + return true; + } + if (config.display === 'short') { + return screen.columnKey === 'short'; + } else if (config.display === 'long') { + return screen.columnKey === 'long'; + } + return true; + }); + + return filteredOptions; + }; + + let menuOptions = filterFormats(Utils.getFieldFormats(), self.original.format); self.format = uiElementSelect.new(menuOptions).val(self.original.format || null); + self.previousFormat = self.format.val(); + + self.updateFormatOptions = function (dynamicFormats, formatsToInclude) { + let updateMenuOptions = Utils.getFieldFormats(); + updateMenuOptions = updateMenuOptions.filter(function (option) { + if (!dynamicFormats.includes(option.value)) { + return true; + } + return formatsToInclude.includes(option.value); + }); + + const currentFormatValue = self.format && self.format.val ? self.format.val() : null; + updateMenuOptions = filterFormats(updateMenuOptions, currentFormatValue); + + self.format.setOptions(updateMenuOptions); + + const shouldClearSelection = currentFormatValue && dynamicFormats.includes(currentFormatValue) && + !formatsToInclude.includes(currentFormatValue); + if (shouldClearSelection) { + self.format.val('plain'); + self.format.ui.find('select').val('plain'); + const message = Utils.dynamicFormats.getDependencyAlertMessage(currentFormatValue); + alertUser.alert_user(message, 'warning', false, true); + } else if (currentFormatValue !== null) { + self.format.val(currentFormatValue); + } + }; + self.supportsOptimizations = ko.observable(false); self.setSupportOptimizations = function () { let optimizationsSupported = ( diff --git a/corehq/apps/app_manager/static/app_manager/js/details/bootstrap5/screen.js b/corehq/apps/app_manager/static/app_manager/js/details/bootstrap5/screen.js index 621d1e657c19..586ddeab7055 100644 --- a/corehq/apps/app_manager/static/app_manager/js/details/bootstrap5/screen.js +++ b/corehq/apps/app_manager/static/app_manager/js/details/bootstrap5/screen.js @@ -354,6 +354,37 @@ export default function (spec, config, options) { self.fire('change'); }; + const COLUMN_FORMAT_DEPENDENCIES = Utils.dynamicFormats.COLUMN_FORMAT_DEPENDENCIES; + const uniqueDependencies = Array.from( + new Set(_.flatten( + Object.values(COLUMN_FORMAT_DEPENDENCIES).map(config => config.dependencies) + )) + ); + + const calculateDynamicFormatsToInclude = function () { + const formatSet = new Set( + self.columns() + .map(col => col.format?.val?.()) + .filter(Boolean) + ); + + return Object.entries(COLUMN_FORMAT_DEPENDENCIES) + .filter(([, config]) => + config.dependencies.every(dep => formatSet.has(dep)) + ) + .map(([formatName]) => formatName); + }; + + const updateAllColumnFormats = function () { + const formatsToInclude = calculateDynamicFormatsToInclude(); + const dynamicFormats = Object.keys(COLUMN_FORMAT_DEPENDENCIES); + _.each(self.columns(), function (col) { + if (!col.isTab) { + col.updateFormatOptions(dynamicFormats, formatsToInclude); + } + }); + }; + self.initColumnAsColumn = function (column) { column.model.setEdit(false); column.field.setEdit(true); @@ -395,6 +426,16 @@ export default function (spec, config, options) { }]); } }); + column.format.on('change', function () { + const newFormat = column.format.val(); + const dependencyChanged = + uniqueDependencies.includes(column.previousFormat) || + uniqueDependencies.includes(newFormat); + if (dependencyChanged) { + updateAllColumnFormats(); + } + column.previousFormat = newFormat; + }); return column; }; @@ -436,6 +477,9 @@ export default function (spec, config, options) { self.initColumnAsColumn(self.columns()[i]); } + // Update all column formats on page load to account for dynamic formats + updateAllColumnFormats(); + self.caseTileRowMax = ko.computed(() => _.max([self.columns().length + 1, 7])); self.caseTileRowMax.subscribe(function (newValue) { self.updateTileRowMaxForColumns(newValue); @@ -492,6 +536,7 @@ export default function (spec, config, options) { } else if (change.status === 'deleted') { move = 1; affectedColumns = self.columns.slice(change.index); + updateAllColumnFormats(); } if (affectedColumns) { affectedColumns.forEach(c => { @@ -509,7 +554,12 @@ export default function (spec, config, options) { // Only save if property names are valid var errors = [], containsTab = false, - imageColumnCount = 0; + imageColumnCount = 0, + geoBoundaryCount = 0, + geoBoundaryColorCount = 0, + geoPointsCount = 0, + geoPointsColorsCount = 0; + _.each(self.columns(), function (column) { column.saveAttempted(true); if (column.isTab) { @@ -521,12 +571,32 @@ export default function (spec, config, options) { errors.push(gettext("There is an error in your property name: ") + column.field.value); } else if (column.format.value === 'image') { imageColumnCount += 1; + } else if (column.format.value === 'geo-boundary') { + geoBoundaryCount += 1; + } else if (column.format.value === 'geo-boundary-color') { + geoBoundaryColorCount += 1; + } else if (column.format.value === 'geo-points') { + geoPointsCount += 1; + } else if (column.format.value === 'geo-points-colors') { + geoPointsColorsCount += 1; } }); if (imageColumnCount > 1) { errors.push(gettext("You can only have one property with the 'Image' format")); } + if (geoBoundaryCount > 1) { + errors.push(gettext("You can only have one property with the 'Geo Boundary' format")); + } + if (geoBoundaryColorCount > 1) { + errors.push(gettext("You can only have one property with the 'Geo Boundary Color' format")); + } + if (geoPointsCount > 1) { + errors.push(gettext("You can only have one property with the 'Geo Points' format")); + } + if (geoPointsColorsCount > 1) { + errors.push(gettext("You can only have one property with the 'Geo Points Colors' format")); + } if (containsTab) { if (!self.columns()[0].isTab) { errors.push(gettext("All properties must be below a tab.")); @@ -678,6 +748,12 @@ export default function (spec, config, options) { self.columns.splice(index, 0, column); } column.useXpathExpression = !!columnConfiguration.useXpathExpression; + + const formatsToInclude = calculateDynamicFormatsToInclude(); + const dynamicFormats = Object.keys(COLUMN_FORMAT_DEPENDENCIES); + if (!column.isTab) { + column.updateFormatOptions(dynamicFormats, formatsToInclude); + } }; self.pasteCallback = function (data, index) { try { diff --git a/corehq/apps/app_manager/static/app_manager/js/details/utils.js b/corehq/apps/app_manager/static/app_manager/js/details/utils.js index c358d8ea5379..c63a4fe0f52b 100644 --- a/corehq/apps/app_manager/static/app_manager/js/details/utils.js +++ b/corehq/apps/app_manager/static/app_manager/js/details/utils.js @@ -45,6 +45,18 @@ module.getFieldFormats = function () { }, { value: "markdown", label: gettext('Markdown'), + },{ + value: "geo-boundary", + label: gettext('Geo Boundary (Mobile only)'), + }, { + value: "geo-boundary-color", + label: gettext('Geo Boundary Color (Mobile only)'), + }, { + value: "geo-points", + label: gettext('Geo Points (Mobile only)'), + }, { + value: "geo-points-colors", + label: gettext('Geo Points Colors (Mobile only)'), }]; if (toggles.toggleEnabled('CASE_LIST_MAP')) { @@ -101,6 +113,51 @@ module.getFieldFormats = function () { return formats; }; +// Dynamic format configuration and utilities +module.dynamicFormats = { + // Configuration: Define format dependencies and display restrictions + // Key = format name, Value = object with: + // - dependencies: array of required dependency formats (all must be present) + // - display: where format should be shown ('short' for case list, 'long' for case-detail, or 'both') + COLUMN_FORMAT_DEPENDENCIES: { + 'geo-boundary': { + dependencies: ['address'], + display: 'short' + }, + 'geo-points': { + dependencies: ['address'], + display: 'short' + }, + 'geo-boundary-color': { + dependencies: ['address', 'geo-boundary'], + display: 'short' + }, + 'geo-points-colors': { + dependencies: ['address', 'geo-points'], + display: 'short' + }, + }, + + getFormatLabel: function (formatValue) { + const format = module.getFieldFormats().find(f => f.value === formatValue); + return format.label; + }, + + getDependencyAlertMessage: function (formatValue) { + const formatLabel = this.getFormatLabel(formatValue); + const dependencies = this.COLUMN_FORMAT_DEPENDENCIES[formatValue].dependencies; + const dependencyLabels = dependencies.map(dep => this.getFormatLabel(dep)).join(', '); + return interpolate( + gettext('The "%(format)s" format requires columns with formats: %(dependencies)s.'), + { + format: formatLabel, + dependencies: dependencyLabels + }, + true + ); + }, +}; + module.getFieldHtml = function (field) { var text = field || ''; if (module.isAttachmentProperty(text)) { diff --git a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/app_manager/js/details/column.js.diff.txt b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/app_manager/js/details/column.js.diff.txt index 4a2033e259a4..63e88bc4cea0 100644 --- a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/app_manager/js/details/column.js.diff.txt +++ b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/app_manager/js/details/column.js.diff.txt @@ -1,11 +1,13 @@ --- +++ -@@ -14,14 +14,14 @@ +@@ -14,15 +14,15 @@ import ko from "knockout"; import _ from "underscore"; import initialPageData from "hqwebapp/js/initial_page_data"; -import main from "hqwebapp/js/bootstrap3/main"; +-import alertUser from "hqwebapp/js/bootstrap3/alert_user"; +import main from "hqwebapp/js/bootstrap5/main"; ++import alertUser from "hqwebapp/js/bootstrap5/alert_user"; import Utils from "app_manager/js/details/utils"; -import uiElementInput from "hqwebapp/js/ui_elements/bootstrap3/ui-element-input"; -import uiElementKeyValueMapping from "hqwebapp/js/ui_elements/bootstrap3/ui-element-key-val-mapping"; @@ -21,7 +23,7 @@ import google from "analytix/js/google"; const microCaseImageName = 'cc_case_image'; -@@ -146,7 +146,7 @@ +@@ -147,7 +147,7 @@ $modalDiv.koApplyBindings(self); const $modal = $modalDiv.find('.modal'); $modal.appendTo('body');