diff --git a/.env.example b/.env.example index 664abd3bf7..b3ed82c802 100644 --- a/.env.example +++ b/.env.example @@ -210,6 +210,8 @@ UNKEY_ROOT_KEY= # The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin. # It's used automatically by Sentry during the build for authentication when uploading source maps. # SENTRY_AUTH_TOKEN= +# The SENTRY_ENVIRONMENT is the environment which the error will belong to in the Sentry dashboard +# SENTRY_ENVIRONMENT= # Configure the minimum role for user management from UI(owner, manager, disabled) # USER_MANAGEMENT_MINIMUM_ROLE="manager" diff --git a/.github/actions/upload-sentry-sourcemaps/action.yml b/.github/actions/upload-sentry-sourcemaps/action.yml new file mode 100644 index 0000000000..e8510aa2f2 --- /dev/null +++ b/.github/actions/upload-sentry-sourcemaps/action.yml @@ -0,0 +1,121 @@ +name: 'Upload Sentry Sourcemaps' +description: 'Extract sourcemaps from Docker image and upload to Sentry' + +inputs: + docker_image: + description: 'Docker image to extract sourcemaps from' + required: true + release_version: + description: 'Sentry release version (e.g., v1.2.3)' + required: true + sentry_auth_token: + description: 'Sentry authentication token' + required: true + +runs: + using: 'composite' + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate Sentry auth token + shell: bash + run: | + set -euo pipefail + echo "๐Ÿ” Validating Sentry authentication token..." + + # Assign token to local variable for secure handling + SENTRY_TOKEN="${{ inputs.sentry_auth_token }}" + + # Test the token by making a simple API call to Sentry + response=$(curl -s -w "%{http_code}" -o /tmp/sentry_response.json \ + -H "Authorization: Bearer $SENTRY_TOKEN" \ + "https://sentry.io/api/0/organizations/formbricks/") + + http_code=$(echo "$response" | tail -n1) + + if [ "$http_code" != "200" ]; then + echo "โŒ Error: Invalid Sentry auth token (HTTP $http_code)" + echo "Please check your SENTRY_AUTH_TOKEN is correct and has the necessary permissions." + if [ -f /tmp/sentry_response.json ]; then + echo "Response body:" + cat /tmp/sentry_response.json + fi + exit 1 + fi + + echo "โœ… Sentry auth token validated successfully" + + # Clean up temp file + rm -f /tmp/sentry_response.json + + - name: Extract sourcemaps from Docker image + shell: bash + run: | + set -euo pipefail + echo "๐Ÿ“ฆ Extracting sourcemaps from Docker image: ${{ inputs.docker_image }}" + + # Create temporary container from the image and capture its ID + echo "Creating temporary container..." + CONTAINER_ID=$(docker create "${{ inputs.docker_image }}") + echo "Container created with ID: $CONTAINER_ID" + + # Set up cleanup function to ensure container is removed on script exit + cleanup_container() { + # Capture the current exit code to preserve it + local original_exit_code=$? + + echo "๐Ÿงน Cleaning up Docker container..." + + # Remove the container if it exists (ignore errors if already removed) + if [ -n "$CONTAINER_ID" ]; then + docker rm -f "$CONTAINER_ID" 2>/dev/null || true + echo "Container $CONTAINER_ID removed" + fi + + # Exit with the original exit code to preserve script success/failure status + exit $original_exit_code + } + + # Register cleanup function to run on script exit (success or failure) + trap cleanup_container EXIT + + # Extract .next directory containing sourcemaps + docker cp "$CONTAINER_ID:/home/nextjs/apps/web/.next" ./extracted-next + + # Verify sourcemaps exist + if [ ! -d "./extracted-next/static/chunks" ]; then + echo "โŒ Error: .next/static/chunks directory not found in Docker image" + echo "Expected structure: /home/nextjs/apps/web/.next/static/chunks/" + exit 1 + fi + + sourcemap_count=$(find ./extracted-next/static/chunks -name "*.map" | wc -l) + echo "โœ… Found $sourcemap_count sourcemap files" + + if [ "$sourcemap_count" -eq 0 ]; then + echo "โŒ Error: No sourcemap files found. Check that productionBrowserSourceMaps is enabled." + exit 1 + fi + + - name: Create Sentry release and upload sourcemaps + uses: getsentry/action-release@v3 + env: + SENTRY_AUTH_TOKEN: ${{ inputs.sentry_auth_token }} + SENTRY_ORG: formbricks + SENTRY_PROJECT: formbricks-cloud + with: + environment: production + version: ${{ inputs.release_version }} + sourcemaps: './extracted-next/' + + - name: Clean up extracted files + shell: bash + if: always() + run: | + set -euo pipefail + # Clean up extracted files + rm -rf ./extracted-next + echo "๐Ÿงน Cleaned up extracted files" diff --git a/.github/workflows/formbricks-release.yml b/.github/workflows/formbricks-release.yml index 68f45a88b5..6df33e7dd8 100644 --- a/.github/workflows/formbricks-release.yml +++ b/.github/workflows/formbricks-release.yml @@ -32,3 +32,25 @@ jobs: with: VERSION: v${{ needs.docker-build.outputs.VERSION }} ENVIRONMENT: "prod" + + upload-sentry-sourcemaps: + name: Upload Sentry Sourcemaps + runs-on: ubuntu-latest + permissions: + contents: read + needs: + - docker-build + - deploy-formbricks-cloud + steps: + - name: Checkout + uses: actions/checkout@v4.2.2 + with: + fetch-depth: 0 + + - name: Upload Sentry Sourcemaps + uses: ./.github/actions/upload-sentry-sourcemaps + continue-on-error: true + with: + docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }} + release_version: v${{ needs.docker-build.outputs.VERSION }} + sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/.github/workflows/upload-sentry-sourcemaps.yml b/.github/workflows/upload-sentry-sourcemaps.yml new file mode 100644 index 0000000000..7af92ebc10 --- /dev/null +++ b/.github/workflows/upload-sentry-sourcemaps.yml @@ -0,0 +1,46 @@ +name: Upload Sentry Sourcemaps (Manual) + +on: + workflow_dispatch: + inputs: + docker_image: + description: "Docker image to extract sourcemaps from" + required: true + type: string + release_version: + description: "Release version (e.g., v1.2.3)" + required: true + type: string + tag_version: + description: "Docker image tag (leave empty to use release_version)" + required: false + type: string + +permissions: + contents: read + +jobs: + upload-sourcemaps: + name: Upload Sourcemaps to Sentry + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4.2.2 + with: + fetch-depth: 0 + + - name: Set Docker Image + run: | + if [ -n "${{ inputs.tag_version }}" ]; then + echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.tag_version }}" >> $GITHUB_ENV + else + echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.release_version }}" >> $GITHUB_ENV + fi + + - name: Upload Sourcemaps to Sentry + uses: ./.github/actions/upload-sentry-sourcemaps + with: + docker_image: ${{ env.DOCKER_IMAGE }} + release_version: ${{ inputs.release_version }} + sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6563dae2aa..13e4eb4e5f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,17 +14,7 @@ Are you brimming with brilliant ideas? For new features that can elevate Formbri ## ๐Ÿ›  Crafting Pull Requests -Ready to dive into the code and make a real impact? Here's your path: - -1. **Read our Best Practices**: [It takes 5 minutes](https://formbricks.com/docs/developer-docs/contributing/get-started) but will help you save hours ๐Ÿค“ - -1. **Fork the Repository:** Fork our repository or use [Gitpod](https://gitpod.io) or use [Github Codespaces](https://github.com/features/codespaces) to get started instantly. - -1. **Tweak and Transform:** Work your coding magic and apply your changes. - -1. **Pull Request Act:** If you're ready to go, craft a new pull request closely following our PR template ๐Ÿ™ - -Would you prefer a chat before you dive into a lot of work? [Github Discussions](https://github.com/formbricks/formbricks/discussions) is your harbor. Share your thoughts, and we'll meet you there with open arms. We're responsive and friendly, promise! +For the time being, we don't have the capacity to properly facilitate community contributions. It's a lot of engineering attention often spent on issues which don't follow our prioritization, so we've decided to only facilitate community code contributions in rare exceptions in the coming months. ## ๐Ÿš€ Aspiring Features diff --git a/README.md b/README.md index 91345fa99c..0672946a3c 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ Here are a few options: - Upvote issues with ๐Ÿ‘ reaction so we know what the demand for a particular issue is to prioritize it within the roadmap. -Please check out [our contribution guide](https://formbricks.com/docs/developer-docs/contributing/get-started) and our [list of open issues](https://github.com/formbricks/formbricks/issues) for more information. +- Note: For the time being, we can only facilitate code contributions as an exception. ## All Thanks To Our Contributors diff --git a/apps/storybook/.storybook/main.ts b/apps/storybook/.storybook/main.ts index 68129e5354..1636dded8a 100644 --- a/apps/storybook/.storybook/main.ts +++ b/apps/storybook/.storybook/main.ts @@ -14,10 +14,9 @@ const config: StorybookConfig = { addons: [ getAbsolutePath("@storybook/addon-onboarding"), getAbsolutePath("@storybook/addon-links"), - getAbsolutePath("@storybook/addon-essentials"), getAbsolutePath("@chromatic-com/storybook"), - getAbsolutePath("@storybook/addon-interactions"), getAbsolutePath("@storybook/addon-a11y"), + getAbsolutePath("@storybook/addon-docs"), ], framework: { name: getAbsolutePath("@storybook/react-vite"), diff --git a/apps/storybook/.storybook/preview.ts b/apps/storybook/.storybook/preview.ts index dfdb46f022..a7780cc71e 100644 --- a/apps/storybook/.storybook/preview.ts +++ b/apps/storybook/.storybook/preview.ts @@ -1,5 +1,21 @@ -import type { Preview } from "@storybook/react"; +import type { Preview } from "@storybook/react-vite"; +import { TolgeeProvider } from "@tolgee/react"; +import React from "react"; import "../../web/modules/ui/globals.css"; +import { TolgeeBase } from "../../web/tolgee/shared"; + +// Create a Storybook-specific Tolgee decorator +const withTolgee = (Story: any) => { + const tolgee = TolgeeBase().init({ + tagNewKeys: [], // No branch tagging in Storybook + }); + + return React.createElement( + TolgeeProvider, + { tolgee, fallback: "Loading", ssr: { language: "en", staticData: {} } }, + React.createElement(Story) + ); +}; const preview: Preview = { parameters: { @@ -10,6 +26,7 @@ const preview: Preview = { }, }, }, + decorators: [withTolgee], }; export default preview; diff --git a/apps/storybook/package.json b/apps/storybook/package.json index 3ba48446be..f6ea3ceff3 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -14,23 +14,19 @@ "eslint-plugin-react-refresh": "0.4.20" }, "devDependencies": { - "@chromatic-com/storybook": "3.2.6", - "@storybook/addon-a11y": "8.6.12", - "@storybook/addon-essentials": "8.6.12", - "@storybook/addon-interactions": "8.6.12", - "@storybook/addon-links": "8.6.12", - "@storybook/addon-onboarding": "8.6.12", - "@storybook/blocks": "8.6.12", - "@storybook/react": "8.6.12", - "@storybook/react-vite": "8.6.12", - "@storybook/test": "8.6.12", + "@chromatic-com/storybook": "^4.0.1", + "@storybook/addon-a11y": "9.0.15", + "@storybook/addon-links": "9.0.15", + "@storybook/addon-onboarding": "9.0.15", + "@storybook/react-vite": "9.0.15", "@typescript-eslint/eslint-plugin": "8.32.0", "@typescript-eslint/parser": "8.32.0", "@vitejs/plugin-react": "4.4.1", "esbuild": "0.25.4", - "eslint-plugin-storybook": "0.12.0", + "eslint-plugin-storybook": "9.0.15", "prop-types": "15.8.1", - "storybook": "8.6.12", - "vite": "6.3.5" + "storybook": "9.0.15", + "vite": "6.3.5", + "@storybook/addon-docs": "9.0.15" } } diff --git a/apps/storybook/src/stories/Configure.mdx b/apps/storybook/src/stories/Configure.mdx index 6969c2bbd5..22d53458f8 100644 --- a/apps/storybook/src/stories/Configure.mdx +++ b/apps/storybook/src/stories/Configure.mdx @@ -1,4 +1,4 @@ -import { Meta } from "@storybook/blocks"; +import { Meta } from "@storybook/addon-docs/blocks"; import Accessibility from "./assets/accessibility.png"; import AddonLibrary from "./assets/addon-library.png"; diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index e9729940cf..48dadbeb2a 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -25,21 +25,9 @@ RUN corepack prepare pnpm@9.15.9 --activate # Install necessary build tools and compilers RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3 -# BuildKit secret handling without hardcoded fallback values -# This approach relies entirely on secrets passed from GitHub Actions -RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \ - echo 'if [ -f "/run/secrets/database_url" ]; then' >> /tmp/read-secrets.sh && \ - echo ' export DATABASE_URL=$(cat /run/secrets/database_url)' >> /tmp/read-secrets.sh && \ - echo 'else' >> /tmp/read-secrets.sh && \ - echo ' echo "DATABASE_URL secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \ - echo 'fi' >> /tmp/read-secrets.sh && \ - echo 'if [ -f "/run/secrets/encryption_key" ]; then' >> /tmp/read-secrets.sh && \ - echo ' export ENCRYPTION_KEY=$(cat /run/secrets/encryption_key)' >> /tmp/read-secrets.sh && \ - echo 'else' >> /tmp/read-secrets.sh && \ - echo ' echo "ENCRYPTION_KEY secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \ - echo 'fi' >> /tmp/read-secrets.sh && \ - echo 'exec "$@"' >> /tmp/read-secrets.sh && \ - chmod +x /tmp/read-secrets.sh +# Copy the secrets handling script +COPY apps/web/scripts/docker/read-secrets.sh /tmp/read-secrets.sh +RUN chmod +x /tmp/read-secrets.sh # Increase Node.js memory limit as a regular build argument ARG NODE_OPTIONS="--max_old_space_size=4096" @@ -62,6 +50,9 @@ RUN touch apps/web/.env # Install the dependencies RUN pnpm install --ignore-scripts +# Build the database package first +RUN pnpm build --filter=@formbricks/database + # Build the project using our secret reader script # This mounts the secrets only during this build step without storing them in layers RUN --mount=type=secret,id=database_url \ @@ -106,20 +97,8 @@ RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma -COPY --from=installer /app/packages/database/package.json ./packages/database/package.json -RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json - -COPY --from=installer /app/packages/database/migration ./packages/database/migration -RUN chown -R nextjs:nextjs ./packages/database/migration && chmod -R 755 ./packages/database/migration - -COPY --from=installer /app/packages/database/src ./packages/database/src -RUN chown -R nextjs:nextjs ./packages/database/src && chmod -R 755 ./packages/database/src - -COPY --from=installer /app/packages/database/node_modules ./packages/database/node_modules -RUN chown -R nextjs:nextjs ./packages/database/node_modules && chmod -R 755 ./packages/database/node_modules - -COPY --from=installer /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist -RUN chown -R nextjs:nextjs ./packages/database/node_modules/@formbricks/logger/dist && chmod -R 755 ./packages/database/node_modules/@formbricks/logger/dist +COPY --from=installer /app/packages/database/dist ./packages/database/dist +RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client @@ -142,12 +121,14 @@ RUN chmod -R 755 ./node_modules/@noble/hashes COPY --from=installer /app/node_modules/zod ./node_modules/zod RUN chmod -R 755 ./node_modules/zod -RUN npm install --ignore-scripts -g tsx typescript pino-pretty RUN npm install -g prisma +# Create a startup script to handle the conditional logic +COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh +RUN chown nextjs:nextjs /home/nextjs/start.sh && chmod +x /home/nextjs/start.sh + EXPOSE 3000 -ENV HOSTNAME "0.0.0.0" -ENV NODE_ENV="production" +ENV HOSTNAME="0.0.0.0" USER nextjs # Prepare volume for uploads @@ -158,12 +139,4 @@ VOLUME /home/nextjs/apps/web/uploads/ RUN mkdir -p /home/nextjs/apps/web/saml-connection VOLUME /home/nextjs/apps/web/saml-connection -CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \ - echo "Starting cron jobs..."; \ - supercronic -quiet /app/docker/cronjobs & \ - else \ - echo "Docker cron jobs are disabled via DOCKER_CRON_ENABLED=0"; \ - fi; \ - (cd packages/database && npm run db:migrate:deploy) && \ - (cd packages/database && npm run db:create-saml-database:deploy) && \ - exec node apps/web/server.js \ No newline at end of file +CMD ["/home/nextjs/start.sh"] \ No newline at end of file diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.test.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.test.tsx index 40ab57335f..fb33d1991c 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.test.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.test.tsx @@ -94,6 +94,7 @@ describe("LandingSidebar component", () => { organizationId: "o1", redirect: true, callbackUrl: "/auth/login", + clearEnvironmentId: true, }); }); }); diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.tsx index ce5e8b7b4a..f50e589875 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.tsx @@ -130,6 +130,7 @@ export const LandingSidebar = ({ organizationId: organization.id, redirect: true, callbackUrl: "/auth/login", + clearEnvironmentId: true, }); }} icon={}> diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionDetailModal.test.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionDetailModal.test.tsx index 317bfde390..9e5f0b78d8 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionDetailModal.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionDetailModal.test.tsx @@ -1,5 +1,5 @@ -import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs"; -import { cleanup, render } from "@testing-library/react"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, test, vi } from "vitest"; import { TActionClass } from "@formbricks/types/action-classes"; import { TEnvironment } from "@formbricks/types/environment"; @@ -8,23 +8,40 @@ import { ActionDetailModal } from "./ActionDetailModal"; // Import mocked components import { ActionSettingsTab } from "./ActionSettingsTab"; -// Mock child components -vi.mock("@/modules/ui/components/modal-with-tabs", () => ({ - ModalWithTabs: vi.fn(({ tabs, icon, label, description, open, setOpen }) => ( -
- {label} - {description} - {open.toString()} - - {icon} - {tabs.map((tab) => ( -
-

{tab.title}

- {tab.children} -
- ))} -
- )), +// Mock the Dialog components +vi.mock("@/modules/ui/components/dialog", () => ({ + Dialog: ({ + open, + onOpenChange, + children, + }: { + open: boolean; + onOpenChange: (open: boolean) => void; + children: React.ReactNode; + }) => + open ? ( +
+ {children} + +
+ ) : null, + DialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogTitle: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + DialogDescription: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + DialogBody: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), })); vi.mock("./ActionActivityTab", () => ({ @@ -44,6 +61,22 @@ vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({ }, })); +// Mock useTranslate +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => { + const translations = { + "common.activity": "Activity", + "common.settings": "Settings", + "common.no_code": "No Code", + "common.action": "Action", + "common.code": "Code", + }; + return translations[key] || key; + }, + }), +})); + const mockEnvironmentId = "test-env-id"; const mockSetOpen = vi.fn(); @@ -89,58 +122,68 @@ describe("ActionDetailModal", () => { vi.clearAllMocks(); // Clear mocks after each test }); - test("renders ModalWithTabs with correct props", () => { + test("renders correctly when open", () => { render(); - const mockedModalWithTabs = vi.mocked(ModalWithTabs); + expect(screen.getByTestId("dialog")).toBeInTheDocument(); + expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Action"); + expect(screen.getByTestId("dialog-description")).toHaveTextContent("This is a test action"); + expect(screen.getByTestId("code-icon")).toBeInTheDocument(); + expect(screen.getByText("Activity")).toBeInTheDocument(); + expect(screen.getByText("Settings")).toBeInTheDocument(); + // Only the first tab (Activity) should be active initially + expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument(); + expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument(); + }); - expect(mockedModalWithTabs).toHaveBeenCalled(); - const props = mockedModalWithTabs.mock.calls[0][0]; + test("does not render when open is false", () => { + render(); + expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); + }); - // Check basic props - expect(props.open).toBe(true); - expect(props.setOpen).toBe(mockSetOpen); - expect(props.label).toBe(mockActionClass.name); - expect(props.description).toBe(mockActionClass.description); + test("switches tabs correctly", async () => { + const user = userEvent.setup(); + render(); - // Check icon data-testid based on the mock for the default 'code' type - expect(props.icon).toBeDefined(); - if (!props.icon) { - throw new Error("Icon prop is not defined"); - } - expect((props.icon as any).props["data-testid"]).toBe("code-icon"); + // Initially shows activity tab (first tab is active) + expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument(); + expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument(); - // Check tabs structure - expect(props.tabs).toHaveLength(2); - expect(props.tabs[0].title).toBe("common.activity"); - expect(props.tabs[1].title).toBe("common.settings"); + // Click settings tab + const settingsTab = screen.getByText("Settings"); + await user.click(settingsTab); - // Check if the correct mocked components are used as children - // Access the mocked functions directly - const mockedActionActivityTab = vi.mocked(ActionActivityTab); - const mockedActionSettingsTab = vi.mocked(ActionSettingsTab); + // Now shows settings tab content + expect(screen.queryByTestId("action-activity-tab")).not.toBeInTheDocument(); + expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument(); - if (!props.tabs[0].children || !props.tabs[1].children) { - throw new Error("Tabs children are not defined"); - } + // Click activity tab again + const activityTab = screen.getByText("Activity"); + await user.click(activityTab); - expect((props.tabs[0].children as any).type).toBe(mockedActionActivityTab); - expect((props.tabs[1].children as any).type).toBe(mockedActionSettingsTab); + // Back to activity tab content + expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument(); + expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument(); + }); - // Check props passed to child components - const activityTabProps = (props.tabs[0].children as any).props; - expect(activityTabProps.otherEnvActionClasses).toBe(mockOtherEnvActionClasses); - expect(activityTabProps.otherEnvironment).toBe(mockOtherEnvironment); - expect(activityTabProps.isReadOnly).toBe(false); - expect(activityTabProps.environment).toBe(mockEnvironment); - expect(activityTabProps.actionClass).toBe(mockActionClass); - expect(activityTabProps.environmentId).toBe(mockEnvironmentId); + test("resets to first tab when modal is reopened", async () => { + const user = userEvent.setup(); + const { rerender } = render(); - const settingsTabProps = (props.tabs[1].children as any).props; - expect(settingsTabProps.actionClass).toBe(mockActionClass); - expect(settingsTabProps.actionClasses).toBe(mockActionClasses); - expect(settingsTabProps.setOpen).toBe(mockSetOpen); - expect(settingsTabProps.isReadOnly).toBe(false); + // Switch to settings tab + const settingsTab = screen.getByText("Settings"); + await user.click(settingsTab); + expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument(); + + // Close modal + rerender(); + + // Reopen modal + rerender(); + + // Should be back to activity tab (first tab) + expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument(); + expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument(); }); test("renders correct icon based on action type", () => { @@ -148,33 +191,68 @@ describe("ActionDetailModal", () => { const noCodeAction: TActionClass = { ...mockActionClass, type: "noCode" } as TActionClass; render(); - const mockedModalWithTabs = vi.mocked(ModalWithTabs); - const props = mockedModalWithTabs.mock.calls[0][0]; + expect(screen.getByTestId("nocode-icon")).toBeInTheDocument(); + expect(screen.queryByTestId("code-icon")).not.toBeInTheDocument(); + }); - // Expect the 'nocode-icon' based on the updated mock and action type - expect(props.icon).toBeDefined(); + test("handles action without description", () => { + const actionWithoutDescription = { ...mockActionClass, description: "" }; + render(); - if (!props.icon) { - throw new Error("Icon prop is not defined"); - } + expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Action"); + expect(screen.getByTestId("dialog-description")).toHaveTextContent("Code action"); + }); - expect((props.icon as any).props["data-testid"]).toBe("nocode-icon"); + test("passes correct props to ActionActivityTab", () => { + render(); + + const mockedActionActivityTab = vi.mocked(ActionActivityTab); + expect(mockedActionActivityTab).toHaveBeenCalledWith( + { + otherEnvActionClasses: mockOtherEnvActionClasses, + otherEnvironment: mockOtherEnvironment, + isReadOnly: false, + environment: mockEnvironment, + actionClass: mockActionClass, + environmentId: mockEnvironmentId, + }, + undefined + ); + }); + + test("passes correct props to ActionSettingsTab when tab is active", async () => { + const user = userEvent.setup(); + render(); + + // ActionSettingsTab should not be called initially since first tab is active + const mockedActionSettingsTab = vi.mocked(ActionSettingsTab); + expect(mockedActionSettingsTab).not.toHaveBeenCalled(); + + // Click the settings tab to activate ActionSettingsTab + const settingsTab = screen.getByText("Settings"); + await user.click(settingsTab); + + // Now ActionSettingsTab should be called with correct props + expect(mockedActionSettingsTab).toHaveBeenCalledWith( + { + actionClass: mockActionClass, + actionClasses: mockActionClasses, + setOpen: mockSetOpen, + isReadOnly: false, + }, + undefined + ); }); test("passes isReadOnly prop correctly", () => { render(); - // Access the mocked component directly - const mockedModalWithTabs = vi.mocked(ModalWithTabs); - const props = mockedModalWithTabs.mock.calls[0][0]; - if (!props.tabs[0].children || !props.tabs[1].children) { - throw new Error("Tabs children are not defined"); - } - - const activityTabProps = (props.tabs[0].children as any).props; - expect(activityTabProps.isReadOnly).toBe(true); - - const settingsTabProps = (props.tabs[1].children as any).props; - expect(settingsTabProps.isReadOnly).toBe(true); + const mockedActionActivityTab = vi.mocked(ActionActivityTab); + expect(mockedActionActivityTab).toHaveBeenCalledWith( + expect.objectContaining({ + isReadOnly: true, + }), + undefined + ); }); }); diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionDetailModal.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionDetailModal.tsx index 8a3f169abc..cc08c0ee91 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionDetailModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionDetailModal.tsx @@ -59,6 +59,16 @@ export const ActionDetailModal = ({ }, ]; + const typeDescription = () => { + if (actionClass.description) return actionClass.description; + else + return ( + (actionClass.type && actionClass.type === "noCode" ? t("common.no_code") : t("common.code")) + + " " + + t("common.action").toLowerCase() + ); + }; + return ( <> ); diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionRowData.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionRowData.tsx index cb5a49a39c..e4b4c12c6e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionRowData.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionRowData.tsx @@ -11,22 +11,21 @@ export const ActionClassDataRow = ({ locale: TUserLocale; }) => { return ( -
-
-
-
+
+
+
+
{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
-
-
{actionClass.name}
-
{actionClass.description}
+
+
{actionClass.name}
+
{actionClass.description}
{timeSince(actionClass.createdAt.toString(), locale)}
-
); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionSettingsTab.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionSettingsTab.tsx index cb6da32089..f7bd77a262 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionSettingsTab.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionSettingsTab.tsx @@ -210,14 +210,13 @@ export const ActionSettingsTab = ({ )}
-
-
+
+
{!isReadOnly ? ( +
) : null, + DialogContent: ({ children, ...props }: any) => ( +
+ {children} +
+ ), + DialogHeader: ({ children }: any) =>
{children}
, + DialogTitle: ({ children, className }: any) => ( +

+ {children} +

+ ), + DialogDescription: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogBody: ({ children }: any) =>
{children}
, })); vi.mock("@tolgee/react", () => ({ @@ -70,17 +85,21 @@ describe("AddActionModal", () => { ); expect(screen.getByRole("button", { name: "common.add_action" })).toBeInTheDocument(); expect(screen.getByTestId("plus-icon")).toBeInTheDocument(); - expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); + expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); }); - test("opens the modal when the 'Add Action' button is clicked", async () => { + test("opens the dialog when the 'Add Action' button is clicked", async () => { render( ); const addButton = screen.getByRole("button", { name: "common.add_action" }); await userEvent.click(addButton); - expect(screen.getByTestId("modal")).toBeInTheDocument(); + expect(screen.getByTestId("dialog")).toBeInTheDocument(); + expect(screen.getByTestId("dialog-content")).toBeInTheDocument(); + expect(screen.getByTestId("dialog-header")).toBeInTheDocument(); + expect(screen.getByTestId("dialog-title")).toBeInTheDocument(); + expect(screen.getByTestId("dialog-body")).toBeInTheDocument(); expect(screen.getByTestId("mouse-pointer-icon")).toBeInTheDocument(); expect(screen.getByText("environments.actions.track_new_user_action")).toBeInTheDocument(); expect( @@ -108,35 +127,35 @@ describe("AddActionModal", () => { expect(props.setActionClasses).toBeInstanceOf(Function); }); - test("closes the modal when the close button (simulated) is clicked", async () => { + test("closes the dialog when the close button (simulated) is clicked", async () => { render( ); const addButton = screen.getByRole("button", { name: "common.add_action" }); await userEvent.click(addButton); - expect(screen.getByTestId("modal")).toBeInTheDocument(); + expect(screen.getByTestId("dialog")).toBeInTheDocument(); - // Simulate closing via the mocked Modal's close button - const closeModalButton = screen.getByText("Close Modal"); - await userEvent.click(closeModalButton); + // Simulate closing via the mocked Dialog's close button + const closeDialogButton = screen.getByText("Close Dialog"); + await userEvent.click(closeDialogButton); - expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); + expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); }); - test("closes the modal when setOpen is called from CreateNewActionTab", async () => { + test("closes the dialog when setOpen is called from CreateNewActionTab", async () => { render( ); const addButton = screen.getByRole("button", { name: "common.add_action" }); await userEvent.click(addButton); - expect(screen.getByTestId("modal")).toBeInTheDocument(); + expect(screen.getByTestId("dialog")).toBeInTheDocument(); // Simulate closing via the mocked CreateNewActionTab's button const closeFromTabButton = screen.getByText("Close from Tab"); await userEvent.click(closeFromTabButton); - expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); + expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); }); }); diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/AddActionModal.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/AddActionModal.tsx index 635f94c10a..99b05c0439 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions/components/AddActionModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/AddActionModal.tsx @@ -2,7 +2,14 @@ import { CreateNewActionTab } from "@/modules/survey/editor/components/create-new-action-tab"; import { Button } from "@/modules/ui/components/button"; -import { Modal } from "@/modules/ui/components/modal"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/modules/ui/components/dialog"; import { useTranslate } from "@tolgee/react"; import { MousePointerClickIcon, PlusIcon } from "lucide-react"; import { useState } from "react"; @@ -26,36 +33,26 @@ export const AddActionModal = ({ environmentId, actionClasses, isReadOnly }: Add {t("common.add_action")} - -
-
-
-
-
- -
-
-
- {t("environments.actions.track_new_user_action")} -
-
- {t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")} -
-
-
-
-
-
-
- -
-
+ + + + + {t("environments.actions.track_new_user_action")} + + {t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")} + + + + + + + ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.test.tsx index 0809609434..56df681c75 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.test.tsx @@ -221,7 +221,6 @@ describe("MainNavigation", () => { vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut }); // Set up localStorage spy on the mocked localStorage - const removeItemSpy = vi.spyOn(window.localStorage, "removeItem"); render(); @@ -243,23 +242,18 @@ describe("MainNavigation", () => { const logoutButton = screen.getByText("common.logout"); await userEvent.click(logoutButton); - // Verify localStorage.removeItem is called with the correct key - expect(removeItemSpy).toHaveBeenCalledWith("formbricks-environment-id"); - expect(mockSignOut).toHaveBeenCalledWith({ reason: "user_initiated", redirectUrl: "/auth/login", organizationId: "org1", redirect: false, callbackUrl: "/auth/login", + clearEnvironmentId: true, }); await waitFor(() => { expect(mockRouterPush).toHaveBeenCalledWith("/auth/login"); }); - - // Clean up spy - removeItemSpy.mockRestore(); }); test("handles organization switching", async () => { diff --git a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx index d492cfa87b..8a955cbf34 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx @@ -4,7 +4,6 @@ import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[enviro import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink"; import FBLogo from "@/images/formbricks-wordmark.svg"; import { cn } from "@/lib/cn"; -import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage"; import { getAccessFlags } from "@/lib/membership/utils"; import { capitalizeFirstLetter } from "@/lib/utils/strings"; import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; @@ -391,14 +390,13 @@ export const MainNavigation = ({ { - localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS); - const route = await signOutWithAudit({ reason: "user_initiated", redirectUrl: "/auth/login", organizationId: organization.id, redirect: false, callbackUrl: "/auth/login", + clearEnvironmentId: true, }); router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings }} diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal.test.tsx index 1c5094c157..360e39a47a 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal.test.tsx @@ -92,14 +92,24 @@ vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
), })); -vi.mock("@/modules/ui/components/modal", () => ({ - Modal: ({ children, open, setOpen }) => +vi.mock("@/modules/ui/components/dialog", () => ({ + Dialog: ({ children, open, onOpenChange }: any) => open ? ( -
+
{children} - +
) : null, + DialogContent: ({ children, ...props }: any) => ( +
+ {children} +
+ ), + DialogHeader: ({ children }: any) =>
{children}
, + DialogTitle: ({ children }: any) =>

{children}

, + DialogDescription: ({ children }: any) =>

{children}

, + DialogBody: ({ children }: any) =>
{children}
, + DialogFooter: ({ children }: any) =>
{children}
, })); vi.mock("@/modules/ui/components/alert", () => ({ Alert: ({ children }) =>
{children}
, diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal.tsx index 9f8db1a8f1..5ee6ad83ea 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal.tsx @@ -10,8 +10,16 @@ import { AdditionalIntegrationSettings } from "@/modules/ui/components/additiona import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert"; import { Button } from "@/modules/ui/components/button"; import { Checkbox } from "@/modules/ui/components/checkbox"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/modules/ui/components/dialog"; import { Label } from "@/modules/ui/components/label"; -import { Modal } from "@/modules/ui/components/modal"; import { Select, SelectContent, @@ -19,11 +27,11 @@ import { SelectTrigger, SelectValue, } from "@/modules/ui/components/select"; -import { useTranslate } from "@tolgee/react"; +import { TFnType, useTranslate } from "@tolgee/react"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; -import { Controller, useForm } from "react-hook-form"; +import { Control, Controller, useForm } from "react-hook-form"; import { toast } from "react-hot-toast"; import { TIntegrationItem } from "@formbricks/types/integration"; import { @@ -68,6 +76,80 @@ const NoBaseFoundError = () => { ); }; +const renderQuestionSelection = ({ + t, + selectedSurvey, + control, + includeVariables, + setIncludeVariables, + includeHiddenFields, + includeMetadata, + setIncludeHiddenFields, + setIncludeMetadata, + includeCreatedAt, + setIncludeCreatedAt, +}: { + t: TFnType; + selectedSurvey: TSurvey; + control: Control; + includeVariables: boolean; + setIncludeVariables: (value: boolean) => void; + includeHiddenFields: boolean; + includeMetadata: boolean; + setIncludeHiddenFields: (value: boolean) => void; + setIncludeMetadata: (value: boolean) => void; + includeCreatedAt: boolean; + setIncludeCreatedAt: (value: boolean) => void; +}) => { + return ( +
+
+ +
+
+ {replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => ( + ( +
+ +
+ )} + /> + ))} +
+
+
+ +
+ ); +}; + export const AddIntegrationModal = ({ open, setOpenWithStates, @@ -210,182 +292,148 @@ export const AddIntegrationModal = ({ }; return ( - -
-
+ + +
-
- Airtable logo +
+ {t("environments.integrations.airtable.airtable_logo")}
-
-
- {t("environments.integrations.airtable.link_airtable_table")} -
-
+
+ {t("environments.integrations.airtable.link_airtable_table")} + {t("environments.integrations.airtable.sync_responses_with_airtable")} -
+
-
-
-
-
-
- {airtableArray.length ? ( - - ) : ( - - )} - -
- -
- + + +
+ {airtableArray.length ? ( + ( - - )} + isLoading={isLoading} + fetchTable={fetchTable} + airtableArray={airtableArray} + setValue={setValue} + defaultValue={defaultData?.base} /> -
-
+ ) : ( + + )} - {surveys.length ? (
- +
( )} />
- ) : null} - {!surveys.length ? ( -

- {t("environments.integrations.create_survey_warning")} -

- ) : null} - - {survey && selectedSurvey && ( -
-
- -
-
- {replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => ( - ( -
- -
- )} - /> - ))} -
+ {surveys.length ? ( +
+ +
+ ( + + )} + />
- -
- )} - -
- {isEditMode ? ( - ) : ( - +

+ {t("environments.integrations.create_survey_warning")} +

)} - + {survey && + selectedSurvey && + renderQuestionSelection({ + t, + selectedSurvey, + control, + includeVariables, + setIncludeVariables, + includeHiddenFields, + includeMetadata, + setIncludeHiddenFields, + setIncludeMetadata, + includeCreatedAt, + setIncludeCreatedAt, + })}
-
-
- - + + + {isEditMode ? ( + + ) : ( + + )} + + + + + +
); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.test.tsx index 23e63c8543..ba7c5d7fc7 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.test.tsx @@ -88,9 +88,24 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
), })); -vi.mock("@/modules/ui/components/modal", () => ({ - Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) => - open ?
{children}
: null, +vi.mock("@/modules/ui/components/dialog", () => ({ + Dialog: ({ children, open, onOpenChange }: any) => + open ? ( +
+ {children} + +
+ ) : null, + DialogContent: ({ children, ...props }: any) => ( +
+ {children} +
+ ), + DialogHeader: ({ children }: any) =>
{children}
, + DialogTitle: ({ children }: any) =>

{children}

, + DialogDescription: ({ children }: any) =>

{children}

, + DialogBody: ({ children }: any) =>
{children}
, + DialogFooter: ({ children }: any) =>
{children}
, })); vi.mock("next/image", () => ({ // eslint-disable-next-line @next/next/no-img-element @@ -304,10 +319,9 @@ describe("AddIntegrationModal", () => { /> ); - expect(screen.getByTestId("modal")).toBeInTheDocument(); - expect( - screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" }) - ).toBeInTheDocument(); + expect(screen.getByTestId("dialog")).toBeInTheDocument(); + expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet"); + expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets."); // Use getByPlaceholderText for the input expect( screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/") @@ -332,10 +346,9 @@ describe("AddIntegrationModal", () => { /> ); - expect(screen.getByTestId("modal")).toBeInTheDocument(); - expect( - screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" }) - ).toBeInTheDocument(); + expect(screen.getByTestId("dialog")).toBeInTheDocument(); + expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet"); + expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets."); // Use getByPlaceholderText for the input expect( screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/") diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.tsx index b44f656ee2..19b6445269 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.tsx @@ -14,10 +14,18 @@ import { replaceHeadlineRecall } from "@/lib/utils/recall"; import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings"; import { Button } from "@/modules/ui/components/button"; import { Checkbox } from "@/modules/ui/components/checkbox"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/modules/ui/components/dialog"; import { DropdownSelector } from "@/modules/ui/components/dropdown-selector"; import { Input } from "@/modules/ui/components/input"; import { Label } from "@/modules/ui/components/label"; -import { Modal } from "@/modules/ui/components/modal"; import { useTranslate } from "@tolgee/react"; import Image from "next/image"; import { useEffect, useState } from "react"; @@ -202,31 +210,28 @@ export const AddIntegrationModal = ({ }; return ( - -
-
-
-
-
- {t("environments.integrations.google_sheets.google_sheet_logo")} -
-
-
- {t("environments.integrations.google_sheets.link_google_sheet")} -
-
- {t("environments.integrations.google_sheets.google_sheets_integration_description")} -
-
+ + + +
+
+ {t("environments.integrations.google_sheets.google_sheet_logo")} +
+
+ {t("environments.integrations.google_sheets.link_google_sheet")} + + {t("environments.integrations.google_sheets.google_sheets_integration_description")} +
-
-
-
+ + +
@@ -292,39 +297,37 @@ export const AddIntegrationModal = ({
)}
-
-
-
- {selectedIntegration ? ( - - ) : ( - - )} - -
-
+ ) : ( + + )} + + -
- + + ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal.test.tsx index 4aa615f2aa..51e0aa9bb8 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal.test.tsx @@ -74,13 +74,41 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({ vi.mock("@/modules/ui/components/label", () => ({ Label: ({ children }: { children: React.ReactNode }) => , })); -vi.mock("@/modules/ui/components/modal", () => ({ - Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) => - open ?
{children}
: null, +vi.mock("@/modules/ui/components/dialog", () => ({ + Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => ( +
+ {children} +
+ ), + DialogHeader: ({ children, className }: { children: React.ReactNode; className?: string }) => ( +
+ {children} +
+ ), + DialogDescription: ({ children, className }: { children: React.ReactNode; className?: string }) => ( +

+ {children} +

+ ), + DialogTitle: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + DialogBody: ({ children, className }: { children: React.ReactNode; className?: string }) => ( +
+ {children} +
+ ), + DialogFooter: ({ children, className }: { children: React.ReactNode; className?: string }) => ( +
+ {children} +
+ ), })); vi.mock("lucide-react", () => ({ PlusIcon: () => +, - XIcon: () => x, + TrashIcon: () => ๐Ÿ—‘๏ธ, })); vi.mock("next/image", () => ({ // eslint-disable-next-line @next/next/no-img-element @@ -334,7 +362,7 @@ describe("AddIntegrationModal (Notion)", () => { /> ); - expect(screen.getByTestId("modal")).toBeInTheDocument(); + expect(screen.getByTestId("dialog")).toBeInTheDocument(); expect(screen.getByText("environments.integrations.notion.link_database")).toBeInTheDocument(); expect(screen.getByTestId("dropdown-select-a-database")).toBeInTheDocument(); expect(screen.getByTestId("dropdown-select-survey")).toBeInTheDocument(); @@ -359,7 +387,7 @@ describe("AddIntegrationModal (Notion)", () => { /> ); - expect(screen.getByTestId("modal")).toBeInTheDocument(); + expect(screen.getByTestId("dialog")).toBeInTheDocument(); expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(databases[0].id); expect(screen.getByTestId("dropdown-select-survey")).toHaveValue(surveys[0].id); expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument(); @@ -381,7 +409,7 @@ describe("AddIntegrationModal (Notion)", () => { expect(columnDropdowns[1]).toHaveValue("p2"); expect(screen.getAllByTestId("plus-icon").length).toBeGreaterThan(0); - expect(screen.getAllByTestId("x-icon").length).toBeGreaterThan(0); + expect(screen.getAllByTestId("trash-icon").length).toBeGreaterThan(0); }); expect(screen.getByText("Delete")).toBeInTheDocument(); @@ -445,8 +473,8 @@ describe("AddIntegrationModal (Notion)", () => { expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(2); - const xButton = screen.getAllByTestId("x-icon")[0]; // Get the first X button - await userEvent.click(xButton); + const trashButton = screen.getAllByTestId("trash-icon")[0]; // Get the first trash button + await userEvent.click(trashButton); expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1); }); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal.tsx index d810c0d4b4..321b710560 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal.tsx @@ -12,11 +12,19 @@ import { structuredClone } from "@/lib/pollyfills/structuredClone"; import { replaceHeadlineRecall } from "@/lib/utils/recall"; import { getQuestionTypes } from "@/modules/survey/lib/questions"; import { Button } from "@/modules/ui/components/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/modules/ui/components/dialog"; import { DropdownSelector } from "@/modules/ui/components/dropdown-selector"; import { Label } from "@/modules/ui/components/label"; -import { Modal } from "@/modules/ui/components/modal"; import { useTranslate } from "@tolgee/react"; -import { PlusIcon, XIcon } from "lucide-react"; +import { PlusIcon, TrashIcon } from "lucide-react"; import Image from "next/image"; import React, { useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; @@ -336,9 +344,9 @@ export const AddIntegrationModal = ({ col={mapping[idx].column} ques={mapping[idx].question} /> -
+
-
+
-
+
- - +
+ {mapping.length > 1 && ( + + )} + +
); }; return ( - -
-
-
-
-
- {t("environments.integrations.notion.notion_logo")} -
-
-
- {t("environments.integrations.notion.link_notion_database")} -
-
- {t("environments.integrations.notion.sync_responses_with_a_notion_database")} -
-
+ + + +
+
+ {t("environments.integrations.notion.notion_logo")} +
+
+ {t("environments.integrations.notion.link_notion_database")} + + {t("environments.integrations.notion.notion_integration_description")} +
-
-
-
+ + + +
@@ -521,7 +521,7 @@ export const AddIntegrationModal = ({ -
+
{mapping.map((_, idx) => ( ))} @@ -530,43 +530,40 @@ export const AddIntegrationModal = ({ )}
-
-
-
- {selectedIntegration ? ( - - ) : ( - - )} + + + + {selectedIntegration ? ( -
-
+ ) : ( + + )} + + -
- + + ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/AddChannelMappingModal.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/AddChannelMappingModal.test.tsx index 715d8c1c06..a7b1cbfa72 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/AddChannelMappingModal.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/AddChannelMappingModal.test.tsx @@ -83,9 +83,24 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
), })); -vi.mock("@/modules/ui/components/modal", () => ({ - Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) => - open ?
{children}
: null, +vi.mock("@/modules/ui/components/dialog", () => ({ + Dialog: ({ children, open, onOpenChange }: any) => + open ? ( +
+ {children} + +
+ ) : null, + DialogContent: ({ children, ...props }: any) => ( +
+ {children} +
+ ), + DialogHeader: ({ children }: any) =>
{children}
, + DialogTitle: ({ children }: any) =>

{children}

, + DialogDescription: ({ children }: any) =>

{children}

, + DialogBody: ({ children }: any) =>
{children}
, + DialogFooter: ({ children }: any) =>
{children}
, })); vi.mock("next/image", () => ({ // eslint-disable-next-line @next/next/no-img-element @@ -121,6 +136,8 @@ vi.mock("@tolgee/react", async () => { if (key === "common.all_questions") return "All questions"; if (key === "common.selected_questions") return "Selected questions"; if (key === "environments.integrations.slack.link_slack_channel") return "Link Slack Channel"; + if (key === "environments.integrations.slack.slack_integration_description") + return "Send responses directly to Slack."; if (key === "common.update") return "Update"; if (key === "common.delete") return "Delete"; if (key === "common.cancel") return "Cancel"; @@ -312,10 +329,9 @@ describe("AddChannelMappingModal", () => { /> ); - expect(screen.getByTestId("modal")).toBeInTheDocument(); - expect( - screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" }) - ).toBeInTheDocument(); + expect(screen.getByTestId("dialog")).toBeInTheDocument(); + expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Slack Channel"); + expect(screen.getByTestId("dialog-description")).toHaveTextContent("Send responses directly to Slack."); expect(screen.getByTestId("channel-dropdown")).toBeInTheDocument(); expect(screen.getByTestId("survey-dropdown")).toBeInTheDocument(); expect(screen.getByText("Cancel")).toBeInTheDocument(); @@ -339,10 +355,9 @@ describe("AddChannelMappingModal", () => { /> ); - expect(screen.getByTestId("modal")).toBeInTheDocument(); - expect( - screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" }) - ).toBeInTheDocument(); + expect(screen.getByTestId("dialog")).toBeInTheDocument(); + expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Slack Channel"); + expect(screen.getByTestId("dialog-description")).toHaveTextContent("Send responses directly to Slack."); expect(screen.getByTestId("channel-dropdown")).toHaveValue(channels[0].id); expect(screen.getByTestId("survey-dropdown")).toHaveValue(surveys[0].id); expect(screen.getByText("Questions")).toBeInTheDocument(); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/AddChannelMappingModal.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/AddChannelMappingModal.tsx index 257959ed5d..ecc14defbf 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/AddChannelMappingModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/AddChannelMappingModal.tsx @@ -7,9 +7,17 @@ import { replaceHeadlineRecall } from "@/lib/utils/recall"; import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings"; import { Button } from "@/modules/ui/components/button"; import { Checkbox } from "@/modules/ui/components/checkbox"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/modules/ui/components/dialog"; import { DropdownSelector } from "@/modules/ui/components/dropdown-selector"; import { Label } from "@/modules/ui/components/label"; -import { Modal } from "@/modules/ui/components/modal"; import { useTranslate } from "@tolgee/react"; import { CircleHelpIcon } from "lucide-react"; import Image from "next/image"; @@ -189,24 +197,28 @@ export const AddChannelMappingModal = ({ ); return ( - -
-
-
-
-
- Slack logo -
-
-
- {t("environments.integrations.slack.link_slack_channel")} -
-
+ + + +
+
+ {t("environments.integrations.slack.slack_logo")} +
+
+ {t("environments.integrations.slack.link_slack_channel")} + + {t("environments.integrations.slack.slack_integration_description")} +
-
-
-
+ + +
@@ -289,31 +301,29 @@ export const AddChannelMappingModal = ({
)}
-
-
-
- {selectedIntegration ? ( - - ) : ( - - )} - -
-
+ ) : ( + + )} + + -
- + + ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/actions.ts b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/actions.ts index 065a9f9309..cdfd3efeab 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/actions.ts @@ -13,7 +13,7 @@ import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/co import { rateLimit } from "@/lib/utils/rate-limit"; import { updateBrevoCustomer } from "@/modules/auth/lib/brevo"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; -import { sendVerificationNewEmail } from "@/modules/email"; +import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email"; import { z } from "zod"; import { ZId } from "@formbricks/types/common"; import { @@ -162,3 +162,21 @@ export const removeAvatarAction = authenticatedActionClient.schema(ZRemoveAvatar } ) ); + +export const resetPasswordAction = authenticatedActionClient.action( + withAuditLogging( + "passwordReset", + "user", + async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => { + if (ctx.user.identityProvider !== "email") { + throw new OperationNotAllowedError("auth.reset-password.not-allowed"); + } + + await sendForgotPasswordEmail(ctx.user); + + ctx.auditLoggingCtx.userId = ctx.user.id; + + return { success: true }; + } + ) +); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.test.tsx index ea6c290c8b..a9a779e55b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.test.tsx @@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event"; import toast from "react-hot-toast"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { TUser } from "@formbricks/types/user"; -import { updateUserAction } from "../actions"; +import { resetPasswordAction, updateUserAction } from "../actions"; import { EditProfileDetailsForm } from "./EditProfileDetailsForm"; const mockUser = { @@ -24,6 +24,8 @@ const mockUser = { objective: "other", } as unknown as TUser; +vi.mock("next-auth/react", () => ({ signOut: vi.fn() })); + // Mock window.location.reload const originalLocation = window.location; beforeEach(() => { @@ -35,6 +37,11 @@ beforeEach(() => { vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({ updateUserAction: vi.fn(), + resetPasswordAction: vi.fn(), +})); + +vi.mock("@/modules/auth/forgot-password/actions", () => ({ + forgotPasswordAction: vi.fn(), })); afterEach(() => { @@ -50,7 +57,13 @@ describe("EditProfileDetailsForm", () => { test("renders with initial user data and updates successfully", async () => { vi.mocked(updateUserAction).mockResolvedValue({ ...mockUser, name: "New Name" } as any); - render(); + render( + + ); const nameInput = screen.getByPlaceholderText("common.full_name"); expect(nameInput).toHaveValue(mockUser.name); @@ -91,7 +104,13 @@ describe("EditProfileDetailsForm", () => { const errorMessage = "Update failed"; vi.mocked(updateUserAction).mockRejectedValue(new Error(errorMessage)); - render(); + render( + + ); const nameInput = screen.getByPlaceholderText("common.full_name"); await userEvent.clear(nameInput); @@ -109,7 +128,13 @@ describe("EditProfileDetailsForm", () => { }); test("update button is disabled initially and enables on change", async () => { - render(); + render( + + ); const updateButton = screen.getByText("common.update"); expect(updateButton).toBeDisabled(); @@ -117,4 +142,68 @@ describe("EditProfileDetailsForm", () => { await userEvent.type(nameInput, " updated"); expect(updateButton).toBeEnabled(); }); + + test("reset password button works", async () => { + vi.mocked(resetPasswordAction).mockResolvedValue({ data: { success: true } }); + + render( + + ); + + const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" }); + await userEvent.click(resetButton); + + await waitFor(() => { + expect(resetPasswordAction).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith("auth.forgot-password.email-sent.heading"); + }); + }); + + test("reset password button handles error correctly", async () => { + const errorMessage = "Reset failed"; + vi.mocked(resetPasswordAction).mockResolvedValue({ serverError: errorMessage }); + + render( + + ); + + const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" }); + await userEvent.click(resetButton); + + await waitFor(() => { + expect(resetPasswordAction).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith(errorMessage); + }); + }); + + test("reset password button shows loading state", async () => { + vi.mocked(resetPasswordAction).mockImplementation(() => new Promise(() => {})); // Never resolves + + render( + + ); + + const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" }); + await userEvent.click(resetButton); + + expect(resetButton).toBeDisabled(); + }); }); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.tsx index 2b794c20cf..8c85a2d780 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.tsx @@ -14,6 +14,7 @@ import { } from "@/modules/ui/components/dropdown-menu"; import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form"; import { Input } from "@/modules/ui/components/input"; +import { Label } from "@/modules/ui/components/label"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslate } from "@tolgee/react"; import { ChevronDownIcon } from "lucide-react"; @@ -22,7 +23,7 @@ import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; import toast from "react-hot-toast"; import { z } from "zod"; import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user"; -import { updateUserAction } from "../actions"; +import { resetPasswordAction, updateUserAction } from "../actions"; // Schema & types const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true }).extend({ @@ -30,13 +31,17 @@ const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: }); type TEditProfileNameForm = z.infer; +interface IEditProfileDetailsFormProps { + user: TUser; + isPasswordResetEnabled?: boolean; + emailVerificationDisabled: boolean; +} + export const EditProfileDetailsForm = ({ user, + isPasswordResetEnabled, emailVerificationDisabled, -}: { - user: TUser; - emailVerificationDisabled: boolean; -}) => { +}: IEditProfileDetailsFormProps) => { const { t } = useTranslate(); const form = useForm({ @@ -50,6 +55,8 @@ export const EditProfileDetailsForm = ({ }); const { isSubmitting, isDirty } = form.formState; + + const [isResettingPassword, setIsResettingPassword] = useState(false); const [showModal, setShowModal] = useState(false); const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email }); @@ -90,6 +97,7 @@ export const EditProfileDetailsForm = ({ redirectUrl: "/email-change-without-verification-success", redirect: true, callbackUrl: "/email-change-without-verification-success", + clearEnvironmentId: true, }); return; } @@ -121,6 +129,28 @@ export const EditProfileDetailsForm = ({ } }; + const handleResetPassword = async () => { + setIsResettingPassword(true); + + const result = await resetPasswordAction(); + if (result?.data) { + toast.success(t("auth.forgot-password.email-sent.heading")); + + await signOutWithAudit({ + reason: "password_reset", + redirectUrl: "/auth/login", + redirect: true, + callbackUrl: "/auth/login", + clearEnvironmentId: true, + }); + } else { + const errorMessage = getFormattedErrorMessage(result); + toast.error(t(errorMessage)); + } + + setIsResettingPassword(false); + }; + return ( <> @@ -205,6 +235,26 @@ export const EditProfileDetailsForm = ({ )} /> + {isPasswordResetEnabled && ( +
+ +

+ {t("auth.forgot-password.reset_password_description")} +

+
+ + +
+
+ )} +
) : null, + DialogContent: ({ children, ...props }: any) => ( +
+ {children} +
+ ), + DialogHeader: ({ children }: any) =>
{children}
, + DialogTitle: ({ children }: any) =>

{children}

, + DialogDescription: ({ children }: any) =>

{children}

, + DialogBody: ({ children }: any) =>
{children}
, + DialogFooter: ({ children }: any) =>
{children}
, })); // Mock the PasswordInput component @@ -54,13 +63,13 @@ describe("PasswordConfirmationModal", () => { test("renders nothing when open is false", () => { render(); - expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); + expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); }); - test("renders modal content when open is true", () => { + test("renders dialog content when open is true", () => { render(); - expect(screen.getByTestId("modal")).toBeInTheDocument(); - expect(screen.getByTestId("modal-title")).toBeInTheDocument(); + expect(screen.getByTestId("dialog")).toBeInTheDocument(); + expect(screen.getByTestId("dialog-title")).toBeInTheDocument(); }); test("displays old and new email addresses", () => { diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal.tsx index ce8db7449f..0cd3edca87 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal.tsx @@ -1,8 +1,16 @@ "use client"; import { Button } from "@/modules/ui/components/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/modules/ui/components/dialog"; import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form"; -import { Modal } from "@/modules/ui/components/modal"; import { PasswordInput } from "@/modules/ui/components/password-input"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslate } from "@tolgee/react"; @@ -54,64 +62,69 @@ export const PasswordConfirmationModal = ({ }; return ( - - -
-

- {t("auth.email-change.confirm_password_description")} -

+ + + + {t("auth.forgot-password.reset.confirm_password")} + {t("auth.email-change.confirm_password_description")} + + + + +
+
+

+ {t("auth.email-change.old_email")}: +
{oldEmail.toLowerCase()} +

+

+ {t("auth.email-change.new_email")}: +
{newEmail.toLowerCase()} +

+
-
-

- {t("auth.email-change.old_email")}: -
{oldEmail.toLowerCase()} -

-

- {t("auth.email-change.new_email")}: -
{newEmail.toLowerCase()} -

-
- - ( - - -
- field.onChange(password)} - /> - {error?.message && {error.message}} -
-
-
- )} - /> - -
- - -
- - - + ( + + +
+ field.onChange(password)} + /> + {error?.message && {error.message}} +
+
+
+ )} + /> +
+
+ + + + + +
+
+
); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.test.tsx index 5c44ba733f..feed9087f1 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.test.tsx @@ -12,7 +12,8 @@ import Page from "./page"; // Mock services and utils vi.mock("@/lib/constants", () => ({ - IS_FORMBRICKS_CLOUD: true, + IS_FORMBRICKS_CLOUD: 1, + PASSWORD_RESET_DISABLED: 1, EMAIL_VERIFICATION_DISABLED: true, })); vi.mock("@/lib/organization/service", () => ({ diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.tsx index ba3d4107d2..fdecacadce 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.tsx @@ -1,6 +1,6 @@ import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar"; import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity"; -import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD } from "@/lib/constants"; +import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants"; import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service"; import { getUser } from "@/lib/user/service"; import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils"; @@ -32,6 +32,8 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => { throw new Error(t("common.user_not_found")); } + const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email"; + return ( @@ -42,7 +44,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => { - + ({ )), })); -vi.mock("@/modules/ui/components/modal", () => ({ - Modal: vi.fn(({ children, open }) => (open ?
{children}
: null)), +vi.mock("@/modules/ui/components/dialog", () => ({ + Dialog: vi.fn(({ children, open, onOpenChange }) => + open ? ( +
+ {children} + +
+ ) : null + ), + DialogContent: vi.fn(({ children, hideCloseButton, width, className }) => ( +
+ {children} +
+ )), + DialogBody: vi.fn(({ children }) =>
{children}
), + DialogFooter: vi.fn(({ children }) =>
{children}
), })); const mockResponses = [ @@ -163,12 +181,12 @@ describe("ResponseCardModal", () => { test("should not render if selectedResponseId is null", () => { const { container } = render(); expect(container.firstChild).toBeNull(); - expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); + expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); }); - test("should render the modal when a response is selected", () => { + test("should render the dialog when a response is selected", () => { render(); - expect(screen.getByTestId("modal")).toBeInTheDocument(); + expect(screen.getByTestId("dialog")).toBeInTheDocument(); expect(screen.getByTestId("single-response-card")).toBeInTheDocument(); }); @@ -204,14 +222,6 @@ describe("ResponseCardModal", () => { expect(nextButton).toBeDisabled(); }); - test("should call setSelectedResponseId with null when close button is clicked", async () => { - render(); - const buttons = screen.getAllByTestId("mock-button"); - const closeButton = buttons.find((button) => button.querySelector("svg.lucide-x")); - if (closeButton) await userEvent.click(closeButton); - expect(mockSetSelectedResponseId).toHaveBeenCalledWith(null); - }); - test("useEffect should set open to true and currentIndex when selectedResponseId is provided", () => { render(); expect(mockSetOpen).toHaveBeenCalledWith(true); @@ -229,11 +239,10 @@ describe("ResponseCardModal", () => { expect(mockSetOpen).toHaveBeenCalledWith(false); }); - test("should render ChevronLeft, ChevronRight, and XIcon", () => { + test("should render ChevronLeft and ChevronRight icons", () => { render(); expect(document.querySelector(".lucide-chevron-left")).toBeInTheDocument(); expect(document.querySelector(".lucide-chevron-right")).toBeInTheDocument(); - expect(document.querySelector(".lucide-x")).toBeInTheDocument(); }); }); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.tsx index 1ac85cdc85..0ec8b0a447 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.tsx @@ -1,7 +1,7 @@ import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard"; import { Button } from "@/modules/ui/components/button"; -import { Modal } from "@/modules/ui/components/modal"; -import { ChevronLeft, ChevronRight, XIcon } from "lucide-react"; +import { Dialog, DialogBody, DialogContent, DialogFooter } from "@/modules/ui/components/dialog"; +import { ChevronLeft, ChevronRight } from "lucide-react"; import { useEffect, useState } from "react"; import { TEnvironment } from "@formbricks/types/environment"; import { TResponse } from "@formbricks/types/responses"; @@ -64,42 +64,20 @@ export const ResponseCardModal = ({ } }; - const handleClose = () => { - setSelectedResponseId(null); + const handleClose = (open: boolean) => { + setOpen(open); + if (!open) { + setSelectedResponseId(null); + } }; // If no response is selected or currentIndex is null, do not render the modal if (selectedResponseId === null || currentIndex === null) return null; return ( - -
-
-
- - - -
+ + + -
-
-
+ + + + + + + ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx index b805f7c559..28ef8a3452 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx @@ -1,13 +1,15 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation"; import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage"; import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA"; -import { RESPONSES_PER_PAGE } from "@/lib/constants"; +import { IS_FORMBRICKS_CLOUD, RESPONSES_PER_PAGE } from "@/lib/constants"; import { getPublicDomain } from "@/lib/getPublicUrl"; import { getResponseCountBySurveyId } from "@/lib/response/service"; import { getSurvey } from "@/lib/survey/service"; import { getTagsByEnvironmentId } from "@/lib/tag/service"; import { getUser } from "@/lib/user/service"; import { findMatchingLocale } from "@/lib/utils/locale"; +import { getSegments } from "@/modules/ee/contacts/segments/lib/segments"; +import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageHeader } from "@/modules/ui/components/page-header"; @@ -33,6 +35,9 @@ const Page = async (props) => { const tags = await getTagsByEnvironmentId(params.environmentId); + const isContactsEnabled = await getIsContactsEnabled(); + const segments = isContactsEnabled ? await getSegments(params.environmentId) : []; + // Get response count for the CTA component const responseCount = await getResponseCountBySurveyId(params.surveyId); @@ -51,6 +56,9 @@ const Page = async (props) => { user={user} publicDomain={publicDomain} responseCount={responseCount} + segments={segments} + isContactsEnabled={isContactsEnabled} + isFormbricksCloud={IS_FORMBRICKS_CLOUD} /> }> diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts index 90971ece05..0204af4aaa 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts @@ -1,18 +1,23 @@ "use server"; import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate"; +import { WEBAPP_URL } from "@/lib/constants"; +import { putFile } from "@/lib/storage/service"; import { getSurvey, updateSurvey } from "@/lib/survey/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; +import { convertToCsv } from "@/lib/utils/file-conversion"; import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; +import { generatePersonalLinks } from "@/modules/ee/contacts/lib/contacts"; +import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization"; import { sendEmbedSurveyPreviewEmail } from "@/modules/email"; import { customAlphabet } from "nanoid"; import { z } from "zod"; import { ZId } from "@formbricks/types/common"; -import { ResourceNotFoundError } from "@formbricks/types/errors"; +import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors"; const ZSendEmbedSurveyPreviewEmailAction = z.object({ surveyId: ZId, @@ -222,3 +227,89 @@ export const getEmailHtmlAction = authenticatedActionClient return await getEmailTemplateHtml(parsedInput.surveyId, ctx.user.locale); }); + +const ZGeneratePersonalLinksAction = z.object({ + surveyId: ZId, + segmentId: ZId, + environmentId: ZId, + expirationDays: z.number().optional(), +}); + +export const generatePersonalLinksAction = authenticatedActionClient + .schema(ZGeneratePersonalLinksAction) + .action(async ({ ctx, parsedInput }) => { + const isContactsEnabled = await getIsContactsEnabled(); + if (!isContactsEnabled) { + throw new OperationNotAllowedError("Contacts are not enabled for this environment"); + } + + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "projectTeam", + projectId: await getProjectIdFromSurveyId(parsedInput.surveyId), + minPermission: "readWrite", + }, + ], + }); + + // Get contacts and generate personal links + const contactsResult = await generatePersonalLinks( + parsedInput.surveyId, + parsedInput.segmentId, + parsedInput.expirationDays + ); + + if (!contactsResult || contactsResult.length === 0) { + throw new UnknownError("No contacts found for the selected segment"); + } + + // Prepare CSV data with the specified headers and order + const csvHeaders = [ + "Formbricks Contact ID", + "User ID", + "First Name", + "Last Name", + "Email", + "Personal Link", + ]; + + const csvData = contactsResult + .map((contact) => { + if (!contact) { + return null; + } + const attributes = contact.attributes ?? {}; + return { + "Formbricks Contact ID": contact.contactId, + "User ID": attributes.userId ?? "", + "First Name": attributes.firstName ?? "", + "Last Name": attributes.lastName ?? "", + Email: attributes.email ?? "", + "Personal Link": contact.surveyUrl, + }; + }) + .filter((contact) => contact !== null); + + // Convert to CSV using the file conversion utility + const csvContent = await convertToCsv(csvHeaders, csvData); + const fileName = `personal-links-${parsedInput.surveyId}-${Date.now()}.csv`; + + // Store file temporarily and return download URL + const fileBuffer = Buffer.from(csvContent); + await putFile(fileName, fileBuffer, "private", parsedInput.environmentId); + + const downloadUrl = `${WEBAPP_URL}/storage/${parsedInput.environmentId}/private/${fileName}`; + + return { + downloadUrl, + fileName, + count: csvData.length, + }; + }); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.test.tsx index bc0abd95c0..fd02efa286 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.test.tsx @@ -117,9 +117,9 @@ vi.mock("./shareEmbedModal/EmbedView", () => ({ EmbedView: (props: any) => mockEmbedViewComponent(props), })); -const mockPanelInfoViewComponent = vi.fn(); -vi.mock("./shareEmbedModal/PanelInfoView", () => ({ - PanelInfoView: (props: any) => mockPanelInfoViewComponent(props), +// Mock getSurveyUrl to return a predictable URL +vi.mock("@/modules/analysis/utils", () => ({ + getSurveyUrl: vi.fn().mockResolvedValue("https://public-domain.com/s/survey1"), })); let capturedDialogOnOpenChange: ((open: boolean) => void) | undefined; @@ -133,8 +133,6 @@ vi.mock("@/modules/ui/components/dialog", async () => { capturedDialogOnOpenChange = props.onOpenChange; return ; }, - // DialogTitle, DialogContent, DialogDescription will be the actual components - // due to ...actual spread and no specific mock for them here. }; }); @@ -154,13 +152,15 @@ describe("ShareEmbedSurvey", () => { modalView: "start" as "start" | "embed" | "panel", setOpen: mockSetOpen, user: mockUser, + segments: [], + isContactsEnabled: true, + isFormbricksCloud: true, }; beforeEach(() => { mockEmbedViewComponent.mockImplementation( - ({ handleInitialPageButton, tabs, activeId, survey, email, surveyUrl, publicDomain, locale }) => ( + ({ tabs, activeId, survey, email, surveyUrl, publicDomain, locale }) => (
-
{JSON.stringify(tabs)}
{activeId}
{survey.id}
@@ -171,9 +171,6 @@ describe("ShareEmbedSurvey", () => {
) ); - mockPanelInfoViewComponent.mockImplementation(({ handleInitialPageButton }) => ( - - )); }); test("renders initial 'start' view correctly when open and modalView is 'start' for link survey", () => { @@ -205,43 +202,15 @@ describe("ShareEmbedSurvey", () => { const embedButton = screen.getByText("environments.surveys.summary.embed_survey"); await userEvent.click(embedButton); expect(mockEmbedViewComponent).toHaveBeenCalled(); - expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument(); + expect(screen.getByTestId("embedview-tabs")).toBeInTheDocument(); }); test("switches to 'panel' view when 'Send to panel' button is clicked", async () => { render(); const panelButton = screen.getByText("environments.surveys.summary.send_to_panel"); await userEvent.click(panelButton); - expect(mockPanelInfoViewComponent).toHaveBeenCalled(); - expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument(); - }); - - test("returns to 'start' view when handleInitialPageButton is triggered from EmbedView", async () => { - render(); - expect(mockEmbedViewComponent).toHaveBeenCalled(); - expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument(); - - const embedViewButton = screen.getByText("EmbedViewMockContent"); - await userEvent.click(embedViewButton); - - // Should go back to start view, not close the modal - expect(screen.getByText("environments.surveys.summary.your_survey_is_public ๐ŸŽ‰")).toBeInTheDocument(); - expect(screen.queryByText("EmbedViewMockContent")).not.toBeInTheDocument(); - expect(mockSetOpen).not.toHaveBeenCalled(); - }); - - test("returns to 'start' view when handleInitialPageButton is triggered from PanelInfoView", async () => { - render(); - expect(mockPanelInfoViewComponent).toHaveBeenCalled(); - expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument(); - - const panelInfoViewButton = screen.getByText("PanelInfoViewMockContent"); - await userEvent.click(panelInfoViewButton); - - // Should go back to start view, not close the modal - expect(screen.getByText("environments.surveys.summary.your_survey_is_public ๐ŸŽ‰")).toBeInTheDocument(); - expect(screen.queryByText("PanelInfoViewMockContent")).not.toBeInTheDocument(); - expect(mockSetOpen).not.toHaveBeenCalled(); + // Panel view currently just shows a title, no component is rendered + expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument(); }); test("handleOpenChange (when Dialog calls its onOpenChange prop)", () => { @@ -267,7 +236,7 @@ describe("ShareEmbedSurvey", () => { tabs: { id: string; label: string; icon: LucideIcon }[]; activeId: string; }; - expect(embedViewProps.tabs.length).toBe(3); + expect(embedViewProps.tabs.length).toBe(4); expect(embedViewProps.tabs.find((tab) => tab.id === "app")).toBeUndefined(); expect(embedViewProps.tabs[0].id).toBe("link"); expect(embedViewProps.activeId).toBe("link"); @@ -297,24 +266,21 @@ describe("ShareEmbedSurvey", () => { test("initial showView is set by modalView prop when open is true", () => { render(); expect(mockEmbedViewComponent).toHaveBeenCalled(); - expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument(); + expect(screen.getByTestId("embedview-tabs")).toBeInTheDocument(); cleanup(); render(); - expect(mockPanelInfoViewComponent).toHaveBeenCalled(); - expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument(); + // Panel view currently just shows a title + expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument(); }); test("useEffect sets showView to 'start' when open becomes false", () => { const { rerender } = render(); - expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument(); // Starts in embed + expect(screen.getByTestId("embedview-tabs")).toBeInTheDocument(); // Starts in embed rerender(); // Dialog mock returns null when open is false, so EmbedViewMockContent is not found - expect(screen.queryByText("EmbedViewMockContent")).not.toBeInTheDocument(); - // To verify showView is 'start', we'd need to inspect internal state or render start view elements - // For now, we trust the useEffect sets showView, and if it were to re-open in 'start' mode, it would show. - // The main check is that the previous view ('embed') is gone. + expect(screen.queryByTestId("embedview-tabs")).not.toBeInTheDocument(); }); test("renders correct label for link tab based on singleUse survey property", () => { diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx index 81b46af20e..ac9006e1c1 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx @@ -12,15 +12,16 @@ import { LinkIcon, MailIcon, SmartphoneIcon, + UserIcon, UsersRound, } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; +import { TSegment } from "@formbricks/types/segment"; import { TSurvey } from "@formbricks/types/surveys/types"; import { TUser } from "@formbricks/types/user"; import { EmbedView } from "./shareEmbedModal/EmbedView"; -import { PanelInfoView } from "./shareEmbedModal/PanelInfoView"; interface ShareEmbedSurveyProps { survey: TSurvey; @@ -29,6 +30,9 @@ interface ShareEmbedSurveyProps { modalView: "start" | "embed" | "panel"; setOpen: React.Dispatch>; user: TUser; + segments: TSegment[]; + isContactsEnabled: boolean; + isFormbricksCloud: boolean; } export const ShareEmbedSurvey = ({ @@ -38,6 +42,9 @@ export const ShareEmbedSurvey = ({ modalView, setOpen, user, + segments, + isContactsEnabled, + isFormbricksCloud, }: ShareEmbedSurveyProps) => { const router = useRouter(); const environmentId = survey.environmentId; @@ -52,6 +59,7 @@ export const ShareEmbedSurvey = ({ label: `${isSingleUseLinkSurvey ? t("environments.surveys.summary.single_use_links") : t("environments.surveys.summary.share_the_link")}`, icon: LinkIcon, }, + { id: "personal-links", label: t("environments.surveys.summary.personal_links"), icon: UserIcon }, { id: "email", label: t("environments.surveys.summary.embed_in_an_email"), icon: MailIcon }, { id: "webpage", label: t("environments.surveys.summary.embed_on_website"), icon: Code2Icon }, @@ -60,8 +68,8 @@ export const ShareEmbedSurvey = ({ [t, isSingleUseLinkSurvey, survey.type] ); - const [activeId, setActiveId] = useState(survey.type === "link" ? tabs[0].id : tabs[3].id); - const [showView, setShowView] = useState<"start" | "embed" | "panel">("start"); + const [activeId, setActiveId] = useState(survey.type === "link" ? tabs[0].id : tabs[4].id); + const [showView, setShowView] = useState<"start" | "embed" | "panel" | "personal-links">("start"); const [surveyUrl, setSurveyUrl] = useState(""); useEffect(() => { @@ -80,7 +88,7 @@ export const ShareEmbedSurvey = ({ useEffect(() => { if (survey.type !== "link") { - setActiveId(tabs[3].id); + setActiveId(tabs[4].id); } }, [survey.type, tabs]); @@ -93,7 +101,7 @@ export const ShareEmbedSurvey = ({ }, [open, modalView]); const handleOpenChange = (open: boolean) => { - setActiveId(survey.type === "link" ? tabs[0].id : tabs[3].id); + setActiveId(survey.type === "link" ? tabs[0].id : tabs[4].id); setOpen(open); if (!open) { setShowView("start"); @@ -101,10 +109,6 @@ export const ShareEmbedSurvey = ({ router.refresh(); }; - const handleInitialPageButton = () => { - setShowView("start"); - }; - return ( @@ -166,22 +170,28 @@ export const ShareEmbedSurvey = ({
) : showView === "embed" ? ( - + <> + {t("environments.surveys.summary.embed_survey")} + + ) : showView === "panel" ? ( - + <> + {t("environments.surveys.summary.send_to_panel")} + ) : null} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareSurveyResults.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareSurveyResults.test.tsx index 28e3f1d74c..1d7da2e604 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareSurveyResults.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareSurveyResults.test.tsx @@ -20,9 +20,22 @@ vi.mock("@/modules/ui/components/button", () => ({ }), })); -// Mock Modal -vi.mock("@/modules/ui/components/modal", () => ({ - Modal: vi.fn(({ children, open }) => (open ?
{children}
: null)), +// Mock Dialog +vi.mock("@/modules/ui/components/dialog", () => ({ + Dialog: vi.fn(({ children, open, onOpenChange }) => + open ? ( +
+ {children} + +
+ ) : null + ), + DialogContent: vi.fn(({ children, ...props }) => ( +
+ {children} +
+ )), + DialogBody: vi.fn(({ children }) =>
{children}
), })); // Mock useTranslate @@ -120,7 +133,7 @@ describe("ShareSurveyResults", () => { test("does not render content when modal is closed (open is false)", () => { render(); - expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); + expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); expect(screen.queryByText("environments.surveys.summary.publish_to_web_warning")).not.toBeInTheDocument(); expect( screen.queryByText("environments.surveys.summary.survey_results_are_public") diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareSurveyResults.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareSurveyResults.tsx index bcba47a083..19fe748857 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareSurveyResults.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareSurveyResults.tsx @@ -1,7 +1,7 @@ "use client"; import { Button } from "@/modules/ui/components/button"; -import { Modal } from "@/modules/ui/components/modal"; +import { Dialog, DialogBody, DialogContent } from "@/modules/ui/components/dialog"; import { useTranslate } from "@tolgee/react"; import { AlertCircleIcon, CheckCircle2Icon } from "lucide-react"; import { Clipboard } from "lucide-react"; @@ -26,70 +26,72 @@ export const ShareSurveyResults = ({ }: ShareEmbedSurveyProps) => { const { t } = useTranslate(); return ( - - {showPublishModal && surveyUrl ? ( -
-
- -
-

- {t("environments.surveys.summary.survey_results_are_public")} -

-

- {t("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")} -

-
-
-
- {surveyUrl} + + + + {showPublishModal && surveyUrl ? ( +
+ +
+

+ {t("environments.surveys.summary.survey_results_are_public")} +

+

+ {t("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")} +

+
+
+
+ {surveyUrl} +
+ +
+
+ +
-
-
- - + ) : ( +
+
+ +
+

+ {t("environments.surveys.summary.publish_to_web_warning")} +

+

+ {t("environments.surveys.summary.publish_to_web_warning_description")} +

+
+ +
-
-
- ) : ( -
-
- -
-

- {t("environments.surveys.summary.publish_to_web_warning")} -

-

- {t("environments.surveys.summary.publish_to_web_warning_description")} -

-
- -
-
- )} - + )} + + + ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx index db1f3b4f83..3de84da281 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx @@ -15,6 +15,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; import toast from "react-hot-toast"; import { TEnvironment } from "@formbricks/types/environment"; +import { TSegment } from "@formbricks/types/segment"; import { TSurvey } from "@formbricks/types/surveys/types"; import { TUser } from "@formbricks/types/user"; @@ -25,6 +26,9 @@ interface SurveyAnalysisCTAProps { user: TUser; publicDomain: string; responseCount: number; + segments: TSegment[]; + isContactsEnabled: boolean; + isFormbricksCloud: boolean; } interface ModalState { @@ -41,6 +45,9 @@ export const SurveyAnalysisCTA = ({ user, publicDomain, responseCount, + segments, + isContactsEnabled, + isFormbricksCloud, }: SurveyAnalysisCTAProps) => { const { t } = useTranslate(); const searchParams = useSearchParams(); @@ -175,6 +182,9 @@ export const SurveyAnalysisCTA = ({ setOpen={setOpen} user={user} modalView={modalView} + segments={segments} + isContactsEnabled={isContactsEnabled} + isFormbricksCloud={isFormbricksCloud} /> ))} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmbedView.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmbedView.test.tsx index bbe7226893..1bf3cce6aa 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmbedView.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmbedView.test.tsx @@ -29,6 +29,22 @@ vi.mock("./WebsiteTab", () => ({ ), })); +vi.mock("./personal-links-tab", () => ({ + PersonalLinksTab: (props: { segments: any[]; surveyId: string; environmentId: string }) => ( +
+ PersonalLinksTab Content for {props.surveyId} in {props.environmentId} +
+ ), +})); + +vi.mock("@/modules/ui/components/upgrade-prompt", () => ({ + UpgradePrompt: (props: { title: string; description: string; buttons: any[] }) => ( +
+ {props.title} - {props.description} +
+ ), +})); + // Mock @tolgee/react vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ @@ -43,6 +59,21 @@ vi.mock("lucide-react", () => ({ LinkIcon: () =>
LinkIcon
, GlobeIcon: () =>
GlobeIcon
, SmartphoneIcon: () =>
SmartphoneIcon
, + AlertCircle: ({ className }: { className?: string }) => ( +
+ AlertCircle +
+ ), + AlertTriangle: ({ className }: { className?: string }) => ( +
+ AlertTriangle +
+ ), + Info: ({ className }: { className?: string }) => ( +
+ Info +
+ ), })); const mockTabs = [ @@ -56,7 +87,6 @@ const mockSurveyLink = { id: "survey1", type: "link" }; const mockSurveyWeb = { id: "survey2", type: "web" }; const defaultProps = { - handleInitialPageButton: vi.fn(), tabs: mockTabs, activeId: "email", setActiveId: vi.fn(), @@ -67,7 +97,9 @@ const defaultProps = { publicDomain: "http://example.com", setSurveyUrl: vi.fn(), locale: "en" as any, - disableBack: false, + segments: [], + isContactsEnabled: true, + isFormbricksCloud: false, }; describe("EmbedView", () => { @@ -76,11 +108,6 @@ describe("EmbedView", () => { vi.clearAllMocks(); }); - test("does not render back button when disableBack is true", () => { - render(); - expect(screen.queryByRole("button", { name: "common.back" })).not.toBeInTheDocument(); - }); - test("does not render desktop tabs for non-link survey type", () => { render(); // Desktop tabs container should not be present or not have lg:flex if it's a common parent diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmbedView.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmbedView.tsx index 5ab82f8e51..e93a711fa5 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmbedView.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmbedView.tsx @@ -2,33 +2,32 @@ import { cn } from "@/lib/cn"; import { Button } from "@/modules/ui/components/button"; -import { useTranslate } from "@tolgee/react"; -import { ArrowLeftIcon } from "lucide-react"; +import { TSegment } from "@formbricks/types/segment"; import { TUserLocale } from "@formbricks/types/user"; import { AppTab } from "./AppTab"; import { EmailTab } from "./EmailTab"; import { LinkTab } from "./LinkTab"; import { WebsiteTab } from "./WebsiteTab"; +import { PersonalLinksTab } from "./personal-links-tab"; interface EmbedViewProps { - handleInitialPageButton: () => void; tabs: Array<{ id: string; label: string; icon: any }>; activeId: string; setActiveId: React.Dispatch>; environmentId: string; - disableBack: boolean; survey: any; email: string; surveyUrl: string; publicDomain: string; setSurveyUrl: React.Dispatch>; locale: TUserLocale; + segments: TSegment[]; + isContactsEnabled: boolean; + isFormbricksCloud: boolean; } export const EmbedView = ({ - handleInitialPageButton, tabs, - disableBack, activeId, setActiveId, environmentId, @@ -38,18 +37,45 @@ export const EmbedView = ({ publicDomain, setSurveyUrl, locale, + segments, + isContactsEnabled, + isFormbricksCloud, }: EmbedViewProps) => { - const { t } = useTranslate(); + const renderActiveTab = () => { + switch (activeId) { + case "email": + return ; + case "webpage": + return ; + case "link": + return ( + + ); + case "app": + return ; + case "personal-links": + return ( + + ); + default: + return null; + } + }; + return (
- {!disableBack && ( -
- -
- )}
{survey.type === "link" && (