diff --git a/.env.example b/.env.example index 1bb9ba3d0c..dd5dd755f9 100644 --- a/.env.example +++ b/.env.example @@ -92,3 +92,9 @@ 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= + +#pat session expiry in minutes +PAT_SESSION_EXPIRY= + +#pat expiry in days +PAT_EXPIRY= diff --git a/.github/workflows/cypress-platform.yml b/.github/workflows/cypress-platform.yml index d3374604f8..1f6d39997f 100644 --- a/.github/workflows/cypress-platform.yml +++ b/.github/workflows/cypress-platform.yml @@ -37,21 +37,9 @@ jobs: with: ref: ${{ github.event.pull_request.head.ref }} - # Create Docker Buildx builder with platform configuration - - name: Set up Docker Buildx - run: | - mkdir -p ~/.docker/cli-plugins - curl -SL https://github.com/docker/buildx/releases/download/v0.11.0/buildx-v0.11.0.linux-amd64 -o ~/.docker/cli-plugins/docker-buildx - chmod a+x ~/.docker/cli-plugins/docker-buildx - docker buildx create --name mybuilder --platform linux/arm64,linux/amd64 - docker buildx use mybuilder - - name: Set DOCKER_CLI_EXPERIMENTAL run: echo "DOCKER_CLI_EXPERIMENTAL=enabled" >> $GITHUB_ENV - - name: use mybuilder buildx - run: docker buildx use mybuilder - - name: Docker Login uses: docker/login-action@v2 with: diff --git a/.github/workflows/merging-pr.yml b/.github/workflows/merging-pr.yml index bb4f144eac..f091baf0ec 100644 --- a/.github/workflows/merging-pr.yml +++ b/.github/workflows/merging-pr.yml @@ -37,6 +37,40 @@ jobs: env: GH_TOKEN: ${{ secrets.TOKEN_PR }} + + update-submodule-sha: + needs: merge-submodules + if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' + runs-on: ubuntu-latest + + steps: + - name: Checkout base repo + uses: actions/checkout@v4 + with: + repository: ToolJet/ToolJet + token: ${{ secrets.TOKEN_PR }} + ref: main + submodules: recursive + + - name: Update submodules to latest main + run: | + git config user.name "adishM98 Bot" + git config user.email "adish.madhu@gmail.com" + + git submodule update --remote frontend/ee + git submodule update --remote server/ee + + git add frontend/ee server/ee + + if git diff --cached --quiet; then + echo "No submodule updates found." + else + git commit -m "🔄 chore: update submodules to latest main after auto-merge" + git push origin main + fi + env: + GH_TOKEN: ${{ secrets.TOKEN_PR }} + check-submodule-prs: if: github.event.action == 'labeled' && github.event.label.name == 'ready-to-merge' runs-on: ubuntu-latest diff --git a/.github/workflows/vulnerability-ci.yml b/.github/workflows/vulnerability-ci.yml index 568ab6df31..ca8c525ace 100644 --- a/.github/workflows/vulnerability-ci.yml +++ b/.github/workflows/vulnerability-ci.yml @@ -24,10 +24,10 @@ jobs: with: ref: refs/heads/main - - name: Use Node.js 18.18.2 + - name: Use Node.js 22.15.1 uses: actions/setup-node@v3 with: - node-version: 18.18.2 + node-version: 22.15.1 - name: Install dependencies run: npm --prefix frontend install @@ -75,10 +75,10 @@ jobs: with: ref: refs/heads/main - - name: Use Node.js 18.18.2 + - name: Use Node.js 22.15.1 uses: actions/setup-node@v3 with: - node-version: 18.18.2 + node-version: 22.15.1 - name: Install dependencies run: npm --prefix server install @@ -106,7 +106,7 @@ jobs: - name: Send Slack Notification run: | - message="### Periodic Security Audit Report Of Server directory\n + message="Periodic Security Audit Report Of Server directory\n Node module vulnerabilities summary:\n 🔴 Critical: ${{ steps.parse-audit.outputs.critical }}\n 🟠 High: ${{ steps.parse-audit.outputs.high }}\n @@ -126,10 +126,10 @@ jobs: with: ref: refs/heads/main - - name: Use Node.js 18.18.2 + - name: Use Node.js 22.15.1 uses: actions/setup-node@v3 with: - node-version: 18.18.2 + node-version: 22.15.1 - name: Install dependencies run: npm --prefix marketplace install @@ -177,10 +177,10 @@ jobs: with: ref: refs/heads/main - - name: Use Node.js 18.18.2 + - name: Use Node.js 22.15.1 uses: actions/setup-node@v3 with: - node-version: 18.18.2 + node-version: 22.15.1 - name: Install dependencies run: npm --prefix plugins install @@ -228,10 +228,10 @@ jobs: with: ref: refs/heads/main - - name: Use Node.js 18.18.2 + - name: Use Node.js 22.15.1 uses: actions/setup-node@v3 with: - node-version: 18.18.2 + node-version: 22.15.1 - name: Install dependencies run: npm --prefix cypress-tests install @@ -279,10 +279,10 @@ jobs: with: ref: refs/heads/main - - name: Use Node.js 18.18.2 + - name: Use Node.js 22.15.1 uses: actions/setup-node@v3 with: - node-version: 18.18.2 + node-version: 22.15.1 - name: Install dependencies run: npm install @@ -330,10 +330,10 @@ jobs: with: ref: ${{ github.event.pull_request.head.ref }} - - name: Use Node.js 18.18.2 + - name: Use Node.js 22.15.1 uses: actions/setup-node@v3 with: - node-version: 18.18.2 + node-version: 22.15.1 - name: Install dependencies run: npm --prefix frontend install @@ -385,10 +385,10 @@ jobs: with: ref: ${{ github.event.pull_request.head.ref }} - - name: Use Node.js 18.18.2 + - name: Use Node.js 22.15.1 uses: actions/setup-node@v3 with: - node-version: 18.18.2 + node-version: 22.15.1 - name: Install dependencies run: npm --prefix server install @@ -440,10 +440,10 @@ jobs: with: ref: ${{ github.event.pull_request.head.ref }} - - name: Use Node.js 18.18.2 + - name: Use Node.js 22.15.1 uses: actions/setup-node@v3 with: - node-version: 18.18.2 + node-version: 22.15.1 - name: Install dependencies run: npm --prefix marketplace install @@ -494,10 +494,10 @@ jobs: with: ref: ${{ github.event.pull_request.head.ref }} - - name: Use Node.js 18.18.2 + - name: Use Node.js 22.15.1 uses: actions/setup-node@v3 with: - node-version: 18.18.2 + node-version: 22.15.1 - name: Install dependencies run: npm --prefix plugins install @@ -550,10 +550,10 @@ jobs: with: ref: ${{ github.event.pull_request.head.ref }} - - name: Use Node.js 18.18.2 + - name: Use Node.js 22.15.1 uses: actions/setup-node@v3 with: - node-version: 18.18.2 + node-version: 22.15.1 - name: Install dependencies run: npm --prefix cypress-tests install @@ -606,10 +606,10 @@ jobs: with: ref: ${{ github.event.pull_request.head.ref }} - - name: Use Node.js 18.18.2 + - name: Use Node.js 22.15.1 uses: actions/setup-node@v3 with: - node-version: 18.18.2 + node-version: 22.15.1 - name: Install dependencies run: npm install @@ -648,4 +648,4 @@ jobs: 🟠 High: ${{ steps.parse-audit.outputs.high }} 🟡 Moderate: ${{ steps.parse-audit.outputs.moderate }} - Please find the JSON file in the [summary page](${{ github.root_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}). \ No newline at end of file + Please find the JSON file in the [summary page](${{ github.root_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}). diff --git a/.vscode/settings.json b/.vscode/settings.json index e13d54d778..ac6e6079cc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { "[javascript, typescript]": { - "editor.defaultFormatter": "dbaeumer.vscode-eslint", + "editor.defaultFormatter": "dbaeumer.vscode-eslint" }, "eslint.validate": [ "javascript", @@ -8,8 +8,8 @@ "typescript", "typescriptreact" ], - "eslint.format.enable": false, - "editor.formatOnSave": false, + "eslint.format.enable": true, + "editor.formatOnSave": true, "json.schemas": [ { "fileMatch": [ diff --git a/cypress-tests/cypress-ee-platform.config.js b/cypress-tests/cypress-ee-platform.config.js index 02b8c1d952..25aa7f6f15 100644 --- a/cypress-tests/cypress-ee-platform.config.js +++ b/cypress-tests/cypress-ee-platform.config.js @@ -98,7 +98,7 @@ module.exports = defineConfig({ configFile: environment.configFile, specPattern: [ "cypress/e2e/happyPath/platform/firstUser/firstUserOnboarding.cy.js", - "cypress/e2e/happyPath/platform/commonTestcases/**/*.cy.js", + "cypress/e2e/happyPath/platform/commonTestcases/userManagment/*.cy.js", "cypress/e2e/happyPath/platform/eeTestcases/**/*.cy.js", ], numTestsKeptInMemory: 1, diff --git a/cypress-tests/cypress/commands/apiCommands.js b/cypress-tests/cypress/commands/apiCommands.js index 62d72f7cdc..c8ca26fd2f 100644 --- a/cypress-tests/cypress/commands/apiCommands.js +++ b/cypress-tests/cypress/commands/apiCommands.js @@ -221,21 +221,21 @@ Cypress.Commands.add( const requestBody = envVar === "Enterprise" ? { - email: userEmail, - firstName: userName, - groups: [], - lastName: "", - role: userRole, - userMetadata: metaData, - } + email: userEmail, + firstName: userName, + groups: [], + lastName: "", + role: userRole, + userMetadata: metaData, + } : { - email: userEmail, - firstName: userName, - groups: [], - lastName: "", - role: userRole, - userMetadata: metaData, - }; + email: userEmail, + firstName: userName, + groups: [], + lastName: "", + role: userRole, + userMetadata: metaData, + }; cy.getCookie("tj_auth_token").then((cookie) => { cy.request( @@ -509,7 +509,7 @@ Cypress.Commands.add("apiDeleteGranularPermission", (groupName) => { // Delete the granular permission cy.request({ method: "DELETE", - url: `${Cypress.env("server_host")}/api/v2/group-permissions/granular-permissions/${granularPermissionId}`, + url: `${Cypress.env("server_host")}/api/v2/group-permissions/granular-permissions/app/${granularPermissionId}`, headers, log: false, }).then((deleteResponse) => { diff --git a/cypress-tests/cypress/commands/commands.js b/cypress-tests/cypress/commands/commands.js index 3bb63cc926..39e68da18a 100644 --- a/cypress-tests/cypress/commands/commands.js +++ b/cypress-tests/cypress/commands/commands.js @@ -11,7 +11,7 @@ import { selectAppCardOption } from "Support/utils/common"; const API_ENDPOINT = Cypress.env("environment") === "Community" ? "/api/library_apps" - : "/api/library_apps/"; + : "/api/library_apps"; Cypress.Commands.add( "appUILogin", @@ -226,9 +226,9 @@ Cypress.Commands.add( .invoke("text") .then((text) => { cy.wrap(subject).realType(createBackspaceText(text)), - { - delay: 0, - }; + { + delay: 0, + }; }); } ); @@ -548,7 +548,7 @@ Cypress.Commands.add("installMarketplacePlugin", (pluginName) => { } }); - function installPlugin(pluginName) { + function installPlugin (pluginName) { cy.get('[data-cy="-list-item"]').eq(1).click(); cy.wait(1000); @@ -621,3 +621,12 @@ Cypress.Commands.add( .and("have.text", `${fieldName} is required`); } ); + +Cypress.Commands.add('ifEnv', (expectedEnvs, callback) => { + const actualEnv = Cypress.env("environment"); + const envArray = Array.isArray(expectedEnvs) ? expectedEnvs : [expectedEnvs]; + + if (envArray.includes(actualEnv)) { + callback(); + } +}); \ No newline at end of file diff --git a/cypress-tests/cypress/constants/selectors/common.js b/cypress-tests/cypress/constants/selectors/common.js index c5785dcef1..06f34baa99 100644 --- a/cypress-tests/cypress/constants/selectors/common.js +++ b/cypress-tests/cypress/constants/selectors/common.js @@ -18,7 +18,7 @@ export const commonSelectors = { canvas: "[data-cy=real-canvas]", appCardOptionsButton: "[data-cy=app-card-menu-icon]", autoSave: "[data-cy=autosave-indicator]", - nameInputFieldd: "[data-cy=name-input-field]", + inputFieldName: "[data-cy=name-input-field]", valueInputFieldd: "[data-cy=value-input-field]", skipButton: ".driver-close-btn", skipInstallationModal: "[data-cy=skip-button]", diff --git a/cypress-tests/cypress/constants/selectors/eeCommon.js b/cypress-tests/cypress/constants/selectors/eeCommon.js new file mode 100644 index 0000000000..de54858f45 --- /dev/null +++ b/cypress-tests/cypress/constants/selectors/eeCommon.js @@ -0,0 +1,202 @@ +import { cyParamName } from "./common"; + +export const commonEeSelectors = { + instanceSettingIcon: '[data-cy="instance-settings-option"]', + auditLogIcon: '[data-cy="audit-log-option"]', + cancelButton: '[data-cy="cancel-button"]', + saveButton: '[data-cy="save-button"]', + pageTitle: '[data-cy="dashboard-section-header"]', + modalTitle: '[data-cy="modal-title"]', + modalCloseButton: '[data-cy="modal-close-button"]', + saveButton: '[data-cy="save-button"]', + cardTitle: '[data-cy="card-title"]', + AddQueryButton: '[data-cy="show-ds-popover-button"]', + promoteButton: '[data-cy="promote-button"]', + settingsIcon: '[data-cy="icon-settings"]', + gitSyncIcon: '[data-cy="git-sync-icon"]', + confirmButton: '[data-cy="confirm-button"]', + importFromGit: '[data-cy="import-from-git-button"]', + searchBar: '[data-cy="query-manager-search-bar"]', + nameHeader: '[data-cy="name-header"]', + modalMessage: '[data-cy="modal-message"]', + paginationSection: '[data-cy="pagination-section"]', + +}; + +export const ssoEeSelector = { + oidc: '[data-cy="openid-connect-sso-card"]', + statusLabel: '[data-cy="status-label"]', + oidcToggle: '[data-cy="openid-toggle-input"] > .slider', + oidcPageElements: { + oidcToggleLabel: '[data-cy="openid-toggle-label"]', + nameLabel: '[data-cy="name-label"]', + clientIdLabel: '[data-cy="client-id-label"]', + clientSecretLabel: '[data-cy="client-secret-label"]', + encryptedLabel: '[data-cy="encripted-label"]', + WellKnownUrlLabel: '[data-cy="well-known-url-label"]', + // redirectUrlLabel: '[data-cy="redirect-url-label"]', + }, + nameInput: '[data-cy="name-input"]', + clientIdInput: '[data-cy="client-id-input"]', + clientSecretInput: '[data-cy="client-secret-input"]', + WellKnownUrlInput: '[data-cy="well-known-url-input"]', + redirectUrl: '[data-cy="redirect-url"]', + copyIcon: '[data-cy="copy-icon]', + oidcSSOText: '[data-cy="oidc-sso-button-text"]', + oidcSSOIcon: '[data-cy="oidc-so-icon"]', + ldapPageElements: { + ldapToggleLabel: '[data-cy="ldap-toggle-label"]', + nameLabel: '[data-cy="name-label"]', + hostLabel: '[data-cy="host-label"]', + portLabel: '[data-cy="port-label"]', + baseDnLabel: '[data-cy="base-dn-label"]', + baseDnHelperText: '[data-cy="base-dn-helper-text"]', + sslLabel: '[data-cy="ssl-label"]', + }, + ldapToggle: '[data-cy="ldap-toggle-input"] > .slider', + hostInput: '[data-cy="host-input"]', + portInput: '[data-cy="port-input"]', + baseDnInput: '[data-cy="base-dn-input"]', + sslToggleInput: '[data-cy="ssl-toggle-input"]', + ldapSSOText: '[data-cy="ldap-sso-button-text"]', + userNameInputLabel: '[data-cy="user-name-input-label"]', + passwordInputLabel: '[data-cy="password-label"]', + passwordInputField: '[data-cy="password-input-field"]', + + samlModalElements: { + toggleLabel: '[data-cy="saml-toggle-label"]', + NameLabel: '[data-cy="name-label"]', + metaDataLabel: '[data-cy="idp-metadata-label"]', + baseDNHelperText: '[data-cy="base-dn-helper-text"]', + groupAttributeLabel: '[data-cy="group-attribute-label"]', + groupAttributeHelperText: '[data-cy="group-attribute-helper-text"]', + } +}; + +export const eeGroupsSelector = { + resourceDs: '[data-cy="resource-datasources"]', + dsCreateCheck: '[data-cy="checkbox-create-ds"]', + dsDeleteCheck: '[data-cy="checkbox-delete-ds"]', + datasourceLink: '[data-cy="datasource-link"]', + dsSearch: '[data-cy="datasource-select-search"]', + AddDsButton: '[data-cy="datasource-add-button"]', + dsNameHeader: '[data-cy="datasource-name-header"]', +}; + +export const instanceSettingsSelector = { + allUsersTab: '[data-cy="all-users-list-item"]', + manageInstanceSettings: '[data-cy="manage-instance-settings-list-item"]', + typeColumnHeader: '[data-cy="users-table-type-column-header"]', + workspaceColumnHeader: '[data-cy="users-table-workspaces-column-header"]', + userName: (userName) => { + return `[data-cy="${cyParamName(userName)}-user-name"]`; + }, + userEmail: (userName) => { + return `[data-cy="${cyParamName(userName)}-user-email"]`; + }, + userType: (userName) => { + return `[data-cy="${cyParamName(userName)}-user-type"]`; + }, + userStatus: (userName) => { + return `[data-cy="${cyParamName(userName)}-user-status"]`; + }, + viewButton: (userName) => { + return `[data-cy="${cyParamName(userName)}-user-view-button"]`; + }, + editButton: (userName) => { + return `[data-cy="${cyParamName(userName)}-user-edit-button"]`; + }, + viewModalNoColumnHeader: '[data-cy="number-column-header"]', + viewModalNameColumnHeader: '[data-cy="name-column-header"]', + viewModalStatusColumnHeader: '[data-cy="status-column-header"]', + archiveAllButton: '[data-cy="archive-all-button"]', + viewModalRow: (workspaceName) => { + return `[data-cy="${cyParamName(workspaceName)}-workspace-row"]>`; + }, + + workspaceName: (workspaceName) => { + return `[data-cy="${cyParamName(workspaceName)}-workspace-name"]`; + }, + userStatusChangeButton: '[data-cy="user-state-change-button"]', + superAdminToggle: '[data-cy="super-admin-form-check-input"]', + superAdminToggleLabel: '[data-cy="super-admin-form-check-label"]', + allowWorkspaceToggle: '[data-cy="form-check-input"]', + allowWorkspaceToggleLabel: '[data-cy="form-check-label"]', + allowWorkspaceHelperText: '[data-cy="instance-settings-help-text"]', + allWorkspaceTab: '[data-cy="all-workspaces-list-item"]', +}; + + +export const multiEnvSelector = { + envContainer: '[data-cy="env-container"]', + currentEnvName: '[data-cy="list-current-env-name"]', + envArrow: '[data-cy="env-arrow"]', + selectedEnvName: '[data-cy="selected-current-env-name"]', + envNameList: '[data-cy="env-name-list"]', + appVersionLabel: '[data-cy="app-version-label"]', + currentVersion: '[data-cy="current-version"]', + createNewVersionButton: '[data-cy="create-new-version-button"]', + fromLabel: '[data-cy="from-label"]', + toLabel: '[data-cy="to-label"]', + currEnvName: '[data-cy="current-env-name"]', + targetEnvName: '[data-cy="target-env-name"]', + stagingLabel: '[data-cy="staging-label"]', + productionLabel: '[data-cy="production-label"]', +}; + +export const whiteLabellingSelectors = { + whiteLabelList: '[data-cy="white-labelling-list-item"]', + appLogoLabel: '[data-cy="app-logo-label"]', + appLogoInput: '[data-cy="input-field-app-logo"]', + appLogoHelpText: '[data-cy="app-logo-help-text"]', + pageTitleLabel: '[data-cy="page-title-label"]', + pageTitleInput: '[data-cy="input-field-page-title"]', + pageTitleHelpText: '[data-cy="page-title-help-text"]', + favIconLabel: '[data-cy="fav-icon-label"]', + favIconInput: '[data-cy="input-field-fav-icon"]', + favIconHelpText: '[data-cy="fav-icon-help-text"]', +}; + +export const gitSyncSelector = { + gitCommitInput: '[data-cy="git-commit-input"]', + commitHelperText: '[data-cy="commit-helper-text"]', + gitRepoInput: '[data-cy="git-repo-input"]', + commitMessageInput: '[data-cy="commit-message-input"]', + lastCommitInput: '[data-cy="las-commit-message"]', + lastCommitVersion: '[data-cy="last-commit-version"]', + autherInfo: '[data-cy="auther-info"]', + commitButton: '[data-cy="commit-button"]', + gitSyncToggleInput: '[data-cy="git-sync-toggle-input"]', + gitSyncApphelperText: '[data-cy="sync-app-helper-text"]', + connectRepoButton: '[data-cy="connect-repo-button"]', + toggleMessage: '[data-cy="toggle-message"]', + sshInput: '[data-cy="git-ssh-input"]', + generateSshButton: '[data-cy="generate-ssh-key-button"', + sshInputHelperText: '[data-cy="git-ssh-input-helper-text"]', + configDeleteButton: '[data-cy="button-config-delete"]', + testConnectionButton: '[data-cy="test-connection-button"]', + sshKey: '[data-cy="ssh-key"]', + deployKeyHelperText: '[data-cy="deploy-key-helper-text"]', + gitRepoLink: '[data-cy="git-repo-link"]', + appNameField: '[data-cy="app-name-field"]', + gitRepoInfo: '[data-cy="git-repo-info"]', + pullButton: '[data-cy="pull-button"]' + + +} + +export const workspaceSelector = { + activelink: '[data-cy="active-link"]', + archivedLik: '[data-cy="archived-link"]', + userStatusChange: '[data-cy="button-user-status-change"]', + workspaceStatusChange: '[data-cy="button-ws-status-change"]', + switchWsModalTitle: '[data-cy="switch-modal-title"]', + switchWsModalMessage: '[data-cy="switch-modal-message"]', + workspaceName: (workspaceName) => { + return `[data-cy="${workspaceName}-workspace-name"]` + }, + workspaceInput: (workspaceName) => { + return `[data-cy="${workspaceName}-workspace-input"]` + }, + +} \ No newline at end of file diff --git a/cypress-tests/cypress/constants/selectors/onboarding.js b/cypress-tests/cypress/constants/selectors/onboarding.js index a7839afa74..cf2404f6f3 100644 --- a/cypress-tests/cypress/constants/selectors/onboarding.js +++ b/cypress-tests/cypress/constants/selectors/onboarding.js @@ -54,8 +54,8 @@ export const onboardingSelectors = { basicPlanTitle: '[data-cy="basic-plan-title"]', planPrice: '[data-cy="plan-price"]', pricePeriod: '[data-cy="price-period"]', - flexibleTitle: '[data-cy="flexible-title"]', - businessTitle: '[data-cy="business-title"]', + flexibleTitle: '[data-cy="pro-title"]', + businessTitle: '[data-cy="team-title"]', enterpriseTitle: '[data-cy="enterprise-title"]', customPricingHeader: '[data-cy="custom-pricing-header"]', noCreditCardBanner: '[data-cy="no-credit-card-banner"]', diff --git a/cypress-tests/cypress/constants/texts/eeCommon.js b/cypress-tests/cypress/constants/texts/eeCommon.js new file mode 100644 index 0000000000..b112762f65 --- /dev/null +++ b/cypress-tests/cypress/constants/texts/eeCommon.js @@ -0,0 +1,75 @@ +export const commonEeText = { + cancelButton: "Cancel", + saveButton: "Save changes", + closeButton: "Close", + defaultWorkspace: "My workspace", +}; + +export const ssoEeText = { + statusLabel: "Disabled", + enabledLabel: "Enabled", + disabledLabel: "Disabled", + oidcPageElements: { + oidcToggleLabel: "OpenID Connect", + nameLabel: "Name", + clientIdLabel: "Client ID", + clientSecretLabel: "Client secretEncrypted", + encryptedLabel: "Encrypted", + WellKnownUrlLabel: "Well known URL", + // redirectUrlLabel: "Redirect URL", + }, + oidcEnabledToast: "Enabled OpenId SSO", + oidcDisabledToast: "Disabled OpenId SSO", + oidcUpdatedToast: "updated SSO configurations", + testName: "Tooljet OIDC", + testclientId: "24567098-mklj8t20za1smb2if.apps.googleusercontent.com", + testclientSecret: "2345-client-id-.apps.googleusercontent.com", + testWellknownUrl: "google.com", + oidcSSOText: "Sign in with Tooljet OIDC", + + ldapPageElements: { + ldapToggleLabel: "LDAP", + nameLabel: "Name", + hostLabel: "Host name", + portLabel: "Port", + baseDnLabel: "Base DN", + baseDnHelperText: "Location without UID or CN", + sslLabel: "SSL", + }, + ldapSSOText: "Sign in with Tooljet LDAP Auth", + userNameInputLabel: "Username", + samlModalElements: { + toggleLabel: "SAML", + NameLabel: "Name", + metaDataLabel: "Identity provider metadata", + baseDNHelperText: + "Ensure the Identity provider metadata is in XML format. You can download it from your IdP's site", + groupAttributeLabel: "Group attribute", + groupAttributeHelperText: + "Define attribute for user-to-group mapping based on the IdP", + }, +}; +export const eeGroupsText = { + resourceDs: "Datasources", + AddDsButton: "Add", + dsNameHeader: "Datasource name", +}; + +export const instanceSettingsText = { + pageTitle: "Settings", + allUsersTab: "All users", + manageInstanceSettings: "Manage instance settings", + typeColumnHeader: "Type", + workspaceColumnHeader: "Workspaces", + superAdminType: "instance", + viewModalTitle: "Workspaces of The Developer", + archiveAllButton: "Archive All", + archiveState: "Archive", + editModalTitle: "Edit user details", + superAdminToggleLabel: "Super admin", + allowWorkspaceToggleLabel: "Allow personal workspace", + allowWorkspaceHelperText: + "This feature will enable users to create their own workspace", + saveButton: "Save", + untitledWorkspace: "Untitled workspace", +}; diff --git a/cypress-tests/cypress/constants/texts/manageSSO.js b/cypress-tests/cypress/constants/texts/manageSSO.js index c1a95e7179..56052be58f 100644 --- a/cypress-tests/cypress/constants/texts/manageSSO.js +++ b/cypress-tests/cypress/constants/texts/manageSSO.js @@ -59,4 +59,9 @@ export const ssoText = { alertText: "Danger zone", disablePasswordHelperText: "Disable password login only if your SSO is configured otherwise you will get locked out", + disablePasswordHelperText: + "Disable password login only if your SSO is configured otherwise you will get locked out", + toggleUpdateToast: (toggle) => { + return `Saved ${toggle} SSO configurations` + } }; diff --git a/cypress-tests/cypress/constants/texts/onboarding.js b/cypress-tests/cypress/constants/texts/onboarding.js index a41a654525..18efeb9006 100644 --- a/cypress-tests/cypress/constants/texts/onboarding.js +++ b/cypress-tests/cypress/constants/texts/onboarding.js @@ -23,8 +23,8 @@ export const onboardingText = { endUserPriceText: "$10", comparePlansText: "Compare plans", basicPlanText: "Basic Plan", - flexibleText: "Flexible", - businessText: "Business", + flexibleText: "Pro", + businessText: "Team", enterpriseText: "Enterprise", customPricingText: "Custom pricing", noCreditCardText: "No credit card required!", diff --git a/cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/restAPIHappyPath.cy.js b/cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/restAPIHappyPath.cy.js index 09559e2ba7..ab3d3993a3 100644 --- a/cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/restAPIHappyPath.cy.js +++ b/cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/restAPIHappyPath.cy.js @@ -337,7 +337,7 @@ describe("Data source Rest API", () => { `cypress-${data.dataSourceName}-restapi`, "restapi", [ - { key: "url", value: Cypress.env("restAPI_BaseURL") }, + { key: "url", value: "https://jsonplaceholder.typicode.com" }, { key: "auth_type", value: "none" }, { key: "grant_type", value: "authorization_code" }, { key: "add_token_to", value: "header" }, @@ -370,62 +370,100 @@ describe("Data source Rest API", () => { cy.apiCreateApp(`${fake.companyName}-restAPI-CURD-App`); cy.openApp(); createAndRunRestAPIQuery({ - queryName: "get_beeceptor_data", + queryName: "get_all_users", dsName: `cypress-${data.dataSourceName}-restapi`, method: "GET", - urlSuffix: "/api/users", + urlSuffix: "/users", run: true, + expectedResponseShape: { + "0.id": true, + "0.name": true, + "0.email": true, + }, }); createAndRunRestAPIQuery({ queryName: "post_restapi", dsName: `cypress-${data.dataSourceName}-restapi`, method: "POST", headersList: [["Content-Type", "application/json"]], - rawBody: '{"price": 200,"name": "Violin"}', - urlSuffix: "/api/users", - expectedResponseShape: { price: 200, name: "Violin", id: true }, + rawBody: `{ + "name": "Test User", + "username": "testuser", + "email": "test@example.com", + "address": { + "street": "123 Test St", + "city": "Testville", + "zipcode": "12345" + } + }`, + urlSuffix: "/users", + run: true, + expectedResponseShape: { + id: true, + name: "Test User", + email: "test@example.com", + }, }); - cy.readFile("cypress/fixtures/restAPI/storedId.json").then( - (postResponseID) => { - const id1 = postResponseID.id; + const id1 = 1; - createAndRunRestAPIQuery({ - queryName: "put_restapi_id", - dsName: `cypress-${data.dataSourceName}-restapi`, - method: "PUT", - headersList: [["Content-Type", "application/json"]], - rawBody: '{"price": 500,"name": "Guitar"}', - urlSuffix: `/api/users/${id1}`, - expectedResponseShape: { price: 500, name: "Guitar", id: id1 }, - }); - createAndRunRestAPIQuery({ - queryName: "patch_restapi_id", - dsName: `cypress-${data.dataSourceName}-restapi`, - method: "PATCH", - headersList: [["Content-Type", "application/json"]], - rawBody: '{"price": 999 }', - urlSuffix: `/api/users/${id1}`, - run: true, - expectedResponseShape: { price: 999, id: id1 }, - }); - createAndRunRestAPIQuery({ - queryName: "get_restapi_id", - dsName: `cypress-${data.dataSourceName}-restapi`, - method: "GET", - urlSuffix: `/api/users/${id1}`, - run: true, - expectedResponseShape: { price: 999, name: "Guitar", id: id1 }, - }); - createAndRunRestAPIQuery({ - queryName: "delete_restapi_id", - dsName: `cypress-${data.dataSourceName}-restapi`, - method: "DELETE", - urlSuffix: `/api/users/${id1}`, - run: true, - expectedResponseShape: { success: true }, - }); - } - ); + createAndRunRestAPIQuery({ + queryName: "put_restapi_id", + dsName: `cypress-${data.dataSourceName}-restapi`, + method: "PUT", + headersList: [["Content-Type", "application/json"]], + rawBody: `{ + "id": 1, + "name": "Fully Updated User", + "username": "updateduser", + "email": "updated@example.com", + "address": { + "street": "456 Updated St", + "city": "Updatedville", + "zipcode": "54321" + } + }`, + urlSuffix: `/users/${id1}`, + run: true, + expectedResponseShape: { + id: id1, + name: "Fully Updated User", + email: "updated@example.com", + }, + }); + createAndRunRestAPIQuery({ + queryName: "patch_restapi_id", + dsName: `cypress-${data.dataSourceName}-restapi`, + method: "PATCH", + headersList: [["Content-Type", "application/json"]], + rawBody: `{ + "email": "partially.updated@example.com" + }`, + urlSuffix: `/users/${id1}`, + run: true, + expectedResponseShape: { + id: id1, + email: "partially.updated@example.com", + }, + }); + createAndRunRestAPIQuery({ + queryName: "get_restapi_id", + dsName: `cypress-${data.dataSourceName}-restapi`, + method: "GET", + urlSuffix: `/users/${id1}`, + run: true, + expectedResponseShape: { + id: id1, + email: "Sincere@april.biz", + }, + }); + createAndRunRestAPIQuery({ + queryName: "delete_restapi_id", + dsName: `cypress-${data.dataSourceName}-restapi`, + method: "DELETE", + urlSuffix: `/users/${id1}`, + run: true, + expectedResponseShape: {}, + }); cy.apiDeleteApp(`${fake.companyName}-restAPI-CURD-App`); cy.apiDeleteGDS(`cypress-${data.dataSourceName}-restapi`); }); diff --git a/cypress-tests/cypress/e2e/happyPath/platform/ceTestcases/apps/privateAndpublicApps.cy.js b/cypress-tests/cypress/e2e/happyPath/platform/ceTestcases/apps/privateAndpublicApps.cy.js index d6483fa400..19b87c6efe 100644 --- a/cypress-tests/cypress/e2e/happyPath/platform/ceTestcases/apps/privateAndpublicApps.cy.js +++ b/cypress-tests/cypress/e2e/happyPath/platform/ceTestcases/apps/privateAndpublicApps.cy.js @@ -5,10 +5,7 @@ import { inviteUserToWorkspace } from "Support/utils/manageUsers"; import { setSignupStatus } from "Support/utils/manageSSO"; import { onboardingSelectors } from "Selectors/onboarding"; import { commonText } from "Texts/common"; -import { - userSignUp, - addNewUser, -} from "Support/utils/onboarding"; +import { userSignUp, addNewUser } from "Support/utils/onboarding"; import { setUpSlug, setupAppWithSlug, @@ -16,347 +13,371 @@ import { onboardUserFromAppLink, } from "Support/utils/apps"; +describe( + "Private and Public apps", + { + retries: { runMode: 2 }, + }, + () => { + let data; -describe("Private and Public apps", { - retries: { runMode: 2 }, -}, () => { - let data; + beforeEach(() => { + data = { + appName: `${fake.companyName} P P App`, + slug: `${fake.companyName} P P App`.toLowerCase().replace(/\s+/g, "-"), + firstName: fake.firstName, + email: fake.email.toLowerCase(), + workspaceName: fake.firstName, + workspaceSlug: fake.firstName.toLowerCase().replace(/\s+/g, "-"), + }; - beforeEach(() => { - data = { - appName: `${fake.companyName} P P App`, - slug: `${fake.companyName} P P App`.toLowerCase().replace(/\s+/g, "-"), - firstName: fake.firstName, - email: fake.email.toLowerCase(), - workspaceName: fake.firstName, - workspaceSlug: fake.firstName.toLowerCase().replace(/\s+/g, "-"), - } - - cy.defaultWorkspaceLogin(); - cy.skipWalkthrough(); - }); - - it("Verify private and public app share functionality", () => { - cy.apiCreateApp(data.appName); - cy.openApp(); - cy.apiAddComponentToApp(data.appName, "text1"); - - // Check unreleased version state - cy.get('[data-cy="share-button-link"]>span').should("be.visible").click(); - cy.contains("This version has not been released yet").should("be.visible"); - cy.get(commonWidgetSelector.modalCloseButton).click(); - - // Release and verify share modal - releaseApp(); - cy.get(commonWidgetSelector.shareAppButton).click(); - for (const elements in commonWidgetSelector.shareModalElements) { - cy.get(commonWidgetSelector.shareModalElements[elements]) - .verifyVisibleElement("have.text", commonText.shareModalElements[elements]); - } - - // Verify share modal elements - const shareModalSelectors = [ - 'copyAppLinkButton', - 'makePublicAppToggle', - 'appLink', - 'appNameSlugInput', - 'modalCloseButton' - ]; - shareModalSelectors.forEach(selector => { - cy.get(commonWidgetSelector[selector]).should("be.visible"); + cy.defaultWorkspaceLogin(); + cy.skipWalkthrough(); }); - // Configure and verify slug - cy.clearAndType(commonWidgetSelector.appNameSlugInput, data.slug); - cy.get('[data-cy="app-slug-accepted-label"]') - .should("be.visible") - .and("have.text", "Slug accepted!"); + it("Verify private and public app share functionality", () => { + cy.apiCreateApp(data.appName); + cy.openApp(); + cy.apiAddComponentToApp(data.appName, "text1"); - cy.get(commonWidgetSelector.modalCloseButton).click(); - cy.forceClickOnCanvas(); - cy.backToApps(); + // Check unreleased version state + cy.get('[data-cy="share-button-link"]>span').should("be.visible").click(); + cy.contains("This version has not been released yet").should( + "be.visible" + ); + cy.get(commonWidgetSelector.modalCloseButton).click(); - // Test private access - logout(); + // Release and verify share modal + releaseApp(); + cy.get(commonWidgetSelector.shareAppButton).click(); + for (const elements in commonWidgetSelector.shareModalElements) { + cy.get( + commonWidgetSelector.shareModalElements[elements] + ).verifyVisibleElement( + "have.text", + commonText.shareModalElements[elements] + ); + } - cy.visitSlug({ - actualUrl: `${Cypress.config("baseUrl")}/applications/${data.slug}`, + // Verify share modal elements + const shareModalSelectors = [ + "copyAppLinkButton", + "makePublicAppToggle", + "appLink", + "appNameSlugInput", + "modalCloseButton", + ]; + shareModalSelectors.forEach((selector) => { + cy.get(commonWidgetSelector[selector]).should("be.visible"); + }); + + // Configure and verify slug + cy.clearAndType(commonWidgetSelector.appNameSlugInput, data.slug); + cy.get('[data-cy="app-slug-accepted-label"]') + .should("be.visible") + .and("have.text", "Slug accepted!"); + + cy.get(commonWidgetSelector.modalCloseButton).click(); + cy.forceClickOnCanvas(); + cy.backToApps(); + + // Test private access + logout(); + + cy.visitSlug({ + actualUrl: `${Cypress.config("baseUrl")}/applications/${data.slug}`, + }); + + cy.get(onboardingSelectors.signInButton, { timeout: 20000 }).should( + "be.visible" + ); + cy.wait(2000); + cy.appUILogin(); + cy.get(commonWidgetSelector.draggableWidget("text1")).should( + "be.visible" + ); + + // Test public access + cy.get(commonSelectors.viewerPageLogo).click(); + cy.openApp( + "appSlug", + Cypress.env("workspaceId"), + Cypress.env("appId"), + commonWidgetSelector.draggableWidget("text1") + ); + cy.get(commonWidgetSelector.shareAppButton).click(); + cy.get(commonWidgetSelector.makePublicAppToggle).check(); + cy.get(commonWidgetSelector.modalCloseButton).click(); + cy.backToApps(); + + logout(); + cy.visitSlug({ + actualUrl: `${Cypress.config("baseUrl")}/applications/${data.slug}`, + }); + cy.get(commonWidgetSelector.draggableWidget("text1")).should( + "be.visible" + ); }); - cy.get(onboardingSelectors.signInButton, { timeout: 20000 }).should("be.visible"); - cy.wait(2000); - cy.appUILogin(); - cy.get(commonWidgetSelector.draggableWidget("text1")).should("be.visible"); + it("Verify app private and public app visibility for the same workspace user", () => { + setupAppWithSlug(data.appName, data.slug); + inviteUserToWorkspace(data.firstName, data.email); + logout(); + cy.visit("/"); + cy.wait(2000); + cy.get(onboardingSelectors.signInButton, { timeout: 20000 }).should( + "be.visible" + ); - // Test public access - cy.get(commonSelectors.viewerPageLogo).click(); - cy.openApp( - "appSlug", - Cypress.env("workspaceId"), - Cypress.env("appId"), - commonWidgetSelector.draggableWidget("text1") - ); - cy.get(commonWidgetSelector.shareAppButton).click(); - cy.get(commonWidgetSelector.makePublicAppToggle).check(); - cy.get(commonWidgetSelector.modalCloseButton).click(); - cy.backToApps(); + // Test private access + cy.visitSlug({ + actualUrl: `${Cypress.config("baseUrl")}/applications/${data.slug}`, + }); - logout(); - cy.visitSlug({ - actualUrl: `${Cypress.config("baseUrl")}/applications/${data.slug}`, - }); - cy.get(commonWidgetSelector.draggableWidget("text1")).should("be.visible"); + cy.wait(2000); + cy.appUILogin(data.email, "password"); + cy.get(commonWidgetSelector.draggableWidget("text1")).should( + "be.visible" + ); - }); + // Test with private app valid session + cy.visitSlug({ + actualUrl: `${Cypress.config("baseUrl")}/applications/${data.slug}`, + }); + cy.get(commonWidgetSelector.draggableWidget("text1")).should( + "be.visible" + ); - it("Verify app private and public app visibility for the same workspace user", () => { - setupAppWithSlug(data.appName, data.slug); + cy.get(commonSelectors.viewerPageLogo).click(); - inviteUserToWorkspace(data.firstName, data.email); - logout(); - cy.visit("/"); - cy.wait(2000); - cy.get(onboardingSelectors.signInButton, { timeout: 20000 }).should("be.visible"); + // Test public access + cy.defaultWorkspaceLogin(); + cy.wait(1000); + cy.apiMakeAppPublic(); + logout(); + cy.wait(1000); + cy.get(onboardingSelectors.signInButton, { timeout: 20000 }).should( + "be.visible" + ); - // Test private access - cy.visitSlug({ - actualUrl: `${Cypress.config("baseUrl")}/applications/${data.slug}`, + cy.visitSlug({ + actualUrl: `${Cypress.config("baseUrl")}/applications/${data.slug}`, + }); + cy.get(commonWidgetSelector.draggableWidget("text1")).should( + "be.visible" + ); + + // Test with public app with valid session + cy.apiLogin(data.email, "password"); + cy.visitSlug({ + actualUrl: `${Cypress.config("baseUrl")}/applications/${data.slug}`, + }); + cy.get(commonWidgetSelector.draggableWidget("text1")).should( + "be.visible" + ); }); - cy.wait(2000); - cy.appUILogin(data.email, "password"); + it("Verify app private and public app visibility for the same instance user", () => { + setupAppWithSlug(data.appName, data.slug); - cy.get(commonWidgetSelector.draggableWidget("text1")).should("be.visible"); + cy.apiLogout(); + userSignUp(data.firstName, data.email, data.workspaceName); + cy.wait(1000); + cy.visitSlug({ + actualUrl: `${Cypress.config("baseUrl")}/applications/${data.slug}`, + }); - // Test with private app valid session - cy.visitSlug({ - actualUrl: `${Cypress.config("baseUrl")}/applications/${data.slug}`, - }); - cy.get(commonWidgetSelector.draggableWidget("text1")).should("be.visible"); + cy.visit("/"); + logout(); + // Test public access + cy.defaultWorkspaceLogin(); + cy.apiMakeAppPublic(); + logout(); - cy.get(commonSelectors.viewerPageLogo).click(); + cy.wait(1000); + cy.get(onboardingSelectors.signInButton, { timeout: 20000 }).should( + "be.visible" + ); - // Test public access - cy.defaultWorkspaceLogin(); - cy.wait(1000); - cy.apiMakeAppPublic(); - logout(); - cy.wait(1000); - cy.get(onboardingSelectors.signInButton, { timeout: 20000 }).should("be.visible"); + cy.visitSlug({ + actualUrl: `${Cypress.config("baseUrl")}/applications/${data.slug}`, + }); + cy.get(commonWidgetSelector.draggableWidget("text1")).should( + "be.visible" + ); - cy.visitSlug({ - actualUrl: `${Cypress.config("baseUrl")}/applications/${data.slug}`, - }); - cy.get(commonWidgetSelector.draggableWidget("text1")).should("be.visible"); - - - - // Test with public app with valid session - cy.apiLogin(data.email, "password"); - cy.visitSlug({ - actualUrl: `${Cypress.config("baseUrl")}/applications/${data.slug}`, - }); - cy.get(commonWidgetSelector.draggableWidget("text1")).should("be.visible"); - - - }); - - it("Verify app private and public app visibility for the same instance user", () => { - setupAppWithSlug(data.appName, data.slug); - - cy.apiLogout(); - userSignUp(data.firstName, data.email, data.workspaceName); - cy.wait(1000); - cy.visitSlug({ - actualUrl: `${Cypress.config("baseUrl")}/applications/${data.slug}`, + // Verify public app with valid session + cy.apiLogin(data.email, "password"); + cy.visitSlug({ + actualUrl: `${Cypress.config("baseUrl")}/applications/${data.slug}`, + }); + cy.get(commonWidgetSelector.draggableWidget("text1")).should( + "be.visible" + ); }); - cy.visit("/"); - logout(); + it("Should redirect to workspace login and handle signup flow of existing and non-existing user", () => { + setSignupStatus(true); + setupAppWithSlug(data.appName, data.slug); - // Test public access - cy.defaultWorkspaceLogin(); - cy.apiMakeAppPublic(); - logout(); + cy.apiLogout(); + cy.visitSlug({ + actualUrl: `${Cypress.config("baseUrl")}/applications/${data.slug}`, + }); - cy.wait(1000); - cy.get(onboardingSelectors.signInButton, { timeout: 20000 }).should("be.visible"); + cy.get(commonSelectors.workspaceName).verifyVisibleElement( + "have.text", + "My workspace" + ); - cy.visitSlug({ - actualUrl: `${Cypress.config("baseUrl")}/applications/${data.slug}`, - }); - cy.get(commonWidgetSelector.draggableWidget("text1")).should("be.visible"); + // Test signup flow + cy.intercept("POST", "/api/onboarding/signup").as("signup"); + cy.get(commonSelectors.createAnAccountLink).click(); + cy.wait(3000); + cy.clearAndType(commonSelectors.inputFieldFullName, data.firstName); + cy.clearAndType(commonSelectors.inputFieldEmailAddress, data.email); + cy.clearAndType(onboardingSelectors.loginPasswordInput, "password"); + cy.get(commonSelectors.signUpButton).click(); + cy.wait("@signup").then((interception) => { + expect(interception.response.statusCode).to.eq(201); + }); + // Process invitation + onboardUserFromAppLink(data.email, data.slug); - // Verify public app with valid session - cy.apiLogin(data.email, "password"); - cy.visitSlug({ - actualUrl: `${Cypress.config("baseUrl")}/applications/${data.slug}`, - }); - cy.get(commonWidgetSelector.draggableWidget("text1")).should("be.visible"); + cy.get(commonWidgetSelector.draggableWidget("text1")).should( + "be.visible" + ); + cy.get('[data-cy="viewer-page-logo"]').click(); + logout(); + cy.wait(1000); + cy.get(onboardingSelectors.signInButton, { timeout: 20000 }).should( + "be.visible" + ); - }); + // Setup new workspace and app + cy.defaultWorkspaceLogin(); + cy.apiCreateWorkspace(data.workspaceName, data.workspaceSlug); + cy.apiLogout(); + cy.apiLogin(); + cy.visit(`${data.workspaceSlug}`); + setSignupStatus(true, data.workspaceName); - it("Should redirect to workspace login and handle signup flow of existing and non-existing user", () => { - setSignupStatus(true); - setupAppWithSlug(data.appName, data.slug); + data.slug = fake.firstName.toLowerCase().replace(/\s+/g, "-"); - cy.apiLogout(); - cy.visitSlug({ - actualUrl: `${Cypress.config("baseUrl")}/applications/${data.slug}`, + cy.createApp(data.appName); + cy.dragAndDropWidget("Text", 500, 500); + releaseApp(); + setUpSlug(data.slug); + cy.forceClickOnCanvas(); + cy.backToApps(); + + // Test signup flow in new workspace + cy.apiLogout(); + cy.visitSlug({ + actualUrl: `${Cypress.config("baseUrl")}/applications/${data.slug}`, + }); + + cy.get(commonSelectors.workspaceName).verifyVisibleElement( + "have.text", + data.workspaceName + ); + + cy.get(commonSelectors.createAnAccountLink).click(); + cy.wait(3000); + cy.clearAndType(commonSelectors.inputFieldFullName, data.firstName); + cy.clearAndType(commonSelectors.inputFieldEmailAddress, data.email); + cy.clearAndType(onboardingSelectors.loginPasswordInput, "password"); + cy.get(commonSelectors.signUpButton).click(); + cy.wait("@signup").then((interception) => { + expect(interception.response.statusCode).to.eq(201); + }); + + onboardUserFromAppLink(data.email, data.slug, data.workspaceName, false); + cy.get(commonWidgetSelector.draggableWidget("text1")).should( + "be.visible" + ); }); - cy.get(commonSelectors.workspaceName).verifyVisibleElement( - "have.text", - "My workspace" - ); + it("Should verify restricted app access", () => { + data.workspaceName = fake.firstName; + data.workspaceSlug = fake.firstName.toLowerCase().replace(/\s+/g, "-"); - // Test signup flow - cy.intercept("POST", "/api/onboarding/signup").as("signup"); - cy.get(commonSelectors.createAnAccountLink).click(); - cy.wait(3000); - cy.clearAndType(commonSelectors.inputFieldFullName, data.firstName); - cy.clearAndType(commonSelectors.inputFieldEmailAddress, data.email); - cy.clearAndType(onboardingSelectors.loginPasswordInput, "password"); - cy.get(commonSelectors.signUpButton).click(); + cy.apiCreateWorkspace(data.workspaceName, data.workspaceSlug); + cy.apiLogout(); + cy.apiLogin(); + cy.visit(`${data.workspaceSlug}`); + cy.apiDeleteGranularPermission("end-user"); + setSignupStatus(true, data.workspaceName); - cy.wait('@signup').then((interception) => { - expect(interception.response.statusCode).to.eq(201); + setupAppWithSlug(data.appName, data.slug); + + inviteUserToWorkspace(data.firstName, data.email); + + // Verify restricted access + cy.visitSlug({ + actualUrl: `${Cypress.config("baseUrl")}/applications/${data.slug}`, + }); + verifyRestrictedAccess(); + cy.get('[data-cy="back-to-home-button"]').click(); + cy.get(commonSelectors.homePageLogo).should("be.visible"); + + cy.apiLogout(); }); - // Process invitation - onboardUserFromAppLink(data.email, data.slug); + it.skip("Should verify private app access for different workspace users", () => { + const firstName1 = fake.firstName; + const email1 = fake.email.toLowerCase(); + const permissionName = fake.firstName.toLowerCase(); // Defined but not used in original + const urls = { + editor: `${Cypress.config("baseUrl")}/my-workspace/apps/${data.slug}/home`, + preview: `${Cypress.config("baseUrl")}/applications/${data.slug}/home?version=v1`, + released: `${Cypress.config("baseUrl")}/applications/${data.slug}`, + }; - cy.get(commonWidgetSelector.draggableWidget("text1")).should("be.visible"); + // Setup workspace and app + cy.apiCreateWorkspace(data.workspaceName, data.workspaceSlug); + cy.apiLogout(); + cy.apiLogin(); + cy.visit(`${data.workspaceSlug}`); + setupAppWithSlug(data.appName, data.slug); + // Invite workspace user + addNewUser(data.firstName, data.email); + cy.wait(500); - cy.get('[data-cy="viewer-page-logo"]').click(); - logout(); - cy.wait(1000); - cy.get(onboardingSelectors.signInButton, { timeout: 20000 }).should("be.visible"); + // Verify access restrictions + cy.visitSlug({ actualUrl: urls.editor }); + verifyRestrictedAccess(); + cy.get('[data-cy="back-to-home-button"]').click(); + cy.get(commonSelectors.homePageLogo).should("be.visible"); - // Setup new workspace and app - cy.defaultWorkspaceLogin(); - cy.apiCreateWorkspace(data.workspaceName, data.workspaceSlug); - cy.apiLogout(); - cy.apiLogin(); - cy.visit(`${data.workspaceSlug}`); - setSignupStatus(true, data.workspaceName); + cy.visitSlug({ actualUrl: urls.preview }); - data.slug = fake.firstName.toLowerCase().replace(/\s+/g, "-"); + // Switch users and verify access + cy.apiLogout(); + cy.apiLogin(); + cy.apiDeleteGranularPermission("end-user"); - cy.createApp(data.appName); - cy.dragAndDropWidget("Text", 500, 500); - releaseApp(); - setUpSlug(data.slug); - cy.forceClickOnCanvas(); - cy.backToApps(); + cy.apiLogin(data.email, "password"); + cy.visitSlug({ actualUrl: urls.editor }); + verifyRestrictedAccess(); + cy.get('[data-cy="back-to-home-button"]').click(); + cy.get(commonSelectors.homePageLogo).should("be.visible"); + cy.visitSlug({ actualUrl: urls.preview }); - // Test signup flow in new workspace - cy.apiLogout(); - cy.visitSlug({ - actualUrl: `${Cypress.config("baseUrl")}/applications/${data.slug}`, + cy.apiLogout(); + + // Test with new user + userSignUp(firstName1, email1, data.workspaceName); + cy.visitSlug({ actualUrl: urls.editor }); + cy.visitSlug({ actualUrl: urls.preview }); + cy.visitSlug({ actualUrl: urls.released }); }); - - cy.get(commonSelectors.workspaceName).verifyVisibleElement( - "have.text", - data.workspaceName - ); - - cy.get(commonSelectors.createAnAccountLink).click(); - cy.wait(3000); - cy.clearAndType(commonSelectors.inputFieldFullName, data.firstName); - cy.clearAndType(commonSelectors.inputFieldEmailAddress, data.email); - cy.clearAndType(onboardingSelectors.loginPasswordInput, "password"); - cy.get(commonSelectors.signUpButton).click(); - cy.wait('@signup').then((interception) => { - expect(interception.response.statusCode).to.eq(201); - }); - - onboardUserFromAppLink(data.email, data.slug, data.workspaceName, false); - cy.get(commonWidgetSelector.draggableWidget("text1")).should("be.visible"); - - - }); - - it("Should verify restricted app access", () => { - data.workspaceName = fake.firstName; - data.workspaceSlug = fake.firstName.toLowerCase().replace(/\s+/g, "-"); - - cy.apiCreateWorkspace(data.workspaceName, data.workspaceSlug); - cy.apiLogout(); - cy.apiLogin(); - cy.visit(`${data.workspaceSlug}`); - cy.apiDeleteGranularPermission("end-user"); - setSignupStatus(true, data.workspaceName); - - setupAppWithSlug(data.appName, data.slug); - - inviteUserToWorkspace(data.firstName, data.email); - - // Verify restricted access - cy.visitSlug({ - actualUrl: `${Cypress.config("baseUrl")}/applications/${data.slug}`, - }); - verifyRestrictedAccess(); - cy.get('[data-cy="back-to-home-button"]').click(); - cy.get(commonSelectors.homePageLogo).should("be.visible"); - - cy.apiLogout(); - }); - - it.skip("Should verify private app access for different workspace users", () => { - const firstName1 = fake.firstName; - const email1 = fake.email.toLowerCase(); - const permissionName = fake.firstName.toLowerCase(); // Defined but not used in original - const urls = { - editor: `${Cypress.config("baseUrl")}/my-workspace/apps/${data.slug}/home`, - preview: `${Cypress.config("baseUrl")}/applications/${data.slug}/home?version=v1`, - released: `${Cypress.config("baseUrl")}/applications/${data.slug}` - }; - - // Setup workspace and app - cy.apiCreateWorkspace(data.workspaceName, data.workspaceSlug); - cy.apiLogout(); - cy.apiLogin(); - cy.visit(`${data.workspaceSlug}`); - setupAppWithSlug(data.appName, data.slug); - - // Invite workspace user - addNewUser(data.firstName, data.email); - cy.wait(500); - - // Verify access restrictions - cy.visitSlug({ actualUrl: urls.editor }); - verifyRestrictedAccess(); - cy.get('[data-cy="back-to-home-button"]').click(); - cy.get(commonSelectors.homePageLogo).should("be.visible"); - - cy.visitSlug({ actualUrl: urls.preview }); - - // Switch users and verify access - cy.apiLogout(); - cy.apiLogin(); - cy.apiDeleteGranularPermission("end-user"); - - cy.apiLogin(data.email, "password"); - cy.visitSlug({ actualUrl: urls.editor }); - verifyRestrictedAccess(); - cy.get('[data-cy="back-to-home-button"]').click(); - cy.get(commonSelectors.homePageLogo).should("be.visible"); - cy.visitSlug({ actualUrl: urls.preview }); - - cy.apiLogout(); - - // Test with new user - userSignUp(firstName1, email1, data.workspaceName); - cy.visitSlug({ actualUrl: urls.editor }); - cy.visitSlug({ actualUrl: urls.preview }); - cy.visitSlug({ actualUrl: urls.released }); - }); -}); \ No newline at end of file + } +); diff --git a/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/userManagment/Login.cy.js b/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/userManagment/Login.cy.js index 55688c9222..ad30526421 100644 --- a/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/userManagment/Login.cy.js +++ b/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/userManagment/Login.cy.js @@ -50,9 +50,6 @@ describe("Login functionality", () => { it("Should be able to login with valid credentials", () => { cy.appUILogin(user.email, user.password); - if (envVar === "Enterprise") { - cy.get(".btn-close").click(); - } cy.get(commonSelectors.settingsIcon).click(); cy.get(dashboardSelector.logoutLink); }); diff --git a/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/userManagment/Signup.cy.js b/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/userManagment/Signup.cy.js index 40bc083798..3cc22ce9b8 100644 --- a/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/userManagment/Signup.cy.js +++ b/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/userManagment/Signup.cy.js @@ -15,11 +15,19 @@ import { } from "Support/utils/selfHostSignUp"; import { onboardingSelectors } from "Selectors/onboarding"; import { logout } from "Support/utils/common"; +import { enableInstanceSignup } from "Support/utils/manageSSO"; describe("User signup", () => { const data = {}; let invitationLink = ""; + before(() => { + cy.ifEnv("Enterprise", () => { + enableInstanceSignup() + }); + + }); + it("Verify the signup flow and UI elements", () => { data.fullName = fake.fullName; data.email = fake.email.toLowerCase().replaceAll("[^A-Za-z]", ""); diff --git a/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/userManagment/UserInviteFlow.cy.js b/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/userManagment/UserInviteFlow.cy.js index 5cdc6c5764..9883fef40d 100644 --- a/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/userManagment/UserInviteFlow.cy.js +++ b/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/userManagment/UserInviteFlow.cy.js @@ -23,6 +23,8 @@ import { import { groupsSelector } from "Selectors/manageGroups"; import { groupsText } from "Texts/manageGroups"; import { onboardingSelectors } from "Selectors/onboarding"; +import { enableInstanceSignup } from "Support/utils/manageSSO"; + let invitationToken, organizationToken, @@ -36,9 +38,9 @@ const envVar = Cypress.env("environment"); describe("user invite flow cases", () => { beforeEach(() => { cy.defaultWorkspaceLogin(); - if (envVar === "Enterprise") { - cy.get(".btn-close").click(); - } + cy.ifEnv("Enterprise", () => { + enableInstanceSignup() + }); }); it("Should verify the Manage users page", () => { diff --git a/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/userManagment/bulkUsersUpload.cy.js b/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/userManagment/bulkUsersUpload.cy.js index b769f09d64..32e0a6c064 100644 --- a/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/userManagment/bulkUsersUpload.cy.js +++ b/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/userManagment/bulkUsersUpload.cy.js @@ -1,59 +1,126 @@ import { commonSelectors } from "Selectors/common"; -import { usersText } from "Texts/manageUsers"; import { usersSelector } from "Selectors/manageUsers"; import { groupsSelector } from "Selectors/manageGroups"; import { fake } from "Fixtures/fake"; import * as common from "Support/utils/common"; import { bulkUserUpload } from "Support/utils/manageUsers"; +// Helper to resolve correct test data based on env +const getFile = (fileGroup) => { + const env = Cypress.env("environment"); + return env === "Community" ? fileGroup.default : fileGroup.alt; +}; + + describe("Bulk User Upload", () => { - // Test data configuration const TEST_FILES = { MISSING_NAME: { - path: "cypress/fixtures/bulkUser/without_name.csv", - fileName: "without_name", - error: - "Missing first_name,last_name,groups information in 2 row(s);. No users were uploaded, please update and try again.", + default: { + path: "cypress/fixtures/bulkUser/missing_name.csv", + fileName: "missing_name", + error: + "Missing first_name,last_name,groups information in 2 row(s);. No users were uploaded, please update and try again.", + }, + alt: { + path: "cypress/fixtures/bulkUser/missing_name_ee.csv", + fileName: "missing_name_ee", + error: + "Missing first_name,last_name,groups,metadata,userMetadata information in 2 row(s);. No users were uploaded, please update and try again.", + }, }, MISSING_EMAIL: { - path: "cypress/fixtures/bulkUser/without_email.csv", - fileName: "without_email", - error: - "Missing email,groups information in 2 row(s);. No users were uploaded, please update and try again.", + default: { + path: "cypress/fixtures/bulkUser/missing_email.csv", + fileName: "missing_email", + error: + "Missing email,groups information in 2 row(s);. No users were uploaded, please update and try again.", + }, + alt: { + path: "cypress/fixtures/bulkUser/missing_email_ee.csv", + fileName: "missing_email_ee", + error: + "Missing first_name,last_name,groups,metadata,userMetadata information in 2 row(s);. No users were uploaded, please update and try again.", + }, }, DUPLICATE_EMAIL: { - path: "cypress/fixtures/bulkUser/same_email.csv", - fileName: "same_email", - error: "Duplicate email found. Please provide a unique email address.", - isDuplicate: true, + default: { + path: "cypress/fixtures/bulkUser/same_email.csv", + fileName: "same_email", + error: "Duplicate email found. Please provide a unique email address.", + isDuplicate: true, + }, + alt: { + path: "cypress/fixtures/bulkUser/same_email_ee.csv", + fileName: "same_email_ee", + error: "Duplicate email found. Please provide a unique email address.", + isDuplicate: true, + }, }, EMPTY_NAMES: { - path: "cypress/fixtures/bulkUser/empty_first_and_last_name.csv", - fileName: "empty_first_and_last_name", - error: - "Missing first_name,last_name,groups information in 1 row(s);. No users were uploaded, please update and try again.", + default: { + path: "cypress/fixtures/bulkUser/empty_names.csv", + fileName: "empty_names", + error: + "Missing first_name,last_name,groups information in 1 row(s);. No users were uploaded, please update and try again.", + }, + alt: { + path: "cypress/fixtures/bulkUser/empty_names_ee.csv", + fileName: "empty_names_ee", + error: + "Missing first_name,last_name,groups,metadata,userMetadata information in 1 row(s);. No users were uploaded, please update and try again.", + }, }, LIMIT_EXCEEDED: { - path: "cypress/fixtures/bulkUser/500_invite_users.csv", - fileName: "500_invite_users", - error: "You can only invite 250 users at a time", + default: { + path: "cypress/fixtures/bulkUser/limit_exceeded.csv", + fileName: "limit_exceeded", + error: "You can only invite 250 users at a time", + }, + alt: { + path: "cypress/fixtures/bulkUser/limit_exceeded_ee.csv", + fileName: "limit_exceeded_ee", + error: "You can only invite 250 users at a time", + }, }, MISSING_ROLE: { - path: "cypress/fixtures/bulkUser/without_role.csv", - fileName: "without_role", - error: - "Missing user_role,groups information in 2 row(s);. No users were uploaded, please update and try again.", + default: { + path: "cypress/fixtures/bulkUser/missing_role.csv", + fileName: "missing_role", + error: + "Missing user_role,groups information in 2 row(s);. No users were uploaded, please update and try again.", + }, + alt: { + path: "cypress/fixtures/bulkUser/missing_role_ee.csv", + fileName: "missing_role_ee", + error: + "Missing user_role,groups,metadata,userMetadata information in 2 row(s);. No users were uploaded, please update and try again.", + }, }, NONEXISTENT_GROUP: { - path: "cypress/fixtures/bulkUser/non_existing_group.csv", - fileName: "non_existing_group", - error: "2 groups doesn't exist. No users were uploaded", + default: { + path: "cypress/fixtures/bulkUser/non_existing_group.csv", + fileName: "non_existing_group", + error: "2 groups doesn't exist. No users were uploaded", + }, + alt: { + path: "cypress/fixtures/bulkUser/non_existing_group_ee.csv", + fileName: "non_existing_group_ee", + error: "2 groups doesn't exist. No users were uploaded", + }, }, VALID_USERS: { - path: "cypress/fixtures/bulkUser/3usersupload.csv", - fileName: "3usersupload", - testEmail: "test12@gmail.com", - successMessage: "3 users are being added", + default: { + path: "cypress/fixtures/bulkUser/3_users_upload.csv", + fileName: "3_users_upload", + successMessage: "3 users are being added", + email: "test12@gmail.com", + }, + alt: { + path: "cypress/fixtures/bulkUser/3_users_upload_ee.csv", + fileName: "3_users_upload_ee", + successMessage: "3 users are being added", + email: "test12@gmail.com", + }, }, }; @@ -70,7 +137,6 @@ describe("Bulk User Upload", () => { cy.get(usersSelector.buttonAddUsers).click(); cy.get(usersSelector.buttonUploadCsvFile).click(); - // Test all error cases [ TEST_FILES.MISSING_ROLE, TEST_FILES.MISSING_NAME, @@ -79,7 +145,8 @@ describe("Bulk User Upload", () => { TEST_FILES.EMPTY_NAMES, TEST_FILES.NONEXISTENT_GROUP, TEST_FILES.LIMIT_EXCEEDED, - ].forEach((testCase) => { + ].forEach((testCaseGroup) => { + const testCase = getFile(testCaseGroup); bulkUserUpload( testCase.path, testCase.fileName, @@ -90,32 +157,30 @@ describe("Bulk User Upload", () => { }); it("Should successfully upload valid users", () => { + const file = getFile(TEST_FILES.VALID_USERS); cy.get(usersSelector.buttonAddUsers).click(); cy.get(usersSelector.buttonUploadCsvFile).click(); - cy.get(usersSelector.inputFieldBulkUpload).selectFile( - TEST_FILES.VALID_USERS.path, - { - force: true, - } - ); - cy.get(commonSelectors.fileSelector).should( - "contain", - TEST_FILES.VALID_USERS.fileName - ); + cy.get(usersSelector.inputFieldBulkUpload).selectFile(file.path, { + force: true, + }); + + cy.get(commonSelectors.fileSelector).should("contain", file.fileName); cy.get(usersSelector.buttonUploadUsers).click(); cy.get(".go2072408551") .should("be.visible") - .and("have.text", TEST_FILES.VALID_USERS.successMessage); - common.searchUser("test12@gmail.com"); - cy.contains("td", "test12@gmail.com") + .and("have.text", file.successMessage); + + common.searchUser(file.email); + cy.contains("td", file.email) .parent() .within(() => { cy.get("td small").should("have.text", "invited"); }); + common.navigateToManageGroups(); cy.get(groupsSelector.groupLink("Admin")).click(); cy.get(groupsSelector.usersLink).click(); - cy.contains("test12@gmail.com").should("be.visible"); + cy.contains(file.email).should("be.visible"); }); }); diff --git a/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/userManagment/userInviteFlowEdgeCases.cy.js b/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/userManagment/userInviteFlowEdgeCases.cy.js index 29c521a58b..0769f2fbf3 100644 --- a/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/userManagment/userInviteFlowEdgeCases.cy.js +++ b/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/userManagment/userInviteFlowEdgeCases.cy.js @@ -21,6 +21,7 @@ import { } from "Support/utils/common"; import { onboardingSelectors } from "Selectors/onboarding"; +import { enableInstanceSignup } from "Support/utils/manageSSO"; const data = {}; const envVar = Cypress.env("environment"); @@ -28,9 +29,9 @@ const envVar = Cypress.env("environment"); describe("inviteflow edge cases", () => { beforeEach(() => { cy.defaultWorkspaceLogin(); - if (envVar === "Enterprise") { - cy.get(".btn-close").click(); - } + cy.ifEnv("Enterprise", () => { + enableInstanceSignup(); + }); }); it("Should verify exisiting user invite flow", () => { @@ -69,55 +70,6 @@ describe("inviteflow edge cases", () => { }); }); - it("should verify the user signup after invited in a workspace", () => { - data.firstName = fake.firstName; - data.email = fake.email.toLowerCase().replaceAll("[^A-Za-z]", ""); - data.signUpName = fake.firstName; - data.workspaceName = fake.companyName; - - enableInstanceSignUp(); - setSignupStatus(true); - navigateToManageUsers(); - fillUserInviteForm(data.firstName, data.email); - cy.get(usersSelector.buttonInviteUsers).click(); - cy.apiLogout(); - - cy.visit("/"); - cy.get(commonSelectors.createAnAccountLink).click(); - SignUpPageElements(); - cy.wait(3000); - cy.clearAndType(onboardingSelectors.nameInput, data.signUpName); - cy.clearAndType(onboardingSelectors.signupEmailInput, data.email); - cy.clearAndType( - onboardingSelectors.loginPasswordInput, - commonText.password - ); - cy.get(commonSelectors.signUpButton).click(); - cy.wait(1000); - signUpLink(data.email); - if (envVar === "Enterprise") { - verifyOnboardingQuestions(data.workspaceName); - cy.wait(1000); - cy.get(commonSelectors.skipbutton).click(); - cy.backToApps(); - } - cy.wait(1000); - visitWorkspaceInvitation(data.email, "My workspace"); - cy.clearAndType(onboardingSelectors.signupEmailInput, data.email); - cy.clearAndType(onboardingSelectors.loginPasswordInput, usersText.password); - cy.get(onboardingSelectors.signInButton).click(); - cy.wait(3000); - cy.get(commonSelectors.invitedUserName).verifyVisibleElement( - "have.text", - data.signUpName - ); - cy.get(commonSelectors.acceptInviteButton).click(); - cy.get(commonSelectors.workspaceName).verifyVisibleElement( - "have.text", - "My workspace" - ); - }); - it("should verify the user signup with same creds after invited in a workspace", () => { data.firstName = fake.firstName; data.email = fake.email.toLowerCase().replaceAll("[^A-Za-z]", ""); diff --git a/cypress-tests/cypress/e2e/happyPath/platform/eeTestcases/externalApi/workspace/ldapOnboarding.cy.js b/cypress-tests/cypress/e2e/happyPath/platform/eeTestcases/externalApi/workspace/ldapOnboarding.cy.js new file mode 100644 index 0000000000..7cc5692226 --- /dev/null +++ b/cypress-tests/cypress/e2e/happyPath/platform/eeTestcases/externalApi/workspace/ldapOnboarding.cy.js @@ -0,0 +1,250 @@ +import { commonSelectors } from "Selectors/common"; +import { commonEeSelectors, ssoEeSelector } from "Selectors/eeCommon"; +import { ssoEeText } from "Texts/eeCommon"; +import { setSSOStatus, setSignupStatus } from "Support/utils/manageSSO"; +import { usersText } from "Texts/manageUsers"; +import { fake } from "Fixtures/fake"; +import { + logout, + navigateToManageSSO, + navigateToManageUsers, + searchUser, + pinInspector, + navigateToAppEditor, + navigateToManageGroups, +} from "Support/utils/common"; +import { ssoText } from "Texts/manageSSO"; +import { enableToggle, disableToggle } from "Support/utils/platform/eeCommon"; +import { setupAndUpdateRole } from "Support/utils/manageGroups"; + +describe("LDAP flow", () => { + const TEST_DATA = { + appName: `${fake.companyName} App`, + ldapUser: { + username: "Hubert J. Farnsworth", + password: "professor", + email: "professor@planetexpress.com", + }, + ldapConfig: { + name: "Tooljet LDAP Auth", + host: Cypress.env("ldap_host"), + port: "10389", + baseDn: Cypress.env("ldap_base_dn"), + }, + }; + + const ldapLogin = ( + username = TEST_DATA.ldapUser.username, + password = TEST_DATA.ldapUser.password + ) => { + cy.get(ssoEeSelector.ldapSSOText).click(); + cy.clearAndType(commonSelectors.inputFieldName, username); + cy.clearAndType(ssoEeSelector.passwordInputField, password); + cy.get(commonSelectors.signUpButton).click(); + }; + + const toggleUserArchiveStatus = (shouldArchive = true) => { + navigateToManageUsers(); + searchUser(TEST_DATA.ldapUser.email); + cy.get('[data-cy="user-actions-button"]').click(); + cy.get('[data-cy="archive-button"]').click(); + + const expectedToast = shouldArchive + ? usersText.archivedToast + : usersText.unarchivedToast; + cy.verifyToastMessage(commonSelectors.toastMessage, expectedToast); + + if (shouldArchive) { + cy.contains("td", TEST_DATA.ldapUser.email) + .parent() + .within(() => { + cy.get("td small").should("have.text", usersText.archivedStatus); + }); + } + }; + + beforeEach(() => { + cy.visit("/"); + cy.appUILogin(); + }); + + it("Verify complete LDAP flow: UI, user onboarding, inspector SSO info, and archive functionality", () => { + cy.intercept("GET", "api/library_apps").as("apps"); + + // ========== SECTION 1: LDAP Configuration and UI Verification ========== + setSSOStatus("My workspace", "ldap", false); + navigateToManageSSO(); + cy.wait(1000); + + cy.get('[data-cy="ldap-sso-card"]') + .verifyVisibleElement("have.text", "LDAP") + .click(); + + cy.get(ssoEeSelector.ldapToggle).should("be.visible"); + + for (const element in ssoEeSelector.ldapPageElements) { + cy.get(ssoEeSelector.ldapPageElements[element]).verifyVisibleElement( + "have.text", + ssoEeText.ldapPageElements[element] + ); + } + + const formElements = [ + ssoEeSelector.statusLabel, + ssoEeSelector.nameInput, + ssoEeSelector.hostInput, + ssoEeSelector.portInput, + ssoEeSelector.baseDnInput, + ssoEeSelector.sslToggleInput, + ]; + + formElements.forEach((selector) => { + cy.get(selector).should("be.visible"); + }); + + cy.get(commonSelectors.cancelButton) + .eq(1) + .verifyVisibleElement("have.text", "Cancel"); + cy.get(commonEeSelectors.saveButton) + .eq(1) + .verifyVisibleElement("have.text", "Save changes"); + + enableToggle(ssoEeSelector.sslToggleInput); + cy.get(ssoEeSelector.ldapPageElements.sslLabel) + .eq(1) + .verifyVisibleElement("have.text", "SSL certificate"); + cy.get(".css-1x65k0v-control").should("be.visible"); + + cy.clearAndType(ssoEeSelector.nameInput, TEST_DATA.ldapConfig.name); + cy.clearAndType(ssoEeSelector.hostInput, TEST_DATA.ldapConfig.host); + cy.clearAndType(ssoEeSelector.portInput, TEST_DATA.ldapConfig.port); + cy.clearAndType(ssoEeSelector.baseDnInput, TEST_DATA.ldapConfig.baseDn); + + cy.get(ssoEeSelector.sslToggleInput).uncheck(); + cy.get(ssoEeSelector.ldapToggle).click(); + disableToggle(ssoEeSelector.sslToggleInput); + + cy.get(commonEeSelectors.saveButton).eq(1).click(); + cy.verifyToastMessage( + commonSelectors.toastMessage, + ssoText.toggleUpdateToast("LDAP") + ); + cy.get(commonSelectors.cancelButton).eq(1).click(); + logout(); + + // ========== SECTION 2: LDAP Login Page and User Onboarding ========== + cy.get(ssoEeSelector.ldapSSOText).verifyVisibleElement( + "have.text", + ssoEeText.ldapSSOText + ); + + cy.get(ssoEeSelector.ldapSSOText).click(); + + const loginPageElements = [ + { selector: '[data-cy="key-logo"]', assertion: "be.visible" }, + { + selector: ssoEeSelector.userNameInputLabel, + text: ssoEeText.userNameInputLabel, + }, + { selector: commonSelectors.inputFieldName, assertion: "be.visible" }, + { selector: ssoEeSelector.passwordInputLabel, text: "Password" }, + { selector: ssoEeSelector.passwordInputField, assertion: "be.visible" }, + { selector: commonSelectors.signUpButton, text: "Sign in" }, + ]; + + loginPageElements.forEach((element) => { + if (element.text) { + cy.get(element.selector).verifyVisibleElement( + "have.text", + element.text + ); + } else { + cy.get(element.selector).should(element.assertion); + } + }); + + // Test failed login (user doesn't exist in workspace) + cy.clearAndType( + commonSelectors.inputFieldName, + TEST_DATA.ldapUser.username + ); + cy.clearAndType( + ssoEeSelector.passwordInputField, + TEST_DATA.ldapUser.password + ); + cy.get(commonSelectors.signUpButton).click(); + cy.verifyToastMessage( + commonSelectors.toastMessage, + "LDAP login failed - User does not exist in the workspace" + ); + + cy.defaultWorkspaceLogin(); + setSignupStatus(true); + logout(); + + ldapLogin(); + cy.get(commonSelectors.pageSectionHeader).verifyVisibleElement( + "have.text", + "Applications" + ); + logout(); + + // ========== SECTION 3: Setup App and User Permissions for Inspector Test ========== + cy.defaultWorkspaceLogin(); + cy.apiCreateApp(TEST_DATA.appName); + + navigateToManageGroups(); + setupAndUpdateRole("End-user", "Builder", TEST_DATA.ldapUser.email); + logout(); + + // ========== SECTION 4: Verify SSO User Info in Inspector ========== + ldapLogin(); + + cy.wait("@apps"); + cy.wait(1000); + + navigateToAppEditor(TEST_DATA.appName); + pinInspector(); + + const inspectorPath = [ + '[data-cy="inspector-node-globals"] > .node-key', + '[data-cy="inspector-node-currentuser"] > .node-key', + '[data-cy="inspector-node-ssouserinfo"] > .node-key', + '[data-cy="inspector-node-mail"] > .node-key', + ]; + + inspectorPath.forEach((selector) => cy.get(selector).click()); + + cy.get('[data-cy="inspector-node-0"] > .mx-2').verifyVisibleElement( + "have.text", + `"${TEST_DATA.ldapUser.email}"` + ); + cy.backToApps(); + logout(); + + // ========== SECTION 5: Archive/Unarchive Functionality ========== + cy.defaultWorkspaceLogin(); + + // Archive user and verify status + toggleUserArchiveStatus(true); + logout(); + + ldapLogin(); + cy.verifyToastMessage( + commonSelectors.toastMessage, + "LDAP login failed - User is archived in the workspace" + ); + + // Unarchive user + cy.go("back"); + cy.appUILogin(); + toggleUserArchiveStatus(false); + logout(); + + ldapLogin(); + cy.get(commonSelectors.pageSectionHeader).verifyVisibleElement( + "have.text", + "Applications" + ); + }); +}); diff --git a/cypress-tests/cypress/e2e/happyPath/platform/eeTestcases/externalApi/workspace/openId.cy.js b/cypress-tests/cypress/e2e/happyPath/platform/eeTestcases/externalApi/workspace/openId.cy.js new file mode 100644 index 0000000000..d304d76a07 --- /dev/null +++ b/cypress-tests/cypress/e2e/happyPath/platform/eeTestcases/externalApi/workspace/openId.cy.js @@ -0,0 +1,239 @@ +import * as common from "Support/utils/common"; +import { ssoText } from "Texts/manageSSO"; +import { + inviteUser, + WorkspaceInvitationLink, +} from "Support/utils/platform/eeCommon.js"; +import { commonSelectors } from "Selectors/common"; +import { + commonEeSelectors, + ssoEeSelector, + instanceSettingsSelector, +} from "Selectors/eeCommon"; +import { commonEeText } from "Texts/eeCommon"; +import { + setSignupStatus, + defaultSSO, + deleteOrganisationSSO, +} from "Support/utils/manageSSO"; +import { confirmInviteElements } from "Support/utils/manageUsers"; +import { usersText } from "Texts/manageUsers"; +import { usersSelector } from "Selectors/manageUsers"; + +import { fetchAndVisitInviteLink } from "Support/utils/manageUsers"; +import { enableInstanceSignup } from "Support/utils/manageSSO"; + +describe("Verify OIDC user onboarding", () => { + const envVar = Cypress.env("environment"); + + beforeEach(() => { + cy.defaultWorkspaceLogin(); + cy.intercept("GET", "api/library_apps").as("apps"); + cy.wait(2000); + defaultSSO(true); + }); + + it("Verify user onboarding using workspace OIDC", () => { + deleteOrganisationSSO("My workspace", ["openid"]); + common.navigateToManageSSO(); + defaultSSO(false); + setSignupStatus(false); + cy.wait(1000); + + cy.get(ssoEeSelector.oidc).click(); + cy.get(ssoEeSelector.oidcToggle).click(); + cy.clearAndType(ssoEeSelector.nameInput, "Tooljet OIDC"); + cy.clearAndType( + ssoEeSelector.clientIdInput, + Cypress.env("SSO_OPENID_CLIENT_ID") + ); + cy.clearAndType( + ssoEeSelector.clientSecretInput, + Cypress.env("SSO_OPENID_CLIENT_SECRET") + ); + cy.clearAndType( + ssoEeSelector.WellKnownUrlInput, + Cypress.env("SSO_OPENID_WELL_KNOWN_URL") + ); + cy.get(commonEeSelectors.saveButton).eq(1).click(); + cy.get('[data-cy="enable-button"]').click(); + cy.verifyToastMessage( + commonSelectors.toastMessage, + ssoText.toggleUpdateToast("OpenID") + ); + + cy.apiLogout(); + cy.visit("/login/my-workspace"); + cy.get(ssoEeSelector.oidcSSOText).verifyVisibleElement( + "have.text", + "Sign in with Tooljet OIDC" + ); + cy.get(ssoEeSelector.oidcSSOText).realClick(); + cy.get(".superadmin-button").click(); + + cy.verifyToastMessage( + commonSelectors.toastMessage, + "Open ID login failed - User does not exist in the workspace" + ); + + cy.apiLogin(); + setSignupStatus(true); + cy.apiLogout(); + + cy.visit("/login/my-workspace"); + cy.get(ssoEeSelector.oidcSSOText).realClick(); + cy.get(".superadmin-button").click(); + + common.logout(); + + cy.defaultWorkspaceLogin(); + common.navigateToManageUsers(); + common.searchUser("superadmin@tooljet.com"); + + cy.contains("td", "superadmin@tooljet.com") + .parent() + .within(() => { + cy.get("td small").should("have.text", usersText.activeStatus); + }); + + cy.apiLogout(); + cy.visit("/my-workspace"); + cy.get(ssoEeSelector.oidcSSOText).realClick(); + cy.get(".superadmin-button").click(); + }); + + it("Verify invited user onboarding using instance level OIDC", () => { + setSignupStatus(true); + + cy.ifEnv("Enterprise", () => { + enableInstanceSignup(); + }); + + common.navigateToManageUsers(); + inviteUser("user", "user@tooljet.com"); + confirmInviteElements("user@tooljet.com"); + cy.wait(2000); + cy.get(ssoEeSelector.oidcSSOText).realClick(); + cy.get(".user-button").click(); + cy.wait(1000); + + cy.get(commonSelectors.acceptInviteButton).click(); + cy.wait("@apps"); + cy.contains("My workspace").should("be.visible"); + common.logout(); + + cy.defaultWorkspaceLogin(); + setSignupStatus(false); + + common.navigateToManageUsers(); + cy.wait(500); + inviteUser("user", "userthree@tooljet.com"); + cy.wait(2000); + cy.get(ssoEeSelector.oidcSSOText).realClick(); + cy.get(".user-four-button").click(); + + cy.get(commonSelectors.toastMessage) + .should("be.visible") + .and( + "have.text", + "Open ID login failed - Invalid Email: Please use the email address provided in the invitation." + ); + cy.wait(500); + cy.defaultWorkspaceLogin(); + + setSignupStatus(true); + fetchAndVisitInviteLink("userthree@tooljet.com"); + cy.wait(2000); + cy.get(ssoEeSelector.oidcSSOText).realClick(); + cy.get(".user-four-button").click(); + cy.get(commonSelectors.toastMessage) + .should("be.visible") + .and( + "have.text", + "Open ID login failed - Invalid Email: Please use the email address provided in the invitation." + ); + cy.get(ssoEeSelector.oidcSSOText).realClick(); + cy.get(".superadmin-button").click(); + cy.get(commonSelectors.toastMessage) + .should("be.visible") + .and( + "have.text", + "Open ID login failed - Invalid Email: Please use the email address provided in the invitation." + ); + }); + + if (envVar === "Enterprise") { + it("Verify user onboarding using instance level OIDC", () => { + enableInstanceSignup(); + cy.apiLogout(); + + cy.visit("/"); + cy.get(ssoEeSelector.oidcSSOText).realClick(); + cy.get(".admin-button").click(); + cy.wait(3000); + common.logout(); + + cy.defaultWorkspaceLogin(); + cy.get(commonSelectors.settingsIcon).click(); + cy.get(commonEeSelectors.instanceSettingIcon).click(); + cy.clearAndType(commonSelectors.inputUserSearch, "admin@tooljet.com"); + + cy.get(instanceSettingsSelector.userStatus("admin")).verifyVisibleElement( + "have.text", + usersText.activeStatus + ); + + cy.apiLogout(); + cy.visit("/"); + cy.get(ssoEeSelector.oidcSSOText).realClick(); + cy.get(".admin-button").click(); + }); + } + + + it("Verify archived user login using OIDC", () => { + setSignupStatus(true); + cy.ifEnv("Enterprise", () => { + enableInstanceSignup(); + }); + common.navigateToManageUsers(); + cy.get(usersSelector.buttonAddUsers).click(); + cy.get(commonSelectors.inputFieldFullName).type("user two"); + cy.get(commonSelectors.inputFieldEmailAddress).type("usertwo@tooljet.com"); + cy.get(usersSelector.buttonInviteUsers).click(); + WorkspaceInvitationLink("usertwo@tooljet.com"); + + cy.wait(2000); + cy.get(ssoEeSelector.oidcSSOText).realClick(); + cy.get(".user-two-button").click(); + + cy.get(commonSelectors.acceptInviteButton).click(); + cy.wait("@apps"); + cy.contains("My workspace").should("be.visible"); + common.logout(); + + cy.defaultWorkspaceLogin(); + common.navigateToManageUsers(); + common.searchUser("usertwo@tooljet.com"); + cy.get('[data-cy="user-actions-button"]').click(); + cy.get('[data-cy="archive-button"]').click(); + + cy.verifyToastMessage( + commonSelectors.toastMessage, + usersText.archivedToast + ); + cy.get(instanceSettingsSelector.userStatus("user two"), { + timeout: 9000, + }).should("have.text", usersText.archivedStatus); + cy.apiLogout(); + cy.visit("/my-workspace"); + cy.wait(2000); + cy.get(ssoEeSelector.oidcSSOText).realClick(); + cy.get(".user-two-button").click(); + + cy.verifyToastMessage( + commonSelectors.toastMessage, + "Open ID login failed - User is archived in the workspace" + ); + }); +}); diff --git a/cypress-tests/cypress/e2e/happyPath/platform/firstUser/firstUserOnboarding.cy.js b/cypress-tests/cypress/e2e/happyPath/platform/firstUser/firstUserOnboarding.cy.js index ebf671e667..0c536c8ec3 100644 --- a/cypress-tests/cypress/e2e/happyPath/platform/firstUser/firstUserOnboarding.cy.js +++ b/cypress-tests/cypress/e2e/happyPath/platform/firstUser/firstUserOnboarding.cy.js @@ -18,7 +18,7 @@ describe("Self host onboarding", () => { }); it("verify elements on self host onboarding page", () => { - if (envVar === "Enterprise") { + cy.ifEnv("Enterprise", () => { cy.get(commonSelectors.HostBanner).should("be.visible"); cy.get(commonSelectors.pageLogo).should("be.visible"); cy.get('[data-cy="welcome-to-tooljet!-header"]').verifyVisibleElement( @@ -34,7 +34,7 @@ describe("Self host onboarding", () => { "Set up ToolJet" ); cy.get('[data-cy="set-up-tooljet-button"]').click(); - } + }); const commonElements = [ { selector: commonSelectors.HostBanner }, @@ -76,20 +76,22 @@ describe("Self host onboarding", () => { cy.get(check.selector).verifyVisibleElement("have.text", check.text); }); - if (envVar === "Community") { + cy.ifEnv("Community", () => { cy.get(commonSelectors.signUpTermsHelperText).should(($el) => { expect($el.contents().first().text().trim()).to.eq( // commonText.selfHostSignUpTermsHelperText "By signing up you are agreeing to the" ); }); - } else if (envVar === "Enterprise") { + }); + + cy.ifEnv("Enterprise", () => { cy.get(commonSelectors.signUpTermsHelperText).should(($el) => { expect($el.contents().first().text().trim()).to.eq( "By signing up you are agreeing to the" ); }); - } + }); const links = [ { @@ -116,20 +118,15 @@ describe("Self host onboarding", () => { cy.get(onboardingSelectors.passwordInput).type("password"); cy.get(commonSelectors.continueButton).click(); - if (envVar === "Enterprise") { + cy.ifEnv("Enterprise", () => { bannerElementsVerification(); onboardingStepOne(); - } + }); bannerElementsVerification(); onboardingStepTwo(); - // if (envVar === "Enterprise") { - // bannerElementsVerification(); - // onboardingStepTwo(); - // } - - if (envVar === "Enterprise") { + cy.ifEnv("Enterprise", () => { bannerElementsVerification(); const trialPageTexts = [ @@ -173,7 +170,7 @@ describe("Self host onboarding", () => { cy.get(onboardingSelectors.onPremiseLink) .verifyVisibleElement("have.text", "Click here") .and("have.attr", "href") - .and("equal", "https://www.tooljet.com/pricing?payment=onpremise"); + .and("equal", "https://tooljet.ai/pricing?payment=onpremise"); const planTitles = [ { @@ -196,66 +193,59 @@ describe("Self host onboarding", () => { const prices = [ { selector: `${onboardingSelectors.planPrice}:eq(0)`, text: "$0" }, - { selector: `${onboardingSelectors.planPrice}:eq(1)`, text: "$30" }, + { + selector: '[data-cy="pro-plan-price"]:eq(0)', + text: "$79/monthper builder", + }, + { + selector: '[data-cy="pro-plan-price"]:eq(1)', + text: "$199/monthper builder", + }, + { + selector: `${onboardingSelectors.planToggleLabel}:eq(0)`, + text: "Yearly20% off", + }, + { + selector: `${onboardingSelectors.planToggleLabel}:eq(1)`, + text: "Yearly20% off", + }, ]; prices.forEach((item) => { cy.get(item.selector).should("be.visible").and("have.text", item.text); }); - cy.get(onboardingSelectors.planToggleInput).should("be.visible"); - cy.get(onboardingSelectors.planToggleLabel).verifyVisibleElement( - "have.text", - "Yearly20% off" - ); - cy.get(onboardingSelectors.discountDetails).verifyVisibleElement( - "have.text", - "20% off" - ); + cy.get(onboardingSelectors.planToggleInput).eq(0).should("be.visible"); + cy.get(onboardingSelectors.planToggleInput).eq(1).should("be.visible"); - cy.get(onboardingSelectors.builderPrice).verifyVisibleElement( - "have.text", - "$24" - ); - cy.get('[data-cy="builder-price-period"]').verifyVisibleElement( - "have.text", - onboardingText.priceMonthlyText - ); - cy.get('[data-cy="builder-price-description"]').verifyVisibleElement( - "have.text", - "per builder" - ); - - cy.get(onboardingSelectors.endUserPrice).verifyVisibleElement( - "have.text", - "$8" - ); - cy.get('[data-cy="enduser-price-period"]').verifyVisibleElement( - "have.text", - onboardingText.priceMonthlyText - ); - cy.get('[data-cy="enduser-price-description"]').verifyVisibleElement( - "have.text", - "per end user" - ); - - cy.get(onboardingSelectors.pricingPlanToggle).uncheck({ force: true }); + cy.get(onboardingSelectors.pricingPlanToggle) + .eq(0) + .uncheck({ force: true }); cy.get(onboardingSelectors.planToggleLabel) - .first() + .eq(0) .verifyVisibleElement("have.text", "Monthly20% off"); cy.get(onboardingSelectors.discountDetails) .should("have.css", "text-decoration") .and("include", "line-through"); - cy.get(onboardingSelectors.builderPrice).verifyVisibleElement( - "have.text", - "$30" - ); - cy.get(onboardingSelectors.endUserPrice).verifyVisibleElement( - "have.text", - "$10" - ); + cy.get('[data-cy="pro-plan-price"]') + .eq(0) + .verifyVisibleElement("have.text", "$99/monthper builder"); + + cy.get(onboardingSelectors.pricingPlanToggle) + .eq(1) + .uncheck({ force: true }); + cy.get(onboardingSelectors.planToggleLabel) + .eq(1) + .verifyVisibleElement("have.text", "Monthly20% off"); + cy.get(onboardingSelectors.discountDetails) + .should("have.css", "text-decoration") + .and("include", "line-through"); + + cy.get('[data-cy="pro-plan-price"]') + .eq(1) + .verifyVisibleElement("have.text", "$249/monthper builder"); cy.get(onboardingSelectors.enterpriseTitle).verifyVisibleElement( "have.text", @@ -274,19 +264,11 @@ describe("Self host onboarding", () => { bannerElementsVerification(); onboardingStepThree(); - } + }); cy.get(commonSelectors.skipbutton).click(); cy.backToApps(); - if (envVar === "Enterprise") { - cy.get(".btn-close").click(); - } - - if (envVar === "Enterprise") { - cy.get(".btn-close").click(); - } - logout(); cy.appUILogin(); diff --git a/cypress-tests/cypress/fixtures/bulkUser/3_users_upload.csv b/cypress-tests/cypress/fixtures/bulkUser/3_users_upload.csv new file mode 100644 index 0000000000..9109b69d4e --- /dev/null +++ b/cypress-tests/cypress/fixtures/bulkUser/3_users_upload.csv @@ -0,0 +1,4 @@ +First Name,Last Name,Email,User Role,Group +test1,user,test1@gmail.com,Builder, +test2,user,test3@gmail.com,End User, +Test3,Example,test12@gmail.com,Admin, diff --git a/cypress-tests/cypress/fixtures/bulkUser/3_users_upload_ee.csv b/cypress-tests/cypress/fixtures/bulkUser/3_users_upload_ee.csv new file mode 100644 index 0000000000..5a7996a60a --- /dev/null +++ b/cypress-tests/cypress/fixtures/bulkUser/3_users_upload_ee.csv @@ -0,0 +1,4 @@ +First Name,Last Name,Email,User Role,Group,Metadata +test1,user,test1@gmail.com,Builder,, +test2,user,test3@gmail.com,End User,, +Test3,Example,test12@gmail.com,Admin,, diff --git a/cypress-tests/cypress/fixtures/bulkUser/empty_names.csv b/cypress-tests/cypress/fixtures/bulkUser/empty_names.csv new file mode 100644 index 0000000000..56fcda708f --- /dev/null +++ b/cypress-tests/cypress/fixtures/bulkUser/empty_names.csv @@ -0,0 +1,3 @@ +First Name,Last Name,Email,User Role,Group +,,test12empty@gmail.com,Admin,Admin +Test,Example,test12empty@gmail.com,Builder,Builder diff --git a/cypress-tests/cypress/fixtures/bulkUser/empty_names_ee.csv b/cypress-tests/cypress/fixtures/bulkUser/empty_names_ee.csv new file mode 100644 index 0000000000..5718fe99f2 --- /dev/null +++ b/cypress-tests/cypress/fixtures/bulkUser/empty_names_ee.csv @@ -0,0 +1,3 @@ +First Name,Last Name,Email,User Role,Group,Metadata +,,test12empty@gmail.com,Admin,Admin, +Test,Example,test12empty@gmail.com,Builder,Builder, diff --git a/cypress-tests/cypress/fixtures/bulkUser/limit_exceeded.csv b/cypress-tests/cypress/fixtures/bulkUser/limit_exceeded.csv new file mode 100644 index 0000000000..23e3f2e728 --- /dev/null +++ b/cypress-tests/cypress/fixtures/bulkUser/limit_exceeded.csv @@ -0,0 +1,252 @@ +First Name,Last Name,Email,User Role,Group +Vijay,Yadav,vjyaav1@gmail.com,Admin, +Vijay,Yadav,vjyaav2@gmail.com,Builder, +Vijay,Yadav,vjyaav3@gmail.com,Builder, +Vijay,Yadav,vjyaav4@gmail.com,End User, +Vijay,Yadav,vjyaav5@gmail.com,End User, +Vijay,Yadav,vjyaav6@gmail.com,End User, +Vijay,Yadav,vjyaav7@gmail.com,End User, +Vijay,Yadav,vjyaav8@gmail.com,End User, +Vijay,Yadav,vjyaav9@gmail.com,End User, +Vijay,Yadav,vjyaav10@gmail.com,End User, +Vijay,Yadav,vjyaav11@gmail.com,End User, +Vijay,Yadav,vjyaav12@gmail.com,End User, +Vijay,Yadav,vjyaav13@gmail.com,End User, +Vijay,Yadav,vjyaav14@gmail.com,End User, +Vijay,Yadav,vjyaav15@gmail.com,End User, +Vijay,Yadav,vjyaav16@gmail.com,End User, +Vijay,Yadav,vjyaav17@gmail.com,End User, +Vijay,Yadav,vjyaav18@gmail.com,End User, +Vijay,Yadav,vjyaav19@gmail.com,End User, +Vijay,Yadav,vjyaav20@gmail.com,End User, +Vijay,Yadav,vjyaav21@gmail.com,End User, +Vijay,Yadav,vjyaav22@gmail.com,End User, +Vijay,Yadav,vjyaav23@gmail.com,End User, +Vijay,Yadav,vjyaav24@gmail.com,End User, +Vijay,Yadav,vjyaav25@gmail.com,End User, +Vijay,Yadav,vjyaav26@gmail.com,End User, +Vijay,Yadav,vjyaav27@gmail.com,End User, +Vijay,Yadav,vjyaav28@gmail.com,End User, +Vijay,Yadav,vjyaav29@gmail.com,End User, +Vijay,Yadav,vjyaav30@gmail.com,End User, +Vijay,Yadav,vjyaav31@gmail.com,End User, +Vijay,Yadav,vjyaav32@gmail.com,End User, +Vijay,Yadav,vjyaav33@gmail.com,End User, +Vijay,Yadav,vjyaav34@gmail.com,End User, +Vijay,Yadav,vjyaav35@gmail.com,End User, +Vijay,Yadav,vjyaav36@gmail.com,End User, +Vijay,Yadav,vjyaav37@gmail.com,End User, +Vijay,Yadav,vjyaav38@gmail.com,End User, +Vijay,Yadav,vjyaav39@gmail.com,End User, +Vijay,Yadav,vjyaav40@gmail.com,End User, +Vijay,Yadav,vjyaav41@gmail.com,End User, +Vijay,Yadav,vjyaav42@gmail.com,End User, +Vijay,Yadav,vjyaav43@gmail.com,End User, +Vijay,Yadav,vjyaav44@gmail.com,End User, +Vijay,Yadav,vjyaav45@gmail.com,End User, +Vijay,Yadav,vjyaav46@gmail.com,End User, +Vijay,Yadav,vjyaav47@gmail.com,End User, +Vijay,Yadav,vjyaav48@gmail.com,End User, +Vijay,Yadav,vjyaav49@gmail.com,End User, +Vijay,Yadav,vjyaav50@gmail.com,End User, +Vijay,Yadav,vjyaav51@gmail.com,End User, +Vijay,Yadav,vjyaav52@gmail.com,End User, +Vijay,Yadav,vjyaav53@gmail.com,End User, +Vijay,Yadav,vjyaav54@gmail.com,End User, +Vijay,Yadav,vjyaav55@gmail.com,End User, +Vijay,Yadav,vjyaav56@gmail.com,End User, +Vijay,Yadav,vjyaav57@gmail.com,End User, +Vijay,Yadav,vjyaav58@gmail.com,End User, +Vijay,Yadav,vjyaav59@gmail.com,End User, +Vijay,Yadav,vjyaav60@gmail.com,End User, +Vijay,Yadav,vjyaav61@gmail.com,End User, +Vijay,Yadav,vjyaav62@gmail.com,End User, +Vijay,Yadav,vjyaav63@gmail.com,End User, +Vijay,Yadav,vjyaav64@gmail.com,End User, +Vijay,Yadav,vjyaav65@gmail.com,End User, +Vijay,Yadav,vjyaav66@gmail.com,End User, +Vijay,Yadav,vjyaav67@gmail.com,End User, +Vijay,Yadav,vjyaav68@gmail.com,End User, +Vijay,Yadav,vjyaav69@gmail.com,End User, +Vijay,Yadav,vjyaav70@gmail.com,End User, +Vijay,Yadav,vjyaav71@gmail.com,End User, +Vijay,Yadav,vjyaav72@gmail.com,End User, +Vijay,Yadav,vjyaav73@gmail.com,End User, +Vijay,Yadav,vjyaav74@gmail.com,End User, +Vijay,Yadav,vjyaav75@gmail.com,End User, +Vijay,Yadav,vjyaav76@gmail.com,End User, +Vijay,Yadav,vjyaav77@gmail.com,End User, +Vijay,Yadav,vjyaav78@gmail.com,End User, +Vijay,Yadav,vjyaav79@gmail.com,End User, +Vijay,Yadav,vjyaav80@gmail.com,End User, +Vijay,Yadav,vjyaav81@gmail.com,End User, +Vijay,Yadav,vjyaav82@gmail.com,End User, +Vijay,Yadav,vjyaav83@gmail.com,End User, +Vijay,Yadav,vjyaav84@gmail.com,End User, +Vijay,Yadav,vjyaav85@gmail.com,End User, +Vijay,Yadav,vjyaav86@gmail.com,End User, +Vijay,Yadav,vjyaav87@gmail.com,End User, +Vijay,Yadav,vjyaav88@gmail.com,End User, +Vijay,Yadav,vjyaav89@gmail.com,End User, +Vijay,Yadav,vjyaav90@gmail.com,End User, +Vijay,Yadav,vjyaav91@gmail.com,End User, +Vijay,Yadav,vjyaav92@gmail.com,End User, +Vijay,Yadav,vjyaav93@gmail.com,End User, +Vijay,Yadav,vjyaav94@gmail.com,End User, +Vijay,Yadav,vjyaav95@gmail.com,End User, +Vijay,Yadav,vjyaav96@gmail.com,End User, +Vijay,Yadav,vjyaav97@gmail.com,End User, +Vijay,Yadav,vjyaav98@gmail.com,End User, +Vijay,Yadav,vjyaav99@gmail.com,End User, +Vijay,Yadav,vjyaav100@gmail.com,End User, +Vijay,Yadav,vjyaav101@gmail.com,End User, +Vijay,Yadav,vjyaav102@gmail.com,End User, +Vijay,Yadav,vjyaav103@gmail.com,End User, +Vijay,Yadav,vjyaav104@gmail.com,End User, +Vijay,Yadav,vjyaav105@gmail.com,End User, +Vijay,Yadav,vjyaav106@gmail.com,End User, +Vijay,Yadav,vjyaav107@gmail.com,End User, +Vijay,Yadav,vjyaav108@gmail.com,End User, +Vijay,Yadav,vjyaav109@gmail.com,End User, +Vijay,Yadav,vjyaav110@gmail.com,End User, +Vijay,Yadav,vjyaav111@gmail.com,End User, +Vijay,Yadav,vjyaav112@gmail.com,End User, +Vijay,Yadav,vjyaav113@gmail.com,End User, +Vijay,Yadav,vjyaav114@gmail.com,End User, +Vijay,Yadav,vjyaav115@gmail.com,End User, +Vijay,Yadav,vjyaav116@gmail.com,End User, +Vijay,Yadav,vjyaav117@gmail.com,End User, +Vijay,Yadav,vjyaav118@gmail.com,End User, +Vijay,Yadav,vjyaav119@gmail.com,End User, +Vijay,Yadav,vjyaav120@gmail.com,End User, +Vijay,Yadav,vjyaav121@gmail.com,End User, +Vijay,Yadav,vjyaav122@gmail.com,End User, +Vijay,Yadav,vjyaav123@gmail.com,End User, +Vijay,Yadav,vjyaav124@gmail.com,End User, +Vijay,Yadav,vjyaav125@gmail.com,End User, +Vijay,Yadav,vjyaav126@gmail.com,End User, +Vijay,Yadav,vjyaav127@gmail.com,End User, +Vijay,Yadav,vjyaav128@gmail.com,End User, +Vijay,Yadav,vjyaav129@gmail.com,End User, +Vijay,Yadav,vjyaav130@gmail.com,End User, +Vijay,Yadav,vjyaav131@gmail.com,End User, +Vijay,Yadav,vjyaav132@gmail.com,End User, +Vijay,Yadav,vjyaav133@gmail.com,End User, +Vijay,Yadav,vjyaav134@gmail.com,End User, +Vijay,Yadav,vjyaav135@gmail.com,End User, +Vijay,Yadav,vjyaav136@gmail.com,End User, +Vijay,Yadav,vjyaav137@gmail.com,End User, +Vijay,Yadav,vjyaav138@gmail.com,End User, +Vijay,Yadav,vjyaav139@gmail.com,End User, +Vijay,Yadav,vjyaav140@gmail.com,End User, +Vijay,Yadav,vjyaav141@gmail.com,End User, +Vijay,Yadav,vjyaav142@gmail.com,End User, +Vijay,Yadav,vjyaav143@gmail.com,End User, +Vijay,Yadav,vjyaav144@gmail.com,End User, +Vijay,Yadav,vjyaav145@gmail.com,End User, +Vijay,Yadav,vjyaav146@gmail.com,End User, +Vijay,Yadav,vjyaav147@gmail.com,End User, +Vijay,Yadav,vjyaav148@gmail.com,End User, +Vijay,Yadav,vjyaav149@gmail.com,End User, +Vijay,Yadav,vjyaav150@gmail.com,End User, +Vijay,Yadav,vjyaav151@gmail.com,End User, +Vijay,Yadav,vjyaav152@gmail.com,End User, +Vijay,Yadav,vjyaav153@gmail.com,End User, +Vijay,Yadav,vjyaav154@gmail.com,End User, +Vijay,Yadav,vjyaav155@gmail.com,End User, +Vijay,Yadav,vjyaav156@gmail.com,End User, +Vijay,Yadav,vjyaav157@gmail.com,End User, +Vijay,Yadav,vjyaav158@gmail.com,End User, +Vijay,Yadav,vjyaav159@gmail.com,End User, +Vijay,Yadav,vjyaav160@gmail.com,End User, +Vijay,Yadav,vjyaav161@gmail.com,End User, +Vijay,Yadav,vjyaav162@gmail.com,End User, +Vijay,Yadav,vjyaav163@gmail.com,End User, +Vijay,Yadav,vjyaav164@gmail.com,End User, +Vijay,Yadav,vjyaav165@gmail.com,End User, +Vijay,Yadav,vjyaav166@gmail.com,End User, +Vijay,Yadav,vjyaav167@gmail.com,End User, +Vijay,Yadav,vjyaav168@gmail.com,End User, +Vijay,Yadav,vjyaav169@gmail.com,End User, +Vijay,Yadav,vjyaav170@gmail.com,End User, +Vijay,Yadav,vjyaav171@gmail.com,End User, +Vijay,Yadav,vjyaav172@gmail.com,End User, +Vijay,Yadav,vjyaav173@gmail.com,End User, +Vijay,Yadav,vjyaav174@gmail.com,End User, +Vijay,Yadav,vjyaav175@gmail.com,End User, +Vijay,Yadav,vjyaav176@gmail.com,End User, +Vijay,Yadav,vjyaav177@gmail.com,End User, +Vijay,Yadav,vjyaav178@gmail.com,End User, +Vijay,Yadav,vjyaav179@gmail.com,End User, +Vijay,Yadav,vjyaav180@gmail.com,End User, +Vijay,Yadav,vjyaav181@gmail.com,End User, +Vijay,Yadav,vjyaav182@gmail.com,End User, +Vijay,Yadav,vjyaav183@gmail.com,End User, +Vijay,Yadav,vjyaav184@gmail.com,End User, +Vijay,Yadav,vjyaav185@gmail.com,End User, +Vijay,Yadav,vjyaav186@gmail.com,End User, +Vijay,Yadav,vjyaav187@gmail.com,End User, +Vijay,Yadav,vjyaav188@gmail.com,End User, +Vijay,Yadav,vjyaav189@gmail.com,End User, +Vijay,Yadav,vjyaav190@gmail.com,End User, +Vijay,Yadav,vjyaav191@gmail.com,End User, +Vijay,Yadav,vjyaav192@gmail.com,End User, +Vijay,Yadav,vjyaav193@gmail.com,End User, +Vijay,Yadav,vjyaav194@gmail.com,End User, +Vijay,Yadav,vjyaav195@gmail.com,End User, +Vijay,Yadav,vjyaav196@gmail.com,End User, +Vijay,Yadav,vjyaav197@gmail.com,End User, +Vijay,Yadav,vjyaav198@gmail.com,End User, +Vijay,Yadav,vjyaav199@gmail.com,End User, +Vijay,Yadav,vjyaav200@gmail.com,End User, +Vijay,Yadav,vjyaav201@gmail.com,End User, +Vijay,Yadav,vjyaav202@gmail.com,End User, +Vijay,Yadav,vjyaav203@gmail.com,End User, +Vijay,Yadav,vjyaav204@gmail.com,End User, +Vijay,Yadav,vjyaav205@gmail.com,End User, +Vijay,Yadav,vjyaav206@gmail.com,End User, +Vijay,Yadav,vjyaav207@gmail.com,End User, +Vijay,Yadav,vjyaav208@gmail.com,End User, +Vijay,Yadav,vjyaav209@gmail.com,End User, +Vijay,Yadav,vjyaav210@gmail.com,End User, +Vijay,Yadav,vjyaav211@gmail.com,End User, +Vijay,Yadav,vjyaav212@gmail.com,End User, +Vijay,Yadav,vjyaav213@gmail.com,End User, +Vijay,Yadav,vjyaav214@gmail.com,End User, +Vijay,Yadav,vjyaav215@gmail.com,End User, +Vijay,Yadav,vjyaav216@gmail.com,End User, +Vijay,Yadav,vjyaav217@gmail.com,End User, +Vijay,Yadav,vjyaav218@gmail.com,End User, +Vijay,Yadav,vjyaav219@gmail.com,End User, +Vijay,Yadav,vjyaav220@gmail.com,End User, +Vijay,Yadav,vjyaav221@gmail.com,End User, +Vijay,Yadav,vjyaav222@gmail.com,End User, +Vijay,Yadav,vjyaav223@gmail.com,End User, +Vijay,Yadav,vjyaav224@gmail.com,End User, +Vijay,Yadav,vjyaav225@gmail.com,End User, +Vijay,Yadav,vjyaav226@gmail.com,End User, +Vijay,Yadav,vjyaav227@gmail.com,End User, +Vijay,Yadav,vjyaav228@gmail.com,End User, +Vijay,Yadav,vjyaav229@gmail.com,End User, +Vijay,Yadav,vjyaav230@gmail.com,End User, +Vijay,Yadav,vjyaav231@gmail.com,End User, +Vijay,Yadav,vjyaav232@gmail.com,End User, +Vijay,Yadav,vjyaav233@gmail.com,End User, +Vijay,Yadav,vjyaav234@gmail.com,End User, +Vijay,Yadav,vjyaav235@gmail.com,End User, +Vijay,Yadav,vjyaav236@gmail.com,End User, +Vijay,Yadav,vjyaav237@gmail.com,End User, +Vijay,Yadav,vjyaav238@gmail.com,End User, +Vijay,Yadav,vjyaav239@gmail.com,End User, +Vijay,Yadav,vjyaav240@gmail.com,End User, +Vijay,Yadav,vjyaav241@gmail.com,End User, +Vijay,Yadav,vjyaav242@gmail.com,End User, +Vijay,Yadav,vjyaav243@gmail.com,End User, +Vijay,Yadav,vjyaav244@gmail.com,End User, +Vijay,Yadav,vjyaav245@gmail.com,End User, +Vijay,Yadav,vjyaav246@gmail.com,End User, +Vijay,Yadav,vjyaav247@gmail.com,End User, +Vijay,Yadav,vjyaav248@gmail.com,End User, +Vijay,Yadav,vjyaav249@gmail.com,End User, +Vijay,Yadav,vjyaav250@gmail.com,End User, +Vijay,Yadav,vjyaav251@gmail.com,End User, \ No newline at end of file diff --git a/cypress-tests/cypress/fixtures/bulkUser/limit_exceeded_ee.csv b/cypress-tests/cypress/fixtures/bulkUser/limit_exceeded_ee.csv new file mode 100644 index 0000000000..5d5de9460f --- /dev/null +++ b/cypress-tests/cypress/fixtures/bulkUser/limit_exceeded_ee.csv @@ -0,0 +1,252 @@ +First Name,Last Name,Email,User Role,Group,Metadata +Vijay,Yadav,vjyaav1@gmail.com,Admin,, +Vijay,Yadav,vjyaav2@gmail.com,Builder,, +Vijay,Yadav,vjyaav3@gmail.com,Builder,, +Vijay,Yadav,vjyaav4@gmail.com,End User,, +Vijay,Yadav,vjyaav5@gmail.com,End User,, +Vijay,Yadav,vjyaav6@gmail.com,End User,, +Vijay,Yadav,vjyaav7@gmail.com,End User,, +Vijay,Yadav,vjyaav8@gmail.com,End User,, +Vijay,Yadav,vjyaav9@gmail.com,End User,, +Vijay,Yadav,vjyaav10@gmail.com,End User,, +Vijay,Yadav,vjyaav11@gmail.com,End User,, +Vijay,Yadav,vjyaav12@gmail.com,End User,, +Vijay,Yadav,vjyaav13@gmail.com,End User,, +Vijay,Yadav,vjyaav14@gmail.com,End User,, +Vijay,Yadav,vjyaav15@gmail.com,End User,, +Vijay,Yadav,vjyaav16@gmail.com,End User,, +Vijay,Yadav,vjyaav17@gmail.com,End User,, +Vijay,Yadav,vjyaav18@gmail.com,End User,, +Vijay,Yadav,vjyaav19@gmail.com,End User,, +Vijay,Yadav,vjyaav20@gmail.com,End User,, +Vijay,Yadav,vjyaav21@gmail.com,End User,, +Vijay,Yadav,vjyaav22@gmail.com,End User,, +Vijay,Yadav,vjyaav23@gmail.com,End User,, +Vijay,Yadav,vjyaav24@gmail.com,End User,, +Vijay,Yadav,vjyaav25@gmail.com,End User,, +Vijay,Yadav,vjyaav26@gmail.com,End User,, +Vijay,Yadav,vjyaav27@gmail.com,End User,, +Vijay,Yadav,vjyaav28@gmail.com,End User,, +Vijay,Yadav,vjyaav29@gmail.com,End User,, +Vijay,Yadav,vjyaav30@gmail.com,End User,, +Vijay,Yadav,vjyaav31@gmail.com,End User,, +Vijay,Yadav,vjyaav32@gmail.com,End User,, +Vijay,Yadav,vjyaav33@gmail.com,End User,, +Vijay,Yadav,vjyaav34@gmail.com,End User,, +Vijay,Yadav,vjyaav35@gmail.com,End User,, +Vijay,Yadav,vjyaav36@gmail.com,End User,, +Vijay,Yadav,vjyaav37@gmail.com,End User,, +Vijay,Yadav,vjyaav38@gmail.com,End User,, +Vijay,Yadav,vjyaav39@gmail.com,End User,, +Vijay,Yadav,vjyaav40@gmail.com,End User,, +Vijay,Yadav,vjyaav41@gmail.com,End User,, +Vijay,Yadav,vjyaav42@gmail.com,End User,, +Vijay,Yadav,vjyaav43@gmail.com,End User,, +Vijay,Yadav,vjyaav44@gmail.com,End User,, +Vijay,Yadav,vjyaav45@gmail.com,End User,, +Vijay,Yadav,vjyaav46@gmail.com,End User,, +Vijay,Yadav,vjyaav47@gmail.com,End User,, +Vijay,Yadav,vjyaav48@gmail.com,End User,, +Vijay,Yadav,vjyaav49@gmail.com,End User,, +Vijay,Yadav,vjyaav50@gmail.com,End User,, +Vijay,Yadav,vjyaav51@gmail.com,End User,, +Vijay,Yadav,vjyaav52@gmail.com,End User,, +Vijay,Yadav,vjyaav53@gmail.com,End User,, +Vijay,Yadav,vjyaav54@gmail.com,End User,, +Vijay,Yadav,vjyaav55@gmail.com,End User,, +Vijay,Yadav,vjyaav56@gmail.com,End User,, +Vijay,Yadav,vjyaav57@gmail.com,End User,, +Vijay,Yadav,vjyaav58@gmail.com,End User,, +Vijay,Yadav,vjyaav59@gmail.com,End User,, +Vijay,Yadav,vjyaav60@gmail.com,End User,, +Vijay,Yadav,vjyaav61@gmail.com,End User,, +Vijay,Yadav,vjyaav62@gmail.com,End User,, +Vijay,Yadav,vjyaav63@gmail.com,End User,, +Vijay,Yadav,vjyaav64@gmail.com,End User,, +Vijay,Yadav,vjyaav65@gmail.com,End User,, +Vijay,Yadav,vjyaav66@gmail.com,End User,, +Vijay,Yadav,vjyaav67@gmail.com,End User,, +Vijay,Yadav,vjyaav68@gmail.com,End User,, +Vijay,Yadav,vjyaav69@gmail.com,End User,, +Vijay,Yadav,vjyaav70@gmail.com,End User,, +Vijay,Yadav,vjyaav71@gmail.com,End User,, +Vijay,Yadav,vjyaav72@gmail.com,End User,, +Vijay,Yadav,vjyaav73@gmail.com,End User,, +Vijay,Yadav,vjyaav74@gmail.com,End User,, +Vijay,Yadav,vjyaav75@gmail.com,End User,, +Vijay,Yadav,vjyaav76@gmail.com,End User,, +Vijay,Yadav,vjyaav77@gmail.com,End User,, +Vijay,Yadav,vjyaav78@gmail.com,End User,, +Vijay,Yadav,vjyaav79@gmail.com,End User,, +Vijay,Yadav,vjyaav80@gmail.com,End User,, +Vijay,Yadav,vjyaav81@gmail.com,End User,, +Vijay,Yadav,vjyaav82@gmail.com,End User,, +Vijay,Yadav,vjyaav83@gmail.com,End User,, +Vijay,Yadav,vjyaav84@gmail.com,End User,, +Vijay,Yadav,vjyaav85@gmail.com,End User,, +Vijay,Yadav,vjyaav86@gmail.com,End User,, +Vijay,Yadav,vjyaav87@gmail.com,End User,, +Vijay,Yadav,vjyaav88@gmail.com,End User,, +Vijay,Yadav,vjyaav89@gmail.com,End User,, +Vijay,Yadav,vjyaav90@gmail.com,End User,, +Vijay,Yadav,vjyaav91@gmail.com,End User,, +Vijay,Yadav,vjyaav92@gmail.com,End User,, +Vijay,Yadav,vjyaav93@gmail.com,End User,, +Vijay,Yadav,vjyaav94@gmail.com,End User,, +Vijay,Yadav,vjyaav95@gmail.com,End User,, +Vijay,Yadav,vjyaav96@gmail.com,End User,, +Vijay,Yadav,vjyaav97@gmail.com,End User,, +Vijay,Yadav,vjyaav98@gmail.com,End User,, +Vijay,Yadav,vjyaav99@gmail.com,End User,, +Vijay,Yadav,vjyaav100@gmail.com,End User,, +Vijay,Yadav,vjyaav101@gmail.com,End User,, +Vijay,Yadav,vjyaav102@gmail.com,End User,, +Vijay,Yadav,vjyaav103@gmail.com,End User,, +Vijay,Yadav,vjyaav104@gmail.com,End User,, +Vijay,Yadav,vjyaav105@gmail.com,End User,, +Vijay,Yadav,vjyaav106@gmail.com,End User,, +Vijay,Yadav,vjyaav107@gmail.com,End User,, +Vijay,Yadav,vjyaav108@gmail.com,End User,, +Vijay,Yadav,vjyaav109@gmail.com,End User,, +Vijay,Yadav,vjyaav110@gmail.com,End User,, +Vijay,Yadav,vjyaav111@gmail.com,End User,, +Vijay,Yadav,vjyaav112@gmail.com,End User,, +Vijay,Yadav,vjyaav113@gmail.com,End User,, +Vijay,Yadav,vjyaav114@gmail.com,End User,, +Vijay,Yadav,vjyaav115@gmail.com,End User,, +Vijay,Yadav,vjyaav116@gmail.com,End User,, +Vijay,Yadav,vjyaav117@gmail.com,End User,, +Vijay,Yadav,vjyaav118@gmail.com,End User,, +Vijay,Yadav,vjyaav119@gmail.com,End User,, +Vijay,Yadav,vjyaav120@gmail.com,End User,, +Vijay,Yadav,vjyaav121@gmail.com,End User,, +Vijay,Yadav,vjyaav122@gmail.com,End User,, +Vijay,Yadav,vjyaav123@gmail.com,End User,, +Vijay,Yadav,vjyaav124@gmail.com,End User,, +Vijay,Yadav,vjyaav125@gmail.com,End User,, +Vijay,Yadav,vjyaav126@gmail.com,End User,, +Vijay,Yadav,vjyaav127@gmail.com,End User,, +Vijay,Yadav,vjyaav128@gmail.com,End User,, +Vijay,Yadav,vjyaav129@gmail.com,End User,, +Vijay,Yadav,vjyaav130@gmail.com,End User,, +Vijay,Yadav,vjyaav131@gmail.com,End User,, +Vijay,Yadav,vjyaav132@gmail.com,End User,, +Vijay,Yadav,vjyaav133@gmail.com,End User,, +Vijay,Yadav,vjyaav134@gmail.com,End User,, +Vijay,Yadav,vjyaav135@gmail.com,End User,, +Vijay,Yadav,vjyaav136@gmail.com,End User,, +Vijay,Yadav,vjyaav137@gmail.com,End User,, +Vijay,Yadav,vjyaav138@gmail.com,End User,, +Vijay,Yadav,vjyaav139@gmail.com,End User,, +Vijay,Yadav,vjyaav140@gmail.com,End User,, +Vijay,Yadav,vjyaav141@gmail.com,End User,, +Vijay,Yadav,vjyaav142@gmail.com,End User,, +Vijay,Yadav,vjyaav143@gmail.com,End User,, +Vijay,Yadav,vjyaav144@gmail.com,End User,, +Vijay,Yadav,vjyaav145@gmail.com,End User,, +Vijay,Yadav,vjyaav146@gmail.com,End User,, +Vijay,Yadav,vjyaav147@gmail.com,End User,, +Vijay,Yadav,vjyaav148@gmail.com,End User,, +Vijay,Yadav,vjyaav149@gmail.com,End User,, +Vijay,Yadav,vjyaav150@gmail.com,End User,, +Vijay,Yadav,vjyaav151@gmail.com,End User,, +Vijay,Yadav,vjyaav152@gmail.com,End User,, +Vijay,Yadav,vjyaav153@gmail.com,End User,, +Vijay,Yadav,vjyaav154@gmail.com,End User,, +Vijay,Yadav,vjyaav155@gmail.com,End User,, +Vijay,Yadav,vjyaav156@gmail.com,End User,, +Vijay,Yadav,vjyaav157@gmail.com,End User,, +Vijay,Yadav,vjyaav158@gmail.com,End User,, +Vijay,Yadav,vjyaav159@gmail.com,End User,, +Vijay,Yadav,vjyaav160@gmail.com,End User,, +Vijay,Yadav,vjyaav161@gmail.com,End User,, +Vijay,Yadav,vjyaav162@gmail.com,End User,, +Vijay,Yadav,vjyaav163@gmail.com,End User,, +Vijay,Yadav,vjyaav164@gmail.com,End User,, +Vijay,Yadav,vjyaav165@gmail.com,End User,, +Vijay,Yadav,vjyaav166@gmail.com,End User,, +Vijay,Yadav,vjyaav167@gmail.com,End User,, +Vijay,Yadav,vjyaav168@gmail.com,End User,, +Vijay,Yadav,vjyaav169@gmail.com,End User,, +Vijay,Yadav,vjyaav170@gmail.com,End User,, +Vijay,Yadav,vjyaav171@gmail.com,End User,, +Vijay,Yadav,vjyaav172@gmail.com,End User,, +Vijay,Yadav,vjyaav173@gmail.com,End User,, +Vijay,Yadav,vjyaav174@gmail.com,End User,, +Vijay,Yadav,vjyaav175@gmail.com,End User,, +Vijay,Yadav,vjyaav176@gmail.com,End User,, +Vijay,Yadav,vjyaav177@gmail.com,End User,, +Vijay,Yadav,vjyaav178@gmail.com,End User,, +Vijay,Yadav,vjyaav179@gmail.com,End User,, +Vijay,Yadav,vjyaav180@gmail.com,End User,, +Vijay,Yadav,vjyaav181@gmail.com,End User,, +Vijay,Yadav,vjyaav182@gmail.com,End User,, +Vijay,Yadav,vjyaav183@gmail.com,End User,, +Vijay,Yadav,vjyaav184@gmail.com,End User,, +Vijay,Yadav,vjyaav185@gmail.com,End User,, +Vijay,Yadav,vjyaav186@gmail.com,End User,, +Vijay,Yadav,vjyaav187@gmail.com,End User,, +Vijay,Yadav,vjyaav188@gmail.com,End User,, +Vijay,Yadav,vjyaav189@gmail.com,End User,, +Vijay,Yadav,vjyaav190@gmail.com,End User,, +Vijay,Yadav,vjyaav191@gmail.com,End User,, +Vijay,Yadav,vjyaav192@gmail.com,End User,, +Vijay,Yadav,vjyaav193@gmail.com,End User,, +Vijay,Yadav,vjyaav194@gmail.com,End User,, +Vijay,Yadav,vjyaav195@gmail.com,End User,, +Vijay,Yadav,vjyaav196@gmail.com,End User,, +Vijay,Yadav,vjyaav197@gmail.com,End User,, +Vijay,Yadav,vjyaav198@gmail.com,End User,, +Vijay,Yadav,vjyaav199@gmail.com,End User,, +Vijay,Yadav,vjyaav200@gmail.com,End User,, +Vijay,Yadav,vjyaav201@gmail.com,End User,, +Vijay,Yadav,vjyaav202@gmail.com,End User,, +Vijay,Yadav,vjyaav203@gmail.com,End User,, +Vijay,Yadav,vjyaav204@gmail.com,End User,, +Vijay,Yadav,vjyaav205@gmail.com,End User,, +Vijay,Yadav,vjyaav206@gmail.com,End User,, +Vijay,Yadav,vjyaav207@gmail.com,End User,, +Vijay,Yadav,vjyaav208@gmail.com,End User,, +Vijay,Yadav,vjyaav209@gmail.com,End User,, +Vijay,Yadav,vjyaav210@gmail.com,End User,, +Vijay,Yadav,vjyaav211@gmail.com,End User,, +Vijay,Yadav,vjyaav212@gmail.com,End User,, +Vijay,Yadav,vjyaav213@gmail.com,End User,, +Vijay,Yadav,vjyaav214@gmail.com,End User,, +Vijay,Yadav,vjyaav215@gmail.com,End User,, +Vijay,Yadav,vjyaav216@gmail.com,End User,, +Vijay,Yadav,vjyaav217@gmail.com,End User,, +Vijay,Yadav,vjyaav218@gmail.com,End User,, +Vijay,Yadav,vjyaav219@gmail.com,End User,, +Vijay,Yadav,vjyaav220@gmail.com,End User,, +Vijay,Yadav,vjyaav221@gmail.com,End User,, +Vijay,Yadav,vjyaav222@gmail.com,End User,, +Vijay,Yadav,vjyaav223@gmail.com,End User,, +Vijay,Yadav,vjyaav224@gmail.com,End User,, +Vijay,Yadav,vjyaav225@gmail.com,End User,, +Vijay,Yadav,vjyaav226@gmail.com,End User,, +Vijay,Yadav,vjyaav227@gmail.com,End User,, +Vijay,Yadav,vjyaav228@gmail.com,End User,, +Vijay,Yadav,vjyaav229@gmail.com,End User,, +Vijay,Yadav,vjyaav230@gmail.com,End User,, +Vijay,Yadav,vjyaav231@gmail.com,End User,, +Vijay,Yadav,vjyaav232@gmail.com,End User,, +Vijay,Yadav,vjyaav233@gmail.com,End User,, +Vijay,Yadav,vjyaav234@gmail.com,End User,, +Vijay,Yadav,vjyaav235@gmail.com,End User,, +Vijay,Yadav,vjyaav236@gmail.com,End User,, +Vijay,Yadav,vjyaav237@gmail.com,End User,, +Vijay,Yadav,vjyaav238@gmail.com,End User,, +Vijay,Yadav,vjyaav239@gmail.com,End User,, +Vijay,Yadav,vjyaav240@gmail.com,End User,, +Vijay,Yadav,vjyaav241@gmail.com,End User,, +Vijay,Yadav,vjyaav242@gmail.com,End User,, +Vijay,Yadav,vjyaav243@gmail.com,End User,, +Vijay,Yadav,vjyaav244@gmail.com,End User,, +Vijay,Yadav,vjyaav245@gmail.com,End User,, +Vijay,Yadav,vjyaav246@gmail.com,End User,, +Vijay,Yadav,vjyaav247@gmail.com,End User,, +Vijay,Yadav,vjyaav248@gmail.com,End User,, +Vijay,Yadav,vjyaav249@gmail.com,End User,, +Vijay,Yadav,vjyaav250@gmail.com,End User,, +Vijay,Yadav,vjyaav251@gmail.com,End User,, \ No newline at end of file diff --git a/cypress-tests/cypress/fixtures/bulkUser/missing_email.csv b/cypress-tests/cypress/fixtures/bulkUser/missing_email.csv new file mode 100644 index 0000000000..dd1b93fca2 --- /dev/null +++ b/cypress-tests/cypress/fixtures/bulkUser/missing_email.csv @@ -0,0 +1,3 @@ +First Name,Last Name,Email,User Role,Group +test,test,,Admin,Admin +test,test,,Builder,Builder diff --git a/cypress-tests/cypress/fixtures/bulkUser/missing_email_ee.csv b/cypress-tests/cypress/fixtures/bulkUser/missing_email_ee.csv new file mode 100644 index 0000000000..c9fa53181a --- /dev/null +++ b/cypress-tests/cypress/fixtures/bulkUser/missing_email_ee.csv @@ -0,0 +1,3 @@ +First Name,Last Name,Email,User Role,Group,Metadata +,,withoutname1@gmail.com,Admin,Admin, +,,withoutname2@gmail.com,Builder,Builder, diff --git a/cypress-tests/cypress/fixtures/bulkUser/missing_name.csv b/cypress-tests/cypress/fixtures/bulkUser/missing_name.csv new file mode 100644 index 0000000000..b4bc6d323d --- /dev/null +++ b/cypress-tests/cypress/fixtures/bulkUser/missing_name.csv @@ -0,0 +1,3 @@ +First Name,Last Name,Email,User Role,Group +,,withoutname1@gmail.com,Admin,Admin +,,withoutname2@gmail.com,Builder,Builder diff --git a/cypress-tests/cypress/fixtures/bulkUser/missing_name_ee.csv b/cypress-tests/cypress/fixtures/bulkUser/missing_name_ee.csv new file mode 100644 index 0000000000..c9fa53181a --- /dev/null +++ b/cypress-tests/cypress/fixtures/bulkUser/missing_name_ee.csv @@ -0,0 +1,3 @@ +First Name,Last Name,Email,User Role,Group,Metadata +,,withoutname1@gmail.com,Admin,Admin, +,,withoutname2@gmail.com,Builder,Builder, diff --git a/cypress-tests/cypress/fixtures/bulkUser/missing_role.csv b/cypress-tests/cypress/fixtures/bulkUser/missing_role.csv new file mode 100644 index 0000000000..2330a5f7df --- /dev/null +++ b/cypress-tests/cypress/fixtures/bulkUser/missing_role.csv @@ -0,0 +1,3 @@ +First Name,Last Name,Email,User Role,Group +Test,Example,test12@gmail.com,, +Test,Example,test13@gmail.com,, diff --git a/cypress-tests/cypress/fixtures/bulkUser/missing_role_ee.csv b/cypress-tests/cypress/fixtures/bulkUser/missing_role_ee.csv new file mode 100644 index 0000000000..aec9c2b880 --- /dev/null +++ b/cypress-tests/cypress/fixtures/bulkUser/missing_role_ee.csv @@ -0,0 +1,3 @@ +First Name,Last Name,Email,User Role,Group,Metadata +Test,Example,test12@gmail.com,,, +Test,Example,test13@gmail.com,,, diff --git a/cypress-tests/cypress/fixtures/bulkUser/non_existing_group_ee.csv b/cypress-tests/cypress/fixtures/bulkUser/non_existing_group_ee.csv new file mode 100644 index 0000000000..16620eb8cc --- /dev/null +++ b/cypress-tests/cypress/fixtures/bulkUser/non_existing_group_ee.csv @@ -0,0 +1,3 @@ +First Name,Last Name,Email,User Role,Group,Metadata +test,test,demo1@gmail.com,Admin,test, +test,test,demo2@gmail.com,Builder,abc, diff --git a/cypress-tests/cypress/fixtures/bulkUser/same_email_ee.csv b/cypress-tests/cypress/fixtures/bulkUser/same_email_ee.csv new file mode 100644 index 0000000000..634585c86e --- /dev/null +++ b/cypress-tests/cypress/fixtures/bulkUser/same_email_ee.csv @@ -0,0 +1,4 @@ +First Name,Last Name,Email,User Role,Group,Metadata +test,test,demo11@gmail.com,Admin,, +test,test,demo11@gmail.com,Builder,, + diff --git a/cypress-tests/cypress/support/utils/manageGroups.js b/cypress-tests/cypress/support/utils/manageGroups.js index 3f0c85d1cf..2ddd80379d 100644 --- a/cypress-tests/cypress/support/utils/manageGroups.js +++ b/cypress-tests/cypress/support/utils/manageGroups.js @@ -658,7 +658,7 @@ export const createGroupAddAppAndUserToGroup = (groupName, email) => { cy.request({ method: "POST", - url: `${Cypress.env("server_host")}/api/v2/group-permissions/${groupId}/granular-permissions`, + url: `${Cypress.env("server_host")}/api/v2/group-permissions/${groupId}/granular-permissions/app`, headers: headers, body: { name: "Apps", @@ -676,7 +676,6 @@ export const createGroupAddAppAndUserToGroup = (groupName, email) => { ], }, }, - }).then((response) => { expect(response.status).to.equal(201); }); @@ -727,7 +726,7 @@ export const duplicateMultipleGroups = (groupNames) => { cy.get(commonSelectors.duplicateOption).click(); // Click on the duplicate option cy.get(commonSelectors.confirmDuplicateButton).click(); // Confirm duplication if needed }); -} +}; export const verifyGroupCardOptions = (groupName) => { cy.get(groupsSelector.groupLink(groupName)).click(); @@ -865,7 +864,7 @@ export const addUserInGroup = (groupName, email) => { commonSelectors.toastMessage, groupsText.userAddedToast ); -} +}; export const inviteUserBasedOnRole = (firstName, email, role = "end-user") => { fillUserInviteForm(firstName, email); @@ -891,7 +890,13 @@ export const verifyBasicPermissions = (canCreate = true) => { ); }; -export const setupWorkspaceAndInviteUser = (workspaceName, workspaceSlug, firstName, email, role = "end-user") => { +export const setupWorkspaceAndInviteUser = ( + workspaceName, + workspaceSlug, + firstName, + email, + role = "end-user" +) => { cy.apiCreateWorkspace(workspaceName, workspaceSlug); cy.visit(workspaceSlug); cy.wait(1000); @@ -907,7 +912,10 @@ export const verifySettingsAccess = (shouldExist = true) => { ); }; -export const verifyUserPrivileges = (expectedButtonState, shouldHaveWorkspaceSettings) => { +export const verifyUserPrivileges = ( + expectedButtonState, + shouldHaveWorkspaceSettings +) => { cy.get(commonSelectors.dashboardAppCreateButton).should(expectedButtonState); cy.get(commonSelectors.settingsIcon).click(); @@ -923,4 +931,4 @@ export const setupAndUpdateRole = (currentRole, endRole, email) => { updateRole(currentRole, endRole, email); cy.wait(1000); cy.apiLogout(); -}; \ No newline at end of file +}; diff --git a/cypress-tests/cypress/support/utils/manageSSO.js b/cypress-tests/cypress/support/utils/manageSSO.js index a736afacc8..b8e64e4f56 100644 --- a/cypress-tests/cypress/support/utils/manageSSO.js +++ b/cypress-tests/cypress/support/utils/manageSSO.js @@ -371,7 +371,7 @@ export const defaultSSO = (enable) => { }); }; -export const setSignupStatus = (enable, workspaceName = 'My workspace') => { +export const setSignupStatus = (enable, workspaceName = "My workspace") => { cy.task("dbConnection", { dbconfig: Cypress.env("app_db"), sql: `SELECT id FROM organizations WHERE name = '${workspaceName}'`, @@ -429,3 +429,23 @@ export const resetDomain = () => { }); }); }; + +export const enableInstanceSignup = ( + allowPersonalWorkspace = true, + enableLoginConfig = true, + allowedDomains = "" +) => { + const allowValue = allowPersonalWorkspace ? "true" : "false"; + const loginConfigValue = enableLoginConfig ? "true" : "false"; + + const sql = ` + UPDATE instance_settings SET value = '${allowValue}' WHERE key = 'ALLOW_PERSONAL_WORKSPACE'; + UPDATE instance_settings SET value = '${loginConfigValue}' WHERE key = 'ENABLE_SIGNUP'; + UPDATE instance_settings SET value = '${allowedDomains}' WHERE key = 'ALLOWED_DOMAINS'; + `; + + cy.task("dbConnection", { + dbconfig: Cypress.env("app_db"), + sql, + }); +}; diff --git a/cypress-tests/cypress/support/utils/platform/eeCommon.js b/cypress-tests/cypress/support/utils/platform/eeCommon.js new file mode 100644 index 0000000000..80db62fca9 --- /dev/null +++ b/cypress-tests/cypress/support/utils/platform/eeCommon.js @@ -0,0 +1,591 @@ +import { + commonEeSelectors, + ssoEeSelector, + instanceSettingsSelector, + multiEnvSelector, + workspaceSelector, +} from "Selectors/eeCommon"; +import { ssoEeText } from "Texts/eeCommon"; +import { commonSelectors, commonWidgetSelector } from "Selectors/common"; +import * as common from "Support/utils/common"; +import { groupsSelector } from "Selectors/manageGroups"; +import { groupsText } from "Texts/manageGroups"; +import { eeGroupsSelector } from "Selectors/eeCommon"; +import { eeGroupsText } from "Texts/eeCommon"; +import { + // verifyOnboardingQuestions, + // verifyCloudOnboardingQuestions, + fetchAndVisitInviteLink, +} from "Support/utils/manageUsers"; +import { commonText } from "Texts/common"; +import { dashboardText } from "Texts/dashboard"; +import { usersText } from "Texts/manageUsers"; +import { usersSelector } from "Selectors/manageUsers"; +import { ssoSelector } from "Selectors/manageSSO"; +import { ssoText } from "Texts/manageSSO"; +// import { appPromote } from "Support/utils/multiEnv"; + +export const oidcSSOPageElements = () => { + cy.get(ssoEeSelector.oidcToggle).click(); + cy.get(ssoSelector.saveButton).eq(1).click(); + cy.get('[data-cy="modal-title"]').verifyVisibleElement( + "have.text", + "Enable OpenID Connect" + ); + cy.get('[data-cy="modal-close-button"]').should("be.visible"); + cy.get('[data-cy="modal-message"]').verifyVisibleElement( + "have.text", + "Enabling OpenID Connect at the workspace level will override any OpenID Connect configurations set at the instance level." + ); + cy.get('[data-cy="confirmation-text"]').verifyVisibleElement( + "have.text", + "Are you sure you want to continue?" + ); + cy.get('[data-cy="cancel-button"]') + .eq(2) + .verifyVisibleElement("have.text", "Cancel"); + cy.get('[data-cy="enable-button"]').verifyVisibleElement( + "have.text", + "Enable" + ); + + cy.get('[data-cy="cancel-button"]').eq(2).click(); + cy.get('[data-cy="status-label"]').verifyVisibleElement( + "have.text", + ssoText.disabledLabel + ); + + cy.get(ssoEeSelector.oidcToggle).click(); + cy.get(ssoSelector.saveButton).eq(1).click(); + cy.get('[data-cy="enable-button"]').click(); + + cy.verifyToastMessage( + commonSelectors.toastMessage, + ssoText.toggleUpdateToast("OpenID") + ); + + cy.get(ssoEeSelector.statusLabel).verifyVisibleElement( + "have.text", + ssoEeText.enabledLabel + ); + + cy.get('[data-cy="redirect-url-label"]').verifyVisibleElement( + "have.text", + ssoText.redirectUrlLabel + ); + cy.get('[data-cy="redirect-url"]').should("be.visible"); + cy.get('[data-cy="copy-icon"]').should("be.visible"); + + cy.get(ssoEeSelector.oidcToggle).click(); + cy.get(ssoSelector.saveButton).eq(1).click(); + // cy.get('[data-cy="enable-button"]').click(); + cy.verifyToastMessage( + commonSelectors.toastMessage, + ssoText.toggleUpdateToast("OpenID") + ); + cy.get(ssoSelector.statusLabel).verifyVisibleElement( + "have.text", + ssoText.disabledLabel + ); + + cy.get(ssoEeSelector.oidcToggle).click(); + cy.clearAndType(ssoEeSelector.nameInput, ssoEeText.testName); + cy.clearAndType(ssoEeSelector.clientIdInput, ssoEeText.testclientId); + cy.clearAndType(ssoEeSelector.clientSecretInput, ssoEeText.testclientSecret); + cy.clearAndType(ssoEeSelector.WellKnownUrlInput, ssoEeText.testWellknownUrl); + cy.get(ssoSelector.saveButton).eq(1).click(); + cy.get('[data-cy="enable-button"]').click(); + cy.verifyToastMessage( + commonSelectors.toastMessage, + ssoText.toggleUpdateToast("OpenID") + ); + cy.get(ssoEeSelector.nameInput).should("have.value", ssoEeText.testName); + cy.get(ssoEeSelector.clientIdInput).should( + "have.value", + ssoEeText.testclientId + ); + cy.get(ssoEeSelector.clientSecretInput).should( + "have.value", + ssoEeText.testclientSecret + ); + cy.get(ssoEeSelector.WellKnownUrlInput).should( + "have.value", + ssoEeText.testWellknownUrl + ); +}; + +export const resetDsPermissions = () => { + common.navigateToManageGroups(); + cy.wait(200); + cy.get(groupsSelector.permissionsLink).click(); + + cy.get(groupsSelector.appsCreateCheck).then(($el) => { + if ($el.is(":checked")) { + cy.get(groupsSelector.appsCreateCheck).uncheck(); + } + }); + cy.get(eeGroupsSelector.dsCreateCheck).then(($el) => { + if ($el.is(":checked")) { + cy.get(eeGroupsSelector.dsCreateCheck).uncheck(); + } + }); + cy.get(eeGroupsSelector.dsDeleteCheck).then(($el) => { + if ($el.is(":checked")) { + cy.get(eeGroupsSelector.dsDeleteCheck).uncheck(); + } + }); +}; + +export const deleteAssignedDatasources = () => { + common.navigateToManageGroups(); + cy.get('[data-cy="datasource-link"]').click(); + cy.get("body").then(($body) => { + const removeAllButtons = $body.find('[data-cy="remove-button"]'); + if (removeAllButtons.length > 0) { + cy.get('[data-cy="remove-button"]').click({ multiple: true }); + } + }); +}; + +export const userSignUp = (fullName, email, workspaceName) => { + const verificationFunction = + Cypress.env("environment") === "Enterprise" + ? verifyOnboardingQuestions + : verifyCloudOnboardingQuestions; + + let invitationLink = ""; + cy.visit("/"); + cy.wait(500); + cy.get(commonSelectors.createAnAccountLink).realClick(); + cy.clearAndType(commonSelectors.nameInputField, fullName); + cy.clearAndType(commonSelectors.emailInputField, email); + cy.clearAndType(commonSelectors.passwordInputField, commonText.password); + cy.get(commonSelectors.signUpButton).click(); + + cy.wait(500); + cy.task("dbConnection", { + dbconfig: Cypress.env("app_db"), + sql: `select invitation_token from users where email='${email}';`, + }).then((resp) => { + invitationLink = `/invitations/${resp.rows[0].invitation_token}`; + cy.visit(invitationLink); + cy.get(commonSelectors.setUpToolJetButton).click(); + cy.wait(4000); + + verificationFunction(fullName, workspaceName); + }); +}; + +export const allowPersonalWorkspace = (allow = true) => { + const value = allow ? "true" : "false"; + cy.task("dbConnection", { + dbconfig: Cypress.env("app_db"), + sql: `UPDATE instance_settings SET value = '${value}' WHERE key = 'ALLOW_PERSONAL_WORKSPACE';`, + }); +}; + +export const addNewUserEE = (firstName, email) => { + common.navigateToManageUsers(); + cy.get(usersSelector.buttonAddUsers).click(); + cy.get(commonSelectors.inputFieldFullName).type(firstName); + cy.get(commonSelectors.inputFieldEmailAddress).type(email); + + cy.get(usersSelector.buttonInviteUsers).click(); + cy.verifyToastMessage( + commonSelectors.toastMessage, + usersText.userCreatedToast + ); + WorkspaceInvitationLink(email); + cy.clearAndType(commonSelectors.passwordInputField, usersText.password); + cy.get(commonSelectors.signUpButton).click(); + cy.wait(2000); + cy.get(commonSelectors.acceptInviteButton).click(); + cy.get(commonSelectors.workspaceName).verifyVisibleElement( + "have.text", + "My workspace" + ); +}; + +export const inviteUser = (firstName, email) => { + cy.get(usersSelector.buttonAddUsers).click(); + cy.get(commonSelectors.inputFieldFullName).type(firstName); + cy.get(commonSelectors.inputFieldEmailAddress).type(email); + + cy.get(usersSelector.buttonInviteUsers).click(); + cy.verifyToastMessage( + commonSelectors.toastMessage, + usersText.userCreatedToast + ); + fetchAndVisitInviteLink(email); +}; + +export const defaultWorkspace = () => { + cy.get(".org-select-container").then(($title) => { + if (!$title.text().includes("My workspace")) { + cy.get(commonSelectors.workspaceName).realClick(); + cy.contains("My workspace").realClick(); + cy.wait(2000); + defaultWorkspace(); + } + }); +}; + +export const trunOffAllowPersonalWorkspace = () => { + cy.get(commonSelectors.settingsIcon).click(); + cy.get(commonEeSelectors.instanceSettingIcon).click(); + cy.get(instanceSettingsSelector.manageInstanceSettings).click(); + cy.get(instanceSettingsSelector.allowWorkspaceToggle) + .eq(0) + .then(($el) => { + if ($el.is(":checked")) { + cy.get(instanceSettingsSelector.allowWorkspaceToggle).eq(0).uncheck(); + cy.get(commonEeSelectors.saveButton).click(); + cy.verifyToastMessage( + commonSelectors.toastMessage, + "Instance settings have been updated" + ); + } + }); +}; + +export const verifySSOSignUpPageElements = () => { + cy.get(commonSelectors.invitePageHeader).verifyVisibleElement( + "have.text", + "Join ToolJet" + ); + cy.get(commonSelectors.invitePageSubHeader).verifyVisibleElement( + "have.text", + "You are invited to ToolJet." + ); + cy.get(commonSelectors.userNameInputLabel).verifyVisibleElement( + "have.text", + commonText.userNameInputLabel + ); + cy.get(commonSelectors.invitedUserName).should("be.visible"); + cy.get(commonSelectors.emailInputLabel).verifyVisibleElement( + "have.text", + commonText.emailInputLabel + ); + cy.get(commonSelectors.invitedUserEmail).should("be.visible"); + cy.get(commonSelectors.acceptInviteButton).verifyVisibleElement( + "have.text", + commonText.acceptInviteButton + ); + + cy.get(commonSelectors.signUpTermsHelperText).should(($el) => { + expect($el.contents().first().text().trim()).to.eq( + commonText.signUpTermsHelperText + ); + }); + cy.get(commonSelectors.termsOfServiceLink) + .verifyVisibleElement("have.text", commonText.termsOfServiceLink) + .and("have.attr", "href") + .and("equal", "https://www.tooljet.com/terms"); + cy.get(commonSelectors.privacyPolicyLink) + .verifyVisibleElement("have.text", commonText.privacyPolicyLink) + .and("have.attr", "href") + .and("equal", "https://www.tooljet.com/privacy"); +}; + +export const VerifyWorkspaceInvitePageElements = () => { + cy.get(commonSelectors.invitePageHeader).verifyVisibleElement( + "have.text", + commonText.invitePageHeader + ); + cy.get(commonSelectors.invitePageSubHeader).verifyVisibleElement( + "have.text", + commonText.invitePageSubHeader + ); + cy.verifyLabel(commonText.userNameInputLabel); + cy.get(commonSelectors.invitedUserName).should("be.visible"); + cy.verifyLabel(commonText.emailInputLabel); + cy.get(commonSelectors.invitedUserEmail).should("be.visible"); + cy.get(commonSelectors.acceptInviteButton).verifyVisibleElement( + "have.text", + commonText.acceptInviteButton + ); + + cy.get(commonSelectors.signUpTermsHelperText).should(($el) => { + expect($el.contents().first().text().trim()).to.eq( + commonText.signUpTermsHelperText + ); + }); + cy.get(commonSelectors.termsOfServiceLink) + .verifyVisibleElement("have.text", commonText.termsOfServiceLink) + .and("have.attr", "href") + .and("equal", "https://www.tooljet.com/terms"); + cy.get(commonSelectors.privacyPolicyLink) + .verifyVisibleElement("have.text", commonText.privacyPolicyLink) + .and("have.attr", "href") + .and("equal", "https://www.tooljet.com/privacy"); + + cy.get("body").then(($el) => { + if ($el.text().includes("Google")) { + cy.get(ssoSelector.googleSSOText).verifyVisibleElement( + "have.text", + ssoText.googleSignUpText + ); + cy.get(ssoSelector.gitSSOText).verifyVisibleElement( + "have.text", + ssoText.gitSignUpText + ); + cy.get(commonSelectors.onboardingSeperator).should("be.visible"); + } + }); +}; + +export const WorkspaceInvitationLink = (email) => { + let invitationToken, + organizationToken, + workspaceId, + userId, + url = ""; + cy.task("dbConnection", { + dbconfig: Cypress.env("app_db"), + sql: `select invitation_token from users where email='${email}';`, + }).then((resp) => { + invitationToken = resp.rows[0].invitation_token; + + cy.task("dbConnection", { + dbconfig: Cypress.env("app_db"), + sql: "select id from organizations where name='My workspace';", + }).then((resp) => { + workspaceId = resp.rows[0].id; + + cy.task("dbConnection", { + dbconfig: Cypress.env("app_db"), + sql: `select id from users where email='${email}';`, + }).then((resp) => { + userId = resp.rows[0].id; + + cy.task("dbConnection", { + dbconfig: Cypress.env("app_db"), + sql: `select invitation_token from organization_users where user_id='${userId}';`, + }).then((resp) => { + organizationToken = resp.rows[0].invitation_token; + + url = `/invitations/${invitationToken}/workspaces/${organizationToken}?oid=${workspaceId}`; + common.logout(); + cy.visit(url); + }); + }); + }); + }); +}; + +export const enableDefaultSSO = () => { + common.navigateToManageSSO(); + cy.get("body").then(($el) => { + if (!$el.text().includes("Allowed domains")) { + cy.get(ssoSelector.generalSettingsElements.generalSettings).click(); + } + }); + cy.get(ssoSelector.allowDefaultSSOToggle).then(($el) => { + if (!$el.is(":checked")) { + cy.get(ssoSelector.allowDefaultSSOToggle).check(); + cy.get(ssoSelector.saveButton).click(); + cy.verifyToastMessage(commonSelectors.toastMessage, ssoText.ssoToast); + } + }); +}; + +export const disableSSO = (ssoSelector, toggleSelector) => { + cy.wait(1000); + cy.get(ssoSelector).click(); + cy.get(toggleSelector).then(($el) => { + if ($el.is(":checked")) { + cy.get(toggleSelector).uncheck(); + } + }); +}; + +export const AddDataSourceToGroup = (groupName, dsName) => { + common.navigateToManageGroups(); + cy.get(groupsSelector.groupLink(groupName)).click(); + cy.get(eeGroupsSelector.datasourceLink).click(); + cy.wait(500); + cy.get( + '[data-cy="datasource-select-search"] >> .rmsc > .dropdown-container > .dropdown-heading > .dropdown-heading-value > .gray' + ).click(); + cy.contains(dsName).realClick(); + + cy.get(eeGroupsSelector.AddDsButton).click(); + cy.verifyToastMessage( + commonSelectors.toastMessage, + "Datasources added to the group" + ); +}; + +export const enableToggle = (toggleSelector) => { + cy.get(toggleSelector).then(($el) => { + if (!$el.is(":checked")) { + cy.get(toggleSelector).check(); + } + }); +}; + +export const disableToggle = (toggleSelector) => { + cy.get(toggleSelector).then(($el) => { + if ($el.is(":checked")) { + cy.get(toggleSelector).uncheck(); + } + }); +}; + +export const verifyPromoteModalUI = (versionName, currEnv, targetEnv) => { + cy.get(commonEeSelectors.promoteButton) + .verifyVisibleElement("have.text", " Promote ") + .click(); + cy.get(commonEeSelectors.modalTitle).verifyVisibleElement( + "have.text", + `Promote ${versionName}` + ); + cy.get(commonSelectors.closeButton).should("be.visible"); + cy.get(multiEnvSelector.fromLabel).verifyVisibleElement("have.text", "FROM"); + cy.get(multiEnvSelector.toLabel).verifyVisibleElement("have.text", "TO"); + cy.get(multiEnvSelector.currEnvName).verifyVisibleElement( + "have.text", + currEnv + ); + cy.get('[data-cy="target-env-name"]').verifyVisibleElement( + "have.text", + targetEnv + ); + cy.get('[data-cy="cancel-button"]').verifyVisibleElement( + "have.text", + "Cancel" + ); + cy.get(commonEeSelectors.promoteButton) + .eq(1) + .verifyVisibleElement("have.text", "Promote "); +}; + +export const resetPassword = (email) => { + cy.visit("/"); + cy.get(commonSelectors.forgotPasswordLink).click(); + cy.clearAndType(commonSelectors.emailInputField, email); + cy.get(commonSelectors.resetPasswordLinkButton).click(); + + cy.task("dbConnection", { + dbconfig: Cypress.env("app_db"), + sql: `select forgot_password_token from users where email='${email}';`, + }).then((resp) => { + const passwordResetLink = `/reset-password/${resp.rows[0].forgot_password_token}`; + cy.visit(passwordResetLink); + }); + cy.wait(500); + + cy.clearAndType(commonSelectors.newPasswordInputField, "Password"); + cy.clearAndType(commonSelectors.confirmPasswordInputField, "Password"); + cy.wait(4000); + cy.get(commonSelectors.resetPasswordButton).click(); + cy.get(commonSelectors.backToLoginButton).click(); +}; + +export const verifyTooltipDisabled = (selector, message) => { + cy.get(selector) + .trigger("mouseover", { force: true }) + .then(() => { + cy.get(".tooltip-inner").last().should("have.text", message); + }); +}; + +export const createAnAppWithSlug = (appName, slug) => { + cy.apiCreateApp(appName); + cy.openApp(); + cy.dragAndDropWidget("Table", 250, 250); + appPromote("development", "release"); + cy.get(commonWidgetSelector.shareAppButton).click(); + cy.clearAndType(commonWidgetSelector.appNameSlugInput, `${slug}`); + cy.wait(2000); + cy.get(commonWidgetSelector.modalCloseButton).click(); +}; + +export const updateLicense = (key) => { + cy.task("dbConnection", { + dbconfig: Cypress.env("app_db"), + sql: `update instance_settings set value='${key}', updated_at= NOW() where key='LICENSE_KEY';`, + }); +}; + +export const openInstanceSettings = () => { + cy.get(commonSelectors.settingsIcon).click(); + cy.get(commonEeSelectors.instanceSettingIcon).click(); +}; + +export const openUserActionMenu = (email) => { + cy.clearAndType(commonSelectors.inputUserSearch, email); + cy.wait(1000); + cy.get('[data-cy="user-actions-button"]').eq(0).click(); + cy.wait(2000); +}; + +export const archiveWorkspace = (workspaceName) => { + cy.get(instanceSettingsSelector.allWorkspaceTab).click(); + cy.clearAndType(commonEeSelectors.searchBar, workspaceName); + cy.get(workspaceSelector.workspaceStatusChange).eq(0).click(); + cy.get(commonEeSelectors.confirmButton).click(); +}; + +export const passwordToggle = (enable) => { + cy.getCookie("tj_auth_token").then((cookie) => { + cy.request( + { + method: "PATCH", + url: "http://localhost:3000/api/organizations/configs", + headers: { + "Tj-Workspace-Id": Cypress.env("workspaceId"), + Cookie: `tj_auth_token=${cookie.value}`, + }, + body: { type: "form", enabled: enable }, + }, + { log: false } + ).then((response) => { + expect(response.status).to.equal(200); + }); + }); +}; + +export const InstanceSSO = (personalWorkspace, enableSignup, workspaceSSO) => { + allowPersonalWorkspace(personalWorkspace); + + cy.task("dbConnection", { + dbconfig: Cypress.env("app_db"), + sql: `UPDATE instance_settings SET value = '${enableSignup}' WHERE key = 'ENABLE_SIGNUP';UPDATE instance_settings SET value = '${workspaceSSO}' WHERE key = 'ENABLE_WORKSPACE_LOGIN_CONFIGURATION';`, + }); +}; + +export const resetInstanceDomain = () => { + cy.getCookie("tj_auth_token").then((cookie) => { + cy.request( + { + method: "PATCH", + url: "http://localhost:3000/api/instance-login-configs", + headers: { + "Tj-Workspace-Id": Cypress.env("workspaceId"), + Cookie: `tj_auth_token=${cookie.value}`, + }, + body: { allowedDomains: "" }, + }, + { log: false } + ).then((response) => { + expect(response.status).to.equal(200); + }); + }); +}; + +export const instanceSSOConfig = (allow = true) => { + const value = allow ? "true" : "false"; + + cy.task("dbConnection", { + dbconfig: Cypress.env("app_db"), + sql: `UPDATE sso_configs SET enabled = ${allow} WHERE sso IN ('google', 'git', 'openid')AND organization_id IS NULL;`, + }); +}; + +export const updateInstanceSettings = (key, value) => { + cy.task("updateSetting", { + dbconfig: Cypress.env("app_db"), + sql: `UPDATE instance_settings SET value = ${value} WHERE key = ${key};`, + }); +}; diff --git a/cypress-tests/cypress/support/utils/restAPI.js b/cypress-tests/cypress/support/utils/restAPI.js index f87c8d6f61..dd205d326d 100644 --- a/cypress-tests/cypress/support/utils/restAPI.js +++ b/cypress-tests/cypress/support/utils/restAPI.js @@ -122,10 +122,11 @@ export const createAndRunRestAPIQuery = ({ } if (Array.isArray(responseData)) { responseData.forEach((item) => { - expect(item).to.have.any.keys("id", "name", "price"); + expect(item).to.have.any.keys("id", "name", "username", "email", "address"); }); } if (responseData?.id) { + cy.log(responseData.id) cy.writeFile("cypress/fixtures/restAPI/storedId.json", { id: responseData.id, }); diff --git a/frontend/assets/translations/en.json b/frontend/assets/translations/en.json index 1feaf4277d..c464a81a65 100644 --- a/frontend/assets/translations/en.json +++ b/frontend/assets/translations/en.json @@ -572,6 +572,7 @@ "styles": "Styles", "general": "General", "validation": "Validation", + "structure": "Structure", "documentation": "Read documentation for {{componentMeta}}", "widgetNameEmptyError": "Widget name cannot be empty", "componentNameExistsError": "Component name already exists", diff --git a/frontend/ee b/frontend/ee index 9458c8d66f..9da4f77691 160000 --- a/frontend/ee +++ b/frontend/ee @@ -1 +1 @@ -Subproject commit 9458c8d66f29f8334765b5757dd096139a8d53d2 +Subproject commit 9da4f776915e328120c3024e551ef6b8032f9f63 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 43b332e6ea..483cb1dfcf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -63,6 +63,7 @@ "dotenv": "^16.0.3", "draft-js": "^0.11.7", "draft-js-export-html": "^1.4.1", + "draft-js-import-html": "^1.4.1", "driver.js": "^0.9.8", "emoji-mart": "^5.5.2", "file-loader": "^6.2.0", @@ -105,7 +106,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 +16949,31 @@ "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==", + "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==", + "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", @@ -32645,6 +32671,11 @@ "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==" + }, "node_modules/tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 41dbb51e92..4dc4be5289 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -101,7 +101,7 @@ "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.2.0", - "react-dropzone": "^14.2.3", + "react-dropzone": "^14.3.8", "react-highlight-words": "^0.21.0", "react-hot-toast": "^2.4.0", "react-hotkeys-hook": "^4.3.5", diff --git a/frontend/src/App/App.jsx b/frontend/src/App/App.jsx index 7e47c82ce2..4a463d93ae 100644 --- a/frontend/src/App/App.jsx +++ b/frontend/src/App/App.jsx @@ -42,6 +42,7 @@ import { shallow } from 'zustand/shallow'; import useStore from '@/AppBuilder/_stores/store'; import { checkIfToolJetCloud } from '@/_helpers/utils'; import { BasicPlanMigrationBanner } from '@/HomePage/BasicPlanMigrationBanner/BasicPlanMigrationBanner'; +import EmbedApp from '@/AppBuilder/EmbedApp'; const AppWrapper = (props) => { const { isAppDarkMode } = useAppDarkMode(); @@ -144,7 +145,7 @@ class AppComponent extends React.Component { const pathname = this.props.location.pathname; if (pathname.includes('/apps/')) { return 'editor'; - } else if (pathname.includes('/applications/')) { + } else if (pathname.includes('/applications/') || pathname.includes('/embed-apps/')) { return 'viewer'; } return ''; @@ -404,6 +405,7 @@ class AppComponent extends React.Component { } /> + } /> { diff --git a/frontend/src/AppBuilder/AppBuilder.jsx b/frontend/src/AppBuilder/AppBuilder.jsx index 9f7a8f4e2d..fa12e0d1d9 100644 --- a/frontend/src/AppBuilder/AppBuilder.jsx +++ b/frontend/src/AppBuilder/AppBuilder.jsx @@ -14,6 +14,7 @@ import EditorHeader from '@/AppBuilder/Header'; import LeftSidebar from '@/AppBuilder/LeftSidebar'; import Popups from './Popups'; import { ModuleProvider } from '@/AppBuilder/_contexts/ModuleContext'; +import RightSidebarToggle from '@/AppBuilder/RightSideBar/RightSidebarToggle'; import { shallow } from 'zustand/shallow'; // const EditorHeader = lazy(() => import('@/AppBuilder/Header')); @@ -25,6 +26,7 @@ import { shallow } from 'zustand/shallow'; // TODO: split Loader into separate component and remove editor loading state from Editor export const Editor = ({ id: appId, darkMode, moduleId = 'canvas', switchDarkMode, appType = 'front-end' }) => { useAppData(appId, moduleId, darkMode); + const isRightSidebarOpen = useStore((state) => state.isRightSidebarOpen); const isEditorLoading = useStore((state) => state.loaderStore.modules[moduleId].isEditorLoading, shallow); const currentMode = useStore((state) => state.modeStore.modules[moduleId].currentMode, shallow); const isModuleEditor = appType === 'module'; @@ -54,9 +56,10 @@ export const Editor = ({ id: appId, darkMode, moduleId = 'canvas', switchDarkMod {window?.public_config?.ENABLE_MULTIPLAYER_EDITING === 'true' && } - + - + + {isRightSidebarOpen && }{' '} diff --git a/frontend/src/AppBuilder/AppCanvas/AppCanvas.jsx b/frontend/src/AppBuilder/AppCanvas/AppCanvas.jsx index 10ab1fa054..d82fb93386 100644 --- a/frontend/src/AppBuilder/AppCanvas/AppCanvas.jsx +++ b/frontend/src/AppBuilder/AppCanvas/AppCanvas.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react'; +import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { Container } from './Container'; import Grid from './Grid'; import { EditorSelecto } from './Selecto'; @@ -17,8 +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); @@ -41,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'; @@ -78,15 +107,17 @@ 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) : !isAppDarkMode - ? '#EBEBEF' - : '#2F3C4C'; + ? '#EBEBEF' + : '#2F3C4C'; if (isModuleMode) { return { @@ -100,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 (
id="main-editor-canvas" onMouseUp={handleCanvasContainerMouseUp} > - {creationMode === 'GIT' && } - {creationMode !== 'GIT' && } -
+ +
diff --git a/frontend/src/AppBuilder/CodeBuilder/Elements/Input.jsx b/frontend/src/AppBuilder/CodeBuilder/Elements/Input.jsx index c019e16f1a..4e9eb82455 100644 --- a/frontend/src/AppBuilder/CodeBuilder/Elements/Input.jsx +++ b/frontend/src/AppBuilder/CodeBuilder/Elements/Input.jsx @@ -10,6 +10,7 @@ export const Input = ({ value, onChange, cyLabel, meta }) => { className="tj-input-element tj-text-xsm" value={value} placeholder="" + key={`${String(cyLabel)}-input`} id="labelId" onChange={(e) => { onChange(e.target.value); diff --git a/frontend/src/AppBuilder/CodeBuilder/Elements/Number.jsx b/frontend/src/AppBuilder/CodeBuilder/Elements/Number.jsx index 7811562e12..1127dbaf93 100644 --- a/frontend/src/AppBuilder/CodeBuilder/Elements/Number.jsx +++ b/frontend/src/AppBuilder/CodeBuilder/Elements/Number.jsx @@ -1,13 +1,18 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; export const Number = ({ value, onChange, cyLabel }) => { const [number, setNumber] = useState(value ? value : 0); + useEffect(() => { + setNumber(value); + }, [value]); + return ( <>
{ setNumber(e.target.value); diff --git a/frontend/src/AppBuilder/CodeBuilder/TypeMapping.js b/frontend/src/AppBuilder/CodeBuilder/TypeMapping.js index a249f52676..dac7203e6c 100644 --- a/frontend/src/AppBuilder/CodeBuilder/TypeMapping.js +++ b/frontend/src/AppBuilder/CodeBuilder/TypeMapping.js @@ -1,3 +1,5 @@ +import { drop } from 'lodash'; + export const TypeMapping = { text: 'Text', string: 'Text', @@ -20,5 +22,6 @@ export const TypeMapping = { visibility: 'Visibility', numberInput: 'NumberInput', tableRowHeightInput: 'TableRowHeightInput', + dropdownMenu: 'DropdownMenu', query: 'Query', }; diff --git a/frontend/src/AppBuilder/CodeEditor/CodeHinter.jsx b/frontend/src/AppBuilder/CodeEditor/CodeHinter.jsx index bd5d85ad98..2814fc188e 100644 --- a/frontend/src/AppBuilder/CodeEditor/CodeHinter.jsx +++ b/frontend/src/AppBuilder/CodeEditor/CodeHinter.jsx @@ -20,7 +20,15 @@ const CODE_EDITOR_TYPE = { tjdbHinter: TJDBCodeEditor, }; -const CodeHinter = ({ type = 'basic', initialValue, componentName, disabled, renderCopilot, ...restProps }) => { +const CodeHinter = ({ + type = 'basic', + initialValue, + componentName, + disabled, + renderCopilot, + setCodeEditorView, + ...restProps +}) => { const darkMode = localStorage.getItem('darkMode') === 'true'; const [isOpen, setIsOpen] = React.useState(false); @@ -71,6 +79,7 @@ const CodeHinter = ({ type = 'basic', initialValue, componentName, disabled, ren }} componentName={componentName} disabled={disabled} + setCodeEditorView={setCodeEditorView} {...restProps} /> ); diff --git a/frontend/src/AppBuilder/CodeEditor/DynamicFxTypeRenderer.jsx b/frontend/src/AppBuilder/CodeEditor/DynamicFxTypeRenderer.jsx index 99d80a6050..4d4ff7513c 100644 --- a/frontend/src/AppBuilder/CodeEditor/DynamicFxTypeRenderer.jsx +++ b/frontend/src/AppBuilder/CodeEditor/DynamicFxTypeRenderer.jsx @@ -17,6 +17,7 @@ import { Visibility } from '../CodeBuilder/Elements/Visibility'; import { NumberInput } from '../CodeBuilder/Elements/NumberInput'; import { Datepicker } from '../CodeBuilder/Elements/Datepicker'; import TableRowHeightInput from '../CodeBuilder/Elements/TableRowHeightInput'; +import DropdownMenu from '../CodeBuilder/Elements/DropdownMenu'; import { TimePicker } from '../CodeBuilder/Elements/TimePicker'; import { Query } from '../CodeBuilder/Elements/Query'; import { ColorSwatches } from '@/modules/Appbuilder/components'; @@ -41,6 +42,7 @@ const AllElements = { TableRowHeightInput, Datepicker, TimePicker, + DropdownMenu, Query, }; diff --git a/frontend/src/AppBuilder/CodeEditor/MultiLineCodeEditor.jsx b/frontend/src/AppBuilder/CodeEditor/MultiLineCodeEditor.jsx index 325c5c8ac5..ce3c310f24 100644 --- a/frontend/src/AppBuilder/CodeEditor/MultiLineCodeEditor.jsx +++ b/frontend/src/AppBuilder/CodeEditor/MultiLineCodeEditor.jsx @@ -54,11 +54,15 @@ const MultiLineCodeEditor = (props) => { readOnly = false, editable = true, renderCopilot, + setCodeEditorView, } = props; const replaceIdsWithName = useStore((state) => state.replaceIdsWithName, shallow); const wrapperRef = useRef(null); const getSuggestions = useStore((state) => state.getSuggestions, shallow); - const getServerSideGlobalResolveSuggestions = useStore((state) => state.getServerSideGlobalResolveSuggestions, 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(() => { @@ -276,6 +315,21 @@ const MultiLineCodeEditor = (props) => { return initialValue; }, [initialValue, replaceIdsWithName]); + function updateCurrentLineObserver(editorView) { + if (!editorView || !editorView?.view?.dom) return; + const cursorPos = editorView.state.selection.main.head; + const line = editorView.state.doc.lineAt(cursorPos); + const lineNumber = line.number; + const cmLines = editorView.view.dom.querySelectorAll('.cm-line'); + const currentLineDiv = cmLines[lineNumber - 1] || null; + + // Update intersection observer to watch the current line + if (currentLineObserverRef.current && currentLineDiv && !isObserverTriggeredRef.current) { + currentLineObserverRef.current.disconnect(); + currentLineObserverRef.current.observe(currentLineDiv); + } + } + return (
{ indentWithTab={false} readOnly={readOnly} editable={editable} //for transformations in query manager - onCreateEditor={(view) => setEditorView(view)} - onUpdate={(view) => setIsSearchPanelOpen(searchPanelOpen(view.state))} + onCreateEditor={(view) => { + setEditorView(view); + if (setCodeEditorView) { + setCodeEditorView(view); + } + }} + onUpdate={(view) => { + setIsSearchPanelOpen(searchPanelOpen(view.state)); + updateCurrentLineObserver(view); + }} />
{showPreview && ( diff --git a/frontend/src/AppBuilder/CodeEditor/PreviewBox.jsx b/frontend/src/AppBuilder/CodeEditor/PreviewBox.jsx index a24c41543f..f2b7be403a 100644 --- a/frontend/src/AppBuilder/CodeEditor/PreviewBox.jsx +++ b/frontend/src/AppBuilder/CodeEditor/PreviewBox.jsx @@ -489,7 +489,14 @@ const PreviewContainer = ({ }; const PreviewCodeBlock = ({ code, isExpectValue = false, isLargeDataset }) => { - let preview = code && code.trim ? code?.trim() : `${code}`; + let preview; + if (typeof code === 'string') { + preview = code.trim(); + } else if (typeof code === 'symbol') { + preview = code.toString(); + } else { + preview = String(code); + } const shouldTrim = preview.length > 35; let showJSONTree = false; diff --git a/frontend/src/AppBuilder/CodeEditor/SingleLineCodeEditor.jsx b/frontend/src/AppBuilder/CodeEditor/SingleLineCodeEditor.jsx index fba7322bb2..c3d0bb798c 100644 --- a/frontend/src/AppBuilder/CodeEditor/SingleLineCodeEditor.jsx +++ b/frontend/src/AppBuilder/CodeEditor/SingleLineCodeEditor.jsx @@ -28,10 +28,12 @@ import CodeHinter from './CodeHinter'; import { removeNestedDoubleCurlyBraces } from '@/_helpers/utils'; import useStore from '@/AppBuilder/_stores/store'; import { shallow } from 'zustand/shallow'; +import { getCssVarValue } from '@/Editor/Components/utils'; import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext'; import { CodeHinterContext } from '../CodeBuilder/CodeHinterContext'; import { createReferencesLookup } from '@/_stores/utils'; import { useQueryPanelKeyHooks } from './useQueryPanelKeyHooks'; +import Icon from '@/_ui/Icon/solidIcons/index'; const SingleLineCodeEditor = ({ componentName, fieldMeta = {}, componentId, ...restProps }) => { const { moduleId } = useModuleContext(); @@ -79,7 +81,6 @@ const SingleLineCodeEditor = ({ componentName, fieldMeta = {}, componentId, ...r const replaceIdsWithName = useStore((state) => state.replaceIdsWithName, shallow); let newInitialValue = initialValue; - if (typeof initialValue === 'string' && (initialValue?.includes('components') || initialValue?.includes('queries'))) { newInitialValue = replaceIdsWithName(initialValue); } @@ -209,6 +210,7 @@ const EditorInput = ({ onInputChange, wrapperRef, showSuggestions, + setCodeEditorView = null, // Function to set the CodeMirror view }) => { const codeHinterContext = useContext(CodeHinterContext); const { suggestionList: paramHints } = createReferencesLookup(codeHinterContext, true); @@ -216,7 +218,10 @@ const EditorInput = ({ const getSuggestions = useStore((state) => state.getSuggestions, shallow); const [codeMirrorView, setCodeMirrorView] = useState(undefined); - const getServerSideGlobalResolveSuggestions = useStore((state) => state.getServerSideGlobalResolveSuggestions, shallow); + const getServerSideGlobalResolveSuggestions = useStore( + (state) => state.getServerSideGlobalResolveSuggestions, + shallow + ); const { queryPanelKeybindings } = useQueryPanelKeyHooks(onBlurUpdate, currentValue, 'singleline'); @@ -274,7 +279,10 @@ const EditorInput = ({ } // eslint-disable-next-line react-hooks/exhaustive-deps - const overRideFunction = React.useCallback((context) => autoCompleteExtensionConfig(context), [isInsideQueryManager, paramHints]); + const overRideFunction = React.useCallback( + (context) => autoCompleteExtensionConfig(context), + [isInsideQueryManager, paramHints] + ); const autoCompleteConfig = autocompletion({ override: [overRideFunction], @@ -443,6 +451,9 @@ const EditorInput = ({ { setCodeMirrorView(view); + if (setCodeEditorView) { + setCodeEditorView(view); + } }} value={currentValue} placeholder={placeholder} @@ -451,11 +462,11 @@ const EditorInput = ({ extensions={ showSuggestions ? [ - javascript({ jsx: lang === 'jsx' }), - autoCompleteConfig, - keymap.of([...customKeyMaps]), - customTabKeymap, - ] + javascript({ jsx: lang === 'jsx' }), + autoCompleteConfig, + keymap.of([...customKeyMaps]), + customTabKeymap, + ] : [javascript({ jsx: lang === 'jsx' })] } onChange={(val) => { @@ -487,9 +498,9 @@ const EditorInput = ({ }} />
- - -
+ + + ); }; @@ -514,24 +525,49 @@ const DynamicEditorBridge = (props) => { const [forceCodeBox, setForceCodeBox] = React.useState(fxActive); const codeShow = paramType === 'code' || forceCodeBox; - const HIDDEN_CODE_HINTER_LABELS = ['Table data', 'Column data', 'Text Format']; - const { isFxNotRequired } = fieldMeta; + const HIDDEN_CODE_HINTER_LABELS = ['Table data', 'Column data', 'Text Format', 'Slider type']; + const { isFxNotRequired, newLine = false, section = '' } = fieldMeta; + const isDeprecated = section === 'deprecated'; const { t } = useTranslation(); - const [_, error, value] = type === 'fxEditor' ? resolveReferences(initialValue) : []; + const replaceIdsWithName = useStore((state) => state.replaceIdsWithName, shallow); + let newInitialValue = initialValue, + shouldResolve = true; + + // This is to handle the case when the initial value is a string and contains components or queries + // and we need to replace the ids with names + // but we don't want to resolve the references as it needs to be displayed as it is + if (paramName === 'generateFormFrom') { + if ( + typeof initialValue === 'string' && + (initialValue?.includes('components') || initialValue?.includes('queries')) + ) { + newInitialValue = replaceIdsWithName(initialValue); + shouldResolve = false; + } + } + const [_, error, value] = + type === 'fxEditor' ? (shouldResolve ? resolveReferences(newInitialValue) : [false, '', newInitialValue]) : []; let cyLabel = paramLabel ? paramLabel.toLowerCase().trim().replace(/\s+/g, '-') : props.cyLabel; useEffect(() => { setForceCodeBox(fxActive); }, [component, fxActive]); + let modifiedValue = initialValue; + if (paramType === 'colorSwatches' && typeof initialValue === 'string' && initialValue?.includes('var(')) { + modifiedValue = getCssVarValue(document.documentElement, initialValue); + } + const renderFx = () => { if (paramType === 'query' || !(paramLabel !== 'Type' && isFxNotRequired === undefined)) { return null; } + return (
{ if (codeShow) { setForceCodeBox(false); onFxPress(false); + if (paramType === 'colorSwatches') { + onChange(modifiedValue); + } } else { setForceCodeBox(true); onFxPress(true); @@ -551,48 +590,69 @@ const DynamicEditorBridge = (props) => { }; const fxClass = isEventManagerParam ? 'justify-content-start' : 'justify-content-end'; - return ( -
-
+ + const renderedLabel = () => { + return ( + <> {paramLabel !== ' ' && !HIDDEN_CODE_HINTER_LABELS.includes(paramLabel) && (
+ {isDeprecated && ( + + + + )}
)} + + ); + }; + + const renderDynamicFx = () => { + if (codeShow) return null; + return ( + { + setForceCodeBox(true); + onFxPress(true); + }} + meta={fieldMeta} + cyLabel={cyLabel} + styleDefinition={styleDefinition} + component={component} + onVisibilityChange={onVisibilityChange} + /> + ); + }; + + return ( +
+
+ {renderedLabel()}
{renderFx()}
- {!codeShow && ( - { - setForceCodeBox(true); - onFxPress(true); - }} - meta={fieldMeta} - cyLabel={cyLabel} - styleDefinition={styleDefinition} - component={component} - onVisibilityChange={onVisibilityChange} - /> - )} + {!newLine && renderDynamicFx()}
+ {newLine && renderDynamicFx()} {codeShow && (
- +
diff --git a/frontend/src/AppBuilder/CodeEditor/styles.scss b/frontend/src/AppBuilder/CodeEditor/styles.scss index d0a328bae0..9b1e6c7023 100644 --- a/frontend/src/AppBuilder/CodeEditor/styles.scss +++ b/frontend/src/AppBuilder/CodeEditor/styles.scss @@ -660,6 +660,13 @@ } } + +.code-editor-component { + .cm-editor { + min-height: 0 !important; + } +} + .cm-searchMatch.cm-searchMatch-selected { background-color: #F28F2D !important; } @@ -673,4 +680,4 @@ .cm-theme{ height: 100% ; } -} \ No newline at end of file +} diff --git a/frontend/src/AppBuilder/CodeEditor/utils.js b/frontend/src/AppBuilder/CodeEditor/utils.js index 1b5fe0aafb..227a4a74e5 100644 --- a/frontend/src/AppBuilder/CodeEditor/utils.js +++ b/frontend/src/AppBuilder/CodeEditor/utils.js @@ -367,6 +367,7 @@ export const FxParamTypeMapping = Object.freeze({ visibility: 'Visibility', numberInput: 'NumberInput', tableRowHeightInput: 'TableRowHeightInput', + dropdownMenu: 'DropdownMenu', query: 'Query', }); diff --git a/frontend/src/AppBuilder/EmbedApp.jsx b/frontend/src/AppBuilder/EmbedApp.jsx new file mode 100644 index 0000000000..9c741dc78d --- /dev/null +++ b/frontend/src/AppBuilder/EmbedApp.jsx @@ -0,0 +1,73 @@ +import React, { useEffect } from 'react'; +import useRouter from '@/_hooks/use-router'; +import config from 'config'; +import toast from 'react-hot-toast'; + +// In-memory PAT token store +let inMemoryPatToken = null; + +export function setPatToken(patToken) { + inMemoryPatToken = patToken; +} + +export function getPatToken() { + if (inMemoryPatToken) return inMemoryPatToken; +} + +export default function EmbedAppRedirect() { + const router = useRouter(); + const { appId } = router.query; + + useEffect(() => { + // 🔐 Ensure the page is embedded + if (window.self === window.top) { + // Not inside an iframe + toast.error('This page must be embedded inside a parent application.'); + return; + } + const token = new URLSearchParams(window.location.search).get('personal-access-token'); + + if (!token || typeof appId !== 'string') { + parent?.postMessage({ type: 'TJ_EMBED_APP_LOGOUT', error: 400, message: 'Missing token or appId' }, '*'); + return; + } + + const initiateSession = async () => { + try { + const res = await fetch(`${config.apiUrl}/ext/users/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ appId, accessToken: token }), + }); + if (!res.ok) { + if (res.status === 401 || res.status === 403) { + toast.error('Your pat is expired. Please refresh or contact your admin.'); + // 🔔 Show toast if token is expired or invalid + parent?.postMessage( + { + type: 'TJ_EMBED_APP_LOGOUT', + error: res.status, + message: 'Your pat is expired. Please refresh or contact your admin.', + }, + '*' + ); + } + return; + } + + const result = await res.json(); + // ✅ Store PAT in memory + setPatToken(result.signedPat); + window.name = result.signedPat; + window.location.href = `applications/${appId}`; + } catch (error) { + parent?.postMessage({ type: 'TJ_EMBED_APP_LOGOUT', error: 500, message: 'Network error' }, '*'); + } + }; + + initiateSession(); + }, [appId]); + + return
Loading embedded app...
; +} diff --git a/frontend/src/AppBuilder/Header/AppCanvasBanner.jsx b/frontend/src/AppBuilder/Header/AppCanvasBanner.jsx new file mode 100644 index 0000000000..8ca86ba4b2 --- /dev/null +++ b/frontend/src/AppBuilder/Header/AppCanvasBanner.jsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { withEditionSpecificComponent } from '@/modules/common/helpers/withEditionSpecificComponent'; +import useStore from '@/AppBuilder/_stores/store'; +import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext'; +import FreezeVersionInfo from '@/AppBuilder/Header/FreezeVersionInfo'; +import { shallow } from 'zustand/shallow'; + +const AppCanvasBanner = ({ appId = '' }) => { + const { moduleId } = useModuleContext(); + const currentMode = useStore((state) => state.modeStore.modules[moduleId].currentMode, shallow); + const renderBanner = () => { + if (currentMode === 'edit') { + return ; + } + return null; + }; + return
{renderBanner()}
; +}; + +export default withEditionSpecificComponent(AppCanvasBanner, 'Appbuilder'); diff --git a/frontend/src/AppBuilder/Header/CreateVersionModal.jsx b/frontend/src/AppBuilder/Header/CreateVersionModal.jsx index 64208e5d27..66e62fcd8b 100644 --- a/frontend/src/AppBuilder/Header/CreateVersionModal.jsx +++ b/frontend/src/AppBuilder/Header/CreateVersionModal.jsx @@ -16,15 +16,11 @@ const CreateVersionModal = ({ canCommit, orgGit, fetchingOrgGit, - handleCommitOnVersionCreation = () => {}, + handleCommitOnVersionCreation = () => { }, }) => { const { moduleId } = useModuleContext(); const [isCreatingVersion, setIsCreatingVersion] = useState(false); const [versionName, setVersionName] = useState(''); - const gitSyncEnabled = - orgGit?.org_git?.git_https?.is_enabled || - orgGit?.org_git?.git_ssh?.is_enabled || - orgGit?.org_git?.git_lab?.is_enabled; const { createNewVersionAction, @@ -33,6 +29,7 @@ const CreateVersionModal = ({ appId, setCurrentVersionId, selectedVersion, + currentMode, } = useStore( (state) => ({ createNewVersionAction: state.createNewVersionAction, @@ -45,6 +42,7 @@ const CreateVersionModal = ({ currentVersionId: state.currentVersionId, setCurrentVersionId: state.setCurrentVersionId, selectedVersion: state.selectedVersion, + currentMode: state.currentMode, }), shallow ); @@ -94,7 +92,7 @@ const CreateVersionModal = ({ setIsCreatingVersion(false); setShowCreateAppVersion(false); appVersionService - .getAppVersionData(appId, newVersion.id) + .getAppVersionData(appId, newVersion.id, currentMode) .then((data) => { setCurrentVersionId(newVersion.id); handleCommitOnVersionCreation(data); @@ -104,8 +102,8 @@ const CreateVersionModal = ({ }); }, (error) => { - if (error?.data?.code === '23505') { - toast.error('Version name already exists.'); + if (error?.data?.code === "23505") { + toast.error("Version name already exists."); } else { toast.error(error?.error); } @@ -174,7 +172,7 @@ const CreateVersionModal = ({
- {gitSyncEnabled && ( + {orgGit?.org_git?.is_enabled && (
{ const isEditable = props.selectProps.isEditable; + const creationMode = props?.selectProps?.appCreationMode; + const allowAppEdit = useStore((state) => state.allowEditing); + const [isVersionCreationEnabled, setIsVersionCreationEnabled] = useState( + creationMode !== 'GIT' || (creationMode === 'GIT' && allowAppEdit) + ); + useEffect(() => { + setIsVersionCreationEnabled(creationMode !== 'GIT' || (creationMode === 'GIT' && allowAppEdit)); + }, [allowAppEdit, creationMode]); return (
{isEditable && !props?.selectProps?.value?.isReleasedVersion && (
{ style={{ padding: '8px 12px' }} onClick={() => !props?.selectProps?.value?.isReleasedVersion && - props?.selectProps?.appCreationMode !== 'GIT' && + isVersionCreationEnabled && props.selectProps.setShowEditAppVersion(true) } > @@ -49,18 +58,18 @@ const Menu = (props) => {
{props.children}
{isEditable && (
props?.selectProps?.appCreationMode !== 'GIT' && props?.setShowCreateAppVersion(true)} + onClick={() => isVersionCreationEnabled && props?.setShowCreateAppVersion(true)} data-cy="create-new-version-button" > { fillRule="evenodd" clipRule="evenodd" d="M17 11C17.4142 11 17.75 11.3358 17.75 11.75V16.25H22.25C22.6642 16.25 23 16.5858 23 17C23 17.4142 22.6642 17.75 22.25 17.75H17.75V22.25C17.75 22.6642 17.4142 23 17 23C16.5858 23 16.25 22.6642 16.25 22.25V17.75H11.75C11.3358 17.75 11 17.4142 11 17C11 16.5858 11.3358 16.25 11.75 16.25H16.25V11.75C16.25 11.3358 16.5858 11 17 11Z" - fill={`${props?.selectProps?.appCreationMode !== 'GIT' ? '#3E63DD' : '#C1C8CD'}`} + fill={`${isVersionCreationEnabled ? '#3E63DD' : '#C1C8CD'}`} /> Create new version diff --git a/frontend/src/AppBuilder/Header/EditAppName.jsx b/frontend/src/AppBuilder/Header/EditAppName.jsx index e9dff6074a..24b6dbbf61 100644 --- a/frontend/src/AppBuilder/Header/EditAppName.jsx +++ b/frontend/src/AppBuilder/Header/EditAppName.jsx @@ -149,7 +149,6 @@ function EditAppName() { value={name} maxLength={50} data-cy="app-name-input" - disabled={appCreationMode === 'GIT'} /> { const { moduleId } = useModuleContext(); @@ -117,77 +119,64 @@ const CanvasSettings = ({ darkMode }) => {
-
+
{t('leftSidebar.Settings.backgroundColorOfCanvas', 'Canvas bavkground')}
- {showPicker && ( -
-
setShowPicker(false)} /> - setShowPicker(true)} - color={canvasBackgroundColor} - onChangeComplete={(color) => { +
+ { + if (typeof canvasBackgroundColor === 'string' && canvasBackgroundColor?.includes('var(')) { + const value = getCssVarValue(document.documentElement, canvasBackgroundColor); const options = { - canvasBackgroundColor: [color.hex, color.rgb], - backgroundFxQuery: '', + canvasBackgroundColor: value, + backgroundFxQuery: value, }; - globalSettingsChanged(options); - resolveOthers('canvas', true, { canvasBackgroundColor: [color.hex, color.rgb] }); - }} - /> -
- )} + await Promise.resolve(globalSettingsChanged(options)); + await Promise.resolve(resolveOthers('canvas', true, { canvasBackgroundColor: value })); + } + setForceCodeBox(!forceCodeBox); + }} + /> +
{forceCodeBox && ( -
setShowPicker(true)} style={outerStyles}> -
-
- {canvasBackgroundColor} -
-
+ { + const options = { + canvasBackgroundColor: resolveReferences(color), + backgroundFxQuery: color, + }; + globalSettingsChanged(options); + resolveOthers('canvas', true, { canvasBackgroundColor: color }); + }} + /> )}
{!forceCodeBox && ( - { - const options = { - canvasBackgroundColor: resolveReferences(color), - backgroundFxQuery: color, - }; - globalSettingsChanged(options); - resolveOthers('canvas', true, { canvasBackgroundColor: color }); - }} - /> +
+ { + const options = { + canvasBackgroundColor: resolveReferences(color), + backgroundFxQuery: color, + }; + globalSettingsChanged(options); + resolveOthers('canvas', true, { canvasBackgroundColor: color }); + }} + /> +
)} -
- { - setForceCodeBox(!forceCodeBox); - }} - /> -
diff --git a/frontend/src/AppBuilder/LeftSidebar/LeftSidebar.jsx b/frontend/src/AppBuilder/LeftSidebar/LeftSidebar.jsx index e2b5947206..bcd90bee33 100644 --- a/frontend/src/AppBuilder/LeftSidebar/LeftSidebar.jsx +++ b/frontend/src/AppBuilder/LeftSidebar/LeftSidebar.jsx @@ -5,13 +5,14 @@ import cx from 'classnames'; import { shallow } from 'zustand/shallow'; import { DarkModeToggle } from '@/_components'; import Popover from '@/_ui/Popover'; -import { PageMenu } from './PageMenu'; +// import { PageMenu } from './PageMenu'; import LeftSidebarInspector from './LeftSidebarInspector/LeftSidebarInspector'; import GlobalSettings from './GlobalSettings'; import '../../_styles/left-sidebar.scss'; import Debugger from './Debugger/Debugger'; import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext'; import { withEditionSpecificComponent } from '@/modules/common/helpers/withEditionSpecificComponent'; +import { PageMenu } from '../RightSideBar/PageSettingsTab/PageMenu'; // TODO: remove passing refs to LeftSidebarItem and use state // TODO: need to add datasources to the sidebar. @@ -58,7 +59,6 @@ export const BaseLeftSidebar = ({ const sideBarBtnRefs = useRef({}); const handleSelectedSidebarItem = (item) => { - pinned && localStorage.setItem('selectedSidebarItem', item); if (item === 'debugger') resetUnreadErrorCount(); setSelectedSidebarItem(item); if (item === selectedSidebarItem && !pinned) { @@ -211,15 +211,6 @@ export const BaseLeftSidebar = ({ tip: 'Build with AI', ref: setSideBarBtnRefs('tooljetai'), })} - handleSelectedSidebarItem('page')} - darkMode={darkMode} - icon="page" - className={`left-sidebar-item left-sidebar-layout left-sidebar-page-selector`} - tip="Pages" - ref={setSideBarBtnRefs('page')} - /> {renderCommonItems()} { marginTop: level === 1 ? 4 : 0, marginBottom: level === 1 ? 4 : 0, // borderLeft: level > 1 ? '1px solid var(--slate6, #D7DBDF)' : 'none', + cursor: level === 1 ? 'pointer' : 'default', }} + {...(level === 1 && { onClick: () => onExpand(props) })} > {/* {!['queries', 'globals', 'variables'].includes(type) && ( */}
diff --git a/frontend/src/AppBuilder/LeftSidebar/PageMenu/PageMenuItem.jsx b/frontend/src/AppBuilder/LeftSidebar/PageMenu/PageMenuItem.jsx deleted file mode 100644 index 3f8b3c09d3..0000000000 --- a/frontend/src/AppBuilder/LeftSidebar/PageMenu/PageMenuItem.jsx +++ /dev/null @@ -1,317 +0,0 @@ -import React, { memo, useRef, useState, useCallback } from 'react'; -import cx from 'classnames'; -// import { RenameInput } from './RenameInput'; -// import { PagehandlerMenu } from './PagehandlerMenu'; -// import { EditModal } from './EditModal'; -// import { SettingsModal } from './SettingsModal'; -import { useAppVersionStore } from '@/_stores/appVersionStore'; -import SolidIcon from '@/_ui/Icon/SolidIcons'; -import EyeDisable from '@/_ui/Icon/solidIcons/EyeDisable'; -import FileRemove from '@/_ui/Icon/solidIcons/FIleRemove'; -import Home from '@/_ui/Icon/solidIcons/Home'; -import useStore from '@/AppBuilder/_stores/store'; -import _ from 'lodash'; -import { toast } from 'react-hot-toast'; -import { RenameInput } from './RenameInput'; -import IconSelector from './IconSelector'; -import { withRouter } from '@/_hoc/withRouter'; -import OverflowTooltip from '@/_components/OverflowTooltip'; -import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext'; -import { shallow } from 'zustand/shallow'; -import { ToolTip } from '@/_components/ToolTip'; - -export const PageMenuItem = withRouter( - memo(({ darkMode, page, navigate }) => { - const { moduleId } = useModuleContext(); - const homePageId = useStore((state) => state.appStore.modules[moduleId].app.homePageId); - const isHomePage = page.id === homePageId; - const currentPageId = useStore((state) => state.modules[moduleId].currentPageId); - const isSelected = page.id === currentPageId; - const isHidden = page?.hidden ?? false; - const isDisabled = page?.disabled ?? false; - const [isHovered, setIsHovered] = useState(false); - const shouldFreeze = useStore((state) => state.getShouldFreeze()); - const featureAccess = useStore((state) => state?.license?.featureAccess, shallow); - const licenseValid = !featureAccess?.licenseStatus?.isExpired && featureAccess?.licenseStatus?.isLicenseValid; - const showEditingPopover = useStore((state) => state.showEditingPopover); - const restricted = page?.permissions && page?.permissions?.length > 0; - const { - definition: { styles, properties }, - } = useStore((state) => state.pageSettings); - const setCurrentPageHandle = useStore((state) => state.setCurrentPageHandle); - // only update when the page is being edited - const editingPage = useStore( - (state) => state.editingPage, - (prev, next) => { - if (next?.id === page?.id) return false; - if (prev?.id === page?.id) return false; - return true; - } - ); - const editingPageName = useStore((state) => state.showEditPageNameInput); - const popoverRef = useRef(null); - - const openPageEditPopover = useStore((state) => state.openPageEditPopover); - const toggleEditPageNameInput = useStore((state) => state.toggleEditPageNameInput); - - const isEditingPage = editingPage?.id === page?.id; - const icon = () => { - const iconName = isHomePage && !page.icon ? 'IconHome2' : page.icon; - if (!isDisabled && !isHidden) { - return ; - } - if (isDisabled || (isDisabled && isHidden)) { - return ( - - ); - } - if (isHidden && !isDisabled) { - return ; - } - }; - - const computeStyles = useCallback(() => { - const baseStyles = { - pill: { - borderRadius: `${styles.pillRadius.value}px`, - }, - icon: { - color: !styles.iconColor.isDefault && styles.iconColor.value, - fill: !styles.iconColor.isDefault && styles.iconColor.value, - }, - }; - - switch (true) { - case isSelected: { - return { - ...baseStyles, - text: { - color: !styles.selectedTextColor.isDefault && styles.selectedTextColor.value, - }, - icon: { - stroke: !styles.selectedIconColor.isDefault && styles.selectedIconColor.value, - color: !styles.selectedIconColor.isDefault && styles.selectedIconColor.value, - fill: !styles.selectedIconColor.isDefault && styles.selectedIconColor.value, - }, - pill: { - background: !styles.pillSelectedBackgroundColor.isDefault && styles.pillSelectedBackgroundColor.value, - ...(page.id === editingPage?.id && { - backgroundColor: 'var(--slate1)', - }), - ...baseStyles.pill, - }, - }; - } - case isHovered: { - return { - ...baseStyles, - pill: { - background: !styles.pillHoverBackgroundColor.isDefault && styles.pillHoverBackgroundColor.value, - ...baseStyles.pill, - }, - }; - } - default: { - return { - text: { - color: !styles.textColor.isDefault && styles.textColor.value, - }, - icon: { - color: !styles.iconColor.isDefault && styles.iconColor.value, - fill: !styles.iconColor.isDefault && styles.iconColor.value, - }, - }; - } - } - }, [styles, isSelected, isHovered, page.id, editingPage?.id]); - - const computedStyles = computeStyles(); - - const labelStyle = { - icon: { - hidden: properties.style === 'text', - }, - label: { - hidden: properties.style === 'icon', - }, - }; - - const switchPage = useStore((state) => state.switchPage); - - const handlePageSwitch = useCallback(() => { - if (currentPageId === page.id) { - return; - } - switchPage(page.id, page.handle, [], moduleId); - setCurrentPageHandle(page.handle, moduleId); - }, [currentPageId, page.id, page.handle, switchPage, setCurrentPageHandle, moduleId]); - - const handlePageMenuSettings = useCallback( - (event) => { - event.stopPropagation(); - openPageEditPopover(page, popoverRef); - }, - [popoverRef.current, page] - ); - - function getTooltip() { - const permission = page?.permissions?.length ? page?.permissions[0] : null; - if (!permission) return ''; - const users = permission.users || []; - const isSingle = permission.type === 'SINGLE'; - const isGroup = permission.type === 'GROUP'; - - if (users.length === 0) return null; - - if (isSingle) { - if (users.length === 1) { - const email = users[0].user.email; - return `Access restricted to ${email}`; - } else { - return `Access restricted to ${users.length} users`; - } - } - - if (isGroup) { - if (users.length === 1) { - const groupName = users[0].permissionGroup?.name ?? 'Group'; - return `Access restricted to ${groupName} group`; - } else { - return `Access restricted to ${users.length} groups`; - } - } - - return ''; - } - - return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - style={{ - width: '100%', - }} - > - <> -
- {editingPageName && editingPage?.id === page?.id ? ( - <> - {' '} -
{icon()}
- { - toggleEditPageNameInput(false); - }} - /> - - ) : ( - <> - {' '} -
- {icon()} - - {page.name} - - - {isHomePage && 'Home'} - {isDisabled && 'Disabled'} - {isHidden && !isDisabled && 'Hidden'} - -
-
- {licenseValid && restricted && ( - -
- -
-
- )} -
-
- {!shouldFreeze && ( - - )} -
- - )} -
- -
- ); - }) -); - -export const AddingPageHandler = ({ darkMode }) => { - const toggleShowAddNewPageInput = useStore((state) => state.toggleShowAddNewPageInput); - const addNewPage = useStore((state) => state.addNewPage); - const isPageGroup = useStore((state) => state.isPageGroup); - const handleAddingNewPage = (pageName) => { - if (pageName.trim().length === 0) { - toast(`${isPageGroup ? 'Page group' : 'Page'} name should have at least 1 character`, { - icon: '⚠️', - }); - } else if (pageName.trim().length > 32) { - toast(`${isPageGroup ? 'Page group' : 'Page'} name cannot exceed 32 characters`, { - icon: '⚠️', - }); - } else { - addNewPage(pageName, _.kebabCase(pageName.toLowerCase()), isPageGroup); - } - toggleShowAddNewPageInput(false); - }; - - return ( -
-
- { - const name = event.target.value; - handleAddingNewPage(name); - event.stopPropagation(); - }} - onKeyDown={(event) => { - if (event.key === 'Enter') { - const name = event.target.value; - handleAddingNewPage(name); - event.stopPropagation(); - } - }} - /> -
-
- ); -}; diff --git a/frontend/src/AppBuilder/LeftSidebar/PageMenu/style.scss b/frontend/src/AppBuilder/LeftSidebar/PageMenu/style.scss deleted file mode 100644 index 4510123efc..0000000000 --- a/frontend/src/AppBuilder/LeftSidebar/PageMenu/style.scss +++ /dev/null @@ -1,291 +0,0 @@ -.PopoverContent { - &.page { - min-height: unset !important; - } -} - -.page-handler.ghost { - background-color: #ECEEF0; - border-radius: 4px; - position: relative; - width: 100%; - opacity: 0.8; - - &.dark-theme { - background-color: var(--slate4); - } -} - -.edit-page-overlay-toggle { - opacity: 0; - transition: opacity 0.1s; -} - -.menu-item-drag-handle { - display: none; -} - -.page-menu-icon { - display: block; -} - -.page-menu-icon.rev { - display: none; -} - -.page-menu-icon-padding { - padding-left: 16px; -} - -.menu-item-drag-handle { - display: none !important; -} - -.page-group-icon-text { - font-size: 12px; - font-weight: 400; - line-height: 18px; - text-align: left; - color: var(--slate11); - position: relative; - top: -10px; - width: 194px; - margin-left: auto; -} - -.page-handler { - .page-menu-item { - height: 32px; - border-radius: 4px; - display: flex; - align-items: center; - padding: 0px 8px; - margin-top: 2px; - justify-content: space-between; - - &.highlight { - border: 2px solid #3D63DC; - } - - &.is-selected { - background-color: #f0f4ff; - - &.dark-theme { - background-color: var(--slate5); - } - } - - .page-group-actions { - transition: 0.2s ease; - opacity: 0; - gap: 2px; - display: flex; - width: unset !important; - height: unset !important; - - button { - border-radius: var(--2, 4px); - border: 1px solid var(--border-default, #CCD1D5); - background: var(--button-secondary, #FFF); - width: 20px; - height: 20px; - padding: 0; - /* Elevations/000 */ - box-shadow: 0px 1px 0px 0px var(--_-Dropshadow-000, rgba(0, 0, 0, 0.10)); - display: flex; - align-items: center; - justify-content: center; - } - } - - .meta-text { - color: var(--slate8); - font-size: 12px; - font-style: normal; - font-weight: 400; - line-height: 18px; - margin-top: 3px; - } - - button.edit-page-overlay-toggle { - opacity: 0; - } - - &:hover { - background-color: #ECEEF0; - - button.edit-page-overlay-toggle { - opacity: 1; - } - - .page-group-actions { - opacity: 1; - } - - &.dark-theme { - &:hover { - background-color: var(--slate4); - } - } - } - - .left { - display: flex; - align-items: center; - - // margin-left: 15px; - .page-name { - overflow: hidden; - color: var(--slate12); - font-size: 14px; - font-style: normal; - font-weight: 400; - max-width: 246px; - margin-top: 3px; - } - - } - - .right { - width: 20px; - height: 20px; - justify-content: center; - display: flex; - - svg { - path { - fill: var(--slate12); - } - } - - &:hover { - background: var(--slate1); - box-shadow: 0px 1px 1px 1px var(--slate6); - border-radius: 3px; - } - } - } - - &:hover { - .edit-page-overlay-toggle { - opacity: 1; - } - - .menu-item-drag-handle { - display: block; - } - - .page-menu-icon { - display: none; - } - - .page-menu-icon.no-hover { - display: block !important; - } - - .page-menu-icon.rev { - display: block; - } - - .page-menu-icon-padding { - padding-left: 0px !important; - } - - .menu-item-drag-handle { - display: flex !important; - } - - } -} - -.page-group-trigger-button { - display: flex; - padding: 5px 10px; - justify-content: center; - align-items: center; - gap: 6px; - border-radius: 6px; - width: 63px; -} - -.text { - color: var(--text-default, #1B1F24); - font-size: 12px; - font-style: normal; - font-weight: 500; - line-height: 18px; -} - -.page-menu-action-buttons { - display: inline-flex; - align-items: center; - height: 28px; - width: 28px; - padding: 7px; - gap: 6px; - border: none; - outline: none; - border-radius: 6px; - background: transparent; - - svg path { - fill: #6A727C; - } - - &:hover { - background: var(--button-outline-hover, rgba(136, 144, 153, 0.12)); - } -} - - - -.left-sidebar-header-btn.trigger { - border-radius: 6px; - border: 1px solid var(--border-weak, #E4E7EB); - background: var(--button-secondary, #FFF); - box-shadow: 0px 0px 1px 0px var(--dropshadow-100700-layer-1, rgba(48, 50, 51, 0.05)), 0px 1px 1px 0px var(--dropshadow-100400-layer-2, rgba(48, 50, 51, 0.10)); - - &:hover { - border-radius: 6px; - border: 1px solid var(--border-default, #CCD1D5); - background: linear-gradient(0deg, var(--button-outline-hover, rgba(136, 144, 153, 0.12)) 0%, var(--button-outline-hover, rgba(136, 144, 153, 0.12)) 100%), var(--button-outline, #FFF); - } -} - -#page-handler-menu-group { - border: none; - background: transparent; - border-radius: 10px; - - &.dark-theme { - .popover-body { - box-shadow: 0px 0px 1px 0px rgba(0, 0, 0, 0.9), 0px 8px 16px 0px #000000; - } - } - - .popover-body { - width: 160px; - padding: 8px; - border-radius: 10px; - background: var(--background-surface-layer-01); - box-shadow: 0px 0px 1px 0px rgba(48, 50, 51, 0.05), 0px 8px 16px 0px rgba(48, 50, 51, 0.1); - - .menu-options { - .option { - display: flex; - padding: 6px 8px; - align-items: center; - gap: 6px; - height: 30px; - justify-content: center; - align-self: stretch; - border-radius: 6px; - cursor: pointer; - - &:hover { - background: rgba(136, 144, 153, 0.08); - } - } - } - } -} diff --git a/frontend/src/AppBuilder/QueryManager/Components/DataSourcePicker.jsx b/frontend/src/AppBuilder/QueryManager/Components/DataSourcePicker.jsx index 22d6fcca28..1c6ebe9ebd 100644 --- a/frontend/src/AppBuilder/QueryManager/Components/DataSourcePicker.jsx +++ b/frontend/src/AppBuilder/QueryManager/Components/DataSourcePicker.jsx @@ -93,7 +93,7 @@ function DataSourcePicker({ darkMode }) { documentation diff --git a/frontend/src/AppBuilder/QueryManager/Components/QueryManagerBody.jsx b/frontend/src/AppBuilder/QueryManager/Components/QueryManagerBody.jsx index af76275575..f14f1cdefb 100644 --- a/frontend/src/AppBuilder/QueryManager/Components/QueryManagerBody.jsx +++ b/frontend/src/AppBuilder/QueryManager/Components/QueryManagerBody.jsx @@ -280,10 +280,10 @@ export const BaseQueryManagerBody = ({ darkMode, activeTab, renderCopilot = () = } const isSampleDb = selectedDataSource?.type === DATA_SOURCE_TYPE.SAMPLE; const docLink = isSampleDb - ? 'https://docs.tooljet.com/docs/data-sources/sample-data-sources' + ? 'https://docs.tooljet.ai/docs/data-sources/sample-data-sources' : selectedDataSource?.plugin_id && selectedDataSource.plugin_id.trim() !== '' - ? `https://docs.tooljet.com/docs/marketplace/plugins/marketplace-plugin-${selectedDataSource?.kind}/` - : `https://docs.tooljet.com/docs/data-sources/${selectedDataSource?.kind}`; + ? `https://docs.tooljet.ai/docs/marketplace/plugins/marketplace-plugin-${selectedDataSource?.kind}/` + : `https://docs.tooljet.ai/docs/data-sources/${selectedDataSource?.kind}`; return ( <>
diff --git a/frontend/src/AppBuilder/QueryManager/Components/Transformation.jsx b/frontend/src/AppBuilder/QueryManager/Components/Transformation.jsx index e2c2ab56c8..0c39e917c3 100644 --- a/frontend/src/AppBuilder/QueryManager/Components/Transformation.jsx +++ b/frontend/src/AppBuilder/QueryManager/Components/Transformation.jsx @@ -70,7 +70,7 @@ const EducativeLabel = ({ darkMode }) => { faster. It uses OpenAI's GPT-3.5 to suggest queries based on your data.

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