Merge branch 'main' into release/marketplace-sprint-12

This commit is contained in:
Ganesh Kumar 2025-07-02 16:51:13 +05:30
commit d9b214add8
814 changed files with 30327 additions and 7664 deletions

View file

@ -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=

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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": [

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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 \

View file

@ -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",
(

View file

@ -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();
}
});
});

View file

@ -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"]',

View file

@ -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"]`
},

View file

@ -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) => {

View file

@ -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);

View file

@ -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);

View file

@ -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");
});
});
});

View file

@ -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,

View file

@ -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 wont 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");
});
});

View file

@ -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];
};

View file

@ -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) => {

View file

@ -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");

View file

@ -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

View file

@ -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();
}
};

View file

@ -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) => {

View file

@ -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();

0
docker/cloud/cloud-entrypoint.sh Normal file → Executable file
View file

View file

@ -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"]

View file

@ -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"]

View file

@ -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"]

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"]

View file

@ -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"

View file

@ -0,0 +1,9 @@
<svg width="80" height="56" viewBox="0 0 80 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.15" d="M80 0.589844H0V55.4104H80V0.589844Z" fill="#6A727C"/>
<path d="M80 0.589844H0V8.24173H80V0.589844Z" fill="#6A727C"/>
<path d="M4.35492 5.93528C5.19384 5.93528 5.87391 5.25502 5.87391 4.41588C5.87391 3.57674 5.19384 2.89648 4.35492 2.89648C3.51601 2.89648 2.83594 3.57674 2.83594 4.41588C2.83594 5.25502 3.51601 5.93528 4.35492 5.93528Z" fill="white"/>
<path d="M9.3144 5.93528C10.1533 5.93528 10.8334 5.25502 10.8334 4.41588C10.8334 3.57674 10.1533 2.89648 9.3144 2.89648C8.47548 2.89648 7.79541 3.57674 7.79541 4.41588C7.79541 5.25502 8.47548 5.93528 9.3144 5.93528Z" fill="white"/>
<path d="M14.2729 5.93528C15.1118 5.93528 15.7919 5.25502 15.7919 4.41588C15.7919 3.57674 15.1118 2.89648 14.2729 2.89648C13.434 2.89648 12.7539 3.57674 12.7539 4.41588C12.7539 5.25502 13.434 5.93528 14.2729 5.93528Z" fill="white"/>
<path opacity="0.15" d="M17.9315 13.0801H6.89697V49.5456H17.9315V13.0801Z" fill="#6A727C"/>
<path d="M73.1031 13.0801H17.9307V49.5456H73.1031V13.0801Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -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",

@ -1 +1 @@
Subproject commit aa521205455afd59e85762716a0012c1e44986e1
Subproject commit d47523cfa18e15e774781d3ccf4d16858970479b

View file

@ -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 @@
}
}
}
}
}

View file

@ -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",

View file

@ -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={
<PrivateRoute>
<Workflows switchDarkMode={this.switchDarkMode} darkMode={this.darkMode} />
<Workflows switchDarkMode={this.switchDarkMode} darkMode={darkMode} />
</PrivateRoute>
}
/>
@ -292,8 +293,13 @@ class AppComponent extends React.Component {
path="/:workspaceId/workspace-settings/*"
element={<WorkspaceSettings {...mergedProps} />}
></Route>
<Route path="settings/*" element={<InstanceSettings {...this.props} />}></Route>
<Route path="/:workspaceId/settings/*" element={<Settings {...this.props} />}></Route>
<Route
path="settings/*"
element={
<InstanceSettings switchDarkMode={this.switchDarkMode} darkMode={darkMode} {...this.props} />
}
></Route>
<Route path="/:workspaceId/settings/*" element={<InstanceSettings {...this.props} darkMode={darkMode} switchDarkMode={this.switchDarkMode} />}></Route>
<Route
exact
path="/:workspaceId/modules"
@ -404,6 +410,7 @@ class AppComponent extends React.Component {
</PrivateRoute>
}
/>
<Route exact path="/embed-apps/:appId" element={<EmbedApp />} />
<Route
path="*"
render={() => {

View file

@ -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
</Suspense>
{window?.public_config?.ENABLE_MULTIPLAYER_EDITING === 'true' && <RealtimeCursors />}
<DndProvider backend={HTML5Backend}>
<AppCanvas appId={appId} />
<AppCanvas moduleId={moduleId} appId={appId} switchDarkMode={switchDarkMode} darkMode={darkMode} />
<QueryPanel darkMode={darkMode} />
<RightSideBar darkMode={darkMode} />
<RightSidebarToggle darkMode={darkMode} />
{isRightSidebarOpen && <RightSideBar darkMode={darkMode} />}{' '}
</DndProvider>
<Popups darkMode={darkMode} />
</ModuleProvider>

View file

@ -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 (
<div
@ -127,48 +170,71 @@ export const AppCanvas = ({ appId, isViewerSidebarPinned, isViewer = false }) =>
onMouseUp={handleCanvasContainerMouseUp}
>
<AppCanvasBanner appId={appId} />
<div
ref={canvasContainerRef}
className={cx(
'canvas-container align-items-center page-container',
{ 'dark-theme theme-dark': isAppDarkMode, close: !isViewerSidebarPinned },
{ 'overflow-x-auto': (currentMode === 'edit' && isSidebarOpen) || currentMode === 'view' },
{ 'overflow-x-hidden': moduleId !== 'canvas' } // Disbling horizontal scroll for modules in view mode
)}
style={styles}
>
<div id="sidebar-page-navigation" className="areas d-flex flex-rows">
<div
style={{
minWidth: isModuleMode ? '100%' : `calc((100vw - 300px) - 48px)`,
}}
className={`app-${appId} _tooljet-page-${getPageId()}`}
>
{currentMode === 'edit' && (
<AutoComputeMobileLayoutAlert currentLayout={currentLayout} darkMode={isAppDarkMode} />
ref={canvasContainerRef}
className={cx(
'canvas-container d-flex page-container',
{ 'dark-theme theme-dark': isAppDarkMode, close: !isViewerSidebarPinned },
{ 'overflow-x-auto': currentMode === 'edit' },
{ 'position-top': position === 'top' },
{ 'overflow-x-hidden': moduleId !== 'canvas' } // Disbling horizontal scroll for modules in view mode
)}
<DeleteWidgetConfirmation darkMode={isAppDarkMode} />
<HotkeyProvider mode={currentMode} canvasMaxWidth={canvasMaxWidth} currentLayout={currentLayout}>
{environmentLoadingState !== 'loading' && (
<div>
<Container
id={moduleId}
gridWidth={gridWidth}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
darkMode={isAppDarkMode}
canvasMaxWidth={canvasMaxWidth}
isViewerSidebarPinned={isViewerSidebarPinned}
pageSidebarStyle={pageSidebarStyle}
appType={appType}
/>
{appType !== 'module' && <div id="component-portal" />}
</div>
style={canvasContainerStyles}
>
{showOnDesktop && (
<PagesSidebarNavigation
showHeader={showHeader}
isMobileDevice={currentLayout === 'mobile'}
pages={pages}
currentPageId={currentPageId ?? homePageId}
switchPage={switchPage}
height={currentMode === 'edit' ? canvasContainerHeight : '100%'}
switchDarkMode={switchDarkMode}
isSidebarPinned={isViewerSidebarPinned}
toggleSidebarPinned={toggleSidebarPinned}
darkMode={darkMode}
/>
)}
<div
style={{
minWidth: getMinWidth(),
scrollbarWidth: 'none',
overflow: 'auto',
width: currentMode === 'view' ? `calc(100% - ${isViewerSidebarPinned ? '0px' : '0px'})` : '100%',
}}
className={`app-${appId} _tooljet-page-${getPageId()}`}
>
{currentMode === 'edit' && (
<AutoComputeMobileLayoutAlert currentLayout={currentLayout} darkMode={isAppDarkMode} />
)}
<DeleteWidgetConfirmation darkMode={isAppDarkMode} />
<HotkeyProvider mode={currentMode} canvasMaxWidth={canvasMaxWidth} currentLayout={currentLayout}>
{environmentLoadingState !== 'loading' && (
<div>
<Container
id="canvas"
gridWidth={gridWidth}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
darkMode={isAppDarkMode}
canvasMaxWidth={canvasMaxWidth}
isViewerSidebarPinned={isViewerSidebarPinned}
pageSidebarStyle={pageSidebarStyle}
pagePositionType={position}
appType={appType}
/>
<DragGhostWidget />
<div id="component-portal" />
{appType !== 'module' && <div id="component-portal" />}
</div>
)}
{currentMode === 'view' || (currentLayout === 'mobile' && isAutoMobileLayout) ? null : (
<Grid currentLayout={currentLayout} gridWidth={gridWidth} />
)}
</HotkeyProvider>
{currentMode === 'view' || (currentLayout === 'mobile' && isAutoMobileLayout) ? null : (
<Grid currentLayout={currentLayout} gridWidth={gridWidth} />
)}
</HotkeyProvider>
</div>
</div>
</div>
{currentMode === 'edit' && <EditorSelecto />}

View file

@ -4,6 +4,7 @@ import './configHandle.scss';
import useStore from '@/AppBuilder/_stores/store';
import { findHighestLevelofSelection } from '../Grid/gridUtils';
import SolidIcon from '@/_ui/Icon/solidIcons/index';
import { ToolTip } from '@/_components/ToolTip';
import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext';
import { DROPPABLE_PARENTS } from '../appCanvasConstants';
@ -52,7 +53,40 @@ export const ConfigHandle = ({
);
}, shallow);
const currentPageIndex = useStore((state) => state.modules.canvas.currentPageIndex);
const component = useStore((state) => state.modules.canvas.pages[currentPageIndex].components[id]);
const featureAccess = useStore((state) => state?.license?.featureAccess, shallow);
const licenseValid = !featureAccess?.licenseStatus?.isExpired && featureAccess?.licenseStatus?.isLicenseValid;
const isRestricted = component.permissions && component.permissions.length !== 0;
const draggingComponentId = useStore((state) => state.draggingComponentId);
let height = visibility === false ? 10 : widgetHeight;
const getTooltip = () => {
const permission = component.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 (
<div
className={`config-handle ${customClassName}`}
@ -78,6 +112,22 @@ export const ConfigHandle = ({
}
}}
>
{licenseValid && isRestricted && (
<ToolTip message={getTooltip()} show={licenseValid && isRestricted && !draggingComponentId}>
<span
style={{
background:
visibility === false ? '#c6cad0' : componentType === 'Modal' && isModalOpen ? '#c6cad0' : '#4D72FA',
border: position === 'bottom' ? '1px solid white' : 'none',
color: visibility === false && 'var(--text-placeholder)',
marginRight: '4px',
}}
className="badge handle-content"
>
<SolidIcon width="12" name="lock" fill="var(--icon-on-solid)" />
</span>
</ToolTip>
)}
<span
style={{
background:

View file

@ -10,6 +10,7 @@ import {
addNewWidgetToTheEditor,
computeViewerBackgroundColor,
getSubContainerWidthAfterPadding,
addDefaultButtonIdToForm,
} from './appCanvasUtils';
import {
CANVAS_WIDTHS,
@ -52,6 +53,7 @@ export const Container = React.memo(
canvasMaxWidth,
isViewerSidebarPinned,
pageSidebarStyle,
pagePositionType,
componentType,
appType,
}) => {
@ -84,18 +86,12 @@ export const Container = React.memo(
item.canvasWidth = getContainerCanvasWidth();
},
drop: async ({ componentType, component }, monitor) => {
setShowModuleBorder(false); // Hide the module border when dropping
setShowModuleBorder(false);
if (currentMode === 'view' || (appType === 'module' && componentType !== 'ModuleContainer')) return;
const didDrop = monitor.didDrop();
if (didDrop) return;
if (componentType === 'PDF' && !isPDFSupported()) {
toast.error(
'PDF is not supported in this version of browser. We recommend upgrading to the latest version for full support.'
);
return;
}
// IMPORTANT: This logic needs to be changed when we implement the module versioning
const moduleInfo = component?.moduleId
? {
moduleId: component.moduleId,
@ -106,8 +102,10 @@ export const Container = React.memo(
}
: undefined;
let addedComponent;
if (WIDGETS_WITH_DEFAULT_CHILDREN.includes(componentType)) {
const parentComponent = addNewWidgetToTheEditor(
let parentComponent = addNewWidgetToTheEditor(
componentType,
monitor,
currentLayout,
@ -116,10 +114,11 @@ export const Container = React.memo(
moduleInfo
);
const childComponents = addChildrenWidgetsToParent(componentType, parentComponent?.id, currentLayout);
const newComponents = [parentComponent, ...childComponents];
await addComponentToCurrentPage(newComponents);
// setSelectedComponents([parentComponent?.id]);
setActiveRightSideBarTab(RIGHT_SIDE_BAR_TAB.CONFIGURATION);
if (componentType === 'Form') {
parentComponent = addDefaultButtonIdToForm(parentComponent, childComponents);
}
addedComponent = [parentComponent, ...childComponents];
await addComponentToCurrentPage(addedComponent);
} else {
const newComponent = addNewWidgetToTheEditor(
componentType,
@ -129,11 +128,32 @@ export const Container = React.memo(
id,
moduleInfo
);
await addComponentToCurrentPage([newComponent]);
// setSelectedComponents([newComponent?.id]);
setActiveRightSideBarTab(RIGHT_SIDE_BAR_TAB.CONFIGURATION);
addedComponent = [newComponent];
await addComponentToCurrentPage(addedComponent);
}
setActiveRightSideBarTab(RIGHT_SIDE_BAR_TAB.CONFIGURATION);
const canvas = document.querySelector('.canvas-container');
const sidebar = document.querySelector('.editor-sidebar');
const droppedElem = document.getElementById(addedComponent?.[0]?.id);
if (!canvas || !sidebar || !droppedElem) return;
const droppedRect = droppedElem.getBoundingClientRect();
const sidebarRect = sidebar.getBoundingClientRect();
const isOverlapping = droppedRect.right > sidebarRect.left && droppedRect.left < sidebarRect.right;
if (isOverlapping) {
const overlap = droppedRect.right - sidebarRect.left;
canvas.scrollTo({
left: canvas.scrollLeft + overlap,
behavior: 'smooth',
});
}
},
collect: (monitor) => ({
isOverCurrent: monitor.isOver({ shallow: true }),
}),
@ -149,10 +169,11 @@ export const Container = React.memo(
if (canvasWidth !== undefined) {
if (componentType === 'Listview' && listViewMode == 'grid') return canvasWidth / columns - 2;
if (id === 'canvas') return canvasWidth;
return getSubContainerWidthAfterPadding(canvasWidth, componentType, id);
return getSubContainerWidthAfterPadding(canvasWidth, componentType, id, realCanvasRef);
}
return realCanvasRef?.current?.offsetWidth;
}
const gridWidth = getContainerCanvasWidth() / NO_OF_GRIDS;
useEffect(() => {
useGridStore.getState().actions.setSubContainerWidths(id, gridWidth);
@ -160,18 +181,27 @@ export const Container = React.memo(
}, [canvasWidth, listViewMode, columns]);
const getCanvasWidth = useCallback(() => {
if (
id === 'canvas' &&
!isPagesSidebarHidden &&
isViewerSidebarPinned &&
currentLayout !== 'mobile' &&
currentMode !== 'edit' &&
appType !== 'module'
) {
return `calc(100% - ${pageSidebarStyle === 'icon' ? '65px' : '210px'})`;
}
// if (
// id === 'canvas' &&
// !isPagesSidebarHidden &&
// isViewerSidebarPinned &&
// currentLayout !== 'mobile' &&
// pagePositionType == 'side' &&
// appType !== 'module'
// ) {
// return `calc(100% - ${pageSidebarStyle === 'icon' ? '85px' : '226px'})`;
// }
// if (
// id === 'canvas' &&
// !isPagesSidebarHidden &&
// !isViewerSidebarPinned &&
// currentLayout !== 'mobile' &&
// pagePositionType == 'side'
// ) {
// return `calc(100% - ${'44px'})`;
// }
return '100%';
}, [isViewerSidebarPinned, currentLayout, id, currentMode, pageSidebarStyle]);
}, [id, isPagesSidebarHidden, isViewerSidebarPinned, currentLayout, pagePositionType, pageSidebarStyle]);
const handleCanvasClick = useCallback(
(e) => {
@ -223,7 +253,7 @@ export const Container = React.memo(
: id === 'canvas'
? canvasBgColor
: '#f0f0f0',
width: getCanvasWidth(),
width: '100%',
maxWidth: (() => {
// For Main Canvas
if (id === 'canvas') {

View file

@ -1,17 +1,24 @@
import React from 'react';
import useStore from '@/AppBuilder/_stores/store';
export const DragGhostWidget = () => {
const draggingComponentId = useStore((state) => state.draggingComponentId);
if (!draggingComponentId) return null;
export const DragGhostWidget = ({ isDragging }) => {
if (!isDragging) return '';
return (
<div
id={'moveable-drag-ghost'}
id="moveable-drag-ghost"
style={{
zIndex: 4,
position: 'absolute',
background: '#D9E2FC',
opacity: '0.7',
pointerEvents: 'none',
left: 0,
top: 0,
}}
></div>
/>
);
};

View file

@ -1,176 +1,207 @@
.target, .nested-target {
position: absolute;
box-sizing: border-box;
.target,
.nested-target {
position: absolute;
box-sizing: border-box;
}
.target.hovered{
z-index: 2;
.target.hovered {
z-index: 2;
}
.moveable-control-box>.moveable-control-box:not(.moveable-control-box-d-block, .moveable-dragging, .selected-component){
visibility: hidden !important;
}
.moveable-control-box>.moveable-control-box:hover, .selected-component{
visibility: visible !important;
}
.moveable-control-box>.moveable-control-box:hover, .moveable-control-box>.moveable-dragging{
visibility: visible !important;
}
.moveable-control-box.modal-moveable{
z-index: 3001 !important;
.moveable-control-box
> .moveable-control-box:not(
.moveable-control-box-d-block,
.moveable-dragging,
.selected-component
) {
visibility: hidden !important;
}
.moveable-control-box > .moveable-control-box:hover,
.selected-component {
visibility: visible !important;
}
.moveable-e.moveable-control{
/* height: 24px !important;
.moveable-control-box > .moveable-control-box:hover,
.moveable-control-box > .moveable-dragging {
visibility: visible !important;
}
.moveable-control-box.modal-moveable {
z-index: 3001 !important;
}
.moveable-e.moveable-control {
/* height: 24px !important;
top: -5px !important; */
border-radius: 2px !important;
border: 1px solid #3E63DD !important;
background: #fff !important;
width: 6px !important;
left: 4px !important;
border-radius: 2px !important;
border: 1px solid #3e63dd !important;
background: #fff !important;
width: 6px !important;
left: 4px !important;
}
.moveable-w.moveable-control{
/* height: 24px !important;
.moveable-w.moveable-control {
/* height: 24px !important;
top: -5px !important; */
border-radius: 2px !important;
border: 1px solid #3E63DD !important;
background: #fff !important;
width: 6px !important;
left: 4px !important;
border-radius: 2px !important;
border: 1px solid #3e63dd !important;
background: #fff !important;
width: 6px !important;
left: 4px !important;
}
.moveable-n.moveable-control{
/* height: 24px !important; */
top: 4px !important;
border-radius: 2px !important;
border: 1px solid #3E63DD !important;
background: #fff !important;
height: 6px !important;
/* left: 3px !important; */
.moveable-horizontal-only {
.moveable-direction.moveable-w:not(.moveable-edge),
.moveable-direction.moveable-e:not(.moveable-edge) {
height: 20px !important;
width: 7.5px !important;
opacity: 1 !important;
background-color: #fff !important;
border-radius: 10px !important;
}
.moveable-direction.moveable-w:not(.moveable-edge) {
left: 1px !important;
top: -6.5px !important;
}
.moveable-direction.moveable-e:not(.moveable-edge) {
left: 1px !important;
top: -6.5px !important;
}
}
.moveable-s.moveable-control{
/* height: 24px !important; */
top: 4px !important;
border-radius: 2px !important;
border: 1px solid #3E63DD !important;
background: #fff !important;
height: 6px !important;
/* left: 3px !important; */
.moveable-n.moveable-control {
/* height: 24px !important; */
top: 4px !important;
border-radius: 2px !important;
border: 1px solid #3e63dd !important;
background: #fff !important;
height: 6px !important;
/* left: 3px !important; */
}
.moveable-s.moveable-control {
/* height: 24px !important; */
top: 4px !important;
border-radius: 2px !important;
border: 1px solid #3e63dd !important;
background: #fff !important;
height: 6px !important;
/* left: 3px !important; */
}
.grid-guide-lines {
background: #8DA4EF !important;
background: #8da4ef !important;
}
.moveable-control-box:not([data-able-groupable]) .moveable-control-box:not(:hover) {
opacity: 0;
.moveable-control-box:not([data-able-groupable])
.moveable-control-box:not(:hover) {
opacity: 0;
}
.dragged-movable-control-box, [data-hovered-control="true"] {
opacity: 1 !important;
.dragged-movable-control-box,
[data-hovered-control="true"] {
opacity: 1 !important;
}
.moveable-line.moveable-e,
.moveable-line.moveable-w {
border: 5px solid #fff0;
border: 5px solid #fff0;
}
.moveable-line.moveable-n {
border-bottom: 5px solid #fff0;
border-bottom: 5px solid #fff0;
}
.moveable-line.moveable-s {
border-bottom: 5px solid #fff0;
border-bottom: 5px solid #fff0;
}
.moveable-control[data-rotation="0"], .moveable-control[data-rotation="90"],
.moveable-around-control[data-rotation="0"], .moveable-around-control[data-rotation="90"] {
opacity: 0;
width: 0px !important;
height: 0px !important;
.moveable-control[data-rotation="0"],
.moveable-control[data-rotation="90"],
.moveable-around-control[data-rotation="0"],
.moveable-around-control[data-rotation="90"] {
opacity: 0;
width: 0px !important;
height: 0px !important;
}
.moveable-control {
width: 8px !important;
height: 8px !important;
border: 1px solid var(--moveable-color) !important;
background: #fff !important;
margin-top: -4px !important;
margin-left: -4px !important;
width: 8px !important;
height: 8px !important;
border: 1px solid var(--moveable-color) !important;
background: #fff !important;
margin-top: -4px !important;
margin-left: -4px !important;
}
.moveable-around-control{
height: 10px !important;
width: 10px !important;
.moveable-around-control {
height: 10px !important;
width: 10px !important;
}
.moveable-around-control[data-direction*="nw"] {
left: -11px;
top: -11px;
left: -11px;
top: -11px;
}
.moveable-around-control[data-direction*="ne"] {
top: -11px;
top: -11px;
}
.moveable-around-control[data-direction*="ne"] {
top: -11px;
top: -11px;
}
.moveable-around-control[data-direction*="sw"] {
left: -11px;
top: -1px;
left: -11px;
top: -1px;
}
.moveable-draggable-dragging {
opacity: 1 !important;
opacity: 1 !important;
}
[data-off-screen="true"] {
display: none;
display: none;
}
.moveable-guideline {
background: #97AEFC !important;
opacity: 0.8;
z-index: 9999;
background: #97aefc !important;
opacity: 0.8;
z-index: 9999;
}
.moveable-guideline.moveable-horizontal {
height: 1px !important;
width: 100% !important;
background: #97AEFC !important;
left: 0 !important;
height: 1px !important;
width: 100% !important;
background: #97aefc !important;
left: 0 !important;
}
.moveable-guideline.moveable-vertical {
width: 1px !important;
height: 100% !important;
background: #97AEFC !important;
top: 0 !important;
width: 1px !important;
height: 100% !important;
background: #97aefc !important;
top: 0 !important;
}
.moveable-guideline-group {
z-index: 9999;
z-index: 9999;
}
.dragging-component-canvas {
outline: 1px solid var(--border-accent-strong) !important;
outline-offset: 0px; /* Creates space between element and outline */
z-index: 999 !important;
outline: 1px solid var(--border-accent-strong) !important;
outline-offset: 0px;
/* Creates space between element and outline */
z-index: 999 !important;
}
.non-dragging-component {
outline: 1px dotted var(--border-accent-weak) !important;
outline-offset: 0px; /* Creates space between element and outline */
z-index: 999 !important;
.non-dragging-component {
outline: 1px dotted var(--border-accent-weak) !important;
outline-offset: 0px;
/* Creates space between element and outline */
z-index: 999 !important;
}

View file

@ -24,10 +24,13 @@ import {
handleActivateNonDraggingComponents,
computeScrollDelta,
computeScrollDeltaOnDrag,
getDraggingWidgetWidth,
positionDragGhostWidget,
} from './gridUtils';
import { dragContextBuilder, getAdjustedDropPosition } from './helpers/dragEnd';
import useStore from '@/AppBuilder/_stores/store';
import './Grid.css';
import { useGroupedTargetsScrollHandler } from './hooks/useGroupedTargetsScrollHandler';
import { DROPPABLE_PARENTS, NO_OF_GRIDS, SUBCONTAINER_WIDGETS } from '../appCanvasConstants';
import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext';
const CANVAS_BOUNDS = { left: 0, top: 0, right: 0, position: 'css' };
@ -35,6 +38,12 @@ const RESIZABLE_CONFIG = {
edge: ['nw', 'n', 'ne', 'w', 'e', 'sw', 's', 'se'],
renderDirections: ['nw', 'n', 'ne', 'w', 'e', 'sw', 's', 'se'],
};
const HORIZONTAL_CONFIG = {
edge: ['e', 'w'],
renderDirections: ['w', 'e'],
};
export const GRID_HEIGHT = 10;
export default function Grid({ gridWidth, currentLayout }) {
@ -49,8 +58,9 @@ export default function Grid({ gridWidth, currentLayout }) {
const setSelectedComponents = useStore((state) => state.setSelectedComponents, shallow);
const getComponentTypeFromId = useStore((state) => state.getComponentTypeFromId, shallow);
const getResolvedValue = useStore((state) => state.getResolvedValue, shallow);
const temporaryHeight = useStore((state) => state.temporaryLayouts?.[selectedComponents?.[0]]?.height, shallow);
const isGroupHandleHoverd = useIsGroupHandleHoverd();
const checkHoveredComponentDynamicHeight = useStore((state) => state.checkHoveredComponentDynamicHeight, shallow);
const openModalWidgetId = useOpenModalWidgetId();
const moveableRef = useRef(null);
const triggerCanvasUpdater = useStore((state) => state.triggerCanvasUpdater, shallow);
@ -60,9 +70,10 @@ export default function Grid({ gridWidth, currentLayout }) {
const canvasWidth = NO_OF_GRIDS * gridWidth;
const getHoveredComponentForGrid = useStore((state) => state.getHoveredComponentForGrid, shallow);
const getResolvedComponent = useStore((state) => state.getResolvedComponent, shallow);
const getTemporaryLayouts = useStore((state) => state.getTemporaryLayouts, shallow);
const updateContainerAutoHeight = useStore((state) => state.updateContainerAutoHeight, shallow);
const [canvasBounds, setCanvasBounds] = useState(CANVAS_BOUNDS);
const draggingComponentId = useStore((state) => state.draggingComponentId, shallow);
const draggingComponentId = useGridStore((state) => state.draggingComponentId, shallow);
const resizingComponentId = useGridStore((state) => state.resizingComponentId, shallow);
const [dragParentId, setDragParentId] = useState(null);
const [elementGuidelines, setElementGuidelines] = useState([]);
@ -73,6 +84,8 @@ export default function Grid({ gridWidth, currentLayout }) {
const checkIfAnyWidgetVisibilityChanged = useStore((state) => state.checkIfAnyWidgetVisibilityChanged(), shallow);
const getExposedValueOfComponent = useStore((state) => state.getExposedValueOfComponent, shallow);
const setReorderContainerChildren = useStore((state) => state.setReorderContainerChildren, shallow);
const [isVerticalExpansionRestricted, setIsVerticalExpansionRestricted] = useState(false);
const toggleRightSidebar = useStore((state) => state.toggleRightSidebar, shallow);
useEffect(() => {
const selectedSet = new Set(selectedComponents);
@ -121,6 +134,7 @@ export default function Grid({ gridWidth, currentLayout }) {
top: widget?.layouts?.[currentLayout]?.top,
width: widget?.layouts?.[currentLayout]?.width,
parent: widget?.component?.parent,
componentType: widget?.component?.component,
component: widget?.component,
};
})
@ -143,24 +157,26 @@ export default function Grid({ gridWidth, currentLayout }) {
const handleResizeStop = useCallback(
(boxList) => {
const transformedBoxes = boxList.reduce((acc, box) => {
acc[box.id] = box;
return acc;
}, {});
const temporaryLayouts = getTemporaryLayouts();
boxList.forEach(({ id, height, width, x, y, gw }) => {
const _canvasWidth = gw ? gw * NO_OF_GRIDS : canvasWidth;
let newWidth = Math.round((width * NO_OF_GRIDS) / _canvasWidth);
y = Math.round(y / GRID_HEIGHT) * GRID_HEIGHT;
// Consider temporary layout position if it exists
const temporaryLayout = temporaryLayouts[id];
y = temporaryLayout?.top ?? Math.round(y / GRID_HEIGHT) * GRID_HEIGHT;
gw = gw ? gw : gridWidth;
const parent = transformedBoxes[id]?.component?.parent;
const parent = boxList.find((box) => box.id === id)?.component?.parent;
if (y < 0) {
y = 0;
}
if (parent) {
const parentElem = document.getElementById(`canvas-${parent}`);
const parentId = parent.includes('-') ? parent?.split('-').slice(0, -1).join('-') : parent;
const componentType = transformedBoxes.find((box) => box.id === parentId)?.component.component;
const componentType = boxList.find((box) => box.id === parentId)?.component.component;
var parentHeight = parentElem?.clientHeight || height;
if (height > parentHeight && ['Tabs', 'Listview'].includes(componentType)) {
height = parentHeight;
@ -253,10 +269,16 @@ export default function Grid({ gridWidth, currentLayout }) {
}
e.props.target.classList.add('hovered');
e.controlBox.classList.add('moveable-control-box-d-block');
const isHorizontallyExpandable = checkHoveredComponentDynamicHeight();
if (isHorizontallyExpandable) {
e.controlBox.classList.add('moveable-horizontal-only');
}
setIsVerticalExpansionRestricted(!!isHorizontallyExpandable);
},
mouseLeave(e) {
e.props.target.classList.remove('hovered');
e.controlBox.classList.remove('moveable-control-box-d-block');
e.controlBox.classList.remove('moveable-horizonta-only');
},
};
@ -321,6 +343,11 @@ export default function Grid({ gridWidth, currentLayout }) {
const groupedTargets = [...findHighestLevelofSelection().map((component) => '.ele-' + component.id)];
useEffect(() => {
if (moveableRef.current) {
moveableRef.current.updateTarget();
}
}, [temporaryHeight]);
useEffect(() => {
reloadGrid();
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -580,6 +607,8 @@ export default function Grid({ gridWidth, currentLayout }) {
}
}, [draggingComponentId, resizingComponentId, isGroupDragging, selectedComponents]);
useGroupedTargetsScrollHandler(groupedTargets, boxList, moveableRef);
if (mode !== 'edit') return null;
return (
@ -598,23 +627,34 @@ export default function Grid({ gridWidth, currentLayout }) {
origin={false}
individualGroupable={groupedTargets.length <= 1}
draggable={!shouldFreeze && mode !== 'view'}
resizable={!shouldFreeze ? RESIZABLE_CONFIG : false && mode !== 'view'}
resizable={
!shouldFreeze
? isVerticalExpansionRestricted
? HORIZONTAL_CONFIG
: RESIZABLE_CONFIG
: false && mode !== 'view'
}
keepRatio={false}
individualGroupableProps={individualGroupableProps}
onResize={(e) => {
const temporaryLayouts = getTemporaryLayouts();
if (resizingComponentId !== e.target.id) {
useGridStore.getState().actions.setResizingComponentId(e.target.id);
showGridLines();
}
const currentWidget = boxList.find(({ id }) => id === e.target.id);
let _gridWidth = useGridStore.getState().subContainerWidths[currentWidget.component?.parent] || gridWidth;
// Show grid during resize
if (currentWidget.component?.parent) {
document.getElementById('canvas-' + currentWidget.component?.parent)?.classList.add('show-grid');
setDragParentId(currentWidget.component?.parent);
} else {
document.getElementById('real-canvas').classList.add('show-grid');
}
handleActivateTargets(currentWidget.component?.parent);
const currentWidth = currentWidget.width * _gridWidth;
const diffWidth = e.width - currentWidth;
@ -622,20 +662,30 @@ export default function Grid({ gridWidth, currentLayout }) {
const isLeftChanged = e.direction[0] === -1;
const isTopChanged = e.direction[1] === -1;
// Calculate positions considering temporary layouts'
let transformX = currentWidget.left * _gridWidth;
let transformY = currentWidget.top;
let transformY = temporaryLayouts[currentWidget.id]?.top ?? currentWidget.top;
if (isLeftChanged) {
transformX = currentWidget.left * _gridWidth - diffWidth;
// Left resize
transformX = transformX - diffWidth;
}
if (isTopChanged) {
transformY = currentWidget.top - diffHeight;
// Top resize
transformY = transformY - diffHeight;
}
// Apply container bounds
const elemContainer = e.target.closest('.real-canvas');
const containerHeight = elemContainer.clientHeight;
const containerWidth = elemContainer.clientWidth;
const maxY = containerHeight - e.target.clientHeight;
const maxLeft = containerWidth - e.target.clientWidth;
transformY = Math.max(0, Math.min(transformY, maxY));
transformX = Math.max(0, Math.min(transformX, maxLeft));
// Update element style
const maxWidthHit = transformX < 0 || transformX >= maxLeft;
const maxHeightHit = transformY < 0 || transformY >= maxY;
if (!maxWidthHit || e.width < e.target.clientWidth) {
@ -645,14 +695,8 @@ export default function Grid({ gridWidth, currentLayout }) {
e.target.style.height = `${e.height}px`;
}
e.target.style.transform = `translate(${transformX}px, ${transformY}px)`;
// Postion ghost element exactly with respect to resizing element
if (document.getElementById('resize-ghost-widget')) {
document.getElementById(
'resize-ghost-widget'
).style.transform = `translate(${transformX}px, ${transformY}px)`;
document.getElementById('resize-ghost-widget').style.width = `${e.target.clientWidth}px`;
document.getElementById('resize-ghost-widget').style.height = `${e.target.clientHeight}px`;
}
if (e.width > 0) e.target.style.width = `${e.width}px`;
if (e.height > 0) e.target.style.height = `${e.height}px`;
}}
onResizeStart={(e) => {
if (
@ -827,6 +871,7 @@ export default function Grid({ gridWidth, currentLayout }) {
if (getHoveredComponentForGrid() !== e.target.id) {
return false;
}
toggleRightSidebar();
newDragParentId.current = boxList.find((box) => box.id === e.target.id)?.parent;
e?.moveable?.controlBox?.removeAttribute('data-off-screen');
@ -949,6 +994,9 @@ export default function Grid({ gridWidth, currentLayout }) {
let left = Math.round(e.translate[0] / _gridWidth) * _gridWidth;
let top = Math.round(e.translate[1] / GRID_HEIGHT) * GRID_HEIGHT;
const draggingWidgetWidth = getDraggingWidgetWidth(_dragParentId, e.target.clientWidth);
e.target.style.width = `${draggingWidgetWidth}px`;
// This logic is to handle the case when the dragged element is over a new canvas
if (_dragParentId !== currentParentId) {
left = e.translate[0];
@ -1014,6 +1062,7 @@ export default function Grid({ gridWidth, currentLayout }) {
} else if (parentComponent?.component?.component === 'Modal') {
// Never update parentId for Modal
newParentId = parentComponent?.id;
e.target.style.width = `${e.target.clientWidth}px`;
}
if (newParentId !== prevDragParentId.current) {
@ -1034,12 +1083,7 @@ export default function Grid({ gridWidth, currentLayout }) {
`translate: ${e.translate[0]} | Round: ${Math.round(e.translate[0] / gridWidth) * gridWidth} | ${gridWidth}`
);
// Postion ghost element exactly as same at dragged element
if (document.getElementById(`moveable-drag-ghost`)) {
document.getElementById(`moveable-drag-ghost`).style.transform = `translate(${left}px, ${top}px)`;
document.getElementById(`moveable-drag-ghost`).style.width = `${e.target.clientWidth}px`;
document.getElementById(`moveable-drag-ghost`).style.height = `${e.target.clientHeight}px`;
}
positionDragGhostWidget(e.target);
}}
onDragGroup={(ev) => {
const { events } = ev;

View file

@ -2,6 +2,8 @@ import { useGridStore } from '@/_stores/gridStore';
import { isEmpty } from 'lodash';
import useStore from '@/AppBuilder/_stores/store';
import { getTabId, getSubContainerIdWithSlots } from '../appCanvasUtils';
import { NO_OF_GRIDS } from '../appCanvasConstants';
export function correctBounds(layout, bounds) {
layout = scaleLayouts(layout);
const collidesWith = [];
@ -517,3 +519,35 @@ export const computeScrollDelta = ({ source }) => {
};
export const computeScrollDeltaOnDrag = computeScrollDelta;
export const getDraggingWidgetWidth = (canvasParentId, widgetWidth) => {
const targetCanvasWidth =
document.getElementById(`canvas-${canvasParentId}`)?.offsetWidth ||
document.getElementById('real-canvas')?.offsetWidth;
const gridUnitWidth = targetCanvasWidth / NO_OF_GRIDS;
const gridUnits = Math.round(widgetWidth / gridUnitWidth);
const draggingWidgetWidth = gridUnits * gridUnitWidth;
return draggingWidgetWidth;
};
export const positionDragGhostWidget = (draggedElement) => {
const ghostElement = document.getElementById('moveable-drag-ghost');
if (!ghostElement || !draggedElement) return;
const mainCanvas = document.getElementById('real-canvas');
if (!mainCanvas) return;
const mainCanvasRect = mainCanvas.getBoundingClientRect();
const draggedRect = draggedElement.getBoundingClientRect();
// Calculate position relative to main canvas
const relativeLeft = draggedRect.left - mainCanvasRect.left;
const relativeTop = draggedRect.top - mainCanvasRect.top;
// Apply the position
ghostElement.style.left = `${relativeLeft}px`;
ghostElement.style.top = `${relativeTop}px`;
ghostElement.style.width = `${draggedRect.width}px`;
ghostElement.style.height = `${draggedRect.height}px`;
};

View file

@ -0,0 +1,49 @@
import { useEffect, useMemo, useCallback, useRef } from 'react';
export const useGroupedTargetsScrollHandler = (groupedTargets, boxList, moveableRef) => {
const scrollRAF = useRef(null); // // Stores the requestAnimationFrame ID
const parentCanvasId = useMemo(() => {
if (!groupedTargets?.[0] || groupedTargets.length === 0) return null;
const targetId = groupedTargets[0].replace('.ele-', '');
const targetBox = boxList.find((box) => box.id === targetId);
return targetBox?.parent || null;
}, [groupedTargets, boxList]);
const containerId = useMemo(() => {
return parentCanvasId ? `canvas-${parentCanvasId}` : null;
}, [parentCanvasId]);
const scrollHandler = useCallback(() => {
if (!scrollRAF.current) {
scrollRAF.current = requestAnimationFrame(() => {
if (groupedTargets.length > 1 && moveableRef.current) {
moveableRef.current.updateRect();
}
scrollRAF.current = null;
});
}
}, [groupedTargets.length, moveableRef]);
useEffect(() => {
// Early return if no container ID or not enough grouped targets
if (!containerId || groupedTargets.length <= 1) {
return;
}
const canvasContainer = document.getElementById(containerId);
if (!canvasContainer) {
return;
}
canvasContainer.addEventListener('scroll', scrollHandler, { passive: true });
return () => {
canvasContainer.removeEventListener('scroll', scrollHandler);
if (scrollRAF.current) {
cancelAnimationFrame(scrollRAF.current);
}
};
}, [containerId, groupedTargets.length, scrollHandler]);
};

View file

@ -35,6 +35,7 @@ const SHOULD_ADD_BOX_SHADOW_AND_VISIBILITY = [
'VerticalDivider',
'Link',
'Form',
'FilePicker',
];
const RenderWidget = ({
@ -51,6 +52,8 @@ const RenderWidget = ({
const { moduleId } = useModuleContext();
const componentDefinition = useStore((state) => state.getComponentDefinition(id, moduleId), shallow);
const getDefaultStyles = useStore((state) => state.debugger.getDefaultStyles, shallow);
const adjustComponentPositions = useStore((state) => state.adjustComponentPositions, shallow);
const componentCount = useStore((state) => state.getContainerChildrenMapping(id)?.length || 0, shallow);
const component = componentDefinition?.component;
const componentName = component?.name;
const [key, setKey] = useState(Math.random());
@ -152,6 +155,9 @@ const RenderWidget = ({
}, []);
if (!componentDefinition?.component) return null;
const disabledState = resolvedProperties?.disabledState;
const loadingState = resolvedProperties?.loadingState;
return (
<ErrorBoundary>
<OverlayTrigger
@ -185,7 +191,9 @@ const RenderWidget = ({
padding: resolvedStyles?.padding == 'none' ? '0px' : `${BOX_PADDING}px`, //chart and image has a padding property other than container padding
}}
role={'Box'}
className={inCanvas ? `_tooljet-${component?.component} _tooljet-${component?.name}` : ''} //required for custom CSS
className={`canvas-component ${
inCanvas ? `_tooljet-${component?.component} _tooljet-${component?.name}` : ''
} ${disabledState || loadingState ? 'disabled' : ''}`} //required for custom CSS
>
<ComponentToRender
id={id}
@ -202,6 +210,8 @@ const RenderWidget = ({
onComponentClick={onComponentClick}
darkMode={darkMode}
componentName={componentName}
adjustComponentPositions={adjustComponentPositions}
componentCount={componentCount}
dataCy={`draggable-widget-${componentName}`}
/>
</div>

View file

@ -32,6 +32,7 @@ const WidgetWrapper = memo(
(state) => state.getComponentDefinition(id, moduleId)?.layouts?.[currentLayout],
shallow
);
const temporaryLayouts = useStore((state) => state.temporaryLayouts?.[id], shallow);
const isWidgetActive = useStore((state) => state.selectedComponents.find((sc) => sc === id) && !readOnly, shallow);
const isDragging = useStore((state) => state.draggingComponentId === id);
const isResizing = useGridStore((state) => state.resizingComponentId === id);
@ -106,8 +107,8 @@ const WidgetWrapper = memo(
{mode == 'edit' && (
<ConfigHandle
id={id}
widgetTop={newLayoutData.top}
widgetHeight={newLayoutData.height}
widgetTop={temporaryLayouts?.top ?? layoutData.top}
widgetHeight={temporaryLayouts?.height ?? layoutData.height}
showHandle={isWidgetActive}
componentType={componentType}
visibility={visibility}
@ -128,7 +129,6 @@ const WidgetWrapper = memo(
onOptionsChange={onOptionsChange}
/>
</div>
<DragGhostWidget isDragging={isDragging} />
<ResizeGhostWidget isResizing={isResizing} />
</>
);

View file

@ -2,6 +2,12 @@
&:focus-visible{
outline: none;
}
&.page-container {
&.position-top {
flex-direction: column;
}
}
}
.modal-backdrop {

View file

@ -16,6 +16,8 @@ export const APP_HEADER_HEIGHT = 47;
export const LEFT_SIDEBAR_WIDTH = 348; // exclusive of border
export const RIGHT_SIDEBAR_WIDTH = 299;
export const SUBCONTAINER_WIDGETS = ['Container', 'Tabs', 'Listview', 'Kanban', 'Form'];
export const CONTAINER_FORM_CANVAS_PADDING = 7;
@ -39,3 +41,5 @@ export const DROPPABLE_PARENTS = new Set([
export const TAB_CANVAS_PADDING = 7.5;
export const MODAL_CANVAS_PADDING = 5;
export const LISTVIEW_CANVAS_PADDING = 7;

View file

@ -15,6 +15,7 @@ import {
BOX_PADDING,
TAB_CANVAS_PADDING,
MODAL_CANVAS_PADDING,
LISTVIEW_CANVAS_PADDING,
} from './appCanvasConstants';
export function snapToGrid(canvasWidth, x, y) {
@ -65,10 +66,10 @@ export const addNewWidgetToTheEditor = (
componentData.definition.properties.moduleVersionId = { value: moduleInfo.versionId };
componentData.definition.properties.moduleEnvironmentId = { value: moduleInfo.environmentId };
componentData.definition.properties.visibility = { value: true };
customLayouts = moduleInfo.moduleContainer.layouts;
customLayouts = moduleInfo?.moduleContainer?.layouts;
const inputItems = Object.values(
moduleInfo.moduleContainer.component.definition.properties?.input_items?.value ?? {}
moduleInfo.moduleContainer?.component.definition.properties?.input_items?.value ?? {}
);
for (const { name, default_value } of inputItems) {
@ -776,7 +777,7 @@ export const getSubContainerIdWithSlots = (parentId) => {
return cleanParentId;
};
export const getSubContainerWidthAfterPadding = (canvasWidth, componentType, componentId) => {
export const getSubContainerWidthAfterPadding = (canvasWidth, componentType, componentId, realCanvasRef) => {
let padding = 2; //Need to update this 2 to correct value for other subcontainers
if (componentType === 'Container' || componentType === 'Form') {
padding = 2 * CONTAINER_FORM_CANVAS_PADDING + 2 * SUBCONTAINER_CANVAS_BORDER_WIDTH + 2 * BOX_PADDING;
@ -789,11 +790,19 @@ export const getSubContainerWidthAfterPadding = (canvasWidth, componentType, com
if (isModalHeader) {
const isModalHeaderCloseBtnEnabled = !useStore.getState().getResolvedComponent(componentId)?.properties
?.hideCloseButton;
console.log('isModalHeaderCloseBtnEnabled', isModalHeaderCloseBtnEnabled);
padding = 2 * (MODAL_CANVAS_PADDING + (isModalHeaderCloseBtnEnabled ? 56 : 0));
} else {
padding = 2 * MODAL_CANVAS_PADDING;
}
}
if (componentType === 'Listview') {
padding = 2 * LISTVIEW_CANVAS_PADDING + 5; // 5 is accounting for scrollbar
}
return canvasWidth - padding;
};
export const addDefaultButtonIdToForm = (formComponent, defaultChildComponents) => {
const { id } = defaultChildComponents[defaultChildComponents.length - 1]; // Assuming the last child is the button
formComponent.component.definition.properties.buttonToSubmit = { value: id };
return formComponent;
};

View file

@ -7,12 +7,15 @@ import debounce from 'lodash/debounce';
const useAppCanvasMaxWidth = ({ mode }) => {
const canvasMaxWidth = useStore((state) => state.globalSettings.canvasMaxWidth, shallow);
const canvasMaxWidthType = useStore((state) => state.globalSettings.canvasMaxWidthType, shallow);
const isRightSidebarOpen = useStore((state) => state.isRightSidebarOpen, shallow);
const isRightSidebarPinned = useStore((state) => state.isRightSidebarPinned, shallow);
let [maxWidth, setMaxWidth] = useState(0);
const getEditorCanvasWidth = useCallback(() => {
let _maxWidth;
const windowWidth = window.innerWidth;
const widthInPx = windowWidth - (CANVAS_WIDTHS.leftSideBarWidth + CANVAS_WIDTHS.rightSideBarWidth);
const widthInPx = windowWidth - CANVAS_WIDTHS.leftSideBarWidth;
if (canvasMaxWidthType === 'px') {
_maxWidth = +canvasMaxWidth;
}
@ -51,7 +54,7 @@ const useAppCanvasMaxWidth = ({ mode }) => {
debouncedGetCanvasWidth.cancel(); // Cancel any pending debounced calls
}
};
}, [debouncedGetCanvasWidth, getEditorCanvasWidth, getViewerWidth, mode]);
}, [debouncedGetCanvasWidth, getEditorCanvasWidth, getViewerWidth, mode, isRightSidebarOpen, isRightSidebarPinned]);
return maxWidth;
};

View file

@ -0,0 +1,28 @@
import { useState, useEffect } from 'react';
import { isEmpty } from 'lodash';
import useStore from '@/AppBuilder/_stores/store';
import { shallow } from 'zustand/shallow';
import { RIGHT_SIDEBAR_WIDTH } from './appCanvasConstants';
import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext';
const useRightSidebarMargin = (canvasContainerRef) => {
const { moduleId } = useModuleContext();
const [editorMarginRight, setEditorMarginRight] = useState(0);
const isRightSidebarOpen = useStore((state) => state.isRightSidebarOpen, shallow);
const mode = useStore((state) => state.modeStore.modules[moduleId].currentMode, shallow);
useEffect(() => {
if (mode !== 'view') setEditorMarginRight(isRightSidebarOpen ? RIGHT_SIDEBAR_WIDTH : 0);
else setEditorMarginRight(0);
}, [isRightSidebarOpen, mode]);
useEffect(() => {
if (!isEmpty(canvasContainerRef?.current)) {
canvasContainerRef.current.scrollRight += editorMarginRight;
}
}, [editorMarginRight, canvasContainerRef]);
return editorMarginRight;
};
export default useRightSidebarMargin;

View file

@ -0,0 +1,179 @@
import React, { useMemo, useState, useRef, useEffect } from 'react';
import useStore from '@/AppBuilder/_stores/store';
import { shallow } from 'zustand/shallow';
import DataSourceIcon from '@/AppBuilder/QueryManager/Components/DataSourceIcon';
import SolidIcon from '@/_ui/Icon/SolidIcons';
import { LabeledDivider } from '@/AppBuilder/RightSideBar/Inspector/Components/Form/_components';
import cx from 'classnames';
import './styles.scss';
export const DropdownMenu = (props) => {
const { value, onChange, forceCodeBox } = props;
const dataQueries = useStore((state) => state.dataQuery.queries.modules.canvas, shallow);
// Simple emoji/text icons instead of lucide icons
const sourceOptions = useMemo(
() => [
{ id: 'rawJson', label: 'Raw JSON', icon: <SolidIcon name="curlybraces" /> },
{ id: 'jsonSchema', label: 'JSON schema', icon: <SolidIcon name="curlybraces" /> },
// { id: 'json-schema', label: 'JSON schema' },
],
[]
);
const queryOptions = useMemo(() => {
return dataQueries.map((query) => ({
id: query.id,
value: `{{queries.${query.id}.data}}`,
label: query.name,
icon: <DataSourceIcon source={query} height={16} />,
type: 'query',
}));
}, [dataQueries]);
const getSelectedSource = (value) => {
if (!value) return null;
const selectedItem = sourceOptions.find((option) => option.id === value);
if (selectedItem) {
return selectedItem;
}
if (!value.startsWith('{{queries.')) {
return null;
}
const queryName = value.split('.')[1]?.replace('}}', '');
const selectedQuery = queryOptions.find((option) => option.label === queryName);
if (selectedQuery) {
return selectedQuery;
}
return null;
};
const [isOpen, setIsOpen] = useState(false);
const [selectedSource, setSelectedSource] = useState(() => getSelectedSource(value));
const dropdownRef = useRef(null);
// Handle outside clicks
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const toggleDropdown = () => {
setIsOpen(!isOpen);
};
const selectSource = (source) => {
setSelectedSource(source);
setIsOpen(false);
if (source.id === 'rawJson' || source.id === 'jsonSchema') {
onChange(source.id);
} else if (source.type === 'query') {
onChange(source.value);
forceCodeBox();
}
};
const renderCheckIcon = ({ id }) => {
if (value === id) {
return <SolidIcon name="check" width="16" height="16" fill="#4368E3" viewBox="0 0 16 16" />;
} else {
return <div style={{ width: '16px', height: '16px' }}></div>;
}
};
return (
<div className="tw-w-full tw-max-w-md dropdown-menu-inspector" ref={dropdownRef}>
<div className="tw-relative">
{/* Dropdown trigger div */}
<button
onClick={toggleDropdown}
className={cx(
'tw-flex tw-items-center tw-justify-between tw-w-full tw-px-4 tw-py-2 tw-text-left tw-bg-white dropdown-menu-trigger',
{
'is-open': isOpen,
}
)}
>
<div className="tw-flex tw-items-center">
{selectedSource ? (
<>
<span className="tw-mr-2">{selectedSource.icon}</span>
<span>{selectedSource.label}</span>
</>
) : (
<>
<span className="tw-mr-2 tw-text-gray-400">
<SolidIcon name="code" width="16" height="16" fill="#CCD1D5" />
</span>
<span className="tw-text-gray-400 dropdown-menu-placeholder">Select a source</span>
</>
)}
</div>
<span className="tw-ml-2">
{isOpen ? (
<SolidIcon name="TriangleDownCenter" width={16} />
) : (
<SolidIcon name="TriangleUpCenter" width={16} />
)}
</span>
</button>
{/* Dropdown menu */}
{isOpen && (
<div className="tw-absolute tw-z-10 tw-w-full tw-mt-1 tw-bg-white tw-border tw-border-gray-300 tw-rounded-md tw-shadow-lg tw-p-2">
{/* Source options section */}
<div className="tw-py-1 dropdown-menu-items">
{sourceOptions.map((option) => (
<div
key={option.id}
onClick={() => selectSource(option)}
className="tw-flex tw-items-center tw-w-full tw-px-4 tw-py-2 tw-text-left tw-hover:bg-gray-100"
>
{renderCheckIcon(option)}
<span className="icon-image">{option.icon}</span>
<span>{option.label}</span>
</div>
))}
</div>
{dataQueries.length > 0 && (
<>
{/* Divider with "From query" text */}
<LabeledDivider label="From query" />
{/* Query options section */}
<div className="tw-py-1 dropdown-menu-items">
{queryOptions.map((option) => (
<div
key={option.id}
onClick={() => selectSource(option)}
className="tw-flex tw-items-center tw-w-full tw-px-4 tw-py-2 tw-text-left tw-hover:bg-gray-100"
>
{renderCheckIcon(option)}
<span className="icon-image">{option.icon}</span>
<span>{option.label}</span>
</div>
))}
</div>
</>
)}
</div>
)}
</div>
</div>
);
};

View file

@ -0,0 +1 @@
export { DropdownMenu as default } from './DropdownMenu';

View file

@ -0,0 +1,52 @@
.dropdown-menu-inspector {
margin-top: 2px;
font-size: 12px;
.dropdown-menu-trigger {
height: 34px;
padding: 7px 12px;
align-items: center;
flex-shrink: 0;
align-self: stretch;
border-radius: 6px;
border: 1px solid var(--border-default, #CCD1D5);
&.is-open {
border: 2px solid var(--interactive-focus-outline, #4368E3);
}
}
.dropdown-menu-placeholder {
color: var(--text-placeholder, #6A727C);
}
.dropdown-menu-items {
color: var(--text-default, #1B1F24);
>div {
border-radius: 6px;
&:hover {
cursor: pointer;
background-color: var(--slate4);
}
}
}
.icon-image {
margin: 0px 6px;
width: 16px;
height: 16px;
}
.custom-line {
border-color: var(--border-default, #CCD1D5);
border-top: 0px
}
.separator-text {
color: var(--text-placeholder, #6A727C);
background-color: white;
padding: 0 6px;
}
}

View file

@ -7,7 +7,14 @@ import * as Icons from '@tabler/icons-react';
import { VirtuosoGrid } from 'react-virtuoso';
import { Visibility } from './Visibility';
export const Icon = ({ value, onChange, onVisibilityChange, styleDefinition, component }) => {
export const Icon = ({
value,
onChange,
onVisibilityChange,
styleDefinition,
component,
isVisibilityEnabled = true,
}) => {
const [searchText, setSearchText] = useState('');
const [showPopOver, setPopOverVisibility] = useState(false);
const iconList = useRef(Object.keys(Icons));
@ -111,13 +118,15 @@ export const Icon = ({ value, onChange, onVisibilityChange, styleDefinition, com
>
{String(value)}
</div>
<Visibility
value={value}
onChange={onChange}
onVisibilityChange={onVisibilityChange}
component={component}
styleDefinition={styleDefinition}
/>
{isVisibilityEnabled && (
<Visibility
value={value}
onChange={onChange}
onVisibilityChange={onVisibilityChange}
component={component}
styleDefinition={styleDefinition}
/>
)}
</div>
</OverlayTrigger>
</div>

View file

@ -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);

View file

@ -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 (
<>
<div className="field tj-app-input" style={{ padding: '0.225rem 0.35rem' }}>
<input
className={'inspector-field-number'}
key={`${String(cyLabel)}-input`}
type="number"
onChange={(e) => {
setNumber(e.target.value);

View file

@ -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',
};

View file

@ -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}
/>
);

View file

@ -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,
};

View file

@ -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 (
<div
className={`code-hinter-wrapper position-relative ${isInsideQueryPane ? 'code-editor-query-panel' : ''}`}
@ -349,8 +403,16 @@ const MultiLineCodeEditor = (props) => {
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);
}}
/>
</div>
{showPreview && (

View file

@ -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;

View file

@ -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 = ({
<CodeMirror
onCreateEditor={(view) => {
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 = ({
}}
/>
</div>
</ErrorBoundary >
</CodeHinter.Portal >
</div >
</ErrorBoundary>
</CodeHinter.Portal>
</div>
);
};
@ -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 (
<div
className={`col-auto pt-0 fx-common fx-button-container ${(isEventManagerParam || codeShow) && 'show-fx-button-container'
}`}
className={`col-auto pt-0 fx-common fx-button-container ${
(isEventManagerParam || codeShow) && 'show-fx-button-container'
}`}
>
<FxButton
active={codeShow}
@ -539,6 +575,9 @@ const DynamicEditorBridge = (props) => {
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 (
<div className={cx({ 'codeShow-active': codeShow }, 'wrapper-div-code-editor')}>
<div className={cx('d-flex align-items-center justify-content-between code-flex-wrapper')}>
const renderedLabel = () => {
return (
<>
{paramLabel !== ' ' && !HIDDEN_CODE_HINTER_LABELS.includes(paramLabel) && (
<div className={`field ${className}`} data-cy={`${cyLabel}-widget-parameter-label`}>
<ToolTip
label={t(`widget.commonProperties.${camelCase(paramLabel)}`, paramLabel)}
meta={fieldMeta}
labelClass={`tj-text-xsm color-slate12 ${codeShow ? 'mb-2' : 'mb-0'} ${darkMode && 'color-whitish-darkmode'
}`}
labelClass={`tj-text-xsm color-slate12 ${codeShow ? 'mb-2' : 'mb-0'} ${
darkMode && 'color-whitish-darkmode'
}`}
/>
{isDeprecated && (
<span className={'list-item-deprecated-column-type'}>
<Icon name={'warning'} height={14} width={14} fill="#DB4324" />
</span>
)}
</div>
)}
</>
);
};
const renderDynamicFx = () => {
if (codeShow) return null;
return (
<DynamicFxTypeRenderer
value={!error ? value : ''}
onChange={onChange}
paramName={paramName}
paramLabel={paramLabel}
paramType={paramType}
forceCodeBox={() => {
setForceCodeBox(true);
onFxPress(true);
}}
meta={fieldMeta}
cyLabel={cyLabel}
styleDefinition={styleDefinition}
component={component}
onVisibilityChange={onVisibilityChange}
/>
);
};
return (
<div className={cx({ 'codeShow-active': codeShow }, 'wrapper-div-code-editor')}>
<div className={cx('d-flex align-items-center justify-content-between code-flex-wrapper')}>
{renderedLabel()}
<div className={`${(paramType ?? 'code') === 'code' ? 'd-none' : ''} flex-grow-1`}>
<div style={{ marginBottom: codeShow ? '0.5rem' : '0px' }} className={`d-flex align-items-center ${fxClass}`}>
{renderFx()}
</div>
</div>
{!codeShow && (
<DynamicFxTypeRenderer
value={!error ? value : ''}
onChange={onChange}
paramName={paramName}
paramLabel={paramLabel}
paramType={paramType}
forceCodeBox={() => {
setForceCodeBox(true);
onFxPress(true);
}}
meta={fieldMeta}
cyLabel={cyLabel}
styleDefinition={styleDefinition}
component={component}
onVisibilityChange={onVisibilityChange}
/>
)}
{!newLine && renderDynamicFx()}
</div>
{newLine && renderDynamicFx()}
{codeShow && (
<div className={`row custom-row`} style={{ display: codeShow ? 'flex' : 'none' }}>
<div className={`col code-hinter-col`}>
<div className="d-flex">
<SingleLineCodeEditor initialValue {...props} />
<SingleLineCodeEditor {...props} initialValue={modifiedValue} />
</div>
</div>
</div>

View file

@ -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% ;
}
}
}

View file

@ -367,6 +367,7 @@ export const FxParamTypeMapping = Object.freeze({
visibility: 'Visibility',
numberInput: 'NumberInput',
tableRowHeightInput: 'TableRowHeightInput',
dropdownMenu: 'DropdownMenu',
query: 'Query',
});

View file

@ -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 <div>Loading embedded app...</div>;
}

View file

@ -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 = ({
</div>
</div>
{gitSyncEnabled && (
{orgGit?.org_git?.is_enabled && (
<div className="commit-changes" style={{ marginTop: '-1rem', marginBottom: '2rem' }}>
<div>
<input

View file

@ -45,9 +45,7 @@ const Menu = (props) => {
{props?.selectProps?.value?.appVersionName &&
decodeEntities(props?.selectProps?.value?.appVersionName)}
</div>
<div
className={cx('col-1', { 'disabled-action-tooltip': props?.selectProps?.appCreationMode === 'GIT' })}
>
<div className={cx('col-1', { 'disabled-action-tooltip': !isVersionCreationEnabled })}>
<EditWhite />
</div>
</div>

View file

@ -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 }) => {
</div>
</div>
<div className="d-flex justify-content-between mb-3">
<div className="d-flex mb-3" style={{ height: '42px', gap: '20px' }}>
<span className="pt-2" data-cy={`label-bg-canvas`}>
{t('leftSidebar.Settings.backgroundColorOfCanvas', 'Canvas bavkground')}
</span>
<div className="canvas-codehinter-container">
{showPicker && (
<div>
<div style={coverStyles} onClick={() => setShowPicker(false)} />
<SketchPicker
data-cy={`color-picker-canvas`}
className="canvas-background-picker"
onFocus={() => setShowPicker(true)}
color={canvasBackgroundColor}
onChangeComplete={(color) => {
<div className={`fx-canvas `}>
<FxButton
dataCy={`canvas-bg-color`}
active={!forceCodeBox ? true : false}
onPress={async () => {
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] });
}}
/>
</div>
)}
await Promise.resolve(globalSettingsChanged(options));
await Promise.resolve(resolveOthers('canvas', true, { canvasBackgroundColor: value }));
}
setForceCodeBox(!forceCodeBox);
}}
/>
</div>
{forceCodeBox && (
<div className="row mx-0 color-picker-input d-flex" onClick={() => setShowPicker(true)} style={outerStyles}>
<div
data-cy={`canvas-bg-color-picker`}
className="col-auto"
style={{
float: 'right',
width: '24px',
height: '24px',
backgroundColor: canvasBackgroundColor,
borderRadius: ' 6px',
border: `1px solid var(--slate7, #D7DBDF)`,
boxShadow: `0px 1px 2px 0px rgba(16, 24, 40, 0.05)`,
}}
></div>
<div style={{ height: '20px' }} className="col">
{canvasBackgroundColor}
</div>
</div>
<ColorSwatches
data-cy={`color-picker-canvas`}
outerWidth="155px"
value={canvasBackgroundColor}
onChange={(color) => {
const options = {
canvasBackgroundColor: resolveReferences(color),
backgroundFxQuery: color,
};
globalSettingsChanged(options);
resolveOthers('canvas', true, { canvasBackgroundColor: color });
}}
/>
)}
<div className={`${!forceCodeBox && 'hinter-canvas-input'} `}>
{!forceCodeBox && (
<CodeHinter
cyLabel={`canvas-bg-colour`}
initialValue={backgroundFxQuery ? backgroundFxQuery : canvasBackgroundColor}
lang="javascript"
className="canvas-hinter-wrap"
lineNumbers={false}
onChange={(color) => {
const options = {
canvasBackgroundColor: resolveReferences(color),
backgroundFxQuery: color,
};
globalSettingsChanged(options);
resolveOthers('canvas', true, { canvasBackgroundColor: color });
}}
/>
<div className="canvas-hinter-wrap-container">
<CodeHinter
cyLabel={`canvas-bg-colour`}
initialValue={backgroundFxQuery ? backgroundFxQuery : canvasBackgroundColor}
lang="javascript"
className="canvas-hinter-wrap"
lineNumbers={false}
onChange={(color) => {
const options = {
canvasBackgroundColor: resolveReferences(color),
backgroundFxQuery: color,
};
globalSettingsChanged(options);
resolveOthers('canvas', true, { canvasBackgroundColor: color });
}}
/>
</div>
)}
<div className={`fx-canvas `}>
<FxButton
dataCy={`canvas-bg-color`}
active={!forceCodeBox ? true : false}
onPress={() => {
setForceCodeBox(!forceCodeBox);
}}
/>
</div>
</div>
</div>
</div>

View file

@ -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 (
<>
<ModuleProvider moduleId={'canvas'}>
<div>
<div bsPrefix="global-settings-popover" className="global-settings-panel">
<HeaderSection>
@ -44,7 +45,7 @@ const GlobalSettings = ({ darkMode }) => {
</div>
</div>
</div>
</>
</ModuleProvider>
);
};

View file

@ -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'),
})}
<SidebarItem
selectedSidebarItem={selectedSidebarItem}
onClick={() => handleSelectedSidebarItem('page')}
darkMode={darkMode}
icon="page"
className={`left-sidebar-item left-sidebar-layout left-sidebar-page-selector`}
tip="Pages"
ref={setSideBarBtnRefs('page')}
/>
{renderCommonItems()}
<SidebarItem
icon="settings"

View file

@ -103,7 +103,9 @@ export const Node = (props) => {
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) && ( */}
<div className="node-expansion-icon">

View file

@ -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 <IconSelector iconColor={computedStyles?.icon?.color} iconName={iconName} pageId={page.id} />;
}
if (isDisabled || (isDisabled && isHidden)) {
return (
<FileRemove fill={computedStyles?.icon?.fill} className=" " width={16} height={16} viewBox={'0 0 16 16'} />
);
}
if (isHidden && !isDisabled) {
return <EyeDisable className="" width={16} height={16} />;
}
};
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 (
<div
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
width: '100%',
}}
>
<>
<div
onClick={handlePageSwitch}
className={`page-menu-item ${isSelected && 'is-selected'} ${darkMode && 'dark-theme'}`}
style={{
position: 'relative',
width: '100%',
...computedStyles?.pill,
}}
>
{editingPageName && editingPage?.id === page?.id ? (
<>
{' '}
<div className="left">{icon()}</div>
<RenameInput
page={page}
updaterCallback={() => {
toggleEditPageNameInput(false);
}}
/>
</>
) : (
<>
{' '}
<div className="left" data-cy={`pages-name-${page.name.toLowerCase()}`}>
{icon()}
<OverflowTooltip childrenClassName="page-name" style={{ ...computedStyles?.text }}>
{page.name}
</OverflowTooltip>
<span
style={{
marginLeft: '8px',
}}
className="color-slate09 meta-text"
>
{isHomePage && 'Home'}
{isDisabled && 'Disabled'}
{isHidden && !isDisabled && 'Hidden'}
</span>
</div>
<div style={{ marginLeft: '8px', marginRight: 'auto' }}>
{licenseValid && restricted && (
<ToolTip message={getTooltip()}>
<div>
<SolidIcon width="16" name="lock" fill="var(--icon-strong)" />
</div>
</ToolTip>
)}
</div>
<div className={cx('right', { 'handler-menu-open': showEditingPopover })}>
{!shouldFreeze && (
<button
style={{
backgroundColor: 'transparent',
border: 'none',
color: 'var(--color-slate12)',
cursor: 'pointer',
padding: '0',
...((isEditingPage || currentPageId === page?.id) && {
opacity: 1,
}),
}}
className="edit-page-overlay-toggle"
onClick={handlePageMenuSettings}
ref={popoverRef}
id={`edit-popover-${page.id}`}
>
<SolidIcon width="20" dataCy={`page-menu`} name="morevertical" />
</button>
)}
</div>
</>
)}
</div>
</>
</div>
);
})
);
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 (
<div role="button" style={{ marginTop: '2px' }}>
<div>
<input
type="text"
className={`form-control page-name-input color-slate12 ${darkMode && 'bg-transparent'}`}
autoFocus
onBlur={(event) => {
const name = event.target.value;
handleAddingNewPage(name);
event.stopPropagation();
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
const name = event.target.value;
handleAddingNewPage(name);
event.stopPropagation();
}
}}
/>
</div>
</div>
);
};

View file

@ -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 = '' }) => {
/>
</div>
<div
style={{ width: '32px', minWidth: '32px', borderRadius: '0 4px 4px 0' }}
style={{
width: '32px',
minWidth: '32px',
borderRadius: '0 4px 4px 0',
}}
className="d-flex justify-content-center align-items-center border"
onClick={() => handleDeleteAggregate(aggregateKey)}
>

View file

@ -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 (
<>
<div
className={`row query-row pe-2 ${darkMode && 'dark-theme'}` + (isQuerySelected ? ' query-row-selected' : '')}
key={dataQuery.id}
onClick={() => {
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 }) => {
<DataSourceIcon source={dataQuery} height={16} />
</div>
<div className="col query-row-query-name">
{renamingQuery ? (
{isRenaming ? (
<input
data-cy={`query-edit-input-field`}
className={`query-name query-name-input-field border-indigo-09 bg-transparent ${
darkMode && 'text-white'
}`}
className={`query-name query-name-input-field border-indigo-09 bg-transparent ${darkMode && 'text-white'
}`}
type="text"
defaultValue={decodeEntities(dataQuery.name)}
autoFocus={true}
@ -121,7 +144,12 @@ export const QueryCard = ({ dataQuery, darkMode = false, localDs }) => {
data-tooltip-dynamic="true"
>
{decodeEntities(dataQuery.name)}
</span>{' '}
</span>
<ToolTip message={getTooltip()} show={licenseValid && isRestricted}>
<div className="d-flex align-items-center" style={{ marginLeft: '8px', marginRight: 'auto' }}>
{licenseValid && isRestricted && <SolidIcon width="16" name="lock" fill="var(--icon-strong)" />}
</div>
</ToolTip>{' '}
{!isQueryRunnable(dataQuery) && <small className="mx-2 text-secondary">Draft</small>}
{localDs && (
<>
@ -143,80 +171,25 @@ export const QueryCard = ({ dataQuery, darkMode = false, localDs }) => {
</div>
)}
</div>
{!shouldFreeze && isQuerySelected && (
<div className="col-auto query-rename-delete-btn">
<div
className={`col-auto ${(renamingQuery || !hasPermissions) && 'd-none'} rename-query`}
onClick={() => setRenamingQuery(true)}
>
<span className="d-flex" data-tooltip-id="query-card-btn-tooltip" data-tooltip-content="Rename query">
<svg
data-cy={`edit-query-${dataQuery.name.toLowerCase()}`}
width="100%"
height="100%"
viewBox="0 0 19 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M13.7087 1.40712C14.29 0.826221 15.0782 0.499893 15.9 0.499893C16.7222 0.499893 17.5107 0.82651 18.0921 1.40789C18.6735 1.98928 19.0001 2.7778 19.0001 3.6C19.0001 4.42197 18.6737 5.21028 18.0926 5.79162C18.0924 5.79178 18.0928 5.79145 18.0926 5.79162L16.8287 7.06006C16.7936 7.11191 16.753 7.16118 16.7071 7.20711C16.6621 7.25215 16.6138 7.292 16.563 7.32665L9.70837 14.2058C9.52073 14.3942 9.26584 14.5 9 14.5H6C5.44772 14.5 5 14.0523 5 13.5V10.5C5 10.2342 5.10585 9.97927 5.29416 9.79163L12.1733 2.93697C12.208 2.88621 12.2478 2.83794 12.2929 2.79289C12.3388 2.74697 12.3881 2.70645 12.4399 2.67132L13.7079 1.40789C13.7082 1.40763 13.7084 1.40738 13.7087 1.40712ZM13.0112 4.92545L7 10.9153V12.5H8.58474L14.5745 6.48876L13.0112 4.92545ZM15.9862 5.07202L14.428 3.51376L15.1221 2.82211C15.3284 2.6158 15.6082 2.49989 15.9 2.49989C16.1918 2.49989 16.4716 2.6158 16.6779 2.82211C16.8842 3.02842 17.0001 3.30823 17.0001 3.6C17.0001 3.89177 16.8842 4.17158 16.6779 4.37789L15.9862 5.07202ZM0.87868 5.37868C1.44129 4.81607 2.20435 4.5 3 4.5H4C4.55228 4.5 5 4.94772 5 5.5C5 6.05228 4.55228 6.5 4 6.5H3C2.73478 6.5 2.48043 6.60536 2.29289 6.79289C2.10536 6.98043 2 7.23478 2 7.5V16.5C2 16.7652 2.10536 17.0196 2.29289 17.2071C2.48043 17.3946 2.73478 17.5 3 17.5H12C12.2652 17.5 12.5196 17.3946 12.7071 17.2071C12.8946 17.0196 13 16.7652 13 16.5V15.5C13 14.9477 13.4477 14.5 14 14.5C14.5523 14.5 15 14.9477 15 15.5V16.5C15 17.2957 14.6839 18.0587 14.1213 18.6213C13.5587 19.1839 12.7957 19.5 12 19.5H3C2.20435 19.5 1.44129 19.1839 0.87868 18.6213C0.31607 18.0587 0 17.2957 0 16.5V7.5C0 6.70435 0.31607 5.94129 0.87868 5.37868Z"
fill="#11181C"
/>
</svg>
</span>
</div>
<div
className={`col-auto rename-query ${!hasPermissions && 'd-none'}`}
onClick={() => debouncedDuplicateQuery(dataQuery?.id, appId)}
>
<span className="d-flex" data-tooltip-id="query-card-btn-tooltip" data-tooltip-content="Duplicate query">
<Copy height={16} width={16} viewBox="0 5 20 20" />
</span>
</div>
<div className="col-auto">
{isDeletingQueryInProcess ? (
<div className="px-2">
<div className="text-center spinner-border spinner-border-sm" role="status"></div>
</div>
) : (
<span
className={`delete-query ${!hasPermissions && 'd-none'}`}
onClick={deleteDataQuery}
data-tooltip-id="query-card-btn-tooltip"
data-tooltip-content="Delete query"
>
<span className="d-flex">
<svg
data-cy={`delete-query-${dataQuery.name.toLowerCase()}`}
width="100%"
height="100%"
viewBox="0 0 18 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.58579 0.585786C5.96086 0.210714 6.46957 0 7 0H11C11.5304 0 12.0391 0.210714 12.4142 0.585786C12.7893 0.960859 13 1.46957 13 2V4H15.9883C15.9953 3.99993 16.0024 3.99993 16.0095 4H17C17.5523 4 18 4.44772 18 5C18 5.55228 17.5523 6 17 6H16.9201L15.9997 17.0458C15.9878 17.8249 15.6731 18.5695 15.1213 19.1213C14.5587 19.6839 13.7957 20 13 20H5C4.20435 20 3.44129 19.6839 2.87868 19.1213C2.32687 18.5695 2.01223 17.8249 2.00035 17.0458L1.07987 6H1C0.447715 6 0 5.55228 0 5C0 4.44772 0.447715 4 1 4H1.99054C1.9976 3.99993 2.00466 3.99993 2.0117 4H5V2C5 1.46957 5.21071 0.960859 5.58579 0.585786ZM3.0868 6L3.99655 16.917C3.99885 16.9446 4 16.9723 4 17C4 17.2652 4.10536 17.5196 4.29289 17.7071C4.48043 17.8946 4.73478 18 5 18H13C13.2652 18 13.5196 17.8946 13.7071 17.7071C13.8946 17.5196 14 17.2652 14 17C14 16.9723 14.0012 16.9446 14.0035 16.917L14.9132 6H3.0868ZM11 4H7V2H11V4ZM6.29289 10.7071C5.90237 10.3166 5.90237 9.68342 6.29289 9.29289C6.68342 8.90237 7.31658 8.90237 7.70711 9.29289L9 10.5858L10.2929 9.29289C10.6834 8.90237 11.3166 8.90237 11.7071 9.29289C12.0976 9.68342 12.0976 10.3166 11.7071 10.7071L10.4142 12L11.7071 13.2929C12.0976 13.6834 12.0976 14.3166 11.7071 14.7071C11.3166 15.0976 10.6834 15.0976 10.2929 14.7071L9 13.4142L7.70711 14.7071C7.31658 15.0976 6.68342 15.0976 6.29289 14.7071C5.90237 14.3166 5.90237 13.6834 6.29289 13.2929L7.58579 12L6.29289 10.7071Z"
fill="#DB4324"
/>
</svg>
</span>
</span>
)}
</div>
<Tooltip id="query-card-btn-tooltip" className="tooltip" />
</div>
)}
<div className={`col-auto query-rename-delete-btn ${!shouldFreeze && isQuerySelected ? 'd-flex' : 'd-none'}`}>
<ButtonComponent
iconOnly
leadingIcon="morevertical01"
onClick={(e) => 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()}`}
/>
</div>
</div>
<Confirm
show={showDeleteConfirmation}
show={isDeleting}
message={'Do you really want to delete this query?'}
confirmButtonLoading={isDeletingQueryInProcess}
onConfirm={executeDataQueryDeletion}
onCancel={() => setShowDeleteConfirmation(false)}
onCancel={() => deleteDataQuery(null)}
darkMode={darkMode}
/>
</>

View file

@ -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: <Edit width={16} />,
showTooltip: false,
},
{
label: 'Duplicate',
value: 'duplicate',
icon: <Copy width={16} />,
showTooltip: false,
},
{
label: 'Query permission',
value: 'permission',
icon: (
<img
alt="permission-icon"
src="assets/images/icons/editor/left-sidebar/authorization.svg"
width="16"
height="16"
/>
),
trailingIcon: <SolidIcon width={16} name="enterprisecrown" className="mx-1" />,
},
{
label: 'Delete',
value: 'delete',
icon: <Trash width={16} fill={'#E54D2E'} />,
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 (
<Overlay
placement="bottom-start"
target={targetElement}
show={showQueryHandlerMenu}
rootClose
onHide={() => toggleQueryHandlerMenu(false)}
popperConfig={{
modifiers: [
{
name: 'flip',
options: {
fallbackPlacements: ['top-start'],
flipVariations: true,
allowedAutoPlacements: ['top', 'bottom'],
boundary: 'viewport',
},
},
{
name: 'offset',
options: {
offset: [0, 3],
},
},
],
}}
>
{(props) => (
<Popover {...props} id="query-list-menu" className={darkMode && 'dark-theme'}>
<Popover.Body bsPrefix="list-item-popover-body">
{QUERY_MENU_OPTIONS.map((option) => {
const optionBody = (
<div
data-cy={`component-inspector-${String(option?.value).toLowerCase()}-button`}
className="list-item-popover-option"
key={option?.value}
onClick={(e) => {
e.stopPropagation();
handleQueryMenuActions(option.value);
}}
>
<div className="list-item-popover-menu-option-icon">{option.icon}</div>
<div
className={classNames('list-item-option-menu-label', {
'color-tomato9': option.value === 'delete',
'color-disabled': option.value === 'permission' && !licenseValid,
})}
>
{option?.label}
</div>
{option.value === 'permission' && !licenseValid && option.trailingIcon && option.trailingIcon}
</div>
);
return option.value === 'permission' ? (
<ToolTip
key={option.value}
message={'Component permissions are available only in paid plans'}
placement="left"
show={!licenseValid}
>
{optionBody}
</ToolTip>
) : (
optionBody
);
})}
</Popover.Body>
</Popover>
)}
</Overlay>
);
};
export default QueryCardMenu;

View file

@ -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) => (
<QueryCard key={query.id} dataQuery={query} darkMode={darkMode} localDs={!!isDataSourceLocal(query)} />
))}
<QueryCardMenu darkMode={darkMode} />
{licenseValid && (
<AppPermissionsModal
modalType="query"
resourceId={selectedQuery?.id}
resourceName={selectedQuery?.name}
showModal={showQueryPermissionModal}
toggleModal={toggleQueryPermissionModal}
darkMode={darkMode}
fetchPermission={(id, appId) => 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);
}}
/>
)}
</div>
<Tooltip
id="query-card-name-tooltip"

View file

@ -4,12 +4,33 @@ import { Inspector } from '@/AppBuilder/RightSideBar/Inspector/Inspector';
import useStore from '@/AppBuilder/_stores/store';
import { RIGHT_SIDE_BAR_TAB } from '@/AppBuilder/RightSideBar/rightSidebarConstants';
import { shallow } from 'zustand/shallow';
import SolidIcon from '@/_ui/Icon/SolidIcons';
export const ComponentConfigurationTab = ({ darkMode, isModuleEditor }) => {
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 (
<>
<div className="empty-configuration-header">
<div className="header">Component properties</div>
<div className="icon-btn cursor-pointer" onClick={() => toggleRightSidebarPin()}>
<SolidIcon fill="var(--icon-strong)" name={isRightSidebarPinned ? 'unpin' : 'pin'} width="16" />
</div>
</div>
<div className="d-flex align-items-center justify-content-center no-component-selected">
<SolidIcon name="cursorclick" width="28" />
<div className="tj-text-sm font-weight-500 heading">No component selected</div>
<div className="tj-text-xsm sub-heading">
Click a component on the canvas to view and edit its properties.
</div>
</div>
</>
);
}
return (
<Inspector

View file

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import './styles.scss';
export const ComponentModuleTab = ({ onChangeTab }) => {
export const ComponentModuleTab = ({ onChangeTab, hasModuleAccess }) => {
const [activeTab, setActiveTab] = useState(1);
const handleChangeTab = (tab) => {
@ -18,13 +18,15 @@ export const ComponentModuleTab = ({ onChangeTab }) => {
>
<span>Components</span>
</button>
<button
className={`tj-drawer-tabs-btn tj-text-xsm ${activeTab == 2 && 'tj-drawer-tabs-btn-active'}`}
onClick={() => handleChangeTab(2)}
data-cy="button-upload-csv-file"
>
<span>Modules</span>
</button>
{hasModuleAccess && (
<button
className={`tj-drawer-tabs-btn tj-text-xsm ${activeTab == 2 && 'tj-drawer-tabs-btn-active'}`}
onClick={() => handleChangeTab(2)}
data-cy="button-upload-csv-file"
>
<span>Modules</span>
</button>
)}
</div>
</div>
);

View file

@ -4,6 +4,8 @@
height: 36px;
margin-bottom: 8px;
margin-top: 16px;
margin-left: 16px;
margin-right: 16px;
}
.tj-tabs-container {

View file

@ -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 (
<div className="component-card-group-container">
<span className="widget-header">{header}</span>
<div className="component-card-group-wrapper">
{items.map((component, i) => renderComponentCard(component, i))}
</div>
@ -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 (
<div className="mt-3">
<Accordion items={items} isTitleCase={false} />
</div>
);
}
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 <div className="widgets-list col-sm-12 col-lg-12 row">{segregateSections()}</div>;
}
return <ModuleManager searchQuery={searchQuery} />;
// If there was an error accessing modules, redirect to components tab
if (moduleError) {
return <div className="widgets-list col-sm-12 col-lg-12 row">{segregateSections()}</div>;
}
return (
<ModuleErrorBoundary onError={() => setModuleError(true)}>
<ModuleManager searchQuery={searchQuery} />
</ModuleErrorBoundary>
);
};
return (
@ -189,13 +226,13 @@ export const ComponentsManagerTab = ({ darkMode, isModuleEditor }) => {
{isModuleEditor ? (
<p className="widgets-manager-header">Components</p>
) : (
<ComponentModuleTab onChangeTab={handleChangeTab} />
<ComponentModuleTab onChangeTab={handleChangeTab} hasModuleAccess={hasModuleAccess} />
)}
<div className="input-icon tj-app-input">
<SearchBox
dataCy={`widget-search-box`}
initialValue={''}
callBack={(e) => handleSearchQueryChange(e)}
callBack={(e) => handleSearchQueryChange(e.target.value)}
onClearCallback={() => {
setSearchQuery('');
if (activeTab === 1) {

View file

@ -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 && <CustomDragLayer size={size} />}
<div ref={drag} className="draggable-box" style={{ height: '100%', width: isModuleTab && '100%' }}>
{isModuleTab ? <ModuleWidgetBox module={component} /> : <WidgetBox index={index} component={component} />}
<div
ref={disabled ? undefined : drag}
className={`draggable-box${disabled ? ' disabled' : ''}`}
style={{ height: '100%', width: isModuleTab && '100%' }}
>
{isModuleTab ? (
<ModuleWidgetBox module={component} disabled={disabled} />
) : (
<WidgetBox index={index} component={component} />
)}
</div>
</>
);
@ -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 (
<div
style={{
position: 'fixed',
pointerEvents: 'none',
zIndex: 1000,
left: canvasBounds?.left || 0,
top: canvasBounds?.top || 0,
height: `${height}px`,
width: `${width}px`,
zIndex: -1,
}}
>
<div

View file

@ -0,0 +1,70 @@
const sectionConfig = {
commonlyUsed: {
title: 'Commonly used',
valueSet: new Set(['Table', 'Button', 'Text', 'TextInput', 'DatetimePickerV2', 'Form']),
},
buttons: {
title: 'Buttons',
valueSet: new Set(['Button', 'ButtonGroup']),
},
data: {
title: 'Data',
valueSet: new Set(['Table', 'Chart']),
},
layouts: {
title: 'Layouts',
valueSet: new Set(['Form', 'ModalV2', 'Container', 'Tabs', 'Listview', 'Kanban', 'Calendar']),
},
textInputs: {
title: 'Text inputs',
valueSet: new Set(['TextInput', 'TextArea', 'EmailInput', 'PasswordInput', 'RichTextEditor']),
},
numberInputs: {
title: 'Number inputs',
valueSet: new Set(['NumberInput', 'PhoneInput', 'CurrencyInput', 'RangeSlider', 'StarRating']),
},
selectInputs: {
title: 'Select inputs',
valueSet: new Set(['DropdownV2', 'MultiselectV2', 'ToggleSwitchV2', 'RadioButtonV2', 'Checkbox', 'TreeSelect']),
},
dateTimeInputs: {
title: 'Date and time inputs',
valueSet: new Set(['DaterangePicker', 'DatePickerV2', 'TimePicker', 'DatetimePickerV2']),
},
navigation: {
title: 'Navigation',
valueSet: new Set(['Link', 'Pagination', 'Steps']),
},
media: {
title: 'Media',
valueSet: new Set(['Icon', 'Image', 'SvgImage', 'PDF', 'Map']),
},
presentation: {
title: 'Presentation',
valueSet: new Set([
'Text',
'Tags',
'CircularProgressBar',
'Timeline',
'Divider',
'VerticalDivider',
'Spinner',
'Statistics',
'Timer',
]),
},
custom: {
title: 'Custom',
valueSet: new Set(['CustomComponent', 'Html', 'IFrame']),
},
miscellaneous: {
title: 'Miscellaneous',
valueSet: new Set(['FilePicker', 'CodeEditor', 'ColorPicker', 'BoundedBox', 'QrScanner']),
},
legacy: {
title: 'Legacy',
valueSet: new Set(['Modal', 'Datepicker', 'RadioButton', 'ToggleSwitch', 'DropDown', 'Multiselect']),
},
};
export default sectionConfig;

View file

@ -27,10 +27,18 @@ const SHOW_ADDITIONAL_ACTIONS = [
'Button',
'RichTextEditor',
'Image',
'CodeEditor',
'TextArea',
'Container',
'Form',
'Divider',
'VerticalDivider',
'ModalV2',
'Tabs',
'RangeSlider',
'Link',
'FilePicker',
'Listview',
];
const PROPERTIES_VS_ACCORDION_TITLE = {
Text: 'Data',
@ -46,6 +54,8 @@ const PROPERTIES_VS_ACCORDION_TITLE = {
Divider: 'Data',
VerticalDivider: 'Data',
ModalV2: 'Data',
Tabs: 'Data',
RangeSlider: 'Data',
Link: 'Data',
};
@ -144,9 +154,12 @@ export const baseComponentProperties = (
'DropdownV2',
'MultiselectV2',
'Image',
'RangeSlider',
'Divider',
'VerticalDivider',
'Link',
'FilePicker',
'Tabs',
],
Layout: [],
};

View file

@ -1,8 +1,114 @@
import React from 'react';
import React, { useState } from 'react';
import Accordion from '@/_ui/Accordion';
import { renderElement } from '../Utils';
import { baseComponentProperties } from './DefaultComponent';
import { resolveReferences } from '@/_helpers/utils';
import cx from 'classnames';
import styles from '@/_ui/Select/styles';
import useStore from '@/AppBuilder/_stores/store';
import Select from '@/_ui/Select';
import CodeHinter from '@/AppBuilder/CodeEditor';
import FxButton from '@/AppBuilder/CodeBuilder/Elements/FxButton';
const FILE_TYPE_OPTIONS = [
{ value: '*/*', label: 'Any Files' },
{ value: 'image/*', label: 'Image files' },
{ value: '.pdf,.doc,.docx,.ppt,.pptx', label: 'Document files' },
{ value: '.xls,.xlsx,.csv,.ods', label: 'Spreadsheet files' },
{ value: 'text/*,.md,.json,.xml,.yaml', label: 'Text files' },
{ value: 'audio/*', label: 'Audio files' },
{ value: 'video/*', label: 'Video files' },
{ value: '.zip,.rar,.7z,.tar,.gz', label: 'Archive/Compressed files' },
];
const FxSelect = ({ label, paramName, initialValue, darkMode, paramUpdated, options, onValueChange }) => {
const [isFxActive, setIsFxActive] = useState(false);
const handleFxButtonClick = () => {
paramUpdated({ name: paramName }, 'fxActive', !isFxActive, 'properties');
setIsFxActive(!isFxActive);
};
return (
<div
data-cy={`input-date-display-format`}
className="field mb-2 w-100 input-date-display-format"
onClick={(e) => e.stopPropagation()}
>
<div className="field mb-2" onClick={(e) => e.stopPropagation()}>
<div className="d-flex justify-content-between mb-1">
<label className="form-label">{label}</label>
<div className={cx({ 'hide-fx': !isFxActive })}>
<FxButton active={isFxActive} onPress={handleFxButtonClick} />
</div>
</div>
{isFxActive ? (
<CodeHinter
initialValue={initialValue}
theme={darkMode ? 'monokai' : 'default'}
mode="javascript"
lineNumbers={false}
onChange={onValueChange}
/>
) : (
<Select
options={options}
value={initialValue ?? '*/*'}
search={true}
closeOnSelect={true}
onChange={onValueChange}
fuzzySearch
placeholder="Select.."
useCustomStyles={true}
styles={styles(darkMode, '100%', 32, { fontSize: '12px' })}
/>
)}
</div>
</div>
);
};
/** Remove minFileCount and maxFileCount validations if multiple file selection is disabled */
const getValidations = (componentMeta, component) => {
const validations = Object.keys(componentMeta.validation || {});
const enableMultipleValue = resolveReferences(component.component.definition.properties.enableMultiple?.value ?? false);
const enableMultipleFxActive = component.component.definition.properties.enableMultiple?.fxActive;
if (!enableMultipleValue && !enableMultipleFxActive) {
return validations.filter((validation) => !['minFileCount', 'maxFileCount'].includes(validation));
}
return validations;
};
const getPropertiesBySection = (propertiesMeta) => {
const properties = [];
const additionalActions = [];
const dataProperties = [];
for (const [key, value] of Object.entries(propertiesMeta)) {
if (value?.section === 'additionalActions') {
additionalActions.push(key);
} else if (value?.accordian === 'Data') {
dataProperties.push(key);
} else {
properties.push(key);
}
}
return { properties, additionalActions, dataProperties };
};
const getConditionalAccordionItems = (component, renderCustomElement) => {
const parseContent = resolveReferences(component.component.definition.properties.parseContent?.value ?? false);
const options = ['parseContent'];
let renderOptions = options.map((option) => renderCustomElement(option));
const conditionalOptions = [{ name: 'parseFileType', condition: parseContent }];
conditionalOptions.forEach(({ name, condition }) => {
if (condition) renderOptions.push(renderCustomElement(name));
});
return renderOptions;
};
export const FilePicker = ({ componentMeta, darkMode, ...restProps }) => {
const {
@ -16,38 +122,22 @@ export const FilePicker = ({ componentMeta, darkMode, ...restProps }) => {
allComponents,
} = restProps;
const renderCustomElement = (param, paramType = 'properties') => {
return renderElement(component, componentMeta, paramUpdated, dataQueries, param, paramType, currentState);
};
const conditionalAccordionItems = (component) => {
const parseContent = resolveReferences(component.component.definition.properties.parseContent?.value ?? false);
const accordionItems = [];
const options = ['parseContent'];
const resolvedValidations = useStore((state) => state.getResolvedComponent(component.id)?.validation);
const fileTypeValue = resolvedValidations?.fileType;
let renderOptions = [];
const renderCustomElement = (param, paramType = 'properties') =>
renderElement(component, componentMeta, paramUpdated, dataQueries, param, paramType, currentState);
options.map((option) => renderOptions.push(renderCustomElement(option)));
// Debug logs
// console.log('component.component.definition', component.component.definition);
const conditionalOptions = [{ name: 'parseFileType', condition: parseContent }];
conditionalOptions.map(({ name, condition }) => {
if (condition) renderOptions.push(renderCustomElement(name));
});
accordionItems.push({
title: 'Options',
children: renderOptions,
});
return accordionItems;
};
const properties = Object.keys(componentMeta.properties);
const events = Object.keys(componentMeta.events);
const validations = Object.keys(componentMeta.validation || {});
const validations = getValidations(componentMeta, component);
const filteredProperties = properties.filter(
(property) => property !== 'parseContent' && property !== 'parseFileType'
);
// console.log('validations', validations, enableMultipleValue, component.component.definition.properties.enableMultiple?.value, enableMultipleFxActive);
const { additionalActions, dataProperties } = getPropertiesBySection(componentMeta?.properties);
const filteredProperties = [...dataProperties];
const accordionItems = baseComponentProperties(
filteredProperties,
@ -62,10 +152,26 @@ export const FilePicker = ({ componentMeta, darkMode, ...restProps }) => {
apps,
allComponents,
validations,
darkMode
darkMode,
[],
additionalActions
);
accordionItems.splice(1, 0, ...conditionalAccordionItems(component));
// Insert conditional accordion items
accordionItems[0].children.push(...getConditionalAccordionItems(component, renderCustomElement));
// Insert FxSelect for file type
accordionItems[2].children[1] = (
<FxSelect
label={'File type'}
paramName="fileType"
initialValue={fileTypeValue}
darkMode={darkMode}
paramUpdated={paramUpdated}
options={FILE_TYPE_OPTIONS}
onValueChange={(value) => paramUpdated({ name: 'fileType' }, 'value', value, 'validation')}
/>
);
return <Accordion items={accordionItems} />;
};

View file

@ -0,0 +1,86 @@
import React from 'react';
import Accordion from '@/_ui/Accordion';
import useStore from '@/AppBuilder/_stores/store';
import { shallow } from 'zustand/shallow';
import { useFormLogic } from './_hooks';
import { processComponentMeta } from './utils/componentMetaUtils';
import { createAccordionItems } from './config/accordionConfig';
import { DataSection } from './_components';
import './styles.scss';
export const Form = ({
componentMeta,
darkMode,
layoutPropertyChanged,
component,
paramUpdated,
dataQueries,
currentState,
eventsChanged,
apps,
allComponents,
pages,
}) => {
const resolveReferences = useStore((state) => state.resolveReferences, shallow);
// Use the combined form logic hook
const formLogic = useFormLogic(component, paramUpdated);
// Get resolved custom schema
const resolvedCustomSchema = resolveReferences('canvas', component.component.definition.properties.advanced.value);
// Process component metadata
const { tempComponentMeta, properties, additionalActions, deprecatedProperties, events, validations } =
processComponentMeta(componentMeta, component, allComponents, resolvedCustomSchema);
// Create render data element function
const renderDataElement = DataSection({
component,
componentMeta,
paramUpdatedInterceptor: formLogic.paramUpdatedInterceptor,
dataQueries,
currentState,
allComponents,
darkMode,
resolvedCustomSchema,
source: formLogic.source,
JSONData: formLogic.JSONData,
setCodeEditorView: formLogic.setCodeEditorView,
currentStatusRef: formLogic.currentStatusRef,
saveDataSection: formLogic.saveDataSection,
openModal: formLogic.openModal,
setParentModalState: formLogic.setOpenModal,
performColumnMapping: formLogic.performColumnMapping,
existingResolvedJsonData: formLogic.existingResolvedJsonData,
savedSourceValue: formLogic.savedSourceValue.current,
resolveReferences,
isLoading: formLogic.isLoading,
});
// Create accordion items
const accordionItems = createAccordionItems({
properties,
events,
component,
componentMeta: tempComponentMeta,
layoutPropertyChanged,
paramUpdated: formLogic.paramUpdatedInterceptor,
dataQueries,
currentState,
eventsChanged,
apps,
allComponents,
validations,
darkMode,
pages,
additionalActions,
deprecatedProperties,
renderDataElement,
});
return (
<>
<Accordion items={accordionItems} />
</>
);
};

View file

@ -0,0 +1,530 @@
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { Button } from '@/components/ui/Button/Button';
import Checkbox from '@/components/ui/Checkbox/Index';
import cx from 'classnames';
import SolidIcon from '@/_ui/Icon/SolidIcons';
import Modal from 'react-bootstrap/Modal';
import Dropdown from '@/components/ui/Dropdown/Index';
import Input from '@/components/ui/Input/Index';
import { getInputTypeOptions, isTrueValue, isPropertyFxControlled } from '../utils/utils';
import { FORM_STATUS } from '../constants';
import useStore from '@/AppBuilder/_stores/store';
import { shallow } from 'zustand/shallow';
import { extractAndReplaceReferencesFromString } from '@/AppBuilder/_stores/ast';
import Loader from '@/ToolJetUI/Loader/Loader';
import { useColumnBuilder, useGroupedColumns, useCheckboxStates } from './hooks/useColumnMapping';
// Constants for section display names
const SECTION_DISPLAY_NAMES = {
existing: 'Existing',
isCustomField: 'Custom fields',
isNew: 'New',
isRemoved: 'Removed',
};
/**
* Reusable editable icon component
*/
const EditableIcon = ({ darkMode }) => (
<div className="tw-mr-2 editable-icon">
<SolidIcon name="editable" width="12" height="12" fill={darkMode ? '#4C5155' : '#C1C8CD'} viewBox="0 0 12 12" />
</div>
);
/**
* Modal header component
*/
const ModalHeader = ({ currentStatus, onClose }) => (
<div className="column-mapping-modal-header tw-flex tw-p-4 tw-flex-col tw-items-start tw-gap-2 tw-self-stretch tw-border-b bg-white">
<div className="tw-flex tw-justify-between tw-items-center tw-w-full" style={{ height: '28px' }}>
<h4 className="text-default tw-font-ibmplex tw-font-medium tw-leading-5 tw-m-0">
{currentStatus !== FORM_STATUS.GENERATE_FIELDS ? 'Manage fields' : 'Map columns'}
</h4>
<button className="tw-bg-transparent tw-border-0 tw-p-0 tw-cursor-pointer hover:tw-opacity-70" onClick={onClose}>
<SolidIcon name="remove" width="16" height="16" fill="#6A727C" />
</button>
</div>
</div>
);
/**
* Modal footer component
*/
const ModalFooter = ({ currentStatus, refreshData, handleSubmit, isSaving, allSectionsEmpty }) => (
<div
className={`tw-flex ${
currentStatus !== FORM_STATUS.GENERATE_FIELDS ? 'tw-justify-between' : 'tw-justify-end'
} tw-items-center`}
>
{currentStatus !== FORM_STATUS.GENERATE_FIELDS && (
<Button fill={'#ACB2B9'} leadingIcon={'arrowdirectionloop'} variant="outline" onClick={refreshData}>
Refresh data
</Button>
)}
<Button
variant="primary"
onClick={handleSubmit}
disabled={isSaving || allSectionsEmpty}
leadingIcon={currentStatus !== FORM_STATUS.GENERATE_FIELDS ? 'save' : 'plus'}
isLoading={isSaving}
loaderText={currentStatus !== FORM_STATUS.GENERATE_FIELDS ? 'Saving' : 'Generating'}
>
{currentStatus !== FORM_STATUS.GENERATE_FIELDS ? 'Save' : 'Generate form'}
</Button>
</div>
);
/**
* Loader component
*/
const LoaderComponent = () => (
<div className="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center tw-z-10">
<Loader width="32" absolute={false} />
</div>
);
/**
* Disable the checkbox if the property is fx controlled and it will not be included while selectAll is called.
* This is to prevent users from changing the state of fx controlled properties directly.
* Instead, they should use the fx editor to manage these properties.
*/
const ColumnMappingRow = ({
column,
onChange,
onCheckboxChange,
index,
darkMode = false,
disabled = false,
sectionType,
}) => {
if (!column) return null;
const inputTypeOptions = getInputTypeOptions(darkMode);
const isMandatoryFxControlled = isPropertyFxControlled(column.mandatory);
const handleLabelChange = (e) => {
onChange?.({
...column,
label: e.target.value,
});
};
const handleMandatoryChange = (checked) => {
if (typeof column.mandatory === 'object') {
onChange?.({
...column,
mandatory: {
...column.mandatory,
value: checked,
},
});
} else {
onChange?.({
...column,
mandatory: checked,
});
}
};
const handleInputTypeChange = (value) => {
onChange?.({
...column,
componentType: value,
});
};
const shouldHideCheckbox = sectionType === 'isCustomField';
return (
<div className="tw-flex tw-items-center tw-w-full tw-py-3 tw-px-2 tw-border-b tw-border-border-lighter column-mapping-row">
{/* Checkbox */}
<div className={cx(`tw-w-6`, { 'tw-invisible': disabled || shouldHideCheckbox })}>
<Checkbox checked={column.selected} onCheckedChange={onCheckboxChange} />
</div>
{/* Column Name and Type */}
<div className="name-column tw-flex tw-items-center base-regular tw-justify-between">
{column.key ? (
<>
<span className="base-regular">{column.key}</span>
<span className="tw-ml-2 data-type">{column.dataType}</span>
</>
) : (
<span className="no-mapped-column small-medium">No mapped columns</span>
)}
</div>
{/* Mapped To */}
<div
className={cx('arrow-column tw-flex tw-justify-center', {
'tw-invisible': column.key === undefined,
})}
>
<SolidIcon name="arrowright" width="24" height="24" fill="#CCD1D5" />
</div>
{/* Input Type Selector */}
<div className="mapped-column tw-relative hide-border">
<Dropdown
options={inputTypeOptions}
name={`dropdown-${index}`}
id={`dropdown-${index}`}
size="small"
zIndex={9999}
value={column.componentType || 'TextInput'}
leadingIcon={inputTypeOptions[column.componentType || 'TextInput'].leadingIcon}
onChange={handleInputTypeChange}
width="140px"
disabled={disabled}
/>
</div>
{/* Input Label */}
<div className="type-column rows tw-flex-1 hide-border">
<Input
value={column.label}
onChange={handleLabelChange}
placeholder="Input label"
size="small"
disabled={disabled}
/>
</div>
{/* Mandatory Checkbox */}
<div className={cx(`mandatory-column rows tw-flex tw-justify-end`, { 'tw-invisible': disabled })}>
<Checkbox
checked={isTrueValue(column.mandatory.value)}
onCheckedChange={handleMandatoryChange}
disabled={isMandatoryFxControlled} // Disable if fx controlled
/>
</div>
</div>
);
};
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 (
<div className="tw-flex tw-items-center tw-w-full tw-py-[10px] tw-px-2 header-row column-mapping-row">
<div className={cx(`tw-w-6 header-column`, { 'tw-invisible': disabled || shouldHideSelectAll })}>
<Checkbox
checked={isAllSelected || isIntermediateSelected}
onCheckedChange={handleSelectAll}
intermediate={isIntermediateSelected}
/>
</div>
<div className="name-column header-column">
<span className="text-default small-medium">Column name</span>
</div>
<div className="arrow-column header-column" />
<div className="mapped-column header-column tw-flex">
<EditableIcon darkMode={darkMode} />
<span className="text-default small-medium">Mapped to</span>
</div>
<div className="type-column tw-flex-1 header-column tw-flex">
<EditableIcon darkMode={darkMode} />
<span className="text-default small-medium">Input label</span>
</div>
<div className="mandatory-column header-column tw-flex tw-justify-end">
<span className="text-default small-medium tw-mr-2">Mandatory?</span>
<div className={cx({ 'tw-invisible': disabled })}>
<Checkbox
checked={isAllSelectedMandatory || isIntermediateMandatory}
onCheckedChange={handleSelectAllMandatory}
intermediate={!isAllSelectedMandatory && isIntermediateMandatory}
/>
</div>
</div>
</div>
);
};
return (
<div className="tw-w-full column-mapping-modal-body-content">
<div
className={cx('large-medium column-mapping-modal-title', {
new: sectionType === 'isNew',
removed: sectionType === 'isRemoved',
'tw-hidden': sectionDisplayName === '',
})}
>
{sectionDisplayName}
</div>
{renderHeader()}
<div>
{columnsArray.length > 0 ? (
columnsArray.map((column, index) => (
<ColumnMappingRow
key={column.name}
column={columnsArray.find((c) => c.name === column.name)}
onCheckboxChange={(checked) => handleColumnSelect(column.name, checked)}
onChange={(changes) => handleColumnChange(column.name, changes)}
index={index}
darkMode={darkMode}
disabled={disabled}
sectionType={sectionType}
/>
))
) : (
<div className="tw-py-4 tw-text-center tw-text-gray-500">No {sectionDisplayName.toLowerCase()} available</div>
)}
</div>
</div>
);
};
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 = (
<>
<div
ref={bodyContainerRef}
className="tw-w-full column-mapping-modal-body-container tw-max-h-[500px] tw-overflow-y-auto tw-p-4 tw-pb-0 tw-relative"
style={showLoader && lastBodyHeightRef.current ? { minHeight: `${lastBodyHeightRef.current}px` } : undefined}
>
{showLoader && <LoaderComponent />}
{!showLoader && (
<div>
{sectionTypes.map((sectionType) => {
return (
groupedColumns[sectionType]?.length > 0 && (
<RenderSection
key={sectionType}
mappedColumns={groupedColumns[sectionType]}
setMappedColumns={(updatedColumns) => updateSectionColumns(sectionType, updatedColumns)}
darkMode={darkMode}
sectionType={sectionType}
sectionDisplayName={
currentStatus !== FORM_STATUS.GENERATE_FIELDS ? getSectionDisplayName(sectionType) : ''
}
disabled={sectionType === 'isRemoved'}
/>
)
);
})}
</div>
)}
</div>
<div className="tw-p-4 tw-border-t tw-border-border-lighter">
<ModalFooter
currentStatus={currentStatus}
refreshData={refreshData}
handleSubmit={handleSubmit}
isSaving={isSaving}
allSectionsEmpty={allSectionsEmpty}
/>
</div>
</>
);
return (
<Modal show={isOpen} onHide={onClose} size="lg">
<ModalHeader currentStatus={currentStatus} onClose={onClose} />
<div className="column-mapping-modal-body">{modalBody}</div>
</Modal>
);
};
export default ColumnMappingComponent;

View file

@ -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 () => (
<div className={`${resolvedCustomSchema ? 'tw-pointer-events-none opacity-60' : ''}`}>
{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' && (
<DataSectionWrapper
currentStatusRef={currentStatusRef}
source={source}
JSONData={JSONData}
component={component}
darkMode={darkMode}
saveDataSection={saveDataSection}
openModalFromParent={openModal}
setParentModalState={setParentModalState}
performColumnMapping={performColumnMapping}
newResolvedJsonData={resolveReferences('canvas', JSONData.value)}
existingResolvedJsonData={existingResolvedJsonData}
savedSourceValue={savedSourceValue}
isLoading={isLoading}
/>
)}
</div>
);
};

View file

@ -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 (
<Button
iconOnly
leadingIcon="sliders"
variant="ghost"
size="small"
onClick={() => {
currentStatusRef.current = FORM_STATUS.MANAGE_FIELDS;
setParentModalState(true);
setIsModalOpen(true);
}}
/>
);
};
const renderAddCustomFieldButton = () => {
return (
<OverlayTrigger
trigger="click"
placement="left"
show={showAddFieldPopover}
onToggle={(show) => {
if (!show && shouldPreventPopoverClose) {
return;
}
setShowAddFieldPopover(show);
}}
rootClose
overlay={
<Popover id="add-field-popover" className="shadow form-fields-column-popover">
<FieldPopoverContent
field={undefined}
onChange={handleAddField}
onClose={() => setShowAddFieldPopover(false)}
darkMode={darkMode}
mode="add"
onDropdownOpen={handleDropdownOpen}
onDropdownClose={handleDropdownClose}
shouldPreventPopoverClose={shouldPreventPopoverClose}
/>
</Popover>
}
>
<Button ref={addFieldButtonRef} iconOnly leadingIcon="plus" variant="ghost" size="small" />
</OverlayTrigger>
);
};
const closeModal = useCallback(() => {
setIsModalOpen(false);
setParentModalState(false);
}, [setIsModalOpen]);
return (
<>
<div className="tw-flex tw-justify-between tw-items-center tw-gap-1.5">
<div className="tw-flex-1">
<LabeledDivider label="Fields" rightContentCount={hideManageFields ? 1 : 2} />
</div>
<div className="tw-flex tw-items-center">
{!hideManageFields && renderManageFieldsIcon()}
{renderAddCustomFieldButton()}
</div>
</div>
<FormFieldsList
fields={formFieldsWithComponentDefinition}
onDeleteField={handleDeleteField}
currentStatusRef={currentStatusRef}
onSave={performColumnMapping}
/>
{isModalOpen && (
<ColumnMappingComponent
isOpen={isModalOpen}
onClose={closeModal}
darkMode={darkMode}
currentStatusRef={currentStatusRef}
onSubmit={performColumnMapping}
// Add new props for buildColumns
component={component}
newResolvedJsonData={newResolvedJsonData}
existingResolvedJsonData={existingResolvedJsonData}
source={source}
JSONData={JSONData}
isDataLoading={isDataLoading}
/>
)}
</>
);
};
export default DataSectionUI;

View file

@ -0,0 +1,61 @@
import React, { useEffect } from 'react';
import useStore from '@/AppBuilder/_stores/store';
import { shallow } from 'zustand/shallow';
import { DataSectionUI } from './index';
import { isEqual } from 'lodash';
import { FORM_STATUS } from '../constants';
const DataSectionWrapper = ({
source,
JSONData,
component,
performColumnMapping,
currentStatusRef,
savedSourceValue,
...restProps
}) => {
const getFormDataSectionData = useStore((state) => state.getFormDataSectionData, shallow);
useEffect(() => {
const existingData = getFormDataSectionData(component?.id);
const isFormGenerated = existingData && existingData.generateFormFrom && existingData.JSONData;
const sourceChanged = !isEqual(savedSourceValue, source?.value);
const JSONDataChanged = !isEqual(existingData?.JSONData?.value, JSONData?.value);
// Case: Form not generated yet
if (!isFormGenerated) {
currentStatusRef.current = FORM_STATUS.GENERATE_FIELDS;
}
// Case: Form is already generated
else {
// Source changed - need to regenerate form
if (sourceChanged) {
currentStatusRef.current = FORM_STATUS.GENERATE_FIELDS;
}
// Source is same but JSON data changed - refresh form
else if (JSONDataChanged) {
currentStatusRef.current = FORM_STATUS.REFRESH_FIELDS;
}
// No changes
else {
currentStatusRef.current = FORM_STATUS.REFRESH_FIELDS;
}
}
}, [source, JSONData, component?.id, getFormDataSectionData, currentStatusRef, savedSourceValue]);
// You'll need to return the actual button component here instead of null
return (
<DataSectionUI
component={component}
{...restProps}
currentStatusRef={currentStatusRef}
performColumnMapping={performColumnMapping}
source={source}
JSONData={JSONData}
savedSourceValue={savedSourceValue}
/>
);
};
export default DataSectionWrapper;

View file

@ -0,0 +1,233 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { isEqual } from 'lodash';
import CodeHinter from '@/AppBuilder/CodeEditor';
import Dropdown from '@/components/ui/Dropdown/Index';
import Popover from 'react-bootstrap/Popover';
import { Button } from '@/components/ui/Button/Button';
import { getInputTypeOptions, isPropertyFxControlled, isTrueValue } from '../utils/utils';
const FieldPopoverContent = ({
field,
onChange,
onClose,
darkMode = false,
mode = 'edit',
onDropdownOpen,
onDropdownClose,
setSelectedComponents,
}) => {
const [localField, setLocalField] = useState(field ?? {});
useEffect(() => {
setLocalField({ ...field });
}, [field]);
// Memoize expensive computations
const isVisibilityFxControlled = useMemo(
() => (mode === 'edit' ? isPropertyFxControlled(localField.visibility) : false),
[mode, localField.visibility]
);
const isCurrentlyVisibility = useMemo(
() => (mode === 'edit' ? isTrueValue(localField.visibility?.value) : false),
[mode, localField.visibility?.value]
);
const inputTypeOptions = useMemo(() => getInputTypeOptions(darkMode), [darkMode]);
const handleFieldChange = useCallback((property, value) => {
if (property === 'mandatory' || property === 'visibility') {
return setLocalField((prevField) => ({
...prevField,
[property]: { ...prevField[property], value },
}));
}
setLocalField((prevField) => ({ ...prevField, [property]: value }));
}, []);
const handleFxChange = useCallback((property, fxActive) => {
setLocalField((prevField) => ({
...prevField,
[property]: { ...prevField[property], fxActive },
}));
}, []);
const handleSubmit = useCallback(() => {
onChange?.(localField);
if (mode !== 'edit') {
onClose?.();
}
}, [localField, onChange, onClose, mode]);
const renderPlaceholder = () => {
if (
[
'Checkbox',
'RadioButtonV2',
'Datepicker',
'DatetimePickerV2',
'Checkbox',
'ToggleSwitchV2',
'DatePickerV2',
'TimePicker',
'DaterangePicker',
].includes(localField.componentType)
)
return null;
return (
<div>
<label className="tw-text-text-default base-medium">Placeholder</label>
<CodeHinter
type={'basic'}
initialValue={localField.placeholder || ''}
theme={darkMode ? 'monokai' : 'default'}
mode="javascript"
lineNumbers={false}
onChange={(value) => handleFieldChange('placeholder', value)}
/>
</div>
);
};
const renderDefaultValue = () => {
if (['RadioButtonV2', 'DropdownV2', 'MultiselectV2'].includes(localField.componentType)) return null;
return (
<div>
<label className="tw-text-text-default base-medium">Default value</label>
<CodeHinter
type={'basic'}
initialValue={localField.value || ''}
theme={darkMode ? 'monokai' : 'default'}
mode="javascript"
lineNumbers={false}
onChange={(value) => handleFieldChange('value', value)}
/>
</div>
);
};
return (
<>
<Popover.Header className="d-flex justify-content-between align-items-center tw-px-4 tw-py-2 form-field-popover-header bg-white">
<span className="tw-text-text-default base-medium">{mode === 'edit' ? 'Edit field' : 'New custom field'}</span>
<div className="tw-flex">
{mode === 'edit' ? (
<>
<Button
iconOnly
leadingIcon="inspect"
variant="ghost"
size="medium"
onClick={() => setSelectedComponents([field.componentId])}
/>
<Button
iconOnly
leadingIcon={isCurrentlyVisibility ? 'eye' : 'eyedisable'}
variant="ghost"
size="medium"
disabled={isVisibilityFxControlled}
className={`${isVisibilityFxControlled ? 'tw-opacity-50' : ''}`}
onClick={() => {
handleFieldChange('visibility', !isCurrentlyVisibility);
}}
/>
</>
) : (
<Button iconOnly leadingIcon="remove" variant="ghost" size="medium" onClick={onClose} />
)}
</div>
</Popover.Header>
<Popover.Body className="bg-white tw-p-4 form-field-popover-body">
<div className="tw-space-y-[12px]">
<div>
<Dropdown
options={inputTypeOptions}
name="field-type"
id="field-type"
size="medium"
zIndex={9999}
value={localField.componentType || 'TextInput'}
leadingIcon={inputTypeOptions[localField.componentType || 'TextInput'].leadingIcon}
onChange={(value) => {
handleFieldChange('componentType', value);
}}
width="100%"
label="Component"
onOpen={onDropdownOpen}
onClose={onDropdownClose}
/>
</div>
<div>
<label className="tw-text-text-default base-medium">Label</label>
<CodeHinter
type={'basic'}
initialValue={localField.label || ''}
theme={darkMode ? 'monokai' : 'default'}
mode="javascript"
lineNumbers={false}
placeholder={'Email id'}
onChange={(value) => handleFieldChange('label', value)}
/>
</div>
{renderPlaceholder()}
{renderDefaultValue()}
<div className="field mb-2">
<CodeHinter
initialValue={localField.mandatory?.value ?? false}
theme={darkMode ? 'monokai' : 'default'}
mode="javascript"
lineNumbers={false}
type={'fxEditor'}
paramLabel={'Make this field mandatory'}
paramName={'isMandatory'}
fxActive={localField.mandatory?.fxActive ?? false}
fieldMeta={{
type: 'toggle',
displayName: 'Make editable',
}}
paramType={'toggle'}
onChange={(value) => handleFieldChange('mandatory', value)}
onFxPress={(active) => handleFxChange('mandatory', active)}
/>
</div>
{mode === 'edit' && (
<div className="field m-0">
<CodeHinter
initialValue={localField.visibility?.value ?? true}
theme={darkMode ? 'monokai' : 'default'}
mode="javascript"
lineNumbers={false}
type={'fxEditor'}
paramLabel={'Visibility'}
paramName={'visible'}
fxActive={localField.visibility?.fxActive ?? false}
fieldMeta={{
type: 'toggle',
displayName: 'Make editable',
}}
paramType={'toggle'}
onChange={(value) => handleFieldChange('visibility', value)}
onFxPress={(active) => handleFxChange('visibility', active)}
/>
</div>
)}
<Button
leadingIcon={mode === 'edit' ? 'save' : 'plus'}
variant="secondary"
onClick={handleSubmit}
className="tw-w-full tw-rounded-[6px]"
disabled={field && isEqual(localField, field)}
>
{mode === 'edit' ? 'Save' : 'Add Field'}
</Button>
</div>
</Popover.Body>
</>
);
};
export default React.memo(FieldPopoverContent);

View file

@ -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 = (
<Popover id="popover-basic" className="shadow form-fields-column-popover">
<FieldPopoverContent
field={fieldData}
mode="edit"
onClose={() => setShowPopover(false)}
onChange={handleFieldChange}
onDropdownOpen={handleDropdownOpen}
onDropdownClose={handleDropdownClose}
shouldPreventPopoverClose={shouldPreventPopoverClose}
setSelectedComponents={setSelectedComponents}
/>
</Popover>
);
const menuPopover = (
<Popover id="menu-popover" className="shadow">
<Popover.Body className="tw-p-2">
<div className="tw-flex tw-flex-col">
<Button
variant="ghost"
size="default"
onClick={(e) => {
e.stopPropagation();
const newValue = !isCurrentlyMandatory;
handleFieldChange({
...fieldData,
mandatory:
typeof fieldData.mandatory === 'object'
? { ...fieldData.mandatory, value: `{{${newValue}}}` }
: `{{${newValue}}}`,
});
onMenuToggle(null);
}}
disabled={isMandatoryFxControlled}
className={`base-regular ${isMandatoryFxControlled ? 'tw-opacity-50' : ''}`}
leadingIcon="asterix"
fill="#CCD1D5"
>
{isCurrentlyMandatory ? 'Make optional' : 'Make mandatory'}
</Button>
<Button
variant="ghost"
size="default"
onClick={(e) => {
e.stopPropagation();
onMenuToggle(null);
setSelectedComponents([field.componentId]);
}}
className="base-regular"
leadingIcon="inspect"
fill="#CCD1D5"
>
View properties and styles
</Button>
<Button
variant="ghost"
size="default"
onClick={(e) => {
e.stopPropagation();
onDelete(fieldData);
onMenuToggle(null);
}}
className="base-regular"
leadingIcon="remove"
fill="#CCD1D5"
>
Remove from form
</Button>
</div>
</Popover.Body>
</Popover>
);
return (
<div className="tw-relative tw-group">
<OverlayTrigger
trigger="click"
placement="left"
show={showPopover}
onToggle={(show) => {
if (!show && shouldPreventPopoverClose) {
return;
}
if (show) onMenuToggle(null);
setShowPopover(show);
}}
rootClose
overlay={mainPopover}
>
<div
className={`field-item tw-flex tw-items-center tw-justify-between tw-gap-2 hover:tw-cursor-pointer ${
(fieldData.name === activeMenu || showPopover) && 'selected'
}`}
>
<div className="tw-flex tw-items-center tw-gap-[6px] tw-flex-1" style={{ width: 'calc(100% - 100px)' }}>
<div className="field-icon tw-w-6 tw-h-6 tw-flex tw-items-center tw-justify-center tw-rounded tw-bg-gray-100">
{getComponentIcon(fieldData.componentType, darkMode)}
</div>
<span className="field-name tw-text-sm base-regular">{fieldData.name}</span>
</div>
<OverlayTrigger
trigger="click"
placement="bottom-start"
show={activeMenu === fieldData.name}
onToggle={(show) => {
setShowPopover(false);
if (show) {
onMenuToggle(fieldData.name);
} else {
onMenuToggle(null);
}
}}
rootClose
overlay={menuPopover}
>
<Button
iconOnly
leadingIcon="morevertical"
variant="ghost"
size="default"
className="tw-opacity-0 group-hover:tw-opacity-100 more-btn"
onClick={(e) => e.stopPropagation()}
/>
</OverlayTrigger>
</div>
</OverlayTrigger>
</div>
);
};

View file

@ -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 (
<span className="base-regular text-placeholder tw-block tw-p-3 tw-text-center">
No fields yet. Generate a form from a data source or add custom fields.
</span>
);
}
return (
<div className="tw-flex tw-flex-col" style={{ maxHeight: '400px' }}>
<div className="tw-flex-grow tw-overflow-y-auto tw-max-h-[calc(100%-50px)]">
<div className="tw-flex tw-flex-col tw-gap-1">
{fields.map((field) => (
<FormField
key={field.name}
field={field}
activeMenu={activeMenuField}
onMenuToggle={(fieldName) => {
currentStatusRef.current = null;
setActiveMenuField(fieldName);
}}
onDelete={onDeleteField}
onSave={onSave}
/>
))}
</div>
</div>
</div>
);
};

Some files were not shown because too many files have changed in this diff Show more