feat: Implements HTML as a column type in table widget. (#37997)
This commit is contained in:
parent
dcdd52f5ff
commit
3610bae834
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
56
app/client/cypress/fixtures/htmlCellInTableWidgetV2.ts
Normal file
56
app/client/cypress/fixtures/htmlCellInTableWidgetV2.ts
Normal 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",
|
||||
},
|
||||
];
|
||||
|
|
@ -27,7 +27,8 @@ type columnTypeValues =
|
|||
| "Button"
|
||||
| "Menu button"
|
||||
| "Icon button"
|
||||
| "Select";
|
||||
| "Select"
|
||||
| "HTML";
|
||||
|
||||
export class Table {
|
||||
private agHelper = ObjectsRegistry.AggregateHelper;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { getCellText } from "./PlainTextCell";
|
||||
import { getCellText } from "../PlainTextCell";
|
||||
import { ColumnTypes } from "widgets/TableWidgetV2/constants";
|
||||
|
||||
describe("DefaultRendere - ", () => {
|
||||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>© Active & Ready</span>",
|
||||
__originalIndex__: 0,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Usain Bolt",
|
||||
status: "<span>Pending > Review</span>",
|
||||
__originalIndex__: 1,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Elon Musk",
|
||||
status: "<span>© Active & 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>© Active & Ready</span>",
|
||||
__originalIndex__: 2,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: "Jim Doe",
|
||||
status: "<span>© Active & 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", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
@ -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"],
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user