Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
<graph type="gantt">
<field name="name" axis="x"/>
<field name="date_start" axis="y" role="start" label="project_id"/>
<field name="date_end" axis="y" role="end"/>
</graph>
```

`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:
Expand Down
2 changes: 2 additions & 0 deletions ooui/graph/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -10,6 +11,7 @@
'line': GraphChart,
'pie': GraphChart,
'bar': GraphChart,
'gantt': GraphGantt,
}


Expand Down
3 changes: 3 additions & 0 deletions ooui/graph/chart.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
76 changes: 76 additions & 0 deletions ooui/graph/gantt.py
Original file line number Diff line number Diff line change
@@ -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,
}
79 changes: 79 additions & 0 deletions spec/graph/gantt_spec.py
Original file line number Diff line number Diff line change
@@ -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 type="gantt">'
'<field name="name" axis="x"/>'
'<field name="start" axis="y" role="start" label="group"/>'
'<field name="end" axis="y" role="end"/>'
'</graph>')
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 type="gantt">'
'<field name="name" axis="x"/>'
'<field name="start" axis="y" role="start"/>'
'<field name="end" axis="y" role="end" label="project"/>'
'</graph>')
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 type="gantt">'
'<field name="name" axis="x"/>'
'<field name="start" axis="y" role="start"/>'
'<field name="end" axis="y" role="end"/>'
'</graph>')
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 = ('<graph type="gantt"><field name="name" axis="x"/>'
'<field name="start" axis="y" role="start"/>'
'<field name="end" axis="y" role="start"/></graph>')
expect(lambda: parse_graph(xml)).to(raise_error(ValueError))
28 changes: 28 additions & 0 deletions spec/graph/slider_spec.py
Original file line number Diff line number Diff line change
@@ -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 = ('<graph type="{}"><field name="name" axis="x"/>'
'<field name="amount" axis="y" operator="+"/>'
'</graph>').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 = ('<graph type="{}" y_range="slider">'
'<field name="name" axis="x"/>'
'<field name="amount" axis="y" operator="+"/>'
'</graph>').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'}))
Loading