mirror of
https://github.com/ToolJet/ToolJet
synced 2026-05-06 06:48:21 +00:00
Merge branch 'main' into release/marketplace-sprint-12
This commit is contained in:
commit
d9b214add8
814 changed files with 30327 additions and 7664 deletions
10
.env.example
10
.env.example
|
|
@ -92,3 +92,13 @@ ENABLE_PRIVATE_APP_EMBED=
|
|||
|
||||
#Enable cors else restricted to TOOLJET_HOST. Set the value true if you are serving front end from diffrent host
|
||||
ENABLE_CORS=
|
||||
|
||||
# cloud specific variables
|
||||
ORGANIZATION_LICENSE_URL=
|
||||
ORGANIZATION_LICENSE_API_KEY=
|
||||
|
||||
#pat session expiry in minutes
|
||||
PAT_SESSION_EXPIRY=
|
||||
|
||||
#pat expiry in days
|
||||
PAT_EXPIRY=
|
||||
|
|
|
|||
4
.github/workflows/docker-release.yml
vendored
4
.github/workflows/docker-release.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
57
.github/workflows/manual-docker-build.yml
vendored
Normal file
57
.github/workflows/manual-docker-build.yml
vendored
Normal 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 }}
|
||||
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"]',
|
||||
|
|
|
|||
|
|
@ -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"]`
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,662 @@
|
|||
import { fake } from "Fixtures/fake";
|
||||
import { commonSelectors, commonWidgetSelector } from "Selectors/common";
|
||||
import { commonEeText, ssoEeText } from "Texts/eeCommon";
|
||||
import { commonEeSelectors, multiEnvSelector } from "Selectors/eeCommon";
|
||||
import {
|
||||
verifyPromoteModalUI,
|
||||
verifyTooltipDisabled,
|
||||
} from "Support/utils/platform/eeCommon";
|
||||
import { dataSourceSelector } from "Selectors/dataSource";
|
||||
import {
|
||||
navigateToAppEditor,
|
||||
pinInspector,
|
||||
verifyTooltip,
|
||||
} from "Support/utils/common";
|
||||
import { addQuery, selectDatasource } from "Support/utils/dataSource";
|
||||
import {
|
||||
appPromote,
|
||||
createNewVersion,
|
||||
selectVersion,
|
||||
selectEnv,
|
||||
} from "Support/utils/platform/multiEnv";
|
||||
import { appVersionSelectors } from "Selectors/exportImport";
|
||||
|
||||
import { editAndVerifyWidgetName } from "Support/utils/commonWidget";
|
||||
import { deleteVersionAndVerify } from "Support/utils/version";
|
||||
import { deleteVersionText } from "Texts/version";
|
||||
|
||||
describe("Multi env", () => {
|
||||
const data = {};
|
||||
data.appName = `${fake.companyName} App`;
|
||||
data.ds = fake.lastName.toLowerCase().replaceAll("[^A-Za-z]", "");
|
||||
data.constName = fake.firstName.toLowerCase().replaceAll("[^A-Za-z]", "");
|
||||
const slug = data.appName.toLowerCase().replace(/\s+/g, "-");
|
||||
let currentVersion = "";
|
||||
let newVersion = [];
|
||||
let versionFrom = "";
|
||||
|
||||
beforeEach(() => {
|
||||
cy.apiLogin();
|
||||
cy.viewport(1800, 1800);
|
||||
cy.skipWalkthrough();
|
||||
});
|
||||
|
||||
it.only("Verify the datasource configuration and data on each env", () => {
|
||||
cy.apiCreateGDS(
|
||||
`${Cypress.env("server_host")}/api/data-sources`,
|
||||
data.ds,
|
||||
"restapi",
|
||||
[
|
||||
{ key: "url", value: "" },
|
||||
{ key: "auth_type", value: "none" },
|
||||
{ key: "grant_type", value: "authorization_code" },
|
||||
{ key: "add_token_to", value: "header" },
|
||||
{ key: "header_prefix", value: "Bearer " },
|
||||
{ key: "access_token_url", value: "" },
|
||||
{ key: "client_ide", value: "" },
|
||||
{ key: "client_secret", value: "", encrypted: true },
|
||||
{ key: "scopes", value: "read, write" },
|
||||
{ key: "username", value: "", encrypted: false },
|
||||
{ key: "password", value: "", encrypted: true },
|
||||
{ key: "bearer_token", value: "", encrypted: true },
|
||||
{ key: "auth_url", value: "" },
|
||||
{ key: "client_auth", value: "header" },
|
||||
{ key: "headers", value: [["", ""]] },
|
||||
{ key: "custom_query_params", value: [["", ""]], encrypted: false },
|
||||
{ key: "custom_auth_params", value: [["", ""]] },
|
||||
{
|
||||
key: "access_token_custom_headers",
|
||||
value: [["", ""]],
|
||||
encrypted: false,
|
||||
},
|
||||
{ key: "multiple_auth_enabled", value: false, encrypted: false },
|
||||
{ key: "ssl_certificate", value: "none", encrypted: false },
|
||||
]
|
||||
);
|
||||
cy.apiCreateApp(data.appName);
|
||||
cy.visit("/my-workspace");
|
||||
cy.get(commonSelectors.globalDataSourceIcon).click();
|
||||
selectDatasource(data.ds);
|
||||
cy.get('[data-cy="development-label"]').click();
|
||||
cy.clearAndType(
|
||||
'[data-cy="base-url-text-field"]',
|
||||
"https://reqres.in/api/users?page=1"
|
||||
);
|
||||
cy.get(dataSourceSelector.buttonSave).click();
|
||||
cy.wait(2000);
|
||||
|
||||
cy.get(commonSelectors.dashboardIcon).click();
|
||||
|
||||
cy.openApp();
|
||||
// cy.waitForAppLoad();
|
||||
cy.wait(2000);
|
||||
cy.get(`[data-cy="${data.ds}-add-query-card"] > .text-truncate`).click();
|
||||
cy.wait(1000);
|
||||
cy.get(dataSourceSelector.queryCreateAndRunButton).click();
|
||||
cy.get('[data-cy="query-tab-settings"]').click();
|
||||
cy.get(':nth-child(1) > .custom-toggle-switch > .switch > .slider').click();
|
||||
cy.waitForAutoSave();
|
||||
|
||||
cy.dragAndDropWidget("Text Input", 550, 650);
|
||||
editAndVerifyWidgetName(data.constName, []);
|
||||
cy.waitForAutoSave();
|
||||
|
||||
cy.get(
|
||||
'[data-cy="default-value-input-field"]'
|
||||
).clearAndTypeOnCodeMirror(`{{queries.restapi1.data.data[0].email`);
|
||||
cy.wait(1000);
|
||||
cy.forceClickOnCanvas();
|
||||
cy.waitForAutoSave();
|
||||
cy.get(dataSourceSelector.queryCreateAndRunButton).click();
|
||||
cy.get(
|
||||
commonWidgetSelector.draggableWidget(data.constName)
|
||||
).verifyVisibleElement("have.value", "george.bluth@reqres.in");
|
||||
|
||||
pinInspector();
|
||||
cy.get(commonWidgetSelector.sidebarinspector).click();
|
||||
cy.get(commonWidgetSelector.inspectorNodeComponents).click();
|
||||
cy.get(commonWidgetSelector.nodeComponent(data.constName)).click();
|
||||
cy.get('[data-cy="inspector-node-value"] > .mx-2').verifyVisibleElement(
|
||||
"have.text",
|
||||
`"george.bluth@reqres.in"`
|
||||
);
|
||||
cy.get('[style="height: 13px; width: 13px;"] > img').should("exist");
|
||||
cy.get('[data-cy="inspector-node-globals"] > .node-key').click();
|
||||
cy.get('[data-cy="inspector-node-environment"] > .node-key').click();
|
||||
cy.get('[data-cy="inspector-node-name"] > .mx-2').verifyVisibleElement(
|
||||
"have.text",
|
||||
`"development"`
|
||||
);
|
||||
|
||||
cy.openInCurrentTab(commonWidgetSelector.previewButton);
|
||||
cy.wait(4000);
|
||||
|
||||
cy.get(
|
||||
commonWidgetSelector.draggableWidget(data.constName)
|
||||
).verifyVisibleElement("have.value", "george.bluth@reqres.in");
|
||||
|
||||
cy.go("back");
|
||||
cy.waitForAppLoad();
|
||||
cy.wait(3000);
|
||||
cy.get(commonEeSelectors.promoteButton).click();
|
||||
cy.get(commonEeSelectors.promoteButton).eq(1).click();
|
||||
cy.waitForAppLoad();
|
||||
cy.wait(3000);
|
||||
|
||||
cy.get(dataSourceSelector.queryCreateAndRunButton, {
|
||||
timeout: 20000,
|
||||
}).click();
|
||||
cy.verifyToastMessage(
|
||||
commonSelectors.toastMessage,
|
||||
"Query could not be completed"
|
||||
);
|
||||
|
||||
cy.backToApps();
|
||||
cy.get(commonSelectors.globalDataSourceIcon).click();
|
||||
selectDatasource(data.ds);
|
||||
cy.get('[data-cy="staging-label"]').click();
|
||||
cy.clearAndType(
|
||||
'[data-cy="base-url-text-field"]',
|
||||
"https://reqres.in/api/users?page=2"
|
||||
);
|
||||
cy.get(dataSourceSelector.buttonSave).click();
|
||||
cy.wait(2000);
|
||||
|
||||
cy.get(commonSelectors.dashboardIcon).click();
|
||||
navigateToAppEditor(data.appName);
|
||||
cy.get(dataSourceSelector.queryCreateAndRunButton).click();
|
||||
cy.get(
|
||||
commonWidgetSelector.draggableWidget(data.constName)
|
||||
).verifyVisibleElement("have.value", "michael.lawson@reqres.in");
|
||||
|
||||
cy.get(commonWidgetSelector.sidebarinspector).click();
|
||||
cy.get(commonWidgetSelector.inspectorNodeComponents).click();
|
||||
cy.get(commonWidgetSelector.nodeComponent(data.constName)).click();
|
||||
cy.get('[data-cy="inspector-node-value"] > .mx-2').verifyVisibleElement(
|
||||
"have.text",
|
||||
`"michael.lawson@reqres.in"`
|
||||
);
|
||||
cy.get('[style="height: 13px; width: 13px;"] > img').should("not.exist");
|
||||
cy.get('[data-cy="inspector-node-globals"] > .node-key').click();
|
||||
cy.get('[data-cy="inspector-node-environment"] > .node-key').click();
|
||||
cy.get('[data-cy="inspector-node-name"] > .mx-2').verifyVisibleElement(
|
||||
"have.text",
|
||||
`"staging"`
|
||||
);
|
||||
|
||||
cy.openInCurrentTab(commonWidgetSelector.previewButton);
|
||||
cy.wait(4000);
|
||||
|
||||
cy.get(
|
||||
commonWidgetSelector.draggableWidget(data.constName)
|
||||
).verifyVisibleElement("have.value", "michael.lawson@reqres.in");
|
||||
|
||||
cy.go("back");
|
||||
cy.waitForAppLoad();
|
||||
cy.wait(3000);
|
||||
cy.get(commonEeSelectors.promoteButton).click();
|
||||
cy.get(commonEeSelectors.promoteButton).eq(1).click();
|
||||
cy.waitForAppLoad();
|
||||
cy.wait(3000);
|
||||
|
||||
cy.get(dataSourceSelector.queryCreateAndRunButton, {
|
||||
timeout: 20000,
|
||||
}).click();
|
||||
cy.verifyToastMessage(
|
||||
commonSelectors.toastMessage,
|
||||
"Query could not be completed"
|
||||
);
|
||||
|
||||
cy.backToApps();
|
||||
cy.get(commonSelectors.globalDataSourceIcon).click();
|
||||
selectDatasource(data.ds);
|
||||
cy.get('[data-cy="production-label"]').click();
|
||||
cy.clearAndType(
|
||||
'[data-cy="base-url-text-field"]',
|
||||
"https://reqres.in/api/users?page=1"
|
||||
);
|
||||
cy.get(dataSourceSelector.buttonSave).click();
|
||||
cy.wait(2000);
|
||||
|
||||
cy.get(commonSelectors.dashboardIcon).click();
|
||||
navigateToAppEditor(data.appName);
|
||||
cy.get(dataSourceSelector.queryCreateAndRunButton).click();
|
||||
cy.get(
|
||||
commonWidgetSelector.draggableWidget(data.constName)
|
||||
).verifyVisibleElement("have.value", "george.bluth@reqres.in");
|
||||
|
||||
cy.get(commonWidgetSelector.sidebarinspector).click();
|
||||
cy.get(commonWidgetSelector.inspectorNodeComponents).click();
|
||||
cy.get(commonWidgetSelector.nodeComponent(data.constName)).click();
|
||||
cy.get('[data-cy="inspector-node-value"] > .mx-2').verifyVisibleElement(
|
||||
"have.text",
|
||||
`"george.bluth@reqres.in"`
|
||||
);
|
||||
cy.get('[style="height: 13px; width: 13px;"] > img').should("not.exist");
|
||||
cy.get('[data-cy="inspector-node-globals"] > .node-key').click();
|
||||
cy.get('[data-cy="inspector-node-environment"] > .node-key').click();
|
||||
cy.get('[data-cy="inspector-node-name"] > .mx-2').verifyVisibleElement(
|
||||
"have.text",
|
||||
`"production"`
|
||||
);
|
||||
|
||||
cy.openInCurrentTab(commonWidgetSelector.previewButton);
|
||||
cy.wait(4000);
|
||||
|
||||
cy.get(
|
||||
commonWidgetSelector.draggableWidget(data.constName)
|
||||
).verifyVisibleElement("have.value", "george.bluth@reqres.in");
|
||||
|
||||
cy.go("back");
|
||||
cy.waitForAppLoad();
|
||||
cy.wait(3000);
|
||||
cy.get(commonSelectors.releaseButton).click();
|
||||
cy.get(commonSelectors.yesButton).click();
|
||||
cy.verifyToastMessage(commonSelectors.toastMessage, "Version v1 released");
|
||||
cy.wait(4000);
|
||||
|
||||
cy.get(commonWidgetSelector.shareAppButton).click();
|
||||
cy.clearAndType(commonWidgetSelector.appNameSlugInput, `${slug}`);
|
||||
cy.wait(2000);
|
||||
cy.get(commonWidgetSelector.modalCloseButton).click();
|
||||
|
||||
cy.visit(`/applications/${slug}`);
|
||||
cy.get(
|
||||
commonWidgetSelector.draggableWidget(data.constName)
|
||||
).verifyVisibleElement("have.value", "george.bluth@reqres.in");
|
||||
});
|
||||
|
||||
it("should verify edit privilages of a promoted version", () => {
|
||||
data.appName = `${fake.companyName} App`;
|
||||
cy.apiCreateApp(data.appName);
|
||||
cy.openApp();
|
||||
cy.waitForAppLoad();
|
||||
cy.dragAndDropWidget("Text", 550, 650);
|
||||
appPromote("development", "production");
|
||||
|
||||
createNewVersion(
|
||||
(currentVersion = "v1"),
|
||||
(newVersion = ["v2"]),
|
||||
(versionFrom = "v1")
|
||||
);
|
||||
appPromote("development", "release");
|
||||
|
||||
createNewVersion(
|
||||
(currentVersion = "v2"),
|
||||
(newVersion = ["v3"]),
|
||||
(versionFrom = "v2")
|
||||
);
|
||||
appPromote("development", "staging");
|
||||
|
||||
selectVersion((currentVersion = "v3"), (newVersion = ["v1"]));
|
||||
cy.get(commonSelectors.warningText).eq(0).verifyVisibleElement(
|
||||
"have.text",
|
||||
"App cannot be edited after promotion. Please create a new version from Development to make any changes."
|
||||
);
|
||||
|
||||
cy.forceClickOnCanvas();
|
||||
cy.get(".datasource-picker").should("have.class", "disabled");
|
||||
cy.get(commonEeSelectors.AddQueryButton).should("be.disabled");
|
||||
cy.get(".components-container").should("have.class", "disabled");
|
||||
|
||||
cy.wait(1000);
|
||||
selectEnv("development");
|
||||
cy.get(commonSelectors.warningText).eq(0).verifyVisibleElement(
|
||||
"have.text",
|
||||
"App cannot be edited after promotion. Please create a new version from Development to make any changes."
|
||||
);
|
||||
cy.get(".datasource-picker").should("have.class", "disabled");
|
||||
cy.get(commonEeSelectors.AddQueryButton).should("be.disabled");
|
||||
cy.get(".components-container").should("have.class", "disabled");
|
||||
|
||||
selectVersion((currentVersion = "v1"), (newVersion = ["v2"]));
|
||||
cy.get(commonSelectors.warningText).eq(0).verifyVisibleElement(
|
||||
"have.text",
|
||||
"This version of the app is released. Please create a new version in development to make any changes."
|
||||
);
|
||||
cy.get(".datasource-picker").should("have.class", "disabled");
|
||||
cy.get(commonEeSelectors.AddQueryButton).should("be.disabled");
|
||||
cy.get(".components-container").should("have.class", "disabled");
|
||||
|
||||
cy.wait(1000);
|
||||
selectEnv("staging");
|
||||
cy.get(commonSelectors.warningText).eq(0).verifyVisibleElement(
|
||||
"have.text",
|
||||
"This version of the app is released. Please create a new version in development to make any changes."
|
||||
);
|
||||
cy.get(".datasource-picker").should("have.class", "disabled");
|
||||
cy.get(commonEeSelectors.AddQueryButton).should("be.disabled");
|
||||
cy.get(".components-container").should("have.class", "disabled");
|
||||
|
||||
cy.wait(1000);
|
||||
selectEnv("production");
|
||||
cy.get(commonSelectors.warningText).eq(0).verifyVisibleElement(
|
||||
"have.text",
|
||||
"This version of the app is released. Please create a new version in development to make any changes."
|
||||
);
|
||||
cy.get(".datasource-picker").should("have.class", "disabled");
|
||||
cy.get(commonEeSelectors.AddQueryButton).should("be.disabled");
|
||||
cy.get(".components-container").should("have.class", "disabled");
|
||||
cy.get(commonSelectors.releaseButton).should("be.disabled");
|
||||
});
|
||||
|
||||
it("Should verify last exisiting version", () => {
|
||||
data.appName = `${fake.companyName} App`;
|
||||
cy.apiCreateApp(data.appName);
|
||||
cy.openApp();
|
||||
cy.waitForAppLoad();
|
||||
cy.dragAndDropWidget("Text", 550, 650);
|
||||
|
||||
appPromote("development", "staging");
|
||||
createNewVersion(
|
||||
(currentVersion = "v1"),
|
||||
(newVersion = ["v2"]),
|
||||
(versionFrom = "v1")
|
||||
);
|
||||
|
||||
selectVersion((currentVersion = "v2"), (newVersion = ["v1"]));
|
||||
|
||||
cy.wait(1000);
|
||||
selectEnv("staging");
|
||||
|
||||
cy.get(appVersionSelectors.currentVersionField(newVersion[0]))
|
||||
.should("be.visible")
|
||||
.and("have.text", "v1");
|
||||
|
||||
appPromote("staging", "production");
|
||||
|
||||
cy.wait(3000)
|
||||
deleteVersionAndVerify(
|
||||
(currentVersion = "v1"),
|
||||
deleteVersionText.deleteToastMessage((currentVersion = "v1"))
|
||||
);
|
||||
|
||||
cy.wait(2000);
|
||||
cy.get('[data-cy="list-current-env-name"]').click();
|
||||
verifyTooltip(
|
||||
'[data-cy="env-name-dropdown"]:eq(1)',
|
||||
"There are no versions in this environment"
|
||||
);
|
||||
verifyTooltip(
|
||||
'[data-cy="env-name-dropdown"]:eq(2)',
|
||||
"There are no versions in this environment"
|
||||
);
|
||||
});
|
||||
|
||||
it("Should verify version deletion", () => {
|
||||
data.appName = `${fake.companyName} App`;
|
||||
cy.apiCreateApp(data.appName);
|
||||
cy.openApp();
|
||||
cy.waitForAppLoad();
|
||||
cy.dragAndDropWidget("Text", 550, 650);
|
||||
|
||||
appPromote("development", "staging");
|
||||
createNewVersion(
|
||||
(currentVersion = "v1"),
|
||||
(newVersion = ["v2"]),
|
||||
(versionFrom = "v1")
|
||||
);
|
||||
appPromote("development", "staging");
|
||||
|
||||
createNewVersion(
|
||||
(currentVersion = "v2"),
|
||||
(newVersion = ["v3"]),
|
||||
(versionFrom = "v2")
|
||||
);
|
||||
appPromote("development", "production");
|
||||
|
||||
selectEnv("staging");
|
||||
selectVersion((currentVersion = "v3"), (newVersion = ["v2"]));
|
||||
deleteVersionAndVerify(
|
||||
(currentVersion = "v2"),
|
||||
deleteVersionText.deleteToastMessage((currentVersion = "v2"))
|
||||
);
|
||||
|
||||
cy.get('[data-cy="v3-current-version-text"]')
|
||||
.should("be.visible")
|
||||
.and("have.text", "v3");
|
||||
|
||||
cy.get('[data-cy="list-current-env-name"]').should(
|
||||
"have.text",
|
||||
"Staging"
|
||||
);
|
||||
})
|
||||
|
||||
it("Verify the multi env components UI", () => {
|
||||
data.appName = `${fake.companyName} App`;
|
||||
cy.apiCreateApp(data.appName);
|
||||
cy.openApp();
|
||||
cy.waitForAppLoad();
|
||||
cy.dragAndDropWidget("Text", 550, 650);
|
||||
cy.get(multiEnvSelector.envContainer).should("be.visible");
|
||||
cy.get(multiEnvSelector.currentEnvName)
|
||||
.verifyVisibleElement("have.text", "Development")
|
||||
.click();
|
||||
cy.get(multiEnvSelector.envArrow).should("be.visible");
|
||||
cy.get(multiEnvSelector.selectedEnvName).verifyVisibleElement(
|
||||
"have.text",
|
||||
" Development"
|
||||
);
|
||||
cy.get(multiEnvSelector.envNameList)
|
||||
.eq(0)
|
||||
.verifyVisibleElement("have.text", "Development");
|
||||
cy.get(multiEnvSelector.envNameList)
|
||||
.eq(1)
|
||||
.verifyVisibleElement("have.text", "Staging");
|
||||
cy.get(multiEnvSelector.envNameList)
|
||||
.eq(2)
|
||||
.verifyVisibleElement("have.text", "Production");
|
||||
|
||||
verifyTooltip(
|
||||
'[data-cy="env-name-dropdown"]:eq(1)',
|
||||
"There are no versions in this environment"
|
||||
);
|
||||
verifyTooltip(
|
||||
'[data-cy="env-name-dropdown"]:eq(2)',
|
||||
"There are no versions in this environment"
|
||||
);
|
||||
|
||||
cy.get(multiEnvSelector.appVersionLabel).should("be.visible");
|
||||
cy.get('[data-cy="v1-current-version-text"]')
|
||||
.verifyVisibleElement("have.text", "v1")
|
||||
.click();
|
||||
cy.get(multiEnvSelector.currentVersion).verifyVisibleElement(
|
||||
"have.text",
|
||||
"v1"
|
||||
);
|
||||
cy.get(".col-10 > .app-version-name").verifyVisibleElement(
|
||||
"have.text",
|
||||
"v1"
|
||||
);
|
||||
cy.get(multiEnvSelector.createNewVersionButton).verifyVisibleElement(
|
||||
"have.text",
|
||||
"Create new version"
|
||||
);
|
||||
|
||||
verifyPromoteModalUI("v1", "Development", "Staging");
|
||||
cy.get('[data-cy="env-change-info-text"]').verifyVisibleElement(
|
||||
"have.text",
|
||||
"You won’t be able to edit this version after promotion. Are you sure you want to continue?"
|
||||
);
|
||||
cy.get(commonSelectors.closeButton).click();
|
||||
cy.get(multiEnvSelector.currentEnvName).verifyVisibleElement(
|
||||
"have.text",
|
||||
"Development"
|
||||
);
|
||||
|
||||
cy.get(commonEeSelectors.promoteButton).click();
|
||||
cy.get(commonSelectors.cancelButton).click();
|
||||
cy.get(multiEnvSelector.currentEnvName).verifyVisibleElement(
|
||||
"have.text",
|
||||
"Development"
|
||||
);
|
||||
|
||||
cy.get(commonEeSelectors.promoteButton).click();
|
||||
cy.get(commonEeSelectors.promoteButton).eq(1).click();
|
||||
|
||||
cy.waitForAppLoad();
|
||||
cy.wait(3000);
|
||||
|
||||
cy.get(commonSelectors.warningText).eq(0).verifyVisibleElement(
|
||||
"have.text",
|
||||
"App cannot be edited after promotion. Please create a new version from Development to make any changes."
|
||||
);
|
||||
cy.get(multiEnvSelector.envContainer).should("be.visible");
|
||||
cy.get(multiEnvSelector.currentEnvName)
|
||||
.verifyVisibleElement("have.text", "Staging")
|
||||
.click();
|
||||
cy.get(multiEnvSelector.envArrow).should("be.visible");
|
||||
cy.get(multiEnvSelector.currentEnvName).verifyVisibleElement(
|
||||
"have.text",
|
||||
"Staging"
|
||||
);
|
||||
cy.get(multiEnvSelector.envNameList)
|
||||
.eq(0)
|
||||
.verifyVisibleElement("have.text", "Development");
|
||||
cy.get(multiEnvSelector.envNameList)
|
||||
.eq(1)
|
||||
.verifyVisibleElement("have.text", "Staging");
|
||||
cy.get(multiEnvSelector.envNameList)
|
||||
.eq(2)
|
||||
.verifyVisibleElement("have.text", "Production");
|
||||
cy.wait(2000)
|
||||
verifyTooltip(
|
||||
'[data-cy="env-name-dropdown"]:eq(2)',
|
||||
"There are no versions in this environment"
|
||||
);
|
||||
|
||||
cy.get(multiEnvSelector.appVersionLabel).should("be.visible");
|
||||
cy.get('[data-cy="v1-current-version-text"]')
|
||||
.verifyVisibleElement("have.text", "v1")
|
||||
.click();
|
||||
cy.get(multiEnvSelector.currentVersion).verifyVisibleElement(
|
||||
"have.text",
|
||||
"v1"
|
||||
);
|
||||
cy.get(".col-10 > .app-version-name").verifyVisibleElement(
|
||||
"have.text",
|
||||
"v1"
|
||||
);
|
||||
cy.get(multiEnvSelector.createNewVersionButton).verifyVisibleElement(
|
||||
"have.text",
|
||||
"Create new version"
|
||||
);
|
||||
|
||||
verifyTooltip(
|
||||
multiEnvSelector.createNewVersionButton,
|
||||
"New versions can only be created in development"
|
||||
);
|
||||
cy.forceClickOnCanvas();
|
||||
cy.get(".datasource-picker").should("have.class", "disabled");
|
||||
cy.get(commonEeSelectors.AddQueryButton).should("be.disabled");
|
||||
cy.get(".components-container").should("have.class", "disabled");
|
||||
|
||||
verifyPromoteModalUI("v1", "Staging", "Production");
|
||||
cy.get(commonSelectors.closeButton).click();
|
||||
cy.get(multiEnvSelector.currentEnvName).verifyVisibleElement(
|
||||
"have.text",
|
||||
"Staging"
|
||||
);
|
||||
|
||||
cy.get(commonEeSelectors.promoteButton).click();
|
||||
cy.get(commonSelectors.cancelButton).click();
|
||||
cy.get(multiEnvSelector.currentEnvName).verifyVisibleElement(
|
||||
"have.text",
|
||||
"Staging"
|
||||
);
|
||||
|
||||
cy.get(commonEeSelectors.promoteButton).click();
|
||||
cy.get(commonEeSelectors.promoteButton).eq(1).click();
|
||||
cy.waitForAppLoad();
|
||||
cy.wait(3000);
|
||||
|
||||
cy.get(commonSelectors.warningText).eq(0).verifyVisibleElement(
|
||||
"have.text",
|
||||
"App cannot be edited after promotion. Please create a new version from Development to make any changes."
|
||||
);
|
||||
cy.get(multiEnvSelector.envContainer).should("be.visible");
|
||||
cy.get(multiEnvSelector.currentEnvName)
|
||||
.verifyVisibleElement("have.text", "Production")
|
||||
.click();
|
||||
cy.get(multiEnvSelector.envArrow).should("be.visible");
|
||||
cy.get(multiEnvSelector.currentEnvName).verifyVisibleElement(
|
||||
"have.text",
|
||||
"Production"
|
||||
);
|
||||
cy.get(multiEnvSelector.envNameList)
|
||||
.eq(0)
|
||||
.verifyVisibleElement("have.text", "Development");
|
||||
cy.get(multiEnvSelector.envNameList)
|
||||
.eq(1)
|
||||
.verifyVisibleElement("have.text", "Staging");
|
||||
cy.get(multiEnvSelector.envNameList)
|
||||
.eq(2)
|
||||
.verifyVisibleElement("have.text", "Production");
|
||||
|
||||
cy.get(multiEnvSelector.appVersionLabel).should("be.visible");
|
||||
cy.get('[data-cy="v1-current-version-text"]')
|
||||
.verifyVisibleElement("have.text", "v1")
|
||||
.click();
|
||||
cy.get(multiEnvSelector.currentVersion).verifyVisibleElement(
|
||||
"have.text",
|
||||
"v1"
|
||||
);
|
||||
cy.get(".col-10 > .app-version-name").verifyVisibleElement(
|
||||
"have.text",
|
||||
"v1"
|
||||
);
|
||||
cy.get(multiEnvSelector.createNewVersionButton).verifyVisibleElement(
|
||||
"have.text",
|
||||
"Create new version"
|
||||
);
|
||||
|
||||
cy.get(commonSelectors.releaseButton)
|
||||
.verifyVisibleElement("have.text", "Release")
|
||||
.click();
|
||||
cy.get('[data-cy="modal-title"]').verifyVisibleElement(
|
||||
"have.text",
|
||||
"Release Version"
|
||||
);
|
||||
cy.get(commonSelectors.closeButton).should("be.visible");
|
||||
cy.get('[data-cy="confirm-dialogue-box-text"]').verifyVisibleElement(
|
||||
"have.text",
|
||||
"Are you sure you want to release this version?"
|
||||
);
|
||||
cy.get(commonSelectors.cancelButton).verifyVisibleElement(
|
||||
"have.text",
|
||||
"Cancel"
|
||||
);
|
||||
cy.get(commonSelectors.yesButton).verifyVisibleElement("have.text", "Yes");
|
||||
|
||||
cy.get(commonSelectors.closeButton).click();
|
||||
cy.get(multiEnvSelector.currentEnvName).verifyVisibleElement(
|
||||
"have.text",
|
||||
"Production"
|
||||
);
|
||||
|
||||
cy.get(commonSelectors.releaseButton).click();
|
||||
cy.get(commonSelectors.cancelButton).click();
|
||||
cy.get(multiEnvSelector.currentEnvName).verifyVisibleElement(
|
||||
"have.text",
|
||||
"Production"
|
||||
);
|
||||
|
||||
cy.get(commonSelectors.releaseButton).click();
|
||||
cy.get(commonSelectors.yesButton).click();
|
||||
cy.verifyToastMessage(commonSelectors.toastMessage, "Version v1 released");
|
||||
cy.wait(500);
|
||||
cy.get(commonSelectors.warningText).eq(0).verifyVisibleElement(
|
||||
"have.text",
|
||||
"This version of the app is released. Please create a new version in development to make any changes."
|
||||
);
|
||||
cy.get('[data-cy="v1-current-version-text"]').click();
|
||||
verifyTooltip(
|
||||
multiEnvSelector.createNewVersionButton,
|
||||
"New versions can only be created in development"
|
||||
);
|
||||
cy.get(".datasource-picker").should("have.class", "disabled");
|
||||
cy.get(commonEeSelectors.AddQueryButton).should("be.disabled");
|
||||
cy.get(".components-container").should("have.class", "disabled");
|
||||
cy.get(commonSelectors.releaseButton).should("be.disabled");
|
||||
});
|
||||
});
|
||||
|
|
@ -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];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
120
cypress-tests/cypress/support/utils/platform/multiEnv.js
Normal file
120
cypress-tests/cypress/support/utils/platform/multiEnv.js
Normal 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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
0
docker/cloud/cloud-entrypoint.sh
Normal file → Executable 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"]
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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"
|
||||
9
frontend/assets/images/icons/empty-modules.svg
Normal file
9
frontend/assets/images/icons/empty-modules.svg
Normal 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 |
|
|
@ -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
|
||||
85
frontend/package-lock.json
generated
85
frontend/package-lock.json
generated
|
|
@ -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 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
&:focus-visible{
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&.page-container {
|
||||
&.position-top {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
28
frontend/src/AppBuilder/AppCanvas/userRightSidebarMargin.jsx
Normal file
28
frontend/src/AppBuilder/AppCanvas/userRightSidebarMargin.jsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { DropdownMenu as default } from './DropdownMenu';
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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% ;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -367,6 +367,7 @@ export const FxParamTypeMapping = Object.freeze({
|
|||
visibility: 'Visibility',
|
||||
numberInput: 'NumberInput',
|
||||
tableRowHeightInput: 'TableRowHeightInput',
|
||||
dropdownMenu: 'DropdownMenu',
|
||||
query: 'Query',
|
||||
});
|
||||
|
||||
|
|
|
|||
73
frontend/src/AppBuilder/EmbedApp.jsx
Normal file
73
frontend/src/AppBuilder/EmbedApp.jsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
174
frontend/src/AppBuilder/QueryPanel/QueryCardMenu.jsx
Normal file
174
frontend/src/AppBuilder/QueryPanel/QueryCardMenu.jsx
Normal 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;
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
height: 36px;
|
||||
margin-bottom: 8px;
|
||||
margin-top: 16px;
|
||||
margin-left: 16px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.tj-tabs-container {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
Loading…
Reference in a new issue