diff --git a/app/client/cypress/fixtures/example.json b/app/client/cypress/fixtures/example.json index b0193ab60f..0c97a68480 100644 --- a/app/client/cypress/fixtures/example.json +++ b/app/client/cypress/fixtures/example.json @@ -120,6 +120,7 @@ "TableInput": [ { "id": 2381224, + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGNiAAAABgADNjd8qAAAAABJRU5ErkJggg==", "email": "michael.lawson@reqres.in", "userName": "Michael Lawson", "productName": "Chicken Sandwich", @@ -127,6 +128,7 @@ }, { "id": 2736212, + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGNiAAAABgADNjd8qAAAAABJRU5ErkJggg==", "email": "lindsay.ferguson@reqres.in", "userName": "Lindsay Ferguson", "productName": "Tuna Salad", @@ -134,6 +136,7 @@ }, { "id": 6788734, + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGNiAAAABgADNjd8qAAAAABJRU5ErkJggg==", "email": "tobias.funke@reqres.in", "userName": "Tobias Funke", "productName": "Beef steak", @@ -141,6 +144,7 @@ }, { "id": 7434532, + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGNiAAAABgADNjd8qAAAAABJRU5ErkJggg==", "email": "byron.fields@reqres.in", "userName": "Byron Fields", "productName": "Chicken Sandwich", @@ -148,6 +152,7 @@ }, { "id": 7434532, + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGNiAAAABgADNjd8qAAAAABJRU5ErkJggg==", "email": "ryan.holmes@reqres.in", "userName": "Ryan Holmes", "productName": "Avocado Panini", diff --git a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/DisplayWidgets/Table_spec.js b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/DisplayWidgets/Table_spec.js index 587fb6f576..87c5320215 100644 --- a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/DisplayWidgets/Table_spec.js +++ b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/DisplayWidgets/Table_spec.js @@ -39,18 +39,33 @@ describe("Table Widget Functionality", function() { cy.get(commonlocators.editPropCrossButton).click(); cy.PublishtheApp(); }); + it("Table Widget Functionality To Verify The Data", function() { cy.isSelectRow(1); - cy.readTabledataPublish("1", "2").then((tabData) => { + cy.readTabledataPublish("1", "3").then((tabData) => { const tabValue = tabData; expect(tabValue).to.be.equal("Lindsay Ferguson"); cy.log("the value is" + tabValue); }); }); + it("Table Widget Functionality To Show a Base64 Image", function() { + cy.get(publish.backToEditor).click(); + cy.openPropertyPane("tablewidget"); + cy.editColumn("image"); + cy.changeColumnType("Image"); + cy.isSelectRow(1); + + const index = 1; + const imageVal = this.data.TableInput[index].image; + cy.readTableLinkPublish(index, "1").then((hrefVal) => { + expect(hrefVal).to.be.equal(imageVal); + }); + }); + it("Table Widget Functionality To Search The Data", function() { cy.isSelectRow(1); - cy.readTabledataPublish("1", "2").then((tabData) => { + cy.readTabledataPublish("1", "3").then((tabData) => { const tabValue = tabData; expect(tabValue).to.be.equal("Lindsay Ferguson"); cy.log("the value is" + tabValue); @@ -58,7 +73,7 @@ describe("Table Widget Functionality", function() { .first() .type(tabData); cy.wait(500); - cy.readTabledataPublish("0", "2").then((tabData) => { + cy.readTabledataPublish("1", "3").then((tabData) => { const tabValue = tabData; expect(tabValue).to.be.equal("Lindsay Ferguson"); }); @@ -69,7 +84,7 @@ describe("Table Widget Functionality", function() { .clear() .type("7434532"); cy.wait(1000); - cy.readTabledataPublish("0", "2").then((tabData) => { + cy.readTabledataPublish("3", "3").then((tabData) => { const tabValue = tabData; expect(tabValue).to.be.equal("Byron Fields"); }); @@ -82,7 +97,7 @@ describe("Table Widget Functionality", function() { .clear(); cy.wait(1000); cy.isSelectRow(1); - cy.readTabledataPublish("1", "2").then((tabData) => { + cy.readTabledataPublish("1", "3").then((tabData) => { const tabValue = tabData; expect(tabValue).to.be.equal("Lindsay Ferguson"); cy.log("the value is" + tabValue); @@ -100,14 +115,14 @@ describe("Table Widget Functionality", function() { cy.get(publish.canvas) .first() .click(); - cy.readTabledataPublish("0", "2").then((tabData) => { + cy.readTabledataPublish("0", "3").then((tabData) => { const tabValue = tabData; expect(tabValue).to.be.equal("Lindsay Ferguson"); }); cy.get(publish.filterBtn).click(); cy.get(publish.removeFilter).click(); cy.wait(500); - cy.readTabledataPublish("0", "2").then((tabData) => { + cy.readTabledataPublish("0", "3").then((tabData) => { const tabValue = tabData; expect(tabValue).to.be.equal("Michael Lawson"); }); @@ -119,7 +134,7 @@ describe("Table Widget Functionality", function() { it("Table Widget Functionality To Filter The Data using contains", function() { cy.isSelectRow(1); - cy.readTabledataPublish("1", "2").then((tabData) => { + cy.readTabledataPublish("1", "3").then((tabData) => { const tabValue = tabData; expect(tabValue).to.be.equal("Lindsay Ferguson"); cy.log("the value is" + tabValue); @@ -137,14 +152,14 @@ describe("Table Widget Functionality", function() { cy.get(publish.canvas) .first() .click(); - cy.readTabledataPublish("0", "2").then((tabData) => { + cy.readTabledataPublish("0", "3").then((tabData) => { const tabValue = tabData; expect(tabValue).to.be.equal("Lindsay Ferguson"); }); cy.get(publish.filterBtn).click(); cy.get(publish.removeFilter).click(); cy.wait(500); - cy.readTabledataPublish("0", "2").then((tabData) => { + cy.readTabledataPublish("0", "3").then((tabData) => { const tabValue = tabData; expect(tabValue).to.be.equal("Michael Lawson"); }); @@ -156,7 +171,7 @@ describe("Table Widget Functionality", function() { it("Table Widget Functionality To Filter The Data using starts with ", function() { cy.isSelectRow(1); - cy.readTabledataPublish("1", "2").then((tabData) => { + cy.readTabledataPublish("1", "3").then((tabData) => { const tabValue = tabData; expect(tabValue).to.be.equal("Lindsay Ferguson"); cy.log("the value is" + tabValue); @@ -174,14 +189,14 @@ describe("Table Widget Functionality", function() { cy.get(publish.canvas) .first() .click(); - cy.readTabledataPublish("0", "2").then((tabData) => { + cy.readTabledataPublish("0", "3").then((tabData) => { const tabValue = tabData; expect(tabValue).to.be.equal("Lindsay Ferguson"); }); cy.get(publish.filterBtn).click(); cy.get(publish.removeFilter).click(); cy.wait(500); - cy.readTabledataPublish("0", "2").then((tabData) => { + cy.readTabledataPublish("0", "3").then((tabData) => { const tabValue = tabData; expect(tabValue).to.be.equal("Michael Lawson"); }); @@ -193,7 +208,7 @@ describe("Table Widget Functionality", function() { it("Table Widget Functionality To Filter The Data using ends with ", function() { cy.isSelectRow(1); - cy.readTabledataPublish("1", "2").then((tabData) => { + cy.readTabledataPublish("1", "3").then((tabData) => { const tabValue = tabData; expect(tabValue).to.be.equal("Lindsay Ferguson"); cy.log("the value is" + tabValue); @@ -211,14 +226,14 @@ describe("Table Widget Functionality", function() { cy.get(publish.canvas) .first() .click(); - cy.readTabledataPublish("0", "2").then((tabData) => { + cy.readTabledataPublish("0", "3").then((tabData) => { const tabValue = tabData; expect(tabValue).to.be.equal("Lindsay Ferguson"); }); cy.get(publish.filterBtn).click(); cy.get(publish.removeFilter).click(); cy.wait(500); - cy.readTabledataPublish("0", "2").then((tabData) => { + cy.readTabledataPublish("0", "3").then((tabData) => { const tabValue = tabData; expect(tabValue).to.be.equal("Michael Lawson"); }); @@ -230,7 +245,7 @@ describe("Table Widget Functionality", function() { it("Table Widget Functionality To Check Compact Mode", function() { cy.isSelectRow(1); - cy.readTabledataPublish("1", "2").then((tabData) => { + cy.readTabledataPublish("1", "3").then((tabData) => { const tabValue = tabData; expect(tabValue).to.be.equal("Lindsay Ferguson"); cy.log("the value is" + tabValue); @@ -238,7 +253,7 @@ describe("Table Widget Functionality", function() { cy.get(publish.compactOpt) .contains("Tall") .click(); - cy.scrollTabledataPublish("3", "2").then((tabData) => { + cy.scrollTabledataPublish("3", "3").then((tabData) => { const tabValue = tabData; expect(tabValue).to.be.equal("Byron Fields"); }); @@ -246,7 +261,7 @@ describe("Table Widget Functionality", function() { cy.get(publish.compactOpt) .contains("Short") .click(); - cy.readTabledataPublish("4", "2").then((tabData) => { + cy.readTabledataPublish("4", "3").then((tabData) => { const tabValue = tabData; expect(tabValue).to.be.equal("Ryan Holmes"); }); @@ -261,7 +276,7 @@ describe("Table Widget Functionality", function() { .first() .click(); cy.isSelectRow(1); - cy.readTabledataPublish("1", "2").then(tabData => { + cy.readTabledataPublish("1", "3").then(tabData => { const tabValue = tabData; expect(tabValue).to.be.equal("Lindsay Ferguson"); cy.log("the value is" + tabValue); @@ -270,7 +285,7 @@ describe("Table Widget Functionality", function() { .contains("userName") .click(); cy.get(publish.containerWidget).click(); - cy.readTabledataPublish("1", "2").then(tabData => { + cy.readTabledataPublish("1", "3").then(tabData => { const tabValue = tabData; expect(tabValue).to.not.equal("Lindsay Ferguson"); }); @@ -279,7 +294,7 @@ describe("Table Widget Functionality", function() { .contains("userName") .click(); cy.get(publish.containerWidget).click(); - cy.readTabledataPublish("1", "2").then(tabData => { + cy.readTabledataPublish("1", "3").then(tabData => { const tabValue = tabData; expect(tabValue).to.be.equal("Lindsay Ferguson"); }); diff --git a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/GlobalSearch/GlobalSearch_spec.js b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/GlobalSearch/GlobalSearch_spec.js new file mode 100644 index 0000000000..5b0763637d --- /dev/null +++ b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/GlobalSearch/GlobalSearch_spec.js @@ -0,0 +1,122 @@ +const commonlocators = require("../../../../locators/commonlocators.json"); +const queryLocators = require("../../../../locators/QueryEditor.json"); +const dsl = require("../../../../fixtures/MultipleWidgetDsl.json"); + +describe("GlobalSearch", function() { + before(() => { + cy.addDsl(dsl); + }); + + it("showsAndHidesUsingKeyboardShortcuts", () => { + const isMac = Cypress.platform === "darwin"; + if (isMac) { + cy.wait(2000); + cy.get("body").type("{cmd}{k}"); + cy.get(commonlocators.globalSearchModal); + cy.get("body").type("{esc}"); + cy.get(commonlocators.globalSearchModal).should("not.exist"); + } else { + cy.wait(2000); + cy.get("body").type("{ctrl}{k}"); + cy.get(commonlocators.globalSearchModal); + cy.get("body").type("{esc}"); + cy.get(commonlocators.globalSearchModal).should("not.exist"); + } + }); + + it("selectsWidget", () => { + const table = dsl.dsl.children[2]; + cy.get(commonlocators.globalSearchTrigger).click({ force: true }); + cy.wait(1000); + cy.get(commonlocators.globalSearchInput).type(table.widgetName); + cy.get("body").type("{enter}"); + cy.window() + .its("store") + .invoke("getState") + .then((state) => { + const { selectedWidget } = state.ui.widgetDragResize; + expect(selectedWidget).to.be.equal(table.widgetId); + }); + }); + + it("navigatesToApi", () => { + cy.NavigateToAPI_Panel(); + cy.CreateAPI("SomeApi"); + + cy.get(commonlocators.globalSearchTrigger).click({ force: true }); + cy.wait(1000); + cy.get(commonlocators.globalSearchClearInput).click({ force: true }); + cy.get(commonlocators.globalSearchInput).type("Page1"); + cy.get("body").type("{enter}"); + + cy.get(commonlocators.globalSearchTrigger).click({ force: true }); + cy.wait(1000); + cy.get(commonlocators.globalSearchClearInput).click({ force: true }); + cy.get(commonlocators.globalSearchInput).type("SomeApi"); + cy.get("body").type("{enter}"); + cy.window() + .its("store") + .invoke("getState") + .then((state) => { + const { actions } = state.entities; + const expectedAction = actions.find( + (actions) => actions.config.name === "SomeApi", + ); + cy.location().should((loc) => { + expect(loc.pathname).includes(expectedAction.config.id); + }); + }); + }); + + it("navigatesToDatasourceHavingAQuery", () => { + cy.window() + .its("store") + .invoke("getState") + .then((state) => { + cy.createPostgresDatasource(); + cy.NavigateToQueryEditor(); + + const { datasources } = state.entities; + const expectedDatasource = + datasources.list[datasources.list.length - 1]; + + cy.contains(".t--datasource-name", expectedDatasource.name) + .find(queryLocators.createQuery) + .click(); + + cy.get(commonlocators.globalSearchTrigger).click({ force: true }); + cy.wait(1000); + cy.get(commonlocators.globalSearchClearInput).click({ force: true }); + cy.get(commonlocators.globalSearchInput).type("Page1"); + cy.get("body").type("{enter}"); + + cy.get(commonlocators.globalSearchTrigger).click({ force: true }); + cy.wait(1000); + cy.get(commonlocators.globalSearchClearInput).click({ force: true }); + cy.get(commonlocators.globalSearchInput).type(expectedDatasource.name); + cy.get("body").type("{enter}"); + cy.location().should((loc) => { + expect(loc.pathname).includes(expectedDatasource.id); + }); + }); + }); + + it("navigatesToPage", () => { + cy.Createpage("NewPage"); + cy.get(commonlocators.globalSearchTrigger).click({ force: true }); + cy.wait(1000); + cy.get(commonlocators.globalSearchClearInput).click({ force: true }); + cy.get(commonlocators.globalSearchInput).type("Page1"); + cy.get("body").type("{enter}"); + cy.window() + .its("store") + .invoke("getState") + .then((state) => { + const { pages } = state.entities.pageList; + const expectedPage = pages.find((page) => page.pageName === "Page1"); + cy.location().should((loc) => { + expect(loc.pathname).includes(expectedPage.pageId); + }); + }); + }); +}); diff --git a/app/client/cypress/locators/commonlocators.json b/app/client/cypress/locators/commonlocators.json index a147b98e6e..e057e1896c 100644 --- a/app/client/cypress/locators/commonlocators.json +++ b/app/client/cypress/locators/commonlocators.json @@ -100,5 +100,9 @@ "filePickerUploadButton": ".uppy-StatusBar-actionBtn--upload", "filePickerOnFilesSelected": ".t--property-control-onfilesselected", "dataType": ".t--property-control-datatype", - "evaluateMsg": ".t--CodeEditor-evaluatedValue p" -} + "evaluateMsg": ".t--CodeEditor-evaluatedValue p", + "globalSearchModal": ".t--global-search-modal", + "globalSearchInput": ".t--global-search-input", + "globalSearchTrigger": ".t--global-search-modal-trigger", + "globalSearchClearInput": ".t--global-clear-input" +} \ No newline at end of file diff --git a/app/client/cypress/manual_TestSuite/Modal_Spec.js b/app/client/cypress/manual_TestSuite/Modal_Spec.js new file mode 100644 index 0000000000..1c183dec0b --- /dev/null +++ b/app/client/cypress/manual_TestSuite/Modal_Spec.js @@ -0,0 +1,50 @@ +const dsl = require("../../../fixtures/ModalWidgetDsl.json"); + +describe("Modal Functionality ", function() { + +it("Collapse the tabs of Property pane", function() + { + // Add a modal widget from teh entity explorer + // Click on the property Pane + // Select Form Type as Modal Type + // Add any widget on the Modal + // Add a table + // Click on the property pane + // Add a custom column + // Click on control pane of the column + // Select Column type as button + // Add action to "on click" + // Add Modal + // Close the modal + // Click on the Table Action button + // Ensure the modal pop up + } + ) + + it("Rename a modal", function() + { + // Click on the entity explore + // Ensure modal is dispalyed to user + // Rename the modal + // Ensure the modal name is replaced in the table + // Click on the action button + // Ensure the modal pop up + } + ) +it("Convert Modal to ", function() + { + // Click on the entity explore + // Ensure modal is dispalyed to user + // Add a button widget + // Add an "On click" action with modal + // Click on the button + // Ensure the Alert modal is dispalyed to user + // Now click on the Modal in entity explorer + // Convert the Modal from "Alert" to "Form" + // Click on the button + // Ensure a form modal is dispalyed to user + } + ) + +} +) diff --git a/app/client/cypress/manual_TestSuite/new_Table_Spec.js b/app/client/cypress/manual_TestSuite/new_Table_Spec.js new file mode 100644 index 0000000000..d7742fe184 --- /dev/null +++ b/app/client/cypress/manual_TestSuite/new_Table_Spec.js @@ -0,0 +1,53 @@ +const dsl = require("../../../fixtures/tableWidgetDsl.json"); + +describe("Table functionality ", function() { + it("Adding background Colour to table", function() + { + // Add a table + // Click on the property pane + // Scroll Styles + // Add background colour + // Add Text Colour + // Navigate to one of the column + // Click on the setting/ Control pane of the column + // Navigate to add background colour and Text colour + // Ensure the row colour gets overlapped on table colour + } + ) + it("Collapse the tabs of Property pane", function() + { + // Add a table + // Click on the property pane + // Collapse the General ,Action and Tab option + } + ) + it("Bind the column with same name", function() + { + // Add a table + // Click on the property pane + // Click on the Add new column + // Ensure to add two new column + // Name two column with same name + // Add an input widget + // Bind the column with new column name + // Select the row from the binded table + } + ) + + it("Hide and created custom column ", function() + { + // Add a table + // Click on the property pane + // Click on the Add new column + // Click on Setting of column + // Select Column Type "Date" + // Now navigate to exsiting column + // Click on the hide icon + // and observe on edit mode the table column is dispalyed + // Click on deploy + // Ensure the hidden column is not displayed and custom column is disaplyed to user + } + ) + +} +) diff --git a/app/client/cypress/support/commands.js b/app/client/cypress/support/commands.js index 63ef42eae6..2ac304b46b 100644 --- a/app/client/cypress/support/commands.js +++ b/app/client/cypress/support/commands.js @@ -2098,6 +2098,12 @@ Cypress.Commands.add("scrollTabledataPublish", (rowNum, colNum) => { return tabVal; }); +Cypress.Commands.add("readTableLinkPublish", (rowNum, colNum) => { + const selector = `.t--widget-tablewidget .tbody .td[data-rowindex=${rowNum}][data-colindex=${colNum}] a`; + const hrefVal = cy.get(selector).invoke("attr", "href"); + return hrefVal; +}); + Cypress.Commands.add("assertEvaluatedValuePopup", (expectedType) => { cy.get(dynamicInputLocators.evaluatedValue) .should("be.visible") diff --git a/app/client/package.json b/app/client/package.json index 0b9ca59fd5..17b993c423 100644 --- a/app/client/package.json +++ b/app/client/package.json @@ -73,11 +73,13 @@ "lodash": "^4.17.19", "loglevel": "^1.6.7", "lottie-web": "^5.7.4", + "marked": "^2.0.0", "moment": "^2.24.0", "moment-timezone": "^0.5.27", "nanoid": "^2.0.4", "node-sass": "^4.11.0", "normalizr": "^3.3.0", + "path-to-regexp": "^6.2.0", "popper.js": "^1.15.0", "prettier": "^1.18.2", "prismjs": "^1.23.0", @@ -112,6 +114,7 @@ "redux-form": "^8.2.6", "redux-saga": "^1.1.3", "reselect": "^4.0.0", + "scroll-into-view-if-needed": "^2.2.26", "shallowequal": "^1.1.0", "smartlook-client": "^4.5.1", "styled-components": "^5.2.0", @@ -172,6 +175,7 @@ "@types/deep-diff": "^1.0.0", "@types/downloadjs": "^1.4.2", "@types/jest": "^24.0.22", + "@types/marked": "^1.2.2", "@types/react-beautiful-dnd": "^11.0.4", "@types/react-select": "^3.0.5", "@types/react-tabs": "^2.3.1", diff --git a/app/client/src/actions/controlActions.tsx b/app/client/src/actions/controlActions.tsx index 6e529a1943..567438dc51 100644 --- a/app/client/src/actions/controlActions.tsx +++ b/app/client/src/actions/controlActions.tsx @@ -1,6 +1,5 @@ import { ReduxActionTypes, ReduxAction } from "constants/ReduxActionConstants"; import { RenderMode } from "constants/WidgetConstants"; -import { BatchAction, batchAction } from "actions/batchActions"; import { DynamicPath } from "utils/DynamicBindingUtils"; export const updateWidgetPropertyRequest = ( @@ -20,24 +19,6 @@ export const updateWidgetPropertyRequest = ( }; }; -export const updateWidgetProperty = ( - widgetId: string, - updates: Record, - dynamicUpdates?: { - dynamicBindingPathList: DynamicPath[]; - dynamicTriggerPathList: DynamicPath[]; - }, -): BatchAction => { - return batchAction({ - type: ReduxActionTypes.UPDATE_WIDGET_PROPERTY, - payload: { - widgetId, - updates, - dynamicUpdates, - }, - }); -}; - export interface BatchPropertyUpdatePayload { modify?: Record; //Key value pairs of paths and values to update remove?: string[]; //Array of paths to delete diff --git a/app/client/src/actions/globalSearchActions.ts b/app/client/src/actions/globalSearchActions.ts new file mode 100644 index 0000000000..25383afb95 --- /dev/null +++ b/app/client/src/actions/globalSearchActions.ts @@ -0,0 +1,34 @@ +import { ReduxActionTypes } from "constants/ReduxActionConstants"; +import { RecentEntity } from "components/editorComponents/GlobalSearch/utils"; + +export const setGlobalSearchQuery = (query: string) => ({ + type: ReduxActionTypes.SET_GLOBAL_SEARCH_QUERY, + payload: query, +}); + +export const toggleShowGlobalSearchModal = () => ({ + type: ReduxActionTypes.TOGGLE_SHOW_GLOBAL_SEARCH_MODAL, +}); + +export const updateRecentEntity = (payload: RecentEntity) => ({ + type: ReduxActionTypes.UPDATE_RECENT_ENTITY, + payload, +}); + +export const restoreRecentEntitiesRequest = (payload: string) => ({ + type: ReduxActionTypes.RESTORE_RECENT_ENTITIES_REQUEST, + payload, +}); + +export const restoreRecentEntitiesSuccess = () => ({ + type: ReduxActionTypes.RESTORE_RECENT_ENTITIES_SUCCESS, +}); + +export const resetRecentEntities = () => ({ + type: ReduxActionTypes.RESET_RECENT_ENTITIES, +}); + +export const setRecentEntities = (payload: Array) => ({ + type: ReduxActionTypes.SET_RECENT_ENTITIES, + payload, +}); diff --git a/app/client/src/actions/initActions.ts b/app/client/src/actions/initActions.ts index 9c3854e816..a8267536e2 100644 --- a/app/client/src/actions/initActions.ts +++ b/app/client/src/actions/initActions.ts @@ -14,3 +14,11 @@ export const initEditor = ( pageId, }, }); + +export const resetEditorRequest = () => ({ + type: ReduxActionTypes.RESET_EDITOR_REQUEST, +}); + +export const resetEditorSuccess = () => ({ + type: ReduxActionTypes.RESET_EDITOR_SUCCESS, +}); diff --git a/app/client/src/actions/recentEntityActions.ts b/app/client/src/actions/recentEntityActions.ts new file mode 100644 index 0000000000..967c209667 --- /dev/null +++ b/app/client/src/actions/recentEntityActions.ts @@ -0,0 +1,6 @@ +import { ReduxActionTypes } from "constants/ReduxActionConstants"; + +export const handlePathUpdated = (pathName: string) => ({ + type: ReduxActionTypes.HANDLE_PATH_UPDATED, + payload: { pathName }, +}); diff --git a/app/client/src/actions/widgetActions.tsx b/app/client/src/actions/widgetActions.tsx index 50674ebaf5..c6c6170d63 100644 --- a/app/client/src/actions/widgetActions.tsx +++ b/app/client/src/actions/widgetActions.tsx @@ -66,6 +66,13 @@ export const focusWidget = ( payload: { widgetId }, }); +export const selectWidget = ( + widgetId?: string, +): ReduxAction<{ widgetId?: string }> => ({ + type: ReduxActionTypes.SELECT_WIDGET, + payload: { widgetId }, +}); + export const showModal = (id: string) => { return { type: ReduxActionTypes.SHOW_MODAL, diff --git a/app/client/src/api/ApplicationApi.tsx b/app/client/src/api/ApplicationApi.tsx index 1dfa43cb80..9423fe3cd2 100644 --- a/app/client/src/api/ApplicationApi.tsx +++ b/app/client/src/api/ApplicationApi.tsx @@ -65,6 +65,11 @@ export interface DuplicateApplicationRequest { applicationId: string; } +export interface ForkApplicationRequest { + applicationId: string; + organizationId: string; +} + export interface GetAllApplicationResponse extends ApiResponse { data: Array; } @@ -196,6 +201,17 @@ class ApplicationApi extends Api { ): AxiosPromise { return Api.post(ApplicationApi.baseURL + "clone/" + request.applicationId); } + + static forkApplication( + request: ForkApplicationRequest, + ): AxiosPromise { + return Api.post( + "v1/applications/" + + request.applicationId + + "/fork/" + + request.organizationId, + ); + } } export default ApplicationApi; diff --git a/app/client/src/assets/gifs/deploy_orange.gif b/app/client/src/assets/gifs/deploy_orange.gif index 1a244d3b35..d6e999fde8 100644 Binary files a/app/client/src/assets/gifs/deploy_orange.gif and b/app/client/src/assets/gifs/deploy_orange.gif differ diff --git a/app/client/src/assets/gifs/handwave.gif b/app/client/src/assets/gifs/handwave.gif index fd46f86ce5..936c67894b 100644 Binary files a/app/client/src/assets/gifs/handwave.gif and b/app/client/src/assets/gifs/handwave.gif differ diff --git a/app/client/src/assets/gifs/input_drag.gif b/app/client/src/assets/gifs/input_drag.gif index 6edb888fac..de93b4f9e5 100644 Binary files a/app/client/src/assets/gifs/input_drag.gif and b/app/client/src/assets/gifs/input_drag.gif differ diff --git a/app/client/src/assets/gifs/onsubmit.gif b/app/client/src/assets/gifs/onsubmit.gif index bf9b5bb139..09fad03e4c 100644 Binary files a/app/client/src/assets/gifs/onsubmit.gif and b/app/client/src/assets/gifs/onsubmit.gif differ diff --git a/app/client/src/assets/gifs/super_hero.gif b/app/client/src/assets/gifs/super_hero.gif index 0c55aff84b..0fabde9bbb 100644 Binary files a/app/client/src/assets/gifs/super_hero.gif and b/app/client/src/assets/gifs/super_hero.gif differ diff --git a/app/client/src/assets/gifs/table_drag.gif b/app/client/src/assets/gifs/table_drag.gif index 6e1446f373..af65672d74 100644 Binary files a/app/client/src/assets/gifs/table_drag.gif and b/app/client/src/assets/gifs/table_drag.gif differ diff --git a/app/client/src/assets/icons/ads/book.svg b/app/client/src/assets/icons/ads/book.svg index 5bf30f5fa3..67ae3f8b60 100644 --- a/app/client/src/assets/icons/ads/book.svg +++ b/app/client/src/assets/icons/ads/book.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/app/client/src/assets/icons/ads/desktop.svg b/app/client/src/assets/icons/ads/desktop.svg index 26debfc887..60eab7ec35 100644 --- a/app/client/src/assets/icons/ads/desktop.svg +++ b/app/client/src/assets/icons/ads/desktop.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/app/client/src/assets/icons/ads/docs.svg b/app/client/src/assets/icons/ads/docs.svg new file mode 100644 index 0000000000..1605226f58 --- /dev/null +++ b/app/client/src/assets/icons/ads/docs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/client/src/assets/icons/ads/entities.svg b/app/client/src/assets/icons/ads/entities.svg new file mode 100644 index 0000000000..181f517469 --- /dev/null +++ b/app/client/src/assets/icons/ads/entities.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/client/src/assets/icons/ads/fluid.svg b/app/client/src/assets/icons/ads/fluid.svg index 1f72390ebc..c9c164575b 100644 --- a/app/client/src/assets/icons/ads/fluid.svg +++ b/app/client/src/assets/icons/ads/fluid.svg @@ -1,8 +1 @@ - - - - - - - - + \ No newline at end of file diff --git a/app/client/src/assets/icons/ads/link.svg b/app/client/src/assets/icons/ads/link.svg new file mode 100644 index 0000000000..ebebb0ab13 --- /dev/null +++ b/app/client/src/assets/icons/ads/link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/client/src/assets/icons/ads/mobile.svg b/app/client/src/assets/icons/ads/mobile.svg index ce0ed16c0b..7ff2985ebe 100644 --- a/app/client/src/assets/icons/ads/mobile.svg +++ b/app/client/src/assets/icons/ads/mobile.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/app/client/src/assets/icons/ads/recent.svg b/app/client/src/assets/icons/ads/recent.svg new file mode 100644 index 0000000000..4558e57619 --- /dev/null +++ b/app/client/src/assets/icons/ads/recent.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/client/src/assets/icons/ads/shine.svg b/app/client/src/assets/icons/ads/shine.svg index 9817de446b..f7e9d9dfe7 100644 --- a/app/client/src/assets/icons/ads/shine.svg +++ b/app/client/src/assets/icons/ads/shine.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/app/client/src/assets/icons/ads/tablet.svg b/app/client/src/assets/icons/ads/tablet.svg index 21b964d092..12c7aba397 100644 --- a/app/client/src/assets/icons/ads/tablet.svg +++ b/app/client/src/assets/icons/ads/tablet.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/app/client/src/assets/images/no_search_data.png b/app/client/src/assets/images/no_search_data.png new file mode 100644 index 0000000000..85e8c7990e Binary files /dev/null and b/app/client/src/assets/images/no_search_data.png differ diff --git a/app/client/src/assets/images/preview.png b/app/client/src/assets/images/preview.png index 82dfcddc42..3ad307c87d 100644 Binary files a/app/client/src/assets/images/preview.png and b/app/client/src/assets/images/preview.png differ diff --git a/app/client/src/assets/images/tick.svg b/app/client/src/assets/images/tick.svg index addda09eb8..8ae793744a 100644 --- a/app/client/src/assets/images/tick.svg +++ b/app/client/src/assets/images/tick.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/app/client/src/components/ads/DialogComponent.tsx b/app/client/src/components/ads/DialogComponent.tsx index 4d62c50978..698b996b7c 100644 --- a/app/client/src/components/ads/DialogComponent.tsx +++ b/app/client/src/components/ads/DialogComponent.tsx @@ -90,6 +90,7 @@ type DialogComponentProps = { showHeaderUnderline?: boolean; getHeader?: () => ReactNode; canEscapeKeyClose?: boolean; + className?: string; }; export const DialogComponent = (props: DialogComponentProps) => { @@ -108,6 +109,7 @@ export const DialogComponent = (props: DialogComponentProps) => { return ( { setIsOpen(true); }} @@ -126,6 +128,7 @@ export const DialogComponent = (props: DialogComponentProps) => { maxHeight={props.maxHeight} onOpening={props.onOpening} showHeaderUnderline={props.showHeaderUnderline} + className={props.className} > {getHeader && getHeader()}
{props.children}
diff --git a/app/client/src/components/ads/Icon.tsx b/app/client/src/components/ads/Icon.tsx index 8f95db4ff8..3186419c99 100644 --- a/app/client/src/components/ads/Icon.tsx +++ b/app/client/src/components/ads/Icon.tsx @@ -27,6 +27,8 @@ import { ReactComponent as ArrowLeft } from "assets/icons/ads/arrow-left.svg"; import { ReactComponent as Fork } from "assets/icons/ads/fork.svg"; import { ReactComponent as ChevronLeft } from "assets/icons/ads/chevron_left.svg"; import { ReactComponent as ChevronRight } from "assets/icons/ads/chevron_right.svg"; +import { ReactComponent as LinkIcon } from "assets/icons/ads/link.svg"; +import { ReactComponent as HelpIcon } from "assets/icons/help/help.svg"; import { ReactComponent as CloseModalIcon } from "assets/icons/ads/close-modal.svg"; import { ReactComponent as NoResponseIcon } from "assets/icons/ads/no-response.svg"; import { ReactComponent as LightningIcon } from "assets/icons/ads/lightning.svg"; @@ -120,6 +122,8 @@ export const IconCollection = [ "fork", "chevron-left", "chevron-right", + "link", + "help", "close-modal", "no-response", "lightning", @@ -265,6 +269,12 @@ const Icon = forwardRef( case "chevron-right": returnIcon = ; break; + case "link": + returnIcon = ; + break; + case "help": + returnIcon = ; + break; case "close-modal": returnIcon = ; break; diff --git a/app/client/src/components/ads/Toast.tsx b/app/client/src/components/ads/Toast.tsx index f3aa0ba43e..ad54862175 100644 --- a/app/client/src/components/ads/Toast.tsx +++ b/app/client/src/components/ads/Toast.tsx @@ -110,7 +110,7 @@ const ToastComponent = (props: ToastProps & { undoAction?: () => void }) => { return ( diff --git a/app/client/src/components/designSystems/appsmith/TableComponent/TableUtilities.tsx b/app/client/src/components/designSystems/appsmith/TableComponent/TableUtilities.tsx index 73e89f996a..6f2c38ed4b 100644 --- a/app/client/src/components/designSystems/appsmith/TableComponent/TableUtilities.tsx +++ b/app/client/src/components/designSystems/appsmith/TableComponent/TableUtilities.tsx @@ -63,14 +63,16 @@ export const renderCell = ( ); } - const imageRegex = /(http(s?):)([/|.|\w|\s|-])*\.(?:jpeg|jpg|gif|png)??(?:&?[^=&]*=[^=&]*)*/; + const imageSplitRegex = /[^(base64)],/; + const imageUrlRegex = /(http(s?):)([/|.|\w|\s|-])*\.(?:jpeg|jpg|gif|png)??(?:&?[^=&]*=[^=&]*)*/; + const base64ImageRegex = /^data:image\/.*;base64/; return ( {value .toString() - .split(",") + .split(imageSplitRegex) .map((item: string, index: number) => { - if (imageRegex.test(item)) { + if (imageUrlRegex.test(item) || base64ImageRegex.test(item)) { return ( e.stopPropagation()} diff --git a/app/client/src/components/designSystems/appsmith/help/DocumentationSearch.tsx b/app/client/src/components/designSystems/appsmith/help/DocumentationSearch.tsx index ca7fd9216b..3251af8b36 100644 --- a/app/client/src/components/designSystems/appsmith/help/DocumentationSearch.tsx +++ b/app/client/src/components/designSystems/appsmith/help/DocumentationSearch.tsx @@ -22,6 +22,8 @@ import { } from "actions/helpActions"; import { Icon } from "@blueprintjs/core"; import moment from "moment"; +import { getCurrentUser } from "selectors/usersSelectors"; +import { User } from "constants/userConstants"; const { algolia, @@ -290,12 +292,26 @@ const HelpFooter = styled.div` font-size: 6pt; `; -const HelpBody = styled.div` - padding-top: 68px; +const HelpBody = styled.div<{ hideSearch?: boolean }>` + ${(props) => + props.hideSearch + ? ` + padding: ${props.theme.spaces[2]}px; + ` + : ` + padding-top: 68px; + `} flex: 5; `; -type Props = { hitsPerPage: number; defaultRefinement: string; dispatch: any }; +type Props = { + hitsPerPage: number; + defaultRefinement: string; + dispatch: any; + hideSearch?: boolean; + hideMinimizeBtn?: boolean; + user?: User; +}; type State = { showResults: boolean }; type HelpItem = { @@ -343,6 +359,17 @@ class DocumentationSearch extends React.Component { showResults: props.defaultRefinement.length > 0, }; } + componentDidMount() { + const { user } = this.props; + if (cloudHosting && intercomAppID && window.Intercom) { + window.Intercom("boot", { + app_id: intercomAppID, + user_id: user?.username, + name: user?.name, + email: user?.email, + }); + } + } onSearchValueChange = (event: SyntheticEvent) => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: No types available @@ -365,34 +392,38 @@ class DocumentationSearch extends React.Component { if (!algolia.enabled) return null; return ( - + {!this.props.hideMinimizeBtn && ( + + )} -
- - -
- + {!this.props.hideSearch && ( +
+ + +
+ )} + {this.state.showResults ? ( ) : ( @@ -422,6 +453,7 @@ class DocumentationSearch extends React.Component { const mapStateToProps = (state: AppState) => ({ defaultRefinement: getDefaultRefinement(state), + user: getCurrentUser(state), }); export default connect(mapStateToProps)(DocumentationSearch); diff --git a/app/client/src/components/designSystems/appsmith/help/HelpModal.tsx b/app/client/src/components/designSystems/appsmith/help/HelpModal.tsx index a12b46207b..f71ff00a4f 100644 --- a/app/client/src/components/designSystems/appsmith/help/HelpModal.tsx +++ b/app/client/src/components/designSystems/appsmith/help/HelpModal.tsx @@ -13,11 +13,10 @@ import { getAppsmithConfigs } from "configs"; import { LayersContext } from "constants/Layers"; import { connect } from "react-redux"; import { AppState } from "reducers"; -import { getCurrentUser } from "selectors/usersSelectors"; -import { User } from "constants/userConstants"; import AnalyticsUtil from "utils/AnalyticsUtil"; +import { HELP_MODAL_HEIGHT, HELP_MODAL_WIDTH } from "constants/HelpConstants"; -const { algolia, cloudHosting, intercomAppID } = getAppsmithConfigs(); +const { algolia } = getAppsmithConfigs(); const HelpButton = styled.button<{ highlight: boolean; layer: number; @@ -47,8 +46,8 @@ const HelpButton = styled.button<{ } `; -const MODAL_WIDTH = 240; -const MODAL_HEIGHT = 206; +const MODAL_WIDTH = HELP_MODAL_WIDTH; +const MODAL_HEIGHT = HELP_MODAL_HEIGHT; const MODAL_BOTTOM_DISTANCE = 100; const MODAL_RIGHT_DISTANCE = 27; @@ -58,25 +57,12 @@ const CloseIcon = HelpIcons.CLOSE_ICON; type Props = { isHelpModalOpen: boolean; dispatch: any; - user?: User; page: string; }; class HelpModal extends React.Component { static contextType = LayersContext; - componentDidMount() { - const { user } = this.props; - if (cloudHosting && intercomAppID && window.Intercom) { - window.Intercom("boot", { - app_id: intercomAppID, - user_id: user?.username, - name: user?.name, - email: user?.email, - }); - } - } - /** * closes help modal * @@ -151,7 +137,6 @@ class HelpModal extends React.Component { const mapStateToProps = (state: AppState) => ({ isHelpModalOpen: getHelpModalOpen(state), - user: getCurrentUser(state), }); export default connect(mapStateToProps)(HelpModal); diff --git a/app/client/src/components/designSystems/blueprint/ModalComponent.tsx b/app/client/src/components/designSystems/blueprint/ModalComponent.tsx index 3934f18ca7..a5b8d9a06a 100644 --- a/app/client/src/components/designSystems/blueprint/ModalComponent.tsx +++ b/app/client/src/components/designSystems/blueprint/ModalComponent.tsx @@ -26,6 +26,7 @@ const Container = styled.div<{ justify-content: center; align-items: center; & .${Classes.OVERLAY_CONTENT} { + max-width: 95%; width: ${(props) => props.width}px; min-height: ${(props) => props.height}px; background: white; diff --git a/app/client/src/components/editorComponents/GlobalSearch/ActionLink.tsx b/app/client/src/components/editorComponents/GlobalSearch/ActionLink.tsx new file mode 100644 index 0000000000..8141aa1572 --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/ActionLink.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import Icon, { IconSize } from "components/ads/Icon"; +import { Theme } from "constants/DefaultTheme"; +import { useContext } from "react"; +import styled, { withTheme } from "styled-components"; +import SearchContext from "./GlobalSearchContext"; +import { SearchItem } from "./utils"; + +export const StyledActionLink = styled.span<{ isActiveItem?: boolean }>` + visibility: ${(props) => (props.isActiveItem ? "visible" : "hidden")}; + display: inline-flex; +`; + +export const ActionLink = withTheme( + ({ + item, + theme, + isActiveItem, + }: { + item: SearchItem; + theme: Theme; + isActiveItem?: boolean; + }) => { + const searchContext = useContext(SearchContext); + return ( + + { + e.stopPropagation(); // to prevent toggleModal getting called twice + searchContext?.handleItemLinkClick(item, "SEARCH_ITEM_ICON_CLICK"); + }} + /> + + ); + }, +); + +export default ActionLink; diff --git a/app/client/src/components/editorComponents/GlobalSearch/AlgoliaSearchWrapper.tsx b/app/client/src/components/editorComponents/GlobalSearch/AlgoliaSearchWrapper.tsx new file mode 100644 index 0000000000..76f9efa7cb --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/AlgoliaSearchWrapper.tsx @@ -0,0 +1,37 @@ +import React, { useState, useCallback, useEffect } from "react"; +import algoliasearch from "algoliasearch/lite"; +import { InstantSearch } from "react-instantsearch-dom"; +import { getAppsmithConfigs } from "configs"; +import { debounce } from "lodash"; + +const { algolia } = getAppsmithConfigs(); +const searchClient = algoliasearch(algolia.apiId, algolia.apiKey); + +type SearchProps = { + query: string; + children: React.ReactNode; +}; + +const Search = ({ query, children }: SearchProps) => { + const [queryInState, setQueryInState] = useState(query); + const debouncedSetQueryInState = useCallback( + debounce(setQueryInState, 100), + [], + ); + + useEffect(() => { + debouncedSetQueryInState(query); + }, [query]); + + return ( + + {children} + + ); +}; + +export default Search; diff --git a/app/client/src/components/editorComponents/GlobalSearch/Description.tsx b/app/client/src/components/editorComponents/GlobalSearch/Description.tsx new file mode 100644 index 0000000000..937d8fef0b --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/Description.tsx @@ -0,0 +1,177 @@ +import React, { useCallback, useEffect } from "react"; +import styled from "styled-components"; +import ActionLink from "./ActionLink"; +import Highlight from "./Highlight"; +import { getItemTitle, SEARCH_ITEM_TYPES } from "./utils"; +import { getTypographyByKey } from "constants/DefaultTheme"; +import { SearchItem } from "./utils"; +import parseDocumentationContent from "./parseDocumentationContent"; + +type Props = { + activeItem: SearchItem; + activeItemType?: SEARCH_ITEM_TYPES; + query: string; + scrollPositionRef: React.MutableRefObject; +}; + +const Container = styled.div` + flex: 1; + display: flex; + flex-direction: column; + padding: ${(props) => + `${props.theme.spaces[5]}px ${props.theme.spaces[7]}px 0`}; + color: ${(props) => props.theme.colors.globalSearch.searchItemText}; + overflow: auto; + + ${(props) => getTypographyByKey(props, "spacedOutP1")}; + [class^="ais-"] { + ${(props) => getTypographyByKey(props, "spacedOutP1")}; + } + + img { + max-width: 100%; + } + + h1 { + ${(props) => getTypographyByKey(props, "largeH1")}; + word-break: break-word; + } + + h1, + h2, + h3, + strong { + color: #fff; + } + + .documentation-cta { + ${(props) => getTypographyByKey(props, "p3")} + white-space: nowrap; + background: ${(props) => + props.theme.colors.globalSearch.documentationCtaBackground}; + color: ${(props) => props.theme.colors.globalSearch.documentationCtaText}; + padding: ${(props) => props.theme.spaces[2]}px; + margin: 0 ${(props) => props.theme.spaces[2]}px; + position: relative; + bottom: 3px; + } + + & a { + color: ${(props) => props.theme.colors.globalSearch.documentLink}; + } + + code { + word-break: break-word; + background: ${(props) => props.theme.colors.globalSearch.codeBackground}; + padding: ${(props) => props.theme.spaces[2]}px; + } + + pre { + background: ${(props) => props.theme.colors.globalSearch.codeBackground}; + white-space: pre-wrap; + overflow: hidden; + padding: ${(props) => props.theme.spaces[6]}px; + } +`; + +const DocumentationDescription = ({ item }: { item: SearchItem }) => { + try { + const { + _highlightResult: { + document: { value: rawDocument }, + title: { value: rawTitle }, + }, + } = item; + const content = parseDocumentationContent({ + rawDocument: rawDocument, + rawTitle: rawTitle, + path: item.path, + }); + + return content ? ( +
+ ) : null; + } catch (e) { + return null; + } +}; + +const StyledHitEnterMessageContainer = styled.div` + background: ${(props) => + props.theme.colors.globalSearch.navigateUsingEnterSection}; + padding: ${(props) => + `${props.theme.spaces[6]}px ${props.theme.spaces[3]}px`}; + ${(props) => getTypographyByKey(props, "p3")} +`; + +const StyledKey = styled.span` + margin: 0 ${(props) => props.theme.spaces[1]}px; + color: ${(props) => props.theme.colors.globalSearch.navigateToEntityEnterkey}; + font-weight: bold; +`; + +const StyledHighlightWrapper = styled.span` + margin: 0 ${(props) => props.theme.spaces[1]}px; +`; + +const HitEnterMessage = ({ + item, + query, +}: { + item: SearchItem; + query: string; +}) => { + const title = getItemTitle(item); + + return ( + + ✨ Press to navigate to + + + + + + ); +}; + +const descriptionByType = { + [SEARCH_ITEM_TYPES.document]: DocumentationDescription, + [SEARCH_ITEM_TYPES.action]: HitEnterMessage, + [SEARCH_ITEM_TYPES.widget]: HitEnterMessage, + [SEARCH_ITEM_TYPES.datasource]: HitEnterMessage, + [SEARCH_ITEM_TYPES.page]: HitEnterMessage, + [SEARCH_ITEM_TYPES.sectionTitle]: () => null, +}; + +const Description = (props: Props) => { + const { activeItem, activeItemType } = props; + const containerRef = React.useRef(null); + + const onScroll = useCallback((e: React.UIEvent) => { + if ( + props.scrollPositionRef?.current || + props.scrollPositionRef?.current === 0 + ) { + props.scrollPositionRef.current = (e.target as HTMLDivElement).scrollTop; + } + }, []); + + useEffect(() => { + if (containerRef.current) { + containerRef.current.scrollTop = props.scrollPositionRef?.current; + } + }, [containerRef.current, activeItem]); + + if (!activeItemType || !activeItem) return null; + const Component = descriptionByType[activeItemType]; + + return ( + + + + ); +}; + +export default Description; diff --git a/app/client/src/components/editorComponents/GlobalSearch/GlobalSearchContext.tsx b/app/client/src/components/editorComponents/GlobalSearch/GlobalSearchContext.tsx new file mode 100644 index 0000000000..4520e7e7a7 --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/GlobalSearchContext.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import { SearchItem } from "./utils"; + +type SearchContextType = { + handleItemLinkClick: (item?: SearchItem, source?: string) => void; + setActiveItemIndex: (index: number) => void; + activeItemIndex: number; +}; + +const SearchContext = React.createContext( + undefined, +); + +export default SearchContext; diff --git a/app/client/src/components/editorComponents/GlobalSearch/GlobalSearchHotKeys.tsx b/app/client/src/components/editorComponents/GlobalSearch/GlobalSearchHotKeys.tsx new file mode 100644 index 0000000000..2091bae430 --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/GlobalSearchHotKeys.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { HotkeysTarget } from "@blueprintjs/core/lib/esnext/components/hotkeys/hotkeysTarget.js"; +import { Hotkey, Hotkeys } from "@blueprintjs/core"; +import { SearchItem } from "./utils"; + +type Props = { + modalOpen: boolean; + toggleShow: () => void; + handleUpKey: () => void; + handleDownKey: () => void; + handleItemLinkClick: (item?: SearchItem, source?: string) => void; + children: React.ReactNode; +}; +@HotkeysTarget +class GlobalSearchHotKeys extends React.Component { + get hotKeysConfig() { + return [ + { + combo: "up", + onKeyDown: this.props.handleUpKey, + hideWhenModalClosed: true, + allowInInput: true, + group: "Omnibar", + label: "Move up the list", + }, + { + combo: "down", + onKeyDown: this.props.handleDownKey, + hideWhenModalClosed: true, + allowInInput: true, + group: "Omnibar", + label: "Move down the list", + }, + { + combo: "return", + onKeyDown: () => { + const activeElement = document.activeElement as any; + activeElement?.blur(); // scroll into view doesn't work with the search input focused + this.props.handleItemLinkClick(null, "ENTER_KEY"); + }, + hideWhenModalClosed: true, + allowInInput: true, + group: "Omnibar", + label: "Navigate", + }, + ].filter( + ({ hideWhenModalClosed }) => + !hideWhenModalClosed || (hideWhenModalClosed && this.props.modalOpen), + ); + } + + renderHotkeys() { + return ( + + {this.hotKeysConfig.map( + ({ combo, onKeyDown, allowInInput, label, group }, index) => ( + + ), + )} + + ); + } + + render() { + return
{this.props.children}
; + } +} + +export default GlobalSearchHotKeys; diff --git a/app/client/src/components/editorComponents/GlobalSearch/HelpBar.tsx b/app/client/src/components/editorComponents/GlobalSearch/HelpBar.tsx new file mode 100644 index 0000000000..e824ecedc1 --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/HelpBar.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { connect } from "react-redux"; +import styled from "styled-components"; +import { getTypographyByKey } from "constants/DefaultTheme"; +import Text, { TextType } from "components/ads/Text"; +import { toggleShowGlobalSearchModal } from "actions/globalSearchActions"; +import { HELPBAR_PLACEHOLDER } from "constants/messages"; +import AnalyticsUtil from "utils/AnalyticsUtil"; +import { isMac } from "utils/helpers"; + +const StyledHelpBar = styled.div` + padding: 0 ${(props) => props.theme.spaces[4]}px; + .placeholder-text { + ${(props) => getTypographyByKey(props, "p2")} + } + display: flex; + justify-content: space-between; + align-items: center; + color: ${(props) => props.theme.colors.globalSearch.helpBarText}; + background: ${(props) => props.theme.colors.globalSearch.helpBarBackground}; + height: 28px; + flex: 1; + max-width: 350px; +`; + +const modText = () => (isMac() ? : "ctrl"); +const comboText = <>{modText()} + K; + +type Props = { + toggleShowModal: () => void; +}; + +const HelpBar = ({ toggleShowModal }: Props) => { + return ( + + {HELPBAR_PLACEHOLDER} + + {comboText} + + + ); +}; + +const mapDispatchToProps = (dispatch: any) => ({ + toggleShowModal: () => { + AnalyticsUtil.logEvent("OPEN_OMNIBAR", { source: "NAVBAR_CLICK" }); + dispatch(toggleShowGlobalSearchModal()); + }, +}); + +export default connect(null, mapDispatchToProps)(HelpBar); diff --git a/app/client/src/components/editorComponents/GlobalSearch/Highlight.tsx b/app/client/src/components/editorComponents/GlobalSearch/Highlight.tsx new file mode 100644 index 0000000000..e62f3a1f39 --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/Highlight.tsx @@ -0,0 +1,32 @@ +import React from "react"; + +const Highlight = ({ match, text }: { match: string; text: string }) => { + if (!match) return {text}; + + const regEx = new RegExp(match, "ig"); + const parts = text?.split(regEx); + if (parts?.length === 1) return {text}; + let lastIndex = 0; + + return ( + + {parts?.map((part, index) => { + lastIndex += Math.max(part.length, 0); + const result = ( + + {part} + {index !== parts.length - 1 && ( + + {text.slice(lastIndex, lastIndex + match.length)} + + )} + + ); + lastIndex += match.length; + return result; + })} + + ); +}; + +export default Highlight; diff --git a/app/client/src/components/editorComponents/GlobalSearch/ResultsNotFound.tsx b/app/client/src/components/editorComponents/GlobalSearch/ResultsNotFound.tsx new file mode 100644 index 0000000000..70fc40e63f --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/ResultsNotFound.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import styled from "styled-components"; +import NoSearchDataImage from "assets/images/no_search_data.png"; +import { NO_SEARCH_DATA_TEXT } from "constants/messages"; +import { getTypographyByKey } from "constants/DefaultTheme"; + +const Container = styled.div` + display: flex; + width: 100%; + height: 100%; + justify-content: center; + align-items: center; + flex-direction: column; + + ${(props) => getTypographyByKey(props, "spacedOutP1")} + color: ${(props) => props.theme.colors.globalSearch.emptyStateText}; + + .no-data-title { + margin-top: ${(props) => props.theme.spaces[3]}px; + } +`; + +const ResultsNotFound = () => ( + + No data +
{NO_SEARCH_DATA_TEXT}
+
+); + +export default ResultsNotFound; diff --git a/app/client/src/components/editorComponents/GlobalSearch/SearchBox.tsx b/app/client/src/components/editorComponents/GlobalSearch/SearchBox.tsx new file mode 100644 index 0000000000..5c5584b6d2 --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/SearchBox.tsx @@ -0,0 +1,89 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useSelector } from "react-redux"; +import styled from "styled-components"; +import { connectSearchBox } from "react-instantsearch-dom"; +import { SearchBoxProvided } from "react-instantsearch-core"; +import { getTypographyByKey } from "constants/DefaultTheme"; +import Icon from "components/ads/Icon"; +import { AppState } from "reducers"; +import { OMNIBAR_PLACEHOLDER } from "constants/messages"; + +const Container = styled.div` + padding: ${(props) => `0 ${props.theme.spaces[11]}px`}; + & input { + ${(props) => getTypographyByKey(props, "cardSubheader")} + background: transparent; + color: ${(props) => props.theme.colors.globalSearch.searchInputText}; + border: none; + padding: ${(props) => `${props.theme.spaces[7]}px 0`}; + flex: 1; + } +`; + +const InputContainer = styled.div` + display: flex; +`; + +const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.keyCode === 38 || e.key === "ArrowUp") { + e.preventDefault(); + } +}; + +type SearchBoxProps = SearchBoxProvided & { + query: string; + setQuery: (query: string) => void; +}; + +const useListenToChange = (modalOpen: boolean) => { + const [listenToChange, setListenToChange] = useState(false); + + useEffect(() => { + setListenToChange(false); + let timer: number; + if (modalOpen) { + timer = setTimeout(() => setListenToChange(true), 100); + } + return () => clearTimeout(timer); + }, [modalOpen]); + + return listenToChange; +}; + +const SearchBox = ({ query, setQuery }: SearchBoxProps) => { + const { modalOpen } = useSelector((state: AppState) => state.ui.globalSearch); + const listenToChange = useListenToChange(modalOpen); + + const updateSearchQuery = useCallback( + (query) => { + // to prevent key combo to open modal from trigging query update + if (!listenToChange) return; + setQuery(query); + }, + [listenToChange], + ); + + return ( + + + updateSearchQuery(e.currentTarget.value)} + autoFocus + onKeyDown={handleKeyDown} + placeholder={OMNIBAR_PLACEHOLDER} + className="t--global-search-input" + /> + {query && ( + updateSearchQuery("")} + /> + )} + + + ); +}; + +export default connectSearchBox(SearchBox); diff --git a/app/client/src/components/editorComponents/GlobalSearch/SearchModal.tsx b/app/client/src/components/editorComponents/GlobalSearch/SearchModal.tsx new file mode 100644 index 0000000000..b314064464 --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/SearchModal.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import styled from "styled-components"; +import { Overlay, Classes } from "@blueprintjs/core"; +import AnalyticsUtil from "utils/AnalyticsUtil"; + +const StyledDocsSearchModal = styled.div` + & { + .${Classes.OVERLAY} { + position: fixed; + bottom: 0; + left: 0; + right: 0; + display: flex; + justify-content: center; + .${Classes.OVERLAY_CONTENT} { + overflow: hidden; + top: 10vh; + } + } + } +`; + +type Props = { + modalOpen: boolean; + toggleShow: () => void; + children: React.ReactNode; +}; + +const DocsSearchModal = ({ modalOpen, toggleShow, children }: Props) => ( + + { + AnalyticsUtil.logEvent("CLOSE_OMNIBAR"); + }} + transitionDuration={25} + > +
+ {children} +
+
+
+); + +export default DocsSearchModal; diff --git a/app/client/src/components/editorComponents/GlobalSearch/SearchResults.tsx b/app/client/src/components/editorComponents/GlobalSearch/SearchResults.tsx new file mode 100644 index 0000000000..22088d0f3e --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/SearchResults.tsx @@ -0,0 +1,322 @@ +import React, { useEffect, useRef, useContext, useMemo } from "react"; +import { useSelector } from "react-redux"; +import { Highlight as AlgoliaHighlight } from "react-instantsearch-dom"; +import { Hit as IHit } from "react-instantsearch-core"; +import styled from "styled-components"; +import { getTypographyByKey } from "constants/DefaultTheme"; +import Highlight from "./Highlight"; +import ActionLink, { StyledActionLink } from "./ActionLink"; +import scrollIntoView from "scroll-into-view-if-needed"; +import { + getItemType, + getItemTitle, + SEARCH_ITEM_TYPES, + SearchItem, +} from "./utils"; +import SearchContext from "./GlobalSearchContext"; +import { + getWidgetIcon, + getPluginIcon, + homePageIcon, + pageIcon, +} from "pages/Editor/Explorer/ExplorerIcons"; +import { HelpIcons } from "icons/HelpIcons"; +import { getActionConfig } from "pages/Editor/Explorer/Actions/helpers"; +import { AppState } from "reducers"; +import { keyBy, noop } from "lodash"; +import { getPageList } from "selectors/editorSelectors"; + +const DocumentIcon = HelpIcons.DOCUMENT; + +export const SearchItemContainer = styled.div<{ + isActiveItem: boolean; + itemType: SEARCH_ITEM_TYPES; +}>` + cursor: ${(props) => + props.itemType !== SEARCH_ITEM_TYPES.sectionTitle ? "pointer" : "default"}; + display: flex; + align-items: center; + padding: ${(props) => + `${props.theme.spaces[4]}px ${props.theme.spaces[4]}px`}; + color: ${(props) => props.theme.colors.globalSearch.searchItemText}; + margin: ${(props) => props.theme.spaces[1]}px 0; + background-color: ${(props) => + props.isActiveItem && props.itemType !== SEARCH_ITEM_TYPES.sectionTitle + ? props.theme.colors.globalSearch.activeSearchItemBackground + : "unset"}; + + &:hover { + background-color: ${(props) => + props.itemType !== SEARCH_ITEM_TYPES.sectionTitle + ? props.theme.colors.globalSearch.activeSearchItemBackground + : "unset"}; + ${StyledActionLink} { + visibility: visible; + } + } + + ${(props) => getTypographyByKey(props, "p3")}; + [class^="ais-"] { + ${(props) => getTypographyByKey(props, "p3")}; + } +`; + +const ItemTitle = styled.div` + margin-left: ${(props) => props.theme.spaces[5]}px; + display: flex; + justify-content: space-between; + flex: 1; + align-items: center; + ${(props) => getTypographyByKey(props, "p3")}; + font-w [class^="ais-"] { + ${(props) => getTypographyByKey(props, "p3")}; + } +`; + +const StyledDocumentIcon = styled(DocumentIcon)` + svg { + width: 14px; + height: 14px; + path { + fill: transparent; + } + } + display: flex; +`; + +const DocumentationItem = (props: { + item: SearchItem; + isActiveItem: boolean; +}) => { + return ( + <> + + + + + + + + + ); +}; + +const WidgetIconWrapper = styled.span` + svg { + height: 14px; + } + display: flex; +`; + +const usePageName = (pageId: string) => { + const pages = useSelector(getPageList); + const page = pages.find((page) => page.pageId === pageId); + return page?.pageName; +}; + +const WidgetItem = (props: { + query: string; + item: SearchItem; + isActiveItem: boolean; +}) => { + const { query, item } = props; + const { type } = item || {}; + let title = getItemTitle(item); + const pageName = usePageName(item.pageId); + title = `${pageName} / ${title}`; + + return ( + <> + {getWidgetIcon(type)} + + + + + + ); +}; + +const ActionIconWrapper = styled.div` + & > div { + display: flex; + align-items: center; + } +`; + +const ActionItem = (props: { + query: string; + item: SearchItem; + isActiveItem: boolean; +}) => { + const { item, query } = props; + const { config } = item || {}; + const { pluginType } = config; + const plugins = useSelector((state: AppState) => { + return state.entities.plugins.list; + }); + const pluginGroups = useMemo(() => keyBy(plugins, "id"), [plugins]); + const icon = getActionConfig(pluginType)?.getIcon( + item.config, + pluginGroups[item.config.datasource.pluginId], + ); + + let title = getItemTitle(item); + const pageName = usePageName(config.pageId); + title = `${pageName} / ${title}`; + + return ( + <> + {icon} + + + + + + ); +}; + +const DatasourceItem = (props: { + query: string; + item: SearchItem; + isActiveItem: boolean; +}) => { + const { item, query } = props; + const plugins = useSelector((state: AppState) => { + return state.entities.plugins.list; + }); + const pluginGroups = useMemo(() => keyBy(plugins, "id"), [plugins]); + const icon = getPluginIcon(pluginGroups[item.pluginId]); + const title = getItemTitle(item); + return ( + <> + {icon} + + + + + + ); +}; + +const PageItem = (props: { + query: string; + item: SearchItem; + isActiveItem: boolean; +}) => { + const { query, item } = props; + const title = getItemTitle(item); + const icon = item.isDefault ? homePageIcon : pageIcon; + + return ( + <> + {icon} + + + + + + ); +}; + +const StyledSectionTitleContainer = styled.div` + display: flex; + align-items: center; + & .section-title__icon { + width: 14px; + height: 14px; + margin-right: ${(props) => props.theme.spaces[5]}px; + } + & .section-title__text { + color: ${(props) => props.theme.colors.globalSearch.sectionTitle}; + } + margin-left: -${(props) => props.theme.spaces[3]}px; +`; + +const SectionTitle = ({ item }: { item: SearchItem }) => ( + + + {item.title} + +); + +const SearchItemByType = { + [SEARCH_ITEM_TYPES.document]: DocumentationItem, + [SEARCH_ITEM_TYPES.widget]: WidgetItem, + [SEARCH_ITEM_TYPES.action]: ActionItem, + [SEARCH_ITEM_TYPES.datasource]: DatasourceItem, + [SEARCH_ITEM_TYPES.page]: PageItem, + [SEARCH_ITEM_TYPES.sectionTitle]: SectionTitle, +}; + +type ItemProps = { + item: IHit | SearchItem; + index: number; + query: string; +}; + +const SearchItemComponent = (props: ItemProps) => { + const { item, index, query } = props; + const itemRef = useRef(null); + const searchContext = useContext(SearchContext); + const activeItemIndex = searchContext?.activeItemIndex; + const setActiveItemIndex = searchContext?.setActiveItemIndex || noop; + + const isActiveItem = activeItemIndex === index; + + useEffect(() => { + if (isActiveItem && itemRef.current) { + scrollIntoView(itemRef.current, { scrollMode: "if-needed" }); + } + }, [isActiveItem]); + + const itemType = getItemType(item); + const Item = SearchItemByType[itemType]; + + return ( + { + if (itemType !== SEARCH_ITEM_TYPES.sectionTitle) { + setActiveItemIndex(index); + if (itemType !== SEARCH_ITEM_TYPES.document) { + searchContext?.handleItemLinkClick(item, "SEARCH_ITEM"); + } + } + }} + className="t--docHit" + isActiveItem={isActiveItem} + itemType={itemType} + > + + + ); +}; + +const SearchResultsContainer = styled.div` + padding: 0 ${(props) => props.theme.spaces[6]}px; + overflow: auto; + width: 250px; +`; + +const SearchResults = ({ + searchResults, + query, +}: { + searchResults: SearchItem[]; + query: string; +}) => { + return ( + + {searchResults.map((item: SearchItem, index: number) => ( + + ))} + + ); +}; + +export default SearchResults; diff --git a/app/client/src/components/editorComponents/GlobalSearch/SetSearchResults.tsx b/app/client/src/components/editorComponents/GlobalSearch/SetSearchResults.tsx new file mode 100644 index 0000000000..17ce6f433c --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/SetSearchResults.tsx @@ -0,0 +1,28 @@ +import { useEffect, useCallback } from "react"; +import { connectHits } from "react-instantsearch-dom"; +import { Hit as IHit } from "react-instantsearch-core"; +import { debounce } from "lodash"; +import { DocSearchItem, SearchItem, SEARCH_ITEM_TYPES } from "./utils"; + +type Props = { + setDocumentationSearchResults: (item: DocSearchItem) => void; + hits: IHit[]; +}; + +const SearchResults = ({ hits, setDocumentationSearchResults }: Props) => { + const debounsedSetter = useCallback( + debounce(setDocumentationSearchResults, 100), + [], + ); + + useEffect(() => { + const filteredHits = hits.filter( + (doc: SearchItem) => doc.kind === SEARCH_ITEM_TYPES.document, + ); + debounsedSetter(filteredHits as any); + }, [hits]); + + return null; +}; + +export default connectHits(SearchResults); diff --git a/app/client/src/components/editorComponents/GlobalSearch/index.tsx b/app/client/src/components/editorComponents/GlobalSearch/index.tsx new file mode 100644 index 0000000000..2717bb2a1e --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/index.tsx @@ -0,0 +1,391 @@ +import React, { + useState, + useMemo, + useCallback, + useEffect, + useRef, +} from "react"; +import { useDispatch, useSelector } from "react-redux"; +import styled from "styled-components"; +import { useParams } from "react-router"; +import history from "utils/history"; +import { AppState } from "reducers"; +import SearchModal from "./SearchModal"; +import AlgoliaSearchWrapper from "./AlgoliaSearchWrapper"; +import SearchBox from "./SearchBox"; +import SearchResults from "./SearchResults"; +import SetSearchResults from "./SetSearchResults"; +import GlobalSearchHotKeys from "./GlobalSearchHotKeys"; +import SearchContext from "./GlobalSearchContext"; +import Description from "./Description"; +import ResultsNotFound from "./ResultsNotFound"; +import { getActions, getAllPageWidgets } from "selectors/entitiesSelector"; +import { useNavigateToWidget } from "pages/Editor/Explorer/Widgets/WidgetEntity"; +import { + toggleShowGlobalSearchModal, + setGlobalSearchQuery, +} from "actions/globalSearchActions"; +import { + getItemType, + SEARCH_ITEM_TYPES, + useDefaultDocumentationResults, + DocSearchItem, + SearchItem, + algoliaHighlightTag, + attachKind, +} from "./utils"; +import { getActionConfig } from "pages/Editor/Explorer/Actions/helpers"; +import { HelpBaseURL } from "constants/HelpConstants"; +import { ExplorerURLParams } from "pages/Editor/Explorer/helpers"; +import { BUILDER_PAGE_URL, DATA_SOURCES_EDITOR_ID_URL } from "constants/routes"; +import { getSelectedWidget } from "selectors/ui"; +import AnalyticsUtil from "utils/AnalyticsUtil"; +import { getPageList } from "selectors/editorSelectors"; +import useRecentEntities from "./useRecentEntities"; +import { keyBy, noop } from "lodash"; +import EntitiesIcon from "assets/icons/ads/entities.svg"; +import DocsIcon from "assets/icons/ads/docs.svg"; +import RecentIcon from "assets/icons/ads/recent.svg"; + +const StyledContainer = styled.div` + width: 750px; + height: 45vh; + background: ${(props) => props.theme.colors.globalSearch.containerBackground}; + box-shadow: ${(props) => props.theme.colors.globalSearch.containerShadow}; + display: flex; + flex-direction: column; + & .main { + display: flex; + flex: 1; + overflow: hidden; + background-color: #383838; + } + ${algoliaHighlightTag}, + & .ais-Highlight-highlighted, + & .search-highlighted { + background: unset; + color: ${(props) => props.theme.colors.globalSearch.searchItemHighlight}; + font-style: normal; + text-decoration: underline; + text-decoration-color: ${(props) => + props.theme.colors.globalSearch.highlightedTextUnderline}; + } +`; + +const Separator = styled.div` + margin: ${(props) => props.theme.spaces[3]}px 0; + width: 1px; + background-color: ${(props) => props.theme.colors.globalSearch.separator}; +`; + +const isModalOpenSelector = (state: AppState) => + state.ui.globalSearch.modalOpen; + +const searchQuerySelector = (state: AppState) => state.ui.globalSearch.query; + +const isMatching = (text = "", query = "") => + text?.toLowerCase().indexOf(query?.toLowerCase()) > -1; + +const getSectionTitle = (title: string, icon: any) => ({ + kind: SEARCH_ITEM_TYPES.sectionTitle, + title, + icon, +}); + +const GlobalSearch = () => { + const defaultDocs = useDefaultDocumentationResults(); + const params = useParams(); + const dispatch = useDispatch(); + const toggleShow = () => dispatch(toggleShowGlobalSearchModal()); + const [query, setQueryInState] = useState(""); + const setQuery = useCallback((query: string) => { + setQueryInState(query); + }, []); + const scrollPositionRef = useRef(0); + + const [ + documentationSearchResults, + setDocumentationSearchResultsInState, + ] = useState>([]); + + const setDocumentationSearchResults = useCallback((res) => { + setDocumentationSearchResultsInState(res); + }, []); + + const [activeItemIndex, setActiveItemIndexInState] = useState(1); + const setActiveItemIndex = useCallback((index) => { + scrollPositionRef.current = 0; + setActiveItemIndexInState(index); + }, []); + + const allWidgets = useSelector(getAllPageWidgets); + + const searchableWidgets = useMemo( + () => + allWidgets.filter( + (widget: any) => + ["CANVAS_WIDGET", "ICON_WIDGET"].indexOf(widget.type) === -1, + ), + [allWidgets], + ); + const actions = useSelector(getActions); + const modalOpen = useSelector(isModalOpenSelector); + const pages = useSelector(getPageList) || []; + const pageMap = keyBy(pages, "pageId"); + + const reducerDatasources = useSelector((state: AppState) => { + return state.entities.datasources.list; + }); + const datasourcesList = useMemo(() => { + return reducerDatasources.map((datasource) => ({ + ...datasource, + pageId: params?.pageId, + })); + }, [reducerDatasources]); + + const filteredDatasources = useMemo(() => { + if (!query) return datasourcesList; + return datasourcesList.filter((datasource) => + isMatching(datasource.name, query), + ); + }, [reducerDatasources, query]); + const recentEntities = useRecentEntities(); + + const resetSearchQuery = useSelector(searchQuerySelector); + const selectedWidgetId = useSelector(getSelectedWidget); + + // keeping query in component state until we can figure out fixed for the perf issues + // this is used to update query from outside the component, for ex. using the help button within prop. pane + useEffect(() => { + if (modalOpen && resetSearchQuery) { + setQuery(resetSearchQuery); + } else { + dispatch(setGlobalSearchQuery("")); + if (!query) setActiveItemIndex(1); + } + }, [modalOpen]); + + useEffect(() => { + setActiveItemIndex(1); + }, [query]); + + const filteredWidgets = useMemo(() => { + if (!query) return searchableWidgets; + + return searchableWidgets.filter((widget: any) => { + const page = pageMap[widget.pageId]; + const isPageNameMatching = isMatching(page?.pageName, query); + const isWidgetNameMatching = isMatching(widget?.widgetName, query); + + return isWidgetNameMatching || isPageNameMatching; + }); + }, [allWidgets, query]); + const filteredActions = useMemo(() => { + if (!query) return actions; + + return actions.filter((action: any) => { + const page = pageMap[action?.config?.pageId]; + const isPageNameMatching = isMatching(page?.pageName, query); + const isActionNameMatching = isMatching(action?.config?.name, query); + + return isActionNameMatching || isPageNameMatching; + }); + }, [actions, query]); + const filteredPages = useMemo(() => { + if (!query) return attachKind(pages, SEARCH_ITEM_TYPES.page); + + return attachKind( + pages.filter( + (page: any) => + page.pageName.toLowerCase().indexOf(query?.toLowerCase()) > -1, + ), + SEARCH_ITEM_TYPES.page, + ); + }, [pages, query]); + + const recentsSectionTitle = getSectionTitle("Recents", RecentIcon); + const docsSectionTitle = getSectionTitle("Documentation Links", DocsIcon); + const entitiesSectionTitle = getSectionTitle("Entities", EntitiesIcon); + + const searchResults = useMemo(() => { + if (!query) { + return [ + recentsSectionTitle, + ...recentEntities, + docsSectionTitle, + ...defaultDocs, + ]; + } + + const results = []; + + const entities = [ + entitiesSectionTitle, + ...filteredPages, + ...filteredWidgets, + ...filteredActions, + ...filteredDatasources, + ]; + + if (entities.length > 1) { + results.push(...entities); + } + + if (documentationSearchResults.length > 0) { + results.push(docsSectionTitle, ...documentationSearchResults); + } + + return results; + }, [ + filteredWidgets, + filteredActions, + documentationSearchResults, + filteredDatasources, + query, + recentEntities, + ]); + + const activeItem = useMemo(() => { + return searchResults[activeItemIndex] || {}; + }, [searchResults, activeItemIndex]); + + const getNextActiveItem = (nextIndex: number) => { + const max = Math.max(searchResults.length - 1, 0); + if (nextIndex < 0) return max; + else if (nextIndex > max) return 0; + else return nextIndex; + }; + + const handleUpKey = () => { + let nextIndex = getNextActiveItem(activeItemIndex - 1); + const activeItem = searchResults[nextIndex]; + if (activeItem && activeItem?.kind === SEARCH_ITEM_TYPES.sectionTitle) { + nextIndex = getNextActiveItem(nextIndex - 1); + } + setActiveItemIndex(nextIndex); + }; + + const handleDownKey = () => { + let nextIndex = getNextActiveItem(activeItemIndex + 1); + const activeItem = searchResults[nextIndex]; + if (activeItem && activeItem?.kind === SEARCH_ITEM_TYPES.sectionTitle) { + nextIndex = getNextActiveItem(nextIndex + 1); + } + setActiveItemIndex(nextIndex); + }; + + const { navigateToWidget } = useNavigateToWidget(); + + const handleDocumentationItemClick = (item: SearchItem) => { + window.open(item.path.replace("master", HelpBaseURL), "_blank"); + }; + + const handleWidgetClick = (activeItem: SearchItem) => { + toggleShow(); + navigateToWidget( + activeItem.widgetId, + activeItem.type, + activeItem.pageId, + selectedWidgetId === activeItem.widgetId, + activeItem.parentModalId, + ); + }; + + const handleActionClick = (item: SearchItem) => { + const { config } = item; + const { pageId, pluginType, id } = config; + const actionConfig = getActionConfig(pluginType); + const url = actionConfig?.getURL(params.applicationId, pageId, id); + toggleShow(); + url && history.push(url); + }; + + const handleDatasourceClick = (item: SearchItem) => { + toggleShow(); + history.push( + DATA_SOURCES_EDITOR_ID_URL(params.applicationId, item.pageId, item.id), + ); + }; + + const handlePageClick = (item: SearchItem) => { + toggleShow(); + history.push(BUILDER_PAGE_URL(params.applicationId, item.pageId)); + }; + + const itemClickHandlerByType = { + [SEARCH_ITEM_TYPES.document]: handleDocumentationItemClick, + [SEARCH_ITEM_TYPES.widget]: handleWidgetClick, + [SEARCH_ITEM_TYPES.action]: handleActionClick, + [SEARCH_ITEM_TYPES.datasource]: handleDatasourceClick, + [SEARCH_ITEM_TYPES.page]: handlePageClick, + [SEARCH_ITEM_TYPES.sectionTitle]: noop, + }; + + const handleItemLinkClick = (itemArg?: SearchItem, source?: string) => { + const item = itemArg || activeItem; + const type = getItemType(item) as SEARCH_ITEM_TYPES; + + AnalyticsUtil.logEvent("NAVIGATE_TO_ENTITY_FROM_OMNIBAR", { + type, + source, + }); + + itemClickHandlerByType[type](item); + }; + + const searchContext = { + handleItemLinkClick, + setActiveItemIndex, + activeItemIndex, + }; + + const hotKeyProps = { + modalOpen, + toggleShow, + handleUpKey, + handleDownKey, + handleItemLinkClick, + }; + + const activeItemType = useMemo(() => { + return activeItem ? getItemType(activeItem) : undefined; + }, [activeItem]); + + return ( + + + + + + +
+ + {searchResults.length > 0 ? ( + <> + + + + + ) : ( + + )} +
+
+
+
+
+
+ ); +}; + +export default GlobalSearch; diff --git a/app/client/src/components/editorComponents/GlobalSearch/parseDocumentationContent.test.ts b/app/client/src/components/editorComponents/GlobalSearch/parseDocumentationContent.test.ts new file mode 100644 index 0000000000..bc7fa299e8 --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/parseDocumentationContent.test.ts @@ -0,0 +1,44 @@ +// eslint-disable-next-line +import parseDocumentationContent from "./parseDocumentationContent"; + +const expectedResult = `

Security Open Documentation

Does Appsmith store my data?

+

No, Appsmith does not store any data returned from your API endpoints or DB queries. Appsmith only acts as a proxy layer. When you query your database/API endpoint, the Appsmith server only appends sensitive credentials before forwarding the request to your backend. The Appsmith server doesn't expose sensitive credentials to the browser because that can lead to security breaches. Such a routing ensures security of your systems and data.

+

Security measures within Appsmith

+

Appsmith applications are secure-by-default. The security measures implemented for Appsmith installations are:

+
    +
  • On Appsmith Cloud, all connections are encrypted with TLS. For self-hosted instances, we offer the capability to setup SSL certificates via LetsEncrypt during the installation process.
  • +
  • Encrypt all sensitive credentials such as database credentials with AES-256 encryption. Each self-hosted Appsmith instance is configured with unique salt and password values ensuring data-at-rest security.
  • +
  • Appsmith Cloud will only connect to your databases/API endpoints through whitelisted IPs: 18.223.74.85 & 3.131.104.27. This ensures that you only have to expose database access to specific IPs when using our cloud offering.
  • +
  • Appsmith Cloud is hosted in AWS data centers on servers that are SOC 1 and SOC 2 compliant. We also maintain data redundancy on our cloud instances via regular backups.
  • +
  • Internal access to Appsmith Cloud is controlled through 2-factor authentication system along with audit logs.
  • +
  • Maintain an open channel of communication with security researchers to allow them to report security vulnerabilities responsibly. If you notice a security vulnerability, please email security@appsmith.com and we'll resolve them ASAP.
  • +
`; + +const sampleTitleResponse = `Security`; + +const sampleDocumentResponse = `# Does Appsmith store my data? + +No, Appsmith does not store any data returned from your API endpoints or DB queries. Appsmith only acts as a proxy layer. When you query your database/API endpoint, the Appsmith server only appends sensitive credentials before forwarding the request to your backend. The Appsmith server doesn't expose sensitive credentials to the browser because that can lead to security breaches. Such a routing ensures security of your systems and data. + +# Security measures within Appsmith + +Appsmith applications are secure-by-default. The security measures implemented for Appsmith installations are: + +* On Appsmith Cloud, all connections are encrypted with TLS. For self-hosted instances, we offer the capability to setup SSL certificates via LetsEncrypt during the installation process. +* Encrypt all sensitive credentials such as database credentials with AES-256 encryption. Each self-hosted Appsmith instance is configured with unique salt and password values ensuring data-at-rest security. +* Appsmith Cloud will only connect to your databases/API endpoints through whitelisted IPs: 18.223.74.85 & 3.131.104.27. This ensures that you only have to expose database access to specific IPs when using our cloud offering. +* Appsmith Cloud is hosted in AWS data centers on servers that are SOC 1 and SOC 2 compliant. We also maintain data redundancy on our cloud instances via regular backups. +* Internal access to Appsmith Cloud is controlled through 2-factor authentication system along with audit logs. +* Maintain an open channel of communication with security researchers to allow them to report security vulnerabilities responsibly. If you notice a security vulnerability, please email [security@appsmith.com](mailto:security@appsmith.com) and we'll resolve them ASAP.`; + +describe("parseDocumentationContent", () => { + it("works as expected", () => { + const sampleItem = { + rawTitle: sampleTitleResponse, + rawDocument: sampleDocumentResponse, + path: "master/security", + }; + const result = parseDocumentationContent(sampleItem); + expect(result).toStrictEqual(expectedResult); + }); +}); diff --git a/app/client/src/components/editorComponents/GlobalSearch/parseDocumentationContent.ts b/app/client/src/components/editorComponents/GlobalSearch/parseDocumentationContent.ts new file mode 100644 index 0000000000..43e2dccf6c --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/parseDocumentationContent.ts @@ -0,0 +1,131 @@ +import marked from "marked"; +import { HelpBaseURL } from "constants/HelpConstants"; +import { algoliaHighlightTag } from "./utils"; + +/** + * @param {String} HTML representing a single element + * @return {Element} + */ +export const htmlToElement = (html: string) => { + const template = document.createElement("template"); + html = html.trim(); // Never return a text node of whitespace as the result + template.innerHTML = html; + return template.content.firstChild; +}; + +/** + * strip: + * gitbook plugin tags + */ +const strip = (text: string) => text.replace(/{% .*?%}/gm, ""); + +/** + * strip: description tag from the top + */ +const stripMarkdown = (text: string) => + text.replace(/---\n[description]([\S\s]*?)---/gm, ""); + +const getDocumentationCTA = (path: any) => { + const href = path.replace("master", HelpBaseURL); + const htmlString = `Open Documentation`; + return htmlToElement(htmlString); +}; + +/** + * Replace all H1s with H2s + * Check first child of body + * if exact match as title -> replace with h1 + * else prepend h1 + * Append open documentation button to title + */ +const updateDocumentDescriptionTitle = (documentObj: any, item: any) => { + const { rawTitle, path } = item; + + Array.from(documentObj.querySelectorAll("h1")).forEach((match: any) => { + match.outerHTML = `

${match.innerHTML}

`; + }); + + let firstChild = documentObj.querySelector("body") + ?.firstChild as HTMLElement | null; + + const matchesExactly = rawTitle === firstChild?.innerHTML; + + // additional space for word-break + if (matchesExactly && firstChild) { + firstChild.outerHTML = `

${firstChild?.innerHTML}

`; + } else { + const h = document.createElement("h1"); + h.innerHTML = `${rawTitle} `; + firstChild?.parentNode?.insertBefore(h, firstChild); + } + + firstChild = documentObj.querySelector("body") + ?.firstChild as HTMLElement | null; + + if (firstChild) { + // append documentation button after title: + const ctaElement = getDocumentationCTA(path) as Node; + firstChild.appendChild(ctaElement); + } +}; + +const replaceHintTagsWithCode = (text: string) => { + let result = text.replace(/{% hint .*?%}/, "```"); + result = result.replace(/{% endhint .*?%}/, "```"); + result = marked(result); + return result; +}; + +const parseDocumentationContent = (item: any): string | undefined => { + try { + const { rawDocument } = item; + let value = rawDocument; + if (!value) return; + + value = stripMarkdown(value); + value = replaceHintTagsWithCode(value); + + const parsedDocument = marked(value); + + const domparser = new DOMParser(); + const documentObj = domparser.parseFromString(parsedDocument, "text/html"); + + // remove algolia highlight within code sections + const aisTag = new RegExp( + `<${algoliaHighlightTag}>|</${algoliaHighlightTag}>`, + "g", + ); + Array.from(documentObj.querySelectorAll("code")).forEach((match) => { + match.innerHTML = match.innerHTML.replace(aisTag, ""); + }); + + // update link hrefs and target + const aisTagEncoded = new RegExp( + `%3C${algoliaHighlightTag}%3E|%3C/${algoliaHighlightTag}%3E`, + "g", + ); + + Array.from(documentObj.querySelectorAll("a")).forEach((match) => { + match.target = "_blank"; + try { + const hrefURL = new URL(match.href); + const isRelativeURL = hrefURL.hostname === window.location.hostname; + match.href = !isRelativeURL + ? match.href + : `${HelpBaseURL}/${match.getAttribute("href")}`; + match.href = match.href.replace(aisTagEncoded, ""); + } catch (e) {} + }); + + // update description title + updateDocumentDescriptionTitle(documentObj, item); + + const content = strip(documentObj.body.innerHTML).trim(); + return content; + } catch (e) { + console.log(e, "err"); + return; + } +}; + +export default parseDocumentationContent; diff --git a/app/client/src/components/editorComponents/GlobalSearch/useRecentEntities.tsx b/app/client/src/components/editorComponents/GlobalSearch/useRecentEntities.tsx new file mode 100644 index 0000000000..a2957bf538 --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/useRecentEntities.tsx @@ -0,0 +1,55 @@ +import { useSelector } from "react-redux"; +import { AppState } from "reducers"; +import { getPageList } from "selectors/editorSelectors"; +import { getActions, getAllWidgetsMap } from "selectors/entitiesSelector"; +import { SEARCH_ITEM_TYPES } from "./utils"; +import { get } from "lodash"; + +const recentEntitiesSelector = (state: AppState) => + state.ui.globalSearch.recentEntities; + +const useResentEntities = () => { + const widgetsMap = useSelector(getAllWidgetsMap); + const recentEntities = useSelector(recentEntitiesSelector); + const actions = useSelector(getActions); + const reducerDatasources = useSelector((state: AppState) => { + return state.entities.datasources.list; + }); + + const pages = useSelector(getPageList) || []; + + const populatedRecentEntities = recentEntities + .map((entity) => { + const { type, id, params } = entity; + if (type === "page") { + const result = pages.find((page) => page.pageId === id); + if (result) { + return { + ...result, + kind: SEARCH_ITEM_TYPES.page, + }; + } else { + return null; + } + } else if (type === "datasource") { + const datasource = reducerDatasources.find( + (reducerDatasource) => reducerDatasource.id === id, + ); + return ( + datasource && { + ...datasource, + pageId: params?.pageId, + } + ); + } else if (type === "action") + return actions.find((action) => action?.config?.id === id); + else if (type === "widget") { + return get(widgetsMap, id, null); + } + }) + .filter(Boolean); + + return populatedRecentEntities; +}; + +export default useResentEntities; diff --git a/app/client/src/components/editorComponents/GlobalSearch/utils.tsx b/app/client/src/components/editorComponents/GlobalSearch/utils.tsx new file mode 100644 index 0000000000..0c4294bf74 --- /dev/null +++ b/app/client/src/components/editorComponents/GlobalSearch/utils.tsx @@ -0,0 +1,140 @@ +import { Datasource } from "entities/Datasource"; +import { useEffect, useState } from "react"; + +export type RecentEntity = { + type: string; + id: string; + params?: Record; +}; + +export enum SEARCH_ITEM_TYPES { + document = "document", + action = "action", + widget = "widget", + datasource = "datasource", + page = "page", + sectionTitle = "sectionTitle", +} + +export type DocSearchItem = { + document?: string; + title: string; + _highlightResult: { + document: { value: string }; + title: { value: string }; + }; + kind: string; + path: string; +}; + +export type SearchItem = DocSearchItem | Datasource | any; + +// todo better checks here? +export const getItemType = (item: SearchItem): SEARCH_ITEM_TYPES => { + let type: SEARCH_ITEM_TYPES; + if (item.widgetName) type = SEARCH_ITEM_TYPES.widget; + else if ( + item.kind === SEARCH_ITEM_TYPES.document || + item.kind === SEARCH_ITEM_TYPES.page || + item.kind === SEARCH_ITEM_TYPES.sectionTitle + ) + type = item.kind; + else if (item.kind === SEARCH_ITEM_TYPES.page) type = SEARCH_ITEM_TYPES.page; + else if (item.config?.name) type = SEARCH_ITEM_TYPES.action; + else type = SEARCH_ITEM_TYPES.datasource; + + return type; +}; + +export const getItemTitle = (item: SearchItem): string => { + const type = getItemType(item); + + switch (type) { + case SEARCH_ITEM_TYPES.action: + return item?.config?.name; + case SEARCH_ITEM_TYPES.widget: + return item?.widgetName; + case SEARCH_ITEM_TYPES.datasource: + return item?.name; + case SEARCH_ITEM_TYPES.page: + return item?.pageName; + case SEARCH_ITEM_TYPES.sectionTitle: + return item?.title; + default: + return ""; + } +}; + +const defaultDocsConfig = [ + { + link: + "https://raw.githubusercontent.com/appsmithorg/appsmith-docs/v1.2.1/tutorial-1/README.md", + title: "Tutorial", + path: "master/tutorial-1", + kind: "document", + }, + { + link: + "https://raw.githubusercontent.com/appsmithorg/appsmith-docs/v1.2.1/core-concepts/connecting-to-data-sources/README.md", + title: "Connecting to Data Sources", + path: "master/core-concepts/connecting-to-data-sources", + kind: "document", + }, + { + link: + "https://raw.githubusercontent.com/appsmithorg/appsmith-docs/v1.2.1/core-concepts/displaying-data-read/README.md", + title: "Displaying Data (Read)", + path: "master/core-concepts/displaying-data-read", + kind: "document", + }, + { + link: + "https://raw.githubusercontent.com/appsmithorg/appsmith-docs/v1.2.1/core-concepts/writing-code/README.md", + title: "Writing Code", + path: "master/core-concepts/writing-code", + kind: "document", + }, +]; + +const githubDocsAssetsPath = + "https://raw.githubusercontent.com/appsmithorg/appsmith-docs/v1.2.1/.gitbook"; + +export const useDefaultDocumentationResults = () => { + const [defaultDocs, setDefaultDocs] = useState([]); + + useEffect(() => { + (async () => { + const data = await Promise.all( + defaultDocsConfig.map(async (doc: any) => { + const response = await fetch(doc.link); + let document = await response.text(); + const assetRegex = new RegExp("[../]*?/.gitbook", "g"); + document = document.replaceAll(assetRegex, githubDocsAssetsPath); + return { + _highlightResult: { + document: { + value: document, + }, + title: { + value: doc.title, + }, + }, + ...doc, + } as DocSearchItem; + }), + ); + setDefaultDocs(data); + })(); + }, []); + + return defaultDocs; +}; + +export const algoliaHighlightTag = "ais-highlight-0000000000"; + +export const attachKind = (source: any[], kind: string) => { + return source.map((s) => ({ + ...s, + kind, + })); +}; diff --git a/app/client/src/components/editorComponents/WidgetNameComponent/index.tsx b/app/client/src/components/editorComponents/WidgetNameComponent/index.tsx index 515d739166..eeb10ce381 100644 --- a/app/client/src/components/editorComponents/WidgetNameComponent/index.tsx +++ b/app/client/src/components/editorComponents/WidgetNameComponent/index.tsx @@ -10,7 +10,6 @@ import { } from "utils/hooks/dragResizeHooks"; import AnalyticsUtil from "utils/AnalyticsUtil"; import { WidgetType } from "constants/WidgetConstants"; -import HelpControl from "./HelpControl"; import PerformanceTracker, { PerformanceTransactionName, } from "utils/PerformanceTracker"; @@ -110,10 +109,6 @@ export const WidgetNameComponent = (props: WidgetNameComponentProps) => { return showWidgetName ? ( - { name={`${field}.${keyName[1]}`} showError validate={keyFieldValidate} + placeholder={ + (extraData && extraData[0].placeholderText) || "" + } />
{!props.actionConfig && ( @@ -116,6 +119,9 @@ const KeyValueRow = (props: KeyValueArrayProps & WrappedFieldArrayProps) => { {index === props.fields.length - 1 ? ( diff --git a/app/client/src/components/propertyControls/ChartDataControl.tsx b/app/client/src/components/propertyControls/ChartDataControl.tsx index c95202106c..bf490b7b28 100644 --- a/app/client/src/components/propertyControls/ChartDataControl.tsx +++ b/app/client/src/components/propertyControls/ChartDataControl.tsx @@ -12,7 +12,6 @@ import { EditorTheme, TabBehaviour, } from "components/editorComponents/CodeEditor/EditorConfig"; -import * as Sentry from "@sentry/react"; const StyledOptionControlWrapper = styled(ControlWrapper)` display: flex; @@ -174,32 +173,6 @@ class ChartDataControl extends BaseControl { return []; }; - componentDidMount() { - this.migrateChartData(this.props.propertyValue); - } - - migrateChartData(chartData: Array<{ seriesName: string; data: string }>) { - // Added a migration script for older chart data that was strings - // deprecate after enough charts have moved to the new format - if (_.isString(chartData)) { - try { - const parsedData: Array<{ - seriesName: string; - data: string; - }> = JSON.parse(chartData); - this.updateProperty(this.props.propertyName, parsedData); - return parsedData; - } catch (error) { - Sentry.captureException({ - message: "Chart Migration Failed", - oldData: this.props.propertyValue, - }); - } - } else { - return this.props.propertyValue; - } - } - render() { const chartData: Array<{ seriesName: string; data: string }> = _.isString( this.props.propertyValue, diff --git a/app/client/src/constants/DefaultTheme.tsx b/app/client/src/constants/DefaultTheme.tsx index 7862fa4784..36eef2432a 100644 --- a/app/client/src/constants/DefaultTheme.tsx +++ b/app/client/src/constants/DefaultTheme.tsx @@ -881,6 +881,27 @@ type ColorType = { activeTabBorderBottom: string; activeTabText: string; }; + globalSearch: { + containerBackground: string; + activeSearchItemBackground: string; + searchInputText: string; + containerShadow: string; + separator: string; + searchItemHighlight: string; + searchItemText: string; + highlightedTextUnderline: string; + documentationCtaBackground: string; + documentationCtaText: string; + emptyStateText: string; + navigateUsingEnterSection: string; + codeBackground: string; + documentLink: string; + helpBarBackground: string; + helpButtonBackground: string; + helpBarBorder: string; + sectionTitle: string; + navigateToEntityEnterkey: string; + }; gif: { overlay: string; text: string; @@ -916,7 +937,33 @@ const formMessage = { }, }; +const globalSearch = { + containerBackground: + "linear-gradient(0deg, rgba(43, 43, 43, 0.9), rgba(43, 43, 43, 0.9)), linear-gradient(119.61deg, rgba(35, 35, 35, 0.01) 0.43%, rgba(49, 49, 49, 0.01) 100.67%);", + activeSearchItemBackground: "rgba(0, 0, 0, 0.24)", + searchInputText: "#fff", + containerShadow: "0px 0px 32px 8px rgba(0, 0, 0, 0.25)", + separator: "#424242", + searchItemHighlight: "#fff", + searchItemText: "rgba(255, 255, 255, 0.6)", + highlightedTextUnderline: "#03B365", + helpBarText: "#C2C2C2", + documentationCtaBackground: "rgba(3, 179, 101, 0.1)", + documentationCtaText: "#03B365", + emptyStateText: "#ABABAB", + navigateUsingEnterSection: "#154E6B", + codeBackground: "#494949", + documentLink: "#54a9fb", + helpBarBackground: "#000", + helpButtonBackground: "#333333", + helpBarBorder: "#404040", + helpButtonBorder: "#404040", + sectionTitle: "#D4D4D4", + navigateToEntityEnterkey: "#3DA5D9", +}; + export const dark: ColorType = { + globalSearch, header: { separator: darkShades[4], appName: darkShades[7], @@ -1304,6 +1351,7 @@ export const dark: ColorType = { }; export const light: ColorType = { + globalSearch, header: { separator: "#E0DEDE", appName: lightShades[8], @@ -1780,18 +1828,30 @@ export const theme: Theme = { letterSpacing: -0.24, fontWeight: "normal", }, - authCardHeader: { + cardHeader: { fontStyle: "normal", fontWeight: 600, fontSize: 25, lineHeight: 20, }, - authCardSubheader: { + cardSubheader: { fontStyle: "normal", fontWeight: "normal", fontSize: 15, lineHeight: 20, }, + largeH1: { + fontStyle: "normal", + fontWeight: "bold", + fontSize: 28, + lineHeight: 36, + }, + spacedOutP1: { + fontStyle: "normal", + fontWeight: "normal", + fontSize: 14, + lineHeight: 24, + }, }, iconSizes: { XXS: 8, @@ -2026,7 +2086,6 @@ export const theme: Theme = { export const scrollbarLight = css<{ backgroundColor?: Color }>` scrollbar-color: ${(props) => props.theme.colors.paneText}; - scrollbar-width: thin; &::-webkit-scrollbar { width: 4px; diff --git a/app/client/src/constants/HelpConstants.ts b/app/client/src/constants/HelpConstants.ts index 43fe688253..70fd203670 100644 --- a/app/client/src/constants/HelpConstants.ts +++ b/app/client/src/constants/HelpConstants.ts @@ -110,3 +110,6 @@ export const HelpMap = { }; export const HelpBaseURL = "https://docs.appsmith.com"; + +export const HELP_MODAL_WIDTH = 240; +export const HELP_MODAL_HEIGHT = 206; diff --git a/app/client/src/constants/ReduxActionConstants.tsx b/app/client/src/constants/ReduxActionConstants.tsx index e9ea9bd582..01bd577632 100644 --- a/app/client/src/constants/ReduxActionConstants.tsx +++ b/app/client/src/constants/ReduxActionConstants.tsx @@ -5,6 +5,9 @@ import { ERROR_CODES } from "constants/ApiConstants"; import { AppLayoutConfig } from "reducers/entityReducers/pageListReducer"; export const ReduxActionTypes: { [key: string]: string } = { + HANDLE_PATH_UPDATED: "HANDLE_PATH_UPDATED", + RESET_EDITOR_REQUEST: "RESET_EDITOR_REQUEST", + RESET_EDITOR_SUCCESS: "RESET_EDITOR_SUCCESS", INITIALIZE_EDITOR: "INITIALIZE_EDITOR", INITIALIZE_EDITOR_SUCCESS: "INITIALIZE_EDITOR_SUCCESS", REPORT_ERROR: "REPORT_ERROR", @@ -337,11 +340,20 @@ export const ReduxActionTypes: { [key: string]: string } = { START_EVALUATION: "START_EVALUATION", CURRENT_APPLICATION_NAME_UPDATE: "CURRENT_APPLICATION_NAME_UPDATE", CURRENT_APPLICATION_LAYOUT_UPDATE: "CURRENT_APPLICATION_LAYOUT_UPDATE", + FORK_APPLICATION_INIT: "FORK_APPLICATION_INIT", + FORK_APPLICATION_SUCCESS: "FORK_APPLICATION_SUCCESS", SET_WIDGET_LOADING: "SET_WIDGET_LOADING", + SET_GLOBAL_SEARCH_QUERY: "SET_GLOBAL_SEARCH_QUERY", + TOGGLE_SHOW_GLOBAL_SEARCH_MODAL: "TOGGLE_SHOW_GLOBAL_SEARCH_MODAL", FETCH_RELEASES_SUCCESS: "FETCH_RELEASES_SUCCESS", RESET_UNREAD_RELEASES_COUNT: "RESET_UNREAD_RELEASES_COUNT", SET_LOADING_ENTITIES: "SET_LOADING_ENTITIES", RESET_CURRENT_APPLICATION: "RESET_CURRENT_APPLICATION", + UPDATE_RECENT_ENTITY: "UPDATE_RECENT_ENTITY", + RESTORE_RECENT_ENTITIES_REQUEST: "RESTORE_RECENT_ENTITIES_REQUEST", + RESTORE_RECENT_ENTITIES_SUCCESS: "RESTORE_RECENT_ENTITIES_SUCCESS", + SET_RECENT_ENTITIES: "SET_RECENT_ENTITIES", + RESET_RECENT_ENTITIES: "RESET_RECENT_ENTITIES", UPDATE_API_ACTION_BODY_CONTENT_TYPE: "UPDATE_API_ACTION_BODY_CONTENT_TYPE", }; @@ -435,6 +447,7 @@ export const ReduxActionErrorTypes: { [key: string]: string } = { "FETCH_PROVIDER_DETAILS_BY_PROVIDER_ID_ERROR", SAVE_ACTION_NAME_ERROR: "SAVE_ACTION_NAME_ERROR", FETCH_USER_APPLICATIONS_ORGS_ERROR: "FETCH_USER_APPLICATIONS_ORGS_ERROR", + FORK_APPLICATION_ERROR: "FORK_APPLICATION_ERROR", FETCH_ALL_USERS_ERROR: "FETCH_ALL_USERS_ERROR", FETCH_ALL_ROLES_ERROR: "FETCH_ALL_ROLES_ERROR", UPDATE_USER_DETAILS_ERROR: "UPDATE_USER_DETAILS_ERROR", @@ -535,6 +548,7 @@ export type ApplicationPayload = { isPublic?: boolean; userPermissions?: string[]; appIsExample: boolean; + forkingEnabled?: boolean; appLayout?: AppLayoutConfig; }; diff --git a/app/client/src/constants/messages.ts b/app/client/src/constants/messages.ts index 5f95ddc1fb..457540a374 100644 --- a/app/client/src/constants/messages.ts +++ b/app/client/src/constants/messages.ts @@ -190,3 +190,7 @@ export const LOCAL_STORAGE_QUOTA_EXCEEDED_MESSAGE = "Error saving a key in localStorage. You have exceeded the allowed storage size limit"; export const LOCAL_STORAGE_NO_SPACE_LEFT_ON_DEVICE_MESSAGE = "Error saving a key in localStorage. You have run out of disk space"; + +export const OMNIBAR_PLACEHOLDER = "Search Widgets, Queries, Documentation"; +export const HELPBAR_PLACEHOLDER = "Quick search & navigation"; +export const NO_SEARCH_DATA_TEXT = "Search you must meaningful but"; diff --git a/app/client/src/constants/routes.ts b/app/client/src/constants/routes.ts index 8dcb2a6e61..9fe215b42f 100644 --- a/app/client/src/constants/routes.ts +++ b/app/client/src/constants/routes.ts @@ -1,3 +1,5 @@ +const { match } = require("path-to-regexp"); + export const BASE_URL = "/"; export const ORG_URL = "/org"; export const PAGE_NOT_FOUND_URL = "/404"; @@ -176,3 +178,8 @@ export const AUTH_LOGIN_URL = `${USER_AUTH_URL}/login`; export const ORG_INVITE_USERS_PAGE_URL = `${ORG_URL}/invite`; export const ORG_SETTINGS_PAGE_URL = `${ORG_URL}/settings`; + +export const matchApiPath = match(API_EDITOR_ID_URL()); +export const matchDatasourcePath = match(DATA_SOURCES_EDITOR_ID_URL()); +export const matchQueryPath = match(QUERIES_EDITOR_ID_URL()); +export const matchBuilderPath = match(BUILDER_URL); diff --git a/app/client/src/entities/Widget/utils.ts b/app/client/src/entities/Widget/utils.ts index 9ac2f3342f..6502ebf3e4 100644 --- a/app/client/src/entities/Widget/utils.ts +++ b/app/client/src/entities/Widget/utils.ts @@ -84,26 +84,28 @@ export const getAllPathsFromPropertyConfig = ( if (controlConfig.children) { // Property in array structure const basePropertyPath = controlConfig.propertyName; - const widgetPropertyValue = get(widget, basePropertyPath); - widgetPropertyValue.forEach( - (arrayPropertyValue: any, index: number) => { - const arrayIndexPropertyPath = `${basePropertyPath}[${index}]`; - controlConfig.children.forEach((childPropertyConfig: any) => { - const childArrayPropertyPath = `${arrayIndexPropertyPath}.${childPropertyConfig.propertyName}`; - if ( - childPropertyConfig.isBindProperty && - !childPropertyConfig.isTriggerProperty - ) { - bindingPaths[childArrayPropertyPath] = true; - } else if ( - childPropertyConfig.isBindProperty && - childPropertyConfig.isTriggerProperty - ) { - triggerPaths[childArrayPropertyPath] = true; - } - }); - }, - ); + const widgetPropertyValue = get(widget, basePropertyPath, []); + if (Array.isArray(widgetPropertyValue)) { + widgetPropertyValue.forEach( + (arrayPropertyValue: any, index: number) => { + const arrayIndexPropertyPath = `${basePropertyPath}[${index}]`; + controlConfig.children.forEach((childPropertyConfig: any) => { + const childArrayPropertyPath = `${arrayIndexPropertyPath}.${childPropertyConfig.propertyName}`; + if ( + childPropertyConfig.isBindProperty && + !childPropertyConfig.isTriggerProperty + ) { + bindingPaths[childArrayPropertyPath] = true; + } else if ( + childPropertyConfig.isBindProperty && + childPropertyConfig.isTriggerProperty + ) { + triggerPaths[childArrayPropertyPath] = true; + } + }); + }, + ); + } } }); } diff --git a/app/client/src/mockResponses/WidgetSidebarResponse.tsx b/app/client/src/mockResponses/WidgetSidebarResponse.tsx index 205bb38231..3290fa508f 100644 --- a/app/client/src/mockResponses/WidgetSidebarResponse.tsx +++ b/app/client/src/mockResponses/WidgetSidebarResponse.tsx @@ -1,5 +1,6 @@ import { WidgetCardProps } from "widgets/BaseWidget"; import { generateReactKey } from "utils/generators"; +import { keyBy } from "lodash"; /* eslint-disable no-useless-computed-key */ const WidgetSidebarResponse: WidgetCardProps[] = [ @@ -101,3 +102,5 @@ const WidgetSidebarResponse: WidgetCardProps[] = [ ]; export default WidgetSidebarResponse; + +export const widgetSidebarConfig = keyBy(WidgetSidebarResponse, "type"); diff --git a/app/client/src/pages/AppViewer/viewer/AppViewerHeader.tsx b/app/client/src/pages/AppViewer/viewer/AppViewerHeader.tsx index eaab6fcd68..30a4a8cf35 100644 --- a/app/client/src/pages/AppViewer/viewer/AppViewerHeader.tsx +++ b/app/client/src/pages/AppViewer/viewer/AppViewerHeader.tsx @@ -13,11 +13,7 @@ import { ApplicationPayload, PageListPayload, } from "constants/ReduxActionConstants"; -import { - APPLICATIONS_URL, - AUTH_LOGIN_URL, - SIGN_UP_URL, -} from "constants/routes"; +import { APPLICATIONS_URL, AUTH_LOGIN_URL } from "constants/routes"; import { connect } from "react-redux"; import { AppState } from "reducers"; import { getEditorURL } from "selectors/appViewSelectors"; @@ -37,6 +33,7 @@ import ProfileDropdown from "pages/common/ProfileDropdown"; import { Profile } from "pages/common/ProfileImage"; import PageTabsContainer from "./PageTabsContainer"; import { getThemeDetails, ThemeMode } from "selectors/themeSelectors"; +import ForkApplicationModal from "pages/Applications/ForkApplicationModal"; const HeaderWrapper = styled(StyledHeader)<{ hasPages: boolean }>` box-shadow: unset; @@ -69,6 +66,14 @@ const HeaderWrapper = styled(StyledHeader)<{ hasPages: boolean }>` } } + .header__application-fork-btn-wrapper { + height: 100%; + } + + .header__application-fork-btn-wrapper .ads-dialog-trigger { + height: 100%; + } + & ${Profile} { width: 24px; height: 24px; @@ -111,8 +116,8 @@ const ForkButton = styled(Cta)` svg { transform: rotate(-90deg); } + height: ${(props) => `calc(${props.theme.smallHeaderHeight})`}; `; - const HeaderRightItemContainer = styled.div` display: flex; align-items: center; @@ -136,7 +141,6 @@ type AppViewerHeaderProps = { export const AppViewerHeader = (props: AppViewerHeaderProps) => { const { currentApplicationDetails, currentOrgId, currentUser, pages } = props; - const isExampleApp = currentApplicationDetails?.appIsExample; const userPermissions = currentApplicationDetails?.userPermissions ?? []; const permissionRequired = PERMISSION_TYPE.MANAGE_APPLICATION; const canEdit = isPermitted(userPermissions, permissionRequired); @@ -155,8 +159,7 @@ export const AppViewerHeader = (props: AppViewerHeaderProps) => { }; if (hideHeader) return ; - const forkAppUrl = `${window.location.origin}${SIGN_UP_URL}?appId=${currentApplicationDetails?.id}`; - const loginAppUrl = `${window.location.origin}${AUTH_LOGIN_URL}?appId=${currentApplicationDetails?.id}`; + const redirectUrl = `${AUTH_LOGIN_URL}?redirectUrl=${window.location.href}`; let CTA = null; @@ -169,11 +172,15 @@ export const AppViewerHeader = (props: AppViewerHeaderProps) => { text={EDIT_APP} /> ); - } else if (isExampleApp) { + } else if ( + currentApplicationDetails?.forkingEnabled && + currentApplicationDetails?.isPublic && + currentUser?.username === ANONYMOUS_USERNAME + ) { CTA = ( @@ -182,7 +189,7 @@ export const AppViewerHeader = (props: AppViewerHeaderProps) => { currentApplicationDetails?.isPublic && currentUser?.username === ANONYMOUS_USERNAME ) { - CTA = ; + CTA = ; } return ( @@ -218,6 +225,15 @@ export const AppViewerHeader = (props: AppViewerHeaderProps) => { title={currentApplicationDetails.name} canOutsideClickClose={true} /> + {currentUser && + currentUser.username !== ANONYMOUS_USERNAME && + currentApplicationDetails?.forkingEnabled && ( +
+ +
+ )} {CTA && ( {CTA} )} diff --git a/app/client/src/pages/Applications/ForkApplicationModal.tsx b/app/client/src/pages/Applications/ForkApplicationModal.tsx new file mode 100644 index 0000000000..998a35e0bc --- /dev/null +++ b/app/client/src/pages/Applications/ForkApplicationModal.tsx @@ -0,0 +1,126 @@ +import React, { useEffect, useMemo, useState } from "react"; +import Dialog from "components/ads/DialogComponent"; +import Button, { Size } from "components/ads/Button"; +import styled from "styled-components"; +import { getTypographyByKey } from "constants/DefaultTheme"; +import Divider from "components/editorComponents/Divider"; +import { FORK_APP } from "constants/messages"; +import { useDispatch } from "react-redux"; +import { getAllApplications } from "actions/applicationActions"; +import { useSelector } from "store"; +import { getUserApplicationsOrgs } from "selectors/applicationSelectors"; +import { isPermitted, PERMISSION_TYPE } from "./permissionHelpers"; +import RadioComponent from "components/ads/Radio"; +import { ReduxActionTypes } from "constants/ReduxActionConstants"; +import { Classes } from "@blueprintjs/core"; + +const TriggerButton = styled(Button)` + ${(props) => getTypographyByKey(props, "btnLarge")} + height: 100%; + svg { + transform: rotate(-90deg); + } + margin-right: ${(props) => props.theme.spaces[7]}px; +`; + +const StyledDialog = styled(Dialog)` + && .${Classes.DIALOG_BODY} { + padding-top: 0px; + } +`; + +const StyledRadioComponent = styled(RadioComponent)` + label { + font-size: 16px; + margin-bottom: 32px; + } +`; + +const ForkButton = styled(Button)` + height: 38px; + width: 203px; +`; + +const OrganizationList = styled.div` + overflow: auto; + max-height: 250px; + margin-bottom: 10px; + margin-top: 20px; +`; + +const ForkApplicationModal = (props: any) => { + const [organizationId, selectOrganizationId] = useState(""); + const dispatch = useDispatch(); + useEffect(() => { + dispatch(getAllApplications()); + }, [dispatch, getAllApplications]); + const userOrgs = useSelector(getUserApplicationsOrgs); + + const forkApplication = () => { + dispatch({ + type: ReduxActionTypes.FORK_APPLICATION_INIT, + payload: { + applicationId: props.applicationId, + organizationId, + }, + }); + }; + + const organizationList = useMemo(() => { + const filteredUserOrgs = userOrgs.filter((item) => { + const permitted = isPermitted( + item.organization.userPermissions ?? [], + PERMISSION_TYPE.CREATE_APPLICATION, + ); + return permitted; + }); + + if (filteredUserOrgs.length) { + selectOrganizationId(filteredUserOrgs[0].organization.id); + } + + return filteredUserOrgs.map((org) => { + return { + label: org.organization.name, + value: org.organization.id, + }; + }); + }, [userOrgs]); + + return ( + + } + > + + {organizationList.length && ( + + selectOrganizationId(value)} + /> + + )} + + + ); +}; + +export default ForkApplicationModal; diff --git a/app/client/src/pages/Editor/EditorHeader.tsx b/app/client/src/pages/Editor/EditorHeader.tsx index e26edf4172..2ec26f47cd 100644 --- a/app/client/src/pages/Editor/EditorHeader.tsx +++ b/app/client/src/pages/Editor/EditorHeader.tsx @@ -11,7 +11,6 @@ import { import AppInviteUsersForm from "pages/organization/AppInviteUsersForm"; import StyledHeader from "components/designSystems/appsmith/StyledHeader"; import AnalyticsUtil from "utils/AnalyticsUtil"; -import HelpModal from "components/designSystems/appsmith/help/HelpModal"; import { FormDialogComponent } from "components/editorComponents/form/FormDialogComponent"; import AppsmithLogo from "assets/images/appsmith_logo_square.png"; import { Link } from "react-router-dom"; @@ -38,6 +37,7 @@ import EditableAppName from "./EditableAppName"; import Boxed from "components/editorComponents/Onboarding/Boxed"; import OnboardingHelper from "components/editorComponents/Onboarding/Helper"; import { OnboardingStep } from "constants/OnboardingConstants"; +import GlobalSearch from "components/editorComponents/GlobalSearch"; import EndOnboardingTour from "components/editorComponents/Onboarding/EndTour"; import ProfileDropdown from "pages/common/ProfileDropdown"; import { getCurrentUser } from "selectors/usersSelectors"; @@ -46,6 +46,8 @@ import Button, { Size } from "components/ads/Button"; import { IconWrapper } from "components/ads/Icon"; import { Profile } from "pages/common/ProfileImage"; import { getTypographyByKey } from "constants/DefaultTheme"; +import HelpBar from "components/editorComponents/GlobalSearch/HelpBar"; +import HelpButton from "./HelpButton"; import OnboardingIndicator from "components/editorComponents/Onboarding/Indicator"; import { getThemeDetails, ThemeMode } from "selectors/themeSelectors"; @@ -81,7 +83,10 @@ const HeaderWrapper = styled(StyledHeader)` } `; +// looks offset by 1px even though, checking bounding rect values const HeaderSection = styled.div` + position: relative; + top: -1px; display: flex; flex: 1; overflow: hidden; @@ -90,6 +95,9 @@ const HeaderSection = styled.div` justify-content: flex-start; } :nth-child(2) { + justify-content: center; + } + :nth-child(3) { justify-content: flex-end; } `; @@ -231,6 +239,10 @@ export const EditorHeader = (props: EditorHeaderProps) => { )} + + + + @@ -294,8 +306,8 @@ export const EditorHeader = (props: EditorHeaderProps) => { )} - + ); diff --git a/app/client/src/pages/Editor/Explorer/Actions/ActionEntityContextMenu.tsx b/app/client/src/pages/Editor/Explorer/Actions/ActionEntityContextMenu.tsx index 456d1444bf..56ac660691 100644 --- a/app/client/src/pages/Editor/Explorer/Actions/ActionEntityContextMenu.tsx +++ b/app/client/src/pages/Editor/Explorer/Actions/ActionEntityContextMenu.tsx @@ -3,7 +3,6 @@ import { useDispatch, useSelector } from "react-redux"; import TreeDropdown from "components/editorComponents/actioncreator/TreeDropdown"; import { AppState } from "reducers"; -import { getNextEntityName } from "utils/AppsmithUtils"; import ContextMenuTrigger from "../ContextMenuTrigger"; import { @@ -15,18 +14,7 @@ import { import { initExplorerEntityNameEdit } from "actions/explorerActions"; import { ContextMenuPopoverModifiers } from "../helpers"; import { noop } from "lodash"; - -const useNewAPIName = () => { - // This takes into consideration only the current page widgets - // If we're moving to a different page, there could be a widget - // with the same name as the generated API name - // TODO: Figure out how to handle this scenario - const apiNames = useSelector((state: AppState) => - state.entities.actions.map((action) => action.config.name), - ); - return (name: string) => - apiNames.indexOf(name) > -1 ? getNextEntityName(name, apiNames) : name; -}; +import { useNewActionName } from "./helpers"; type EntityContextMenuProps = { id: string; @@ -35,7 +23,7 @@ type EntityContextMenuProps = { pageId: string; }; export const ActionEntityContextMenu = (props: EntityContextMenuProps) => { - const nextEntityName = useNewAPIName(); + const nextEntityName = useNewActionName(); const dispatch = useDispatch(); const copyActionToPage = useCallback( @@ -44,7 +32,7 @@ export const ActionEntityContextMenu = (props: EntityContextMenuProps) => { copyActionRequest({ id: actionId, destinationPageId: pageId, - name: nextEntityName(`${actionName}Copy`), + name: nextEntityName(`${actionName}Copy`, pageId), }), ), [dispatch, nextEntityName], @@ -56,7 +44,7 @@ export const ActionEntityContextMenu = (props: EntityContextMenuProps) => { id: actionId, destinationPageId, originalPageId: props.pageId, - name: nextEntityName(actionName), + name: nextEntityName(actionName, destinationPageId), }), ), [dispatch, nextEntityName, props.pageId], diff --git a/app/client/src/pages/Editor/Explorer/Actions/MoreActionsMenu.tsx b/app/client/src/pages/Editor/Explorer/Actions/MoreActionsMenu.tsx index e8a416a779..9a76bd6b83 100644 --- a/app/client/src/pages/Editor/Explorer/Actions/MoreActionsMenu.tsx +++ b/app/client/src/pages/Editor/Explorer/Actions/MoreActionsMenu.tsx @@ -2,7 +2,6 @@ import React, { useCallback } from "react"; import { useDispatch, useSelector } from "react-redux"; import { AppState } from "reducers"; -import { getNextEntityName } from "utils/AppsmithUtils"; import { moveActionRequest, @@ -13,18 +12,7 @@ import { import { ContextMenuPopoverModifiers } from "../helpers"; import { noop } from "lodash"; import TreeDropdown from "components/ads/TreeDropdown"; - -const useNewAPIName = () => { - // This takes into consideration only the current page widgets - // If we're moving to a different page, there could be a widget - // with the same name as the generated API name - // TODO: Figure out how to handle this scenario - const apiNames = useSelector((state: AppState) => - state.entities.actions.map((action) => action.config.name), - ); - return (name: string) => - apiNames.indexOf(name) > -1 ? getNextEntityName(name, apiNames) : name; -}; +import { useNewActionName } from "./helpers"; type EntityContextMenuProps = { id: string; @@ -33,7 +21,7 @@ type EntityContextMenuProps = { pageId: string; }; export const MoreActionsMenu = (props: EntityContextMenuProps) => { - const nextEntityName = useNewAPIName(); + const nextEntityName = useNewActionName(); const dispatch = useDispatch(); const copyActionToPage = useCallback( @@ -42,7 +30,7 @@ export const MoreActionsMenu = (props: EntityContextMenuProps) => { copyActionRequest({ id: actionId, destinationPageId: pageId, - name: nextEntityName(`${actionName}Copy`), + name: nextEntityName(`${actionName}Copy`, pageId), }), ), [dispatch, nextEntityName], @@ -54,7 +42,7 @@ export const MoreActionsMenu = (props: EntityContextMenuProps) => { id: actionId, destinationPageId, originalPageId: props.pageId, - name: nextEntityName(actionName), + name: nextEntityName(actionName, destinationPageId), }), ), [dispatch, nextEntityName, props.pageId], diff --git a/app/client/src/pages/Editor/Explorer/Actions/helpers.tsx b/app/client/src/pages/Editor/Explorer/Actions/helpers.tsx index 022066adea..665f7811bf 100644 --- a/app/client/src/pages/Editor/Explorer/Actions/helpers.tsx +++ b/app/client/src/pages/Editor/Explorer/Actions/helpers.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode } from "react"; +import React, { ReactNode, useMemo } from "react"; import { apiIcon, dbQueryIcon, MethodTag, QueryIcon } from "../ExplorerIcons"; import { PluginType } from "entities/Action"; import { generateReactKey } from "utils/generators"; @@ -15,6 +15,11 @@ import { ExplorerURLParams } from "../helpers"; import { Datasource } from "entities/Datasource"; import { Plugin } from "api/PluginApi"; import PluginGroup from "../PluginGroup/PluginGroup"; +import { useSelector } from "react-redux"; +import { AppState } from "reducers"; +import { groupBy } from "lodash"; +import { ActionData } from "reducers/entityReducers/actionsReducer"; +import { getNextEntityName } from "utils/AppsmithUtils"; export type ActionGroupConfig = { groupName: string; @@ -90,6 +95,12 @@ export const ACTION_PLUGIN_MAP: Array< } }); +export const getActionConfig = (type: PluginType) => + ACTION_PLUGIN_MAP.find( + (configByType: ActionGroupConfig | undefined) => + configByType?.type === type, + ); + export const getPluginGroups = ( page: Page, step: number, @@ -135,3 +146,25 @@ export const getPluginGroups = ( ); }); }; + +export const useNewActionName = () => { + // This takes into consideration only the current page widgets + // If we're moving to a different page, there could be a widget + // with the same name as the generated API name + // TODO: Figure out how to handle this scenario + const actions = useSelector((state: AppState) => state.entities.actions); + const groupedActions = useMemo(() => { + return groupBy(actions, "config.pageId"); + }, [actions]); + return (name: string, destinationPageId: string) => { + const pageActions = groupedActions[destinationPageId]; + // Get action names of the destination page only + const actionNames = pageActions + ? pageActions.map((action: ActionData) => action.config.name) + : []; + + return actionNames.indexOf(name) > -1 + ? getNextEntityName(name, actionNames) + : name; + }; +}; diff --git a/app/client/src/pages/Editor/Explorer/Pages/PageEntity.tsx b/app/client/src/pages/Editor/Explorer/Pages/PageEntity.tsx index 5fecc1c0d1..557ead89eb 100644 --- a/app/client/src/pages/Editor/Explorer/Pages/PageEntity.tsx +++ b/app/client/src/pages/Editor/Explorer/Pages/PageEntity.tsx @@ -14,7 +14,7 @@ import { hiddenPageIcon, homePageIcon, pageIcon } from "../ExplorerIcons"; import { getPluginGroups } from "../Actions/helpers"; import ExplorerWidgetGroup from "../Widgets/WidgetGroup"; import { resolveAsSpaceChar } from "utils/helpers"; -import { CanvasStructure } from "reducers/uiReducers/pageCanvasStructure"; +import { CanvasStructure } from "reducers/uiReducers/pageCanvasStructureReducer"; import { Datasource } from "entities/Datasource"; import { Plugin } from "api/PluginApi"; diff --git a/app/client/src/pages/Editor/Explorer/Pages/PageGroup.tsx b/app/client/src/pages/Editor/Explorer/Pages/PageGroup.tsx index d6d3601f5b..ea09de0db9 100644 --- a/app/client/src/pages/Editor/Explorer/Pages/PageGroup.tsx +++ b/app/client/src/pages/Editor/Explorer/Pages/PageGroup.tsx @@ -9,7 +9,7 @@ import { ExplorerURLParams } from "../helpers"; import { Page } from "constants/ReduxActionConstants"; import ExplorerPageEntity from "./PageEntity"; import { AppState } from "reducers"; -import { CanvasStructure } from "reducers/uiReducers/pageCanvasStructure"; +import { CanvasStructure } from "reducers/uiReducers/pageCanvasStructureReducer"; import { Datasource } from "entities/Datasource"; import { Plugin } from "api/PluginApi"; diff --git a/app/client/src/pages/Editor/Explorer/Widgets/WidgetEntity.tsx b/app/client/src/pages/Editor/Explorer/Widgets/WidgetEntity.tsx index 296a9d8fb2..2c7671ca22 100644 --- a/app/client/src/pages/Editor/Explorer/Widgets/WidgetEntity.tsx +++ b/app/client/src/pages/Editor/Explorer/Widgets/WidgetEntity.tsx @@ -21,7 +21,7 @@ import WidgetContextMenu from "./WidgetContextMenu"; import { updateWidgetName } from "actions/propertyPaneActions"; import { ENTITY_TYPE } from "entities/DataTree/dataTreeFactory"; import EntityProperties from "../Entity/EntityProperties"; -import { CanvasStructure } from "reducers/uiReducers/pageCanvasStructure"; +import { CanvasStructure } from "reducers/uiReducers/pageCanvasStructureReducer"; import CurrentPageEntityProperties from "../Entity/CurrentPageEntityProperties"; export type WidgetTree = WidgetProps & { children?: WidgetTree[] }; @@ -43,15 +43,42 @@ export const navigateToCanvas = ( } }; +export const useNavigateToWidget = () => { + const params = useParams(); + const dispatch = useDispatch(); + const { selectWidget } = useWidgetSelection(); + + const navigateToWidget = useCallback( + ( + widgetId: string, + widgetType: WidgetType, + pageId: string, + isWidgetSelected?: boolean, + parentModalId?: string, + ) => { + if (widgetType === WidgetTypes.MODAL_WIDGET) { + dispatch(showModal(widgetId)); + return; + } + if (parentModalId) dispatch(showModal(parentModalId)); + else dispatch(closeAllModals()); + navigateToCanvas(params, window.location.pathname, pageId, widgetId); + flashElementById(widgetId); + if (!isWidgetSelected) selectWidget(widgetId); + dispatch(forceOpenPropertyPane(widgetId)); + }, + [dispatch, params, selectWidget], + ); + + return { navigateToWidget }; +}; + const useWidget = ( widgetId: string, widgetType: WidgetType, pageId: string, parentModalId?: string, ) => { - const params = useParams(); - const dispatch = useDispatch(); - const { selectWidget } = useWidgetSelection(); const selectedWidget = useSelector( (state: AppState) => state.ui.widgetDragResize.selectedWidget, ); @@ -60,29 +87,21 @@ const useWidget = ( widgetId, ]); - const navigateToWidget = useCallback(() => { - if (widgetType === WidgetTypes.MODAL_WIDGET) { - dispatch(showModal(widgetId)); - return; - } - if (parentModalId) dispatch(showModal(parentModalId)); - else dispatch(closeAllModals()); - navigateToCanvas(params, window.location.pathname, pageId, widgetId); - flashElementById(widgetId); - if (!isWidgetSelected) selectWidget(widgetId); - dispatch(forceOpenPropertyPane(widgetId)); - }, [ - dispatch, - params, - selectWidget, - widgetType, - widgetId, - parentModalId, - pageId, - isWidgetSelected, - ]); + const { navigateToWidget } = useNavigateToWidget(); - return { navigateToWidget, isWidgetSelected }; + const boundNavigateToWidget = useCallback( + () => + navigateToWidget( + widgetId, + widgetType, + pageId, + isWidgetSelected, + parentModalId, + ), + [widgetId, widgetType, pageId, isWidgetSelected, parentModalId], + ); + + return { navigateToWidget: boundNavigateToWidget, isWidgetSelected }; }; export type WidgetEntityProps = { diff --git a/app/client/src/pages/Editor/Explorer/Widgets/WidgetGroup.tsx b/app/client/src/pages/Editor/Explorer/Widgets/WidgetGroup.tsx index 6947fb2872..611606f178 100644 --- a/app/client/src/pages/Editor/Explorer/Widgets/WidgetGroup.tsx +++ b/app/client/src/pages/Editor/Explorer/Widgets/WidgetGroup.tsx @@ -10,7 +10,7 @@ import { BUILDER_PAGE_URL } from "constants/routes"; import { Link } from "react-router-dom"; import styled from "styled-components"; import { AppState } from "reducers"; -import { CanvasStructure } from "reducers/uiReducers/pageCanvasStructure"; +import { CanvasStructure } from "reducers/uiReducers/pageCanvasStructureReducer"; type ExplorerWidgetGroupProps = { pageId: string; diff --git a/app/client/src/pages/Editor/Explorer/hooks.ts b/app/client/src/pages/Editor/Explorer/hooks.ts index 592363c27f..76b4d2a37d 100644 --- a/app/client/src/pages/Editor/Explorer/hooks.ts +++ b/app/client/src/pages/Editor/Explorer/hooks.ts @@ -14,7 +14,7 @@ import { debounce } from "lodash"; import { WidgetProps } from "widgets/BaseWidget"; import log from "loglevel"; import produce from "immer"; -import { CanvasStructure } from "reducers/uiReducers/pageCanvasStructure"; +import { CanvasStructure } from "reducers/uiReducers/pageCanvasStructureReducer"; const findWidgets = (widgets: CanvasStructure, keyword: string) => { if (!widgets || !widgets.widgetName) return widgets; diff --git a/app/client/src/pages/Editor/GlobalHotKeys.tsx b/app/client/src/pages/Editor/GlobalHotKeys.tsx new file mode 100644 index 0000000000..6b5db918c7 --- /dev/null +++ b/app/client/src/pages/Editor/GlobalHotKeys.tsx @@ -0,0 +1,156 @@ +import React from "react"; +import { connect } from "react-redux"; +import { AppState } from "reducers"; +import { Hotkey, Hotkeys } from "@blueprintjs/core"; +import { HotkeysTarget } from "@blueprintjs/core/lib/esnext/components/hotkeys/hotkeysTarget.js"; +import { + copyWidget, + cutWidget, + deleteSelectedWidget, + pasteWidget, +} from "actions/widgetActions"; +import { toggleShowGlobalSearchModal } from "actions/globalSearchActions"; +import { isMac } from "utils/helpers"; +import { getSelectedWidget } from "selectors/ui"; +import { MAIN_CONTAINER_WIDGET_ID } from "constants/WidgetConstants"; +import { getSelectedText } from "utils/helpers"; +import AnalyticsUtil from "utils/AnalyticsUtil"; +import { + ENTITY_EXPLORER_SEARCH_ID, + WIDGETS_SEARCH_ID, +} from "constants/Explorer"; + +type Props = { + copySelectedWidget: () => void; + pasteCopiedWidget: () => void; + deleteSelectedWidget: () => void; + cutSelectedWidget: () => void; + toggleShowGlobalSearchModal: () => void; + selectedWidget?: string; + children: React.ReactNode; +}; + +@HotkeysTarget +class GlobalHotKeys extends React.Component { + public stopPropagationIfWidgetSelected(e: KeyboardEvent): boolean { + if ( + this.props.selectedWidget && + this.props.selectedWidget != MAIN_CONTAINER_WIDGET_ID && + !getSelectedText() + ) { + e.preventDefault(); + e.stopPropagation(); + return true; + } + return false; + } + + public renderHotkeys() { + return ( + + { + const entitySearchInput = document.getElementById( + ENTITY_EXPLORER_SEARCH_ID, + ); + const widgetSearchInput = document.getElementById( + WIDGETS_SEARCH_ID, + ); + if (entitySearchInput) entitySearchInput.focus(); + if (widgetSearchInput) widgetSearchInput.focus(); + e.preventDefault(); + e.stopPropagation(); + }} + /> + { + console.log("toggleShowGlobalSearchModal"); + e.preventDefault(); + this.props.toggleShowGlobalSearchModal(); + AnalyticsUtil.logEvent("OPEN_OMNIBAR", { source: "HOTKEY_COMBO" }); + }} + allowInInput={false} + label="Show omnibar" + global={true} + /> + { + if (this.stopPropagationIfWidgetSelected(e)) { + this.props.copySelectedWidget(); + } + }} + /> + { + this.props.pasteCopiedWidget(); + }} + /> + { + if (this.stopPropagationIfWidgetSelected(e) && isMac()) { + this.props.deleteSelectedWidget(); + } + }} + /> + { + if (this.stopPropagationIfWidgetSelected(e)) { + this.props.deleteSelectedWidget(); + } + }} + /> + { + if (this.stopPropagationIfWidgetSelected(e)) { + this.props.cutSelectedWidget(); + } + }} + /> + + ); + } + + render() { + return
{this.props.children}
; + } +} + +const mapStateToProps = (state: AppState) => ({ + selectedWidget: getSelectedWidget(state), +}); + +const mapDispatchToProps = (dispatch: any) => { + return { + copySelectedWidget: () => dispatch(copyWidget(true)), + pasteCopiedWidget: () => dispatch(pasteWidget()), + deleteSelectedWidget: () => dispatch(deleteSelectedWidget(true)), + cutSelectedWidget: () => dispatch(cutWidget()), + toggleShowGlobalSearchModal: () => dispatch(toggleShowGlobalSearchModal()), + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(GlobalHotKeys); diff --git a/app/client/src/pages/Editor/HelpButton.tsx b/app/client/src/pages/Editor/HelpButton.tsx new file mode 100644 index 0000000000..76f0127009 --- /dev/null +++ b/app/client/src/pages/Editor/HelpButton.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import styled, { createGlobalStyle } from "styled-components"; +import { Popover, Position } from "@blueprintjs/core"; + +import DocumentationSearch from "components/designSystems/appsmith/help/DocumentationSearch"; +import Icon, { IconSize } from "components/ads/Icon"; + +import { HELP_MODAL_WIDTH } from "constants/HelpConstants"; +import AnalyticsUtil from "utils/AnalyticsUtil"; + +const HelpPopoverStyle = createGlobalStyle` + .bp3-popover.bp3-minimal.navbar-help-popover { + margin-top: 0 !important; + } +`; + +const StyledTrigger = styled.div` + cursor: pointer; + width: 25px; + height: 25px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin: 0 ${(props) => props.theme.spaces[2]}px; + background: ${(props) => + props.theme.colors.globalSearch.helpButtonBackground}; +`; + +const Trigger = () => ( + + + +); + +const onOpened = () => { + AnalyticsUtil.logEvent("OPEN_HELP", { page: "Editor" }); +}; +const HelpButton = () => { + return ( + + <> + + + +
+ +
+
+ ); +}; + +export default HelpButton; diff --git a/app/client/src/pages/Editor/PropertyPaneHelpButton.tsx b/app/client/src/pages/Editor/PropertyPaneHelpButton.tsx new file mode 100644 index 0000000000..0fcd5b8fdf --- /dev/null +++ b/app/client/src/pages/Editor/PropertyPaneHelpButton.tsx @@ -0,0 +1,39 @@ +import React, { useCallback } from "react"; +import { useSelector, useDispatch } from "react-redux"; +import { withTheme } from "styled-components"; +import { Icon } from "@blueprintjs/core"; + +import { + setGlobalSearchQuery, + toggleShowGlobalSearchModal, +} from "actions/globalSearchActions"; +import { getSelectedWidget } from "sagas/selectors"; +import { Theme } from "constants/DefaultTheme"; +import { widgetSidebarConfig } from "mockResponses/WidgetSidebarResponse"; + +type Props = { + theme: Theme; +}; + +const PropertyPaneHelpButton = withTheme(({ theme }: Props) => { + const selectedWidget = useSelector(getSelectedWidget); + const selectedWidgetType = selectedWidget?.type; + const dispatch = useDispatch(); + const config = selectedWidgetType && widgetSidebarConfig[selectedWidgetType]; + + const openHelpModal = useCallback(() => { + dispatch(setGlobalSearchQuery(config?.widgetCardName || "")); + dispatch(toggleShowGlobalSearchModal()); + }, [selectedWidgetType]); + + return ( + + ); +}); + +export default PropertyPaneHelpButton; diff --git a/app/client/src/pages/Editor/PropertyPaneTitle.tsx b/app/client/src/pages/Editor/PropertyPaneTitle.tsx index 34c4e58610..9dff508a8f 100644 --- a/app/client/src/pages/Editor/PropertyPaneTitle.tsx +++ b/app/client/src/pages/Editor/PropertyPaneTitle.tsx @@ -11,7 +11,7 @@ import { getExistingWidgetNames } from "sagas/selectors"; import { removeSpecialChars } from "utils/helpers"; import { useToggleEditWidgetName } from "utils/hooks/dragResizeHooks"; import AnalyticsUtil from "utils/AnalyticsUtil"; -import { BindingText } from "pages/Editor/APIEditor/Form"; +import PropertyPaneHelpButton from "pages/Editor/PropertyPaneHelpButton"; import { Icon, Tooltip, Position, Classes } from "@blueprintjs/core"; import { WidgetType } from "constants/WidgetConstants"; @@ -19,6 +19,7 @@ import { theme } from "constants/DefaultTheme"; import { ControlIcons } from "icons/ControlIcons"; import { FormIcons } from "icons/FormIcons"; import { deleteSelectedWidget, copyWidget } from "actions/widgetActions"; + const CopyIcon = ControlIcons.COPY_CONTROL; const DeleteIcon = FormIcons.DELETE_ICON; const Wrapper = styled.div` @@ -151,18 +152,12 @@ const PropertyPaneTitle = memo((props: PropertyPaneTitleProps) => { /> - You can connect data from your API by adding - {`{{apiName.data}}`} - to a widget property - - } + content={Explore widget related docs} position={Position.TOP} hoverOpenDelay={200} boundary="window" > - + void; - pasteCopiedWidget: () => void; - deleteSelectedWidget: () => void; - cutSelectedWidget: () => void; user?: User; selectedWidget?: string; lightTheme: Theme; + resetEditorRequest: () => void; + handlePathUpdated: (pathName: string) => void; }; type Props = EditorProps & RouteComponentProps; -const getSelectedText = () => { - if (typeof window.getSelection === "function") { - const selectionObj = window.getSelection(); - return selectionObj && selectionObj.toString(); - } -}; - -@HotkeysTarget class Editor extends Component { - public stopPropagationIfWidgetSelected(e: KeyboardEvent): boolean { - if ( - this.props.selectedWidget && - this.props.selectedWidget != MAIN_CONTAINER_WIDGET_ID && - !getSelectedText() - ) { - e.preventDefault(); - e.stopPropagation(); - return true; - } - return false; - } + unlisten: any; - public renderHotkeys() { - return ( - - { - const entitySearchInput = document.getElementById( - ENTITY_EXPLORER_SEARCH_ID, - ); - const widgetSearchInput = document.getElementById( - WIDGETS_SEARCH_ID, - ); - if (entitySearchInput) entitySearchInput.focus(); - if (widgetSearchInput) widgetSearchInput.focus(); - e.preventDefault(); - e.stopPropagation(); - }} - /> - { - if (this.stopPropagationIfWidgetSelected(e)) { - this.props.copySelectedWidget(); - } - }} - /> - { - this.props.pasteCopiedWidget(); - }} - /> - { - if (this.stopPropagationIfWidgetSelected(e) && isMac()) { - this.props.deleteSelectedWidget(); - } - }} - /> - { - if (this.stopPropagationIfWidgetSelected(e)) { - this.props.deleteSelectedWidget(); - } - }} - /> - { - if (this.stopPropagationIfWidgetSelected(e)) { - this.props.cutSelectedWidget(); - } - }} - /> - - ); - } public state = { registered: false, }; @@ -173,6 +67,8 @@ class Editor extends Component { if (applicationId && pageId) { this.props.initEditor(applicationId, pageId); } + this.props.handlePathUpdated(window.location.pathname); + this.unlisten = history.listen(this.handleHistoryChange); } shouldComponentUpdate(nextProps: Props, nextState: { registered: boolean }) { @@ -191,6 +87,15 @@ class Editor extends Component { ); } + componentWillUnmount() { + this.props.resetEditorRequest(); + if (typeof this.unlisten === "function") this.unlisten(); + } + + handleHistoryChange = (location: any) => { + this.props.handlePathUpdated(location.pathname); + }; + public render() { if (this.props.creatingOnboardingDatabase) { return ; @@ -216,7 +121,9 @@ class Editor extends Component { Editor | Appsmith - + + + @@ -242,10 +149,9 @@ const mapDispatchToProps = (dispatch: any) => { return { initEditor: (applicationId: string, pageId: string) => dispatch(initEditor(applicationId, pageId)), - copySelectedWidget: () => dispatch(copyWidget(true)), - pasteCopiedWidget: () => dispatch(pasteWidget()), - deleteSelectedWidget: () => dispatch(deleteSelectedWidget(true)), - cutSelectedWidget: () => dispatch(cutWidget()), + resetEditorRequest: () => dispatch(resetEditorRequest()), + handlePathUpdated: (pathName: string) => + dispatch(handlePathUpdated(pathName)), }; }; diff --git a/app/client/src/pages/UserAuth/StyledComponents.tsx b/app/client/src/pages/UserAuth/StyledComponents.tsx index a1a0818a96..edfba5708d 100644 --- a/app/client/src/pages/UserAuth/StyledComponents.tsx +++ b/app/client/src/pages/UserAuth/StyledComponents.tsx @@ -39,7 +39,7 @@ export const AuthCard = styled(Card)` text-align: center; padding: 0; margin: 0; - ${(props) => getTypographyByKey(props, "authCardHeader")} + ${(props) => getTypographyByKey(props, "cardHeader")} color: ${(props) => props.theme.colors.auth.headingText}; } & .form-message-container { @@ -120,13 +120,13 @@ export const FormActions = styled.div` `; export const SignUpLinkSection = styled.div` - ${(props) => getTypographyByKey(props, "authCardSubheader")} + ${(props) => getTypographyByKey(props, "cardSubheader")} color: ${(props) => props.theme.colors.auth.text}; text-align: center; `; export const ForgotPasswordLink = styled.div` - ${(props) => getTypographyByKey(props, "authCardSubheader")} + ${(props) => getTypographyByKey(props, "cardSubheader")} color: ${(props) => props.theme.colors.auth.text}; text-align: center; margin-top: ${(props) => props.theme.spaces[11]}px; diff --git a/app/client/src/reducers/entityReducers/canvasWidgetsReducer.tsx b/app/client/src/reducers/entityReducers/canvasWidgetsReducer.tsx index 540a487e5c..1500d393aa 100644 --- a/app/client/src/reducers/entityReducers/canvasWidgetsReducer.tsx +++ b/app/client/src/reducers/entityReducers/canvasWidgetsReducer.tsx @@ -5,11 +5,8 @@ import { ReduxAction, } from "constants/ReduxActionConstants"; import { WidgetProps } from "widgets/BaseWidget"; -import { - UpdateCanvasLayout, - UpdateWidgetPropertyPayload, -} from "actions/controlActions"; -import { set, uniqBy } from "lodash"; +import { UpdateCanvasLayout } from "actions/controlActions"; +import { set } from "lodash"; import { MAIN_CONTAINER_WIDGET_ID } from "constants/WidgetConstants"; const initialState: CanvasWidgetsReduxState = {}; @@ -37,32 +34,6 @@ const canvasWidgetsReducer = createImmerReducer(initialState, { ) => { set(state[MAIN_CONTAINER_WIDGET_ID], "rightColumn", action.payload.width); }, - [ReduxActionTypes.UPDATE_WIDGET_PROPERTY]: ( - state: CanvasWidgetsReduxState, - action: ReduxAction, - ) => { - const { dynamicUpdates, updates, widgetId } = action.payload; - // We loop over all updates - Object.entries(updates).forEach(([propertyPath, propertyValue]) => { - // since property paths could be nested, we use lodash set method - set(state[widgetId], propertyPath, propertyValue); - }); - - if (dynamicUpdates && dynamicUpdates.dynamicBindingPathList.length) { - const currentList = state[widgetId].dynamicBindingPathList || []; - state[widgetId].dynamicBindingPathList = uniqBy( - [...currentList, ...dynamicUpdates.dynamicBindingPathList], - "key", - ); - } - if (dynamicUpdates && dynamicUpdates.dynamicTriggerPathList.length) { - const currentList = state[widgetId].dynamicTriggerPathList || []; - state[widgetId].dynamicTriggerPathList = uniqBy( - [...currentList, ...dynamicUpdates.dynamicTriggerPathList], - "key", - ); - } - }, }); export interface CanvasWidgetsReduxState { diff --git a/app/client/src/reducers/index.tsx b/app/client/src/reducers/index.tsx index 482348f380..7bcfc20354 100644 --- a/app/client/src/reducers/index.tsx +++ b/app/client/src/reducers/index.tsx @@ -30,7 +30,7 @@ import { ImportReduxState } from "reducers/uiReducers/importReducer"; import { HelpReduxState } from "./uiReducers/helpReducer"; import { ApiNameReduxState } from "./uiReducers/apiNameReducer"; import { ExplorerReduxState } from "./uiReducers/explorerReducer"; -import { PageCanvasStructureReduxState } from "./uiReducers/pageCanvasStructure"; +import { PageCanvasStructureReduxState } from "reducers/uiReducers/pageCanvasStructureReducer"; import { ConfirmRunActionReduxState } from "./uiReducers/confirmRunActionReducer"; import { AppDataState } from "reducers/entityReducers/appReducer"; import { DatasourceNameReduxState } from "./uiReducers/datasourceNameReducer"; @@ -38,6 +38,7 @@ import { EvaluatedTreeState } from "./evaluationReducers/treeReducer"; import { EvaluationDependencyState } from "./evaluationReducers/dependencyReducer"; import { PageWidgetsReduxState } from "./uiReducers/pageWidgetsReducer"; import { OnboardingState } from "./uiReducers/onBoardingReducer"; +import { GlobalSearchReduxState } from "./uiReducers/globalSearchReducer"; import { ReleasesState } from "./uiReducers/releasesReducer"; import { LoadingEntitiesState } from "./evaluationReducers/loadingEntitiesReducer"; @@ -77,6 +78,7 @@ export interface AppState { datasourceName: DatasourceNameReduxState; theme: ThemeState; onBoarding: OnboardingState; + globalSearch: GlobalSearchReduxState; releases: ReleasesState; }; entities: { diff --git a/app/client/src/reducers/uiReducers/applicationsReducer.tsx b/app/client/src/reducers/uiReducers/applicationsReducer.tsx index af0c7548c1..7fd018ec18 100644 --- a/app/client/src/reducers/uiReducers/applicationsReducer.tsx +++ b/app/client/src/reducers/uiReducers/applicationsReducer.tsx @@ -19,6 +19,7 @@ const initialState: ApplicationsReduxState = { applicationList: [], creatingApplication: {}, deletingApplication: false, + forkingApplication: false, duplicatingApplication: false, userOrgs: [], isSavingOrgInfo: false, @@ -176,6 +177,39 @@ const applicationsReducer = createReducer(initialState, { createApplicationError: ERROR_MESSAGE_CREATE_APPLICATION, }; }, + [ReduxActionTypes.FORK_APPLICATION_INIT]: (state: ApplicationsReduxState) => { + return { ...state, forkingApplication: true }; + }, + [ReduxActionTypes.FORK_APPLICATION_SUCCESS]: ( + state: ApplicationsReduxState, + action: ReduxAction<{ orgId: string; application: ApplicationPayload }>, + ) => { + const _organizations = state.userOrgs.map((org: Organization) => { + if (org.organization.id === action.payload.orgId) { + const applications = org.applications; + org.applications = [...applications, action.payload.application]; + return { + ...org, + }; + } + return org; + }); + + return { + ...state, + forkingApplication: false, + applicationList: [...state.applicationList, action.payload.application], + userOrgs: _organizations, + }; + }, + [ReduxActionErrorTypes.FORK_APPLICATION_ERROR]: ( + state: ApplicationsReduxState, + ) => { + return { + ...state, + forkingApplication: false, + }; + }, [ReduxActionTypes.SAVING_ORG_INFO]: (state: ApplicationsReduxState) => { return { ...state, @@ -299,6 +333,7 @@ export interface ApplicationsReduxState { creatingApplication: creatingApplicationMap; createApplicationError?: string; deletingApplication: boolean; + forkingApplication: boolean; duplicatingApplication: boolean; currentApplication?: ApplicationPayload; userOrgs: Organization[]; diff --git a/app/client/src/reducers/uiReducers/editorReducer.tsx b/app/client/src/reducers/uiReducers/editorReducer.tsx index f5ade60ca1..22ac72b8f4 100644 --- a/app/client/src/reducers/uiReducers/editorReducer.tsx +++ b/app/client/src/reducers/uiReducers/editorReducer.tsx @@ -28,6 +28,9 @@ const initialState: EditorReduxState = { }; const editorReducer = createReducer(initialState, { + [ReduxActionTypes.RESET_EDITOR_SUCCESS]: (state: EditorReduxState) => { + return { ...state, initialized: false }; + }, [ReduxActionTypes.INITIALIZE_EDITOR_SUCCESS]: (state: EditorReduxState) => { return { ...state, initialized: true }; }, diff --git a/app/client/src/reducers/uiReducers/globalSearchReducer.ts b/app/client/src/reducers/uiReducers/globalSearchReducer.ts new file mode 100644 index 0000000000..8699621722 --- /dev/null +++ b/app/client/src/reducers/uiReducers/globalSearchReducer.ts @@ -0,0 +1,49 @@ +import { createReducer } from "utils/AppsmithUtils"; +import { ReduxAction, ReduxActionTypes } from "constants/ReduxActionConstants"; +import { RecentEntity } from "components/editorComponents/GlobalSearch/utils"; + +const initialState: GlobalSearchReduxState = { + query: "", // used to prefill when opened via contextual help links + modalOpen: false, + recentEntities: [], + recentEntitiesRestored: false, +}; + +const globalSearchReducer = createReducer(initialState, { + [ReduxActionTypes.SET_GLOBAL_SEARCH_QUERY]: ( + state: GlobalSearchReduxState, + action: ReduxAction, + ) => ({ ...state, query: action.payload }), + [ReduxActionTypes.TOGGLE_SHOW_GLOBAL_SEARCH_MODAL]: ( + state: GlobalSearchReduxState, + ) => ({ ...state, modalOpen: !state.modalOpen }), + [ReduxActionTypes.SET_RECENT_ENTITIES]: ( + state: GlobalSearchReduxState, + action: ReduxAction>, + ) => ({ + ...state, + recentEntities: action.payload, + }), + [ReduxActionTypes.RESET_RECENT_ENTITIES]: ( + state: GlobalSearchReduxState, + ) => ({ + ...state, + recentEntities: [], + recentEntitiesRestored: false, + }), + [ReduxActionTypes.RESTORE_RECENT_ENTITIES_SUCCESS]: ( + state: GlobalSearchReduxState, + ) => ({ + ...state, + recentEntitiesRestored: true, + }), +}); + +export interface GlobalSearchReduxState { + query: string; + modalOpen: boolean; + recentEntities: Array; + recentEntitiesRestored: boolean; +} + +export default globalSearchReducer; diff --git a/app/client/src/reducers/uiReducers/index.tsx b/app/client/src/reducers/uiReducers/index.tsx index 3780a5f40e..d4015785dc 100644 --- a/app/client/src/reducers/uiReducers/index.tsx +++ b/app/client/src/reducers/uiReducers/index.tsx @@ -21,9 +21,10 @@ import explorerReducer from "./explorerReducer"; import confirmRunActionReducer from "./confirmRunActionReducer"; import themeReducer from "./themeReducer"; import datasourceNameReducer from "./datasourceNameReducer"; -import pageCanvasStructureReducer from "./pageCanvasStructure"; +import pageCanvasStructureReducer from "reducers/uiReducers/pageCanvasStructureReducer"; import pageWidgetsReducer from "./pageWidgetsReducer"; import onBoardingReducer from "./onBoardingReducer"; +import globalSearchReducer from "./globalSearchReducer"; import releasesReducer from "./releasesReducer"; const uiReducer = combineReducers({ @@ -52,6 +53,7 @@ const uiReducer = combineReducers({ theme: themeReducer, confirmRunAction: confirmRunActionReducer, onBoarding: onBoardingReducer, + globalSearch: globalSearchReducer, releases: releasesReducer, }); export default uiReducer; diff --git a/app/client/src/reducers/uiReducers/pageCanvasStructure.ts b/app/client/src/reducers/uiReducers/pageCanvasStructureReducer.ts similarity index 100% rename from app/client/src/reducers/uiReducers/pageCanvasStructure.ts rename to app/client/src/reducers/uiReducers/pageCanvasStructureReducer.ts diff --git a/app/client/src/reducers/uiReducers/pageWidgetsReducer.ts b/app/client/src/reducers/uiReducers/pageWidgetsReducer.ts index a88f40af1d..b8acd49816 100644 --- a/app/client/src/reducers/uiReducers/pageWidgetsReducer.ts +++ b/app/client/src/reducers/uiReducers/pageWidgetsReducer.ts @@ -1,6 +1,6 @@ import { createImmerReducer } from "utils/AppsmithUtils"; import { ReduxActionTypes, ReduxAction } from "constants/ReduxActionConstants"; -import { DSL } from "./pageCanvasStructure"; +import { DSL } from "reducers/uiReducers/pageCanvasStructureReducer"; import { WidgetProps } from "widgets/BaseWidget"; import CanvasWidgetsNormalizer from "normalizers/CanvasWidgetsNormalizer"; diff --git a/app/client/src/sagas/ActionExecutionSagas.ts b/app/client/src/sagas/ActionExecutionSagas.ts index 6d55b51ddf..a4c4772817 100644 --- a/app/client/src/sagas/ActionExecutionSagas.ts +++ b/app/client/src/sagas/ActionExecutionSagas.ts @@ -587,6 +587,12 @@ function* executeAppAction(action: ReduxAction) { const { dynamicString, event, responseData } = action.payload; log.debug({ dynamicString, responseData }); + if (dynamicString === undefined) { + if (event.callback) event.callback({ success: false }); + log.error("Executing undefined action", event); + return; + } + const triggers = yield call( evaluateDynamicTrigger, dynamicString, diff --git a/app/client/src/sagas/ApplicationSagas.tsx b/app/client/src/sagas/ApplicationSagas.tsx index 83de640457..ac0322f1ac 100644 --- a/app/client/src/sagas/ApplicationSagas.tsx +++ b/app/client/src/sagas/ApplicationSagas.tsx @@ -14,6 +14,7 @@ import ApplicationApi, { DuplicateApplicationRequest, FetchApplicationsResponse, FetchUsersApplicationsOrgsResponse, + ForkApplicationRequest, OrganizationApplicationObject, PublishApplicationRequest, PublishApplicationResponse, @@ -52,6 +53,7 @@ import { getCurrentPageId, } from "selectors/editorSelectors"; import { showCompletionDialog } from "./OnboardingSagas"; +import { deleteRecentAppEntities } from "utils/storage"; const getDefaultPageId = ( pages?: ApplicationPagePayload[], @@ -288,6 +290,7 @@ export function* deleteApplicationSaga( type: ReduxActionTypes.DELETE_APPLICATION_SUCCESS, payload: response.data, }); + yield call(deleteRecentAppEntities, request.applicationId); } } catch (error) { yield put({ @@ -447,6 +450,44 @@ export function* createApplicationSaga( } } +export function* forkApplicationSaga( + action: ReduxAction, +) { + try { + const response: ApiResponse = yield call( + ApplicationApi.forkApplication, + action.payload, + ); + const isValidResponse = yield validateResponse(response); + if (isValidResponse) { + yield put(resetCurrentApplication()); + const application: ApplicationPayload = { + ...response.data, + defaultPageId: getDefaultPageId(response.data.pages), + }; + yield put({ + type: ReduxActionTypes.FORK_APPLICATION_SUCCESS, + payload: { + orgId: action.payload.organizationId, + application, + }, + }); + const pageURL = BUILDER_PAGE_URL( + application.id, + application.defaultPageId, + ); + history.push(pageURL); + } + } catch (error) { + yield put({ + type: ReduxActionErrorTypes.FORK_APPLICATION_ERROR, + payload: { + error, + }, + }); + } +} + export default function* applicationSagas() { yield all([ takeLatest( @@ -464,6 +505,7 @@ export default function* applicationSagas() { getAllApplicationSaga, ), takeLatest(ReduxActionTypes.FETCH_APPLICATION_INIT, fetchApplicationSaga), + takeLatest(ReduxActionTypes.FORK_APPLICATION_INIT, forkApplicationSaga), takeLatest(ReduxActionTypes.CREATE_APPLICATION_INIT, createApplicationSaga), takeLatest( ReduxActionTypes.SET_DEFAULT_APPLICATION_PAGE_INIT, diff --git a/app/client/src/sagas/EvaluationsSaga.ts b/app/client/src/sagas/EvaluationsSaga.ts index 2d848b8b94..f38d095517 100644 --- a/app/client/src/sagas/EvaluationsSaga.ts +++ b/app/client/src/sagas/EvaluationsSaga.ts @@ -43,11 +43,24 @@ const evalErrorHandler = (errors: EvalError[]) => { errors.forEach((error) => { switch (error.type) { case EvalErrorTypes.DEPENDENCY_ERROR: { - Toaster.show({ - text: error.message, - variant: Variant.danger, - }); - Sentry.captureException(new Error(error.message)); + if (error.context) { + // Add more info about node for the toast + const { node, entityType } = error.context; + Toaster.show({ + text: `${error.message} Node was: ${node}`, + variant: Variant.danger, + }); + // Send the generic error message to sentry for better grouping + Sentry.captureException(new Error(error.message), { + tags: { + node, + entityType, + }, + // Level is warning because it could be a user error + level: Sentry.Severity.Warning, + }); + } + break; } case EvalErrorTypes.EVAL_TREE_ERROR: { diff --git a/app/client/src/sagas/GlobalSearchSagas.ts b/app/client/src/sagas/GlobalSearchSagas.ts new file mode 100644 index 0000000000..03274eb939 --- /dev/null +++ b/app/client/src/sagas/GlobalSearchSagas.ts @@ -0,0 +1,89 @@ +import { ReduxActionTypes, ReduxAction } from "constants/ReduxActionConstants"; +import { + all, + call, + put, + takeLatest, + select, + putResolve, + take, +} from "redux-saga/effects"; +import { setRecentAppEntities, fetchRecentAppEntities } from "utils/storage"; +import { + restoreRecentEntitiesSuccess, + setRecentEntities, +} from "actions/globalSearchActions"; +import { AppState } from "reducers"; +import { getIsEditorInitialized } from "selectors/editorSelectors"; +import { RecentEntity } from "components/editorComponents/GlobalSearch/utils"; + +export function* updateRecentEntity(actionPayload: ReduxAction) { + try { + const recentEntitiesRestored = yield select( + (state: AppState) => state.ui.globalSearch.recentEntitiesRestored, + ); + const isEditorInitialised = yield select(getIsEditorInitialized); + + const waitForEffects = []; + + if (!isEditorInitialised) { + waitForEffects.push(take(ReduxActionTypes.INITIALIZE_EDITOR_SUCCESS)); + } + + if (!recentEntitiesRestored) { + waitForEffects.push( + take(ReduxActionTypes.RESTORE_RECENT_ENTITIES_SUCCESS), + ); + } + + yield all(waitForEffects); + + const { payload: entity } = actionPayload; + let recentEntities = yield select( + (state: AppState) => state.ui.globalSearch.recentEntities, + ); + + recentEntities = recentEntities.slice(); + + const existingIndex = recentEntities.findIndex( + (recentEntity: { type: string; id: string }) => + recentEntity.id === entity.id, + ); + + if (existingIndex === -1) { + recentEntities.unshift(entity); + recentEntities = recentEntities.slice(0, 5); + } else { + recentEntities.splice(existingIndex, 1); + recentEntities.unshift(entity); + } + + yield put(setRecentEntities(recentEntities)); + if (entity?.params?.applicationId) { + yield call( + setRecentAppEntities, + recentEntities, + entity?.params?.applicationId, + ); + } + } catch (e) { + console.log(e, "error"); + } +} + +export function* restoreRecentEntities(actionPayload: ReduxAction) { + const { payload: appId } = actionPayload; + const recentAppEntities = yield call(fetchRecentAppEntities, appId); + yield putResolve(setRecentEntities(recentAppEntities)); + yield put(restoreRecentEntitiesSuccess()); +} + +export default function* globalSearchSagas() { + yield all([ + takeLatest(ReduxActionTypes.UPDATE_RECENT_ENTITY, updateRecentEntity), + takeLatest( + ReduxActionTypes.RESTORE_RECENT_ENTITIES_REQUEST, + restoreRecentEntities, + ), + ]); +} diff --git a/app/client/src/sagas/InitSagas.ts b/app/client/src/sagas/InitSagas.ts index 465fd9109f..7fd658d49d 100644 --- a/app/client/src/sagas/InitSagas.ts +++ b/app/client/src/sagas/InitSagas.ts @@ -36,6 +36,11 @@ import { getDefaultPageId } from "./selectors"; import { populatePageDSLsSaga } from "./PageSagas"; import log from "loglevel"; import * as Sentry from "@sentry/react"; +import { + restoreRecentEntitiesRequest, + resetRecentEntities, +} from "actions/globalSearchActions"; +import { resetEditorSuccess } from "actions/initActions"; function* initializeEditorSaga( initializeEditorAction: ReduxAction, @@ -52,6 +57,8 @@ function* initializeEditorSaga( put(fetchApplication(applicationId, APP_MODE.EDIT)), ]); + yield put(restoreRecentEntitiesRequest(applicationId)); + const resultOfPrimaryCalls = yield race({ success: all([ take(ReduxActionTypes.FETCH_PAGE_LIST_SUCCESS), @@ -218,6 +225,11 @@ export function* initializeAppViewerSaga( } } +function* resetEditorSaga() { + yield put(resetEditorSuccess()); + yield put(resetRecentEntities()); +} + export default function* watchInitSagas() { yield all([ takeLatest(ReduxActionTypes.INITIALIZE_EDITOR, initializeEditorSaga), @@ -225,5 +237,6 @@ export default function* watchInitSagas() { ReduxActionTypes.INITIALIZE_PAGE_VIEWER, initializeAppViewerSaga, ), + takeLatest(ReduxActionTypes.RESET_EDITOR_REQUEST, resetEditorSaga), ]); } diff --git a/app/client/src/sagas/OnboardingSagas.ts b/app/client/src/sagas/OnboardingSagas.ts index 130ed8fca8..53a431f324 100644 --- a/app/client/src/sagas/OnboardingSagas.ts +++ b/app/client/src/sagas/OnboardingSagas.ts @@ -84,7 +84,7 @@ import { generateReactKey } from "utils/generators"; import { forceOpenPropertyPane } from "actions/widgetActions"; import { navigateToCanvas } from "pages/Editor/Explorer/Widgets/WidgetEntity"; import { - updateWidgetProperty, + batchUpdateWidgetProperty, updateWidgetPropertyRequest, } from "../actions/controlActions"; import OnSubmitGif from "assets/gifs/onsubmit.gif"; @@ -146,14 +146,16 @@ function* listenForWidgetAdditions() { selectedWidget.tableData === initialTableData ) { yield put( - updateWidgetProperty(selectedWidget.widgetId, { - tableData: [], - columnSizeMap: { - avatar: 20, - name: 30, + batchUpdateWidgetProperty(selectedWidget.widgetId, { + modify: { + tableData: [], + columnSizeMap: { + avatar: 20, + name: 30, + }, + migrated: false, + ...getStandupTableDimensions(), }, - migrated: false, - ...getStandupTableDimensions(), }), ); } @@ -209,9 +211,11 @@ function* listenForAddInputWidget() { ), ); yield put( - updateWidgetProperty(inputWidget.widgetId, { - ...getStandupInputDimensions(), - ...getStandupInputProps(), + batchUpdateWidgetProperty(inputWidget.widgetId, { + modify: { + ...getStandupInputDimensions(), + ...getStandupInputProps(), + }, }), ); yield put(setCurrentSubstep(2)); @@ -219,12 +223,12 @@ function* listenForAddInputWidget() { yield put(showIndicator(OnboardingStep.ADD_INPUT_WIDGET)); } - const helperConfig = yield select( + const helperConfig: OnboardingHelperConfig = yield select( (state) => state.ui.onBoarding.helperStepConfig, ); const onSubmitGifUrl = OnSubmitGif; - if (helperConfig?.image.src !== onSubmitGifUrl) { + if (helperConfig.image?.src !== onSubmitGifUrl) { yield put( setHelperConfig({ ...helperConfig, @@ -306,11 +310,13 @@ function* listenForSuccessfulBinding() { if (bindSuccessful) { yield put( - updateWidgetProperty(selectedWidget.widgetId, { - columnTypeMap: { - avatar: { - type: "image", - format: "", + batchUpdateWidgetProperty(selectedWidget.widgetId, { + modify: { + columnTypeMap: { + avatar: { + type: "image", + format: "", + }, }, }, }), diff --git a/app/client/src/sagas/PageSagas.tsx b/app/client/src/sagas/PageSagas.tsx index d138a66388..0e9d5dcb55 100644 --- a/app/client/src/sagas/PageSagas.tsx +++ b/app/client/src/sagas/PageSagas.tsx @@ -82,6 +82,8 @@ import { Variant } from "components/ads/common"; import { migrateIncorrectDynamicBindingPathLists } from "utils/migrations/IncorrectDynamicBindingPathLists"; import * as Sentry from "@sentry/react"; import { ERROR_CODES } from "constants/ApiConstants"; +import AnalyticsUtil from "utils/AnalyticsUtil"; +import DEFAULT_TEMPLATE from "templates/default"; const getWidgetName = (state: AppState, widgetId: string) => state.entities.canvasWidgets[widgetId]; @@ -303,6 +305,7 @@ function* savePageSaga(action: ReduxAction<{ isRetry?: boolean }>) { pageId: savePageRequest.pageId, }, ); + AnalyticsUtil.logEvent("PAGE_SAVE", savePageRequest); try { // Store the updated DSL in the pageDSLs reducer yield put({ @@ -376,11 +379,18 @@ function* savePageSaga(action: ReduxAction<{ isRetry?: boolean }>) { }, }); } else { - const correctWidget = migrateIncorrectDynamicBindingPathLists( + const correctedWidget = migrateIncorrectDynamicBindingPathLists( widgets[widgetId], ); + AnalyticsUtil.logEvent("CORRECT_BAD_BINDING", { + error: incorrectBindingError, + correctWidget: correctedWidget, + }); yield put( - updateAndSaveLayout({ ...widgets, [widgetId]: correctWidget }, true), + updateAndSaveLayout( + { ...widgets, [widgetId]: correctedWidget }, + true, + ), ); } } @@ -744,13 +754,17 @@ function* fetchPageDSLSaga(pageId: string) { } } catch (error) { yield put({ - type: ReduxActionTypes.FETCH_PAGE_DSL_ERROR, + type: ReduxActionErrorTypes.FETCH_PAGE_DSL_ERROR, payload: { pageId: pageId, error, - show: false, + show: true, }, }); + return { + pageId: pageId, + dsl: DEFAULT_TEMPLATE, + }; } } diff --git a/app/client/src/sagas/RecentEntitiesSagas.ts b/app/client/src/sagas/RecentEntitiesSagas.ts new file mode 100644 index 0000000000..ebcb802655 --- /dev/null +++ b/app/client/src/sagas/RecentEntitiesSagas.ts @@ -0,0 +1,75 @@ +import { ReduxActionTypes, ReduxAction } from "constants/ReduxActionConstants"; +import { all, put, takeLatest } from "redux-saga/effects"; +import { updateRecentEntity } from "actions/globalSearchActions"; + +import { + matchApiPath, + matchDatasourcePath, + matchQueryPath, + matchBuilderPath, +} from "constants/routes"; +import { MAIN_CONTAINER_WIDGET_ID } from "constants/WidgetConstants"; + +const getRecentEntity = (pathName: string) => { + const builderMatch = matchBuilderPath(pathName); + if (builderMatch) + return { + type: "page", + id: builderMatch?.params?.pageId, + params: builderMatch?.params, + }; + + const apiMatch = matchApiPath(pathName); + if (apiMatch) + return { + type: "action", + id: apiMatch?.params?.apiId, + params: apiMatch?.params, + }; + + const queryMatch = matchQueryPath(pathName); + if (queryMatch) + return { + type: "action", + id: queryMatch.params?.queryId, + params: queryMatch?.params, + }; + + const datasourceMatch = matchDatasourcePath(pathName); + if (datasourceMatch) + return { + type: "datasource", + id: datasourceMatch?.params?.datasourceId, + params: datasourceMatch?.params, + }; + + return {}; +}; + +function* handleSelectWidget(action: ReduxAction<{ widgetId: string }>) { + const builderMatch = matchBuilderPath(window.location.pathname); + const { payload } = action; + const selectedWidget = payload.widgetId; + if (selectedWidget && selectedWidget !== MAIN_CONTAINER_WIDGET_ID) + yield put( + updateRecentEntity({ + type: "widget", + id: selectedWidget, + params: builderMatch?.params, + }), + ); +} + +function* handlePathUpdated(action: ReduxAction<{ pathName: string }>) { + const { type, id, params } = getRecentEntity(action.payload.pathName); + if (type && id && id.indexOf(":") === -1) { + yield put(updateRecentEntity({ type, id, params })); + } +} + +export default function* recentEntitiesSagas() { + yield all([ + takeLatest(ReduxActionTypes.SELECT_WIDGET, handleSelectWidget), + takeLatest(ReduxActionTypes.HANDLE_PATH_UPDATED, handlePathUpdated), + ]); +} diff --git a/app/client/src/sagas/WidgetOperationSagas.tsx b/app/client/src/sagas/WidgetOperationSagas.tsx index f2d0b40167..2f8e779075 100644 --- a/app/client/src/sagas/WidgetOperationSagas.tsx +++ b/app/client/src/sagas/WidgetOperationSagas.tsx @@ -38,7 +38,6 @@ import { batchUpdateWidgetProperty, DeleteWidgetPropertyPayload, SetWidgetDynamicPropertyPayload, - updateWidgetProperty, UpdateWidgetPropertyPayload, UpdateWidgetPropertyRequestPayload, } from "actions/controlActions"; @@ -780,15 +779,20 @@ function* setWidgetDynamicPropertySaga( action: ReduxAction, ) { const { isDynamic, propertyPath, widgetId } = action.payload; - const widget: WidgetProps = yield select(getWidget, widgetId); + const stateWidget: WidgetProps = yield select(getWidget, widgetId); + let widget = { ...stateWidget }; const propertyValue = _.get(widget, propertyPath); let dynamicPropertyPathList = getWidgetDynamicPropertyPathList(widget); - const propertyUpdates: Record = {}; if (isDynamic) { - dynamicPropertyPathList.push({ - key: propertyPath, - }); - propertyUpdates[propertyPath] = convertToString(propertyValue); + const keyExists = + dynamicPropertyPathList.findIndex((path) => path.key === propertyPath) > + -1; + if (!keyExists) { + dynamicPropertyPathList.push({ + key: propertyPath, + }); + } + widget = set(widget, propertyPath, convertToString(propertyValue)); } else { dynamicPropertyPathList = _.reject(dynamicPropertyPathList, { key: propertyPath, @@ -800,11 +804,9 @@ function* setWidgetDynamicPropertySaga( propertyValue, widget, ); - propertyUpdates[propertyPath] = parsed; + widget = set(widget, propertyPath, parsed); } - propertyUpdates.dynamicPropertyPathList = dynamicPropertyPathList; - - yield put(updateWidgetProperty(widgetId, propertyUpdates)); + widget.dynamicPropertyPathList = dynamicPropertyPathList; const stateWidgets = yield select(getWidgets); const widgets = { ...stateWidgets, [widgetId]: widget }; @@ -929,7 +931,6 @@ function* batchUpdateWidgetPropertySaga( const stateWidgets = yield select(getWidgets); const widgets = { ...stateWidgets, [widgetId]: widget }; - log.debug( "Batch widget property update calculations took: ", performance.now() - start, @@ -1056,7 +1057,9 @@ function* updateCanvasSize( // Check this out when non canvas widgets are updating snapRows // erstwhile: Math.round((rows * props.snapRowSpace) / props.parentRowSpace), yield put( - updateWidgetProperty(canvasWidgetId, { bottomRow: newBottomRow }), + batchUpdateWidgetProperty(canvasWidgetId, { + modify: { bottomRow: newBottomRow }, + }), ); } } diff --git a/app/client/src/sagas/index.tsx b/app/client/src/sagas/index.tsx index b4537c4bdf..95fd5cbf75 100644 --- a/app/client/src/sagas/index.tsx +++ b/app/client/src/sagas/index.tsx @@ -22,6 +22,8 @@ import themeSagas from "./ThemeSaga"; import evaluationsSaga from "./EvaluationsSaga"; import onboardingSaga from "./OnboardingSagas"; import actionExecutionChangeListeners from "./WidgetLoadingSaga"; +import globalSearchSagas from "./GlobalSearchSagas"; +import recentEntitiesSagas from "./RecentEntitiesSagas"; import log from "loglevel"; import * as sentry from "@sentry/react"; @@ -50,6 +52,8 @@ export function* rootSaga() { evaluationsSaga, onboardingSaga, actionExecutionChangeListeners, + globalSearchSagas, + recentEntitiesSagas, ]; yield all( sagas.map((saga) => diff --git a/app/client/src/selectors/entitiesSelector.ts b/app/client/src/selectors/entitiesSelector.ts index 69178c3aff..23a549eb14 100644 --- a/app/client/src/selectors/entitiesSelector.ts +++ b/app/client/src/selectors/entitiesSelector.ts @@ -11,6 +11,7 @@ import { Action } from "entities/Action"; import { find } from "lodash"; import ImageAlt from "assets/images/placeholder-image.svg"; import { CanvasWidgetsReduxState } from "../reducers/entityReducers/canvasWidgetsReducer"; +import { MAIN_CONTAINER_WIDGET_ID } from "constants/WidgetConstants"; export const getEntities = (state: AppState): AppState["entities"] => state.entities; @@ -283,3 +284,52 @@ export const getAppData = (state: AppState) => state.entities.app; export const getCanvasWidgets = (state: AppState): CanvasWidgetsReduxState => state.entities.canvasWidgets; + +const getPageWidgets = (state: AppState) => state.ui.pageWidgets; + +export const getAllWidgetsMap = createSelector( + getPageWidgets, + (widgetsByPage) => { + return Object.entries(widgetsByPage).reduce( + (res: any, [pageId, pageWidgets]: any) => { + const widgetsMap = Object.entries(pageWidgets).reduce( + (res, [widgetId, widget]: any) => { + let parentModalId; + let { parentId } = widget; + let parentWidget = pageWidgets[parentId]; + while (parentId && parentId !== MAIN_CONTAINER_WIDGET_ID) { + if (parentWidget?.type === "MODAL_WIDGET") { + parentModalId = parentId; + break; + } + parentId = parentWidget?.parentId; + parentWidget = pageWidgets[parentId]; + } + + return { + ...res, + [widgetId]: { ...widget, pageId, parentModalId }, + }; + }, + {}, + ); + + return { + ...res, + ...widgetsMap, + }; + }, + {}, + ); + }, +); + +export const getAllPageWidgets = createSelector( + getAllWidgetsMap, + (widgetsMap) => { + return Object.entries(widgetsMap).reduce( + (res: any[], [, widget]: any) => [...res, widget], + [], + ); + }, +); diff --git a/app/client/src/utils/AnalyticsUtil.tsx b/app/client/src/utils/AnalyticsUtil.tsx index dcb8f4f6f8..43f85f8dc5 100644 --- a/app/client/src/utils/AnalyticsUtil.tsx +++ b/app/client/src/utils/AnalyticsUtil.tsx @@ -106,7 +106,12 @@ export type EventName = | "ONBOARDING_NEXT_MISSION" | "ONBOARDING_GO_HOME" | "END_ONBOARDING" - | "ONBOARDING_COMPLETE"; + | "ONBOARDING_COMPLETE" + | "OPEN_OMNIBAR" + | "CLOSE_OMNIBAR" + | "NAVIGATE_TO_ENTITY_FROM_OMNIBAR" + | "PAGE_SAVE" + | "CORRECT_BAD_BINDING"; function getApplicationId(location: Location) { const pathSplit = location.pathname.split("/"); diff --git a/app/client/src/utils/WidgetPropsUtils.tsx b/app/client/src/utils/WidgetPropsUtils.tsx index 551e9b4844..5c1824b4c4 100644 --- a/app/client/src/utils/WidgetPropsUtils.tsx +++ b/app/client/src/utils/WidgetPropsUtils.tsx @@ -27,6 +27,7 @@ import { tableWidgetPropertyPaneMigrations, } from "utils/migrations/TableWidget"; import { migrateIncorrectDynamicBindingPathLists } from "utils/migrations/IncorrectDynamicBindingPathLists"; +import * as Sentry from "@sentry/react"; export type WidgetOperationParams = { operation: WidgetOperation; @@ -316,7 +317,26 @@ const renamedCanvasNameConflictMigration = ( return currentDSL; }; -// A rudimentary transform function which updates the DSL based on its version. +function migrateOldChartData(currentDSL: ContainerWidgetProps) { + if (currentDSL.type === WidgetTypes.CHART_WIDGET) { + if (isString(currentDSL.chartData)) { + try { + currentDSL.chartData = JSON.parse(currentDSL.chartData); + } catch (error) { + Sentry.captureException({ + message: "Chart Migration Failed", + oldData: currentDSL.chartData, + }); + currentDSL.chartData = []; + } + } + } + if (currentDSL.children && currentDSL.children.length) { + currentDSL.children = currentDSL.children.map(migrateOldChartData); + } + return currentDSL; +} + // A more modular approach needs to be designed. const transformDSL = (currentDSL: ContainerWidgetProps) => { if (currentDSL.version === undefined) { @@ -398,6 +418,11 @@ const transformDSL = (currentDSL: ContainerWidgetProps) => { currentDSL.version = 13; } + if (currentDSL.version === 13) { + currentDSL = migrateOldChartData(currentDSL); + currentDSL.version = 14; + } + return currentDSL; }; diff --git a/app/client/src/utils/canvasStructureHelpers.test.ts b/app/client/src/utils/canvasStructureHelpers.test.ts index 52a408f084..8f6a2a9a41 100644 --- a/app/client/src/utils/canvasStructureHelpers.test.ts +++ b/app/client/src/utils/canvasStructureHelpers.test.ts @@ -1,4 +1,4 @@ -import { CanvasStructure } from "reducers/uiReducers/pageCanvasStructure"; +import { CanvasStructure } from "reducers/uiReducers/pageCanvasStructureReducer"; import { compareAndGenerateImmutableCanvasStructure } from "./canvasStructureHelpers"; const canvasStructure: CanvasStructure = { widgetId: "x", diff --git a/app/client/src/utils/canvasStructureHelpers.ts b/app/client/src/utils/canvasStructureHelpers.ts index 1e0410e2d0..aa00748fbb 100644 --- a/app/client/src/utils/canvasStructureHelpers.ts +++ b/app/client/src/utils/canvasStructureHelpers.ts @@ -1,5 +1,8 @@ import { WidgetTypes } from "constants/WidgetConstants"; -import { CanvasStructure, DSL } from "reducers/uiReducers/pageCanvasStructure"; +import { + CanvasStructure, + DSL, +} from "reducers/uiReducers/pageCanvasStructureReducer"; export const compareAndGenerateImmutableCanvasStructure = ( original: CanvasStructure, diff --git a/app/client/src/utils/helpers.tsx b/app/client/src/utils/helpers.tsx index 12a1fb1b90..5f00a201d2 100644 --- a/app/client/src/utils/helpers.tsx +++ b/app/client/src/utils/helpers.tsx @@ -253,3 +253,10 @@ const playLottieAnimation = ( container.removeChild(el); }, duration); }; + +export const getSelectedText = () => { + if (typeof window.getSelection === "function") { + const selectionObj = window.getSelection(); + return selectionObj && selectionObj.toString(); + } +}; diff --git a/app/client/src/utils/hooks/dragResizeHooks.tsx b/app/client/src/utils/hooks/dragResizeHooks.tsx index ef4d4fe907..f31ff28353 100644 --- a/app/client/src/utils/hooks/dragResizeHooks.tsx +++ b/app/client/src/utils/hooks/dragResizeHooks.tsx @@ -1,6 +1,6 @@ import { useDispatch } from "react-redux"; import { ReduxActionTypes } from "constants/ReduxActionConstants"; -import { focusWidget } from "actions/widgetActions"; +import { focusWidget, selectWidget } from "actions/widgetActions"; import { useCallback, useEffect, useState } from "react"; export const useShowPropertyPane = () => { @@ -64,10 +64,7 @@ export const useWidgetSelection = () => { return { selectWidget: useCallback( (widgetId?: string) => { - dispatch({ - type: ReduxActionTypes.SELECT_WIDGET, - payload: { widgetId }, - }); + dispatch(selectWidget(widgetId)); }, [dispatch], ), diff --git a/app/client/src/utils/storage.ts b/app/client/src/utils/storage.ts index 0f7b050442..4bf126e284 100644 --- a/app/client/src/utils/storage.ts +++ b/app/client/src/utils/storage.ts @@ -8,6 +8,7 @@ const STORAGE_KEYS: { [id: string]: string } = { DELETED_WIDGET_PREFIX: "DeletedWidget-", ONBOARDING_STATE: "OnboardingState", ONBOARDING_WELCOME_STATE: "OnboardingWelcomeState", + RECENT_ENTITIES: "RecentEntities", }; const store = localforage.createInstance({ @@ -139,3 +140,44 @@ export const getOnboardingWelcomeState = async () => { ); } }; + +export const setRecentAppEntities = async (entities: any, appId: string) => { + try { + const recentEntities = + ((await store.getItem(STORAGE_KEYS.RECENT_ENTITIES)) as Record< + string, + any + >) || {}; + recentEntities[appId] = entities; + await store.setItem(STORAGE_KEYS.RECENT_ENTITIES, recentEntities); + } catch (error) { + console.log("An error occurred while saving recent entities", error); + } +}; + +export const fetchRecentAppEntities = async (appId: string) => { + try { + const recentEntities = (await store.getItem( + STORAGE_KEYS.RECENT_ENTITIES, + )) as Record; + return (recentEntities && recentEntities[appId]) || []; + } catch (error) { + console.log("An error occurred while fetching recent entities", error); + } +}; + +export const deleteRecentAppEntities = async (appId: string) => { + try { + const recentEntities = + ((await store.getItem(STORAGE_KEYS.RECENT_ENTITIES)) as Record< + string, + any + >) || {}; + if (typeof recentEntities === "object") { + delete recentEntities[appId]; + } + await store.setItem(STORAGE_KEYS.RECENT_ENTITIES, recentEntities); + } catch (error) { + console.log("An error occurred while saving recent entities", error); + } +}; diff --git a/app/client/src/widgets/BaseWidget.tsx b/app/client/src/widgets/BaseWidget.tsx index d8cc762830..3c4dba6207 100644 --- a/app/client/src/widgets/BaseWidget.tsx +++ b/app/client/src/widgets/BaseWidget.tsx @@ -121,11 +121,9 @@ abstract class BaseWidget< } updateWidgetProperty(propertyName: string, propertyValue: any): void { - const { updateWidgetProperty } = this.context; - const { widgetId } = this.props; - if (updateWidgetProperty && widgetId) { - updateWidgetProperty(widgetId, propertyName, propertyValue); - } + this.batchUpdateWidgetProperty({ + modify: { [propertyName]: propertyValue }, + }); } resetChildrenMetaProperty(widgetId: string) { diff --git a/app/client/src/widgets/ChartWidget/propertyConfig.ts b/app/client/src/widgets/ChartWidget/propertyConfig.ts index 89af4f527b..84ead67683 100644 --- a/app/client/src/widgets/ChartWidget/propertyConfig.ts +++ b/app/client/src/widgets/ChartWidget/propertyConfig.ts @@ -1,5 +1,3 @@ -import { ChartWidgetProps } from "widgets/ChartWidget/index"; - export default [ { sectionName: "General", @@ -50,8 +48,6 @@ export default [ placeholderText: 'Enter [{ "x": "val", "y": "val" }]', label: "Chart Data", controlType: "CHART_DATA", - hidden: (props: ChartWidgetProps) => - props && props.chartType === "PIE_CHART", isBindProperty: false, isTriggerProperty: false, children: [ diff --git a/app/client/src/widgets/ModalWidget.tsx b/app/client/src/widgets/ModalWidget.tsx index 44a4a5de0a..59cfd55835 100644 --- a/app/client/src/widgets/ModalWidget.tsx +++ b/app/client/src/widgets/ModalWidget.tsx @@ -9,10 +9,13 @@ import { WidgetTypes, RenderMode, GridDefaults, + MAIN_CONTAINER_WIDGET_ID, } from "constants/WidgetConstants"; import { generateClassName } from "utils/generators"; import * as Sentry from "@sentry/react"; import withMeta, { WithMeta } from "./MetaHOC"; +import { AppState } from "reducers"; +import { getWidget } from "sagas/selectors"; const MODAL_SIZE: { [id: string]: { width: number; height: number } } = { MODAL_SMALL: { @@ -72,6 +75,14 @@ export class ModalWidget extends BaseWidget { canEscapeKeyClose: false, }; + getModalWidth() { + const widthFromOverlay = this.props.mainContainer.rightColumn * 0.95; + const defaultModalWidth = MODAL_SIZE[this.props.size].width; + return widthFromOverlay < defaultModalWidth + ? widthFromOverlay + : defaultModalWidth; + } + renderChildWidget = (childWidgetData: WidgetProps): ReactNode => { childWidgetData.parentId = this.props.widgetId; childWidgetData.shouldScrollContents = false; @@ -82,7 +93,7 @@ export class ModalWidget extends BaseWidget { childWidgetData.isVisible = this.props.isVisible; childWidgetData.containerStyle = "none"; childWidgetData.minHeight = MODAL_SIZE[this.props.size].height; - childWidgetData.rightColumn = MODAL_SIZE[this.props.size].width; + childWidgetData.rightColumn = this.getModalWidth(); return WidgetFactory.createWidget(childWidgetData, this.props.renderMode); }; @@ -108,7 +119,7 @@ export class ModalWidget extends BaseWidget { ({ @@ -169,8 +181,15 @@ const mapDispatchToProps = (dispatch: any) => ({ }); }, }); + +const mapStateToProps = (state: AppState) => { + const props = { + mainContainer: getWidget(state, MAIN_CONTAINER_WIDGET_ID), + }; + return props; +}; export default ModalWidget; export const ProfiledModalWidget = connect( - null, + mapStateToProps, mapDispatchToProps, )(Sentry.withProfiler(withMeta(ModalWidget))); diff --git a/app/client/src/widgets/TableWidget/index.tsx b/app/client/src/widgets/TableWidget/index.tsx index 1d5c781294..9002c53b33 100644 --- a/app/client/src/widgets/TableWidget/index.tsx +++ b/app/client/src/widgets/TableWidget/index.tsx @@ -743,12 +743,14 @@ class TableWidget extends BaseWidget { } if (this.props.pageSize !== prevProps.pageSize) { - super.executeAction({ - dynamicString: this.props.onPageSizeChange, - event: { - type: EventType.ON_PAGE_SIZE_CHANGE, - }, - }); + if (this.props.onPageSizeChange) { + super.executeAction({ + dynamicString: this.props.onPageSizeChange, + event: { + type: EventType.ON_PAGE_SIZE_CHANGE, + }, + }); + } } } diff --git a/app/client/src/workers/DataTreeEvaluator.ts b/app/client/src/workers/DataTreeEvaluator.ts index 12272c9425..3921a7b91c 100644 --- a/app/client/src/workers/DataTreeEvaluator.ts +++ b/app/client/src/workers/DataTreeEvaluator.ts @@ -460,9 +460,26 @@ export default class DataTreeEvaluator { .reverse() .filter((d) => !!d); } catch (e) { + // Cyclic dependency found. Extract all node and entity type + const node = e.message.match( + new RegExp('Cyclic dependency, node was:"(.*)"'), + )[1]; + + let entityType = "UNKNOWN"; + const entityName = node.split(".")[0]; + const entity = _.find(this.oldUnEvalTree, { name: entityName }); + if (entity && isWidget(entity)) { + entityType = entity.type; + } else if (entity && isAction(entity)) { + entityType = entity.pluginType; + } this.errors.push({ type: EvalErrorTypes.DEPENDENCY_ERROR, - message: e.message, + message: "Cyclic dependency found while evaluating.", + context: { + node, + entityType, + }, }); console.error("CYCLICAL DEPENDENCY MAP", dependencyMap); throw new CrashingError(e.message); diff --git a/app/client/yarn.lock b/app/client/yarn.lock index 0b30e7388b..0124769778 100644 --- a/app/client/yarn.lock +++ b/app/client/yarn.lock @@ -4010,6 +4010,11 @@ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.162.tgz#65d78c397e0d883f44afbf1f7ba9867022411470" integrity sha512-alvcho1kRUnnD1Gcl4J+hK0eencvzq9rmzvFPRmP5rPHx9VVsJj6bKLTATPVf9ktgv4ujzh7T+XWKp+jhuODig== +"@types/marked@^1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@types/marked/-/marked-1.2.2.tgz#1f858a0e690247ecf3b2eef576f98f86e8d960d4" + integrity sha512-wLfw1hnuuDYrFz97IzJja0pdVsC0oedtS4QsKH1/inyW9qkLQbXgMUqEQT0MVtUBx3twjWeInUfjQbhBVLECXw== + "@types/mdast@^3.0.0": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.3.tgz#2d7d671b1cd1ea3deb306ea75036c2a0407d2deb" @@ -6995,6 +7000,11 @@ compression@^1.7.4: safe-buffer "5.1.2" vary "~1.1.2" +compute-scroll-into-view@^1.0.16: + version "1.0.16" + resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.16.tgz#5b7bf4f7127ea2c19b750353d7ce6776a90ee088" + integrity sha512-a85LHKY81oQnikatZYA90pufpZ6sQx++BoCxOEMsjpZx+ZnaKGQnCyCehTRr/1p9GBIAHTjcU9k71kSYWloLiQ== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -12661,6 +12671,11 @@ markdown-to-jsx@^6.10.3, markdown-to-jsx@^6.11.4: prop-types "^15.6.2" unquote "^1.1.0" +marked@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/marked/-/marked-2.0.0.tgz#9662bbcb77ebbded0662a7be66ff929a8611cee5" + integrity sha512-NqRSh2+LlN2NInpqTQnS614Y/3NkVMFFU6sJlRFEpxJ/LHuK/qJECH7/fXZjk4VZstPW/Pevjil/VtSONsLc7Q== + marker-clusterer-plus@^2.1.4: version "2.1.4" resolved "https://registry.yarnpkg.com/marker-clusterer-plus/-/marker-clusterer-plus-2.1.4.tgz#f8eff74d599dab3b7d0e3fed5264ea0e704f5d67" @@ -14101,6 +14116,11 @@ path-to-regexp@^1.7.0: dependencies: isarray "0.0.1" +path-to-regexp@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.2.0.tgz#f7b3803336104c346889adece614669230645f38" + integrity sha512-f66KywYG6+43afgE/8j/GoiNyygk/bnoCbps++3ErRKsIYkGGupyv07R2Ok5m9i67Iqc+T2g1eAUGUPzWhYTyg== + path-type@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441" @@ -17060,6 +17080,13 @@ scriptjs@^2.5.8: resolved "https://registry.yarnpkg.com/scriptjs/-/scriptjs-2.5.9.tgz#343915cd2ec2ed9bfdde2b9875cd28f59394b35f" integrity sha512-qGVDoreyYiP1pkQnbnFAUIS5AjenNwwQBdl7zeos9etl+hYKWahjRTfzAZZYBv5xNHx7vNKCmaLDQZ6Fr2AEXg== +scroll-into-view-if-needed@^2.2.26: + version "2.2.26" + resolved "https://registry.yarnpkg.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.26.tgz#e4917da0c820135ff65ad6f7e4b7d7af568c4f13" + integrity sha512-SQ6AOKfABaSchokAmmaxVnL9IArxEnLEX9j4wAZw+x4iUTb40q7irtHG3z4GtAWz5veVZcCnubXDBRyLVQaohw== + dependencies: + compute-scroll-into-view "^1.0.16" + scss-tokenizer@^0.2.3: version "0.2.3" resolved "https://registry.yarnpkg.com/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz#8eb06db9a9723333824d3f5530641149847ce5d1" diff --git a/app/server/appsmith-interfaces/pom.xml b/app/server/appsmith-interfaces/pom.xml index cba3efd203..446d30f44a 100644 --- a/app/server/appsmith-interfaces/pom.xml +++ b/app/server/appsmith-interfaces/pom.xml @@ -115,6 +115,11 @@ assertj-core test + + org.hibernate.validator + hibernate-validator + 6.0.18.Final + diff --git a/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/constants/DataType.java b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/constants/DataType.java index 27e18ce5c5..d00a934e02 100644 --- a/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/constants/DataType.java +++ b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/constants/DataType.java @@ -12,5 +12,6 @@ public enum DataType { BINARY, BYTES, STRING, - NULL + NULL, + ARRAY, } diff --git a/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/exceptions/pluginExceptions/AppsmithPluginError.java b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/exceptions/pluginExceptions/AppsmithPluginError.java index 23fbd3f7c0..9e6b5ae991 100644 --- a/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/exceptions/pluginExceptions/AppsmithPluginError.java +++ b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/exceptions/pluginExceptions/AppsmithPluginError.java @@ -21,6 +21,7 @@ public enum AppsmithPluginError { AppsmithErrorAction.DEFAULT), PLUGIN_DATASOURCE_TEST_GENERIC_ERROR(500, 5007, "Plugin failed to test with the given configuration. Please reach out to Appsmith customer support to report this", AppsmithErrorAction.LOG_EXTERNALLY), + PLUGIN_DATASOURCE_TIMEOUT_ERROR(504, 5008, "{0}", AppsmithErrorAction.DEFAULT), ; private final Integer httpErrorCode; diff --git a/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/helpers/SqlStringUtils.java b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/helpers/SqlStringUtils.java index d2d0f5ce8e..fdd1ee37af 100644 --- a/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/helpers/SqlStringUtils.java +++ b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/helpers/SqlStringUtils.java @@ -1,19 +1,10 @@ package com.appsmith.external.helpers; import com.appsmith.external.constants.DataType; -import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginError; -import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginException; import com.appsmith.external.models.ActionConfiguration; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.io.IOUtils; import org.apache.commons.validator.routines.DateValidator; -import java.io.UnsupportedEncodingException; -import java.sql.Date; -import java.sql.PreparedStatement; -import java.sql.SQLException; -import java.sql.Time; -import java.sql.Types; import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; @@ -64,6 +55,17 @@ public class SqlStringUtils { return DataType.NULL; } + input = input.trim(); + + if (input.startsWith("[") && input.endsWith("]")) { + String betweenBraces = input.substring(1, input.length() - 1); + String trimmedInputBetweenBraces = betweenBraces.trim(); + if (trimmedInputBetweenBraces.isEmpty()) { + return DataType.NULL; + } + return DataType.ARRAY; + } + try { Integer.parseInt(input); return DataType.INTEGER; @@ -137,74 +139,10 @@ public class SqlStringUtils { // // Not byte // } - // default return type if none of the above matches. return DataType.STRING; } - public static PreparedStatement setValueInPreparedStatement(int index, String binding, String value, PreparedStatement preparedStatement) throws UnsupportedEncodingException, AppsmithPluginException { - DataType valueType = SqlStringUtils.stringToKnownDataTypeConverter(value); - - try { - - switch (valueType) { - case NULL: { - preparedStatement.setNull(index, Types.NULL); - break; - } - case BINARY: { - preparedStatement.setBinaryStream(index, IOUtils.toInputStream(value)); - break; - } - case BYTES: { - preparedStatement.setBytes(index, value.getBytes("UTF-8")); - break; - } - case INTEGER: { - preparedStatement.setInt(index, Integer.parseInt(value)); - break; - } - case LONG: { - preparedStatement.setLong(index, Long.parseLong(value)); - break; - } - case FLOAT: { - preparedStatement.setFloat(index, Float.parseFloat(value)); - break; - } - case DOUBLE: { - preparedStatement.setDouble(index, Double.parseDouble(value)); - break; - } - case BOOLEAN: { - preparedStatement.setBoolean(index, Boolean.parseBoolean(value)); - break; - } - case DATE: { - preparedStatement.setDate(index, Date.valueOf(value)); - break; - } - case TIME: { - preparedStatement.setTime(index, Time.valueOf(value)); - break; - } - case STRING: { - preparedStatement.setString(index, value); - break; - } - default: - break; - } - - } catch (SQLException | IllegalArgumentException e) { - String message = "Query preparation failed while inserting value: " - + value + " for binding: {{" + binding + "}}. Please check the query again.\nError: " + e.getMessage(); - throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, message); - } - - return preparedStatement; - } - private static boolean isBinary(String input) { for (int i = 0; i < input.length(); i++) { int tempB = input.charAt(i); diff --git a/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/models/ActionConfiguration.java b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/models/ActionConfiguration.java index 730a293bd6..044be683af 100644 --- a/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/models/ActionConfiguration.java +++ b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/models/ActionConfiguration.java @@ -4,9 +4,11 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import lombok.ToString; +import org.hibernate.validator.constraints.Range; import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.http.HttpMethod; +import java.sql.Statement; import java.util.List; import static com.appsmith.external.constants.ActionConstants.DEFAULT_ACTION_EXECUTION_TIMEOUT_MS; @@ -17,6 +19,10 @@ import static com.appsmith.external.constants.ActionConstants.DEFAULT_ACTION_EXE @NoArgsConstructor @Document public class ActionConfiguration { + private static final int MIN_TIMEOUT_VALUE = 0; // in Milliseconds + private static final int MAX_TIMEOUT_VALUE = 60000; // in Milliseconds + private static final String TIMEOUT_OUT_OF_RANGE_MESSAGE = "'Query timeout' field must be an integer between " + + MIN_TIMEOUT_VALUE + " and " + MAX_TIMEOUT_VALUE; /* * Any of the fields mentioned below could be represented in mustache * template. If the mustache template is found, it would be replaced @@ -26,6 +32,9 @@ public class ActionConfiguration { * action execution. */ + @Range(min=MIN_TIMEOUT_VALUE, + max=MAX_TIMEOUT_VALUE, + message=TIMEOUT_OUT_OF_RANGE_MESSAGE) Integer timeoutInMillisecond; PaginationType paginationType = PaginationType.NONE; @@ -56,6 +65,17 @@ public class ActionConfiguration { */ List pluginSpecifiedTemplates; + public void setTimeoutInMillisecond(String timeoutInMillisecond) { + try { + this.timeoutInMillisecond = Integer.valueOf(timeoutInMillisecond); + } + catch (NumberFormatException e) { + System.out.println("Failed to convert timeout request parameter to Integer. Setting it to max valid " + + "value."); + this.timeoutInMillisecond = MAX_TIMEOUT_VALUE; + } + } + public Integer getTimeoutInMillisecond() { return (timeoutInMillisecond == null || timeoutInMillisecond <= 0) ? DEFAULT_ACTION_EXECUTION_TIMEOUT_MS : timeoutInMillisecond; diff --git a/app/server/appsmith-plugins/amazons3Plugin/src/main/java/com/external/plugins/AmazonS3Plugin.java b/app/server/appsmith-plugins/amazons3Plugin/src/main/java/com/external/plugins/AmazonS3Plugin.java index 05269b8ff1..b2a99accfe 100644 --- a/app/server/appsmith-plugins/amazons3Plugin/src/main/java/com/external/plugins/AmazonS3Plugin.java +++ b/app/server/appsmith-plugins/amazons3Plugin/src/main/java/com/external/plugins/AmazonS3Plugin.java @@ -3,6 +3,7 @@ package com.external.plugins; import com.amazonaws.HttpMethod; import com.amazonaws.auth.AWSStaticCredentialsProvider; import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.client.builder.AwsClientBuilder; import com.amazonaws.regions.Regions; import com.amazonaws.services.s3.AmazonS3; import com.amazonaws.services.s3.AmazonS3ClientBuilder; @@ -66,10 +67,14 @@ public class AmazonS3Plugin extends BasePlugin { private static final int READ_WITH_BASE64_ENCODING_PROPERTY_INDEX = 5; private static final int USING_FILEPICKER_FOR_UPLOAD_PROPERTY_INDEX = 6; private static final int URL_EXPIRY_DURATION_FOR_UPLOAD_PROPERTY_INDEX = 7; - private static final int CLIENT_REGION_PROPERTY_INDEX = 0; + private static final int AWS_S3_REGION_PROPERTY_INDEX = 0; + private static final int S3_SERVICE_PROVIDER_PROPERTY_INDEX = 1; + private static final int CUSTOM_ENDPOINT_REGION_PROPERTY_INDEX = 2; + private static final int CUSTOM_ENDPOINT_INDEX = 0; private static final int DEFAULT_URL_EXPIRY_IN_MINUTES = 5; // max 7 days is possible private static final String YES = "YES"; private static final String BASE64_DELIMITER = ";base64,"; + private static final String AMAZON_S3_SERVICE_PROVIDER = "amazon-s3"; public AmazonS3Plugin(PluginWrapper wrapper) { super(wrapper); @@ -578,42 +583,74 @@ public class AmazonS3Plugin extends BasePlugin { return (Mono) Mono.fromCallable(() -> { List properties = datasourceConfiguration.getProperties(); - if (properties == null || properties.get(CLIENT_REGION_PROPERTY_INDEX) == null) { + + /* + * - Ideally, properties must never be null because the fields contained in the properties list have a + * default value defined. + * - Ideally, properties.get(S3_SERVICE_PROVIDER_PROPERTY_INDEX) must never be null/empty, because the + * `S3 Service Provider` dropdown has a default value. + */ + if(properties == null + || properties.get(S3_SERVICE_PROVIDER_PROPERTY_INDEX) == null + || StringUtils.isEmpty(properties.get(S3_SERVICE_PROVIDER_PROPERTY_INDEX).getValue())) { return Mono.error( new AppsmithPluginException( AppsmithPluginError.PLUGIN_DATASOURCE_ARGUMENT_ERROR, - "Mandatory parameter 'Region' is empty. Did you forget to edit the 'Region' field" + + "Appsmith has failed to fetch the 'S3 Service Provider' field properties. Please " + + "reach out to Appsmith customer support to resolve this." + ) + ); + } + + final boolean usingCustomEndpoint = + !AMAZON_S3_SERVICE_PROVIDER.equals(properties.get(S3_SERVICE_PROVIDER_PROPERTY_INDEX).getValue()); + + if (!usingCustomEndpoint + && (properties.size() < (AWS_S3_REGION_PROPERTY_INDEX + 1) + || properties.get(AWS_S3_REGION_PROPERTY_INDEX) == null + || StringUtils.isEmpty(properties.get(AWS_S3_REGION_PROPERTY_INDEX).getValue()))) { + return Mono.error( + new AppsmithPluginException( + AppsmithPluginError.PLUGIN_DATASOURCE_ARGUMENT_ERROR, + "Required parameter 'Region' is empty. Did you forget to edit the 'Region' field" + " in the datasource creation form ? You need to fill it with the region where " + - "your AWS instance is hosted." + "your AWS S3 instance is hosted." ) ); } - final String region = properties.get(CLIENT_REGION_PROPERTY_INDEX).getValue(); - if (StringUtils.isEmpty(region)) { + if (usingCustomEndpoint + && (datasourceConfiguration.getEndpoints() == null + || CollectionUtils.isEmpty(datasourceConfiguration.getEndpoints()) + || datasourceConfiguration.getEndpoints().get(CUSTOM_ENDPOINT_INDEX) == null + || StringUtils.isEmpty(datasourceConfiguration.getEndpoints().get(CUSTOM_ENDPOINT_INDEX).getHost()))) { return Mono.error( new AppsmithPluginException( AppsmithPluginError.PLUGIN_DATASOURCE_ARGUMENT_ERROR, - "Mandatory parameter 'Region' is empty. Did you forget to edit the 'Region' field" + - " in the datasource creation form? You need to fill it with the region where " + - "your AWS instance is hosted." + "Required parameter 'Endpoint URL' is empty. Did you forget to edit the 'Endpoint" + + " URL' field in the datasource creation form ? You need to fill it with " + + "the endpoint URL of your S3 instance." + ) + ); + } + + if (usingCustomEndpoint + && (properties.size() < (CUSTOM_ENDPOINT_REGION_PROPERTY_INDEX + 1) + || properties.get(CUSTOM_ENDPOINT_REGION_PROPERTY_INDEX) == null + || StringUtils.isEmpty(properties.get(CUSTOM_ENDPOINT_REGION_PROPERTY_INDEX).getValue()))) { + return Mono.error( + new AppsmithPluginException( + AppsmithPluginError.PLUGIN_DATASOURCE_ARGUMENT_ERROR, + "Required parameter 'Region' is empty. Did you forget to edit the 'Region' field" + + " in the datasource creation form ? You need to fill it with the region where " + + "your S3 instance is hosted." ) ); } - final Regions clientRegion; - try { - clientRegion = Regions.fromName(region); - } catch (IllegalArgumentException e) { - return Mono.error( - new AppsmithPluginException( - AppsmithPluginError.PLUGIN_DATASOURCE_ARGUMENT_ERROR, - "Appsmith server has encountered an error when " + - "parsing AWS S3 instance region from the AWS S3 datasource configuration " + - "provided: " + e.getMessage() - ) - ); - } + final String region = usingCustomEndpoint ? + properties.get(CUSTOM_ENDPOINT_REGION_PROPERTY_INDEX).getValue() : + properties.get(AWS_S3_REGION_PROPERTY_INDEX).getValue(); DBAuth authentication = (DBAuth) datasourceConfiguration.getAuthentication(); if (authentication == null @@ -630,6 +667,7 @@ public class AmazonS3Plugin extends BasePlugin { String accessKey = authentication.getUsername(); String secretKey = authentication.getPassword(); + BasicAWSCredentials awsCreds; try { awsCreds = new BasicAWSCredentials(accessKey, secretKey); @@ -643,11 +681,35 @@ public class AmazonS3Plugin extends BasePlugin { ); } - return Mono.just(AmazonS3ClientBuilder + AmazonS3ClientBuilder s3ClientBuilder = AmazonS3ClientBuilder .standard() - .withRegion(clientRegion) - .withCredentials(new AWSStaticCredentialsProvider(awsCreds)) - .build()); + .withCredentials(new AWSStaticCredentialsProvider(awsCreds)); + + if(!usingCustomEndpoint) { + Regions clientRegion = null; + + try { + clientRegion = Regions.fromName(region); + } catch (IllegalArgumentException e) { + return Mono.error( + new AppsmithPluginException( + AppsmithPluginError.PLUGIN_DATASOURCE_ARGUMENT_ERROR, + "Appsmith server has encountered an error when " + + "parsing AWS S3 instance region from the AWS S3 datasource configuration " + + "provided: " + e.getMessage() + ) + ); + } + + s3ClientBuilder = s3ClientBuilder.withRegion(clientRegion); + } + else { + String endpoint = datasourceConfiguration.getEndpoints().get(CUSTOM_ENDPOINT_INDEX).getHost(); + s3ClientBuilder = s3ClientBuilder + .withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(endpoint, region)); + } + + return Mono.just(s3ClientBuilder.build()); }) .flatMap(obj -> obj) @@ -707,16 +769,48 @@ public class AmazonS3Plugin extends BasePlugin { } List properties = datasourceConfiguration.getProperties(); - try { - if (StringUtils.isBlank(properties.get(CLIENT_REGION_PROPERTY_INDEX).getValue())) { - invalids.add("Mandatory parameter 'Region' is empty. Did you forget to edit the 'Region' field in" + - " the datasource creation form? You need to fill it with the region where your AWS " + - "instance is hosted."); - } - } catch (Exception e) { - invalids.add("Mandatory parameter 'Region' is empty. Did you forget to edit the 'Region' field in" + - " the datasource creation form? You need to fill it with the region where your AWS " + - "instance is hosted."); + + /* + * - Ideally, properties must never be null because the fields contained in the properties list have a + * default value defined. + * - Ideally, properties.get(S3_SERVICE_PROVIDER_PROPERTY_INDEX) must never be null/empty, because the + * `S3 Service Provider` dropdown has a default value. + */ + if(properties == null + || properties.get(S3_SERVICE_PROVIDER_PROPERTY_INDEX) == null + || StringUtils.isEmpty(properties.get(S3_SERVICE_PROVIDER_PROPERTY_INDEX).getValue())) { + invalids.add("Appsmith has failed to fetch the 'S3 Service Provider' field properties. Please " + + "reach out to Appsmith customer support to resolve this."); + } + final boolean usingCustomEndpoint = + !AMAZON_S3_SERVICE_PROVIDER.equals(properties.get(S3_SERVICE_PROVIDER_PROPERTY_INDEX).getValue()); + + if (!usingCustomEndpoint + && (properties.size() < (AWS_S3_REGION_PROPERTY_INDEX + 1) + || properties.get(AWS_S3_REGION_PROPERTY_INDEX) == null + || StringUtils.isEmpty(properties.get(AWS_S3_REGION_PROPERTY_INDEX).getValue()))) { + invalids.add("Required parameter 'Region' is empty. Did you forget to edit the 'Region' field" + + " in the datasource creation form ? You need to fill it with the region where " + + "your AWS S3 instance is hosted."); + } + + if (usingCustomEndpoint + && (datasourceConfiguration.getEndpoints() == null + || CollectionUtils.isEmpty(datasourceConfiguration.getEndpoints()) + || datasourceConfiguration.getEndpoints().get(CUSTOM_ENDPOINT_INDEX) == null + || StringUtils.isEmpty(datasourceConfiguration.getEndpoints().get(CUSTOM_ENDPOINT_INDEX).getHost()))) { + invalids.add("Required parameter 'Endpoint URL' is empty. Did you forget to edit the 'Endpoint" + + " URL' field in the datasource creation form ? You need to fill it with " + + "the endpoint URL of your S3 instance."); + } + + if (usingCustomEndpoint + && (properties.size() < (CUSTOM_ENDPOINT_REGION_PROPERTY_INDEX + 1) + || properties.get(CUSTOM_ENDPOINT_REGION_PROPERTY_INDEX) == null + || StringUtils.isEmpty(properties.get(CUSTOM_ENDPOINT_REGION_PROPERTY_INDEX).getValue()))) { + invalids.add("Required parameter 'Region' is empty. Did you forget to edit the 'Region' field" + + " in the datasource creation form ? You need to fill it with the region where " + + "your S3 instance is hosted."); } return invalids; @@ -741,6 +835,7 @@ public class AmazonS3Plugin extends BasePlugin { * - Hence, adding a listBuckets() method call to test the connection. */ connection.listBuckets(); + try { connection.shutdown(); } catch (Exception e) { diff --git a/app/server/appsmith-plugins/amazons3Plugin/src/main/resources/editor.json b/app/server/appsmith-plugins/amazons3Plugin/src/main/resources/editor.json index caa984eea8..593c5b2512 100644 --- a/app/server/appsmith-plugins/amazons3Plugin/src/main/resources/editor.json +++ b/app/server/appsmith-plugins/amazons3Plugin/src/main/resources/editor.json @@ -11,10 +11,6 @@ "isRequired": true, "initialValue": "LIST", "options": [ - { - "label": "-- Select --", - "value": "" - }, { "label": "List files in bucket", "value": "LIST" @@ -36,14 +32,14 @@ { "label": "Bucket Name", "configProperty": "actionConfiguration.pluginSpecifiedTemplates[1].value", - "controlType": "INPUT_TEXT", + "controlType": "QUERY_DYNAMIC_INPUT_TEXT", "isRequired": true, "initialValue": "" }, { "label": "File Path", "configProperty": "actionConfiguration.path", - "controlType": "INPUT_TEXT", + "controlType": "QUERY_DYNAMIC_INPUT_TEXT", "initialValue": "", "hidden": { "path": "actionConfiguration.pluginSpecifiedTemplates[0].value", @@ -75,8 +71,8 @@ { "label": "Expiry Duration of Signed URL (Minutes)", "configProperty": "actionConfiguration.pluginSpecifiedTemplates[7].value", - "controlType": "INPUT_TEXT", - "initialValue": "10080", + "controlType": "QUERY_DYNAMIC_INPUT_TEXT", + "initialValue": "5", "hidden": { "path": "actionConfiguration.pluginSpecifiedTemplates[0].value", "comparison": "NOT_EQUALS", @@ -97,7 +93,7 @@ { "label": "Prefix", "configProperty": "actionConfiguration.pluginSpecifiedTemplates[4].value", - "controlType": "INPUT_TEXT", + "controlType": "QUERY_DYNAMIC_INPUT_TEXT", "initialValue": "", "hidden": { "path": "actionConfiguration.pluginSpecifiedTemplates[0].value", @@ -129,7 +125,7 @@ { "label": "Expiry Duration of Signed URL (Minutes)", "configProperty": "actionConfiguration.pluginSpecifiedTemplates[3].value", - "controlType": "INPUT_TEXT", + "controlType": "QUERY_DYNAMIC_INPUT_TEXT", "initialValue": "5", "hidden": { "path": "actionConfiguration.pluginSpecifiedTemplates[2].value", diff --git a/app/server/appsmith-plugins/amazons3Plugin/src/main/resources/form.json b/app/server/appsmith-plugins/amazons3Plugin/src/main/resources/form.json index 313fa3656f..db7bd0da7d 100644 --- a/app/server/appsmith-plugins/amazons3Plugin/src/main/resources/form.json +++ b/app/server/appsmith-plugins/amazons3Plugin/src/main/resources/form.json @@ -4,6 +4,46 @@ "sectionName": "Details", "id": 1, "children": [ + { + "label": "S3 Service Provider Key", + "configProperty": "datasourceConfiguration.properties[1].key", + "controlType": "INPUT_TEXT", + "initialValue": "s3Provider", + "hidden": true + }, + { + "label": "S3 Service Provider", + "configProperty": "datasourceConfiguration.properties[1].value", + "controlType": "DROP_DOWN", + "isRequired": true, + "initialValue": "amazon-s3", + "options": [ + { + "label": "Amazon S3", + "value": "amazon-s3" + }, + { + "label": "Upcloud", + "value": "upcloud" + }, + { + "label": "Digital Ocean Spaces", + "value": "digital-ocean-spaces" + }, + { + "label": "Wasabi", + "value": "wasabi" + }, + { + "label": "DreamObjects", + "value": "dream-objects" + }, + { + "label": "Other", + "value": "other" + } + ] + }, { "label": "Access Key", "configProperty": "datasourceConfiguration.authentication.username", @@ -23,6 +63,11 @@ "configProperty": "datasourceConfiguration.properties[0].value", "controlType": "DROP_DOWN", "isRequired": true, + "hidden": { + "path": "datasourceConfiguration.properties[1].value", + "comparison": "NOT_EQUALS", + "value": "amazon-s3" + }, "initialValue": "ap-south-1", "options": [ { @@ -98,6 +143,37 @@ "value": "ca-central-1" } ] + }, + { + "label": "Endpoint URL", + "configProperty": "datasourceConfiguration.endpoints[0].host", + "controlType": "INPUT_TEXT", + "initialValue": "", + "placeholderText": "user-storage.de-fra1.upcloudobjects.com", + "hidden": { + "path": "datasourceConfiguration.properties[1].value", + "comparison": "EQUALS", + "value": "amazon-s3" + } + }, + { + "label": "Custom Endpoint URL Key", + "configProperty": "datasourceConfiguration.properties[2].key", + "controlType": "INPUT_TEXT", + "initialValue": "customRegion", + "hidden": true + }, + { + "label": "Region", + "configProperty": "datasourceConfiguration.properties[2].value", + "controlType": "INPUT_TEXT", + "initialValue": "", + "placeholderText": "de-fra1", + "hidden": { + "path": "datasourceConfiguration.properties[1].value", + "comparison": "EQUALS", + "value": "amazon-s3" + } } ] } diff --git a/app/server/appsmith-plugins/amazons3Plugin/src/test/java/com/external/plugins/AmazonS3PluginTest.java b/app/server/appsmith-plugins/amazons3Plugin/src/test/java/com/external/plugins/AmazonS3PluginTest.java index e7227efe67..23b3ff9ad2 100644 --- a/app/server/appsmith-plugins/amazons3Plugin/src/test/java/com/external/plugins/AmazonS3PluginTest.java +++ b/app/server/appsmith-plugins/amazons3Plugin/src/test/java/com/external/plugins/AmazonS3PluginTest.java @@ -11,6 +11,7 @@ import com.appsmith.external.models.ActionConfiguration; import com.appsmith.external.models.ActionExecutionResult; import com.appsmith.external.models.DBAuth; import com.appsmith.external.models.DatasourceConfiguration; +import com.appsmith.external.models.Endpoint; import com.appsmith.external.models.Property; import lombok.extern.slf4j.Slf4j; import org.junit.Assert; @@ -45,12 +46,14 @@ public class AmazonS3PluginTest { private static String accessKey; private static String secretKey; private static String region; + private static String serviceProvider; @BeforeClass public static void setUp() { accessKey = "access_key"; secretKey = "secret_key"; region = "ap-south-1"; + serviceProvider = "amazon-s3"; } private DatasourceConfiguration createDatasourceConfiguration() { @@ -62,8 +65,11 @@ public class AmazonS3PluginTest { DatasourceConfiguration dsConfig = new DatasourceConfiguration(); dsConfig.setAuthentication(authDTO); ArrayList properties = new ArrayList<>(); - properties.add(new Property("region", region)); + properties.add(new Property("amazon s3 region", region)); + properties.add(new Property("s3 service provider", serviceProvider)); + properties.add(new Property("custom endpoint region", region)); dsConfig.setProperties(properties); + dsConfig.setEndpoints(List.of(new Endpoint("s3-connection-url", 0L))); return dsConfig; } @@ -116,11 +122,9 @@ public class AmazonS3PluginTest { } @Test - public void testValidateDatasourceWithMissingRegion() { + public void testValidateDatasourceWithMissingRegionWithAmazonS3() { DatasourceConfiguration datasourceConfiguration = createDatasourceConfiguration(); - ArrayList properties = new ArrayList<>(); - properties.add(new Property("region", "")); - datasourceConfiguration.setProperties(properties); + datasourceConfiguration.getProperties().get(0).setValue(""); AmazonS3Plugin.S3PluginExecutor pluginExecutor = new AmazonS3Plugin.S3PluginExecutor(); Mono pluginExecutorMono = Mono.just(pluginExecutor); @@ -128,10 +132,57 @@ public class AmazonS3PluginTest { StepVerifier.create(pluginExecutorMono) .assertNext(executor -> { Set res = executor.validateDatasource(datasourceConfiguration); - Assert.assertNotEquals(0, res.size()); + assertNotEquals(0, res.size()); List errorList = new ArrayList<>(res); - assertTrue(errorList.get(0).contains("Mandatory parameter 'Region' is empty")); + assertTrue(errorList.get(0).contains("Required parameter 'Region' is empty. Did you forget to " + + "edit the 'Region' field in the datasource creation form ? You need to fill it with the " + + "region where your AWS S3 instance is hosted.")); + }) + .verifyComplete(); + } + + @Test + public void testValidateDatasourceWithMissingRegionWithNonAmazonProvider() { + DatasourceConfiguration datasourceConfiguration = createDatasourceConfiguration(); + datasourceConfiguration.getProperties().get(1).setValue("upcloud"); + datasourceConfiguration.getProperties().get(2).setValue(""); + + AmazonS3Plugin.S3PluginExecutor pluginExecutor = new AmazonS3Plugin.S3PluginExecutor(); + Mono pluginExecutorMono = Mono.just(pluginExecutor); + + StepVerifier.create(pluginExecutorMono) + .assertNext(executor -> { + Set res = executor.validateDatasource(datasourceConfiguration); + assertNotEquals(0, res.size()); + + List errorList = new ArrayList<>(res); + assertTrue(errorList.get(0).contains("Required parameter 'Region' is empty. Did you forget to " + + "edit the 'Region' field in the datasource creation form ? You need to fill it with the " + + "region where your S3 instance is hosted.")); + }) + .verifyComplete(); + } + + @Test + public void testValidateDatasourceWithMissingUrlWithNonAmazonProvider() { + DatasourceConfiguration datasourceConfiguration = createDatasourceConfiguration(); + datasourceConfiguration.getProperties().get(1).setValue("upcloud"); + datasourceConfiguration.getProperties().get(2).setValue(""); + datasourceConfiguration.getEndpoints().get(0).setHost(""); + + AmazonS3Plugin.S3PluginExecutor pluginExecutor = new AmazonS3Plugin.S3PluginExecutor(); + Mono pluginExecutorMono = Mono.just(pluginExecutor); + + StepVerifier.create(pluginExecutorMono) + .assertNext(executor -> { + Set res = executor.validateDatasource(datasourceConfiguration); + assertNotEquals(0, res.size()); + + List errorList = new ArrayList<>(res); + assertTrue(errorList.get(0).contains("Required parameter 'Endpoint URL' is empty. Did you forget " + + "to edit the 'Endpoint URL' field in the datasource creation form ? You need to fill it " + + "with the endpoint URL of your S3 instance.")); }) .verifyComplete(); } diff --git a/app/server/appsmith-plugins/elasticSearchPlugin/src/main/resources/editor.json b/app/server/appsmith-plugins/elasticSearchPlugin/src/main/resources/editor.json index ee3cfc1645..80ae3909ec 100644 --- a/app/server/appsmith-plugins/elasticSearchPlugin/src/main/resources/editor.json +++ b/app/server/appsmith-plugins/elasticSearchPlugin/src/main/resources/editor.json @@ -32,7 +32,7 @@ { "label": "Path", "configProperty": "actionConfiguration.path", - "controlType": "INPUT_TEXT" + "controlType": "QUERY_DYNAMIC_INPUT_TEXT" }, { "label": "Body", diff --git a/app/server/appsmith-plugins/firestorePlugin/src/main/resources/editor.json b/app/server/appsmith-plugins/firestorePlugin/src/main/resources/editor.json index b54a5c5d9b..e1c4fa158c 100644 --- a/app/server/appsmith-plugins/firestorePlugin/src/main/resources/editor.json +++ b/app/server/appsmith-plugins/firestorePlugin/src/main/resources/editor.json @@ -51,7 +51,7 @@ { "label": "Document/Collection Path", "configProperty": "actionConfiguration.path", - "controlType": "INPUT_TEXT", + "controlType": "QUERY_DYNAMIC_INPUT_TEXT", "isRequired": true, "initialValue": "" }, @@ -65,7 +65,7 @@ { "label": "Order By (JSON array of field names to order by)", "configProperty": "actionConfiguration.pluginSpecifiedTemplates[1].value", - "controlType": "INPUT_TEXT", + "controlType": "QUERY_DYNAMIC_INPUT_TEXT", "hidden": { "path": "actionConfiguration.pluginSpecifiedTemplates[0].value", "comparison": "NOT_EQUALS", @@ -84,7 +84,7 @@ { "label": "Start After", "configProperty": "actionConfiguration.pluginSpecifiedTemplates[6].value", - "controlType": "INPUT_TEXT", + "controlType": "QUERY_DYNAMIC_INPUT_TEXT", "hidden": { "path": "actionConfiguration.pluginSpecifiedTemplates[0].value", "comparison": "NOT_EQUALS", @@ -102,7 +102,7 @@ { "label": "End Before", "configProperty": "actionConfiguration.pluginSpecifiedTemplates[7].value", - "controlType": "INPUT_TEXT", + "controlType": "QUERY_DYNAMIC_INPUT_TEXT", "hidden": { "path": "actionConfiguration.pluginSpecifiedTemplates[0].value", "comparison": "NOT_EQUALS", @@ -120,7 +120,7 @@ { "label": "Limit Documents", "configProperty": "actionConfiguration.pluginSpecifiedTemplates[2].value", - "controlType": "INPUT_TEXT", + "controlType": "QUERY_DYNAMIC_INPUT_TEXT", "hidden": { "path": "actionConfiguration.pluginSpecifiedTemplates[0].value", "comparison": "NOT_EQUALS", @@ -142,7 +142,7 @@ { "label": "Where Condition: Field Path (leave empty to not apply any conditions)", "configProperty": "actionConfiguration.pluginSpecifiedTemplates[3].value", - "controlType": "INPUT_TEXT", + "controlType": "QUERY_DYNAMIC_INPUT_TEXT", "hidden": { "path": "actionConfiguration.pluginSpecifiedTemplates[0].value", "comparison": "NOT_EQUALS", @@ -212,7 +212,7 @@ { "label": "Where Condition: Value", "configProperty": "actionConfiguration.pluginSpecifiedTemplates[5].value", - "controlType": "INPUT_TEXT", + "controlType": "QUERY_DYNAMIC_INPUT_TEXT", "hidden": { "path": "actionConfiguration.pluginSpecifiedTemplates[0].value", "comparison": "NOT_EQUALS", diff --git a/app/server/appsmith-plugins/mongoPlugin/src/main/java/com/external/plugins/MongoPlugin.java b/app/server/appsmith-plugins/mongoPlugin/src/main/java/com/external/plugins/MongoPlugin.java index 8cb64bb2a4..900880a81c 100644 --- a/app/server/appsmith-plugins/mongoPlugin/src/main/java/com/external/plugins/MongoPlugin.java +++ b/app/server/appsmith-plugins/mongoPlugin/src/main/java/com/external/plugins/MongoPlugin.java @@ -16,6 +16,7 @@ import com.appsmith.external.models.SSLDetails; import com.appsmith.external.plugins.BasePlugin; import com.appsmith.external.plugins.PluginExecutor; import com.mongodb.MongoCommandException; +import com.mongodb.MongoTimeoutException; import com.mongodb.reactivestreams.client.MongoClient; import com.mongodb.reactivestreams.client.MongoClients; import com.mongodb.reactivestreams.client.MongoDatabase; @@ -39,6 +40,7 @@ import java.math.BigDecimal; import java.math.BigInteger; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.time.Instant; import java.time.format.DateTimeFormatter; import java.util.ArrayList; @@ -50,6 +52,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.TimeoutException; import java.util.stream.Collectors; public class MongoPlugin extends BasePlugin { @@ -70,6 +73,8 @@ public class MongoPlugin extends BasePlugin { private static final String VALUE_STR = "value"; + private static final int TEST_DATASOURCE_TIMEOUT_SECONDS = 15; + public MongoPlugin(PluginWrapper wrapper) { super(wrapper); } @@ -382,6 +387,15 @@ public class MongoPlugin extends BasePlugin { } }) .then(Mono.just(new DatasourceTestResult())) + .timeout(Duration.ofSeconds(TEST_DATASOURCE_TIMEOUT_SECONDS)) + .onErrorMap( + TimeoutException.class, + error -> new AppsmithPluginException( + AppsmithPluginError.PLUGIN_DATASOURCE_TIMEOUT_ERROR, + "Connection timed out. Please check if the datasource configuration fields have " + + "been filled correctly." + ) + ) .onErrorResume(error -> { /** * 1. Return OK response on "Unauthorized" exception. diff --git a/app/server/appsmith-plugins/mongoPlugin/src/test/java/com/external/plugins/MongoPluginTest.java b/app/server/appsmith-plugins/mongoPlugin/src/test/java/com/external/plugins/MongoPluginTest.java index e7a8e1ae0a..ebcfb4986f 100644 --- a/app/server/appsmith-plugins/mongoPlugin/src/test/java/com/external/plugins/MongoPluginTest.java +++ b/app/server/appsmith-plugins/mongoPlugin/src/test/java/com/external/plugins/MongoPluginTest.java @@ -5,6 +5,7 @@ import com.appsmith.external.models.ActionExecutionResult; import com.appsmith.external.models.Connection; import com.appsmith.external.models.DatasourceConfiguration; import com.appsmith.external.models.DatasourceStructure; +import com.appsmith.external.models.DatasourceTestResult; import com.appsmith.external.models.Endpoint; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -420,4 +421,27 @@ public class MongoPluginTest { }) .verifyComplete(); } + + @Test + public void testTestDatasourceTimeoutError() { + String badHost = "mongo-bad-url.mongodb.net"; + DatasourceConfiguration dsConfig = createDatasourceConfiguration(); + dsConfig.getEndpoints().get(0).setHost(badHost); + + Mono datasourceTestResult = pluginExecutor.testDatasource(dsConfig); + + StepVerifier.create(datasourceTestResult) + .assertNext(result -> { + assertFalse(result.isSuccess()); + assertTrue(result.getInvalids().size() == 1); + assertTrue(result + .getInvalids() + .stream() + .anyMatch(error -> error.contains( + "Connection timed out. Please check if the datasource configuration fields have " + + "been filled correctly." + ))); + }) + .verifyComplete(); + } } diff --git a/app/server/appsmith-plugins/mssqlPlugin/src/main/java/com/external/plugins/MssqlPlugin.java b/app/server/appsmith-plugins/mssqlPlugin/src/main/java/com/external/plugins/MssqlPlugin.java index cd709ea113..4526fea9bf 100644 --- a/app/server/appsmith-plugins/mssqlPlugin/src/main/java/com/external/plugins/MssqlPlugin.java +++ b/app/server/appsmith-plugins/mssqlPlugin/src/main/java/com/external/plugins/MssqlPlugin.java @@ -1,8 +1,12 @@ package com.external.plugins; +import com.appsmith.external.constants.DataType; +import com.appsmith.external.dtos.ExecuteActionDTO; import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginError; import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginException; import com.appsmith.external.exceptions.pluginExceptions.StaleConnectionException; +import com.appsmith.external.helpers.MustacheHelper; +import com.appsmith.external.helpers.SqlStringUtils; import com.appsmith.external.models.ActionConfiguration; import com.appsmith.external.models.ActionExecutionRequest; import com.appsmith.external.models.ActionExecutionResult; @@ -10,11 +14,14 @@ import com.appsmith.external.models.DBAuth; import com.appsmith.external.models.DatasourceConfiguration; import com.appsmith.external.models.DatasourceTestResult; import com.appsmith.external.models.Endpoint; +import com.appsmith.external.models.Param; +import com.appsmith.external.models.Property; import com.appsmith.external.models.SSLDetails; import com.appsmith.external.plugins.BasePlugin; import com.appsmith.external.plugins.PluginExecutor; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.io.IOUtils; import org.apache.commons.lang.ObjectUtils; import org.pf4j.Extension; import org.pf4j.PluginWrapper; @@ -24,23 +31,32 @@ import reactor.core.publisher.Mono; import reactor.core.scheduler.Scheduler; import reactor.core.scheduler.Schedulers; +import java.io.IOException; import java.sql.Connection; +import java.sql.Date; import java.sql.DriverManager; +import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.ResultSetMetaData; import java.sql.SQLException; import java.sql.Statement; +import java.sql.Time; +import java.sql.Types; import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import static com.appsmith.external.models.Connection.Mode.READ_ONLY; +import static java.lang.Boolean.FALSE; +import static java.lang.Boolean.TRUE; public class MssqlPlugin extends BasePlugin { @@ -64,10 +80,72 @@ public class MssqlPlugin extends BasePlugin { private final Scheduler scheduler = Schedulers.elastic(); + private static final int PREPARED_STATEMENT_INDEX = 0; + + /** + * Instead of using the default executeParametrized provided by pluginExecutor, this implementation affords an opportunity + * to use PreparedStatement (if configured) which requires the variable substitution, etc. to happen in a particular format + * supported by PreparedStatement. In case of PreparedStatement turned off, the action and datasource configurations are + * prepared (binding replacement) using PluginExecutor.variableSubstitution + * + * @param connection : This is the connection that is established to the data source. This connection is according + * to the parameters in Datasource Configuration + * @param executeActionDTO : This is the data structure sent by the client during execute. This contains the params + * which would be used for substitution + * @param datasourceConfiguration : These are the configurations which have been used to create a Datasource from a Plugin + * @param actionConfiguration : These are the configurations which have been used to create an Action from a Datasource. + * @return + */ @Override - public Mono execute(Connection connection, - DatasourceConfiguration datasourceConfiguration, - ActionConfiguration actionConfiguration) { + public Mono executeParameterized(Connection connection, + ExecuteActionDTO executeActionDTO, + DatasourceConfiguration datasourceConfiguration, + ActionConfiguration actionConfiguration) { + + String query = actionConfiguration.getBody(); + // Check for query parameter before performing the probably expensive fetch connection from the pool op. + if (query == null) { + return Mono.error(new AppsmithPluginException(AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, "Missing required " + + "parameter: Query.")); + } + + Boolean isPreparedStatement; + + final List properties = actionConfiguration.getPluginSpecifiedTemplates(); + if (properties == null || properties.get(PREPARED_STATEMENT_INDEX) == null) { + /** + * TODO : + * In case the prepared statement configuration is missing, default to true once PreparedStatement + * is no longer in beta. + */ + isPreparedStatement = false; + } else { + isPreparedStatement = Boolean.parseBoolean(properties.get(PREPARED_STATEMENT_INDEX).getValue()); + } + + // In case of non prepared statement, simply do binding replacement and execute + if (FALSE.equals(isPreparedStatement)) { + prepareConfigurationsForExecution(executeActionDTO, actionConfiguration, datasourceConfiguration); + return executeCommon(connection, actionConfiguration, FALSE, null, null); + } + + //Prepared Statement + // First extract all the bindings in order + List mustacheKeysInOrder = MustacheHelper.extractMustacheKeysInOrder(query); + // Replace all the bindings with a ? as expected in a prepared statement. + String updatedQuery = SqlStringUtils.replaceMustacheWithQuestionMark(query, mustacheKeysInOrder); + actionConfiguration.setBody(updatedQuery); + return executeCommon(connection, actionConfiguration, TRUE, mustacheKeysInOrder, executeActionDTO); + } + + public Mono executeCommon(Connection connection, + ActionConfiguration actionConfiguration, + Boolean preparedStatement, + List mustacheValuesInOrder, + ExecuteActionDTO executeActionDTO) { + + final Map requestData = new HashMap<>(); + requestData.put("preparedStatement", TRUE.equals(preparedStatement) ? true : false); String query = actionConfiguration.getBody(); @@ -91,13 +169,43 @@ public class MssqlPlugin extends BasePlugin { List> rowsList = new ArrayList<>(50); Statement statement = null; + PreparedStatement preparedQuery = null; ResultSet resultSet = null; - try { - statement = connection.createStatement(); - boolean isResultSet = statement.execute(query); + boolean isResultSet; - if (isResultSet) { + try { + if (FALSE.equals(preparedStatement)) { + statement = connection.createStatement(); + isResultSet = statement.execute(query); resultSet = statement.getResultSet(); + } else { + preparedQuery = connection.prepareStatement(query); + if (mustacheValuesInOrder != null && !mustacheValuesInOrder.isEmpty()) { + List params = executeActionDTO.getParams(); + List parameters = new ArrayList<>(); + for (int i = 0; i < mustacheValuesInOrder.size(); i++) { + String key = mustacheValuesInOrder.get(i); + Optional matchingParam = params.stream().filter(param -> param.getKey().trim().equals(key)).findFirst(); + if (matchingParam.isPresent()) { + String value = matchingParam.get().getValue(); + parameters.add(value); + preparedQuery = setValueInPreparedStatement(i + 1, key, + value, preparedQuery); + } + } + requestData.put("parameters", parameters); + } + isResultSet = preparedQuery.execute(); + resultSet = preparedQuery.getResultSet(); + } + + if (!isResultSet) { + Object updateCount = FALSE.equals(preparedStatement) ? + ObjectUtils.defaultIfNull(statement.getUpdateCount(), 0) : + ObjectUtils.defaultIfNull(preparedQuery.getUpdateCount(), 0); + + rowsList.add(Map.of("affectedRows", updateCount)); + } else { ResultSetMetaData metaData = resultSet.getMetaData(); int colCount = metaData.getColumnCount(); @@ -145,12 +253,6 @@ public class MssqlPlugin extends BasePlugin { rowsList.add(row); } - } else { - rowsList.add(Map.of( - "affectedRows", - ObjectUtils.defaultIfNull(statement.getUpdateCount(), 0)) - ); - } } catch (SQLException e) { @@ -173,6 +275,14 @@ public class MssqlPlugin extends BasePlugin { } } + if (preparedQuery != null) { + try { + preparedQuery.close(); + } catch (SQLException e) { + log.warn("Error closing MsSQL Statement", e); + } + } + } ActionExecutionResult result = new ActionExecutionResult(); @@ -183,7 +293,7 @@ public class MssqlPlugin extends BasePlugin { }) .flatMap(obj -> obj) .map(obj -> (ActionExecutionResult) obj) - .onErrorResume(AppsmithPluginException.class, error -> { + .onErrorResume(AppsmithPluginException.class, error -> { ActionExecutionResult result = new ActionExecutionResult(); result.setIsExecutionSuccess(false); result.setStatusCode(error.getAppErrorCode().toString()); @@ -334,6 +444,82 @@ public class MssqlPlugin extends BasePlugin { .onErrorResume(error -> Mono.just(new DatasourceTestResult(error.getMessage()))); } + @Override + public Mono execute(Connection connection, + DatasourceConfiguration datasourceConfiguration, + ActionConfiguration actionConfiguration) { + // Unused function + return Mono.error(new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Unsupported Operation")); + } + + private static PreparedStatement setValueInPreparedStatement(int index, + String binding, + String value, + PreparedStatement preparedStatement) throws AppsmithPluginException { + DataType valueType = SqlStringUtils.stringToKnownDataTypeConverter(value); + + try { + switch (valueType) { + case NULL: { + preparedStatement.setNull(index, Types.NULL); + break; + } + case BINARY: { + preparedStatement.setBinaryStream(index, IOUtils.toInputStream(value)); + break; + } + case BYTES: { + preparedStatement.setBytes(index, value.getBytes("UTF-8")); + break; + } + case INTEGER: { + preparedStatement.setInt(index, Integer.parseInt(value)); + break; + } + case LONG: { + preparedStatement.setLong(index, Long.parseLong(value)); + break; + } + case FLOAT: { + preparedStatement.setFloat(index, Float.parseFloat(value)); + break; + } + case DOUBLE: { + preparedStatement.setDouble(index, Double.parseDouble(value)); + break; + } + case BOOLEAN: { + preparedStatement.setBoolean(index, Boolean.parseBoolean(value)); + break; + } + case DATE: { + preparedStatement.setDate(index, Date.valueOf(value)); + break; + } + case TIME: { + preparedStatement.setTime(index, Time.valueOf(value)); + break; + } + case ARRAY: { + throw new IllegalArgumentException("Array datatype is not supported in MS SQL"); + } + case STRING: { + preparedStatement.setString(index, value); + break; + } + default: + break; + } + + } catch (SQLException | IllegalArgumentException | IOException e) { + String message = "Query preparation failed while inserting value: " + + value + " for binding: {{" + binding + "}}. Please check the query again.\nError: " + e.getMessage(); + throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, message); + } + + return preparedStatement; + } + } } diff --git a/app/server/appsmith-plugins/mssqlPlugin/src/main/resources/setting.json b/app/server/appsmith-plugins/mssqlPlugin/src/main/resources/setting.json new file mode 100644 index 0000000000..4c433b6bc8 --- /dev/null +++ b/app/server/appsmith-plugins/mssqlPlugin/src/main/resources/setting.json @@ -0,0 +1,36 @@ +{ + "setting": [ + { + "sectionName": "", + "id": 1, + "children": [ + { + "label": "Run query on page load", + "configProperty": "executeOnLoad", + "controlType": "SWITCH", + "info": "Will refresh data each time the page is loaded" + }, + { + "label": "Request confirmation before running query", + "configProperty": "confirmBeforeExecute", + "controlType": "SWITCH", + "info": "Ask confirmation from the user each time before refreshing data" + }, + { + "label": "[Beta] Use Prepared Statement", + "info": "Turning on Prepared Statement makes the query parametrized. This in turn makes it resilient against SQL injections", + "configProperty": "actionConfiguration.pluginSpecifiedTemplates[0].value", + "controlType": "SWITCH", + "initialValue": false + }, + { + "label": "Query timeout (in milliseconds)", + "info": "Maximum time after which the query will return", + "configProperty": "actionConfiguration.timeoutInMillisecond", + "controlType": "INPUT_TEXT", + "dataType": "NUMBER" + } + ] + } + ] +} diff --git a/app/server/appsmith-plugins/mssqlPlugin/src/test/java/com/external/plugins/MssqlPluginTest.java b/app/server/appsmith-plugins/mssqlPlugin/src/test/java/com/external/plugins/MssqlPluginTest.java index 1a87f61daf..6fcd2f7f14 100644 --- a/app/server/appsmith-plugins/mssqlPlugin/src/test/java/com/external/plugins/MssqlPluginTest.java +++ b/app/server/appsmith-plugins/mssqlPlugin/src/test/java/com/external/plugins/MssqlPluginTest.java @@ -1,11 +1,14 @@ package com.external.plugins; +import com.appsmith.external.dtos.ExecuteActionDTO; +import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginException; import com.appsmith.external.models.ActionConfiguration; import com.appsmith.external.models.ActionExecutionResult; import com.appsmith.external.models.DBAuth; import com.appsmith.external.models.DatasourceConfiguration; import com.appsmith.external.models.Endpoint; -import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginException; +import com.appsmith.external.models.Param; +import com.appsmith.external.models.Property; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -23,6 +26,7 @@ import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; import java.sql.Statement; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -65,9 +69,9 @@ public class MssqlPluginTest { try (Statement statement = connection.createStatement()) { statement.execute("CREATE TABLE users (\n" + " id int identity (1, 1) NOT NULL,\n" + - " username VARCHAR (50) UNIQUE NOT NULL,\n" + - " password VARCHAR (50) NOT NULL,\n" + - " email VARCHAR (355) UNIQUE NOT NULL,\n" + + " username VARCHAR (50),\n" + + " password VARCHAR (50),\n" + + " email VARCHAR (355),\n" + " spouse_dob DATE,\n" + " dob DATE NOT NULL,\n" + " time1 TIME NOT NULL,\n" + @@ -103,6 +107,14 @@ public class MssqlPluginTest { ")"); } + try (Statement statement = connection.createStatement()) { + statement.execute( + "INSERT INTO users (id, username, password, email, spouse_dob, dob, time1) VALUES (" + + "3, 'JackJill', 'jaji', 'jaji@exemplars.com', NULL, '2021-01-31'," + + " '15:45:30'" + + ")"); + } + } } @@ -143,7 +155,7 @@ public class MssqlPluginTest { actionConfiguration.setBody("SELECT id as user_id FROM users WHERE id = 1"); Mono executeMono = dsConnectionMono - .flatMap(conn -> pluginExecutor.execute(conn, dsConfig, actionConfiguration)); + .flatMap(conn -> pluginExecutor.executeParameterized(conn, new ExecuteActionDTO(), dsConfig, actionConfiguration)); StepVerifier.create(executeMono) .assertNext(result -> { @@ -170,7 +182,7 @@ public class MssqlPluginTest { actionConfiguration.setBody("SELECT * FROM users WHERE id = 1"); Mono executeMono = dsConnectionMono - .flatMap(conn -> pluginExecutor.execute(conn, dsConfig, actionConfiguration)); + .flatMap(conn -> pluginExecutor.executeParameterized(conn, new ExecuteActionDTO(), dsConfig, actionConfiguration)); StepVerifier.create(executeMono) .assertNext(result -> { @@ -219,4 +231,278 @@ public class MssqlPluginTest { .verify(); } + @Test + public void testPreparedStatementWithoutQuotes() { + DatasourceConfiguration dsConfig = createDatasourceConfiguration(); + + ActionConfiguration actionConfiguration = new ActionConfiguration(); + // First test with the binding not surrounded with quotes + actionConfiguration.setBody("SELECT * FROM users where id = {{binding1}};"); + + List pluginSpecifiedTemplates = new ArrayList<>(); + pluginSpecifiedTemplates.add(new Property("preparedStatement", "true")); + actionConfiguration.setPluginSpecifiedTemplates(pluginSpecifiedTemplates); + + ExecuteActionDTO executeActionDTO = new ExecuteActionDTO(); + List params = new ArrayList<>(); + Param param = new Param(); + param.setKey("binding1"); + param.setValue("1"); + params.add(param); + executeActionDTO.setParams(params); + + Mono connectionCreateMono = pluginExecutor.datasourceCreate(dsConfig).cache(); + + Mono resultMono = connectionCreateMono + .flatMap(pool -> pluginExecutor.executeParameterized(pool, executeActionDTO, dsConfig, actionConfiguration)); + + StepVerifier.create(resultMono) + .assertNext(result -> { + assertTrue(result.getIsExecutionSuccess()); + + final JsonNode node = ((ArrayNode) result.getBody()).get(0); + assertEquals("Jack", node.get("username").asText()); + assertEquals("jack@exemplars.com", node.get("email").asText()); + assertEquals("2018-12-31", node.get("dob").asText()); + assertEquals("18:32:45.0000000", node.get("time1").asText()); + assertTrue(node.get("spouse_dob").isNull()); + + // Check the order of the columns. + // Check the order of the columns. + assertArrayEquals( + new String[]{ + "id", + "username", + "password", + "email", + "spouse_dob", + "dob", + "time1", + }, + new ObjectMapper() + .convertValue(node, LinkedHashMap.class) + .keySet() + .toArray() + ); + + }) + .verifyComplete(); + } + + @Test + public void testPreparedStatementWithDoubleQuotes() { + DatasourceConfiguration dsConfig = createDatasourceConfiguration(); + + ActionConfiguration actionConfiguration = new ActionConfiguration(); + actionConfiguration.setBody("SELECT * FROM users where id = \"{{binding1}}\";"); + + List pluginSpecifiedTemplates = new ArrayList<>(); + pluginSpecifiedTemplates.add(new Property("preparedStatement", "true")); + actionConfiguration.setPluginSpecifiedTemplates(pluginSpecifiedTemplates); + + ExecuteActionDTO executeActionDTO = new ExecuteActionDTO(); + List params = new ArrayList<>(); + Param param = new Param(); + param.setKey("binding1"); + param.setValue("1"); + params.add(param); + executeActionDTO.setParams(params); + + Mono connectionCreateMono = pluginExecutor.datasourceCreate(dsConfig).cache(); + + Mono resultMono = connectionCreateMono + .flatMap(pool -> pluginExecutor.executeParameterized(pool, executeActionDTO, dsConfig, actionConfiguration)); + + StepVerifier.create(resultMono) + .assertNext(result -> { + + assertTrue(result.getIsExecutionSuccess()); + + final JsonNode node = ((ArrayNode) result.getBody()).get(0); + assertEquals("Jack", node.get("username").asText()); + assertEquals("jack@exemplars.com", node.get("email").asText()); + assertEquals("2018-12-31", node.get("dob").asText()); + assertEquals("18:32:45.0000000", node.get("time1").asText()); + assertTrue(node.get("spouse_dob").isNull()); + + // Check the order of the columns. + assertArrayEquals( + new String[]{ + "id", + "username", + "password", + "email", + "spouse_dob", + "dob", + "time1", + }, + new ObjectMapper() + .convertValue(node, LinkedHashMap.class) + .keySet() + .toArray() + ); + + }) + .verifyComplete(); + } + + @Test + public void testPreparedStatementWithSingleQuotes() { + DatasourceConfiguration dsConfig = createDatasourceConfiguration(); + + ActionConfiguration actionConfiguration = new ActionConfiguration(); + actionConfiguration.setBody("SELECT * FROM users where id = '{{binding1}}';"); + + List pluginSpecifiedTemplates = new ArrayList<>(); + pluginSpecifiedTemplates.add(new Property("preparedStatement", "true")); + actionConfiguration.setPluginSpecifiedTemplates(pluginSpecifiedTemplates); + + ExecuteActionDTO executeActionDTO = new ExecuteActionDTO(); + List params = new ArrayList<>(); + Param param = new Param(); + param.setKey("binding1"); + param.setValue("1"); + params.add(param); + executeActionDTO.setParams(params); + + Mono connectionCreateMono = pluginExecutor.datasourceCreate(dsConfig).cache(); + + Mono resultMono = connectionCreateMono + .flatMap(pool -> pluginExecutor.executeParameterized(pool, executeActionDTO, dsConfig, actionConfiguration)); + + StepVerifier.create(resultMono) + .assertNext(result -> { + + assertTrue(result.getIsExecutionSuccess()); + + final JsonNode node = ((ArrayNode) result.getBody()).get(0); + assertEquals("Jack", node.get("username").asText()); + assertEquals("jack@exemplars.com", node.get("email").asText()); + assertEquals("2018-12-31", node.get("dob").asText()); + assertEquals("18:32:45.0000000", node.get("time1").asText()); + assertTrue(node.get("spouse_dob").isNull()); + + // Check the order of the columns. + assertArrayEquals( + new String[]{ + "id", + "username", + "password", + "email", + "spouse_dob", + "dob", + "time1", + }, + new ObjectMapper() + .convertValue(node, LinkedHashMap.class) + .keySet() + .toArray() + ); + + }) + .verifyComplete(); + } + + @Test + public void testPreparedStatementWithNullStringValue() { + DatasourceConfiguration dsConfig = createDatasourceConfiguration(); + + ActionConfiguration actionConfiguration = new ActionConfiguration(); + actionConfiguration.setBody("UPDATE users set " + + "username = {{binding1}}, " + + "password = {{binding1}},\n" + + "email = {{binding1}}" + + " where id = 2;"); + + List pluginSpecifiedTemplates = new ArrayList<>(); + pluginSpecifiedTemplates.add(new Property("preparedStatement", "true")); + actionConfiguration.setPluginSpecifiedTemplates(pluginSpecifiedTemplates); + + ExecuteActionDTO executeActionDTO = new ExecuteActionDTO(); + List params = new ArrayList<>(); + Param param = new Param(); + param.setKey("binding1"); + param.setValue("null"); + params.add(param); + executeActionDTO.setParams(params); + + Mono connectionCreateMono = pluginExecutor.datasourceCreate(dsConfig).cache(); + + Mono resultMono = connectionCreateMono + .flatMap(pool -> pluginExecutor.executeParameterized(pool, executeActionDTO, dsConfig, actionConfiguration)); + + StepVerifier.create(resultMono) + .assertNext(result -> { + assertTrue(result.getIsExecutionSuccess()); + }) + .verifyComplete(); + + actionConfiguration.setBody("SELECT * FROM users where id = 2;"); + resultMono = connectionCreateMono + .flatMap(pool -> pluginExecutor.executeParameterized(pool, executeActionDTO, dsConfig, actionConfiguration)); + + StepVerifier.create(resultMono) + .assertNext(result -> { + assertTrue(result.getIsExecutionSuccess()); + + final JsonNode node = ((ArrayNode) result.getBody()).get(0); + assertTrue(node.get("username").isNull()); + assertTrue(node.get("password").isNull()); + assertTrue(node.get("email").isNull()); + }) + .verifyComplete(); + } + + @Test + public void testPreparedStatementWithNullValue() { + DatasourceConfiguration dsConfig = createDatasourceConfiguration(); + + + ActionConfiguration actionConfiguration = new ActionConfiguration(); + actionConfiguration.setBody("UPDATE users set " + + "username = {{binding1}}, " + + "password = {{binding1}}, " + + "email = {{binding1}}" + + " where id = 3;"); + + List pluginSpecifiedTemplates = new ArrayList<>(); + pluginSpecifiedTemplates.add(new Property("preparedStatement", "true")); + actionConfiguration.setPluginSpecifiedTemplates(pluginSpecifiedTemplates); + + ExecuteActionDTO executeActionDTO = new ExecuteActionDTO(); + List params = new ArrayList<>(); + Param param = new Param(); + param.setKey("binding1"); + param.setValue(null); + params.add(param); + executeActionDTO.setParams(params); + + Mono connectionCreateMono = pluginExecutor.datasourceCreate(dsConfig).cache(); + + Mono resultMono = connectionCreateMono + .flatMap(pool -> pluginExecutor.executeParameterized(pool, executeActionDTO, dsConfig, actionConfiguration)); + + StepVerifier.create(resultMono) + .assertNext(result -> { + System.out.printf("result : " + result); + assertTrue(result.getIsExecutionSuccess()); + }) + .verifyComplete(); + + actionConfiguration.setBody("SELECT * FROM users where id = 3;"); + resultMono = connectionCreateMono + .flatMap(pool -> pluginExecutor.executeParameterized(pool, executeActionDTO, dsConfig, actionConfiguration)); + + StepVerifier.create(resultMono) + .assertNext(result -> { + assertTrue(result.getIsExecutionSuccess()); + + final JsonNode node = ((ArrayNode) result.getBody()).get(0); + assertTrue(node.get("username").isNull()); + assertTrue(node.get("password").isNull()); + assertTrue(node.get("email").isNull()); + }) + .verifyComplete(); + } + } diff --git a/app/server/appsmith-plugins/mysqlPlugin/src/main/java/com/external/plugins/MySqlPlugin.java b/app/server/appsmith-plugins/mysqlPlugin/src/main/java/com/external/plugins/MySqlPlugin.java index 3daa4a4ebc..4c0839bc2f 100644 --- a/app/server/appsmith-plugins/mysqlPlugin/src/main/java/com/external/plugins/MySqlPlugin.java +++ b/app/server/appsmith-plugins/mysqlPlugin/src/main/java/com/external/plugins/MySqlPlugin.java @@ -1,8 +1,12 @@ package com.external.plugins; +import com.appsmith.external.constants.DataType; +import com.appsmith.external.dtos.ExecuteActionDTO; import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginError; import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginException; import com.appsmith.external.exceptions.pluginExceptions.StaleConnectionException; +import com.appsmith.external.helpers.MustacheHelper; +import com.appsmith.external.helpers.SqlStringUtils; import com.appsmith.external.models.ActionConfiguration; import com.appsmith.external.models.ActionExecutionRequest; import com.appsmith.external.models.ActionExecutionResult; @@ -11,6 +15,7 @@ import com.appsmith.external.models.DatasourceConfiguration; import com.appsmith.external.models.DatasourceStructure; import com.appsmith.external.models.DatasourceTestResult; import com.appsmith.external.models.Endpoint; +import com.appsmith.external.models.Param; import com.appsmith.external.models.Property; import com.appsmith.external.plugins.BasePlugin; import com.appsmith.external.plugins.PluginExecutor; @@ -21,6 +26,7 @@ import io.r2dbc.spi.ConnectionFactoryOptions; import io.r2dbc.spi.Result; import io.r2dbc.spi.Row; import io.r2dbc.spi.RowMetadata; +import io.r2dbc.spi.Statement; import io.r2dbc.spi.ValidationDepth; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang.ObjectUtils; @@ -45,9 +51,13 @@ import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +import static java.lang.Boolean.FALSE; +import static java.lang.Boolean.TRUE; + public class MySqlPlugin extends BasePlugin { private static final String DATE_COLUMN_TYPE_NAME = "date"; @@ -113,8 +123,208 @@ public class MySqlPlugin extends BasePlugin { @Slf4j @Extension public static class MySqlPluginExecutor implements PluginExecutor { + private final Scheduler scheduler = Schedulers.elastic(); + private static final int PREPARED_STATEMENT_INDEX = 0; + + /** + * Instead of using the default executeParametrized provided by pluginExecutor, this implementation affords an opportunity + * to use PreparedStatement (if configured) which requires the variable substitution, etc. to happen in a particular format + * supported by PreparedStatement. In case of PreparedStatement turned off, the action and datasource configurations are + * prepared (binding replacement) using PluginExecutor.variableSubstitution + * + * @param connection : This is the connection that is established to the data source. This connection is according + * to the parameters in Datasource Configuration + * @param executeActionDTO : This is the data structure sent by the client during execute. This contains the params + * which would be used for substitution + * @param datasourceConfiguration : These are the configurations which have been used to create a Datasource from a Plugin + * @param actionConfiguration : These are the configurations which have been used to create an Action from a Datasource. + * @return + */ + @Override + public Mono executeParameterized(Connection connection, + ExecuteActionDTO executeActionDTO, + DatasourceConfiguration datasourceConfiguration, + ActionConfiguration actionConfiguration) { + + final Map requestData = new HashMap<>(); + + Boolean isPreparedStatement; + + final List properties = actionConfiguration.getPluginSpecifiedTemplates(); + if (properties == null || properties.get(PREPARED_STATEMENT_INDEX) == null) { + /** + * TODO : + * In case the prepared statement configuration is missing, default to true once PreparedStatement + * is no longer in beta. + */ + isPreparedStatement = false; + } else { + isPreparedStatement = Boolean.parseBoolean(properties.get(PREPARED_STATEMENT_INDEX).getValue()); + } + requestData.put("preparedStatement", TRUE.equals(isPreparedStatement) ? true : false); + + String query = actionConfiguration.getBody(); + // Check for query parameter before performing the probably expensive fetch connection from the pool op. + if (query == null) { + ActionExecutionResult errorResult = new ActionExecutionResult(); + errorResult.setStatusCode(AppsmithPluginError.PLUGIN_ERROR.getAppErrorCode().toString()); + errorResult.setIsExecutionSuccess(false); + errorResult.setBody(AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR.getMessage("Missing required " + + "parameter: Query.")); + ActionExecutionRequest actionExecutionRequest = new ActionExecutionRequest(); + actionExecutionRequest.setProperties(requestData); + errorResult.setRequest(actionExecutionRequest); + return Mono.just(errorResult); + } + + actionConfiguration.setBody(query.trim()); + + // In case of non prepared statement, simply do binding replacement and execute + if (FALSE.equals(isPreparedStatement)) { + prepareConfigurationsForExecution(executeActionDTO, actionConfiguration, datasourceConfiguration); + return executeCommon(connection, actionConfiguration, FALSE, null, null, requestData); + } + + //This has to be executed as Prepared Statement + // First extract all the bindings in order + List mustacheKeysInOrder = MustacheHelper.extractMustacheKeysInOrder(query); + // Replace all the bindings with a ? as expected in a prepared statement. + String updatedQuery = SqlStringUtils.replaceMustacheWithQuestionMark(query, mustacheKeysInOrder); + // Set the query with bindings extracted and replaced with '?' back in config + actionConfiguration.setBody(updatedQuery); + return executeCommon(connection, actionConfiguration, TRUE, mustacheKeysInOrder, executeActionDTO, requestData); + } + + public Mono executeCommon(Connection connection, + ActionConfiguration actionConfiguration, + Boolean preparedStatement, + List mustacheValuesInOrder, + ExecuteActionDTO executeActionDTO, + Map requestData) { + + String query = actionConfiguration.getBody(); + + boolean isSelectOrShowQuery = getIsSelectOrShowQuery(query); + + final List> rowsList = new ArrayList<>(50); + + Flux resultFlux = Mono.from(connection.validate(ValidationDepth.REMOTE)) + .flatMapMany(isValid -> { + if (isValid) { + return createAndExecuteQueryFromConnection(query, + connection, + preparedStatement, + mustacheValuesInOrder, + executeActionDTO, + requestData); + } + return Flux.error(new StaleConnectionException()); + }); + + Mono>> resultMono; + + if (isSelectOrShowQuery) { + resultMono = resultFlux + .flatMap(result -> + result.map((row, meta) -> { + rowsList.add(getRow(row, meta)); + return result; + } + ) + ) + .collectList() + .thenReturn(rowsList); + } else { + resultMono = resultFlux + .flatMap(result -> result.getRowsUpdated()) + .collectList() + .flatMap(list -> Mono.just(list.get(list.size() - 1))) + .map(rowsUpdated -> { + rowsList.add( + Map.of( + "affectedRows", + ObjectUtils.defaultIfNull(rowsUpdated, 0) + ) + ); + return rowsList; + }); + } + + return resultMono + .map(res -> { + ActionExecutionResult result = new ActionExecutionResult(); + result.setBody(objectMapper.valueToTree(rowsList)); + result.setIsExecutionSuccess(true); + System.out.println(Thread.currentThread().getName() + " In the MySqlPlugin, got action " + + "execution result"); + return result; + }) + .onErrorResume(AppsmithPluginException.class, error -> { + ActionExecutionResult result = new ActionExecutionResult(); + result.setIsExecutionSuccess(false); + result.setStatusCode(error.getAppErrorCode().toString()); + result.setBody(error.getMessage()); + return Mono.just(result); + }) + // Now set the request in the result to be returned back to the server + .map(actionExecutionResult -> { + ActionExecutionRequest request = new ActionExecutionRequest(); + request.setQuery(query); + request.setProperties(requestData); + ActionExecutionResult result = actionExecutionResult; + result.setRequest(request); + return result; + }) + .subscribeOn(scheduler); + + } + + private Flux createAndExecuteQueryFromConnection(String query, + Connection connection, + Boolean preparedStatement, + List mustacheValuesInOrder, + ExecuteActionDTO executeActionDTO, + Map requestData) { + + Statement connectionStatement = connection.createStatement(query); + if (FALSE.equals(preparedStatement) || mustacheValuesInOrder == null || mustacheValuesInOrder.isEmpty()) { + return Flux.from(connectionStatement.execute()); + } + + System.out.println("Query : " + query); + + List params = executeActionDTO.getParams(); + List parameters = new ArrayList<>(); + + for (int i = 0; i < mustacheValuesInOrder.size(); i++) { + String key = mustacheValuesInOrder.get(i); + Optional matchingParam = params + .stream() + .filter(param -> param.getKey().trim().equals(key)) + .findFirst(); + if (matchingParam.isPresent()) { + String value = matchingParam.get().getValue(); + parameters.add(value); + DataType valueType = SqlStringUtils.stringToKnownDataTypeConverter(value); + if (DataType.NULL.equals(valueType)) { + try { + connectionStatement.bindNull(i, Object.class); + } catch (UnsupportedOperationException e) { + // Do nothing. Move on + } + } else { + connectionStatement.bind(i, value); + } + } + } + requestData.put("parameters", parameters); + + return Flux.from(connectionStatement.execute()); + + } + /** * 1. Parse the actual row objects returned by r2dbc driver for mysql statements. * 2. Return the row as a map {column_name -> column_value}. @@ -129,24 +339,19 @@ public class MySqlPlugin extends BasePlugin { String typeName = metaData.getJavaType().toString(); Object columnValue = row.get(columnName); - if (java.time.LocalDate.class.toString().equalsIgnoreCase(typeName) - && columnValue != null) { - columnValue = DateTimeFormatter.ISO_DATE.format(row.get(columnName, - LocalDate.class)); - } else if ((java.time.LocalDateTime.class.toString().equalsIgnoreCase(typeName)) - && columnValue != null) { + if (java.time.LocalDate.class.toString().equalsIgnoreCase(typeName) && columnValue != null) { + columnValue = DateTimeFormatter.ISO_DATE.format(row.get(columnName, LocalDate.class)); + } else if ((java.time.LocalDateTime.class.toString().equalsIgnoreCase(typeName)) && columnValue != null) { columnValue = DateTimeFormatter.ISO_DATE_TIME.format( LocalDateTime.of( row.get(columnName, LocalDateTime.class).toLocalDate(), row.get(columnName, LocalDateTime.class).toLocalTime() ) ) + "Z"; - } else if (java.time.LocalTime.class.toString().equalsIgnoreCase(typeName) - && columnValue != null) { + } else if (java.time.LocalTime.class.toString().equalsIgnoreCase(typeName) && columnValue != null) { columnValue = DateTimeFormatter.ISO_TIME.format(row.get(columnName, LocalTime.class)); - } else if (java.time.Year.class.toString().equalsIgnoreCase(typeName) - && columnValue != null) { + } else if (java.time.Year.class.toString().equalsIgnoreCase(typeName) && columnValue != null) { columnValue = row.get(columnName, LocalDate.class).getYear(); } else { columnValue = row.get(columnName); @@ -168,89 +373,17 @@ public class MySqlPlugin extends BasePlugin { */ private boolean getIsSelectOrShowQuery(String query) { String[] queries = query.split(";"); - return (queries[queries.length - 1].trim().split(" ")[0].equalsIgnoreCase("select") - || queries[queries.length - 1].trim().split(" ")[0].equalsIgnoreCase("show")); + + String lastQuery = queries[queries.length - 1].trim(); + + return (lastQuery.trim().split(" ")[0].equalsIgnoreCase("select") + || lastQuery.trim().split(" ")[0].equalsIgnoreCase("show")); } @Override - public Mono execute(Connection connection, - DatasourceConfiguration datasourceConfiguration, - ActionConfiguration actionConfiguration) { - - String query = actionConfiguration.getBody().trim(); - - if (query == null) { - return Mono.error(new AppsmithPluginException(AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, "Missing required " + - "parameter: Query.")); - } - - boolean isSelectOrShowQuery = getIsSelectOrShowQuery(query); - final List> rowsList = new ArrayList<>(50); - Flux resultFlux = Mono.from(connection.validate(ValidationDepth.REMOTE)) - .flatMapMany(isValid -> { - if (isValid) { - return connection.createStatement(query).execute(); - } else { - return Flux.error(new StaleConnectionException()); - } - }); - - Mono>> resultMono = null; - - if (isSelectOrShowQuery) { - resultMono = resultFlux - .flatMap(result -> { - return result.map((row, meta) -> { - rowsList.add(getRow(row, meta)); - return result; - }); - }) - .collectList() - .flatMap(execResult -> { - return Mono.just(rowsList); - }); - } else { - resultMono = resultFlux - .flatMap(result -> result.getRowsUpdated()) - .collectList() - .flatMap(list -> Mono.just(list.get(list.size() - 1))) - .flatMap(rowsUpdated -> { - rowsList.add( - Map.of( - "affectedRows", - ObjectUtils.defaultIfNull(rowsUpdated, 0) - ) - ); - return Mono.just(rowsList); - }); - } - - return resultMono - .map(res -> { - ActionExecutionResult result = new ActionExecutionResult(); - result.setBody(objectMapper.valueToTree(rowsList)); - result.setIsExecutionSuccess(true); - System.out.println(Thread.currentThread().getName() + " In the MySqlPlugin, got action " + - "execution result"); - return result; - }) - .onErrorResume(AppsmithPluginException.class, error -> { - ActionExecutionResult result = new ActionExecutionResult(); - result.setIsExecutionSuccess(false); - result.setStatusCode(error.getAppErrorCode().toString()); - result.setBody(error.getMessage()); - return Mono.just(result); - }) - // Now set the request in the result to be returned back to the server - .map(actionExecutionResult -> { - ActionExecutionRequest request = new ActionExecutionRequest(); - request.setQuery(query); - ActionExecutionResult result = actionExecutionResult; - result.setRequest(request); - return result; - }) - .subscribeOn(scheduler); - + public Mono execute(Connection connection, DatasourceConfiguration datasourceConfiguration, ActionConfiguration actionConfiguration) { + // Unused function + return Mono.error(new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Unsupported Operation")); } @Override @@ -294,12 +427,10 @@ public class MySqlPlugin extends BasePlugin { ob = ob.option(ConnectionFactoryOptions.PASSWORD, authentication.getPassword()); return (Mono) Mono.from(ConnectionFactories.get(ob.build()).create()) - .onErrorResume(exception -> { - return Mono.error(new AppsmithPluginException( - AppsmithPluginError.PLUGIN_DATASOURCE_ARGUMENT_ERROR, - exception - )); - }) + .onErrorResume(exception -> Mono.error(new AppsmithPluginException( + AppsmithPluginError.PLUGIN_DATASOURCE_ARGUMENT_ERROR, + exception + ))) .subscribeOn(scheduler); } diff --git a/app/server/appsmith-plugins/mysqlPlugin/src/main/resources/setting.json b/app/server/appsmith-plugins/mysqlPlugin/src/main/resources/setting.json new file mode 100644 index 0000000000..4c433b6bc8 --- /dev/null +++ b/app/server/appsmith-plugins/mysqlPlugin/src/main/resources/setting.json @@ -0,0 +1,36 @@ +{ + "setting": [ + { + "sectionName": "", + "id": 1, + "children": [ + { + "label": "Run query on page load", + "configProperty": "executeOnLoad", + "controlType": "SWITCH", + "info": "Will refresh data each time the page is loaded" + }, + { + "label": "Request confirmation before running query", + "configProperty": "confirmBeforeExecute", + "controlType": "SWITCH", + "info": "Ask confirmation from the user each time before refreshing data" + }, + { + "label": "[Beta] Use Prepared Statement", + "info": "Turning on Prepared Statement makes the query parametrized. This in turn makes it resilient against SQL injections", + "configProperty": "actionConfiguration.pluginSpecifiedTemplates[0].value", + "controlType": "SWITCH", + "initialValue": false + }, + { + "label": "Query timeout (in milliseconds)", + "info": "Maximum time after which the query will return", + "configProperty": "actionConfiguration.timeoutInMillisecond", + "controlType": "INPUT_TEXT", + "dataType": "NUMBER" + } + ] + } + ] +} diff --git a/app/server/appsmith-plugins/mysqlPlugin/src/test/java/com/external/plugins/MySqlPluginTest.java b/app/server/appsmith-plugins/mysqlPlugin/src/test/java/com/external/plugins/MySqlPluginTest.java index db176dcf8b..86e0a6d135 100644 --- a/app/server/appsmith-plugins/mysqlPlugin/src/test/java/com/external/plugins/MySqlPluginTest.java +++ b/app/server/appsmith-plugins/mysqlPlugin/src/test/java/com/external/plugins/MySqlPluginTest.java @@ -1,5 +1,7 @@ package com.external.plugins; +import com.appsmith.external.dtos.ExecuteActionDTO; +import com.appsmith.external.exceptions.pluginExceptions.StaleConnectionException; import com.appsmith.external.models.ActionConfiguration; import com.appsmith.external.models.ActionExecutionResult; import com.appsmith.external.models.DBAuth; @@ -7,14 +9,12 @@ import com.appsmith.external.models.DatasourceConfiguration; import com.appsmith.external.models.DatasourceStructure; import com.appsmith.external.models.Endpoint; import com.appsmith.external.models.Property; -import com.appsmith.external.exceptions.pluginExceptions.StaleConnectionException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; - -import io.r2dbc.spi.ConnectionFactoryOptions; import io.r2dbc.spi.Connection; import io.r2dbc.spi.ConnectionFactories; +import io.r2dbc.spi.ConnectionFactoryOptions; import lombok.extern.log4j.Log4j; import org.junit.Assert; import org.junit.BeforeClass; @@ -30,11 +30,11 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Set; -import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; -import static org.junit.Assert.assertArrayEquals; @Log4j public class MySqlPluginTest { @@ -116,6 +116,14 @@ public class MySqlPluginTest { " '15:45:30'," + " '2019-11-30 23:59:59', '2019-11-30 23:59:59'" + ")" + ) + .add("INSERT INTO users VALUES (" + + "3, 'MiniJackJill', 'jaji', 'jaji@exemplars.com', NULL, '2021-01-31'," + + " '15:45:30', '04:05:06 PST'," + + " TIMESTAMP '2021-01-31 23:59:59', TIMESTAMP WITH TIME ZONE '2021-01-31 23:59:59 CET'," + + " '0 years'," + + " '{1, 2, 3}', '{\"a\", \"b\"}'" + + ")" ); }) .flatMap(batch -> Mono.from(batch.execute())) @@ -205,7 +213,7 @@ public class MySqlPluginTest { ActionConfiguration actionConfiguration = new ActionConfiguration(); actionConfiguration.setBody("show databases"); - Mono executeMono = dsConnectionMono.flatMap(conn -> pluginExecutor.execute(conn, dsConfig, actionConfiguration)); + Mono executeMono = dsConnectionMono.flatMap(conn -> pluginExecutor.executeParameterized(conn, new ExecuteActionDTO(), dsConfig, actionConfiguration)); StepVerifier.create(executeMono) .assertNext(obj -> { ActionExecutionResult result = (ActionExecutionResult) obj; @@ -223,7 +231,7 @@ public class MySqlPluginTest { Connection connection = pluginExecutor.datasourceCreate(dsConfig).block(); Flux resultFlux = Mono.from(connection.close()) - .thenMany(pluginExecutor.execute(connection, dsConfig, actionConfiguration)); + .thenMany(pluginExecutor.executeParameterized(connection, new ExecuteActionDTO(), dsConfig, actionConfiguration)); StepVerifier.create(resultFlux) .expectErrorMatches(throwable -> throwable instanceof StaleConnectionException) @@ -287,7 +295,7 @@ public class MySqlPluginTest { actionConfiguration.setBody("SELECT id as user_id FROM users WHERE id = 1"); Mono executeMono = dsConnectionMono - .flatMap(conn -> pluginExecutor.execute(conn, dsConfig, actionConfiguration)); + .flatMap(conn -> pluginExecutor.executeParameterized(conn, new ExecuteActionDTO(), dsConfig, actionConfiguration)); StepVerifier.create(executeMono) .assertNext(result -> { @@ -316,7 +324,7 @@ public class MySqlPluginTest { actionConfiguration.setBody("SELECT * FROM users WHERE id = 1"); Mono executeMono = dsConnectionMono - .flatMap(conn -> pluginExecutor.execute(conn, dsConfig, actionConfiguration)); + .flatMap(conn -> pluginExecutor.executeParameterized(conn, new ExecuteActionDTO(), dsConfig, actionConfiguration)); StepVerifier.create(executeMono) .assertNext(result -> { @@ -433,7 +441,7 @@ public class MySqlPluginTest { Mono dsConnectionMono = pluginExecutor.datasourceCreate(dsConfig); ActionConfiguration actionConfiguration = new ActionConfiguration(); actionConfiguration.setBody(query); - Mono executeMono = dsConnectionMono.flatMap(conn -> pluginExecutor.execute(conn, dsConfig, actionConfiguration)); + Mono executeMono = dsConnectionMono.flatMap(conn -> pluginExecutor.executeParameterized(conn, new ExecuteActionDTO(), dsConfig, actionConfiguration)); StepVerifier.create(executeMono) .assertNext(obj -> { ActionExecutionResult result = (ActionExecutionResult) obj; diff --git a/app/server/appsmith-plugins/postgresPlugin/src/main/java/com/external/plugins/PostgresPlugin.java b/app/server/appsmith-plugins/postgresPlugin/src/main/java/com/external/plugins/PostgresPlugin.java index f942ead0a7..ed77ed1f86 100644 --- a/app/server/appsmith-plugins/postgresPlugin/src/main/java/com/external/plugins/PostgresPlugin.java +++ b/app/server/appsmith-plugins/postgresPlugin/src/main/java/com/external/plugins/PostgresPlugin.java @@ -1,5 +1,6 @@ package com.external.plugins; +import com.appsmith.external.constants.DataType; import com.appsmith.external.dtos.ExecuteActionDTO; import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginError; import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginException; @@ -22,6 +23,7 @@ import com.appsmith.external.plugins.PluginExecutor; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; import com.zaxxer.hikari.HikariPoolMXBean; +import org.apache.commons.io.IOUtils; import org.apache.commons.lang.ObjectUtils; import org.pf4j.Extension; import org.pf4j.PluginWrapper; @@ -31,12 +33,17 @@ import reactor.core.publisher.Mono; import reactor.core.scheduler.Scheduler; import reactor.core.scheduler.Schedulers; +import java.io.IOException; +import java.sql.Array; import java.sql.Connection; +import java.sql.Date; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.ResultSetMetaData; import java.sql.SQLException; import java.sql.Statement; +import java.sql.Time; +import java.sql.Types; import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; @@ -231,16 +238,20 @@ public class PostgresPlugin extends BasePlugin { } else { preparedQuery = connectionFromPool.prepareStatement(query); if (mustacheValuesInOrder != null && !mustacheValuesInOrder.isEmpty()) { + List params = executeActionDTO.getParams(); List parameters = new ArrayList<>(); + for (int i = 0; i < mustacheValuesInOrder.size(); i++) { String key = mustacheValuesInOrder.get(i); Optional matchingParam = params.stream().filter(param -> param.getKey().trim().equals(key)).findFirst(); + + // If the evaluated value of the mustache binding is present, set it in the prepared statement if (matchingParam.isPresent()) { String value = matchingParam.get().getValue(); parameters.add(value); - preparedQuery = SqlStringUtils.setValueInPreparedStatement(i + 1, key, - value, preparedQuery); + preparedQuery = setValueInPreparedStatement(i + 1, key, + value, preparedQuery, connectionFromPool); } } requestData.put("parameters", parameters); @@ -340,6 +351,15 @@ public class PostgresPlugin extends BasePlugin { } } + if (preparedQuery != null) { + try { + preparedQuery.close(); + } catch (SQLException e) { + System.out.println(Thread.currentThread().getName() + + ": Execute Error closing Postgres Statement" + e.getMessage()); + } + } + if (connectionFromPool != null) { try { // Return the connetion back to the pool @@ -761,4 +781,110 @@ public class PostgresPlugin extends BasePlugin { return connection; } + private static PreparedStatement setValueInPreparedStatement(int index, + String binding, + String value, + PreparedStatement preparedStatement, + Connection connection) throws AppsmithPluginException { + DataType valueType = SqlStringUtils.stringToKnownDataTypeConverter(value); + + try { + switch (valueType) { + case NULL: { + preparedStatement.setNull(index, Types.NULL); + break; + } + case BINARY: { + preparedStatement.setBinaryStream(index, IOUtils.toInputStream(value)); + break; + } + case BYTES: { + preparedStatement.setBytes(index, value.getBytes("UTF-8")); + break; + } + case INTEGER: { + preparedStatement.setInt(index, Integer.parseInt(value)); + break; + } + case LONG: { + preparedStatement.setLong(index, Long.parseLong(value)); + break; + } + case FLOAT: { + preparedStatement.setFloat(index, Float.parseFloat(value)); + break; + } + case DOUBLE: { + preparedStatement.setDouble(index, Double.parseDouble(value)); + break; + } + case BOOLEAN: { + preparedStatement.setBoolean(index, Boolean.parseBoolean(value)); + break; + } + case DATE: { + preparedStatement.setDate(index, Date.valueOf(value)); + break; + } + case TIME: { + preparedStatement.setTime(index, Time.valueOf(value)); + break; + } + case ARRAY: { + List arrayListFromInput = objectMapper.readValue(value, List.class); + if (arrayListFromInput.isEmpty()) { + break; + } + // Find the type of the entries in the list + Object firstEntry = arrayListFromInput.get(0); + DataType dataType = SqlStringUtils.stringToKnownDataTypeConverter((String.valueOf(firstEntry))); + String typeName = toPostgresqlPrimitiveTypeName(dataType); + + // Create the Sql Array and set it. + Array inputArray = connection.createArrayOf(typeName, arrayListFromInput.toArray()); + preparedStatement.setArray(index, inputArray); + break; + } + case STRING: { + preparedStatement.setString(index, value); + break; + } + default: + break; + } + + } catch (SQLException | IllegalArgumentException | IOException e) { + String message = "Query preparation failed while inserting value: " + + value + " for binding: {{" + binding + "}}. Please check the query again.\nError: " + e.getMessage(); + throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, message); + } + + return preparedStatement; + } + + private static String toPostgresqlPrimitiveTypeName(DataType type) { + switch (type) { + case LONG: + return "int8"; + case INTEGER: + return "int4"; + case FLOAT: + return "decimal"; + case STRING: + return "varchar"; + case BOOLEAN: + return "bool"; + case DATE: + return "date"; + case TIME: + return "time"; + case DOUBLE: + return "float8"; + case ARRAY: + throw new IllegalArgumentException("Array of Array datatype is not supported."); + default: + throw new IllegalArgumentException("Unable to map the computed data type to primitive Postgresql type"); + } + } + } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ActionController.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ActionController.java index 8433b403c3..a133819711 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ActionController.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ActionController.java @@ -62,7 +62,7 @@ public class ActionController { } @PutMapping("/{id}") - public Mono> updateAction(@PathVariable String id, @RequestBody ActionDTO resource) { + public Mono> updateAction(@PathVariable String id, @Valid @RequestBody ActionDTO resource) { log.debug("Going to update resource with id: {}", id); return actionCollectionService.updateAction(id, resource) .map(updatedResource -> new ResponseDTO<>(HttpStatus.OK.value(), updatedResource, null)); diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/AssetController.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/AssetController.java index 8ae02015fe..af735039d6 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/AssetController.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/AssetController.java @@ -4,11 +4,6 @@ import com.appsmith.server.constants.Url; import com.appsmith.server.services.AssetService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.core.io.buffer.DefaultDataBuffer; -import org.springframework.core.io.buffer.DefaultDataBufferFactory; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -26,21 +21,7 @@ public class AssetController { @GetMapping("/{id}") public Mono getById(@PathVariable String id, ServerWebExchange exchange) { - log.debug("Returning asset with ID '{}'.", id); - - final ServerHttpResponse response = exchange.getResponse(); - response.setStatusCode(HttpStatus.OK); - - final Mono imageBufferMono = service.getById(id) - .map(asset -> { - final String contentType = asset.getContentType(); - if (contentType != null) { - response.getHeaders().set(HttpHeaders.CONTENT_TYPE, contentType); - } - return new DefaultDataBufferFactory().wrap(asset.getData()); - }); - - return response.writeWith(imageBufferMono); + return service.makeImageResponse(exchange, id); } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/UserController.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/UserController.java index 47ef8f41bf..42e0a66836 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/UserController.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/UserController.java @@ -2,6 +2,7 @@ package com.appsmith.server.controllers; import com.appsmith.server.constants.Url; import com.appsmith.server.domains.User; +import com.appsmith.server.domains.UserData; import com.appsmith.server.dtos.InviteUsersDTO; import com.appsmith.server.dtos.ResetUserPasswordDTO; import com.appsmith.server.dtos.ResponseDTO; @@ -14,6 +15,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.codec.multipart.Part; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -22,6 +25,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ServerWebExchange; @@ -142,4 +146,34 @@ public class UserController extends BaseController { .thenReturn(new ResponseDTO<>(HttpStatus.OK.value(), null, null)); } + @PostMapping(value = "/photo", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public Mono> uploadProfilePhoto(@RequestPart("file") Mono fileMono) { + return fileMono + .flatMap(userDataService::saveProfilePhoto) + .map(url -> new ResponseDTO<>(HttpStatus.OK.value(), url, null)); + } + + @DeleteMapping("/photo") + public Mono> deleteProfilePhoto() { + return userDataService + .deleteProfilePhoto() + .map(ignored -> new ResponseDTO<>(HttpStatus.OK.value(), null, null)); + } + + @GetMapping("/photo") + public Mono getProfilePhoto(ServerWebExchange exchange) { + return userDataService.makeProfilePhotoResponse(exchange) + .switchIfEmpty(Mono.fromRunnable(() -> { + exchange.getResponse().setStatusCode(HttpStatus.NOT_FOUND); + })); + } + + @GetMapping("/photo/{email}") + public Mono getProfilePhoto(ServerWebExchange exchange, @PathVariable String email) { + return userDataService.makeProfilePhotoResponse(exchange, email) + .switchIfEmpty(Mono.fromRunnable(() -> { + exchange.getResponse().setStatusCode(HttpStatus.NOT_FOUND); + })); + } + } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserData.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserData.java index b397e347e7..0d5b30df11 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserData.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserData.java @@ -3,6 +3,7 @@ package com.appsmith.server.domains; import com.appsmith.external.models.BaseDomain; import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; import lombok.ToString; import org.springframework.data.mongodb.core.mapping.Document; @@ -14,11 +15,15 @@ import org.springframework.data.mongodb.core.mapping.Document; @Setter @ToString @Document +@NoArgsConstructor public class UserData extends BaseDomain { @JsonIgnore String userId; + // The ID of the asset which has the profile photo of this user. + private String profilePhotoAssetId; + // The version where this user has last viewed the release notes. private String releaseNotesViewedVersion; diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithError.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithError.java index c8bf19c8a3..3e49202aa8 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithError.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithError.java @@ -31,13 +31,13 @@ public enum AppsmithError { PAGE_DOESNT_BELONG_TO_APPLICATION(400, 4018, "Unexpected state. Page {0} does not seem belong to the application {1}. Please reach out to Appsmith customer support to resolve this.", AppsmithErrorAction.LOG_EXTERNALLY), INVALID_DYNAMIC_BINDING_REFERENCE(400, 4022, - " \"message\" : \"Binding path in the widget not found. Please reach out to Appsmith customer support to resolve this.\"," + " \"widgetType\" : \"{0}\"," + - " \"widgetName\" : \"{1}\"," + - " \"widgetId\" : \"{2}\"," + - " \"bindingPath\" : \"{3}\"," + - " \"pageId\" : \"{4}\"," + - " \"layoutId\" : \"{5}\"", + " \"bindingPath\" : \"{3}\"," + + " \"message\" : \"Binding path in the widget not found. Please reach out to Appsmith customer support to resolve this.\"," + + " \"widgetName\" : \"{1}\"," + + " \"widgetId\" : \"{2}\"," + + " \"pageId\" : \"{4}\"," + + " \"layoutId\" : \"{5}\"", AppsmithErrorAction.LOG_EXTERNALLY), USER_ALREADY_EXISTS_IN_ORGANIZATION(400, 4021, "The user {0} has already been added to the organization with role {1}. To change the role, please navigate to `Manage Users` page.", AppsmithErrorAction.DEFAULT), UNAUTHORIZED_DOMAIN(401, 4019, "Invalid email domain {0} used for sign in/sign up. Please contact the administrator to configure this domain if this is unexpected.", AppsmithErrorAction.DEFAULT), diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/DatabaseChangelog.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/DatabaseChangelog.java index 79ad491602..e1816df99f 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/DatabaseChangelog.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/DatabaseChangelog.java @@ -1866,4 +1866,54 @@ public class DatabaseChangelog { } } } + + @ChangeSet(order = "057", id = "update-database-action-configuration-timeout", author = "") + public void updateActionConfigurationTimeout(MongoTemplate mongoTemplate) { + + for (NewAction action : mongoTemplate.findAll(NewAction.class)) { + boolean updateTimeout = false; + + if (action.getUnpublishedAction() != null + && action.getUnpublishedAction().getActionConfiguration() != null + && action.getUnpublishedAction().getActionConfiguration().getTimeoutInMillisecond() != null + && action.getUnpublishedAction().getActionConfiguration().getTimeoutInMillisecond() > 60000) { + action.getUnpublishedAction().getActionConfiguration().setTimeoutInMillisecond("60000"); + updateTimeout = true; + } + + if (action.getPublishedAction() != null + && action.getPublishedAction().getActionConfiguration() != null + && action.getPublishedAction().getActionConfiguration().getTimeoutInMillisecond() != null + && action.getPublishedAction().getActionConfiguration().getTimeoutInMillisecond() > 60000) { + action.getPublishedAction().getActionConfiguration().setTimeoutInMillisecond("60000"); + updateTimeout = true; + } + + if(updateTimeout) { + mongoTemplate.save(action); + } + } + } + + @ChangeSet(order = "058", id = "update-s3-datasource-configuration-and-label", author = "") + public void updateS3DatasourceConfigurationAndLabel(MongoTemplate mongoTemplate) { + Plugin s3Plugin = mongoTemplate + .find(query(where("name").is("Amazon S3")), Plugin.class).get(0); + s3Plugin.setName("S3"); + mongoTemplate.save(s3Plugin); + + List s3Datasources = mongoTemplate + .find(query(where("pluginId").is(s3Plugin.getId())), Datasource.class); + + s3Datasources + .stream() + .forEach(datasource -> { + datasource + .getDatasourceConfiguration() + .getProperties() + .add(new Property("s3Provider", "amazon-s3")); + + mongoTemplate.save(datasource); + }); + } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AnalyticsService.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AnalyticsService.java index 90b58d8ce6..1ab4073352 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AnalyticsService.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AnalyticsService.java @@ -68,6 +68,12 @@ public class AnalyticsService { TrackMessage.Builder messageBuilder = TrackMessage.builder(event).userId(userId); if (!CollectionUtils.isEmpty(properties)) { + // Segment throws an NPE if any value in `properties` is null. + for (final Map.Entry entry : properties.entrySet()) { + if (entry.getValue() == null) { + properties.put(entry.getKey(), ""); + } + } messageBuilder = messageBuilder.properties(properties); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AssetService.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AssetService.java index 40ae3d4ce5..fd412cb042 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AssetService.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AssetService.java @@ -1,10 +1,17 @@ package com.appsmith.server.services; import com.appsmith.server.domains.Asset; +import org.springframework.http.codec.multipart.Part; +import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; public interface AssetService { Mono getById(String id); + Mono upload(Part filePart, int i); + + Mono remove(String assetId); + + Mono makeImageResponse(ServerWebExchange exchange, String assetId); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AssetServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AssetServiceImpl.java index 5919413c72..50239f5458 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AssetServiceImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AssetServiceImpl.java @@ -1,12 +1,26 @@ package com.appsmith.server.services; import com.appsmith.server.domains.Asset; +import com.appsmith.server.exceptions.AppsmithError; +import com.appsmith.server.exceptions.AppsmithException; import com.appsmith.server.repositories.AssetRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.codec.multipart.Part; +import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.stereotype.Service; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import java.util.Set; + @Slf4j @Service @RequiredArgsConstructor @@ -14,9 +28,84 @@ public class AssetServiceImpl implements AssetService { private final AssetRepository repository; + private final AnalyticsService analyticsService; + + private static final Set ALLOWED_CONTENT_TYPES = Set.of(MediaType.IMAGE_JPEG, MediaType.IMAGE_PNG); + @Override public Mono getById(String id) { return repository.findById(id); } + @Override + public Mono upload(Part filePart, int maxFileSizeKB) { + if (filePart == null) { + return Mono.error(new AppsmithException(AppsmithError.VALIDATION_FAILURE, "Please upload a valid image.")); + } + + // The reason we restrict file types here is to avoid having to deal with dangerous image types such as SVG, + // which can have arbitrary HTML/JS inside of them. + final MediaType contentType = filePart.headers().getContentType(); + if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) { + return Mono.error(new AppsmithException( + AppsmithError.VALIDATION_FAILURE, + "Please upload a valid image. Only JPEG and PNG are allowed." + )); + } + + final Flux contentCache = filePart.content().cache(); + + return contentCache.count() + .defaultIfEmpty(0L) + .flatMap(count -> { + // Default implementation for the BufferFactory used breaks down the FilePart into chunks of 4KB. + // So we multiply the count of chunks with 4 to get an estimate on the file size in KB. + if (4 * count > maxFileSizeKB) { + return Mono.error(new AppsmithException(AppsmithError.PAYLOAD_TOO_LARGE, maxFileSizeKB)); + } + return DataBufferUtils.join(contentCache); + }) + .flatMap(dataBuffer -> { + byte[] data = new byte[dataBuffer.readableByteCount()]; + dataBuffer.read(data); + DataBufferUtils.release(dataBuffer); + return repository.save(new Asset(contentType, data)); + }) + .flatMap(analyticsService::sendCreateEvent); + } + + /** + * This function hard-deletes (read: not archive) the asset given by the ID. It is intended to be used to delete an + * old asset when a user uploads a new one. For example, when a new profile photo or an organization logo is, + * uploaded, this method is used to completely delete the old one, if any. + * @param assetId The ID string of the asset to delete. + * @return empty Mono + */ + @Override + public Mono remove(String assetId) { + final Asset tempAsset = new Asset(); + tempAsset.setId(assetId); + return repository.deleteById(assetId) + .then(analyticsService.sendDeleteEvent(tempAsset)) + .then(); + } + + @Override + public Mono makeImageResponse(ServerWebExchange exchange, String assetId) { + log.debug("Returning asset with ID '{}'.", assetId); + return getById(assetId) + .flatMap(asset -> { + final String contentType = asset.getContentType(); + final ServerHttpResponse response = exchange.getResponse(); + + response.setStatusCode(HttpStatus.OK); + + if (contentType != null) { + response.getHeaders().set(HttpHeaders.CONTENT_TYPE, contentType); + } + + return response.writeWith(Mono.just(new DefaultDataBufferFactory().wrap(asset.getData()))); + }); + } + } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/NewActionServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/NewActionServiceImpl.java index 8b76964b3d..155371b4b3 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/NewActionServiceImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/NewActionServiceImpl.java @@ -283,6 +283,14 @@ public class NewActionServiceImpl extends BaseService generateActionByViewMode(savedAction, false)); } + // Validate actionConfiguration + ActionConfiguration actionConfig = action.getActionConfiguration(); + if(actionConfig != null) { + validator.validate(actionConfig) + .stream() + .forEach(x -> invalids.add(x.getMessage())); + } + Mono datasourceMono; if (action.getDatasource().getId() == null) { if (action.getDatasource().getDatasourceConfiguration() != null && @@ -659,6 +667,13 @@ public class NewActionServiceImpl extends BaseService { + // In case the action was executed in view mode, do not return the request object + if (TRUE.equals(executeActionDTO.getViewMode())) { + result.setRequest(null); + } + return result; + }) .elapsed() .map(tuple -> { log.debug("{}: Action {} with id {} execution time : {} ms", diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/OrganizationServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/OrganizationServiceImpl.java index 71d11eff9f..c4913d73dd 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/OrganizationServiceImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/OrganizationServiceImpl.java @@ -19,8 +19,6 @@ import com.appsmith.server.repositories.PluginRepository; import com.appsmith.server.repositories.UserRepository; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.core.io.buffer.DataBuffer; -import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.data.domain.Sort; import org.springframework.data.mongodb.core.ReactiveMongoTemplate; import org.springframework.data.mongodb.core.convert.MongoConverter; @@ -28,6 +26,7 @@ import org.springframework.http.codec.multipart.Part; import org.springframework.stereotype.Service; import org.springframework.util.CollectionUtils; import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Scheduler; @@ -55,6 +54,7 @@ public class OrganizationServiceImpl extends BaseService uploadLogo(String organizationId, Part filePart) { - return repository - .findById(organizationId, MANAGE_ORGANIZATIONS) - .switchIfEmpty(Mono.error(new AppsmithException(AppsmithError.NO_RESOURCE_FOUND, FieldName.ORGANIZATION, organizationId))) - .flatMap(organization -> { - if (filePart != null && filePart.headers().getContentType() != null) { - // Default implementation for the BufferFactory used breaks down the FilePart into chunks of 4KB - // To limit file size to 250KB, we only allow 63 (250/4 = 62.5) such chunks to be derived from the incoming FilePart - return filePart.content().count().flatMap(count -> { - if (count > (int) Math.ceil(Constraint.ORGANIZATION_LOGO_SIZE_KB / 4.0)) { - return Mono.error(new AppsmithException(AppsmithError.PAYLOAD_TOO_LARGE, Constraint.ORGANIZATION_LOGO_SIZE_KB)); - } else { - return Mono.zip(Mono.just(organization), DataBufferUtils.join(filePart.content())); - } - }); - } else { - return Mono.error(new AppsmithException(AppsmithError.VALIDATION_FAILURE, "Please upload a valid image.")); - } - }) + final Mono findOrganizationMono = repository.findById(organizationId, MANAGE_ORGANIZATIONS) + .switchIfEmpty(Mono.error(new AppsmithException(AppsmithError.NO_RESOURCE_FOUND, FieldName.ORGANIZATION, organizationId))); + + // We don't execute the upload Mono if we don't find the organization. + final Mono uploadAssetMono = assetService.upload(filePart, Constraint.ORGANIZATION_LOGO_SIZE_KB); + + return findOrganizationMono + .flatMap(organization -> Mono.zip(Mono.just(organization), uploadAssetMono)) .flatMap(tuple -> { final Organization organization = tuple.getT1(); - final DataBuffer dataBuffer = tuple.getT2(); + final Asset uploadedAsset = tuple.getT2(); final String prevAssetId = organization.getLogoAssetId(); - byte[] data = new byte[dataBuffer.readableByteCount()]; - dataBuffer.read(data); - DataBufferUtils.release(dataBuffer); - - return assetRepository - .save(new Asset(filePart.headers().getContentType(), data)) - .flatMap(asset -> { - organization.setLogoAssetId(asset.getId()); - Mono savedOrganization = repository.save(organization); - Mono createdAsset = analyticsService.sendCreateEvent(asset); - return savedOrganization.zipWith(createdAsset); - }) - .flatMap(savedTuple -> { - Organization savedOrganization = savedTuple.getT1(); - if (prevAssetId != null) { - return assetRepository.findById(prevAssetId) - .flatMap(asset -> assetRepository.delete(asset).thenReturn(asset)) - .flatMap(analyticsService::sendDeleteEvent) - .thenReturn(savedOrganization); - } else { + organization.setLogoAssetId(uploadedAsset.getId()); + return repository.save(organization) + .flatMap(savedOrganization -> { + if (StringUtils.isEmpty(prevAssetId)) { return Mono.just(savedOrganization); + } else { + return assetService.remove(prevAssetId).thenReturn(savedOrganization); } }); }); diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataService.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataService.java index 843e2285ab..288b8a2bbf 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataService.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataService.java @@ -2,6 +2,8 @@ package com.appsmith.server.services; import com.appsmith.server.domains.User; import com.appsmith.server.domains.UserData; +import org.springframework.http.codec.multipart.Part; +import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; public interface UserDataService { @@ -9,9 +11,24 @@ public interface UserDataService { Mono getForUser(String userId); + Mono getForCurrentUser(); + + Mono getForUserEmail(String email); + + Mono updateForCurrentUser(UserData updates); + Mono setViewedCurrentVersionReleaseNotes(User user); Mono setViewedCurrentVersionReleaseNotes(User user, String version); Mono ensureViewedCurrentVersionReleaseNotes(User user); + + Mono saveProfilePhoto(Part filePart); + + Mono deleteProfilePhoto(); + + Mono makeProfilePhotoResponse(ServerWebExchange exchange, String email); + + Mono makeProfilePhotoResponse(ServerWebExchange exchange); + } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataServiceImpl.java index 5edf5c64c7..d1ac5c2966 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataServiceImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataServiceImpl.java @@ -1,26 +1,46 @@ package com.appsmith.server.services; +import com.appsmith.server.domains.Asset; +import com.appsmith.server.domains.QUserData; import com.appsmith.server.domains.User; import com.appsmith.server.domains.UserData; +import com.appsmith.server.exceptions.AppsmithError; +import com.appsmith.server.exceptions.AppsmithException; import com.appsmith.server.repositories.UserDataRepository; import com.appsmith.server.solutions.ReleaseNotesService; +import com.mongodb.DBObject; +import org.apache.commons.lang3.ObjectUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.mongodb.core.ReactiveMongoTemplate; import org.springframework.data.mongodb.core.convert.MongoConverter; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.http.codec.multipart.Part; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import reactor.core.scheduler.Scheduler; import javax.validation.Validator; +import java.util.Map; + +import static com.appsmith.server.repositories.BaseAppsmithRepositoryImpl.fieldName; @Service public class UserDataServiceImpl extends BaseService implements UserDataService { private final UserService userService; + private final SessionUserService sessionUserService; + + private final AssetService assetService; + private final ReleaseNotesService releaseNotesService; + private static final int MAX_PROFILE_PHOTO_SIZE_KB = 250; + @Autowired public UserDataServiceImpl(Scheduler scheduler, Validator validator, @@ -29,11 +49,15 @@ public class UserDataServiceImpl extends BaseService getForCurrentUser() { + return sessionUserService.getCurrentUser() + .map(User::getEmail) + .flatMap(this::getForUserEmail); + } + + @Override + public Mono getForUserEmail(String email) { + return userService.findByEmail(email) + .flatMap(this::getForUser); + } + + @Override + public Mono updateForCurrentUser(UserData updates) { + return sessionUserService.getCurrentUser() + .flatMap(user -> userService.findByEmail(user.getEmail())) + .flatMap(user -> { + // If a UserData document exists for this user, update it. If not, create one. + updates.setUserId(user.getId()); + final Mono updaterMono = update(user.getId(), updates); + final Mono creatorMono = Mono.just(updates).flatMap(this::create); + return updaterMono.switchIfEmpty(creatorMono); + }); + } + + @Override + public Mono update(String userId, UserData resource) { + if (userId == null) { + return Mono.error(new AppsmithException(AppsmithError.INVALID_PARAMETER, fieldName(QUserData.userData.userId))); + } + + Query query = new Query(Criteria.where(fieldName(QUserData.userData.userId)).is(userId)); + + // In case the update is not used to update the policies, then set the policies to null to ensure that the + // existing policies are not overwritten. + if (resource.getPolicies().isEmpty()) { + resource.setPolicies(null); + } + + DBObject update = getDbObject(resource); + + Update updateObj = new Update(); + Map updateMap = update.toMap(); + updateMap.entrySet().stream().forEach(entry -> updateObj.set(entry.getKey(), entry.getValue())); + + return mongoTemplate.updateFirst(query, updateObj, resource.getClass()) + .flatMap(updateResult -> updateResult.getMatchedCount() == 0 ? Mono.empty() : repository.findByUserId(userId)) + .flatMap(analyticsService::sendUpdateEvent); + } + @Override public Mono setViewedCurrentVersionReleaseNotes(User user) { final String version = releaseNotesService.getReleasedVersion(); @@ -86,4 +161,50 @@ public class UserDataServiceImpl extends BaseService saveProfilePhoto(Part filePart) { + final Mono prevAssetIdMono = getForCurrentUser() + .map(userData -> ObjectUtils.defaultIfNull(userData.getProfilePhotoAssetId(), "")); + + final Mono uploaderMono = assetService.upload(filePart, MAX_PROFILE_PHOTO_SIZE_KB); + + return Mono.zip(prevAssetIdMono, uploaderMono) + .flatMap(tuple -> { + final String oldAssetId = tuple.getT1(); + final Asset uploadedAsset = tuple.getT2(); + final UserData updates = new UserData(); + updates.setProfilePhotoAssetId(uploadedAsset.getId()); + final Mono updateMono = updateForCurrentUser(updates); + if (StringUtils.isEmpty(oldAssetId)) { + return updateMono; + } else { + return assetService.remove(oldAssetId).then(updateMono); + } + }); + } + + @Override + public Mono deleteProfilePhoto() { + return getForCurrentUser() + .flatMap(userData -> Mono.justOrEmpty(userData.getProfilePhotoAssetId())) + .flatMap(assetService::remove); + } + + @Override + public Mono makeProfilePhotoResponse(ServerWebExchange exchange, String email) { + return getForUserEmail(email) + .flatMap(userData -> makeProfilePhotoResponse(exchange, userData)); + } + + @Override + public Mono makeProfilePhotoResponse(ServerWebExchange exchange) { + return getForCurrentUser() + .flatMap(userData -> makeProfilePhotoResponse(exchange, userData)); + } + + private Mono makeProfilePhotoResponse(ServerWebExchange exchange, UserData userData) { + return Mono.justOrEmpty(userData.getProfilePhotoAssetId()) + .flatMap(assetId -> assetService.makeImageResponse(exchange, assetId)); + } + } diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ActionServiceTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ActionServiceTest.java index 77222187c8..ff81c7eaf4 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ActionServiceTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ActionServiceTest.java @@ -589,7 +589,7 @@ public class ActionServiceTest { new Property("random-header-key", "random-header-value"), new Property("", "") )); - actionConfiguration.setTimeoutInMillisecond(1000); + actionConfiguration.setTimeoutInMillisecond(String.valueOf(1000)); action.setActionConfiguration(actionConfiguration); action.setPageId(testPage.getId()); action.setName("testActionExecuteSecondaryStaleConnection"); @@ -630,7 +630,7 @@ public class ActionServiceTest { new Property("random-header-key", "random-header-value"), new Property("", "") )); - actionConfiguration.setTimeoutInMillisecond(10); + actionConfiguration.setTimeoutInMillisecond(String.valueOf(10)); action.setActionConfiguration(actionConfiguration); action.setPageId(testPage.getId()); action.setName("testActionExecuteTimeout"); @@ -676,7 +676,7 @@ public class ActionServiceTest { action1.setPageId(testPage.getId()); ActionConfiguration actionConfiguration1 = new ActionConfiguration(); actionConfiguration1.setHttpMethod(HttpMethod.GET); - actionConfiguration1.setTimeoutInMillisecond(20000); + actionConfiguration1.setTimeoutInMillisecond(String.valueOf(20000)); action1.setActionConfiguration(actionConfiguration1); action1.setDatasource(datasource); @@ -1029,4 +1029,142 @@ public class ActionServiceTest { }) .verifyComplete(); } + + @Test + @WithUserDetails(value = "api_user") + public void testUpdateActionWithOutOfRangeTimeout() { + Mockito.when(pluginExecutorHelper.getPluginExecutor(Mockito.any())).thenReturn(Mono.just(new MockPluginExecutor())); + + ActionDTO action = new ActionDTO(); + action.setName("testAction"); + action.setPageId(testPage.getId()); + ActionConfiguration actionConfiguration = new ActionConfiguration(); + actionConfiguration.setTimeoutInMillisecond("60001"); + action.setActionConfiguration(actionConfiguration); + action.setDatasource(datasource); + + Mono newActionMono = newActionService + .createAction(action); + + Mono updateActionMono = newActionMono + .flatMap(preUpdateAction -> { + ActionDTO actionUpdate = action; + actionUpdate.getActionConfiguration().setBody("New Body"); + return actionCollectionService.updateAction(preUpdateAction.getId(), actionUpdate); + }); + + StepVerifier + .create(updateActionMono) + .assertNext(updatedAction -> { + assertThat(updatedAction).isNotNull(); + assertThat(updatedAction + .getInvalids() + .stream() + .anyMatch(errorMsg -> errorMsg.contains("'Query timeout' field must be an integer between" + + " 0 and 60000")) + ).isTrue(); + }) + .verifyComplete(); + } + + @Test + @WithUserDetails(value = "api_user") + public void testUpdateActionWithValidRangeTimeout() { + Mockito.when(pluginExecutorHelper.getPluginExecutor(Mockito.any())).thenReturn(Mono.just(new MockPluginExecutor())); + + ActionDTO action = new ActionDTO(); + action.setName("testAction"); + action.setPageId(testPage.getId()); + ActionConfiguration actionConfiguration = new ActionConfiguration(); + actionConfiguration.setTimeoutInMillisecond("6000"); + action.setActionConfiguration(actionConfiguration); + action.setDatasource(datasource); + + Mono newActionMono = newActionService + .createAction(action); + + Mono updateActionMono = newActionMono + .flatMap(preUpdateAction -> { + ActionDTO actionUpdate = action; + actionUpdate.getActionConfiguration().setBody("New Body"); + return actionCollectionService.updateAction(preUpdateAction.getId(), actionUpdate); + }); + + StepVerifier + .create(updateActionMono) + .assertNext(updatedAction -> { + assertThat(updatedAction).isNotNull(); + assertThat(updatedAction + .getInvalids() + .stream() + .anyMatch(errorMsg -> errorMsg.contains("'Query timeout' field must be an integer between")) + ).isFalse(); + }) + .verifyComplete(); + } + + @Test + @WithUserDetails(value = "api_user") + public void testCreateActionWithOutOfRangeTimeout() { + Mockito.when(pluginExecutorHelper.getPluginExecutor(Mockito.any())).thenReturn(Mono.just(new MockPluginExecutor())); + + ActionDTO action = new ActionDTO(); + action.setName("validAction"); + action.setPageId(testPage.getId()); + action.setExecuteOnLoad(true); + ActionConfiguration actionConfiguration = new ActionConfiguration(); + actionConfiguration.setHttpMethod(HttpMethod.GET); + actionConfiguration.setTimeoutInMillisecond("60001"); + action.setActionConfiguration(actionConfiguration); + action.setDatasource(datasource); + + Mono actionMono = newActionService.createAction(action) + .flatMap(createdAction -> newActionService.findById(createdAction.getId(), READ_ACTIONS)) + .flatMap(newAction -> newActionService.generateActionByViewMode(newAction, false)); + + StepVerifier + .create(actionMono) + .assertNext(createdAction -> { + assertThat(createdAction).isNotNull(); + assertThat(createdAction + .getInvalids() + .stream() + .anyMatch(errorMsg -> errorMsg.contains("'Query timeout' field must be an integer between" + + " 0 and 60000")) + ).isTrue(); + }) + .verifyComplete(); + } + + @Test + @WithUserDetails(value = "api_user") + public void testCreateActionWithValidRangeTimeout() { + Mockito.when(pluginExecutorHelper.getPluginExecutor(Mockito.any())).thenReturn(Mono.just(new MockPluginExecutor())); + + ActionDTO action = new ActionDTO(); + action.setName("validAction"); + action.setPageId(testPage.getId()); + action.setExecuteOnLoad(true); + ActionConfiguration actionConfiguration = new ActionConfiguration(); + actionConfiguration.setHttpMethod(HttpMethod.GET); + actionConfiguration.setTimeoutInMillisecond("6000"); + action.setActionConfiguration(actionConfiguration); + action.setDatasource(datasource); + + Mono actionMono = newActionService.createAction(action) + .flatMap(createdAction -> newActionService.findById(createdAction.getId(), READ_ACTIONS)) + .flatMap(newAction -> newActionService.generateActionByViewMode(newAction, false)); + + StepVerifier + .create(actionMono) + .assertNext(createdAction -> { + assertThat(createdAction).isNotNull(); + assertThat(createdAction + .getInvalids() + .stream() + .anyMatch(errorMsg -> errorMsg.contains("'Query timeout' field must be an integer between")) + ).isFalse(); + }) + .verifyComplete(); + } } diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserDataServiceTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserDataServiceTest.java index 283252c535..cf314e6c3e 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserDataServiceTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserDataServiceTest.java @@ -1,16 +1,31 @@ package com.appsmith.server.services; +import com.appsmith.server.domains.Asset; import com.appsmith.server.domains.User; import com.appsmith.server.domains.UserData; +import com.appsmith.server.exceptions.AppsmithException; +import com.appsmith.server.repositories.AssetRepository; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.MediaType; +import org.springframework.http.codec.multipart.FilePart; +import org.springframework.security.test.context.support.WithUserDetails; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.junit4.SpringRunner; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; +import reactor.util.function.Tuple2; + +import java.time.Duration; import static org.assertj.core.api.Assertions.assertThat; @@ -25,6 +40,9 @@ public class UserDataServiceTest { @Autowired private UserDataService userDataService; + @Autowired + private AssetRepository assetRepository; + private Mono userMono; @Before @@ -72,4 +90,81 @@ public class UserDataServiceTest { .verifyComplete(); } + @Test + @WithUserDetails(value = "api_user") + public void testUploadAndDeleteProfilePhoto_validImage() { + FilePart filepart = Mockito.mock(FilePart.class, Mockito.RETURNS_DEEP_STUBS); + Flux dataBufferFlux = DataBufferUtils + .read(new ClassPathResource("test_assets/OrganizationServiceTest/my_organization_logo.png"), new DefaultDataBufferFactory(), 4096) + .cache(); + + Mockito.when(filepart.content()).thenReturn(dataBufferFlux); + Mockito.when(filepart.headers().getContentType()).thenReturn(MediaType.IMAGE_PNG); + + Mono> loadProfileImageMono = userDataService.getForUserEmail("api_user") + .flatMap(userData -> Mono.zip( + Mono.just(userData), + assetRepository.findById(userData.getProfilePhotoAssetId()) + )); + + final Mono saveMono = userDataService.saveProfilePhoto(filepart).cache(); + final Mono> saveAndGetMono = saveMono.then(loadProfileImageMono); + final Mono> deleteAndGetMono = saveMono.then(userDataService.deleteProfilePhoto()).then(loadProfileImageMono); + + StepVerifier.create(saveAndGetMono) + .assertNext(tuple -> { + final UserData userData = tuple.getT1(); + assertThat(userData.getProfilePhotoAssetId()).isNotNull(); + + final Asset asset = tuple.getT2(); + assertThat(asset).isNotNull(); + DataBuffer buffer = DataBufferUtils.join(dataBufferFlux).block(Duration.ofSeconds(3)); + byte[] res = new byte[buffer.readableByteCount()]; + buffer.read(res); + assertThat(asset.getData()).isEqualTo(res); + }) + .verifyComplete(); + + StepVerifier.create(deleteAndGetMono) + // Should be empty since the profile photo has been deleted. + .verifyComplete(); + } + + @Test + @WithUserDetails(value = "api_user") + public void testUploadProfilePhoto_invalidImageFormat() { + FilePart filepart = Mockito.mock(FilePart.class, Mockito.RETURNS_DEEP_STUBS); + Flux dataBufferFlux = DataBufferUtils + .read(new ClassPathResource("test_assets/OrganizationServiceTest/my_organization_logo.png"), new DefaultDataBufferFactory(), 4096) + .cache(); + + Mockito.when(filepart.content()).thenReturn(dataBufferFlux); + Mockito.when(filepart.headers().getContentType()).thenReturn(MediaType.IMAGE_GIF); + + final Mono saveMono = userDataService.saveProfilePhoto(filepart).cache(); + + StepVerifier.create(saveMono) + .expectErrorMatches(error -> error instanceof AppsmithException) + .verify(); + } + + @Test + @WithUserDetails(value = "api_user") + public void testUploadProfilePhoto_invalidImageSize() { + FilePart filepart = Mockito.mock(FilePart.class, Mockito.RETURNS_DEEP_STUBS); + Flux dataBufferFlux = DataBufferUtils + .read(new ClassPathResource("test_assets/OrganizationServiceTest/my_organization_logo.png"), new DefaultDataBufferFactory(), 4096) + .repeat(70) // So the file size looks like it's much larger than what it actually is. + .cache(); + + Mockito.when(filepart.content()).thenReturn(dataBufferFlux); + Mockito.when(filepart.headers().getContentType()).thenReturn(MediaType.IMAGE_PNG); + + final Mono saveMono = userDataService.saveProfilePhoto(filepart).cache(); + + StepVerifier.create(saveMono) + .expectErrorMatches(error -> error instanceof AppsmithException) + .verify(); + } + } diff --git a/app/server/appsmith-server/src/test/resources/test_assets/apple.svg b/app/server/appsmith-server/src/test/resources/test_assets/apple.svg new file mode 100644 index 0000000000..31ff74313f --- /dev/null +++ b/app/server/appsmith-server/src/test/resources/test_assets/apple.svg @@ -0,0 +1,13 @@ + + + + + + +