Using The XTreeWidget: How to display data that sort nicely, localize well, and remember where you were

The XTreeWidget is used throughout the xTuple ERP application to display data in tabular form. In xTuple ERP releases 2.3.2 and earlier, developers who wanted anything more than a simple list of records had to write C++ code to manually insert each row into the XTreeWidget. The queries had to convert all of the data to character strings for localization purposes, which often meant that numeric values had to be selected twice or even calculated twice - once for display purposes and again for performing calculations. Because the displayed data were actually character strings, sorting the data by clicking on column headers often behaved strangely and, to get dates to behave reasonably, the formats used for dates were different when displayed in XTreeWidgets than in date entry fields.

Starting with release 3.0, much of this complexity can be moved to the query, often resulting in less code that is easier to write and easier to maintain. This page documents how to use the XTreeWidget to best advantage, writing as little C++ or QtScript code as possible.

Write The Basic Query

The primary usage of XTreeWidgets is to show the results of database queries. So start by writing a query that returns the data you want to see. There are a few rules the queries have to follow to be usable by the XTreeWidget:

  1. The first column must be an integer value, preferably the id column of the primary table being displayed.
  2. All result columns must be named.
  3. Avoid formatting the result columns as much as possible.
  4. Avoid using AS to create column names that end with role, except as described below.

What does this mean in practice? Here are two examples, one that follows the rules and one that doesn't:

-- A Good Query:
SELECT item.*, i.uom_name AS inv_uom_name, p.uom_name AS price_uom_name
FROM item LEFT OUTER JOIN uom i ON (item_inv_uom_id=i.uom_id)
          LEFT OUTER JOIN uom p ON (item_price_uom_id=p.uom_id)
LIMIT 100;

-- A Bad Query:
SELECT item_number,
       itemCapUOM(item_id),
       formatPrice(item_listprice) AS listp,
       itemCapInvRat(item_id) AS capacityrole
FROM item
LIMIT 100;

Note the following about the good query:

The bad query breaks all of the rules:

Creating XTreeWidgets And Their Columns

A query on its own isn't enough; we need a place to display the results of that query and the right widget for that is the XTreeWidget.

To use an XTreeWidget on a window, you must either drop the widget onto the graphical representation of the window in Qt Designer or add it to the window using C++ or QtScript. When you do this and nothing else, the widget appears as an empty rectangle when you open the window.

The next step is to add columns to the widget. If you're writing in C++, then this is usually done in the window's constructor. Using the good query from above we could do something like this in C++:

// C++ example
...
_xtree->addColumn(tr("Item"),        _itemColumn, Qt::AlignLeft,   true, "item_number");
_xtree->addColumn(tr("Description"),          -1, Qt::AlignLeft,   true, "item_descrip1");
_xtree->addColumn(tr("List Price"), _moneyColumn, Qt::AlignRight,  true, "item_listprice");
_xtree->addColumn(tr("Price UOM"),           100, Qt::AlignCenter, false,"price_uom_name");
...
// Script version
...
_xtree.addColumn("Item",       100, 1, true, "item_number");
_xtree.addColumn("Description", -1, 1, true, "item_descrip1");
_xtree.addColumn("List Price",  60, 2, true, "item_listprice");
_xtree.addColumn("Price UOM",  100, 4, false,"price_uom_name");
...

This creates four columns in the XTreeWidget itself. Each column has certain properties controlled by the arguments to addColumn():

  1. The title of the column. The tr() wrapper invokes Qt's translation mechanisms.

  2. The width of the column in pixels. Positive numeric values are used literally; for example the 100 on the "Price UOM" line means that the columns should be made 100 pixels wide. This value is ignored if this window has previously been opened and column width memory has been turned on (the default). The values _itemColumn and _moneyColumn are symbolic values defined in the source file widgets/xtreewidget.h; these are used throughout the application to provide consistency in how XTreeWidgets are presented. The value -1 is a special case and means that this column should take up whatever space is left after all of the other columns have been sized.

  3. The alignment of the data in this column. These are symbolic values defined by Qt.
  4. The default visibility of this column: If true then the column is visible by default and if false then the column is not visible by default. Note that these defaults only apply if the user has never opened this window before or if column memory has been turned off; otherwise the column memory feature of xTuple ERP will take over and set the visibility.

  5. The query column to use when populating this column in the XTreeWidget.

If you are writing in QtScript then the code looks similar but not identical. The differences are in the column widths and how they are aligned. The symbolic values used in the C++ code are defined using C++ mechanisms and aren't available to QtScript users. In addition there is no context to provide run-time translation.

Populating XTreeWidgets

Equipped with a query and an XTreeWidget with columns, we can now populate the XTreeWidget with the results of that query:

// C++:
XSqlQuery myq;
myq.exec("SELECT item.*, i.uom_name AS inv_uom_name, p.uom_name AS price_uom_name "
         "FROM item LEFT OUTER JOIN uom i ON (item_inv_uom_id=i.uom_id) "
         "          LEFT OUTER JOIN uom p ON (item_price_uom_id=p.uom_id) "
         "LIMIT 100;");
_xtree->populate(myq);

//QtScript:
var myq = toolbox.executeQuery(
         "SELECT item.*, i.uom_name AS inv_uom_name, p.uom_name AS price_uom_name "
       + "FROM item LEFT OUTER JOIN uom i ON (item_inv_uom_id=i.uom_id) "
       + "          LEFT OUTER JOIN uom p ON (item_price_uom_id=p.uom_id) "
       + "LIMIT 100;");
 mywindow.findChild("_xtree").populate(myq);

If it turns out that we need to add another column to the display, all we have to do is add another addColumn() call. The XTreeWidget takes care of most of the localization issues for us, showing dates in the current locale's format and choosing the decimal and group separators for numeric values appropriate for that locale. One thing that does not happen automatically is formatting numbers to the proper number of decimal places.

Numeric Formatting

Numeric values can be formatted according to rules set through system locales. To do so add special columns to the query that name the "numeric role" of this value -- whether this is a weight, a cost, a percentage, etc. The value of the result column must be one of a small set of special values (see Full Disclosure for a full list) and the name of the result column must follow the following convention: nameOfBaseResultColumn_xtnumericrole. For example:

'salesprice' AS item_listprice_xtnumericrole,
'uomratio'   AS item_altcapinvrat_xtnumericrole,
...

The advantages this provides over the old scheme of calling formatting functions are:

Adding Color

You can also add color to the display. Again you must follow a naming convention for the name of the result column: nameOfBaseResultColumn_qtforegroundrole. There are three ways to name the color (see Full Disclosure for details):

  1. Use a localizable color name. This will make your display match the colors used in the rest of xTuple ERP.
  2. Use a standardized color name.
  3. Use an RGB value.

To color a specific value you have to be careful to write the query in such a way that the special color is only used for that value. Make sure you write your query so that the color gets the NULL value wherever you want the default color. For example you could highlight the item number for all items that do not have a pricing unit of measure using xTuple ERP locale value for emphasis:

...
CASE WHEN (price_uom_name IS NULL) THEN 'emphasis'
     END AS item_number_qtforegroundrole,
...

Alternatively you can color the entire row simply by not giving a prefix to qtforegroundrole:

...
CASE WHEN (price_uom_name IS NULL) THEN 'emphasis'
     END AS qtforegroundrole,
...

Adding Indentation

Time to get really fancy...

Let's say you want to create a display that shows a list of orders and the line item details of those orders but you want those details nested underneath the orders so they can be expanded or hidden. With careful crafting of the database query the XTreeWidget can handle this. The trick is to write the query in such a way that the right data appear in the right columns and can be sorted so that all of the line items appear directly after their parent order.

Again the XTreeWidget uses a specially named column in the query result to handle indentation. Since indentation is always performed for the whole line, there is never a prefix to tie the role to a particular column. The keyword for indenting is xtindentrole.

Any line which sets xtindentrole to 0 will appear as a top-level item in the XTreeWidget. Anything with an xtindentrole greater than 0 will be indented under the last item returned by the query with a smaller xtindentrole. This means that level 2 items will be indented under the prior level 1 item and level 1 items will be indented under the prior level 0 item. It also means that if there is a level 0 row followed immediately by a level 3 row, the level 3 row will be indented directly under the level 0 row.

As an example, here's the query that populates the indented Vouchering Edit List in xTuple ERP 3.0:

SELECT orderid, seq,
       CASE WHEN seq = 0 THEN vouchernumber
            ELSE ''
       END AS vouchernumber, ponumber, itemnumber,
       vendnumber, description, itemtype, iteminvuom, f_qty, cost,
       'curr' AS cost_xtnumericrole,
       seq AS cost_xttotalrole,
       CASE WHEN seq = 3 THEN 1
            ELSE seq END AS xtindentrole,
       CASE WHEN findAPAccount(vendid) < 0 THEN 'error'
       END AS qtforegroundrole
FROM (SELECT orderid,
      CASE WHEN length(ponumber) > 0 THEN 0 ELSE 1 END AS seq,
      vouchernumber, ponumber,
      CASE WHEN (itemid = 1) THEN invoicenumber
           ELSE itemnumber END AS itemnumber,
      CASE WHEN (itemid = 1) THEN itemnumber
           ELSE ''         END AS vendnumber,
       vendid, description,
       itemtype, iteminvuom, f_qty, cost
FROM voucheringEditList
UNION -- pull out the credits
SELECT DISTINCT orderid, 2 AS seq, vouchernumber, '' AS ponumber,
       :credit AS itemnumber, '' AS vendnumber, vendid,
       account AS description,
       '' AS itemtype, '' AS iteminvuom, NULL as f_qty, cost
FROM voucheringEditList
WHERE itemid = 2
UNION -- calculate the debits
SELECT orderid, 3 AS seq, vouchernumber, '' AS ponumber,
       :debit AS itemnumber, '' AS vendnumber, vendid,
       CASE WHEN findAPAccount(vendid) < 0 THEN :notassigned
            ELSE formatGLAccountLong(findAPAccount(vendid))
       END AS description,
       '' AS itemtype, '' AS iteminvuom, NULL as f_qty,
       SUM(cost) AS cost
FROM voucheringEditList
WHERE itemid = 2
GROUP BY orderid, vouchernumber, vendid
ORDER BY vouchernumber, ponumber desc, seq) AS sub;

There's a lot of code here but it isn't too hard to understand if you break it down:

Here is an example of what this query produces:

Note that the vouchered quantity column is not formatted the same way as the cost column. This example was created while running in a non-US locale. The comma (',') decimal separator used in the cost column comes from the locale, as does the one-decimal-place precision, while the vouchered quantity is actually a string value produced by the view, which is not locale-aware (in xTuple ERP version 3.0).

Full Disclosure

Let's be honest here... the only way to get full disclosure is by reading the source code - which you can do if you like: go to http://postbooks.svn.sourceforge.net/viewvc/postbooks/xtuple/trunk/widgets/ and click on the number in the column next to xtreewidget.cpp, near the bottom of the window. Until you get around to that, however, here are some useful details about how to use the XTreeWidget.

Colors

xTuple recommends that you use as little color as necessary to make your point, and when you do need to add color that you use xTuple ERP's localizable color scheme whenever possible. This will let your changes blend well with the rest of the application and respond to the needs of users. Colors are chose from the locale palette by using one of the following names:

These correspond to the similarly named fields in the locale table and on the Locale window.

If you need to use something other than one of the six named locale colors, you can use any color that Qt understands:

#2F9

#EE0055

#1267BC

Role keywords

So, what is the full list of fancy formatting things you can do with the XTreeWidget and a carefully crafted query?

Query Column Name

Description

Allowed Values

Prefix Optional?

Additional Notes

column_qtdisplayrole

This specifies the value to display if, for some reason, you don't want to show the raw selected value. For example, many displays which show expiration dates use the word 'Never' to show that there is no expiration date. This is done by setting the qtdisplayrole for the expiration date to 'Never' if the expiration date is either NULL or the same as the endOfTime().

Yes

column_qttextalignmentrole

This specifies how the data should be aligned in the available space. This is a numeric value which can combine both horizontal and vertical information. See the Qt documentation for full details.

The most common values will probably be: 1 for align left, 2 for align right, or 4 for horizontal centering.

Yes

column_qtbackgroundrole

This is the color to use for the background of the cell or row to be displayed.

See Colors

Yes

column_qtforegroundrole

This is the color to use for the text of the cell or row to be displayed.

Yes

column_qttooltiprole

This specifies the text to show when the user hovers the mouse pointer over the cell or row.

Yes

column_qtstatustiprole

This specifies the text to show in the status bar when the user hovers the mouse pointer of the cell or row.

Yes

This has not been tested as of release 3.1

column_qtfontrole 

This specifies the font to use when drawing the cell or row contents.

Any valid font name on your system.

Yes

Use this sparingly, as different machines may have different fonts installed.

column_qtkeyrole 

Yes

Not implemented yet.

column_xtrunningrole  

This is used to indicate that the column in the output should be displayed as a running total rather than as the raw value of column. The xtrunningrole should only be used on numeric columns.

To show a running total of an entire data set, set the xtrunningrole to 0. If you want to show running totals of subsets of the data, set this to a different integer for each subset.

No

Subset running totals have not been tested thoroughly as of release 3.1.

column_xtrunninginitrole

The xtrunninginitrole lets you give a starting value for running totals. For example, the register for a checking account over a particular month might start with a balance from the previous month. The query that populates this check register should set the xtrunninginitrole to this fixed initial balance for the column that shows the running balance of that account.

No

column_xtgrouprunningrole

No

Not implemented yet.

column_xtttotalrole

This indicates that the contents of this column should be totaled and the total displayed in an additional 'Totals' line at the end of the display. Set this to 0.

No

Eventually this may be changed to allow grouping subtotals. For example, show separate total lines for each currency.

column_xtnumericrole

This is used to indicate how many decimal places should be displayed.

cost, extprice, percent, purchprice, qty, qtyper, salesprice, scrap, uomratio, weight, or anything starting curr

No

The percent and scrap roles multiply the value by 100. This differs from the application-level functions and stored procedures which format numeric values for percentages and scrap values.

column_xtnullrole

This is a special case handler for NULL database values. If no qtdisplay role has been given but the xtnullrole has and the database value is NULL, then the value of the xtnullrole will be displayed instead of NULL. For example, this can be used to display N/A if numeric values are missing.

No

xtindentrole

This is described in detail above.

Prefix Not Allowed

xthiddenrole

This lets you hide rows that you want to select without filtering at the query level but decide not to show.

SQL true or false

Prefix Not Allowed

added in 3.1

Other useful functionality

Here are some useful methods defined in the XTreeWidget class:

void populate(XSqlQuery, bool = false);

Given an executed query, populate the XTreeWidget with its data. The widget assumes that the first column is the primary id for this record. If the second parameter is set to true then the second column of the query is an alternate id for this row which can be retrieved later with the altId() method.

void populate(XSqlQuery, int, bool = false);

Populate the widget as described above but also select the record with the given id after finishing.

int id();

Return the id of the currently selected row or -1 if no row is selected.

int altId();

Return the alternate id of the currently selected row, or -1 if no row is selected or the widget was populated without alternate ids.

int column(QString);

Return the index of the column in the widget that holds data from the named query column. For example, _xtree->column("item_number");

void sortItems(int, Qt::!SortOrder);

Sort the data based on the values in the given column, either in ascending order (Qt::!AscendingOrder = 0) or descending order (Qt::!DescendingOrder = 1).

void hideColumn(int);

Hide the column indicated by its index.

void hideColumn(QString &);

Hide the column indicated by its query result column name.

Here are some of the signals emitted by the XTreeWidget class:

valid(bool)

Emitted when the selection changes, this gives the listener a boolean value indicating whether the selection is a valid row or not.

newId(int)

This signal is emitted when selection changes, giving the listener the id of the selected row or -1.

populateMenu(QMenu*, QTreeWidgetItem*)

When the user right-clicks on a row in the XTreeWidget, this tells the listener the QMenu to which it can add items and the clicked QTreeWidgetItem.

populateMenu(QMenu*, QTreeWidgetItem*, int)

Like the previous signal, this is emitted when the user right-clicks. It gives the additional information of the column the user clicked on.

resorted()

This signal is emitted after the data in the XTreeWidget have been sorted.

Each row of data in the XTreeWidget is represented internally by an XTreeWidgetItem. Look at the documentation for QTreeWidget for ways to get items from the tree. For the purposes of this document, the most useful thing you can do with the XTreeWidgetItem is get the raw values stored in a particular column, something like this:

QString itemnum = _xtree->topLevelItem(4)->rawValue("item_number").toString();

There are other methods, like id() and altId(), but you don't really need those very frequently since the XTreeWidget has these methods, too, and they return values based on the user's current selection.

Some Words Of Advice

SELECT item.*, i.uom_name AS inv_uom_name, p.uom_name AS price_uom_name,
       'salesprice' AS item_listprice_xtnumericrole,
       'uomratio'   AS item_altcapinvrat_xtnumericrole,
       CASE WHEN (price_uom_name IS NULL) THEN 'emphasis',
       END AS item_number_qtforegroundrole
FROM item LEFT OUTER JOIN uom i ON (item_inv_uom_id=i.uom_id)
          LEFT OUTER JOIN uom p ON (item_price_uom_id=p.uom_id)
LIMIT 100;

SELECT item_id,
       item_number,
       CASE WHEN (price_uom_name IS NULL) THEN 'emphasis',
       END AS item_number_qtforegroundrole,
       i.uom_name AS inv_uom_name, p.uom_name AS price_uom_name,
       item_listprice,    'salesprice' AS item_listprice_xtnumericrole,
       item_altcapinvrat, 'uomratio'   AS item_altcapinvrat_xtnumericrole
FROM item LEFT OUTER JOIN uom i ON (item_inv_uom_id=i.uom_id)
          LEFT OUTER JOIN uom p ON (item_price_uom_id=p.uom_id)
LIMIT 100;

UsingTheXTreeWidget (last edited 2008-12-31 22:05:02 by ptyler)