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}