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}