diff --git a/corehq/apps/app_manager/helpers/validators.py b/corehq/apps/app_manager/helpers/validators.py index 38570a1b4c34..3d11a99ce8dd 100644 --- a/corehq/apps/app_manager/helpers/validators.py +++ b/corehq/apps/app_manager/helpers/validators.py @@ -647,19 +647,35 @@ def validate_details_for_build(self): for detail in [self.module.case_details.short, self.module.case_details.long]: if detail.case_tile_template: - if not detail.display == "short": - errors.append({ - 'type': self.__invalid_tile_configuration_type, - 'module': self.get_module_info(), - 'reason': _('Case tiles may only be used for the case list (not the case details).') - }) + if detail.display != "short": + if detail.case_tile_template != "custom": + errors.append({ + 'type': self.__invalid_tile_configuration_type, + 'module': self.get_module_info(), + 'reason': _('Case tiles on the case detail must be manually configured.'), + }) + + tab_spans = detail.get_tab_spans() + tile_rows = defaultdict(set) # tile row index => {tabs that appear in that row} + for index, span in enumerate(tab_spans): + for col in detail.columns[span[0]:span[1]]: + if col.grid_y is not None: + tile_rows[col.grid_y].add(index) + for row_index, tab_index_list in tile_rows.items(): + if len(tab_index_list) > 1: + errors.append({ + 'type': self.__invalid_tile_configuration_type, + 'module': self.get_module_info(), + 'reason': _('Each row of the tile may contain fields only from a single tab. ' + 'Row #{} contains fields from multiple tabs.').format(row_index + 1), + }) col_by_tile_field = {c.case_tile_field: c for c in detail.columns} for field in case_tile_template_config(detail.case_tile_template).fields: if field not in col_by_tile_field: errors.append({ 'type': self.__invalid_tile_configuration_type, 'module': self.get_module_info(), - 'reason': _('A case property must be assigned to the "{}" tile field.'.format(field)) + 'reason': _('A case property must be assigned to the "{}" tile field.').format(field) }) self._validate_fields_with_format_duplicate('address', 'Address', detail.columns, errors) self._validate_clickable_icons(detail.columns, errors) diff --git a/corehq/apps/app_manager/static/app_manager/js/details/column.js b/corehq/apps/app_manager/static/app_manager/js/details/column.js index f11a1b7e5352..d930c7bdcf78 100644 --- a/corehq/apps/app_manager/static/app_manager/js/details/column.js +++ b/corehq/apps/app_manager/static/app_manager/js/details/column.js @@ -48,6 +48,23 @@ hqDefine("app_manager/js/details/column", function () { }); self.original.late_flag = _.isNumber(self.original.late_flag) ? self.original.late_flag : 30; + // Set up tab defaults + const tabDefaults = { + isTab: false, + hasNodeset: false, + nodeset: "", + nodesetCaseType: "", + nodesetFilter: "", + relevant: "", + }; + self.original = _.defaults(self.original, tabDefaults); + let screenHasChildCaseTypes = screen.childCaseTypes && screen.childCaseTypes.length; + if (!self.original.nodeset && !self.original.nodesetCaseType && screenHasChildCaseTypes) { + // If there's no nodeset but there are child case types, default to showing a case type + self.original.nodesetCaseType = screen.childCaseTypes[0]; + } + _.extend(self, _.pick(self.original, _.keys(tabDefaults))); + self.original.case_tile_field = ko.utils.unwrapObservable(self.original.case_tile_field) || ""; self.case_tile_field = ko.observable(self.original.case_tile_field); @@ -99,30 +116,13 @@ hqDefine("app_manager/js/details/column", function () { return Number(self.tileColumnStart()) + Number(self.tileWidth()); }); self.showInTilePreview = ko.computed(function () { - return self.coordinatesVisible() && self.tileRowStart() && self.tileColumnStart() && self.tileWidth() && self.tileHeight(); + return !self.isTab && self.coordinatesVisible() && self.tileRowStart() && self.tileColumnStart() && self.tileWidth() && self.tileHeight(); }); self.tileContent = ko.observable(); self.setTileContent = function () { self.tileContent(self.header.val()); }; - // Set up tab defaults - const tabDefaults = { - isTab: false, - hasNodeset: false, - nodeset: "", - nodesetCaseType: "", - nodesetFilter: "", - relevant: "", - }; - self.original = _.defaults(self.original, tabDefaults); - let screenHasChildCaseTypes = screen.childCaseTypes && screen.childCaseTypes.length; - if (!self.original.nodeset && !self.original.nodesetCaseType && screenHasChildCaseTypes) { - // If there's no nodeset but there are child case types, default to showing a case type - self.original.nodesetCaseType = screen.childCaseTypes[0]; - } - _.extend(self, _.pick(self.original, _.keys(tabDefaults))); - self.screen = screen; self.lang = screen.lang; self.model = uiElement.select([{ diff --git a/corehq/apps/app_manager/static/app_manager/js/details/screen.js b/corehq/apps/app_manager/static/app_manager/js/details/screen.js index 0bc09047c88e..a5d53c55648e 100644 --- a/corehq/apps/app_manager/static/app_manager/js/details/screen.js +++ b/corehq/apps/app_manager/static/app_manager/js/details/screen.js @@ -64,8 +64,11 @@ hqDefine("app_manager/js/details/screen", function () { if (hqImport('hqwebapp/js/toggles').toggleEnabled('CASE_LIST_TILE_CUSTOM')) { baseCaseTileTemplateOptions = baseCaseTileTemplateOptions.concat([["custom", gettext("Manually configure Case Tiles")]]); } + if (self.columnKey === 'short') { + baseCaseTileTemplateOptions = baseCaseTileTemplateOptions.concat(options.caseTileTemplateOptions); + } - self.caseTileTemplateOptions = baseCaseTileTemplateOptions.concat(options.caseTileTemplateOptions); + self.caseTileTemplateOptions = baseCaseTileTemplateOptions; self.caseTileTemplateOptions = self.caseTileTemplateOptions.map(function (templateOption) { return {templateValue: templateOption[0], templateName: templateOption[1]}; }); @@ -150,6 +153,21 @@ hqDefine("app_manager/js/details/screen", function () { }); }; + // Given a column model, return a boolean indicating whether the column is on an odd + // or an even tab, for the sake of being able to differentiate in the case tile preview + // which rows go with which tab. + self.tabPolarity = function (column) { + const self = this; + let flag = false; + _.find(self.columns(), function (c) { + if (c.isTab) { + flag = !flag; + } + return c === column; + }); + return flag; + }; + self.adjustTileGridArea = function (activeColumnIndex, rowDelta, columnDelta, widthDelta, heightDelta) { let matrix = self._buildMatrix(); matrix = self._adjustTileGridArea(matrix, activeColumnIndex, rowDelta, columnDelta, widthDelta, heightDelta); diff --git a/corehq/apps/app_manager/suite_xml/features/case_tiles.py b/corehq/apps/app_manager/suite_xml/features/case_tiles.py index 33a24eae58c9..0373be45f542 100644 --- a/corehq/apps/app_manager/suite_xml/features/case_tiles.py +++ b/corehq/apps/app_manager/suite_xml/features/case_tiles.py @@ -11,7 +11,7 @@ from corehq.apps.app_manager import id_strings from corehq.apps.app_manager.exceptions import SuiteError from corehq.apps.app_manager.suite_xml.xml_models import ( - Detail, XPathVariable, Text, TileGroup, Style, EndpointAction + Detail, XPathVariable, TileGroup, Style, EndpointAction ) from corehq.apps.app_manager.util import ( module_offers_search, @@ -75,7 +75,7 @@ def __init__(self, app, module, detail, detail_id, detail_type, build_profile_id self.detail_column_infos = detail_column_infos self.entries_helper = entries_helper - def build_case_tile_detail(self): + def build_case_tile_detail(self, detail, start, end): from corehq.apps.app_manager.suite_xml.sections.details import DetailContributor """ Return a Detail node from an apps.app_manager.models.Detail that is @@ -87,10 +87,10 @@ def build_case_tile_detail(self): if self.detail.case_tile_template == CUSTOM: from corehq.apps.app_manager.detail_screen import get_column_generator - title = Text(locale_id=id_strings.detail_title_locale(self.detail_type)) - detail = Detail(id=self.detail_id, title=title) - for column_info in self.detail_column_infos: + start = start or 0 + end = end or len(self.detail_column_infos) + for column_info in self.detail_column_infos[start:end]: # column_info is an instance of DetailColumnInfo named tuple. style = None if any(field is not None for field in [column_info.column.grid_x, column_info.column.grid_y, @@ -133,31 +133,32 @@ def build_case_tile_detail(self): self.app, self.module, detail.actions, self.build_profile_id, self.entries_helper) # Add case search action if needed - if module_offers_search(self.module) and not module_uses_inline_search(self.module): - if (case_search_action := DetailContributor.get_case_search_action( - self.module, - self.build_profile_id, - self.detail_id - )) is not None: - detail.actions.append(case_search_action) - - # Excludes legacy tile template to preserve behavior of existing apps using this template. - if self.detail.case_tile_template not in [CaseTileTemplates.PERSON_SIMPLE.value, CUSTOM]: - self._populate_sort_elements_in_detail(detail) - - DetailContributor.add_no_items_text_to_detail(detail, self.app, self.detail_type, self.module) - - DetailContributor.add_select_text_to_detail(detail, self.app, self.detail_type, self.module) - - if self.module.has_grouped_tiles(): - detail.tile_group = TileGroup( - function=f"string(./index/{self.detail.case_tile_group.index_identifier})", - header_rows=self.detail.case_tile_group.header_rows - ) + if self.detail_type.endswith('short'): + if module_offers_search(self.module) and not module_uses_inline_search(self.module): + if (case_search_action := DetailContributor.get_case_search_action( + self.module, + self.build_profile_id, + self.detail_id + )) is not None: + detail.actions.append(case_search_action) + + # Excludes legacy tile template to preserve behavior of existing apps using this template. + if self.detail.case_tile_template not in [CaseTileTemplates.PERSON_SIMPLE.value, CUSTOM]: + self._populate_sort_elements_in_detail(detail) + + DetailContributor.add_no_items_text_to_detail(detail, self.app, self.detail_type, self.module) + + DetailContributor.add_select_text_to_detail(detail, self.app, self.detail_type, self.module) + + if self.module.has_grouped_tiles(): + detail.tile_group = TileGroup( + function=f"string(./index/{self.detail.case_tile_group.index_identifier})", + header_rows=self.detail.case_tile_group.header_rows + ) + + if hasattr(self.module, 'lazy_load_case_list_fields') and self.module.lazy_load_case_list_fields: + detail.lazy_loading = self.module.lazy_load_case_list_fields - if (self.detail_type == 'case_short' or self.detail_type == 'search_short') \ - and hasattr(self.module, 'lazy_load_case_list_fields') and self.module.lazy_load_case_list_fields: - detail.lazy_loading = self.module.lazy_load_case_list_fields return detail def _get_matched_detail_column(self, case_tile_field): diff --git a/corehq/apps/app_manager/suite_xml/sections/details.py b/corehq/apps/app_manager/suite_xml/sections/details.py index abb7ad55a288..b296ba5a35da 100644 --- a/corehq/apps/app_manager/suite_xml/sections/details.py +++ b/corehq/apps/app_manager/suite_xml/sections/details.py @@ -116,31 +116,20 @@ def get_section_elements(self): ) # list of DetailColumnInfo named tuples if detail_column_infos: detail_id = id_strings.detail(module, detail_type) - if detail.case_tile_template: - helper = CaseTileHelper(self.app, module, detail, detail_id, detail_type, - self.build_profile_id, detail_column_infos, self.entries_helper) - - d = helper.build_case_tile_detail() - self._add_custom_variables(detail, d) + locale_id = id_strings.detail_title_locale(detail_type) + title = Text(locale_id=locale_id) if locale_id else Text() + d = self.build_detail( + module, + detail_type, + detail, + detail_column_infos, + title, + tabs=list(detail.get_tabs()), + id=detail_id, + print_template=detail.print_template['path'] if detail.print_template else None, + ) + if d: elements.append(d) - else: - print_template_path = None - if detail.print_template: - print_template_path = detail.print_template['path'] - locale_id = id_strings.detail_title_locale(detail_type) - title = Text(locale_id=locale_id) if locale_id else Text() - d = self.build_detail( - module, - detail_type, - detail, - detail_column_infos, - tabs=list(detail.get_tabs()), - id=detail_id, - title=title, - print_template=print_template_path, - ) - if d: - elements.append(d) # add the persist case context if needed and if # case tiles are present and have their own persistent block @@ -159,8 +148,8 @@ def get_section_elements(self): return elements - def build_detail(self, module, detail_type, detail, detail_column_infos, tabs=None, id=None, - title=None, nodeset=None, print_template=None, start=0, end=None, relevant=None): + def build_detail(self, module, detail_type, detail, detail_column_infos, title, tabs=None, id=None, + nodeset=None, print_template=None, start=0, end=None, relevant=None): """ Recursively builds the Detail object. (Details can contain other details for each of their tabs) @@ -185,7 +174,7 @@ def build_detail(self, module, detail_type, detail, detail_column_infos, tabs=No detail_type, detail, detail_column_infos, - title=Text(locale_id=id_strings.detail_tab_title_locale( + Text(locale_id=id_strings.detail_tab_title_locale( module, detail_type, tab )), nodeset=self._get_detail_tab_nodeset(module, detail, tab), @@ -223,19 +212,33 @@ def build_detail(self, module, detail_type, detail, detail_column_infos, tabs=No ) if variables: d.variables.extend(variables) - - # Add fields if end is None: end = len(detail_column_infos) - for column_info in detail_column_infos[start:end]: - # column_info is an instance of DetailColumnInfo named tuple. - fields = get_column_generator( - self.app, module, detail, parent_tab_nodeset=nodeset, - detail_type=detail_type, entries_helper=self.entries_helper, - *column_info - ).fields - for field in fields: - d.fields.append(field) + + # Add fields + if detail.case_tile_template: + detail_id = id_strings.detail(module, detail_type) + helper = CaseTileHelper( + self.app, + module, + detail, + detail_id, + detail_type, + self.build_profile_id, + detail_column_infos, + self.entries_helper, + ) + d = helper.build_case_tile_detail(d, start, end) + else: + for column_info in detail_column_infos[start:end]: + # column_info is an instance of DetailColumnInfo named tuple. + fields = get_column_generator( + self.app, module, detail, parent_tab_nodeset=nodeset, + detail_type=detail_type, entries_helper=self.entries_helper, + *column_info + ).fields + for field in fields: + d.fields.append(field) # Add actions if detail_type.endswith('short') and not module.put_in_root: diff --git a/corehq/apps/app_manager/templates/app_manager/partials/modules/case_detail.html b/corehq/apps/app_manager/templates/app_manager/partials/modules/case_detail.html index dd0f660fde0f..f42158049401 100644 --- a/corehq/apps/app_manager/templates/app_manager/partials/modules/case_detail.html +++ b/corehq/apps/app_manager/templates/app_manager/partials/modules/case_detail.html @@ -19,6 +19,11 @@
+ {% if request|toggle_enabled:'CASE_LIST_TILE_CUSTOM' %} + {% include 'app_manager/partials/modules/case_tile_templates.html' %} + {% include 'app_manager/partials/modules/case_tile_preview.html' %} + {% endif %} + {% include 'app_manager/partials/modules/case_list_properties.html' %}
diff --git a/corehq/apps/app_manager/templates/app_manager/partials/modules/case_list_properties.html b/corehq/apps/app_manager/templates/app_manager/partials/modules/case_list_properties.html index dd0b6d0ff20f..1a1b7f2c416c 100644 --- a/corehq/apps/app_manager/templates/app_manager/partials/modules/case_list_properties.html +++ b/corehq/apps/app_manager/templates/app_manager/partials/modules/case_list_properties.html @@ -7,13 +7,12 @@ {% trans "Property" %} + {% trans "Display Text" %} - {% trans "Display Text" %} {% trans "Format" %} {% trans "Case Tile Mapping" %} - {% trans "Display Text" %} {% trans "Format" %} @@ -58,24 +57,21 @@ - + - + - + - + @@ -89,7 +85,7 @@
+ data-bind="text: gettext('Please enter an expression.'), visible: showWarning"> @@ -99,10 +95,15 @@ + + + + + - + diff --git a/corehq/apps/app_manager/templates/app_manager/partials/modules/case_tile_preview.html b/corehq/apps/app_manager/templates/app_manager/partials/modules/case_tile_preview.html index d035a5b9e7dd..d2cc1d79c93b 100644 --- a/corehq/apps/app_manager/templates/app_manager/partials/modules/case_tile_preview.html +++ b/corehq/apps/app_manager/templates/app_manager/partials/modules/case_tile_preview.html @@ -13,7 +13,9 @@ 'grid-row-end': tileRowEnd, 'grid-column-start': tileColumnStart, 'grid-column-end': tileColumnEnd, - }"> + }, css: { + 'odd-tab': $parent.tabPolarity($data), + },">
+ + <text> + <locale id="cchq.case"/> + </text> + + + + <text> + <locale id="m0.case_long.tab.1.title"/> + </text> + + + +
+ + + +
+ +
+ + +
+ + + +
+ +
+
+ + + <text> + <locale id="m0.case_long.tab.2.title"/> + </text> + + + +
+ + + +
+ +
+ + +
+ + + +
+ +
+ + +
+ + + +
+ +
+
+ + + + + diff --git a/corehq/apps/app_manager/tests/data/suite/case-tile-case-detail.xml b/corehq/apps/app_manager/tests/data/suite/case-tile-case-detail.xml new file mode 100644 index 000000000000..a148da24ab77 --- /dev/null +++ b/corehq/apps/app_manager/tests/data/suite/case-tile-case-detail.xml @@ -0,0 +1,39 @@ + + + + <text> + <locale id="cchq.case"/> + </text> + + + +
+ + + +
+ +
+ + +
+ + + +
+ +
+
+
diff --git a/corehq/apps/app_manager/tests/test_build_errors.py b/corehq/apps/app_manager/tests/test_build_errors.py index b41adcb8789d..7570c03cc94b 100644 --- a/corehq/apps/app_manager/tests/test_build_errors.py +++ b/corehq/apps/app_manager/tests/test_build_errors.py @@ -18,6 +18,7 @@ CaseSearchLabel, CaseSearchProperty, DetailColumn, + DetailTab, FormLink, Module, ) @@ -132,7 +133,7 @@ def test_parent_cycle_in_app(self, *args): self._clean_unique_id(errors) self.assertIn(cycle_error, errors) - def test_case_tile_configuration_errors(self, *args): + def test_case_tile_mapping_errors(self, *args): case_tile_error = { 'type': "invalid tile configuration", 'module': {'id': 0, 'name': {'en': 'View'}}, @@ -147,6 +148,83 @@ def test_case_tile_configuration_errors(self, *args): self._clean_unique_id(errors) self.assertIn(case_tile_error, errors) + def test_case_tile_case_detail(self, *args): + case_tile_error = { + 'type': 'invalid tile configuration', + 'module': {'id': 0, 'name': {'en': 'Add Song module'}}, + 'reason': 'Case tiles on the case detail must be manually configured.', + } + factory = AppFactory(build_version='2.51.0') + app = factory.app + module = factory.new_basic_module('Add Song', 'song', with_form=False) + module.case_details.long.case_tile_template = "one_3X_two_4X_one_2X" + module.case_details.long.columns.append(DetailColumn( + format='plain', + field='artist', + header={'en': 'Artist'}, + )) + + errors = app.validate_app() + self._clean_unique_id(errors) + self.assertIn(case_tile_error, errors) + + module.case_details.long.case_tile_template = "custom" + errors = app.validate_app() + self._clean_unique_id(errors) + self.assertNotIn(case_tile_error, errors) + + def test_case_tile_case_detail_tabs(self, *args): + case_tile_error = { + 'type': 'invalid tile configuration', + 'module': {'id': 0, 'name': {'en': 'Add Song module'}}, + 'reason': 'Each row of the tile may contain fields only from a single tab. ' + 'Row #1 contains fields from multiple tabs.' + } + factory = AppFactory(build_version='2.51.0') + app = factory.app + module = factory.new_basic_module('Add Song', 'song', with_form=False) + module.case_details.long.case_tile_template = "custom" + + # Start with a legitimate tab+column layout + module.case_details.long.tabs = [ + DetailTab(starting_index=0), + DetailTab(starting_index=2), + ] + module.case_details.long.columns = [] + module.case_details.long.columns.append(DetailColumn( + format='plain', + field='artist', header={'en': 'Artist'}, + grid_x=0, grid_y=0, width=4, height=1, + )) + module.case_details.long.columns.append(DetailColumn( + format='plain', + field='name', header={'en': 'Name'}, + grid_x=5, grid_y=0, width=4, height=1, + )) + module.case_details.long.columns.append(DetailColumn( + format='plain', + field='mood', header={'en': 'Mood'}, + grid_x=0, grid_y=1, width=4, height=1, + )) + module.case_details.long.columns.append(DetailColumn( + format='plain', + field='energy', header={'en': 'Energy'}, + grid_x=5, grid_y=1, width=4, height=1, + )) + + module.case_details.long_case_tile_template = "custom" + errors = app.validate_app() + self._clean_unique_id(errors) + self.assertNotIn(case_tile_error, errors) + + # Move field from second tab into first row of tile + module.case_details.long.columns[2].grid_y = 0 + module.case_details.long.columns[2].grid_x = 9 + + errors = app.validate_app() + self._clean_unique_id(errors) + self.assertIn(case_tile_error, errors) + def create_app_with_module(self): factory = AppFactory(build_version='2.51.0') app = factory.app diff --git a/corehq/apps/app_manager/tests/test_suite_custom_case_tiles.py b/corehq/apps/app_manager/tests/test_suite_custom_case_tiles.py index 3b5df18f3861..4b04886cebea 100644 --- a/corehq/apps/app_manager/tests/test_suite_custom_case_tiles.py +++ b/corehq/apps/app_manager/tests/test_suite_custom_case_tiles.py @@ -3,8 +3,10 @@ from corehq.apps.app_manager.models import ( Application, DetailColumn, + DetailTab, Module, ) +from corehq.apps.app_manager.tests.app_factory import AppFactory from corehq.apps.app_manager.tests.util import ( SuiteMixin, patch_get_xform_resource_overrides, @@ -31,6 +33,7 @@ def add_columns_for_case_details(_module, field='a', format='plain', useXpathExp @patch_get_xform_resource_overrides() class SuiteCustomCaseTilesTest(SimpleTestCase, SuiteMixin): + file_path = ('data', 'suite') def test_custom_case_tile(self, *args): app = Application.new_app('domain', 'Untitled Application') @@ -84,10 +87,9 @@ def test_custom_case_tile(self, *args): self.assertXmlDoesNotHaveXpath(suite, "./detail[@id='m0_case_short']/field[2]") def test_custom_case_tile_address(self, *args): - app = Application.new_app('domain', 'Untitled Application') - - module = app.add_module(Module.new_module('Untitled Module', None)) - module.case_type = 'patient' + factory = AppFactory(build_version='2.51.0') + app = factory.app + module = factory.new_basic_module('register', 'patient', with_form=False) module.case_details.short.case_tile_template = "custom" add_columns_for_case_details(module, format='address') @@ -116,10 +118,9 @@ def test_custom_case_tile_address(self, *args): ) def test_custom_case_tile_empty_style(self, *args): - app = Application.new_app('domain', 'Untitled Application') - - module = app.add_module(Module.new_module('Untitled Module', None)) - module.case_type = 'patient' + factory = AppFactory(build_version='2.51.0') + app = factory.app + module = factory.new_basic_module('register', 'patient', with_form=False) module.case_details.short.case_tile_template = "custom" module.case_details.short.columns = [ DetailColumn( @@ -150,3 +151,73 @@ def test_custom_case_tile_empty_style(self, *args): app.create_suite(), "./detail[@id='m0_case_short']/field[1]" ) + + def test_case_tile_for_case_detail(self, *args): + factory = AppFactory(build_version='2.51.0') + app = factory.app + module, form = factory.new_basic_module('Add Song', 'song') + module.case_details.long.case_tile_template = "custom" + + module.case_details.long.columns = [ + DetailColumn( + model='case', format='plain', + field='artist', header={'en': 'Artist'}, + grid_x=0, grid_y=0, width=4, height=1, + ), + DetailColumn( + model='case', format='plain', + field='name', header={'en': 'Name'}, + grid_x=5, grid_y=0, width=4, height=1, + ), + ] + + self.assertXmlPartialEqual( + self.get_xml('case-tile-case-detail'), + app.create_suite(), + "./detail[@id='m0_case_long']" + ) + + def test_case_tile_for_case_detail_tabs(self, *args): + factory = AppFactory(build_version='2.51.0') + app = factory.app + module, form = factory.new_basic_module('Add Song', 'song') + module.case_details.long.case_tile_template = "custom" + + module.case_details.long.tabs = [ + DetailTab(starting_index=0), + DetailTab(starting_index=2), + ] + + module.case_details.long.columns = [ + DetailColumn( + model='case', format='plain', + field='artist', header={'en': 'Artist'}, + grid_x=0, grid_y=0, width=6, height=1, + ), + DetailColumn( + model='case', format='plain', + field='name', header={'en': 'Name'}, + grid_x=5, grid_y=0, width=6, height=1, + ), + DetailColumn( + model='case', format='plain', + field='rating', header={'en': 'Rating'}, + grid_x=0, grid_y=1, width=4, height=1, + ), + DetailColumn( + model='case', format='plain', + field='energy', header={'en': 'Energy'}, + grid_x=5, grid_y=1, width=4, height=1, + ), + DetailColumn( + model='case', format='plain', + field='mood', he1der={'en': 'Mood'}, + grid_x=9, grid_y=1, width=4, height=1, + ), + ] + + self.assertXmlPartialEqual( + self.get_xml('case-tile-case-detail-tabs'), + app.create_suite(), + "./detail[@id='m0_case_long']" + ) diff --git a/corehq/apps/app_manager/views/modules.py b/corehq/apps/app_manager/views/modules.py index 40f5e69ac84f..a02ed94acd5b 100644 --- a/corehq/apps/app_manager/views/modules.py +++ b/corehq/apps/app_manager/views/modules.py @@ -1197,6 +1197,7 @@ def edit_module_detail_screens(request, domain, app_id, module_unique_id): parent_select = params.get('parent_select', None) fixture_select = params.get('fixture_select', None) sort_elements = params.get('sort_elements', None) + case_tile_template = params.get('caseTileTemplate', None) print_template = params.get('printTemplate', None) custom_variables_dict = { 'short': params.get("short_custom_variables_dict", None), @@ -1232,6 +1233,11 @@ def edit_module_detail_screens(request, domain, app_id, module_unique_id): # Note that we use the empty tuple as the sentinel because a filter # value of None represents clearing the filter. detail.short.filter = filter + if case_tile_template is not None: + if short is not None: + detail.short.case_tile_template = case_tile_template + else: + detail.long.case_tile_template = case_tile_template if custom_xml is not None: detail.short.custom_xml = custom_xml @@ -1416,7 +1422,6 @@ def _set_if_not_none(param_name, attribute_name=None): if value is not None: setattr(detail.short, attribute_name or param_name, value) - _set_if_not_none('caseTileTemplate', 'case_tile_template') _set_if_not_none('persistTileOnForms', 'persist_tile_on_forms') _set_if_not_none('persistentCaseTileFromModule', 'persistent_case_tile_from_module') _set_if_not_none('enableTilePullDown', 'pull_down_tile') diff --git a/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/controller.js b/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/controller.js index a0a4b58bdbb1..0f1420e4a646 100644 --- a/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/controller.js +++ b/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/controller.js @@ -202,15 +202,21 @@ hqDefine("cloudcare/js/formplayer/menus/controller", function () { } return; } - var detailObject = detailObjects[detailTabIndex]; - var menuListView = getDetailList(detailObject); - var tabModels = _.map(detailObjects, function (detail, index) { return {title: detail.get('title'), id: index, active: index === detailTabIndex}; }); var tabCollection = new Backbone.Collection(); tabCollection.reset(tabModels); + let contentView; + const detailObject = detailObjects[detailTabIndex], + usesCaseTiles = detailObject.get('usesCaseTiles'); + if (usesCaseTiles && !detailObject.get('entities')) { + contentView = getCaseTile(detailObject.toJSON()); + } else { + contentView = getDetailList(detailObject); + } + var tabListView = views.DetailTabListView({ collection: tabCollection, onTabClick: function (detailTabIndex) { @@ -223,7 +229,7 @@ hqDefine("cloudcare/js/formplayer/menus/controller", function () { isMultiSelect: isMultiSelect, }); $('#case-detail-modal').find('.js-detail-tabs').html(tabListView.render().el); - $('#case-detail-modal').find('.js-detail-content').html(menuListView.render().el); + $('#case-detail-modal').find('.js-detail-content').html(contentView.render().el); $('#case-detail-modal').find('.js-detail-footer-content').html(detailFooterView.render().el); $('#case-detail-modal').modal('show'); @@ -248,8 +254,12 @@ hqDefine("cloudcare/js/formplayer/menus/controller", function () { collection: listCollection, headers: detailObject.get('headers'), styles: detailObject.get('styles'), + tiles: detailObject.get('tiles'), title: detailObject.get('title'), }; + if (detailObject.get('usesCaseTiles')) { + return views.CaseTileDetailView(menuData); + } return views.CaseListDetailView(menuData); } @@ -279,7 +289,7 @@ hqDefine("cloudcare/js/formplayer/menus/controller", function () { }); }; - // return a case tile from a detail object (for persistent case tile) + // return a case tile from a detail object (for persistent case tile and case tile in case detail) var getCaseTile = function (detailObject) { var detailModel = new Backbone.Model({ data: detailObject.details, diff --git a/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js b/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js index 31bea2283f8a..cb21ef78123a 100644 --- a/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js +++ b/corehq/apps/cloudcare/static/cloudcare/js/formplayer/menus/views.js @@ -473,6 +473,34 @@ hqDefine("cloudcare/js/formplayer/menus/views", function () { }, }); + const CaseTileViewUnclickable = CaseTileView.extend({ + events: {}, + className: "list-cell-wrapper-style panel panel-default", + rowClick: function () {}, + }); + + const initCaseTileList = function (options) { + const numEntitiesPerRow = options.numEntitiesPerRow || 1; + const numRows = options.maxHeight; + const numColumns = options.maxWidth; + const useUniformUnits = options.useUniformUnits; + + const caseTileStyles = buildCaseTileStyles(options.tiles, options.styles, numRows, numColumns, + numEntitiesPerRow, useUniformUnits, 'list'); + + const gridPolyfillPath = FormplayerFrontend.getChannel().request('gridPolyfillPath'); + + $("#list-cell-layout-style").html(caseTileStyles.cellLayoutStyle).data("css-polyfilled", false); + $("#list-cell-grid-style").html(caseTileStyles.cellGridStyle).data("css-polyfilled", false); + // If we have multiple cases per line, need to generate the outer grid style as well + if (caseTileStyles.cellWrapperStyle && caseTileStyles.cellContainerStyle) { + $("#list-cell-wrapper-style").html(caseTileStyles.cellWrapperStyle).data("css-polyfilled", false); + $("#list-cell-container-style").html(caseTileStyles.cellContainerStyle).data("css-polyfilled", false); + } + + $.getScript(gridPolyfillPath); + }; + const CaseTileGroupedView = CaseTileView.extend({ tagName: "div", className: "formplayer-request list-cell-wrapper-style case-tile-group panel panel-default", @@ -540,7 +568,9 @@ hqDefine("cloudcare/js/formplayer/menus/views", function () { }); const PersistentCaseTileView = CaseTileView.extend({ - className: "formplayer-request persistent-sticky", + className: function () { + return "persistent-sticky" + (this.options.hasInlineTile ? " formplayer-request" : ""); + }, rowClick: function (e) { e.preventDefault(); if (this.options.hasInlineTile) { @@ -599,8 +629,8 @@ hqDefine("cloudcare/js/formplayer/menus/views", function () { }, initialize: function (options) { - const self = this; - var sidebarNoItemsText = gettext("Please perform a search."); + const self = this, + sidebarNoItemsText = gettext("Please perform a search."); self.styles = options.styles; self.hasNoItems = options.collection.length === 0 || options.triggerEmptyCaseList; self.noItemsText = options.triggerEmptyCaseList ? sidebarNoItemsText : this.options.collection.noItemsText; @@ -1086,26 +1116,7 @@ hqDefine("cloudcare/js/formplayer/menus/views", function () { initialize: function (options) { CaseTileListView.__super__.initialize.apply(this, arguments); - - const numEntitiesPerRow = options.numEntitiesPerRow || 1; - const numRows = options.maxHeight; - const numColumns = options.maxWidth; - const useUniformUnits = options.useUniformUnits; - - const caseTileStyles = buildCaseTileStyles(options.tiles, options.styles, numRows, numColumns, - numEntitiesPerRow, useUniformUnits, 'list'); - - const gridPolyfillPath = FormplayerFrontend.getChannel().request('gridPolyfillPath'); - - $("#list-cell-layout-style").html(caseTileStyles.cellLayoutStyle).data("css-polyfilled", false); - $("#list-cell-grid-style").html(caseTileStyles.cellGridStyle).data("css-polyfilled", false); - // If we have multiple cases per line, need to generate the outer grid style as well - if (caseTileStyles.cellWrapperStyle && caseTileStyles.cellContainerStyle) { - $("#list-cell-wrapper-style").html(caseTileStyles.cellWrapperStyle).data("css-polyfilled", false); - $("#list-cell-container-style").html(caseTileStyles.cellContainerStyle).data("css-polyfilled", false); - } - - $.getScript(gridPolyfillPath); + initCaseTileList(options); registerContinueListener(this, options); }, @@ -1213,6 +1224,21 @@ hqDefine("cloudcare/js/formplayer/menus/views", function () { template: _.template($("#case-view-list-detail-template").html() || ""), childView: CaseViewUnclickable, }); + const CaseTileDetailView = CaseListView.extend({ + template: _.template($("#case-view-tile-detail-template").html() || ""), + childView: CaseTileViewUnclickable, + + initialize: function (options) { + CaseTileDetailView.__super__.initialize.apply(this, arguments); + initCaseTileList(options); + }, + + childViewOptions: function () { + const dict = CaseTileDetailView.__super__.childViewOptions.apply(this, arguments); + dict.prefix = 'list'; + return dict; + }, + }); const BreadcrumbView = Marionette.View.extend({ tagName: "li", @@ -1446,6 +1472,9 @@ hqDefine("cloudcare/js/formplayer/menus/views", function () { CaseListDetailView: function (options) { return new CaseListDetailView(options); }, + CaseTileDetailView: function (options) { + return new CaseTileDetailView(options); + }, CaseListView: function (options) { return new CaseListView(options); }, diff --git a/corehq/apps/cloudcare/templates/formplayer/case_list.html b/corehq/apps/cloudcare/templates/formplayer/case_list.html index 5af57581b198..ab82c658d01c 100644 --- a/corehq/apps/cloudcare/templates/formplayer/case_list.html +++ b/corehq/apps/cloudcare/templates/formplayer/case_list.html @@ -331,3 +331,7 @@

<%- title %>

+ + diff --git a/corehq/apps/hqwebapp/static/app_manager/less/case_tile_preview.less b/corehq/apps/hqwebapp/static/app_manager/less/case_tile_preview.less index 3d6f592a602e..01dc8d9d0fb7 100644 --- a/corehq/apps/hqwebapp/static/app_manager/less/case_tile_preview.less +++ b/corehq/apps/hqwebapp/static/app_manager/less/case_tile_preview.less @@ -7,12 +7,16 @@ .cell { display: grid; margin: 2px; - border: 1px @cc-light-cool-accent-low dotted; + border: 1px @cc-brand-mid dotted; position: relative; // TODO: to be truly adorable, add transitions on changes to grid style, this doesn't work transition: 2s; + &.odd-tab { + background-color: @cc-bg; + } + .content { padding: 5px; } diff --git a/corehq/toggles/__init__.py b/corehq/toggles/__init__.py index e152a3e28dbd..866b618eed06 100644 --- a/corehq/toggles/__init__.py +++ b/corehq/toggles/__init__.py @@ -709,7 +709,7 @@ def _ensure_valid_randomness(randomness): CASE_LIST_TILE_CUSTOM = StaticToggle( 'case_list_tile_custom', - 'USH: Configure custom case list tile', + 'USH: Configure custom case tile for case list and case detail', TAG_CUSTOM, [NAMESPACE_DOMAIN], help_link='https://confluence.dimagi.com/pages/viewpage.action?'