diff --git a/docs/usage.md b/docs/usage.md index 2ebaf99..cd2a53f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -19,6 +19,7 @@ Python OOUI supports several graph types: - **line**: Line charts for trend visualization - **bar**: Bar charts for comparative data - **pie**: Pie charts for proportional data +- **gantt**: One interval per record, with start and end fields - **indicator**: Single-value indicators - **indicatorField**: Field-based indicators @@ -65,6 +66,25 @@ result = graph.process(data, fields) print(result) # Processed graph data ready for visualization ``` +For a Y-axis range slider, set `y_range="slider"` on a line or bar graph. +The processed result includes `yAxisOpts: {'mode': 'slider'}`; line graphs also +retain their existing `yAxisProps` response. + +Gantt graphs use one X field for the task name and two Y fields with `start` +and `end` roles. An optional `label` on either Y field groups intervals: + +```xml + + + + + +``` + +`process(records, fields)` keeps each record as a separate interval, even when +task names repeat. The result contains `id`, `x`, `start`, `end`, `type`, and +`record` for each interval, plus `num_items`, `isGroup`, and `isStack`. + ### Indicator Graphs Indicators display single KPI values and support several display modes: diff --git a/ooui/graph/__init__.py b/ooui/graph/__init__.py index 82fe251..9093b55 100644 --- a/ooui/graph/__init__.py +++ b/ooui/graph/__init__.py @@ -2,6 +2,7 @@ from lxml import etree from ooui.graph.indicator import GraphIndicator, GraphIndicatorField from ooui.graph.chart import GraphChart +from ooui.graph.gantt import GraphGantt GRAPH_TYPES = { @@ -10,6 +11,7 @@ 'line': GraphChart, 'pie': GraphChart, 'bar': GraphChart, + 'gantt': GraphGantt, } diff --git a/ooui/graph/chart.py b/ooui/graph/chart.py index 96eb084..ba8ae0f 100644 --- a/ooui/graph/chart.py +++ b/ooui/graph/chart.py @@ -172,4 +172,7 @@ def process(self, values, fields, options=None): y_axis_props['valueOpts'] = get_min_max(final_data) result['yAxisProps'] = y_axis_props + if self.type in ('line', 'bar') and self.y_range == 'slider': + result['yAxisOpts'] = {'mode': 'slider'} + return result diff --git a/ooui/graph/gantt.py b/ooui/graph/gantt.py new file mode 100644 index 0000000..41fb301 --- /dev/null +++ b/ooui/graph/gantt.py @@ -0,0 +1,76 @@ +from __future__ import absolute_import, unicode_literals + +from ooui.graph.base import Graph + + +class GraphGantt(Graph): + def __init__(self, graph_type, element): + super(GraphGantt, self).__init__(element) + self._type = graph_type + + graph_fields = element.findall('field') + if any(not field.get('name') or not field.get('axis') + for field in graph_fields): + raise ValueError('A Gantt field requires name and axis attributes') + + x_fields = [field for field in graph_fields if field.get('axis') == 'x'] + y_fields = [field for field in graph_fields if field.get('axis') == 'y'] + start_fields = [field for field in y_fields + if field.get('role') == 'start'] + end_fields = [field for field in y_fields + if field.get('role') == 'end'] + if (len(x_fields) != 1 or len(y_fields) != 2 + or len(start_fields) != 1 or len(end_fields) != 1): + raise ValueError('A Gantt chart requires one x, start and end field') + + self.x = x_fields[0].get('name') + self.start = start_fields[0].get('name') + self.end = end_fields[0].get('name') + self.group = start_fields[0].get('label') or end_fields[0].get('label') + self._fields = [self.x] + for field in y_fields: + for name in (field.get('name'), field.get('label')): + if name and name not in self._fields: + self._fields.append(name) + + @property + def fields(self): + return self._fields + + def _label(self, record, fields, name, uninformed_string): + field = fields[name] + value = record.get(name) + if field.get('type') == 'many2one': + value = value[1] if value else None + elif field.get('type') == 'selection': + value = next(( + label for key, label in field.get('selection', []) + if key == value and isinstance(key, bool) == isinstance(value, bool) + ), None) + return value or uninformed_string + + def process(self, values, fields, options=None): + options = options or {} + uninformed_string = options.get('uninformedString', 'Not informed') + data = [] + for record in values: + if self.group: + label = self._label(record, fields, self.group, + uninformed_string) + else: + label = fields[self.start].get('string') or self.start + data.append({ + 'id': record.get('id'), + 'x': self._label(record, fields, self.x, uninformed_string), + 'start': record.get(self.start), + 'end': record.get(self.end), + 'type': label, + 'record': record, + }) + return { + 'type': self.type, + 'data': data, + 'num_items': len(values), + 'isGroup': bool(self.group), + 'isStack': False, + } diff --git a/spec/graph/gantt_spec.py b/spec/graph/gantt_spec.py new file mode 100644 index 0000000..284ff48 --- /dev/null +++ b/spec/graph/gantt_spec.py @@ -0,0 +1,79 @@ +from mamba import * +from expects import * +from ooui.graph import parse_graph + + +with description('Gantt graph'): + with it('preserves every Gantt interval and labels selection zero separately from false'): + xml = ('' + '' + '' + '' + '') + graph = parse_graph(xml) + expect(graph.fields).to(equal(['name', 'start', 'group', 'end'])) + fields = { + 'name': {'type': 'char'}, + 'start': {'type': 'datetime', 'string': 'Start'}, + 'end': {'type': 'datetime'}, + 'group': {'type': 'selection', 'selection': [(0, 'Zero'), (1, 'One')]}, + } + values = [ + {'id': 1, 'name': 'Task', 'start': '2026-09-22 08:00:00', + 'end': '2026-09-22 10:00:00', 'group': 0}, + {'id': 2, 'name': 'Task', 'start': '2026-09-22 11:00:00', + 'end': '2026-09-22 12:00:00', 'group': False}, + ] + result = graph.process(values, fields, {'uninformedString': 'Sense informar'}) + expect(result['type']).to(equal('gantt')) + expect(result['num_items']).to(equal(2)) + expect(result['isGroup']).to(be_true) + expect(result['isStack']).to(be_false) + expect([item['type'] for item in result['data']]).to(equal(['Zero', 'Sense informar'])) + expect([item['id'] for item in result['data']]).to(equal([1, 2])) + expect([item['record'] for item in result['data']]).to(equal(values)) + + with it('uses relational group labels and leaves repeated task names separate'): + xml = ('' + '' + '' + '' + '') + graph = parse_graph(xml) + expect(graph.fields).to(equal(['name', 'start', 'end', 'project'])) + fields = { + 'name': {'type': 'char'}, + 'start': {'type': 'date', 'string': 'Start'}, + 'end': {'type': 'date'}, + 'project': {'type': 'many2one'}, + } + values = [ + {'id': 1, 'name': 'Task', 'start': '2026-09-22', + 'end': '2026-09-23', 'project': [5, 'North']}, + {'id': 2, 'name': 'Task', 'start': '2026-09-24', + 'end': '2026-09-25', 'project': [6, 'South']}, + ] + result = graph.process(values, fields) + expect([item['type'] for item in result['data']]).to(equal(['North', 'South'])) + expect([item['id'] for item in result['data']]).to(equal([1, 2])) + + with it('keeps ungrouped and empty Gantt results valid'): + xml = ('' + '' + '' + '' + '') + graph = parse_graph(xml) + fields = {'name': {'type': 'char'}, + 'start': {'type': 'date', 'string': 'Start'}, + 'end': {'type': 'date'}} + expect(graph.process([], fields)['data']).to(equal([])) + result = graph.process([{'id': 1, 'name': 'Task'}], fields) + expect(result['isGroup']).to(be_false) + expect(result['data'][0]['type']).to(equal('Start')) + + with it('rejects Gantt without one start and one end'): + xml = ('' + '' + '') + expect(lambda: parse_graph(xml)).to(raise_error(ValueError)) diff --git a/spec/graph/slider_spec.py b/spec/graph/slider_spec.py new file mode 100644 index 0000000..086bb9c --- /dev/null +++ b/spec/graph/slider_spec.py @@ -0,0 +1,28 @@ +from mamba import * +from expects import * +from ooui.graph import parse_graph + + +with description('Graph Y-axis slider'): + with it('leaves old line and bar payloads unchanged without a slider'): + fields = {'name': {'type': 'char'}, 'amount': {'type': 'integer'}} + values = [{'name': 'January', 'amount': 10}] + for graph_type in ('line', 'bar'): + xml = ('' + '' + '').format(graph_type) + result = parse_graph(xml).process(values, fields) + expect(result).not_to(have_key('yAxisOpts')) + + with it('returns slider metadata for line and bar without removing legacy axis props'): + fields = {'name': {'type': 'char'}, 'amount': {'type': 'integer', 'string': 'Amount'}} + values = [{'name': 'January', 'amount': 10}] + for graph_type in ('line', 'bar'): + xml = ('' + '' + '' + '').format(graph_type) + result = parse_graph(xml).process(values, fields) + expect(result['yAxisOpts']).to(equal({'mode': 'slider'})) + if graph_type == 'line': + expect(result['yAxisProps']).to(equal({'mode': 'slider'}))