Documentation for this module may be created at Module:UtilsLayout/Table/doc
local p = {} local h = {} local utilsFunction = require("Module:UtilsFunction") local utilsLayout = require("Module:UtilsLayout/Tabs") local utilsNumber = require("Module:UtilsNumber") local utilsString = require("Module:UtilsString") local utilsTable = require("Module:UtilsTable") function p.table(data) data = h.resolveShorthand(data) if data.hideEmptyColumns then data = h.hideEmptyColumns(data) end if data.hideEmptyRows then data = h.hideEmptyRows(data) end data = h.splitCells(data) return h.createTable(data) end function p.tabbedTable(data) local tabs = {} local headerRows = data.headerRows or {} local footerRows = data.footerRows or {} for i, headerRow in ipairs(headerRows) do headerRow.header = true end for i, footerRow in ipairs(footerRows) do footerRow.footer = true end for i, tabData in ipairs(data.tabs) do local tabRows = utilsTable.concat(headerRows, tabData.rows, footerRows) table.insert(tabs, { label = tabData.label, content = p.table({ rows = tabRows }) }) end return utilsLayout.tabs(tabs) end function h.createTable(data) local defaultClass = "wikitable" local html = mw.html.create("table") :addClass(data.class or defaultClass) :addClass(data.sortable and "sortable" or nil) :css({ width = data.stretch and "100%" or nil }) if data.caption then html:tag("caption"):wikitext(data.caption) end for _, row in ipairs(data.rows) do html:node(h.createRow(row)) end local margins if data.align == "center" then margins = { margin = "0 auto" } elseif data.align == "right" then margins = { ["margin-left"] = "auto" } end html:css(margins or {}) html:css(data.styles or {}) return tostring(html) end function h.createRow(row) local html = mw.html.create("tr") if row.id then html:attr("id", row.id) end for _, cell in ipairs(row.cells) do local cellTag = (row.header or row.footer or cell.header or cell.footer) and "th" or "td" local colspan = cell.colspan local rowspan = cell.rowspan if colspan and colspan < 0 then colspan = 1000 end if rowspan and rowspan < 0 then rowspan = 1000 end local cellElement = html:tag(cellTag) :attr("colspan", colspan) :attr("rowspan", rowspan) :css(cell.styles or {}) :wikitext(mw.getCurrentFrame():preprocess(cell.content)) local cellClasses = utilsTable.compact({cell.class or nil, cell.unsortable and "unsortable" or nil}) if #cellClasses > 0 then cellElement:addClass(table.concat(cellClasses, " ")) end if cell.sortValue then cellElement:attr("data-sort-value", cell.sortValue) end end return tostring(html) end function h.resolveShorthand(data) data = mw.clone(data) if data.headers then for i, headerCell in ipairs(data.headers) do data.headers[i] = { unsortable = type(data.sortable) == "table" and not utilsTable.includes(data.sortable, i), content = headerCell, } end table.insert(data.rows, 1, { header = true, cells = data.headers, }) end for i, row in ipairs(data.rows) do row.cells = row.cells or utilsTable.ivalues(row) for j, cell in ipairs(row.cells) do local cell = h.resolveShorthandCell(cell) data.rows[i].cells[j] = cell if cell then cell.styles = utilsTable.merge(row.styles or {}, cell.styles or {}) end end if utilsTable.isEmpty(data.rows[i]) then data.rows[i] = nil end end return data end function h.resolveShorthandCell(cell) if type(cell) == "string" or type(cell) == "number" or type(cell) == "boolean" then return { content = cell } end if type(cell) == "table" and utilsTable.isEmpty(cell) then return nil end if type(cell) == "table" and cell.content and not utilsTable.isArray(cell.content) then return cell end if not cell.content then cell = { content = utilsTable.ivalues(cell) } end if utilsTable.isArray(cell.content) then for i, subrow in ipairs(cell.content) do table.remove(cell, i) cell.content[i] = utilsTable.map(subrow, h.resolveShorthandCell) end end return cell end function h.hideEmptyRows(data) for i, row in ipairs(data.rows) do local isNonEmptyCell = function(cell) return not cell.header and not h.isCellEmpty(cell) end local nonEmptyCells = utilsTable.filter(row.cells, isNonEmptyCell) if #nonEmptyCells == 0 then table.remove(data.rows, i) end end return data end function h.hideEmptyColumns(data) local totalColumns = h.countTotalColumns(data.rows) local emptyCellsPerColumn = {} for i, row in ipairs(data.rows) do for j, cell in ipairs(row.cells) do emptyCellsPerColumn[j] = emptyCellsPerColumn[j] or 0 if (not cell.header and not cell.footer) and h.isCellEmpty(cell) then emptyCellsPerColumn[j] = emptyCellsPerColumn[j] + 1 end end for i in utilsFunction.range(#row.cells + 1, totalColumns) do emptyCellsPerColumn[i] = emptyCellsPerColumn[i] + 1 end end local data = mw.clone(data) local headerRows = utilsTable.filter(data.rows, "header") local footerRows = utilsTable.filter(data.rows, "footer") local totalRows = #data.rows - (math.max(#headerRows, #footerRows)) for i, row in ipairs(data.rows) do for j, cell in ipairs(row.cells) do if emptyCellsPerColumn[j] == totalRows then row.cells[j] = nil end end row.cells = utilsTable.compact(row.cells) end return data end -- Cell is empty if: -- its content is nil or the empty string -- it has subdivisons where all the rows are empty function h.isCellEmpty(cell) if type(cell.content) == "string" and utilsString.isEmpty(cell.content) then return true end if type(cell.content) == "table" then if #utilsTable.keys(cell.content) == 0 then return true end for _, subRow in ipairs(cell.content) do for _, subCell in ipairs(subRow) do if not utilsString.isEmpty(cell.subCell) then return true end end end end return false end function h.countTotalColumns(rows) local maxColumns = 0 for _, row in ipairs(rows) do maxColumns = math.max(maxColumns, #row.cells) end return maxColumns end function h.splitCells(data) data = h.normalize(data) data = h.splitRows(data) data = h.applyColspans(data) data = h.flattenCellGroups(data) return data end function h.normalize(data) for i, row in ipairs(data.rows) do for j, cell in ipairs(row.cells) do data.rows[i].cells[j] = h.normalizeCell(cell) end end return data end function h.normalizeCell(cell) if utilsTable.isArray(cell.content) then return cell.content end return {{cell}} end function h.splitRows(data) data.rows = utilsTable.flatMap(data.rows, h.splitRow) return data end function h.splitRow(row) local splitRows = utilsTable.zip(row.cells, {}) local cellSubrows = utilsTable.zip(splitRows) for i, subrows in ipairs(cellSubrows) do local isNotEmpty = utilsFunction.negate(utilsTable.isEmpty) local lastSubrow, lastSubrowIndex = utilsTable.findLast(subrows, isNotEmpty) if lastSubrow then local rowspan = #subrows - lastSubrowIndex + 1 h.applyRowspan(lastSubrow, rowspan) end end local rows = {} for i, splitRow in ipairs(splitRows) do rows[i] = { header = row.header, footer = row.footer, styles = row.styles, id = row.id, cells = splitRow, } end return rows end function h.applyRowspan(cellGroup, rowspan) if rowspan <= 1 or not cellGroup then return end for i, cell in ipairs(cellGroup) do if cell.rowspan and cell.rowspan > 1 then cell.rowspan = rowspan + cell.rowspan else cell.rowspan = rowspan end end end function h.applyColspans(data) local colspansPerColumn = h.getColspansForEachColumn(data) for i, row in ipairs(data.rows) do for j, cellGroup in ipairs(row.cells) do h.applyColspan(cellGroup, colspansPerColumn[j]) end end return data end function h.getColspansForEachColumn(data) return utilsFunction.pipe(data.rows) { utilsTable._map("cells"), utilsTable._map( utilsTable._map(utilsTable.size) ), utilsTable.zip, utilsTable._map(utilsTable._padNils(utilsNumber.MIN)), utilsTable._map(utilsTable.max), } end function h.applyColspan(cellGroup, colspan) if #cellGroup > 0 and #cellGroup < colspan then cellGroup[#cellGroup].colspan = colspan - #cellGroup + 1 end end function h.flattenCellGroups(data) for i, row in ipairs(data.rows) do row.cells = utilsTable.flatten(row.cells) end return data end function p.Schemas() return { table = { data = { definitions = { rowOptions = { type = "record", properties = { { name = "header", type = "boolean", desc = "If <code>true</code> renders all cells in row as <code><nowiki><th></nowiki></code> elements.", }, { name = "footer", type = "boolean", desc = "If <code>true</code> renders all cells in row as <code><nowiki><th></nowiki></code> elements.", }, { name = "id", type = "string", desc = "Sets the [https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/id id attribute] on the row element which makes the row clickable if [[MediaWiki:Gadget-Tables]] is enabled.", }, { name = "styles", type = "map", keys = { type = "string" }, values = { type = "string" }, desc = "Key-value pairs of CSS properties to apply to each cell in the row.", }, }, }, rows = { type = "array", items = { _ref = "#/definitions/row" }, }, row = { allOf = { { _ref = "#/definitions/rowOptions" }, { type = "record", properties = { { name = "cells", type = "array", items = { _ref = "#/definitions/cell" } }, }, } } }, cells = { type = "record", properties = { { name = "cells", type = "array", items = { _ref = "#/definitions/cell" } }, }, }, cell = { oneOf = { { type = "record", properties = { { name = "header", type = "boolean", desc = "If <code>true</code>, renders the cell as a <code><nowiki><th></nowiki></code> element.", }, { name = "colspan", type = "number", }, { name = "rowspan", type = "number", }, { name = "sortValue", type = "string", desc = "Sort value for the cell.", }, { name = "class", type = "string", desc = "Sets the cell's <code>class</code> HTML attribute." }, { name = "styles", type = "map", keys = { type = "string" }, values = { type = "string" }, desc = "Key-value pairs of CSS properties to apply to the cell. Overrides any conflicting row styles. Consider instead using the <code>class</code> property and CSS stylesheets (e.g. [[MediaWiki:Templates.css]]).", }, { name = "content", required = true, oneOf = { { type = "string", desc = "Wikitext content for the cell.", }, { type = "array", _ref = "#/definitions/rows", desc = "Subvidisions for the cell. Think of it as a table within a table." } } } }, } } }, }, type = "record", required = true, desc = "A Lua table representing the desired wikitable.", properties = { { name = "sortable", oneOf = { { type = "boolean", desc = "If <code>true</code>, renders a table that can be sorted by any of its columns." }, { type = "array", items = { type = "number" }, desc = "An array of column indices to be made sortable.", }, }, }, { name = "hideEmptyColumns", type = "boolean", desc = "If <code>true</code>, columns that are completely empty (except for header and footer rows) are omitted.", }, { name = "hideEmptyRows", type = "boolean", desc = "If <code>true</code>, rows that are completely empty (except for header cells) are omitted.", }, { name = "class", type = "string", default = "wikitable", desc = "Sets the table's <code>class</code> HTML attribute. Note that overrides the <code>sortable</code> option." }, { name = "styles", type = "map", keys = { type = "string" }, values = { type = "string" }, desc = "Key-value pairs of CSS properties to apply to the <code><nowiki><table></nowiki></code> element.\n\nConsider instead using the <code>class</code> property and CSS stylesheets (e.g. [[MediaWiki:Templates.css]]).", }, { name = "caption", type = "string", desc = "Wikitext for a table caption.", }, { name = "headers", type = "array", items = { type = "string" }, desc = "An array of strings to serve as a header row.", }, { name = "rows", required = true, type = "array", items = { allOf = { { _ref = "#/definitions/rowOptions" }, { _ref = "#/definitions/cells" }, } }, }, }, } }, tabbedTable = { data = { type = "record", required = true, properties = { { name = "headerRows", type = "any", typeLabel = "rows", desc = "Header rows common to every tab.", }, { name = "headerRows", type = "any", typeLabel = "rows", desc = "Footer rows common to every tab.", }, { name = "tabs", required = true, allOf = { { type = "record", properties = { { name = "label", type = "string", required = true, desc = "Tab label", }, { name = "rows", type = "any", required = true, typeLabel = "rows", desc = "Table rows for the tab. See {{Sect|table}} for format.", } }, }, } } } }, }, } end function p.Documentation() return { table = { params = {"data"}, returns = "Wikitext for the table using {{mediawiki|Help:Table#Other table syntax|XHTML syntax}}.", cases = { resultOnly = true, { desc = "A table with header row, footer row, and caption", args = { { caption = "A table", rows = { { header = true, cells = { 'column1', 'column2', 'column3'}, }, { id = "row-1", cells = {'cell1', 'cell2', 'cell3'}, }, { id = "row-2", cells = {'cell4', 'cell5', 'cell6'}, }, { footer = true, cells = {'foot1', 'foot2', 'foot2'}, }, }, }, }, }, { desc = "Shorthand syntax", args = { { rows = { {'cell1', 'cell2', 'cell3'}, {'cell4', 'cell5', 'cell6'} }, headers = {'column1', 'column2', 'column3'}, }, }, }, { desc = "Works with pipe characters", args = { { rows = { {"cell | 1", "cell |} 2", "cell {| 3"}, }, } }, }, { desc = "Sortable table - note how <code>Bosses</code> sorts alphabetically but <code>Temples</code> sorts by in-game order due to <code>sortValue</code>.", args = { { sortable = {1, 2}, headers = {"Temples", "Bosses", "Unsortable"}, rows = { { { content = "Forest Temple", sortValue = "1", }, { content = "Gohma", }, }, { { content = "Fire Temple", sortValue = "2", }, { content = "Volvagia", }, }, { { content = "Water Temple", sortValue = "3", }, { content = "Morpha", }, }, { { content = "Shadow Temple", sortValue = "4", }, { content = "Bongo Bongo", }, }, }, }, }, }, { desc = "Cells spanning multiple rows and columns", args = { { rows = { { { rowspan = 2, content = "spans 2 rows", }, { colspan = 2, content = "spans 2 columns", }, { colspan = 3, content = "spans 3 columns", }, }, { { colspan = -1, content = "spans remaining columns", }, }, { { colspan = -1, content = "spans all columns", }, }, }, headers = {"col1", "col2", "col3", "col4", "col5", "col6"} } }, }, { desc = "Option to hide columns when they're completely empty except for headers/footers", args = { { hideEmptyRows = true, hideEmptyColumns = true, rows = { {"row1", "", "row1"}, {"row2", "", "row2"}, }, headers = {"not empty", "empty", "not empty"}, } } }, { desc = "Option to hide rows when they're completely empty except for headers", args = { { hideEmptyRows = true, rows = { { { header = true, content = "Header1"}, "not empty"}, { { header = true, content = "Header2" }, "" }, { }, } } } }, { desc = "Table styles are applied at the table level. Cell styles can be specified individually, or once for the entire row. The table class and cell class can be overridden.", args = { { class = "wikitable custom-element", styles = { ["width"] = "20em", ["text-align"] = "center", }, rows = { { "centered", "centered" }, { styles = { ["text-align"] = "left" }, cells = { { content = "left-aligned" }, { content = "right-aligned", styles = { ["text-align"] = "right", }, class = "custom-cell", }, }, }, }, }, }, }, { desc = "Individual cells subdivided into multiple columns and rows", args = { { styles = { ["text-align"] = "center" }, rows = { { header = true, cells = {"Column1", "Column2", "Column3"}, }, { "A", { {"B1"}, {"B2"}, }, "C" }, { { {"D1", "D2"} }, { {"E1"}, {{ content = "E2", header = true }, "E3"}, }, { {"F1"}, {"F2"}, {"F3"}, } }, { { { "G1", "G2", "G3"} }, "H", {{}}, } }, }, }, } } }, tabbedTable = { params = {"data"}, returns = "A series of wikitables displayed using tabs. Useful for representing data with three dimensions, or data with too many columns.", cases = { resultOnly = true, { desc = "Tabbed table with shared headers and footers", args = { { headerRows = { {"col1", "col2", "col3"} }, footerRows = { {"foo12", "foot2", "foot3"} }, tabs = { { label = "tab1", rows = { {'cell1', 'cell2', 'cell3'}, {'cell4', 'cell5', 'cell6'}, } }, { label = "tab2", rows = { {'cell7', 'cell8', 'cell9'}, {'cell10', 'cell11', 'cell12'}, }, }, }, }, }, }, }, }, } end return p