diff --git a/.env.example b/.env.example index 1bb9ba3d0c..3d49db3358 100644 --- a/.env.example +++ b/.env.example @@ -92,3 +92,13 @@ ENABLE_PRIVATE_APP_EMBED= #Enable cors else restricted to TOOLJET_HOST. Set the value true if you are serving front end from diffrent host ENABLE_CORS= + +# cloud specific variables +ORGANIZATION_LICENSE_URL= +ORGANIZATION_LICENSE_API_KEY= + +#pat session expiry in minutes +PAT_SESSION_EXPIRY= + +#pat expiry in days +PAT_EXPIRY= diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index b5f3acd0d5..ca3a7420b7 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -139,9 +139,10 @@ jobs: context: . build-args: | CUSTOM_GITHUB_TOKEN=${{ secrets.CUSTOM_GITHUB_TOKEN }} + BRANCH_NAME=main file: docker/ee/ee-production.Dockerfile push: true - tags: tooljet/tooljet-ee:${{ github.event.release.tag_name }},tooljet/tooljet-ee:ee-lts-latest,tooljet/tooljet:ee-lts-latest,tooljet/tooljet:${{ github.event.release.tag_name }} + tags: tooljet/tooljet-ee:${{ github.event.release.tag_name }},tooljet/tooljet-ee:ee-latest,tooljet/tooljet:ee-latest,tooljet/tooljet:${{ github.event.release.tag_name }} platforms: linux/amd64 env: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} @@ -155,6 +156,7 @@ jobs: context: . build-args: | CUSTOM_GITHUB_TOKEN=${{ secrets.CUSTOM_GITHUB_TOKEN }} + BRANCH_NAME=lts file: docker/ee/ee-production.Dockerfile push: true tags: tooljet/tooljet-ee:${{ github.event.release.tag_name }},tooljet/tooljet-ee:ee-lts-latest,tooljet/tooljet:ee-lts-latest,tooljet/tooljet:${{ github.event.release.tag_name }} diff --git a/.github/workflows/manual-docker-build.yml b/.github/workflows/manual-docker-build.yml new file mode 100644 index 0000000000..1a1e0c954d --- /dev/null +++ b/.github/workflows/manual-docker-build.yml @@ -0,0 +1,57 @@ +name: Manual Docker Build and Push + +on: + workflow_dispatch: + inputs: + branch_name: + description: 'Git branch to build from' + required: true + default: 'main' + dockerfile_path: + description: 'Path to Dockerfile' + required: true + docker_tag: + description: 'Docker tag suffix (e.g., pre-release-14)' + required: true + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.branch_name }} + fetch-depth: 0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Generate full Docker tag + id: taggen + run: | + input_tag="${{ github.event.inputs.docker_tag }}" + if [[ "$input_tag" == *"/"* ]]; then + echo "tag=$input_tag" >> $GITHUB_OUTPUT + else + echo "tag=tooljet/tj-osv:$input_tag" >> $GITHUB_OUTPUT + fi + + - name: Build and Push Docker image + uses: docker/build-push-action@v4 + with: + context: . + file: ${{ github.event.inputs.dockerfile_path }} + push: true + tags: ${{ steps.taggen.outputs.tag }} + platforms: linux/amd64 + build-args: | + CUSTOM_GITHUB_TOKEN=${{ secrets.CUSTOM_GITHUB_TOKEN }} + BRANCH_NAME=${{ github.event.inputs.branch_name }} diff --git a/.vscode/settings.json b/.vscode/settings.json index e13d54d778..ac6e6079cc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { "[javascript, typescript]": { - "editor.defaultFormatter": "dbaeumer.vscode-eslint", + "editor.defaultFormatter": "dbaeumer.vscode-eslint" }, "eslint.validate": [ "javascript", @@ -8,8 +8,8 @@ "typescript", "typescriptreact" ], - "eslint.format.enable": false, - "editor.formatOnSave": false, + "eslint.format.enable": true, + "editor.formatOnSave": true, "json.schemas": [ { "fileMatch": [ diff --git a/CODEOWNERS b/CODEOWNERS index 63a9ee034f..d2c5181abc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -17,6 +17,9 @@ /package.json @shah21 @gsmithun4 @adishm98 /package-lock.json @shah21 @gsmithun4 @adishm98 -# Server service files -/server/src/services/email.service.ts @shah21 @gsmithun4 -/server/src/mails @shah21 @gsmithun4 +# Code owners for all module.ts files +**/module.ts @shah21 @gsmithun4 + +# Server migration directories +/server/migrations/* @shah21 @gsmithun4 +/server/data-migrations/* @shah21 @gsmithun4 diff --git a/cypress-tests/cypress-ee-platform.config.js b/cypress-tests/cypress-ee-platform.config.js index 25aa7f6f15..fb4f45faeb 100644 --- a/cypress-tests/cypress-ee-platform.config.js +++ b/cypress-tests/cypress-ee-platform.config.js @@ -98,8 +98,10 @@ module.exports = defineConfig({ configFile: environment.configFile, specPattern: [ "cypress/e2e/happyPath/platform/firstUser/firstUserOnboarding.cy.js", + "cypress/e2e/happyPath/platform/ceTestcases/apps/appSlug.cy.js", + "cypress/e2e/happyPath/platform/ceTestcases/apps/!(*appSlug).cy.js", "cypress/e2e/happyPath/platform/commonTestcases/userManagment/*.cy.js", - "cypress/e2e/happyPath/platform/eeTestcases/**/*.cy.js", + "cypress/e2e/happyPath/platform/eeTestcases/workspace/*.cy.js", ], numTestsKeptInMemory: 1, redirectionLimit: 15, diff --git a/cypress-tests/cypress-platform.config.js b/cypress-tests/cypress-platform.config.js index 6b1954140a..cb3575906a 100644 --- a/cypress-tests/cypress-platform.config.js +++ b/cypress-tests/cypress-platform.config.js @@ -98,8 +98,12 @@ module.exports = defineConfig({ configFile: environment.configFile, specPattern: [ "cypress/e2e/happyPath/platform/firstUser/firstUserOnboarding.cy.js", + // Exclude specific files from ceTestcases/apps and ceTestcases/workspace "cypress/e2e/happyPath/platform/ceTestcases/apps/appSlug.cy.js", - "cypress/e2e/happyPath/platform/ceTestcases/**/!(*appSlug).cy.js", + "cypress/e2e/happyPath/platform/ceTestcases/**/!(*appSlug|appImport|privateAndpublicApps|version).cy.js", + // Exclude workspaceConstants.cy.js explicitly + "cypress/e2e/happyPath/platform/ceTestcases/workspace/!(*groupDuplication|workspaceConstants).cy.js", + "!cypress/e2e/happyPath/platform/ceTestcases/workspace/workspaceConstants.cy.js", "cypress/e2e/happyPath/platform/commonTestcases/**/*.cy.js", ], numTestsKeptInMemory: 1, diff --git a/cypress-tests/cypress.Dockerfile b/cypress-tests/cypress.Dockerfile index 32607825e7..705ae6bea6 100644 --- a/cypress-tests/cypress.Dockerfile +++ b/cypress-tests/cypress.Dockerfile @@ -22,7 +22,7 @@ RUN git checkout ${BRANCH_NAME} RUN git submodule update --init --recursive # Checkout the same branch in submodules if it exists, otherwise stay on default branch -RUN git submodule foreach 'git checkout ${BRANCH_NAME} || true' +RUN git submodule foreach 'git checkout main' # Scripts for building COPY ./package.json ./package.json @@ -54,7 +54,7 @@ RUN npm install -g @nestjs/cli RUN npm install -g copyfiles RUN npm --prefix server run build -FROM node:22.15.1 +FROM node:22.15.1-bullseye RUN apt-get update -yq \ && apt-get install curl wget gnupg zip -yq \ diff --git a/cypress-tests/cypress/commands/apiCommands.js b/cypress-tests/cypress/commands/apiCommands.js index c8ca26fd2f..60e747a48c 100644 --- a/cypress-tests/cypress/commands/apiCommands.js +++ b/cypress-tests/cypress/commands/apiCommands.js @@ -479,24 +479,22 @@ Cypress.Commands.add("apiMakeAppPublic", (appId = Cypress.env("appId")) => { }); }); -Cypress.Commands.add("apiDeleteGranularPermission", (groupName) => { +Cypress.Commands.add("apiDeleteGranularPermission", (groupName, typesToDelete = []) => { cy.getAuthHeaders().then((headers) => { - // Fetch group permissions + // Step 1: Get the group by name cy.request({ method: "GET", url: `${Cypress.env("server_host")}/api/v2/group-permissions`, - headers: headers, + headers, log: false, }).then((response) => { expect(response.status).to.equal(200); - const group = response.body.groupPermissions.find( - (g) => g.name === groupName - ); + const group = response.body.groupPermissions.find((g) => g.name === groupName); if (!group) throw new Error(`Group with name ${groupName} not found`); const groupId = group.id; - // Fetch granular permissions for the specific group + // Step 2: Get all granular permissions for the group cy.request({ method: "GET", url: `${Cypress.env("server_host")}/api/v2/group-permissions/${groupId}/granular-permissions`, @@ -504,22 +502,31 @@ Cypress.Commands.add("apiDeleteGranularPermission", (groupName) => { log: false, }).then((granularResponse) => { expect(granularResponse.status).to.equal(200); - const granularPermissionId = granularResponse.body[0].id; + const granularPermissions = granularResponse.body; - // Delete the granular permission - cy.request({ - method: "DELETE", - url: `${Cypress.env("server_host")}/api/v2/group-permissions/granular-permissions/app/${granularPermissionId}`, - headers, - log: false, - }).then((deleteResponse) => { - expect(deleteResponse.status).to.equal(200); + // Step 3: Filter if typesToDelete is specified + const permissionsToDelete = typesToDelete.length + ? granularPermissions.filter((perm) => typesToDelete.includes(perm.type)) + : granularPermissions; + + // Step 4: Delete each granular permission + permissionsToDelete.forEach((permission) => { + cy.request({ + method: "DELETE", + url: `${Cypress.env("server_host")}/api/v2/group-permissions/granular-permissions/app/${permission.id}`, + headers, + log: false, + }).then((deleteResponse) => { + expect(deleteResponse.status).to.equal(200); + cy.log(`Deleted granular permission: ${permission.name}`); + }); }); }); }); }); }); + Cypress.Commands.add( "apiCreateGranularPermission", ( diff --git a/cypress-tests/cypress/commands/commands.js b/cypress-tests/cypress/commands/commands.js index 39e68da18a..98cbcb31d0 100644 --- a/cypress-tests/cypress/commands/commands.js +++ b/cypress-tests/cypress/commands/commands.js @@ -84,7 +84,20 @@ Cypress.Commands.add( const dataTransfer = new DataTransfer(); cy.forceClickOnCanvas(); - cy.clearAndType(commonSelectors.searchField, widgetName); + cy.get("body") + .then(($body) => { + const isSearchVisible = $body + .find(commonSelectors.searchField) + .is(":visible"); + + if (!isSearchVisible) { + cy.get('[data-cy="right-sidebar-plus-button"]').click(); + } + }) + .then(() => { + cy.clearAndType(commonSelectors.searchField, widgetName); + }); + cy.get(commonWidgetSelector.widgetBox(widgetName2)).trigger( "dragstart", { dataTransfer }, @@ -622,11 +635,11 @@ Cypress.Commands.add( } ); -Cypress.Commands.add('ifEnv', (expectedEnvs, callback) => { +Cypress.Commands.add("ifEnv", (expectedEnvs, callback) => { const actualEnv = Cypress.env("environment"); const envArray = Array.isArray(expectedEnvs) ? expectedEnvs : [expectedEnvs]; if (envArray.includes(actualEnv)) { callback(); } -}); \ No newline at end of file +}); diff --git a/cypress-tests/cypress/constants/selectors/common.js b/cypress-tests/cypress/constants/selectors/common.js index 06f34baa99..ca0367ba44 100644 --- a/cypress-tests/cypress/constants/selectors/common.js +++ b/cypress-tests/cypress/constants/selectors/common.js @@ -177,7 +177,7 @@ export const commonSelectors = { breadcrumbPageTitle: '[data-cy="breadcrumb-page-title"]', labelFullNameInput: '[data-cy="name-label"]', duplicateOption: '[data-cy="duplicate-group-card-option"]', - confirmDuplicateButton: '[data-cy="confim-button"]', + confirmDuplicateButton: '[data-cy="confirm-button"]', inputFieldFullName: '[data-cy="name-input"]', labelEmailInput: '[data-cy="email-label"]', inputFieldEmailAddress: '[data-cy="email-input"]', diff --git a/cypress-tests/cypress/constants/selectors/manageGroups.js b/cypress-tests/cypress/constants/selectors/manageGroups.js index 5066342ff6..9ed4273ba5 100644 --- a/cypress-tests/cypress/constants/selectors/manageGroups.js +++ b/cypress-tests/cypress/constants/selectors/manageGroups.js @@ -133,7 +133,7 @@ export const groupsSelector = { usersCheckInput: '[data-cy="users-check-input"]', permissionCheckInput: '[data-cy="permissions-check-input"]', appsCheckInput: '[data-cy="apps-check-input"]', - confimButton: '[data-cy="confim-button"]', + confimButton: '[data-cy="confirm-button"]', duplicatedGroupLink: (groupName) => { return `[data-cy="${cyParamName(groupName)}_copy-list-item"]` }, diff --git a/cypress-tests/cypress/e2e/happyPath/platform/ceTestcases/apps/appImport.cy.js b/cypress-tests/cypress/e2e/happyPath/platform/ceTestcases/apps/appImport.cy.js index 2bd1ccf51e..f75edd64ce 100644 --- a/cypress-tests/cypress/e2e/happyPath/platform/ceTestcases/apps/appImport.cy.js +++ b/cypress-tests/cypress/e2e/happyPath/platform/ceTestcases/apps/appImport.cy.js @@ -22,7 +22,7 @@ describe("App Import Functionality", () => { let data; beforeEach(() => { - cy.viewport(1200, 1300); + cy.viewport(1400, 1400); data = { workspaceName: fake.firstName, workspaceSlug: fake.firstName.toLowerCase().replace(/\s+/g, "-"), @@ -34,7 +34,7 @@ describe("App Import Functionality", () => { cy.apiLogin(); cy.apiCreateWorkspace(data.workspaceName, data.workspaceSlug); cy.apiLogout(); - cy.skipWalkthrough() + cy.skipWalkthrough(); }); it("should verify app import functionality", () => { @@ -151,23 +151,49 @@ describe("App Import Functionality", () => { cy.visit(`${data.workspaceSlug}/data-sources`); cy.get('[data-cy="postgresql-button"]').should("be.visible"); - cy.apiUpdateDataSource("postgresql", "production", { - options: [ - { - key: "password", - value: `${Cypress.env("pg_password")}`, - encrypted: true, - }, - ], + + cy.ifEnv("Community", () => { + cy.apiUpdateDataSource("postgresql", "production", { + options: [ + { + key: "password", + value: `${Cypress.env("pg_password")}`, + encrypted: true, + }, + ], + }); + }); + cy.ifEnv("Enterprise", () => { + cy.apiUpdateDataSource("postgresql", "development", { + options: [ + { + key: "password", + value: `${Cypress.env("pg_password")}`, + encrypted: true, + }, + ], + }); }); - cy.apiCreateWsConstant( - "pageHeader", - "Import and Export", - ["Global"], - ["production"] - ); - cy.apiCreateWsConstant("db_name", "persons", ["Secret"], ["production"]); + cy.ifEnv("Community", () => { + cy.apiCreateWsConstant( + "pageHeader", + "Import and Export", + ["Global"], + ["production"] + ); + cy.apiCreateWsConstant("db_name", "persons", ["Secret"], ["production"]); + }); + + cy.ifEnv("Enterprise", () => { + cy.apiCreateWsConstant( + "pageHeader", + "Import and Export", + ["Global"], + ["development"] + ); + cy.apiCreateWsConstant("db_name", "persons", ["Secret"], ["development"]); + }); // Verify app after setup cy.wait("@importApp").then((interception) => { diff --git a/cypress-tests/cypress/e2e/happyPath/platform/ceTestcases/apps/appSlug.cy.js b/cypress-tests/cypress/e2e/happyPath/platform/ceTestcases/apps/appSlug.cy.js index 796ac009b3..c4a2c674ff 100644 --- a/cypress-tests/cypress/e2e/happyPath/platform/ceTestcases/apps/appSlug.cy.js +++ b/cypress-tests/cypress/e2e/happyPath/platform/ceTestcases/apps/appSlug.cy.js @@ -7,6 +7,7 @@ import { verifyURLs, resolveHost, } from "Support/utils/apps"; +import { appPromote } from "Support/utils/platform/multiEnv"; describe("App Slug", () => { const data = {}; @@ -153,6 +154,7 @@ describe("App Slug", () => { cy.visit("/my-workspace"); cy.apiCreateApp(data.slug); cy.openApp("my-workspace"); + releaseApp(); cy.get(commonWidgetSelector.shareAppButton).click(); cy.clearAndType(commonWidgetSelector.appNameSlugInput, data.slug); diff --git a/cypress-tests/cypress/e2e/happyPath/platform/ceTestcases/apps/privateAndpublicApps.cy.js b/cypress-tests/cypress/e2e/happyPath/platform/ceTestcases/apps/privateAndpublicApps.cy.js index 19b87c6efe..f763211bea 100644 --- a/cypress-tests/cypress/e2e/happyPath/platform/ceTestcases/apps/privateAndpublicApps.cy.js +++ b/cypress-tests/cypress/e2e/happyPath/platform/ceTestcases/apps/privateAndpublicApps.cy.js @@ -12,6 +12,8 @@ import { verifyRestrictedAccess, onboardUserFromAppLink, } from "Support/utils/apps"; +import { appPromote } from "Support/utils/platform/multiEnv"; +import { InstanceSSO } from "Support/utils/platform/eeCommon"; describe( "Private and Public apps", @@ -183,6 +185,9 @@ describe( setupAppWithSlug(data.appName, data.slug); cy.apiLogout(); + cy.ifEnv("Enterprise", () => { + InstanceSSO(true, true, true); + }); userSignUp(data.firstName, data.email, data.workspaceName); cy.wait(1000); cy.visitSlug({ @@ -312,7 +317,9 @@ describe( cy.apiLogout(); cy.apiLogin(); cy.visit(`${data.workspaceSlug}`); - cy.apiDeleteGranularPermission("end-user"); + + cy.apiDeleteGranularPermission("end-user", ["app", "workflow"]); + setSignupStatus(true, data.workspaceName); setupAppWithSlug(data.appName, data.slug); diff --git a/cypress-tests/cypress/e2e/happyPath/platform/ceTestcases/apps/version.cy.js b/cypress-tests/cypress/e2e/happyPath/platform/ceTestcases/apps/version.cy.js index 3432744fc3..433d74b05e 100644 --- a/cypress-tests/cypress/e2e/happyPath/platform/ceTestcases/apps/version.cy.js +++ b/cypress-tests/cypress/e2e/happyPath/platform/ceTestcases/apps/version.cy.js @@ -1,7 +1,6 @@ import { commonSelectors, commonWidgetSelector } from "Selectors/common"; import { fake } from "Fixtures/fake"; import { commonText } from "Texts/common"; - import { editVersionAndVerify, deleteVersionAndVerify, @@ -13,25 +12,20 @@ import { navigateToEditVersionModal, switchVersionAndVerify, } from "Support/utils/version"; - import { appVersionSelectors } from "Selectors/exportImport"; import { editVersionSelectors } from "Selectors/version"; import { editVersionText } from "Texts/version"; import { createNewVersion } from "Support/utils/exportImport"; - import { verifyModal, closeModal } from "Support/utils/common"; - import { verifyComponent, verifyComponentinrightpannel, deleteComponentAndVerify, } from "Support/utils/basicComponents"; - import { deleteVersionText, onlydeleteVersionText } from "Texts/version"; - import { createRestAPIQuery } from "Support/utils/dataSource"; import { deleteQuery } from "Support/utils/queries"; - +import { selectEnv, appPromote } from "Support/utils/platform/multiEnv"; describe("App Version", () => { let data; @@ -50,6 +44,8 @@ describe("App Version", () => { cy.defaultWorkspaceLogin(); cy.apiCreateApp(data.appName); cy.openApp(); + cy.viewport(1400, 1400); + }); it("should verify basic version management operations", () => { @@ -120,7 +116,15 @@ describe("App Version", () => { // Preview and release verification cy.openInCurrentTab(commonWidgetSelector.previewButton); - cy.url().should("include", "/home?version=v2"); + + cy.ifEnv("Community", () => { + cy.url().should("include", "/home?version=v2"); + }); + + cy.ifEnv("Enterprise", () => { + cy.url().should("include", "/home?env=development&version=v2"); + }); + cy.openApp( "", Cypress.env("workspaceId"), @@ -149,7 +153,11 @@ describe("App Version", () => { createRestAPIQuery(data.query1, data.datasourceName, "", "", "/1", true); - // Version v2 creation and verification + cy.ifEnv("Enterprise", () => { + appPromote("development", "production"); + }); + + // Version v2 creation and verification and v2 is created from v1 production environment navigateToCreateNewVersionModal("v1"); createNewVersion(["v2"], "v1"); cy.get(commonWidgetSelector.draggableWidget("text1")).verifyVisibleElement( @@ -201,7 +209,8 @@ describe("App Version", () => { versionChecks.forEach((check) => { navigateToCreateNewVersionModal(check.create.from); createNewVersion([check.create.version], check.create.from); - + cy.waitForAutoSave(); + cy.wait(1000); if (check.verify.component.value) { cy.get( commonWidgetSelector.draggableWidget(check.verify.component.selector) @@ -224,6 +233,9 @@ describe("App Version", () => { ); // Version switching and component verification + cy.ifEnv("Enterprise", () => { + selectEnv("development"); + }); cy.get(appVersionSelectors.currentVersionField("v5")).click(); cy.contains(`[id*="react-select-"]`, "v4").click(); cy.get(appVersionSelectors.currentVersionField("v4")).should( @@ -238,7 +250,14 @@ describe("App Version", () => { // Preview and version switching verification cy.openInCurrentTab(commonWidgetSelector.previewButton); - cy.url().should("include", "/home?version=v4"); + + cy.ifEnv("Community", () => { + cy.url().should("include", "/home?version=v4"); + }); + cy.ifEnv("Enterprise", () => { + cy.url().should("include", "/home?env=development&version=v4"); + }); + cy.get(commonWidgetSelector.draggableWidget("text1")).verifyVisibleElement( "have.text", "Leanne Graham" @@ -250,8 +269,74 @@ describe("App Version", () => { cy.get( commonWidgetSelector.draggableWidget("textInput") ).verifyVisibleElement("have.value", "Ervin Howell"); - //url validation should be added after bug fix - // cy.url().should("include", "/home?version=v5"); + cy.ifEnv("Enterprise", () => { + cy.openApp( + "", + Cypress.env("workspaceId"), + Cypress.env("appId"), + commonWidgetSelector.draggableWidget("textInput") + ); + + navigateToCreateNewVersionModal("v5"); + createNewVersion(["v6"], "v5"); + cy.waitForAutoSave(); + cy.wait(1000); + + appPromote("development", "staging"); + cy.get( + commonWidgetSelector.draggableWidget("textInput") + ).verifyVisibleElement("have.value", "Ervin Howell"); + cy.get(`[data-cy="list-query-${data.query2}"]`).should("be.visible"); + + appPromote("staging", "production"); + + cy.get( + commonWidgetSelector.draggableWidget("textInput") + ).verifyVisibleElement("have.value", "Ervin Howell"); + cy.get(`[data-cy="list-query-${data.query2}"]`).should("be.visible"); + + cy.openInCurrentTab(commonWidgetSelector.previewButton); + cy.get( + commonWidgetSelector.draggableWidget("textInput") + ).verifyVisibleElement("have.value", "Ervin Howell"); + cy.url().should("include", "/home?env=production&version=v6"); + + cy.wait(1000); + + cy.get('[data-cy="preview-settings"]').click(); + switchVersionAndVerify("v6", "v1"); + + cy.get( + commonWidgetSelector.draggableWidget("text1") + ).verifyVisibleElement("have.text", "Leanne Graham"); + // url bug + // cy.url().should("include", "/home?env=production&version=v1"); + cy.wait(1000); + cy.get('[data-cy="preview-settings"]').click(); + switchVersionAndVerify("v1", "v6"); + + cy.wait(1000); + cy.get('[data-cy="preview-settings"]').click(); + selectEnv("staging"); + + cy.get( + commonWidgetSelector.draggableWidget("textInput") + ).verifyVisibleElement("have.value", "Ervin Howell"); + // cy.url().should("include", "/home?env=staging&version=v6"); + + + cy.wait(1000); + cy.get('[data-cy="preview-settings"]').click(); + selectEnv("development"); + + cy.wait(1000); + cy.get('[data-cy="preview-settings"]').click(); + switchVersionAndVerify("v6", "v1"); + + cy.get( + commonWidgetSelector.draggableWidget("text1") + ).verifyVisibleElement("have.text", "Leanne Graham"); + }); }); }); diff --git a/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/userManagment/UserInviteFlow.cy.js b/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/userManagment/UserInviteFlow.cy.js index 9883fef40d..be286792a5 100644 --- a/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/userManagment/UserInviteFlow.cy.js +++ b/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/userManagment/UserInviteFlow.cy.js @@ -369,7 +369,7 @@ describe("user invite flow cases", () => { "have.text", "Cancel" ); - cy.get('[data-cy="confim-button"]').verifyVisibleElement( + cy.get('[data-cy="confirm-button"]').verifyVisibleElement( "have.text", "Continue" ); @@ -407,7 +407,7 @@ describe("user invite flow cases", () => { cy.get('[data-cy="group-check-input"]').eq(0).check(); cy.get(usersSelector.buttonInviteUsers).click(); - cy.get('[data-cy="confim-button"]').click(); + cy.get('[data-cy="confirm-button"]').click(); cy.verifyToastMessage( commonSelectors.toastMessage, @@ -426,7 +426,7 @@ describe("user invite flow cases", () => { cy.get('[data-cy="group-check-input"]').eq(0).check(); cy.get(usersSelector.buttonInviteUsers).click(); - cy.get('[data-cy="confim-button"]').click(); + cy.get('[data-cy="confirm-button"]').click(); cy.verifyToastMessage( commonSelectors.toastMessage, diff --git a/cypress-tests/cypress/e2e/happyPath/platform/eeTestcases/multi-env/multiEnv.cy.js b/cypress-tests/cypress/e2e/happyPath/platform/eeTestcases/multi-env/multiEnv.cy.js new file mode 100644 index 0000000000..4197213244 --- /dev/null +++ b/cypress-tests/cypress/e2e/happyPath/platform/eeTestcases/multi-env/multiEnv.cy.js @@ -0,0 +1,662 @@ +import { fake } from "Fixtures/fake"; +import { commonSelectors, commonWidgetSelector } from "Selectors/common"; +import { commonEeText, ssoEeText } from "Texts/eeCommon"; +import { commonEeSelectors, multiEnvSelector } from "Selectors/eeCommon"; +import { + verifyPromoteModalUI, + verifyTooltipDisabled, +} from "Support/utils/platform/eeCommon"; +import { dataSourceSelector } from "Selectors/dataSource"; +import { + navigateToAppEditor, + pinInspector, + verifyTooltip, +} from "Support/utils/common"; +import { addQuery, selectDatasource } from "Support/utils/dataSource"; +import { + appPromote, + createNewVersion, + selectVersion, + selectEnv, +} from "Support/utils/platform/multiEnv"; +import { appVersionSelectors } from "Selectors/exportImport"; + +import { editAndVerifyWidgetName } from "Support/utils/commonWidget"; +import { deleteVersionAndVerify } from "Support/utils/version"; +import { deleteVersionText } from "Texts/version"; + +describe("Multi env", () => { + const data = {}; + data.appName = `${fake.companyName} App`; + data.ds = fake.lastName.toLowerCase().replaceAll("[^A-Za-z]", ""); + data.constName = fake.firstName.toLowerCase().replaceAll("[^A-Za-z]", ""); + const slug = data.appName.toLowerCase().replace(/\s+/g, "-"); + let currentVersion = ""; + let newVersion = []; + let versionFrom = ""; + + beforeEach(() => { + cy.apiLogin(); + cy.viewport(1800, 1800); + cy.skipWalkthrough(); + }); + + it.only("Verify the datasource configuration and data on each env", () => { + cy.apiCreateGDS( + `${Cypress.env("server_host")}/api/data-sources`, + data.ds, + "restapi", + [ + { key: "url", value: "" }, + { key: "auth_type", value: "none" }, + { key: "grant_type", value: "authorization_code" }, + { key: "add_token_to", value: "header" }, + { key: "header_prefix", value: "Bearer " }, + { key: "access_token_url", value: "" }, + { key: "client_ide", value: "" }, + { key: "client_secret", value: "", encrypted: true }, + { key: "scopes", value: "read, write" }, + { key: "username", value: "", encrypted: false }, + { key: "password", value: "", encrypted: true }, + { key: "bearer_token", value: "", encrypted: true }, + { key: "auth_url", value: "" }, + { key: "client_auth", value: "header" }, + { key: "headers", value: [["", ""]] }, + { key: "custom_query_params", value: [["", ""]], encrypted: false }, + { key: "custom_auth_params", value: [["", ""]] }, + { + key: "access_token_custom_headers", + value: [["", ""]], + encrypted: false, + }, + { key: "multiple_auth_enabled", value: false, encrypted: false }, + { key: "ssl_certificate", value: "none", encrypted: false }, + ] + ); + cy.apiCreateApp(data.appName); + cy.visit("/my-workspace"); + cy.get(commonSelectors.globalDataSourceIcon).click(); + selectDatasource(data.ds); + cy.get('[data-cy="development-label"]').click(); + cy.clearAndType( + '[data-cy="base-url-text-field"]', + "https://reqres.in/api/users?page=1" + ); + cy.get(dataSourceSelector.buttonSave).click(); + cy.wait(2000); + + cy.get(commonSelectors.dashboardIcon).click(); + + cy.openApp(); + // cy.waitForAppLoad(); + cy.wait(2000); + cy.get(`[data-cy="${data.ds}-add-query-card"] > .text-truncate`).click(); + cy.wait(1000); + cy.get(dataSourceSelector.queryCreateAndRunButton).click(); + cy.get('[data-cy="query-tab-settings"]').click(); + cy.get(':nth-child(1) > .custom-toggle-switch > .switch > .slider').click(); + cy.waitForAutoSave(); + + cy.dragAndDropWidget("Text Input", 550, 650); + editAndVerifyWidgetName(data.constName, []); + cy.waitForAutoSave(); + + cy.get( + '[data-cy="default-value-input-field"]' + ).clearAndTypeOnCodeMirror(`{{queries.restapi1.data.data[0].email`); + cy.wait(1000); + cy.forceClickOnCanvas(); + cy.waitForAutoSave(); + cy.get(dataSourceSelector.queryCreateAndRunButton).click(); + cy.get( + commonWidgetSelector.draggableWidget(data.constName) + ).verifyVisibleElement("have.value", "george.bluth@reqres.in"); + + pinInspector(); + cy.get(commonWidgetSelector.sidebarinspector).click(); + cy.get(commonWidgetSelector.inspectorNodeComponents).click(); + cy.get(commonWidgetSelector.nodeComponent(data.constName)).click(); + cy.get('[data-cy="inspector-node-value"] > .mx-2').verifyVisibleElement( + "have.text", + `"george.bluth@reqres.in"` + ); + cy.get('[style="height: 13px; width: 13px;"] > img').should("exist"); + cy.get('[data-cy="inspector-node-globals"] > .node-key').click(); + cy.get('[data-cy="inspector-node-environment"] > .node-key').click(); + cy.get('[data-cy="inspector-node-name"] > .mx-2').verifyVisibleElement( + "have.text", + `"development"` + ); + + cy.openInCurrentTab(commonWidgetSelector.previewButton); + cy.wait(4000); + + cy.get( + commonWidgetSelector.draggableWidget(data.constName) + ).verifyVisibleElement("have.value", "george.bluth@reqres.in"); + + cy.go("back"); + cy.waitForAppLoad(); + cy.wait(3000); + cy.get(commonEeSelectors.promoteButton).click(); + cy.get(commonEeSelectors.promoteButton).eq(1).click(); + cy.waitForAppLoad(); + cy.wait(3000); + + cy.get(dataSourceSelector.queryCreateAndRunButton, { + timeout: 20000, + }).click(); + cy.verifyToastMessage( + commonSelectors.toastMessage, + "Query could not be completed" + ); + + cy.backToApps(); + cy.get(commonSelectors.globalDataSourceIcon).click(); + selectDatasource(data.ds); + cy.get('[data-cy="staging-label"]').click(); + cy.clearAndType( + '[data-cy="base-url-text-field"]', + "https://reqres.in/api/users?page=2" + ); + cy.get(dataSourceSelector.buttonSave).click(); + cy.wait(2000); + + cy.get(commonSelectors.dashboardIcon).click(); + navigateToAppEditor(data.appName); + cy.get(dataSourceSelector.queryCreateAndRunButton).click(); + cy.get( + commonWidgetSelector.draggableWidget(data.constName) + ).verifyVisibleElement("have.value", "michael.lawson@reqres.in"); + + cy.get(commonWidgetSelector.sidebarinspector).click(); + cy.get(commonWidgetSelector.inspectorNodeComponents).click(); + cy.get(commonWidgetSelector.nodeComponent(data.constName)).click(); + cy.get('[data-cy="inspector-node-value"] > .mx-2').verifyVisibleElement( + "have.text", + `"michael.lawson@reqres.in"` + ); + cy.get('[style="height: 13px; width: 13px;"] > img').should("not.exist"); + cy.get('[data-cy="inspector-node-globals"] > .node-key').click(); + cy.get('[data-cy="inspector-node-environment"] > .node-key').click(); + cy.get('[data-cy="inspector-node-name"] > .mx-2').verifyVisibleElement( + "have.text", + `"staging"` + ); + + cy.openInCurrentTab(commonWidgetSelector.previewButton); + cy.wait(4000); + + cy.get( + commonWidgetSelector.draggableWidget(data.constName) + ).verifyVisibleElement("have.value", "michael.lawson@reqres.in"); + + cy.go("back"); + cy.waitForAppLoad(); + cy.wait(3000); + cy.get(commonEeSelectors.promoteButton).click(); + cy.get(commonEeSelectors.promoteButton).eq(1).click(); + cy.waitForAppLoad(); + cy.wait(3000); + + cy.get(dataSourceSelector.queryCreateAndRunButton, { + timeout: 20000, + }).click(); + cy.verifyToastMessage( + commonSelectors.toastMessage, + "Query could not be completed" + ); + + cy.backToApps(); + cy.get(commonSelectors.globalDataSourceIcon).click(); + selectDatasource(data.ds); + cy.get('[data-cy="production-label"]').click(); + cy.clearAndType( + '[data-cy="base-url-text-field"]', + "https://reqres.in/api/users?page=1" + ); + cy.get(dataSourceSelector.buttonSave).click(); + cy.wait(2000); + + cy.get(commonSelectors.dashboardIcon).click(); + navigateToAppEditor(data.appName); + cy.get(dataSourceSelector.queryCreateAndRunButton).click(); + cy.get( + commonWidgetSelector.draggableWidget(data.constName) + ).verifyVisibleElement("have.value", "george.bluth@reqres.in"); + + cy.get(commonWidgetSelector.sidebarinspector).click(); + cy.get(commonWidgetSelector.inspectorNodeComponents).click(); + cy.get(commonWidgetSelector.nodeComponent(data.constName)).click(); + cy.get('[data-cy="inspector-node-value"] > .mx-2').verifyVisibleElement( + "have.text", + `"george.bluth@reqres.in"` + ); + cy.get('[style="height: 13px; width: 13px;"] > img').should("not.exist"); + cy.get('[data-cy="inspector-node-globals"] > .node-key').click(); + cy.get('[data-cy="inspector-node-environment"] > .node-key').click(); + cy.get('[data-cy="inspector-node-name"] > .mx-2').verifyVisibleElement( + "have.text", + `"production"` + ); + + cy.openInCurrentTab(commonWidgetSelector.previewButton); + cy.wait(4000); + + cy.get( + commonWidgetSelector.draggableWidget(data.constName) + ).verifyVisibleElement("have.value", "george.bluth@reqres.in"); + + cy.go("back"); + cy.waitForAppLoad(); + cy.wait(3000); + cy.get(commonSelectors.releaseButton).click(); + cy.get(commonSelectors.yesButton).click(); + cy.verifyToastMessage(commonSelectors.toastMessage, "Version v1 released"); + cy.wait(4000); + + cy.get(commonWidgetSelector.shareAppButton).click(); + cy.clearAndType(commonWidgetSelector.appNameSlugInput, `${slug}`); + cy.wait(2000); + cy.get(commonWidgetSelector.modalCloseButton).click(); + + cy.visit(`/applications/${slug}`); + cy.get( + commonWidgetSelector.draggableWidget(data.constName) + ).verifyVisibleElement("have.value", "george.bluth@reqres.in"); + }); + + it("should verify edit privilages of a promoted version", () => { + data.appName = `${fake.companyName} App`; + cy.apiCreateApp(data.appName); + cy.openApp(); + cy.waitForAppLoad(); + cy.dragAndDropWidget("Text", 550, 650); + appPromote("development", "production"); + + createNewVersion( + (currentVersion = "v1"), + (newVersion = ["v2"]), + (versionFrom = "v1") + ); + appPromote("development", "release"); + + createNewVersion( + (currentVersion = "v2"), + (newVersion = ["v3"]), + (versionFrom = "v2") + ); + appPromote("development", "staging"); + + selectVersion((currentVersion = "v3"), (newVersion = ["v1"])); + cy.get(commonSelectors.warningText).eq(0).verifyVisibleElement( + "have.text", + "App cannot be edited after promotion. Please create a new version from Development to make any changes." + ); + + cy.forceClickOnCanvas(); + cy.get(".datasource-picker").should("have.class", "disabled"); + cy.get(commonEeSelectors.AddQueryButton).should("be.disabled"); + cy.get(".components-container").should("have.class", "disabled"); + + cy.wait(1000); + selectEnv("development"); + cy.get(commonSelectors.warningText).eq(0).verifyVisibleElement( + "have.text", + "App cannot be edited after promotion. Please create a new version from Development to make any changes." + ); + cy.get(".datasource-picker").should("have.class", "disabled"); + cy.get(commonEeSelectors.AddQueryButton).should("be.disabled"); + cy.get(".components-container").should("have.class", "disabled"); + + selectVersion((currentVersion = "v1"), (newVersion = ["v2"])); + cy.get(commonSelectors.warningText).eq(0).verifyVisibleElement( + "have.text", + "This version of the app is released. Please create a new version in development to make any changes." + ); + cy.get(".datasource-picker").should("have.class", "disabled"); + cy.get(commonEeSelectors.AddQueryButton).should("be.disabled"); + cy.get(".components-container").should("have.class", "disabled"); + + cy.wait(1000); + selectEnv("staging"); + cy.get(commonSelectors.warningText).eq(0).verifyVisibleElement( + "have.text", + "This version of the app is released. Please create a new version in development to make any changes." + ); + cy.get(".datasource-picker").should("have.class", "disabled"); + cy.get(commonEeSelectors.AddQueryButton).should("be.disabled"); + cy.get(".components-container").should("have.class", "disabled"); + + cy.wait(1000); + selectEnv("production"); + cy.get(commonSelectors.warningText).eq(0).verifyVisibleElement( + "have.text", + "This version of the app is released. Please create a new version in development to make any changes." + ); + cy.get(".datasource-picker").should("have.class", "disabled"); + cy.get(commonEeSelectors.AddQueryButton).should("be.disabled"); + cy.get(".components-container").should("have.class", "disabled"); + cy.get(commonSelectors.releaseButton).should("be.disabled"); + }); + + it("Should verify last exisiting version", () => { + data.appName = `${fake.companyName} App`; + cy.apiCreateApp(data.appName); + cy.openApp(); + cy.waitForAppLoad(); + cy.dragAndDropWidget("Text", 550, 650); + + appPromote("development", "staging"); + createNewVersion( + (currentVersion = "v1"), + (newVersion = ["v2"]), + (versionFrom = "v1") + ); + + selectVersion((currentVersion = "v2"), (newVersion = ["v1"])); + + cy.wait(1000); + selectEnv("staging"); + + cy.get(appVersionSelectors.currentVersionField(newVersion[0])) + .should("be.visible") + .and("have.text", "v1"); + + appPromote("staging", "production"); + + cy.wait(3000) + deleteVersionAndVerify( + (currentVersion = "v1"), + deleteVersionText.deleteToastMessage((currentVersion = "v1")) + ); + + cy.wait(2000); + cy.get('[data-cy="list-current-env-name"]').click(); + verifyTooltip( + '[data-cy="env-name-dropdown"]:eq(1)', + "There are no versions in this environment" + ); + verifyTooltip( + '[data-cy="env-name-dropdown"]:eq(2)', + "There are no versions in this environment" + ); + }); + + it("Should verify version deletion", () => { + data.appName = `${fake.companyName} App`; + cy.apiCreateApp(data.appName); + cy.openApp(); + cy.waitForAppLoad(); + cy.dragAndDropWidget("Text", 550, 650); + + appPromote("development", "staging"); + createNewVersion( + (currentVersion = "v1"), + (newVersion = ["v2"]), + (versionFrom = "v1") + ); + appPromote("development", "staging"); + + createNewVersion( + (currentVersion = "v2"), + (newVersion = ["v3"]), + (versionFrom = "v2") + ); + appPromote("development", "production"); + + selectEnv("staging"); + selectVersion((currentVersion = "v3"), (newVersion = ["v2"])); + deleteVersionAndVerify( + (currentVersion = "v2"), + deleteVersionText.deleteToastMessage((currentVersion = "v2")) + ); + + cy.get('[data-cy="v3-current-version-text"]') + .should("be.visible") + .and("have.text", "v3"); + + cy.get('[data-cy="list-current-env-name"]').should( + "have.text", + "Staging" + ); + }) + + it("Verify the multi env components UI", () => { + data.appName = `${fake.companyName} App`; + cy.apiCreateApp(data.appName); + cy.openApp(); + cy.waitForAppLoad(); + cy.dragAndDropWidget("Text", 550, 650); + cy.get(multiEnvSelector.envContainer).should("be.visible"); + cy.get(multiEnvSelector.currentEnvName) + .verifyVisibleElement("have.text", "Development") + .click(); + cy.get(multiEnvSelector.envArrow).should("be.visible"); + cy.get(multiEnvSelector.selectedEnvName).verifyVisibleElement( + "have.text", + " Development" + ); + cy.get(multiEnvSelector.envNameList) + .eq(0) + .verifyVisibleElement("have.text", "Development"); + cy.get(multiEnvSelector.envNameList) + .eq(1) + .verifyVisibleElement("have.text", "Staging"); + cy.get(multiEnvSelector.envNameList) + .eq(2) + .verifyVisibleElement("have.text", "Production"); + + verifyTooltip( + '[data-cy="env-name-dropdown"]:eq(1)', + "There are no versions in this environment" + ); + verifyTooltip( + '[data-cy="env-name-dropdown"]:eq(2)', + "There are no versions in this environment" + ); + + cy.get(multiEnvSelector.appVersionLabel).should("be.visible"); + cy.get('[data-cy="v1-current-version-text"]') + .verifyVisibleElement("have.text", "v1") + .click(); + cy.get(multiEnvSelector.currentVersion).verifyVisibleElement( + "have.text", + "v1" + ); + cy.get(".col-10 > .app-version-name").verifyVisibleElement( + "have.text", + "v1" + ); + cy.get(multiEnvSelector.createNewVersionButton).verifyVisibleElement( + "have.text", + "Create new version" + ); + + verifyPromoteModalUI("v1", "Development", "Staging"); + cy.get('[data-cy="env-change-info-text"]').verifyVisibleElement( + "have.text", + "You won’t be able to edit this version after promotion. Are you sure you want to continue?" + ); + cy.get(commonSelectors.closeButton).click(); + cy.get(multiEnvSelector.currentEnvName).verifyVisibleElement( + "have.text", + "Development" + ); + + cy.get(commonEeSelectors.promoteButton).click(); + cy.get(commonSelectors.cancelButton).click(); + cy.get(multiEnvSelector.currentEnvName).verifyVisibleElement( + "have.text", + "Development" + ); + + cy.get(commonEeSelectors.promoteButton).click(); + cy.get(commonEeSelectors.promoteButton).eq(1).click(); + + cy.waitForAppLoad(); + cy.wait(3000); + + cy.get(commonSelectors.warningText).eq(0).verifyVisibleElement( + "have.text", + "App cannot be edited after promotion. Please create a new version from Development to make any changes." + ); + cy.get(multiEnvSelector.envContainer).should("be.visible"); + cy.get(multiEnvSelector.currentEnvName) + .verifyVisibleElement("have.text", "Staging") + .click(); + cy.get(multiEnvSelector.envArrow).should("be.visible"); + cy.get(multiEnvSelector.currentEnvName).verifyVisibleElement( + "have.text", + "Staging" + ); + cy.get(multiEnvSelector.envNameList) + .eq(0) + .verifyVisibleElement("have.text", "Development"); + cy.get(multiEnvSelector.envNameList) + .eq(1) + .verifyVisibleElement("have.text", "Staging"); + cy.get(multiEnvSelector.envNameList) + .eq(2) + .verifyVisibleElement("have.text", "Production"); + cy.wait(2000) + verifyTooltip( + '[data-cy="env-name-dropdown"]:eq(2)', + "There are no versions in this environment" + ); + + cy.get(multiEnvSelector.appVersionLabel).should("be.visible"); + cy.get('[data-cy="v1-current-version-text"]') + .verifyVisibleElement("have.text", "v1") + .click(); + cy.get(multiEnvSelector.currentVersion).verifyVisibleElement( + "have.text", + "v1" + ); + cy.get(".col-10 > .app-version-name").verifyVisibleElement( + "have.text", + "v1" + ); + cy.get(multiEnvSelector.createNewVersionButton).verifyVisibleElement( + "have.text", + "Create new version" + ); + + verifyTooltip( + multiEnvSelector.createNewVersionButton, + "New versions can only be created in development" + ); + cy.forceClickOnCanvas(); + cy.get(".datasource-picker").should("have.class", "disabled"); + cy.get(commonEeSelectors.AddQueryButton).should("be.disabled"); + cy.get(".components-container").should("have.class", "disabled"); + + verifyPromoteModalUI("v1", "Staging", "Production"); + cy.get(commonSelectors.closeButton).click(); + cy.get(multiEnvSelector.currentEnvName).verifyVisibleElement( + "have.text", + "Staging" + ); + + cy.get(commonEeSelectors.promoteButton).click(); + cy.get(commonSelectors.cancelButton).click(); + cy.get(multiEnvSelector.currentEnvName).verifyVisibleElement( + "have.text", + "Staging" + ); + + cy.get(commonEeSelectors.promoteButton).click(); + cy.get(commonEeSelectors.promoteButton).eq(1).click(); + cy.waitForAppLoad(); + cy.wait(3000); + + cy.get(commonSelectors.warningText).eq(0).verifyVisibleElement( + "have.text", + "App cannot be edited after promotion. Please create a new version from Development to make any changes." + ); + cy.get(multiEnvSelector.envContainer).should("be.visible"); + cy.get(multiEnvSelector.currentEnvName) + .verifyVisibleElement("have.text", "Production") + .click(); + cy.get(multiEnvSelector.envArrow).should("be.visible"); + cy.get(multiEnvSelector.currentEnvName).verifyVisibleElement( + "have.text", + "Production" + ); + cy.get(multiEnvSelector.envNameList) + .eq(0) + .verifyVisibleElement("have.text", "Development"); + cy.get(multiEnvSelector.envNameList) + .eq(1) + .verifyVisibleElement("have.text", "Staging"); + cy.get(multiEnvSelector.envNameList) + .eq(2) + .verifyVisibleElement("have.text", "Production"); + + cy.get(multiEnvSelector.appVersionLabel).should("be.visible"); + cy.get('[data-cy="v1-current-version-text"]') + .verifyVisibleElement("have.text", "v1") + .click(); + cy.get(multiEnvSelector.currentVersion).verifyVisibleElement( + "have.text", + "v1" + ); + cy.get(".col-10 > .app-version-name").verifyVisibleElement( + "have.text", + "v1" + ); + cy.get(multiEnvSelector.createNewVersionButton).verifyVisibleElement( + "have.text", + "Create new version" + ); + + cy.get(commonSelectors.releaseButton) + .verifyVisibleElement("have.text", "Release") + .click(); + cy.get('[data-cy="modal-title"]').verifyVisibleElement( + "have.text", + "Release Version" + ); + cy.get(commonSelectors.closeButton).should("be.visible"); + cy.get('[data-cy="confirm-dialogue-box-text"]').verifyVisibleElement( + "have.text", + "Are you sure you want to release this version?" + ); + cy.get(commonSelectors.cancelButton).verifyVisibleElement( + "have.text", + "Cancel" + ); + cy.get(commonSelectors.yesButton).verifyVisibleElement("have.text", "Yes"); + + cy.get(commonSelectors.closeButton).click(); + cy.get(multiEnvSelector.currentEnvName).verifyVisibleElement( + "have.text", + "Production" + ); + + cy.get(commonSelectors.releaseButton).click(); + cy.get(commonSelectors.cancelButton).click(); + cy.get(multiEnvSelector.currentEnvName).verifyVisibleElement( + "have.text", + "Production" + ); + + cy.get(commonSelectors.releaseButton).click(); + cy.get(commonSelectors.yesButton).click(); + cy.verifyToastMessage(commonSelectors.toastMessage, "Version v1 released"); + cy.wait(500); + cy.get(commonSelectors.warningText).eq(0).verifyVisibleElement( + "have.text", + "This version of the app is released. Please create a new version in development to make any changes." + ); + cy.get('[data-cy="v1-current-version-text"]').click(); + verifyTooltip( + multiEnvSelector.createNewVersionButton, + "New versions can only be created in development" + ); + cy.get(".datasource-picker").should("have.class", "disabled"); + cy.get(commonEeSelectors.AddQueryButton).should("be.disabled"); + cy.get(".components-container").should("have.class", "disabled"); + cy.get(commonSelectors.releaseButton).should("be.disabled"); + }); +}); diff --git a/cypress-tests/cypress/e2e/happyPath/platform/eeTestcases/externalApi/workspace/ldapOnboarding.cy.js b/cypress-tests/cypress/e2e/happyPath/platform/eeTestcases/workspace/ldapOnboarding.cy.js similarity index 100% rename from cypress-tests/cypress/e2e/happyPath/platform/eeTestcases/externalApi/workspace/ldapOnboarding.cy.js rename to cypress-tests/cypress/e2e/happyPath/platform/eeTestcases/workspace/ldapOnboarding.cy.js diff --git a/cypress-tests/cypress/e2e/happyPath/platform/eeTestcases/externalApi/workspace/openId.cy.js b/cypress-tests/cypress/e2e/happyPath/platform/eeTestcases/workspace/openId.cy.js similarity index 100% rename from cypress-tests/cypress/e2e/happyPath/platform/eeTestcases/externalApi/workspace/openId.cy.js rename to cypress-tests/cypress/e2e/happyPath/platform/eeTestcases/workspace/openId.cy.js diff --git a/cypress-tests/cypress/support/utils/apps.js b/cypress-tests/cypress/support/utils/apps.js index ff791d6d6c..4041c3ce46 100644 --- a/cypress-tests/cypress/support/utils/apps.js +++ b/cypress-tests/cypress/support/utils/apps.js @@ -1,112 +1,132 @@ import { commonSelectors, commonWidgetSelector } from "Selectors/common"; +import { appPromote } from "Support/utils/platform/multiEnv"; const slugValidations = [ - { input: "", error: "App slug can't be empty" }, - { input: "_2#", error: "Special characters are not accepted." }, - { input: "t ", error: "Cannot contain spaces" }, - { input: "T", error: "Only lowercase letters are accepted." }, + { input: "", error: "App slug can't be empty" }, + { input: "_2#", error: "Special characters are not accepted." }, + { input: "t ", error: "Cannot contain spaces" }, + { input: "T", error: "Only lowercase letters are accepted." }, ]; export const verifySlugValidations = (inputSelector) => { - slugValidations.forEach(({ input, error }) => { - cy.get(inputSelector).clear(); - if (input) cy.clearAndType(inputSelector, input); - cy.wait(500); - cy.get(commonWidgetSelector.appSlugErrorLabel).verifyVisibleElement( - "have.text", - error - ); - }); + slugValidations.forEach(({ input, error }) => { + cy.get(inputSelector).clear(); + if (input) cy.clearAndType(inputSelector, input); + cy.wait(500); + cy.get(commonWidgetSelector.appSlugErrorLabel).verifyVisibleElement( + "have.text", + error + ); + }); }; export const verifySuccessfulSlugUpdate = (workspaceId, slug) => { - const host = resolveHost(); - cy.get('[data-cy="app-slug-accepted-label"]').verifyVisibleElement( - "have.text", - "Slug accepted!" - ); + const host = resolveHost(); + cy.get('[data-cy="app-slug-accepted-label"]').verifyVisibleElement( + "have.text", + "Slug accepted!" + ); - cy.wait(500); - // cy.get(commonWidgetSelector.appLinkSucessLabel).should('be.visible'); - cy.get(commonWidgetSelector.appLinkSucessLabel).should( - "have.text", - "Link updated successfully!" - ); - cy.get(commonWidgetSelector.appLinkField).verifyVisibleElement( - "have.text", - `${host}/${workspaceId}/apps/${slug}` - ); + cy.wait(500); + // cy.get(commonWidgetSelector.appLinkSucessLabel).should('be.visible'); + cy.get(commonWidgetSelector.appLinkSucessLabel).should( + "have.text", + "Link updated successfully!" + ); + cy.get(commonWidgetSelector.appLinkField).verifyVisibleElement( + "have.text", + `${host}/${workspaceId}/apps/${slug}` + ); }; export const verifyURLs = (workspaceId, slug, page) => { - const baseUrl = Cypress.config("baseUrl"); + const baseUrl = Cypress.config("baseUrl"); - cy.url().should( - "eq", - page - ? `${baseUrl}/${workspaceId}/apps/${slug}/home` - : `${baseUrl}/${workspaceId}/apps/${slug}` - ); + cy.url().should( + "eq", + page + ? `${baseUrl}/${workspaceId}/apps/${slug}/home` + : `${baseUrl}/${workspaceId}/apps/${slug}` + ); - cy.openInCurrentTab(commonWidgetSelector.previewButton); + cy.openInCurrentTab(commonWidgetSelector.previewButton); + cy.ifEnv("Community", () => { cy.url().should("eq", `${baseUrl}/applications/${slug}/home?version=v1`); + }); + cy.ifEnv("Enterprise", () => { + cy.url().should( + "eq", + `${baseUrl}/applications/${slug}/home?env=production&version=v1` + ); + }); - cy.visit("/my-workspace"); - cy.visitSlug({ - actualUrl: `${baseUrl}/applications/${slug}`, - }); - cy.url().should("eq", `${baseUrl}/applications/${slug}`); + cy.visit("/my-workspace"); + cy.visitSlug({ + actualUrl: `${baseUrl}/applications/${slug}`, + }); + cy.url().should("eq", `${baseUrl}/applications/${slug}`); }; export const setUpSlug = (slug) => { - cy.get(commonWidgetSelector.shareAppButton).click(); - cy.clearAndType(commonWidgetSelector.appNameSlugInput, slug); - cy.get('[data-cy="app-slug-accepted-label"]') - .should("be.visible") - .and("have.text", "Slug accepted!"); - cy.get(commonWidgetSelector.modalCloseButton).click(); + cy.get(commonWidgetSelector.shareAppButton).click(); + cy.clearAndType(commonWidgetSelector.appNameSlugInput, slug); + cy.get('[data-cy="app-slug-accepted-label"]') + .should("be.visible") + .and("have.text", "Slug accepted!"); + cy.get(commonWidgetSelector.modalCloseButton).click(); }; export const setupAppWithSlug = (appName, slug) => { - cy.apiCreateApp(appName); - cy.apiAddComponentToApp(appName, "text1"); - cy.apiReleaseApp(appName); - cy.apiAddAppSlug(appName, slug); + cy.apiCreateApp(appName); + cy.apiAddComponentToApp(appName, "text1"); + + cy.ifEnv("Enterprise", () => { + cy.openApp( + "", + Cypress.env("workspaceId"), + Cypress.env("appId"), + commonWidgetSelector.draggableWidget("text1") + ); + appPromote("development", "production"); + }); + + cy.apiReleaseApp(appName); + cy.apiAddAppSlug(appName, slug); }; export const verifyRestrictedAccess = () => { - cy.get('[data-cy="modal-header"]').should("have.text", "Restricted access"); - cy.get('[data-cy="modal-description"]') - .invoke("text") - .then((text) => { - const normalizedText = text.replace(/’/g, "'"); - expect(normalizedText).to.equal( - "You don't have access to this app. Kindly contact admin to know more." - ); - }); - cy.get('[data-cy="back-to-home-button"]').verifyVisibleElement( - "have.text", - "Back to home page" - ); + cy.get('[data-cy="modal-header"]').should("have.text", "Restricted access"); + cy.get('[data-cy="modal-description"]') + .invoke("text") + .then((text) => { + const normalizedText = text.replace(/’/g, "'"); + expect(normalizedText).to.equal( + "You don't have access to this app. Kindly contact admin to know more." + ); + }); + cy.get('[data-cy="back-to-home-button"]').verifyVisibleElement( + "have.text", + "Back to home page" + ); }; export const onboardUserFromAppLink = ( - email, - slug, - workspaceName = "My workspace", - isNonExistingUser = true + email, + slug, + workspaceName = "My workspace", + isNonExistingUser = true ) => { - const dbConfig = Cypress.env("app_db"); + const dbConfig = Cypress.env("app_db"); - const query = isNonExistingUser - ? ` + const query = isNonExistingUser + ? ` SELECT u.invitation_token, o.id AS workspace_id, ou.invitation_token AS organization_token FROM users u JOIN organization_users ou ON u.id = ou.user_id JOIN organizations o ON ou.organization_id = o.id WHERE u.email = '${email}' AND o.name = '${workspaceName}'; ` - : ` + : ` SELECT ou.invitation_token, o.id AS workspace_id FROM users u JOIN organization_users ou ON u.id = ou.user_id @@ -114,33 +134,33 @@ export const onboardUserFromAppLink = ( WHERE u.email = '${email}' AND o.name = '${workspaceName}'; `; - cy.task("dbConnection", { dbconfig: dbConfig, sql: query }).then((resp) => { - if (!resp.rows || resp.rows.length === 0) { - throw new Error( - `No records found for email: ${email} and workspace: ${workspaceName}` - ); - } + cy.task("dbConnection", { dbconfig: dbConfig, sql: query }).then((resp) => { + if (!resp.rows || resp.rows.length === 0) { + throw new Error( + `No records found for email: ${email} and workspace: ${workspaceName}` + ); + } - const { invitation_token, workspace_id, organization_token } = resp.rows[0]; - const token = isNonExistingUser ? organization_token : invitation_token; - const url = isNonExistingUser - ? `${Cypress.config("baseUrl")}/invitations/${invitation_token}/workspaces/${organization_token}?oid=${workspace_id}&redirectTo=%2Fapplications%2F${slug}` - : `${Cypress.config("baseUrl")}/organization-invitations/${token}?oid=${workspace_id}&redirectTo=%2Fapplications%2F${slug}`; + const { invitation_token, workspace_id, organization_token } = resp.rows[0]; + const token = isNonExistingUser ? organization_token : invitation_token; + const url = isNonExistingUser + ? `${Cypress.config("baseUrl")}/invitations/${invitation_token}/workspaces/${organization_token}?oid=${workspace_id}&redirectTo=%2Fapplications%2F${slug}` + : `${Cypress.config("baseUrl")}/organization-invitations/${token}?oid=${workspace_id}&redirectTo=%2Fapplications%2F${slug}`; - cy.visit(url); - }); + cy.visit(url); + }); }; export const resolveHost = () => { - const baseUrl = Cypress.config("baseUrl"); + const baseUrl = Cypress.config("baseUrl"); - const urlMapping = { - "http://localhost:8082": "http://localhost:8082", - "http://localhost:3000": "http://localhost:3000", - "http://localhost:3000/apps": "http://localhost:3000/apps", - "http://localhost:4001": "http://localhost:3000", - "http://localhost:4001/apps": "http://localhost:3000/apps", - }; + const urlMapping = { + "http://localhost:8082": "http://localhost:8082", + "http://localhost:3000": "http://localhost:3000", + "http://localhost:3000/apps": "http://localhost:3000/apps", + "http://localhost:4001": "http://localhost:3000", + "http://localhost:4001/apps": "http://localhost:3000/apps", + }; - return urlMapping[baseUrl]; + return urlMapping[baseUrl]; }; diff --git a/cypress-tests/cypress/support/utils/basicComponents.js b/cypress-tests/cypress/support/utils/basicComponents.js index cae63f493d..9577dee022 100644 --- a/cypress-tests/cypress/support/utils/basicComponents.js +++ b/cypress-tests/cypress/support/utils/basicComponents.js @@ -14,9 +14,21 @@ export const verifyComponent = (widgetName) => { }; export const verifyComponentinrightpannel = (widgetName) => { - cy.get(commonWidgetSelector.widgetBox(widgetName), { - timeout: 10000, - }).should("be.visible"); + cy.get("body") + .then(($body) => { + const isSearchVisible = $body + .find(commonSelectors.searchField) + .is(":visible"); + + if (!isSearchVisible) { + cy.get('[data-cy="right-sidebar-plus-button"]').click(); + } + }) + .then(() => { + cy.get(commonWidgetSelector.widgetBox(widgetName), { + timeout: 10000, + }).should("be.visible"); + }); }; export const deleteComponentAndVerify = (widgetName) => { diff --git a/cypress-tests/cypress/support/utils/common.js b/cypress-tests/cypress/support/utils/common.js index 7a8c55ffcf..63f188db61 100644 --- a/cypress-tests/cypress/support/utils/common.js +++ b/cypress-tests/cypress/support/utils/common.js @@ -6,6 +6,7 @@ import moment from "moment"; import { dashboardSelector } from "Selectors/dashboard"; import { groupsSelector } from "Selectors/manageGroups"; import { groupsText } from "Texts/manageGroups"; +import { appPromote } from "Support/utils/platform/multiEnv"; export const navigateToProfile = () => { cy.get(commonSelectors.settingsIcon).click(); @@ -48,7 +49,7 @@ export const randomDateOrTime = (format = "DD/MM/YYYY") => { let startDate = new Date(2018, 0, 1); startDate = new Date( startDate.getTime() + - Math.random() * (endDate.getTime() - startDate.getTime()) + Math.random() * (endDate.getTime() - startDate.getTime()) ); return moment(startDate).format(format); }; @@ -104,7 +105,7 @@ export const viewAppCardOptions = (appName) => { cy.get(commonSelectors.appCard(appName)) .realHover() .find(commonSelectors.appCardOptionsButton) - .realHover() + .realHover(); cy.contains("div", appName) .parent() .within(() => { @@ -230,6 +231,9 @@ export const navigateToworkspaceConstants = () => { }; export const releaseApp = () => { + cy.ifEnv("Enterprise", () => { + appPromote("development", "production"); + }); cy.get(commonSelectors.releaseButton).click(); cy.get(commonSelectors.yesButton).click(); cy.verifyToastMessage(commonSelectors.toastMessage, "Version v1 released"); diff --git a/cypress-tests/cypress/support/utils/onboarding.js b/cypress-tests/cypress/support/utils/onboarding.js index 182e890acd..74d5c77d3d 100644 --- a/cypress-tests/cypress/support/utils/onboarding.js +++ b/cypress-tests/cypress/support/utils/onboarding.js @@ -92,7 +92,7 @@ export const userSignUp = (fullName, email, workspaceName = "test") => { cy.visit(invitationLink); cy.wait(2500); }); - if (Cypress.env("environment") !== "Community") { + if (Cypress.env("environment") == "Cloud") { cy.clearAndType( '[data-cy="onboarding-workspace-name-input"]', workspaceName diff --git a/cypress-tests/cypress/support/utils/platform/multiEnv.js b/cypress-tests/cypress/support/utils/platform/multiEnv.js new file mode 100644 index 0000000000..be72c3ae53 --- /dev/null +++ b/cypress-tests/cypress/support/utils/platform/multiEnv.js @@ -0,0 +1,120 @@ +import { multiEnvSelector, commonEeSelectors } from "Selectors/eeCommon"; +import { commonSelectors, commonWidgetSelector } from "Selectors/common"; +import { appVersionSelectors } from "Selectors/exportImport"; +import { appVersionText } from "Texts/exportImport"; + +export const promoteApp = () => { + cy.get(commonEeSelectors.promoteButton).click(); + cy.get(commonEeSelectors.promoteButton).eq(1).click(); + cy.waitForAppLoad(); + cy.wait(3000); +}; + +export const releaseApp = () => { + cy.get(commonSelectors.releaseButton).click(); + cy.get(commonSelectors.yesButton).click(); + cy.verifyToastMessage(commonSelectors.toastMessage, "Version v1 released"); + cy.wait(500); +}; + +export const launchApp = () => { + cy.url().then((url) => { + const parts = url.split("/"); + const value = parts[parts.length - 1]; + cy.log(`Extracted value: ${value}`); + cy.visit(`/applications/${value}`); + cy.wait(3000); + }); +}; + +export const appPromote = (fromEnv, toEnv) => { + const commonActions = () => { + cy.get(commonEeSelectors.promoteButton).click(); + cy.get(commonEeSelectors.promoteButton).eq(1).click(); + cy.waitForAppLoad(); + cy.wait(2000); + }; + + const transitions = { + development: { + staging: commonActions, + production: () => { + commonActions(); + appPromote("staging", "production"); + }, + release: () => { + commonActions(); + commonActions(); + cy.get(commonSelectors.releaseButton).click(); + cy.get(commonSelectors.yesButton).click(); + cy.wait(500); + }, + }, + staging: { + production: commonActions, + release: () => { + commonActions(); + cy.get(commonSelectors.releaseButton).click(); + cy.get(commonSelectors.yesButton).click(); + cy.wait(500); + }, + }, + }; + + const transition = transitions[fromEnv]?.[toEnv]; + + transition(); +}; + +export const createNewVersion = (value, newVersion = [], version) => { + cy.get('[data-cy="list-current-env-name"]').click(); + cy.get(multiEnvSelector.envNameList).eq(0).click(); + cy.get(appVersionSelectors.currentVersionField(value)).click(); + cy.get(appVersionSelectors.createNewVersionButton).click(); + cy.get(appVersionSelectors.createVersionInputField).click(); + cy.contains(`[id*="react-select-"]`, version).click(); + cy.get(appVersionSelectors.versionNameInputField).click().type(newVersion[0]); + cy.get(appVersionSelectors.createNewVersionButton).click(); + cy.waitForAppLoad(); + cy.verifyToastMessage( + commonSelectors.toastMessage, + appVersionText.createdToastMessage + ); + cy.get(appVersionSelectors.currentVersionField(newVersion[0])).should( + "be.visible" + ); +}; + +export const selectVersion = (value, newVersion = []) => { + cy.get(appVersionSelectors.currentVersionField(value)).click(); + cy.get(".react-select__menu-list .app-version-name") + .contains(newVersion[0]) + .click(); + cy.waitForAppLoad(); +}; + +export const selectEnv = (envName) => { + const envIndex = { + development: 0, + staging: 1, + production: 2, + }[envName]; + + const isValidEnvName = (envName) => { + return ( + envName === "development" || + envName === "staging" || + envName === "production" + ); + }; + + if (isValidEnvName(envName)) { + cy.get('[data-cy="list-current-env-name"]').click(); + cy.wait(500) + const envSelector = `${multiEnvSelector.envNameList}:eq(${envIndex})`; + cy.get(envSelector).click(); + cy.waitForAppLoad(); + } +}; + + diff --git a/cypress-tests/cypress/support/utils/queries.js b/cypress-tests/cypress/support/utils/queries.js index 478d4498fd..83e8df0e2e 100644 --- a/cypress-tests/cypress/support/utils/queries.js +++ b/cypress-tests/cypress/support/utils/queries.js @@ -11,8 +11,9 @@ export const selectQueryFromLandingPage = (dbName, label) => { }; export const deleteQuery = (queryName) => { - cy.get(`[data-cy="list-query-${queryName}"]`).realHover(); + cy.get(`[data-cy="list-query-${queryName}"]`).click(); cy.get(`[data-cy="delete-query-${queryName}"]`).click(); + cy.get('[data-cy="component-inspector-delete-button"]').click() }; export const query = (action) => { diff --git a/cypress-tests/cypress/support/utils/version.js b/cypress-tests/cypress/support/utils/version.js index b15d84f45b..c6d79853fb 100644 --- a/cypress-tests/cypress/support/utils/version.js +++ b/cypress-tests/cypress/support/utils/version.js @@ -9,6 +9,7 @@ import { } from "Selectors/version"; import { deleteVersionText, releasedVersionText } from "Texts/version"; import { verifyComponent } from "Support/utils/basicComponents"; +import { appPromote } from "./platform/multiEnv"; export const navigateToCreateNewVersionModal = (value) => { cy.get(appVersionSelectors.appVersionLabel).click(); @@ -121,6 +122,9 @@ export const verifyDuplicateVersion = (newVersion = [], version) => { }; export const releasedVersionAndVerify = (currentVersion) => { + cy.ifEnv("Enterprise", () => { + appPromote("development", "production"); + }); cy.contains("Release").click(); cy.get(confirmVersionModalSelectors.yesButton).click(); diff --git a/docker/cloud/cloud-entrypoint.sh b/docker/cloud/cloud-entrypoint.sh old mode 100644 new mode 100755 diff --git a/docker/cloud/cloud-production.Dockerfile b/docker/cloud/cloud-production.Dockerfile index a2e36bedb3..d55671a5be 100644 --- a/docker/cloud/cloud-production.Dockerfile +++ b/docker/cloud/cloud-production.Dockerfile @@ -1,14 +1,33 @@ -FROM node:18.18.2-buster AS builder +FROM node:22.15.1 AS builder # Fix for JS heap limit allocation issue ENV NODE_OPTIONS="--max-old-space-size=4096" -RUN npm i -g npm@9.8.1 +RUN npm i -g npm@10.9.2 RUN mkdir -p /app -# RUN npm cache clean --force +RUN npm cache clean --force WORKDIR /app +# Set GitHub token and branch as build arguments +ARG CUSTOM_GITHUB_TOKEN +ARG BRANCH_NAME + +# Clone and checkout the frontend repository +RUN git config --global url."https://x-access-token:${CUSTOM_GITHUB_TOKEN}@github.com/".insteadOf "https://github.com/" + +RUN git config --global http.version HTTP/1.1 +RUN git config --global http.postBuffer 524288000 +RUN git clone https://github.com/ToolJet/ToolJet.git . + +# The branch name needs to be changed the branch with modularisation in CE repo +RUN git checkout ${BRANCH_NAME} + +RUN git submodule update --init --recursive + +# Checkout the same branch in submodules if it exists, otherwise stay on default branch +RUN git submodule foreach 'git checkout ${BRANCH_NAME} || true' + # Scripts for building COPY ./package.json ./package.json @@ -19,6 +38,8 @@ COPY ./plugins/ ./plugins/ RUN NODE_ENV=production npm --prefix plugins run build RUN npm --prefix plugins prune --production +ENV TOOLJET_EDITION=cloud + # Build frontend COPY ./frontend/package.json ./frontend/package-lock.json ./frontend/ RUN npm --prefix frontend install @@ -26,32 +47,34 @@ COPY ./frontend/ ./frontend/ RUN npm --prefix frontend run build --production RUN npm --prefix frontend prune --production +ENV TOOLJET_EDITION=cloud ENV NODE_ENV=production # Build server COPY ./server/package.json ./server/package-lock.json ./server/ RUN npm --prefix server install COPY ./server/ ./server/ -RUN npm install -g @nestjs/cli +RUN npm install -g @nestjs/cli +RUN npm install -g copyfiles RUN npm --prefix server run build -FROM debian:11 +FROM debian:12 RUN apt-get update -yq \ && apt-get install curl gnupg zip -yq \ && apt-get install -yq build-essential \ && apt-get clean -y - -RUN curl -O https://nodejs.org/dist/v18.18.2/node-v18.18.2-linux-x64.tar.xz \ - && tar -xf node-v18.18.2-linux-x64.tar.xz \ - && mv node-v18.18.2-linux-x64 /usr/local/lib/nodejs \ +RUN curl -O https://nodejs.org/dist/v22.15.1/node-v22.15.1-linux-x64.tar.xz \ + && tar -xf node-v22.15.1-linux-x64.tar.xz \ + && mv node-v22.15.1-linux-x64 /usr/local/lib/nodejs \ && echo 'export PATH="/usr/local/lib/nodejs/bin:$PATH"' >> /etc/profile.d/nodejs.sh \ && /bin/bash -c "source /etc/profile.d/nodejs.sh" \ - && rm node-v18.18.2-linux-x64.tar.xz + && rm node-v22.15.1-linux-x64.tar.xz ENV PATH=/usr/local/lib/nodejs/bin:$PATH ENV NODE_ENV=production +ENV TOOLJET_EDITION=cloud ENV NODE_OPTIONS="--max-old-space-size=4096" RUN apt-get update && \ apt-get install -y postgresql-client freetds-dev libaio1 wget && \ @@ -79,21 +102,23 @@ RUN mkdir -p /app # copy npm scripts COPY --from=builder /app/package.json ./app/package.json - # copy plugins dependencies COPY --from=builder /app/plugins/dist ./app/plugins/dist COPY --from=builder /app/plugins/client.js ./app/plugins/client.js COPY --from=builder /app/plugins/node_modules ./app/plugins/node_modules COPY --from=builder /app/plugins/packages/common ./app/plugins/packages/common COPY --from=builder /app/plugins/package.json ./app/plugins/package.json - +# copy frontend build +COPY --from=builder /app/frontend/build ./app/frontend/build # copy server build COPY --from=builder /app/server/package.json ./app/server/package.json COPY --from=builder /app/server/.version ./app/server/.version +COPY --from=builder /app/server/ee/keys ./app/server/ee/keys COPY --from=builder /app/server/node_modules ./app/server/node_modules COPY --from=builder /app/server/templates ./app/server/templates COPY --from=builder /app/server/scripts ./app/server/scripts COPY --from=builder /app/server/dist ./app/server/dist +COPY --from=builder /app/server/src/assets ./app/server/src/assets COPY ./docker/cloud/cloud-entrypoint.sh ./app/server/cloud-entrypoint.sh @@ -118,4 +143,4 @@ WORKDIR /app # Dependencies for scripts outside nestjs RUN npm install dotenv@10.0.0 joi@17.4.1 -ENTRYPOINT ["./server/cloud-entrypoint.sh"] +ENTRYPOINT ["./server/cloud-entrypoint.sh"] \ No newline at end of file diff --git a/docker/cloud/cloud-server.Dockerfile b/docker/cloud/cloud-server.Dockerfile index 96a9fe0f01..7c04f8e567 100644 --- a/docker/cloud/cloud-server.Dockerfile +++ b/docker/cloud/cloud-server.Dockerfile @@ -1,16 +1,18 @@ -FROM node:18.18.2-buster as builder +FROM node:22.15.1 AS builder # Fix for JS heap limit allocation issue ENV NODE_OPTIONS="--max-old-space-size=4096" -RUN npm i -g npm@9.8.1 +RUN npm i -g npm@10.9.2 +RUN npm cache clean --force RUN npm install -g @nestjs/cli RUN mkdir -p /app WORKDIR /app +# Set GitHub token and branch as build arguments ARG CUSTOM_GITHUB_TOKEN -ARG BRANCH_NAME=main +ARG BRANCH_NAME # Clone and checkout the frontend repository RUN git config --global url."https://x-access-token:${CUSTOM_GITHUB_TOKEN}@github.com/".insteadOf "https://github.com/" @@ -37,28 +39,32 @@ ENV NODE_ENV=production RUN npm --prefix plugins run build RUN npm --prefix plugins prune --production +ENV TOOLJET_EDITION=cloud +ENV NODE_ENV=production + # Building ToolJet server COPY ./server/package.json ./server/package-lock.json ./server/ RUN npm --prefix server install --only=production COPY ./server/ ./server/ RUN npm --prefix server run build -FROM debian:11 +FROM debian:12 RUN apt-get update -yq \ && apt-get install curl gnupg zip -yq \ && apt-get install -yq build-essential \ && apt-get clean -y -RUN curl -O https://nodejs.org/dist/v18.18.2/node-v18.18.2-linux-x64.tar.xz \ - && tar -xf node-v18.18.2-linux-x64.tar.xz \ - && mv node-v18.18.2-linux-x64 /usr/local/lib/nodejs \ +RUN curl -O https://nodejs.org/dist/v22.15.1/node-v22.15.1-linux-x64.tar.xz \ + && tar -xf node-v22.15.1-linux-x64.tar.xz \ + && mv node-v22.15.1-linux-x64 /usr/local/lib/nodejs \ && echo 'export PATH="/usr/local/lib/nodejs/bin:$PATH"' >> /etc/profile.d/nodejs.sh \ && /bin/bash -c "source /etc/profile.d/nodejs.sh" \ - && rm node-v18.18.2-linux-x64.tar.xz + && rm node-v22.15.1-linux-x64.tar.xz ENV PATH=/usr/local/lib/nodejs/bin:$PATH ENV NODE_ENV=production +ENV TOOLJET_EDITION=cloud ENV NODE_OPTIONS="--max-old-space-size=4096" RUN apt-get update && apt-get install -y postgresql-client freetds-dev libaio1 wget @@ -91,10 +97,12 @@ COPY --from=builder /app/plugins/package.json ./app/plugins/package.json # copy server build COPY --from=builder /app/server/package.json ./app/server/package.json COPY --from=builder /app/server/.version ./app/server/.version +COPY --from=builder /app/server/keys ./app/server/keys COPY --from=builder /app/server/node_modules ./app/server/node_modules COPY --from=builder /app/server/templates ./app/server/templates COPY --from=builder /app/server/scripts ./app/server/scripts COPY --from=builder /app/server/dist ./app/server/dist +COPY --from=builder /app/server/src/assets ./app/server/src/assets COPY ./docker/cloud/cloud-entrypoint.sh ./app/server/cloud-entrypoint.sh @@ -105,18 +113,25 @@ RUN useradd --create-home --home-dir /home/appuser appuser \ && chmod u+x /app \ && chmod -R g=u /app +RUN mkdir -p /home/appuser/.npm/_cacache \ + mkdir -p /home/appuser/.npm_cache_tmp \ + mkdir -p /home/appuser/.npm/_logs \ + && chown -R appuser:0 /home/appuser/.npm \ + && chmod g+s /home/appuser/.npm_cache_tmp + # Set npm cache directory -ENV npm_config_cache /home/appuser/.npm +RUN npm config set cache /tmp/npm-cache --global +ENV npm_config_cache /tmp/npm-cache ENV HOME=/home/appuser -# Installing git for simple git commands -RUN apt-get update && apt-get install -y git && apt-get clean - +# Switch back to appuser USER appuser WORKDIR /app # Dependencies for scripts outside nestjs RUN npm install dotenv@10.0.0 joi@17.4.1 +RUN npm cache clean --force + ENTRYPOINT ["./server/cloud-entrypoint.sh"] diff --git a/docker/ee/ee-production.Dockerfile b/docker/ee/ee-production.Dockerfile index 337bafb476..f058b911d5 100644 --- a/docker/ee/ee-production.Dockerfile +++ b/docker/ee/ee-production.Dockerfile @@ -3,15 +3,14 @@ FROM node:22.15.1 AS builder # Fix for JS heap limit allocation issue ENV NODE_OPTIONS="--max-old-space-size=4096" -RUN npm i -g npm@10.9.2 -RUN mkdir -p /app -RUN npm cache clean --force +RUN npm i -g npm@10.9.2 && npm cache clean --force +RUN mkdir -p /app WORKDIR /app # Set GitHub token and branch as build arguments ARG CUSTOM_GITHUB_TOKEN -ARG BRANCH_NAME=main +ARG BRANCH_NAME # Clone and checkout the frontend repository RUN git config --global url."https://x-access-token:${CUSTOM_GITHUB_TOKEN}@github.com/".insteadOf "https://github.com/" @@ -21,7 +20,7 @@ RUN git config --global http.postBuffer 524288000 RUN git clone https://github.com/ToolJet/ToolJet.git . # The branch name needs to be changed the branch with modularisation in CE repo -RUN git checkout main +RUN git checkout ${BRANCH_NAME} RUN git submodule update --init --recursive @@ -33,10 +32,10 @@ COPY ./package.json ./package.json # Build plugins COPY ./plugins/package.json ./plugins/package-lock.json ./plugins/ -RUN npm --prefix plugins install +RUN npm --prefix plugins ci --omit=dev COPY ./plugins/ ./plugins/ RUN NODE_ENV=production npm --prefix plugins run build -RUN npm --prefix plugins prune --production +RUN npm --prefix plugins prune --omit=dev ENV TOOLJET_EDITION=ee @@ -44,74 +43,53 @@ ENV TOOLJET_EDITION=ee COPY ./frontend/package.json ./frontend/package-lock.json ./frontend/ RUN npm --prefix frontend install COPY ./frontend/ ./frontend/ -RUN npm --prefix frontend run build --production -RUN npm --prefix frontend prune --production +RUN npm --prefix frontend run build --production && npm --prefix frontend prune --production ENV NODE_ENV=production ENV TOOLJET_EDITION=ee # Build server COPY ./server/package.json ./server/package-lock.json ./server/ -RUN npm --prefix server install +RUN npm --prefix server ci --omit=dev COPY ./server/ ./server/ -RUN npm install -g @nestjs/cli +RUN npm install -g @nestjs/cli RUN npm install -g copyfiles RUN npm --prefix server run build +RUN npm prune --production --prefix server -FROM debian:12 - -RUN apt-get update -yq \ - && apt-get install curl wget gnupg zip -yq \ - && apt-get install -yq build-essential \ - && apt -y install redis \ - && apt-get clean -y - -# Install required dependencies for downloading and extracting files +# Install dependencies for PostgREST, curl, unzip, etc. RUN apt-get update && apt-get install -y \ - curl tar xz-utils postgresql postgresql-contrib postgresql-client && \ - apt-get clean && rm -rf /var/lib/apt/lists/* + curl ca-certificates unzip tar \ + && rm -rf /var/lib/apt/lists/* -# Install PostgREST from official Docker image -COPY --from=postgrest/postgrest:v12.2.0 /bin/postgrest /bin +ENV POSTGREST_VERSION=v12.2.0 -RUN apt-get update && apt-get install -y supervisor +RUN curl -Lo postgrest.tar.xz https://github.com/PostgREST/postgrest/releases/download/${POSTGREST_VERSION}/postgrest-v12.2.0-linux-static-x64.tar.xz && \ + tar -xf postgrest.tar.xz && \ + mv postgrest /postgrest && \ + rm postgrest.tar.xz && \ + chmod +x /postgrest -# Create supervisord configuration file -RUN echo "[supervisord]\n" \ - "nodaemon=true\n" \ - "\n" \ - "[program:postgrest]\n" \ - "command=/bin/postgrest\n" \ - "autostart=true\n" \ - "autorestart=true\n" \ - "stdout_logfile=/dev/stdout\n" \ - "stderr_logfile=/dev/stderr\n" \ - "stdout_logfile_maxbytes=0\n" \ - "stderr_logfile_maxbytes=0\n" \ - "\n" \ - "[program:neo4j]\n" \ - "command=neo4j console\n" \ - "autostart=true\n" \ - "autorestart=unexpected\n" \ - "startsecs=30\n" \ - "startretries=999\n" \ - "priority=90\n" \ - "exitcodes=0,1,2\n" \ - "stopsignal=SIGTERM\n" \ - "stopasgroup=true\n" \ - "killasgroup=true\n" \ - "redirect_stderr=true\n" \ - "stdout_logfile=/var/log/neo4j/neo4j.log\n" \ - "stdout_logfile_backups=10\n" \ - "stderr_capture_maxbytes=20MB\n" \ - "\n" | sed 's/ //' > /etc/supervisor/conf.d/supervisord.conf +FROM debian:12-slim -# Create a wrapper for PostgREST to prefix its logs -RUN mv /bin/postgrest /bin/postgrest-original && \ - echo '#!/bin/bash\n\ -exec /bin/postgrest-original "$@" 2>&1 | sed "s/^/[PostgREST] /"\n\ -' > /bin/postgrest && \ - chmod +x /bin/postgrest +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + curl \ + wget \ + gnupg \ + unzip \ + ca-certificates \ + xz-utils \ + tar \ + zip \ + postgresql-client \ + redis \ + libaio1 \ + git \ + freetds-dev \ + && apt-get upgrade -y -o Dpkg::Options::="--force-confold" \ + && apt-get autoremove -y \ + && apt-get clean && rm -rf /var/lib/apt/lists/* RUN curl -O https://nodejs.org/dist/v22.15.1/node-v22.15.1-linux-x64.tar.xz \ @@ -125,53 +103,18 @@ ENV PATH=/usr/local/lib/nodejs/bin:$PATH ENV NODE_ENV=production ENV TOOLJET_EDITION=ee ENV NODE_OPTIONS="--max-old-space-size=4096" -RUN apt-get update && \ - apt-get install -y postgresql-client freetds-dev libaio1 wget && \ - apt-get -o Dpkg::Options::="--force-confold" upgrade -q -y --force-yes && \ - apt-get -y autoremove && \ - apt-get -y autoclean -# Install Neo4j +# Install Neo4j + APOC RUN wget -O - https://debian.neo4j.com/neotechnology.gpg.key | apt-key add - && \ echo "deb https://debian.neo4j.com stable 5" > /etc/apt/sources.list.d/neo4j.list && \ - apt-get update && \ - apt-get install -y neo4j=1:5.26.6 && \ - apt-mark hold neo4j && \ - apt-get clean && rm -rf /var/lib/apt/lists/* - -# Set the necessary Neo4j environment variables -ENV NEO4J_HOME=/opt/neo4j -ENV NEO4J_CONF=/etc/neo4j -ENV NEO4J_DATA=/var/lib/neo4j/data -ENV NEO4J_LOG=/var/log/neo4j -ENV NEO4J_PLUGIN=/var/lib/neo4j/plugins -ENV NEO4J_IMPORT=/var/lib/neo4j/import - -# Create the necessary directories for Neo4j -RUN mkdir -p /data/db /data/logs /data/plugins -RUN mkdir -p /opt/neo4j/plugins - -# Configure APOC plugin for Neo4j -ENV NEO4J_dbms_active_plugins=apoc - -# Download and install APOC plugin for Neo4j 5.x (BEFORE creating user) -RUN mkdir -p /var/lib/neo4j/plugins && \ + apt-get update && apt-get install -y neo4j=1:5.26.6 && apt-mark hold neo4j && \ + mkdir -p /var/lib/neo4j/plugins && \ wget -P /var/lib/neo4j/plugins https://github.com/neo4j/apoc/releases/download/5.26.6/apoc-5.26.6-core.jar && \ - # Try to download extended version - (wget -P /var/lib/neo4j/plugins https://github.com/neo4j/apoc/releases/download/5.26.6/apoc-5.26.6-extended.jar || \ - wget -P /var/lib/neo4j/plugins https://neo4j-contrib.github.io/neo4j-apoc-procedures/5.26.6/apoc-5.26.6-extended.jar || \ - echo "Extended JAR not available, continuing with core only") - -# Configure Neo4j with APOC -RUN echo "dbms.security.procedures.unrestricted=apoc.*" >> /etc/neo4j/neo4j.conf && \ + echo "dbms.security.procedures.unrestricted=apoc.*" >> /etc/neo4j/neo4j.conf && \ echo "dbms.security.procedures.allowlist=apoc.*,algo.*,gds.*" >> /etc/neo4j/neo4j.conf && \ - echo "dbms.directories.plugins=/var/lib/neo4j/plugins" >> /etc/neo4j/neo4j.conf - -# Configure Neo4j to use authentication -RUN if [ -f "/etc/neo4j/neo4j.conf" ]; then \ - sed -i '/dbms.security.auth_enabled/d' /etc/neo4j/neo4j.conf && \ - echo "dbms.security.auth_enabled=true" >> /etc/neo4j/neo4j.conf; \ -fi + echo "dbms.directories.plugins=/var/lib/neo4j/plugins" >> /etc/neo4j/neo4j.conf && \ + echo "dbms.security.auth_enabled=true" >> /etc/neo4j/neo4j.conf && \ + apt-get clean && rm -rf /var/lib/apt/lists/* # Install Instantclient Basic Light Oracle and Dependencies WORKDIR /opt/oracle @@ -186,40 +129,39 @@ RUN wget https://tooljet-plugins-production.s3.us-east-2.amazonaws.com/marketpla # Set the Instant Client library paths ENV LD_LIBRARY_PATH="/opt/oracle/instantclient_11_2:/opt/oracle/instantclient_21_10:${LD_LIBRARY_PATH}" +RUN rm -f *.zip *.key && apt-get clean && rm -rf /var/lib/apt/lists/* WORKDIR / RUN mkdir -p /app -# copy npm scripts -COPY --from=builder /app/package.json ./app/package.json -# copy plugins dependencies -COPY --from=builder /app/plugins/dist ./app/plugins/dist -COPY --from=builder /app/plugins/client.js ./app/plugins/client.js -COPY --from=builder /app/plugins/node_modules ./app/plugins/node_modules -COPY --from=builder /app/plugins/packages/common ./app/plugins/packages/common -COPY --from=builder /app/plugins/package.json ./app/plugins/package.json -# copy frontend build -COPY --from=builder /app/frontend/build ./app/frontend/build -# copy server build -COPY --from=builder /app/server/package.json ./app/server/package.json -COPY --from=builder /app/server/.version ./app/server/.version -COPY --from=builder /app/server/ee/keys ./app/server/ee/keys -COPY --from=builder /app/server/node_modules ./app/server/node_modules -COPY --from=builder /app/server/templates ./app/server/templates -COPY --from=builder /app/server/scripts ./app/server/scripts -COPY --from=builder /app/server/dist ./app/server/dist -COPY --from=builder /app/server/src/assets ./app/server/src/assets -COPY ./docker/ee/ee-entrypoint.sh ./app/server/ee-entrypoint.sh +RUN useradd --create-home --home-dir /home/appuser appuser -# Define non-sudo user -RUN useradd --create-home --home-dir /home/appuser appuser \ - && chown -R appuser:0 /app \ - && chown -R appuser:0 /home \ - && chmod u+x /app \ - && chmod u+x /home \ - && chmod -R g=u /app \ - && chmod -R g=u /home +# Use the PostgREST binary from the builder stage +COPY --from=builder --chown=appuser:0 /postgrest /usr/local/bin/postgrest + +RUN mv /usr/local/bin/postgrest /usr/local/bin/postgrest-original && \ + echo '#!/bin/bash\nexec /usr/local/bin/postgrest-original "$@" 2>&1 | sed "s/^/[PostgREST] /"' > /usr/local/bin/postgrest && \ + chmod +x /usr/local/bin/postgrest + + +# Copy application with ownership set directly to avoid chown -R +COPY --from=builder --chown=appuser:0 /app/package.json ./app/package.json +COPY --from=builder --chown=appuser:0 /app/plugins/dist ./app/plugins/dist +COPY --from=builder --chown=appuser:0 /app/plugins/client.js ./app/plugins/client.js +COPY --from=builder --chown=appuser:0 /app/plugins/node_modules ./app/plugins/node_modules +COPY --from=builder --chown=appuser:0 /app/plugins/packages/common ./app/plugins/packages/common +COPY --from=builder --chown=appuser:0 /app/plugins/package.json ./app/plugins/package.json +COPY --from=builder --chown=appuser:0 /app/frontend/build ./app/frontend/build +COPY --from=builder --chown=appuser:0 /app/server/package.json ./app/server/package.json +COPY --from=builder --chown=appuser:0 /app/server/.version ./app/server/.version +COPY --from=builder --chown=appuser:0 /app/server/ee/keys ./app/server/ee/keys +COPY --from=builder --chown=appuser:0 /app/server/node_modules ./app/server/node_modules +COPY --from=builder --chown=appuser:0 /app/server/templates ./app/server/templates +COPY --from=builder --chown=appuser:0 /app/server/scripts ./app/server/scripts +COPY --from=builder --chown=appuser:0 /app/server/dist ./app/server/dist +COPY --from=builder --chown=appuser:0 /app/server/src/assets ./app/server/src/assets +COPY ./docker/ee/ee-entrypoint.sh ./app/server/ee-entrypoint.sh RUN mkdir -p /var/lib/neo4j/data/databases /var/lib/neo4j/data/transactions /var/log/neo4j /opt/neo4j/run && \ chown -R appuser:0 /var/lib/neo4j /var/log/neo4j /etc/neo4j /opt/neo4j/run && \ @@ -258,31 +200,11 @@ RUN mkdir -p /var/lib/redis /var/log/redis /etc/redis \ && chmod g+s /var/lib/redis /var/log/redis /etc/redis \ && chmod -R g=u /var/lib/redis /var/log/redis /etc/redis -# Set permissions for PostgREST binary -RUN chown appuser:0 /bin/postgrest && chmod u+x /bin/postgrest && chmod g=u /bin/postgrest - -RUN touch /tmp/postgrest.conf \ - && chown appuser:0 /tmp/postgrest.conf \ - && chmod 640 /tmp/postgrest.conf - -# Create PostgREST data, log, and configuration directories -RUN mkdir -p /var/lib/postgrest /var/log/postgrest /etc/postgrest \ - && chown -R appuser:0 /var/lib/postgrest /var/log/postgrest /etc/postgrest \ - && chmod g+s /var/lib/postgrest /var/log/postgrest /etc/postgrest \ - && chmod -R g=u /var/lib/postgrest /var/log/postgrest /etc/postgrest - ENV HOME=/home/appuser - -# Installing git for simple git commands -RUN apt-get update && apt-get install -y git && apt-get clean - # Switch back to appuser USER appuser - WORKDIR /app -# Dependencies for scripts outside nestjs -RUN npm install dotenv@10.0.0 joi@17.4.1 -RUN npm cache clean --force +RUN npm install --prefix server --no-save dotenv@10.0.0 joi@17.4.1 && npm cache clean --force ENTRYPOINT ["./server/ee-entrypoint.sh"] diff --git a/docker/ee/ee-try-entrypoint-lts.sh b/docker/ee/ee-try-entrypoint-lts.sh index 27590534d0..f716ae8a20 100755 --- a/docker/ee/ee-try-entrypoint-lts.sh +++ b/docker/ee/ee-try-entrypoint-lts.sh @@ -1,15 +1,117 @@ #!/bin/bash set -e -# Start Redis -# service redis-server start -# redis-server /etc/redis/redis.conf +echo "🚀 Starting Try ToolJet container initialization..." -# Start Postgres +# Configure PostgreSQL authentication +echo "🔧 Configuring PostgreSQL authentication..." +sed -i 's/^local\s\+all\s\+postgres\s\+\(peer\|md5\)/local all postgres trust/' /etc/postgresql/13/main/pg_hba.conf >/dev/null 2>&1 +sed -i 's/^local\s\+all\s\+all\s\+\(peer\|md5\)/local all all trust/' /etc/postgresql/13/main/pg_hba.conf >/dev/null 2>&1 + +# Start PostgreSQL +echo "📈 Starting PostgreSQL..." service postgresql start -# Export the PORT variable to be used by the application +# Wait until PostgreSQL is ready +echo "⏳ Waiting for PostgreSQL..." +until pg_isready -h localhost -p 5432; do + echo "PostgreSQL not ready yet, retrying..." + sleep 2 +done + +# Create user and databases for Temporal +echo "🔧 Creating Temporal DBs and user if needed..." +psql -U postgres -tc "SELECT 1 FROM pg_roles WHERE rolname='tooljet'" | grep -q 1 || \ +psql -U postgres -c "CREATE USER tooljet WITH PASSWORD 'postgres' SUPERUSER;" >/dev/null 2>&1 + +psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'temporal'" | grep -q 1 || \ +psql -U postgres -c "CREATE DATABASE temporal OWNER tooljet;" >/dev/null 2>&1 + +psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'temporal_visibility'" | grep -q 1 || \ +psql -U postgres -c "CREATE DATABASE temporal_visibility OWNER tooljet;" >/dev/null 2>&1 + +# Generate Temporal config +echo "🔧 Generating Temporal config..." +mkdir -p /etc/temporal/config +if [ -f /etc/temporal/temporal-server.template.yaml ]; then + envsubst < /etc/temporal/temporal-server.template.yaml > /etc/temporal/config/temporal-server.yaml >/dev/null 2>&1 +else + echo "❌ Missing template: /etc/temporal/temporal-server.template.yaml" + exit 1 +fi + +# Download schema files if not present +if [ ! -d "/etc/temporal/schema/postgresql" ]; then + echo "📥 Downloading Temporal schema files..." + mkdir -p /etc/temporal/schema + cd /tmp + curl -sOL https://github.com/temporalio/temporal/archive/refs/tags/v1.28.0.tar.gz + tar -xzf v1.28.0.tar.gz + cp -r temporal-1.28.0/schema/postgresql /etc/temporal/schema/ + rm -rf temporal-1.28.0 v1.28.0.tar.gz + cd / +fi + +rm -f /etc/temporal/temporal-sql-tool.yaml ~/.temporal/config.yaml +mkdir -p /tmp/temporal + +# Set up schemas +echo "🔧 Setting up Temporal schemas..." +for db in temporal temporal_visibility; do + PGPASSWORD=postgres /usr/bin/temporal-sql-tool --plugin postgres12 \ + --ep "localhost" --port 5432 --user tooljet --password postgres \ + --database $db setup-schema -v 0.0 >/dev/null 2>&1 + + schema_dir="/etc/temporal/schema/postgresql/v12" + schema_type=$([ "$db" = "temporal" ] && echo "temporal" || echo "visibility") + + PGPASSWORD=postgres /usr/bin/temporal-sql-tool --plugin postgres12 \ + --ep "localhost" --port 5432 --user tooljet --password postgres \ + --database $db update-schema -d "$schema_dir/$schema_type/versioned" >/dev/null 2>&1 +done + +echo "✅ Schema setup complete" + +# Export default port if not set export PORT=${PORT:-80} +# Start Temporal Server +echo "🚀 Starting Temporal Server..." +/usr/bin/temporal-server start >/dev/null 2>&1 & +TEMPORAL_PID=$! + # Start Supervisor -exec supervisord -c /etc/supervisor/conf.d/supervisord.conf +echo "🚀 Starting Supervisor..." +supervisord -c /etc/supervisor/conf.d/supervisord.conf & +SUPERVISOR_PID=$! + +# Wait for Temporal to become ready +echo "⏳ Waiting for Temporal..." +for i in {1..30}; do + if grpcurl -plaintext localhost:7233 grpc.health.v1.Health/Check >/dev/null 2>&1; then + echo "✅ Temporal is ready" + break + fi + sleep 2 +done + +# Check if namespace already exists +echo "Checking if Temporal namespace exists..." +if grpcurl -plaintext localhost:7233 temporal.api.workflowservice.v1.WorkflowService/ListNamespaces | grep -q '"name": "default"'; then + echo "Namespace 'default' already exists." +else + # Register the namespace if it doesn't exist + echo "Registering Temporal namespace..." + grpcurl -plaintext -d '{ + "namespace": "default", + "description": "Default namespace", + "workflowExecutionRetentionPeriod": "259200s" + }' localhost:7233 temporal.api.workflowservice.v1.WorkflowService/RegisterNamespace +fi + +# Wait on background processes +wait $TEMPORAL_PID $SUPERVISOR_PID + +# Start worker (last step) +echo "🚀 Starting ToolJet worker..." +npm run worker:prod diff --git a/docker/ee/ee-try-entrypoint.sh b/docker/ee/ee-try-entrypoint.sh index 5143e10e75..8e2332ba19 100755 --- a/docker/ee/ee-try-entrypoint.sh +++ b/docker/ee/ee-try-entrypoint.sh @@ -1,32 +1,99 @@ #!/bin/bash set -e -# Install grpcurl if not already installed -if ! command -v grpcurl &> /dev/null; then - echo "grpcurl not found, installing..." - apt update && apt install -y curl \ - && curl -sSL https://github.com/fullstorydev/grpcurl/releases/download/v1.8.0/grpcurl_1.8.0_linux_x86_64.tar.gz | tar -xzv -C /usr/local/bin grpcurl -fi +echo "🚀 Starting Try ToolJet container initialization..." -# Start Redis -service redis-server start +# Configure PostgreSQL authentication +echo "🔧 Configuring PostgreSQL authentication..." +sed -i 's/^local\s\+all\s\+postgres\s\+\(peer\|md5\)/local all postgres trust/' /etc/postgresql/13/main/pg_hba.conf >/dev/null 2>&1 +sed -i 's/^local\s\+all\s\+all\s\+\(peer\|md5\)/local all all trust/' /etc/postgresql/13/main/pg_hba.conf >/dev/null 2>&1 -# Start Postgres +# Start PostgreSQL +echo "📈 Starting PostgreSQL..." service postgresql start -# Start Temporal Server (SQLite configuration) -echo "Starting Temporal Server..." -/usr/bin/temporal-server -r / -c /etc/temporal/ -e temporal-server start & +# Wait until PostgreSQL is ready +echo "⏳ Waiting for PostgreSQL..." +until pg_isready -h localhost -p 5432; do + echo "PostgreSQL not ready yet, retrying..." + sleep 2 +done -# Export the PORT variable to be used by the application +# Create user and databases for Temporal +echo "🔧 Creating Temporal DBs and user if needed..." +psql -U postgres -tc "SELECT 1 FROM pg_roles WHERE rolname='tooljet'" | grep -q 1 || \ +psql -U postgres -c "CREATE USER tooljet WITH PASSWORD 'postgres' SUPERUSER;" >/dev/null 2>&1 + +psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'temporal'" | grep -q 1 || \ +psql -U postgres -c "CREATE DATABASE temporal OWNER tooljet;" >/dev/null 2>&1 + +psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'temporal_visibility'" | grep -q 1 || \ +psql -U postgres -c "CREATE DATABASE temporal_visibility OWNER tooljet;" >/dev/null 2>&1 + +# Generate Temporal config +echo "🔧 Generating Temporal config..." +mkdir -p /etc/temporal/config +if [ -f /etc/temporal/temporal-server.template.yaml ]; then + envsubst < /etc/temporal/temporal-server.template.yaml > /etc/temporal/config/temporal-server.yaml >/dev/null 2>&1 +else + echo "❌ Missing template: /etc/temporal/temporal-server.template.yaml" + exit 1 +fi + +# Download schema files if not present +if [ ! -d "/etc/temporal/schema/postgresql" ]; then + echo "📥 Downloading Temporal schema files..." + mkdir -p /etc/temporal/schema + cd /tmp + curl -sOL https://github.com/temporalio/temporal/archive/refs/tags/v1.28.0.tar.gz + tar -xzf v1.28.0.tar.gz + cp -r temporal-1.28.0/schema/postgresql /etc/temporal/schema/ + rm -rf temporal-1.28.0 v1.28.0.tar.gz + cd / +fi + +rm -f /etc/temporal/temporal-sql-tool.yaml ~/.temporal/config.yaml +mkdir -p /tmp/temporal + +# Set up schemas +echo "🔧 Setting up Temporal schemas..." +for db in temporal temporal_visibility; do + PGPASSWORD=postgres /usr/bin/temporal-sql-tool --plugin postgres12 \ + --ep "localhost" --port 5432 --user tooljet --password postgres \ + --database $db setup-schema -v 0.0 >/dev/null 2>&1 + + schema_dir="/etc/temporal/schema/postgresql/v12" + schema_type=$([ "$db" = "temporal" ] && echo "temporal" || echo "visibility") + + PGPASSWORD=postgres /usr/bin/temporal-sql-tool --plugin postgres12 \ + --ep "localhost" --port 5432 --user tooljet --password postgres \ + --database $db update-schema -d "$schema_dir/$schema_type/versioned" >/dev/null 2>&1 +done + +echo "✅ Schema setup complete" + +# Export default port if not set export PORT=${PORT:-80} -# Start Supervisor -exec supervisord -c /etc/supervisor/conf.d/supervisord.conf & +# Start Temporal Server +echo "🚀 Starting Temporal Server..." +/usr/bin/temporal-server start >/dev/null 2>&1 & +TEMPORAL_PID=$! -# Wait for Temporal Server to be ready -echo "Waiting for Temporal Server to be ready..." -sleep 10 +# Start Supervisor +echo "🚀 Starting Supervisor..." +supervisord -c /etc/supervisor/conf.d/supervisord.conf & +SUPERVISOR_PID=$! + +# Wait for Temporal to become ready +echo "⏳ Waiting for Temporal..." +for i in {1..30}; do + if grpcurl -plaintext localhost:7233 grpc.health.v1.Health/Check >/dev/null 2>&1; then + echo "✅ Temporal is ready" + break + fi + sleep 2 +done # Check if namespace already exists echo "Checking if Temporal namespace exists..." @@ -42,6 +109,9 @@ else }' localhost:7233 temporal.api.workflowservice.v1.WorkflowService/RegisterNamespace fi -# Run the worker process (last step) -echo "Starting worker process..." -npm run worker:prod +# Wait on background processes +wait $TEMPORAL_PID $SUPERVISOR_PID + +# Start worker (last step) +echo "🚀 Starting ToolJet worker..." +npm --prefix server run worker:prod diff --git a/docker/ee/ee-try-tooljet-lts.Dockerfile b/docker/ee/ee-try-tooljet-lts.Dockerfile index 5eb10b938a..2dcf56edfe 100644 --- a/docker/ee/ee-try-tooljet-lts.Dockerfile +++ b/docker/ee/ee-try-tooljet-lts.Dockerfile @@ -1,20 +1,19 @@ FROM tooljet/tooljet:ee-lts-latest -# Copy PostgREST executable +# Copy postgrest executable COPY --from=postgrest/postgrest:v12.2.0 /bin/postgrest /bin -# Install PostgreSQL +# Install Postgres USER root RUN wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - -RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ bullseye-pgdg main" | tee /etc/apt/sources.list.d/pgdg.list +RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ bookworm-pgdg main" | tee /etc/apt/sources.list.d/pgdg.list +RUN echo "deb http://deb.debian.org/debian" RUN apt update && apt -y install postgresql-13 postgresql-client-13 supervisor - USER postgres RUN service postgresql start && \ psql -c "create role tooljet with login superuser password 'postgres';" USER root -# Install Redis RUN apt update && apt -y install redis # Create appuser home & ensure permission for supervisord and services @@ -22,6 +21,37 @@ RUN mkdir -p /var/log/supervisor /var/run/postgresql /var/lib/postgresql /var/li chown -R appuser:appuser /etc/supervisor /var/log/supervisor /var/lib/redis && \ chown -R postgres:postgres /var/run/postgresql /var/lib/postgresql +# Install Temporal Server Binaries +RUN curl -OL https://github.com/temporalio/temporal/releases/download/v1.28.0/temporal_1.28.0_linux_amd64.tar.gz \ + && tar -xzf temporal_1.28.0_linux_amd64.tar.gz \ + && mv temporal-server /usr/bin/temporal-server \ + && mv temporal-sql-tool /usr/bin/temporal-sql-tool \ + && chmod +x /usr/bin/temporal-server /usr/bin/temporal-sql-tool \ + && rm temporal_1.28.0_linux_amd64.tar.gz + +# Install Temporal UI Server Binaries +RUN curl -OL https://github.com/temporalio/ui-server/releases/download/v2.28.0/ui-server_2.28.0_linux_amd64.tar.gz && \ + tar -xzf ui-server_2.28.0_linux_amd64.tar.gz && \ + mv ui-server /usr/bin/temporal-ui-server && \ + chmod +x /usr/bin/temporal-ui-server && \ + rm ui-server_2.28.0_linux_amd64.tar.gz + + +# Install Git for schema extraction +RUN apt update && apt install -y git && \ + git clone --depth 1 --branch v1.28.0 https://github.com/temporalio/temporal.git /tmp/temporal && \ + mkdir -p /etc/temporal/schema/postgresql && \ + cp -r /tmp/temporal/schema/postgresql/v12 /etc/temporal/schema/postgresql/ && \ + rm -rf /tmp/temporal + +# Install envsubst and grpcurl +RUN apt update && apt install -y gettext-base curl \ + && curl -sSL https://github.com/fullstorydev/grpcurl/releases/download/v1.8.0/grpcurl_1.8.0_linux_x86_64.tar.gz | tar -xzv -C /usr/local/bin grpcurl + +# Copy Temporal configuration files +COPY ./docker/ee/temporal-server.yaml /etc/temporal/temporal-server.template.yaml +COPY ./docker/ee/temporal-ui-server.yaml /etc/temporal/temporal-ui-server.yaml + # Configure Supervisor to manage PostgREST, ToolJet, and Redis RUN echo "[supervisord] \n" \ "nodaemon=true \n" \ @@ -54,6 +84,7 @@ RUN echo "[supervisord] \n" \ # ENV defaults ENV TOOLJET_HOST=http://localhost \ + TOOLJET_SERVER_URL=http://localhost \ PORT=80 \ NODE_ENV=production \ LOCKBOX_MASTER_KEY=replace_with_lockbox_master_key \ @@ -62,6 +93,7 @@ ENV TOOLJET_HOST=http://localhost \ PG_USER=tooljet \ PG_PASS=postgres \ PG_HOST=localhost \ + PG_PORT=5432 \ ENABLE_TOOLJET_DB=true \ TOOLJET_DB_HOST=localhost \ TOOLJET_DB_USER=tooljet \ @@ -78,7 +110,18 @@ ENV TOOLJET_HOST=http://localhost \ REDIS_PORT=6379 \ REDIS_USER=default \ REDIS_PASS= \ - TERM=xterm + ENABLE_MARKETPLACE_FEATURE=true \ + TERM=xterm \ + ENABLE_WORKFLOW_SCHEDULING=true \ + TEMPORAL_SERVER_ADDRESS=localhost:7233 \ + TEMPORAL_TASK_QUEUE_NAME_FOR_WORKFLOWS=tooljet-workflows \ + TOOLJET_WORKFLOWS_TEMPORAL_NAMESPACE=default \ + TEMPORAL_ADDRESS=localhost:7233 \ + TEMPORAL_DB_HOST=localhost \ + TEMPORAL_DB_PORT=5432 \ + TEMPORAL_DB_USER=tooljet \ + TEMPORAL_DB_PASS=postgres \ + TEMPORAL_CORS_ORIGINS=http://localhost:8080 # Set the entrypoint COPY ./docker/ee/ee-try-entrypoint-lts.sh /ee-try-entrypoint-lts.sh diff --git a/docker/ee/ee-try-tooljet.Dockerfile b/docker/ee/ee-try-tooljet.Dockerfile index 11cbe88be3..3aa416b87d 100644 --- a/docker/ee/ee-try-tooljet.Dockerfile +++ b/docker/ee/ee-try-tooljet.Dockerfile @@ -6,7 +6,7 @@ COPY --from=postgrest/postgrest:v12.2.0 /bin/postgrest /bin # Install Postgres USER root RUN wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - -RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ bullseye-pgdg main" | tee /etc/apt/sources.list.d/pgdg.list +RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ bookworm-pgdg main" | tee /etc/apt/sources.list.d/pgdg.list RUN echo "deb http://deb.debian.org/debian" RUN apt update && apt -y install postgresql-13 postgresql-client-13 supervisor USER postgres @@ -14,7 +14,6 @@ RUN service postgresql start && \ psql -c "create role tooljet with login superuser password 'postgres';" USER root - RUN apt update && apt -y install redis # Create appuser home & ensure permission for supervisord and services @@ -23,11 +22,12 @@ RUN mkdir -p /var/log/supervisor /var/run/postgresql /var/lib/postgresql /var/li chown -R postgres:postgres /var/run/postgresql /var/lib/postgresql # Install Temporal Server Binaries -RUN curl -OL https://github.com/temporalio/temporal/releases/download/v1.24.2/temporal_1.24.2_linux_amd64.tar.gz && \ - tar -xzf temporal_1.24.2_linux_amd64.tar.gz && \ - mv temporal-server /usr/bin/temporal-server && \ - chmod +x /usr/bin/temporal-server && \ - rm temporal_1.24.2_linux_amd64.tar.gz +RUN curl -OL https://github.com/temporalio/temporal/releases/download/v1.28.0/temporal_1.28.0_linux_amd64.tar.gz \ + && tar -xzf temporal_1.28.0_linux_amd64.tar.gz \ + && mv temporal-server /usr/bin/temporal-server \ + && mv temporal-sql-tool /usr/bin/temporal-sql-tool \ + && chmod +x /usr/bin/temporal-server /usr/bin/temporal-sql-tool \ + && rm temporal_1.28.0_linux_amd64.tar.gz # Install Temporal UI Server Binaries RUN curl -OL https://github.com/temporalio/ui-server/releases/download/v2.28.0/ui-server_2.28.0_linux_amd64.tar.gz && \ @@ -36,14 +36,22 @@ RUN curl -OL https://github.com/temporalio/ui-server/releases/download/v2.28.0/u chmod +x /usr/bin/temporal-ui-server && \ rm ui-server_2.28.0_linux_amd64.tar.gz -# Copy Temporal configuration files -COPY ./docker/ee/temporal-server.yaml /etc/temporal/temporal-server.yaml -COPY ./docker/ee/temporal-ui-server.yaml /etc/temporal/temporal-ui-server.yaml -# Install grpcurl -RUN apt update && apt install -y curl \ +# Install Git for schema extraction +RUN apt update && apt install -y git && \ + git clone --depth 1 --branch v1.28.0 https://github.com/temporalio/temporal.git /tmp/temporal && \ + mkdir -p /etc/temporal/schema/postgresql && \ + cp -r /tmp/temporal/schema/postgresql/v12 /etc/temporal/schema/postgresql/ && \ + rm -rf /tmp/temporal + +# Install envsubst and grpcurl +RUN apt update && apt install -y gettext-base curl \ && curl -sSL https://github.com/fullstorydev/grpcurl/releases/download/v1.8.0/grpcurl_1.8.0_linux_x86_64.tar.gz | tar -xzv -C /usr/local/bin grpcurl +# Copy Temporal configuration files +COPY ./docker/ee/temporal-server.yaml /etc/temporal/temporal-server.template.yaml +COPY ./docker/ee/temporal-ui-server.yaml /etc/temporal/temporal-ui-server.yaml + # Configure Supervisor to manage PostgREST, ToolJet, and Redis RUN echo "[supervisord] \n" \ "nodaemon=true \n" \ @@ -74,7 +82,6 @@ RUN echo "[supervisord] \n" \ "stdout_logfile=/dev/stdout \n" \ "stdout_logfile_maxbytes=0 \n" | sed 's/ //' > /etc/supervisor/conf.d/supervisord.conf - # ENV defaults ENV TOOLJET_HOST=http://localhost \ TOOLJET_SERVER_URL=http://localhost \ @@ -86,6 +93,7 @@ ENV TOOLJET_HOST=http://localhost \ PG_USER=tooljet \ PG_PASS=postgres \ PG_HOST=localhost \ + PG_PORT=5432 \ ENABLE_TOOLJET_DB=true \ TOOLJET_DB_HOST=localhost \ TOOLJET_DB_USER=tooljet \ @@ -109,9 +117,13 @@ ENV TOOLJET_HOST=http://localhost \ TEMPORAL_TASK_QUEUE_NAME_FOR_WORKFLOWS=tooljet-workflows \ TOOLJET_WORKFLOWS_TEMPORAL_NAMESPACE=default \ TEMPORAL_ADDRESS=localhost:7233 \ + TEMPORAL_DB_HOST=localhost \ + TEMPORAL_DB_PORT=5432 \ + TEMPORAL_DB_USER=tooljet \ + TEMPORAL_DB_PASS=postgres \ TEMPORAL_CORS_ORIGINS=http://localhost:8080 # Set the entrypoint COPY ./docker/ee/ee-try-entrypoint.sh /ee-try-entrypoint.sh RUN chmod +x /ee-try-entrypoint.sh -ENTRYPOINT ["/ee-try-entrypoint.sh"] +ENTRYPOINT ["/ee-try-entrypoint.sh"] \ No newline at end of file diff --git a/docker/ee/temporal-server.yaml b/docker/ee/temporal-server.yaml index bc17ed934f..45324165a2 100644 --- a/docker/ee/temporal-server.yaml +++ b/docker/ee/temporal-server.yaml @@ -3,29 +3,24 @@ log: level: info persistence: - defaultStore: sqlite-default - visibilityStore: sqlite-visibility + defaultStore: postgres-default + visibilityStore: postgres-visibility numHistoryShards: 4 - datastores: - sqlite-default: + dataStores: + postgres-default: sql: - pluginName: "sqlite" - databaseName: "/etc/temporal/default.db" - connectAddr: "localhost" - connectProtocol: "tcp" - connectAttributes: - cache: "private" - setup: true - - sqlite-visibility: + pluginName: "postgres12" + databaseName: "temporal" + connectAddr: "localhost:5432" + user: "tooljet" + password: "postgres" + postgres-visibility: sql: - pluginName: "sqlite" - databaseName: "/etc/temporal/visibility.db" - connectAddr: "localhost" - connectProtocol: "tcp" - connectAttributes: - cache: "private" - setup: true + pluginName: "postgres12" + databaseName: "temporal_visibility" + connectAddr: "localhost:5432" + user: "tooljet" + password: "postgres" global: membership: @@ -41,7 +36,7 @@ services: membershipPort: 6933 bindOnLocalHost: true httpPort: 7243 - + matching: rpc: grpcPort: 7235 @@ -68,8 +63,8 @@ clusterMetadata: enabled: true initialFailoverVersion: 1 rpcName: "frontend" - rpcAddress: "localhost:7236" + rpcAddress: "localhost:7233" httpAddress: "localhost:7243" dcRedirectionPolicy: - policy: "noop" + policy: "noop" \ No newline at end of file diff --git a/frontend/assets/images/icons/empty-modules.svg b/frontend/assets/images/icons/empty-modules.svg new file mode 100644 index 0000000000..4a93de1815 --- /dev/null +++ b/frontend/assets/images/icons/empty-modules.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/assets/translations/en.json b/frontend/assets/translations/en.json index 1feaf4277d..8233824dd6 100644 --- a/frontend/assets/translations/en.json +++ b/frontend/assets/translations/en.json @@ -43,7 +43,9 @@ "page": "Page", "searchItem": "Search apps in this workspace", "workflowsSearchItem": "Search workflows in this workspace", - "searchComponents": "Search components" + "searchComponents": "Search components", + "promote": "Promote", + "release": "Release" }, "errorBoundary": "Something went wrong.", "viewer": "Sorry!. This app is under maintenance", @@ -572,6 +574,7 @@ "styles": "Styles", "general": "General", "validation": "Validation", + "structure": "Structure", "documentation": "Read documentation for {{componentMeta}}", "widgetNameEmptyError": "Widget name cannot be empty", "componentNameExistsError": "Component name already exists", diff --git a/frontend/ee b/frontend/ee index aa52120545..d47523cfa1 160000 --- a/frontend/ee +++ b/frontend/ee @@ -1 +1 @@ -Subproject commit aa521205455afd59e85762716a0012c1e44986e1 +Subproject commit d47523cfa18e15e774781d3ccf4d16858970479b diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 43b332e6ea..8d15bf0995 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -63,6 +63,7 @@ "dotenv": "^16.0.3", "draft-js": "^0.11.7", "draft-js-export-html": "^1.4.1", + "draft-js-import-html": "^1.4.1", "driver.js": "^0.9.8", "emoji-mart": "^5.5.2", "file-loader": "^6.2.0", @@ -84,6 +85,7 @@ "papaparse": "^5.3.2", "path-browserify": "^1.0.1", "plotly.js-dist-min": "^2.29.1", + "posthog-js": "^1.255.1", "process": "^0.11.10", "psl": "^1.9.0", "query-string": "^8.1.0", @@ -105,7 +107,7 @@ "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.2.0", - "react-dropzone": "^14.2.3", + "react-dropzone": "^14.3.8", "react-highlight-words": "^0.21.0", "react-hot-toast": "^2.4.0", "react-hotkeys-hook": "^4.3.5", @@ -16948,6 +16950,33 @@ "immutable": "3.x.x" } }, + "node_modules/draft-js-import-element": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/draft-js-import-element/-/draft-js-import-element-1.4.0.tgz", + "integrity": "sha512-WmYT5PrCm47lGL5FkH6sRO3TTAcn7qNHsD3igiPqLG/RXrqyKrqN4+wBgbcT2lhna/yfWTRtgzAbQsSJoS1Meg==", + "license": "ISC", + "dependencies": { + "draft-js-utils": "^1.4.0", + "synthetic-dom": "^1.4.0" + }, + "peerDependencies": { + "draft-js": ">=0.10.0", + "immutable": "3.x.x" + } + }, + "node_modules/draft-js-import-html": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/draft-js-import-html/-/draft-js-import-html-1.4.1.tgz", + "integrity": "sha512-KOZmtgxZriCDgg5Smr3Y09TjubvXe7rHPy/2fuLSsL+aSzwUDwH/aHDA/k47U+WfpmL4qgyg4oZhqx9TYJV0tg==", + "license": "ISC", + "dependencies": { + "draft-js-import-element": "^1.4.0" + }, + "peerDependencies": { + "draft-js": ">=0.10.0", + "immutable": "3.x.x" + } + }, "node_modules/draft-js-utils": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/draft-js-utils/-/draft-js-utils-1.4.1.tgz", @@ -27416,6 +27445,36 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/posthog-js": { + "version": "1.255.1", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.255.1.tgz", + "integrity": "sha512-KMh0o9MhORhEZVjXpktXB5rJ8PfDk+poqBoTSoLzWgNjhJf6D8jcyB9jUMA6vVPfn4YeepVX5NuclDRqOwr5Mw==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "core-js": "^3.38.1", + "fflate": "^0.4.8", + "preact": "^10.19.3", + "web-vitals": "^4.2.4" + }, + "peerDependencies": { + "@rrweb/types": "2.0.0-alpha.17", + "rrweb-snapshot": "2.0.0-alpha.17" + }, + "peerDependenciesMeta": { + "@rrweb/types": { + "optional": true + }, + "rrweb-snapshot": { + "optional": true + } + } + }, + "node_modules/posthog-js/node_modules/fflate": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", + "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", + "license": "MIT" + }, "node_modules/potpack": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", @@ -27423,6 +27482,16 @@ "license": "ISC", "peer": true }, + "node_modules/preact": { + "version": "10.26.9", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.26.9.tgz", + "integrity": "sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -32645,6 +32714,12 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/synthetic-dom": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/synthetic-dom/-/synthetic-dom-1.4.0.tgz", + "integrity": "sha512-mHv51ZsmZ+ShT/4s5kg+MGUIhY7Ltq4v03xpN1c8T1Krb5pScsh/lzEjyhrVD0soVDbThbd2e+4dD9vnDG4rhg==", + "license": "ISC" + }, "node_modules/tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", @@ -34297,6 +34372,12 @@ "node": ">= 8" } }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", + "license": "Apache-2.0" + }, "node_modules/webgl-context": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/webgl-context/-/webgl-context-2.2.0.tgz", @@ -35160,4 +35241,4 @@ } } } -} +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 41dbb51e92..57785151d3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -80,6 +80,7 @@ "papaparse": "^5.3.2", "path-browserify": "^1.0.1", "plotly.js-dist-min": "^2.29.1", + "posthog-js": "^1.255.1", "process": "^0.11.10", "psl": "^1.9.0", "query-string": "^8.1.0", @@ -101,7 +102,7 @@ "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.2.0", - "react-dropzone": "^14.2.3", + "react-dropzone": "^14.3.8", "react-highlight-words": "^0.21.0", "react-hot-toast": "^2.4.0", "react-hotkeys-hook": "^4.3.5", diff --git a/frontend/src/App/App.jsx b/frontend/src/App/App.jsx index 7e47c82ce2..d3cda91e3f 100644 --- a/frontend/src/App/App.jsx +++ b/frontend/src/App/App.jsx @@ -42,6 +42,7 @@ import { shallow } from 'zustand/shallow'; import useStore from '@/AppBuilder/_stores/store'; import { checkIfToolJetCloud } from '@/_helpers/utils'; import { BasicPlanMigrationBanner } from '@/HomePage/BasicPlanMigrationBanner/BasicPlanMigrationBanner'; +import EmbedApp from '@/AppBuilder/EmbedApp'; const AppWrapper = (props) => { const { isAppDarkMode } = useAppDarkMode(); @@ -144,7 +145,7 @@ class AppComponent extends React.Component { const pathname = this.props.location.pathname; if (pathname.includes('/apps/')) { return 'editor'; - } else if (pathname.includes('/applications/')) { + } else if (pathname.includes('/applications/') || pathname.includes('/embed-apps/')) { return 'viewer'; } return ''; @@ -283,7 +284,7 @@ class AppComponent extends React.Component { path="/:workspaceId/workflows/*" element={ - + } /> @@ -292,8 +293,13 @@ class AppComponent extends React.Component { path="/:workspaceId/workspace-settings/*" element={} > - }> - }> + + } + > + }> } /> + } /> { diff --git a/frontend/src/AppBuilder/AppBuilder.jsx b/frontend/src/AppBuilder/AppBuilder.jsx index 9f7a8f4e2d..fa12e0d1d9 100644 --- a/frontend/src/AppBuilder/AppBuilder.jsx +++ b/frontend/src/AppBuilder/AppBuilder.jsx @@ -14,6 +14,7 @@ import EditorHeader from '@/AppBuilder/Header'; import LeftSidebar from '@/AppBuilder/LeftSidebar'; import Popups from './Popups'; import { ModuleProvider } from '@/AppBuilder/_contexts/ModuleContext'; +import RightSidebarToggle from '@/AppBuilder/RightSideBar/RightSidebarToggle'; import { shallow } from 'zustand/shallow'; // const EditorHeader = lazy(() => import('@/AppBuilder/Header')); @@ -25,6 +26,7 @@ import { shallow } from 'zustand/shallow'; // TODO: split Loader into separate component and remove editor loading state from Editor export const Editor = ({ id: appId, darkMode, moduleId = 'canvas', switchDarkMode, appType = 'front-end' }) => { useAppData(appId, moduleId, darkMode); + const isRightSidebarOpen = useStore((state) => state.isRightSidebarOpen); const isEditorLoading = useStore((state) => state.loaderStore.modules[moduleId].isEditorLoading, shallow); const currentMode = useStore((state) => state.modeStore.modules[moduleId].currentMode, shallow); const isModuleEditor = appType === 'module'; @@ -54,9 +56,10 @@ export const Editor = ({ id: appId, darkMode, moduleId = 'canvas', switchDarkMod {window?.public_config?.ENABLE_MULTIPLAYER_EDITING === 'true' && } - + - + + {isRightSidebarOpen && }{' '} diff --git a/frontend/src/AppBuilder/AppCanvas/AppCanvas.jsx b/frontend/src/AppBuilder/AppCanvas/AppCanvas.jsx index 6f8711fe2e..12d33283c8 100644 --- a/frontend/src/AppBuilder/AppCanvas/AppCanvas.jsx +++ b/frontend/src/AppBuilder/AppCanvas/AppCanvas.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react'; +import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { Container } from './Container'; import Grid from './Grid'; import { EditorSelecto } from './Selecto'; @@ -17,9 +17,13 @@ import useAppDarkMode from '@/_hooks/useAppDarkMode'; import useAppCanvasMaxWidth from './useAppCanvasMaxWidth'; import { DeleteWidgetConfirmation } from './DeleteWidgetConfirmation'; import useSidebarMargin from './useSidebarMargin'; +import PagesSidebarNavigation from '../RightSideBar/PageSettingsTab/PageMenu/PagesSidebarNavigation'; +import { resolveReferences } from '@/_helpers/utils'; +import useRightSidebarMargin from './userRightSidebarMargin'; +import { DragGhostWidget } from './GhostWidgets'; import AppCanvasBanner from '../../AppBuilder/Header/AppCanvasBanner'; -export const AppCanvas = ({ appId, isViewerSidebarPinned, isViewer = false }) => { +export const AppCanvas = ({ appId, isViewer = false, switchDarkMode, darkMode }) => { const { moduleId, isModuleMode, appType } = useModuleContext(); const canvasContainerRef = useRef(); const handleCanvasContainerMouseUp = useStore((state) => state.handleCanvasContainerMouseUp, shallow); @@ -42,9 +46,33 @@ export const AppCanvas = ({ appId, isViewerSidebarPinned, isViewer = false }) => const setIsComponentLayoutReady = useStore((state) => state.setIsComponentLayoutReady, shallow); const canvasMaxWidth = useAppCanvasMaxWidth({ mode: currentMode }); const editorMarginLeft = useSidebarMargin(canvasContainerRef); - const isPagesSidebarHidden = useStore((state) => state.getPagesSidebarVisibility('canvas'), shallow); + // const editorMarginRight = useRightSidebarMargin(canvasContainerRef); + // const isPagesSidebarHidden = useStore((state) => state.getPagesSidebarVisibility('canvas'), shallow); const isSidebarOpen = useStore((state) => state.isSidebarOpen, shallow); const getPageId = useStore((state) => state.getCurrentPageId, shallow); + const isRightSidebarOpen = useStore((state) => state.isRightSidebarOpen, shallow); + const isRightSidebarPinned = useStore((state) => state.isRightSidebarPinned, shallow); + const currentPageId = useStore((state) => state.modules[moduleId].currentPageId); + const homePageId = useStore((state) => state.appStore.modules[moduleId].app.homePageId); + + const [isViewerSidebarPinned, setIsSidebarPinned] = useState( + localStorage.getItem('isPagesSidebarPinned') !== 'false' + ); + + const { globalSettings, pages, pageSettings, switchPage } = useStore( + (state) => ({ + globalSettings: state.globalSettings, + pages: state.modules.canvas.pages, + pageSettings: state.pageSettings, + switchPage: state.switchPage, + }), + shallow + ); + + const showHeader = !globalSettings?.hideHeader; + const { definition: { styles = {}, properties = {} } = {} } = pageSettings ?? {}; + const { position, disableMenu, showOnDesktop } = properties ?? {}; + const isPagesSidebarHidden = resolveReferences(disableMenu?.value); const hideSidebar = isModuleMode || isPagesSidebarHidden || appType === 'module'; @@ -79,9 +107,11 @@ export const AppCanvas = ({ appId, isViewerSidebarPinned, isViewer = false }) => handleResize(); return () => window.removeEventListener('resize', handleResize); - }, [currentLayout, canvasMaxWidth, isViewerSidebarPinned, moduleId]); + }, [currentLayout, canvasMaxWidth, isViewerSidebarPinned, moduleId, isRightSidebarOpen]); - const styles = useMemo(() => { + useEffect(() => {}, [isViewerSidebarPinned]); + + const canvasContainerStyles = useMemo(() => { const canvasBgColor = currentMode === 'view' ? computeViewerBackgroundColor(isAppDarkMode, canvasBgColor) @@ -101,24 +131,37 @@ export const AppCanvas = ({ appId, isViewerSidebarPinned, isViewer = false }) => borderLeft: currentMode === 'edit' && editorMarginLeft + 'px solid', height: currentMode === 'edit' ? canvasContainerHeight : '100%', background: canvasBgColor, - marginLeft: - isViewerSidebarPinned && !hideSidebar && currentLayout !== 'mobile' && currentMode !== 'edit' - ? pageSidebarStyle === 'icon' - ? '65px' - : '210px' - : 'auto', + width: currentMode === 'edit' ? `calc(100% - 96px)` : '100%', + alignItems: 'unset', + justifyContent: 'unset', + borderRight: currentMode === 'edit' && isRightSidebarOpen && '299' + 'px solid', + padding: currentMode === 'edit' && '8px', + paddingBottom: currentMode === 'edit' && '2px', }; - }, [ - currentMode, - isAppDarkMode, - isModuleMode, - editorMarginLeft, - canvasContainerHeight, - isViewerSidebarPinned, - hideSidebar, - currentLayout, - pageSidebarStyle, - ]); + }, [currentMode, isAppDarkMode, isModuleMode, editorMarginLeft, canvasContainerHeight, isRightSidebarOpen]); + + const toggleSidebarPinned = useCallback(() => { + const newValue = !isViewerSidebarPinned; + setIsSidebarPinned(newValue); + localStorage.setItem('isPagesSidebarPinned', JSON.stringify(newValue)); + }, [isViewerSidebarPinned]); + + function getMinWidth() { + if (isModuleMode) return '100%'; + + const shouldAdjust = isSidebarOpen || (isRightSidebarOpen && currentMode === 'edit'); + + if (!shouldAdjust) return ''; + + let offset; + if (isViewerSidebarPinned) { + offset = position === 'side' ? '352px' : '126px'; + } else { + offset = position === 'side' ? '171px' : '126px'; + } + + return `calc(100vw - ${offset})`; + } return (
onMouseUp={handleCanvasContainerMouseUp} > -
+
diff --git a/frontend/src/AppBuilder/CodeBuilder/Elements/Input.jsx b/frontend/src/AppBuilder/CodeBuilder/Elements/Input.jsx index c019e16f1a..4e9eb82455 100644 --- a/frontend/src/AppBuilder/CodeBuilder/Elements/Input.jsx +++ b/frontend/src/AppBuilder/CodeBuilder/Elements/Input.jsx @@ -10,6 +10,7 @@ export const Input = ({ value, onChange, cyLabel, meta }) => { className="tj-input-element tj-text-xsm" value={value} placeholder="" + key={`${String(cyLabel)}-input`} id="labelId" onChange={(e) => { onChange(e.target.value); diff --git a/frontend/src/AppBuilder/CodeBuilder/Elements/Number.jsx b/frontend/src/AppBuilder/CodeBuilder/Elements/Number.jsx index 7811562e12..1127dbaf93 100644 --- a/frontend/src/AppBuilder/CodeBuilder/Elements/Number.jsx +++ b/frontend/src/AppBuilder/CodeBuilder/Elements/Number.jsx @@ -1,13 +1,18 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; export const Number = ({ value, onChange, cyLabel }) => { const [number, setNumber] = useState(value ? value : 0); + useEffect(() => { + setNumber(value); + }, [value]); + return ( <>
{ setNumber(e.target.value); diff --git a/frontend/src/AppBuilder/CodeBuilder/TypeMapping.js b/frontend/src/AppBuilder/CodeBuilder/TypeMapping.js index a249f52676..dac7203e6c 100644 --- a/frontend/src/AppBuilder/CodeBuilder/TypeMapping.js +++ b/frontend/src/AppBuilder/CodeBuilder/TypeMapping.js @@ -1,3 +1,5 @@ +import { drop } from 'lodash'; + export const TypeMapping = { text: 'Text', string: 'Text', @@ -20,5 +22,6 @@ export const TypeMapping = { visibility: 'Visibility', numberInput: 'NumberInput', tableRowHeightInput: 'TableRowHeightInput', + dropdownMenu: 'DropdownMenu', query: 'Query', }; diff --git a/frontend/src/AppBuilder/CodeEditor/CodeHinter.jsx b/frontend/src/AppBuilder/CodeEditor/CodeHinter.jsx index bd5d85ad98..2814fc188e 100644 --- a/frontend/src/AppBuilder/CodeEditor/CodeHinter.jsx +++ b/frontend/src/AppBuilder/CodeEditor/CodeHinter.jsx @@ -20,7 +20,15 @@ const CODE_EDITOR_TYPE = { tjdbHinter: TJDBCodeEditor, }; -const CodeHinter = ({ type = 'basic', initialValue, componentName, disabled, renderCopilot, ...restProps }) => { +const CodeHinter = ({ + type = 'basic', + initialValue, + componentName, + disabled, + renderCopilot, + setCodeEditorView, + ...restProps +}) => { const darkMode = localStorage.getItem('darkMode') === 'true'; const [isOpen, setIsOpen] = React.useState(false); @@ -71,6 +79,7 @@ const CodeHinter = ({ type = 'basic', initialValue, componentName, disabled, ren }} componentName={componentName} disabled={disabled} + setCodeEditorView={setCodeEditorView} {...restProps} /> ); diff --git a/frontend/src/AppBuilder/CodeEditor/DynamicFxTypeRenderer.jsx b/frontend/src/AppBuilder/CodeEditor/DynamicFxTypeRenderer.jsx index 99d80a6050..4d4ff7513c 100644 --- a/frontend/src/AppBuilder/CodeEditor/DynamicFxTypeRenderer.jsx +++ b/frontend/src/AppBuilder/CodeEditor/DynamicFxTypeRenderer.jsx @@ -17,6 +17,7 @@ import { Visibility } from '../CodeBuilder/Elements/Visibility'; import { NumberInput } from '../CodeBuilder/Elements/NumberInput'; import { Datepicker } from '../CodeBuilder/Elements/Datepicker'; import TableRowHeightInput from '../CodeBuilder/Elements/TableRowHeightInput'; +import DropdownMenu from '../CodeBuilder/Elements/DropdownMenu'; import { TimePicker } from '../CodeBuilder/Elements/TimePicker'; import { Query } from '../CodeBuilder/Elements/Query'; import { ColorSwatches } from '@/modules/Appbuilder/components'; @@ -41,6 +42,7 @@ const AllElements = { TableRowHeightInput, Datepicker, TimePicker, + DropdownMenu, Query, }; diff --git a/frontend/src/AppBuilder/CodeEditor/MultiLineCodeEditor.jsx b/frontend/src/AppBuilder/CodeEditor/MultiLineCodeEditor.jsx index 98af1dc9e4..ce3c310f24 100644 --- a/frontend/src/AppBuilder/CodeEditor/MultiLineCodeEditor.jsx +++ b/frontend/src/AppBuilder/CodeEditor/MultiLineCodeEditor.jsx @@ -54,11 +54,15 @@ const MultiLineCodeEditor = (props) => { readOnly = false, editable = true, renderCopilot, + setCodeEditorView, } = props; const replaceIdsWithName = useStore((state) => state.replaceIdsWithName, shallow); const wrapperRef = useRef(null); const getSuggestions = useStore((state) => state.getSuggestions, shallow); - const getServerSideGlobalSuggestions = useStore((state) => state.getServerSideGlobalSuggestions, shallow); + const getServerSideGlobalResolveSuggestions = useStore( + (state) => state.getServerSideGlobalResolveSuggestions, + shallow + ); const isInsideQueryPane = !!document.querySelector('.code-hinter-wrapper')?.closest('.query-details'); const isInsideQueryManager = useMemo( @@ -72,13 +76,48 @@ const MultiLineCodeEditor = (props) => { const currentValueRef = useRef(initialValue); - const handleChange = (val) => (currentValueRef.current = val); - const [editorView, setEditorView] = React.useState(null); const [isSearchPanelOpen, setIsSearchPanelOpen] = React.useState(false); const { queryPanelKeybindings } = useQueryPanelKeyHooks(onChange, currentValueRef, 'multiline'); + // Add state for tracking autocomplete visibility + const [showSuggestions, setShowSuggestions] = React.useState(true); + const currentLineObserverRef = useRef(null); + const isObserverTriggeredRef = useRef(false); + + // Intersection observer to detect when current line goes out of view + useEffect(() => { + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.intersectionRatio < 1) { + setShowSuggestions(false); + isObserverTriggeredRef.current = true; + // Close autocomplete dropdown by dispatching a selection change + if (editorView) { + editorView.dispatch({ + selection: editorView.state.selection, + }); + } + } else { + setShowSuggestions(true); + isObserverTriggeredRef.current = false; + } + }, + { root: null, threshold: [1] } + ); + + currentLineObserverRef.current = observer; + + return () => { + if (currentLineObserverRef.current) { + currentLineObserverRef.current.disconnect(); + } + }; + }, [editorView]); + + const handleChange = (val) => (currentValueRef.current = val); + const handleOnBlur = () => { if (!delayOnChange) return onChange(currentValueRef.current); setTimeout(() => { @@ -116,7 +155,7 @@ const MultiLineCodeEditor = (props) => { const hints = getSuggestions(); - const serverHints = getServerSideGlobalSuggestions(isInsideQueryManager); + const serverHints = getServerSideGlobalResolveSuggestions(isInsideQueryManager); const allHints = { ...hints, @@ -276,6 +315,21 @@ const MultiLineCodeEditor = (props) => { return initialValue; }, [initialValue, replaceIdsWithName]); + function updateCurrentLineObserver(editorView) { + if (!editorView || !editorView?.view?.dom) return; + const cursorPos = editorView.state.selection.main.head; + const line = editorView.state.doc.lineAt(cursorPos); + const lineNumber = line.number; + const cmLines = editorView.view.dom.querySelectorAll('.cm-line'); + const currentLineDiv = cmLines[lineNumber - 1] || null; + + // Update intersection observer to watch the current line + if (currentLineObserverRef.current && currentLineDiv && !isObserverTriggeredRef.current) { + currentLineObserverRef.current.disconnect(); + currentLineObserverRef.current.observe(currentLineDiv); + } + } + return (
{ indentWithTab={false} readOnly={readOnly} editable={editable} //for transformations in query manager - onCreateEditor={(view) => setEditorView(view)} - onUpdate={(view) => setIsSearchPanelOpen(searchPanelOpen(view.state))} + onCreateEditor={(view) => { + setEditorView(view); + if (setCodeEditorView) { + setCodeEditorView(view); + } + }} + onUpdate={(view) => { + setIsSearchPanelOpen(searchPanelOpen(view.state)); + updateCurrentLineObserver(view); + }} />
{showPreview && ( diff --git a/frontend/src/AppBuilder/CodeEditor/PreviewBox.jsx b/frontend/src/AppBuilder/CodeEditor/PreviewBox.jsx index 5c422b1eb3..f2b7be403a 100644 --- a/frontend/src/AppBuilder/CodeEditor/PreviewBox.jsx +++ b/frontend/src/AppBuilder/CodeEditor/PreviewBox.jsx @@ -98,7 +98,7 @@ export const PreviewBox = ({ const [largeDataset, setLargeDataset] = useState(false); const globals = useStore((state) => state.getAllExposedValues(moduleId).constants || {}, shallow); const secrets = useStore((state) => state.getSecrets(), shallow); - const globalServerConstantsRegex = /^\{\{.*globals\.server.*\}\}$/; + const globalServerConstantsRegex = /\{\{.*globals\.server.*\}\}/; const getPreviewContent = (content, type) => { if (content === undefined || content === null) return currentValue; @@ -251,7 +251,10 @@ const RenderResolvedValue = ({ isServerConstant = false, isLargeDataset, }) => { - const isServerSideGlobalEnabled = useStore((state) => !!state?.license?.featureAccess?.serverSideGlobal, shallow); + const isServerSideGlobalResolveEnabled = useStore( + (state) => !!state?.license?.featureAccess?.serverSideGlobalResolve, + shallow + ); const computeCoersionPreview = (resolvedValue, coersionData) => { if (coersionData?.typeBeforeCoercion === coersionData?.typeAfterCoercion) return resolvedValue; @@ -276,7 +279,7 @@ const RenderResolvedValue = ({ : previewType; const previewContent = isServerConstant - ? isServerSideGlobalEnabled + ? isServerSideGlobalResolveEnabled ? 'Server variables would be resolved at runtime' : 'Server variables are only available in paid plans' : isSecretConstant @@ -486,7 +489,14 @@ const PreviewContainer = ({ }; const PreviewCodeBlock = ({ code, isExpectValue = false, isLargeDataset }) => { - let preview = code && code.trim ? code?.trim() : `${code}`; + let preview; + if (typeof code === 'string') { + preview = code.trim(); + } else if (typeof code === 'symbol') { + preview = code.toString(); + } else { + preview = String(code); + } const shouldTrim = preview.length > 35; let showJSONTree = false; diff --git a/frontend/src/AppBuilder/CodeEditor/SingleLineCodeEditor.jsx b/frontend/src/AppBuilder/CodeEditor/SingleLineCodeEditor.jsx index 9c85e0bf43..c3d0bb798c 100644 --- a/frontend/src/AppBuilder/CodeEditor/SingleLineCodeEditor.jsx +++ b/frontend/src/AppBuilder/CodeEditor/SingleLineCodeEditor.jsx @@ -28,10 +28,12 @@ import CodeHinter from './CodeHinter'; import { removeNestedDoubleCurlyBraces } from '@/_helpers/utils'; import useStore from '@/AppBuilder/_stores/store'; import { shallow } from 'zustand/shallow'; +import { getCssVarValue } from '@/Editor/Components/utils'; import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext'; import { CodeHinterContext } from '../CodeBuilder/CodeHinterContext'; import { createReferencesLookup } from '@/_stores/utils'; import { useQueryPanelKeyHooks } from './useQueryPanelKeyHooks'; +import Icon from '@/_ui/Icon/solidIcons/index'; const SingleLineCodeEditor = ({ componentName, fieldMeta = {}, componentId, ...restProps }) => { const { moduleId } = useModuleContext(); @@ -79,7 +81,6 @@ const SingleLineCodeEditor = ({ componentName, fieldMeta = {}, componentId, ...r const replaceIdsWithName = useStore((state) => state.replaceIdsWithName, shallow); let newInitialValue = initialValue; - if (typeof initialValue === 'string' && (initialValue?.includes('components') || initialValue?.includes('queries'))) { newInitialValue = replaceIdsWithName(initialValue); } @@ -209,6 +210,7 @@ const EditorInput = ({ onInputChange, wrapperRef, showSuggestions, + setCodeEditorView = null, // Function to set the CodeMirror view }) => { const codeHinterContext = useContext(CodeHinterContext); const { suggestionList: paramHints } = createReferencesLookup(codeHinterContext, true); @@ -216,7 +218,10 @@ const EditorInput = ({ const getSuggestions = useStore((state) => state.getSuggestions, shallow); const [codeMirrorView, setCodeMirrorView] = useState(undefined); - const getServerSideGlobalSuggestions = useStore((state) => state.getServerSideGlobalSuggestions, shallow); + const getServerSideGlobalResolveSuggestions = useStore( + (state) => state.getServerSideGlobalResolveSuggestions, + shallow + ); const { queryPanelKeybindings } = useQueryPanelKeyHooks(onBlurUpdate, currentValue, 'singleline'); @@ -226,7 +231,7 @@ const EditorInput = ({ ); function autoCompleteExtensionConfig(context) { const hintsWithoutParamHints = getSuggestions(); - const serverHints = getServerSideGlobalSuggestions(isInsideQueryManager); + const serverHints = getServerSideGlobalResolveSuggestions(isInsideQueryManager); let word = context.matchBefore(/\w*/); @@ -274,7 +279,10 @@ const EditorInput = ({ } // eslint-disable-next-line react-hooks/exhaustive-deps - const overRideFunction = React.useCallback((context) => autoCompleteExtensionConfig(context), [isInsideQueryManager, paramHints]); + const overRideFunction = React.useCallback( + (context) => autoCompleteExtensionConfig(context), + [isInsideQueryManager, paramHints] + ); const autoCompleteConfig = autocompletion({ override: [overRideFunction], @@ -443,6 +451,9 @@ const EditorInput = ({ { setCodeMirrorView(view); + if (setCodeEditorView) { + setCodeEditorView(view); + } }} value={currentValue} placeholder={placeholder} @@ -451,11 +462,11 @@ const EditorInput = ({ extensions={ showSuggestions ? [ - javascript({ jsx: lang === 'jsx' }), - autoCompleteConfig, - keymap.of([...customKeyMaps]), - customTabKeymap, - ] + javascript({ jsx: lang === 'jsx' }), + autoCompleteConfig, + keymap.of([...customKeyMaps]), + customTabKeymap, + ] : [javascript({ jsx: lang === 'jsx' })] } onChange={(val) => { @@ -487,9 +498,9 @@ const EditorInput = ({ }} />
- - -
+ + + ); }; @@ -514,24 +525,49 @@ const DynamicEditorBridge = (props) => { const [forceCodeBox, setForceCodeBox] = React.useState(fxActive); const codeShow = paramType === 'code' || forceCodeBox; - const HIDDEN_CODE_HINTER_LABELS = ['Table data', 'Column data', 'Text Format']; - const { isFxNotRequired } = fieldMeta; + const HIDDEN_CODE_HINTER_LABELS = ['Table data', 'Column data', 'Text Format', 'Slider type']; + const { isFxNotRequired, newLine = false, section = '' } = fieldMeta; + const isDeprecated = section === 'deprecated'; const { t } = useTranslation(); - const [_, error, value] = type === 'fxEditor' ? resolveReferences(initialValue) : []; + const replaceIdsWithName = useStore((state) => state.replaceIdsWithName, shallow); + let newInitialValue = initialValue, + shouldResolve = true; + + // This is to handle the case when the initial value is a string and contains components or queries + // and we need to replace the ids with names + // but we don't want to resolve the references as it needs to be displayed as it is + if (paramName === 'generateFormFrom') { + if ( + typeof initialValue === 'string' && + (initialValue?.includes('components') || initialValue?.includes('queries')) + ) { + newInitialValue = replaceIdsWithName(initialValue); + shouldResolve = false; + } + } + const [_, error, value] = + type === 'fxEditor' ? (shouldResolve ? resolveReferences(newInitialValue) : [false, '', newInitialValue]) : []; let cyLabel = paramLabel ? paramLabel.toLowerCase().trim().replace(/\s+/g, '-') : props.cyLabel; useEffect(() => { setForceCodeBox(fxActive); }, [component, fxActive]); + let modifiedValue = initialValue; + if (paramType === 'colorSwatches' && typeof initialValue === 'string' && initialValue?.includes('var(')) { + modifiedValue = getCssVarValue(document.documentElement, initialValue); + } + const renderFx = () => { if (paramType === 'query' || !(paramLabel !== 'Type' && isFxNotRequired === undefined)) { return null; } + return (
{ if (codeShow) { setForceCodeBox(false); onFxPress(false); + if (paramType === 'colorSwatches') { + onChange(modifiedValue); + } } else { setForceCodeBox(true); onFxPress(true); @@ -551,48 +590,69 @@ const DynamicEditorBridge = (props) => { }; const fxClass = isEventManagerParam ? 'justify-content-start' : 'justify-content-end'; - return ( -
-
+ + const renderedLabel = () => { + return ( + <> {paramLabel !== ' ' && !HIDDEN_CODE_HINTER_LABELS.includes(paramLabel) && (
+ {isDeprecated && ( + + + + )}
)} + + ); + }; + + const renderDynamicFx = () => { + if (codeShow) return null; + return ( + { + setForceCodeBox(true); + onFxPress(true); + }} + meta={fieldMeta} + cyLabel={cyLabel} + styleDefinition={styleDefinition} + component={component} + onVisibilityChange={onVisibilityChange} + /> + ); + }; + + return ( +
+
+ {renderedLabel()}
{renderFx()}
- {!codeShow && ( - { - setForceCodeBox(true); - onFxPress(true); - }} - meta={fieldMeta} - cyLabel={cyLabel} - styleDefinition={styleDefinition} - component={component} - onVisibilityChange={onVisibilityChange} - /> - )} + {!newLine && renderDynamicFx()}
+ {newLine && renderDynamicFx()} {codeShow && (
- +
diff --git a/frontend/src/AppBuilder/CodeEditor/styles.scss b/frontend/src/AppBuilder/CodeEditor/styles.scss index d0a328bae0..9b1e6c7023 100644 --- a/frontend/src/AppBuilder/CodeEditor/styles.scss +++ b/frontend/src/AppBuilder/CodeEditor/styles.scss @@ -660,6 +660,13 @@ } } + +.code-editor-component { + .cm-editor { + min-height: 0 !important; + } +} + .cm-searchMatch.cm-searchMatch-selected { background-color: #F28F2D !important; } @@ -673,4 +680,4 @@ .cm-theme{ height: 100% ; } -} \ No newline at end of file +} diff --git a/frontend/src/AppBuilder/CodeEditor/utils.js b/frontend/src/AppBuilder/CodeEditor/utils.js index 1b5fe0aafb..227a4a74e5 100644 --- a/frontend/src/AppBuilder/CodeEditor/utils.js +++ b/frontend/src/AppBuilder/CodeEditor/utils.js @@ -367,6 +367,7 @@ export const FxParamTypeMapping = Object.freeze({ visibility: 'Visibility', numberInput: 'NumberInput', tableRowHeightInput: 'TableRowHeightInput', + dropdownMenu: 'DropdownMenu', query: 'Query', }); diff --git a/frontend/src/AppBuilder/EmbedApp.jsx b/frontend/src/AppBuilder/EmbedApp.jsx new file mode 100644 index 0000000000..9c741dc78d --- /dev/null +++ b/frontend/src/AppBuilder/EmbedApp.jsx @@ -0,0 +1,73 @@ +import React, { useEffect } from 'react'; +import useRouter from '@/_hooks/use-router'; +import config from 'config'; +import toast from 'react-hot-toast'; + +// In-memory PAT token store +let inMemoryPatToken = null; + +export function setPatToken(patToken) { + inMemoryPatToken = patToken; +} + +export function getPatToken() { + if (inMemoryPatToken) return inMemoryPatToken; +} + +export default function EmbedAppRedirect() { + const router = useRouter(); + const { appId } = router.query; + + useEffect(() => { + // 🔐 Ensure the page is embedded + if (window.self === window.top) { + // Not inside an iframe + toast.error('This page must be embedded inside a parent application.'); + return; + } + const token = new URLSearchParams(window.location.search).get('personal-access-token'); + + if (!token || typeof appId !== 'string') { + parent?.postMessage({ type: 'TJ_EMBED_APP_LOGOUT', error: 400, message: 'Missing token or appId' }, '*'); + return; + } + + const initiateSession = async () => { + try { + const res = await fetch(`${config.apiUrl}/ext/users/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ appId, accessToken: token }), + }); + if (!res.ok) { + if (res.status === 401 || res.status === 403) { + toast.error('Your pat is expired. Please refresh or contact your admin.'); + // 🔔 Show toast if token is expired or invalid + parent?.postMessage( + { + type: 'TJ_EMBED_APP_LOGOUT', + error: res.status, + message: 'Your pat is expired. Please refresh or contact your admin.', + }, + '*' + ); + } + return; + } + + const result = await res.json(); + // ✅ Store PAT in memory + setPatToken(result.signedPat); + window.name = result.signedPat; + window.location.href = `applications/${appId}`; + } catch (error) { + parent?.postMessage({ type: 'TJ_EMBED_APP_LOGOUT', error: 500, message: 'Network error' }, '*'); + } + }; + + initiateSession(); + }, [appId]); + + return
Loading embedded app...
; +} diff --git a/frontend/src/AppBuilder/Header/CreateVersionModal.jsx b/frontend/src/AppBuilder/Header/CreateVersionModal.jsx index 64208e5d27..66e62fcd8b 100644 --- a/frontend/src/AppBuilder/Header/CreateVersionModal.jsx +++ b/frontend/src/AppBuilder/Header/CreateVersionModal.jsx @@ -16,15 +16,11 @@ const CreateVersionModal = ({ canCommit, orgGit, fetchingOrgGit, - handleCommitOnVersionCreation = () => {}, + handleCommitOnVersionCreation = () => { }, }) => { const { moduleId } = useModuleContext(); const [isCreatingVersion, setIsCreatingVersion] = useState(false); const [versionName, setVersionName] = useState(''); - const gitSyncEnabled = - orgGit?.org_git?.git_https?.is_enabled || - orgGit?.org_git?.git_ssh?.is_enabled || - orgGit?.org_git?.git_lab?.is_enabled; const { createNewVersionAction, @@ -33,6 +29,7 @@ const CreateVersionModal = ({ appId, setCurrentVersionId, selectedVersion, + currentMode, } = useStore( (state) => ({ createNewVersionAction: state.createNewVersionAction, @@ -45,6 +42,7 @@ const CreateVersionModal = ({ currentVersionId: state.currentVersionId, setCurrentVersionId: state.setCurrentVersionId, selectedVersion: state.selectedVersion, + currentMode: state.currentMode, }), shallow ); @@ -94,7 +92,7 @@ const CreateVersionModal = ({ setIsCreatingVersion(false); setShowCreateAppVersion(false); appVersionService - .getAppVersionData(appId, newVersion.id) + .getAppVersionData(appId, newVersion.id, currentMode) .then((data) => { setCurrentVersionId(newVersion.id); handleCommitOnVersionCreation(data); @@ -104,8 +102,8 @@ const CreateVersionModal = ({ }); }, (error) => { - if (error?.data?.code === '23505') { - toast.error('Version name already exists.'); + if (error?.data?.code === "23505") { + toast.error("Version name already exists."); } else { toast.error(error?.error); } @@ -174,7 +172,7 @@ const CreateVersionModal = ({
- {gitSyncEnabled && ( + {orgGit?.org_git?.is_enabled && (
{ {props?.selectProps?.value?.appVersionName && decodeEntities(props?.selectProps?.value?.appVersionName)}
-
+
diff --git a/frontend/src/AppBuilder/LeftSidebar/GlobalSettings/CanvasSettings.jsx b/frontend/src/AppBuilder/LeftSidebar/GlobalSettings/CanvasSettings.jsx index 209677b747..3f0d90ccab 100644 --- a/frontend/src/AppBuilder/LeftSidebar/GlobalSettings/CanvasSettings.jsx +++ b/frontend/src/AppBuilder/LeftSidebar/GlobalSettings/CanvasSettings.jsx @@ -10,8 +10,10 @@ import { resolveReferences } from '@/_helpers/utils'; import FxButton from '@/Editor/CodeBuilder/Elements/FxButton'; import { useTranslation } from 'react-i18next'; import { Confirm } from '@/Editor/Viewer/Confirm'; +import { ColorSwatches } from '@/modules/Appbuilder/components'; import { shallow } from 'zustand/shallow'; import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext'; +import { getCssVarValue } from '@/Editor/Components/utils'; const CanvasSettings = ({ darkMode }) => { const { moduleId } = useModuleContext(); @@ -117,77 +119,64 @@ const CanvasSettings = ({ darkMode }) => {
-
+
{t('leftSidebar.Settings.backgroundColorOfCanvas', 'Canvas bavkground')}
- {showPicker && ( -
-
setShowPicker(false)} /> - setShowPicker(true)} - color={canvasBackgroundColor} - onChangeComplete={(color) => { +
+ { + if (typeof canvasBackgroundColor === 'string' && canvasBackgroundColor?.includes('var(')) { + const value = getCssVarValue(document.documentElement, canvasBackgroundColor); const options = { - canvasBackgroundColor: [color.hex, color.rgb], - backgroundFxQuery: '', + canvasBackgroundColor: value, + backgroundFxQuery: value, }; - globalSettingsChanged(options); - resolveOthers('canvas', true, { canvasBackgroundColor: [color.hex, color.rgb] }); - }} - /> -
- )} + await Promise.resolve(globalSettingsChanged(options)); + await Promise.resolve(resolveOthers('canvas', true, { canvasBackgroundColor: value })); + } + setForceCodeBox(!forceCodeBox); + }} + /> +
{forceCodeBox && ( -
setShowPicker(true)} style={outerStyles}> -
-
- {canvasBackgroundColor} -
-
+ { + const options = { + canvasBackgroundColor: resolveReferences(color), + backgroundFxQuery: color, + }; + globalSettingsChanged(options); + resolveOthers('canvas', true, { canvasBackgroundColor: color }); + }} + /> )}
{!forceCodeBox && ( - { - const options = { - canvasBackgroundColor: resolveReferences(color), - backgroundFxQuery: color, - }; - globalSettingsChanged(options); - resolveOthers('canvas', true, { canvasBackgroundColor: color }); - }} - /> +
+ { + const options = { + canvasBackgroundColor: resolveReferences(color), + backgroundFxQuery: color, + }; + globalSettingsChanged(options); + resolveOthers('canvas', true, { canvasBackgroundColor: color }); + }} + /> +
)} -
- { - setForceCodeBox(!forceCodeBox); - }} - /> -
diff --git a/frontend/src/AppBuilder/LeftSidebar/GlobalSettings/index.jsx b/frontend/src/AppBuilder/LeftSidebar/GlobalSettings/index.jsx index d85917c797..9f8379f85c 100644 --- a/frontend/src/AppBuilder/LeftSidebar/GlobalSettings/index.jsx +++ b/frontend/src/AppBuilder/LeftSidebar/GlobalSettings/index.jsx @@ -10,12 +10,13 @@ import AppModeToggle from './AppModeToggle'; import { ThemeSelect } from '@/modules/Appbuilder/components'; import MaintenanceMode from './MaintenanceMode'; import HideHeaderToggle from './HideHeaderToggle'; +import { ModuleProvider } from '@/AppBuilder/_contexts/ModuleContext'; const GlobalSettings = ({ darkMode }) => { const shouldFreeze = useStore((state) => state.getShouldFreeze()); return ( - <> +
@@ -44,7 +45,7 @@ const GlobalSettings = ({ darkMode }) => {
- + ); }; diff --git a/frontend/src/AppBuilder/LeftSidebar/LeftSidebar.jsx b/frontend/src/AppBuilder/LeftSidebar/LeftSidebar.jsx index e2b5947206..bcd90bee33 100644 --- a/frontend/src/AppBuilder/LeftSidebar/LeftSidebar.jsx +++ b/frontend/src/AppBuilder/LeftSidebar/LeftSidebar.jsx @@ -5,13 +5,14 @@ import cx from 'classnames'; import { shallow } from 'zustand/shallow'; import { DarkModeToggle } from '@/_components'; import Popover from '@/_ui/Popover'; -import { PageMenu } from './PageMenu'; +// import { PageMenu } from './PageMenu'; import LeftSidebarInspector from './LeftSidebarInspector/LeftSidebarInspector'; import GlobalSettings from './GlobalSettings'; import '../../_styles/left-sidebar.scss'; import Debugger from './Debugger/Debugger'; import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext'; import { withEditionSpecificComponent } from '@/modules/common/helpers/withEditionSpecificComponent'; +import { PageMenu } from '../RightSideBar/PageSettingsTab/PageMenu'; // TODO: remove passing refs to LeftSidebarItem and use state // TODO: need to add datasources to the sidebar. @@ -58,7 +59,6 @@ export const BaseLeftSidebar = ({ const sideBarBtnRefs = useRef({}); const handleSelectedSidebarItem = (item) => { - pinned && localStorage.setItem('selectedSidebarItem', item); if (item === 'debugger') resetUnreadErrorCount(); setSelectedSidebarItem(item); if (item === selectedSidebarItem && !pinned) { @@ -211,15 +211,6 @@ export const BaseLeftSidebar = ({ tip: 'Build with AI', ref: setSideBarBtnRefs('tooljetai'), })} - handleSelectedSidebarItem('page')} - darkMode={darkMode} - icon="page" - className={`left-sidebar-item left-sidebar-layout left-sidebar-page-selector`} - tip="Pages" - ref={setSideBarBtnRefs('page')} - /> {renderCommonItems()} { marginTop: level === 1 ? 4 : 0, marginBottom: level === 1 ? 4 : 0, // borderLeft: level > 1 ? '1px solid var(--slate6, #D7DBDF)' : 'none', + cursor: level === 1 ? 'pointer' : 'default', }} + {...(level === 1 && { onClick: () => onExpand(props) })} > {/* {!['queries', 'globals', 'variables'].includes(type) && ( */}
diff --git a/frontend/src/AppBuilder/LeftSidebar/PageMenu/PageMenuItem.jsx b/frontend/src/AppBuilder/LeftSidebar/PageMenu/PageMenuItem.jsx deleted file mode 100644 index 3f8b3c09d3..0000000000 --- a/frontend/src/AppBuilder/LeftSidebar/PageMenu/PageMenuItem.jsx +++ /dev/null @@ -1,317 +0,0 @@ -import React, { memo, useRef, useState, useCallback } from 'react'; -import cx from 'classnames'; -// import { RenameInput } from './RenameInput'; -// import { PagehandlerMenu } from './PagehandlerMenu'; -// import { EditModal } from './EditModal'; -// import { SettingsModal } from './SettingsModal'; -import { useAppVersionStore } from '@/_stores/appVersionStore'; -import SolidIcon from '@/_ui/Icon/SolidIcons'; -import EyeDisable from '@/_ui/Icon/solidIcons/EyeDisable'; -import FileRemove from '@/_ui/Icon/solidIcons/FIleRemove'; -import Home from '@/_ui/Icon/solidIcons/Home'; -import useStore from '@/AppBuilder/_stores/store'; -import _ from 'lodash'; -import { toast } from 'react-hot-toast'; -import { RenameInput } from './RenameInput'; -import IconSelector from './IconSelector'; -import { withRouter } from '@/_hoc/withRouter'; -import OverflowTooltip from '@/_components/OverflowTooltip'; -import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext'; -import { shallow } from 'zustand/shallow'; -import { ToolTip } from '@/_components/ToolTip'; - -export const PageMenuItem = withRouter( - memo(({ darkMode, page, navigate }) => { - const { moduleId } = useModuleContext(); - const homePageId = useStore((state) => state.appStore.modules[moduleId].app.homePageId); - const isHomePage = page.id === homePageId; - const currentPageId = useStore((state) => state.modules[moduleId].currentPageId); - const isSelected = page.id === currentPageId; - const isHidden = page?.hidden ?? false; - const isDisabled = page?.disabled ?? false; - const [isHovered, setIsHovered] = useState(false); - const shouldFreeze = useStore((state) => state.getShouldFreeze()); - const featureAccess = useStore((state) => state?.license?.featureAccess, shallow); - const licenseValid = !featureAccess?.licenseStatus?.isExpired && featureAccess?.licenseStatus?.isLicenseValid; - const showEditingPopover = useStore((state) => state.showEditingPopover); - const restricted = page?.permissions && page?.permissions?.length > 0; - const { - definition: { styles, properties }, - } = useStore((state) => state.pageSettings); - const setCurrentPageHandle = useStore((state) => state.setCurrentPageHandle); - // only update when the page is being edited - const editingPage = useStore( - (state) => state.editingPage, - (prev, next) => { - if (next?.id === page?.id) return false; - if (prev?.id === page?.id) return false; - return true; - } - ); - const editingPageName = useStore((state) => state.showEditPageNameInput); - const popoverRef = useRef(null); - - const openPageEditPopover = useStore((state) => state.openPageEditPopover); - const toggleEditPageNameInput = useStore((state) => state.toggleEditPageNameInput); - - const isEditingPage = editingPage?.id === page?.id; - const icon = () => { - const iconName = isHomePage && !page.icon ? 'IconHome2' : page.icon; - if (!isDisabled && !isHidden) { - return ; - } - if (isDisabled || (isDisabled && isHidden)) { - return ( - - ); - } - if (isHidden && !isDisabled) { - return ; - } - }; - - const computeStyles = useCallback(() => { - const baseStyles = { - pill: { - borderRadius: `${styles.pillRadius.value}px`, - }, - icon: { - color: !styles.iconColor.isDefault && styles.iconColor.value, - fill: !styles.iconColor.isDefault && styles.iconColor.value, - }, - }; - - switch (true) { - case isSelected: { - return { - ...baseStyles, - text: { - color: !styles.selectedTextColor.isDefault && styles.selectedTextColor.value, - }, - icon: { - stroke: !styles.selectedIconColor.isDefault && styles.selectedIconColor.value, - color: !styles.selectedIconColor.isDefault && styles.selectedIconColor.value, - fill: !styles.selectedIconColor.isDefault && styles.selectedIconColor.value, - }, - pill: { - background: !styles.pillSelectedBackgroundColor.isDefault && styles.pillSelectedBackgroundColor.value, - ...(page.id === editingPage?.id && { - backgroundColor: 'var(--slate1)', - }), - ...baseStyles.pill, - }, - }; - } - case isHovered: { - return { - ...baseStyles, - pill: { - background: !styles.pillHoverBackgroundColor.isDefault && styles.pillHoverBackgroundColor.value, - ...baseStyles.pill, - }, - }; - } - default: { - return { - text: { - color: !styles.textColor.isDefault && styles.textColor.value, - }, - icon: { - color: !styles.iconColor.isDefault && styles.iconColor.value, - fill: !styles.iconColor.isDefault && styles.iconColor.value, - }, - }; - } - } - }, [styles, isSelected, isHovered, page.id, editingPage?.id]); - - const computedStyles = computeStyles(); - - const labelStyle = { - icon: { - hidden: properties.style === 'text', - }, - label: { - hidden: properties.style === 'icon', - }, - }; - - const switchPage = useStore((state) => state.switchPage); - - const handlePageSwitch = useCallback(() => { - if (currentPageId === page.id) { - return; - } - switchPage(page.id, page.handle, [], moduleId); - setCurrentPageHandle(page.handle, moduleId); - }, [currentPageId, page.id, page.handle, switchPage, setCurrentPageHandle, moduleId]); - - const handlePageMenuSettings = useCallback( - (event) => { - event.stopPropagation(); - openPageEditPopover(page, popoverRef); - }, - [popoverRef.current, page] - ); - - function getTooltip() { - const permission = page?.permissions?.length ? page?.permissions[0] : null; - if (!permission) return ''; - const users = permission.users || []; - const isSingle = permission.type === 'SINGLE'; - const isGroup = permission.type === 'GROUP'; - - if (users.length === 0) return null; - - if (isSingle) { - if (users.length === 1) { - const email = users[0].user.email; - return `Access restricted to ${email}`; - } else { - return `Access restricted to ${users.length} users`; - } - } - - if (isGroup) { - if (users.length === 1) { - const groupName = users[0].permissionGroup?.name ?? 'Group'; - return `Access restricted to ${groupName} group`; - } else { - return `Access restricted to ${users.length} groups`; - } - } - - return ''; - } - - return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - style={{ - width: '100%', - }} - > - <> -
- {editingPageName && editingPage?.id === page?.id ? ( - <> - {' '} -
{icon()}
- { - toggleEditPageNameInput(false); - }} - /> - - ) : ( - <> - {' '} -
- {icon()} - - {page.name} - - - {isHomePage && 'Home'} - {isDisabled && 'Disabled'} - {isHidden && !isDisabled && 'Hidden'} - -
-
- {licenseValid && restricted && ( - -
- -
-
- )} -
-
- {!shouldFreeze && ( - - )} -
- - )} -
- -
- ); - }) -); - -export const AddingPageHandler = ({ darkMode }) => { - const toggleShowAddNewPageInput = useStore((state) => state.toggleShowAddNewPageInput); - const addNewPage = useStore((state) => state.addNewPage); - const isPageGroup = useStore((state) => state.isPageGroup); - const handleAddingNewPage = (pageName) => { - if (pageName.trim().length === 0) { - toast(`${isPageGroup ? 'Page group' : 'Page'} name should have at least 1 character`, { - icon: '⚠️', - }); - } else if (pageName.trim().length > 32) { - toast(`${isPageGroup ? 'Page group' : 'Page'} name cannot exceed 32 characters`, { - icon: '⚠️', - }); - } else { - addNewPage(pageName, _.kebabCase(pageName.toLowerCase()), isPageGroup); - } - toggleShowAddNewPageInput(false); - }; - - return ( -
-
- { - const name = event.target.value; - handleAddingNewPage(name); - event.stopPropagation(); - }} - onKeyDown={(event) => { - if (event.key === 'Enter') { - const name = event.target.value; - handleAddingNewPage(name); - event.stopPropagation(); - } - }} - /> -
-
- ); -}; diff --git a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/AggregateUI/index.jsx b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/AggregateUI/index.jsx index a2cf74da10..d9a670f200 100644 --- a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/AggregateUI/index.jsx +++ b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/AggregateUI/index.jsx @@ -277,7 +277,11 @@ export const AggregateFilter = ({ darkMode, operation = '' }) => { }; const aggFxOptions = [ - { label: 'Sum', value: 'sum', description: 'Sum of all values in this column' }, + { + label: 'Sum', + value: 'sum', + description: 'Sum of all values in this column', + }, { label: 'Count', value: 'count', @@ -402,7 +406,11 @@ export const AggregateFilter = ({ darkMode, operation = '' }) => { />
handleDeleteAggregate(aggregateKey)} > diff --git a/frontend/src/AppBuilder/QueryPanel/QueryCard.jsx b/frontend/src/AppBuilder/QueryPanel/QueryCard.jsx index 7328b7dfc6..9a37c8295e 100644 --- a/frontend/src/AppBuilder/QueryPanel/QueryCard.jsx +++ b/frontend/src/AppBuilder/QueryPanel/QueryCard.jsx @@ -1,10 +1,10 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState } from 'react'; import { Tooltip } from 'react-tooltip'; +import { ToolTip } from '@/_components/ToolTip'; import { updateQuerySuggestions } from '@/_helpers/appUtils'; // import { Confirm } from '../Viewer/Confirm'; import { toast } from 'react-hot-toast'; import { shallow } from 'zustand/shallow'; -import Copy from '@/_ui/Icon/solidIcons/Copy'; import DataSourceIcon from '../QueryManager/Components/DataSourceIcon'; import { isQueryRunnable, decodeEntities } from '@/_helpers/utils'; import { canDeleteDataSource, canReadDataSource, canUpdateDataSource } from '@/_helpers'; @@ -12,13 +12,10 @@ import useStore from '@/AppBuilder/_stores/store'; //TODO: Remove this import { Confirm } from '@/Editor/Viewer/Confirm'; // TODO: enable delete query confirmation popup -import { debounce } from 'lodash'; -import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext'; +import { Button as ButtonComponent } from '@/components/ui/Button/Button.jsx'; +import SolidIcon from '@/_ui/Icon/SolidIcons'; export const QueryCard = ({ dataQuery, darkMode = false, localDs }) => { - const { moduleId } = useModuleContext(); - const appId = useStore((state) => state.appStore.modules[moduleId].app.appId); - const isQuerySelected = useStore((state) => state.queryPanel.isQuerySelected(dataQuery.id), shallow); const setSelectedQuery = useStore((state) => state.queryPanel.setSelectedQuery); const checkExistingQueryName = useStore((state) => state.dataQuery.checkExistingQueryName); @@ -26,67 +23,94 @@ export const QueryCard = ({ dataQuery, darkMode = false, localDs }) => { const isDeletingQueryInProcess = useStore((state) => state.dataQuery.isDeletingQueryInProcess); const renameQuery = useStore((state) => state.dataQuery.renameQuery); const deleteDataQueries = useStore((state) => state.dataQuery.deleteDataQueries); - const duplicateQuery = useStore((state) => state.dataQuery.duplicateQuery); const setPreviewData = useStore((state) => state.queryPanel.setPreviewData); - const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false); + const shouldFreeze = useStore((state) => state.getShouldFreeze()); + + const renamingQueryId = useStore((state) => state.queryPanel.renamingQueryId); + const deletingQueryId = useStore((state) => state.queryPanel.deletingQueryId); + const setRenamingQuery = useStore((state) => state.queryPanel.setRenamingQuery); + const deleteDataQuery = useStore((state) => state.queryPanel.deleteDataQuery); + const isRenaming = renamingQueryId === dataQuery.id; + const isDeleting = deletingQueryId === dataQuery.id; + const hasPermissions = selectedDataSourceScope === 'global' ? canUpdateDataSource(dataQuery?.data_source_id) || - canReadDataSource(dataQuery?.data_source_id) || - canDeleteDataSource() + canReadDataSource(dataQuery?.data_source_id) || + canDeleteDataSource() : true; - const shouldFreeze = useStore((state) => state.getShouldFreeze()); - - const [renamingQuery, setRenamingQuery] = useState(false); - - const deleteDataQuery = (e) => { - e.stopPropagation(); - setShowDeleteConfirmation(true); - }; + const toggleQueryHandlerMenu = useStore((state) => state.queryPanel.toggleQueryHandlerMenu); + const featureAccess = useStore((state) => state?.license?.featureAccess, shallow); + const licenseValid = !featureAccess?.licenseStatus?.isExpired && featureAccess?.licenseStatus?.isLicenseValid; + const isRestricted = dataQuery.permissions && dataQuery.permissions.length !== 0; const updateQueryName = (dataQuery, newName) => { const { name } = dataQuery; if (name === newName) { - return setRenamingQuery(false); + return setRenamingQuery(null); } const isNewQueryNameAlreadyExists = checkExistingQueryName(newName); if (newName && !isNewQueryNameAlreadyExists) { renameQuery(dataQuery?.id, newName); - setRenamingQuery(false); + setRenamingQuery(null); updateQuerySuggestions(name, newName); } else { if (isNewQueryNameAlreadyExists) { toast.error('Query name already exists'); } - setRenamingQuery(false); + setRenamingQuery(null); } }; const executeDataQueryDeletion = () => { - setShowDeleteConfirmation(false); + deleteDataQuery(null); deleteDataQueries(dataQuery?.id); setPreviewData(null); }; - // To prevent user clicking from continuous clicks - const debouncedDuplicateQuery = useCallback( - debounce((queryId, appId) => { - duplicateQuery(queryId, appId); - setPreviewData(null); - }, 500), - [duplicateQuery] - ); + const getTooltip = () => { + const permission = dataQuery.permissions?.[0]; + if (!permission) return null; + + const users = permission.groups || permission.users || []; + if (users.length === 0) return null; + + const isSingle = permission.type === 'SINGLE'; + const isGroup = permission.type === 'GROUP'; + + if (isSingle) { + return users.length === 1 + ? `Access restricted to ${users[0].user.email}` + : `Access restricted to ${users.length} users`; + } + + if (isGroup) { + return users.length === 1 + ? `Access restricted to ${users[0].permission_group?.name || users[0].permissionGroup?.name} group` + : `Access restricted to ${users.length} user groups`; + } + + return null; + }; return ( <>
{ + onClick={(e) => { if (isQuerySelected) return; - setSelectedQuery(dataQuery?.id); - setPreviewData(null); + const menuBtn = document.getElementById(`query-handler-menu-${dataQuery?.id}`); + if (menuBtn.contains(e.target)) { + e.stopPropagation(); + } else { + toggleQueryHandlerMenu(false); + } + setTimeout(() => { + setSelectedQuery(dataQuery?.id); + setPreviewData(null); + }, 0); }} role="button" > @@ -94,12 +118,11 @@ export const QueryCard = ({ dataQuery, darkMode = false, localDs }) => {
- {renamingQuery ? ( + {isRenaming ? ( { data-tooltip-dynamic="true" > {decodeEntities(dataQuery.name)} - {' '} + + +
+ {licenseValid && isRestricted && } +
+
{' '} {!isQueryRunnable(dataQuery) && Draft} {localDs && ( <> @@ -143,80 +171,25 @@ export const QueryCard = ({ dataQuery, darkMode = false, localDs }) => {
)}
- {!shouldFreeze && isQuerySelected && ( -
-
setRenamingQuery(true)} - > - - - - - -
-
debouncedDuplicateQuery(dataQuery?.id, appId)} - > - - - -
-
- {isDeletingQueryInProcess ? ( -
-
-
- ) : ( - - - - - - - - )} -
- -
- )} +
+ toggleQueryHandlerMenu(true, `query-handler-menu-${dataQuery?.id}`)} + size="small" + variant="outline" + className="" + id={`query-handler-menu-${dataQuery?.id}`} + data-cy={`delete-query-${dataQuery.name.toLowerCase()}`} + /> +
setShowDeleteConfirmation(false)} + onCancel={() => deleteDataQuery(null)} darkMode={darkMode} /> diff --git a/frontend/src/AppBuilder/QueryPanel/QueryCardMenu.jsx b/frontend/src/AppBuilder/QueryPanel/QueryCardMenu.jsx new file mode 100644 index 0000000000..a9e3030b51 --- /dev/null +++ b/frontend/src/AppBuilder/QueryPanel/QueryCardMenu.jsx @@ -0,0 +1,174 @@ +import React, { useCallback } from 'react'; +import { Overlay, Popover } from 'react-bootstrap'; +import useStore from '@/AppBuilder/_stores/store'; +import classNames from 'classnames'; +import Edit from '@/_ui/Icon/bulkIcons/Edit'; +import Trash from '@/_ui/Icon/solidIcons/Trash'; +import Copy from '@/_ui/Icon/solidIcons/Copy'; +import SolidIcon from '@/_ui/Icon/SolidIcons'; +import { shallow } from 'zustand/shallow'; +import { ToolTip } from '@/_components/ToolTip'; +import { debounce } from 'lodash'; +import usePopoverObserver from '@/AppBuilder/_hooks/usePopoverObserver'; +import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext'; + +const QueryCardMenu = ({ darkMode }) => { + const { moduleId } = useModuleContext(); + const appId = useStore((state) => state.appStore.modules[moduleId].app.appId); + const selectedQuery = useStore((state) => state.queryPanel.selectedQuery); + const toggleQueryPermissionModal = useStore((state) => state.queryPanel.toggleQueryPermissionModal); + const featureAccess = useStore((state) => state?.license?.featureAccess, shallow); + const licenseValid = !featureAccess?.licenseStatus?.isExpired && featureAccess?.licenseStatus?.isLicenseValid; + const targetBtnForMenu = useStore((state) => state.queryPanel.targetBtnForMenu); + const targetElement = document.getElementById(targetBtnForMenu); + const showQueryHandlerMenu = useStore((state) => state.queryPanel.showQueryHandlerMenu); + const toggleQueryHandlerMenu = useStore((state) => state.queryPanel.toggleQueryHandlerMenu); + const duplicateQuery = useStore((state) => state.dataQuery.duplicateQuery); + const setPreviewData = useStore((state) => state.queryPanel.setPreviewData); + const setRenamingQuery = useStore((state) => state.queryPanel.setRenamingQuery); + const deleteDataQuery = useStore((state) => state.queryPanel.deleteDataQuery); + + const QUERY_MENU_OPTIONS = [ + { + label: 'Rename', + value: 'rename', + icon: , + showTooltip: false, + }, + { + label: 'Duplicate', + value: 'duplicate', + icon: , + showTooltip: false, + }, + { + label: 'Query permission', + value: 'permission', + icon: ( + permission-icon + ), + trailingIcon: , + }, + { + label: 'Delete', + value: 'delete', + icon: , + showTooltip: false, + }, + ]; + + // To prevent user clicking from continuous clicks + const debouncedDuplicateQuery = useCallback( + debounce((queryId, appId) => { + duplicateQuery(queryId, appId); + setPreviewData(null); + }, 500), + [duplicateQuery] + ); + + const handleQueryMenuActions = (value) => { + if (value === 'rename') { + setRenamingQuery(selectedQuery?.id); + } + if (value === 'duplicate') { + debouncedDuplicateQuery(selectedQuery?.id, appId); + } + if (value === 'permission') { + if (!licenseValid) return; + toggleQueryPermissionModal(true); + } + if (value === 'delete') { + deleteDataQuery(selectedQuery?.id); + } + toggleQueryHandlerMenu(false); + }; + + usePopoverObserver( + document.getElementsByClassName('query-list')[0], + targetElement, + document.getElementById('query-list-menu'), + showQueryHandlerMenu, + () => (document.getElementById('query-list-menu').style.display = 'block'), + () => (document.getElementById('query-list-menu').style.display = 'none') + ); + + return ( + toggleQueryHandlerMenu(false)} + popperConfig={{ + modifiers: [ + { + name: 'flip', + options: { + fallbackPlacements: ['top-start'], + flipVariations: true, + allowedAutoPlacements: ['top', 'bottom'], + boundary: 'viewport', + }, + }, + { + name: 'offset', + options: { + offset: [0, 3], + }, + }, + ], + }} + > + {(props) => ( + + + {QUERY_MENU_OPTIONS.map((option) => { + const optionBody = ( +
{ + e.stopPropagation(); + handleQueryMenuActions(option.value); + }} + > +
{option.icon}
+
+ {option?.label} +
+ {option.value === 'permission' && !licenseValid && option.trailingIcon && option.trailingIcon} +
+ ); + + return option.value === 'permission' ? ( + + {optionBody} + + ) : ( + optionBody + ); + })} +
+
+ )} +
+ ); +}; + +export default QueryCardMenu; diff --git a/frontend/src/AppBuilder/QueryPanel/QueryDataPane.jsx b/frontend/src/AppBuilder/QueryPanel/QueryDataPane.jsx index 9ac052ae51..97b4daa68f 100644 --- a/frontend/src/AppBuilder/QueryPanel/QueryDataPane.jsx +++ b/frontend/src/AppBuilder/QueryPanel/QueryDataPane.jsx @@ -16,6 +16,10 @@ import DataSourceSelect from '../QueryManager/Components/DataSourceSelect'; import { OverlayTrigger, Popover } from 'react-bootstrap'; import FolderEmpty from '@/_ui/Icon/solidIcons/FolderEmpty'; import useStore from '@/AppBuilder/_stores/store'; +import AppPermissionsModal from '@/modules/Appbuilder/components/AppPermissionsModal'; +import { shallow } from 'zustand/shallow'; +import { appPermissionService } from '@/_services'; +import QueryCardMenu from './QueryCardMenu'; export const QueryDataPane = ({ darkMode }) => { const { t } = useTranslation(); @@ -34,6 +38,12 @@ export const QueryDataPane = ({ darkMode }) => { function isDataSourceLocal(dataQuery) { return dataSources.some((dataSource) => dataSource.id === dataQuery.data_source_id); } + const featureAccess = useStore((state) => state?.license?.featureAccess, shallow); + const licenseValid = !featureAccess?.licenseStatus?.isExpired && featureAccess?.licenseStatus?.isLicenseValid; + const selectedQuery = useStore((state) => state.queryPanel.selectedQuery); + const showQueryPermissionModal = useStore((state) => state.queryPanel.showQueryPermissionModal); + const toggleQueryPermissionModal = useStore((state) => state.queryPanel.toggleQueryPermissionModal); + const setQueries = useStore((state) => state.dataQuery.setQueries); useEffect(() => { setQueryPanelSearchTerm(searchTermForFilters); @@ -171,6 +181,33 @@ export const QueryDataPane = ({ darkMode }) => { {filteredQueries.map((query) => ( ))} + + {licenseValid && ( + appPermissionService.getQueryPermission(appId, id)} + createPermission={(id, appId, body) => appPermissionService.createQueryPermission(appId, id, body)} + updatePermission={(id, appId, body) => appPermissionService.updateQueryPermission(appId, id, body)} + deletePermission={(id, appId) => appPermissionService.deleteQueryPermission(appId, id)} + onSuccess={(data) => { + const updatedDataQueries = dataQueries.map((query) => { + if (query.id === selectedQuery.id) { + return { + ...query, + permissions: data.length === 0 || data.length === undefined ? [] : [data[0]], + }; + } + return query; + }); + setQueries(updatedDataQueries); + }} + /> + )}
{ const selectedComponentId = useStore((state) => state.selectedComponents?.[0], shallow); + const activeTab = useStore((state) => state.activeRightSideBarTab, shallow); + const toggleRightSidebarPin = useStore((state) => state.toggleRightSidebarPin); + const isRightSidebarPinned = useStore((state) => state.isRightSidebarPinned); const setActiveRightSideBarTab = useStore((state) => state.setActiveRightSideBarTab); - if (!selectedComponentId) { - return setActiveRightSideBarTab(RIGHT_SIDE_BAR_TAB.COMPONENTS); + if (!selectedComponentId && activeTab !== RIGHT_SIDE_BAR_TAB.PAGES) { + // return setActiveRightSideBarTab(RIGHT_SIDE_BAR_TAB.COMPONENTS); + return ( + <> +
+
Component properties
+
toggleRightSidebarPin()}> + +
+
+
+ +
No component selected
+
+ Click a component on the canvas to view and edit its properties. +
+
+ + ); } return ( { +export const ComponentModuleTab = ({ onChangeTab, hasModuleAccess }) => { const [activeTab, setActiveTab] = useState(1); const handleChangeTab = (tab) => { @@ -18,13 +18,15 @@ export const ComponentModuleTab = ({ onChangeTab }) => { > Components - + {hasModuleAccess && ( + + )} ); diff --git a/frontend/src/AppBuilder/RightSideBar/ComponentManagerTab/ComponentModuleTab/styles.scss b/frontend/src/AppBuilder/RightSideBar/ComponentManagerTab/ComponentModuleTab/styles.scss index 51fdce0d61..3182cb2368 100644 --- a/frontend/src/AppBuilder/RightSideBar/ComponentManagerTab/ComponentModuleTab/styles.scss +++ b/frontend/src/AppBuilder/RightSideBar/ComponentManagerTab/ComponentModuleTab/styles.scss @@ -4,6 +4,8 @@ height: 36px; margin-bottom: 8px; margin-top: 16px; + margin-left: 16px; + margin-right: 16px; } .tj-tabs-container { diff --git a/frontend/src/AppBuilder/RightSideBar/ComponentManagerTab/ComponentsManagerTab.jsx b/frontend/src/AppBuilder/RightSideBar/ComponentManagerTab/ComponentsManagerTab.jsx index 38783c3b51..a6c7db5e52 100644 --- a/frontend/src/AppBuilder/RightSideBar/ComponentManagerTab/ComponentsManagerTab.jsx +++ b/frontend/src/AppBuilder/RightSideBar/ComponentManagerTab/ComponentsManagerTab.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState, useEffect } from 'react'; import { isEmpty, debounce } from 'lodash'; import { useTranslation } from 'react-i18next'; import { LEGACY_ITEMS, IGNORED_ITEMS } from './constants'; @@ -7,8 +7,37 @@ import Fuse from 'fuse.js'; import { SearchBox } from '@/_components'; import { DragLayer } from './DragLayer'; import useStore from '@/AppBuilder/_stores/store'; +import Accordion from '@/_ui/Accordion'; +import sectionConfig from './sectionConfig'; +import SolidIcon from '@/_ui/Icon/SolidIcons'; import { ModuleManager } from '@/modules/Modules/components'; import { ComponentModuleTab } from '@/modules/Appbuilder/components'; +import { useLicenseStore } from '@/_stores/licenseStore'; +import { shallow } from 'zustand/shallow'; + +// Simple error boundary component for module errors +class ModuleErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error) { + return { hasError: true }; + } + + componentDidCatch(error, errorInfo) { + console.error('Module error:', error, errorInfo); + this.props.onError(); + } + + render() { + if (this.state.hasError) { + return null; // Let parent handle the fallback + } + return this.props.children; + } +} // TODO: Hardcode all the component-section mapping in a constant file and just loop over it // TODO: styling @@ -25,15 +54,32 @@ export const ComponentsManagerTab = ({ darkMode, isModuleEditor }) => { const [filteredComponents, setFilteredComponents] = useState(componentList); const [searchQuery, setSearchQuery] = useState(''); const [activeTab, setActiveTab] = useState(1); + const [moduleError, setModuleError] = useState(false); const _shouldFreeze = useStore((state) => state.getShouldFreeze()); const isAutoMobileLayout = useStore((state) => state.currentLayout === 'mobile' && state.getIsAutoMobileLayout()); const shouldFreeze = _shouldFreeze || isAutoMobileLayout; - const handleSearchQueryChange = useCallback( - debounce((e) => { - const { value } = e.target; - setSearchQuery(value); + const { hasModuleAccess } = useLicenseStore( + (state) => ({ + hasModuleAccess: state.hasModuleAccess, + }), + shallow + ); + // Force re-render when hasModuleAccess changes + useEffect(() => { + // If modules access is denied and we're on the modules tab, switch to components + if (!hasModuleAccess && activeTab === 2) { + setActiveTab(1); + } + }, [hasModuleAccess, activeTab]); + + const toggleRightSidebarPin = useStore((state) => state.toggleRightSidebarPin); + const isRightSidebarPinned = useStore((state) => state.isRightSidebarPinned); + + const handleSearchQueryChange = useCallback( + debounce((value) => { + setSearchQuery(value); if (activeTab === 1) { filterComponents(value); } @@ -78,11 +124,10 @@ export const ComponentsManagerTab = ({ darkMode, isModuleEditor }) => { ); } - function renderList(header, items) { + function renderList(items) { if (isEmpty(items)) return null; return (
- {header}
{items.map((component, i) => renderComponentCard(component, i))}
@@ -105,6 +150,7 @@ export const ComponentsManagerTab = ({ darkMode, isModuleEditor }) => { className=" btn-sm tj-tertiary-btn mt-3" onClick={() => { setFilteredComponents([]); + handleSearchQueryChange(''); }} > {t('widgetManager.clearQuery', 'clear query')} @@ -113,75 +159,66 @@ export const ComponentsManagerTab = ({ darkMode, isModuleEditor }) => { ); } - if (filteredComponents.length != componentList.length) { - return <>{renderList(undefined, filteredComponents)}; - } else { - const commonSection = { title: t('widgetManager.commonlyUsed', 'commonly used'), items: [] }; - const layoutsSection = { title: t('widgetManager.layouts', 'layouts'), items: [] }; - const formSection = { title: t('widgetManager.forms', 'forms'), items: [] }; - const integrationSection = { title: t('widgetManager.integrations', 'integrations'), items: [] }; - const otherSection = { title: t('widgetManager.others', 'others'), items: [] }; - const legacySection = { title: 'Legacy', items: [] }; - - const commonItems = ['Table', 'Button', 'Text', 'TextInput', 'DatetimePickerV2', 'Form']; - const formItems = [ - 'Form', - 'TextInput', - 'NumberInput', - 'PasswordInput', - 'TextArea', - 'EmailInput', - 'PhoneInput', - 'CurrencyInput', - 'ToggleSwitchV2', - 'DropdownV2', - 'MultiselectV2', - 'RichTextEditor', - 'Checkbox', - 'RadioButtonV2', - 'DatetimePickerV2', - 'DatePickerV2', - 'TimePicker', - 'DaterangePicker', - 'FilePicker', - 'StarRating', - ]; - const integrationItems = ['Map']; - const layoutItems = ['Container', 'Listview', 'Tabs', 'ModalV2']; - - filteredComponents.forEach((f) => { - if (commonItems.includes(f)) commonSection.items.push(f); - if (formItems.includes(f)) formSection.items.push(f); - else if (integrationItems.includes(f)) integrationSection.items.push(f); - else if (LEGACY_ITEMS.includes(f)) legacySection.items.push(f); - else if (layoutItems.includes(f)) layoutsSection.items.push(f); - else otherSection.items.push(f); - }); - - return ( - <> - {renderList(commonSection.title, commonSection.items)} - {renderList(layoutsSection.title, layoutsSection.items)} - {renderList(formSection.title, formSection.items)} - {renderList(otherSection.title, otherSection.items)} - {renderList(integrationSection.title, integrationSection.items)} - {renderList(legacySection.title, legacySection.items)} - - ); + if (filteredComponents.length !== componentList.length) { + return <>{renderList(filteredComponents)}; } + + const sections = Object.entries(sectionConfig).map(([key, config]) => ({ + title: config.title, + items: filteredComponents.filter((component) => config.valueSet.has(component)), + })); + + const items = []; + sections.forEach((section) => { + if (section.items.length > 0) { + items.push({ + title: section.title, + isOpen: true, + children: renderList(section.items), + }); + } + }); + + return ( +
+ +
+ ); } const handleChangeTab = (tab) => { + if (tab === 2 && !hasModuleAccess) { + setActiveTab(1); + return; + } setActiveTab(tab); + if (tab === 1) setModuleError(false); // When changing tabs, we don't need to reset the search // The search query will be applied to the new tab }; + // Handle module errors by redirecting to components tab + useEffect(() => { + if (moduleError && activeTab === 2) { + setActiveTab(1); + } + }, [moduleError, activeTab]); + const renderSection = () => { if (activeTab === 1) { return
{segregateSections()}
; } - return ; + + // If there was an error accessing modules, redirect to components tab + if (moduleError) { + return
{segregateSections()}
; + } + + return ( + setModuleError(true)}> + + + ); }; return ( @@ -189,13 +226,13 @@ export const ComponentsManagerTab = ({ darkMode, isModuleEditor }) => { {isModuleEditor ? (

Components

) : ( - + )}
handleSearchQueryChange(e)} + callBack={(e) => handleSearchQueryChange(e.target.value)} onClearCallback={() => { setSearchQuery(''); if (activeTab === 1) { diff --git a/frontend/src/AppBuilder/RightSideBar/ComponentManagerTab/DragLayer.jsx b/frontend/src/AppBuilder/RightSideBar/ComponentManagerTab/DragLayer.jsx index 0c6f7327af..16b8a74f77 100644 --- a/frontend/src/AppBuilder/RightSideBar/ComponentManagerTab/DragLayer.jsx +++ b/frontend/src/AppBuilder/RightSideBar/ComponentManagerTab/DragLayer.jsx @@ -10,7 +10,12 @@ import { shallow } from 'zustand/shallow'; import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext'; import { noop } from 'lodash'; -export const DragLayer = ({ index, component, isModuleTab = false }) => { +export const DragLayer = ({ index, component, isModuleTab = false, disabled = false }) => { + const [isRightSidebarOpen, toggleRightSidebar] = useStore( + (state) => [state.isRightSidebarOpen, state.toggleRightSidebar], + shallow + ); + const isRightSidebarPinned = useStore((state) => state.isRightSidebarPinned); const { isModuleEditor } = useModuleContext(); const setShowModuleBorder = useStore((state) => state.setShowModuleBorder, shallow) || noop; const [{ isDragging }, drag, preview] = useDrag( @@ -28,11 +33,14 @@ export const DragLayer = ({ index, component, isModuleTab = false }) => { useEffect(() => { if (isDragging && !isModuleEditor) { + if (!isRightSidebarPinned) { + toggleRightSidebar(!isRightSidebarOpen); + } setShowModuleBorder(true); } else { setShowModuleBorder(false); } - }, [isDragging, setShowModuleBorder, isModuleEditor]); + }, [isDragging, setShowModuleBorder, isModuleEditor, toggleRightSidebar]); // const size = isModuleTab // ? component.module_container.layouts[currentLayout] @@ -43,8 +51,16 @@ export const DragLayer = ({ index, component, isModuleTab = false }) => { return ( <> {isDragging && } -
- {isModuleTab ? : } +
+ {isModuleTab ? ( + + ) : ( + + )}
); @@ -55,36 +71,43 @@ const CustomDragLayer = ({ size }) => { currentOffset: monitor.getSourceClientOffset(), item: monitor.getItem(), })); - + console.log(currentOffset, 'currentOffset'); if (!currentOffset) return null; const canvasWidth = item?.canvasWidth; const canvasBounds = item?.canvasRef?.getBoundingClientRect(); const height = size.height; - const mainCanvasWidth = document.getElementById('real-canvas')?.offsetWidth || 0; + const appCanvasWidth = document.getElementById('real-canvas')?.offsetWidth || 0; + + // Calculate width based on the app canvas's grid + let width = (appCanvasWidth * size.width) / NO_OF_GRIDS; - let width = (mainCanvasWidth * size.width) / NO_OF_GRIDS; // Calculate position relative to the current canvas (parent or child) const left = currentOffset.x - (canvasBounds?.left || 0); const top = currentOffset.y - (canvasBounds?.top || 0); - // Adjust position and width if exceeding grid bounds - if (width >= canvasWidth) { + // Ensure width doesn't exceed the current container's width + if (width > canvasWidth) { width = canvasWidth; } + // Snap width to grid (round to nearest grid unit) + const gridUnitWidth = canvasWidth / NO_OF_GRIDS; + const gridUnits = Math.round(width / gridUnitWidth); + width = gridUnits * gridUnitWidth; + const [x, y] = snapToGrid(canvasWidth, left, top); return (
{ + const [isFxActive, setIsFxActive] = useState(false); + + const handleFxButtonClick = () => { + paramUpdated({ name: paramName }, 'fxActive', !isFxActive, 'properties'); + setIsFxActive(!isFxActive); + }; + + return ( +
e.stopPropagation()} + > +
e.stopPropagation()}> +
+ +
+ +
+
+ {isFxActive ? ( + + ) : ( + +
+ + {/* Mandatory Checkbox */} +
+ +
+
+ ); +}; + +const RenderSection = ({ + mappedColumns = [], + setMappedColumns, + darkMode, + sectionType, + sectionDisplayName, + disabled = false, +}) => { + const columnsArray = useMemo(() => { + return Array.isArray(mappedColumns) ? mappedColumns : []; + }, [mappedColumns]); + + const checkboxStates = useCheckboxStates(columnsArray); + + const { isAllSelected, isIntermediateSelected, isAllSelectedMandatory, isIntermediateMandatory } = checkboxStates; + + const handleSelectAll = useCallback( + (checked) => { + if (columnsArray.length > 0) { + const updatedColumns = columnsArray.map((col) => ({ + ...col, + selected: checked, + })); + setMappedColumns(updatedColumns); + } + }, + [columnsArray, setMappedColumns] + ); + + const handleSelectAllMandatory = useCallback( + (checked) => { + if (columnsArray.length > 0) { + const updatedColumns = columnsArray.map((col) => { + if (isPropertyFxControlled(col.mandatory)) { + return col; + } + + return { + ...col, + mandatory: { + ...col.mandatory, + value: checked, + }, + }; + }); + setMappedColumns(updatedColumns); + } + }, + [columnsArray, setMappedColumns] + ); + + const handleColumnSelect = useCallback( + (columnName, checked) => { + if (columnsArray.length > 0) { + const updatedColumns = columnsArray.map((col) => { + if (col.name !== columnName) { + return col; + } + + return { + ...col, + selected: checked, + }; + }); + setMappedColumns(updatedColumns); + } + }, + [columnsArray, setMappedColumns] + ); + + const handleColumnChange = useCallback( + (columnName, changes) => { + if (columnsArray.length > 0) { + const updatedColumns = columnsArray.map((col) => (col.name === columnName ? { ...col, ...changes } : col)); + setMappedColumns(updatedColumns); + } + }, + [columnsArray, setMappedColumns] + ); + + const shouldHideSelectAll = sectionType === 'isCustomField'; + + const renderHeader = () => { + return ( +
+
+ +
+
+ Column name +
+
+
+ + Mapped to +
+
+ + Input label +
+
+ Mandatory? +
+ +
+
+
+ ); + }; + + return ( +
+
+ {sectionDisplayName} +
+ + {renderHeader()} + +
+ {columnsArray.length > 0 ? ( + columnsArray.map((column, index) => ( + c.name === column.name)} + onCheckboxChange={(checked) => handleColumnSelect(column.name, checked)} + onChange={(changes) => handleColumnChange(column.name, changes)} + index={index} + darkMode={darkMode} + disabled={disabled} + sectionType={sectionType} + /> + )) + ) : ( +
No {sectionDisplayName.toLowerCase()} available
+ )} +
+
+ ); +}; + +const ColumnMappingComponent = ({ + isOpen, + onClose, + darkMode = false, + onSubmit, + currentStatusRef, + component, + newResolvedJsonData, + existingResolvedJsonData, + source, + isDataLoading, +}) => { + const { resolveReferences, getComponentDefinition, getFormFields } = useStore( + (state) => ({ + resolveReferences: state.resolveReferences, + getComponentDefinition: state.getComponentDefinition, + getFormFields: state.getFormFields, + }), + shallow + ); + + const componentNameIdMapping = useStore((state) => state.modules.canvas.componentNameIdMapping, shallow); + const queryNameIdMapping = useStore((state) => state.modules.canvas.queryNameIdMapping, shallow); + const runQuery = useStore((state) => state.queryPanel.runQuery, shallow); + + const [isSaving, setIsSaving] = useState(false); + const [refreshedColumns, setRefreshedColumns] = useState([]); + const [showLoader, setShowLoader] = useState(false); + const bodyContainerRef = useRef(null); + const lastBodyHeightRef = useRef(60); + + useEffect(() => { + setShowLoader(isDataLoading); + }, [isDataLoading]); + + // Track body height when content is loaded + useEffect(() => { + if (!showLoader && bodyContainerRef.current) { + // Use setTimeout to ensure DOM is fully rendered + setTimeout(() => { + if (bodyContainerRef.current) { + const height = bodyContainerRef.current.scrollHeight; + if (height > 0) { + lastBodyHeightRef.current = height; + } + } + }, 0); + } + }, [showLoader, groupedColumns]); + + const currentStatus = currentStatusRef.current; + + console.log('here--- existingResolvedJsonData--- ', existingResolvedJsonData); + + const columnsToUse = useColumnBuilder( + component, + currentStatus, + newResolvedJsonData, + existingResolvedJsonData, + refreshedColumns?.length === 0 || Object.keys(refreshedColumns).length === 0 + ? newResolvedJsonData + : refreshedColumns, + getFormFields, + getComponentDefinition + ); + + const { groupedColumns, sectionTypes, updateSectionColumns } = useGroupedColumns(columnsToUse, currentStatus); + + const refreshData = useCallback(async () => { + setShowLoader(true); + currentStatusRef.current = FORM_STATUS.REFRESH_FIELDS; + const res = extractAndReplaceReferencesFromString(source.value, componentNameIdMapping, queryNameIdMapping); + const { allRefs, valueWithBrackets } = res; + + const queryRefs = allRefs + .filter((ref) => ref.entityType === 'queries') + .filter((ref, index, self) => index === self.findIndex((r) => r.entityNameOrId === ref.entityNameOrId)); + + await Promise.all( + queryRefs.map(async (ref) => { + const queryId = ref.entityNameOrId; + await runQuery(queryId, '', false, 'edit'); + }) + ); + + const resolvedValue = resolveReferences('canvas', valueWithBrackets); + setRefreshedColumns(resolvedValue); + setShowLoader(false); + }, [source.value, componentNameIdMapping, queryNameIdMapping, runQuery, resolveReferences, currentStatusRef]); + + const handleSubmit = useCallback(() => { + setIsSaving(true); + const flatColumns = Object.entries(groupedColumns) + .flatMap(([, columns]) => columns) + .filter((col) => !col.isCustomField); + const combinedColumns = flatColumns.map((column) => { + if (!column.selected) { + return { + ...column, + isRemoved: true, + }; + } else return column; + }); + + onSubmit?.(combinedColumns); + }, [groupedColumns, onSubmit]); + + // Get display name for section type + const getSectionDisplayName = useCallback((sectionType) => { + return SECTION_DISPLAY_NAMES[sectionType] || ''; + }, []); + + const allSectionsEmpty = useMemo(() => { + return Object.values(groupedColumns).every((sectionColumns) => { + return Array.isArray(sectionColumns) ? sectionColumns.every((col) => !col.selected) : true; + }); + }, [groupedColumns]); + + const modalBody = ( + <> +
+ {showLoader && } + + {!showLoader && ( +
+ {sectionTypes.map((sectionType) => { + return ( + groupedColumns[sectionType]?.length > 0 && ( + updateSectionColumns(sectionType, updatedColumns)} + darkMode={darkMode} + sectionType={sectionType} + sectionDisplayName={ + currentStatus !== FORM_STATUS.GENERATE_FIELDS ? getSectionDisplayName(sectionType) : '' + } + disabled={sectionType === 'isRemoved'} + /> + ) + ); + })} +
+ )} +
+
+ +
+ + ); + + return ( + + +
{modalBody}
+
+ ); +}; + +export default ColumnMappingComponent; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/DataSection.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/DataSection.jsx new file mode 100644 index 0000000000..aca6ee612f --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/DataSection.jsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { renderElement } from '../../../Utils'; +import { DataSectionWrapper } from './index'; + +export const DataSection = ({ + component, + componentMeta, + paramUpdatedInterceptor, + dataQueries, + currentState, + allComponents, + darkMode, + resolvedCustomSchema, + source, + JSONData, + setCodeEditorView, + currentStatusRef, + saveDataSection, + openModal, + setParentModalState, + performColumnMapping, + existingResolvedJsonData, + savedSourceValue, + resolveReferences, + isLoading = false, +}) => { + return () => ( +
+ {componentMeta?.properties && + Object.keys(componentMeta.properties).map((property) => { + if (componentMeta?.properties[property]?.section !== 'data') return null; + + // Mutating the component definition properties to set the generateFormFrom source + component.component.definition.properties.generateFormFrom = source; + component.component.definition.properties.JSONData = JSONData; + const focusCodeEditor = property === 'JSONData' ? setCodeEditorView : undefined; + + return renderElement( + component, + componentMeta, + paramUpdatedInterceptor, + dataQueries, + property, + 'properties', + currentState, + allComponents, + darkMode, + '', + null, + focusCodeEditor + ); + })} + {source.value !== 'jsonSchema' && ( + + )} +
+ ); +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/DataSectionUI.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/DataSectionUI.jsx new file mode 100644 index 0000000000..c557644440 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/DataSectionUI.jsx @@ -0,0 +1,201 @@ +import React, { useState, useRef, useMemo, useCallback, useEffect } from 'react'; +import { Button } from '@/components/ui/Button/Button'; +import { LabeledDivider, ColumnMappingComponent, FormFieldsList, FieldPopoverContent } from './index'; +import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; +import Popover from 'react-bootstrap/Popover'; +import { useDropdownState } from '../_hooks/useDropdownState'; +import useStore from '@/AppBuilder/_stores/store'; +import { shallow } from 'zustand/shallow'; +import { findNextElementTop, mergeFieldsWithComponentDefinition } from '../utils/utils'; +import { createNewComponentFromMeta } from '../utils/fieldOperations'; +import { FORM_STATUS, COMPONENT_LAYOUT_DETAILS } from '../constants'; +import { checkDiff } from '@/AppBuilder/Widgets/componentUtils'; + +/* IMPORTANT - mandatory and selected (visibility) properties are objects with value and fxActive + This is to support dynamic values and fx expressions in the form fields. + When using these properties, ensure to access the value like so: field.mandatory.value + or field.selected.value. + Rest all the fields are directly accessible as strings or booleans. + For example: field.label, field.name, field.value, etc. +*/ + +const DataSectionUI = ({ + component, + darkMode = false, + currentStatusRef, + openModalFromParent = false, + setParentModalState, + performColumnMapping, + newResolvedJsonData, + existingResolvedJsonData, + source, + JSONData, + isLoading: isDataLoading, + savedSourceValue = '', +}) => { + const { getChildComponents, currentLayout, getComponentDefinition, performBatchComponentOperations, saveFormFields } = + useStore( + (state) => ({ + getChildComponents: state.getChildComponents, + currentLayout: state.currentLayout, + getComponentDefinition: state.getComponentDefinition, + performBatchComponentOperations: state.performBatchComponentOperations, + saveFormFields: state.saveFormFields, + }), + shallow + ); + + const formFields = useStore((state) => state.getFormFields(component.id), checkDiff); + + const formFieldsWithComponentDefinition = useMemo( + () => mergeFieldsWithComponentDefinition(formFields, getComponentDefinition), + [formFields, getComponentDefinition] + ); + + const { handleDropdownOpen, handleDropdownClose, shouldPreventPopoverClose } = useDropdownState(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [showAddFieldPopover, setShowAddFieldPopover] = useState(false); + const addFieldButtonRef = useRef(null); + + const hideManageFields = formFields.length === 0 || savedSourceValue === 'rawJson'; + + useEffect(() => { + if (openModalFromParent && openModalFromParent !== isModalOpen) { + setIsModalOpen(true); + } else if (!openModalFromParent) setIsModalOpen(false); + }, [openModalFromParent, isModalOpen]); + + const handleDeleteField = (field) => { + const updatedFields = formFields.filter((f) => f.componentId !== field.componentId); + let operations = { + updated: {}, + added: {}, + deleted: [field.componentId], + }; + performBatchComponentOperations(operations); + saveFormFields(component.id, updatedFields, 'canvas'); + }; + + const handleAddField = (newField) => { + const updatedFields = { + componentType: newField.componentType, + name: 'custom', + mandatory: newField.mandatory, + label: newField.label, + value: '', + placeholder: newField.placeholder, + selected: true, + isCustomField: true, + }; + const childComponents = getChildComponents(component?.id); + // Get the last position of the child components + const nextElementsTop = findNextElementTop(childComponents, currentLayout); + const { added = {} } = createNewComponentFromMeta( + updatedFields, + component.id, + nextElementsTop + COMPONENT_LAYOUT_DETAILS.spacing + ); + let operations = { + updated: {}, + added: {}, + deleted: [], + }; + operations.added[added.id] = added; + + performBatchComponentOperations(operations); + saveFormFields(component.id, [...formFields, { componentId: added.id, isCustomField: true }], 'canvas'); + setShowAddFieldPopover(false); + }; + + const renderManageFieldsIcon = () => { + return ( +
+ + +
+
+ { + handleFieldChange('componentType', value); + }} + width="100%" + label="Component" + onOpen={onDropdownOpen} + onClose={onDropdownClose} + /> +
+ +
+ + handleFieldChange('label', value)} + /> +
+ + {renderPlaceholder()} + {renderDefaultValue()} + +
+ handleFieldChange('mandatory', value)} + onFxPress={(active) => handleFxChange('mandatory', active)} + /> +
+ {mode === 'edit' && ( +
+ handleFieldChange('visibility', value)} + onFxPress={(active) => handleFxChange('visibility', active)} + /> +
+ )} + +
+
+ + ); +}; + +export default React.memo(FieldPopoverContent); diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/FormField.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/FormField.jsx new file mode 100644 index 0000000000..eadff717f9 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/FormField.jsx @@ -0,0 +1,166 @@ +import React, { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/Button/Button'; +import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; +import Popover from 'react-bootstrap/Popover'; +import { FieldPopoverContent } from './index'; +import { useDropdownState } from '../_hooks/useDropdownState'; +import useStore from '@/AppBuilder/_stores/store'; +import { shallow } from 'zustand/shallow'; +import { isTrueValue, isPropertyFxControlled, getComponentIcon } from '../utils/utils'; + +export const FormField = ({ field, onDelete, activeMenu, onMenuToggle, onSave, darkMode = false }) => { + const setSelectedComponents = useStore((state) => state.setSelectedComponents, shallow); + const [showPopover, setShowPopover] = useState(false); + const [fieldData, setFieldData] = useState(field); + const { handleDropdownOpen, handleDropdownClose, shouldPreventPopoverClose } = useDropdownState(); + + useEffect(() => { + if (activeMenu && activeMenu !== fieldData.name) { + setShowPopover(false); + } + }, [activeMenu, fieldData.name]); + + useEffect(() => { + setFieldData(field); + }, [field]); + + const handleFieldChange = (updatedField) => { + setFieldData(updatedField); + onSave([updatedField], true); + }; + + const isMandatoryFxControlled = isPropertyFxControlled(fieldData.mandatory); + + const isCurrentlyMandatory = isTrueValue(fieldData.mandatory?.value); + + const mainPopover = ( + + setShowPopover(false)} + onChange={handleFieldChange} + onDropdownOpen={handleDropdownOpen} + onDropdownClose={handleDropdownClose} + shouldPreventPopoverClose={shouldPreventPopoverClose} + setSelectedComponents={setSelectedComponents} + /> + + ); + + const menuPopover = ( + + +
+ + + +
+
+
+ ); + + return ( +
+ { + if (!show && shouldPreventPopoverClose) { + return; + } + if (show) onMenuToggle(null); + setShowPopover(show); + }} + rootClose + overlay={mainPopover} + > +
+
+
+ {getComponentIcon(fieldData.componentType, darkMode)} +
+ {fieldData.name} +
+ + { + setShowPopover(false); + if (show) { + onMenuToggle(fieldData.name); + } else { + onMenuToggle(null); + } + }} + rootClose + overlay={menuPopover} + > +
+
+
+ ); +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/FormFieldsList.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/FormFieldsList.jsx new file mode 100644 index 0000000000..12a8856673 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/FormFieldsList.jsx @@ -0,0 +1,36 @@ +import React, { useState } from 'react'; +import { FormField } from './index'; + +export const FormFieldsList = ({ fields, onDeleteField, currentStatusRef, onSave }) => { + const [activeMenuField, setActiveMenuField] = useState(null); + + if (fields.length === 0) { + return ( + + No fields yet. Generate a form from a data source or add custom fields. + + ); + } + + return ( +
+
+
+ {fields.map((field) => ( + { + currentStatusRef.current = null; + setActiveMenuField(fieldName); + }} + onDelete={onDeleteField} + onSave={onSave} + /> + ))} +
+
+
+ ); +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/LabeledDivider.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/LabeledDivider.jsx new file mode 100644 index 0000000000..7e2ffb293d --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/LabeledDivider.jsx @@ -0,0 +1,39 @@ +import React from 'react'; + +const LabeledDivider = ({ label, rightContentCount = 0 }) => { + return ( +
+ {/* Background line */} +
+
+
+ + {/* Label container - centered accounting for right content */} +
+ + {label} + +
+
+ ); +}; + +export default LabeledDivider; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/hooks/useColumnMapping.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/hooks/useColumnMapping.js new file mode 100644 index 0000000000..fb0aee7ca7 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/hooks/useColumnMapping.js @@ -0,0 +1,144 @@ +import { useEffect, useState, useCallback, useMemo } from 'react'; +import { + isTrueValue, + isPropertyFxControlled, + parseDataAndBuildFields, + analyzeJsonDifferences, + mergeFieldsWithComponentDefinition, + mergeFormFieldsWithNewData, + mergeArrays, +} from '../../utils/utils'; +import { FORM_STATUS } from '../../constants'; +import { merge } from 'lodash'; + +// Constants for section order preference +const SECTION_ORDER = ['isNew', 'isRemoved', 'existing', 'isCustomField']; + +/** + * Custom hook for managing column building logic + */ +export const useColumnBuilder = ( + component, + currentStatus, + newResolvedJsonData, + existingResolvedJsonData, + refreshedColumns, + getFormFields, + getComponentDefinition +) => { + return useMemo(() => { + const formFields = getFormFields(component.id); + const formFieldsWithComponentDefinition = mergeFieldsWithComponentDefinition(formFields, getComponentDefinition); + + if (currentStatus === FORM_STATUS.MANAGE_FIELDS) { + const allColumnsFromJsonData = parseDataAndBuildFields(newResolvedJsonData); + return mergeArrays(allColumnsFromJsonData, formFieldsWithComponentDefinition); + } else if (currentStatus === FORM_STATUS.REFRESH_FIELDS) { + const jsonDifferences = analyzeJsonDifferences(refreshedColumns, existingResolvedJsonData); + const mergedJsonData = merge({}, existingResolvedJsonData, refreshedColumns); + const parsedFields = parseDataAndBuildFields(mergedJsonData, jsonDifferences); + const mergedFields = mergeFormFieldsWithNewData(formFieldsWithComponentDefinition, parsedFields); + const enhancedFieldsWithComponentDefinition = mergeFieldsWithComponentDefinition( + mergedFields, + getComponentDefinition + ); + return [ + ...enhancedFieldsWithComponentDefinition, + ...formFieldsWithComponentDefinition.filter((f) => f.isCustomField), + ]; + } + return parseDataAndBuildFields(newResolvedJsonData || []); + }, [ + component.id, + currentStatus, + newResolvedJsonData, + existingResolvedJsonData, + refreshedColumns, + getFormFields, + getComponentDefinition, + ]); +}; + +/** + * Custom hook for managing grouped columns state + */ +export const useGroupedColumns = (columnsToUse, currentStatus) => { + const [groupedColumns, setGroupedColumns] = useState({}); + const [sectionTypes, setSectionTypes] = useState([]); + + useEffect(() => { + const grouped = {}; + const isGenerateFieldsMode = currentStatus === FORM_STATUS.GENERATE_FIELDS; + const isRefreshFormMode = currentStatus === FORM_STATUS.REFRESH_FIELDS; + const shouldSelectByDefault = isGenerateFieldsMode || isRefreshFormMode; + + columnsToUse.forEach((col) => { + let sectionType = 'existing'; + + if (col.isNew) { + sectionType = 'isNew'; + } else if (col.isRemoved) { + sectionType = 'isRemoved'; + } else if (col.isCustomField) { + sectionType = 'isCustomField'; + } + + if (!grouped[sectionType]) { + grouped[sectionType] = []; + } + + // Auto-select columns based on mode + if ( + shouldSelectByDefault && + sectionType !== 'isRemoved' && + (isGenerateFieldsMode || (isRefreshFormMode && sectionType === 'isNew')) + ) { + grouped[sectionType].push({ ...col, selected: true }); + } else { + grouped[sectionType].push(col); + } + }); + + const types = SECTION_ORDER.filter((type) => grouped[type] && grouped[type].length > 0); + + setGroupedColumns(grouped); + setSectionTypes(types); + }, [columnsToUse, currentStatus]); + + const updateSectionColumns = useCallback((sectionType, updatedColumns) => { + setGroupedColumns((prev) => ({ + ...prev, + [sectionType]: updatedColumns, + })); + }, []); + + return { groupedColumns, sectionTypes, updateSectionColumns }; +}; + +/** + * Hook for checkbox state calculations + */ +export const useCheckboxStates = (columnsArray) => { + return useMemo(() => { + const mandatorySettableColumns = columnsArray.filter((col) => !isPropertyFxControlled(col.mandatory)); + + const isAllSelected = columnsArray.length > 0 ? columnsArray.every((col) => col.selected) : false; + const isIntermediateSelected = !isAllSelected && columnsArray.some((col) => col.selected); + + const isAllSelectedMandatory = + mandatorySettableColumns.length > 0 + ? mandatorySettableColumns.every((col) => isTrueValue(col.mandatory.value)) + : false; + + const isIntermediateMandatory = + !isAllSelectedMandatory && mandatorySettableColumns.some((col) => isTrueValue(col.mandatory.value)); + + return { + isAllSelected, + isIntermediateSelected, + isAllSelectedMandatory, + isIntermediateMandatory, + mandatorySettableColumns, + }; + }, [columnsArray]); +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/index.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/index.js new file mode 100644 index 0000000000..682f2d4533 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/index.js @@ -0,0 +1,9 @@ +// Component exports for cleaner imports +export { DataSection } from './DataSection'; +export { default as DataSectionWrapper } from './DataSectionWrapper'; +export { default as DataSectionUI } from './DataSectionUI'; +export { default as ColumnMappingComponent } from './ColumnMappingComponent'; +export { default as FieldPopoverContent } from './FieldPopoverContent'; +export { FormField } from './FormField'; +export { FormFieldsList } from './FormFieldsList'; +export { default as LabeledDivider } from './LabeledDivider'; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/index.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/index.js new file mode 100644 index 0000000000..4a70ccee82 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/index.js @@ -0,0 +1,4 @@ +export { useFormState } from './useFormState'; +export { useFormData } from './useFormData'; +export { useFormLogic } from './useFormLogic'; +export { useDropdownState } from './useDropdownState'; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/useDropdownState.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/useDropdownState.js new file mode 100644 index 0000000000..4cc35aee7a --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/useDropdownState.js @@ -0,0 +1,23 @@ +import { useState, useCallback } from 'react'; + +export const useDropdownState = () => { + const [dropdownState, setDropdownState] = useState('closed'); // 'closed' | 'opening' | 'open' + + const handleDropdownOpen = useCallback(() => { + setDropdownState('open'); + }, []); + + const handleDropdownClose = useCallback(() => { + setDropdownState('closing'); + setTimeout(() => setDropdownState('closed'), 100); + }, []); + + const shouldPreventPopoverClose = dropdownState !== 'closed'; + + return { + dropdownState, + handleDropdownOpen, + handleDropdownClose, + shouldPreventPopoverClose, + }; +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/useFormData.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/useFormData.js new file mode 100644 index 0000000000..35e277850e --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/useFormData.js @@ -0,0 +1,37 @@ +import React from 'react'; +import useStore from '@/AppBuilder/_stores/store'; +import { shallow } from 'zustand/shallow'; +import { mergeFieldsWithComponentDefinition } from '../utils/utils'; + +export const useFormData = (component) => { + const resolveReferences = useStore((state) => state.resolveReferences, shallow); + const getFormDataSectionData = useStore((state) => state.getFormDataSectionData, shallow); + const getComponentDefinition = useStore((state) => state.getComponentDefinition, shallow); + const formFields = useStore((state) => state.getFormFields(component.id), shallow); + + // Get form data and process it + const existingData = getFormDataSectionData(component?.id); + let isFormGenerated = existingData?.generateFormFrom?.value ?? false; + + // Memoized form fields with component definition + const formFieldsWithComponentDefinition = React.useMemo( + () => mergeFieldsWithComponentDefinition(formFields, getComponentDefinition), + [formFields, getComponentDefinition] + ); + + // Process JSON data + let existingResolvedJsonData = existingData?.JSONData?.value; + existingResolvedJsonData = resolveReferences('canvas', existingResolvedJsonData); + + const newJSONValue = component.component.definition.properties['JSONData']?.value; + const newResolvedJsonData = resolveReferences('canvas', newJSONValue); + + return { + existingData, + isFormGenerated, + formFieldsWithComponentDefinition, + existingResolvedJsonData, + newJSONValue, + newResolvedJsonData, + }; +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/useFormLogic.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/useFormLogic.js new file mode 100644 index 0000000000..0484f64f26 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/useFormLogic.js @@ -0,0 +1,114 @@ +import { useEffect } from 'react'; +import useStore from '@/AppBuilder/_stores/store'; +import { shallow } from 'zustand/shallow'; +import { useFormState } from './useFormState'; +import { useFormData } from './useFormData'; +import { createParamUpdatedInterceptor, createColumnMappingHandler, createJSONDataBlurHandler } from '../handlers'; + +export const useFormLogic = (component, paramUpdated) => { + // Store selectors + const resolveReferences = useStore((state) => state.resolveReferences, shallow); + const getFormDataSectionData = useStore((state) => state.getFormDataSectionData, shallow); + const saveFormDataSectionData = useStore((state) => state.saveFormDataSectionData, shallow); + const componentNameIdMapping = useStore((state) => state.modules.canvas.componentNameIdMapping, shallow); + const queryNameIdMapping = useStore((state) => state.modules.canvas.queryNameIdMapping, shallow); + const getChildComponents = useStore((state) => state.getChildComponents, shallow); + const runQuery = useStore((state) => state.queryPanel.runQuery, shallow); + const getExposedValueOfQuery = useStore((state) => state.getExposedValueOfQuery, shallow); + const currentLayout = useStore((state) => state.currentLayout, shallow); + const getComponentDefinition = useStore((state) => state.getComponentDefinition, shallow); + const performBatchComponentOperations = useStore((state) => state.performBatchComponentOperations, shallow); + + // Custom hooks + const formState = useFormState(component); + const formData = useFormData(component); + + // Save data section function + const saveDataSection = (fields) => { + formState.savedSourceValue.current = formState.source.value; + const newJsonData = formState.JSONData; + + if (newJsonData?.value === undefined) { + newJsonData.value = resolveReferences('canvas', formState.source.value); + } + + saveFormDataSectionData( + component?.id, + { + generateFormFrom: formState.source, + JSONData: formState.JSONData, + }, + fields + ); + }; + + // Create column mapping handler + const performColumnMapping = createColumnMappingHandler({ + component, + isFormGenerated: formData.isFormGenerated, + currentStatusRef: formState.currentStatusRef, + formFields: useStore((state) => state.getFormFields(component.id), shallow), + formFieldsWithComponentDefinition: formData.formFieldsWithComponentDefinition, + getChildComponents, + currentLayout, + performBatchComponentOperations, + saveDataSection, + setOpenModal: formState.setOpenModal, + }); + + // Create JSON data blur handler + const handleJSONDataBlur = createJSONDataBlurHandler({ + component, + currentStatusRef: formState.currentStatusRef, + resolveReferences, + getFormDataSectionData, + savedSourceValue: formState.savedSourceValue, + source: formState.source, + formFieldsWithComponentDefinition: formData.formFieldsWithComponentDefinition, + existingResolvedJsonData: formData.existingResolvedJsonData, + getComponentDefinition, + performColumnMapping, + saveDataSection, + codeEditorView: formState.codeEditorView, + }); + + // Create parameter updated interceptor + const paramUpdatedInterceptor = createParamUpdatedInterceptor({ + component, + paramUpdated, + source: formState.source, + setSource: formState.setSource, + setJSONData: formState.setJSONData, + setOpenModal: formState.setOpenModal, + shouldFocusJSONDataEditor: formState.shouldFocusJSONDataEditor, + shouldInvokeBlurEvent: formState.shouldInvokeBlurEvent, + savedSourceValue: formState.savedSourceValue, + componentNameIdMapping, + queryNameIdMapping, + getFormDataSectionData, + getExposedValueOfQuery, + runQuery, + resolveReferences, + setLoading: formState.setLoading, + }); + + // Effect for handling JSON data blur + useEffect(() => { + if (formState.shouldInvokeBlurEvent.current) { + formState.shouldInvokeBlurEvent.current = false; + handleJSONDataBlur(formState.JSONData.value); + } + }, [formState.shouldInvokeBlurEvent, formState.JSONData, handleJSONDataBlur]); + + return { + ...formState, + ...formData, + paramUpdatedInterceptor, + performColumnMapping, + handleJSONDataBlur, + saveDataSection, + closeModal: () => { + formState.setOpenModal(false); + }, + }; +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/useFormState.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/useFormState.js new file mode 100644 index 0000000000..39104b0909 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/useFormState.js @@ -0,0 +1,78 @@ +import { useState, useRef, useEffect } from 'react'; +import useStore from '@/AppBuilder/_stores/store'; +import { shallow } from 'zustand/shallow'; +import { INPUT_COMPONENTS_FOR_FORM } from '../constants'; + +export const useFormState = (component) => { + const getChildComponents = useStore((state) => state.getChildComponents, shallow); + const saveFormFields = useStore((state) => state.saveFormFields, shallow); + const resolveReferences = useStore((state) => state.resolveReferences, shallow); + + const [source, setSource] = useState({ + value: component.component.definition.properties?.generateFormFrom?.value, + fxActive: component.component.definition.properties?.generateFormFrom?.fxActive, + }); + + const resolvedSource = resolveReferences( + 'canvas', + component.component.definition.properties?.generateFormFrom?.value + ); + + const [JSONData, setJSONData] = useState({ + value: resolvedSource === 'rawJson' ? component.component.definition.properties?.JSONData?.value : resolvedSource, + }); + + const [openModal, setOpenModal] = useState(false); + const [isLoading, setLoading] = useState(false); + const [codeEditorView, setCodeEditorView] = useState(null); + + // Refs for managing component state + const shouldFocusJSONDataEditor = useRef(false); + const currentStatusRef = useRef(null); + const shouldInvokeBlurEvent = useRef(false); + const savedSourceValue = useRef(component.component.definition.properties?.generateFormFrom?.value); + + // Backfill fields if not present + const fields = component.component.definition.properties?.fields; + if (fields === undefined) { + const newFields = []; + const childComponents = getChildComponents(component.id); + Object.keys(childComponents).forEach((childId) => { + if (INPUT_COMPONENTS_FOR_FORM.includes(childComponents[childId].component.component.component)) { + newFields.push({ + componentId: childId, + isCustomField: true, + }); + } + }); + saveFormFields(component.id, newFields, 'canvas'); + } + + // Focus management effect + useEffect(() => { + if (codeEditorView && shouldFocusJSONDataEditor.current) { + codeEditorView.focus(); + // Add 'focused' class to the parent of codeEditorView.dom + if (codeEditorView.dom && codeEditorView.dom.parentNode) { + codeEditorView.dom.parentNode.classList.add('focused'); + } + } + }, [codeEditorView, shouldFocusJSONDataEditor]); + + return { + source, + setSource, + JSONData, + setJSONData, + openModal, + setOpenModal, + codeEditorView, + setCodeEditorView, + shouldFocusJSONDataEditor, + currentStatusRef, + shouldInvokeBlurEvent, + savedSourceValue, + isLoading, + setLoading, + }; +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/config/accordionConfig.js similarity index 51% rename from frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form.jsx rename to frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/config/accordionConfig.js index 6b0bc05422..cb630bbcc4 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/config/accordionConfig.js @@ -1,93 +1,10 @@ import React from 'react'; -import Accordion from '@/_ui/Accordion'; -import { EventManager } from '../EventManager'; -import { renderElement } from '../Utils'; // eslint-disable-next-line import/no-unresolved import i18next from 'i18next'; -import { deepClone } from '@/_helpers/utilities/utils.helpers'; +import { EventManager } from '../../../EventManager'; +import { renderElement } from '../../../Utils'; -export const Form = ({ - componentMeta, - darkMode, - layoutPropertyChanged, - component, - paramUpdated, - dataQueries, - currentState, - eventsChanged, - apps, - allComponents, - pages, -}) => { - const tempComponentMeta = deepClone(componentMeta); - - let properties = []; - let additionalActions = []; - let dataProperties = []; - - const events = Object.keys(componentMeta.events); - const validations = Object.keys(componentMeta.validation || {}); - - for (const [key] of Object.entries(componentMeta?.properties)) { - if (componentMeta?.properties[key]?.section === 'additionalActions') { - additionalActions.push(key); - } else if (componentMeta?.properties[key]?.accordian === 'Data') { - dataProperties.push(key); - } else { - properties.push(key); - } - } - - const { id } = component; - const newOptions = [{ name: 'None', value: 'none' }]; - - Object.entries(allComponents).forEach(([componentId, _component]) => { - const validParent = - _component.component.parent === id || - _component.component.parent === `${id}-footer` || - _component.component.parent === `${id}-header`; - if (validParent && _component?.component?.component === 'Button') { - newOptions.push({ name: _component.component.name, value: componentId }); - } - }); - - tempComponentMeta.properties.buttonToSubmit.options = newOptions; - - // Hide header footer if custom schema is turned on - - if (component.component.definition.properties.advanced.value === '{{true}}') { - component.component.properties.showHeader = { - ...component.component.properties.headerHeight, - isHidden: true, - }; - component.component.properties.showFooter = { - ...component.component.properties.headerHeight, - isHidden: true, - }; - } - - const accordionItems = baseComponentProperties( - properties, - events, - component, - tempComponentMeta, - layoutPropertyChanged, - paramUpdated, - dataQueries, - currentState, - eventsChanged, - apps, - allComponents, - validations, - darkMode, - pages, - additionalActions - ); - - return ; -}; - -export const baseComponentProperties = ( +export const createAccordionItems = ({ properties, events, component, @@ -102,12 +19,16 @@ export const baseComponentProperties = ( validations, darkMode, pages, - additionalActions -) => { + additionalActions, + deprecatedProperties, + renderDataElement, +}) => { let items = []; + + // Structure section if (properties.length > 0) { items.push({ - title: `${i18next.t('widget.common.properties', 'Properties')}`, + title: `${i18next.t('widget.common.structure', 'Structure')}`, children: properties.map((property) => renderElement( component, @@ -124,6 +45,14 @@ export const baseComponentProperties = ( }); } + // Data section + items.push({ + title: 'Data', + isOpen: true, + children: renderDataElement(), + }); + + // Events section if (events.length > 0) { items.push({ title: `${i18next.t('widget.common.events', 'Events')}`, @@ -145,6 +74,7 @@ export const baseComponentProperties = ( }); } + // Additional actions section items.push({ title: 'Additional actions', isOpen: true, @@ -163,6 +93,7 @@ export const baseComponentProperties = ( ), }); + // Validation section if (validations.length > 0) { items.push({ title: `${i18next.t('widget.common.validation', 'Validation')}`, @@ -182,6 +113,7 @@ export const baseComponentProperties = ( }); } + // Devices section items.push({ title: `${i18next.t('widget.common.devices', 'Devices')}`, isOpen: true, @@ -211,5 +143,24 @@ export const baseComponentProperties = ( ), }); + // Deprecated section + items.push({ + title: 'Deprecated', + isOpen: true, + children: deprecatedProperties?.map((property) => + renderElement( + component, + componentMeta, + paramUpdated, + dataQueries, + property, + 'properties', + currentState, + allComponents, + darkMode + ) + ), + }); + return items; }; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/constants.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/constants.js new file mode 100644 index 0000000000..adb487e5a8 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/constants.js @@ -0,0 +1,47 @@ +export const DATATYPE_TO_COMPONENT = { + string: 'TextInput', + number: 'NumberInput', + date: 'DatePickerV2', + boolean: 'Checkbox', + array: 'DropdownV2', +}; + +export const COMPONENT_WITH_OPTIONS = ['DropdownV2', 'MultiselectV2', 'RadioButtonV2']; + +export const INPUT_COMPONENTS_FOR_FORM = [ + 'TextInput', + 'PasswordInput', + 'EmailInput', + 'PhoneInput', + 'CurrencyInput', + 'NumberInput', + 'DropdownV2', + 'MultiselectV2', + 'RadioButtonV2', + 'DatetimePickerV2', + 'Checkbox', + 'ToggleSwitchV2', + 'DatePickerV2', + 'TimePicker', + 'DaterangePicker', + 'TextArea', +]; + +export const JSON_DIFFERENCE = { + isExisting: [], + isNew: [], + isRemoved: [], +}; + +export const FORM_STATUS = { + MANAGE_FIELDS: 'manageFields', + GENERATE_FIELDS: 'generateFields', + REFRESH_FIELDS: 'refreshFields', +}; + +export const COMPONENT_LAYOUT_DETAILS = { + spacing: 40, + defaultWidth: 37, + defaultHeight: 30, + defaultLeft: 3, +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/handlers/columnMappingHandlers.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/handlers/columnMappingHandlers.js new file mode 100644 index 0000000000..c561534837 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/handlers/columnMappingHandlers.js @@ -0,0 +1,135 @@ +import { isEqual } from 'lodash'; +import { FORM_STATUS, COMPONENT_LAYOUT_DETAILS } from '../constants'; +import { findNextElementTop, cleanupFormFields } from '../utils/utils'; +import { updateFormFieldComponent } from '../utils/fieldOperations'; + +export const createColumnMappingHandler = ({ + component, + isFormGenerated, + currentStatusRef, + formFields, + formFieldsWithComponentDefinition, + getChildComponents, + currentLayout, + performBatchComponentOperations, + saveDataSection, + setOpenModal, +}) => { + return (columns, isSingleUpdate = false) => { + const newColumns = isSingleUpdate ? formFields.filter((field) => field.componentId !== columns[0].componentId) : []; + let operations = { + updated: {}, + added: {}, + deleted: [], + }, + componentsToBeRemoved = []; + + const isFormRegeneration = isFormGenerated && currentStatusRef.current === FORM_STATUS.GENERATE_FIELDS; + + if (!isSingleUpdate) { + if (isFormRegeneration) { + formFields.forEach((field) => { + if (!field.isCustomField) { + componentsToBeRemoved.push(field.componentId); + operations.deleted.push(field.componentId); + } else { + newColumns.push(field); + } + }); + } else if (currentStatusRef.current === FORM_STATUS.GENERATE_FIELDS) { + newColumns.push(...formFields); + } else { + formFields.forEach((field) => { + if (field.isCustomField) { + newColumns.push(field); + } + }); + columns.forEach((column) => { + if (column.isRemoved) { + componentsToBeRemoved.push(column.componentId); + } + }); + } + } + + const childComponents = getChildComponents(component?.id); + // Get the last position of the child components + const nextElementsTop = findNextElementTop(childComponents, currentLayout, componentsToBeRemoved); + // Create form field components from columns + + if (columns && Array.isArray(columns) && columns.length > 0) { + let nextTop = nextElementsTop + COMPONENT_LAYOUT_DETAILS.spacing; + + columns.forEach((column, index) => { + if (column.isRemoved) return operations.deleted.push(column.componentId); + + if (currentStatusRef.current === FORM_STATUS.REFRESH_FIELDS) { + delete column.isRemoved; + delete column.isNew; + delete column.isExisting; + if ( + isEqual( + column, + formFieldsWithComponentDefinition.find((field) => field.componentId === column.componentId) + ) + ) { + return newColumns.push(column); + } + } + + if ( + currentStatusRef.current === FORM_STATUS.MANAGE_FIELDS && + isEqual( + column, + formFieldsWithComponentDefinition.find((field) => field.componentId === column.componentId) + ) + ) { + return newColumns.push(column); + } + + const { + added = {}, + updated = {}, + deleted = false, + } = updateFormFieldComponent(column, {}, component.id, nextTop); + + if (Object.keys(updated).length !== 0) { + operations.updated[column.componentId] = updated; + newColumns.push(column); + } + if (Object.keys(added).length !== 0) { + operations.added[added.id] = added; + if (added.component.component === 'Checkbox') { + nextTop = nextTop + added.layouts['desktop'].height + 10; + } else { + nextTop = nextTop + added.layouts['desktop'].height + COMPONENT_LAYOUT_DETAILS.spacing; + } + + // Create simplified column structure with only the required fields + const simplifiedColumn = { + componentId: added.id, + isCustomField: column.isCustomField ?? false, + dataType: column.dataType, + key: column.key || column.name, + }; + + columns[index] = simplifiedColumn; // Replace with simplified structure + newColumns.push(simplifiedColumn); + } + if (deleted) { + operations.deleted.push(column.componentId); + } + }); + + if ( + Object.keys(operations.updated).length > 0 || + Object.keys(operations.added).length > 0 || + operations.deleted.length > 0 + ) { + performBatchComponentOperations(operations); + saveDataSection(cleanupFormFields(newColumns)); + } + setOpenModal(false); + } + }; +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/handlers/index.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/handlers/index.js new file mode 100644 index 0000000000..ce043f77a1 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/handlers/index.js @@ -0,0 +1,3 @@ +export { createParamUpdatedInterceptor } from './parameterHandlers'; +export { createColumnMappingHandler } from './columnMappingHandlers'; +export { createJSONDataBlurHandler } from './jsonDataHandlers'; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/handlers/jsonDataHandlers.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/handlers/jsonDataHandlers.js new file mode 100644 index 0000000000..7c08168da4 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/handlers/jsonDataHandlers.js @@ -0,0 +1,79 @@ +import { isEqual, merge } from 'lodash'; +import { FORM_STATUS } from '../constants'; +import { + parseDataAndBuildFields, + analyzeJsonDifferences, + mergeFormFieldsWithNewData, + mergeFieldsWithComponentDefinition, +} from '../utils/utils'; + +export const createJSONDataBlurHandler = ({ + component, + currentStatusRef, + resolveReferences, + getFormDataSectionData, + savedSourceValue, + source, + formFieldsWithComponentDefinition, + existingResolvedJsonData, + getComponentDefinition, + performColumnMapping, + saveDataSection, + codeEditorView, +}) => { + return async (newJSONValue = null) => { + if (codeEditorView.dom && codeEditorView.dom.parentNode) { + codeEditorView.dom.parentNode.classList.remove('focused'); + } + + const existingData = getFormDataSectionData(component?.id); + const isFormGenerated = existingData && existingData.generateFormFrom && existingData.JSONData; + + // Resolve both values to compare actual data, not just string comparison + const resolvedNewJSONValue = resolveReferences('canvas', newJSONValue); + const existingResolvedValue = existingData?.JSONData?.value + ? resolveReferences('canvas', existingData.JSONData.value) + : null; + + // Use deep comparison to check if there's actual content change + const hasDataChanged = !isEqual(resolvedNewJSONValue, existingResolvedValue); + + // Only proceed if there's actual data and changes + if (!resolvedNewJSONValue || !newJSONValue) { + return; + } + + if (!isFormGenerated) { + currentStatusRef.current = FORM_STATUS.GENERATE_FIELDS; + const columns = parseDataAndBuildFields(resolvedNewJSONValue); + + if (columns && columns.length > 0) { + performColumnMapping(columns); + } + return; + } + + if (hasDataChanged) { + const sourceChanged = !isEqual(savedSourceValue.current, source?.value); + currentStatusRef.current = sourceChanged ? FORM_STATUS.GENERATE_FIELDS : FORM_STATUS.REFRESH_FIELDS; + const jsonDifferences = analyzeJsonDifferences( + resolvedNewJSONValue, + sourceChanged ? null : existingResolvedJsonData + ); + + const mergedJsonData = merge({}, sourceChanged ? {} : existingResolvedJsonData, resolvedNewJSONValue); + const parsedFields = parseDataAndBuildFields(mergedJsonData, jsonDifferences); + const mergedFields = mergeFormFieldsWithNewData(formFieldsWithComponentDefinition, parsedFields); + const enhancedFieldsWithComponentDefinition = mergeFieldsWithComponentDefinition( + mergedFields, + getComponentDefinition + ); + + if (enhancedFieldsWithComponentDefinition && enhancedFieldsWithComponentDefinition.length > 0) { + performColumnMapping(enhancedFieldsWithComponentDefinition); + } + } else if (savedSourceValue.current === 'jsonSchema') { + return saveDataSection(formFieldsWithComponentDefinition); + } + }; +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/handlers/parameterHandlers.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/handlers/parameterHandlers.js new file mode 100644 index 0000000000..a120175f67 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/handlers/parameterHandlers.js @@ -0,0 +1,105 @@ +import { extractAndReplaceReferencesFromString } from '@/AppBuilder/_stores/ast'; +import { findFirstKeyValuePairWithPath } from '../utils/utils'; + +export const createParamUpdatedInterceptor = ({ + component, + paramUpdated, + source, + setSource, + setJSONData, + setOpenModal, + shouldFocusJSONDataEditor, + shouldInvokeBlurEvent, + savedSourceValue, + componentNameIdMapping, + queryNameIdMapping, + getFormDataSectionData, + getExposedValueOfQuery, + runQuery, + resolveReferences, + setLoading, +}) => { + return async (param, attr, value, paramType, ...restArgs) => { + // Handle generateFormFrom parameter + if (param?.name === 'generateFormFrom') { + shouldFocusJSONDataEditor.current = false; + if (attr === 'value') { + const res = extractAndReplaceReferencesFromString(value, componentNameIdMapping, queryNameIdMapping); + let { valueWithId: selectedQuery, allRefs, valueWithBrackets } = res; + const { generateFormFrom, JSONData } = getFormDataSectionData(component?.id); + + if (value === generateFormFrom?.value) { + setSource((prev) => ({ ...prev, value })); + return setJSONData({ value: JSONData.value }); + } + + if (value === 'jsonSchema') { + setSource({ value: 'jsonSchema' }); + savedSourceValue.current = 'jsonSchema'; + return paramUpdated(param, attr, value, paramType, ...restArgs); + } else if (value === 'rawJson') { + shouldFocusJSONDataEditor.current = true; + setJSONData({ + value: + "{{{ 'name': 'John Doe', 'age': 35, 'isActive': true, 'dob': '01-01-1990', 'hobbies': ['reading', 'gaming', 'cycling'], 'address': { 'street': '123 Main Street', 'city': 'New York' } }}}", + }); + return setSource((prev) => ({ ...prev, value })); + } else if (value !== 'rawJson' && value !== 'jsonSchema') { + // Set the source value to the selected query until the query is run + setSource((prev) => ({ ...prev, value: selectedQuery })); + setLoading(true); + + const queryRefs = allRefs + .filter((ref) => ref.entityType === 'queries') + .filter((ref, index, self) => index === self.findIndex((r) => r.entityNameOrId === ref.entityNameOrId)); + + setOpenModal(true); + await Promise.all( + queryRefs.map(async (ref) => { + const queryId = ref.entityNameOrId; + const resolvedValueofQuery = getExposedValueOfQuery(queryId, 'canvas'); + + const hasMetadata = + resolvedValueofQuery && typeof resolvedValueofQuery === 'object' && 'metadata' in resolvedValueofQuery; + if (!hasMetadata && queryId && runQuery) { + await runQuery(queryId, '', false, 'edit'); + } + }) + ); + + let resolvedValue; + + resolvedValue = resolveReferences('canvas', valueWithBrackets); + setLoading(false); + + if (!source?.fxActive) { + const transformedData = findFirstKeyValuePairWithPath(resolvedValue, selectedQuery); + setJSONData({ value: transformedData.value }); + return setSource((prev) => ({ ...prev, value: transformedData.path })); + } + + setJSONData({ value: resolvedValue }); + setOpenModal(true); + } + setSource((prev) => ({ ...prev, value: selectedQuery })); + } else if (attr === 'fxActive') { + setSource((prev) => ({ ...prev, fxActive: value })); + } + return; + } + + // Handle JSONData parameter + if (param.name === 'JSONData') { + if (attr === 'value') { + if (source.value === 'rawJson') { + shouldInvokeBlurEvent.current = true; + } + setJSONData({ value }); + } + return; + } + + // Default parameter update + paramUpdated(param, attr, value, paramType, ...restArgs); + }; +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/index.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/index.js new file mode 100644 index 0000000000..ccb7dede95 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/index.js @@ -0,0 +1 @@ +export { Form as default } from './Form'; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/styles.scss b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/styles.scss new file mode 100644 index 0000000000..3f3f9ecef0 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/styles.scss @@ -0,0 +1,258 @@ +.form-generate-form-btn { + button { + &:disabled { + border: 1px solid var(--border-weak, #E4E7EB) !important; + box-shadow: none; + } + } +} + +.column-mapping-modal-header { + background-color: var(--primary-white) !important; + border-bottom: 1px solid var(--border-medium, rgba(106, 114, 124, 0.26)); + +} + +.column-mapping-modal-body { + background: var(--page-page-default, #F6F8FA); + box-shadow: 0px 0px 1px 0px var(--dropshadow-100700-layer-1, rgba(48, 50, 51, 0.05)), 0px 8px 16px 0px var(--dropshadow-100400-layer-2, rgba(48, 50, 51, 0.10)); + + .column-mapping-modal-body-content { + background-color: var(--primary-white) !important; + border-radius: 8px; + margin-bottom: 1rem; + + &:last-child { + margin-bottom: 0px; + } + + .column-mapping-modal-title { + padding-left: 10px; + padding-top: 10px; + color: var(--text-default, #1B1F24); + + &.new { + color: var(--text-success, #1E823B); + } + + &.removed { + color: var(--text-danger, #D92D2A); + } + } + + .header-row { + height: 36px; + + .header-column { + height: 16px; + + span { + vertical-align: top; + } + + button[role="checkbox"] { + margin-top: 0px; + } + + .editable-icon svg { + vertical-align: initial; + } + } + + } + + .name-column { + width: 230px; + margin-right: 6px; + } + + .arrow-column { + width: 40px + } + + .mapped-column { + width: 160px; + padding: 0px 10px; + } + + .type-column { + width: 76px; + padding: 0px 10px; + + &.rows { + width: 160px; + } + } + + .mandatory-column { + width: 84px; + margin-left: 8px; + + &.rows { + width: 16px; + } + } + + + .column-mapping-row { + height: 40px; + color: var(--text-default, #1B1F24); + border-bottom: 1px solid var(--border-weak, #E4E7EB); + + span.base-regular { + color: var(--text-default, #1B1F24); + } + + &:last-child { + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; + } + + .data-type { + color: var(--text-placeholder, #6A727C); + font-family: monospace; + font-size: 11px; + font-style: normal; + font-weight: 400; + line-height: 16px; + } + + .hide-border { + + button[role="combobox"], + input[type="text"] { + border-color: transparent; + border-radius: 6px; + + &:hover { + border-color: var(--border-strong, #ACB2B9); + } + } + } + + .no-mapped-column { + border-radius: 9px; + background-color: var(--interactive-default); + height: 18px; + padding: 0px 6px; + color: var(--text-placeholder, #6A727C); + } + } + + } +} + +.field-item { + background-color: var(--interactive-default); + border-radius: 6px; + padding: 7px 8px; + height: 32px; + + &:hover { + background-color: var(--interactive-hover) + } + + &.selected { + background-color: var(--interactive-selected) !important; + } + + .field-name { + overflow: hidden; + color: var(--text-default, #1B1F24); + text-overflow: ellipsis; + } + + .more-btn { + width: 22px; + height: 22px; + padding: 4px; + border-radius: 4px; + border: 1px solid var(--border-weak, #E4E7EB); + background: var(--button-secondary, #FFF); + /* Elevations/100 */ + box-shadow: 0px 0px 1px 0px var(--dropshadow-100700-layer-1, rgba(48, 50, 51, 0.05)), 0px 1px 1px 0px var(--dropshadow-100400-layer-2, rgba(48, 50, 51, 0.10)); + } +} + +.field-icon { + background-color: inherit; + color: #6b7280; +} + +.field-popover { + animation: popoverFade 0.2s ease-in-out; +} + +@keyframes popoverFade { + from { + opacity: 0; + transform: translateY(-8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +#menu-popover { + .popover-body { + min-width: 200px; + } + + button { + width: 100%; + text-align: left; + justify-content: flex-start !important; + border-radius: 6px; + padding: 6px 8px; + color: var(--text-default, #1B1F24); + + &:hover { + background-color: var(--interactive-default); + } + } +} + +.form-fields-column-popover { + border-radius: 8px; + width: 303px; + + .form-field-popover-header { + border-bottom: 1px solid var(--border-weak, #E4E7EB); + } + + .form-field-popover-body { + + button[role="combobox"] { + border-radius: 6px; + } + + label { + margin-bottom: 2px; + font-family: "IBM Plex Sans"; + } + } + +} + +.refresh-data-section { + border-radius: 6px; + background: var(--background-surface-layer-02); + margin-top: 12px; + + .neutral-light-color { + color: var(--neutral-light-n-900, #091E42); + } + + .refresh-data-button { + width: 147px; + margin: 0px 24px; + } +} + +.custom-schema-fields-section { + background: var(--background-warning-weak, #FAEFE7); + border-radius: 6px; + margin-top: 12px; +} \ No newline at end of file diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/utils/componentMetaUtils.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/utils/componentMetaUtils.js new file mode 100644 index 0000000000..2f009b4784 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/utils/componentMetaUtils.js @@ -0,0 +1,66 @@ +import { deepClone } from '@/_helpers/utilities/utils.helpers'; + +export const processComponentMeta = (componentMeta, component, allComponents, resolvedCustomSchema) => { + const tempComponentMeta = deepClone(componentMeta); + + let properties = []; + let additionalActions = []; + let dataProperties = []; + let deprecatedProperties = []; + + const events = Object.keys(componentMeta.events); + const validations = Object.keys(componentMeta.validation || {}); + + // Categorize properties + for (const [key] of Object.entries(componentMeta?.properties)) { + if (componentMeta?.properties[key]?.section === 'additionalActions') { + additionalActions.push(key); + } else if (componentMeta?.properties[key]?.section === 'data') { + dataProperties.push(key); + } else if (componentMeta?.properties[key]?.section === 'deprecated') { + deprecatedProperties.push(key); + } else { + // Skip the fields property as it is handled separately + if (key === 'fields') continue; + properties.push(key); + } + } + + // Process button to submit options + const { id } = component; + const newOptions = [{ name: 'None', value: 'none' }]; + + Object.entries(allComponents).forEach(([componentId, _component]) => { + const validParent = + _component.component.parent === id || + _component.component.parent === `${id}-footer` || + _component.component.parent === `${id}-header`; + if (validParent && _component?.component?.component === 'Button') { + newOptions.push({ name: _component.component.name, value: componentId }); + } + }); + + tempComponentMeta.properties.buttonToSubmit.options = newOptions; + + // Hide header footer if custom schema is turned on + if (resolvedCustomSchema) { + component.component.properties.showHeader = { + ...component.component.properties.headerHeight, + isHidden: true, + }; + component.component.properties.showFooter = { + ...component.component.properties.headerHeight, + isHidden: true, + }; + } + + return { + tempComponentMeta, + properties, + additionalActions, + dataProperties, + deprecatedProperties, + events, + validations, + }; +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/utils/fieldOperations.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/utils/fieldOperations.js new file mode 100644 index 0000000000..2cfc01ca8b --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/utils/fieldOperations.js @@ -0,0 +1,292 @@ +import { merge, set } from 'lodash'; +import { deepClone } from '@/_helpers/utilities/utils.helpers'; +import { v4 as uuidv4 } from 'uuid'; +import { componentTypes } from '@/AppBuilder/WidgetManager'; +import useStore from '@/AppBuilder/_stores/store'; +// eslint-disable-next-line import/no-unresolved +import { diff } from 'deep-object-diff'; +import { ensureHandlebars, buildOptions } from './utils'; +import { COMPONENT_LAYOUT_DETAILS, COMPONENT_WITH_OPTIONS } from '../constants'; + +export const createNewComponentFromMeta = (column, parentId, nextTop) => { + const currentLayout = useStore.getState().currentLayout; + const componentType = column.componentType || 'TextInput'; + const fieldId = uuidv4(); + + const componentMeta = componentTypes.find((comp) => comp.component === componentType); + + if (!componentMeta) { + console.error(`Component type ${componentType} not found in componentTypes`); + return; + } + + const defaultHeight = componentMeta.defaultSize?.height || COMPONENT_LAYOUT_DETAILS.defaultHeight; + + const componentData = deepClone(componentMeta); + const componentName = useStore.getState().generateUniqueComponentNameFromBaseName(column.name); + + const formField = { + id: fieldId, + name: componentName, + component: { + ...componentData, + type: componentType, + name: componentName, + parent: parentId, + definition: merge({}, componentData.definition, { + properties: { + label: { + value: column.label, + }, + }, + styles: { + alignment: { value: 'top' }, + }, + validation: { + mandatory: column.mandatory, + }, + others: { + showOnDesktop: { + value: currentLayout === 'desktop' ? '{{true}}' : '{{false}}', + }, + showOnMobile: { + value: currentLayout === 'mobile' ? '{{true}}' : '{{false}}', + }, + }, + }), + }, + layouts: { + desktop: { + top: nextTop, + left: COMPONENT_LAYOUT_DETAILS.defaultLeft, + width: COMPONENT_LAYOUT_DETAILS.defaultWidth, + height: defaultHeight, + }, + mobile: { + top: nextTop, + left: COMPONENT_LAYOUT_DETAILS.defaultLeft, + width: COMPONENT_LAYOUT_DETAILS.defaultWidth, + height: defaultHeight, + }, + }, + }; + + setValuesBasedOnType(column, componentType, formField, false); + + return { + deleted: false, + added: formField, + updated: {}, + }; +}; + +/** + * Updates an existing form field component with new values + * @param {string} componentId - ID of the component to update + * @param {Object} updatedField - New field values to apply + * @param {Object} currentField - Current field data + * @returns {Object} Updated component definition + */ +export const updateFormFieldComponent = (updatedField, currentField, parentId, nextTop = 0) => { + const componentId = updatedField?.componentId; + + if (!componentId) { + // componentId is not available, create a new component + return createNewComponentFromMeta(updatedField, parentId, nextTop); + } + + // Get the current component from the store + const componentToUpdate = useStore.getState().getComponentDefinition(componentId); + + if (!componentToUpdate) { + console.error(`Component with ID ${componentId} not found`); + return null; + } + + if (updatedField.componentType !== componentToUpdate.component.component) { + return handleComponentTypeChange(componentToUpdate, updatedField); + } + + // Create a deep clone of the component to avoid reference issues + const updatedComponent = deepClone(componentToUpdate); + + // Update label if changed + if (updatedField.label !== currentField.label) { + set(updatedComponent.component.definition.properties, 'label.value', updatedField.label); + } + + // Update mandatory status + if (updatedField.mandatory !== currentField.mandatory) { + set(updatedComponent.component.definition.validation, 'mandatory', updatedField.mandatory); + } + + // Update visibility status + if (updatedField.visibility !== currentField.visibility) { + set(updatedComponent.component.definition.properties, 'visibility', updatedField.visibility); + } + + // Update component type specific properties + const componentType = updatedField.componentType || componentToUpdate.component.component; + + setValuesBasedOnType(updatedField, componentType, updatedComponent, false); + + return { updated: diff(componentToUpdate, updatedComponent) }; +}; + +const handleComponentTypeChange = (componentToUpdate, updatedField) => { + const newComponentId = uuidv4(); + + const addOptions = + COMPONENT_WITH_OPTIONS.includes(updatedField.componentType) && + COMPONENT_WITH_OPTIONS.includes(componentToUpdate.component.component); + + const currentLayout = useStore.getState().currentLayout; + const nonActiveLayout = currentLayout === 'desktop' ? 'mobile' : 'desktop'; + + const componentMeta = componentTypes.find((comp) => comp.component === updatedField.componentType); + + if (!componentMeta) { + console.error(`Component type ${updatedField.componentType} not found in componentTypes`); + return null; + } + + const existingLayouts = componentToUpdate.layouts || {}; + + const componentName = useStore + .getState() + .generateUniqueComponentNameFromBaseName(updatedField.name || componentToUpdate.component.name); + + const componentData = deepClone(componentMeta); + + const newComponent = { + id: newComponentId, + name: componentName, + component: { + ...componentData, + type: updatedField.componentType, + name: componentName, + parent: componentToUpdate.component.parent, + definition: merge({}, componentData.definition, { + properties: { + label: { + value: updatedField.label || componentToUpdate.component.definition.properties.label?.value, + }, + ...(addOptions && { options: componentToUpdate.component.definition.properties.options }), + }, + styles: { + alignment: { value: 'top' }, + }, + validation: { + mandatory: updatedField.mandatory || componentToUpdate.component.definition.validation.mandatory, + }, + others: { + showOnDesktop: componentToUpdate.component.definition.others?.showOnDesktop || { value: '{{true}}' }, + showOnMobile: componentToUpdate.component.definition.others?.showOnMobile || { value: '{{false}}' }, + }, + }), + }, + layouts: { + [currentLayout]: existingLayouts[currentLayout] || { top: 0, left: 3, width: 37, height: 30 }, + [nonActiveLayout]: existingLayouts[nonActiveLayout] || { top: 0, left: 3, width: 37, height: 30 }, + }, + }; + + setValuesBasedOnType(updatedField, updatedField.componentType, newComponent, true); + + // Return an object that indicates to: + // 1. Delete the old component + // 2. Add the new component + return { + deleted: true, + added: newComponent, + updated: {}, + }; +}; + +const setValuesBasedOnType = (column, componentType, formField, isTypeChange = false) => { + if (column.value !== undefined && column.value !== null) { + if (componentType === 'TextInput' || componentType === 'PasswordInput' || componentType === 'TextArea') { + set(formField.component.definition.properties, 'value.value', column.value); + } + if (componentType === 'NumberInput') { + set(formField.component.definition.properties, 'value.value', ensureHandlebars(column.value)); + } else if (componentType === 'Checkbox' || componentType === 'DatePickerV2' || componentType === 'ToggleSwitchV2') { + set(formField.component.definition.properties, 'defaultValue.value', column.value); + } else if ( + componentType === 'DropdownV2' || + componentType === 'MultiselectV2' || + componentType === 'RadioButtonV2' + ) { + if (!isTypeChange) { + set(formField.component.definition.properties, 'options.value', buildOptions(column.value)); + } else if (Array.isArray(formField.component.definition.properties?.options)) { + set( + formField.component.definition.properties, + 'options.value', + buildOptions(formField.component.definition.properties.options) + ); + } + } + } + + if (isTypeChange && componentType === 'TextArea') { + set(formField, 'layouts.desktop.height', 50); + set(formField, 'layouts.mobile.height', 50); + } + + if ( + column.placeholder && + componentType !== 'Checkbox' && + componentType !== 'DatePickerV2' && + componentType !== 'ToggleSwitchV2' && + componentType !== 'DaterangePicker' + ) { + set(formField.component.definition.properties, 'placeholder.value', column.placeholder); + } +}; + +/** + * Retrieves field data from a component definition in the store + * @param {string} componentId - Component ID to fetch definition for + * @param {Function} getComponentDefinition - Function to get component definition + * @returns {Object} Field data with merged component definition values + */ +export const getFieldDataFromComponent = (componentId, getComponentDefinition) => { + if (!componentId) { + return null; + } + + const component = getComponentDefinition(componentId); + if (!component) return null; + + const componentType = component.component.component; + const definition = component.component.definition; + + // Get values from component definition + const label = definition.properties?.label?.value || ''; + const name = component.component.name; + + // Different components store values in different properties + let value; + if (componentType === 'Checkbox' || componentType === 'DatePickerV2') { + value = definition.properties?.defaultValue?.value; + } else { + value = definition.properties?.value?.value; + } + + const mandatory = definition.validation?.mandatory; + const visibility = definition.properties?.visibility; + const selected = true; + const placeholder = definition.properties?.placeholder?.value || ''; + + return { + label, + name, + value, + mandatory, + visibility, + selected, + placeholder, + componentType, + }; +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/utils/utils.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/utils/utils.js new file mode 100644 index 0000000000..d5bb362dda --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/utils/utils.js @@ -0,0 +1,333 @@ +import React from 'react'; +import WidgetIcon from '@/../assets/images/icons/widgets'; +import { DATATYPE_TO_COMPONENT, JSON_DIFFERENCE, INPUT_COMPONENTS_FOR_FORM } from '../constants'; +import { startCase, omit, uniqBy } from 'lodash'; +import { getFieldDataFromComponent } from './fieldOperations'; +import { componentTypeDefinitionMap } from '@/AppBuilder/WidgetManager'; + +export const buildOptions = (options = []) => { + if (Array.isArray(options)) + return options.map((option, index) => ({ + label: option, + value: index, + disable: { value: false }, + visible: { value: true }, + default: { value: false }, + })); +}; + +export const ensureHandlebars = (value) => { + if (typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}')) { + return value; // Already has handlebars + } + return `{{${value}}}`; +}; + +// Helper function to check if a value is considered "true" +export const isTrueValue = (value) => { + if (value === true) return true; + if (typeof value === 'string') { + const trimmedValue = value.trim().toLowerCase(); + // Check for "{{true}}" format or just "true" + return trimmedValue === '{{true}}' || trimmedValue === 'true'; + } + return false; +}; + +export const isPropertyFxControlled = (property) => { + return property && typeof property === 'object' && property.fxActive === true; +}; + +export const isValidJSONObject = (jsonString) => { + try { + const parsed = JSON.parse(jsonString); + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed); + } catch (e) { + return false; + } +}; + +export const getDataType = (value) => { + if (Array.isArray(value)) return 'array'; + if (typeof value === 'string') { + const date = new Date(value); + if (!isNaN(date.getTime())) return 'date'; + return 'string'; + } + if (typeof value === 'object' && value !== null) return 'object'; + return typeof value; +}; + +export const buildFieldObject = (key, value, label, jsonDifferences) => { + const dataType = getDataType(value); + + return { + key, + name: key, + label: startCase(label) || startCase(key), + value: dataType === 'number' || dataType === 'boolean' ? ensureHandlebars(value) : value, + dataType, + componentType: DATATYPE_TO_COMPONENT[dataType] || 'TextInput', + mandatory: { value: false }, + selected: false, + isCustomField: false, + isNew: jsonDifferences.isNew.includes(key), + isRemoved: jsonDifferences.isRemoved.includes(key), + isExisting: jsonDifferences.isExisting.includes(key), + }; +}; + +export const parseDataAndBuildFields = (data, jsonDifferences = JSON_DIFFERENCE) => { + const obj = data || {}; + const result = []; + + Object.entries(obj).forEach(([key, value]) => { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + const nestedKeys = Object.keys(value); + if (nestedKeys.length === 0) { + return; + } + + nestedKeys.forEach((nestedKey) => { + const nestedValue = value[nestedKey]; + if ( + typeof nestedValue === 'object' && + nestedValue !== null && + !Array.isArray(nestedValue) && + Object.keys(nestedValue).length === 0 + ) { + return; + } + + result.push(buildFieldObject(`${key}.${nestedKey}`, nestedValue, nestedKey, jsonDifferences)); + }); + } else { + result.push(buildFieldObject(key, value, key, jsonDifferences)); + } + }); + + return result; +}; + +export const findNextElementTop = (childComponents, currentLayout = 'desktop', componentsToBeIgnored = []) => { + const defaultTop = 0; + + if (!childComponents || typeof childComponents !== 'object' || Object.keys(childComponents).length === 0) { + return defaultTop; + } + + try { + let highestTop = -1; + let lastComponent = null; + + Object.entries(childComponents).forEach(([componentId, component]) => { + if (componentsToBeIgnored.includes(componentId)) { + return; + } + + const currentTop = component?.component?.layouts?.[currentLayout]?.top || 0; + + if (currentTop > highestTop) { + highestTop = currentTop; + lastComponent = component; + } + }); + + if ( + lastComponent && + lastComponent.component && + lastComponent.component.layouts && + lastComponent.component.layouts[currentLayout] + ) { + const { top = 0, height = 0 } = lastComponent.component.layouts[currentLayout]; + + return top + height; + } + + return defaultTop; + } catch (error) { + console.error('Error finding last element position:', error); + return defaultTop; + } +}; + +export const getComponentIcon = (componentType, darkMode) => { + if (!componentType) return null; + + const component = componentTypeDefinitionMap[componentType]; + + const iconName = component.name.toLowerCase(); + return ; +}; + +export const getInputTypeOptions = (darkMode) => { + const constructOptions = (component) => { + return { + label: component.displayName, + value: component.component, + leadingIcon: ( + + ), + }; + }; + + return INPUT_COMPONENTS_FOR_FORM.reduce((options, component) => { + options[component] = constructOptions(componentTypeDefinitionMap[component]); + return options; + }, {}); +}; + +export const constructFeildForSave = (field) => { + const { key, value, dataType, componentType, mandatory, selected, isCustomField } = field; + + return { + key, + value: dataType === 'number' || dataType === 'boolean' ? ensureHandlebars(value) : value, + dataType, + componentType, + mandatory: mandatory?.value || false, + selected: selected?.value || false, + isCustomField: isCustomField || false, + }; +}; + +const extractKeys = (json, parentKey = '') => { + if (!json || typeof json !== 'object') return []; + + return Object.keys(json).reduce((keys, key) => { + const currentKey = parentKey ? `${parentKey}.${key}` : key; + const value = json[key]; + + if (value && typeof value === 'object' && !Array.isArray(value)) { + return [...keys, currentKey, ...extractKeys(value, currentKey)]; + } + + return [...keys, currentKey]; + }, []); +}; + +export const analyzeJsonDifferences = (newJson, existingJson) => { + if (!newJson) return JSON_DIFFERENCE; + + const newKeys = extractKeys(newJson); + const existingKeys = extractKeys(existingJson); + + return { + isExisting: newKeys.filter((key) => existingKeys.includes(key)), + isNew: newKeys.filter((key) => !existingKeys.includes(key)), + isRemoved: existingKeys.filter((key) => !newKeys.includes(key)), + }; +}; + +export const mergeFieldsWithComponentDefinition = (fields, getComponentDefinition) => { + return fields + .map((field) => { + if (field.componentId) { + const componentData = getFieldDataFromComponent(field.componentId, getComponentDefinition); + + if (!componentData) { + return null; + } + + return { + ...field, + label: componentData?.label || field.label || '', + name: componentData?.name || field.name || '', + value: componentData?.value || field.value || '', + mandatory: componentData?.mandatory || field.mandatory || false, + visibility: componentData?.visibility || field.visibility || false, + selected: componentData?.selected || field.selected || false, + placeholder: componentData?.placeholder || field.placeholder || '', + componentType: componentData?.componentType || field.componentType || 'TextInput', + }; + } + return field; + }) + .filter((field) => field !== null); +}; + +export const mergeFormFieldsWithNewData = (existingFields, newFields) => { + if (!existingFields || existingFields.length === 0) return newFields; + + const existingFieldsMap = {}; + existingFields.forEach((field) => { + if (field.key) { + existingFieldsMap[field.key] = field; + } + }); + + return newFields.map((newField) => { + if (newField.isNew || !existingFieldsMap[newField.key]) { + return newField; + } + return { + ...newField, + ...omit(existingFieldsMap[newField.key], ['isNew']), + }; + }); +}; + +export const cleanupFormFields = (fields) => { + return uniqBy( + fields.filter((field) => !!field.componentId), + 'componentId' + ).map((field) => ({ + componentId: field.componentId, + isCustomField: field.isCustomField, + dataType: field.dataType, + key: field.key, + })); +}; + +export const findFirstKeyValuePairWithPath = (data, basePath = '') => { + let current = data; + let pathSegments = []; + + if (data === null || data === undefined || data?.length === 0) { + return { + value: data, + path: basePath, + }; + } + + while (Array.isArray(current) && current.length > 0) { + pathSegments.push('[0]'); + current = current[0]; + } + + if (current && typeof current === 'object' && !Array.isArray(current)) { + // Inject path segments before the closing "}}" + const insertAt = basePath.lastIndexOf('}}'); + const fullPath = + insertAt !== -1 + ? basePath.slice(0, insertAt) + pathSegments.join('') + basePath.slice(insertAt) + : basePath + pathSegments.join(''); + + return { + value: current, + path: fullPath, + }; + } + + return { + value: null, + path: null, + }; +}; + +export const mergeArrays = (arr1, arr2) => { + const map = new Map(); + + // Add all from arr1 + arr1.forEach((item) => map.set(item.isCustomField ? item.componentId : item.key, item)); + + // Overwrite/add from arr2 + arr2.forEach((item) => map.set(item.isCustomField ? item.componentId : item.key, item)); + + return Array.from(map.values()); +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Modal.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Modal.jsx index ed1814eb0e..b44bc860df 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Modal.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Modal.jsx @@ -43,7 +43,9 @@ export const Modal = ({ componentMeta, darkMode, ...restProps }) => { return accordionItems; }; - const properties = Object.keys(componentMeta.properties); + const properties = Object.keys(componentMeta.properties || {}).filter( + (key) => componentMeta.properties[key].section !== 'additionalActions' + ); const events = Object.keys(componentMeta.events); const validations = Object.keys(componentMeta.validation || {}); @@ -64,7 +66,8 @@ export const Modal = ({ componentMeta, darkMode, ...restProps }) => { apps, allComponents, validations, - darkMode + darkMode, + undefined ); accordionItems.splice(1, 0, ...conditionalAccordionItems(component)); diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/TabComponent.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/TabComponent.jsx new file mode 100644 index 0000000000..35fa70ae66 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/TabComponent.jsx @@ -0,0 +1,527 @@ +import React, { useState, useEffect } from 'react'; +import Accordion from '@/_ui/Accordion'; +import { EventManager } from '../EventManager'; +import { renderElement } from '../Utils'; +import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; +import Popover from 'react-bootstrap/Popover'; +import List from '@/ToolJetUI/List/List'; +import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd'; +import CodeHinter from '@/AppBuilder/CodeEditor'; +import { resolveReferences } from '@/_helpers/utils'; +import AddNewButton from '@/ToolJetUI/Buttons/AddNewButton/AddNewButton'; +import ListGroup from 'react-bootstrap/ListGroup'; +import { ButtonSolid } from '@/_ui/AppButton/AppButton'; +import SortableList from '@/_components/SortableList'; +import Trash from '@/_ui/Icon/solidIcons/Trash'; + +export function TabsLayout({ componentMeta, darkMode, ...restProps }) { + const { + layoutPropertyChanged, + component, + dataQueries, + paramUpdated, + currentState, + eventsChanged, + apps, + allComponents, + pages, + } = restProps; + + const isDynamicEnabled = resolveReferences( + component?.component?.definition?.properties?.useDynamicOptions?.value, + currentState + ); + + const [tabItems, setTabItems] = useState([]); + const [activeColumnPopoverIndex, setActiveColumnPopoverIndex] = useState(null); + const [hoveredTabItemIndex, setHoveredTabItemIndex] = useState(null); + let properties = []; + let additionalActions = []; + + for (const [key] of Object.entries(componentMeta?.properties)) { + if (componentMeta?.properties[key]?.section === 'additionalActions') { + additionalActions.push(key); + } else { + properties.push(key); + } + } + + const constructTabItems = () => { + const tabItemsValue = component?.component?.definition?.properties?.tabItems?.value; + let tabItems = []; + + if (typeof tabItemsValue === 'string') { + tabItems = resolveReferences(tabItemsValue, currentState); + } else { + tabItems = tabItemsValue?.map((tabItem) => tabItem); + } + return tabItems?.map((tabItem) => { + const newTabItem = { ...tabItem }; + + Object.keys(tabItem)?.forEach((key) => { + if (typeof tabItem[key]?.value === 'boolean') { + newTabItem[key]['value'] = `{{${tabItem[key]?.value}}}`; + } + }); + + return newTabItem; + }); + }; + + const handleAddTabItem = () => { + const generateNewTabItem = () => { + let found = false; + let title = ''; + let currentNumber = tabItems.length; + let id = `t${currentNumber}`; + while (!found) { + title = `Tab ${currentNumber}`; + if (tabItems.find((tabItem) => tabItem.title === title) === undefined) { + found = true; + } + currentNumber += 1; + } + return { + id: id, + title, + visible: { value: '{{true}}' }, + disable: { value: '{{false}}' }, + iconVisibility: { value: '{{false}}' }, + icon: { value: 'IconHome2' }, + }; + }; + + let newTabItem = generateNewTabItem(); + const updatedTabItems = [...tabItems, newTabItem]; + setTabItems(updatedTabItems); + updateAllTabItemsParams(updatedTabItems); + }; + + const updateAllTabItemsParams = (tabItems) => { + paramUpdated({ name: 'tabItems' }, 'value', tabItems, 'properties', false); + }; + + const getItemStyle = (isDragging, draggableStyle) => ({ + userSelect: 'none', + ...draggableStyle, + }); + + const handleDeleteTabItem = (index) => { + const updatedTabItems = tabItems.filter((tabItem, i) => i !== index); + setTabItems(updatedTabItems); + updateAllTabItemsParams(updatedTabItems); + }; + + const reorderTabItems = (startIndex, endIndex) => { + const result = Array.from(tabItems); + const [removed] = result.splice(startIndex, 1); + result.splice(endIndex, 0, removed); + setTabItems(result); + updateAllTabItemsParams(result); + }; + + const onDragEnd = ({ source, destination }) => { + if (!destination || source.index === destination.index) { + return; + } + reorderTabItems(source.index, destination.index); + }; + + const handleValueChange = (item, value, property, index) => { + const updatedTabItems = tabItems.map((tabItem) => { + if (tabItem.id === item.id) { + return { + ...tabItem, + [property]: value, + }; + } + return tabItem; + }); + + setTabItems(updatedTabItems); + updateAllTabItemsParams(updatedTabItems); + }; + + const onChangeVisibility = (item, value, property, index) => { + const updatedTabItems = tabItems.map((tabItem) => { + if (tabItem.id === item.id) { + let newVisibilityValue = resolveReferences(tabItem[property]); + newVisibilityValue = typeof newVisibilityValue === 'boolean' ? newVisibilityValue : newVisibilityValue['value']; + return { + ...tabItem, + [property]: !newVisibilityValue, + }; + } + return tabItem; + }); + + setTabItems(updatedTabItems); + updateAllTabItemsParams(updatedTabItems); + }; + + const onChangeIcon = (item, value, property, index) => { + const updatedTabItems = tabItems.map((tabItem) => { + if (tabItem.id === item.id) { + return { + ...tabItem, + [property]: value, + iconVisibility: { value: true }, + }; + } + return tabItem; + }); + + setTabItems(updatedTabItems); + updateAllTabItemsParams(updatedTabItems); + }; + + const _renderOverlay = (item, index) => { + return ( + + +
+ + handleValueChange(item, value, 'title', index)} + /> +
+ +
+ + handleValueChange(item, value, 'id', index)} + /> +
+ +
+ { + onChangeIcon(item, { value }, 'icon', index); + }} + onVisibilityChange={(value) => onChangeVisibility(item, { value: true }, 'iconVisibility', index)} + fieldMeta={{ type: 'icon', displayName: 'Icon' }} + paramType={'icon'} + /> +
+ +
+ { + handleValueChange(item, { value }, 'fieldBackgroundColor', index); + }} + fieldMeta={{ type: 'color', displayName: 'Background' }} + paramType={'color'} + /> +
+ +
+ { + handleValueChange(item, { value }, 'loading', index); + }} + fieldMeta={{ type: 'toggle', displayName: 'Loading' }} + paramType={'toggle'} + /> +
+ +
+ handleValueChange(item, { value }, 'visible', index)} + paramName={'visible'} + onFxPress={(active) => handleOnFxPress(active, index, 'visible')} + fxActive={item?.visible?.fxActive} + fieldMeta={{ + type: 'toggle', + displayName: 'Visible', + }} + paramType={'toggle'} + /> +
+
+ handleValueChange(item, { value }, 'disable', index)} + onFxPress={(active) => handleOnFxPress(active, index, 'disable')} + fxActive={item?.disable?.fxActive} + fieldMeta={{ + type: 'toggle', + displayName: 'Disable', + }} + paramType={'toggle'} + /> +
+
+
+ ); + }; + + useEffect(() => { + setTabItems(constructTabItems()); + }, [isDynamicEnabled, component?.id]); + + const handleToggleColumnPopover = (index) => { + setActiveColumnPopoverIndex(index); + }; + + const _renderTabOptions = () => { + return ( + + { + onDragEnd(result); + }} + > + + {({ innerRef, droppableProps, placeholder }) => ( +
+ {tabItems?.map((item, index) => { + return ( + + {(provided, snapshot) => ( +
+ { + if (show) { + handleToggleColumnPopover(index); + } else { + handleToggleColumnPopover(null); + } + }} + > +
+ setHoveredTabItemIndex(index)} + onMouseLeave={() => setHoveredTabItemIndex(null)} + className={activeColumnPopoverIndex === index && 'active-column-list'} + {...restProps} + > +
+
+ +
+
+ {resolveReferences(item.title, currentState)} +
+
+ {index === hoveredTabItemIndex && ( + { + e.stopPropagation(); + handleDeleteTabItem(index); + }} + > + + + + + )} +
+
+
+
+
+
+ )} +
+ ); + })} + {placeholder} +
+ )} +
+
+ + Add new tab + +
+ ); + }; + + let items = []; + + if (properties.length > 0) { + items.push({ + title: 'Options', + isOpen: true, + children: isDynamicEnabled ? ( + properties?.map((property) => + renderElement( + component, + componentMeta, + paramUpdated, + dataQueries, + property, + 'properties', + currentState, + allComponents, + darkMode + ) + ) + ) : ( + <> + {renderElement( + component, + componentMeta, + paramUpdated, + dataQueries, + 'useDynamicOptions', + 'properties', + currentState, + allComponents + )} + {_renderTabOptions()} + + ), + }); + } + + items.push({ + title: 'Events', + isOpen: true, + children: ( + + ), + }); + + items.push({ + title: `Additional Actions`, + isOpen: true, + children: additionalActions.map((property) => { + return renderElement( + component, + componentMeta, + paramUpdated, + dataQueries, + property, + 'properties', + currentState, + allComponents, + darkMode, + componentMeta.properties?.[property]?.placeholder + ); + }), + }); + + items.push({ + title: 'Devices', + isOpen: true, + children: ( + <> + {renderElement( + component, + componentMeta, + layoutPropertyChanged, + dataQueries, + 'showOnDesktop', + 'others', + currentState, + allComponents + )} + {renderElement( + component, + componentMeta, + layoutPropertyChanged, + dataQueries, + 'showOnMobile', + 'others', + currentState, + allComponents + )} + + ), + }); + + return ; +} diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ColumnManager/ColumnPopover.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ColumnManager/ColumnPopover.jsx index de1da44195..4ed1f7a026 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ColumnManager/ColumnPopover.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ColumnManager/ColumnPopover.jsx @@ -76,7 +76,10 @@ export const ColumnPopoverContent = ({
- + {activeTab === 'propertiesTab' ? ( { + const ColumnIcon = getColumnIcon(props.data.value); + const isDeprecated = checkIfTableColumnDeprecated(props.data.value); + + return ( + + +
+
+ {ColumnIcon && } + {props.label} +
+
+ {props.isSelected && ( + + + + )} + {isDeprecated && ( + + + + )} +
+
+
+
+ ); +}; + +const CustomValueContainer = ({ data, ...props }) => { + const Icon = getColumnIcon(data.value); + return ( +
+ {Icon && } + {data.label} +
+ ); +}; export const PropertiesTabElements = ({ column, @@ -54,6 +100,7 @@ export const PropertiesTabElements = ({ { label: 'Link', value: 'link' }, { label: 'JSON', value: 'json' }, { label: 'Markdown', value: 'markdown' }, + { label: 'HTML', value: 'html' }, // Following column types are deprecated { label: 'Default', value: 'default' }, { label: 'Dropdown', value: 'dropdown' }, @@ -64,7 +111,11 @@ export const PropertiesTabElements = ({ { label: 'Multiple badges', value: 'badges' }, { label: 'Tags', value: 'tags' }, ]} - components={{ DropdownIndicator, Option }} + components={{ + DropdownIndicator, + Option: CustomOption, + SingleValue: CustomValueContainer, + }} onChange={(value) => { onColumnItemChange(index, 'columnType', value); }} diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ColumnManager/StylesTabElements.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ColumnManager/StylesTabElements.jsx index 2b91a8dd15..e1d06686d1 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ColumnManager/StylesTabElements.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ColumnManager/StylesTabElements.jsx @@ -129,6 +129,7 @@ export const StylesTabElements = ({ 'number', 'json', 'markdown', + 'html', 'boolean', 'select', 'text', @@ -147,7 +148,7 @@ export const StylesTabElements = ({ property="textColor" props={column} component={component} - paramMeta={{ type: 'color', displayName: 'Text color' }} + paramMeta={{ type: 'colorSwatches', displayName: 'Text color' }} paramType="properties" />
@@ -162,7 +163,7 @@ export const StylesTabElements = ({ property="cellBackgroundColor" props={column} component={component} - paramMeta={{ type: 'color', displayName: 'Cell color' }} + paramMeta={{ type: 'colorSwatches', displayName: 'Cell color' }} paramType="properties" />
diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ProgramaticallyHandleProperties.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ProgramaticallyHandleProperties.jsx index c3fb47d612..da6ee1d34c 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ProgramaticallyHandleProperties.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ProgramaticallyHandleProperties.jsx @@ -34,6 +34,8 @@ export const ProgramaticallyHandleProperties = ({ return props.linkColor; case 'useDynamicOptions': return props?.useDynamicOptions; + case 'autoAssignColors': + return props?.autoAssignColors; case 'makeDefaultOption': return props?.[index]?.makeDefaultOption; case 'textColor': @@ -52,6 +54,10 @@ export const ProgramaticallyHandleProperties = ({ return props?.isDateSelectionEnabled; case 'jsonIndentation': return props?.jsonIndentation; + case 'labelColor': + return props?.labelColor; + case 'optionColor': + return props?.optionColor; default: return; } @@ -74,6 +80,14 @@ export const ProgramaticallyHandleProperties = ({ if (property === 'textColor') { return definitionObj?.value ?? '#11181C'; } + if (property === 'labelColor') { + // return definitionObj?.value ?? 'var(--cc-primary-text)'; + return definitionObj?.value ?? '#1B1F24'; + } + if (property === 'optionColor') { + // return definitionObj?.value ?? 'var(--cc-surface2-surface)'; + return definitionObj?.value ?? '#E4E7EB'; + } if (property === 'underlineColor') { return definitionObj?.value ?? '#4368E3'; } diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/SelectOptionsList/OptionsList.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/SelectOptionsList/OptionsList.jsx index 1d6860ecd1..494b2e1b55 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/SelectOptionsList/OptionsList.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/SelectOptionsList/OptionsList.jsx @@ -9,6 +9,7 @@ import Popover from 'react-bootstrap/Popover'; import CodeHinter from '@/AppBuilder/CodeEditor'; import { ProgramaticallyHandleProperties } from '../ProgramaticallyHandleProperties'; import { resolveReferences } from '@/_helpers/utils'; +import { Button as ButtonComponent } from '@/components/ui/Button/Button'; import { unset } from 'lodash'; export const OptionsList = ({ column, @@ -141,12 +142,23 @@ export const OptionsList = ({ props.paramUpdated({ name: 'columns' }, 'value', newColumns, 'properties', true); }; + + const handleOptionColorChange = (index, property, value) => { + handleSelectOption(option, optionIndex, value, index, property); + }; + return ( e.stopPropagation()} - style={{ zIndex: 99999, minWidth: 200 }} + style={{ + zIndex: 99999, + minWidth: 200, + boxShadow: '0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03)', + borderRadius: '6px', + border: '1px solid var(--border-default)', + }} >
e.stopPropagation()}> @@ -167,7 +179,7 @@ export const OptionsList = ({ }} />
-
e.stopPropagation()}> +
e.stopPropagation()}> @@ -185,8 +197,34 @@ export const OptionsList = ({ }} />
+
+ +
+
+ +
+ {column?.options?.length === 0 && }
- createNewOption()}> - {/* {this.props.t('widget.Table.addNewColumn', ' Add new column')} */} + { + createNewOption(); + }} + variant="secondary" + className="tw-w-full mt-2" + width="100%" + > Add new option - +
diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/Table.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/Table.jsx index bb6c1d94bb..3fb5760d24 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/Table.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/Table.jsx @@ -19,6 +19,24 @@ import { ProgramaticallyHandleProperties } from './ProgramaticallyHandleProperti import { ColumnPopoverContent } from './ColumnManager/ColumnPopover'; import { useAppDataStore } from '@/_stores/appDataStore'; import { checkIfTableColumnDeprecated } from './ColumnManager/DeprecatedColumnTypeMsg'; +import { + TextTypeIcon, + DatepickerTypeIcon, + SelectTypeIcon, + MultiselectTypeIcon, + BooleanTypeIcon, + ImageTypeIcon, + LinkTypeIcon, + JSONTypeIcon, + MarkdownTypeIcon, + HTMLTypeIcon, + NumberTypeIcon, + StringTypeIcon, + BadgeTypeIcon, + TagsTypeIcon, + RadioTypeIcon, +} from './_assets'; +import { getColumnIcon } from './utils'; const NON_EDITABLE_COLUMNS = ['link', 'image']; class TableComponent extends React.Component { @@ -633,6 +651,8 @@ class TableComponent extends React.Component { return 'JSON'; case 'markdown': return 'Markdown'; + case 'html': + return 'HTML'; default: capitalize(text ?? ''); } @@ -677,6 +697,7 @@ class TableComponent extends React.Component { } }} darkMode={darkMode} + showIconOnHover={true} // menuActions={[ // { // label: 'Delete', @@ -692,6 +713,7 @@ class TableComponent extends React.Component { }`} columnType={item?.columnType} isDeprecated={checkIfTableColumnDeprecated(item?.columnType)} + Icon={getColumnIcon(item?.columnType)} />
@@ -777,6 +799,7 @@ class TableComponent extends React.Component { 'showBulkUpdateActions', 'visibility', 'disabledState', + 'dynamicHeight', ]; items.push({ diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/BadgeTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/BadgeTypeIcon.jsx new file mode 100644 index 0000000000..bb9c075a6e --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/BadgeTypeIcon.jsx @@ -0,0 +1,20 @@ +import React from 'react'; + +const BadgeTypeIcon = ({ fill = '#ACB2B9', width = '14', className = '', viewBox = '0 0 14 14', style, height }) => ( + + + +); + +export default BadgeTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/BooleanTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/BooleanTypeIcon.jsx new file mode 100644 index 0000000000..cf6b09ec04 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/BooleanTypeIcon.jsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const BooleanTypeIcon = ({ fill = '#ACB2B9', width = '16', className = '', viewBox = '0 0 16 16', style, height }) => ( + + + +); + +export default BooleanTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/DatepickerTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/DatepickerTypeIcon.jsx new file mode 100644 index 0000000000..22bacbd1e8 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/DatepickerTypeIcon.jsx @@ -0,0 +1,29 @@ +import React from 'react'; + +const DatepickerTypeIcon = ({ + fill = '#ACB2B9', + width = '16', + className = '', + viewBox = '0 0 16 16', + style, + height, +}) => ( + + + +); + +export default DatepickerTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/HTMLTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/HTMLTypeIcon.jsx new file mode 100644 index 0000000000..eb134bf034 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/HTMLTypeIcon.jsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const HTMLTypeIcon = ({ fill = '#ACB2B9', width = '16', className = '', viewBox = '0 0 16 16', style, height }) => ( + + + +); + +export default HTMLTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/ImageTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/ImageTypeIcon.jsx new file mode 100644 index 0000000000..3c38af7a5d --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/ImageTypeIcon.jsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const ImageTypeIcon = ({ fill = '#ACB2B9', width = '16', className = '', viewBox = '0 0 16 16', style, height }) => ( + + + +); + +export default ImageTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/JSONTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/JSONTypeIcon.jsx new file mode 100644 index 0000000000..ede33bb51d --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/JSONTypeIcon.jsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const JSONTypeIcon = ({ fill = '#ACB2B9', width = '16', className = '', viewBox = '0 0 16 16', style, height }) => ( + + + +); + +export default JSONTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/LinkTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/LinkTypeIcon.jsx new file mode 100644 index 0000000000..07033546d7 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/LinkTypeIcon.jsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const LinkTypeIcon = ({ fill = '#ACB2B9', width = '16', className = '', viewBox = '0 0 16 16', style, height }) => ( + + + +); + +export default LinkTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/MarkdownTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/MarkdownTypeIcon.jsx new file mode 100644 index 0000000000..2156121ca4 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/MarkdownTypeIcon.jsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const MarkdownTypeIcon = ({ fill = '#ACB2B9', width = '16', className = '', viewBox = '0 0 16 16', style, height }) => ( + + + +); + +export default MarkdownTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/MultiselectTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/MultiselectTypeIcon.jsx new file mode 100644 index 0000000000..a487f5ccb5 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/MultiselectTypeIcon.jsx @@ -0,0 +1,29 @@ +import React from 'react'; + +const MultiselectTypeIcon = ({ + fill = '#ACB2B9', + width = '16', + className = '', + viewBox = '0 0 16 16', + style, + height, +}) => ( + + + +); + +export default MultiselectTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/NumberTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/NumberTypeIcon.jsx new file mode 100644 index 0000000000..edb5ed1e6f --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/NumberTypeIcon.jsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const NumberTypeIcon = ({ fill = '#ACB2B9', width = '16', className = '', viewBox = '0 0 16 16', style, height }) => ( + + + +); + +export default NumberTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/RadioTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/RadioTypeIcon.jsx new file mode 100644 index 0000000000..3cf24f146c --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/RadioTypeIcon.jsx @@ -0,0 +1,26 @@ +import React from 'react'; + +const RadioTypeIcon = ({ fill = '#ACB2B9', width = '16', className = '', viewBox = '0 0 16 16', style, height }) => ( + + + + +); + +export default RadioTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/SelectTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/SelectTypeIcon.jsx new file mode 100644 index 0000000000..c90b6a22fa --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/SelectTypeIcon.jsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const SelectTypeIcon = ({ fill = '#ACB2B9', width = '16', className = '', viewBox = '0 0 16 16', style, height }) => ( + + + +); + +export default SelectTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/StringTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/StringTypeIcon.jsx new file mode 100644 index 0000000000..e5ded3d294 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/StringTypeIcon.jsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const StringTypeIcon = ({ fill = '#ACB2B9', width = '16', className = '', viewBox = '0 0 16 16', style, height }) => ( + + + +); + +export default StringTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/TagsTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/TagsTypeIcon.jsx new file mode 100644 index 0000000000..79b39609e4 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/TagsTypeIcon.jsx @@ -0,0 +1,28 @@ +import React from 'react'; + +const TagsTypeIcon = ({ fill = '#ACB2B9', width = '16', className = '', viewBox = '0 0 16 16', style, height }) => ( + + + + +); + +export default TagsTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/TextTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/TextTypeIcon.jsx new file mode 100644 index 0000000000..95b61a03a2 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/TextTypeIcon.jsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const TextTypeIcon = ({ fill = '#ACB2B9', width = '16', className = '', viewBox = '0 0 16 16', style, height }) => ( + + + +); + +export default TextTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/index.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/index.js new file mode 100644 index 0000000000..03ae127c26 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/index.js @@ -0,0 +1,15 @@ +export { default as TextTypeIcon } from './TextTypeIcon'; +export { default as NumberTypeIcon } from './NumberTypeIcon'; +export { default as StringTypeIcon } from './StringTypeIcon'; +export { default as DatepickerTypeIcon } from './DatepickerTypeIcon'; +export { default as SelectTypeIcon } from './SelectTypeIcon'; +export { default as MultiselectTypeIcon } from './MultiselectTypeIcon'; +export { default as BooleanTypeIcon } from './BooleanTypeIcon'; +export { default as ImageTypeIcon } from './ImageTypeIcon'; +export { default as LinkTypeIcon } from './LinkTypeIcon'; +export { default as JSONTypeIcon } from './JSONTypeIcon'; +export { default as MarkdownTypeIcon } from './MarkdownTypeIcon'; +export { default as HTMLTypeIcon } from './HTMLTypeIcon'; +export { default as BadgeTypeIcon } from './BadgeTypeIcon'; +export { default as TagsTypeIcon } from './TagsTypeIcon'; +export { default as RadioTypeIcon } from './RadioTypeIcon'; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/utils.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/utils.js new file mode 100644 index 0000000000..9af3ed7f05 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/utils.js @@ -0,0 +1,60 @@ +import { + TextTypeIcon, + DatepickerTypeIcon, + SelectTypeIcon, + MultiselectTypeIcon, + BooleanTypeIcon, + ImageTypeIcon, + LinkTypeIcon, + JSONTypeIcon, + MarkdownTypeIcon, + HTMLTypeIcon, + NumberTypeIcon, + StringTypeIcon, + BadgeTypeIcon, + TagsTypeIcon, + RadioTypeIcon, +} from './_assets'; + +export const getColumnIcon = (columnType) => { + switch (columnType) { + case 'default': + case 'string': + return StringTypeIcon; + case 'number': + return NumberTypeIcon; + case 'text': + return TextTypeIcon; + case 'datepicker': + return DatepickerTypeIcon; + case 'dropdown': + case 'select': + return SelectTypeIcon; + case 'multiselect': + case 'newMultiSelect': + return MultiselectTypeIcon; + case 'boolean': + case 'toggle': + return BooleanTypeIcon; + case 'image': + return ImageTypeIcon; + case 'link': + return LinkTypeIcon; + case 'json': + return JSONTypeIcon; + case 'markdown': + return MarkdownTypeIcon; + case 'html': + return HTMLTypeIcon; + case 'radio': + return RadioTypeIcon; + case 'badges': + return BadgeTypeIcon; + case 'badge': + return BadgeTypeIcon; + case 'tags': + return TagsTypeIcon; + default: + return null; + } +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Elements/Code.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Elements/Code.jsx index f2b0ff7594..2474fd96c6 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Elements/Code.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Elements/Code.jsx @@ -20,6 +20,7 @@ export const Code = ({ placeholder, validationFn, isHidden = false, + setCodeEditorView, customMeta, }) => { const currentState = useCurrentState(); @@ -55,7 +56,7 @@ export const Code = ({ if (isHidden) return null; return ( -
+
); diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/EventManager.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/EventManager.jsx index a5f5bb2202..60c0118855 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/EventManager.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/EventManager.jsx @@ -30,6 +30,7 @@ import { appService } from '@/_services'; import { deepClone } from '@/_helpers/utilities/utils.helpers'; import useStore from '@/AppBuilder/_stores/store'; import { useEventActions, useEvents } from '@/AppBuilder/_stores/slices/eventsSlice'; +import { get } from 'lodash'; import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext'; import ToggleGroup from '@/ToolJetUI/SwitchGroup/ToggleGroup'; import ToggleGroupItem from '@/ToolJetUI/SwitchGroup/ToggleGroupItem'; @@ -437,8 +438,8 @@ export const EventManager = ({ const newParams = params.length > 0 ? params.map((paramOfParamList) => { - return paramOfParamList.handle === param.handle ? newParam : paramOfParamList; - }) + return paramOfParamList.handle === param.handle ? newParam : paramOfParamList; + }) : [newParam]; return handlerChanged(index, 'componentSpecificActionParams', newParams); @@ -467,7 +468,7 @@ export const EventManager = ({ if (data.label === 'run-action') return; return (
); @@ -987,51 +988,60 @@ export const EventManager = ({
{event?.componentId && event?.componentSpecificActionHandle && - (getAction(event?.componentId, event?.componentSpecificActionHandle)?.params ?? []).map((param) => ( -
-
- {param?.displayName} + (getAction(event?.componentId, event?.componentSpecificActionHandle)?.params ?? []).map((param) => { + let optionsList = param.isDynamicOpiton + ? get({ ...components[event?.componentId] }, param.optionsGetter, []).map((tab) => ({ + name: tab.title, + value: tab.id, + })) + : param.options; + + return ( +
+
+ {param?.displayName} +
+ + {param.type === 'select' ? ( +
+ { - onChangeHandlerForComponentSpecificActionHandle(value, index, param, event); - }} - placeholder={t('globals.select', 'Select') + '...'} - styles={styles} - useMenuPortal={false} - useCustomStyles={true} - /> -
- ) : ( -
- { - onChangeHandlerForComponentSpecificActionHandle(value, index, param, event); - }} - paramLabel={' '} - paramType={param?.type} - fieldMeta={{ options: param?.options }} - cyLabel={`event-${param.displayName}`} - component={component} - isEventManagerParam={true} - /> -
- )} -
- ))} + ); + })} )}
diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Inspector.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Inspector.jsx index a1a24eee9c..cf1558c893 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Inspector.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Inspector.jsx @@ -1,7 +1,8 @@ import React, { useState, useEffect } from 'react'; import { Table } from './Components/Table/Table.jsx'; +import { TabsLayout } from './Components/TabComponent'; import { Chart } from './Components/Chart'; -import { Form } from './Components/Form'; +import Form from './Components/Form/index.js'; import { renderElement, renderCustomStyles } from './Utils'; import { toast } from 'react-hot-toast'; import { validateQueryName, convertToKebabCase, resolveReferences } from '@/_helpers/utils'; @@ -43,6 +44,9 @@ import useStore from '@/AppBuilder/_stores/store'; import { componentTypes } from '@/AppBuilder/WidgetManager/componentTypes'; import { copyComponents } from '@/AppBuilder/AppCanvas/appCanvasUtils.js'; import DatetimePickerV2 from './Components/DatetimePickerV2.jsx'; +import { ToolTip } from '@/_components/ToolTip'; +import AppPermissionsModal from '@/modules/Appbuilder/components/AppPermissionsModal'; +import { appPermissionService } from '@/_services'; import { ModuleContainerInspector, ModuleViewerInspector, ModuleEditorBanner } from '@/modules/Modules/components'; const INSPECTOR_HEADER_OPTIONS = [ @@ -61,6 +65,19 @@ const INSPECTOR_HEADER_OPTIONS = [ value: 'duplicate', icon: , }, + { + label: 'Component permission', + value: 'permission', + icon: ( + permission-icon + ), + trailingIcon: , + }, { label: 'Delete', value: 'delete', @@ -81,6 +98,9 @@ const NEW_REVAMPED_COMPONENTS = [ 'ToggleSwitchV2', 'Checkbox', 'DatetimePickerV2', + 'DatePickerV2', + 'TimePicker', + 'DaterangePicker', 'DropdownV2', 'MultiselectV2', 'RadioButtonV2', @@ -91,8 +111,11 @@ const NEW_REVAMPED_COMPONENTS = [ 'Divider', 'VerticalDivider', 'ModalV2', + 'Tabs', + 'RangeSlider', 'Link', 'Steps', + 'FilePicker', ]; export const Inspector = ({ componentDefinitionChanged, darkMode, pages, selectedComponentId }) => { @@ -104,6 +127,11 @@ export const Inspector = ({ componentDefinitionChanged, darkMode, pages, selecte const isVersionReleased = useStore((state) => state.isVersionReleased); const setWidgetDeleteConfirmation = useStore((state) => state.setWidgetDeleteConfirmation); const setComponentToInspect = useStore((state) => state.setComponentToInspect); + const featureAccess = useStore((state) => state?.license?.featureAccess, shallow); + const licenseValid = !featureAccess?.licenseStatus?.isExpired && featureAccess?.licenseStatus?.isLicenseValid; + const showComponentPermissionModal = useStore((state) => state.showComponentPermissionModal); + const toggleComponentPermissionModal = useStore((state) => state.toggleComponentPermissionModal); + const setComponentPermission = useStore((state) => state.setComponentPermission); const dataQueries = useDataQueries(); const currentState = useCurrentState(); @@ -378,9 +406,14 @@ export const Inspector = ({ componentDefinitionChanged, darkMode, pages, selecte if (value === 'delete') { setWidgetDeleteConfirmation(true); } + if (value === 'permission') { + if (!licenseValid) return; + toggleComponentPermissionModal(true); + } if (value === 'duplicate') { copyComponents({ isCloning: true }); } + setShowHeaderActionsMenu(false); }; const buildGeneralStyle = () => { if (!componentMeta?.definition?.generalStyles) { @@ -446,7 +479,7 @@ export const Inspector = ({ componentDefinitionChanged, darkMode, pages, selecte React.useEffect(() => { const handleClickOutside = (event) => { - if (showHeaderActionsMenu && event.target.closest('.list-menu') === null) { + if (showHeaderActionsMenu && event.target.closest('#list-menu') === null) { setShowHeaderActionsMenu(false); } }; @@ -458,6 +491,8 @@ export const Inspector = ({ componentDefinitionChanged, darkMode, pages, selecte // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify({ showHeaderActionsMenu })]); + const toggleRightSidebarPin = useStore((state) => state.toggleRightSidebarPin); + const isRightSidebarPinned = useStore((state) => state.isRightSidebarPinned); const renderAppNameInput = () => { if (isModuleContainer) { return ; @@ -504,44 +539,79 @@ export const Inspector = ({ componentDefinitionChanged, darkMode, pages, selecte
{renderAppNameInput()}
{!isModuleContainer && ( -
- - - {INSPECTOR_HEADER_OPTIONS.map((option) => ( -
{ - e.stopPropagation(); - handleInspectorHeaderActions(option.value); - }} - > -
{option.icon}
-
- {option?.label} -
-
- ))} -
- - } - > - setShowHeaderActionsMenu(true)}> - - -
-
+ <> +
+ + + {INSPECTOR_HEADER_OPTIONS.map((option) => { + const optionBody = ( +
{ + e.stopPropagation(); + handleInspectorHeaderActions(option.value); + }} + > +
{option.icon}
+
+ {option?.label} +
+ {option.value === 'permission' && + !licenseValid && + option.trailingIcon && + option.trailingIcon} +
+ ); + + return option.value === 'permission' ? ( + + {optionBody} + + ) : ( + optionBody + ); + })} +
+ + } + > + setShowHeaderActionsMenu(true)}> + + +
+
+ appPermissionService.getComponentPermission(appId, id)} + createPermission={(id, appId, body) => appPermissionService.createComponentPermission(appId, id, body)} + updatePermission={(id, appId, body) => appPermissionService.updateComponentPermission(appId, id, body)} + deletePermission={(id, appId) => appPermissionService.deleteComponentPermission(appId, id)} + onSuccess={(data) => setComponentPermission(selectedComponentId, data)} + /> + )}
@@ -557,8 +627,8 @@ export const Inspector = ({ componentDefinitionChanged, darkMode, pages, selecte componentMeta.displayName === 'Toggle Switch (Legacy)' ? 'Toggle (Legacy)' : componentMeta.displayName === 'Toggle Switch' - ? 'Toggle Switch' - : componentMeta.component, + ? 'Toggle Switch' + : componentMeta.component, })} @@ -727,6 +797,9 @@ const GetAccordion = React.memo( case 'Table': return ; + case 'Tabs': + return ; + case 'Chart': return ; @@ -746,7 +819,7 @@ const GetAccordion = React.memo( return ; case 'Form': - return
; + return ; case 'DropdownV2': case 'MultiselectV2': diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Utils.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Utils.js index a9b981eb1a..0e4128729c 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Utils.js +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Utils.js @@ -55,7 +55,9 @@ export function renderCustomStyles( componentConfig.component == 'RadioButtonV2' || componentConfig.component == 'Button' || componentConfig.component == 'Image' || - componentConfig.component == 'ModalV2' + componentConfig.component == 'ModalV2' || + componentConfig.component == 'RangeSlider' || + componentConfig.component == 'FilePicker' ) { const paramTypeConfig = componentMeta[paramType] || {}; const paramConfig = paramTypeConfig[param] || {}; @@ -131,7 +133,8 @@ export function renderElement( darkMode = false, placeholder = '', validationFn, - customMeta + setCodeEditorView = null, + customMeta = null ) { const componentConfig = component.component; const componentDefinition = componentConfig.definition; @@ -144,7 +147,8 @@ export function renderElement( componentConfig.component == 'DropDown' || componentConfig.component == 'Form' || componentConfig.component == 'Listview' || - componentConfig.component == 'Image' + componentConfig.component == 'Image' || + componentConfig.component == 'RangeSlider' ) { const paramTypeConfig = componentMeta[paramType] || {}; const paramConfig = paramTypeConfig[param] || {}; @@ -179,6 +183,7 @@ export function renderElement( placeholder={placeholder} validationFn={validationFn} isHidden={isHidden} + setCodeEditorView={setCodeEditorView} customMeta={customMeta} /> ); diff --git a/frontend/src/AppBuilder/RightSideBar/PageSettingsTab/PageMenu/AddNewPageMenu.jsx b/frontend/src/AppBuilder/RightSideBar/PageSettingsTab/PageMenu/AddNewPageMenu.jsx new file mode 100644 index 0000000000..261ab9d0d1 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/PageSettingsTab/PageMenu/AddNewPageMenu.jsx @@ -0,0 +1,107 @@ +import React, { useRef, useState } from 'react'; +import { Overlay, Popover } from 'react-bootstrap'; +import { Button } from '@/components/ui/Button/Button'; +import useStore from '@/AppBuilder/_stores/store'; +import { AddEditPagePopup } from './AddNewPagePopup'; +import PageOptions from './PageOptions'; +import { ToolTip as LicenseTooltip } from '@/_components/ToolTip'; +import SolidIcon from '@/_ui/Icon/SolidIcons'; + +export function AddNewPageMenu({ darkMode, isLicensed }) { + const newPageBtnRef = useRef(null); + const [showMenuPopover, setShowMenuPopover] = useState(false); + const setNewPagePopupConfig = useStore((state) => state.setNewPagePopupConfig); + const setEditingPage = useStore((state) => state.setEditingPage); + const newPagePopupConfig = useStore((state) => state.newPagePopupConfig); + + const handleOpenPopup = (type) => { + setShowMenuPopover(false); + setNewPagePopupConfig({ type, show: true, mode: 'add' }); + }; + + return ( +
+ + +
+ ); +} diff --git a/frontend/src/AppBuilder/RightSideBar/PageSettingsTab/PageMenu/AddNewPagePopup.jsx b/frontend/src/AppBuilder/RightSideBar/PageSettingsTab/PageMenu/AddNewPagePopup.jsx new file mode 100644 index 0000000000..277ba12bbf --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/PageSettingsTab/PageMenu/AddNewPagePopup.jsx @@ -0,0 +1,654 @@ +import React, { forwardRef, useCallback, useEffect, useState } from 'react'; +import cx from 'classnames'; +import { Popover } from 'react-bootstrap'; +import useStore from '@/AppBuilder/_stores/store'; +import SolidIcon from '@/_ui/Icon/SolidIcons'; +import { Button } from '@/_ui/LeftSidebar'; +import { Icon } from '@/AppBuilder/CodeBuilder/Elements/Icon'; +import { EventManager } from '../../Inspector/EventManager'; +import { kebabCase } from 'lodash'; +import Select from '@/_ui/Select'; +import ToggleGroup from '@/ToolJetUI/SwitchGroup/ToggleGroup'; +import ToggleGroupItem from '@/ToolJetUI/SwitchGroup/ToggleGroupItem'; +import { appService } from '@/_services'; +import { ToolTip } from '@/_components'; +import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext'; +import CodeHinter from '@/AppBuilder/CodeEditor'; +import FxButton from '@/Editor/CodeBuilder/Elements/FxButton'; +import { resolveReferences, validateKebabCase } from '@/_helpers/utils'; +import { ToolTip as InspectorTooltip } from '../../Inspector/Elements/Components/ToolTip'; + +const POPOVER_TITLES = { + add: { + default: 'New page', + app: 'New nav item with app', + url: 'New nav item with URL', + group: 'New nav group', + }, + edit: { + default: 'Edit page', + app: 'Edit nav item', + url: 'Edit nav item', + group: 'Edit nav group', + }, +}; + +const OPEN_APP_MODES = [ + { label: 'New tab', value: 'new_tab' }, + { label: 'Same tab', value: 'same_tab' }, +]; + +const POPOVER_ACTIONS = { + default: 'page', + url: 'page', + app: 'page', + group: 'group', +}; + +export const AddEditPagePopup = forwardRef(({ darkMode, ...props }, ref) => { + const { moduleId } = useModuleContext(); + const { show, mode, type } = useStore((state) => state.newPagePopupConfig); + const editingPage = useStore((state) => state.editingPage); + const pages = useStore((state) => state?.modules?.canvas?.pages ?? []); + const addNewPage = useStore((state) => state.addNewPage); + const updatePageName = useStore((state) => state.updatePageName); + const updatePageHandle = useStore((state) => state.updatePageHandle); + const updatePageTarget = useStore((state) => state.updatePageTarget); + const updatePageURL = useStore((state) => state.updatePageURL); + const updatePageIcon = useStore((state) => state.updatePageIcon); + const markAsHomePage = useStore((state) => state.markAsHomePage); + const clonePage = useStore((state) => state.clonePage); + const cloneGroup = useStore((state) => state.cloneGroup); + const toggleDeleteConfirmationModal = useStore((state) => state.toggleDeleteConfirmationModal); + const switchPage = useStore((state) => state.switchPage); + + const isPageGroup = useStore((state) => state.isPageGroup); + const homePageId = useStore((state) => state.appStore.modules[moduleId].app.homePageId); + const updatePageVisibility = useStore((state) => state.updatePageVisibility); + const disableOrEnablePage = useStore((state) => state.disableOrEnablePage); + const updatePageAppId = useStore((state) => state.updatePageAppId); + const currentPageId = useStore((state) => state.currentPageId); + const setCurrentPageHandle = useStore((state) => state.setCurrentPageHandle); + const openPageEditPopover = useStore((state) => state.openPageEditPopover); + const appId = useStore((state) => state.appStore.modules[moduleId].app.homePageId); + + const [page, setPage] = useState(editingPage || props?.page); + const [pageName, setPageName] = useState(''); + const [handle, setHandle] = useState(''); + const [pageURL, setPageURL] = useState(''); + const [hasAutoSaved, setHasAutoSaved] = useState(false); + const [error, setError] = useState(null); + + const allpages = pages.filter((p) => p.id !== page?.id); + const isHomePage = page?.id === homePageId; + + //Nav item with app + const [appOptions, setAppOptions] = useState([]); + const [appOptionsLoading, setAppOptionsLoading] = useState(true); + + useEffect(() => { + setError(null); + }, [show]); + + useEffect(() => { + if (mode === 'add' && type === 'default' && !hasAutoSaved) { + const existingNames = pages.map((p) => p.name.toLowerCase()); + let index = 1; + let newName = `Page ${index}`; + while (existingNames.includes(newName.toLowerCase())) { + index++; + newName = `Page ${index}`; + } + const pageObj = { type: 'default' }; + addNewPage(newName, kebabCase(newName.toLowerCase()), isPageGroup, pageObj).then((data) => { + setPage(data); + setPageName(newName); + setHandle(data?.handle); + }); + + setHasAutoSaved(true); + } else if (editingPage) { + setPage(editingPage); + setPageName(editingPage.name); + setHandle(editingPage.handle); + } + }, [mode, hasAutoSaved, pages, editingPage, addNewPage, isPageGroup, type]); + + //Nav item with URL hooks + useEffect(() => { + if (mode === 'add' && type === 'url' && !hasAutoSaved) { + const existingNames = pages.map((p) => p.name.toLowerCase()); + let index = 1; + let newName = `URL ${index}`; + while (existingNames.includes(newName.toLowerCase())) { + index++; + newName = `URL ${index}`; + } + const pageObj = { type: 'url', openIn: 'new_tab', url: 'https://www.tooljet.ai' }; + addNewPage(newName, kebabCase(newName.toLowerCase()), isPageGroup, pageObj).then((data) => { + setPage(data); + setPageName(newName); + setPageURL(data?.url); + }); + + setHasAutoSaved(true); + } else if (editingPage) { + setPage(editingPage); + setPageName(editingPage.name); + setPageURL(editingPage.url); + } + }, [addNewPage, appOptions, editingPage, hasAutoSaved, isPageGroup, mode, pages, type]); + + //Nav item with app hooks + useEffect(() => { + const fetchApps = async (page) => { + const { apps } = await appService.getAll(page); + return apps; + }; + + // eslint-disable-next-line no-inner-declarations + async function getAllApps() { + const apps = await fetchApps(0); + let appsOptionsList = []; + apps + .filter((item) => item.slug !== undefined && item.id !== appId && item.current_version_id) + .forEach((item) => { + appsOptionsList.push({ + name: item.name, + value: item.slug, + }); + }); + return appsOptionsList; + } + + getAllApps() + .then((apps) => { + setAppOptions(apps); + }) + .finally(() => { + setAppOptionsLoading(false); + }); + if (mode === 'add' && type === 'app' && !hasAutoSaved) { + const existingNames = pages.map((p) => p.name.toLowerCase()); + let index = 1; + let newName = `App ${index}`; + while (existingNames.includes(newName.toLowerCase())) { + index++; + newName = `App ${index}`; + } + const pageObj = { type: 'app', openIn: 'new_tab' }; + addNewPage(newName, kebabCase(newName.toLowerCase()), isPageGroup, pageObj).then((data) => { + setPage(data); + setPageName(newName); + }); + + setHasAutoSaved(true); + } else if (editingPage) { + setPage(editingPage); + setPageName(editingPage.name); + } + }, [mode, hasAutoSaved, pages, editingPage, addNewPage, isPageGroup, type, appId]); + + //Nav item with group + useEffect(() => { + if (mode === 'add' && type === 'group' && !hasAutoSaved) { + const existingNames = pages.map((p) => p.name.toLowerCase()); + let index = 1; + let newName = `Group ${index}`; + while (existingNames.includes(newName.toLowerCase())) { + index++; + newName = `Group ${index}`; + } + const pageObj = { type: 'group', openIn: 'new_tab' }; + addNewPage(newName, kebabCase(newName.toLowerCase()), true, pageObj).then((data) => { + setPage(data); + setPageName(newName); + }); + + setHasAutoSaved(true); + } else if (editingPage) { + setPage(editingPage); + setPageName(editingPage.name); + } + }, [mode, hasAutoSaved, pages, editingPage, addNewPage, isPageGroup, type, appId]); + + const handlePageSwitch = useCallback(() => { + if (currentPageId === page.id) { + return; + } + switchPage(page.id, page.handle); + setCurrentPageHandle(page.handle); + }, [currentPageId, page?.id, page?.handle, switchPage, setCurrentPageHandle]); + + const onChangePageHandleValue = (event) => { + setError(null); + const newHandle = event.target.value; + + if (newHandle === '') setError('Page handle cannot be empty'); + if (newHandle === handle) setError('Page handle cannot be same as the existing page handle'); + const isValidKebabCase = validateKebabCase(newHandle); + if (!isValidKebabCase.isValid) { + setError(isValidKebabCase.error); + } + setHandle(newHandle); + }; + + const handleSave = () => { + if (handle === page.handle) { + setError(null); + return; + } + const { isValid, error } = validateKebabCase(handle); + if (!isValid) { + setError(error); + return; + } + const transformedPageHandle = kebabCase(handle); + updatePageHandle(page.id, transformedPageHandle); + setError(null); + }; + + return ( + + +
+
{POPOVER_TITLES?.[mode]?.[type]}
+
+ {type !== 'group' && ( + <> + +
+ +
+
+ + )} + + +
(type === 'group' ? cloneGroup(page?.id) : clonePage(page?.id))} className="icon-btn"> + +
+
+ + +
{ + openPageEditPopover(page); + toggleDeleteConfirmationModal(true); + }} + className="icon-btn" + > + +
+
+
+
+
+ + {type === 'default' && ( + <> +
+
+ + setPageName(e.target.value)} + onBlur={(e) => { + pageName && pageName !== page?.name && updatePageName(page?.id, pageName); + }} + minLength="1" + /> +
+
+
+
+ + onChangePageHandleValue(e)} + onBlur={(e) => handleSave(e)} + value={handle} + minLength="1" + /> +
+ {error} +
+
+
+
+
+ + updatePageIcon(page?.id, value)} + value={page?.icon || 'IconFile'} + /> +
+
+
+
+ + +
+ {/*
+ + +
*/} +
+ + + )} + {type === 'url' && ( + <> +
+
+ + setPageName(e.target.value)} + className="form-control" + value={pageName} + autoFocus={true} + onBlur={(e) => { + pageName && pageName !== page?.name && updatePageName(page?.id, pageName); + }} + minLength="1" + /> +
+
+
+
+ +