diff --git a/app/client/cypress/e2e/Regression/ClientSide/Widgets/TableV2/columnTypes/HTMLCell_spec.ts b/app/client/cypress/e2e/Regression/ClientSide/Widgets/TableV2/columnTypes/HTMLCell_spec.ts new file mode 100644 index 000000000000..cac2e52a2b3a --- /dev/null +++ b/app/client/cypress/e2e/Regression/ClientSide/Widgets/TableV2/columnTypes/HTMLCell_spec.ts @@ -0,0 +1,80 @@ +import { htmlTableData } from "../../../../../../fixtures/htmlCellInTableWidgetV2"; +import { featureFlagIntercept } from "../../../../../../support/Objects/FeatureFlags"; +import { + agHelper, + entityExplorer, + propPane, + table, +} from "../../../../../../support/Objects/ObjectsCore"; + +describe( + "Table Filter for HTML Cell", + { tags: ["@tag.Widget", "@tag.Table"] }, + function () { + before(() => { + featureFlagIntercept({ + release_table_html_column_type_enabled: true, + }); + entityExplorer.DragDropWidgetNVerify("tablewidgetv2", 650, 250); + propPane.EnterJSContext("Table data", JSON.stringify(htmlTableData)); + }); + + it("1. Ensures HTML column type is available", function () { + table.ReadTableRowColumnData(1, 3, "v2").then(($cellData) => { + expect($cellData).to.include("Active"); + }); + }); + + it("2. Verify HTML columns are searchable", function () { + table.ReadTableRowColumnData(1, 3, "v2").then(($cellData) => { + expect($cellData).to.include("Active"); + table.SearchTable($cellData); + table.ReadTableRowColumnData(0, 3, "v2").then((afterSearch) => { + expect(afterSearch).to.eq($cellData); + }); + }); + table.RemoveSearchTextNVerify("1", "v2"); + }); + + it("3. Verify Table Filter for HTML columns", function () { + propPane.ExpandIfCollapsedSection("search\\&filters"); + agHelper.AssertExistingToggleState("Allow filtering", "false"); + propPane.TogglePropertyState("Allow filtering", "On"); + + table.OpenNFilterTable("status", "contains", "Active"); + table.ReadTableRowColumnData(0, 3, "v2").then(($cellData) => { + expect($cellData).to.include("Active"); + }); + table.RemoveFilterNVerify("1", true, true, 0, "v2"); + + table.OpenNFilterTable("status", "contains", "Suspended"); + table.ReadTableRowColumnData(0, 3, "v2").then(($cellData) => { + expect($cellData).to.include("Suspended"); + }); + table.RemoveFilterNVerify("1", true, true, 0, "v2"); + + table.OpenNFilterTable("status", "empty", ""); + table.ReadTableRowColumnData(0, 0, "v2").then(($cellData) => { + expect($cellData).to.include("1"); + }); + table.RemoveFilterNVerify("1", true, true, 0, "v2"); + + table.OpenNFilterTable("status", "not empty", ""); + table.ReadTableRowColumnData(0, 0, "v2").then(($cellData) => { + expect($cellData).to.include("2"); + }); + table.RemoveFilterNVerify("1", true, true, 0, "v2"); + }); + + it("4. Verify Table sorting for HTML columns", function () { + table.SortColumn("status", "asc"); + table.ReadTableRowColumnData(0, 3, "v2").then(($cellData) => { + expect($cellData).to.include("Active"); + }); + table.SortColumn("status", "desc"); + table.ReadTableRowColumnData(0, 3, "v2").then(($cellData) => { + expect($cellData).to.include("Suspended"); + }); + }); + }, +); diff --git a/app/client/cypress/fixtures/htmlCellInTableWidgetV2.ts b/app/client/cypress/fixtures/htmlCellInTableWidgetV2.ts new file mode 100644 index 000000000000..9cd595e5393a --- /dev/null +++ b/app/client/cypress/fixtures/htmlCellInTableWidgetV2.ts @@ -0,0 +1,56 @@ +export const htmlTableData = [ + { + id: 1, + name: "John Smith", + email: "john.smith@email.com", + role: undefined, + status: null, + applicationDate: "2024-02-15", + lastUpdated: "2024-03-20", + department: "Engineering", + }, + { + id: 2, + name: "Emma Wilson", + email: "emma.w@email.com", + role: "Designer", + status: + "Active", + applicationDate: "2024-03-01", + lastUpdated: "2024-03-19", + department: "Design", + }, + { + id: 3, + name: "Michael Brown", + email: "m.brown@email.com", + role: "Manager", + status: + "Suspended", + applicationDate: "2024-01-10", + lastUpdated: "2024-03-18", + department: "Operations", + }, + { + id: 4, + name: "Sarah Davis", + email: "sarah.d@email.com", + role: "Developer", + status: + "Active", + applicationDate: "2024-02-20", + lastUpdated: "2024-03-17", + department: "Engineering", + }, + { + id: 5, + name: "James Wilson", + email: "j.wilson@email.com", + role: "Analyst", + status: + "Reviewing", + applicationDate: "2024-03-05", + lastUpdated: "2024-03-16", + department: "Analytics", + }, +]; diff --git a/app/client/cypress/support/Pages/Table.ts b/app/client/cypress/support/Pages/Table.ts index d1439a589271..9a11bfbc3f1c 100644 --- a/app/client/cypress/support/Pages/Table.ts +++ b/app/client/cypress/support/Pages/Table.ts @@ -27,7 +27,8 @@ type columnTypeValues = | "Button" | "Menu button" | "Icon button" - | "Select"; + | "Select" + | "HTML"; export class Table { private agHelper = ObjectsRegistry.AggregateHelper; diff --git a/app/client/src/ce/entities/FeatureFlag.ts b/app/client/src/ce/entities/FeatureFlag.ts index f07d6f65b627..3980394c0f3e 100644 --- a/app/client/src/ce/entities/FeatureFlag.ts +++ b/app/client/src/ce/entities/FeatureFlag.ts @@ -43,6 +43,8 @@ export const FEATURE_FLAG = { "release_table_custom_loading_state_enabled", release_custom_widget_ai_builder: "release_custom_widget_ai_builder", ab_request_new_integration_enabled: "ab_request_new_integration_enabled", + release_table_html_column_type_enabled: + "release_table_html_column_type_enabled", } as const; export type FeatureFlag = keyof typeof FEATURE_FLAG; @@ -81,6 +83,7 @@ export const DEFAULT_FEATURE_FLAG_VALUE: FeatureFlags = { release_table_custom_loading_state_enabled: false, release_custom_widget_ai_builder: false, ab_request_new_integration_enabled: false, + release_table_html_column_type_enabled: false, }; export const AB_TESTING_EVENT_KEYS = { diff --git a/app/client/src/ce/utils/analyticsUtilTypes.ts b/app/client/src/ce/utils/analyticsUtilTypes.ts index 9a62818b7fa4..e054c7cc1eed 100644 --- a/app/client/src/ce/utils/analyticsUtilTypes.ts +++ b/app/client/src/ce/utils/analyticsUtilTypes.ts @@ -354,7 +354,8 @@ export type EventName = | "CANVAS_HOVER" | "MALFORMED_USAGE_PULSE" | "REQUEST_INTEGRATION_CTA" - | "REQUEST_INTEGRATION_SUBMITTED"; + | "REQUEST_INTEGRATION_SUBMITTED" + | "TABLE_WIDGET_V2_HTML_CELL_USAGE"; type HOMEPAGE_CREATE_APP_FROM_TEMPLATE_EVENTS = | "TEMPLATE_DROPDOWN_CLICK" diff --git a/app/client/src/widgets/TableWidgetV2/component/Table.tsx b/app/client/src/widgets/TableWidgetV2/component/Table.tsx index 5caf25e08e38..9fe7303a66ff 100644 --- a/app/client/src/widgets/TableWidgetV2/component/Table.tsx +++ b/app/client/src/widgets/TableWidgetV2/component/Table.tsx @@ -30,7 +30,11 @@ import { } from "./Constants"; import { Colors } from "constants/Colors"; import type { EventType } from "constants/AppsmithActionConstants/ActionConstants"; -import type { EditableCell, TableVariant } from "../constants"; +import { + ColumnTypes, + type EditableCell, + type TableVariant, +} from "../constants"; import SimpleBar from "simplebar-react"; import "simplebar-react/dist/simplebar.min.css"; import { createGlobalStyle } from "styled-components"; @@ -323,10 +327,19 @@ export function Table(props: TableProps) { props.width, ]); + /** + * What this really translates is to fixed height rows: + * shouldUseVirtual: false -> fixed height row, irrespective of content small or big + * shouldUseVirtual: true -> height adjusts acc to content + * Right now all HTML content is dynamic height in nature hence + * for server paginated tables it needs this extra handling. + */ const shouldUseVirtual = props.serverSidePaginationEnabled && !props.columns.some( - (column) => !!column.columnProperties.allowCellWrapping, + (column) => + !!column.columnProperties.allowCellWrapping || + column.metaProperties?.type === ColumnTypes.HTML, ); useEffect(() => { diff --git a/app/client/src/widgets/TableWidgetV2/component/cellComponents/HTMLCell/index.tsx b/app/client/src/widgets/TableWidgetV2/component/cellComponents/HTMLCell/index.tsx new file mode 100644 index 000000000000..6eb3f49f7e91 --- /dev/null +++ b/app/client/src/widgets/TableWidgetV2/component/cellComponents/HTMLCell/index.tsx @@ -0,0 +1,153 @@ +import type { RenderMode } from "constants/WidgetConstants"; +import Interweave from "interweave"; +import { isEqual } from "lodash"; +import React, { useEffect, useMemo, useRef } from "react"; +import styled from "styled-components"; +import LinkFilter from "widgets/TextWidget/component/filters/LinkFilter"; +import type { BaseCellComponentProps } from "../../Constants"; +import { CellWrapper } from "../../TableStyledWrappers"; +import { extractHTMLTags, sendHTMLCellAnalytics } from "./utils"; + +const HTMLContainer = styled.div` + & { + height: 100%; + width: 100%; + position: relative; + } + ul { + list-style-type: disc; + list-style-position: inside; + } + ol { + list-style-type: decimal; + list-style-position: inside; + } + ul ul, + ol ul { + list-style-type: circle; + list-style-position: inside; + margin-left: 15px; + } + ol ol, + ul ol { + list-style-type: lower-latin; + list-style-position: inside; + margin-left: 15px; + } + h1 { + font-size: 2em; + margin: 0.67em 0; + } + h2 { + font-size: 1.5em; + margin: 0.75em 0; + } + h3 { + font-size: 1.17em; + margin: 0.83em 0; + } + h5 { + font-size: 0.83em; + margin: 1.5em 0; + } + h6 { + font-size: 0.75em; + margin: 1.67em 0; + } + h1, + h2, + h3, + h4, + h5, + h6 { + font-weight: bold; + } + a { + color: #106ba3; + text-decoration: none; + &:hover { + text-decoration: underline; + } + } +`; + +export interface HTMLCellProps extends BaseCellComponentProps { + value: string; + fontSize?: string; + renderMode: RenderMode; +} + +const HTMLCell = (props: HTMLCellProps) => { + const { + allowCellWrapping, + cellBackground, + compactMode, + fontStyle, + horizontalAlignment, + isCellDisabled, + isCellVisible, + isHidden, + renderMode, + textColor, + textSize, + value, + verticalAlignment, + } = props; + + const previousTagsRef = useRef([]); + + const interweaveCompatibleValue = useMemo(() => { + if (value === null || value === undefined) return ""; + + return String(value); + }, [value]); + + /** + * For analytics, we want to know what tags are being used by users in HTMLCell? + * This will help us in knowing usage patterns and identifying if something is not working out. + */ + const extractedTags = useMemo(() => { + if (!interweaveCompatibleValue) return []; + + return extractHTMLTags(interweaveCompatibleValue); + }, [interweaveCompatibleValue]); + + useEffect(() => { + const areTagsChanged = !isEqual( + [...extractedTags].sort(), + [...previousTagsRef.current].sort(), + ); + + if (extractedTags.length > 0 && areTagsChanged) { + sendHTMLCellAnalytics(extractedTags); + previousTagsRef.current = extractedTags; + } + }, [extractedTags, renderMode]); + + return ( + + + + + + ); +}; + +export default HTMLCell; diff --git a/app/client/src/widgets/TableWidgetV2/component/cellComponents/HTMLCell/utils.tsx b/app/client/src/widgets/TableWidgetV2/component/cellComponents/HTMLCell/utils.tsx new file mode 100644 index 000000000000..424fd30748a3 --- /dev/null +++ b/app/client/src/widgets/TableWidgetV2/component/cellComponents/HTMLCell/utils.tsx @@ -0,0 +1,24 @@ +import AnalyticsUtil from "ee/utils/AnalyticsUtil"; +import { debounce } from "lodash"; + +export const sendHTMLCellAnalytics = debounce( + (tags: string[]) => { + AnalyticsUtil.logEvent("TABLE_WIDGET_V2_HTML_CELL_USAGE", { + tags: tags, + }); + }, + 1000, + { leading: true, trailing: false, maxWait: 5000 }, +); + +export function extractHTMLTags(htmlString: string): string[] { + const div = document.createElement("div"); + + div.innerHTML = htmlString; + const elements = Array.from(div.getElementsByTagName("*")); + const uniqueTags = new Set( + elements.map((element) => element.tagName.toLowerCase()), + ); + + return Array.from(uniqueTags); +} diff --git a/app/client/src/widgets/TableWidgetV2/component/cellComponents/AutoTooltipComponent.test.tsx b/app/client/src/widgets/TableWidgetV2/component/cellComponents/__tests__/AutoTooltipComponent.test.tsx similarity index 96% rename from app/client/src/widgets/TableWidgetV2/component/cellComponents/AutoTooltipComponent.test.tsx rename to app/client/src/widgets/TableWidgetV2/component/cellComponents/__tests__/AutoTooltipComponent.test.tsx index bf46ff76952c..648f51a5c084 100644 --- a/app/client/src/widgets/TableWidgetV2/component/cellComponents/AutoTooltipComponent.test.tsx +++ b/app/client/src/widgets/TableWidgetV2/component/cellComponents/__tests__/AutoTooltipComponent.test.tsx @@ -1,9 +1,9 @@ import React from "react"; import { fireEvent, render, screen } from "@testing-library/react"; -import AutoToolTipComponent from "./AutoToolTipComponent"; +import AutoToolTipComponent from "../AutoToolTipComponent"; import { ColumnTypes } from "widgets/TableWidgetV2/constants"; import "@testing-library/jest-dom"; -import { isButtonTextTruncated } from "./AutoToolTipComponent"; +import { isButtonTextTruncated } from "../AutoToolTipComponent"; jest.mock("react", () => { const actualReact = jest.requireActual("react"); diff --git a/app/client/src/widgets/TableWidgetV2/component/cellComponents/BasicCell.test.tsx b/app/client/src/widgets/TableWidgetV2/component/cellComponents/__tests__/BasicCell.test.tsx similarity index 96% rename from app/client/src/widgets/TableWidgetV2/component/cellComponents/BasicCell.test.tsx rename to app/client/src/widgets/TableWidgetV2/component/cellComponents/__tests__/BasicCell.test.tsx index ee37e656b95b..696cb430f616 100644 --- a/app/client/src/widgets/TableWidgetV2/component/cellComponents/BasicCell.test.tsx +++ b/app/client/src/widgets/TableWidgetV2/component/cellComponents/__tests__/BasicCell.test.tsx @@ -1,7 +1,7 @@ import React from "react"; import { render, screen, fireEvent } from "@testing-library/react"; import "@testing-library/jest-dom"; -import { BasicCell, type PropType } from "./BasicCell"; +import { BasicCell, type PropType } from "../BasicCell"; import { ColumnTypes } from "widgets/TableWidgetV2/constants"; import { CompactModeTypes } from "widgets/TableWidget/component/Constants"; diff --git a/app/client/src/widgets/TableWidgetV2/component/cellComponents/__tests__/HTMLCell.test.tsx b/app/client/src/widgets/TableWidgetV2/component/cellComponents/__tests__/HTMLCell.test.tsx new file mode 100644 index 000000000000..3e6342badf5f --- /dev/null +++ b/app/client/src/widgets/TableWidgetV2/component/cellComponents/__tests__/HTMLCell.test.tsx @@ -0,0 +1,139 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import { unitTestBaseMockStore } from "layoutSystems/common/dropTarget/unitTestUtils"; +import React from "react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; +import { lightTheme } from "selectors/themeSelectors"; +import { ThemeProvider } from "styled-components"; +import { CompactModeTypes } from "../../Constants"; +import HTMLCell, { type HTMLCellProps } from "../HTMLCell"; + +const mockStore = configureStore([]); + +const defaultProps: HTMLCellProps = { + value: "

Hello World

", + cellBackground: "", + compactMode: CompactModeTypes.DEFAULT, + fontStyle: "", + horizontalAlignment: "LEFT", + isCellDisabled: false, + isCellVisible: true, + isHidden: false, + textColor: "", + textSize: "0.875rem", + verticalAlignment: "CENTER", + allowCellWrapping: false, + renderMode: "CANVAS", +}; + +const renderComponent = ( + props: Partial = {}, + store = unitTestBaseMockStore, +) => { + return render( + + + + + , + ); +}; + +describe("HTMLCell", () => { + describe("renders HTML content correctly", () => { + it("with data-testid", () => { + renderComponent({ + value: '

Hello World

', + }); + + expect(screen.getByTestId("html-content")).toBeInTheDocument(); + expect(screen.getByText("Hello World")).toBeInTheDocument(); + }); + + it("renders complex HTML with lists correctly", () => { + const complexHTML = ` + + `; + + renderComponent({ value: complexHTML }); + expect(screen.getByText("Item 1")).toBeInTheDocument(); + expect(screen.getByText("Item 2")).toBeInTheDocument(); + }); + + it("renders links with correct styling", () => { + const htmlWithLink = + 'Click me'; + + renderComponent({ value: htmlWithLink }); + const link = screen.getByText("Click me"); + + expect(link.tagName).toBe("A"); + expect(link).toHaveAttribute("href", "https://example.com"); + expect(link).toHaveAttribute("target", "_blank"); + }); + + it("handles number values correctly", () => { + renderComponent({ value: "123" }); + expect(screen.getByText("123")).toBeInTheDocument(); + }); + }); + + describe("handles null/undefined values", () => { + it("handles null/undefined values", () => { + renderComponent({ value: undefined }); + const htmlCell = screen.getByTestId("t--table-widget-v2-html-cell"); + const span = htmlCell.querySelector("span"); + + expect(span).toBeInTheDocument(); + expect(span).toBeEmptyDOMElement(); + }); + + it("handles null values", () => { + renderComponent({ value: null as unknown as string }); + const span = screen + .getByTestId("t--table-widget-v2-html-cell") + .querySelector("span"); + + expect(span).toBeInTheDocument(); + expect(span).toBeEmptyDOMElement(); + }); + }); + + describe("HTML Sanitization", () => { + it("should allow safe HTML", () => { + const input = "Bold Text"; + + renderComponent({ value: input }); + expect(screen.getByText("Bold Text")).toBeInTheDocument(); + }); + + it("should block " }); + const htmlCell = screen.getByTestId("t--table-widget-v2-html-cell"); + + expect(htmlCell.querySelector("script")).not.toBeInTheDocument(); + }); + + it("should block unsafe attributes like onclick", () => { + renderComponent({ + value: `
Click me
+ `, + }); + const htmlCell = screen.getByTestId("t--table-widget-v2-html-cell"); + + expect(htmlCell.querySelector("div")).not.toHaveAttribute("onclick"); + expect(htmlCell.querySelector("button")).not.toHaveAttribute("onclick"); + }); + + it("should handle invalid HTML gracefully", () => { + renderComponent({ value: "
Unclosed tag" }); + const htmlCell = screen.getByTestId("t--table-widget-v2-html-cell"); + + expect(htmlCell.querySelector("div")).toHaveTextContent("Unclosed tag"); + }); + }); +}); diff --git a/app/client/src/widgets/TableWidgetV2/component/cellComponents/PlainTextCell.test.tsx b/app/client/src/widgets/TableWidgetV2/component/cellComponents/__tests__/PlainTextCell.test.tsx similarity index 97% rename from app/client/src/widgets/TableWidgetV2/component/cellComponents/PlainTextCell.test.tsx rename to app/client/src/widgets/TableWidgetV2/component/cellComponents/__tests__/PlainTextCell.test.tsx index da956d8e1ce2..484bd4152fd8 100644 --- a/app/client/src/widgets/TableWidgetV2/component/cellComponents/PlainTextCell.test.tsx +++ b/app/client/src/widgets/TableWidgetV2/component/cellComponents/__tests__/PlainTextCell.test.tsx @@ -1,4 +1,4 @@ -import { getCellText } from "./PlainTextCell"; +import { getCellText } from "../PlainTextCell"; import { ColumnTypes } from "widgets/TableWidgetV2/constants"; describe("DefaultRendere - ", () => { diff --git a/app/client/src/widgets/TableWidgetV2/component/header/actions/Utilities.test.ts b/app/client/src/widgets/TableWidgetV2/component/header/actions/Utilities.test.ts index b691ac718ba1..deea6b9ca911 100644 --- a/app/client/src/widgets/TableWidgetV2/component/header/actions/Utilities.test.ts +++ b/app/client/src/widgets/TableWidgetV2/component/header/actions/Utilities.test.ts @@ -90,4 +90,28 @@ describe("TransformTableDataIntoArrayOfArray", () => { JSON.stringify(expectedCsvData), ); }); + + it("work as expected with html", () => { + const data = [ + { + id: "

abc

", + }, + { + id: "
abc
", + }, + ]; + const csvData = transformTableDataIntoCsv({ + columns, + data, + }); + const expectedCsvData = [ + ["Id"], + ["

abc

"], + ["
abc
"], + ]; + + expect(JSON.stringify(csvData)).toStrictEqual( + JSON.stringify(expectedCsvData), + ); + }); }); diff --git a/app/client/src/widgets/TableWidgetV2/component/header/actions/Utilities.ts b/app/client/src/widgets/TableWidgetV2/component/header/actions/Utilities.ts index 5cec938a6c76..3922972ed4c3 100644 --- a/app/client/src/widgets/TableWidgetV2/component/header/actions/Utilities.ts +++ b/app/client/src/widgets/TableWidgetV2/component/header/actions/Utilities.ts @@ -1,3 +1,4 @@ +import { ColumnTypes } from "widgets/TableWidgetV2/constants"; import type { TableColumnProps } from "../../Constants"; import { isString } from "lodash"; @@ -35,7 +36,12 @@ export const transformTableDataIntoCsv = (props: { ? value.replace("\n", " ") : value; - if (isString(value) && value.includes(",")) { + // HTML columns output multi line strings. We need to quote them to avoid CSV parsing issues. + const shouldQuote = + (isString(value) && value.includes(",")) || + column.metaProperties.type === ColumnTypes.HTML; + + if (shouldQuote) { csvDataRow.push(`"${value}"`); } else { csvDataRow.push(value); diff --git a/app/client/src/widgets/TableWidgetV2/component/header/actions/filter/CascadeFields.tsx b/app/client/src/widgets/TableWidgetV2/component/header/actions/filter/CascadeFields.tsx index bf6a82b5fb1f..483916108169 100644 --- a/app/client/src/widgets/TableWidgetV2/component/header/actions/filter/CascadeFields.tsx +++ b/app/client/src/widgets/TableWidgetV2/component/header/actions/filter/CascadeFields.tsx @@ -180,6 +180,12 @@ const typeOperatorsMap: Record = { { label: "empty", value: "empty", type: "" }, { label: "not empty", value: "notEmpty", type: "" }, ], + [ColumnTypes.HTML]: [ + { label: "contains", value: "contains", type: "input" }, + { label: "does not contain", value: "doesNotContain", type: "input" }, + { label: "empty", value: "empty", type: "" }, + { label: "not empty", value: "notEmpty", type: "" }, + ], }; const operatorOptions: DropdownOption[] = [ @@ -197,6 +203,7 @@ const columnTypeNameMap: Record = { [ReadOnlyColumnTypes.CHECKBOX]: "Check", [ReadOnlyColumnTypes.SWITCH]: "Check", [ReadOnlyColumnTypes.SELECT]: "Text", + [ReadOnlyColumnTypes.HTML]: "HTML", }; function RenderOption(props: { type: string; title: string; active: boolean }) { diff --git a/app/client/src/widgets/TableWidgetV2/constants.ts b/app/client/src/widgets/TableWidgetV2/constants.ts index c88992218216..717cda817ab6 100644 --- a/app/client/src/widgets/TableWidgetV2/constants.ts +++ b/app/client/src/widgets/TableWidgetV2/constants.ts @@ -146,6 +146,7 @@ export enum ColumnTypes { CHECKBOX = "checkbox", SWITCH = "switch", CURRENCY = "currency", + HTML = "html", } export enum ReadOnlyColumnTypes { @@ -158,6 +159,7 @@ export enum ReadOnlyColumnTypes { CHECKBOX = "checkbox", SWITCH = "switch", SELECT = "select", + HTML = "html", } export const ActionColumnTypes = [ @@ -165,6 +167,7 @@ export const ActionColumnTypes = [ ColumnTypes.ICON_BUTTON, ColumnTypes.MENU_BUTTON, ColumnTypes.EDIT_ACTIONS, + ColumnTypes.HTML, ]; export const FilterableColumnTypes = [ @@ -175,6 +178,7 @@ export const FilterableColumnTypes = [ ColumnTypes.SELECT, ColumnTypes.CHECKBOX, ColumnTypes.SWITCH, + ColumnTypes.HTML, ]; export const DEFAULT_BUTTON_COLOR = "rgb(3, 179, 101)"; @@ -242,3 +246,6 @@ export const ALLOW_TABLE_WIDGET_SERVER_SIDE_FILTERING = export const CUSTOM_LOADING_STATE_ENABLED = FEATURE_FLAG["release_table_custom_loading_state_enabled"]; + +export const HTML_COLUMN_TYPE_ENABLED = + FEATURE_FLAG["release_table_html_column_type_enabled"]; diff --git a/app/client/src/widgets/TableWidgetV2/widget/derived.js b/app/client/src/widgets/TableWidgetV2/widget/derived.js index c62e58ac6f9f..14885dbed5fe 100644 --- a/app/client/src/widgets/TableWidgetV2/widget/derived.js +++ b/app/client/src/widgets/TableWidgetV2/widget/derived.js @@ -281,6 +281,16 @@ export default { return []; } + const getTextFromHTML = (html) => { + if (!html) return ""; + + const tempDiv = document.createElement("div"); + + tempDiv.innerHTML = html; + + return tempDiv.textContent || tempDiv.innerText || ""; + }; + /* extend processedTableData with values from * - computedValues, in case of normal column * - empty values, in case of derived column @@ -504,6 +514,11 @@ export default { ); } } + case "html": + return sortByOrder( + getTextFromHTML(processedA[sortByColumnOriginalId]) > + getTextFromHTML(processedB[sortByColumnOriginalId]), + ); default: return sortByOrder( processedA[sortByColumnOriginalId].toString().toLowerCase() > @@ -700,6 +715,10 @@ export default { (column) => column.columnType === "url" && column.displayText, ); + const columnsWithHTML = Object.values(props.primaryColumns).filter( + (column) => column.columnType === "html", + ); + /* * For select columns with label and values, we need to include the label value * in the search and filter data @@ -781,32 +800,51 @@ export default { }); } - const displayedRow = { - ...row, - ...labelValuesForSelectCell, - ...columnWithDisplayText.reduce((acc, column) => { - let displayText; + const displayTextValues = columnWithDisplayText.reduce((acc, column) => { + let displayText; - if (_.isArray(column.displayText)) { - displayText = column.displayText[row.__originalIndex__]; - } else { - displayText = column.displayText; - } + if (_.isArray(column.displayText)) { + displayText = column.displayText[row.__originalIndex__]; + } else { + displayText = column.displayText; + } + + acc[column.alias] = displayText; + + return acc; + }, {}); + + /* + * We don't want html tags and inline styles to match in search + */ + const htmlValues = columnsWithHTML.reduce((acc, column) => { + const value = row[column.alias]; - acc[column.alias] = displayText; + acc[column.alias] = + value === null || value === undefined ? "" : getTextFromHTML(value); - return acc; - }, {}), + return acc; + }, {}); + + const displayedRow = { + ...row, + ...labelValuesForSelectCell, + ...displayTextValues, + ...htmlValues, }; + const htmlColumns = columnsWithHTML.map((column) => column.alias); if (searchKey) { - isSearchKeyFound = [ + const combinedRowContent = [ ...Object.values(_.omit(displayedRow, hiddenColumns)), - ...Object.values(_.omit(originalRow, hiddenColumns)), + ...Object.values( + _.omit(originalRow, [...hiddenColumns, ...htmlColumns]), + ), ] .join(", ") - .toLowerCase() - .includes(searchKey); + .toLowerCase(); + + isSearchKeyFound = combinedRowContent.includes(searchKey); } if (!isSearchKeyFound) { @@ -834,15 +872,20 @@ export default { ConditionFunctions[props.filters[i].condition]; if (conditionFunction) { + /* + * We don't want html tags and inline styles to match in filter conditions + */ + const isHTMLColumn = htmlColumns.includes(props.filters[i].column); + const originalColValue = isHTMLColumn + ? getTextFromHTML(originalRow[props.filters[i].column]) + : originalRow[props.filters[i].column]; + const displayedColValue = isHTMLColumn + ? getTextFromHTML(displayedRow[props.filters[i].column]) + : displayedRow[props.filters[i].column]; + filterResult = - conditionFunction( - originalRow[props.filters[i].column], - props.filters[i].value, - ) || - conditionFunction( - displayedRow[props.filters[i].column], - props.filters[i].value, - ); + conditionFunction(originalColValue, props.filters[i].value) || + conditionFunction(displayedColValue, props.filters[i].value); } } catch (e) { filterResult = false; diff --git a/app/client/src/widgets/TableWidgetV2/widget/derived.test.js b/app/client/src/widgets/TableWidgetV2/widget/derived.test.js index e12cb35ff306..8b306b09207d 100644 --- a/app/client/src/widgets/TableWidgetV2/widget/derived.test.js +++ b/app/client/src/widgets/TableWidgetV2/widget/derived.test.js @@ -1646,6 +1646,582 @@ describe("Validates getFilteredTableData Properties", () => { expect(result).toStrictEqual(expected); }); + + describe("HTML columns", () => { + const input = { + tableData: [ + { + id: 1, + name: "Jim Doe", + status: "Active", + }, + { + id: 2, + name: "Usain Bolt", + status: "Pending", + }, + { + id: 3, + name: "Elon Musk", + status: "Active", + }, + ], + processedTableData: [ + { + id: 1, + name: "Jim Doe", + status: "Active", + __originalIndex__: 0, + }, + { + id: 2, + name: "Usain Bolt", + status: "Pending", + __originalIndex__: 1, + }, + { + id: 3, + name: "Elon Musk", + status: "Active", + __originalIndex__: 2, + }, + ], + sortOrder: { column: "id", order: "asc" }, + columnOrder: ["id", "name", "status"], + primaryColumns: { + id: { + index: 1, + width: 150, + id: "id", + alias: "id", + originalId: "id", + horizontalAlignment: "LEFT", + verticalAlignment: "CENTER", + columnType: "number", + textColor: "#231F20", + textSize: "PARAGRAPH", + fontStyle: "REGULAR", + enableFilter: true, + enableSort: true, + isVisible: true, + isDerived: false, + label: "id", + isAscOrder: false, + }, + name: { + index: 0, + width: 150, + id: "name", + alias: "name", + originalId: "name", + horizontalAlignment: "LEFT", + verticalAlignment: "CENTER", + columnType: "text", + textColor: "#231F20", + textSize: "PARAGRAPH", + fontStyle: "REGULAR", + enableFilter: true, + enableSort: true, + isVisible: true, + isDerived: false, + label: "awesome", + isAscOrder: undefined, + }, + status: { + index: 0, + width: 150, + id: "status", + alias: "status", + originalId: "status", + horizontalAlignment: "LEFT", + verticalAlignment: "CENTER", + columnType: "html", + textColor: "#231F20", + textSize: "PARAGRAPH", + fontStyle: "REGULAR", + enableFilter: true, + enableSort: true, + isVisible: true, + isDerived: false, + label: "Status", + isAscOrder: undefined, + }, + }, + tableColumns: [ + { + index: 0, + width: 150, + id: "name", + horizontalAlignment: "LEFT", + verticalAlignment: "CENTER", + columnType: "text", + textColor: "#231F20", + textSize: "PARAGRAPH", + fontStyle: "REGULAR", + enableFilter: true, + enableSort: true, + isVisible: true, + isDerived: false, + label: "awesome", + isAscOrder: undefined, + }, + { + index: 1, + width: 150, + id: "id", + horizontalAlignment: "LEFT", + verticalAlignment: "CENTER", + columnType: "number", + textColor: "#231F20", + textSize: "PARAGRAPH", + fontStyle: "REGULAR", + enableFilter: true, + enableSort: true, + isVisible: true, + isDerived: false, + label: "id", + isAscOrder: false, + }, + { + index: 0, + width: 150, + id: "status", + alias: "status", + originalId: "status", + horizontalAlignment: "LEFT", + verticalAlignment: "CENTER", + columnType: "html", + textColor: "#231F20", + textSize: "PARAGRAPH", + fontStyle: "REGULAR", + enableFilter: true, + enableSort: true, + isVisible: true, + isDerived: false, + label: "Status", + isAscOrder: undefined, + }, + ], + }; + + input.orderedTableColumns = Object.values(input.primaryColumns).sort( + (a, b) => { + return input.columnOrder[a.id] < input.columnOrder[b.id]; + }, + ); + const { getFilteredTableData } = derivedProperty; + + it("validate search on table for HTML columns", () => { + input.searchText = "Pending"; + const expected = [ + { + id: 2, + name: "Usain Bolt", + status: "Pending", + __originalIndex__: 1, + }, + ]; + + let result = getFilteredTableData(input, moment, _); + + expect(result).toStrictEqual(expected); + delete input.searchText; + }); + + it("validates filters on table for HTML columns", () => { + input.filters = [ + { + condition: "contains", + column: "status", + value: "Active", + }, + ]; + const expected = [ + { + id: 1, + name: "Jim Doe", + status: "Active", + __originalIndex__: 0, + }, + { + id: 3, + name: "Elon Musk", + status: "Active", + __originalIndex__: 2, + }, + ]; + + let result = getFilteredTableData(input, moment, _); + + expect(result).toStrictEqual(expected); + delete input.filters; + }); + + it("validates sort on table for HTML columns", () => { + input.sortOrder = { column: "status", order: "desc" }; + let expected = [ + { + id: 2, + name: "Usain Bolt", + status: "Pending", + __originalIndex__: 1, + }, + { + id: 1, + name: "Jim Doe", + status: "Active", + __originalIndex__: 0, + }, + { + id: 3, + name: "Elon Musk", + status: "Active", + __originalIndex__: 2, + }, + ]; + + let result = getFilteredTableData(input, moment, _); + + expect(result).toStrictEqual(expected); + + input.sortOrder = { column: "status", order: "asc" }; + expected = [ + { + id: 3, + name: "Elon Musk", + status: "Active", + __originalIndex__: 2, + }, + { + id: 1, + name: "Jim Doe", + status: "Active", + __originalIndex__: 0, + }, + + { + id: 2, + name: "Usain Bolt", + status: "Pending", + __originalIndex__: 1, + }, + ]; + + result = getFilteredTableData(input, moment, _); + expect(result).toStrictEqual(expected); + }); + + it("validates tags are not filterable in html content", () => { + input.filters = [ + { + condition: "contains", + column: "status", + value: "span", + }, + ]; + const expected = []; + + let result = getFilteredTableData(input, moment, _); + + expect(result).toStrictEqual(expected); + + input.filters = [ + { + condition: "contains", + column: "status", + value: "color", + }, + ]; + result = getFilteredTableData(input, moment, _); + expect(result).toStrictEqual(expected); + delete input.filters; + }); + it("validates tags are not searchable in html content", () => { + input.searchText = "span"; + + const expected = []; + + let result = getFilteredTableData(input, moment, _); + + expect(result).toStrictEqual(expected); + + input.searchText = "color"; + result = getFilteredTableData(input, moment, _); + expect(result).toStrictEqual(expected); + delete input.searchText; + }); + + it("validates multiple HTML column filters with AND condition", () => { + const multiFilterInput = _.cloneDeep(input); + + multiFilterInput.processedTableData = [ + { + id: 1, + name: "Jim Doe", + status: "Active", + role: "
Admin
", + __originalIndex__: 0, + }, + { + id: 2, + name: "Usain Bolt", + status: "Pending", + role: "
User
", + __originalIndex__: 1, + }, + { + id: 3, + name: "Elon Musk", + status: "Active", + role: "
Admin
", + __originalIndex__: 2, + }, + ]; + + multiFilterInput.primaryColumns.role = { + index: 3, + width: 150, + id: "role", + alias: "role", + originalId: "role", + horizontalAlignment: "LEFT", + verticalAlignment: "CENTER", + columnType: "html", + textColor: "#231F20", + textSize: "PARAGRAPH", + fontStyle: "REGULAR", + enableFilter: true, + enableSort: true, + isVisible: true, + isDerived: false, + label: "Role", + isAscOrder: undefined, + }; + + multiFilterInput.filters = [ + { + condition: "contains", + column: "status", + value: "Active", + }, + { + condition: "contains", + column: "role", + value: "Admin", + operator: "AND", + }, + ]; + + const expected = [ + { + id: 3, + name: "Elon Musk", + status: "Active", + role: "
Admin
", + __originalIndex__: 2, + }, + { + id: 1, + name: "Jim Doe", + status: "Active", + role: "
Admin
", + __originalIndex__: 0, + }, + ]; + + let result = getFilteredTableData(multiFilterInput, moment, _); + + expect(result).toStrictEqual(expected); + delete input.filters; + }); + + it("validates complex HTML content with nested elements and attributes", () => { + const complexHTMLInput = _.cloneDeep(input); + + complexHTMLInput.processedTableData = [ + { + id: 1, + name: "Jim Doe", + status: + '
Active
', + __originalIndex__: 0, + }, + { + id: 2, + name: "Usain Bolt", + status: + '
Pending
', + __originalIndex__: 1, + }, + { + id: 3, + name: "Elon Musk", + status: + '
Active
', + __originalIndex__: 2, + }, + ]; + + // Test searching through complex HTML + complexHTMLInput.searchText = "Active"; + let expected = [ + { + id: 3, + name: "Elon Musk", + status: + '
Active
', + __originalIndex__: 2, + }, + { + id: 1, + name: "Jim Doe", + status: + '
Active
', + __originalIndex__: 0, + }, + ]; + + let result = getFilteredTableData(complexHTMLInput, moment, _); + + expect(result).toStrictEqual(expected); + delete complexHTMLInput.searchText; + + // Test sorting with complex HTML + complexHTMLInput.sortOrder = { column: "status", order: "desc" }; + expected = [ + { + id: 2, + name: "Usain Bolt", + status: + '
Pending
', + __originalIndex__: 1, + }, + { + id: 1, + name: "Jim Doe", + status: + '
Active
', + __originalIndex__: 0, + }, + { + id: 3, + name: "Elon Musk", + status: + '
Active
', + __originalIndex__: 2, + }, + ]; + + result = getFilteredTableData(complexHTMLInput, moment, _); + expect(result).toStrictEqual(expected); + }); + + it("validates HTML columns with special characters and entities", () => { + const specialCharHTMLInput = _.cloneDeep(input); + + specialCharHTMLInput.processedTableData = [ + { + id: 1, + name: "Jim Doe", + status: "© Active & Ready", + __originalIndex__: 0, + }, + { + id: 2, + name: "Usain Bolt", + status: "Pending > Review", + __originalIndex__: 1, + }, + { + id: 3, + name: "Elon Musk", + status: "© Active & Ready", + __originalIndex__: 2, + }, + ]; + + // Test filtering with HTML entities + specialCharHTMLInput.filters = [ + { + condition: "contains", + column: "status", + value: "Active & Ready", + }, + ]; + + const expected = [ + { + id: 3, + name: "Elon Musk", + status: "© Active & Ready", + __originalIndex__: 2, + }, + { + id: 1, + name: "Jim Doe", + status: "© Active & Ready", + __originalIndex__: 0, + }, + ]; + + let result = getFilteredTableData(specialCharHTMLInput, moment, _); + + expect(result).toStrictEqual(expected); + delete specialCharHTMLInput.filters; + }); + + it("validates filtering with null and undefined values in HTML columns", () => { + const nullUndefinedInput = _.cloneDeep(input); + + nullUndefinedInput.processedTableData = [ + { + id: 1, + name: "Jim Doe", + status: null, + __originalIndex__: 0, + }, + { + id: 2, + name: "Usain Bolt", + status: undefined, + __originalIndex__: 1, + }, + { + id: 3, + name: "Elon Musk", + status: "Active", + __originalIndex__: 2, + }, + ]; + + // Test filtering for null values + nullUndefinedInput.filters = [ + { + condition: "contains", + column: "status", + value: "null", + }, + ]; + + let result = getFilteredTableData(nullUndefinedInput, moment, _); + + expect(result).toStrictEqual([]); + + // Test filtering for undefined values + nullUndefinedInput.filters = [ + { + condition: "contains", + column: "status", + value: "undefined", + }, + ]; + + result = getFilteredTableData(nullUndefinedInput, moment, _); + expect(result).toStrictEqual([]); + + delete nullUndefinedInput.filters; + }); + }); }); describe("Validate getSelectedRow function", () => { diff --git a/app/client/src/widgets/TableWidgetV2/widget/index.tsx b/app/client/src/widgets/TableWidgetV2/widget/index.tsx index 4d8e31b3e57b..586fcd9bc2d3 100644 --- a/app/client/src/widgets/TableWidgetV2/widget/index.tsx +++ b/app/client/src/widgets/TableWidgetV2/widget/index.tsx @@ -3,6 +3,7 @@ import log from "loglevel"; import memoizeOne from "memoize-one"; import _, { + cloneDeep, filter, isArray, isEmpty, @@ -58,6 +59,7 @@ import { DEFAULT_MENU_VARIANT, defaultEditableCell, EditableCellActions, + HTML_COLUMN_TYPE_ENABLED, InlineEditingSaveOptions, ORIGINAL_INDEX_KEY, PaginationDirection, @@ -139,6 +141,7 @@ import { import IconSVG from "../icon.svg"; import ThumbnailSVG from "../thumbnail.svg"; import { klonaRegularWithTelemetry } from "utils/helpers"; +import HTMLCell from "../component/cellComponents/HTMLCell"; const ReactTableComponent = lazy(async () => retryPromise(async () => import("../component")), @@ -911,6 +914,36 @@ class TableWidgetV2 extends BaseWidget { //dont neet to batch this since single action this.hydrateStickyColumns(); } + + /** + * Why we are doing this? + * This is a safety net! Consider this scenario: + * 1. HTML column type is enabled. + * 2. User creates a table with HTML columns. + * 3. HTML column type is disabled. (For any reason) + * + * In this scenario, we don't want incomplete experience for the user. + * Without this safety net, the property pane will not show the HTML as type and the `ColumnType` will be lost(and empty), which is confusing for the user. + * With this safety net, we will update the column type to TEXT. + * @rahulbarwal Remove this once we remove the feature flag + */ + if (!TableWidgetV2.getFeatureFlag(HTML_COLUMN_TYPE_ENABLED)) { + const updatedPrimaryColumns = cloneDeep(this.props.primaryColumns); + let hasHTMLColumns = false; + + Object.values(updatedPrimaryColumns).forEach( + (column: ColumnProperties) => { + if (column.columnType === ColumnTypes.HTML) { + column.columnType = ColumnTypes.TEXT; + hasHTMLColumns = true; + } + }, + ); + + if (hasHTMLColumns) { + this.updateWidgetProperty("primaryColumns", updatedPrimaryColumns); + } + } } componentDidUpdate(prevProps: TableWidgetProps) { @@ -2517,6 +2550,25 @@ class TableWidgetV2 extends BaseWidget { /> ); + case ColumnTypes.HTML: + return ( + + ); + default: let validationErrorMessage; diff --git a/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/PanelConfig/Data/ColumnType.ts b/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/PanelConfig/Data/ColumnType.ts new file mode 100644 index 000000000000..ee66fa0aa194 --- /dev/null +++ b/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/PanelConfig/Data/ColumnType.ts @@ -0,0 +1,137 @@ +import { + ColumnTypes, + HTML_COLUMN_TYPE_ENABLED, + type TableWidgetProps, +} from "widgets/TableWidgetV2/constants"; +import { composePropertyUpdateHook } from "widgets/WidgetUtils"; + +import Widget from "../../../index"; +import { + showByColumnType, + updateCurrencyDefaultValues, + updateMenuItemsSource, + updateNumberColumnTypeTextAlignment, + updateThemeStylesheetsInColumns, +} from "../../../propertyUtils"; +const ColumnTypeOptions = [ + { + label: "Button", + value: ColumnTypes.BUTTON, + }, + { + label: "Checkbox", + value: ColumnTypes.CHECKBOX, + }, + { + label: "Currency", + value: ColumnTypes.CURRENCY, + }, + { + label: "Date", + value: ColumnTypes.DATE, + }, + { + label: "Icon button", + value: ColumnTypes.ICON_BUTTON, + }, + { + label: "Image", + value: ColumnTypes.IMAGE, + }, + { + label: "Menu button", + value: ColumnTypes.MENU_BUTTON, + }, + { + label: "Number", + value: ColumnTypes.NUMBER, + }, + { + label: "Plain text", + value: ColumnTypes.TEXT, + }, + { + label: "Select", + value: ColumnTypes.SELECT, + }, + { + label: "Switch", + value: ColumnTypes.SWITCH, + }, + { + label: "URL", + value: ColumnTypes.URL, + }, + { + label: "Video", + value: ColumnTypes.VIDEO, + }, +]; + +// TODO: @rahulbarwal Remove this once we have a feature flag for this +// This is a temporary solution to position the HTML column type alphabetically +const columnTypeWithHtml = [ + ...ColumnTypeOptions.slice(0, 4), + { label: "HTML", value: ColumnTypes.HTML }, + ...ColumnTypeOptions.slice(4), +]; + +export const columnTypeConfig = { + propertyName: "columnType", + label: "Column type", + helpText: + "Type of column to be shown corresponding to the data of the column", + controlType: "DROP_DOWN", + // TODO: Remove this once we have a feature flag for this + // Since we want to position the column types alphabetically, right now this is hardcoded + options: ColumnTypeOptions, + updateHook: composePropertyUpdateHook([ + updateNumberColumnTypeTextAlignment, + updateThemeStylesheetsInColumns, + updateMenuItemsSource, + updateCurrencyDefaultValues, + ]), + dependencies: ["primaryColumns", "columnOrder", "childStylesheet"], + isBindProperty: false, + isTriggerProperty: false, + hidden: (props: TableWidgetProps, propertyPath: string) => { + const isHTMLColumnTypeEnabled = Widget.getFeatureFlag( + HTML_COLUMN_TYPE_ENABLED, + ); + + return ( + isHTMLColumnTypeEnabled || + showByColumnType(props, propertyPath, [ColumnTypes.EDIT_ACTIONS]) + ); + }, +}; + +export const columnTypeWithHtmlConfig = { + propertyName: "columnType", + label: "Column type", + helpText: + "Type of column to be shown corresponding to the data of the column", + controlType: "DROP_DOWN", + // TODO: Remove this once we have a feature flag for this + // Since we want to position the column types alphabetically, right now this is hardcoded + options: columnTypeWithHtml, + updateHook: composePropertyUpdateHook([ + updateNumberColumnTypeTextAlignment, + updateThemeStylesheetsInColumns, + updateMenuItemsSource, + updateCurrencyDefaultValues, + ]), + dependencies: ["primaryColumns", "columnOrder", "childStylesheet"], + isBindProperty: false, + isTriggerProperty: false, + hidden: (props: TableWidgetProps, propertyPath: string) => { + const isHTMLColumnTypeEnabled = Widget.getFeatureFlag( + HTML_COLUMN_TYPE_ENABLED, + ); + + return ( + !isHTMLColumnTypeEnabled || + showByColumnType(props, propertyPath, [ColumnTypes.EDIT_ACTIONS]) + ); + }, +}; diff --git a/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/PanelConfig/Data.ts b/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/PanelConfig/Data/index.ts similarity index 85% rename from app/client/src/widgets/TableWidgetV2/widget/propertyConfig/PanelConfig/Data.ts rename to app/client/src/widgets/TableWidgetV2/widget/propertyConfig/PanelConfig/Data/index.ts index fb1b4ab11d67..6296da47e1a2 100644 --- a/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/PanelConfig/Data.ts +++ b/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/PanelConfig/Data/index.ts @@ -1,99 +1,21 @@ import { ValidationTypes } from "constants/WidgetValidation"; +import { get } from "lodash"; +import { AutocompleteDataType } from "utils/autocomplete/AutocompleteDataType"; +import { CurrencyDropdownOptions } from "widgets/CurrencyInputWidget/component/CurrencyCodeDropdown"; import type { TableWidgetProps } from "widgets/TableWidgetV2/constants"; import { ColumnTypes, DateInputFormat } from "widgets/TableWidgetV2/constants"; -import { get } from "lodash"; import { getBasePropertyPath, hideByColumnType, - showByColumnType, uniqueColumnAliasValidation, - updateCurrencyDefaultValues, - updateMenuItemsSource, - updateNumberColumnTypeTextAlignment, - updateThemeStylesheetsInColumns, -} from "../../propertyUtils"; -import { AutocompleteDataType } from "utils/autocomplete/AutocompleteDataType"; -import { composePropertyUpdateHook } from "widgets/WidgetUtils"; -import { CurrencyDropdownOptions } from "widgets/CurrencyInputWidget/component/CurrencyCodeDropdown"; +} from "../../../propertyUtils"; +import { columnTypeConfig, columnTypeWithHtmlConfig } from "./ColumnType"; export default { sectionName: "Data", children: [ - { - propertyName: "columnType", - label: "Column type", - helpText: - "Type of column to be shown corresponding to the data of the column", - controlType: "DROP_DOWN", - options: [ - { - label: "Button", - value: ColumnTypes.BUTTON, - }, - { - label: "Checkbox", - value: ColumnTypes.CHECKBOX, - }, - { - label: "Currency", - value: ColumnTypes.CURRENCY, - }, - { - label: "Date", - value: ColumnTypes.DATE, - }, - { - label: "Icon button", - value: ColumnTypes.ICON_BUTTON, - }, - { - label: "Image", - value: ColumnTypes.IMAGE, - }, - { - label: "Menu button", - value: ColumnTypes.MENU_BUTTON, - }, - { - label: "Number", - value: ColumnTypes.NUMBER, - }, - { - label: "Plain text", - value: ColumnTypes.TEXT, - }, - { - label: "Select", - value: ColumnTypes.SELECT, - }, - { - label: "Switch", - value: ColumnTypes.SWITCH, - }, - { - label: "URL", - value: ColumnTypes.URL, - }, - { - label: "Video", - value: ColumnTypes.VIDEO, - }, - ], - updateHook: composePropertyUpdateHook([ - updateNumberColumnTypeTextAlignment, - updateThemeStylesheetsInColumns, - updateMenuItemsSource, - updateCurrencyDefaultValues, - ]), - dependencies: ["primaryColumns", "columnOrder", "childStylesheet"], - isBindProperty: false, - isTriggerProperty: false, - hidden: (props: TableWidgetProps, propertyPath: string) => { - return showByColumnType(props, propertyPath, [ - ColumnTypes.EDIT_ACTIONS, - ]); - }, - }, + columnTypeConfig, + columnTypeWithHtmlConfig, { helpText: "The alias that you use in selectedrow", propertyName: "alias", @@ -173,6 +95,7 @@ export default { ColumnTypes.SWITCH, ColumnTypes.SELECT, ColumnTypes.CURRENCY, + ColumnTypes.HTML, ]); }, dependencies: ["primaryColumns", "columnOrder"], diff --git a/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/PanelConfig/index.ts b/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/PanelConfig/index.ts index 79446122f428..b71d607a7eeb 100644 --- a/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/PanelConfig/index.ts +++ b/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/PanelConfig/index.ts @@ -4,6 +4,7 @@ import Basic from "./Basic"; import BorderAndShadow from "./BorderAndShadow"; import Color from "./Color"; import Data from "./Data"; +import DateProperties from "./DateProperties"; import DiscardButtonproperties, { discardButtonStyleConfig, } from "./DiscardButtonproperties"; @@ -16,7 +17,6 @@ import SaveButtonProperties, { import Select from "./Select"; import TextFormatting from "./TextFormatting"; import Validations from "./Validation"; -import DateProperties from "./DateProperties"; export default { editableTitle: true, diff --git a/app/client/src/widgets/TableWidgetV2/widget/utilities.ts b/app/client/src/widgets/TableWidgetV2/widget/utilities.ts index 20f5d00561e5..502c8527779a 100644 --- a/app/client/src/widgets/TableWidgetV2/widget/utilities.ts +++ b/app/client/src/widgets/TableWidgetV2/widget/utilities.ts @@ -770,12 +770,23 @@ export const getColumnType = ( return ColumnTypes.NUMBER; case "boolean": return ColumnTypes.CHECKBOX; - case "string": - return dateFormatOptions.some(({ value: format }) => + case "string": { + const isHTML = /<[^>]*>/.test(columnValue as string); + + if (isHTML) { + return ColumnTypes.HTML; + } + + const isAnyValidDate = dateFormatOptions.some(({ value: format }) => moment(columnValue as string, format, true).isValid(), - ) - ? ColumnTypes.DATE - : ColumnTypes.TEXT; + ); + + if (isAnyValidDate) { + return ColumnTypes.DATE; + } + + return ColumnTypes.TEXT; + } default: return ColumnTypes.TEXT; }