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}