feat: Implements HTML as a column type in table widget. (#37997)

This commit is contained in:
Rahul Barwal 2024-12-11 13:07:30 +05:30 committed by GitHub
parent dcdd52f5ff
commit 3610bae834
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1381 additions and 125 deletions

View File

@ -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");
});
});
},
);

View File

@ -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:
"<span style='background-color: #22c55e; color: white; padding: 4px 12px; border-radius: 20px; font-size: 14px;'><strong>Active</strong></span>",
applicationDate: "2024-03-01",
lastUpdated: "2024-03-19",
department: "Design",
},
{
id: 3,
name: "Michael Brown",
email: "m.brown@email.com",
role: "Manager",
status:
"<span style='background-color: #ef4444; color: white; padding: 4px 12px; border-radius: 20px; font-size: 14px;'><strong>Suspended</strong></span>",
applicationDate: "2024-01-10",
lastUpdated: "2024-03-18",
department: "Operations",
},
{
id: 4,
name: "Sarah Davis",
email: "sarah.d@email.com",
role: "Developer",
status:
"<span style='background-color: #22c55e; color: white; padding: 4px 12px; border-radius: 20px; font-size: 14px;'><strong>Active</strong></span>",
applicationDate: "2024-02-20",
lastUpdated: "2024-03-17",
department: "Engineering",
},
{
id: 5,
name: "James Wilson",
email: "j.wilson@email.com",
role: "Analyst",
status:
"<span style='background-color: #3b82f6; color: white; padding: 4px 12px; border-radius: 20px; font-size: 14px;'><strong>Reviewing</strong></span>",
applicationDate: "2024-03-05",
lastUpdated: "2024-03-16",
department: "Analytics",
},
];

View File

@ -27,7 +27,8 @@ type columnTypeValues =
| "Button"
| "Menu button"
| "Icon button"
| "Select";
| "Select"
| "HTML";
export class Table {
private agHelper = ObjectsRegistry.AggregateHelper;

View File

@ -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 = {

View File

@ -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"

View File

@ -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(() => {

View File

@ -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<string[]>([]);
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 (
<CellWrapper
allowCellWrapping={allowCellWrapping}
cellBackground={cellBackground}
className="cell-wrapper"
compactMode={compactMode}
fontStyle={fontStyle}
horizontalAlignment={horizontalAlignment}
isCellDisabled={isCellDisabled}
isCellVisible={isCellVisible}
isHidden={isHidden}
textColor={textColor}
textSize={textSize}
verticalAlignment={verticalAlignment}
>
<HTMLContainer data-testid="t--table-widget-v2-html-cell">
<Interweave
content={interweaveCompatibleValue}
filters={[new LinkFilter()]}
newWindow
/>
</HTMLContainer>
</CellWrapper>
);
};
export default HTMLCell;

View File

@ -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);
}

View File

@ -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");

View File

@ -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";

View File

@ -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: "<p>Hello World</p>",
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<HTMLCellProps> = {},
store = unitTestBaseMockStore,
) => {
return render(
<Provider store={mockStore(store)}>
<ThemeProvider theme={lightTheme}>
<HTMLCell {...defaultProps} {...props} />
</ThemeProvider>
</Provider>,
);
};
describe("HTMLCell", () => {
describe("renders HTML content correctly", () => {
it("with data-testid", () => {
renderComponent({
value: '<p data-testid="html-content">Hello World</p>',
});
expect(screen.getByTestId("html-content")).toBeInTheDocument();
expect(screen.getByText("Hello World")).toBeInTheDocument();
});
it("renders complex HTML with lists correctly", () => {
const complexHTML = `
<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>
`;
renderComponent({ value: complexHTML });
expect(screen.getByText("Item 1")).toBeInTheDocument();
expect(screen.getByText("Item 2")).toBeInTheDocument();
});
it("renders links with correct styling", () => {
const htmlWithLink =
'<a target="_blank" href="https://example.com">Click me</a>';
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 = "<b>Bold Text</b>";
renderComponent({ value: input });
expect(screen.getByText("Bold Text")).toBeInTheDocument();
});
it("should block <script> tags", () => {
renderComponent({ value: "<script>alert('XSS')</script>" });
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: `<div onclick="alert('hack')">Click me</div>
<button onclick="alert('hack')">Click me</button>`,
});
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: "<div><b>Unclosed tag" });
const htmlCell = screen.getByTestId("t--table-widget-v2-html-cell");
expect(htmlCell.querySelector("div")).toHaveTextContent("Unclosed tag");
});
});
});

View File

@ -1,4 +1,4 @@
import { getCellText } from "./PlainTextCell";
import { getCellText } from "../PlainTextCell";
import { ColumnTypes } from "widgets/TableWidgetV2/constants";
describe("DefaultRendere - ", () => {

View File

@ -90,4 +90,28 @@ describe("TransformTableDataIntoArrayOfArray", () => {
JSON.stringify(expectedCsvData),
);
});
it("work as expected with html", () => {
const data = [
{
id: "<p>abc</p>",
},
{
id: "<table><tr><td>abc</td></tr></table>",
},
];
const csvData = transformTableDataIntoCsv({
columns,
data,
});
const expectedCsvData = [
["Id"],
["<p>abc</p>"],
["<table><tr><td>abc</td></tr></table>"],
];
expect(JSON.stringify(csvData)).toStrictEqual(
JSON.stringify(expectedCsvData),
);
});
});

View File

@ -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);

View File

@ -180,6 +180,12 @@ const typeOperatorsMap: Record<ReadOnlyColumnTypes, DropdownOption[]> = {
{ 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, string> = {
[ReadOnlyColumnTypes.CHECKBOX]: "Check",
[ReadOnlyColumnTypes.SWITCH]: "Check",
[ReadOnlyColumnTypes.SELECT]: "Text",
[ReadOnlyColumnTypes.HTML]: "HTML",
};
function RenderOption(props: { type: string; title: string; active: boolean }) {

View File

@ -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"];

View File

@ -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 displayTextValues = columnWithDisplayText.reduce((acc, column) => {
let 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] =
value === null || value === undefined ? "" : getTextFromHTML(value);
return acc;
}, {});
const displayedRow = {
...row,
...labelValuesForSelectCell,
...columnWithDisplayText.reduce((acc, column) => {
let displayText;
if (_.isArray(column.displayText)) {
displayText = column.displayText[row.__originalIndex__];
} else {
displayText = column.displayText;
}
acc[column.alias] = displayText;
return acc;
}, {}),
...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;

View File

@ -1646,6 +1646,582 @@ describe("Validates getFilteredTableData Properties", () => {
expect(result).toStrictEqual(expected);
});
describe("HTML columns", () => {
const input = {
tableData: [
{
id: 1,
name: "Jim Doe",
status: "<span style='color: green;'>Active</span>",
},
{
id: 2,
name: "Usain Bolt",
status: "<span style='color: yellow;'>Pending</span>",
},
{
id: 3,
name: "Elon Musk",
status: "<span style='color: green;'>Active</span>",
},
],
processedTableData: [
{
id: 1,
name: "Jim Doe",
status: "<span style='color: green;'>Active</span>",
__originalIndex__: 0,
},
{
id: 2,
name: "Usain Bolt",
status: "<span style='color: yellow;'>Pending</span>",
__originalIndex__: 1,
},
{
id: 3,
name: "Elon Musk",
status: "<span style='color: green;'>Active</span>",
__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: "<span style='color: yellow;'>Pending</span>",
__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: "<span style='color: green;'>Active</span>",
__originalIndex__: 0,
},
{
id: 3,
name: "Elon Musk",
status: "<span style='color: green;'>Active</span>",
__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: "<span style='color: yellow;'>Pending</span>",
__originalIndex__: 1,
},
{
id: 1,
name: "Jim Doe",
status: "<span style='color: green;'>Active</span>",
__originalIndex__: 0,
},
{
id: 3,
name: "Elon Musk",
status: "<span style='color: green;'>Active</span>",
__originalIndex__: 2,
},
];
let result = getFilteredTableData(input, moment, _);
expect(result).toStrictEqual(expected);
input.sortOrder = { column: "status", order: "asc" };
expected = [
{
id: 3,
name: "Elon Musk",
status: "<span style='color: green;'>Active</span>",
__originalIndex__: 2,
},
{
id: 1,
name: "Jim Doe",
status: "<span style='color: green;'>Active</span>",
__originalIndex__: 0,
},
{
id: 2,
name: "Usain Bolt",
status: "<span style='color: yellow;'>Pending</span>",
__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: "<span style='color: green;'>Active</span>",
role: "<div>Admin</div>",
__originalIndex__: 0,
},
{
id: 2,
name: "Usain Bolt",
status: "<span style='color: yellow;'>Pending</span>",
role: "<div>User</div>",
__originalIndex__: 1,
},
{
id: 3,
name: "Elon Musk",
status: "<span style='color: green;'>Active</span>",
role: "<div>Admin</div>",
__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: "<span style='color: green;'>Active</span>",
role: "<div>Admin</div>",
__originalIndex__: 2,
},
{
id: 1,
name: "Jim Doe",
status: "<span style='color: green;'>Active</span>",
role: "<div>Admin</div>",
__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:
'<div class="status-badge"><span style="color: green">Active</span></div>',
__originalIndex__: 0,
},
{
id: 2,
name: "Usain Bolt",
status:
'<div class="status-badge"><span style="color: orange">Pending</span></div>',
__originalIndex__: 1,
},
{
id: 3,
name: "Elon Musk",
status:
'<div class="status-badge"><span style="color: green">Active</span></div>',
__originalIndex__: 2,
},
];
// Test searching through complex HTML
complexHTMLInput.searchText = "Active";
let expected = [
{
id: 3,
name: "Elon Musk",
status:
'<div class="status-badge"><span style="color: green">Active</span></div>',
__originalIndex__: 2,
},
{
id: 1,
name: "Jim Doe",
status:
'<div class="status-badge"><span style="color: green">Active</span></div>',
__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:
'<div class="status-badge"><span style="color: orange">Pending</span></div>',
__originalIndex__: 1,
},
{
id: 1,
name: "Jim Doe",
status:
'<div class="status-badge"><span style="color: green">Active</span></div>',
__originalIndex__: 0,
},
{
id: 3,
name: "Elon Musk",
status:
'<div class="status-badge"><span style="color: green">Active</span></div>',
__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: "<span>&copy; Active &amp; Ready</span>",
__originalIndex__: 0,
},
{
id: 2,
name: "Usain Bolt",
status: "<span>Pending &gt; Review</span>",
__originalIndex__: 1,
},
{
id: 3,
name: "Elon Musk",
status: "<span>&copy; Active &amp; Ready</span>",
__originalIndex__: 2,
},
];
// Test filtering with HTML entities
specialCharHTMLInput.filters = [
{
condition: "contains",
column: "status",
value: "Active & Ready",
},
];
const expected = [
{
id: 3,
name: "Elon Musk",
status: "<span>&copy; Active &amp; Ready</span>",
__originalIndex__: 2,
},
{
id: 1,
name: "Jim Doe",
status: "<span>&copy; Active &amp; Ready</span>",
__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: "<span>Active</span>",
__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", () => {

View File

@ -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<TableWidgetProps, WidgetState> {
//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<TableWidgetProps, WidgetState> {
/>
);
case ColumnTypes.HTML:
return (
<HTMLCell
allowCellWrapping={cellProperties.allowCellWrapping}
cellBackground={cellProperties.cellBackground}
compactMode={compactMode}
fontStyle={cellProperties.fontStyle}
horizontalAlignment={cellProperties.horizontalAlignment}
isCellDisabled={cellProperties.isCellDisabled}
isCellVisible={cellProperties.isCellVisible ?? true}
isHidden={isHidden}
renderMode={this.props.renderMode}
textColor={cellProperties.textColor}
textSize={cellProperties.textSize}
value={props.cell.value}
verticalAlignment={cellProperties.verticalAlignment}
/>
);
default:
let validationErrorMessage;

View File

@ -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])
);
},
};

View File

@ -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"],

View File

@ -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,

View File

@ -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;
}