001package com.identityworksllc.iiq.common.table;
002
003import org.apache.commons.logging.Log;
004import org.apache.commons.logging.LogFactory;
005import sailpoint.tools.GeneralException;
006import sailpoint.tools.Util;
007
008import java.util.ArrayList;
009import java.util.Arrays;
010import java.util.Collection;
011import java.util.List;
012import java.util.Map;
013
014/**
015 * Type-safe fluent API for creating HTML tables from data inputs. Usage examples:
016 *
017 *  Table table = new Table();
018 *
019 *  (Populating a table with individual cell data)
020 *  table
021 *      .withCellClasses("cellClass")
022 *      .row().header()
023 *          .cell("Header A").withClass("abc")
024 *          .cell("Header B")
025 *      .row()
026 *          .cell(val1)
027 *          .cell(val2)
028 *      .row()
029 *          .cell(val3)
030 *          .cell(val4);
031 *
032 *  (Populating a table with list data)
033 *  table
034 *      .row(headers).header()
035 *      .row(list1).withClass("firstRowClass")
036 *      .row(list2);
037 *
038 *  (Populating a table with lists of lists)
039 *  List rowData = List.of(row1, row2, row3);
040 *  table
041 *      .row(headers).header()
042 *      .rows(rowData);
043 */
044@SuppressWarnings("unused")
045public class Table extends Element {
046    private static class BuilderState {
047        private final List<String> alwaysCellClasses;
048        private final List<CellOption> alwaysCellOptions;
049        private final List<String> alwaysRowClasses;
050        private Cell currentCell;
051        private Row currentRow;
052        private boolean header;
053        private boolean singleInsertCells;
054
055        public BuilderState() {
056            this.alwaysCellOptions = new ArrayList<>();
057            this.alwaysCellClasses = new ArrayList<>();
058            this.alwaysRowClasses = new ArrayList<>();
059        }
060    }
061
062    /**
063     * Table logger
064     */
065    private final static Log log = LogFactory.getLog(Table.class);
066
067    /**
068     * The current state of the table builder
069     */
070    private final transient BuilderState builderState;
071
072    /**
073     * The list of rows
074     */
075    private final List<Row> rows;
076
077    /**
078     * The table width (in percent). You should use CSS instead.
079     */
080    private int width;
081
082    /**
083     * Constructs a new table
084     */
085    public Table() {
086        this.rows = new ArrayList<>();
087        this.cssClasses = new ArrayList<>();
088        this.builderState = new BuilderState();
089        this.width = -1;
090    }
091
092    /**
093     * Creates a new Table and populates it with the given row data and options
094     * @param rows The row data to add
095     * @param options The cell options
096     * @throws GeneralException on failures applying the cell options
097     */
098    public Table(List<Object> rows, CellOption... options) throws GeneralException {
099        this();
100        this.rows(rows, options);
101    }
102
103    /**
104     * Adds a new cell to the current row with the given value content
105     * @param value The value to add
106     */
107    public Table cell(Object value, CellOption... options) throws GeneralException {
108        if (!builderState.singleInsertCells) {
109            throw new IllegalStateException("You cannot add a new cell() after invoking row(list) or rows(). Call row() first.");
110        }
111        if (!(value instanceof String || value instanceof Collection || value instanceof Cell || value == null)) {
112            throw new IllegalArgumentException("Cell values must be either String or Collection types");
113        }
114        if (value instanceof Cell) {
115            // Beanshell may not figure out which method to call, so we'll help it
116            return cell((Cell)value);
117        } else {
118            if (this.builderState.currentRow == null) {
119                throw new IllegalStateException("You must call row() before adding a cell");
120            }
121            Cell newCell = startCell();
122
123            if (value == null) {
124                newCell.setContent("");
125            } else {
126                newCell.setContent(value);
127            }
128
129            if (options != null) {
130                for (CellOption option : options) {
131                    option.accept(newCell);
132                }
133            }
134
135            return this;
136        }
137    }
138
139    /**
140     * Adds a specified cell to the current row. The cell will be modified to add
141     * the 'withCellClasses' classes, if any have been specified.
142     *
143     * @param cell The cell to add
144     */
145    public Table cell(Cell cell) throws GeneralException {
146        if (!builderState.singleInsertCells) {
147            throw new IllegalStateException("You cannot add a new cell() after invoking row(list) or rows(). Call row() first.");
148        }
149        if (this.builderState.currentRow == null) {
150            throw new IllegalStateException("You must call row() before adding a cell");
151        }
152
153        // You may get here from Beanshell if you call cell(var), where var is null
154        if (cell == null) {
155            cell = startCell();
156            cell.setContent("");
157        }
158
159        for(String c : Util.safeIterable(builderState.alwaysCellClasses)) {
160            cell.getCssClasses().add(c);
161        }
162
163        this.builderState.currentRow.add(cell);
164
165        for(CellOption cellOption : Util.safeIterable(builderState.alwaysCellOptions)) {
166            cellOption.accept(cell);
167        }
168
169        this.builderState.currentCell = cell;
170        return this;
171    }
172
173    /**
174     * Clears the cell classes list set by {@link #withCellClasses(String...)}
175     */
176    public Table clearCellClasses() {
177        this.builderState.alwaysCellClasses.clear();
178        return this;
179    }
180
181    /**
182     * Sets the current row or cell to be a header. If the current object
183     * is a row, all cells in that row will be header cells.
184     */
185    public Table header() {
186        if (this.builderState.currentCell != null) {
187            this.builderState.currentCell.setHeader(true);
188        } else if (this.builderState.currentRow != null) {
189            this.builderState.header = true;
190            this.builderState.currentRow.setHeader(true);
191        } else {
192            throw new IllegalStateException("You must call row() before setting the header flag");
193        }
194        return this;
195    }
196
197    /**
198     * Creates a new header row and populates it with the given cell values
199     * @param values The values to add
200     */
201    public Table header(List<Object> values) throws GeneralException {
202        row();
203        header();
204        for(Object v : Util.safeIterable(values)) {
205            cell(v);
206        }
207        return this;
208    }
209
210    /**
211     * Adds a new HTML cell to the current row
212     * @param value The HTML contents
213     */
214    public Table htmlCell(Object value) throws GeneralException {
215        if (!(value instanceof String || value instanceof Collection)) {
216            throw new IllegalArgumentException("Cell values must be either String or Collection types");
217        }
218        if (this.builderState.currentRow == null) {
219            throw new IllegalStateException("You must call row() before adding a cell");
220        }
221        Cell newCell = startCell();
222        newCell.setHtml(true);
223        newCell.setContent(value);
224
225        return this;
226    }
227
228    /**
229     * Renders the table as HTML
230     * @return The rendered HTML
231     */
232    public String render() {
233        StringBuilder html = new StringBuilder();
234        html.append("<table");
235        if (!this.cssClasses.isEmpty()) {
236            html.append(" class=\"").append(getEscapedCssClassAttr()).append("\"");
237        }
238        if (Util.isNotNullOrEmpty(this.style)) {
239            html.append(" style=\"").append(getEscapedStyle()).append("\"");
240        }
241        if (width > 0) {
242            html.append(" width=\"").append(width).append("%\"");
243        }
244        html.append(">");
245
246        boolean inHeader = false;
247        for(Row row : this.rows) {
248            if (row.isHeader() && !inHeader) {
249                html.append("<thead>");
250                inHeader = true;
251            } else if (!row.isHeader() && inHeader) {
252                html.append("</thead><tbody>");
253                inHeader = false;
254            }
255            row.render(html);
256        }
257
258        html.append("</tbody></table>");
259        return html.toString();
260    }
261
262    /**
263     * Starts a new row in the table
264     */
265    public Table row() {
266        Row newRow = new Row();
267        for(String c : Util.safeIterable(builderState.alwaysRowClasses)) {
268            newRow.getCssClasses().add(c);
269        }
270
271        this.builderState.currentRow = newRow;
272        this.builderState.currentCell = null;
273        this.builderState.header = false;
274        this.rows.add(newRow);
275        this.builderState.singleInsertCells = true;
276        return this;
277    }
278
279    /**
280     * Creates a new (non-header) row and populates it with the given cell values.
281     * This will NOT put the builder into cell mode, so all style/class operators will
282     * apply to the row.
283     *
284     * If the value is a Cell object obtained via {@link Cell#of(Object, CellOption...)},
285     * it will be inserted as-is. Otherwise, the provided CellOptions will be applied to
286     * each cell as it is added.
287     *
288     * @param values The values to add
289     * @param options Any cell options you wish to add to each cell
290     */
291    public Table row(List<Object> values, CellOption... options) throws GeneralException {
292        row();
293        for(Object v : Util.safeIterable(values)) {
294            if (v instanceof Cell) {
295                // Avoid an endless loop here
296                cell((Cell)v);
297            } else {
298                cell(v, options);
299            }
300        }
301        // We want 'withXX' operations after this to apply to the row
302        this.builderState.currentCell = null;
303
304        // We want cell() to fail until row() is called again
305        this.builderState.singleInsertCells = false;
306        return this;
307    }
308
309    /**
310     * Accepts a set of row data and adds it to the output table. The input should
311     * be a list of lists. Each item will be interpreted as input to {@link #row(List, CellOption...)}. All
312     * non-list inputs will be quietly ignored.
313     *
314     * The current row and cell will remain blank afterwards, so you cannot use
315     * builder syntax to modify the most recent cell or row.
316     *
317     * @param rowData The row data
318     */
319    public Table rows(List<Object> rowData, CellOption... cellOptions) throws GeneralException {
320        for(Object row : Util.safeIterable(rowData)) {
321            if (row instanceof Collection) {
322                this.row();
323                for(Object item : (Collection<?>)row) {
324                    if (item instanceof Cell) {
325                        this.cell((Cell)item);
326                    } else {
327                        this.cell(item, cellOptions);
328                    }
329                }
330            } else {
331                log.warn("Value passed to Table.rows() that is not a Collection");
332            }
333        }
334        this.builderState.currentCell = null;
335        this.builderState.currentRow = null;
336        this.builderState.singleInsertCells = false;
337        return this;
338    }
339
340    /**
341     * To be used *after* populating the table: applies the given CellOption modifications
342     * to the cells at the given column index in each row. If a given row does not have
343     * enough cells, nothing will happen for that row.
344     *
345     * @param column The column index
346     * @param options The cell options to apply
347     */
348    public Table setColumnCellOptions(int column, CellOption... options) throws GeneralException {
349        if (options != null) {
350            for (Row row : this.rows) {
351                if (row.getCells().size() > column) {
352                    Cell cell = row.getCells().get(column);
353                    if (cell != null) {
354                        for (CellOption option : options) {
355                            option.accept(cell);
356                        }
357                    }
358                }
359            }
360        }
361        return this;
362    }
363
364    /**
365     * To be used *after* populating the table: sets the given style to the
366     * cells at the given column in each row.
367     *
368     * @param column The column index
369     * @param style The style
370     */
371    public Table setColumnStyle(int column, String style) {
372        for(Row row : this.rows) {
373            if (row.getCells().size() > column) {
374                Cell cell = row.getCells().get(column);
375                if (cell != null) {
376                    cell.setStyle(style);
377                }
378            }
379        }
380        return this;
381    }
382
383    /**
384     * To be used *after* populating the table: appends the given style to the
385     * cells at the given column in each row.
386     *
387     * @param column The column index
388     * @param style The style
389     */
390    public Table setExtraColumnStyle(int column, String style) {
391        for(Row row : this.rows) {
392            if (row.getCells().size() > column) {
393                Cell cell = row.getCells().get(column);
394                if (cell != null) {
395                    cell.setStyle(cell.getStyle() + " " + style);
396                }
397            }
398        }
399        return this;
400    }
401
402    /**
403     * To be used *after* populating the table: sets the given style to the given
404     * row, indexed starting from zero, including the header row.
405     *
406     * @param row The row index
407     * @param options The options to apply to each cell in this row
408     */
409    public Table setRowCellOptions(int row, CellOption... options) throws GeneralException {
410        if (this.rows.size() > row) {
411            Row theRow = this.rows.get(row);
412            if (options != null) {
413                for(Cell cell : Util.safeIterable(theRow.getCells())) {
414                    for (CellOption option : options) {
415                        option.accept(cell);
416                    }
417                }
418            }
419        }
420        return this;
421    }
422
423    /**
424     * To be used *after* populating the table: sets the given style to the given
425     * row, indexed starting from zero, including the header row.
426     *
427     * @param row The row index
428     * @param style The style
429     */
430    public Table setRowStyle(int row, String style) {
431        if (this.rows.size() > row) {
432            Row theRow = this.rows.get(row);
433            theRow.setStyle(style);
434        }
435        return this;
436    }
437
438    /**
439     * Starts a new cell, moved here for re-use
440     */
441    private Cell startCell() throws GeneralException {
442        Row currentRow = this.builderState.currentRow;
443        Cell newCell = new Cell();
444        for(String c : Util.safeIterable(builderState.alwaysCellClasses)) {
445            newCell.getCssClasses().add(c);
446        }
447        currentRow.add(newCell);
448        this.builderState.currentCell = newCell;
449        if (this.builderState.header) {
450            newCell.setHeader(true);
451        }
452        return newCell;
453    }
454
455    /**
456     * Sets the table width to the given value, in percent
457     * @param value The percentage
458     */
459    public Table width(int value) {
460        this.width = value;
461        return this;
462    }
463
464    /**
465     * All future cells will have the given classes appended. Note that cells
466     * already in the table will not have the classes added. You should call this
467     * method before adding any cells.
468     */
469    public Table withCellClasses(String... classes) {
470        this.builderState.alwaysCellClasses.addAll(Arrays.asList(classes));
471        return this;
472    }
473
474    /**
475     * Applies the given set of cell options to the current object.
476     *
477     * If applied to a row, it will apply the options to all cells
478     * currently in the row and all cells added to the row in the future.
479     *
480     * If applied to a cell, it apply only to that cell.
481     *
482     * If applied to the table, it will apply to all cells in any row.
483     *
484     * @param options The options to apply to each relevant cell
485     */
486    public Table withCellOptions(List<CellOption> options) throws GeneralException {
487        if (options != null) {
488            if (this.builderState.currentCell != null) {
489                for(CellOption option : options) {
490                    option.accept(this.builderState.currentCell);
491                }
492            } else if (this.builderState.currentRow != null) {
493                for(Cell cell : builderState.currentRow.getCells()) {
494                    for(CellOption option : options) {
495                        option.accept(cell);
496                    }
497                }
498                this.builderState.currentRow.setOptions(new ArrayList<>(options));
499            } else {
500                this.builderState.alwaysCellOptions.clear();
501                this.builderState.alwaysCellOptions.addAll(new ArrayList<>(options));
502            }
503        }
504        return this;
505    }
506
507    /**
508     * Applies the given set of cell options to either the current cell or the
509     * current row. If applied to a row, it will apply the options to all cells
510     * currently in the row and all cells added to the row in the future.
511     *
512     * @param options The options to apply to each relevant cell
513     */
514    public Table withCellOptions(CellOption... options) throws GeneralException {
515        if (options != null) {
516            withCellOptions(Arrays.asList(options));
517        }
518        return this;
519    }
520
521    /**
522     * Adds the given CSS class to the current object
523     * @param cssClasses The CSS class (or space-separated classes)
524     */
525    public Table withClass(String... cssClasses) {
526        for(String cssClass : cssClasses) {
527            if (this.builderState.currentCell != null) {
528                this.builderState.currentCell.getCssClasses().add(cssClass);
529            } else if (this.builderState.currentRow != null) {
530                this.builderState.currentRow.getCssClasses().add(cssClass);
531            } else {
532                this.cssClasses.add(cssClass);
533            }
534        }
535        return this;
536    }
537
538    /**
539     * Resets the first row to be a header row, which will have its header flag
540     * set and any given options applied to all cells in the row.
541     *
542     * @param options An optional list of CellOptions to apply to each cell in the row
543     * @throws GeneralException On any failures applying the CellOptions
544     */
545    public Table withHeaderRow(CellOption... options) throws GeneralException {
546        if (this.rows.size() > 0) {
547            Row firstRow = this.rows.get(0);
548            firstRow.setHeader(true);
549            for(Cell c : Util.safeIterable(firstRow.getCells())) {
550                c.setHeader(true);
551                if (options != null) {
552                    for(CellOption option : options) {
553                        option.accept(c);
554                    }
555                }
556            }
557        }
558        return this;
559    }
560
561    /**
562     * Sets the style of the current item to the given value. If the current item is
563     * not a row or cell, silently does nothing.
564     * @param style The style
565     */
566    public Table withStyle(String style) {
567        if (this.builderState.currentCell != null) {
568            this.builderState.currentCell.setStyle(style);
569        } else if (this.builderState.currentRow != null) {
570            this.builderState.currentRow.setStyle(style);
571        } else {
572            this.setStyle(style);
573        }
574        return this;
575    }
576}