From b22ad3a769f741a746630b9a53d6253f5bf0efcd Mon Sep 17 00:00:00 2001 From: Keyur Paralkar Date: Wed, 15 Feb 2023 17:12:46 +0530 Subject: [PATCH] feat: added column freeze and unfreeze functionality to table widget (#18757) **PRD**: https://www.notion.so/appsmith/Ability-to-freeze-columns-dd118f7ed2e14e008ee305056b79874a?d=300f4968889244da9f737e1bfd8c06dc#2ddaf28e10a0475cb69f1af77b938d0b This PR adds the following features to the table widget: - Freeze the columns to the left or right of the table.(Both canvas and page view mode). - Unfreeze the frozen columns. (Both canvas and page view mode). - Columns that are left frozen, will get unfrozen at a position after the last left frozen column. (Both canvas and page view mode). - Columns that are right frozen, will get unfrozen at a position before the first right frozen column. (Both canvas and page view mode). - Column order can be persisted in the Page view mode. - Users can also unfreeze the columns that are frozen by the developers. - Columns that are frozen cannot be reordered(Both canvas and page view mode) - **Property pane changes (Columns property)**: - If the column is frozen to the left then that column should appear at top of the list. - If the column is frozen to the right then that column should appear at the bottom of the list. - The columns that are frozen cannot be moved or re-ordered in the list. They remain fixed in their position. - In-Page mode, If there is a change in frozen or unfrozen columns in multiple tables then the order of columns and frozen and unfrozen columns should get persisted on refresh i.e. changes should get persisted across refreshes. --- .../TableV2/TableV2_Column_Order_spec.js | 2 +- .../TableV2/TableV2_Widget_Add_button_spec.js | 5 + .../Widgets/TableV2/TableV2_spec.js | 2 +- .../Widgets/TableV2/freeze_column_spec.js | 397 +++++++++++++++++ .../Widgets/TableV2/virtual_row_spec.js | 8 +- app/client/cypress/support/Constants.js | 1 + app/client/cypress/support/widgetCommands.js | 61 +++ app/client/package.json | 2 + .../propertyControls/DraggableListCard.tsx | 12 +- .../DraggableListComponent.tsx | 3 + .../PrimaryColumnsControlV2.tsx | 26 +- .../propertyControls/StyledControls.tsx | 18 + app/client/src/constants/DefaultTheme.tsx | 28 ++ app/client/src/constants/WidgetConstants.tsx | 2 +- app/client/src/icons/ControlIcons.tsx | 6 + .../PropertyPane/DraggableListControl.tsx | 1 + app/client/src/utils/DSLMigration.test.ts | 9 + app/client/src/utils/DSLMigrations.ts | 6 + .../src/utils/migrations/TableWidget.ts | 21 + .../TableWidgetV2/component/Constants.ts | 25 +- .../widgets/TableWidgetV2/component/Table.tsx | 398 ++++++++++++------ .../TableWidgetV2/component/TableBody/Row.tsx | 24 +- .../component/TableBody/index.tsx | 34 +- .../component/TableStyledWrappers.tsx | 169 ++++++-- .../component/cellComponents/EmptyCell.tsx | 122 +++++- .../component/cellComponents/HeaderCell.tsx | 111 ++++- .../cellComponents/SelectionCheckboxCell.tsx | 2 + .../component/header/actions/ActionItem.tsx | 2 +- .../widgets/TableWidgetV2/component/index.tsx | 54 ++- .../src/widgets/TableWidgetV2/constants.ts | 4 + app/client/src/widgets/TableWidgetV2/index.ts | 11 +- .../widgets/TableWidgetV2/widget/derived.js | 1 - .../widgets/TableWidgetV2/widget/index.tsx | 232 +++++++++- .../propertyConfig/PanelConfig/General.ts | 29 ++ .../widget/propertyConfig/contentConfig.ts | 12 + .../widget/propertyUtils.test.ts | 21 +- .../TableWidgetV2/widget/propertyUtils.ts | 47 ++- .../TableWidgetV2/widget/utilities.test.ts | 207 ++++++++- .../widgets/TableWidgetV2/widget/utilities.ts | 209 ++++++++- app/client/yarn.lock | 45 +- 40 files changed, 2124 insertions(+), 245 deletions(-) create mode 100644 app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Widgets/TableV2/freeze_column_spec.js diff --git a/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Widgets/TableV2/TableV2_Column_Order_spec.js b/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Widgets/TableV2/TableV2_Column_Order_spec.js index 8db569ca954..4e436058315 100644 --- a/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Widgets/TableV2/TableV2_Column_Order_spec.js +++ b/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Widgets/TableV2/TableV2_Column_Order_spec.js @@ -7,7 +7,7 @@ describe("Table Widget V2 column order maintained on column change validation", }); it("Table widget V2 column order should be maintained after reorder and new column should be at the end", function() { - const thirdColumnSelector = `${commonlocators.TableV2Head} .tr div:nth-child(3)`; + const thirdColumnSelector = `${commonlocators.TableV2Head} .tr div:nth-child(3) .draggable-header`; const secondColumnSelector = `${commonlocators.TableV2Head} .tr div:nth-child(2) .draggable-header`; cy.get(thirdColumnSelector).trigger("dragstart"); diff --git a/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Widgets/TableV2/TableV2_Widget_Add_button_spec.js b/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Widgets/TableV2/TableV2_Widget_Add_button_spec.js index edcd315045e..c64e987ce1f 100644 --- a/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Widgets/TableV2/TableV2_Widget_Add_button_spec.js +++ b/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Widgets/TableV2/TableV2_Widget_Add_button_spec.js @@ -296,11 +296,16 @@ describe("Table Widget V2 property pane feature validation", function() { //cy.closePropertyPane(); // Click on the Menu Button + cy.get(".t--widget-tablewidgetv2 .bp3-button") + .first() + .scrollIntoView() + .should("be.visible"); cy.get(".t--widget-tablewidgetv2 .bp3-button") .first() .click({ force: true, }); + cy.wait(2000); // check Menu Item 3 is disable cy.get(".bp3-menu-item") .eq(2) diff --git a/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Widgets/TableV2/TableV2_spec.js b/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Widgets/TableV2/TableV2_spec.js index 11cddb29fa4..29326adb6a3 100644 --- a/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Widgets/TableV2/TableV2_spec.js +++ b/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Widgets/TableV2/TableV2_spec.js @@ -68,7 +68,7 @@ describe("Table Widget V2 Functionality", function() { expect(tabValue).to.be.equal("Michael Lawson"); }); // Sort Username Column - cy.contains('[role="columnheader"]', "userName") + cy.contains('[role="columnheader"] .draggable-header', "userName") .first() .click({ force: true, diff --git a/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Widgets/TableV2/freeze_column_spec.js b/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Widgets/TableV2/freeze_column_spec.js new file mode 100644 index 00000000000..e270d9446ce --- /dev/null +++ b/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Widgets/TableV2/freeze_column_spec.js @@ -0,0 +1,397 @@ +import { + getWidgetSelector, + PROPERTY_SELECTOR, + WIDGET, +} from "../../../../../locators/WidgetLocators"; +import { ObjectsRegistry } from "../../../../../support/Objects/Registry"; + +const widgetsPage = require("../../../../../locators/Widgets.json"); +const commonlocators = require("../../../../../locators/commonlocators.json"); +const agHelper = ObjectsRegistry.AggregateHelper; + +describe("1. Check column freeze and unfreeze mechanism in canavs mode", () => { + before(() => { + cy.dragAndDropToCanvas(WIDGET.TABLE, { x: 200, y: 200 }); + cy.dragAndDropToCanvas(WIDGET.TEXT, { x: 200, y: 600 }); + cy.openPropertyPane(WIDGET.TEXT); + cy.updateCodeInput( + PROPERTY_SELECTOR.text, + `{{JSON.stringify({ + step: Table1.primaryColumns.step.sticky, + status: Table1.primaryColumns.status.sticky, + task: Table1.primaryColumns.task.sticky, + action: Table1.primaryColumns.action.sticky, + }, null ,2)}}`, + ); + }); + after(() => { + cy.wait(1000); + cy.get(widgetsPage.tableWidgetV2).then(($elem) => { + if ($elem) { + cy.openPropertyPane(WIDGET.TABLE); + cy.deleteWidget(widgetsPage.tableWidgetV2); + } + }); + }); + describe("1.1 Column freeze and unfreeze testing via propertypane", () => { + it("1.1.1 Freeze column to left", () => { + cy.openPropertyPane(WIDGET.TABLE); + cy.openFieldConfiguration("step"); + cy.get(".t--property-control-columnfreeze .t--button-group-left").click({ + force: true, + }); + cy.checkIfColumnIsFrozenViaCSS("0", "0"); + + cy.get(getWidgetSelector(WIDGET.TEXT)).should( + "contain.text", + '"step": "left"', + ); + }); + + it("1.1.2 Freeze column to right", () => { + cy.get(commonlocators.editPropBackButton).click(); + cy.wait(1000); + cy.openFieldConfiguration("action"); + cy.get(".t--property-control-columnfreeze .t--button-group-right").click({ + force: true, + }); + // Check if the first cell has position sticky: + cy.checkIfColumnIsFrozenViaCSS("0", "3"); + + cy.get(getWidgetSelector(WIDGET.TEXT)).should( + "contain.text", + '"action": "right"', + ); + }); + + it("1.1.3 unFrezee an existing frozen column", () => { + cy.get(commonlocators.editPropBackButton).click(); + cy.wait(1000); + cy.get(".tablewidgetv2-primarycolumn-list > div") + .last() + .then(($elem) => { + cy.wrap($elem) + .find(".t--edit-column-btn") + .last() + .click({ force: true }); + }); + cy.get(".t--property-control-columnfreeze .t--button-group-").click({ + force: true, + }); + // Check if the first cell has position sticky: + cy.getTableV2DataSelector("0", "3").then((selector) => { + cy.get(selector).should("not.have.css", "position", "sticky"); + }); + cy.get(getWidgetSelector(WIDGET.TEXT)).should( + "not.contain.text", + '"action": "right"', + ); + }); + + it("1.1.4 Check column is frozen in page mode", () => { + cy.PublishtheApp(); + // Check if the first cell has position sticky: + cy.checkIfColumnIsFrozenViaCSS("0", "0"); + + cy.get(getWidgetSelector(WIDGET.TEXT)).should( + "contain.text", + '"step": "left"', + ); + cy.goToEditFromPublish(); + }); + }); + + describe("1.2 Column freeze and unfreeze testing via dropdown", () => { + it("1.2.1 Check if column freeze for user mode is enabled", () => { + cy.openPropertyPane(WIDGET.TABLE); + + cy.get( + ".t--property-control-allowcolumnfreeze .bp3-switch input[type='checkbox']", + ).should("be.checked"); + + cy.get(`[role="columnheader"] .header-menu .bp3-popover2-target`) + .first() + .click({ + force: true, + }); + + cy.get(".bp3-menu") + .contains("Freeze column left") + .then(($elem) => { + cy.get($elem) + .parent() + .should("not.have.class", "bp3-disabled"); + }); + + // Check in publish mode. + cy.PublishtheApp(); + cy.get(`[role="columnheader"] .header-menu .bp3-popover2-target`) + .first() + .click({ + force: true, + }); + + cy.get(".bp3-menu") + .contains("Freeze column left") + .then(($elem) => { + cy.get($elem) + .parent() + .should("not.have.class", "bp3-disabled"); + }); + cy.goToEditFromPublish(); + }); + + it("1.2.2 Check if column is freezing in the edit mode", () => { + cy.freezeColumnFromDropdown("step", "left"); + cy.checkColumnPosition("step", 0); + + cy.freezeColumnFromDropdown("step", "left"); + cy.checkIfColumnIsFrozenViaCSS("0", "0"); + + cy.freezeColumnFromDropdown("action", "left"); + cy.checkIfColumnIsFrozenViaCSS("0", "1"); + }); + + it("1.2.3 Check if column can be unfrozen from dropdown", () => { + cy.freezeColumnFromDropdown("step", "left"); + /** + * When column is unfrozen, + * check the column position, it goes after the last frozen coumn from left or + * before the first right frozen column: + * */ + + cy.checkColumnPosition("step", 1); + + /** + * Last column unfrozen should remain in the same position after unfreezing + */ + cy.freezeColumnFromDropdown("action", "left"); + cy.checkColumnPosition("action", 0); + }); + + it("1.2.4 Check if existing left frozen coumn can be right frozen", () => { + cy.freezeColumnFromDropdown("action", "left"); + cy.checkColumnPosition("action", 0); + + // freeze above column to right; + cy.freezeColumnFromDropdown("action", "right"); + cy.checkIfColumnIsFrozenViaCSS("0", "3"); + cy.checkColumnPosition("action", 3); + }); + + it("1.2.5 Check if existing right frozen column can be frozen to left", () => { + cy.freezeColumnFromDropdown("action", "left"); + cy.checkIfColumnIsFrozenViaCSS("0", "0"); + cy.checkColumnPosition("action", 0); + }); + + it("1.2.6 Check if column freeze for user mode is disabled", () => { + cy.openPropertyPane(WIDGET.TABLE); + cy.get( + ".t--property-control-allowcolumnfreeze .bp3-switch input[type='checkbox']", + ).click({ + force: true, + }); + + cy.get( + ".t--property-control-allowcolumnfreeze .bp3-switch input[type='checkbox']", + ).should("not.be.checked"); + + cy.get(`[role="columnheader"] .header-menu .bp3-popover2-target`) + .first() + .click({ + force: true, + }); + + cy.get(".bp3-menu") + .contains("Freeze column left") + .should("have.class", "bp3-disabled"); + + // Check in publish mode. + cy.PublishtheApp(); + cy.get(`[role="columnheader"] .header-menu .bp3-popover2-target`) + .first() + .click({ + force: true, + }); + + cy.get(".bp3-menu") + .contains("Freeze column left") + .should("have.class", "bp3-disabled"); + + cy.goToEditFromPublish(); + }); + }); +}); + +describe("2. Check column freeze and unfreeze mechanism in page mode", () => { + before(() => { + cy.dragAndDropToCanvas(WIDGET.TABLE, { x: 200, y: 200 }); + cy.openPropertyPane(WIDGET.TABLE); + cy.PublishtheApp(); + }); + describe("2.1 Column freeze and unfreeze testing with 0 pre-frozen columns", () => { + beforeEach(() => { + agHelper.RestoreLocalStorageCache(); + }); + + afterEach(() => { + agHelper.SaveLocalStorageCache(); + }); + it("2.1.1 Freeze Columns left", () => { + cy.freezeColumnFromDropdown("step", "left"); + cy.checkIfColumnIsFrozenViaCSS("0", "0"); + cy.checkColumnPosition("step", 0); + cy.checkLocalColumnOrder(["step"], "left"); + + cy.freezeColumnFromDropdown("action", "left"); + cy.checkIfColumnIsFrozenViaCSS("0", "1"); + cy.checkColumnPosition("action", 1); + cy.checkLocalColumnOrder(["step", "action"], "left"); + }); + + it("2.1.2 Freeze Columns right", () => { + cy.freezeColumnFromDropdown("status", "right"); + cy.checkIfColumnIsFrozenViaCSS("0", "3"); + cy.checkColumnPosition("status", 3); + cy.checkLocalColumnOrder(["status"], "right"); + }); + + it("2.1.3 Freeze existing left column to right", () => { + cy.freezeColumnFromDropdown("step", "right"); + cy.checkIfColumnIsFrozenViaCSS("0", "2"); + cy.checkColumnPosition("step", 2); + cy.checkLocalColumnOrder(["step", "status"], "right"); + }); + + it("2.1.3 Freeze existing right column to left", () => { + cy.freezeColumnFromDropdown("status", "left"); + cy.checkIfColumnIsFrozenViaCSS("0", "1"); + cy.checkColumnPosition("status", 1); + cy.checkLocalColumnOrder(["action", "status"], "left"); + }); + + it("2.1.4 Unfreeze existing column", () => { + cy.freezeColumnFromDropdown("status", "left"); + cy.checkColumnPosition("status", 1); + cy.checkLocalColumnOrder(["action"], "left"); + + cy.freezeColumnFromDropdown("action", "left"); + cy.checkColumnPosition("action", 0); + cy.checkLocalColumnOrder([], "left"); + + cy.freezeColumnFromDropdown("step", "right"); + cy.checkColumnPosition("step", 3); + cy.checkLocalColumnOrder([], "right"); + cy.goToEditFromPublish(); + }); + }); + describe("2.2 Column freeze and unfreeze testing with multiple pre-frozen columns", () => { + beforeEach(() => { + agHelper.RestoreLocalStorageCache(); + }); + + afterEach(() => { + agHelper.SaveLocalStorageCache(); + }); + it("2.2.1 Freeze column left", () => { + // Freeze additional column in editor mode + cy.freezeColumnFromDropdown("action", "left"); + cy.checkColumnPosition("action", 0); + + cy.freezeColumnFromDropdown("step", "right"); + cy.checkColumnPosition("step", 3); + + cy.PublishtheApp(); + + // User frozen columns + cy.freezeColumnFromDropdown("status", "left"); + cy.checkColumnPosition("status", 1); + cy.checkIfColumnIsFrozenViaCSS("0", "1"); + cy.checkLocalColumnOrder(["action", "status"], "left"); + }); + + it("2.2.2 Freeze developer left frozen column to right", () => { + cy.freezeColumnFromDropdown("action", "right"); + cy.checkColumnPosition("action", 2); + cy.checkIfColumnIsFrozenViaCSS("0", "2"); + cy.checkLocalColumnOrder(["status"], "left"); + cy.checkLocalColumnOrder(["action", "step"], "right"); + }); + + it("2.2.3 Freeze developer right frozen column to left", () => { + cy.freezeColumnFromDropdown("step", "left"); + cy.checkColumnPosition("step", 1); + cy.checkIfColumnIsFrozenViaCSS("0", "1"); + cy.checkLocalColumnOrder(["status", "step"], "left"); + }); + + it("2.2.4 Unfreeze columns by developers", () => { + agHelper.ClearLocalStorageCache(); + cy.reload(); + cy.wait(1000); + + cy.freezeColumnFromDropdown("action", "left"); + cy.checkColumnPosition("action", 0); + cy.checkLocalColumnOrder([], "left"); + + cy.freezeColumnFromDropdown("step", "right"); + cy.checkColumnPosition("step", 3); + cy.checkLocalColumnOrder([], "right"); + cy.goToEditFromPublish(); + }); + }); + + describe("2.3 Hiding frozen columns", () => { + it("2.3.1 Hide left frozen column and check it's position is before right frozen columns", () => { + cy.openPropertyPane(WIDGET.TABLE); + cy.hideColumn("action"); + cy.getTableV2DataSelector("0", "2").then((selector) => { + cy.get(selector).should("have.class", "hidden-cell"); + }); + // Now check if the column next to this hidden column is right frozen + cy.checkIfColumnIsFrozenViaCSS("0", "3"); + }); + + it("2.3.2 Check if the hidden frozen column comes back to it's original frozen position", () => { + cy.showColumn("action"); + cy.checkColumnPosition("action", 0); + cy.checkIfColumnIsFrozenViaCSS("0", "0"); + }); + + it("2.3.3 Hide and unhide right frozen column", () => { + cy.hideColumn("step"); + cy.getTableV2DataSelector("0", "3").then((selector) => { + cy.get(selector).should("have.class", "hidden-cell"); + }); + cy.showColumn("step"); + cy.checkColumnPosition("step", 3); + cy.checkIfColumnIsFrozenViaCSS("0", "3"); + }); + + it("2.3.4 Hide and unhide frozen columns with existing frozen columns", () => { + /** + * At this point: action is left frozen and step is right frozen + * Adding one more left frozen column + */ + cy.freezeColumnFromDropdown("task", "left"); + + // Hide and unhide one left frozen column and then right frozen column + cy.hideColumn("task"); + cy.getTableV2DataSelector("0", "2").then((selector) => { + cy.get(selector).should("have.class", "hidden-cell"); + }); + cy.hideColumn("step"); + cy.getTableV2DataSelector("0", "3").then((selector) => { + cy.get(selector).should("have.class", "hidden-cell"); + }); + + cy.showColumn("task"); + cy.checkColumnPosition("task", 1); + cy.checkIfColumnIsFrozenViaCSS("0", "1"); + cy.showColumn("step"); + cy.checkColumnPosition("step", 3); + cy.checkIfColumnIsFrozenViaCSS("0", "3"); + }); + }); +}); diff --git a/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Widgets/TableV2/virtual_row_spec.js b/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Widgets/TableV2/virtual_row_spec.js index fc73794cc25..c2f5cd5215e 100644 --- a/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Widgets/TableV2/virtual_row_spec.js +++ b/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Widgets/TableV2/virtual_row_spec.js @@ -31,11 +31,11 @@ describe("Table Widget Virtualized Row", function() { it("2. should check that virtual rows are getting rendered when scrolling through the table", () => { cy.get(".tr[data-rowindex]").should("not.have.length", totalRows); cy.get(".tr[data-rowindex='0']").should("exist"); - cy.get(".tbody > div").scrollTo("bottom"); + cy.get(".virtual-list.simplebar-content").scrollTo("bottom"); cy.wait(500); cy.get(".tr[data-rowindex='0']").should("not.exist"); cy.get(".tr[data-rowindex='98']").should("exist"); - cy.get(".tbody > div").scrollTo("top"); + cy.get(".virtual-list.simplebar-content").scrollTo("top"); cy.wait(500); cy.get(".tr[data-rowindex='0']").should("exist"); cy.get(".tr[data-rowindex='98']").should("not.exist"); @@ -49,11 +49,11 @@ describe("Table Widget Virtualized Row", function() { cy.get(".tr[data-rowindex]").should("have.length", totalRows); cy.get(".tr[data-rowindex='0']").should("exist"); cy.get(".tr[data-rowindex='98']").should("exist"); - cy.get(".tbody").scrollTo("bottom"); + cy.get(".table .simplebar-content-wrapper").scrollTo("bottom"); cy.wait(500); cy.get(".tr[data-rowindex='0']").should("exist"); cy.get(".tr[data-rowindex='98']").should("exist"); - cy.get(".tbody").scrollTo("top"); + cy.get(".table .simplebar-content-wrapper").scrollTo("top"); cy.wait(500); cy.get(".tr[data-rowindex='0']").should("exist"); cy.get(".tr[data-rowindex='98']").should("exist"); diff --git a/app/client/cypress/support/Constants.js b/app/client/cypress/support/Constants.js index dbbaa80b9e2..23a77bd581f 100644 --- a/app/client/cypress/support/Constants.js +++ b/app/client/cypress/support/Constants.js @@ -1 +1,2 @@ export const modifierKey = Cypress.platform === "darwin" ? "meta" : "ctrl"; +export const TABLE_COLUMN_ORDER_KEY = "tableWidgetColumnOrder"; diff --git a/app/client/cypress/support/widgetCommands.js b/app/client/cypress/support/widgetCommands.js index 9dc8769eae6..a6f2c49b3f3 100644 --- a/app/client/cypress/support/widgetCommands.js +++ b/app/client/cypress/support/widgetCommands.js @@ -14,6 +14,7 @@ const dynamicInputLocators = require("../locators/DynamicInput.json"); const viewWidgetsPage = require("../locators/ViewWidgets.json"); const generatePage = require("../locators/GeneratePage.json"); import { ObjectsRegistry } from "../support/Objects/Registry"; +import { TABLE_COLUMN_ORDER_KEY } from "./Constants"; let pageidcopy = " "; @@ -1742,6 +1743,66 @@ Cypress.Commands.add("checkMaxDefaultValue", (endp, value) => { }); }); +Cypress.Commands.add("freezeColumnFromDropdown", (columnName, direction) => { + cy.get( + `[data-header=${columnName}] .header-menu .bp3-popover2-target`, + ).click({ force: true }); + cy.get(".bp3-menu") + .contains(`Freeze column ${direction}`) + .click({ force: true }); + + cy.wait(500); +}); + +Cypress.Commands.add("checkIfColumnIsFrozenViaCSS", (rowNum, coumnNum) => { + cy.getTableV2DataSelector(rowNum, coumnNum).then((selector) => { + cy.get(selector).should("have.css", "position", "sticky"); + }); +}); + +Cypress.Commands.add( + "checkColumnPosition", + (columnName, expectedColumnPosition) => { + cy.get(`[data-header]`) + .eq(expectedColumnPosition) + .then(($elem) => { + const dataHeaderAttribute = $elem.attr("data-header"); + expect(dataHeaderAttribute).to.equal(columnName); + }); + }, +); + +Cypress.Commands.add("readLocalColumnOrder", (columnOrderKey) => { + const localColumnOrder = window.localStorage.getItem(columnOrderKey) || ""; + if (localColumnOrder) { + const parsedTableConfig = JSON.parse(localColumnOrder); + if (parsedTableConfig) { + const tableWidgetId = Object.keys(parsedTableConfig)[0]; + return parsedTableConfig[tableWidgetId]; + } + } +}); + +Cypress.Commands.add( + "checkLocalColumnOrder", + (expectedOrder, direction, columnOrderKey = TABLE_COLUMN_ORDER_KEY) => { + cy.wait(1000); + cy.readLocalColumnOrder(columnOrderKey).then((tableWidgetOrder) => { + if (tableWidgetOrder) { + const { + leftOrder: observedLeftOrder, + rightOrder: observedRightOrder, + } = tableWidgetOrder; + if (direction === "left") { + expect(expectedOrder).to.be.deep.equal(observedLeftOrder); + } + if (direction === "right") { + expect(expectedOrder).to.be.deep.equal(observedRightOrder); + } + } + }); + }, +); Cypress.Commands.add("findAndExpandEvaluatedTypeTitle", () => { cy.get(commonlocators.evaluatedTypeTitle) .first() diff --git a/app/client/package.json b/app/client/package.json index 2e6f7b8691b..47437268419 100644 --- a/app/client/package.json +++ b/app/client/package.json @@ -135,6 +135,7 @@ "react-spring": "^9.4.0", "react-syntax-highlighter": "^15.5.0", "react-table": "^7.0.0", + "react-table-sticky": "^1.1.3", "react-tabs": "^3.0.0", "react-timer-hook": "^3.0.4", "react-toastify": "^5.5.0", @@ -151,6 +152,7 @@ "scroll-into-view-if-needed": "^2.2.26", "shallowequal": "^1.1.0", "showdown": "^1.9.1", + "simplebar-react": "^2.4.3", "smartlook-client": "^8.0.0", "socket.io-client": "^4.5.4", "styled-components": "^5.3.6", diff --git a/app/client/src/components/propertyControls/DraggableListCard.tsx b/app/client/src/components/propertyControls/DraggableListCard.tsx index 134b1263bbf..555c6067b62 100644 --- a/app/client/src/components/propertyControls/DraggableListCard.tsx +++ b/app/client/src/components/propertyControls/DraggableListCard.tsx @@ -11,6 +11,7 @@ import { StyledHiddenIcon, StyledCheckbox, StyledActionContainer, + StyledPinIcon, } from "components/propertyControls/StyledControls"; import { Colors } from "constants/Colors"; import { CheckboxType } from "design-system-old"; @@ -34,6 +35,7 @@ type RenderComponentProps = { isDuplicateLabel?: boolean; isChecked?: boolean; isCheckboxDisabled?: boolean; + isDragDisabled?: boolean; }; isDelete?: boolean; isDragging: boolean; @@ -105,7 +107,7 @@ export function DraggableListCard(props: RenderComponentProps) { const onFocus = () => { setEditing(false); if (updateFocus) { - updateFocus(index, true); + updateFocus(index, false); } }; @@ -143,10 +145,14 @@ export function DraggableListCard(props: RenderComponentProps) { }; const showDelete = !!item.isDerived || isDelete; - return ( - + {item?.isDragDisabled ? ( + + ) : ( + + )} + = { updateItems: (items: TItem[]) => void; onEdit?: (index: number) => void; updateFocus?: (index: number, isFocused: boolean) => void; + keyAccessor?: string; }; export class DroppableComponent< @@ -71,6 +72,7 @@ export class DroppableComponent< isVisible: item.isVisible, isDuplicateLabel: item.isDuplicateLabel, isChecked: item.isChecked, + isDragDisabled: item?.isDragDisabled, }; } @@ -125,6 +127,7 @@ export class DroppableComponent< focusedIndex={this.props.focusedIndex} itemHeight={45} items={this.props.items} + keyAccessor={this.props?.keyAccessor} onUpdate={this.onUpdate} shouldReRender={false} updateDragging={this.updateDragging} diff --git a/app/client/src/components/propertyControls/PrimaryColumnsControlV2.tsx b/app/client/src/components/propertyControls/PrimaryColumnsControlV2.tsx index 21c360ee1e0..d3cf195080d 100644 --- a/app/client/src/components/propertyControls/PrimaryColumnsControlV2.tsx +++ b/app/client/src/components/propertyControls/PrimaryColumnsControlV2.tsx @@ -13,7 +13,10 @@ import EmptyDataState from "components/utils/EmptyDataState"; import EvaluatedValuePopup from "components/editorComponents/CodeEditor/EvaluatedValuePopup"; import { EditorTheme } from "components/editorComponents/CodeEditor/EditorConfig"; import { CodeEditorExpected } from "components/editorComponents/CodeEditor"; -import { ColumnProperties } from "widgets/TableWidgetV2/component/Constants"; +import { + ColumnProperties, + StickyType, +} from "widgets/TableWidgetV2/component/Constants"; import { createColumn, isColumnTypeEditable, @@ -124,18 +127,29 @@ class PrimaryColumnsControlV2 extends BaseControl { hasScrollableList: false, }; } - componentDidMount() { this.checkAndUpdateIfEditableColumnPresent(); } componentDidUpdate(prevProps: ControlProps): void { - //on adding a new column last column should get focused + /** + * On adding a new column the last column should get focused. + * If frozen columns are present then the focus should be on the newly added column + */ if ( Object.keys(prevProps.propertyValue).length + 1 === Object.keys(this.props.propertyValue).length ) { - this.updateFocus(Object.keys(this.props.propertyValue).length - 1, true); + const columns = Object.keys(this.props.propertyValue); + + const frozenColumnIndex = Object.keys(prevProps.propertyValue) + .map((column) => prevProps.propertyValue[column]) + .filter((column) => column.sticky !== StickyType.RIGHT).length; + + this.updateFocus( + frozenColumnIndex === 0 ? columns.length - 1 : frozenColumnIndex, + true, + ); this.checkAndUpdateIfEditableColumnPresent(); } @@ -189,6 +203,9 @@ class PrimaryColumnsControlV2 extends BaseControl { isColumnTypeEditable(column.columnType) && column.isEditable, isCheckboxDisabled: !isColumnTypeEditable(column.columnType) || column.isDerived, + isDragDisabled: + column.sticky === StickyType.LEFT || + column.sticky === StickyType.RIGHT, }; }, ); @@ -231,6 +248,7 @@ class PrimaryColumnsControlV2 extends BaseControl { focusedIndex={this.state.focusedIndex} itemHeight={45} items={draggableComponentColumns} + keyAccessor="id" onEdit={this.onEdit} propertyPath={this.props.dataTreePath} renderComponent={(props: any) => diff --git a/app/client/src/components/propertyControls/StyledControls.tsx b/app/client/src/components/propertyControls/StyledControls.tsx index 2854d58adc8..73e8e11f7a1 100644 --- a/app/client/src/components/propertyControls/StyledControls.tsx +++ b/app/client/src/components/propertyControls/StyledControls.tsx @@ -300,6 +300,24 @@ export const StyledDragIcon = styled(ControlIcons.DRAG_CONTROL)` } `; +export const StyledPinIcon = styled(ControlIcons.PIN)` + padding: 0; + position: absolute; + margin-right: 15px; + cursor: default; + z-index: 1; + left: 4px; + && svg { + width: 16px; + height: 16px; + position: relative; + top: 2px; + path { + fill: ${(props) => props.theme.colors.propertyPane.iconColor}; + } + } +`; + export const FlexWrapper = styled.div` display: flex; `; diff --git a/app/client/src/constants/DefaultTheme.tsx b/app/client/src/constants/DefaultTheme.tsx index 8f9076235e8..5a31963f46d 100644 --- a/app/client/src/constants/DefaultTheme.tsx +++ b/app/client/src/constants/DefaultTheme.tsx @@ -9,6 +9,10 @@ import { JSXElementConstructor } from "react"; import { typography, Typography, TypographyKeys } from "./typography"; import { LabelPosition } from "components/constants"; +import { + TABLE_SCROLLBAR_HEIGHT, + TABLE_SCROLLBAR_WIDTH, +} from "widgets/TableWidgetV2/component/Constants"; export type FontFamily = typeof FontFamilies[keyof typeof FontFamilies]; export const IntentColors: Record = { @@ -527,6 +531,30 @@ export const labelStyle = css` font-weight: ${(props) => props.theme.fontWeights[3]}; `; +export const tableScrollBars = css` + &::-webkit-scrollbar { + width: ${TABLE_SCROLLBAR_WIDTH}px; + height: ${TABLE_SCROLLBAR_HEIGHT}px; + } + + &::-webkit-scrollbar-track { + background: var(--wds-color-bg-disabled); + border-radius: 10px; + } + + &:hover { + &::-webkit-scrollbar-track { + background: var(--wds-color-bg-disabled); + border-radius: 10px; + } + + &::-webkit-scrollbar-thumb { + background: ${getColorWithOpacity(Colors.CHARCOAL, 0.5)}; + border-radius: 10px; + } + } +`; + export const hideScrollbar = css` scrollbar-width: none; -ms-overflow-style: none; diff --git a/app/client/src/constants/WidgetConstants.tsx b/app/client/src/constants/WidgetConstants.tsx index cfdf8d87273..3b92fc1c76a 100644 --- a/app/client/src/constants/WidgetConstants.tsx +++ b/app/client/src/constants/WidgetConstants.tsx @@ -70,7 +70,7 @@ export const layoutConfigurations: LayoutConfigurations = { FLUID: { minWidth: -1, maxWidth: -1 }, }; -export const LATEST_PAGE_VERSION = 76; +export const LATEST_PAGE_VERSION = 77; export const GridDefaults = { DEFAULT_CELL_SIZE: 1, diff --git a/app/client/src/icons/ControlIcons.tsx b/app/client/src/icons/ControlIcons.tsx index 0944fca66cf..ea06f8adc3a 100644 --- a/app/client/src/icons/ControlIcons.tsx +++ b/app/client/src/icons/ControlIcons.tsx @@ -58,6 +58,7 @@ import { ReactComponent as BoxShadowVariant3Icon } from "assets/icons/control/bo import { ReactComponent as BoxShadowVariant4Icon } from "assets/icons/control/box-shadow-variant4.svg"; import { ReactComponent as BoxShadowVariant5Icon } from "assets/icons/control/box-shadow-variant5.svg"; import IncreaseV2Icon from "remixicon-react/AddLineIcon"; +import PinIcon from "remixicon-react/Pushpin2LineIcon"; import PlayIcon from "assets/icons/control/play-icon.png"; import CopyIcon from "remixicon-react/FileCopyLineIcon"; import QuestionIcon from "remixicon-react/QuestionLineIcon"; @@ -400,6 +401,11 @@ export const ControlIcons: { ), + PIN: (props: IconProps) => ( + + + + ), }; export type ControlIconName = keyof typeof ControlIcons; diff --git a/app/client/src/pages/Editor/PropertyPane/DraggableListControl.tsx b/app/client/src/pages/Editor/PropertyPane/DraggableListControl.tsx index abee45f2efe..511dc18e0be 100644 --- a/app/client/src/pages/Editor/PropertyPane/DraggableListControl.tsx +++ b/app/client/src/pages/Editor/PropertyPane/DraggableListControl.tsx @@ -16,6 +16,7 @@ export type DraggableListControlProps< > = DroppableComponentProps & { defaultPanelIndex?: number; propertyPath: string | undefined; + keyAccessor?: string; }; export const DraggableListControl = ( props: DraggableListControlProps, diff --git a/app/client/src/utils/DSLMigration.test.ts b/app/client/src/utils/DSLMigration.test.ts index 3e04eaa332f..4769735be05 100644 --- a/app/client/src/utils/DSLMigration.test.ts +++ b/app/client/src/utils/DSLMigration.test.ts @@ -736,6 +736,15 @@ const migrations: Migration[] = [ ], version: 75, }, + { + functionLookup: [ + { + moduleObj: tableMigrations, + functionName: "migrateColumnFreezeAttributes", + }, + ], + version: 76, + }, ]; const mockFnObj: Record = {}; diff --git a/app/client/src/utils/DSLMigrations.ts b/app/client/src/utils/DSLMigrations.ts index 48e0dacf47b..c022e79b8d3 100644 --- a/app/client/src/utils/DSLMigrations.ts +++ b/app/client/src/utils/DSLMigrations.ts @@ -25,6 +25,7 @@ import { migrateTableWidgetV2ValidationBinding, migrateMenuButtonDynamicItemsInsideTableWidget, migrateTableWidgetV2SelectOption, + migrateColumnFreezeAttributes, } from "./migrations/TableWidget"; import { migrateTextStyleFromTextWidget, @@ -1164,6 +1165,11 @@ export const transformDSL = (currentDSL: DSLWidget, newPage = false) => { if (currentDSL.version === 75) { currentDSL = migrateInputWidgetsMultiLineInputType(currentDSL); + currentDSL.version = 76; + } + + if (currentDSL.version === 76) { + currentDSL = migrateColumnFreezeAttributes(currentDSL); currentDSL.version = LATEST_PAGE_VERSION; } diff --git a/app/client/src/utils/migrations/TableWidget.ts b/app/client/src/utils/migrations/TableWidget.ts index 1f8b0faa688..76a6ce051ee 100644 --- a/app/client/src/utils/migrations/TableWidget.ts +++ b/app/client/src/utils/migrations/TableWidget.ts @@ -22,6 +22,7 @@ import { getSubstringBetweenTwoWords } from "utils/helpers"; import { traverseDSLAndMigrate } from "utils/WidgetMigrationUtils"; import { isDynamicValue } from "utils/DynamicBindingUtils"; import { stringToJS } from "components/editorComponents/ActionCreator/utils"; +import { StickyType } from "widgets/TableWidgetV2/component/Constants"; export const isSortableMigration = (currentDSL: DSLWidget) => { currentDSL.children = currentDSL.children?.map((child: WidgetProps) => { @@ -692,3 +693,23 @@ export const migrateMenuButtonDynamicItemsInsideTableWidget = ( } }); }; + +export const migrateColumnFreezeAttributes = (currentDSL: DSLWidget) => { + return traverseDSLAndMigrate(currentDSL, (widget: WidgetProps) => { + if (widget.type === "TABLE_WIDGET_V2") { + const primaryColumns = widget?.primaryColumns; + + // Assign default sticky value to each column + if (primaryColumns) { + for (const column in primaryColumns) { + if (!primaryColumns[column].hasOwnProperty("sticky")) { + primaryColumns[column].sticky = StickyType.NONE; + } + } + } + + widget.canFreezeColumn = false; + widget.columnUpdatedAt = Date.now(); + } + }); +}; diff --git a/app/client/src/widgets/TableWidgetV2/component/Constants.ts b/app/client/src/widgets/TableWidgetV2/component/Constants.ts index ef28a8664f7..2057910a418 100644 --- a/app/client/src/widgets/TableWidgetV2/component/Constants.ts +++ b/app/client/src/widgets/TableWidgetV2/component/Constants.ts @@ -56,7 +56,7 @@ export enum ImageSizes { export const TABLE_SIZES: { [key: string]: TableSizes } = { [CompactModeTypes.DEFAULT]: { COLUMN_HEADER_HEIGHT: 32, - TABLE_HEADER_HEIGHT: 38, + TABLE_HEADER_HEIGHT: 40, ROW_HEIGHT: 40, ROW_FONT_SIZE: 14, VERTICAL_PADDING: 6, @@ -66,7 +66,7 @@ export const TABLE_SIZES: { [key: string]: TableSizes } = { }, [CompactModeTypes.SHORT]: { COLUMN_HEADER_HEIGHT: 32, - TABLE_HEADER_HEIGHT: 38, + TABLE_HEADER_HEIGHT: 40, ROW_HEIGHT: 30, ROW_FONT_SIZE: 12, VERTICAL_PADDING: 0, @@ -76,7 +76,7 @@ export const TABLE_SIZES: { [key: string]: TableSizes } = { }, [CompactModeTypes.TALL]: { COLUMN_HEADER_HEIGHT: 32, - TABLE_HEADER_HEIGHT: 38, + TABLE_HEADER_HEIGHT: 40, ROW_HEIGHT: 60, ROW_FONT_SIZE: 18, VERTICAL_PADDING: 16, @@ -226,6 +226,11 @@ export interface TableColumnMetaProps { type: ColumnTypes; } +export enum StickyType { + LEFT = "left", + RIGHT = "right", + NONE = "", +} export interface TableColumnProps { id: string; Header: string; @@ -239,6 +244,7 @@ export interface TableColumnProps { metaProperties?: TableColumnMetaProps; isDerived?: boolean; columnProperties: ColumnProperties; + sticky?: StickyType; } export interface ReactTableColumnProps extends TableColumnProps { Cell: (props: any) => JSX.Element; @@ -345,6 +351,7 @@ export interface ColumnProperties onItemClicked?: (onClick: string | undefined) => void; iconButtonStyle?: ButtonStyleType; imageSize?: ImageSize; + sticky?: StickyType; getVisibleItems?: () => Array; menuItemsSource?: MenuItemsSource; configureMenuItems?: ConfigureMenuItems; @@ -509,6 +516,18 @@ export enum AddNewRowActions { export const EDITABLE_CELL_PADDING_OFFSET = 8; +export const TABLE_SCROLLBAR_WIDTH = 10; +export const TABLE_SCROLLBAR_HEIGHT = 8; + +export const POPOVER_ITEMS_TEXT_MAP = { + SORT_ASC: "Sort column ascending", + SORT_DSC: "Sort column descending", + FREEZE_LEFT: "Freeze column left", + FREEZE_RIGHT: "Freeze column right", +}; + +export const HEADER_MENU_PORTAL_CLASS = ".header-menu-portal"; +export const MENU_CONTENT_CLASS = ".menu-content"; export const DEFAULT_FILTER = { id: generateReactKey(), column: "", diff --git a/app/client/src/widgets/TableWidgetV2/component/Table.tsx b/app/client/src/widgets/TableWidgetV2/component/Table.tsx index 06e81ab92fe..9f5c940cf76 100644 --- a/app/client/src/widgets/TableWidgetV2/component/Table.tsx +++ b/app/client/src/widgets/TableWidgetV2/component/Table.tsx @@ -8,6 +8,7 @@ import { useRowSelect, Row as ReactTableRowType, } from "react-table"; +import { useSticky } from "react-table-sticky"; import { TableWrapper, TableHeaderWrapper, @@ -22,18 +23,43 @@ import { CompactMode, CompactModeTypes, AddNewRowActions, + StickyType, + TABLE_SCROLLBAR_WIDTH, + MULTISELECT_CHECKBOX_WIDTH, + TABLE_SCROLLBAR_HEIGHT, } from "./Constants"; import { Colors } from "constants/Colors"; - -import { ScrollIndicator } from "design-system-old"; import { EventType } from "constants/AppsmithActionConstants/ActionConstants"; -import { Scrollbars } from "react-custom-scrollbars"; import { renderEmptyRows } from "./cellComponents/EmptyCell"; import { renderHeaderCheckBoxCell } from "./cellComponents/SelectionCheckboxCell"; import { HeaderCell } from "./cellComponents/HeaderCell"; import { EditableCell, TableVariant } from "../constants"; import { TableBody } from "./TableBody"; +import { areEqual } from "react-window"; +import SimpleBar from "simplebar-react"; +import "simplebar-react/dist/simplebar.min.css"; +import { createGlobalStyle } from "styled-components"; +import { Classes as PopOver2Classes } from "@blueprintjs/popover2"; + +const SCROLL_BAR_OFFSET = 2; +const HEADER_MENU_PORTAL_CLASS = ".header-menu-portal"; +const PopoverStyles = createGlobalStyle<{ + widgetId: string; + borderRadius: string; +}>` + ${HEADER_MENU_PORTAL_CLASS}-${({ widgetId }) => widgetId} + { + font-family: var(--wds-font-family) !important; + + & .${PopOver2Classes.POPOVER2}, + .${PopOver2Classes.POPOVER2_CONTENT}, + .bp3-menu { + border-radius: ${({ borderRadius }) => + borderRadius >= `1.5rem` ? `0.375rem` : borderRadius} !important; + } + } +`; interface TableProps { width: number; height: number; @@ -96,6 +122,8 @@ interface TableProps { onActionComplete: () => void, ) => void; disabledAddNewRowSave: boolean; + handleColumnFreeze?: (columnName: string, sticky?: StickyType) => void; + canFreezeColumn?: boolean; } const defaultColumn = { @@ -103,21 +131,102 @@ const defaultColumn = { width: 150, }; -function ScrollbarVerticalThumb(props: any) { - return
; -} - -function ScrollbarHorizontalThumb(props: any) { - return
; -} - -function ScrollbarHorizontalTrack(props: any) { - return
; -} +type HeaderComponentProps = { + enableDrag: () => void; + disableDrag: () => void; + multiRowSelection?: boolean; + handleAllRowSelectClick: ( + e: React.MouseEvent, + ) => void; + renderHeaderCheckBoxCell: any; + accentColor: string; + borderRadius: string; + headerGroups: any; + canFreezeColumn?: boolean; + editMode: boolean; + handleColumnFreeze?: (columnName: string, sticky?: StickyType) => void; + isResizingColumn: React.MutableRefObject; + isSortable?: boolean; + sortTableColumn: (columnIndex: number, asc: boolean) => void; + columns: ReactTableColumnProps[]; + width: number; + subPage: ReactTableRowType>[]; + prepareRow: any; + headerWidth?: number; + rowSelectionState: 0 | 1 | 2 | null; + widgetId: string; +}; +const HeaderComponent = (props: HeaderComponentProps) => { + return ( +
+ {props.headerGroups.map((headerGroup: any, index: number) => { + const headerRowProps = { + ...headerGroup.getHeaderGroupProps(), + style: { display: "flex", width: props.headerWidth }, + }; + return ( +
+ {props.multiRowSelection && + renderHeaderCheckBoxCell( + props.handleAllRowSelectClick, + props.rowSelectionState, + props.accentColor, + props.borderRadius, + )} + {headerGroup.headers.map((column: any, columnIndex: number) => { + const stickyRightModifier = !column.isHidden + ? columnIndex !== 0 && + props.columns[columnIndex - 1].sticky === StickyType.RIGHT && + props.columns[columnIndex - 1].isHidden + ? "sticky-right-modifier" + : "" + : ""; + return ( + + ); + })} +
+ ); + })} + {props.headerGroups.length === 0 && + renderEmptyRows( + 1, + props.columns, + props.width, + props.subPage, + props.multiRowSelection, + props.accentColor, + props.borderRadius, + {}, + props.prepareRow, + )} +
+ ); +}; export function Table(props: TableProps) { const isResizingColumn = React.useRef(false); - const handleResizeColumn = (columnWidths: Record) => { const columnWidthMap = { ...props.columnWidthMap, @@ -167,6 +276,7 @@ export function Table(props: TableProps) { pageOptions, prepareRow, state, + totalColumnsWidth, } = useTable( { columns: columns, @@ -183,6 +293,7 @@ export function Table(props: TableProps) { useResizeColumns, usePagination, useRowSelect, + useSticky, ); //Set isResizingColumn as true when column is resizing using table state if (state.columnResizing.isResizingColumn) { @@ -239,13 +350,16 @@ export function Table(props: TableProps) { props.isVisiblePagination || props.allowAddNewRow; - const style = useMemo( - () => ({ - width: props.width, - height: 38, - }), - [props.width], - ); + const scrollContainerStyles = useMemo(() => { + return { + height: isHeaderVisible + ? props.height - + tableSizes.TABLE_HEADER_HEIGHT - + TABLE_SCROLLBAR_HEIGHT - + SCROLL_BAR_OFFSET + : props.height - TABLE_SCROLLBAR_HEIGHT - SCROLL_BAR_OFFSET, + }; + }, [isHeaderVisible, props.height, tableSizes.TABLE_HEADER_HEIGHT]); const shouldUseVirtual = props.serverSidePaginationEnabled && @@ -259,6 +373,51 @@ export function Table(props: TableProps) { } }, [props.isAddRowInProgress]); + const MemoizedInnerElement = useMemo( + () => ({ children, outerRef, style, ...rest }: any) => ( + <> + +
+
+ {children} +
+
+ + ), + [ + areEqual, + columns, + props.multiRowSelection, + rowSelectionState, + shouldUseVirtual, + totalColumnsWidth, + props.canFreezeColumn, + props.pageSize, + ], + ); + return ( + {isHeaderVisible && ( - - - - + + )}
- -
-
- {headerGroups.map((headerGroup: any, index: number) => { - const headerRowProps = { - ...headerGroup.getHeaderGroupProps(), - style: { display: "flex" }, - }; - return ( -
- {props.multiRowSelection && - renderHeaderCheckBoxCell( - handleAllRowSelectClick, - rowSelectionState, - props.accentColor, - props.borderRadius, - )} - {headerGroup.headers.map( - (column: any, columnIndex: number) => { - return ( - - ); - }, - )} -
- ); - })} - {headerGroups.length === 0 && - renderEmptyRows( - 1, - props.columns, - props.width, - subPage, - props.multiRowSelection, - props.accentColor, - props.borderRadius, - {}, - prepareRow, - )} -
- -
-
+
+ {!shouldUseVirtual && ( + + + + + )} + + {shouldUseVirtual && ( + + {({ scrollableNodeRef }) => ( + + )} + + )} +
-
); } diff --git a/app/client/src/widgets/TableWidgetV2/component/TableBody/Row.tsx b/app/client/src/widgets/TableWidgetV2/component/TableBody/Row.tsx index c3e79a2559f..f8197f9146c 100644 --- a/app/client/src/widgets/TableWidgetV2/component/TableBody/Row.tsx +++ b/app/client/src/widgets/TableWidgetV2/component/TableBody/Row.tsx @@ -4,6 +4,7 @@ import { ListChildComponentProps } from "react-window"; import { BodyContext } from "."; import { renderEmptyRows } from "../cellComponents/EmptyCell"; import { renderBodyCheckBoxCell } from "../cellComponents/SelectionCheckboxCell"; +import { MULTISELECT_CHECKBOX_WIDTH, StickyType } from "../Constants"; type RowType = { className?: string; @@ -16,6 +17,7 @@ export function Row(props: RowType) { const { accentColor, borderRadius, + columns, isAddRowInProgress, multiRowSelection, prepareRow, @@ -61,10 +63,28 @@ export function Row(props: RowType) { {multiRowSelection && renderBodyCheckBoxCell(isRowSelected, accentColor, borderRadius)} {props.row.cells.map((cell, cellIndex) => { + const cellProperties = cell.getCellProps(); + cellProperties["style"] = { + ...cellProperties.style, + left: + columns[cellIndex].sticky === StickyType.LEFT && multiRowSelection + ? cell.column.totalLeft + MULTISELECT_CHECKBOX_WIDTH + : cellProperties?.style?.left, + }; return (
>[]; height: number; + width?: number; tableSizes: TableSizes; + innerElementType?: ReactElementType; }; const TableVirtualBodyComponent = React.forwardRef( - (props: BodyPropsType, ref: Ref) => { + (props: BodyPropsType & { outerRef?: any }) => { return ( -
+
{rowRenderer} @@ -99,7 +107,7 @@ const TableVirtualBodyComponent = React.forwardRef( const TableBodyComponent = React.forwardRef( (props: BodyPropsType, ref: Ref) => { return ( -
+
{props.rows.map((row, index) => { return ; })} @@ -113,7 +121,8 @@ const TableBodyComponent = React.forwardRef( export const TableBody = React.forwardRef( ( - props: BodyPropsType & BodyContextType & { useVirtual: boolean }, + props: BodyPropsType & + BodyContextType & { useVirtual: boolean; innerRef?: any; outerRef?: any }, ref: Ref, ) => { const { @@ -151,7 +160,12 @@ export const TableBody = React.forwardRef( }} > {useVirtual ? ( - + ) : ( )} diff --git a/app/client/src/widgets/TableWidgetV2/component/TableStyledWrappers.tsx b/app/client/src/widgets/TableWidgetV2/component/TableStyledWrappers.tsx index 265895c7d7e..694683976c7 100644 --- a/app/client/src/widgets/TableWidgetV2/component/TableStyledWrappers.tsx +++ b/app/client/src/widgets/TableWidgetV2/component/TableStyledWrappers.tsx @@ -10,10 +10,11 @@ import { TABLE_SIZES, CellAlignment, VerticalAlignment, - scrollbarOnHoverCSS, ImageSize, ImageSizes, MULTISELECT_CHECKBOX_WIDTH, + TABLE_SCROLLBAR_HEIGHT, + TABLE_SCROLLBAR_WIDTH, } from "./Constants"; import { Colors, Color } from "constants/Colors"; import { hideScrollbar, invisible } from "constants/DefaultTheme"; @@ -21,9 +22,8 @@ import { lightenColor, darkenColor } from "widgets/WidgetUtils"; import { FontStyleTypes } from "constants/WidgetConstants"; import { Classes } from "@blueprintjs/core"; import { TableVariant, TableVariantTypes } from "../constants"; +import { Layers } from "constants/Layers"; -const OFFSET_WITHOUT_HEADER = 40; -const OFFSET_WITH_HEADER = 80; const BORDER_RADIUS = "border-radius: 4px;"; const HEADER_CONTROL_FONT_SIZE = "12px"; @@ -42,6 +42,7 @@ export const TableWrapper = styled.div<{ isResizingColumn?: boolean; variant?: TableVariant; isAddRowInProgress: boolean; + multiRowSelection?: boolean; }>` width: 100%; height: 100%; @@ -56,18 +57,41 @@ export const TableWrapper = styled.div<{ justify-content: space-between; flex-direction: column; overflow: hidden; + .simplebar-track { + opacity: 0.7; + &.simplebar-horizontal { + height: ${TABLE_SCROLLBAR_HEIGHT}px; + .simplebar-scrollbar { + height: 5px; + } + &.simplebar-hover { + height: 10px; + & .simplebar-scrollbar { + height: 8px; + } + } + } + + &.simplebar-vertical { + direction: rtl; + top: ${(props) => props.tableSizes.TABLE_HEADER_HEIGHT - 10}px; + width: ${TABLE_SCROLLBAR_WIDTH}px; + &.simplebar-hover { + width: 10px; + & .simplebar-scrollbar { + width: 11px; + } + } + } + } .tableWrap { height: 100%; display: block; position: relative; - width: ${(props) => props.width}px; - overflow-x: auto; - ${hideScrollbar}; - ${scrollbarOnHoverCSS}; - .thumb-horizontal { - height: 4px !important; - border-radius: ${(props) => props.theme.radii[3]}px; - background: ${(props) => props.theme.colors.scrollbarLight} !important; + width: 100%; + overflow: auto hidden; + &.virtual { + ${hideScrollbar}; } } .table { @@ -77,24 +101,11 @@ export const TableWrapper = styled.div<{ display: table; width: 100%; ${hideScrollbar}; - .thead, - .tbody { - overflow: hidden; - } .tbody { - height: ${(props) => - props.isHeaderVisible - ? props.height - OFFSET_WITH_HEADER - : props.height - OFFSET_WITHOUT_HEADER}px; - width: 100%; - overflow-y: auto; - ${hideScrollbar}; - } - .tbody.no-scroll { - overflow: hidden; + height: fit-content; + width: fit-content; } .tr { - overflow: hidden; cursor: ${(props) => props.triggerRowSelection && "pointer"}; background: ${Colors.WHITE}; &.selected-row { @@ -139,6 +150,9 @@ export const TableWrapper = styled.div<{ line-height: ${(props) => props.tableSizes.ROW_FONT_SIZE}px; :last-child { border-right: 0; + .resizer { + right: 5px; + } } .resizer { display: inline-block; @@ -161,10 +175,6 @@ export const TableWrapper = styled.div<{ font-size: 14px; } - .thead:hover .th { - border-right: 1px solid var(--wds-color-border-onaccent); - } - .th { padding: 0 10px 0 0; height: ${(props) => @@ -182,6 +192,66 @@ export const TableWrapper = styled.div<{ position: sticky; top: 0; z-index: 1; + width: fit-content; + } + } + + .virtual-list { + ${hideScrollbar}; + } + + .column-freeze { + .body { + position: relative; + z-index: 0; + } + + [role="columnheader"] { + background-color: var(--wds-color-bg) !important; + } + + [data-sticky-td] { + position: sticky; + position: -webkit-sticky; + background-color: inherit; + border-bottom: ${(props) => + props.variant === TableVariantTypes.VARIANT2 + ? "none" + : "1px solid var(--wds-color-border-onaccent)"}; + & .draggable-header { + cursor: pointer; + } + &.hidden-cell, + &:has(> .hidden-header) { + z-index: 0; + position: unset !important; + } + + &:has(> .hidden-header) .resizer { + position: relative; + } + } + + [data-sticky-last-left-td] { + left: 0px; + border-right: 3px solid var(--wds-color-border); + &.hidden-cell, + &:has(> .hidden-header) { + border-right: 0.5px solid var(--wds-color-border); + } + } + + [data-sticky-first-right-td] { + right: 0px; + border-left: 3px solid var(--wds-color-border); + &.hidden-cell, + &:has(> .hidden-header) { + border-left: none; + } + } + + & .sticky-right-modifier { + border-left: 3px solid var(--wds-color-border); } } @@ -201,10 +271,10 @@ export const TableWrapper = styled.div<{ } } .draggable-header { - cursor: pointer; + cursor: grab; display: inline-block; width: 100%; - height: 38px; + height: ${(props) => props.tableSizes.COLUMN_HEADER_HEIGHT}; &.reorder-line { width: 1px; height: 100%; @@ -215,6 +285,26 @@ export const TableWrapper = styled.div<{ ${invisible}; } + .header-menu { + cursor: pointer; + width: 24px; + display: flex; + align-items: center; + .bp3-popover2-target { + display: block; + } + + &.hide { + &:hover { + .bp3-popover2-target { + display: block; + } + } + .bp3-popover2-target { + display: none; + } + } + } .column-menu { cursor: pointer; height: ${(props) => props.tableSizes.COLUMN_HEADER_HEIGHT}px; @@ -528,6 +618,8 @@ export const CellCheckboxWrapper = styled(CellWrapper)<{ accentColor?: string; borderRadius?: string; }>` + left: 0; + z-index: ${Layers.modalWidget}; justify-content: center; width: ${MULTISELECT_CHECKBOX_WIDTH}px; height: auto; @@ -575,21 +667,13 @@ export const TableHeaderWrapper = styled.div<{ }>` position: relative; display: flex; - width: ${(props) => props.width}px; + width: 100%; .show-page-items { display: ${(props) => props.width < MIN_WIDTH_TO_SHOW_PAGE_ITEMS ? "none" : "flex"}; } height: ${(props) => props.tableSizes.TABLE_HEADER_HEIGHT}px; min-height: ${(props) => props.tableSizes.TABLE_HEADER_HEIGHT}px; - overflow-x: auto; - ${hideScrollbar}; - ${scrollbarOnHoverCSS}; - .thumb-horizontal { - height: 4px !important; - border-radius: ${(props) => props.theme.radii[3]}px; - background: ${(props) => props.theme.colors.scrollbarLight}; - } `; export const TableHeaderInnerWrapper = styled.div<{ @@ -701,8 +785,9 @@ export const EmptyRow = styled.div` flex: 1 0 auto; `; -export const EmptyCell = styled.div<{ width: number }>` +export const EmptyCell = styled.div<{ width: number; sticky?: string }>` width: ${(props) => props.width}px; boxsizing: border-box; flex: ${(props) => props.width} 0 auto; + z-index: ${(props) => (props.sticky ? Layers.dragPreview : 0)}; `; diff --git a/app/client/src/widgets/TableWidgetV2/component/cellComponents/EmptyCell.tsx b/app/client/src/widgets/TableWidgetV2/component/cellComponents/EmptyCell.tsx index 849d2f1de6b..a3d76c77985 100644 --- a/app/client/src/widgets/TableWidgetV2/component/cellComponents/EmptyCell.tsx +++ b/app/client/src/widgets/TableWidgetV2/component/cellComponents/EmptyCell.tsx @@ -1,9 +1,26 @@ +import { pickBy, sum } from "lodash"; import React, { CSSProperties } from "react"; import { Cell, Row } from "react-table"; -import { ReactTableColumnProps } from "../Constants"; +import { + MULTISELECT_CHECKBOX_WIDTH, + ReactTableColumnProps, + StickyType, +} from "../Constants"; import { EmptyCell, EmptyRow } from "../TableStyledWrappers"; import { renderBodyCheckBoxCell } from "./SelectionCheckboxCell"; +const addStickyModifierClass = ( + columns: ReactTableColumnProps[], + cellIndex: number, +) => { + return columns[cellIndex]?.sticky && + cellIndex !== 0 && + columns[cellIndex - 1].sticky === StickyType.RIGHT && + columns[cellIndex - 1].isHidden + ? " sticky-right-modifier" + : ""; +}; + export const renderEmptyRows = ( rowCount: number, columns: ReactTableColumnProps[], @@ -33,10 +50,39 @@ export const renderEmptyRows = (
{multiRowSelection && renderBodyCheckBoxCell(false, accentColor, borderRadius)} - {row.cells.map((cell: Cell>) => { - const cellProps = cell.getCellProps(); - return
; - })} + {row.cells.map( + (cell: Cell>, cellIndex: number) => { + const cellProps = cell.getCellProps(); + const distanceFromEdge: { + left?: number; + right?: number; + width?: string; + } = {}; + + if ( + multiRowSelection && + columns[cellIndex].sticky === StickyType.LEFT + ) { + distanceFromEdge["left"] = + cellIndex === 0 + ? MULTISELECT_CHECKBOX_WIDTH + : MULTISELECT_CHECKBOX_WIDTH + + columns[cellIndex].columnProperties.width; + } + return ( +
+ ); + }, + )}
); }); @@ -45,14 +91,78 @@ export const renderEmptyRows = ( ? columns : new Array(3).fill({ width: tableWidth / 3, isHidden: false }); + const lastLeftIdx = Object.keys( + pickBy(tableColumns, { sticky: StickyType.LEFT, isHidden: false }), + ).length; + + const firstRightIdx = + tableColumns.length - + Object.keys(pickBy(tableColumns, { sticky: StickyType.RIGHT })).length; + return rows.map((row: string, index: number) => { return ( {multiRowSelection && renderBodyCheckBoxCell(false, accentColor, borderRadius)} {tableColumns.map((column: any, colIndex: number) => { + const distanceFromEdge: { + left?: number; + right?: number; + width?: string; + } = {}; + const stickyAttributes: { + "data-sticky-td"?: boolean; + "data-sticky-last-left-td"?: boolean; + "data-sticky-first-right-td"?: boolean; + } = + column.sticky !== StickyType.NONE + ? { + ["data-sticky-td"]: true, + } + : {}; + + if (column.sticky === StickyType.LEFT) { + const leftColWidths = tableColumns + .slice(0, colIndex) + .map((col) => col.width); + + if (multiRowSelection) { + distanceFromEdge["left"] = + colIndex === 0 + ? MULTISELECT_CHECKBOX_WIDTH + : sum(leftColWidths) + MULTISELECT_CHECKBOX_WIDTH; + } else { + distanceFromEdge["left"] = + colIndex === 0 ? 0 : sum(leftColWidths); + } + + if (colIndex === lastLeftIdx - 1) + stickyAttributes["data-sticky-last-left-td"] = true; + } else if (column.sticky === StickyType.RIGHT) { + const rightColWidths = tableColumns + .slice(colIndex + 1, tableColumns.length) + .map((col) => col.width); + + distanceFromEdge["right"] = + colIndex === tableColumns.length - 1 ? 0 : sum(rightColWidths); + + if (colIndex === firstRightIdx) + stickyAttributes["data-sticky-first-right-td"] = true; + } + return ( - + ); })} diff --git a/app/client/src/widgets/TableWidgetV2/component/cellComponents/HeaderCell.tsx b/app/client/src/widgets/TableWidgetV2/component/cellComponents/HeaderCell.tsx index 66ea0e0098c..4f9b35d5fdb 100644 --- a/app/client/src/widgets/TableWidgetV2/component/cellComponents/HeaderCell.tsx +++ b/app/client/src/widgets/TableWidgetV2/component/cellComponents/HeaderCell.tsx @@ -1,13 +1,25 @@ import React, { createRef, useEffect, useState } from "react"; -import { Tooltip } from "@blueprintjs/core"; +import { MenuItem, Tooltip, Menu } from "@blueprintjs/core"; +import Check from "remixicon-react/CheckFillIcon"; +import ArrowDownIcon from "remixicon-react/ArrowDownSLineIcon"; import { Colors } from "constants/Colors"; import styled from "styled-components"; import { ControlIcons } from "icons/ControlIcons"; -import { CellAlignment, JUSTIFY_CONTENT } from "../Constants"; +import { + CellAlignment, + HEADER_MENU_PORTAL_CLASS, + JUSTIFY_CONTENT, + MENU_CONTENT_CLASS, + MULTISELECT_CHECKBOX_WIDTH, + POPOVER_ITEMS_TEXT_MAP, + StickyType, +} from "../Constants"; import { ReactComponent as EditIcon } from "assets/icons/control/edit-variant1.svg"; import { TooltipContentWrapper } from "../TableStyledWrappers"; import { isColumnTypeEditable } from "widgets/TableWidgetV2/widget/utilities"; +import { Popover2 } from "@blueprintjs/popover2"; +import { MenuDivider } from "design-system-old"; const AscendingIcon = styled(ControlIcons.SORT_CONTROL)` padding: 0; @@ -101,18 +113,33 @@ function Title(props: TitleProps) { const ICON_SIZE = 16; export function HeaderCell(props: { + canFreezeColumn?: boolean; columnName: string; columnIndex: number; isHidden: boolean; isAscOrder?: boolean; + handleColumnFreeze?: (columnName: string, sticky?: StickyType) => void; sortTableColumn: (columnIndex: number, asc: boolean) => void; isResizingColumn: boolean; column: any; editMode?: boolean; isSortable?: boolean; width?: number; + widgetId: string; + stickyRightModifier: string; + multiRowSelection?: boolean; }) { const { column, editMode, isSortable } = props; + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const headerProps = { ...column.getHeaderProps() }; + headerProps["style"] = { + ...headerProps.style, + left: + column.sticky === StickyType.LEFT && props.multiRowSelection + ? MULTISELECT_CHECKBOX_WIDTH + column.totalLeft + : headerProps.style.left, + }; const handleSortColumn = () => { if (props.isResizingColumn) return; let columnIndex = props.columnIndex; @@ -123,6 +150,7 @@ export function HeaderCell(props: { props.isAscOrder === undefined ? false : !props.isAscOrder; props.sortTableColumn(columnIndex, sortOrder); }; + const disableSort = editMode === false && isSortable === false; const isColumnEditable = @@ -130,14 +158,24 @@ export function HeaderCell(props: { column.columnProperties.isEditable && isColumnTypeEditable(column.columnProperties.columnType); + const toggleColumnFreeze = (value: StickyType) => { + props.handleColumnFreeze && + props.handleColumnFreeze( + props.column.id, + props.column.sticky !== value ? value : StickyType.NONE, + ); + }; + return (
-
+
@@ -147,6 +185,67 @@ export function HeaderCell(props: {
+
+ + : undefined} + onClick={() => { + props.sortTableColumn(props.columnIndex, true); + }} + text={POPOVER_ITEMS_TEXT_MAP.SORT_ASC} + /> + : undefined + } + onClick={() => { + props.sortTableColumn(props.columnIndex, false); + }} + text={POPOVER_ITEMS_TEXT_MAP.SORT_DSC} + /> + + : undefined + } + onClick={() => { + toggleColumnFreeze(StickyType.LEFT); + }} + text={POPOVER_ITEMS_TEXT_MAP.FREEZE_LEFT} + /> + : undefined + } + onClick={() => { + toggleColumnFreeze(StickyType.RIGHT); + }} + text={POPOVER_ITEMS_TEXT_MAP.FREEZE_RIGHT} + /> + + } + interactionKind="hover" + isOpen={isMenuOpen} + minimal + onInteraction={setIsMenuOpen} + placement="bottom-end" + portalClassName={`${HEADER_MENU_PORTAL_CLASS}-${props.widgetId}`} + portalContainer={document.getElementById("art-board") || undefined} + > + + +
{props.isAscOrder !== undefined ? (
{props.isAscOrder ? ( diff --git a/app/client/src/widgets/TableWidgetV2/component/cellComponents/SelectionCheckboxCell.tsx b/app/client/src/widgets/TableWidgetV2/component/cellComponents/SelectionCheckboxCell.tsx index a78a8d0e17b..7ea12e81264 100644 --- a/app/client/src/widgets/TableWidgetV2/component/cellComponents/SelectionCheckboxCell.tsx +++ b/app/client/src/widgets/TableWidgetV2/component/cellComponents/SelectionCheckboxCell.tsx @@ -14,6 +14,7 @@ export const renderBodyCheckBoxCell = ( accentColor={accentColor} borderRadius={borderRadius} className="td t--table-multiselect" + data-sticky-td="true" isCellVisible isChecked={isChecked} > @@ -35,6 +36,7 @@ export const renderHeaderCheckBoxCell = ( accentColor={accentColor} borderRadius={borderRadius} className="th header-reorder t--table-multiselect-header" + data-sticky-td="true" isChecked={!!checkState} onClick={onClick} role="columnheader" diff --git a/app/client/src/widgets/TableWidgetV2/component/header/actions/ActionItem.tsx b/app/client/src/widgets/TableWidgetV2/component/header/actions/ActionItem.tsx index 192f229229d..906c4645d52 100644 --- a/app/client/src/widgets/TableWidgetV2/component/header/actions/ActionItem.tsx +++ b/app/client/src/widgets/TableWidgetV2/component/header/actions/ActionItem.tsx @@ -40,7 +40,7 @@ export const TableIconWrapper = styled.div<{ margin-left: 4px; white-space: nowrap; color: ${(props) => props.titleColor || Colors.GRAY}; - margin-top: 3px; + margin-top: 2px; } `; diff --git a/app/client/src/widgets/TableWidgetV2/component/index.tsx b/app/client/src/widgets/TableWidgetV2/component/index.tsx index 4f643d3e9e8..037067f2cd0 100644 --- a/app/client/src/widgets/TableWidgetV2/component/index.tsx +++ b/app/client/src/widgets/TableWidgetV2/component/index.tsx @@ -5,6 +5,7 @@ import { CompactMode, ReactTableColumnProps, ReactTableFilter, + StickyType, } from "./Constants"; import { Row } from "react-table"; @@ -98,6 +99,8 @@ interface ReactTableComponentProps { allowRowSelection: boolean; allowSorting: boolean; disabledAddNewRowSave: boolean; + handleColumnFreeze?: (columnName: string, sticky?: StickyType) => void; + canFreezeColumn?: boolean; } function ReactTableComponent(props: ReactTableComponentProps) { @@ -108,6 +111,7 @@ function ReactTableComponent(props: ReactTableComponentProps) { applyFilter, borderColor, borderWidth, + canFreezeColumn, columns, columnWidthMap, compactMode, @@ -117,6 +121,7 @@ function ReactTableComponent(props: ReactTableComponentProps) { editableCell, editMode, filters, + handleColumnFreeze, handleReorderColumn, handleResizeColumn, height, @@ -156,27 +161,39 @@ function ReactTableComponent(props: ReactTableComponentProps) { width, } = props; - const { columnOrder, hiddenColumns } = useMemo(() => { - const order: string[] = []; + const { hiddenColumns } = useMemo(() => { const hidden: string[] = []; columns.forEach((item) => { if (item.isHidden) { hidden.push(item.alias); - } else { - order.push(item.alias); } }); return { - columnOrder: order, hiddenColumns: hidden, }; }, [columns]); useEffect(() => { let dragged = -1; - const headers = Array.prototype.slice.call( - document.querySelectorAll(`#table${widgetId} .draggable-header`), - ); + const leftOrder: string[] = []; + const rightOrder: string[] = []; + const middleOrder: string[] = []; + columns.forEach((item) => { + if (item.sticky === StickyType.LEFT) { + leftOrder.push(item.alias); + } else if (item.sticky === StickyType.RIGHT) { + rightOrder.push(item.alias); + } else { + middleOrder.push(item.alias); + } + }); + const headers = Array.prototype.slice + .call(document.querySelectorAll(`#table${widgetId} .draggable-header`)) + .filter((header) => { + // Filter out columns that are not sticky. + const parentDataAtrributes = header.parentElement.dataset; + return !("stickyTd" in parentDataAtrributes); + }); headers.forEach((header, i) => { header.setAttribute("draggable", true); @@ -232,7 +249,7 @@ function ReactTableComponent(props: ReactTableComponentProps) { header.parentElement.className = "th header-reorder"; if (i !== dragged && dragged !== -1) { e.preventDefault(); - const newColumnOrder = [...columnOrder]; + const newColumnOrder = [...middleOrder]; // The dragged column const movedColumnName = newColumnOrder.splice(dragged, 1); @@ -240,13 +257,23 @@ function ReactTableComponent(props: ReactTableComponentProps) { if (movedColumnName && movedColumnName.length === 1) { newColumnOrder.splice(i, 0, movedColumnName[0]); } - handleReorderColumn([...newColumnOrder, ...hiddenColumns]); + handleReorderColumn([ + ...leftOrder, + ...newColumnOrder, + ...hiddenColumns, + ...rightOrder, + ]); } else { dragged = -1; } }; }); - }, [props.columns.map((column) => column.alias).toString()]); + }, [ + props.columns.map((column) => column.alias).toString(), + props.serverSidePaginationEnabled, + props.searchKey, + props.multiRowSelection, + ]); const sortTableColumn = (columnIndex: number, asc: boolean) => { if (allowSorting) { @@ -303,6 +330,7 @@ function ReactTableComponent(props: ReactTableComponentProps) { borderRadius={props.borderRadius} borderWidth={borderWidth} boxShadow={props.boxShadow} + canFreezeColumn={canFreezeColumn} columnWidthMap={columnWidthMap} columns={columns} compactMode={compactMode} @@ -314,6 +342,7 @@ function ReactTableComponent(props: ReactTableComponentProps) { editableCell={editableCell} enableDrag={memoziedEnableDrag} filters={filters} + handleColumnFreeze={handleColumnFreeze} handleResizeColumn={handleResizeColumn} height={height} isAddRowInProgress={isAddRowInProgress} @@ -404,6 +433,7 @@ export default React.memo(ReactTableComponent, (prev, next) => { prev.allowAddNewRow === next.allowAddNewRow && prev.allowRowSelection === next.allowRowSelection && prev.allowSorting === next.allowSorting && - prev.disabledAddNewRowSave === next.disabledAddNewRowSave + prev.disabledAddNewRowSave === next.disabledAddNewRowSave && + prev.canFreezeColumn === next.canFreezeColumn ); }); diff --git a/app/client/src/widgets/TableWidgetV2/constants.ts b/app/client/src/widgets/TableWidgetV2/constants.ts index e217d0593f6..179284aecc0 100644 --- a/app/client/src/widgets/TableWidgetV2/constants.ts +++ b/app/client/src/widgets/TableWidgetV2/constants.ts @@ -65,6 +65,8 @@ export interface TableWidgetProps enableClientSideSearch?: boolean; hiddenColumns?: string[]; columnOrder?: string[]; + frozenColumnIndices: Record; + canFreezeColumn?: boolean; columnNameMap?: { [key: string]: string }; columnTypeMap?: { [key: string]: { type: string; format: string; inputFormat?: string }; @@ -114,6 +116,8 @@ export const DEFAULT_COLUMN_WIDTH = 150; export const COLUMN_MIN_WIDTH = 60; +export const TABLE_COLUMN_ORDER_KEY = "tableWidgetColumnOrder"; + export enum ColumnTypes { TEXT = "text", URL = "url", diff --git a/app/client/src/widgets/TableWidgetV2/index.ts b/app/client/src/widgets/TableWidgetV2/index.ts index b4724b21b1a..14886be74bb 100644 --- a/app/client/src/widgets/TableWidgetV2/index.ts +++ b/app/client/src/widgets/TableWidgetV2/index.ts @@ -6,6 +6,7 @@ import { } from "utils/DynamicBindingUtils"; import { WidgetProps } from "widgets/BaseWidget"; import { BlueprintOperationTypes } from "widgets/constants"; +import { StickyType } from "./component/Constants"; import { InlineEditingSaveOptions } from "./constants"; import IconSVG from "./icon.svg"; import Widget from "./widget"; @@ -19,6 +20,8 @@ export const CONFIG = { needsHeightForContent: true, defaults: { rows: 28, + canFreezeColumn: true, + columnUpdatedAt: Date.now(), columns: 34, animateLoading: true, defaultSelectedRowIndex: 0, @@ -77,6 +80,7 @@ export const CONFIG = { label: "step", computedValue: `{{Table1.processedTableData.map((currentRow, currentIndex) => ( currentRow["step"]))}}`, validation: {}, + sticky: StickyType.NONE, }, task: { index: 1, @@ -97,6 +101,7 @@ export const CONFIG = { label: "task", computedValue: `{{Table1.processedTableData.map((currentRow, currentIndex) => ( currentRow["task"]))}}`, validation: {}, + sticky: StickyType.NONE, }, status: { index: 2, @@ -117,6 +122,7 @@ export const CONFIG = { label: "status", computedValue: `{{Table1.processedTableData.map((currentRow, currentIndex) => ( currentRow["status"]))}}`, validation: {}, + sticky: StickyType.NONE, }, action: { index: 3, @@ -140,6 +146,7 @@ export const CONFIG = { "{{currentRow.step === '#1' ? showAlert('Done', 'success') : currentRow.step === '#2' ? navigateTo('https://docs.appsmith.com/core-concepts/connecting-to-data-sources/querying-a-database',undefined,'NEW_WINDOW') : navigateTo('https://docs.appsmith.com/core-concepts/displaying-data-read/display-data-tables',undefined,'NEW_WINDOW')}}", computedValue: `{{Table1.processedTableData.map((currentRow, currentIndex) => ( currentRow["action"]))}}`, validation: {}, + sticky: StickyType.NONE, }, }, tableData: [ @@ -164,8 +171,8 @@ export const CONFIG = { ], columnWidthMap: { task: 245, - step: 62, - status: 75, + step: 70, + status: 85, }, columnOrder: ["step", "task", "status", "action"], blueprint: { diff --git a/app/client/src/widgets/TableWidgetV2/widget/derived.js b/app/client/src/widgets/TableWidgetV2/widget/derived.js index cbcd0189e05..22bd1df80d0 100644 --- a/app/client/src/widgets/TableWidgetV2/widget/derived.js +++ b/app/client/src/widgets/TableWidgetV2/widget/derived.js @@ -214,7 +214,6 @@ export default { getOrderedTableColumns: (props, moment, _) => { let columns = []; let existingColumns = props.primaryColumns || {}; - /* * Assign index based on the columnOrder */ diff --git a/app/client/src/widgets/TableWidgetV2/widget/index.tsx b/app/client/src/widgets/TableWidgetV2/widget/index.tsx index d9113bf5fd6..39a4fb55a18 100644 --- a/app/client/src/widgets/TableWidgetV2/widget/index.tsx +++ b/app/client/src/widgets/TableWidgetV2/widget/index.tsx @@ -13,7 +13,10 @@ import _, { isEmpty, union, isObject, + pickBy, + findIndex, orderBy, + filter, } from "lodash"; import BaseWidget, { WidgetState } from "widgets/BaseWidget"; @@ -28,6 +31,7 @@ import { noop, retryPromise } from "utils/AppsmithUtils"; import { ReactTableFilter, AddNewRowActions, + StickyType, DEFAULT_FILTER, } from "../component/Constants"; import { @@ -46,6 +50,7 @@ import { OnColumnEventArgs, ORIGINAL_INDEX_KEY, TableWidgetProps, + TABLE_COLUMN_ORDER_KEY, TransientDataPayload, } from "../constants"; import derivedProperties from "./parseDerivedProperties"; @@ -60,6 +65,12 @@ import { isColumnTypeEditable, getColumnType, getBooleanPropertyValue, + deleteLocalTableColumnOrderByWidgetId, + fetchSticky, + getColumnOrderByWidgetIdFromLS, + generateLocalNewColumnOrderFromStickyValue, + updateAndSyncTableLocalColumnOrders, + getAllStickyColumnsCount, } from "./utilities"; import { ColumnProperties, @@ -86,6 +97,8 @@ import { CheckboxCell } from "../component/cellComponents/CheckboxCell"; import { SwitchCell } from "../component/cellComponents/SwitchCell"; import { SelectCell } from "../component/cellComponents/SelectCell"; import { CellWrapper } from "../component/TableStyledWrappers"; +import localStorage from "utils/localStorage"; +import { generateNewColumnOrderFromStickyValue } from "./utilities"; import { Stylesheet } from "entities/AppTheming"; import { DateCell } from "../component/cellComponents/DateCell"; import { MenuItem, MenuItemsSource } from "widgets/MenuButtonWidget/constants"; @@ -207,6 +220,7 @@ class TableWidgetV2 extends BaseWidget { if (isArray(orderedTableColumns)) { orderedTableColumns.forEach((column: any) => { const isHidden = !column.isVisible; + const columnData = { id: column.id, Header: column.label, @@ -218,6 +232,12 @@ class TableWidgetV2 extends BaseWidget { isHidden: false, isAscOrder: column.isAscOrder, isDerived: column.isDerived, + sticky: fetchSticky( + column.id, + this.props.primaryColumns, + this.props.renderMode, + this.props.widgetId, + ), metaProperties: { isHidden: isHidden, type: column.columnType, @@ -292,7 +312,16 @@ class TableWidgetV2 extends BaseWidget { } if (hiddenColumns.length && this.props.renderMode === RenderModes.CANVAS) { - columns = columns.concat(hiddenColumns); + // Get the index of the first column that is frozen to right + const rightFrozenColumnIdx = findIndex( + columns, + (col) => col.sticky === StickyType.RIGHT, + ); + if (rightFrozenColumnIdx !== -1) { + columns.splice(rightFrozenColumnIdx, 0, ...hiddenColumns); + } else { + columns = columns.concat(hiddenColumns); + } } return columns.filter((column: ReactTableColumnProps) => !!column.id); @@ -561,8 +590,55 @@ class TableWidgetV2 extends BaseWidget { } }; + hydrateStickyColumns = () => { + const localTableColumnOrder = getColumnOrderByWidgetIdFromLS( + this.props.widgetId, + ); + const leftLen: number = Object.keys( + pickBy(this.props.primaryColumns, (col) => col.sticky === "left"), + ).length; + + const leftOrder = [...(this.props.columnOrder || [])].slice(0, leftLen); + + const rightLen: number = Object.keys( + pickBy(this.props.primaryColumns, (col) => col.sticky !== "right"), + ).length; + + const rightOrder: string[] = [...(this.props.columnOrder || [])].slice( + rightLen, + ); + + if (localTableColumnOrder) { + const { columnOrder, columnUpdatedAt } = localTableColumnOrder; + + if (this.props.columnUpdatedAt !== columnUpdatedAt) { + // Delete and set the column orders defined by the developer + deleteLocalTableColumnOrderByWidgetId(this.props.widgetId); + + this.persistColumnOrder( + this.props.columnOrder ?? [], + leftOrder, + rightOrder, + ); + } else { + this.props.updateWidgetMetaProperty("columnOrder", columnOrder); + } + } else { + // If user deletes local storage or no column orders for the given table widget exists hydrate it with the developer changes. + this.persistColumnOrder( + this.props.columnOrder ?? [], + leftOrder, + rightOrder, + ); + } + }; + componentDidMount() { - const { tableData } = this.props; + const { canFreezeColumn, renderMode, tableData } = this.props; + + if (canFreezeColumn && renderMode === RenderModes.PAGE) { + this.hydrateStickyColumns(); + } if (_.isArray(tableData) && !!tableData.length) { const newPrimaryColumns = this.createTablePrimaryColumns(); @@ -590,6 +666,25 @@ class TableWidgetV2 extends BaseWidget { return; } + if ( + this.props.primaryColumns && + (!equal(prevProps.columnOrder, this.props.columnOrder) || + filter(prevProps.orderedTableColumns, { isVisible: false }).length !== + filter(this.props.orderedTableColumns, { isVisible: false }).length || + getAllStickyColumnsCount(prevProps.orderedTableColumns) !== + getAllStickyColumnsCount(this.props.orderedTableColumns)) + ) { + if (this.props.renderMode === RenderModes.CANVAS) { + super.batchUpdateWidgetProperty( + { + modify: { + columnUpdatedAt: Date.now(), + }, + }, + false, + ); + } + } // Check if tableData is modifed const isTableDataModified = !equal( this.props.tableData, @@ -868,6 +963,7 @@ class TableWidgetV2 extends BaseWidget { borderRadius={this.props.borderRadius} borderWidth={this.props.borderWidth} boxShadow={this.props.boxShadow} + canFreezeColumn={this.props.canFreezeColumn} columnWidthMap={this.props.columnWidthMap} columns={tableColumns} compactMode={this.props.compactMode || CompactModeTypes.DEFAULT} @@ -877,6 +973,7 @@ class TableWidgetV2 extends BaseWidget { editMode={this.props.renderMode === RenderModes.CANVAS} editableCell={this.props.editableCell} filters={this.props.filters} + handleColumnFreeze={this.handleColumnFreeze} handleReorderColumn={this.handleReorderColumn} handleResizeColumn={this.handleResizeColumn} height={componentHeight} @@ -928,12 +1025,143 @@ class TableWidgetV2 extends BaseWidget { ); } + /** + * Function to update or add the tableWidgetColumnOrder key in the local storage + * tableWidgetColumnOrder = { + * : { + * columnOrder: [], + * leftOrder: [], + * rightOrder: [], + * } + * } + */ + persistColumnOrder = ( + newColumnOrder: string[], + leftOrder: string[], + rightOrder: string[], + ) => { + const widgetId = this.props.widgetId; + const localTableWidgetColumnOrder = localStorage.getItem( + TABLE_COLUMN_ORDER_KEY, + ); + let newTableColumnOrder; + + if (localTableWidgetColumnOrder) { + try { + let parsedTableWidgetColumnOrder = JSON.parse( + localTableWidgetColumnOrder, + ); + + let columnOrder; + + if (newColumnOrder) { + columnOrder = newColumnOrder; + } else if (parsedTableWidgetColumnOrder[widgetId]) { + columnOrder = parsedTableWidgetColumnOrder[widgetId]; + } else { + columnOrder = this.props.columnOrder; + } + + parsedTableWidgetColumnOrder = { + ...parsedTableWidgetColumnOrder, + [widgetId]: { + columnOrder, + columnUpdatedAt: this.props.columnUpdatedAt, + leftOrder, + rightOrder, + }, + }; + + newTableColumnOrder = parsedTableWidgetColumnOrder; + } catch (e) { + log.debug("Unable to parse local column order:", { e }); + } + } else { + const tableWidgetColumnOrder = { + [widgetId]: { + columnOrder: newColumnOrder, + columnUpdatedAt: this.props.columnUpdatedAt, + leftOrder, + rightOrder, + }, + }; + newTableColumnOrder = tableWidgetColumnOrder; + } + localStorage.setItem( + TABLE_COLUMN_ORDER_KEY, + JSON.stringify(newTableColumnOrder), + ); + }; + + handleColumnFreeze = (columnName: string, sticky?: StickyType) => { + if (this.props.columnOrder) { + let newColumnOrder; + const localTableColumnOrder = getColumnOrderByWidgetIdFromLS( + this.props.widgetId, + ); + if (this.props.renderMode === RenderModes.CANVAS) { + newColumnOrder = generateNewColumnOrderFromStickyValue( + this.props.primaryColumns, + this.props.columnOrder, + columnName, + sticky, + ); + + // Updating these properties in batch so that undo/redo gets executed in a combined way. + super.batchUpdateWidgetProperty( + { + modify: { + [`primaryColumns.${columnName}.sticky`]: sticky, + columnOrder: newColumnOrder, + }, + }, + true, + ); + } else if ( + localTableColumnOrder && + this.props.renderMode === RenderModes.PAGE + ) { + const { leftOrder, rightOrder } = localTableColumnOrder; + newColumnOrder = generateLocalNewColumnOrderFromStickyValue( + localTableColumnOrder.columnOrder, + columnName, + sticky, + leftOrder, + rightOrder, + ); + const updatedOrders = updateAndSyncTableLocalColumnOrders( + columnName, + leftOrder, + rightOrder, + sticky, + ); + this.persistColumnOrder( + newColumnOrder, + updatedOrders.leftOrder, + updatedOrders.rightOrder, + ); + this.props.updateWidgetMetaProperty("columnOrder", newColumnOrder); + } + } + }; + handleReorderColumn = (columnOrder: string[]) => { columnOrder = columnOrder.map((alias) => this.getColumnIdByAlias(alias)); if (this.props.renderMode === RenderModes.CANVAS) { super.updateWidgetProperty("columnOrder", columnOrder); } else { + if (this.props.canFreezeColumn) { + const localTableColumnOrder = getColumnOrderByWidgetIdFromLS( + this.props.widgetId, + ); + if (localTableColumnOrder) { + const { leftOrder, rightOrder } = localTableColumnOrder; + this.persistColumnOrder(columnOrder, leftOrder, rightOrder); + } else { + this.persistColumnOrder(columnOrder, [], []); + } + } this.props.updateWidgetMetaProperty("columnOrder", columnOrder); } }; diff --git a/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/PanelConfig/General.ts b/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/PanelConfig/General.ts index 2287748c721..cba532289b6 100644 --- a/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/PanelConfig/General.ts +++ b/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/PanelConfig/General.ts @@ -5,11 +5,13 @@ import { getBasePropertyPath, hideByColumnType, updateColumnLevelEditability, + updateColumnOrderWhenFrozen, updateInlineEditingOptionDropdownVisibilityHook, } from "../../propertyUtils"; import { isColumnTypeEditable } from "../../utilities"; import { composePropertyUpdateHook } from "widgets/WidgetUtils"; import { ButtonVariantTypes } from "components/constants"; +import { StickyType } from "widgets/TableWidgetV2/component/Constants"; export default { sectionName: "General", @@ -136,6 +138,33 @@ export default { return !isColumnTypeEditable(columnType) || isDerived; }, }, + { + propertyName: "sticky", + helpText: + "Choose column that needs to be frozen left or right of the table", + controlType: "ICON_TABS", + defaultValue: StickyType.NONE, + label: "Column Freeze", + fullWidth: true, + isBindProperty: true, + isTriggerProperty: false, + dependencies: ["primaryColumns", "columnOrder"], + options: [ + { + icon: "VERTICAL_LEFT", + value: StickyType.LEFT, + }, + { + icon: "COLUMN_UNFREEZE", + value: StickyType.NONE, + }, + { + icon: "VERTICAL_RIGHT", + value: StickyType.RIGHT, + }, + ], + updateHook: updateColumnOrderWhenFrozen, + }, ], }; diff --git a/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/contentConfig.ts b/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/contentConfig.ts index 937434f68c1..fd590535850 100644 --- a/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/contentConfig.ts +++ b/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/contentConfig.ts @@ -54,6 +54,7 @@ export default [ updateCustomColumnAliasOnLabelChange, ]), dependencies: [ + "primaryColumns", "columnOrder", "childStylesheet", "inlineEditingSaveOption", @@ -464,6 +465,17 @@ export default [ isTriggerProperty: false, validation: { type: ValidationTypes.BOOLEAN }, }, + { + propertyName: "canFreezeColumn", + helpText: "Controls whether the user can freeze columns", + label: "Allow Column Freeze", + controlType: "SWITCH", + defaultValue: true, + isJSConvertible: true, + isBindProperty: true, + isTriggerProperty: false, + validation: { type: ValidationTypes.BOOLEAN }, + }, { propertyName: "delimiter", label: "CSV Separator", diff --git a/app/client/src/widgets/TableWidgetV2/widget/propertyUtils.test.ts b/app/client/src/widgets/TableWidgetV2/widget/propertyUtils.test.ts index 66e2f7cbf8d..83737492da7 100644 --- a/app/client/src/widgets/TableWidgetV2/widget/propertyUtils.test.ts +++ b/app/client/src/widgets/TableWidgetV2/widget/propertyUtils.test.ts @@ -12,6 +12,7 @@ import { } from "./propertyUtils"; import _ from "lodash"; import { ColumnTypes, TableWidgetProps } from "../constants"; +import { StickyType } from "../component/Constants"; describe("PropertyUtils - ", () => { it("totalRecordsCountValidation - should test with all possible values", () => { @@ -237,10 +238,22 @@ describe("PropertyUtils - ", () => { }); it("updateColumnOrderHook - should test with all possible values", () => { + const defaultStickyValuesForPrimaryCols = { + column1: { + sticky: StickyType.NONE, + }, + column2: { + sticky: StickyType.NONE, + }, + column3: { + sticky: StickyType.NONE, + }, + }; expect( updateColumnOrderHook( ({ - columnOrder: ["column1", "columns2"], + columnOrder: ["column1", "column2"], + primaryColumns: defaultStickyValuesForPrimaryCols, } as any) as TableWidgetProps, "primaryColumns.column3", { @@ -250,7 +263,7 @@ describe("PropertyUtils - ", () => { ).toEqual([ { propertyPath: "columnOrder", - propertyValue: ["column1", "columns2", "column3"], + propertyValue: ["column1", "column2", "column3"], }, { propertyPath: "primaryColumns.column3", @@ -264,7 +277,7 @@ describe("PropertyUtils - ", () => { expect( updateColumnOrderHook( ({ - columnOrder: ["column1", "columns2"], + columnOrder: ["column1", "column2"], } as any) as TableWidgetProps, "", { @@ -282,7 +295,7 @@ describe("PropertyUtils - ", () => { expect( updateColumnOrderHook( ({ - columnOrder: ["column1", "columns2"], + columnOrder: ["column1", "column2"], } as any) as TableWidgetProps, "primaryColumns.column3.iconAlignment", { diff --git a/app/client/src/widgets/TableWidgetV2/widget/propertyUtils.ts b/app/client/src/widgets/TableWidgetV2/widget/propertyUtils.ts index 37a575155b0..82f03c16d60 100644 --- a/app/client/src/widgets/TableWidgetV2/widget/propertyUtils.ts +++ b/app/client/src/widgets/TableWidgetV2/widget/propertyUtils.ts @@ -5,13 +5,16 @@ import { InlineEditingSaveOptions, TableWidgetProps, } from "../constants"; -import _, { get, isBoolean } from "lodash"; +import _, { findIndex, get, isBoolean } from "lodash"; import { Colors } from "constants/Colors"; import { combineDynamicBindings, getDynamicBindings, } from "utils/DynamicBindingUtils"; -import { createEditActionColumn } from "./utilities"; +import { + createEditActionColumn, + generateNewColumnOrderFromStickyValue, +} from "./utilities"; import { PropertyHookUpdates } from "constants/PropertyControlConstants"; import { MenuItemsSource } from "widgets/MenuButtonWidget/constants"; @@ -188,8 +191,19 @@ export const updateColumnOrderHook = ( propertyValue: any; }> = []; if (props && propertyValue && /^primaryColumns\.\w+$/.test(propertyPath)) { - const oldColumnOrder = props.columnOrder || []; - const newColumnOrder = [...oldColumnOrder, propertyValue.id]; + const newColumnOrder = [...(props.columnOrder || [])]; + + const rightColumnIndex = findIndex( + newColumnOrder, + (colName: string) => props.primaryColumns[colName].sticky === "right", + ); + + if (rightColumnIndex !== -1) { + newColumnOrder.splice(rightColumnIndex, 0, propertyValue.id); + } else { + newColumnOrder.splice(newColumnOrder.length, 0, propertyValue.id); + } + propertiesToUpdate.push({ propertyPath: "columnOrder", propertyValue: newColumnOrder, @@ -300,6 +314,31 @@ export const updateInlineEditingOptionDropdownVisibilityHook = ( }; const CELL_EDITABLITY_PATH_REGEX = /^primaryColumns\.(\w+)\.isCellEditable$/; + +/** + * Hook that updates frozen column's old indices and also adds columns to the frozen positions. + */ +export const updateColumnOrderWhenFrozen = ( + props: TableWidgetProps, + propertyPath: string, + propertyValue: string, +) => { + if (props && props.columnOrder) { + const newColumnOrder = generateNewColumnOrderFromStickyValue( + props.primaryColumns, + props.columnOrder, + propertyPath.split(".")[1], + propertyValue, + ); + + return [ + { + propertyPath: "columnOrder", + propertyValue: newColumnOrder, + }, + ]; + } +}; /* * Hook that updates column level editability when cell level editability is * updaed. diff --git a/app/client/src/widgets/TableWidgetV2/widget/utilities.test.ts b/app/client/src/widgets/TableWidgetV2/widget/utilities.test.ts index 4fa85eb2e59..79e11a99b5f 100644 --- a/app/client/src/widgets/TableWidgetV2/widget/utilities.test.ts +++ b/app/client/src/widgets/TableWidgetV2/widget/utilities.test.ts @@ -1,7 +1,13 @@ -import { ColumnProperties, TableStyles } from "../component/Constants"; +import { klona } from "klona/lite"; +import { + ColumnProperties, + StickyType, + TableStyles, +} from "../component/Constants"; import { ColumnTypes } from "../constants"; import { escapeString, + generateNewColumnOrderFromStickyValue, getAllTableColumnKeys, getArrayPropertyValue, getColumnType, @@ -2339,3 +2345,202 @@ describe("getArrayPropertyValue", () => { ]); }); }); + +describe("generateNewColumnOrderFromStickyValue", () => { + const baseTableConfig: { + primaryColumns: Record>; + columnOrder: string[]; + columnName: string; + sticky?: string; + } = { + primaryColumns: { + step: { + sticky: StickyType.NONE, + }, + task: { + sticky: StickyType.NONE, + }, + status: { + sticky: StickyType.NONE, + }, + action: { + sticky: StickyType.NONE, + }, + }, + columnOrder: ["step", "task", "status", "action"], + columnName: "", + sticky: "", + }; + + let tableConfig: { + primaryColumns: any; + columnOrder: any; + columnName?: string; + sticky?: string | undefined; + }; + let newColumnOrder; + + const resetValues = (config: { + primaryColumns: any; + columnOrder: any; + columnName?: string; + sticky?: string | undefined; + }) => { + config.primaryColumns = { + step: { + sticky: "", + id: "step", + }, + task: { + sticky: "", + id: "task", + }, + status: { + sticky: "", + id: "status", + }, + action: { + sticky: "", + id: "action", + }, + }; + config.columnOrder = ["step", "task", "status", "action"]; + config.columnName = ""; + config.sticky = ""; + return config; + }; + + test("Column order should remain same when leftmost or the right-most columns are frozen", () => { + tableConfig = { ...baseTableConfig }; + newColumnOrder = generateNewColumnOrderFromStickyValue( + tableConfig.primaryColumns as any, + tableConfig.columnOrder, + "step", + "left", + ); + tableConfig.primaryColumns.step.sticky = "left"; + expect(newColumnOrder).toEqual(["step", "task", "status", "action"]); + + newColumnOrder = generateNewColumnOrderFromStickyValue( + tableConfig.primaryColumns as any, + tableConfig.columnOrder, + "action", + "right", + ); + tableConfig.primaryColumns.action.sticky = "right"; + expect(newColumnOrder).toEqual(["step", "task", "status", "action"]); + }); + + test("Column that is frozen to left should appear first in the column order", () => { + tableConfig = resetValues(baseTableConfig); + + newColumnOrder = generateNewColumnOrderFromStickyValue( + tableConfig.primaryColumns as any, + tableConfig.columnOrder, + "action", + "left", + ); + expect(newColumnOrder).toEqual(["action", "step", "task", "status"]); + }); + + test("Column that is frozen to right should appear last in the column order", () => { + tableConfig = resetValues(baseTableConfig); + + newColumnOrder = generateNewColumnOrderFromStickyValue( + tableConfig.primaryColumns as any, + tableConfig.columnOrder, + "step", + "right", + ); + expect(newColumnOrder).toEqual(["task", "status", "action", "step"]); + }); + + test("Column that is frozen to left should appear after the last left frozen column in the column order", () => { + tableConfig = resetValues(baseTableConfig); + // Consisder step to be already frozen to left. + tableConfig.primaryColumns.step.sticky = "left"; + + newColumnOrder = generateNewColumnOrderFromStickyValue( + tableConfig.primaryColumns as any, + tableConfig.columnOrder, + "action", + "left", + ); + expect(newColumnOrder).toEqual(["step", "action", "task", "status"]); + }); + + test("Column that is frozen to right should appear before the first right frozen column in the column order", () => { + tableConfig = resetValues(baseTableConfig); + // Consisder action to be already frozen to right. + tableConfig.primaryColumns.action.sticky = "right"; + + newColumnOrder = generateNewColumnOrderFromStickyValue( + tableConfig.primaryColumns as any, + tableConfig.columnOrder, + "step", + "right", + ); + expect(newColumnOrder).toEqual(["task", "status", "step", "action"]); + }); + + test("When leftmost and rightmost columns are only frozen, then on unfreeze left column should be first and right most column should at last", () => { + tableConfig = resetValues(baseTableConfig); + // Consisder step to be left frozen and action to be frozen to right. + tableConfig.primaryColumns.step.sticky = "left"; + tableConfig.primaryColumns.action.sticky = "right"; + + newColumnOrder = generateNewColumnOrderFromStickyValue( + tableConfig.primaryColumns as any, + tableConfig.columnOrder, + "step", + "", + ); + tableConfig.primaryColumns.step.sticky = ""; + + expect(newColumnOrder).toEqual(["step", "task", "status", "action"]); + newColumnOrder = generateNewColumnOrderFromStickyValue( + tableConfig.primaryColumns as any, + tableConfig.columnOrder, + "action", + "", + ); + + expect(newColumnOrder).toEqual(["step", "task", "status", "action"]); + }); + + test("Unfreezing first column from multiple left frozen columns, should place the unfrozen column after the last frozen column", () => { + tableConfig = resetValues(baseTableConfig); + // Consisder step to be left frozen and action to be frozen to right. + tableConfig.primaryColumns.step.sticky = "left"; + tableConfig.primaryColumns.action.sticky = "left"; + tableConfig.primaryColumns.task.sticky = "left"; + + newColumnOrder = generateNewColumnOrderFromStickyValue( + tableConfig.primaryColumns as any, + ["step", "action", "task", "status"], + "step", + "", + ); + tableConfig.primaryColumns.step.sticky = ""; + + expect(newColumnOrder).toEqual(["action", "task", "step", "status"]); + }); + + test("Unfreezing last column from multiple right frozen columns, should place the unfrozen column before the first frozen column", () => { + tableConfig = resetValues(baseTableConfig); + // Consisder step to be left frozen and action to be frozen to right. + tableConfig.primaryColumns.step.sticky = "right"; + tableConfig.primaryColumns.action.sticky = "right"; + tableConfig.primaryColumns.task.sticky = "right"; + + newColumnOrder = generateNewColumnOrderFromStickyValue( + tableConfig.primaryColumns as any, + ["status", "step", "action", "task"], + "task", + "", + ); + tableConfig.primaryColumns.step.sticky = ""; + + expect(newColumnOrder).toEqual(["status", "task", "step", "action"]); + }); +}); diff --git a/app/client/src/widgets/TableWidgetV2/widget/utilities.ts b/app/client/src/widgets/TableWidgetV2/widget/utilities.ts index b3ccd55af64..69b0e8b234c 100644 --- a/app/client/src/widgets/TableWidgetV2/widget/utilities.ts +++ b/app/client/src/widgets/TableWidgetV2/widget/utilities.ts @@ -1,11 +1,17 @@ import { Colors } from "constants/Colors"; -import { FontStyleTypes } from "constants/WidgetConstants"; -import _, { isBoolean, isObject, uniq, without } from "lodash"; +import { + FontStyleTypes, + RenderMode, + RenderModes, +} from "constants/WidgetConstants"; +import _, { filter, isBoolean, isObject, uniq, without } from "lodash"; import tinycolor from "tinycolor2"; import { CellAlignmentTypes, CellLayoutProperties, ColumnProperties, + StickyType, + TableColumnProps, TableStyles, VerticalAlignmentTypes, } from "../component/Constants"; @@ -13,6 +19,7 @@ import { ColumnTypes, DEFAULT_BUTTON_COLOR, DEFAULT_COLUMN_WIDTH, + TABLE_COLUMN_ORDER_KEY, ORIGINAL_INDEX_KEY, } from "../constants"; import { SelectColumnOptionsValidations } from "./propertyUtils"; @@ -28,6 +35,7 @@ import { dateFormatOptions } from "widgets/constants"; import moment from "moment"; import { Stylesheet } from "entities/AppTheming"; import { getKeysFromSourceDataForEventAutocomplete } from "widgets/MenuButtonWidget/widget/helper"; +import log from "loglevel"; type TableData = Array>; @@ -206,6 +214,7 @@ export function getDefaultColumnProperties( : `{{${widgetName}.processedTableData.map((currentRow, currentIndex) => ( currentRow["${escapeString( id, )}"]))}}`, + sticky: StickyType.NONE, validation: {}, }; @@ -623,6 +632,7 @@ export const createColumn = (props: TableWidgetProps, baseName: string) => { .map((column) => column.index) .sort() .pop(); + const nextIndex = lastItemIndex ? lastItemIndex + 1 : columnIds.length; return { @@ -666,12 +676,17 @@ export const createEditActionColumn = (props: TableWidgetProps) => { label: "Save / Discard", discardButtonVariant: ButtonVariantTypes.TERTIARY, discardButtonColor: Colors.DANGER_SOLID, + sticky: StickyType.RIGHT, }; - const columnOrder = props.columnOrder || []; + const columnOrder = [...(props.columnOrder || [])]; const editActionDynamicProperties = getEditActionColumnDynamicProperties( props.widgetName, ); + const rightColumnIndex = columnOrder + .map((column) => props.primaryColumns[column]) + .filter((col) => col.sticky !== StickyType.RIGHT).length; + columnOrder.splice(rightColumnIndex, 0, column.id); return [ { propertyPath: `primaryColumns.${column.id}`, @@ -682,7 +697,7 @@ export const createEditActionColumn = (props: TableWidgetProps) => { }, { propertyPath: `columnOrder`, - propertyValue: [...columnOrder, column.id], + propertyValue: columnOrder, }, ...Object.entries(editActionDynamicProperties).map(([key, value]) => ({ propertyPath: `primaryColumns.${column.id}.${key}`, @@ -735,6 +750,88 @@ export const getColumnType = ( } }; +export const generateLocalNewColumnOrderFromStickyValue = ( + columnOrder: string[], + columnName: string, + sticky?: string, + leftOrder?: string[], + rightOrder?: string[], +) => { + let newColumnOrder = [...columnOrder]; + newColumnOrder = without(newColumnOrder, columnName); + + let columnIndex = -1; + if (sticky === StickyType.LEFT && leftOrder) { + columnIndex = leftOrder.length; + } else if (sticky === StickyType.RIGHT && rightOrder) { + columnIndex = + rightOrder.length !== 0 + ? columnOrder.indexOf(rightOrder[0]) - 1 + : columnOrder.length - 1; + } else { + if (leftOrder?.includes(columnName)) { + columnIndex = leftOrder.length - 1; + } else if (rightOrder?.includes(columnName)) { + columnIndex = + rightOrder.length !== 0 + ? columnOrder.indexOf(rightOrder[0]) + : columnOrder.length - 1; + } + } + newColumnOrder.splice(columnIndex, 0, columnName); + return newColumnOrder; +}; +/** + * Function to get new column order when there is a change in column's sticky value. + */ +export const generateNewColumnOrderFromStickyValue = ( + primaryColumns: Record, + columnOrder: string[], + columnName: string, + sticky?: string, +) => { + let newColumnOrder = [...columnOrder]; + newColumnOrder = without(newColumnOrder, columnName); + + let columnIndex; + if (sticky === StickyType.LEFT) { + columnIndex = columnOrder + .map((column) => primaryColumns[column]) + .filter((column) => column.sticky === StickyType.LEFT).length; + } else if (sticky === StickyType.RIGHT) { + columnIndex = + columnOrder + .map((column) => primaryColumns[column]) + .filter((column) => column.sticky !== StickyType.RIGHT).length - 1; + } else { + /** + * This block will manage the column order when column is unfrozen. + * Unfreezing can happen in CANVAS or PAGE mode. + * Logic: + * --> If the column is unfrozen when its on the left, then it should be unfrozen after the last left frozen column. + * --> If the column is unfrozen when its on the right, then it should be unfrozen before the first right frozen column. + */ + columnIndex = -1; + + const staleStickyValue = primaryColumns[columnName].sticky; + + if (staleStickyValue === StickyType.LEFT) { + columnIndex = columnOrder + .map((column) => primaryColumns[column]) + .filter( + (column) => + column.sticky === StickyType.LEFT && column.id !== columnName, + ).length; + } else if (staleStickyValue === StickyType.RIGHT) { + columnIndex = columnOrder + .map((column) => primaryColumns[column]) + .filter((column) => column.sticky !== StickyType.RIGHT).length; + } + } + newColumnOrder.splice(columnIndex, 0, columnName); + return newColumnOrder; +}; + export const getSourceDataAndCaluclateKeysForEventAutoComplete = ( props: TableWidgetProps, ): unknown => { @@ -753,3 +850,107 @@ export const getSourceDataAndCaluclateKeysForEventAutoComplete = ( return {}; } }; + +export const deleteLocalTableColumnOrderByWidgetId = (widgetId: string) => { + try { + const localData = localStorage.getItem(TABLE_COLUMN_ORDER_KEY); + if (localData) { + const localColumnOrder = JSON.parse(localData); + delete localColumnOrder[widgetId]; + localStorage.setItem( + TABLE_COLUMN_ORDER_KEY, + JSON.stringify(localColumnOrder), + ); + } + } catch (e) { + log.debug("Error in reading local data", e); + } +}; + +export const fetchSticky = ( + columnId: string, + primaryColumns: Record, + renderMode: RenderMode, + widgetId?: string, +): StickyType | undefined => { + if (renderMode === RenderModes.PAGE && widgetId) { + const localTableColumnOrder = getColumnOrderByWidgetIdFromLS(widgetId); + if (localTableColumnOrder) { + const { leftOrder, rightOrder } = localTableColumnOrder; + if (leftOrder.indexOf(columnId) > -1) { + return StickyType.LEFT; + } else if (rightOrder.indexOf(columnId) > -1) { + return StickyType.RIGHT; + } else { + return StickyType.NONE; + } + } else { + return get(primaryColumns, `${columnId}`).sticky; + } + } + if (renderMode === RenderModes.CANVAS) { + return get(primaryColumns, `${columnId}`).sticky; + } +}; + +export const updateAndSyncTableLocalColumnOrders = ( + columnName: string, + leftOrder: string[], + rightOrder: string[], + sticky?: StickyType, +) => { + if (sticky === StickyType.LEFT) { + leftOrder.push(columnName); + if (rightOrder) { + rightOrder = without(rightOrder, columnName); + } + } else if (sticky === StickyType.RIGHT) { + rightOrder.unshift(columnName); + // When column is frozen to right from left. Remove the column name from leftOrder + if (leftOrder) { + leftOrder = without(leftOrder, columnName); + } + } else { + // remove column from both orders: + leftOrder = without(leftOrder, columnName); + rightOrder = without(rightOrder, columnName); + } + return { leftOrder, rightOrder }; +}; + +export const getColumnOrderByWidgetIdFromLS = (widgetId: string) => { + const localTableWidgetColumnOrder = localStorage.getItem( + TABLE_COLUMN_ORDER_KEY, + ); + if (localTableWidgetColumnOrder) { + try { + const parsedTableWidgetColumnOrder = JSON.parse( + localTableWidgetColumnOrder, + ); + + if (parsedTableWidgetColumnOrder[widgetId]) { + const { + columnOrder, + columnUpdatedAt, + leftOrder, + rightOrder, + } = parsedTableWidgetColumnOrder[widgetId]; + return { + columnOrder, + columnUpdatedAt, + leftOrder, + rightOrder, + }; + } + } catch (e) { + log.debug("Unable to parse local column order:", { e }); + } + } +}; + +export const getAllStickyColumnsCount = (columns: TableColumnProps[]) => { + return ( + filter(columns, { sticky: StickyType.LEFT }).length + + filter(columns, { sticky: StickyType.RIGHT }).length + ); +}; diff --git a/app/client/yarn.lock b/app/client/yarn.lock index a787f9c8360..fb3a5d40aa8 100644 --- a/app/client/yarn.lock +++ b/app/client/yarn.lock @@ -2311,6 +2311,11 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" +"@juggle/resize-observer@^3.3.1": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60" + integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA== + "@leichtgewicht/ip-codec@^2.0.1": version "2.0.4" resolved "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz" @@ -5042,6 +5047,11 @@ camelize@^1.0.0: resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.1.tgz#89b7e16884056331a35d6b5ad064332c91daa6c3" integrity sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ== +can-use-dom@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/can-use-dom/-/can-use-dom-0.1.0.tgz#22cc4a34a0abc43950f42c6411024a3f6366b45a" + integrity sha512-ceOhN1DL7Y4O6M0j9ICgmTYziV89WMd96SvSl0REd8PMgrY0B/WBOPoed5S1KUmJqXgUXh8gzSe6E3ae27upsQ== + caniuse-api@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz" @@ -12908,9 +12918,15 @@ react-syntax-highlighter@^15.5.0: prismjs "^1.27.0" refractor "^3.6.0" +react-table-sticky@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/react-table-sticky/-/react-table-sticky-1.1.3.tgz#af27c0afb2c4a32c292d486b21d9a896d354ba70" + integrity sha512-9hyjbveY1aDyo9wkyMOsmKIpQdFUaw2yG6/f8c5SPE4pOTuKm7L2zLnDa+AxpG+3315USulViaO4XSOkX1KdVA== + react-table@^7.0.0: - version "7.6.0" - resolved "https://registry.npmjs.org/react-table/-/react-table-7.6.0.tgz" + version "7.8.0" + resolved "https://registry.yarnpkg.com/react-table/-/react-table-7.8.0.tgz#07858c01c1718c09f7f1aed7034fcfd7bda907d2" + integrity sha512-hNaz4ygkZO4bESeFfnfOft73iBUj8K5oKi1EcSHPAibEydfsX2MyU6Z8KCr3mv3C9Kqqh71U+DhZkFvibbnPbA== react-tabs@^3.0.0: version "3.1.1" @@ -12993,8 +13009,9 @@ react-webcam@^7.0.1: integrity sha512-8E/Eb/7ksKwn5QdLn67tOR7+TdP9BZdu6E5/DSt20v8yfW/s0VGBigE6VA7R4278mBuBUowovAB3DkCfVmSPvA== react-window@^1.8.6: - version "1.8.6" - resolved "https://registry.npmjs.org/react-window/-/react-window-1.8.6.tgz" + version "1.8.8" + resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.8.tgz#1b52919f009ddf91970cbdb2050a6c7be44df243" + integrity sha512-D4IiBeRtGXziZ1n0XklnFGu7h9gU684zepqyKzgPNzrsrk7xOCxni+TCckjg2Nr/DiaEEGVVmnhYSlT2rB47dQ== dependencies: "@babel/runtime" "^7.0.0" memoize-one ">=3.1.1 <6" @@ -13805,6 +13822,26 @@ signal-exit@^3.0.3, signal-exit@^3.0.7: resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== +simplebar-react@^2.4.3: + version "2.4.3" + resolved "https://registry.yarnpkg.com/simplebar-react/-/simplebar-react-2.4.3.tgz#79c830711c23a5ae457ef73420f5752d4a1b3133" + integrity sha512-Ep8gqAUZAS5IC2lT5RE4t1ZFUIVACqbrSRQvFV9a6NbVUzXzOMnc4P82Hl8Ak77AnPQvmgUwZS7aUKLyBoMAcg== + dependencies: + prop-types "^15.6.1" + simplebar "^5.3.9" + +simplebar@^5.3.9: + version "5.3.9" + resolved "https://registry.yarnpkg.com/simplebar/-/simplebar-5.3.9.tgz#168ea0eb6d52f29f03960e40d9b69a1b28cf6318" + integrity sha512-1vIIpjDvY9sVH14e0LGeiCiTFU3ILqAghzO6OI9axeG+mvU/vMSrvXeAXkBolqFFz3XYaY8n5ahH9MeP3sp2Ag== + dependencies: + "@juggle/resize-observer" "^3.3.1" + can-use-dom "^0.1.0" + core-js "^3.0.1" + lodash.debounce "^4.0.8" + lodash.memoize "^4.1.2" + lodash.throttle "^4.1.1" + sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz"