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