chore: [one click binding] gsheets query adaptor (#23390)

## Description
Developed Gsheets query generator for one click binding epic.

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

#### Type of change
- New feature (non-breaking change which adds functionality)
- Chore (housekeeping or task changes that don't impact user perception)

## Testing
>
#### How Has This Been Tested?
- [x] Manual
- [x] Jest
- [ ] Cypress
>
>
#### Test Plan
> Add Testsmith test cases links that relate to this PR
>
>
#### Issues raised during DP testing
> Link issues raised during DP testing for better visiblity and tracking
(copy link from comments dropped on this PR)
>
>
>
## Checklist:
#### Dev activity
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [ ] 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
- [x] 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/Test-plan-implementation#speedbreaker-features-to-consider-for-every-change)
have been covered
- [ ] Test plan covers all impacted features and [areas of
interest](https://github.com/appsmithorg/TestSmith/wiki/Guidelines-for-test-plans/_edit#areas-of-interest)
- [ ] Test plan has been peer reviewed by project stakeholders and other
QA members
- [ ] Manually tested functionality on DP
- [ ] We had an implementation alignment call with stakeholders post QA
Round 2
- [ ] Cypress test cases have been added and approved by SDET/manual QA
- [ ] Added `Test Plan Approved` label after Cypress tests were reviewed
- [ ] Added `Test Plan Approved` label after JUnit tests were reviewed
This commit is contained in:
Vemparala Surya Vamsi 2023-06-12 14:12:59 +05:30 committed by GitHub
parent 6702ed2ea5
commit f9ad42f667
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 651 additions and 51 deletions

View File

@ -0,0 +1,268 @@
import GSheets from ".";
describe("GSheets WidgetQueryGenerator", () => {
const initialValues = {
actionConfiguration: {
formData: {
entityType: {
data: "ROWS",
},
tableHeaderIndex: {
data: "1",
},
projection: {
data: [],
},
queryFormat: {
data: "ROWS",
},
range: {
data: "",
},
where: {
data: {
condition: "AND",
},
},
pagination: {
data: {
limit: "{{Table1.pageSize}}",
offset: "{{Table1.pageOffset}}",
},
},
smartSubstitution: {
data: true,
},
},
},
};
test("should build select form data correctly", () => {
const expr = GSheets.build(
{
select: {
limit: "data_table.pageSize",
where: "data_table.searchText",
offset: "(data_table.pageNo - 1) * data_table.pageSize",
orderBy: "data_table.sortOrder.column || 'genres'",
sortOrder: 'data_table.sortOrder.order == "desc" ? -1 : 1',
},
totalRecord: false,
},
{
tableName: "someTableUrl",
datasourceId: "someId",
// ignore columns
aliases: [{ name: "someColumn1", alias: "someColumn1" }],
widgetId: "someWidgetId",
searchableColumn: "title",
columns: [],
primaryColumn: "",
sheetName: "someSheet",
tableHeaderIndex: 1,
},
initialValues,
);
expect(expr).toEqual([
{
name: "Find_query",
payload: {
formData: {
command: {
data: "FETCH_MANY",
},
entityType: {
data: "ROWS",
},
pagination: {
data: {
limit: "{{data_table.pageSize}}",
offset: "{{(data_table.pageNo - 1) * data_table.pageSize}}",
},
},
projection: {
data: [],
},
queryFormat: {
data: "ROWS",
},
range: {
data: "",
},
sheetName: {
data: "someSheet",
},
sheetUrl: {
data: "someTableUrl",
},
smartSubstitution: {
data: true,
},
sortBy: {
data: [
{
column: "{{data_table.sortOrder.column || 'genres'}}",
order: 'data_table.sortOrder.order == "desc" ? -1 : 1',
},
],
},
tableHeaderIndex: {
data: "1",
},
where: {
data: {
children: [
{
condition: "CONTAINS",
key: '{{data_table.searchText ? "title" : ""}}',
value: "{{data_table.searchText}}",
},
],
condition: "AND",
},
},
},
},
type: "select",
dynamicBindingPathList: [
{
key: "formData.where.data",
},
{
key: "formData.sortBy.data",
},
{
key: "formData.pagination.data",
},
],
},
]);
});
test("should build update form data correctly ", () => {
const expr = GSheets.build(
{
update: {
value: "update_form.formData",
},
totalRecord: false,
},
{
tableName: "someTableUrl",
datasourceId: "someId",
// ignore columns
aliases: [{ name: "someColumn1", alias: "someColumn1" }],
widgetId: "someWidgetId",
searchableColumn: "title",
columns: [],
primaryColumn: "",
sheetName: "someSheet",
tableHeaderIndex: 1,
},
initialValues,
);
expect(expr).toEqual([
{
name: "Update_query",
payload: {
formData: {
command: {
data: "UPDATE_ONE",
},
entityType: {
data: "ROWS",
},
queryFormat: {
data: "ROWS",
},
rowObjects: {
data: "{{update_form.formData}}",
},
sheetName: {
data: "someSheet",
},
sheetUrl: {
data: "someTableUrl",
},
smartSubstitution: {
data: true,
},
tableHeaderIndex: {
data: "1",
},
},
},
dynamicBindingPathList: [
{
key: "formData.rowObjects.data",
},
],
type: "update",
},
]);
});
test("should build insert form data correctly ", () => {
const expr = GSheets.build(
{
create: {
value: "insert_form.formData",
},
totalRecord: false,
},
{
tableName: "someTableUrl",
datasourceId: "someId",
// ignore columns
aliases: [{ name: "someColumn1", alias: "someColumn1" }],
widgetId: "someWidgetId",
searchableColumn: "title",
columns: [],
primaryColumn: "",
sheetName: "someSheet",
tableHeaderIndex: 1,
},
initialValues,
);
expect(expr).toEqual([
{
name: "Insert_query",
payload: {
formData: {
command: {
data: "INSERT_ONE",
},
entityType: {
data: "ROWS",
},
queryFormat: {
data: "ROWS",
},
rowObjects: {
data: "{{insert_form.formData}}",
},
sheetName: {
data: "someSheet",
},
sheetUrl: {
data: "someTableUrl",
},
smartSubstitution: {
data: true,
},
tableHeaderIndex: {
data: "1",
},
},
},
type: "create",
dynamicBindingPathList: [
{
key: "formData.rowObjects.data",
},
],
},
]);
});
});

View File

@ -0,0 +1,291 @@
import { isEmpty, isNumber, merge } from "lodash";
import { BaseQueryGenerator } from "WidgetQueryGenerators/BaseQueryGenerator";
import { QUERY_TYPE } from "WidgetQueryGenerators/types";
import type {
WidgetQueryGenerationConfig,
WidgetQueryGenerationFormConfig,
GSheetsFormData,
ActionConfigurationGSheets,
} from "WidgetQueryGenerators/types";
enum COMMAND_TYPES {
"FIND" = "FETCH_MANY",
"INSERT" = "INSERT_ONE",
"UPDATE" = "UPDATE_ONE",
"COUNT" = "FETCH_MANY",
}
const COMMON_INITIAL_VALUE_KEYS = [
"smartSubstitution",
"entityType",
"queryFormat",
];
const SELECT_INITAL_VALUE_KEYS = [
"range",
"where",
"pagination",
"tableHeaderIndex",
"projection",
];
export default abstract class GSheets extends BaseQueryGenerator {
private static buildBasicConfig(
command: COMMAND_TYPES,
tableName: string,
sheetName?: string,
tableHeaderIndex?: number,
) {
return {
command: { data: command },
sheetUrl: { data: tableName },
sheetName: { data: sheetName },
tableHeaderIndex: {
data: isNumber(tableHeaderIndex)
? tableHeaderIndex.toString()
: undefined,
},
};
}
private static buildFind(
widgetConfig: WidgetQueryGenerationConfig,
formConfig: WidgetQueryGenerationFormConfig,
) {
const { select } = widgetConfig;
if (select) {
return {
type: QUERY_TYPE.SELECT,
name: "Find_query",
formData: {
where: {
data: {
children: [
{
condition: "CONTAINS",
key: `{{${select["where"]} ? "${formConfig.searchableColumn}" : ""}}`,
value: `{{${select["where"]}}}`,
},
],
},
},
sortBy: {
data: [
{
column: `{{${select["orderBy"]}}}`,
order: select["sortOrder"],
},
],
},
pagination: {
data: {
limit: `{{${select["limit"]}}}`,
offset: `{{${select["offset"]}}}`,
},
},
...this.buildBasicConfig(
COMMAND_TYPES.FIND,
formConfig.tableName,
formConfig.sheetName,
formConfig.tableHeaderIndex,
),
},
dynamicBindingPathList: [
{
key: "formData.where.data",
},
{
key: "formData.sortBy.data",
},
{
key: "formData.pagination.data",
},
],
};
}
}
private static buildTotalRecord(
widgetConfig: WidgetQueryGenerationConfig,
formConfig: WidgetQueryGenerationFormConfig,
) {
const { select } = widgetConfig;
if (select) {
return {
type: QUERY_TYPE.TOTAL_RECORD,
name: "Total_record_query",
formData: {
where: {
data: {
children: [
{
condition: "CONTAINS",
key: `{{${select["where"]} ? "${formConfig.searchableColumn}" : ""}}`,
value: `{{${select["where"]}}}`,
},
],
},
},
...this.buildBasicConfig(
COMMAND_TYPES.COUNT,
formConfig.tableName,
formConfig.sheetName,
formConfig.tableHeaderIndex,
),
},
dynamicBindingPathList: [
{
key: "formData.where.data",
},
],
};
}
}
private static buildUpdate(
widgetConfig: WidgetQueryGenerationConfig,
formConfig: WidgetQueryGenerationFormConfig,
): Record<string, object | string> | undefined {
const { update } = widgetConfig;
if (update) {
return {
type: QUERY_TYPE.UPDATE,
name: "Update_query",
formData: {
rowObjects: {
data: `{{${update.value}}}`,
},
...this.buildBasicConfig(
COMMAND_TYPES.UPDATE,
formConfig.tableName,
formConfig.sheetName,
formConfig.tableHeaderIndex,
),
},
dynamicBindingPathList: [
{
key: "formData.rowObjects.data",
},
],
};
}
}
private static buildInsert(
widgetConfig: WidgetQueryGenerationConfig,
formConfig: WidgetQueryGenerationFormConfig,
) {
const { create } = widgetConfig;
if (create) {
return {
type: QUERY_TYPE.CREATE,
name: "Insert_query",
formData: {
rowObjects: {
data: `{{${create.value}}}`,
},
...this.buildBasicConfig(
COMMAND_TYPES.INSERT,
formConfig.tableName,
formConfig.sheetName,
formConfig.tableHeaderIndex,
),
},
dynamicBindingPathList: [
{
key: "formData.rowObjects.data",
},
],
};
}
}
private static createPayload(
initialValues: GSheetsFormData,
commandKey: string,
builtValues: Record<string, object | string> | undefined,
) {
if (!builtValues || isEmpty(builtValues)) {
return;
}
if (!initialValues || isEmpty(initialValues)) {
return builtValues;
}
const allowedInitialValueKeys = [
...COMMON_INITIAL_VALUE_KEYS,
...(commandKey === "find" ? SELECT_INITAL_VALUE_KEYS : []),
];
const scrubedOutInitialValues = allowedInitialValueKeys
.filter((key) => initialValues[key as keyof GSheetsFormData])
.reduce((acc, key) => {
acc[key] = initialValues[key as keyof GSheetsFormData];
return acc;
}, {} as Record<string, object>);
const { formData, ...rest } = builtValues;
return {
payload: {
formData: merge({}, scrubedOutInitialValues, formData),
},
...rest,
};
}
static build(
widgetConfig: WidgetQueryGenerationConfig,
formConfig: WidgetQueryGenerationFormConfig,
pluginInitalValues: { actionConfiguration: ActionConfigurationGSheets },
) {
const configs = [];
const initialValues = pluginInitalValues?.actionConfiguration?.formData;
if (widgetConfig.select) {
configs.push(
this.createPayload(
initialValues,
"find",
this.buildFind(widgetConfig, formConfig),
),
);
}
if (widgetConfig.update) {
configs.push(
this.createPayload(
initialValues,
"updateMany",
this.buildUpdate(widgetConfig, formConfig),
),
);
}
if (widgetConfig.create) {
configs.push(
this.createPayload(
initialValues,
"insert",
this.buildInsert(widgetConfig, formConfig),
),
);
}
if (widgetConfig.totalRecord) {
configs.push(
this.createPayload(
initialValues,
"count",
this.buildTotalRecord(widgetConfig, formConfig),
),
);
}
return configs.filter((val) => !!val);
}
static getTotalRecordExpression(binding: string) {
return `${binding}.length`;
}
}

View File

@ -1,7 +1,9 @@
import { PluginPackageName } from "entities/Action";
import WidgetQueryGeneratorRegistry from "utils/WidgetQueryGeneratorRegistry";
import GSheets from "./GSheets";
import MongoDB from "./MongoDB";
import PostgreSQL from "./PostgreSQL";
WidgetQueryGeneratorRegistry.register(PluginPackageName.MONGO, MongoDB);
WidgetQueryGeneratorRegistry.register(PluginPackageName.POSTGRES, PostgreSQL);
WidgetQueryGeneratorRegistry.register(PluginPackageName.GOOGLE_SHEETS, GSheets);

View File

@ -1,3 +1,7 @@
type GsheetConfig = {
sheetName?: string;
tableHeaderIndex?: number;
};
export type WidgetQueryGenerationFormConfig = {
tableName: string;
datasourceId: string;
@ -9,7 +13,7 @@ export type WidgetQueryGenerationFormConfig = {
searchableColumn: string;
columns: string[];
primaryColumn: string;
};
} & GsheetConfig;
export type WidgetQueryGenerationConfig = {
select?: {
@ -24,7 +28,7 @@ export type WidgetQueryGenerationConfig = {
};
update?: {
value: string;
where: string;
where?: string;
};
totalRecord: boolean;
};
@ -59,3 +63,17 @@ export type ActionConfigurationMongoDB = {
export type ActionConfigurationPostgreSQL = {
pluginSpecifiedTemplates: Array<object>;
};
export type GSheetsFormData = {
entityType: object;
tableHeaderIndex: object;
projection: object;
queryFormat: object;
range: object;
where: object;
pagination: object;
smartSubstitution: object;
};
export type ActionConfigurationGSheets = {
formData: GSheetsFormData;
};

View File

@ -1,7 +1,7 @@
import React, { memo } from "react";
import { ErrorMessage, SelectWrapper } from "../../styles";
import { ErrorMessage, Label, SelectWrapper } from "../../styles";
import { useTableOrSpreadsheet } from "./useTableOrSpreadsheet";
import { Select, Option } from "design-system";
import { Select, Option, Tooltip } from "design-system";
import { DropdownOption } from "../DatasourceDropdown/DropdownOption";
import type { DefaultOptionType } from "rc-select/lib/Select";
@ -11,6 +11,7 @@ function TableOrSpreadsheetDropdown() {
error,
isLoading,
label,
labelText,
onSelect,
options,
selected,
@ -20,7 +21,9 @@ function TableOrSpreadsheetDropdown() {
if (show) {
return (
<SelectWrapper className="space-y-2">
{label}
<Tooltip content={labelText}>
<Label>{label}</Label>
</Tooltip>
<Select
data-testid="t--one-click-binding-table-selector"
dropdownStyle={{

View File

@ -152,6 +152,7 @@ export function useTableOrSpreadsheet() {
error: isGoogleSheetPluginDS(selectedDatasourcePluginPackageName)
? spreadSheets?.error
: datasourceStructure?.error?.message,
labelText: `Select ${fieldName} from ${selectedDatasource?.name}`,
label: (
<Label>
Select {fieldName} from <Bold>{selectedDatasource?.name}</Bold>

View File

@ -1,7 +1,6 @@
import { ReduxActionTypes } from "@appsmith/constants/ReduxActionConstants";
import type { AppState } from "@appsmith/reducers";
import { PluginPackageName } from "entities/Action";
import { isNumber } from "lodash";
import { useContext } from "react";
import { useDispatch, useSelector } from "react-redux";
import { getWidget } from "sagas/selectors";
@ -9,6 +8,7 @@ import { getPluginPackageFromDatasourceId } from "selectors/entitiesSelector";
import { getisOneClickBindingConnectingForWidget } from "selectors/oneClickBindingSelectors";
import AnalyticsUtil from "utils/AnalyticsUtil";
import { WidgetQueryGeneratorFormContext } from "..";
import { isValidGsheetConfig } from "../utils";
import { useColumns } from "../WidgetSpecificControls/ColumnDropdown/useColumns";
export function useConnectData() {
@ -29,8 +29,10 @@ export function useConnectData() {
const onClick = () => {
const payload = {
tableName: config.table,
sheetName: config.sheet,
datasourceId: config.datasource,
widgetId: widgetId,
tableHeaderIndex: config.tableHeaderIndex,
searchableColumn: config.searchableColumn,
columns: columns.map((column) => column.name),
primaryColumn,
@ -63,9 +65,7 @@ export function useConnectData() {
const disabled =
!config.table ||
(selectedDatasourcePluginPackageName === PluginPackageName.GOOGLE_SHEETS &&
(!config.tableHeaderIndex ||
!isNumber(Number(config.tableHeaderIndex)) ||
isNaN(Number(config.tableHeaderIndex))));
!isValidGsheetConfig(config));
return {
show,

View File

@ -1,33 +1,45 @@
import { DROPDOWN_TRIGGER_DIMENSION } from "components/editorComponents/WidgetQueryGeneratorForm/constants";
import {
ErrorMessage,
Label,
SelectWrapper,
} from "components/editorComponents/WidgetQueryGeneratorForm/styles";
import { Dropdown } from "design-system-old";
import { Tooltip, Select } from "design-system";
import React, { memo } from "react";
import { useSheets } from "./useSheets";
export default memo(function SheetsDropdown() {
const { error, isLoading, label, onSelect, options, selected, show } =
useSheets();
const {
error,
isLoading,
label,
labelText,
onSelect,
options,
selected,
show,
} = useSheets();
if (show) {
return (
<SelectWrapper className="space-y-2">
<Label>{label}</Label>
<Dropdown
<Tooltip content={labelText}>
<Label>{label}</Label>
</Tooltip>
<Select
data-testid="t--sheetName-dropdown"
dropdownMaxHeight={"300px"}
errorMsg={error}
fillOptions
height={DROPDOWN_TRIGGER_DIMENSION.HEIGHT}
dropdownStyle={{
minWidth: "350px",
maxHeight: "300px",
}}
isLoading={isLoading}
isValid={!error}
onSelect={onSelect}
options={options}
selected={selected}
showLabelOnly
width={DROPDOWN_TRIGGER_DIMENSION.WIDTH}
placeholder="Select sheet"
showSearch
value={selected}
/>
<ErrorMessage>{error}</ErrorMessage>
</SelectWrapper>
);
} else {

View File

@ -76,6 +76,7 @@ export function useSheets() {
error: sheets?.error,
options,
isLoading,
labelText: "Select sheet from " + config.table,
label: (
<Label>
Select sheet from <Bold>{config.table}</Bold>

View File

@ -7,21 +7,15 @@ import { Colors } from "constants/Colors";
import { Icon } from "design-system";
import { Tooltip } from "design-system";
import React, { memo } from "react";
import {
Row,
RowHeading,
SelectWrapper,
TooltipWrapper,
} from "../../../styles";
import { Label, Row, RowHeading, SelectWrapper } from "../../../styles";
import styled from "styled-components";
import { useTableHeaderIndex } from "./useTableHeader";
import { Input } from "design-system";
const RoundBg = styled.div`
width: 16px;
height: 16px;
border-radius: 16px;
background-color: ${Colors.GRAY};
background-color: ${Colors.WHITE};
display: flex;
justify-content: center;
align-items: center;
@ -33,22 +27,29 @@ export default memo(function TableHeaderIndex() {
if (show) {
return (
<SelectWrapper className="space-y-2">
<Row>
<RowHeading>{createMessage(GEN_CRUD_TABLE_HEADER_LABEL)}</RowHeading>
<TooltipWrapper>
<Label>
<Row>
<RowHeading>
{createMessage(GEN_CRUD_TABLE_HEADER_LABEL)}
</RowHeading>
<Tooltip
content={createMessage(GEN_CRUD_TABLE_HEADER_TOOLTIP_DESC)}
>
<RoundBg>
<Icon name="help" />
<Icon name="question-line" size="md" />
</RoundBg>
</Tooltip>
</TooltipWrapper>
</Row>
</Row>
</Label>
<Input
className="space-y-4"
errorMessage={error}
isRequired
labelPosition="top"
onChange={onChange}
placeholder="Table Header Index"
placeholder="Table header index"
size="md"
type="number"
value={value.toString()}
/>
</SelectWrapper>

View File

@ -5,6 +5,7 @@ import { isNumber } from "lodash";
import { useCallback, useContext } from "react";
import { useDispatch, useSelector } from "react-redux";
import { getDatasource } from "selectors/entitiesSelector";
import { isValidGsheetConfig } from "components/editorComponents/WidgetQueryGeneratorForm/utils";
export function useTableHeaderIndex() {
const dispatch = useDispatch();
@ -45,13 +46,9 @@ export function useTableHeaderIndex() {
);
return {
error:
(!config.tableHeaderIndex ||
!isNumber(Number(config.tableHeaderIndex)) ||
isNaN(Number(config.tableHeaderIndex))) &&
"Please enter a positive number",
error: !isValidGsheetConfig(config) && "Please enter a positive number",
value: config.tableHeaderIndex,
onChange,
show: !!config.table,
show: !!config.table && !!config.sheet,
};
}

View File

@ -1,6 +1,5 @@
import React from "react";
import ColumnDropdown from "./ColumnDropdown";
import { Section } from "../styles";
import { noop } from "lodash";
type Props = {
@ -30,9 +29,9 @@ export default function WidgetSpecificControls(props: Props) {
}
return (
<Section>
<>
{searchableColumn}
{aliasPicker}
</Section>
</>
);
}

View File

@ -15,6 +15,8 @@ export const SelectWrapper = styled.div`
export const Label = styled.p`
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
export const Bold = styled.span`
@ -29,10 +31,6 @@ export const Row = styled.div`
justify-content: flex-start;
`;
export const TooltipWrapper = styled.div`
margin-top: 2px;
`;
export const RowHeading = styled.p`
margin-right: 10px;
`;

View File

@ -1,2 +1,11 @@
import { isNumber } from "lodash";
export const getSheetUrl = (sheetId: string): string =>
`https://docs.google.com/spreadsheets/d/${sheetId}/edit#gid=0`;
export const isValidGsheetConfig = (config: Record<string, any>) =>
config.sheet &&
config.tableHeaderIndex &&
isNumber(Number(config.tableHeaderIndex)) &&
!isNaN(Number(config.tableHeaderIndex)) &&
config.tableHeaderIndex > 0;

View File

@ -37,7 +37,7 @@ class OneClickBindingControl extends BaseControl<OneClickBindingControlProps> {
this.props.propertyName
];
if (errorObj && errorObj.length && errorObj[0].errorMessage) {
if (errorObj?.[0]?.errorMessage) {
return errorObj[0].errorMessage.message;
} else {
return "";