PromucFlow_constructor/app/client/src/components/propertyControls/JSONFormComputeControl.tsx
srix d34edec1f4
feat: assistive binding (#27070)
> Pull Request Template

## Description
An assistive Binding feature is added. A new code editor hinter menu
will pop up once three characters is pressed, and they match with any
entities. This assistance is expected to help many new app builders
discover binding features.

PRD: [Widget binding &
success](https://www.notion.so/appsmith/Widget-binding-success-bc2f559b67194891992c6163eb8ac457)
UI Design :
[Zeplin](https://app.zeplin.io/project/64df0f50e3f9570e8dcfc803/screen/64df0fa0e771af22508f2267)
POC: [POC for Binding Success -
Engineering](https://www.notion.so/appsmith/POC-for-Binding-Success-Engineering-07157e8e90c7451a850d6d054d975f36)
ERD : [Engineering Requirement - Assistive
Binding](https://www.notion.so/appsmith/Engineering-Requirement-Assistive-Binding-b04e41f07e3b4c998be7b8b49f8324ba)


#### PR fixes following issue(s)
Fixes # (issue number)

When a users input within a property of a widget matches any
query/api/jsobject of their application, a dropdown menu will appear
with possible binding options for users to select from. #26682

When the user adds a new binding from the menu the cursor should be
present in between the moustache bindings #26683

When a user toggles JS mode for the input, bindings with the cursor in
between them should be present by default (incase input has no value)
#26685

#### Media

#### Type of change
> Please delete options that are not relevant.
- New feature (non-breaking change which adds functionality)


## Testing
>
#### How Has This Been Tested?
> Please describe the tests that you ran to verify your changes. Also
list any relevant details for your test configuration.
> Delete anything that is not relevant
- [x] Manual
- [x] Cypress
>
>
#### Test Plan
https://github.com/appsmithorg/TestSmith/issues/2507
#### Issues raised during DP testing
> Link issues raised during DP testing for better visiblity and tracking
(copy link from comments dropped on this PR)

- [x]
https://github.com/appsmithorg/appsmith/pull/27070#issuecomment-1710094372
- [x]
https://github.com/appsmithorg/appsmith/pull/27070#issuecomment-1711189712
- [x]
https://github.com/appsmithorg/appsmith/pull/27070#issuecomment-1711209028
- [x]
https://github.com/appsmithorg/appsmith/pull/27070#issuecomment-1711214677
- [x]
https://github.com/appsmithorg/appsmith/pull/27070#issuecomment-1711311082
- [x]
https://github.com/appsmithorg/appsmith/pull/27070#issuecomment-1711321208
- [x]
https://github.com/appsmithorg/appsmith/pull/27070#issuecomment-1711336112

## Checklist:
#### Dev activity
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] PR is being merged under a feature flag


#### QA activity:
- [ ] [Speedbreak
features](https://github.com/appsmithorg/TestSmith/wiki/Guidelines-for-test-plans#speedbreakers-)
have been covered
- [x] Test plan covers all impacted features and [areas of
interest](https://github.com/appsmithorg/TestSmith/wiki/Guidelines-for-test-plans#areas-of-interest-)
- [x] Test plan has been peer reviewed by project stakeholders and other
QA members
- [x] Manually tested functionality on DP
- [x] We had an implementation alignment call with stakeholders post QA
Round 2
- [x] Cypress test cases have been added and approved by SDET/manual QA
- [x] Added `Test Plan Approved` label after Cypress tests were reviewed
- [ ] Added `Test Plan Approved` label after JUnit tests were reviewed

---------

Co-authored-by: arunvjn <arun@appsmith.com>
Co-authored-by: Favour Ohans <fohanekwu@gmail.com>
Co-authored-by: Aishwarya UR <aishwarya@appsmith.com>
2023-09-15 21:23:51 +05:30

316 lines
8.8 KiB
TypeScript

import React from "react";
import { isString } from "lodash";
import type { ControlProps } from "./BaseControl";
import BaseControl from "./BaseControl";
import { StyledDynamicInput } from "./StyledControls";
import type { CodeEditorExpected } from "components/editorComponents/CodeEditor";
import type { EditorTheme } from "components/editorComponents/CodeEditor/EditorConfig";
import {
EditorModes,
EditorSize,
TabBehaviour,
} from "components/editorComponents/CodeEditor/EditorConfig";
import { getDynamicBindings, isDynamicValue } from "utils/DynamicBindingUtils";
import styled from "styled-components";
import type { JSONFormWidgetProps } from "widgets/JSONFormWidget/widget";
import type { Schema, SchemaItem } from "widgets/JSONFormWidget/constants";
import {
ARRAY_ITEM_KEY,
DataType,
FIELD_TYPE_TO_POTENTIAL_DATA,
getBindingTemplate,
ROOT_SCHEMA_KEY,
} from "widgets/JSONFormWidget/constants";
import LazyCodeEditor from "components/editorComponents/LazyCodeEditor";
import { assistiveBindingHinter } from "components/editorComponents/CodeEditor/assistiveBindingHinter";
const PromptMessage = styled.span`
line-height: 17px;
> .code-wrapper {
font-family: var(--ads-v2-font-family-code);
display: inline-flex;
align-items: center;
}
`;
const CurlyBraces = styled.span`
color: var(--ads-v2-color-fg);
background-color: var(--ads-v2-color-bg-muted);
border-radius: 2px;
padding: 2px;
margin: 0 2px 0 0;
font-size: 10px;
font-weight: var(--ads-v2-font-weight-bold);
`;
// Auxiliary function for processArray, which returns the value for an object field
function processObject(schema: Schema, defaultValue?: any) {
const obj: Record<string, any> = {};
Object.values(schema).forEach((schemaItem) => {
obj[schemaItem.accessor] = processSchemaItemAutocomplete(
schemaItem,
defaultValue,
);
});
return obj;
}
// Auxiliary function for processArray, which returns the value for an array field
function processArray(schema: Schema, defaultValue?: any): any[] {
if (schema[ARRAY_ITEM_KEY]) {
return [
processSchemaItemAutocomplete(schema[ARRAY_ITEM_KEY], defaultValue),
];
}
return [];
}
/**
* This function takes a schemaItem, traverses through it and creates an object out of it. This
* object would look like the form data and this object would be used for autocomplete.
* Eg - {
* fieldType: object,
* children: {
* name: {
* accessor: "name"
* fieldType: "string"
* },
* age: {
* accessor: "आयु"
* fieldType: "number"
* }
* }
* }
*
* @returns
* {
* name: "",
* आयु: 0
* }
*
* @param schema
* @param defaultValue Values that the autocomplete should show for a particular field
*/
export function processSchemaItemAutocomplete(
schemaItem: SchemaItem,
defaultValue?: any,
) {
if (schemaItem.dataType === DataType.OBJECT) {
return processObject(schemaItem.children, defaultValue);
}
if (schemaItem.dataType === DataType.ARRAY) {
return processArray(schemaItem.children, defaultValue);
}
return defaultValue || FIELD_TYPE_TO_POTENTIAL_DATA[schemaItem.fieldType];
}
export function InputText(props: {
label: string;
value: string;
onChange: (event: React.ChangeEvent<HTMLTextAreaElement> | string) => void;
evaluatedValue?: any;
expected?: CodeEditorExpected;
placeholder?: string;
dataTreePath?: string;
additionalDynamicData: Record<string, Record<string, any>>;
theme: EditorTheme;
}) {
const {
additionalDynamicData,
dataTreePath,
evaluatedValue,
expected,
onChange,
placeholder,
theme,
value,
} = props;
return (
<StyledDynamicInput>
<LazyCodeEditor
AIAssisted
additionalDynamicData={additionalDynamicData}
dataTreePath={dataTreePath}
evaluatedValue={evaluatedValue}
expected={expected}
hinting={[assistiveBindingHinter]}
input={{
value: value,
onChange: onChange,
}}
mode={EditorModes.TEXT_WITH_BINDING}
placeholder={placeholder}
positionCursorInsideBinding
promptMessage={
<PromptMessage>
Access the current form using{" "}
<span className="code-wrapper">
<CurlyBraces>{"{{"}</CurlyBraces>
sourceData.fieldName
<CurlyBraces>{"}}"}</CurlyBraces>
</span>
</PromptMessage>
}
size={EditorSize.EXTENDED}
tabBehaviour={TabBehaviour.INDENT}
theme={theme}
/>
</StyledDynamicInput>
);
}
export const stringToJS = (string: string): string => {
const { jsSnippets, stringSegments } = getDynamicBindings(string);
const js = stringSegments
.map((segment, index) => {
if (jsSnippets[index] && jsSnippets[index].length > 0) {
return jsSnippets[index];
} else {
return `\`${segment}\``;
}
})
.join(" + ");
return js;
};
export const JSToString = (js: string): string => {
const segments = js.split(" + ");
return segments
.map((segment) => {
if (segment.charAt(0) === "`") {
return segment.substring(1, segment.length - 1);
} else return "{{" + segment + "}}";
})
.join("");
};
class JSONFormComputeControl extends BaseControl<JSONFormComputeControlProps> {
static getInputComputedValue = (
propertyValue: string,
widgetName: string,
) => {
if (!isDynamicValue(propertyValue)) return propertyValue;
const { prefixTemplate, suffixTemplate } = getBindingTemplate(widgetName);
const value = propertyValue.substring(
prefixTemplate.length,
propertyValue.length - suffixTemplate.length,
);
return JSToString(value);
};
getComputedValue = (value: string) => {
const { widgetName } = this.props.widgetProperties;
/**
* If the input value is not a binding then there is no need of adding binding template
* to the value as it would be of no use.
*
* Original motivation of doing this to allow REGEX to work. If the value is REGEX, eg - ^\d+$ and the
* binding template is added, the REGEX is processed by evaluation and the "\" is considered as a escape and
* is removed from the final value and the regex become ^d+$. In order to make it work inside a binding the "\"
* has to be escaped by doing ^\\d+$.
* As the user is unaware of this binding template being added under the hood, it is not obvious for the user
* to escape the "\".
* Thus now we only add the binding template around a value only if the original value has a binding as that could
* be an indication of the usage of formData/sourceData/fieldState in the value.
*/
if (!isDynamicValue(value)) return value;
const stringToEvaluate = stringToJS(value);
const { prefixTemplate, suffixTemplate } = getBindingTemplate(widgetName);
if (stringToEvaluate === "") {
return stringToEvaluate;
}
return `${prefixTemplate}${stringToEvaluate}${suffixTemplate}`;
};
onTextChange = (event: React.ChangeEvent<HTMLTextAreaElement> | string) => {
const value = isString(event) ? event : event.target?.value;
const output = this.getComputedValue(value);
this.updateProperty(this.props.propertyName, output);
};
render() {
const {
dataTreePath,
defaultValue,
expected,
label,
placeholderText,
propertyValue,
theme,
widgetProperties,
} = this.props;
const { schema } = widgetProperties;
const rootSchemaItem = schema[ROOT_SCHEMA_KEY] || {};
const { sourceData } = rootSchemaItem;
const baseSchemaStructure = processSchemaItemAutocomplete(rootSchemaItem);
const fieldStateStructure = processSchemaItemAutocomplete(rootSchemaItem, {
isVisible: true,
isDisabled: true,
isRequired: true,
isValid: true,
});
const value = (() => {
if (propertyValue && isDynamicValue(propertyValue)) {
const { widgetName } = this.props.widgetProperties;
return JSONFormComputeControl.getInputComputedValue(
propertyValue,
widgetName,
);
}
return propertyValue || defaultValue;
})();
if (value && !propertyValue) {
this.onTextChange(value);
}
return (
<InputText
additionalDynamicData={{
sourceData,
formData: baseSchemaStructure,
fieldState: fieldStateStructure,
}}
dataTreePath={dataTreePath}
expected={expected}
label={label}
onChange={this.onTextChange}
placeholder={placeholderText}
theme={theme}
value={value}
/>
);
}
static getControlType() {
return "JSON_FORM_COMPUTE_VALUE";
}
}
export interface JSONFormComputeControlProps extends ControlProps {
defaultValue?: string;
widgetProperties: JSONFormWidgetProps;
placeholderText?: string;
}
export default JSONFormComputeControl;