Skip to content

Commit f4aad03

Browse files
"Export to CSV" functionality for main tables in oVirt Administration Portal
Signed-off-by: Stepan Ermakov <[email protected]> This feature adds new menu item "Export to CSV" for the following tables of oVirt Administration Portal Compute * Virtual Machines * Templates * Pools * Hosts * Data Centers Network * Networks Storage * Domains * Volumes * Disks Events The menu item allows to export current content (taking in to account current localization, columns visibility, sort order, filter) of the selected table into CSV. And initiates automatic download of the created CSV file. Known limitation: not more than 10,000 can be exported.
1 parent 12af746 commit f4aad03

File tree

17 files changed

+557
-2
lines changed

17 files changed

+557
-2
lines changed

frontend/webadmin/modules/gwt-common/src/main/java/org/ovirt/engine/ui/common/CommonApplicationConstants.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ public interface CommonApplicationConstants extends Constants {
1616

1717
String emailUser();
1818

19+
String exportCsv();
20+
1921
@DefaultStringValue("") // Use annotation and not a properties key to leave it out of translations
2022
String empty();
2123

frontend/webadmin/modules/gwt-common/src/main/java/org/ovirt/engine/ui/common/uicommon/model/SearchableTableModelProvider.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,11 @@
1212
* List model type.
1313
*/
1414
public interface SearchableTableModelProvider<T, M extends SearchableListModel> extends SearchableModelProvider<T, M>, ActionTableDataProvider<T> {
15+
/**
16+
* Returns base file name for exported CSV content. Or <code>null</code> if CSV export is not supported by the table
17+
* @return base file name or <code>null</code>
18+
*/
19+
default String csvExportFilenameBase() {
20+
return null;
21+
}
1522
}

frontend/webadmin/modules/gwt-common/src/main/java/org/ovirt/engine/ui/common/widget/table/SimpleActionTable.java

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,18 +85,25 @@ public SimpleActionTable(final SearchableTableModelProvider<T, ?> dataProvider,
8585
setLoadingState(LoadingState.LOADING);
8686
});
8787

88-
createActionKebab();
88+
createActionKebab(dataProvider);
8989
showActionKebab();
9090
}
9191

92-
private void createActionKebab() {
92+
private void createActionKebab(SearchableTableModelProvider<T, ?> dataProvider) {
9393
ActionButton changeBtn = new ActionAnchorListItem(constants.changeColumnsVisibilityOrder());
9494
changeBtn.addClickHandler(event -> showColumnModificationDialog(event));
9595
actionKebab.addMenuItem(changeBtn);
9696

9797
ActionButton resetBtn = new ActionAnchorListItem(constants.resetGridSettings());
9898
resetBtn.addClickHandler(event -> resetGridSettings());
9999
actionKebab.addMenuItem(resetBtn);
100+
101+
String csvFilenameBase = dataProvider.csvExportFilenameBase();
102+
if (csvFilenameBase != null) {
103+
ActionButton csvExportBtn = new ActionAnchorListItem(constants.exportCsv());
104+
csvExportBtn.addClickHandler(event -> exportCsv(csvFilenameBase));
105+
actionKebab.addMenuItem(csvExportBtn);
106+
}
100107
}
101108

102109
private void showColumnModificationDialog(ClickEvent event) {
@@ -108,6 +115,11 @@ private void resetGridSettings() {
108115
table.resetGridSettings();
109116
}
110117

118+
private void exportCsv(String filenameBase) {
119+
TableCsvExporter<T> csvExporter = new TableCsvExporter<>(filenameBase, getDataProvider(), table);
120+
csvExporter.generateCsv();
121+
}
122+
111123
public void showActionKebab() {
112124
actionKebab.setVisible(true);
113125
}
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
package org.ovirt.engine.ui.common.widget.table;
2+
3+
import java.util.ArrayList;
4+
import java.util.Collection;
5+
import java.util.Date;
6+
import java.util.List;
7+
8+
import org.ovirt.engine.core.common.businessentities.BusinessEntityWithStatus;
9+
import org.ovirt.engine.ui.common.uicommon.model.SearchableTableModelProvider;
10+
import org.ovirt.engine.ui.common.widget.table.column.AbstractColumn;
11+
import org.ovirt.engine.ui.uicommonweb.models.ListModel;
12+
import org.ovirt.engine.ui.uicompat.EnumTranslator;
13+
import org.ovirt.engine.ui.uicompat.Event;
14+
import org.ovirt.engine.ui.uicompat.EventArgs;
15+
import org.ovirt.engine.ui.uicompat.IEventListener;
16+
17+
import com.google.gwt.i18n.client.DateTimeFormat;
18+
import com.google.gwt.safehtml.shared.SafeHtml;
19+
import com.google.gwt.user.cellview.client.AbstractCellTable;
20+
import com.google.gwt.user.cellview.client.Column;
21+
import com.google.gwt.user.cellview.client.Header;
22+
23+
/**
24+
* A utility class that allows to export content of any {@link ActionCellTable} to CSV
25+
* <p>
26+
* This class allows to export content of any {@link ActionCellTable} with {@link SearchableTableModelProvider} to CSV:
27+
* <ul>
28+
* <li>It takes into account current columns visibility. Only visible columns are exported</li>
29+
* <li>Current sorting configuration would be applied to the exported content</li>
30+
* <li>Current filtering configuration would be applied to the exported content</li>
31+
* <li>It generates exported CSV file name in the following way: filenameBase.currentDateAndTime.csv, where the
32+
* filenameBase is provided by {@link SearchableTableModelProvider}, see csvExportFilenameBase method;
33+
* currentDateAndTime is the current date and time in the yyyy-MM-dd.HH-mm format</li>
34+
* <li>It initiates automatic download of the generated CSV file</li>
35+
* <li>The generated CSV file is limited by 10000 rows</li>
36+
* </ul>
37+
* @param <T>
38+
* Table row data type.
39+
**/
40+
public class TableCsvExporter<T> {
41+
private static final int LINES_LIMIT = 10000;
42+
private static final String HTML_TAG_PATTERN = "<[^>]*>";//$NON-NLS-1$
43+
private static final String EMPTY = "";//$NON-NLS-1$
44+
private static final char SPACE = ' ';//$NON-NLS-1$
45+
private static final char NEW_LINE = '\n';//$NON-NLS-1$
46+
private static final char SEPARATOR = ',';//$NON-NLS-1$
47+
private static final char DOT = '.';//$NON-NLS-1$
48+
private static final char SINGLE_QUOTE = '\'';//$NON-NLS-1$
49+
private static final char DOUBLE_QUOTE = '"';//$NON-NLS-1$
50+
private static final String DOUBLE_QUOTE_STR = "\"";//$NON-NLS-1$
51+
private static final String DOUBLE_DOUBLE_QUOTE_STR = "\"\"";//$NON-NLS-1$
52+
private static final String FILE_EXT = ".csv";//$NON-NLS-1$
53+
private static final String FILE_CURRENT_DATE_AND_TIME_FORMAT = "yyyy-MM-dd.HH-mm";//$NON-NLS-1$
54+
55+
private final String filenameBase;
56+
private final SearchableTableModelProvider<T, ?> modelProvider;
57+
private final AbstractCellTable<T> table;
58+
private final ColumnController columnController;
59+
private final boolean testMode;
60+
private final StringBuilder csv;
61+
private int pageOffset;
62+
private int linesExported = -1;
63+
64+
public TableCsvExporter(String filenameBase, SearchableTableModelProvider<T, ?> modelProvider, ActionCellTable<T> table) {
65+
this(filenameBase, modelProvider, table, table);
66+
}
67+
68+
TableCsvExporter(String filenameBase, SearchableTableModelProvider<T, ?> modelProvider, AbstractCellTable<T> table, ColumnController<T> columnController) {
69+
this.filenameBase = filenameBase;
70+
this.modelProvider = modelProvider;
71+
this.table = table;
72+
this.columnController = columnController;
73+
this.csv = new StringBuilder();
74+
this.pageOffset = 0;
75+
this.testMode = table != columnController; // For unit tests
76+
}
77+
78+
public void generateCsv() {
79+
// Header
80+
int colCount = table.getColumnCount();
81+
List<AbstractColumn<T, ?>> columns = new ArrayList<>();
82+
boolean firstInLine = true;
83+
for (int i = 0; i < colCount; i++) {
84+
Column<T, ?> col = table.getColumn(i);
85+
if (columnController.isColumnVisible(col) &&
86+
col instanceof AbstractColumn) {
87+
String colName = ((AbstractColumn<?, ?>)col).getContextMenuTitle();
88+
if (colName == null || colName.isEmpty()) {
89+
Header<?> header = table.getHeader(i);
90+
colName = csvValue(header.getValue());
91+
}
92+
if (colName != null && !colName.isEmpty()) {
93+
columns.add((AbstractColumn<T, ?>) col);
94+
firstInLine = appendItem(firstInLine, colName);
95+
}
96+
}
97+
}
98+
newLine();
99+
100+
// Content
101+
// Note that in order to export content of the table we need to scroll to the first page, then export the content
102+
// by moving forward page by page till the end (or till the 10000 rows limit is reached). And then return to the
103+
// page where the export functionality was initiated.
104+
ListModel<T> model = modelProvider.getModel();
105+
Event<EventArgs> itemsChangedEvent = model.getItemsChangedEvent();
106+
if (modelProvider.canGoBack()) {
107+
// If we are not on the first page then let's move to the first page
108+
itemsChangedEvent.addListener(new IEventListener<EventArgs>() {
109+
@Override
110+
public void eventRaised(Event<? extends EventArgs> ev, Object sender, EventArgs args) {
111+
if (modelProvider.canGoBack()) {
112+
// We are still not on the first page
113+
pageOffset--;
114+
modelProvider.goBack();
115+
} else {
116+
// The first page was reached. Let's generate the CSV file
117+
itemsChangedEvent.removeListener(this);
118+
generateContent(columns);
119+
}
120+
}
121+
});
122+
pageOffset--;
123+
modelProvider.goBack();
124+
} else {
125+
// We are on the first page already. Let's generate the CSV file
126+
generateContent(columns);
127+
}
128+
}
129+
130+
private void generateContent(List<AbstractColumn<T, ?>> columns) {
131+
ListModel<T> model = modelProvider.getModel();
132+
// Export current page to CSV ...
133+
generatePage(columns, model.getItems());
134+
if (hasMoreData()) {
135+
// ... and then move to the next page if any
136+
Event<EventArgs> itemsChangedEvent = model.getItemsChangedEvent();
137+
itemsChangedEvent.addListener(new IEventListener<EventArgs>() {
138+
@Override
139+
public void eventRaised(Event<? extends EventArgs> ev, Object sender, EventArgs args) {
140+
// When the next page was loaded continue the export
141+
itemsChangedEvent.removeListener(this);
142+
generateContent(columns);
143+
}
144+
});
145+
pageOffset++;
146+
modelProvider.goForward();
147+
} else {
148+
// All the content was exported, so move to the initial page and initiate download of the exported CSV file
149+
restorePageAndFinish();
150+
}
151+
}
152+
153+
private void restorePageAndFinish() {
154+
// Before initiating the download of the exported content we want to return to the initial page of the table
155+
if (pageOffset > 0 && modelProvider.canGoBack()) {
156+
// We still are not on the initial page. Let move towards it
157+
ListModel<T> model = modelProvider.getModel();
158+
Event<EventArgs> itemsChangedEvent = model.getItemsChangedEvent();
159+
itemsChangedEvent.addListener(new IEventListener<EventArgs>() {
160+
@Override
161+
public void eventRaised(Event<? extends EventArgs> ev, Object sender, EventArgs args) {
162+
itemsChangedEvent.removeListener(this);
163+
restorePageAndFinish();
164+
}
165+
});
166+
pageOffset--;
167+
modelProvider.goBack();
168+
} else {
169+
// We reached the initial page, let's initiate automatic download of the generated CSV file
170+
if (!testMode) { // disabled for unit tests
171+
downloadCsv(getFileName(), getGeneratedCsv());
172+
}
173+
}
174+
}
175+
176+
private void generatePage(List<AbstractColumn<T, ?>> columns, Collection<T> items) {
177+
boolean firstInLine = true;
178+
for (T item : items) {
179+
for (AbstractColumn<T, ?> col : columns) {
180+
String cellValue = csvValue(col.getValue(item));
181+
if (cellValue == null || cellValue.isEmpty()) {
182+
cellValue = csvValue(col.getTooltip(item));
183+
}
184+
firstInLine = appendItem(firstInLine, cellValue);
185+
}
186+
firstInLine = newLine();
187+
}
188+
}
189+
190+
private boolean hasMoreData() {
191+
return modelProvider.canGoForward() && linesExported < LINES_LIMIT;
192+
}
193+
194+
private String csvValue(Object tableValue) {
195+
String result = null;
196+
if (tableValue instanceof String) {
197+
result = (String) tableValue;
198+
} else if (tableValue instanceof SafeHtml) {
199+
result = ((SafeHtml) tableValue).asString();
200+
} else if (tableValue instanceof BusinessEntityWithStatus) {
201+
result = translateEnum(((BusinessEntityWithStatus) tableValue).getStatus());
202+
}
203+
204+
if (result != null) {
205+
// Sometimes content of a cell contains images (encoded in HTML tags). Let's remove the images. Just leave a
206+
// text of the cell
207+
result = result.replaceAll(HTML_TAG_PATTERN, EMPTY).trim();
208+
}
209+
210+
return result;
211+
}
212+
213+
private String translateEnum(Enum<?> key) {
214+
return testMode ? key.name() : EnumTranslator.getInstance().translate(key);
215+
}
216+
217+
private boolean appendItem(boolean firstInLine, String item) {
218+
if (!firstInLine) {
219+
csv.append(SEPARATOR);
220+
}
221+
if (item != null) {
222+
csv.append(escapeSpecialCharacters(item));
223+
}
224+
return false;
225+
}
226+
227+
private boolean newLine() {
228+
csv.append(NEW_LINE);
229+
linesExported++;
230+
return true;
231+
}
232+
233+
private String escapeSpecialCharacters(String data) {
234+
String escapedData = data.replace(NEW_LINE, SPACE);
235+
if (escapedData.indexOf(SEPARATOR) >= 0 ||
236+
escapedData.indexOf(SINGLE_QUOTE) >= 0 ||
237+
escapedData.indexOf(DOUBLE_QUOTE) >= 0) {
238+
escapedData = DOUBLE_QUOTE + escapedData.replace(DOUBLE_QUOTE_STR, DOUBLE_DOUBLE_QUOTE_STR) + DOUBLE_QUOTE;
239+
}
240+
return escapedData;
241+
}
242+
243+
String getFileName() {
244+
String dt = DateTimeFormat.getFormat(FILE_CURRENT_DATE_AND_TIME_FORMAT).format(new Date());//$NON-NLS-1$
245+
return filenameBase + DOT + dt + FILE_EXT;
246+
}
247+
String getGeneratedCsv() {
248+
return csv.toString();
249+
}
250+
251+
private native void downloadCsv(String filename, String text)/*-{
252+
var pom = document.createElement('a');
253+
pom.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
254+
pom.setAttribute('download', filename);
255+
document.body.appendChild(pom);
256+
pom.click();
257+
document.body.removeChild(pom); }-*/;
258+
}

frontend/webadmin/modules/gwt-common/src/main/resources/org/ovirt/engine/ui/common/CommonApplicationConstants.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -979,6 +979,7 @@ ppcChipset=pseries
979979
s390xChipset=zseries
980980
resetGridSettings=Reset settings
981981
changeColumnsVisibilityOrder=Change columns visibility/order
982+
exportCsv=Export to CSV
982983
typeToSearchPlaceHolder=Type to search
983984
configChangesPending=Configuration changes may be pending. Unplug and replug to apply.
984985
permissionFilter=Permission Filters

0 commit comments

Comments
 (0)