PromucFlow_constructor/app/client/src/components/propertyControls/PrimaryColumnsControlV2.tsx
Keyur Paralkar b22ad3a769
feat: added column freeze and unfreeze functionality to table widget (#18757)
**PRD**:
https://www.notion.so/appsmith/Ability-to-freeze-columns-dd118f7ed2e14e008ee305056b79874a?d=300f4968889244da9f737e1bfd8c06dc#2ddaf28e10a0475cb69f1af77b938d0b

This PR adds the following features to the table widget:

- Freeze the columns to the left or right of the table.(Both canvas and
page view mode).
- Unfreeze the frozen columns. (Both canvas and page view mode).
- Columns that are left frozen, will get unfrozen at a position after
the last left frozen column. (Both canvas and page view mode).
- Columns that are right frozen, will get unfrozen at a position before
the first right frozen column. (Both canvas and page view mode).
- Column order can be persisted in the Page view mode.
- Users can also unfreeze the columns that are frozen by the developers.
- Columns that are frozen cannot be reordered(Both canvas and page view
mode)
- **Property pane changes (Columns property)**:
- If the column is frozen to the left then that column should appear at
top of the list.
- If the column is frozen to the right then that column should appear at
the bottom of the list.
- The columns that are frozen cannot be moved or re-ordered in the list.
They remain fixed in their position.
- In-Page mode, If there is a change in frozen or unfrozen columns in
multiple tables then the order of columns and frozen and unfrozen
columns should get persisted on refresh i.e. changes should get
persisted across refreshes.
2023-02-15 17:12:46 +05:30

570 lines
17 KiB
TypeScript

import React, { Component } from "react";
import { AppState } from "@appsmith/reducers";
import { connect } from "react-redux";
import { Placement } from "popper.js";
import * as Sentry from "@sentry/react";
import _, { toString } from "lodash";
import BaseControl, { ControlProps } from "./BaseControl";
import { StyledPropertyPaneButton } from "./StyledControls";
import styled from "styled-components";
import { Indices } from "constants/Layers";
import { Size, Category } from "design-system-old";
import EmptyDataState from "components/utils/EmptyDataState";
import EvaluatedValuePopup from "components/editorComponents/CodeEditor/EvaluatedValuePopup";
import { EditorTheme } from "components/editorComponents/CodeEditor/EditorConfig";
import { CodeEditorExpected } from "components/editorComponents/CodeEditor";
import {
ColumnProperties,
StickyType,
} from "widgets/TableWidgetV2/component/Constants";
import {
createColumn,
isColumnTypeEditable,
reorderColumns,
} from "widgets/TableWidgetV2/widget/utilities";
import { DataTree } from "entities/DataTree/dataTreeFactory";
import {
getDataTreeForAutocomplete,
getPathEvalErrors,
} from "selectors/dataTreeSelectors";
import {
EvaluationError,
getEvalValuePath,
isDynamicValue,
} from "utils/DynamicBindingUtils";
import { DraggableListCard } from "components/propertyControls/DraggableListCard";
import { Checkbox, CheckboxType } from "design-system-old";
import { ColumnTypes } from "widgets/TableWidgetV2/constants";
import { Colors } from "constants/Colors";
import { DraggableListControl } from "pages/Editor/PropertyPane/DraggableListControl";
const TabsWrapper = styled.div`
width: 100%;
display: flex;
flex-direction: column;
`;
const AddColumnButton = styled(StyledPropertyPaneButton)`
width: 100%;
display: flex;
justify-content: center;
&&&& {
margin-top: 12px;
margin-bottom: 8px;
}
`;
const EdtiableCheckboxWrapper = styled.div<{ rightPadding: boolean | null }>`
position: relative;
${(props) => props.rightPadding && `right: 6px;`}
`;
interface ReduxStateProps {
dynamicData: DataTree;
datasources: any;
errors: EvaluationError[];
}
interface EvaluatedValueProps {
isFocused: boolean;
theme: EditorTheme;
popperPlacement?: Placement;
popperZIndex?: Indices;
dataTreePath?: string;
evaluatedValue?: any;
expected?: CodeEditorExpected;
hideEvaluatedValue?: boolean;
useValidationMessage?: boolean;
children: JSX.Element;
}
type EvaluatedValuePopupWrapperProps = ReduxStateProps & EvaluatedValueProps;
type ColumnsType = Record<string, ColumnProperties>;
const getOriginalColumn = (
columns: ColumnsType,
index: number,
columnOrder?: string[],
): ColumnProperties | undefined => {
const reorderedColumns = reorderColumns(columns, columnOrder || []);
const column: ColumnProperties | undefined = Object.values(
reorderedColumns,
).find((column: ColumnProperties) => column.index === index);
return column;
};
type State = {
focusedIndex: number | null;
duplicateColumnIds: string[];
hasEditableColumn: boolean;
hasScrollableList: boolean;
};
const LIST_CLASSNAME = "tablewidgetv2-primarycolumn-list";
class PrimaryColumnsControlV2 extends BaseControl<ControlProps, State> {
constructor(props: ControlProps) {
super(props);
const columns: ColumnsType = props.propertyValue || {};
const columnOrder = Object.keys(columns);
const reorderedColumns = reorderColumns(columns, columnOrder);
const tableColumnLabels = _.map(reorderedColumns, "label");
const duplicateColumnIds = [];
for (let index = 0; index < tableColumnLabels.length; index++) {
const currLabel = tableColumnLabels[index] as string;
const duplicateValueIndex = tableColumnLabels.indexOf(currLabel);
if (duplicateValueIndex !== index) {
// get column id from columnOrder index
duplicateColumnIds.push(reorderedColumns[columnOrder[index]].id);
}
}
this.state = {
focusedIndex: null,
duplicateColumnIds,
hasEditableColumn: false,
hasScrollableList: false,
};
}
componentDidMount() {
this.checkAndUpdateIfEditableColumnPresent();
}
componentDidUpdate(prevProps: ControlProps): void {
/**
* On adding a new column the last column should get focused.
* If frozen columns are present then the focus should be on the newly added column
*/
if (
Object.keys(prevProps.propertyValue).length + 1 ===
Object.keys(this.props.propertyValue).length
) {
const columns = Object.keys(this.props.propertyValue);
const frozenColumnIndex = Object.keys(prevProps.propertyValue)
.map((column) => prevProps.propertyValue[column])
.filter((column) => column.sticky !== StickyType.RIGHT).length;
this.updateFocus(
frozenColumnIndex === 0 ? columns.length - 1 : frozenColumnIndex,
true,
);
this.checkAndUpdateIfEditableColumnPresent();
}
const listElement = document.querySelector(`.${LIST_CLASSNAME}`);
requestAnimationFrame(() => {
const hasScrollableList =
listElement && listElement?.scrollHeight > listElement?.clientHeight;
if (hasScrollableList !== this.state.hasScrollableList) {
this.setState({
hasScrollableList: !!hasScrollableList,
});
}
});
}
render() {
// Get columns from widget properties
const columns: ColumnsType = this.props.propertyValue || {};
// If there are no columns, show empty state
if (Object.keys(columns).length === 0) {
return <EmptyDataState />;
}
// Get an empty array of length of columns
let columnOrder: string[] = new Array(Object.keys(columns).length);
if (this.props.widgetProperties.columnOrder) {
columnOrder = this.props.widgetProperties.columnOrder;
} else {
columnOrder = Object.keys(columns);
}
const reorderedColumns = reorderColumns(columns, columnOrder);
const draggableComponentColumns = Object.values(reorderedColumns).map(
(column: ColumnProperties) => {
return {
label: column.label || "",
id: column.id,
isVisible: column.isVisible,
isDerived:
column.isDerived && column.columnType !== ColumnTypes.EDIT_ACTIONS,
index: column.index,
isDuplicateLabel: _.includes(
this.state.duplicateColumnIds,
column.id,
),
isChecked:
isColumnTypeEditable(column.columnType) && column.isEditable,
isCheckboxDisabled:
!isColumnTypeEditable(column.columnType) || column.isDerived,
isDragDisabled:
column.sticky === StickyType.LEFT ||
column.sticky === StickyType.RIGHT,
};
},
);
const column: ColumnProperties | undefined = Object.values(
reorderedColumns,
).find(
(column: ColumnProperties) => column.index === this.state.focusedIndex,
);
// show popup on duplicate column label input focused
const isFocused =
!_.isNull(this.state.focusedIndex) &&
_.includes(this.state.duplicateColumnIds, column?.id);
return (
<>
<div className="flex pt-2 pb-2 justify-between">
<div>{Object.values(reorderedColumns).length} columns</div>
{this.state.hasEditableColumn && (
<EdtiableCheckboxWrapper
className="flex t--uber-editable-checkbox"
rightPadding={this.state.hasScrollableList}
>
<span className="mr-2">Editable</span>
<Checkbox
backgroundColor={Colors.GREY_600}
isDefaultChecked={this.isAllColumnsEditable()}
label=""
onCheckChange={this.toggleAllColumnsEditability}
type={CheckboxType.SECONDARY}
/>
</EdtiableCheckboxWrapper>
)}
</div>
<TabsWrapper>
<EvaluatedValuePopupWrapper {...this.props} isFocused={isFocused}>
<DraggableListControl
className={LIST_CLASSNAME}
deleteOption={this.deleteOption}
fixedHeight={370}
focusedIndex={this.state.focusedIndex}
itemHeight={45}
items={draggableComponentColumns}
keyAccessor="id"
onEdit={this.onEdit}
propertyPath={this.props.dataTreePath}
renderComponent={(props: any) =>
DraggableListCard({
...props,
showCheckbox: true,
placeholder: "Column Title",
})
}
toggleCheckbox={this.toggleCheckbox}
toggleVisibility={this.toggleVisibility}
updateFocus={this.updateFocus}
updateItems={this.updateItems}
updateOption={this.updateOption}
/>
</EvaluatedValuePopupWrapper>
<AddColumnButton
category={Category.secondary}
className="t--add-column-btn"
icon="plus"
onClick={this.addNewColumn}
size={Size.medium}
tag="button"
text="Add a new column"
type="button"
/>
</TabsWrapper>
</>
);
}
addNewColumn = () => {
const column = createColumn(this.props.widgetProperties, "customColumn");
this.updateProperty(`${this.props.propertyName}.${column.id}`, column);
};
onEdit = (index: number) => {
const columns: ColumnsType = this.props.propertyValue || {};
const originalColumn = getOriginalColumn(
columns,
index,
this.props.widgetProperties.columnOrder,
);
this.props.openNextPanel({
...originalColumn,
propPaneId: this.props.widgetProperties.widgetId,
});
};
//Used to reorder columns
updateItems = (items: Array<Record<string, unknown>>) => {
this.updateProperty(
"columnOrder",
items.map(({ id }) => id),
);
};
toggleVisibility = (index: number) => {
const columns: ColumnsType = this.props.propertyValue || {};
const originalColumn = getOriginalColumn(
columns,
index,
this.props.widgetProperties.columnOrder,
);
if (originalColumn) {
this.updateProperty(
`${this.props.propertyName}.${originalColumn.id}.isVisible`,
!originalColumn.isVisible,
);
}
};
toggleCheckbox = (index: number, checked: boolean) => {
const columns: ColumnsType = this.props.propertyValue || {};
const originalColumn = getOriginalColumn(
columns,
index,
this.props.widgetProperties.columnOrder,
);
if (originalColumn) {
this.updateProperty(
`${this.props.propertyName}.${originalColumn.id}.isEditable`,
checked,
);
/*
* Check whether isCellEditable property of the column has dynamic value
* if not, toggle isCellEditable value as well. We're doing this to smooth
* the user experience.
*/
if (!isDynamicValue(toString(originalColumn.isCellEditable))) {
this.updateProperty(
`${this.props.propertyName}.${originalColumn.id}.isCellEditable`,
checked,
);
}
}
};
deleteOption = (index: number) => {
const columns: ColumnsType = this.props.propertyValue || {};
const columnOrder = this.props.widgetProperties.columnOrder || [];
const originalColumn = getOriginalColumn(columns, index, columnOrder);
if (originalColumn) {
const propertiesToDelete = [
`${this.props.propertyName}.${originalColumn.id}`,
];
const columnOrderIndex = columnOrder.findIndex(
(column: string) => column === originalColumn.id,
);
if (columnOrderIndex > -1)
propertiesToDelete.push(`columnOrder[${columnOrderIndex}]`);
this.deleteProperties(propertiesToDelete);
// if column deleted, clean up duplicateIndexes
let duplicateColumnIds = [...this.state.duplicateColumnIds];
duplicateColumnIds = duplicateColumnIds.filter(
(id) => id !== originalColumn.id,
);
this.setState({ duplicateColumnIds });
}
};
updateOption = (index: number, updatedLabel: string) => {
const columns: ColumnsType = this.props.propertyValue || {};
const originalColumn = getOriginalColumn(
columns,
index,
this.props.widgetProperties.columnOrder,
);
if (originalColumn) {
this.updateProperty(
`${this.props.propertyName}.${originalColumn.id}.label`,
updatedLabel,
);
// check entered label is unique or duplicate
const tableColumnLabels = _.map(columns, "label");
let duplicateColumnIds = [...this.state.duplicateColumnIds];
// if duplicate, add into array
if (_.includes(tableColumnLabels, updatedLabel)) {
duplicateColumnIds.push(originalColumn.id);
this.setState({ duplicateColumnIds });
} else {
duplicateColumnIds = duplicateColumnIds.filter(
(id) => id !== originalColumn.id,
);
this.setState({ duplicateColumnIds });
}
}
};
updateFocus = (index: number, isFocused: boolean) => {
this.setState({ focusedIndex: isFocused ? index : null });
};
isAllColumnsEditable = () => {
const columns: ColumnsType = this.props.propertyValue || {};
return !Object.values(columns).find(
(column) =>
!column.isEditable &&
isColumnTypeEditable(column.columnType) &&
!column.isDerived,
);
};
toggleAllColumnsEditability = () => {
const isEditable = this.isAllColumnsEditable();
const columns: ColumnsType = this.props.propertyValue || {};
Object.values(columns).forEach((column) => {
if (isColumnTypeEditable(column.columnType) && !column.isDerived) {
this.updateProperty(
`${this.props.propertyName}.${column.id}.isEditable`,
!isEditable,
);
/*
* Check whether isCellEditable property of the column has dynamic value.
* if not, toggle isCellEditable value as well. We're doing this to smooth
* the user experience.
*/
if (!isDynamicValue(toString(column.isCellEditable))) {
this.updateProperty(
`${this.props.propertyName}.${column.id}.isCellEditable`,
!isEditable,
);
}
}
});
if (isEditable) {
const columnOrder = this.props.widgetProperties.columnOrder || [];
const editActionColumn = Object.values(columns).find(
(column) => column.columnType === ColumnTypes.EDIT_ACTIONS,
);
if (editActionColumn) {
this.deleteOption(columnOrder.indexOf(editActionColumn.id));
}
}
};
checkAndUpdateIfEditableColumnPresent = () => {
const hasEditableColumn = !!Object.values(
this.props.propertyValue,
).find((column) =>
isColumnTypeEditable((column as ColumnProperties).columnType),
);
if (hasEditableColumn !== this.state.hasEditableColumn) {
this.setState({
hasEditableColumn,
});
}
};
static getControlType() {
return "PRIMARY_COLUMNS_V2";
}
}
export default PrimaryColumnsControlV2;
/**
* wrapper component on dragable primary columns
* render popup if primary column labels are not unique
* show unique name error in PRIMARY_COLUMNS
*/
class EvaluatedValuePopupWrapperClass extends Component<
EvaluatedValuePopupWrapperProps
> {
getPropertyValidation = (
dataTree: DataTree,
dataTreePath?: string,
): {
isInvalid: boolean;
errors: EvaluationError[];
pathEvaluatedValue: unknown;
} => {
const { errors } = this.props;
if (!dataTreePath) {
return {
isInvalid: false,
errors: [],
pathEvaluatedValue: undefined,
};
}
const pathEvaluatedValue = _.get(dataTree, getEvalValuePath(dataTreePath));
return {
isInvalid: errors.length > 0,
errors: errors,
pathEvaluatedValue,
};
};
render = () => {
const {
dataTreePath,
dynamicData,
evaluatedValue,
expected,
hideEvaluatedValue,
useValidationMessage,
} = this.props;
const {
errors,
isInvalid,
pathEvaluatedValue,
} = this.getPropertyValidation(dynamicData, dataTreePath);
let evaluated = evaluatedValue;
if (dataTreePath) {
evaluated = pathEvaluatedValue;
}
return (
<EvaluatedValuePopup
errors={errors}
evaluatedValue={evaluated}
expected={expected}
hasError={isInvalid}
hideEvaluatedValue={hideEvaluatedValue}
isOpen={this.props.isFocused && isInvalid}
popperPlacement={this.props.popperPlacement}
popperZIndex={this.props.popperZIndex}
theme={this.props.theme || EditorTheme.LIGHT}
useValidationMessage={useValidationMessage}
>
{this.props.children}
</EvaluatedValuePopup>
);
};
}
const mapStateToProps = (
state: AppState,
{ dataTreePath }: EvaluatedValueProps,
): ReduxStateProps => {
return {
dynamicData: getDataTreeForAutocomplete(state),
datasources: state.entities.datasources,
errors: dataTreePath ? getPathEvalErrors(state, dataTreePath) : [],
};
};
const EvaluatedValuePopupWrapper = Sentry.withProfiler(
connect(mapStateToProps)(EvaluatedValuePopupWrapperClass),
);