From 4032799673956f49fe1dc9b30b46189ecb0d0357 Mon Sep 17 00:00:00 2001 From: Eduard Carrerars Date: Tue, 22 Jul 2025 10:37:07 +0200 Subject: [PATCH 1/3] feat(chart): allow dynamic stacked --- ooui/graph/chart.py | 26 +++++-- ooui/graph/processor.py | 29 ++++++++ spec/graph/graph_spec.py | 147 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 5 deletions(-) diff --git a/ooui/graph/chart.py b/ooui/graph/chart.py index 96eb084..922a461 100644 --- a/ooui/graph/chart.py +++ b/ooui/graph/chart.py @@ -5,7 +5,8 @@ from ooui.graph.axis import get_y_axis_fieldname from ooui.graph.timerange import process_timerange_data from ooui.graph.processor import ( - get_values_grouped_by_field, get_values_for_y_field, get_min_max + get_values_grouped_by_field, get_values_grouped_by_fields, + get_values_for_y_field, get_min_max ) @@ -41,6 +42,9 @@ def fields(self): if y.label and y.label not in fields: fields.append(y.label) + if y.stacked and y.stacked not in fields: + fields.append(y.stacked) + return fields def process(self, values, fields, options=None): @@ -88,14 +92,26 @@ def process(self, values, fields, options=None): 'stacked': y_field.stacked }) else: - values_grouped_by_y_label = get_values_grouped_by_field( - y_field.label, fields, objects_for_x_value - ) + if y_field.stacked not in fields: + values_grouped_by_y_label = get_values_grouped_by_field( + y_field.label, fields, objects_for_x_value + ) + else: + values_grouped_by_y_label = get_values_grouped_by_fields( + [y_field.label, y_field.stacked], + fields, + objects_for_x_value + ) for y_unique_value, grouped_entries in values_grouped_by_y_label.items(): entries = grouped_entries['entries'] label = grouped_entries['label'] + if isinstance(y_unique_value, tuple): + y_label, stacked = y_unique_value + else: + stacked = y_field.stacked + values_for_y_field = get_values_for_y_field( entries, y_field.name, fields ) @@ -108,7 +124,7 @@ def process(self, values, fields, options=None): 'value': final_value, 'type': label, 'operator': y_field.operator, - 'stacked': y_field.stacked + 'stacked': stacked }) # Check if data should be flagged as grouped or stacked diff --git a/ooui/graph/processor.py b/ooui/graph/processor.py index 8d7e4d0..f21651e 100644 --- a/ooui/graph/processor.py +++ b/ooui/graph/processor.py @@ -42,6 +42,35 @@ def get_values_for_y_field(entries, field_name, fields): ] +def get_values_grouped_by_fields(fields_names, fields, values): + """ + Group values by multiple fields. + + :param list fields_names: A list of field names by which to group values. + :param dict fields: A dictionary containing field definitions. + :param list values: A list of dictionaries representing the values to be + grouped. + + :rtype: dict + :returns: A dictionary where keys are tuples of field values and values are + dictionaries containing a label and an "entries" list. + """ + grouped_values = {} + + for entry in values: + key = tuple(get_value_and_label_for_field(fields, entry, field_name)['value'] + for field_name in fields_names) + label = ' - '.join(get_value_and_label_for_field(fields, entry, field_name)['label'] + for field_name in fields_names) + + if key not in grouped_values: + grouped_values[key] = {'label': label, 'entries': []} + + grouped_values[key]['entries'].append(entry) + + return grouped_values + + def get_values_grouped_by_field(field_name, fields, values): """ Group values by a specific field. diff --git a/spec/graph/graph_spec.py b/spec/graph/graph_spec.py index 96c8e7a..f37e483 100644 --- a/spec/graph/graph_spec.py +++ b/spec/graph/graph_spec.py @@ -35,6 +35,15 @@ expect(graph.show_percent).to(be_true) expect(graph.suffix).to(equal('kW')) + with it('stacked attribute should be considered a field'): + xml = """ + + + + """ + graph = parse_graph(xml) + expect(graph.fields).to(contain('create_date', 'reclamacio', 'import_phase')) + with it("should parse a chart graph XML with type line"): xml = """ @@ -96,3 +105,141 @@ expect(result['yAxisProps']).to(equal({ 'mode': 'full', })) + with description("A bar graph with stacked field"): + with it("should read the stacked field"): + xml = """ + + + + """ + graph = parse_graph(xml) + fields = { + 'create_date': {'type': 'datetime', 'string': 'Create date'}, + 'reclamacio': {'type': 'char', 'string': 'Reclamacio', 'size': 256}, + 'import_phase': {'type': 'selection', 'selection': [('10', 'Phase 1'), ('20', 'Phase 2')], 'string': 'Import Phase'} + } + values = [ + { + "create_date": "2025-03-03 21:00:57", + "id": 504776, + "reclamacio": "", + "import_phase": "10", + }, + { + "create_date": "2025-02-28 21:01:07", + "id": 504257, + "reclamacio": "48-a2", + "import_phase": "20", + }, + { + "create_date": "2025-02-28 21:00:41", + "id": 503955, + "reclamacio": "48-a2", + "import_phase": "10", + }, + { + "create_date": "2025-02-27 21:01:59", + "id": 503688, + "reclamacio": "48-a3", + "import_phase": "10", + }, + { + "create_date": "2025-02-07 21:00:39", + "id": 488390, + "reclamacio": "", + "import_phase": "10", + }, + { + "create_date": "2025-01-27 21:01:02", + "id": 478886, + "reclamacio": "", + "import_phase": "10", + }, + { + "create_date": "2025-01-24 21:01:20", + "id": 478167, + "reclamacio": "48-a3", + "import_phase": "10", + }, + { + "create_date": "2025-01-23 21:00:45", + "id": 477151, + "reclamacio": "", + "import_phase": "10", + }, + { + "create_date": "2025-01-23 21:00:43", + "id": 477130, + "reclamacio": "", + "import_phase": "20", + }, + { + "create_date": "2025-01-08 21:00:58", + "id": 467715, + "reclamacio": "", + "import_phase": "20", + } + ] + result = graph.process(values, fields) + expect(result).to(equal({ + "num_items": 10, + "type": "bar", + "isGroup": True, + "isStack": True, + "data": [{ + "stacked": "10", + "operator": "count", + "x": "2025-01", + "type": " - Phase 1", + "value": 2.0 + }, + { + "stacked": "20", + "operator": "count", + "x": "2025-01", + "type": " - Phase 2", + "value": 2.0 + }, + { + "stacked": "10", + "operator": "count", + "x": "2025-01", + "type": "48-a3 - Phase 1", + "value": 1.0 + }, + { + "stacked": "10", + "operator": "count", + "x": "2025-02", + "type": " - Phase 1", + "value": 1.0 + }, + { + "stacked": "10", + "operator": "count", + "x": "2025-02", + "type": "48-a2 - Phase 1", + "value": 1.0 + }, + { + "stacked": "20", + "operator": "count", + "x": "2025-02", + "type": "48-a2 - Phase 2", + "value": 1.0 + }, + { + "stacked": "10", + "operator": "count", + "x": "2025-02", + "type": "48-a3 - Phase 1", + "value": 1.0 + }, + { + "stacked": "10", + "operator": "count", + "x": "2025-03", + "type": " - Phase 1", + "value": 1.0 + }] + })) From b8c6dd015afb316248d5a86ae60c242d911ec100 Mon Sep 17 00:00:00 2001 From: Eduard Carreras Date: Tue, 22 Jul 2025 11:02:53 +0200 Subject: [PATCH 2/3] Update ooui/graph/chart.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ooui/graph/chart.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ooui/graph/chart.py b/ooui/graph/chart.py index 922a461..0238fbd 100644 --- a/ooui/graph/chart.py +++ b/ooui/graph/chart.py @@ -92,16 +92,16 @@ def process(self, values, fields, options=None): 'stacked': y_field.stacked }) else: - if y_field.stacked not in fields: - values_grouped_by_y_label = get_values_grouped_by_field( - y_field.label, fields, objects_for_x_value - ) - else: + if y_field.stacked in fields: values_grouped_by_y_label = get_values_grouped_by_fields( [y_field.label, y_field.stacked], fields, objects_for_x_value ) + else: + values_grouped_by_y_label = get_values_grouped_by_field( + y_field.label, fields, objects_for_x_value + ) for y_unique_value, grouped_entries in values_grouped_by_y_label.items(): entries = grouped_entries['entries'] From 1bfa8fda01964319ffb8bc2e30eb859ac61d8c20 Mon Sep 17 00:00:00 2001 From: GISCE Bot Date: Fri, 25 Sep 2026 15:36:40 +0200 Subject: [PATCH 3/3] fix(graph): handle empty dynamic stack labels Co-authored-by: ecarreras <294235+ecarreras@users.noreply.github.com> --- ooui/graph/processor.py | 12 ++++++++---- spec/graph/processor_spec.py | 28 +++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/ooui/graph/processor.py b/ooui/graph/processor.py index f21651e..77457a6 100644 --- a/ooui/graph/processor.py +++ b/ooui/graph/processor.py @@ -58,10 +58,14 @@ def get_values_grouped_by_fields(fields_names, fields, values): grouped_values = {} for entry in values: - key = tuple(get_value_and_label_for_field(fields, entry, field_name)['value'] - for field_name in fields_names) - label = ' - '.join(get_value_and_label_for_field(fields, entry, field_name)['label'] - for field_name in fields_names) + field_values = [ + get_value_and_label_for_field(fields, entry, field_name) + for field_name in fields_names + ] + key = tuple(field_value['value'] for field_value in field_values) + label = ' - '.join( + field_value['label'] or '' for field_value in field_values + ) if key not in grouped_values: grouped_values[key] = {'label': label, 'entries': []} diff --git a/spec/graph/processor_spec.py b/spec/graph/processor_spec.py index b28f6e6..ebda3ea 100644 --- a/spec/graph/processor_spec.py +++ b/spec/graph/processor_spec.py @@ -7,7 +7,8 @@ from ooui.graph import parse_graph from ooui.graph.processor import ( get_values_grouped_by_field, get_all_objects_in_grouped_values, - get_values_for_y_field, process_graph_data, get_min_max + get_values_for_y_field, process_graph_data, get_min_max, + get_values_grouped_by_fields ) current_dir = os.path.dirname(os.path.abspath(__file__)) @@ -471,6 +472,31 @@ def get_graph_data(xml, model): })) +with description('Testing get_values_grouped_by_fields') as self: + with context('when a selection field has no value'): + with it('should preserve the group key and use an empty label'): + fields_data = { + 'category': {'type': 'string'}, + 'phase': {'type': 'selection', 'selection': [ + ('10', 'Phase 1') + ]} + } + values_data = [ + {'category': 'Fruit', 'phase': False} + ] + + grouped_values = get_values_grouped_by_fields( + ['category', 'phase'], fields_data, values_data + ) + + expect(grouped_values).to(equal({ + ('Fruit', False): { + 'label': 'Fruit - ', + 'entries': values_data + } + })) + + with description('Testing get_all_objects_in_grouped_values') as self: with context('when retrieving all objects from grouped values'): with it('should return a single list containing all objects'):