import {nodeToObj, stringToObj} from "diff-dom"
import {parseDate} from "./date"
import {namedNodeMapToObject, objToText} from "./helpers"
import {
cellDataType,
cellType,
columnSettingsType,
DataOption,
dataRowType,
headerCellType,
inputCellType,
inputHeaderCellType,
inputRowType,
nodeType
} from "./types"
export const readDataCell = (cell: inputCellType, columnSettings : columnSettingsType) : cellType => {
let cellData : cellType
let inputData: cellDataType
let attributes: { [key: string]: string } | undefined
// Check if cell is already a cellType object with data property
if (cell?.constructor === Object && Object.prototype.hasOwnProperty.call(cell, "data") && !Object.keys(cell).find(key => !(["text", "order", "data", "attributes"].includes(key)))) {
const cellObj = cell as cellType
inputData = cellObj.data
attributes = cellObj.attributes
// If text and order are already set, return as-is
if (cellObj.text !== undefined && cellObj.order !== undefined) {
return cellObj
}
cellData = {
data: cellObj.data,
text: cellObj.text,
order: cellObj.order,
attributes: cellObj.attributes
}
} else {
inputData = cell
cellData = {
data: cell
}
}
// Only process if text/order are not already set
if (cellData.text === undefined || cellData.order === undefined) {
switch (columnSettings.type) {
case "string":
if (!(typeof inputData === "string")) {
cellData.text = cellData.text ?? String(cellData.data)
cellData.order = cellData.order ?? cellData.text
}
break
case "date":
if (columnSettings.format) {
cellData.order = cellData.order ?? parseDate(String(cellData.data), columnSettings.format)
}
break
case "number":
cellData.text = cellData.text ?? String(cellData.data as number)
cellData.data = parseFloat(cellData.data as string)
cellData.order = cellData.order ?? cellData.data
break
case "html": {
const node = Array.isArray(cellData.data) ?
{nodeName: "TD",
childNodes: (cellData.data as nodeType[])} : // If it is an array, we assume it is an array of nodeType
stringToObj(`
${String(cellData.data)} | `)
cellData.data = node.childNodes || []
const text = objToText(node)
cellData.text = cellData.text ?? text
cellData.order = cellData.order ?? text
break
}
case "boolean":
if (typeof cellData.data === "string") {
cellData.data = cellData.data.toLowerCase().trim()
}
cellData.data = !["false", false, null, undefined, 0].includes(cellData.data as (string | number | boolean | null | undefined))
cellData.order = cellData.order ?? (cellData.data ? 1 : 0)
cellData.text = cellData.text ?? String(cellData.data)
break
case "other":
cellData.text = cellData.text ?? ""
cellData.order = cellData.order ?? 0
break
default:
cellData.text = cellData.text ?? JSON.stringify(cellData.data)
break
}
}
// Preserve attributes if they were provided
if (attributes) {
cellData.attributes = attributes
}
return cellData
}
const readDOMDataCell = (cell: HTMLElement, columnSettings : columnSettingsType) : cellType => {
let cellData : cellType
switch (columnSettings.type) {
case "string":
cellData = {
data: cell.innerText
}
break
case "date": {
const data = cell.innerText
cellData = {
data,
order: parseDate(data, columnSettings.format)
}
break
}
case "number": {
const data = parseFloat(cell.innerText)
cellData = {
data,
order: data,
text: cell.innerText
}
break
}
case "boolean": {
const data = !["false", "0", "null", "undefined"].includes(cell.innerText.toLowerCase().trim())
cellData = {
data,
text: data ? "1" : "0",
order: data ? 1 : 0
}
break
}
default: { // "html", "other"
const node = nodeToObj(cell, {valueDiffing: false})
cellData = {
data: node.childNodes || [],
text: cell.innerText,
order: cell.innerText
}
break
}
}
// Save cell attributes to reference when rendering
cellData.attributes = namedNodeMapToObject(cell.attributes)
return cellData
}
export const readHeaderCell = (cell: inputHeaderCellType) : headerCellType => {
if (
cell instanceof Object &&
cell.constructor === Object &&
cell.hasOwnProperty("data")
) {
// If it's already a headerCellType object, ensure text and type are set if data is a string
const headerCell = cell as headerCellType
if (typeof headerCell.data === "string") {
if (!headerCell.text) {
headerCell.text = headerCell.data
}
if (!headerCell.type) {
headerCell.type = "string"
}
}
return headerCell
}
const cellData : headerCellType = {
data: cell
}
if (typeof cell === "string") {
if (cell.length) {
const node = stringToObj(`${cell} | `)
if (node.childNodes && (node.childNodes.length !== 1 || node.childNodes[0].nodeName !== "#text")) {
cellData.data = node.childNodes
cellData.type = "html"
const text = objToText(node)
cellData.text = text
}
}
} else if ([null, undefined].includes(cell)) {
cellData.text = ""
} else {
cellData.text = JSON.stringify(cell)
}
return cellData
}
export const readDOMHeaderCell = (cell: HTMLElement) : headerCellType => {
const node = nodeToObj(cell, {valueDiffing: false})
let cellData: headerCellType
if (node.childNodes && (node.childNodes.length !== 1 || node.childNodes[0].nodeName !== "#text")) {
cellData = {
data: node.childNodes,
type: "html",
text: objToText(node)
}
} else {
cellData = {
data: cell.innerText,
type: "string"
}
}
// Save header cell attributes to reference when rendering
cellData.attributes = node.attributes
return cellData
}
export const readTableData = (dataOption: DataOption, dom: (HTMLTableElement | undefined)=undefined, columnSettings, defaultType, defaultFormat) => {
const data = {
data: [] as dataRowType[],
headings: [] as headerCellType[]
}
if (dataOption.headings) {
// Process headings and handle colspan
const processedHeadings: headerCellType[] = []
dataOption.headings.forEach((heading: inputHeaderCellType) => {
const headerCell = readHeaderCell(heading)
const colspan = parseInt(headerCell.attributes?.colspan || "1", 10)
processedHeadings.push(headerCell)
// Add placeholder headings for colspan > 1
for (let i = 1; i < colspan; i++) {
processedHeadings.push({
data: "",
text: "",
attributes: {
"data-colspan-placeholder": "true"
}
})
}
})
data.headings = processedHeadings
} else if (dom?.tHead) {
// Collect all headings accounting for colspan
const headings: headerCellType[] = []
Array.from(dom.tHead.querySelectorAll("th")).forEach(th => {
const colspan = parseInt(th.getAttribute("colspan") || "1", 10)
// Add the actual heading with colspan data
const heading = readDOMHeaderCell(th)
headings.push(heading)
// Add placeholder headings for colspan > 1
for (let i = 1; i < colspan; i++) {
headings.push({
data: "",
text: "",
attributes: {
"data-colspan-placeholder": "true"
}
})
}
})
data.headings = headings
// Process column settings for all columns including colspan placeholders
let columnIndex = 0
Array.from(dom.tHead.querySelectorAll("th")).forEach(th => {
const colspan = parseInt(th.getAttribute("colspan") || "1", 10)
for (let i = 0; i < colspan; i++) {
if (!columnSettings[columnIndex]) {
columnSettings[columnIndex] = {
type: defaultType,
format: defaultFormat,
searchable: true,
sortable: true
}
}
const settings = columnSettings[columnIndex]
// Only apply settings from the actual th element to the first column of the colspan
if (i === 0) {
if (th.dataset.sortable?.trim().toLowerCase() === "false" || th.dataset.sort?.trim().toLowerCase() === "false") {
settings.sortable = false
}
if (th.dataset.searchable?.trim().toLowerCase() === "false") {
settings.searchable = false
}
if (th.dataset.hidden?.trim().toLowerCase() === "true" || th.getAttribute("hidden")?.trim().toLowerCase() === "true") {
settings.hidden = true
}
if (th.dataset.type && ["number", "string", "html", "date", "boolean", "other"].includes(th.dataset.type)) {
settings.type = th.dataset.type
if (settings.type === "date" && th.dataset.format) {
settings.format = th.dataset.format
}
}
}
columnIndex++
}
})
} else if (dataOption.data?.length) {
const firstRow = dataOption.data[0]
const firstRowCells = Array.isArray(firstRow) ? firstRow : firstRow.cells
data.headings = firstRowCells.map((_cell: inputCellType) => readHeaderCell(""))
} else if (dom?.tBodies.length) {
data.headings = Array.from(dom.tBodies[0].rows[0].cells).map((_cell: HTMLElement) => readHeaderCell(""))
}
for (let i=0; i heading.data ? String(heading.data) : heading.text)
// Track rowspan carryover: columnIndex -> {remainingRows, cellData}
const rowspanCarryover: Map = new Map()
data.data = dataOption.data.map((row: inputRowType | inputCellType[], _rowIndex: number) => {
let attributes: { [key: string]: string }
let cells: inputCellType[]
if (Array.isArray(row)) {
attributes = {}
cells = row
} else if (row.hasOwnProperty("cells") && Object.keys(row).every(key => ["cells", "attributes"].includes(key))) {
attributes = row.attributes
cells = row.cells
} else {
attributes = {}
cells = []
Object.entries(row).forEach(([heading, cell]) => {
const index = headings.indexOf(heading)
if (index > -1) {
cells[index] = cell
}
})
}
// Process cells and handle colspan and rowspan
const processedCells: cellType[] = []
let cellIndex = 0
let inputCellIndex = 0
while (cellIndex < data.headings.length) {
// Check if this column is occupied by a rowspan from a previous row
if (rowspanCarryover.has(cellIndex)) {
const carryover = rowspanCarryover.get(cellIndex)
// Add placeholder for rowspan
processedCells.push({
data: "",
text: "",
order: "",
attributes: {
"data-rowspan-placeholder": "true"
}
})
// Decrement remaining rows
carryover.remainingRows--
if (carryover.remainingRows <= 0) {
rowspanCarryover.delete(cellIndex)
}
cellIndex++
} else if (inputCellIndex < cells.length) {
// Process the next input cell
const cell = cells[inputCellIndex]
const cellData = readDataCell(cell, columnSettings[cellIndex])
const colspan = parseInt(cellData.attributes?.colspan || "1", 10)
const rowspan = parseInt(cellData.attributes?.rowspan || "1", 10)
processedCells.push(cellData)
// Track rowspan for future rows
if (rowspan > 1) {
rowspanCarryover.set(cellIndex, {
remainingRows: rowspan - 1,
cellData
})
}
cellIndex++
inputCellIndex++
// Add placeholder cells for colspan > 1
for (let i = 1; i < colspan; i++) {
processedCells.push({
data: "",
text: "",
order: "",
attributes: {
"data-colspan-placeholder": "true"
}
})
cellIndex++
}
} else {
// This shouldn't happen if data is well-formed, but handle it gracefully
break
}
}
return {
attributes,
cells: processedCells
} as dataRowType
})
} else if (dom?.tBodies?.length) {
// Track rowspan carryover: columnIndex -> {remainingRows, cellData}
const rowspanCarryover: Map = new Map()
data.data = Array.from(dom.tBodies[0].rows).map(
row => {
const cells: cellType[] = []
let cellIndex = 0
let domCellIndex = 0
const domCells = Array.from(row.cells)
while (cellIndex < data.headings.length) {
// Check if this column is occupied by a rowspan from a previous row
if (rowspanCarryover.has(cellIndex)) {
const carryover = rowspanCarryover.get(cellIndex)
// Add placeholder for rowspan
cells.push({
data: "",
text: "",
order: "",
attributes: {
"data-rowspan-placeholder": "true"
}
})
// Decrement remaining rows
carryover.remainingRows--
if (carryover.remainingRows <= 0) {
rowspanCarryover.delete(cellIndex)
}
cellIndex++
} else if (domCellIndex < domCells.length) {
// Process the next DOM cell
const cell = domCells[domCellIndex]
const colspan = parseInt(cell.getAttribute("colspan") || "1", 10)
const rowspan = parseInt(cell.getAttribute("rowspan") || "1", 10)
// Add the actual cell with colspan and rowspan data
const cellData = cell.dataset.content ?
readDataCell(cell.dataset.content, columnSettings[cellIndex]) :
readDOMDataCell(cell, columnSettings[cellIndex])
if (cell.dataset.order) {
cellData.order = isNaN(parseFloat(cell.dataset.order)) ? cell.dataset.order : parseFloat(cell.dataset.order)
}
cells.push(cellData)
// Track rowspan for future rows
if (rowspan > 1) {
rowspanCarryover.set(cellIndex, {
remainingRows: rowspan - 1,
cellData
})
}
cellIndex++
domCellIndex++
// Add placeholder cells for colspan > 1
for (let i = 1; i < colspan; i++) {
cells.push({
data: "",
text: "",
order: "",
attributes: {
"data-colspan-placeholder": "true"
}
})
cellIndex++
}
} else {
// This shouldn't happen if DOM is well-formed, but handle it gracefully
break
}
}
return {
attributes: namedNodeMapToObject(row.attributes),
cells
} as dataRowType
}
)
}
if (data.data.length && data.data[0].cells.length !== data.headings.length) {
throw new Error(
"Data heading length mismatch."
)
}
return data
}