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 index 0ce8bc321a3..312ebb7c946 100644 --- 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 @@ -43,17 +43,16 @@ describe( // Click unix cell edit table.ClickOnEditIcon(row, column); - // Click on specific date within + // Click on a specific date within the view port of the date picker + // Date picker opens in september 2024 due to the Table data set agHelper.GetNClick( - `${table._dateInputPopover} [aria-label='${table.getFormattedTomorrowDates().verboseFormat}']`, + `${table._dateInputPopover} [aria-label='Thu Sep 26 2024']`, ); - // Check that date is set in column + // Check that the date is set in column table .ReadTableRowColumnData(row, column, "v2") - .then((val) => - expect(val).to.equal(table.getFormattedTomorrowDates().isoFormat), - ); + .then((val) => expect(val).to.equal("2024-09-26")); }; it("1. should allow inline editing of Unix Timestamp in seconds (unix/s)", () => { diff --git a/app/client/cypress/support/Pages/Table.ts b/app/client/cypress/support/Pages/Table.ts index 45dd2132ded..d1439a58927 100644 --- a/app/client/cypress/support/Pages/Table.ts +++ b/app/client/cypress/support/Pages/Table.ts @@ -854,38 +854,4 @@ export class Table { this.agHelper.GetHoverNClick(selector, 1, true); 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, - }; - } } diff --git a/app/client/packages/rts/package.json b/app/client/packages/rts/package.json index e75769c5e5e..53c429b9e2d 100644 --- a/app/client/packages/rts/package.json +++ b/app/client/packages/rts/package.json @@ -15,6 +15,9 @@ "start": "./start-server.sh" }, "dependencies": { + "@opentelemetry/instrumentation-http": "^0.53.0", + "@opentelemetry/sdk-trace-node": "^1.26.0", + "@opentelemetry/semantic-conventions": "^1.27.0", "@shared/ast": "workspace:^", "axios": "^1.7.4", "express": "^4.20.0", diff --git a/app/client/packages/rts/src/instrumentation.ts b/app/client/packages/rts/src/instrumentation.ts new file mode 100644 index 00000000000..cdc892a3b9e --- /dev/null +++ b/app/client/packages/rts/src/instrumentation.ts @@ -0,0 +1,51 @@ +import { + BatchSpanProcessor, + NodeTracerProvider, +} from "@opentelemetry/sdk-trace-node"; +import { Resource } from "@opentelemetry/resources"; +import { + ATTR_DEPLOYMENT_NAME, + ATTR_SERVICE_INSTANCE_ID, +} from "@opentelemetry/semantic-conventions/incubating"; +import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; +import { diag, DiagConsoleLogger, DiagLogLevel } from "@opentelemetry/api"; +import { registerInstrumentations } from "@opentelemetry/instrumentation"; +import { HttpInstrumentation } from "@opentelemetry/instrumentation-http"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; + +const provider = new NodeTracerProvider({ + resource: new Resource({ + [ATTR_DEPLOYMENT_NAME]: `${process.env.APPSMITH_DEPLOYMENT_NAME || "self-hosted"}`, + [ATTR_SERVICE_INSTANCE_ID]: `${process.env.HOSTNAME || "appsmith-0"}`, + [ATTR_SERVICE_NAME]: "rts", + }), +}); + +const nrTracesExporter = new OTLPTraceExporter({ + url: `${process.env.APPSMITH_NEW_RELIC_OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`, + headers: { + "api-key": `${process.env.APPSMITH_NEW_RELIC_OTLP_LICENSE_KEY}`, + }, +}); + +registerInstrumentations({ + instrumentations: [new HttpInstrumentation()], +}); + +const batchSpanProcessor = new BatchSpanProcessor( + nrTracesExporter, + //Optional BatchSpanProcessor Configurations + { + // The maximum queue size. After the size is reached spans are dropped. + maxQueueSize: 100, + // The maximum batch size of every export. It must be smaller or equal to maxQueueSize. + maxExportBatchSize: 50, + // The interval between two consecutive exports + scheduledDelayMillis: 500, + // How long the export can run before it is cancelled + exportTimeoutMillis: 30000, + }, +); + +provider.addSpanProcessor(batchSpanProcessor); +provider.register(); diff --git a/app/client/packages/rts/src/server.ts b/app/client/packages/rts/src/server.ts index e0b0c3765df..b4d77949d4f 100644 --- a/app/client/packages/rts/src/server.ts +++ b/app/client/packages/rts/src/server.ts @@ -1,3 +1,4 @@ +import "./instrumentation"; import http from "http"; import express from "express"; import { Server } from "socket.io"; diff --git a/app/client/src/PluginActionEditor/components/PluginActionForm/components/APIEditorForm.tsx b/app/client/src/PluginActionEditor/components/PluginActionForm/components/APIEditorForm.tsx index 9c046e9119c..a3065ba2e5e 100644 --- a/app/client/src/PluginActionEditor/components/PluginActionForm/components/APIEditorForm.tsx +++ b/app/client/src/PluginActionEditor/components/PluginActionForm/components/APIEditorForm.tsx @@ -10,13 +10,17 @@ import { FEATURE_FLAG } from "ee/entities/FeatureFlag"; import { getHasManageActionPermission } from "ee/utils/BusinessFeatures/permissionPageHelpers"; import Pagination from "pages/Editor/APIEditor/Pagination"; import { reduxForm } from "redux-form"; -import { useHandleRunClick } from "PluginActionEditor/hooks"; +import { + useHandleRunClick, + useAnalyticsOnRunClick, +} from "PluginActionEditor/hooks"; const FORM_NAME = API_EDITOR_FORM_NAME; const APIEditorForm = () => { const { action } = usePluginActionContext(); const { handleRunClick } = useHandleRunClick(); + const { callRunActionAnalytics } = useAnalyticsOnRunClick(); const theme = EditorTheme.LIGHT; const isFeatureEnabled = useFeatureFlag(FEATURE_FLAG.license_gac_enabled); @@ -25,6 +29,11 @@ const APIEditorForm = () => { action.userPermissions, ); + const onTestClick = () => { + callRunActionAnalytics(); + handleRunClick(); + }; + return ( { paginationUiComponent={ diff --git a/app/client/src/PluginActionEditor/components/PluginActionForm/components/GraphQLEditor/PostBodyData.tsx b/app/client/src/PluginActionEditor/components/PluginActionForm/components/GraphQLEditor/PostBodyData.tsx index f897d38246e..21104245270 100644 --- a/app/client/src/PluginActionEditor/components/PluginActionForm/components/GraphQLEditor/PostBodyData.tsx +++ b/app/client/src/PluginActionEditor/components/PluginActionForm/components/GraphQLEditor/PostBodyData.tsx @@ -1,107 +1,87 @@ -import React, { useCallback, useRef } from "react"; +import React from "react"; import styled from "styled-components"; -import QueryEditor from "pages/Editor/APIEditor/GraphQL/QueryEditor"; -import VariableEditor from "pages/Editor/APIEditor/GraphQL/VariableEditor"; -import useHorizontalResize from "utils/hooks/useHorizontalResize"; -import { EditorTheme } from "components/editorComponents/CodeEditor/EditorConfig"; -import classNames from "classnames"; -import { tailwindLayers } from "constants/Layers"; - -const ResizableDiv = styled.div` - display: flex; - height: 100%; - flex-shrink: 0; -`; +import { + CodeEditorBorder, + EditorModes, + EditorSize, + EditorTheme, + TabBehaviour, +} from "components/editorComponents/CodeEditor/EditorConfig"; +import DynamicTextField from "components/editorComponents/form/fields/DynamicTextField"; +import { Section, Zone } from "pages/Editor/ActionForm"; +import { AutocompleteDataType } from "utils/autocomplete/AutocompleteDataType"; +import FormLabel from "components/editorComponents/FormLabel"; const PostBodyContainer = styled.div` - display: flex; - height: 100%; - overflow: hidden; &&&& .CodeMirror { - height: 100%; - border-top: 1px solid var(--ads-v2-color-border); - border-bottom: 1px solid var(--ads-v2-color-border); - border-radius: 0; - padding: 0; - } - & .CodeMirror-scroll { - margin: 0px; - padding: 0px; - overflow: auto !important; + height: auto; + min-height: 250px; } `; -const ResizerHandler = styled.div<{ resizing: boolean }>` - width: 6px; - height: 100%; - margin-left: 2px; - border-right: 1px solid var(--ads-v2-color-border); - background: ${(props) => - props.resizing ? "var(--ads-v2-color-border)" : "transparent"}; - &:hover { - background: var(--ads-v2-color-border); - border-color: transparent; +const StyledFormLabel = styled(FormLabel)` + && { + margin-bottom: var(--ads-v2-spaces-2); + padding: 0; } `; -const DEFAULT_GRAPHQL_VARIABLE_WIDTH = 300; - interface Props { actionName: string; } +const EXPECTED_VARIABLE = { + type: "object", + example: + '{\n "name":"{{ inputName.property }}",\n "preference":"{{ dropdownName.property }}"\n}', + autocompleteDataType: AutocompleteDataType.OBJECT, +}; + function PostBodyData(props: Props) { const { actionName } = props; const theme = EditorTheme.LIGHT; - const resizeableRef = useRef(null); - const [variableEditorWidth, setVariableEditorWidth] = React.useState( - DEFAULT_GRAPHQL_VARIABLE_WIDTH, - ); - /** - * Variable Editor's resizeable handler for the changing of width - */ - const onVariableEditorWidthChange = useCallback((newWidth) => { - setVariableEditorWidth(newWidth); - }, []); - - const { onMouseDown, onMouseUp, onTouchStart, resizing } = - useHorizontalResize( - resizeableRef, - onVariableEditorWidthChange, - undefined, - true, - ); return ( - -
- -
- - - +
+ +
+ Query + +
+
+ +
+ Query variables + +
+
+
); } diff --git a/app/client/src/PluginActionEditor/components/PluginActionToolbar.tsx b/app/client/src/PluginActionEditor/components/PluginActionToolbar.tsx index c41db285b30..5c0362be319 100644 --- a/app/client/src/PluginActionEditor/components/PluginActionToolbar.tsx +++ b/app/client/src/PluginActionEditor/components/PluginActionToolbar.tsx @@ -1,9 +1,13 @@ -import React from "react"; +import React, { useCallback } from "react"; import { IDEToolbar } from "IDE"; import { Button, Menu, MenuContent, MenuTrigger, Tooltip } from "@appsmith/ads"; import { modText } from "utils/helpers"; import { usePluginActionContext } from "../PluginActionContext"; -import { useBlockExecution, useHandleRunClick } from "PluginActionEditor/hooks"; +import { + useBlockExecution, + useHandleRunClick, + useAnalyticsOnRunClick, +} from "PluginActionEditor/hooks"; import { useToggle } from "@mantine/hooks"; import { useSelector } from "react-redux"; import { isActionRunning } from "PluginActionEditor/store"; @@ -17,13 +21,15 @@ interface PluginActionToolbarProps { const PluginActionToolbar = (props: PluginActionToolbarProps) => { const { action } = usePluginActionContext(); const { handleRunClick } = useHandleRunClick(); + const { callRunActionAnalytics } = useAnalyticsOnRunClick(); const [isMenuOpen, toggleMenuOpen] = useToggle([false, true]); const blockExecution = useBlockExecution(); const isRunning = useSelector(isActionRunning(action.id)); - const onRunClick = () => { + const onRunClick = useCallback(() => { + callRunActionAnalytics(); handleRunClick(); - }; + }, [callRunActionAnalytics, handleRunClick]); return ( diff --git a/app/client/src/PluginActionEditor/hooks/index.ts b/app/client/src/PluginActionEditor/hooks/index.ts index 493227f5cd2..c61530a08d8 100644 --- a/app/client/src/PluginActionEditor/hooks/index.ts +++ b/app/client/src/PluginActionEditor/hooks/index.ts @@ -2,3 +2,4 @@ export { useActionSettingsConfig } from "ee/PluginActionEditor/hooks/useActionSe export { useHandleDeleteClick } from "ee/PluginActionEditor/hooks/useHandleDeleteClick"; export { useHandleRunClick } from "ee/PluginActionEditor/hooks/useHandleRunClick"; export { useBlockExecution } from "ee/PluginActionEditor/hooks/useBlockExecution"; +export { useAnalyticsOnRunClick } from "ee/PluginActionEditor/hooks/useAnalyticsOnRunClick"; diff --git a/app/client/src/actions/pageActions.tsx b/app/client/src/actions/pageActions.tsx index 51a10762d29..22cfe873e30 100644 --- a/app/client/src/actions/pageActions.tsx +++ b/app/client/src/actions/pageActions.tsx @@ -68,25 +68,23 @@ export const fetchPageAction = ( export interface FetchPublishedPageActionPayload { pageId: string; bustCache?: boolean; - firstLoad?: boolean; pageWithMigratedDsl?: FetchPageResponse; } export interface FetchPublishedPageResourcesPayload { pageId: string; + basePageId: string; } export const fetchPublishedPageAction = ( pageId: string, bustCache = false, - firstLoad = false, pageWithMigratedDsl?: FetchPageResponse, ): ReduxAction => ({ type: ReduxActionTypes.FETCH_PUBLISHED_PAGE_INIT, payload: { pageId, bustCache, - firstLoad, pageWithMigratedDsl, }, }); @@ -299,12 +297,14 @@ export const clonePageSuccess = ({ // Fetches resources required for published page, currently only used for fetching actions // In future we can reuse this for fetching other page level resources in published mode -export const fetchPublishedPageResourcesAction = ( - pageId: string, -): ReduxAction => ({ +export const fetchPublishedPageResources = ({ + basePageId, + pageId, +}: FetchPublishedPageResourcesPayload): ReduxAction => ({ type: ReduxActionTypes.FETCH_PUBLISHED_PAGE_RESOURCES_INIT, payload: { pageId, + basePageId, }, }); @@ -675,21 +675,18 @@ export const setupPageAction = ( export interface SetupPublishedPageActionPayload { pageId: string; bustCache: boolean; - firstLoad: boolean; pageWithMigratedDsl?: FetchPageResponse; } export const setupPublishedPage = ( pageId: string, bustCache = false, - firstLoad = false, pageWithMigratedDsl?: FetchPageResponse, ): ReduxAction => ({ type: ReduxActionTypes.SETUP_PUBLISHED_PAGE_INIT, payload: { pageId, bustCache, - firstLoad, pageWithMigratedDsl, }, }); diff --git a/app/client/src/ce/PluginActionEditor/components/PluginActionResponse/hooks/usePluginActionResponseTabs.tsx b/app/client/src/ce/PluginActionEditor/components/PluginActionResponse/hooks/usePluginActionResponseTabs.tsx index 55000398a92..4b8f04d18a5 100644 --- a/app/client/src/ce/PluginActionEditor/components/PluginActionResponse/hooks/usePluginActionResponseTabs.tsx +++ b/app/client/src/ce/PluginActionEditor/components/PluginActionResponse/hooks/usePluginActionResponseTabs.tsx @@ -29,13 +29,18 @@ import Schema from "components/editorComponents/Debugger/Schema"; import QueryResponseTab from "pages/Editor/QueryEditor/QueryResponseTab"; import type { SourceEntity } from "entities/AppsmithConsole"; import { ENTITY_TYPE as SOURCE_ENTITY_TYPE } from "ee/entities/AppsmithConsole/utils"; -import { useBlockExecution, useHandleRunClick } from "PluginActionEditor/hooks"; +import { + useBlockExecution, + useHandleRunClick, + useAnalyticsOnRunClick, +} from "PluginActionEditor/hooks"; import useDebuggerTriggerClick from "components/editorComponents/Debugger/hooks/useDebuggerTriggerClick"; function usePluginActionResponseTabs() { const { action, actionResponse, datasource, plugin } = usePluginActionContext(); const { handleRunClick } = useHandleRunClick(); + const { callRunActionAnalytics } = useAnalyticsOnRunClick(); const IDEViewMode = useSelector(getIDEViewMode); const errorCount = useSelector(getErrorCount); @@ -52,6 +57,7 @@ function usePluginActionResponseTabs() { const tabs: BottomTab[] = []; const onRunClick = () => { + callRunActionAnalytics(); handleRunClick(); }; diff --git a/app/client/src/ce/PluginActionEditor/hooks/useAnalyticsOnRunClick.ts b/app/client/src/ce/PluginActionEditor/hooks/useAnalyticsOnRunClick.ts new file mode 100644 index 00000000000..421b9ebcc63 --- /dev/null +++ b/app/client/src/ce/PluginActionEditor/hooks/useAnalyticsOnRunClick.ts @@ -0,0 +1,33 @@ +import { useCallback } from "react"; +import { useSelector } from "react-redux"; +import { usePluginActionContext } from "PluginActionEditor/PluginActionContext"; +import { getPageNameByPageId } from "ee/selectors/entitiesSelector"; +import AnalyticsUtil from "ee/utils/AnalyticsUtil"; + +function useAnalyticsOnRunClick() { + const { action, datasource, plugin } = usePluginActionContext(); + const pageName = useSelector((state) => + getPageNameByPageId(state, action.pageId), + ); + + const actionId = action.id; + const actionName = action.name; + const datasourceId = datasource?.id; + const pluginName = plugin.name; + const isMock = !!datasource?.isMock || false; // as mock db exists only for postgres and mongo plugins + + const callRunActionAnalytics = useCallback(() => { + AnalyticsUtil.logEvent("RUN_ACTION_CLICK", { + actionId, + actionName, + datasourceId, + pageName, + pluginName, + isMock, + }); + }, [actionId, actionName, datasourceId, pageName, pluginName, isMock]); + + return { callRunActionAnalytics }; +} + +export { useAnalyticsOnRunClick }; diff --git a/app/client/src/ce/PluginActionEditor/hooks/useHandleRunClick.ts b/app/client/src/ce/PluginActionEditor/hooks/useHandleRunClick.ts index b44c80f5c61..62d8075bcbd 100644 --- a/app/client/src/ce/PluginActionEditor/hooks/useHandleRunClick.ts +++ b/app/client/src/ce/PluginActionEditor/hooks/useHandleRunClick.ts @@ -1,8 +1,8 @@ +import { useCallback } from "react"; +import { useDispatch } from "react-redux"; import { runAction } from "actions/pluginActionActions"; import type { PaginationField } from "api/ActionAPI"; import { usePluginActionContext } from "PluginActionEditor/PluginActionContext"; -import { useCallback } from "react"; -import { useDispatch } from "react-redux"; function useHandleRunClick() { const { action } = usePluginActionContext(); diff --git a/app/client/src/ce/constants/ReduxActionConstants.tsx b/app/client/src/ce/constants/ReduxActionConstants.tsx index 5d9a68d41c5..3d344202b8a 100644 --- a/app/client/src/ce/constants/ReduxActionConstants.tsx +++ b/app/client/src/ce/constants/ReduxActionConstants.tsx @@ -985,6 +985,8 @@ const AppViewActionTypes = { SET_APP_VIEWER_HEADER_HEIGHT: "SET_APP_VIEWER_HEADER_HEIGHT", SET_APP_SIDEBAR_PINNED: "SET_APP_SIDEBAR_PINNED", FETCH_PUBLISHED_PAGE_RESOURCES_INIT: "FETCH_PUBLISHED_PAGE_RESOURCES_INIT", + FETCH_PUBLISHED_PAGE_RESOURCES_SUCCESS: + "FETCH_PUBLISHED_PAGE_RESOURCES_SUCCESS", }; const AppViewActionErrorTypes = { diff --git a/app/client/src/ce/sagas/PageSagas.tsx b/app/client/src/ce/sagas/PageSagas.tsx index 12e52ba930c..aeee2a7b60c 100644 --- a/app/client/src/ce/sagas/PageSagas.tsx +++ b/app/client/src/ce/sagas/PageSagas.tsx @@ -325,12 +325,47 @@ export function* fetchPageSaga(action: ReduxAction) { } } +export function* updateCanvasLayout(response: FetchPageResponse) { + // Wait for widget config to load before we can get the canvas payload + yield call(waitForWidgetConfigBuild); + // Get Canvas payload + const canvasWidgetsPayload = getCanvasWidgetsPayload(response); + + // resize main canvas + resizePublishedMainCanvasToLowestWidget(canvasWidgetsPayload.widgets); + // Update the canvas + yield put(initCanvasLayout(canvasWidgetsPayload)); + + // Since new page has new layout, we need to generate a data structure + // to compute dynamic height based on the new layout. + yield put(generateAutoHeightLayoutTreeAction(true, true)); +} + +export function* postFetchedPublishedPage( + response: FetchPageResponse, + pageId: string, +) { + // set current page + yield put( + updateCurrentPage( + pageId, + response.data.slug, + response.data.userPermissions, + ), + ); + // Clear any existing caches + yield call(clearEvalCache); + // Set url params + yield call(setDataUrl); + + yield call(updateCanvasLayout, response); +} + export function* fetchPublishedPageSaga( action: ReduxAction, ) { try { - const { bustCache, firstLoad, pageId, pageWithMigratedDsl } = - action.payload; + const { bustCache, pageId, pageWithMigratedDsl } = action.payload; const params = { pageId, bustCache }; const response: FetchPageResponse = yield call( @@ -342,41 +377,9 @@ export function* fetchPublishedPageSaga( const isValidResponse: boolean = yield validateResponse(response); if (isValidResponse) { - // Clear any existing caches - yield call(clearEvalCache); - // Set url params - yield call(setDataUrl); - // Wait for widget config to load before we can get the canvas payload - yield call(waitForWidgetConfigBuild); - // Get Canvas payload - const canvasWidgetsPayload = getCanvasWidgetsPayload(response); - - // resize main canvas - resizePublishedMainCanvasToLowestWidget(canvasWidgetsPayload.widgets); - // Update the canvas - yield put(initCanvasLayout(canvasWidgetsPayload)); - // set current page - yield put( - updateCurrentPage( - pageId, - response.data.slug, - response.data.userPermissions, - ), - ); + yield call(postFetchedPublishedPage, response, pageId); - // dispatch fetch page success yield put(fetchPublishedPageSuccess()); - - // Since new page has new layout, we need to generate a data structure - // to compute dynamic height based on the new layout. - yield put(generateAutoHeightLayoutTreeAction(true, true)); - - /* Currently, All Actions are fetched in initSagas and on pageSwitch we only fetch page - */ - // Hence, if is not isFirstLoad then trigger evaluation with execute pageLoad action - if (!firstLoad) { - yield put(fetchAllPageEntityCompletion([executePageLoadActions()])); - } } } catch (error) { yield put({ @@ -392,9 +395,9 @@ export function* fetchPublishedPageResourcesSaga( action: ReduxAction, ) { try { - const { pageId } = action.payload; + const { basePageId, pageId } = action.payload; - const params = { defaultPageId: pageId }; + const params = { defaultPageId: basePageId }; const initConsolidatedApiResponse: ApiResponse = yield ConsolidatedPageLoadApi.getConsolidatedPageLoadDataView(params); @@ -410,10 +413,18 @@ export function* fetchPublishedPageResourcesSaga( // In future, we can reuse this saga to fetch other resources of the page like actionCollections etc const { publishedActions } = response; - // Sending applicationId as empty as we have publishedActions present, - // it won't call the actions view api with applicationId + yield call( + postFetchedPublishedPage, + response.pageWithMigratedDsl, + pageId, + ); + + // NOTE: fetchActionsForView is used here to update publishedActions in redux store and not to fetch actions again yield put(fetchActionsForView({ applicationId: "", publishedActions })); yield put(fetchAllPageEntityCompletion([executePageLoadActions()])); + yield put({ + type: ReduxActionTypes.FETCH_PUBLISHED_PAGE_RESOURCES_SUCCESS, + }); } } catch (error) { yield put({ @@ -1425,21 +1436,13 @@ export function* setupPublishedPageSaga( action: ReduxAction, ) { try { - const { bustCache, firstLoad, pageId, pageWithMigratedDsl } = - action.payload; + const { bustCache, pageId, pageWithMigratedDsl } = action.payload; /* Added the first line for isPageSwitching redux state to be true when page is being fetched to fix scroll position issue. Added the second line for sync call instead of async (due to first line) as it was leading to issue with on page load actions trigger. */ - yield put( - fetchPublishedPageAction( - pageId, - bustCache, - firstLoad, - pageWithMigratedDsl, - ), - ); + yield put(fetchPublishedPageAction(pageId, bustCache, pageWithMigratedDsl)); yield take(ReduxActionTypes.FETCH_PUBLISHED_PAGE_SUCCESS); yield put({ diff --git a/app/client/src/ce/sagas/__tests__/PageSaga.test.ts b/app/client/src/ce/sagas/__tests__/PageSaga.test.ts index 96dc534cc27..4fb55f74d31 100644 --- a/app/client/src/ce/sagas/__tests__/PageSaga.test.ts +++ b/app/client/src/ce/sagas/__tests__/PageSaga.test.ts @@ -47,7 +47,6 @@ describe("ce/PageSaga", () => { pageWithMigratedDsl: mockResponse.data .pageWithMigratedDsl as FetchPageResponse, bustCache: false, - firstLoad: true, }, }; @@ -57,7 +56,6 @@ describe("ce/PageSaga", () => { fetchPublishedPageAction( action.payload.pageId, action.payload.bustCache, - action.payload.firstLoad, action.payload.pageWithMigratedDsl, ), ) diff --git a/app/client/src/ce/utils/analyticsUtilTypes.ts b/app/client/src/ce/utils/analyticsUtilTypes.ts index ec84c03efdf..9bb8ea464f5 100644 --- a/app/client/src/ce/utils/analyticsUtilTypes.ts +++ b/app/client/src/ce/utils/analyticsUtilTypes.ts @@ -48,9 +48,7 @@ export type EventName = | "DELETE_SAAS" | "RUN_SAAS_API" | "SAVE_API_CLICK" - | "RUN_API" | "RUN_API_CLICK" - | "RUN_API_SHORTCUT" | "DELETE_API" | "IMPORT_API" | "EXPAND_API" @@ -59,9 +57,8 @@ export type EventName = | "ADD_API_PAGE" | "DUPLICATE_ACTION" | "DUPLICATE_ACTION_CLICK" - | "RUN_QUERY" | "RUN_QUERY_CLICK" - | "RUN_QUERY_SHORTCUT" + | "RUN_ACTION_CLICK" | "DELETE_QUERY" | "MOVE_API" | "3P_PROVIDER_CLICK" diff --git a/app/client/src/ee/PluginActionEditor/hooks/useAnalyticsOnRunClick.ts b/app/client/src/ee/PluginActionEditor/hooks/useAnalyticsOnRunClick.ts new file mode 100644 index 00000000000..c77f70fc9d9 --- /dev/null +++ b/app/client/src/ee/PluginActionEditor/hooks/useAnalyticsOnRunClick.ts @@ -0,0 +1 @@ +export * from "ce/PluginActionEditor/hooks/useAnalyticsOnRunClick"; diff --git a/app/client/src/entities/Engine/AppViewerEngine.ts b/app/client/src/entities/Engine/AppViewerEngine.ts index d93d7b6cb1a..e4cca1b7db6 100644 --- a/app/client/src/entities/Engine/AppViewerEngine.ts +++ b/app/client/src/entities/Engine/AppViewerEngine.ts @@ -105,7 +105,7 @@ export default class AppViewerEngine extends AppEngine { }), fetchSelectedAppThemeAction(applicationId, currentTheme), fetchAppThemesAction(applicationId, themes), - setupPublishedPage(toLoadPageId, true, true, pageWithMigratedDsl), + setupPublishedPage(toLoadPageId, true, pageWithMigratedDsl), ]; const successActionEffects = [ diff --git a/app/client/src/pages/AppViewer/index.tsx b/app/client/src/pages/AppViewer/index.tsx index bde4699342c..abe1ebbf388 100644 --- a/app/client/src/pages/AppViewer/index.tsx +++ b/app/client/src/pages/AppViewer/index.tsx @@ -28,10 +28,7 @@ import { useSelector } from "react-redux"; import BrandingBadge from "./BrandingBadge"; import { setAppViewHeaderHeight } from "actions/appViewActions"; import { CANVAS_SELECTOR } from "constants/WidgetConstants"; -import { - setupPublishedPage, - fetchPublishedPageResourcesAction, -} from "actions/pageActions"; +import { fetchPublishedPageResources } from "actions/pageActions"; import usePrevious from "utils/hooks/usePrevious"; import { getIsBranchUpdated } from "../utils"; import { APP_MODE } from "entities/App"; @@ -165,10 +162,12 @@ function AppViewer(props: Props) { )?.pageId; if (pageId) { - dispatch(setupPublishedPage(pageId, true)); - - // Used for fetching page resources - dispatch(fetchPublishedPageResourcesAction(basePageId)); + dispatch( + fetchPublishedPageResources({ + basePageId, + pageId, + }), + ); } } } diff --git a/app/client/src/pages/Editor/APIEditor/CommonEditorForm.tsx b/app/client/src/pages/Editor/APIEditor/CommonEditorForm.tsx index 71baf3d547d..80a1748ad0c 100644 --- a/app/client/src/pages/Editor/APIEditor/CommonEditorForm.tsx +++ b/app/client/src/pages/Editor/APIEditor/CommonEditorForm.tsx @@ -111,6 +111,7 @@ const Wrapper = styled.div` flex-direction: row; height: 100%; position: relative; + overflow: hidden; `; const MainContainer = styled.div` diff --git a/app/client/src/pages/Editor/APIEditor/Editor.tsx b/app/client/src/pages/Editor/APIEditor/Editor.tsx index 5ce681b23e0..1e6fab13010 100644 --- a/app/client/src/pages/Editor/APIEditor/Editor.tsx +++ b/app/client/src/pages/Editor/APIEditor/Editor.tsx @@ -229,9 +229,10 @@ class ApiEditor extends React.Component { const formStyles: CSSProperties = { position: "relative", - height: "100%", display: "flex", flexDirection: "column", + flexGrow: "1", + overflow: "auto", }; // TODO: Fix this the next time the file is edited diff --git a/app/client/src/pages/Editor/APIEditor/GraphQL/QueryEditor.tsx b/app/client/src/pages/Editor/APIEditor/GraphQL/QueryEditor.tsx deleted file mode 100644 index 53948e1520b..00000000000 --- a/app/client/src/pages/Editor/APIEditor/GraphQL/QueryEditor.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React from "react"; -import { Field } from "redux-form"; -// // Codemirror GraphQL plugins -import "codemirror-graphql/hint"; -import "codemirror-graphql/info"; -import "codemirror-graphql/jump"; -import "codemirror-graphql/mode"; - -import QueryWrapper from "./QueryWrapperWithCSS"; -import type { EditorTheme } from "components/editorComponents/CodeEditor/EditorConfig"; -import { - CodeEditorBorder, - EditorModes, - EditorSize, - TabBehaviour, -} from "components/editorComponents/CodeEditor/EditorConfig"; -import styled from "styled-components"; -import { Text, TextType } from "@appsmith/ads-old"; -import LazyCodeEditor from "components/editorComponents/LazyCodeEditor"; - -const QueryHeader = styled(Text)` - background: var(--ads-v2-color-bg-subtle); - padding: 8px 16px; -`; - -interface QueryProps { - // Path to store the value in the actual data object - dataTreePath: string; - // Height for the editor - height: string; - // Name of the field of the form - name: string; - // Theme to be used in CodeEditor - theme: EditorTheme; -} - -/** - * Query Editor is for writing Graphql query using the Codemirror Editor which we use - * @param props Props that are required by the CodeEditor to render the query editor - * @returns Component with Editor - */ -function QueryEditor(props: QueryProps) { - const editorProps = { - mode: EditorModes.GRAPHQL_WITH_BINDING, - tabBehaviour: TabBehaviour.INDENT, - size: EditorSize.EXTENDED, - showLineNumbers: true, - }; - - return ( - - - Query - - - - ); -} - -export default QueryEditor; diff --git a/app/client/src/pages/Editor/APIEditor/GraphQL/VariableEditor.tsx b/app/client/src/pages/Editor/APIEditor/GraphQL/VariableEditor.tsx deleted file mode 100644 index 89a3d3b96d3..00000000000 --- a/app/client/src/pages/Editor/APIEditor/GraphQL/VariableEditor.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import React from "react"; -import type { EditorTheme } from "components/editorComponents/CodeEditor/EditorConfig"; -import { - CodeEditorBorder, - EditorModes, - EditorSize, - TabBehaviour, -} from "components/editorComponents/CodeEditor/EditorConfig"; -import styled from "styled-components"; -import { Text, TextType } from "@appsmith/ads-old"; -import DynamicTextField from "components/editorComponents/form/fields/DynamicTextField"; -import { AutocompleteDataType } from "utils/autocomplete/AutocompleteDataType"; - -const VariableWrapper = styled.div` - display: flex; - flex-direction: column; - width: 100%; - flex-shrink: 0; - &&&&& .CodeMirror { - border: 0px; - } - &&& .CodeMirror-gutters { - background: var(--ads-v2-color-bg-subtle); - } -`; - -const VariableHeader = styled(Text)` - background: var(--ads-v2-color-bg-subtle); - padding: 8px 16px; -`; - -const EXPECTED_VARIABLE = { - type: "object", - example: - '{\n "name":"{{ inputName.property }}",\n "preference":"{{ dropdownName.property }}"\n}', - autocompleteDataType: AutocompleteDataType.OBJECT, -}; - -interface VariableProps { - // Name of the action to define the path to the config property - actionName: string; - // Theme to be used in CodeEditor - theme: EditorTheme; -} - -/** - * Variable Editor is for writing Graphql variables using the Codemirror Editor which we use for JSON - * @param props Props that are required by the CodeEditor to render the variable editor - * @returns Component with Editor - */ -function VariableEditor(props: VariableProps) { - return ( - - - Query variables - - - - ); -} - -export default VariableEditor; diff --git a/app/client/src/pages/Editor/ActionForm/Section/index.tsx b/app/client/src/pages/Editor/ActionForm/Section/index.tsx index 937ad0b631d..7b664e11960 100644 --- a/app/client/src/pages/Editor/ActionForm/Section/index.tsx +++ b/app/client/src/pages/Editor/ActionForm/Section/index.tsx @@ -5,11 +5,13 @@ import styles from "./styles.module.css"; interface SectionProps extends React.HTMLAttributes { children: React.ReactNode; isStandalone?: boolean; + isFullWidth?: boolean; } const Section: React.FC = ({ children, className, + isFullWidth = false, isStandalone = false, ...props }) => { @@ -18,6 +20,7 @@ const Section: React.FC = ({ return (
diff --git a/app/client/src/pages/Editor/ActionForm/Section/styles.module.css b/app/client/src/pages/Editor/ActionForm/Section/styles.module.css index e3d2a2d2f30..fb03da7ffbd 100644 --- a/app/client/src/pages/Editor/ActionForm/Section/styles.module.css +++ b/app/client/src/pages/Editor/ActionForm/Section/styles.module.css @@ -13,4 +13,8 @@ &[data-standalone="false"]:not(:last-child) { border-bottom: 1px solid var(--ads-v2-color-border); } + + &[data-fullwidth="true"] { + max-width: none; + } } diff --git a/app/client/src/reducers/uiReducers/appViewReducer.tsx b/app/client/src/reducers/uiReducers/appViewReducer.tsx index 1d3671392f2..05ffcdbfcad 100644 --- a/app/client/src/reducers/uiReducers/appViewReducer.tsx +++ b/app/client/src/reducers/uiReducers/appViewReducer.tsx @@ -26,6 +26,11 @@ const appViewReducer = createReducer(initialState, { [ReduxActionTypes.FETCH_PUBLISHED_PAGE_INIT]: (state: AppViewReduxState) => { return { ...state, isFetchingPage: true }; }, + [ReduxActionTypes.FETCH_PUBLISHED_PAGE_RESOURCES_INIT]: ( + state: AppViewReduxState, + ) => { + return { ...state, isFetchingPage: true }; + }, [ReduxActionErrorTypes.FETCH_PUBLISHED_PAGE_ERROR]: ( state: AppViewReduxState, ) => { @@ -44,6 +49,14 @@ const appViewReducer = createReducer(initialState, { isFetchingPage: false, }; }, + [ReduxActionTypes.FETCH_PUBLISHED_PAGE_RESOURCES_SUCCESS]: ( + state: AppViewReduxState, + ) => { + return { + ...state, + isFetchingPage: false, + }; + }, [ReduxActionTypes.SET_APP_VIEWER_HEADER_HEIGHT]: ( state: AppViewReduxState, action: ReduxAction, diff --git a/app/client/src/workers/Evaluation/evalTreeWithChanges.test.ts b/app/client/src/workers/Evaluation/evalTreeWithChanges.test.ts index 3227e290ecc..929aa0a3638 100644 --- a/app/client/src/workers/Evaluation/evalTreeWithChanges.test.ts +++ b/app/client/src/workers/Evaluation/evalTreeWithChanges.test.ts @@ -277,6 +277,11 @@ describe("evaluateAndGenerateResponse", () => { [], [], ); + + expect(webworkerResponse.workerResponse.dependencies).toEqual({ + "Text1.text": ["Text2.text", "Text1"], + "Text2.text": ["Text2"], + }); const parsedUpdates = getParsedUpdatesFromWebWorkerResp(webworkerResponse); diff --git a/app/client/src/workers/Evaluation/evalTreeWithChanges.ts b/app/client/src/workers/Evaluation/evalTreeWithChanges.ts index 8b75815c5ba..bc561de0df5 100644 --- a/app/client/src/workers/Evaluation/evalTreeWithChanges.ts +++ b/app/client/src/workers/Evaluation/evalTreeWithChanges.ts @@ -139,6 +139,7 @@ export const evaluateAndGenerateResponse = ( ); defaultResponse.staleMetaIds = updateResponse.staleMetaIds; + defaultResponse.dependencies = dataTreeEvaluator.inverseDependencies; // when additional paths are required to be added as updates, we extract the updates from the data tree using these paths. const additionalUpdates = getNewDataTreeUpdates( diff --git a/app/client/yarn.lock b/app/client/yarn.lock index 65539a33a99..c97f664a5bc 100644 --- a/app/client/yarn.lock +++ b/app/client/yarn.lock @@ -4769,6 +4769,15 @@ __metadata: languageName: node linkType: hard +"@opentelemetry/api-logs@npm:0.53.0": + version: 0.53.0 + resolution: "@opentelemetry/api-logs@npm:0.53.0" + dependencies: + "@opentelemetry/api": ^1.0.0 + checksum: 3383ff75f94a77402370a655f8edf049f9864ad60140f70821a1b775ce43bdb9ca6fade533a1faf46dbca19f3189bcbf1f8805062f5a68bfe2a00281b1712d1f + languageName: node + linkType: hard + "@opentelemetry/api@npm:^1.0.0, @opentelemetry/api@npm:^1.9.0": version: 1.9.0 resolution: "@opentelemetry/api@npm:1.9.0" @@ -4776,6 +4785,15 @@ __metadata: languageName: node linkType: hard +"@opentelemetry/context-async-hooks@npm:1.26.0": + version: 1.26.0 + resolution: "@opentelemetry/context-async-hooks@npm:1.26.0" + peerDependencies: + "@opentelemetry/api": ">=1.0.0 <1.10.0" + checksum: f0fe5bfa3aeed99fbe7d6f6157e3bcc2e4450850a62ef60e551812f3e5aa72cb81e38de8c4e1b6934c93e18579a503664597f78e7e7d9904e271f59c939a3e02 + languageName: node + linkType: hard + "@opentelemetry/context-zone-peer-dep@npm:1.25.1": version: 1.25.1 resolution: "@opentelemetry/context-zone-peer-dep@npm:1.25.1" @@ -4807,7 +4825,7 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/core@npm:^1.26.0": +"@opentelemetry/core@npm:1.26.0, @opentelemetry/core@npm:^1.26.0": version: 1.26.0 resolution: "@opentelemetry/core@npm:1.26.0" dependencies: @@ -4848,6 +4866,20 @@ __metadata: languageName: node linkType: hard +"@opentelemetry/instrumentation-http@npm:^0.53.0": + version: 0.53.0 + resolution: "@opentelemetry/instrumentation-http@npm:0.53.0" + dependencies: + "@opentelemetry/core": 1.26.0 + "@opentelemetry/instrumentation": 0.53.0 + "@opentelemetry/semantic-conventions": 1.27.0 + semver: ^7.5.2 + peerDependencies: + "@opentelemetry/api": ^1.3.0 + checksum: 4ee569f7fc8c7ce50fabaff016d33577f36e63272b0634ac45806d70bffdf38fcf09db3cd9dd27c3150f6c4547fec673c356c419a6ed2399ff2849b9487a6e89 + languageName: node + linkType: hard + "@opentelemetry/instrumentation@npm:0.52.1": version: 0.52.1 resolution: "@opentelemetry/instrumentation@npm:0.52.1" @@ -4864,6 +4896,22 @@ __metadata: languageName: node linkType: hard +"@opentelemetry/instrumentation@npm:0.53.0": + version: 0.53.0 + resolution: "@opentelemetry/instrumentation@npm:0.53.0" + dependencies: + "@opentelemetry/api-logs": 0.53.0 + "@types/shimmer": ^1.2.0 + import-in-the-middle: ^1.8.1 + require-in-the-middle: ^7.1.1 + semver: ^7.5.2 + shimmer: ^1.2.1 + peerDependencies: + "@opentelemetry/api": ^1.3.0 + checksum: a386fe066eab71129a6edbc883ab407b1022850e8acc4750029a12e8730588a8b81442d0b008aaddb46f7614af40d19d331e7348790ca2d08ba8eed6d23ffdae + languageName: node + linkType: hard + "@opentelemetry/otlp-exporter-base@npm:0.52.1": version: 0.52.1 resolution: "@opentelemetry/otlp-exporter-base@npm:0.52.1" @@ -4893,6 +4941,28 @@ __metadata: languageName: node linkType: hard +"@opentelemetry/propagator-b3@npm:1.26.0": + version: 1.26.0 + resolution: "@opentelemetry/propagator-b3@npm:1.26.0" + dependencies: + "@opentelemetry/core": 1.26.0 + peerDependencies: + "@opentelemetry/api": ">=1.0.0 <1.10.0" + checksum: c2e99a8ed2814cf5b8e6e2a79411f2f6d668b7d5fc8351e5302ea4149601a96ec655422cf59470c66d8a408850f8a6b5156bf7deac7afb07d3f7a935c51fff04 + languageName: node + linkType: hard + +"@opentelemetry/propagator-jaeger@npm:1.26.0": + version: 1.26.0 + resolution: "@opentelemetry/propagator-jaeger@npm:1.26.0" + dependencies: + "@opentelemetry/core": 1.26.0 + peerDependencies: + "@opentelemetry/api": ">=1.0.0 <1.10.0" + checksum: a0ac3888c86f1b4671c7ca520396b89b4c47fa9e9d976bd014472d2b7786e7c5bdf4823a6e2a900fed5ea5dfe23eda0bdf6740e77c1352f2c0f82b13a71c03df + languageName: node + linkType: hard + "@opentelemetry/resources@npm:1.25.1": version: 1.25.1 resolution: "@opentelemetry/resources@npm:1.25.1" @@ -4905,6 +4975,18 @@ __metadata: languageName: node linkType: hard +"@opentelemetry/resources@npm:1.26.0": + version: 1.26.0 + resolution: "@opentelemetry/resources@npm:1.26.0" + dependencies: + "@opentelemetry/core": 1.26.0 + "@opentelemetry/semantic-conventions": 1.27.0 + peerDependencies: + "@opentelemetry/api": ">=1.0.0 <1.10.0" + checksum: f70b0fdf4fb00c950bc30084818c92a5339f1be5d709bd681ab14453e877d6bb9f700324b8e65a0eabfeea618d01ed071abf9088e00fa0bf7f3305b1abad22cb + languageName: node + linkType: hard + "@opentelemetry/sdk-logs@npm:0.52.1": version: 0.52.1 resolution: "@opentelemetry/sdk-logs@npm:0.52.1" @@ -4944,6 +5026,35 @@ __metadata: languageName: node linkType: hard +"@opentelemetry/sdk-trace-base@npm:1.26.0": + version: 1.26.0 + resolution: "@opentelemetry/sdk-trace-base@npm:1.26.0" + dependencies: + "@opentelemetry/core": 1.26.0 + "@opentelemetry/resources": 1.26.0 + "@opentelemetry/semantic-conventions": 1.27.0 + peerDependencies: + "@opentelemetry/api": ">=1.0.0 <1.10.0" + checksum: a4f4ddf644fd0d79b2bd49e4377143688d2aa657643a470d8bed6696f26817598fb4e9f16ba2d8c237292af56f06eec56594a7b4cc417d4ea7e490a45a22113b + languageName: node + linkType: hard + +"@opentelemetry/sdk-trace-node@npm:^1.26.0": + version: 1.26.0 + resolution: "@opentelemetry/sdk-trace-node@npm:1.26.0" + dependencies: + "@opentelemetry/context-async-hooks": 1.26.0 + "@opentelemetry/core": 1.26.0 + "@opentelemetry/propagator-b3": 1.26.0 + "@opentelemetry/propagator-jaeger": 1.26.0 + "@opentelemetry/sdk-trace-base": 1.26.0 + semver: ^7.5.2 + peerDependencies: + "@opentelemetry/api": ">=1.0.0 <1.10.0" + checksum: 1d63bed8fc36496698919ccd25be3b7b0e0d0bf9478f413a26bdbfe0bf0d4166bf58bbbee2415fb2fe42d3008b5c32ec7e4e42f2cb6d18b665b349eb025c15eb + languageName: node + linkType: hard + "@opentelemetry/sdk-trace-web@npm:1.25.1": version: 1.25.1 resolution: "@opentelemetry/sdk-trace-web@npm:1.25.1" @@ -4964,7 +5075,7 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/semantic-conventions@npm:1.27.0": +"@opentelemetry/semantic-conventions@npm:1.27.0, @opentelemetry/semantic-conventions@npm:^1.27.0": version: 1.27.0 resolution: "@opentelemetry/semantic-conventions@npm:1.27.0" checksum: 26d85f8d13c8c64024f7a84528cff41d56afc9829e7ff8a654576404f8b2c1a9c264adcc6fa5a9551bacdd938a4a464041fa9493e0a722e5605f2c2ae6752398 @@ -11023,10 +11134,10 @@ __metadata: languageName: node linkType: hard -"@types/shimmer@npm:^1.0.2": - version: 1.0.4 - resolution: "@types/shimmer@npm:1.0.4" - checksum: f1e7f8b773c34ea21b69686cb100117bd94cc0d1f043e3fc50683453b9936d1295c4f48e1872766556234a9ec48ea37fc7e6b5e56212f66ec65d5b2b5d73092b +"@types/shimmer@npm:^1.0.2, @types/shimmer@npm:^1.2.0": + version: 1.2.0 + resolution: "@types/shimmer@npm:1.2.0" + checksum: f081a31d826ce7bfe8cc7ba8129d2b1dffae44fd580eba4fcf741237646c4c2494ae6de2cada4b7713d138f35f4bc512dbf01311d813dee82020f97d7d8c491c languageName: node linkType: hard @@ -12570,6 +12681,9 @@ __metadata: version: 0.0.0-use.local resolution: "appsmith-rts@workspace:packages/rts" dependencies: + "@opentelemetry/instrumentation-http": ^0.53.0 + "@opentelemetry/sdk-trace-node": ^1.26.0 + "@opentelemetry/semantic-conventions": ^1.27.0 "@shared/ast": "workspace:^" "@types/express": ^4.17.14 "@types/jest": ^29.2.3