From f9ad42f667397cd3886e7318b32830703eca5ee6 Mon Sep 17 00:00:00 2001 From: Vemparala Surya Vamsi <121419957+vsvamsi1@users.noreply.github.com> Date: Mon, 12 Jun 2023 14:12:59 +0530 Subject: [PATCH] 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 --- .../GSheets/index.test.ts | 268 ++++++++++++++++ .../WidgetQueryGenerators/GSheets/index.ts | 291 ++++++++++++++++++ app/client/src/WidgetQueryGenerators/index.ts | 2 + app/client/src/WidgetQueryGenerators/types.ts | 22 +- .../TableOrSpreadsheetDropdown/index.tsx | 9 +- .../useTableOrSpreadsheet.tsx | 1 + .../ConnectData/useConnectData.ts | 8 +- .../SheetsDropdown/index.tsx | 38 ++- .../SheetsDropdown/useSheets.tsx | 1 + .../TableHeaderIndex/index.tsx | 31 +- .../TableHeaderIndex/useTableHeader.ts | 9 +- .../WidgetSpecificControls/index.tsx | 5 +- .../WidgetQueryGeneratorForm/styles.tsx | 6 +- .../WidgetQueryGeneratorForm/utils.ts | 9 + .../OneClickBindingControl.tsx | 2 +- 15 files changed, 651 insertions(+), 51 deletions(-) create mode 100644 app/client/src/WidgetQueryGenerators/GSheets/index.test.ts create mode 100644 app/client/src/WidgetQueryGenerators/GSheets/index.ts diff --git a/app/client/src/WidgetQueryGenerators/GSheets/index.test.ts b/app/client/src/WidgetQueryGenerators/GSheets/index.test.ts new file mode 100644 index 0000000000..7e0822a255 --- /dev/null +++ b/app/client/src/WidgetQueryGenerators/GSheets/index.test.ts @@ -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", + }, + ], + }, + ]); + }); +}); diff --git a/app/client/src/WidgetQueryGenerators/GSheets/index.ts b/app/client/src/WidgetQueryGenerators/GSheets/index.ts new file mode 100644 index 0000000000..692c1a5023 --- /dev/null +++ b/app/client/src/WidgetQueryGenerators/GSheets/index.ts @@ -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 | 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 | 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); + + 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`; + } +} diff --git a/app/client/src/WidgetQueryGenerators/index.ts b/app/client/src/WidgetQueryGenerators/index.ts index 6571a3754c..37d52b8074 100644 --- a/app/client/src/WidgetQueryGenerators/index.ts +++ b/app/client/src/WidgetQueryGenerators/index.ts @@ -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); diff --git a/app/client/src/WidgetQueryGenerators/types.ts b/app/client/src/WidgetQueryGenerators/types.ts index f5691139d0..fcc588ac17 100644 --- a/app/client/src/WidgetQueryGenerators/types.ts +++ b/app/client/src/WidgetQueryGenerators/types.ts @@ -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; }; + +export type GSheetsFormData = { + entityType: object; + tableHeaderIndex: object; + projection: object; + queryFormat: object; + range: object; + where: object; + pagination: object; + smartSubstitution: object; +}; +export type ActionConfigurationGSheets = { + formData: GSheetsFormData; +}; diff --git a/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/CommonControls/TableOrSpreadsheetDropdown/index.tsx b/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/CommonControls/TableOrSpreadsheetDropdown/index.tsx index a09bc624bd..dd9e5c15a3 100644 --- a/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/CommonControls/TableOrSpreadsheetDropdown/index.tsx +++ b/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/CommonControls/TableOrSpreadsheetDropdown/index.tsx @@ -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 ( - {label} + + + + {error} ); } else { diff --git a/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/DatasourceSpecificControls/GoogleSheetControls/SheetsDropdown/useSheets.tsx b/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/DatasourceSpecificControls/GoogleSheetControls/SheetsDropdown/useSheets.tsx index 1dfebd397d..863622a257 100644 --- a/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/DatasourceSpecificControls/GoogleSheetControls/SheetsDropdown/useSheets.tsx +++ b/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/DatasourceSpecificControls/GoogleSheetControls/SheetsDropdown/useSheets.tsx @@ -76,6 +76,7 @@ export function useSheets() { error: sheets?.error, options, isLoading, + labelText: "Select sheet from " + config.table, label: ( diff --git a/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/DatasourceSpecificControls/GoogleSheetControls/TableHeaderIndex/useTableHeader.ts b/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/DatasourceSpecificControls/GoogleSheetControls/TableHeaderIndex/useTableHeader.ts index cd5907abe3..5d42de64ff 100644 --- a/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/DatasourceSpecificControls/GoogleSheetControls/TableHeaderIndex/useTableHeader.ts +++ b/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/DatasourceSpecificControls/GoogleSheetControls/TableHeaderIndex/useTableHeader.ts @@ -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, }; } diff --git a/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/WidgetSpecificControls/index.tsx b/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/WidgetSpecificControls/index.tsx index 94728a9723..5c203d0c89 100644 --- a/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/WidgetSpecificControls/index.tsx +++ b/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/WidgetSpecificControls/index.tsx @@ -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 ( -
+ <> {searchableColumn} {aliasPicker} -
+ ); } diff --git a/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/styles.tsx b/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/styles.tsx index b62d33ccda..26807f915d 100644 --- a/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/styles.tsx +++ b/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/styles.tsx @@ -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; `; diff --git a/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/utils.ts b/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/utils.ts index dd8390e875..ff5a265ec1 100644 --- a/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/utils.ts +++ b/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/utils.ts @@ -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) => + config.sheet && + config.tableHeaderIndex && + isNumber(Number(config.tableHeaderIndex)) && + !isNaN(Number(config.tableHeaderIndex)) && + config.tableHeaderIndex > 0; diff --git a/app/client/src/components/propertyControls/OneClickBindingControl.tsx b/app/client/src/components/propertyControls/OneClickBindingControl.tsx index db01c474fc..9a1e7c5538 100644 --- a/app/client/src/components/propertyControls/OneClickBindingControl.tsx +++ b/app/client/src/components/propertyControls/OneClickBindingControl.tsx @@ -37,7 +37,7 @@ class OneClickBindingControl extends BaseControl { this.props.propertyName ]; - if (errorObj && errorObj.length && errorObj[0].errorMessage) { + if (errorObj?.[0]?.errorMessage) { return errorObj[0].errorMessage.message; } else { return "";