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