diff --git a/src/js/modules/ResponsiveLayout/ResponsiveLayout.js b/src/js/modules/ResponsiveLayout/ResponsiveLayout.js index 6718dea0e..d1b0c0714 100644 --- a/src/js/modules/ResponsiveLayout/ResponsiveLayout.js +++ b/src/js/modules/ResponsiveLayout/ResponsiveLayout.js @@ -16,6 +16,7 @@ export default class ResponsiveLayout extends Module{ this.collapseFormatter = []; this.collapseStartOpen = true; this.collapseHandleColumn = false; + this.deferredRows = new Set(); this.registerTableOption("responsiveLayout", false); //responsive layout flags this.registerTableOption("responsiveLayoutCollapseStartOpen", true); //start showing collapsed data @@ -38,14 +39,28 @@ export default class ResponsiveLayout extends Module{ this.subscribe("table-redrawing", this.tableRedraw.bind(this)); - if(this.table.options.responsiveLayout === "collapse"){ + //this.mode is not set until initializeResponsivity + if(this.isCollapseMode(this.table.options.responsiveLayout)){ this.subscribe("row-data-changed", this.generateCollapsedRowContent.bind(this)); this.subscribe("row-init", this.initializeRow.bind(this)); this.subscribe("row-layout", this.layoutRow.bind(this)); + + if(this.isEditableMode(this.table.options.responsiveLayout)){ + this.subscribe("row-responsive-toggled", this.rowResponsiveToggled.bind(this)); + this.subscribe("edit-editor-clear", this.editorCleared.bind(this)); + } } } } + isCollapseMode(mode = this.mode){ + return mode === "collapse" || mode === "collapseEditable"; + } + + isEditableMode(mode = this.mode){ + return mode === "collapseEditable"; + } + tableRedraw(force){ if(["fitColumns", "fitDataStretch"].indexOf(this.layoutMode()) === -1){ if(!force){ @@ -73,7 +88,7 @@ export default class ResponsiveLayout extends Module{ column.modules.responsive.index = i; columns.push(column); - if(!column.visible && this.mode === "collapse"){ + if(!column.visible && this.isCollapseMode()){ this.hiddenColumns.push(column); } } @@ -89,7 +104,7 @@ export default class ResponsiveLayout extends Module{ this.columns = columns; - if(this.mode === "collapse"){ + if(this.isCollapseMode()){ this.generateCollapsedContent(); } @@ -157,7 +172,7 @@ export default class ResponsiveLayout extends Module{ column.hide(false, true); - if(this.mode === "collapse"){ + if(this.isCollapseMode()){ this.hiddenColumns.unshift(column); this.generateCollapsedContent(); @@ -170,11 +185,15 @@ export default class ResponsiveLayout extends Module{ showColumn(column){ var index; + if(this.isEditableMode()){ + this.restoreColumnCells(column); + } + column.show(false, true); //set column width to prevent calculation loops on uninitialized columns column.setWidth(column.getWidth()); - if(this.mode === "collapse"){ + if(this.isCollapseMode()){ index = this.hiddenColumns.indexOf(column); if(index > -1){ @@ -249,8 +268,21 @@ export default class ResponsiveLayout extends Module{ var el, contents; if(row.modules.responsiveLayout){ + //a rebuild would tear an open editor out of the DOM, so defer it + if(this.isEditableMode() && this.rowIsEditing(row)){ + this.deferredRows.add(row); + return; + } + + this.deferredRows.delete(row); + el = row.modules.responsiveLayout.element; + //restore first, so the teardown below moves cells rather than orphaning them + if(this.isEditableMode()){ + this.restoreCollapsedCells(row); + } + while(el.firstChild) el.removeChild(el.firstChild); contents = this.collapseFormatter(this.generateCollapsedRowData(row)); @@ -270,6 +302,20 @@ export default class ResponsiveLayout extends Module{ var value = column.getFieldValue(data); if(column.definition.title && column.field){ + if(this.isEditableMode()){ + let cell = row.getCell(column.field); + + if(cell){ + output.push({ + field: column.field, + title: column.definition.title, + value: this.collapseCell(cell) + }); + } + + return; + } + if(column.modules.format && this.table.options.responsiveLayoutCollapseUseFormatters){ mockCellComponent = { @@ -320,6 +366,90 @@ export default class ResponsiveLayout extends Module{ return output; } + editorCleared(){ + var rows = this.deferredRows; + + if(rows.size){ + this.deferredRows = new Set(); + rows.forEach((row) => { + this.generateCollapsedRowContent(row); + }); + } + } + + rowIsEditing(row){ + var currentCell = this.table.modExists("edit") ? this.table.modules.edit.currentCell : false; + + return !!currentCell && currentCell.row === row; + } + + //an editor left open in a closed container would hold the redraw block + rowResponsiveToggled(row, open){ + if(!open && this.rowIsEditing(row)){ + this.table.modules.edit.cancelEdit(); + } + } + + //only visibility is touched here; sizing is left to the stylesheet + collapseCell(cell){ + var element = cell.getElement(); + + cell.show(); + + return element; + } + + restoreColumnCells(column){ + column.cells.forEach((cell) => { + this.restoreCell(cell); + }); + } + + restoreCollapsedCells(row){ + row.cells.forEach((cell) => { + this.restoreCell(cell); + }); + } + + restoreCell(cell){ + var config = cell.row.modules.responsiveLayout, + cells = cell.row.cells, + anchor = null, + rowEl; + + //cell.element, not getElement(), which would force a lazy render + if(!config || !config.element || !cell.element || !config.element.contains(cell.element)){ + return; + } + + rowEl = cell.row.getElement(); + + for(let i = cells.indexOf(cell) + 1; i < cells.length; i++){ + if(cells[i].element && cells[i].element.parentNode === rowEl){ + anchor = cells[i].element; + break; + } + } + + //the collapse container is always the row's last child + if(!anchor && config.element.parentNode === rowEl){ + anchor = config.element; + } + + if(anchor){ + rowEl.insertBefore(cell.element, anchor); + }else{ + rowEl.appendChild(cell.element); + } + + //the column may have been shown while the cell was away + if(cell.column.visible){ + cell.show(); + }else{ + cell.hide(); + } + } + formatCollapsedData(data){ var list = document.createElement("table"); diff --git a/src/js/modules/ResponsiveLayout/extensions/formatters/responsiveCollapse.js b/src/js/modules/ResponsiveLayout/extensions/formatters/responsiveCollapse.js index 6e480a7c1..5d4d5482a 100644 --- a/src/js/modules/ResponsiveLayout/extensions/formatters/responsiveCollapse.js +++ b/src/js/modules/ResponsiveLayout/extensions/formatters/responsiveCollapse.js @@ -1,6 +1,7 @@ export default function(cell, formatterParams, onRendered){ var el = document.createElement("div"), - config = cell.getRow()._row.modules.responsiveLayout; + row = cell.getRow()._row, + config = row.modules.responsiveLayout; el.classList.add("tabulator-responsive-collapse-toggle"); @@ -35,6 +36,7 @@ export default function(cell, formatterParams, onRendered){ el.addEventListener("click", function(e){ e.stopImmediatePropagation(); toggleList(!config.open); + row.dispatch("row-responsive-toggled", row, config.open); cell.getTable().rowManager.adjustTableSize(); }); diff --git a/src/scss/tabulator.scss b/src/scss/tabulator.scss index fc3ffff20..ff99f6e2c 100644 --- a/src/scss/tabulator.scss +++ b/src/scss/tabulator.scss @@ -883,6 +883,19 @@ $rangeHeaderTextHighlightBackground: #000000 !default; //header text color when } } } + + //a cell relocated here by the collapseEditable mode + .tabulator-cell{ + //the column width and row height are re-applied inline on every pass + width:100% !important; + height:auto !important; + + border-right:none; + + white-space:normal; + overflow:visible; + text-overflow:clip; + } } //cell element diff --git a/test/e2e/responsive-collapse-editable.html b/test/e2e/responsive-collapse-editable.html new file mode 100644 index 000000000..1821f0ff2 --- /dev/null +++ b/test/e2e/responsive-collapse-editable.html @@ -0,0 +1,57 @@ + + + + + Tabulator Responsive Collapse Editable Test + + + + + +
+ + + + diff --git a/test/e2e/responsive-collapse-editable.spec.js b/test/e2e/responsive-collapse-editable.spec.js new file mode 100644 index 000000000..1a1dd4e62 --- /dev/null +++ b/test/e2e/responsive-collapse-editable.spec.js @@ -0,0 +1,234 @@ +// @ts-check +import { test, expect } from "@playwright/test"; +import { join } from "path"; + +test.describe("Editing collapsed responsive columns", () => { + test.beforeEach(async ({ page }) => { + const htmlPath = join(__dirname, "responsive-collapse-editable.html"); + + // wide enough for every column, so each test narrows to the width it wants + await page.setViewportSize({ width: 1100, height: 600 }); + await page.goto(`file://${htmlPath}`); + await page.waitForSelector(".tabulator-row"); + }); + + const row = (page, index) => page.locator(".tabulator-row").nth(index); + const cell = (page, index, field) => row(page, index).locator(`.tabulator-cell[tabulator-field="${field}"]`); + const collapse = (page, index) => row(page, index).locator(".tabulator-responsive-collapse"); + + // the label cell that formatCollapsedData puts beside the relocated cell + const label = (page, index, field) => collapse(page, index) + .locator("tr", { has: page.locator(`.tabulator-cell[tabulator-field="${field}"]`) }) + .locator("td") + .first(); + + // narrow the table until the responsive loop folds the low priority columns + const narrow = async (page) => { + await page.setViewportSize({ width: 420, height: 600 }); + await expect(collapse(page, 0).locator('.tabulator-cell[tabulator-field="city"]')).toBeVisible(); + }; + + test("folds low priority columns into the collapse container as the table narrows", async ({ page }) => { + // wide: every cell sits in the row itself + await expect(cell(page, 0, "city")).toBeVisible(); + await expect(collapse(page, 0).locator(".tabulator-cell")).toHaveCount(0); + + await narrow(page); + + // the relocated cells are the real ones, still carrying their field attribute + await expect(collapse(page, 0).locator(".tabulator-cell")).not.toHaveCount(0); + await expect(collapse(page, 0).locator('.tabulator-cell[tabulator-field="code"]')).toBeVisible(); + await expect(cell(page, 0, "name")).toBeVisible(); + }); + + test("gives the relocated cell the full width of its row in the block", async ({ page }) => { + await narrow(page); + + // the layout re-applies the column's inline width on every pass, so the + // stylesheet is what hands the cell the width of its container. + // measured against the wrapping div, not the td: the td carries 2px of UA + // padding, so its border box is not what a width:100% child resolves against + for(const field of ["age", "city", "code"]){ + const cell = collapse(page, 0).locator(`.tabulator-cell[tabulator-field="${field}"]`); + const cellBox = await cell.boundingBox(); + const hostBox = await cell.locator("xpath=..").boundingBox(); + + expect(cellBox).not.toBeNull(); + expect(hostBox).not.toBeNull(); + expect(Math.abs(cellBox.width - hostBox.width)).toBeLessThanOrEqual(1); + + // and the inline column width really is being overridden, not absent + expect(await cell.evaluate(el => el.style.width)).not.toBe(""); + } + }); + + test("edits a collapsed field and commits through cellEdited", async ({ page }) => { + await narrow(page); + + const city = collapse(page, 0).locator('.tabulator-cell[tabulator-field="city"]'); + + await city.click(); + await expect(city).toHaveClass(/tabulator-editing/); + + const input = city.locator("input"); + + await expect(input).toBeVisible(); + await input.fill("Boston"); + await input.press("Enter"); + + await expect(city).not.toHaveClass(/tabulator-editing/); + await expect(city).toHaveText("Boston"); + + expect(await page.evaluate(() => window.edits)).toEqual([{ field: "city", value: "Boston" }]); + expect(await page.evaluate(() => window.testTable.getData()[0].city)).toBe("Boston"); + }); + + test("keeps the field label beside the editor while it is open", async ({ page }) => { + await narrow(page); + + const city = collapse(page, 0).locator('.tabulator-cell[tabulator-field="city"]'); + const cityLabel = label(page, 0, "city"); + + await expect(cityLabel).toHaveText("City"); + + await city.click(); + await expect(city.locator("input")).toBeVisible(); + + // the label has to stay put, and stay to the left of the editor + await expect(cityLabel).toBeVisible(); + await expect(cityLabel).toHaveText("City"); + + const labelBox = await cityLabel.boundingBox(); + const inputBox = await city.locator("input").boundingBox(); + + expect(labelBox).not.toBeNull(); + expect(inputBox).not.toBeNull(); + expect(labelBox.x + labelBox.width).toBeLessThanOrEqual(inputBox.x + 1); + }); + + test("holds the editor open when the validator fails", async ({ page }) => { + await narrow(page); + + const code = collapse(page, 0).locator('.tabulator-cell[tabulator-field="code"]'); + + await code.click(); + + const input = code.locator("input"); + + await input.fill(""); + await input.press("Enter"); + + await expect(code).toHaveClass(/tabulator-editing/); + await expect(code).toHaveClass(/tabulator-validation-fail/); + expect(await page.evaluate(() => window.edits)).toEqual([]); + expect(await page.evaluate(() => window.testTable.getData()[0].code)).toBe("AA"); + }); + + test("cancels a collapsed edit on Escape", async ({ page }) => { + await narrow(page); + + const city = collapse(page, 0).locator('.tabulator-cell[tabulator-field="city"]'); + + await city.click(); + await city.locator("input").fill("Nowhere"); + await city.locator("input").press("Escape"); + + await expect(city).not.toHaveClass(/tabulator-editing/); + await expect(city).toHaveText("New York"); + expect(await page.evaluate(() => window.cancels)).toContain("city"); + expect(await page.evaluate(() => window.edits)).toEqual([]); + }); + + test("closes the collapsed block, and the editor with it", async ({ page }) => { + await narrow(page); + + const city = collapse(page, 0).locator('.tabulator-cell[tabulator-field="city"]'); + + await city.click(); + await expect(city).toHaveClass(/tabulator-editing/); + + await row(page, 0).locator(".tabulator-responsive-collapse-toggle").click(); + + await expect(collapse(page, 0)).toBeHidden(); + await expect(city).not.toHaveClass(/tabulator-editing/); + expect(await page.evaluate(() => window.cancels)).toContain("city"); + }); + + test("reopens the collapsed block and edits again", async ({ page }) => { + await narrow(page); + + const toggle = row(page, 0).locator(".tabulator-responsive-collapse-toggle"); + + await toggle.click(); + await expect(collapse(page, 0)).toBeHidden(); + + await toggle.click(); + await expect(collapse(page, 0)).toBeVisible(); + + const city = collapse(page, 0).locator('.tabulator-cell[tabulator-field="city"]'); + + await city.click(); + await city.locator("input").fill("Denver"); + await city.locator("input").press("Enter"); + + await expect(city).toHaveText("Denver"); + expect(await page.evaluate(() => window.edits)).toEqual([{ field: "city", value: "Denver" }]); + }); + + test("returns the cell to its column when the table widens again", async ({ page }) => { + await narrow(page); + + // edit while collapsed, then widen: the value has to survive the move back + const collapsedCity = collapse(page, 0).locator('.tabulator-cell[tabulator-field="city"]'); + + await collapsedCity.click(); + await collapsedCity.locator("input").fill("Austin"); + await collapsedCity.locator("input").press("Enter"); + + await page.setViewportSize({ width: 1100, height: 600 }); + + await expect(collapse(page, 0).locator(".tabulator-cell")).toHaveCount(0); + await expect(cell(page, 0, "city")).toBeVisible(); + await expect(cell(page, 0, "city")).toHaveText("Austin"); + + // and it is editable in its column, as any other cell + await cell(page, 0, "city").click(); + await expect(cell(page, 0, "city")).toHaveClass(/tabulator-editing/); + }); + + test("puts the cells back in column order after a fold and unfold", async ({ page }) => { + await narrow(page); + await page.setViewportSize({ width: 1100, height: 600 }); + + await expect(collapse(page, 0).locator(".tabulator-cell")).toHaveCount(0); + + const fields = await row(page, 0).locator(".tabulator-cell").evaluateAll( + els => els.map(el => el.getAttribute("tabulator-field")) + ); + + expect(fields).toEqual([null, "id", "name", "age", "city", "code"]); + + // the collapse container stays the row's last child + const lastIsCollapse = await row(page, 0).evaluate( + el => el.lastElementChild.classList.contains("tabulator-responsive-collapse") + ); + + expect(lastIsCollapse).toBe(true); + }); + + test("edits collapsed fields on more than one row", async ({ page }) => { + await narrow(page); + + for(const [index, value] of [[0, "Boston"], [1, "Dallas"]]){ + const city = collapse(page, Number(index)).locator('.tabulator-cell[tabulator-field="city"]'); + + await city.click(); + await city.locator("input").fill(String(value)); + await city.locator("input").press("Enter"); + + await expect(city).toHaveText(String(value)); + } + + expect(await page.evaluate(() => window.testTable.getData().map(r => r.city))).toEqual(["Boston", "Dallas"]); + }); +}); diff --git a/test/unit/modules/ResponsiveLayoutCollapseEditable.spec.js b/test/unit/modules/ResponsiveLayoutCollapseEditable.spec.js new file mode 100644 index 000000000..ba5ff1fc5 --- /dev/null +++ b/test/unit/modules/ResponsiveLayoutCollapseEditable.spec.js @@ -0,0 +1,636 @@ +import TabulatorFull from '../../../src/js/core/TabulatorFull.js'; + +// jsdom reports every element as 0x0, so Helpers.elVisible() is false for +// everything, and Edit.findNextEditableCell() never finds a cell to move into. +// Give elements a size for the duration of this file. +const sizedElement = {configurable: true, get(){ return 20; }}; +let originalWidth, originalHeight; + +// jsdom also reports clientWidth 0, so ResponsiveLayout.update() sees a table +// with no room at all and folds every eligible column during the build. Give it +// room instead, so each test folds exactly what it means to fold. +const roomyElement = {configurable: true, get(){ return 1000; }}; +const crampedElement = {configurable: true, get(){ return 0; }}; +let originalClientWidth; + +// The built in editors commit or cancel on blur, which settles the edit before a +// test can look at the open editor. Leave teardown to the table. +function passiveEditor(cell, onRendered, success){ + const input = document.createElement("input"); + + input.value = cell.getValue(); + + onRendered(() => input.focus()); + input.addEventListener("change", () => success(input.value)); + + return input; +} + +const buildTable = async (options) => { + document.body.innerHTML = '
'; + + const table = new TabulatorFull("#test-table", options); + + await new Promise(resolve => table.on("tableBuilt", resolve)); + + return table; +}; + +const buildCollapseTable = (options = {}, editor = passiveEditor) => buildTable({ + data: [ + { id: 1, name: "John", age: 25, city: "New York" }, + { id: 2, name: "Jane", age: 30, city: "Boston" }, + ], + columns: [ + { title: "", formatter: "responsiveCollapse", width: 30, headerSort: false, responsive: 0 }, + { title: "ID", field: "id", responsive: 0 }, + { title: "Name", field: "name", editor: editor, responsive: 0 }, + { title: "Age", field: "age", editor: editor, responsive: 2 }, + { title: "City", field: "city", editor: editor, responsive: 3 }, + ], + responsiveLayout: "collapseEditable", + ...options, +}); + +// the row's collapse container, reached the way the toggle formatter reaches it +const collapseEl = (row) => row._row.modules.responsiveLayout.element; + +// fold a column away exactly as the resize loop would; jsdom computes no layout, +// so ResponsiveLayout.update() can never decide to do it on its own +const fold = (table, field) => table.module("responsiveLayout") + .hideColumn(table.columnManager.findColumn(field)); + +const unfold = (table, field) => table.module("responsiveLayout") + .showColumn(table.columnManager.findColumn(field)); + +// the tabulator-field of every cell element still sitting directly in the row, +// in DOM order +const rowFieldOrder = (row) => Array.from(row.getElement().children) + .filter(el => el.classList.contains("tabulator-cell")) + .map(el => el.getAttribute("tabulator-field")); + +const isEditing = (cell) => cell.getElement().classList.contains("tabulator-editing"); + +const editingField = (table) => { + const current = table.module("edit").currentCell; + + return current ? current.column.field : false; +}; + +const settle = () => new Promise(resolve => setTimeout(resolve, 20)); + +describe("ResponsiveLayout - collapseEditable", () => { + let table; + + beforeAll(() => { + originalWidth = Object.getOwnPropertyDescriptor(window.HTMLElement.prototype, "offsetWidth"); + originalHeight = Object.getOwnPropertyDescriptor(window.HTMLElement.prototype, "offsetHeight"); + + Object.defineProperty(window.HTMLElement.prototype, "offsetWidth", sizedElement); + Object.defineProperty(window.HTMLElement.prototype, "offsetHeight", sizedElement); + + // clientWidth lives on Element.prototype in jsdom, not HTMLElement.prototype + originalClientWidth = Object.getOwnPropertyDescriptor(window.Element.prototype, "clientWidth"); + + Object.defineProperty(window.Element.prototype, "clientWidth", roomyElement); + }); + + afterAll(() => { + if(originalWidth){ + Object.defineProperty(window.HTMLElement.prototype, "offsetWidth", originalWidth); + } + + if(originalHeight){ + Object.defineProperty(window.HTMLElement.prototype, "offsetHeight", originalHeight); + } + + if(originalClientWidth){ + Object.defineProperty(window.Element.prototype, "clientWidth", originalClientWidth); + } + }); + + afterEach(() => { + if(table){ + table.destroy(); + table = null; + } + }); + + describe("mode recognition", () => { + it("treats collapseEditable as a collapse mode", async () => { + table = await buildCollapseTable(); + + const mod = table.module("responsiveLayout"); + + expect(mod.mode).toBe("collapseEditable"); + expect(mod.isCollapseMode()).toBe(true); + expect(mod.isEditableMode()).toBe(true); + expect(mod.isCollapseMode("collapse")).toBe(true); + expect(mod.isEditableMode("collapse")).toBe(false); + expect(mod.isCollapseMode("hide")).toBe(false); + }); + + it("builds the row collapse container and tracks the folded column", async () => { + table = await buildCollapseTable(); + + fold(table, "city"); + + const mod = table.module("responsiveLayout"); + const column = table.columnManager.findColumn("city"); + + expect(column.visible).toBe(false); + expect(mod.hiddenColumns).toContain(column); + expect(collapseEl(table.getRows()[0])).not.toBeUndefined(); + }); + }); + + describe("cell relocation", () => { + it("moves the real cell element into the collapse container", async () => { + table = await buildCollapseTable(); + + const row = table.getRows()[0]; + const cell = row.getCell("city"); + + expect(cell.getElement().parentNode).toBe(row.getElement()); + + fold(table, "city"); + + expect(collapseEl(row).contains(cell.getElement())).toBe(true); + expect(cell.getElement().parentNode).not.toBe(row.getElement()); + }); + + it("relocates cells when the responsive loop folds a column on its own", async () => { + table = await buildCollapseTable(); + + const row = table.getRows()[0]; + + // take the room away and let update() choose what to fold + Object.defineProperty(window.Element.prototype, "clientWidth", crampedElement); + + try{ + table.module("responsiveLayout").update(); + }finally{ + Object.defineProperty(window.Element.prototype, "clientWidth", roomyElement); + } + + // highest responsive value folds first + expect(table.module("responsiveLayout").hiddenColumns.map(c => c.field)).toEqual(["age", "city"]); + expect(collapseEl(row).contains(row.getCell("city").getElement())).toBe(true); + expect(collapseEl(row).contains(row.getCell("age").getElement())).toBe(true); + expect(rowFieldOrder(row)).toEqual([null, "id", "name"]); + }); + + it("relocates the cell of every row, not just the first", async () => { + table = await buildCollapseTable(); + + fold(table, "city"); + + table.getRows().forEach((row) => { + expect(collapseEl(row).contains(row.getCell("city").getElement())).toBe(true); + }); + }); + + it("shows the relocated cell, which Cell.hide() had left display:none", async () => { + table = await buildCollapseTable(); + + const cell = table.getRows()[0].getCell("city"); + + fold(table, "city"); + + // Cell.show() clears the inline display, letting the stylesheet decide + expect(cell.getElement().style.display).toBe(""); + + // width is pinned by the playwright spec; jsdom computes no layout + }); + + it("keeps the value rendered by the column's own formatter", async () => { + table = await buildCollapseTable({ + columns: [ + { title: "ID", field: "id", responsive: 0 }, + { title: "City", field: "city", responsive: 3, + formatter: (cell) => "" + cell.getValue() + "" }, + ], + }); + + fold(table, "city"); + + const cell = table.getRows()[0].getCell("city"); + + expect(cell.getElement().querySelector("b")).not.toBeNull(); + expect(cell.getElement().textContent).toBe("New York"); + }); + + it("puts the cell element back in the row, in column order, when unfolded", async () => { + table = await buildCollapseTable(); + + const row = table.getRows()[0]; + const cell = row.getCell("city"); + + fold(table, "city"); + expect(rowFieldOrder(row)).toEqual([null, "id", "name", "age"]); + + unfold(table, "city"); + + expect(cell.getElement().parentNode).toBe(row.getElement()); + expect(collapseEl(row).contains(cell.getElement())).toBe(false); + expect(rowFieldOrder(row)).toEqual([null, "id", "name", "age", "city"]); + }); + + it("keeps the collapse container as the row's last child after a round trip", async () => { + table = await buildCollapseTable(); + + const row = table.getRows()[0]; + + fold(table, "city"); + fold(table, "age"); + unfold(table, "age"); + unfold(table, "city"); + + expect(row.getElement().lastChild).toBe(collapseEl(row)); + expect(rowFieldOrder(row)).toEqual([null, "id", "name", "age", "city"]); + }); + + it("restores a cell whose column was shown outside the responsive loop", async () => { + table = await buildCollapseTable(); + + const row = table.getRows()[0]; + const cell = row.getCell("city"); + + fold(table, "city"); + expect(collapseEl(row).contains(cell.getElement())).toBe(true); + + // a user calling showColumn() directly bypasses ResponsiveLayout.showColumn; + // the rebuild that column-show triggers has to converge anyway + table.showColumn("city"); + + expect(cell.getElement().parentNode).toBe(row.getElement()); + expect(cell.getElement().style.display).not.toBe("none"); + }); + }); + + describe("editing a folded field", () => { + it("opens the column's editor inside the collapse container", async () => { + table = await buildCollapseTable(); + + const row = table.getRows()[0]; + const cell = row.getCell("city"); + + fold(table, "city"); + cell.edit(); + + expect(collapseEl(row).querySelector("input")).not.toBeNull(); + expect(isEditing(cell)).toBe(true); + expect(editingField(table)).toBe("city"); + }); + + it("keeps the field label beside the editor while it is open", async () => { + table = await buildCollapseTable(); + + const row = table.getRows()[0]; + const cell = row.getCell("city"); + + fold(table, "city"); + + const label = cell.getElement().closest("tr").querySelector("td"); + + expect(label.textContent).toBe("City"); + + cell.edit(); + + // the label is a sibling cell of the editor, so it cannot move + expect(label.textContent).toBe("City"); + expect(label.isConnected).toBe(true); + expect(collapseEl(row).contains(label)).toBe(true); + }); + + it("commits through cell.setValue, so row data and cellEdited follow", async () => { + table = await buildCollapseTable(); + + const edited = jest.fn(); + const row = table.getRows()[0]; + const cell = row.getCell("city"); + + table.on("cellEdited", c => edited(c.getField(), c.getValue())); + + fold(table, "city"); + cell.edit(); + + const input = collapseEl(row).querySelector("input"); + + input.value = "Chicago"; + input.dispatchEvent(new window.Event("change", {bubbles: true})); + + expect(cell.getValue()).toBe("Chicago"); + expect(row.getData().city).toBe("Chicago"); + expect(table.getData()[0].city).toBe("Chicago"); + expect(edited).toHaveBeenCalledTimes(1); + expect(edited).toHaveBeenCalledWith("city", "Chicago"); + }); + + it("fires cellEditing and marks the cell edited", async () => { + table = await buildCollapseTable(); + + const editing = jest.fn(); + const row = table.getRows()[0]; + const cell = row.getCell("city"); + + table.on("cellEditing", c => editing(c.getField())); + + fold(table, "city"); + cell.edit(); + + const input = collapseEl(row).querySelector("input"); + + input.value = "Chicago"; + input.dispatchEvent(new window.Event("change", {bubbles: true})); + + expect(editing).toHaveBeenCalledWith("city"); + expect(cell.isEdited()).toBe(true); + expect(table.getEditedCells().map(c => c.getField())).toEqual(["city"]); + }); + + it("runs the column validator and holds the editor open on failure", async () => { + table = await buildCollapseTable({ + columns: [ + { title: "", formatter: "responsiveCollapse", width: 30, headerSort: false, responsive: 0 }, + { title: "ID", field: "id", responsive: 0 }, + { title: "City", field: "city", editor: passiveEditor, validator: "required", responsive: 3 }, + ], + }); + + const row = table.getRows()[0]; + const cell = row.getCell("city"); + + fold(table, "city"); + cell.edit(); + + const input = collapseEl(row).querySelector("input"); + + input.value = ""; + input.dispatchEvent(new window.Event("change", {bubbles: true})); + + await settle(); + + expect(table.module("edit").invalidEdit).toBe(true); + expect(isEditing(cell)).toBe(true); + expect(row.getData().city).toBe("New York"); + expect(cell.getElement().classList.contains("tabulator-validation-fail")).toBe(true); + }); + + it("commits on Enter and cancels on Escape with the built in editor", async () => { + table = await buildCollapseTable({}, "input"); + + const cancelled = jest.fn(); + const row = table.getRows()[0]; + const cell = row.getCell("city"); + + table.on("cellEditCancelled", c => cancelled(c.getField())); + + fold(table, "city"); + + const press = (key) => collapseEl(row).querySelector("input") + .dispatchEvent(new window.KeyboardEvent("keydown", {key: key, bubbles: true})); + + cell.edit(); + collapseEl(row).querySelector("input").value = "Chicago"; + press("Enter"); + + expect(cell.getValue()).toBe("Chicago"); + expect(editingField(table)).toBe(false); + + cell.edit(); + collapseEl(row).querySelector("input").value = "Nowhere"; + press("Escape"); + + expect(cell.getValue()).toBe("Chicago"); + expect(cancelled).toHaveBeenCalledWith("city"); + expect(editingField(table)).toBe(false); + }); + + it("honours editable:false on a folded column", async () => { + table = await buildCollapseTable({ + columns: [ + { title: "ID", field: "id", responsive: 0 }, + { title: "City", field: "city", editor: passiveEditor, editable: false, responsive: 3 }, + ], + }); + + const row = table.getRows()[0]; + const cell = row.getCell("city"); + + fold(table, "city"); + cell.edit(); + + expect(isEditing(cell)).toBe(false); + expect(editingField(table)).toBe(false); + expect(collapseEl(row).querySelector("input")).toBeNull(); + }); + + it("tabs from a visible cell into a folded one", async () => { + table = await buildCollapseTable(); + + const row = table.getRows()[0]; + + fold(table, "city"); + + row.getCell("name").edit(); + expect(editingField(table)).toBe("name"); + + // age is still visible, city is folded but shown inside the container, so + // both pass Helpers.elVisible() and both are reachable + expect(table.navigateNext()).toBe(true); + expect(editingField(table)).toBe("age"); + + expect(table.navigateNext()).toBe(true); + expect(editingField(table)).toBe("city"); + expect(collapseEl(row).querySelector("input")).not.toBeNull(); + }); + }); + + describe("editor survival", () => { + it("leaves an open editor alone when the collapsed content regenerates", async () => { + table = await buildCollapseTable(); + + const row = table.getRows()[0]; + const cell = row.getCell("city"); + + fold(table, "city"); + cell.edit(); + + const input = collapseEl(row).querySelector("input"); + + input.value = "half typed"; + + // a resize driven hide/show, or a row update, lands here mid edit + table.module("responsiveLayout").generateCollapsedRowContent(row._row); + + expect(collapseEl(row).querySelector("input")).toBe(input); + expect(input.value).toBe("half typed"); + expect(isEditing(cell)).toBe(true); + expect(editingField(table)).toBe("city"); + }); + + it("survives another column being folded in beside it", async () => { + table = await buildCollapseTable(); + + const row = table.getRows()[0]; + const cell = row.getCell("city"); + + fold(table, "city"); + cell.edit(); + + const input = collapseEl(row).querySelector("input"); + + fold(table, "age"); + + expect(input.isConnected).toBe(true); + expect(editingField(table)).toBe("city"); + }); + + // a rebuild skipped to protect an open editor has to be replayed, or the + // row keeps collapsed content that no longer matches hiddenColumns + it("replays a rebuild that was deferred by an open editor", async () => { + table = await buildCollapseTable(); + + const row = table.getRows()[0]; + const cell = row.getCell("city"); + + fold(table, "city"); + cell.edit(); + + // folding a second column cannot rebuild this row yet + fold(table, "age"); + expect(collapseEl(row).textContent).not.toContain("Age"); + expect(table.module("responsiveLayout").deferredRows.has(row._row)).toBe(true); + + // closing the editor has to bring the row back in line + table.module("edit").cancelEdit(); + + expect(collapseEl(row).textContent).toContain("Age"); + expect(collapseEl(row).contains(row.getCell("age").getElement())).toBe(true); + expect(table.module("responsiveLayout").deferredRows.size).toBe(0); + }); + + it("replays when the editor was open on a visible cell of the row", async () => { + table = await buildCollapseTable(); + + const row = table.getRows()[0]; + + // the guard keys on the row, not on the edited cell being folded, so an + // edit anywhere in the row defers its rebuild + row.getCell("name").edit(); + fold(table, "city"); + + expect(collapseEl(row).textContent).not.toContain("City"); + + table.module("edit").cancelEdit(); + + expect(collapseEl(row).textContent).toContain("City"); + expect(collapseEl(row).contains(row.getCell("city").getElement())).toBe(true); + }); + + it("replays after a commit, with the committed value in place", async () => { + table = await buildCollapseTable(); + + const row = table.getRows()[0]; + const cell = row.getCell("city"); + + fold(table, "city"); + cell.edit(); + + const input = collapseEl(row).querySelector("input"); + + fold(table, "age"); + + input.value = "Chicago"; + input.dispatchEvent(new window.Event("change", {bubbles: true})); + + expect(cell.getValue()).toBe("Chicago"); + expect(collapseEl(row).textContent).toContain("Age"); + expect(collapseEl(row).textContent).toContain("Chicago"); + }); + + it("closes the editor when the collapsed block is collapsed", async () => { + table = await buildCollapseTable(); + + const cancelled = jest.fn(); + const row = table.getRows()[0]; + const cell = row.getCell("city"); + + table.on("cellEditCancelled", c => cancelled(c.getField())); + + fold(table, "city"); + cell.edit(); + expect(editingField(table)).toBe("city"); + + const toggle = row.getElement().querySelector(".tabulator-responsive-collapse-toggle"); + + toggle.dispatchEvent(new window.MouseEvent("click", {bubbles: true, cancelable: true})); + + expect(row._row.modules.responsiveLayout.open).toBe(false); + expect(editingField(table)).toBe(false); + expect(cancelled).toHaveBeenCalledWith("city"); + }); + + it("leaves the editor open when the block is merely opened", async () => { + table = await buildCollapseTable({responsiveLayoutCollapseStartOpen: false}); + + const row = table.getRows()[0]; + + fold(table, "city"); + + const toggle = row.getElement().querySelector(".tabulator-responsive-collapse-toggle"); + + toggle.dispatchEvent(new window.MouseEvent("click", {bubbles: true, cancelable: true})); + expect(row._row.modules.responsiveLayout.open).toBe(true); + + row.getCell("city").edit(); + + expect(editingField(table)).toBe("city"); + }); + }); + + describe("row updates", () => { + it("re-renders the relocated cell when the row data changes", async () => { + table = await buildCollapseTable(); + + const row = table.getRows()[0]; + const cell = row.getCell("city"); + + fold(table, "city"); + + await row.update({city: "Denver"}); + + expect(cell.getValue()).toBe("Denver"); + expect(collapseEl(row).textContent).toContain("Denver"); + expect(collapseEl(row).contains(row.getCell("city").getElement())).toBe(true); + }); + }); + + describe('responsiveLayout: "collapse" is unchanged', () => { + it("leaves the real cell in the row and formats a copy of the value", async () => { + table = await buildCollapseTable({responsiveLayout: "collapse"}); + + const row = table.getRows()[0]; + const cell = row.getCell("city"); + + fold(table, "city"); + + expect(cell.getElement().parentNode).toBe(row.getElement()); + expect(collapseEl(row).contains(cell.getElement())).toBe(false); + expect(collapseEl(row).textContent).toContain("New York"); + expect(collapseEl(row).querySelector(".tabulator-cell")).toBeNull(); + }); + + it("still cannot edit a folded field", async () => { + table = await buildCollapseTable({responsiveLayout: "collapse"}); + + const row = table.getRows()[0]; + + fold(table, "city"); + + expect(collapseEl(row).querySelector("input")).toBeNull(); + expect(table.module("responsiveLayout").isEditableMode()).toBe(false); + }); + }); +});