diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml index c4dd4001beb..cedac1f8481 100644 --- a/.github/workflows/github-release.yml +++ b/.github/workflows/github-release.yml @@ -247,10 +247,14 @@ jobs: run: | scripts/generate_info_json.sh + # As pg docker image is continuously updated for each scheduled cron on release, we are using the nightly tag while building the latest tag - name: Place server artifacts-es run: | if [[ -f scripts/prepare_server_artifacts.sh ]]; then - scripts/prepare_server_artifacts.sh + PG_TAG=nightly scripts/prepare_server_artifacts.sh + else + echo "No script found to prepare server artifacts" + exit 1 fi - name: Login to DockerHub diff --git a/.github/workflows/test-build-docker-image.yml b/.github/workflows/test-build-docker-image.yml index f14c565e210..e3c21798e6a 100644 --- a/.github/workflows/test-build-docker-image.yml +++ b/.github/workflows/test-build-docker-image.yml @@ -356,8 +356,12 @@ jobs: - name: Place server artifacts-es run: | + run: | if [[ -f scripts/prepare_server_artifacts.sh ]]; then scripts/prepare_server_artifacts.sh + else + echo "No script found to prepare server artifacts" + exit 1 fi - name: Set up Depot CLI @@ -439,6 +443,9 @@ jobs: run: | if [[ -f scripts/prepare_server_artifacts.sh ]]; then scripts/prepare_server_artifacts.sh + else + echo "No script found to prepare server artifacts" + exit 1 fi - name: Set up Depot CLI diff --git a/app/client/cypress/e2e/Regression/ClientSide/Git/GitAutocommit_spec.ts b/app/client/cypress/e2e/Regression/ClientSide/Git/GitAutocommit_spec.ts index 9d38aa357dc..c55b51711ce 100644 --- a/app/client/cypress/e2e/Regression/ClientSide/Git/GitAutocommit_spec.ts +++ b/app/client/cypress/e2e/Regression/ClientSide/Git/GitAutocommit_spec.ts @@ -1,5 +1,4 @@ import ReconnectLocators from "../../../../locators/ReconnectLocators"; -import { featureFlagIntercept } from "../../../../support/Objects/FeatureFlags"; import { agHelper, gitSync, @@ -7,7 +6,7 @@ import { } from "../../../../support/Objects/ObjectsCore"; let wsName: string; -let repoName: string = "TED-testrepo1"; +let repoName: string = "TED-autocommit-test-1"; describe( "Git Autocommit", @@ -15,8 +14,8 @@ describe( tags: [ "@tag.Git", "@tag.GitAutocommit", - "@tag.excludeForAirgap", "@tag.Sanity", + "@tag.TedMigration", ], }, function () { diff --git a/app/client/cypress/e2e/Regression/ClientSide/Widgets/TableV2/Date_column_types_validation_spec.ts b/app/client/cypress/e2e/Regression/ClientSide/Widgets/TableV2/Date_column_types_validation_spec.ts new file mode 100644 index 00000000000..0ce8bc321a3 --- /dev/null +++ b/app/client/cypress/e2e/Regression/ClientSide/Widgets/TableV2/Date_column_types_validation_spec.ts @@ -0,0 +1,149 @@ +import { tableDateColumnTypes } from "../../../../../fixtures/tableDateColumnTypes"; +import { + agHelper, + entityExplorer, + propPane, + table, +} from "../../../../../support/Objects/ObjectsCore"; + +import EditorNavigation, { + EntityType, +} from "../../../../../support/Pages/EditorNavigation"; + +describe( + "Table widget date column type validation", + { tags: ["@tag.Widget", "@tag.Table"] }, + () => { + before(() => { + entityExplorer.DragNDropWidget("tablewidgetv2", 350, 500); + EditorNavigation.SelectEntityByName("Table1", EntityType.Widget); + propPane.ToggleJSMode("Table data", true); + propPane.UpdatePropertyFieldValue("Table data", tableDateColumnTypes); + table.EditColumn("unixs", "v2"); + }); + + beforeEach(() => { + propPane.NavigateBackToPropertyPane(false); + }); + + const setEditableDateFormats = (format: string) => { + // Update date format property + propPane.ToggleJSMode("Date format", true); + propPane.UpdatePropertyFieldValue("Date format", format); + + // Update display format property + propPane.ToggleJSMode("Display format", true); + propPane.UpdatePropertyFieldValue("Display format", "YYYY-MM-DD"); + + // Toggle editable + propPane.TogglePropertyState("Editable", "On"); + }; + + const clickAndValidateDateCell = (row: number, column: number) => { + // Click unix cell edit + table.ClickOnEditIcon(row, column); + + // Click on specific date within + agHelper.GetNClick( + `${table._dateInputPopover} [aria-label='${table.getFormattedTomorrowDates().verboseFormat}']`, + ); + + // Check that date is set in column + table + .ReadTableRowColumnData(row, column, "v2") + .then((val) => + expect(val).to.equal(table.getFormattedTomorrowDates().isoFormat), + ); + }; + + it("1. should allow inline editing of Unix Timestamp in seconds (unix/s)", () => { + table.ChangeColumnType("unixs", "Date"); + setEditableDateFormats("Epoch"); + clickAndValidateDateCell(0, 0); + }); + + it("2. should allow inline editing of Unix Timestamp in milliseconds (unix/ms)", () => { + table.ChangeColumnType("unixms", "Date"); + setEditableDateFormats("Milliseconds"); + clickAndValidateDateCell(0, 1); + }); + + it("3. should allow inline editing of date in YYYY-MM-DD format", () => { + table.EditColumn("yyyymmdd", "v2"); + setEditableDateFormats("YYYY-MM-DD"); + clickAndValidateDateCell(0, 2); + }); + + it("4. should allow inline editing of date in YYYY-MM-DD HH:mm format", () => { + table.EditColumn("yyyymmddhhmm", "v2"); + setEditableDateFormats("YYYY-MM-DD HH:mm"); + clickAndValidateDateCell(0, 3); + }); + + it("5. should allow inline editing of date in ISO 8601 format (YYYY-MM-DDTHH:mm:ss)", () => { + table.EditColumn("iso8601", "v2"); + setEditableDateFormats("YYYY-MM-DDTHH:mm:ss"); + clickAndValidateDateCell(0, 4); + }); + + it("6. should allow inline editing of date in YYYY-MM-DD HH:mm format", () => { + table.EditColumn("yyyymmddTHHmmss", "v2"); + setEditableDateFormats("YYYY-MM-DD HH:mm"); + clickAndValidateDateCell(0, 5); + }); + + it("7. should allow inline editing of date in 'do MMM yyyy' format", () => { + table.ChangeColumnType("yyyymmddhhmmss", "Date"); + setEditableDateFormats("YYYY-MM-DDTHH:mm:ss"); + clickAndValidateDateCell(0, 6); + }); + + it("8. should allow inline editing of date in DD/MM/YYYY format", () => { + table.ChangeColumnType("doMMMyyyy", "Date"); + setEditableDateFormats("Do MMM YYYY"); + clickAndValidateDateCell(0, 7); + }); + + it("9. should allow inline editing of date in DD/MM/YYYY HH:mm format", () => { + table.EditColumn("ddmmyyyy", "v2"); + setEditableDateFormats("DD/MM/YYYY"); + clickAndValidateDateCell(0, 8); + }); + + it("10. should allow inline editing of date in LLL (Month Day, Year Time) format", () => { + table.EditColumn("ddmmyyyyhhmm", "v2"); + setEditableDateFormats("DD/MM/YYYY HH:mm"); + clickAndValidateDateCell(0, 9); + }); + + it("11. should allow inline editing of date in LL (Month Day, Year) format", () => { + table.EditColumn("lll", "v2"); + setEditableDateFormats("LLL"); + clickAndValidateDateCell(0, 10); + }); + + it("12. should allow inline editing of date in 'D MMMM, YYYY' format", () => { + table.EditColumn("ll", "v2"); + setEditableDateFormats("LL"); + clickAndValidateDateCell(0, 11); + }); + + it("13. should allow inline editing of date in 'h:mm A D MMMM, YYYY' format", () => { + table.EditColumn("dmmmmyyyy", "v2"); + setEditableDateFormats("D MMMM, YYYY"); + clickAndValidateDateCell(0, 12); + }); + + it("14. should allow inline editing of date in MM-DD-YYYY format", () => { + table.EditColumn("hmmAdmmmmyyyy", "v2"); + setEditableDateFormats("H:mm A D MMMM, YYYY"); + clickAndValidateDateCell(0, 13); + }); + + it("15. should allow inline editing of date in DD-MM-YYYY format", () => { + table.EditColumn("mm1dd1yyyy", "v2"); + setEditableDateFormats("MM-DD-YYYY"); + clickAndValidateDateCell(0, 14); + }); + }, +); diff --git a/app/client/cypress/e2e/Regression/ServerSide/ApiTests/API_Edit_spec.js b/app/client/cypress/e2e/Regression/ServerSide/ApiTests/API_Edit_spec.js index aa86b788a33..32f675a51fa 100644 --- a/app/client/cypress/e2e/Regression/ServerSide/ApiTests/API_Edit_spec.js +++ b/app/client/cypress/e2e/Regression/ServerSide/ApiTests/API_Edit_spec.js @@ -96,7 +96,7 @@ describe( force: true, }) .type( - "https://www.facebook.com/users/{{Button2.text}}?key=test&val={{Button2.text}}", + "http://host.docker.internal:5001/{{Button2.text}}?key=test&val={{Button2.text}}", { force: true, parseSpecialCharSequences: false }, ) .wait(3000) @@ -106,7 +106,7 @@ describe( .type("{enter}", { parseSpecialCharSequences: true }); cy.validateEvaluatedValue( - "https://www.facebook.com/users/Cancel?key=test&val=Cancel", + "http://host.docker.internal:5001/Cancel?key=test&val=Cancel", ); }); }, diff --git a/app/client/cypress/fixtures/tableDateColumnTypes.ts b/app/client/cypress/fixtures/tableDateColumnTypes.ts new file mode 100644 index 00000000000..614b36b5e11 --- /dev/null +++ b/app/client/cypress/fixtures/tableDateColumnTypes.ts @@ -0,0 +1,26 @@ +export const tableDateColumnTypes = ` +{{ + [ + { + "unixs": 1727212200, + "unixms": 1727212200000, + "yyyymmdd": "2024-09-25", + "yyyymmddhhmm": "2024-09-25 14:30", + iso8601: "2024-09-25T14:30:00.000Z", + "yyyymmddTHHmmss": "2024-09-25T14:30:00", + "yyyymmddhhmmss": "2024-09-25 02:30:00", + "doMMMyyyy": "25th Sep 2024", + "ddmmyyyy": "25/09/2024", + "ddmmyyyyhhmm": "25/09/2024 14:30", + lll: "September 25, 2024 2:30 PM", + ll: "September 25, 2024", + "dmmmmyyyy": "25 September, 2024", + "hmmAdmmmmyyyy": "2:30 PM 25 September, 2024", + "mm1dd1yyyy": "09-25-2024", + "dd1mm1yyyy": "25-09-2024", + "ddimmiyy": "25/09/24", + "mmddyy": "09/25/24", + }, + ] +}} +`; diff --git a/app/client/cypress/support/Pages/Table.ts b/app/client/cypress/support/Pages/Table.ts index e32390df546..ebe2600bee5 100644 --- a/app/client/cypress/support/Pages/Table.ts +++ b/app/client/cypress/support/Pages/Table.ts @@ -855,9 +855,44 @@ export class Table { verify && cy.get(selector).eq(1).should("be.disabled"); } + /** + * Helper function to get formatted date strings for tomorrow's date. + * + * @returns {Object} An object containing: + * - verbose format (e.g., "Sat Sep 21 2024") + * - ISO date format (e.g., "2024-09-21") + */ + public getFormattedTomorrowDates() { + // Create a new Date object for today + const tomorrow = new Date(); + + // Set the date to tomorrow by adding 1 to today's date + tomorrow.setDate(tomorrow.getDate() + 1); + + // Format tomorrow's date in verbose form (e.g., "Sat Sep 21 2024") + const verboseFormat = tomorrow + .toLocaleDateString("en-US", { + weekday: "short", + year: "numeric", + month: "short", + day: "2-digit", + }) + .replace(/,/g, ""); // Remove commas from the formatted string + + // Format tomorrow's date in ISO form (e.g., "2024-09-21") + const isoFormat = tomorrow.toISOString().split("T")[0]; // Extract the date part only + + // Return both formatted date strings as an object + return { + verboseFormat, + isoFormat, + }; + } + public ExpandIfCollapsedSection(sectionName: string) { - cy.get(`.t--property-pane-section-collapse-${sectionName}`).scrollIntoView().then( - ($element) => { + cy.get(`.t--property-pane-section-collapse-${sectionName}`) + .scrollIntoView() + .then(($element) => { cy.wrap($element) .siblings(".bp3-collapse") .then(($sibling) => { @@ -867,7 +902,6 @@ export class Table { $element.click(); } }); - }, - ); + }); } } diff --git a/app/client/src/PluginActionEditor/components/PluginActionForm/PluginActionForm.tsx b/app/client/src/PluginActionEditor/components/PluginActionForm/PluginActionForm.tsx index db91b29b3a2..66ab320a304 100644 --- a/app/client/src/PluginActionEditor/components/PluginActionForm/PluginActionForm.tsx +++ b/app/client/src/PluginActionEditor/components/PluginActionForm/PluginActionForm.tsx @@ -2,13 +2,18 @@ import React from "react"; import APIEditorForm from "./components/APIEditorForm"; import { Flex } from "@appsmith/ads"; import { useChangeActionCall } from "./hooks/useChangeActionCall"; +import { usePluginActionContext } from "../../PluginActionContext"; +import { UIComponentTypes } from "api/PluginApi"; const PluginActionForm = () => { useChangeActionCall(); + const { plugin } = usePluginActionContext(); return ( - + {plugin.uiComponent === UIComponentTypes.ApiEditorForm ? ( + + ) : null} ); }; diff --git a/app/client/src/PluginActionEditor/components/PluginActionForm/components/CommonEditorForm/hooks/useGetFormActionValues.ts b/app/client/src/PluginActionEditor/components/PluginActionForm/components/CommonEditorForm/hooks/useGetFormActionValues.ts index 1078d18282d..da75a1482a2 100644 --- a/app/client/src/PluginActionEditor/components/PluginActionForm/components/CommonEditorForm/hooks/useGetFormActionValues.ts +++ b/app/client/src/PluginActionEditor/components/PluginActionForm/components/CommonEditorForm/hooks/useGetFormActionValues.ts @@ -18,7 +18,7 @@ function useGetFormActionValues() { // In an unlikely scenario where form is not initialised, // return empty values to avoid form ui issues - if (!isAPIAction(formValues)) { + if (!formValues || !isAPIAction(formValues)) { return { actionHeaders: [], actionParams: [], diff --git a/app/client/src/PluginActionEditor/components/PluginActionNameEditor.tsx b/app/client/src/PluginActionEditor/components/PluginActionNameEditor.tsx new file mode 100644 index 00000000000..a51d6006dec --- /dev/null +++ b/app/client/src/PluginActionEditor/components/PluginActionNameEditor.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { useSelector } from "react-redux"; +import ActionNameEditor from "components/editorComponents/ActionNameEditor"; +import { usePluginActionContext } from "PluginActionEditor/PluginActionContext"; +import { useFeatureFlag } from "utils/hooks/useFeatureFlag"; +import { getHasManageActionPermission } from "ee/utils/BusinessFeatures/permissionPageHelpers"; +import { FEATURE_FLAG } from "ee/entities/FeatureFlag"; +import { PluginType } from "entities/Action"; +import type { ReduxAction } from "ee/constants/ReduxActionConstants"; +import styled from "styled-components"; +import { getSavingStatusForActionName } from "selectors/actionSelectors"; +import { getAssetUrl } from "ee/utils/airgapHelpers"; +import { ActionUrlIcon } from "pages/Editor/Explorer/ExplorerIcons"; + +export interface SaveActionNameParams { + id: string; + name: string; +} + +export interface PluginActionNameEditorProps { + saveActionName: ( + params: SaveActionNameParams, + ) => ReduxAction; +} + +const ActionNameEditorWrapper = styled.div` + & .ads-v2-box { + gap: var(--ads-v2-spaces-2); + } + + && .t--action-name-edit-field { + font-size: 12px; + + .bp3-editable-text-content { + height: unset !important; + line-height: unset !important; + } + } + + & .t--plugin-icon-box { + height: 12px; + width: 12px; + + img { + width: 12px; + height: auto; + } + } +`; + +const PluginActionNameEditor = (props: PluginActionNameEditorProps) => { + const { action, plugin } = usePluginActionContext(); + + const isFeatureEnabled = useFeatureFlag(FEATURE_FLAG.license_gac_enabled); + const isChangePermitted = getHasManageActionPermission( + isFeatureEnabled, + action?.userPermissions, + ); + + const saveStatus = useSelector((state) => + getSavingStatusForActionName(state, action?.id || ""), + ); + + const iconUrl = getAssetUrl(plugin?.iconLocation) || ""; + const icon = ActionUrlIcon(iconUrl); + + return ( + + + + ); +}; + +export default PluginActionNameEditor; diff --git a/app/client/src/PluginActionEditor/components/PluginActionResponse/PluginActionResponse.tsx b/app/client/src/PluginActionEditor/components/PluginActionResponse/PluginActionResponse.tsx index f20a395d4cb..9359c8130f3 100644 --- a/app/client/src/PluginActionEditor/components/PluginActionResponse/PluginActionResponse.tsx +++ b/app/client/src/PluginActionEditor/components/PluginActionResponse/PluginActionResponse.tsx @@ -57,7 +57,7 @@ function PluginActionResponse() { expandedHeight={`${ActionExecutionResizerHeight}px`} isCollapsed={!open} onSelect={updateSelectedResponseTab} - selectedTabKey={selectedTab || tabs[0].key} + selectedTabKey={selectedTab || tabs[0]?.key} tabs={tabs} /> diff --git a/app/client/src/PluginActionEditor/index.ts b/app/client/src/PluginActionEditor/index.ts index 20265c8bc5a..e8083e1b0f0 100644 --- a/app/client/src/PluginActionEditor/index.ts +++ b/app/client/src/PluginActionEditor/index.ts @@ -6,3 +6,8 @@ export { export { default as PluginActionToolbar } from "./components/PluginActionToolbar"; export { default as PluginActionForm } from "./components/PluginActionForm"; export { default as PluginActionResponse } from "./components/PluginActionResponse"; +export type { + SaveActionNameParams, + PluginActionNameEditorProps, +} from "./components/PluginActionNameEditor"; +export { default as PluginActionNameEditor } from "./components/PluginActionNameEditor"; diff --git a/app/client/src/components/editorComponents/ActionNameEditor.tsx b/app/client/src/components/editorComponents/ActionNameEditor.tsx index 8467855f479..1d9bf01aa7c 100644 --- a/app/client/src/components/editorComponents/ActionNameEditor.tsx +++ b/app/client/src/components/editorComponents/ActionNameEditor.tsx @@ -1,19 +1,13 @@ import React, { memo } from "react"; -import { useSelector } from "react-redux"; -import { useParams } from "react-router-dom"; import EditableText, { EditInteractionKind, } from "components/editorComponents/EditableText"; import { removeSpecialChars } from "utils/helpers"; -import type { AppState } from "ee/reducers"; -import { saveActionName } from "actions/pluginActionActions"; import { Flex } from "@appsmith/ads"; -import { getActionByBaseId, getPlugin } from "ee/selectors/entitiesSelector"; import NameEditorComponent, { IconBox, - IconWrapper, NameWrapper, } from "components/utils/NameEditorComponent"; import { @@ -21,14 +15,13 @@ import { ACTION_NAME_PLACEHOLDER, createMessage, } from "ee/constants/messages"; -import { getAssetUrl } from "ee/utils/airgapHelpers"; -import { getSavingStatusForActionName } from "selectors/actionSelectors"; import type { ReduxAction } from "ee/constants/ReduxActionConstants"; +import type { SaveActionNameParams } from "PluginActionEditor"; +import { useFeatureFlag } from "utils/hooks/useFeatureFlag"; +import { FEATURE_FLAG } from "ee/entities/FeatureFlag"; +import type { Action } from "entities/Action"; +import type { ModuleInstance } from "ee/constants/ModuleInstanceConstants"; -interface SaveActionNameParams { - id: string; - name: string; -} interface ActionNameEditorProps { /* This prop checks if page is API Pane or Query Pane or Curl Pane @@ -38,38 +31,34 @@ interface ActionNameEditorProps { */ enableFontStyling?: boolean; disabled?: boolean; - saveActionName?: ( + saveActionName: ( params: SaveActionNameParams, ) => ReduxAction; + actionConfig?: Action | ModuleInstance; + icon?: JSX.Element; + saveStatus: { isSaving: boolean; error: boolean }; } function ActionNameEditor(props: ActionNameEditorProps) { - const params = useParams<{ baseApiId?: string; baseQueryId?: string }>(); - - const currentActionConfig = useSelector((state: AppState) => - getActionByBaseId(state, params.baseApiId || params.baseQueryId || ""), - ); - - const currentPlugin = useSelector((state: AppState) => - getPlugin(state, currentActionConfig?.pluginId || ""), - ); + const { + actionConfig, + disabled = false, + enableFontStyling = false, + icon = "", + saveActionName, + saveStatus, + } = props; - const saveStatus = useSelector((state) => - getSavingStatusForActionName(state, currentActionConfig?.id || ""), + const isActionRedesignEnabled = useFeatureFlag( + FEATURE_FLAG.release_actions_redesign_enabled, ); return ( {({ @@ -85,28 +74,22 @@ function ActionNameEditor(props: ActionNameEditorProps) { isNew: boolean; saveStatus: { isSaving: boolean; error: boolean }; }) => ( - + - {currentPlugin && ( - - - - )} + {icon && {icon}} ))} diff --git a/app/client/src/components/utils/NameEditorComponent.tsx b/app/client/src/components/utils/NameEditorComponent.tsx index b7aa2e03ce4..876a9c9d1fd 100644 --- a/app/client/src/components/utils/NameEditorComponent.tsx +++ b/app/client/src/components/utils/NameEditorComponent.tsx @@ -11,6 +11,8 @@ import { } from "ee/constants/messages"; import styled from "styled-components"; import { Classes } from "@blueprintjs/core"; +import type { SaveActionNameParams } from "PluginActionEditor"; +import type { ReduxAction } from "ee/constants/ReduxActionConstants"; export const NameWrapper = styled.div<{ enableFontStyling?: boolean }>` min-width: 50%; @@ -71,9 +73,9 @@ interface NameEditorProps { children: (params: any) => JSX.Element; id?: string; name?: string; - // TODO: Fix this the next time the file is edited - // eslint-disable-next-line @typescript-eslint/no-explicit-any - dispatchAction: (a: any) => any; + onSaveName: ( + params: SaveActionNameParams, + ) => ReduxAction; // TODO: Fix this the next time the file is edited // eslint-disable-next-line @typescript-eslint/no-explicit-any suffixErrorMessage?: (params?: any) => string; @@ -90,10 +92,10 @@ interface NameEditorProps { function NameEditor(props: NameEditorProps) { const { - dispatchAction, id: entityId, idUndefinedErrorMessage, name: entityName, + onSaveName, saveStatus, suffixErrorMessage = ACTION_NAME_CONFLICT_ERROR, } = props; @@ -131,8 +133,8 @@ function NameEditor(props: NameEditorProps) { const handleNameChange = useCallback( (name: string) => { - if (name !== entityName && !isInvalidNameForEntity(name)) { - dispatch(dispatchAction({ id: entityId, name })); + if (name !== entityName && !isInvalidNameForEntity(name) && entityId) { + dispatch(onSaveName({ id: entityId, name })); } }, [dispatch, isInvalidNameForEntity, entityId, entityName], diff --git a/app/client/src/pages/Editor/APIEditor/ApiEditorContext.tsx b/app/client/src/pages/Editor/APIEditor/ApiEditorContext.tsx index a5d3aa5e235..1a2d9d51789 100644 --- a/app/client/src/pages/Editor/APIEditor/ApiEditorContext.tsx +++ b/app/client/src/pages/Editor/APIEditor/ApiEditorContext.tsx @@ -1,11 +1,7 @@ import type { ReduxAction } from "ee/constants/ReduxActionConstants"; import type { PaginationField } from "api/ActionAPI"; import React, { createContext, useMemo } from "react"; - -interface SaveActionNameParams { - id: string; - name: string; -} +import type { SaveActionNameParams } from "PluginActionEditor"; interface ApiEditorContextContextProps { moreActionsMenu?: React.ReactNode; @@ -15,7 +11,7 @@ interface ApiEditorContextContextProps { // TODO: Fix this the next time the file is edited // eslint-disable-next-line @typescript-eslint/no-explicit-any settingsConfig: any; - saveActionName?: ( + saveActionName: ( params: SaveActionNameParams, ) => ReduxAction; closeEditorLink?: React.ReactNode; diff --git a/app/client/src/pages/Editor/APIEditor/CommonEditorForm.tsx b/app/client/src/pages/Editor/APIEditor/CommonEditorForm.tsx index b542cdfbd04..979c4edf1cb 100644 --- a/app/client/src/pages/Editor/APIEditor/CommonEditorForm.tsx +++ b/app/client/src/pages/Editor/APIEditor/CommonEditorForm.tsx @@ -35,6 +35,9 @@ import { InfoFields, RequestTabs, } from "PluginActionEditor/components/PluginActionForm/components/CommonEditorForm"; +import { getSavingStatusForActionName } from "selectors/actionSelectors"; +import { getAssetUrl } from "ee/utils/airgapHelpers"; +import { ActionUrlIcon } from "../Explorer/ExplorerIcons"; const Form = styled.form` position: relative; @@ -245,6 +248,18 @@ function CommonEditorForm(props: CommonFormPropsWithExtraParams) { currentActionConfig?.userPermissions, ); + const currentPlugin = useSelector((state: AppState) => + getPlugin(state, currentActionConfig?.pluginId || ""), + ); + + const saveStatus = useSelector((state) => + getSavingStatusForActionName(state, currentActionConfig?.id || ""), + ); + + const iconUrl = getAssetUrl(currentPlugin?.iconLocation) || ""; + + const icon = ActionUrlIcon(iconUrl); + const plugin = useSelector((state: AppState) => getPlugin(state, pluginId ?? ""), ); @@ -281,9 +296,12 @@ function CommonEditorForm(props: CommonFormPropsWithExtraParams) { diff --git a/app/client/src/pages/Editor/APIEditor/index.tsx b/app/client/src/pages/Editor/APIEditor/index.tsx index 247c2cfd408..31328d25055 100644 --- a/app/client/src/pages/Editor/APIEditor/index.tsx +++ b/app/client/src/pages/Editor/APIEditor/index.tsx @@ -8,7 +8,11 @@ import { getPluginSettingConfigs, getPlugins, } from "ee/selectors/entitiesSelector"; -import { deleteAction, runAction } from "actions/pluginActionActions"; +import { + deleteAction, + runAction, + saveActionName, +} from "actions/pluginActionActions"; import AnalyticsUtil from "ee/utils/AnalyticsUtil"; import Editor from "./Editor"; import BackToCanvas from "components/common/BackToCanvas"; @@ -151,15 +155,7 @@ function ApiEditorWrapper(props: ApiEditorWrapperProps) { }); dispatch(runAction(action?.id ?? "", paginationField)); }, - [ - action?.id, - apiName, - pageName, - getPageName, - plugins, - pluginId, - datasourceId, - ], + [action?.id, apiName, pageName, plugins, pluginId, datasourceId, dispatch], ); const actionRightPaneBackLink = useMemo(() => { @@ -173,13 +169,13 @@ function ApiEditorWrapper(props: ApiEditorWrapperProps) { pageName, }); dispatch(deleteAction({ id: action?.id ?? "", name: apiName })); - }, [getPageName, pages, basePageId, apiName]); + }, [pages, basePageId, apiName, action?.id, dispatch, pageName]); const notification = useMemo(() => { if (!isConverting) return null; return ; - }, [action?.name, isConverting]); + }, [action?.name, isConverting, icon]); const isActionRedesignEnabled = useFeatureFlag( FEATURE_FLAG.release_actions_redesign_enabled, @@ -196,6 +192,7 @@ function ApiEditorWrapper(props: ApiEditorWrapperProps) { handleRunClick={handleRunClick} moreActionsMenu={moreActionsMenu} notification={notification} + saveActionName={saveActionName} settingsConfig={settingsConfig} > diff --git a/app/client/src/pages/Editor/Explorer/Entity/Name.tsx b/app/client/src/pages/Editor/Explorer/Entity/Name.tsx index de937dd86a1..e0fb005bc00 100644 --- a/app/client/src/pages/Editor/Explorer/Entity/Name.tsx +++ b/app/client/src/pages/Editor/Explorer/Entity/Name.tsx @@ -16,6 +16,8 @@ import { import { Tooltip } from "@appsmith/ads"; import { useSelector } from "react-redux"; import { getSavingStatusForActionName } from "selectors/actionSelectors"; +import type { ReduxAction } from "ee/constants/ReduxActionConstants"; +import type { SaveActionNameParams } from "PluginActionEditor"; export const searchHighlightSpanClassName = "token"; export const searchTokenizationDelimiter = "!!"; @@ -84,7 +86,7 @@ export interface EntityNameProps { name: string; isEditing?: boolean; onChange?: (name: string) => void; - updateEntityName: (name: string) => void; + updateEntityName: (name: string) => ReduxAction; entityId: string; searchKeyword?: string; className?: string; @@ -164,10 +166,10 @@ export const EntityName = React.memo( return ( diff --git a/app/client/src/pages/Editor/JSEditor/JSObjectNameEditor.tsx b/app/client/src/pages/Editor/JSEditor/JSObjectNameEditor.tsx index f82860f134b..ff1a598bc1b 100644 --- a/app/client/src/pages/Editor/JSEditor/JSObjectNameEditor.tsx +++ b/app/client/src/pages/Editor/JSEditor/JSObjectNameEditor.tsx @@ -25,11 +25,8 @@ import NameEditorComponent, { } from "components/utils/NameEditorComponent"; import { getSavingStatusForJSObjectName } from "selectors/actionSelectors"; import type { ReduxAction } from "ee/constants/ReduxActionConstants"; +import type { SaveActionNameParams } from "PluginActionEditor"; -export interface SaveActionNameParams { - id: string; - name: string; -} export interface JSObjectNameEditorProps { /* This prop checks if page is API Pane or Query Pane or Curl Pane @@ -64,10 +61,10 @@ export function JSObjectNameEditor(props: JSObjectNameEditorProps) { return ( {({ diff --git a/app/client/src/pages/Editor/QueryEditor/QueryEditorContext.tsx b/app/client/src/pages/Editor/QueryEditor/QueryEditorContext.tsx index 03c46a99d0d..c5089fc6bdf 100644 --- a/app/client/src/pages/Editor/QueryEditor/QueryEditorContext.tsx +++ b/app/client/src/pages/Editor/QueryEditor/QueryEditorContext.tsx @@ -1,18 +1,14 @@ import type { ReduxAction } from "ee/constants/ReduxActionConstants"; +import type { SaveActionNameParams } from "PluginActionEditor"; import React, { createContext, useMemo } from "react"; -interface SaveActionNameParams { - id: string; - name: string; -} - interface QueryEditorContextContextProps { moreActionsMenu?: React.ReactNode; onCreateDatasourceClick?: () => void; onEntityNotFoundBackClick?: () => void; changeQueryPage?: (baseQueryId: string) => void; actionRightPaneBackLink?: React.ReactNode; - saveActionName?: ( + saveActionName: ( params: SaveActionNameParams, ) => ReduxAction; closeEditorLink?: React.ReactNode; diff --git a/app/client/src/pages/Editor/QueryEditor/QueryEditorHeader.tsx b/app/client/src/pages/Editor/QueryEditor/QueryEditorHeader.tsx index af1d28c83a9..38f9a9b3d72 100644 --- a/app/client/src/pages/Editor/QueryEditor/QueryEditorHeader.tsx +++ b/app/client/src/pages/Editor/QueryEditor/QueryEditorHeader.tsx @@ -13,6 +13,7 @@ import { useActiveActionBaseId } from "ee/pages/Editor/Explorer/hooks"; import { useSelector } from "react-redux"; import { getActionByBaseId, + getPlugin, getPluginNameFromId, } from "ee/selectors/entitiesSelector"; import { QueryEditorContext } from "./QueryEditorContext"; @@ -21,6 +22,9 @@ import type { Datasource } from "entities/Datasource"; import type { AppState } from "ee/reducers"; import { SQL_DATASOURCES } from "constants/QueryEditorConstants"; import DatasourceSelector from "./DatasourceSelector"; +import { getSavingStatusForActionName } from "selectors/actionSelectors"; +import { getAssetUrl } from "ee/utils/airgapHelpers"; +import { ActionUrlIcon } from "../Explorer/ExplorerIcons"; const NameWrapper = styled.div` display: flex; @@ -79,6 +83,18 @@ const QueryEditorHeader = (props: Props) => { currentActionConfig?.userPermissions, ); + const currentPlugin = useSelector((state: AppState) => + getPlugin(state, currentActionConfig?.pluginId || ""), + ); + + const saveStatus = useSelector((state) => + getSavingStatusForActionName(state, currentActionConfig?.id || ""), + ); + + const iconUrl = getAssetUrl(currentPlugin?.iconLocation) || ""; + + const icon = ActionUrlIcon(iconUrl); + // get the current action's plugin name const currentActionPluginName = useSelector((state: AppState) => getPluginNameFromId(state, currentActionConfig?.pluginId || ""), @@ -106,8 +122,11 @@ const QueryEditorHeader = (props: Props) => { diff --git a/app/client/src/pages/Editor/QueryEditor/index.tsx b/app/client/src/pages/Editor/QueryEditor/index.tsx index f76cbffe1b5..03457a6228e 100644 --- a/app/client/src/pages/Editor/QueryEditor/index.tsx +++ b/app/client/src/pages/Editor/QueryEditor/index.tsx @@ -42,6 +42,7 @@ import { ENTITY_ICON_SIZE, EntityIcon } from "../Explorer/ExplorerIcons"; import { getIDEViewMode } from "selectors/ideSelectors"; import { EditorViewMode } from "ee/entities/IDE/constants"; import { AppPluginActionEditor } from "../AppPluginActionEditor"; +import { saveActionName } from "actions/pluginActionActions"; type QueryEditorProps = RouteComponentProps; @@ -126,6 +127,7 @@ function QueryEditor(props: QueryEditorProps) { }, [ action?.id, action?.name, + action?.pluginType, isChangePermitted, isDeletePermitted, basePageId, @@ -143,7 +145,7 @@ function QueryEditor(props: QueryEditorProps) { changeQuery({ baseQueryId: baseQueryId, basePageId, applicationId }), ); }, - [basePageId, applicationId], + [basePageId, applicationId, dispatch], ); const onCreateDatasourceClick = useCallback(() => { @@ -159,13 +161,7 @@ function QueryEditor(props: QueryEditorProps) { AnalyticsUtil.logEvent("NAVIGATE_TO_CREATE_NEW_DATASOURCE_PAGE", { entryPoint, }); - }, [ - basePageId, - history, - integrationEditorURL, - DatasourceCreateEntryPoints, - AnalyticsUtil, - ]); + }, [basePageId]); // custom function to return user to integrations page if action is not found const onEntityNotFoundBackClick = useCallback( @@ -176,7 +172,7 @@ function QueryEditor(props: QueryEditorProps) { selectedTab: INTEGRATION_TABS.ACTIVE, }), ), - [basePageId, history, integrationEditorURL], + [basePageId], ); const notification = useMemo(() => { @@ -189,7 +185,7 @@ function QueryEditor(props: QueryEditorProps) { withPadding /> ); - }, [action?.name, isConverting]); + }, [action?.name, isConverting, icon]); const isActionRedesignEnabled = useFeatureFlag( FEATURE_FLAG.release_actions_redesign_enabled, @@ -207,6 +203,7 @@ function QueryEditor(props: QueryEditorProps) { notification={notification} onCreateDatasourceClick={onCreateDatasourceClick} onEntityNotFoundBackClick={onEntityNotFoundBackClick} + saveActionName={saveActionName} > = {}, +) => { + const defaultProps: BaseInputComponentProps = { + value: "", + inputType: "TEXT", + inputHTMLType: "TEXT", + disabled: false, + isLoading: false, + compactMode: false, + isInvalid: false, + label: "Salary", + showError: false, + onValueChange: jest.fn(), + onFocusChange: jest.fn(), + widgetId: "test-widget", + rtl: true, + }; + + return render( + + + , + ); +}; + +describe("BaseInputComponent TestCases", () => { + test("1. Icon should be visible and aligned to the right when the input type is a number", () => { + const { container } = renderBaseInputComponent({ + inputType: "NUMBER", + inputHTMLType: "NUMBER", + value: "123", + onStep: jest.fn(), + rtl: false, + shouldUseLocale: true, + iconName: "add", + iconAlign: "right", + }); + + const numericInputIcon = container.getElementsByClassName( + "bp3-icon bp3-icon-add", + )[0]; + + expect(numericInputIcon).toBeInTheDocument(); + }); +}); diff --git a/app/client/src/widgets/BaseInputWidget/component/index.tsx b/app/client/src/widgets/BaseInputWidget/component/index.tsx index d898b5b73e4..cbb7b21599a 100644 --- a/app/client/src/widgets/BaseInputWidget/component/index.tsx +++ b/app/client/src/widgets/BaseInputWidget/component/index.tsx @@ -565,6 +565,7 @@ class BaseInputComponent extends React.Component< onKeyUp={this.onKeyUp} onValueChange={this.onNumberChange} placeholder={this.props.placeholder} + rightElement={this.getRightIcon()} stepSize={this.props.stepSize} value={this.props.value} {...conditionalProps} diff --git a/app/client/src/widgets/TableWidgetV2/component/cellComponents/DateCell.tsx b/app/client/src/widgets/TableWidgetV2/component/cellComponents/DateCell.tsx index 17f5c679bf4..8e34d928d86 100644 --- a/app/client/src/widgets/TableWidgetV2/component/cellComponents/DateCell.tsx +++ b/app/client/src/widgets/TableWidgetV2/component/cellComponents/DateCell.tsx @@ -9,7 +9,11 @@ import DateComponent from "widgets/DatePickerWidget2/component"; import { TimePrecision } from "widgets/DatePickerWidget2/constants"; import type { RenderDefaultPropsType } from "./PlainTextCell"; import styled from "styled-components"; -import { EditableCellActions } from "widgets/TableWidgetV2/constants"; +import { + DateInputFormat, + EditableCellActions, + MomentDateInputFormat, +} from "widgets/TableWidgetV2/constants"; import { ISO_DATE_FORMAT } from "constants/WidgetValidation"; import moment from "moment"; import { BasicCell } from "./BasicCell"; @@ -196,6 +200,19 @@ export const DateCell = (props: DateComponentProps) => { const [isValid, setIsValid] = useState(true); const [showRequiredError, setShowRequiredError] = useState(false); const contentRef = useRef(null); + + const convertInputFormatToMomentFormat = (inputFormat: string) => { + let momentAdjustedInputFormat = inputFormat; + + if (inputFormat === DateInputFormat.MILLISECONDS) { + momentAdjustedInputFormat = MomentDateInputFormat.MILLISECONDS; + } else if (inputFormat === DateInputFormat.EPOCH) { + momentAdjustedInputFormat = MomentDateInputFormat.SECONDS; + } + + return momentAdjustedInputFormat; + }; + const isCellCompletelyValid = useMemo( () => isEditableCellValid && isValid, [isEditableCellValid, isValid], @@ -218,8 +235,15 @@ export const DateCell = (props: DateComponentProps) => { }, [value, props.outputFormat]); const onDateSelected = (date: string) => { + const momentAdjustedInputFormat = + convertInputFormatToMomentFormat(inputFormat); + + const formattedDate = date + ? moment(date).format(momentAdjustedInputFormat) + : ""; + if (isNewRow) { - updateNewRowValues(alias, date, date); + updateNewRowValues(alias, date, formattedDate); return; } @@ -235,8 +259,6 @@ export const DateCell = (props: DateComponentProps) => { setShowRequiredError(false); setHasFocus(false); - const formattedDate = date ? moment(date).format(inputFormat) : ""; - onDateSave(rowIndex, alias, formattedDate, onDateSelectedString); }; diff --git a/app/client/src/widgets/TableWidgetV2/constants.ts b/app/client/src/widgets/TableWidgetV2/constants.ts index 0e066c83f9a..8fb0e8605aa 100644 --- a/app/client/src/widgets/TableWidgetV2/constants.ts +++ b/app/client/src/widgets/TableWidgetV2/constants.ts @@ -219,6 +219,11 @@ export enum DateInputFormat { MILLISECONDS = "Milliseconds", } +export enum MomentDateInputFormat { + MILLISECONDS = "x", + SECONDS = "X", +} + export const defaultEditableCell: EditableCell = { column: "", index: -1, diff --git a/app/client/src/widgets/TableWidgetV2/widget/reactTableUtils/TransformDataPureFn.test.ts b/app/client/src/widgets/TableWidgetV2/widget/reactTableUtils/TransformDataPureFn.test.ts new file mode 100644 index 00000000000..d95d92df657 --- /dev/null +++ b/app/client/src/widgets/TableWidgetV2/widget/reactTableUtils/TransformDataPureFn.test.ts @@ -0,0 +1,54 @@ +import type { ReactTableColumnProps } from "widgets/TableWidgetV2/component/Constants"; +import { + columns, + columnsNonDate, + expectedDataNonDate, + tableDataNonDate, +} from "./fixtures"; +import { transformDataPureFn } from "./transformDataPureFn"; + +describe("transformDataPureFn", () => { + it("should handle invalid date values", () => { + const invalidTableData = [ + { + epoch: "invalid_epoch", + milliseconds: "invalid_milliseconds", + iso_8601: "invalid_iso_8601", + yyyy_mm_dd: "invalid_date", + lll: "invalid_date", + }, + ]; + + const expectedInvalidData = [ + { + epoch: "Invalid date", + milliseconds: "Invalid date", + iso_8601: "8601-01-01", + yyyy_mm_dd: "Invalid date", + lll: "Invalid date", + }, + ]; + + const result = transformDataPureFn( + invalidTableData, + columns as ReactTableColumnProps[], + ); + + expect(result).toEqual(expectedInvalidData); + }); + + it("should return an empty array when tableData is empty", () => { + const result = transformDataPureFn([], columns as ReactTableColumnProps[]); + + expect(result).toEqual([]); + }); + + it("should not transform non-date data", () => { + const result = transformDataPureFn( + tableDataNonDate, + columnsNonDate as ReactTableColumnProps[], + ); + + expect(result).toEqual(expectedDataNonDate); + }); +}); diff --git a/app/client/src/widgets/TableWidgetV2/widget/reactTableUtils/fixtures.ts b/app/client/src/widgets/TableWidgetV2/widget/reactTableUtils/fixtures.ts new file mode 100644 index 00000000000..75355f2f4b9 --- /dev/null +++ b/app/client/src/widgets/TableWidgetV2/widget/reactTableUtils/fixtures.ts @@ -0,0 +1,145 @@ +import { ColumnTypes, DateInputFormat } from "widgets/TableWidgetV2/constants"; + +// Mock columns data +export const columns = [ + { + alias: "epoch", + metaProperties: { + type: ColumnTypes.DATE, + inputFormat: DateInputFormat.EPOCH, + format: "YYYY-MM-DD", + }, + }, + { + alias: "milliseconds", + metaProperties: { + type: ColumnTypes.DATE, + inputFormat: DateInputFormat.MILLISECONDS, + format: "YYYY-MM-DD", + }, + }, + { + alias: "iso_8601", + metaProperties: { + type: ColumnTypes.DATE, + inputFormat: "YYYY-MM-DDTHH:mm:ss.SSSZ", + format: "YYYY-MM-DD", + }, + }, + { + alias: "yyyy_mm_dd", + metaProperties: { + type: ColumnTypes.DATE, + inputFormat: "YYYY-MM-DD", + format: "YYYY-MM-DD", + }, + }, + { + alias: "lll", + metaProperties: { + type: ColumnTypes.DATE, + inputFormat: "LLL", + format: "YYYY-MM-DD", + }, + }, +]; + +// Mock table data +export const tableData = [ + { + epoch: 1727132400, + milliseconds: 1727132400000, + iso_8601: "2024-09-24T00:00:00.000+01:00", + yyyy_mm_dd: "2024-09-24", + lll: "September 25, 2024 12:00 AM", + }, + { + epoch: 1726980974, + milliseconds: 1726980974328, + iso_8601: "2024-09-23T09:01:53.350627", + yyyy_mm_dd: "2024-09-23", + lll: "Sep 23, 2024 09:01", + }, +]; + +// Expected transformed data +export const expectedData = [ + { + epoch: "2024-09-24", // Converted from epoch to date + milliseconds: "2024-09-24", // Converted from milliseconds to date + iso_8601: "2024-09-24", // ISO 8601 to date + yyyy_mm_dd: "2024-09-24", // No transformation needed + lll: "2024-09-25", // LLL format to date + }, + { + epoch: "2024-09-22", // Converted from epoch to date + milliseconds: "2024-09-22", // Converted from milliseconds to date + iso_8601: "2024-09-23", // ISO 8601 to date + yyyy_mm_dd: "2024-09-23", // No transformation needed + lll: "2024-09-23", // LLL format to date + }, +]; + +// Mock columns for non-date data +export const columnsNonDate = [ + { + id: "role", + alias: "role", + metaProperties: { + type: ColumnTypes.NUMBER, + format: "", + inputFormat: "", + decimals: 0, + }, + }, + { + id: "id", + alias: "id", + metaProperties: { + type: ColumnTypes.NUMBER, + format: "", + inputFormat: "", + decimals: 0, + }, + }, + { + id: "name", + alias: "name", + metaProperties: { + type: ColumnTypes.TEXT, + format: "", + inputFormat: "", + decimals: 0, + }, + }, +]; + +// Mock table data for non-date transformation +export const tableDataNonDate = [ + { + role: 1, + id: 1, + name: "Alice Johnson", + __originalIndex__: 0, + }, + { + role: 2, + id: 2, + name: "Bob Smith", + __originalIndex__: 1, + }, +]; + +// Expected transformed data for non-date columns +export const expectedDataNonDate = [ + { + role: 1, + id: 1, + name: "Alice Johnson", + }, + { + role: 2, + id: 2, + name: "Bob Smith", + }, +]; diff --git a/app/client/src/widgets/TableWidgetV2/widget/reactTableUtils/transformDataPureFn.tsx b/app/client/src/widgets/TableWidgetV2/widget/reactTableUtils/transformDataPureFn.tsx index 4432568c620..1454694ce8b 100644 --- a/app/client/src/widgets/TableWidgetV2/widget/reactTableUtils/transformDataPureFn.tsx +++ b/app/client/src/widgets/TableWidgetV2/widget/reactTableUtils/transformDataPureFn.tsx @@ -1,7 +1,7 @@ import log from "loglevel"; import type { MomentInput } from "moment"; import moment from "moment"; -import _, { isNumber, isNil, isArray } from "lodash"; +import _, { isNil, isArray } from "lodash"; import type { EditableCell } from "../../constants"; import { ColumnTypes, DateInputFormat } from "../../constants"; import type { ReactTableColumnProps } from "../../component/Constants"; @@ -10,7 +10,6 @@ import shallowEqual from "shallowequal"; export type tableData = Array>; -//TODO: (Vamsi) need to unit test this function export const transformDataPureFn = ( tableData: Array>, columns: ReactTableColumnProps[], @@ -45,8 +44,6 @@ export const transformDataPureFn = ( ) { inputFormat = type; moment(value as MomentInput, inputFormat); - } else if (!isNumber(value)) { - isValidDate = false; } } catch (e) { isValidDate = false; diff --git a/app/client/src/workers/Evaluation/handlers/jsLibrary.ts b/app/client/src/workers/Evaluation/handlers/jsLibrary.ts index bdf704f2bab..ce81c2d7bf5 100644 --- a/app/client/src/workers/Evaluation/handlers/jsLibrary.ts +++ b/app/client/src/workers/Evaluation/handlers/jsLibrary.ts @@ -128,39 +128,31 @@ export async function installLibrary( // Find keys add that were installed to the global scope. const keysAfterInstallation = Object.keys(self); - const differentiatingKeys = difference( + let differentiatingKeys = difference( keysAfterInstallation, envKeysBeforeInstallation, ); - if ( - differentiatingKeys.length > 0 && - differentiatingKeys.includes("default") - ) { - // Changing default export to library specific name - const uniqueName = generateUniqueAccessor( - url, - takenAccessors, - takenNamesMap, - ); + // Changing default export to library specific name, if default exported + const uniqueName = generateUniqueAccessor( + url, + takenAccessors, + takenNamesMap, + ); - // mapping default functionality to library name accessor - self[uniqueName] = self["default"]; - // deleting the reference of default key from the self object - delete self["default"]; - // mapping all the references of differentiating keys from the self object to the self[uniqueName] key object - differentiatingKeys.map((key) => { - if (key !== "default") { - self[uniqueName][key] = self[key]; - // deleting the references from the self object - delete self[key]; - } - }); - // pushing the uniqueName to the accessor array - accessors.push(uniqueName); - } else { - accessors.push(...differentiatingKeys); - } + movetheDefaultExportedLibraryToAccessorKey( + differentiatingKeys, + uniqueName, + ); + + // Following the same process which was happening earlier + const keysAfterDefaultOperation = Object.keys(self); + + differentiatingKeys = difference( + keysAfterDefaultOperation, + envKeysBeforeInstallation, + ); + accessors.push(...differentiatingKeys); /** * Check the list of installed library to see if their values have changed. @@ -308,7 +300,18 @@ export async function loadLibraries( try { self.importScripts(url); const keysAfter = Object.keys(self); - const defaultAccessors = difference(keysAfter, keysBefore); + let defaultAccessors = difference(keysAfter, keysBefore); + + // Changing default export to library accessors name which was saved when it was installed, if default export present + movetheDefaultExportedLibraryToAccessorKey( + defaultAccessors, + accessors[0], + ); + + // Following the same process which was happening earlier + const keysAfterDefaultOperation = Object.keys(self); + + defaultAccessors = difference(keysAfterDefaultOperation, keysBefore); /** * Installing 2 different version of lodash tries to add the same accessor on the self object. Let take version a & b for example. @@ -447,3 +450,24 @@ export function flattenModule(module: Record) { return libModule; } + +// This function will update the self keys only when the diffAccessors has default included in it. +function movetheDefaultExportedLibraryToAccessorKey( + diffAccessors: string[], + uniqAccessor: string, +) { + if (diffAccessors.length > 0 && diffAccessors.includes("default")) { + // mapping default functionality to library name accessor + self[uniqAccessor] = self["default"]; + // deleting the reference of default key from the self object + delete self["default"]; + // mapping all the references of differentiating keys from the self object to the self[uniqAccessor] key object + diffAccessors.map((key) => { + if (key !== "default") { + self[uniqAccessor][key] = self[key]; + // deleting the references from the self object + delete self[key]; + } + }); + } +} diff --git a/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/constants/PluginConstants.java b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/constants/PluginConstants.java index 7c52a719d02..960834f9064 100644 --- a/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/constants/PluginConstants.java +++ b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/constants/PluginConstants.java @@ -24,6 +24,7 @@ interface PackageName { String APPSMITH_AI_PLUGIN = "appsmithai-plugin"; String DATABRICKS_PLUGIN = "databricks-plugin"; String AWS_LAMBDA_PLUGIN = "aws-lambda-plugin"; + String MONGO_PLUGIN = "mongo-plugin"; } public static final String DEFAULT_REST_DATASOURCE = "DEFAULT_REST_DATASOURCE"; diff --git a/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/enums/FeatureFlagEnum.java b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/enums/FeatureFlagEnum.java index a654857ec80..e3e96745784 100644 --- a/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/enums/FeatureFlagEnum.java +++ b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/enums/FeatureFlagEnum.java @@ -15,5 +15,9 @@ public enum FeatureFlagEnum { release_embed_hide_share_settings_enabled, rollout_datasource_test_rate_limit_enabled, + // Deprecated CE flags over here + release_git_autocommit_feature_enabled, + release_git_autocommit_eligibility_enabled, + // Add EE flags below this line, to avoid conflicts. } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/DatasourcePluginContext.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/DatasourcePluginContext.java new file mode 100644 index 00000000000..b3385964e2d --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/DatasourcePluginContext.java @@ -0,0 +1,20 @@ +package com.appsmith.server.domains; + +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.time.Instant; + +@Getter +@Setter +@ToString +public class DatasourcePluginContext { + private T connection; + private String pluginId; + private Instant creationTime; + + public DatasourcePluginContext() { + creationTime = Instant.now(); + } +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/DatasourceContextServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/DatasourceContextServiceCEImpl.java index 3693d1348c4..adc1d2ea82b 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/DatasourceContextServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/DatasourceContextServiceCEImpl.java @@ -13,6 +13,7 @@ import com.appsmith.server.datasourcestorages.base.DatasourceStorageService; import com.appsmith.server.domains.DatasourceContext; import com.appsmith.server.domains.DatasourceContextIdentifier; +import com.appsmith.server.domains.DatasourcePluginContext; import com.appsmith.server.domains.Plugin; import com.appsmith.server.exceptions.AppsmithError; import com.appsmith.server.exceptions.AppsmithException; @@ -20,6 +21,10 @@ import com.appsmith.server.plugins.base.PluginService; import com.appsmith.server.services.ConfigService; import com.appsmith.server.solutions.DatasourcePermission; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.RemovalListener; +import com.google.common.cache.RemovalNotification; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; @@ -29,8 +34,12 @@ import java.time.Instant; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import java.util.function.Function; +import static java.lang.Boolean.FALSE; +import static java.lang.Boolean.TRUE; + @Slf4j public class DatasourceContextServiceCEImpl implements DatasourceContextServiceCE { @@ -38,6 +47,21 @@ public class DatasourceContextServiceCEImpl implements DatasourceContextServiceC protected final Map>> datasourceContextMonoMap; protected final Map datasourceContextSynchronizationMonitorMap; protected final Map> datasourceContextMap; + + /** + * This cache is used to store the datasource context for a limited time and a limited max number of connections and + * then destroy the least recently used connection. The cleanup process is triggered when the cache is accessed and + * either the time limit or the max connections are reached. + * The purpose of this is to prevent the large number of open dangling connections to the movies mockDB. + * The removalListener method is called when the connection is removed from the cache. + */ + protected final Cache datasourcePluginContextMapLRUCache = + CacheBuilder.newBuilder() + .removalListener(createRemovalListener()) + .expireAfterAccess(2, TimeUnit.HOURS) + .maximumSize(300) // caches most recently used 300 mock connections per pod + .build(); + private final DatasourceService datasourceService; private final DatasourceStorageService datasourceStorageService; private final PluginService pluginService; @@ -67,6 +91,50 @@ public DatasourceContextServiceCEImpl( this.datasourcePermission = datasourcePermission; } + private RemovalListener createRemovalListener() { + return (RemovalNotification removalNotification) -> { + handleRemoval(removalNotification); + }; + } + + private Object getConnectionFromDatasourceContextMap(DatasourceContextIdentifier datasourceContextIdentifier) { + return this.datasourceContextMap.containsKey(datasourceContextIdentifier) + && this.datasourceContextMap.get(datasourceContextIdentifier) != null + ? this.datasourceContextMap.get(datasourceContextIdentifier).getConnection() + : null; + } + + private void handleRemoval( + RemovalNotification removalNotification) { + final DatasourceContextIdentifier datasourceContextIdentifier = removalNotification.getKey(); + final DatasourcePluginContext datasourcePluginContext = removalNotification.getValue(); + + log.debug( + "Removing Datasource Context from cache and closing the open connection for DatasourceId: {} and environmentId: {}", + datasourceContextIdentifier.getDatasourceId(), + datasourceContextIdentifier.getEnvironmentId()); + log.info("LRU Cache Size after eviction: {}", datasourcePluginContextMapLRUCache.size()); + + // Close connection and remove entry from both cache maps + final Object connection = getConnectionFromDatasourceContextMap(datasourceContextIdentifier); + + Mono pluginMono = + pluginService.findById(datasourcePluginContext.getPluginId()).cache(); + if (connection != null) { + pluginExecutorHelper + .getPluginExecutor(pluginMono) + .flatMap(pluginExecutor -> Mono.fromRunnable(() -> pluginExecutor.datasourceDestroy(connection))) + .onErrorResume(e -> { + log.error("Error destroying stale datasource connection", e); + return Mono.empty(); + }) + .subscribe(); // Trigger the execution + } + // Remove the entries from both maps + datasourceContextMonoMap.remove(datasourceContextIdentifier); + datasourceContextMap.remove(datasourceContextIdentifier); + } + /** * This method defines a critical section that can be executed only by one thread at a time per datasource id - i * .e. if two threads want to create datasource for different datasource ids then they would not be synchronized. @@ -115,6 +183,11 @@ public Mono> getCachedDatasourceContextMono( } datasourceContextMonoMap.remove(datasourceContextIdentifier); datasourceContextMap.remove(datasourceContextIdentifier); + log.info( + "Invalidating the LRU cache entry for datasource id {}, environment id {} as the connection is stale or in error state", + datasourceContextIdentifier.getDatasourceId(), + datasourceContextIdentifier.getEnvironmentId()); + datasourcePluginContextMapLRUCache.invalidate(datasourceContextIdentifier); } /* @@ -129,17 +202,13 @@ public Mono> getCachedDatasourceContextMono( + ": Cached resource context mono exists for datasource id {}, environment id {}. Returning the same.", datasourceContextIdentifier.getDatasourceId(), datasourceContextIdentifier.getEnvironmentId()); + // Accessing the LRU cache to update the last accessed time + datasourcePluginContextMapLRUCache.getIfPresent(datasourceContextIdentifier); return datasourceContextMonoMap.get(datasourceContextIdentifier); } /* Create a fresh datasource context */ DatasourceContext datasourceContext = new DatasourceContext<>(); - if (datasourceContextIdentifier.isKeyValid() && shouldCacheContextForThisPlugin(plugin)) { - /* For this datasource, either the context doesn't exist, or the context is stale. Replace (or add) with - the new connection in the context map. */ - datasourceContextMap.put(datasourceContextIdentifier, datasourceContext); - } - Mono connectionMonoCache = pluginExecutor .datasourceCreate(datasourceStorage.getDatasourceConfiguration()) .cache(); @@ -159,15 +228,34 @@ public Mono> getCachedDatasourceContextMono( datasourceContext) .cache(); /* Cache the value so that further evaluations don't result in new connections */ - if (datasourceContextIdentifier.isKeyValid() && shouldCacheContextForThisPlugin(plugin)) { - datasourceContextMonoMap.put(datasourceContextIdentifier, datasourceContextMonoCache); - } - log.debug( - Thread.currentThread().getName() - + ": Cached new datasource context for datasource id {}, environment id {}", - datasourceContextIdentifier.getDatasourceId(), - datasourceContextIdentifier.getEnvironmentId()); - return datasourceContextMonoCache; + return connectionMonoCache + .flatMap(connection -> { + datasourceContext.setConnection(connection); + if (datasourceContextIdentifier.isKeyValid() + && shouldCacheContextForThisPlugin(plugin)) { + datasourceContextMap.put(datasourceContextIdentifier, datasourceContext); + datasourceContextMonoMap.put( + datasourceContextIdentifier, datasourceContextMonoCache); + + if (TRUE.equals(datasourceStorage.getIsMock()) + && PluginConstants.PackageName.MONGO_PLUGIN.equals( + plugin.getPackageName())) { + log.info( + "Datasource is a mock mongo DB. Adding the connection to LRU cache!"); + DatasourcePluginContext datasourcePluginContext = + new DatasourcePluginContext<>(); + datasourcePluginContext.setConnection(datasourceContext.getConnection()); + datasourcePluginContext.setPluginId(plugin.getId()); + datasourcePluginContextMapLRUCache.put( + datasourceContextIdentifier, datasourcePluginContext); + log.info( + "LRU Cache Size after adding: {}", + datasourcePluginContextMapLRUCache.size()); + } + } + return datasourceContextMonoCache; + }) + .switchIfEmpty(datasourceContextMonoCache); } }) .flatMap(obj -> obj) @@ -195,7 +283,7 @@ public Mono updateDatasourceAndSetAuthentication(Object connection, Data .setAuthentication(updatableConnection.getAuthenticationDTO( datasourceStorage.getDatasourceConfiguration().getAuthentication())); datasourceStorageMono = datasourceStorageService.updateDatasourceStorage( - datasourceStorage, datasourceStorage.getEnvironmentId(), Boolean.FALSE, false); + datasourceStorage, datasourceStorage.getEnvironmentId(), FALSE, false); } return datasourceStorageMono.thenReturn(connection); } @@ -308,6 +396,8 @@ public Mono> getDatasourceContext(DatasourceStorage datasou } else { if (isValidDatasourceContextAvailable(datasourceStorage, datasourceContextIdentifier)) { log.debug("Resource context exists. Returning the same."); + // Accessing the LRU cache to update the last accessed time + datasourcePluginContextMapLRUCache.getIfPresent(datasourceContextIdentifier); return Mono.just(datasourceContextMap.get(datasourceContextIdentifier)); } } @@ -399,7 +489,11 @@ public Mono> deleteDatasourceContext(DatasourceStorage data log.info("Clearing datasource context for datasource storage ID {}.", datasourceStorage.getId()); pluginExecutor.datasourceDestroy(datasourceContext.getConnection()); datasourceContextMonoMap.remove(datasourceContextIdentifier); - + log.info( + "Invalidating the LRU cache entry for datasource id {}, environment id {} as delete datasource context is invoked", + datasourceContextIdentifier.getDatasourceId(), + datasourceContextIdentifier.getEnvironmentId()); + datasourcePluginContextMapLRUCache.invalidate(datasourceContextIdentifier); if (!datasourceContextMap.containsKey(datasourceContextIdentifier)) { log.info( "datasourceContextMap does not contain any entry for datasource storage with id: {} ", diff --git a/deploy/docker/fs/opt/appsmith/caddy-reconfigure.mjs b/deploy/docker/fs/opt/appsmith/caddy-reconfigure.mjs index f265b2da71e..75d5e9296f9 100644 --- a/deploy/docker/fs/opt/appsmith/caddy-reconfigure.mjs +++ b/deploy/docker/fs/opt/appsmith/caddy-reconfigure.mjs @@ -146,7 +146,7 @@ parts.push(` ${isRateLimitingEnabled ? `rate_limit { zone dynamic_zone { - key {http.request.remote_ip} + key {http.request.client_ip} events ${RATE_LIMIT} window 1s } diff --git a/deploy/docker/fs/opt/appsmith/pg-utils.sh b/deploy/docker/fs/opt/appsmith/pg-utils.sh new file mode 100755 index 00000000000..315446f552d --- /dev/null +++ b/deploy/docker/fs/opt/appsmith/pg-utils.sh @@ -0,0 +1,92 @@ +#!/bin/bash + +waitForPostgresAvailability() { + if [ -z "$PG_DB_HOST" ]; then + tlog "PostgreSQL host name is empty. Check env variables. Error. Exiting java setup" + exit 2 + else + + MAX_RETRIES=50 + RETRYSECONDS=10 + retry_count=0 + while true; do + su postgres -c "pg_isready -h '${PG_DB_HOST}' -p '${PG_DB_PORT}'" + status=$? + + case $status in + 0) + tlog "PostgreSQL host '$PG_DB_HOST' is ready." + break + ;; + 1) + tlog "PostgreSQL host '$PG_DB_HOST' is rejecting connections e.g. due to being in recovery mode or not accepting connections eg. connections maxed out." + ;; + 2) + tlog "PostgreSQL host '$PG_DB_HOST' is not responding or running." + ;; + 3) + tlog "The connection check failed e.g. due to network issues or incorrect parameters." + ;; + *) + tlog "pg_isready exited with unexpected status code: $status" + break + ;; + esac + + retry_count=$((retry_count + 1)) + if [ $retry_count -le $MAX_RETRIES ]; then + tlog "PostgreSQL connection failed. Retrying attempt $retry_count/$MAX_RETRIES in $RETRYSECONDS seconds..." + sleep $RETRYSECONDS + else + tlog "Exceeded maximum retry attempts ($MAX_RETRIES). Exiting." + # use exit code 2 to indicate that the script failed to connect to postgres and supervisor conf is set not to restart the program for 2. + exit 2 + fi + + done + fi +} + +# for PostgreSQL, we use APPSMITH_DB_URL=postgresql://username:password@postgresserver:5432/dbname +# Args: +# conn_string (string): PostgreSQL connection string +# Returns: +# None +# Example: +# postgres syntax +# "postgresql://user:password@localhost:5432/appsmith" +# "postgresql://user:password@localhost/appsmith" +# "postgresql://user@localhost:5432/appsmith" +# "postgresql://user@localhost/appsmith" +extract_postgres_db_params() { + local conn_string=$1 + + # Use node to parse the URI and extract components + IFS=' ' read -r USER PASSWORD HOST PORT DB <<<"$(node -e " + const connectionString = process.argv[1]; + const pgUri = connectionString.startsWith(\"postgresql://\") + ? connectionString + : 'http://' + connectionString; //Prepend a fake scheme for URL parsing + const url = require('url'); + const parsedUrl = new url.URL(pgUri); + + // Extract the pathname and remove the leading '/' + const db = parsedUrl.pathname.substring(1); + + // Default the port to 5432 if it's empty + const port = parsedUrl.port || '5432'; + + console.log(\`\${parsedUrl.username || '-'} \${parsedUrl.password || '-'} \${parsedUrl.hostname} \${port} \${db}\`); + " "$conn_string")" + + # Now, set the environment variables + export PG_DB_USER="$USER" + export PG_DB_PASSWORD="$PASSWORD" + export PG_DB_HOST="$HOST" + export PG_DB_PORT="$PORT" + export PG_DB_NAME="$DB" +} + +# Example usage of the functions +# waitForPostgresAvailability +# extract_postgres_db_params "postgresql://user:password@localhost:5432/dbname" \ No newline at end of file diff --git a/deploy/docker/fs/opt/appsmith/run-java.sh b/deploy/docker/fs/opt/appsmith/run-java.sh index 675c8e26511..ed88e26e119 100755 --- a/deploy/docker/fs/opt/appsmith/run-java.sh +++ b/deploy/docker/fs/opt/appsmith/run-java.sh @@ -1,5 +1,8 @@ #!/bin/bash +# Source the helper script +source pg-utils.sh + set -o errexit set -o pipefail set -o nounset @@ -29,6 +32,12 @@ match-proxy-url() { [[ -n $proxy_host ]] } +# Extract the database parameters from the APPSMITH_DB_URL and wait for the database to be available +if [[ "$mode" == "pg" ]]; then + extract_postgres_db_params "$APPSMITH_DB_URL" + waitForPostgresAvailability +fi + if match-proxy-url "${HTTP_PROXY-}"; then extra_args+=(-Dhttp.proxyHost="$proxy_host" -Dhttp.proxyPort="$proxy_port") if [[ -n $proxy_user ]]; then diff --git a/deploy/docker/tests/test-pg-utils.sh b/deploy/docker/tests/test-pg-utils.sh new file mode 100755 index 00000000000..56bec144560 --- /dev/null +++ b/deploy/docker/tests/test-pg-utils.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash + +set -e + +# Include the script to be tested +source /Users/appsmith/Work/appsmith-ce/deploy/docker/fs/opt/appsmith/pg-utils.sh + +assert_equals() { + if [ "$1" != "$2" ]; then + echo "Assertion failed: expected '$2', but got '$1'" + return 1 + fi +} + +# Test extract_postgres_db_params function +test_extract_postgres_db_params_valid_db_string() { + local conn_string="postgresql://user:password@localhost:5432/dbname" + extract_postgres_db_params "$conn_string" + + if [ "$PG_DB_USER" != "user" ] || [ "$PG_DB_PASSWORD" != "password" ] || [ "$PG_DB_HOST" != "localhost" ] || [ "$PG_DB_PORT" != "5432" ] || [ "$PG_DB_NAME" != "dbname" ]; then + echo "Test failed: test_extract_postgres_db_params_valid_db_string did not extract parameters correctly" + echo_params + exit 1 + fi + + echo "Test passed: test_extract_postgres_db_params_valid_db_string" +} + +test_extract_postgres_db_params_empty_dbname() { + local conn_string="postgresql://user:password@localhost:5432" + extract_postgres_db_params "$conn_string" + + if [ "$PG_DB_USER" != "user" ] || [ "$PG_DB_PASSWORD" != "password" ] || [ "$PG_DB_HOST" != "localhost" ] || [ "$PG_DB_PORT" != "5432" ] || [ "$PG_DB_NAME" != "" ]; then + echo "Test failed: test_extract_postgres_db_params_empty_dbname did not extract parameters correctly" + echo_params + exit 1 + fi + + echo "Test passed: test_extract_postgres_db_params_empty_dbname" +} + +test_extract_postgres_db_params_with_spaces() { + local conn_string="postgresql://user:p a s s w o r d@localhost:5432/db_name" + extract_postgres_db_params "$conn_string" + + if [ "$PG_DB_USER" != "user" ] || [ "$PG_DB_PASSWORD" != "p%20a%20s%20s%20w%20o%20r%20d" ] || [ "$PG_DB_HOST" != "localhost" ] || [ "$PG_DB_PORT" != "5432" ] || [ "$PG_DB_NAME" != "db_name" ]; then + echo "Test failed: test_extract_postgres_db_params_with_spaces did not extract parameters correctly" + echo_params + exit 1 + fi + + echo "Test passed: test_extract_postgres_db_params_with_spaces" +} + +echo_params() { + echo "PG_DB_USER: $PG_DB_USER" + echo "PG_DB_PASSWORD: $PG_DB_PASSWORD" + echo "PG_DB_HOST: $PG_DB_HOST" + echo "PG_DB_PORT: $PG_DB_PORT" + echo "PG_DB_NAME: $PG_DB_NAME" +} + +# Run tests +test_extract_postgres_db_params_valid_db_string +test_extract_postgres_db_params_empty_dbname +test_extract_postgres_db_params_with_spaces + +echo "All Tests Pass!" \ No newline at end of file