From 23c2d3dce9f7a705c67c3bd465ed15e2e6fc4908 Mon Sep 17 00:00:00 2001 From: Victor Hugo dos Santos <115753265+victorvhs017@users.noreply.github.com> Date: Thu, 31 Jul 2025 12:48:12 +0700 Subject: [PATCH] feat: Add Regex No Code Action Page Filter (#6305) Co-authored-by: Dhruwang --- .github/workflows/docker-build-validation.yml | 101 ++--- .../actions/components/ActionClassesTable.tsx | 13 +- .../actions/components/ActionDetailModal.tsx | 18 +- .../components/ActionSettingsTab.test.tsx | 196 +++++++-- .../actions/components/ActionSettingsTab.tsx | 165 ++----- apps/web/lib/utils/url.test.ts | 64 ++- apps/web/lib/utils/url.ts | 29 +- apps/web/locales/de-DE.json | 13 +- apps/web/locales/en-US.json | 13 +- apps/web/locales/fr-FR.json | 11 +- apps/web/locales/pt-BR.json | 11 +- apps/web/locales/pt-PT.json | 11 +- apps/web/locales/zh-Hant-TW.json | 11 +- .../components/create-new-action-tab.test.tsx | 316 ++++++++++++-- .../components/create-new-action-tab.tsx | 198 ++------- .../components/saved-actions-tab.test.tsx | 234 ++++++++++ .../editor/components/saved-actions-tab.tsx | 3 +- .../components/when-to-send-card.test.tsx | 154 +++++++ .../editor/components/when-to-send-card.tsx | 44 +- .../survey/editor/lib/action-builder.test.ts | 236 ++++++++++ .../survey/editor/lib/action-builder.ts | 52 +++ .../survey/editor/lib/action-utils.test.ts | 406 ++++++++++++++++++ .../modules/survey/editor/lib/action-utils.ts | 129 ++++++ .../action-class-info/index.test.tsx | 368 ++++++++++++++++ .../ui/components/action-class-info/index.tsx | 66 +++ .../index.test.tsx | 211 +++++++++ .../action-name-description-fields/index.tsx | 78 ++++ .../ui/components/button/index.test.tsx | 5 +- .../modules/ui/components/button/index.tsx | 1 + .../modules/ui/components/button/stories.tsx | 16 +- .../code-action-form/index.test.tsx | 1 + .../ui/components/code-action-form/index.tsx | 9 +- .../components/page-url-selector.test.tsx | 346 +++++++++++++-- .../components/page-url-selector.tsx | 153 ++++--- .../components/no-code-action-form/index.tsx | 4 +- apps/web/scripts/docker/next-start.sh | 60 ++- .../surveys/website-app-surveys/actions.mdx | 2 + packages/js-core/src/lib/common/utils.ts | 11 + packages/js-core/src/types/survey.ts | 3 +- packages/types/action-classes.ts | 21 +- 40 files changed, 3198 insertions(+), 585 deletions(-) create mode 100644 apps/web/modules/survey/editor/lib/action-builder.test.ts create mode 100644 apps/web/modules/survey/editor/lib/action-builder.ts create mode 100644 apps/web/modules/survey/editor/lib/action-utils.test.ts create mode 100644 apps/web/modules/survey/editor/lib/action-utils.ts create mode 100644 apps/web/modules/ui/components/action-class-info/index.test.tsx create mode 100644 apps/web/modules/ui/components/action-class-info/index.tsx create mode 100644 apps/web/modules/ui/components/action-name-description-fields/index.test.tsx create mode 100644 apps/web/modules/ui/components/action-name-description-fields/index.tsx mode change 100644 => 100755 apps/web/scripts/docker/next-start.sh diff --git a/.github/workflows/docker-build-validation.yml b/.github/workflows/docker-build-validation.yml index a420739fb1..ecde9c4f09 100644 --- a/.github/workflows/docker-build-validation.yml +++ b/.github/workflows/docker-build-validation.yml @@ -59,18 +59,32 @@ jobs: database_url=${{ secrets.DUMMY_DATABASE_URL }} encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }} - - name: Verify PostgreSQL Connection + - name: Verify and Initialize PostgreSQL run: | echo "Verifying PostgreSQL connection..." # Install PostgreSQL client to test connection sudo apt-get update && sudo apt-get install -y postgresql-client - # Test connection using psql - PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL" + # Test connection using psql with timeout and proper error handling + echo "Testing PostgreSQL connection with 30 second timeout..." + if timeout 30 bash -c 'until PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" >/dev/null 2>&1; do + echo "Waiting for PostgreSQL to be ready..." + sleep 2 + done'; then + echo "✅ PostgreSQL connection successful" + PGPASSWORD=test psql -h localhost -U test -d formbricks -c "SELECT version();" + + # Enable necessary extensions that might be required by migrations + echo "Enabling required PostgreSQL extensions..." + PGPASSWORD=test psql -h localhost -U test -d formbricks -c "CREATE EXTENSION IF NOT EXISTS vector;" || echo "Vector extension already exists or not available" + + else + echo "❌ PostgreSQL connection failed after 30 seconds" + exit 1 + fi # Show network configuration echo "Network configuration:" - ip addr show netstat -tulpn | grep 5432 || echo "No process listening on port 5432" - name: Test Docker Image with Health Check @@ -89,26 +103,9 @@ jobs: -e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \ -d formbricks-test:${{ github.sha }} - # Give it more time to start up - echo "Waiting 45 seconds for application to start..." - sleep 45 - - # Check if the container is running - if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test)" != "true" ]; then - echo "❌ Container failed to start properly!" - docker logs formbricks-test - exit 1 - else - echo "✅ Container started successfully!" - fi - - # Try connecting to PostgreSQL from inside the container - echo "Testing PostgreSQL connection from inside container..." - docker exec formbricks-test sh -c 'apt-get update && apt-get install -y postgresql-client && PGPASSWORD=test psql -h host.docker.internal -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL from container"' - - # Try to access the health endpoint - echo "🏥 Testing /health endpoint..." - MAX_RETRIES=10 + # Start health check polling immediately (every 5 seconds for up to 5 minutes) + echo "🏥 Polling /health endpoint every 5 seconds for up to 5 minutes..." + MAX_RETRIES=60 # 60 attempts × 5 seconds = 5 minutes RETRY_COUNT=0 HEALTH_CHECK_SUCCESS=false @@ -116,38 +113,32 @@ jobs: while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do RETRY_COUNT=$((RETRY_COUNT + 1)) - echo "Attempt $RETRY_COUNT of $MAX_RETRIES..." - - # Show container logs before each attempt to help debugging - if [ $RETRY_COUNT -gt 1 ]; then - echo "📋 Current container logs:" - docker logs --tail 20 formbricks-test + + # Check if container is still running + if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test 2>/dev/null)" != "true" ]; then + echo "❌ Container stopped running after $((RETRY_COUNT * 5)) seconds!" + echo "📋 Container logs:" + docker logs formbricks-test + exit 1 fi - - # Get detailed curl output for debugging - HTTP_OUTPUT=$(curl -v -s -m 30 http://localhost:3000/health 2>&1) - CURL_EXIT_CODE=$? - - echo "Curl exit code: $CURL_EXIT_CODE" - echo "Curl output: $HTTP_OUTPUT" - - if [ $CURL_EXIT_CODE -eq 0 ]; then - STATUS_CODE=$(echo "$HTTP_OUTPUT" | grep -oP "HTTP/\d(\.\d)? \K\d+") - echo "Status code detected: $STATUS_CODE" - - if [ "$STATUS_CODE" = "200" ]; then - echo "✅ Health check successful!" - HEALTH_CHECK_SUCCESS=true - break - else - echo "❌ Health check returned non-200 status code: $STATUS_CODE" - fi - else - echo "❌ Curl command failed with exit code: $CURL_EXIT_CODE" + + # Show progress and diagnostic info every 12 attempts (1 minute intervals) + if [ $((RETRY_COUNT % 12)) -eq 0 ] || [ $RETRY_COUNT -eq 1 ]; then + echo "Health check attempt $RETRY_COUNT of $MAX_RETRIES ($(($RETRY_COUNT * 5)) seconds elapsed)..." + echo "📋 Recent container logs:" + docker logs --tail 10 formbricks-test fi - - echo "Waiting 15 seconds before next attempt..." - sleep 15 + + # Try health endpoint with shorter timeout for faster polling + # Use -f flag to make curl fail on HTTP error status codes (4xx, 5xx) + if curl -f -s -m 10 http://localhost:3000/health >/dev/null 2>&1; then + echo "✅ Health check successful after $((RETRY_COUNT * 5)) seconds!" + HEALTH_CHECK_SUCCESS=true + break + fi + + # Wait 5 seconds before next attempt + sleep 5 done # Show full container logs for debugging @@ -160,7 +151,7 @@ jobs: # Exit with failure if health check did not succeed if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then - echo "❌ Health check failed after $MAX_RETRIES attempts" + echo "❌ Health check failed after $((MAX_RETRIES * 5)) seconds (5 minutes)" exit 1 fi diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable.tsx index 6a73091bcf..1f074a6707 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable.tsx @@ -24,14 +24,17 @@ export const ActionClassesTable = ({ otherEnvActionClasses, otherEnvironment, }: ActionClassesTableProps) => { - const [isActionDetailModalOpen, setActionDetailModalOpen] = useState(false); + const [isActionDetailModalOpen, setIsActionDetailModalOpen] = useState(false); const [activeActionClass, setActiveActionClass] = useState(); - const handleOpenActionDetailModalClick = (e, actionClass: TActionClass) => { + const handleOpenActionDetailModalClick = ( + e: React.MouseEvent, + actionClass: TActionClass + ) => { e.preventDefault(); setActiveActionClass(actionClass); - setActionDetailModalOpen(true); + setIsActionDetailModalOpen(true); }; return ( @@ -42,7 +45,7 @@ export const ActionClassesTable = ({ {actionClasses.length > 0 ? ( actionClasses.map((actionClass, index) => ( ), })); + vi.mock("@/modules/ui/components/code-action-form", () => ({ CodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
@@ -31,6 +47,7 @@ vi.mock("@/modules/ui/components/code-action-form", () => ({
), })); + vi.mock("@/modules/ui/components/delete-dialog", () => ({ DeleteDialog: ({ open, setOpen, isDeleting, onDelete }: any) => open ? ( @@ -43,6 +60,26 @@ vi.mock("@/modules/ui/components/delete-dialog", () => ({ ) : null, })); + +vi.mock("@/modules/ui/components/action-name-description-fields", () => ({ + ActionNameDescriptionFields: ({ isReadOnly, nameInputId, descriptionInputId }: any) => ( +
+ + +
+ ), +})); + vi.mock("@/modules/ui/components/no-code-action-form", () => ({ NoCodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
@@ -56,6 +93,23 @@ vi.mock("lucide-react", () => ({ TrashIcon: () =>
Trash
, })); +// Mock react-hook-form +const mockHandleSubmit = vi.fn(); +const mockForm = { + handleSubmit: mockHandleSubmit, + control: {}, + formState: { errors: {} }, +}; + +vi.mock("react-hook-form", async () => { + const actual = await vi.importActual("react-hook-form"); + return { + ...actual, + useForm: vi.fn(() => mockForm), + FormProvider: ({ children }: any) =>
{children}
, + }; +}); + const mockSetOpen = vi.fn(); const mockActionClasses: TActionClass[] = [ { @@ -88,6 +142,7 @@ const createMockActionClass = (id: string, type: TActionClassType, name: string) describe("ActionSettingsTab", () => { beforeEach(() => { vi.clearAllMocks(); + mockHandleSubmit.mockImplementation((fn) => fn); }); afterEach(() => { @@ -105,13 +160,9 @@ describe("ActionSettingsTab", () => { /> ); - // Use getByPlaceholderText or getByLabelText now that Input isn't mocked - expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue( - actionClass.name - ); - expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue( - actionClass.description - ); + expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument(); + expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeInTheDocument(); + expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeInTheDocument(); expect(screen.getByTestId("code-action-form")).toBeInTheDocument(); expect( screen.getByText("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base") @@ -131,18 +182,104 @@ describe("ActionSettingsTab", () => { /> ); - // Use getByPlaceholderText or getByLabelText now that Input isn't mocked - expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue( - actionClass.name - ); - expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue( - actionClass.description - ); + expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument(); expect(screen.getByTestId("no-code-action-form")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /common.delete/ })).toBeInTheDocument(); }); + test("renders correctly for other action types (fallback)", () => { + const actionClass = { + ...createMockActionClass("auto1", "noCode", "Auto Action"), + type: "automatic" as any, + }; + render( + + ); + + expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument(); + expect( + screen.getByText( + "environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it" + ) + ).toBeInTheDocument(); + }); + + test("calls utility functions on initialization", async () => { + const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils"); + + const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action"); + render( + + ); + + expect(actionUtilsMock.useActionClassKeys).toHaveBeenCalledWith(mockActionClasses); + expect(actionUtilsMock.createActionClassZodResolver).toHaveBeenCalled(); + }); + + test("handles successful form submission", async () => { + const { updateActionClassAction } = await import( + "@/app/(app)/environments/[environmentId]/actions/actions" + ); + const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils"); + + vi.mocked(updateActionClassAction).mockResolvedValue({ data: {} } as any); + + const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action"); + render( + + ); + + // Check that utility functions were called during component initialization + expect(actionUtilsMock.useActionClassKeys).toHaveBeenCalledWith(mockActionClasses); + expect(actionUtilsMock.createActionClassZodResolver).toHaveBeenCalled(); + }); + + test("handles permission validation error", async () => { + const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils"); + vi.mocked(actionUtilsMock.validatePermissions).mockImplementation(() => { + throw new Error("Not authorized"); + }); + + const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action"); + render( + + ); + + const submitButton = screen.getByRole("button", { name: "common.save_changes" }); + + mockHandleSubmit.mockImplementation((fn) => (e) => { + e.preventDefault(); + return fn({ name: "Test", type: "noCode" }); + }); + + await userEvent.click(submitButton); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("Not authorized"); + }); + }); + test("handles successful deletion", async () => { const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action"); const { deleteActionClassAction } = await import( @@ -209,17 +346,16 @@ describe("ActionSettingsTab", () => { actionClass={actionClass} actionClasses={mockActionClasses} setOpen={mockSetOpen} - isReadOnly={true} // Set to read-only + isReadOnly={true} /> ); - // Use getByPlaceholderText or getByLabelText now that Input isn't mocked - expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toBeDisabled(); - expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toBeDisabled(); + expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeDisabled(); + expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeDisabled(); expect(screen.getByTestId("no-code-action-form")).toHaveAttribute("data-readonly", "true"); expect(screen.queryByRole("button", { name: "common.save_changes" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument(); - expect(screen.getByRole("link", { name: "common.read_docs" })).toBeInTheDocument(); // Docs link still visible + expect(screen.getByRole("link", { name: "common.read_docs" })).toBeInTheDocument(); }); test("prevents delete when read-only", async () => { @@ -228,7 +364,6 @@ describe("ActionSettingsTab", () => { "@/app/(app)/environments/[environmentId]/actions/actions" ); - // Render with isReadOnly=true, but simulate a delete attempt render( { /> ); - // Try to open and confirm delete dialog (buttons won't exist, so we simulate the flow) - // This test primarily checks the logic within handleDeleteAction if it were called. - // A better approach might be to export handleDeleteAction for direct testing, - // but for now, we assume the UI prevents calling it. - - // We can assert that the delete button isn't there to prevent the flow expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument(); expect(deleteActionClassAction).not.toHaveBeenCalled(); }); @@ -262,4 +391,19 @@ describe("ActionSettingsTab", () => { expect(docsLink).toHaveAttribute("href", "https://formbricks.com/docs/actions/no-code"); expect(docsLink).toHaveAttribute("target", "_blank"); }); + + test("uses correct input IDs for ActionNameDescriptionFields", () => { + const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action"); + render( + + ); + + expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeInTheDocument(); + expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeInTheDocument(); + }); }); 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 f7bd77a262..9952d569db 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionSettingsTab.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionSettingsTab.tsx @@ -4,14 +4,17 @@ import { deleteActionClassAction, updateActionClassAction, } from "@/app/(app)/environments/[environmentId]/actions/actions"; -import { isValidCssSelector } from "@/app/lib/actionClass/actionClass"; +import { buildActionObject } from "@/modules/survey/editor/lib/action-builder"; +import { + createActionClassZodResolver, + useActionClassKeys, + validatePermissions, +} from "@/modules/survey/editor/lib/action-utils"; +import { ActionNameDescriptionFields } from "@/modules/ui/components/action-name-description-fields"; import { Button } from "@/modules/ui/components/button"; import { CodeActionForm } from "@/modules/ui/components/code-action-form"; import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; -import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form"; -import { Input } from "@/modules/ui/components/input"; import { NoCodeActionForm } from "@/modules/ui/components/no-code-action-form"; -import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslate } from "@tolgee/react"; import { TrashIcon } from "lucide-react"; import Link from "next/link"; @@ -19,8 +22,7 @@ import { useRouter } from "next/navigation"; import { useMemo, useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { toast } from "react-hot-toast"; -import { z } from "zod"; -import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes"; +import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes"; interface ActionSettingsTabProps { actionClass: TActionClass; @@ -48,63 +50,51 @@ export const ActionSettingsTab = ({ [actionClass.id, actionClasses] ); + const actionClassKeys = useActionClassKeys(actionClasses); + const form = useForm({ defaultValues: { ...restActionClass, }, - resolver: zodResolver( - ZActionClassInput.superRefine((data, ctx) => { - if (data.name && actionClassNames.includes(data.name)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["name"], - message: t("environments.actions.action_with_name_already_exists", { name: data.name }), - }); - } - }) - ), + resolver: createActionClassZodResolver(actionClassNames, actionClassKeys, t), mode: "onChange", }); const { handleSubmit, control } = form; + const renderActionForm = () => { + if (actionClass.type === "code") { + return ( + <> + +

+ {t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")} +

+ + ); + } + + if (actionClass.type === "noCode") { + return ; + } + + return ( +

+ {t("environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it")} +

+ ); + }; + const onSubmit = async (data: TActionClassInput) => { try { - if (isReadOnly) { - throw new Error(t("common.you_are_not_authorised_to_perform_this_action")); - } setIsUpdatingAction(true); + validatePermissions(isReadOnly, t); + const updatedAction = buildActionObject(data, actionClass.environmentId, t); - if (data.name && actionClassNames.includes(data.name)) { - throw new Error(t("environments.actions.action_with_name_already_exists", { name: data.name })); - } - - if ( - data.type === "noCode" && - data.noCodeConfig?.type === "click" && - data.noCodeConfig.elementSelector.cssSelector && - !isValidCssSelector(data.noCodeConfig.elementSelector.cssSelector) - ) { - throw new Error(t("environments.actions.invalid_css_selector")); - } - - const updatedData: TActionClassInput = { - ...data, - ...(data.type === "noCode" && - data.noCodeConfig?.type === "click" && { - noCodeConfig: { - ...data.noCodeConfig, - elementSelector: { - cssSelector: data.noCodeConfig.elementSelector.cssSelector, - innerHtml: data.noCodeConfig.elementSelector.innerHtml, - }, - }, - }), - }; await updateActionClassAction({ actionClassId: actionClass.id, - updatedAction: updatedData, + updatedAction: updatedAction, }); setOpen(false); router.refresh(); @@ -123,7 +113,7 @@ export const ActionSettingsTab = ({ router.refresh(); toast.success(t("environments.actions.action_deleted_successfully")); setOpen(false); - } catch (error) { + } catch { toast.error(t("common.something_went_wrong_please_try_again")); } finally { setIsDeletingAction(false); @@ -135,79 +125,14 @@ export const ActionSettingsTab = ({
-
-
- ( - - - {actionClass.type === "noCode" - ? t("environments.actions.what_did_your_user_do") - : t("environments.actions.display_name")} - + - - - - - - - )} - /> -
- -
- ( - - - {t("common.description")} - - - - - - - )} - /> -
-
- - {actionClass.type === "code" ? ( - <> - -

- {t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")} -

- - ) : actionClass.type === "noCode" ? ( - - ) : ( -

- {t( - "environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it" - )} -

- )} + {renderActionForm()}
diff --git a/apps/web/lib/utils/url.test.ts b/apps/web/lib/utils/url.test.ts index 739c1282bb..67576409f6 100644 --- a/apps/web/lib/utils/url.test.ts +++ b/apps/web/lib/utils/url.test.ts @@ -8,23 +8,67 @@ afterEach(() => { }); describe("testURLmatch", () => { - const testCases: [string, string, TActionClassPageUrlRule, string][] = [ - ["https://example.com", "https://example.com", "exactMatch", "yes"], - ["https://example.com", "https://example.com/page", "contains", "no"], - ["https://example.com/page", "https://example.com", "startsWith", "yes"], - ["https://example.com/page", "page", "endsWith", "yes"], - ["https://example.com", "https://other.com", "notMatch", "yes"], - ["https://example.com", "other", "notContains", "yes"], + // Mock translation function + const mockT = (key: string): string => { + const translations: Record = { + "environments.actions.invalid_regex": "Please use a valid regular expression.", + "environments.actions.invalid_match_type": "The option selected is not available.", + }; + return translations[key] || key; + }; + + const testCases: [string, string, TActionClassPageUrlRule, boolean][] = [ + ["https://example.com", "https://example.com", "exactMatch", true], + ["https://example.com", "https://different.com", "exactMatch", false], + ["https://example.com/page", "example.com", "contains", true], + ["https://example.com", "different.com", "contains", false], + ["https://example.com/page", "https://example.com", "startsWith", true], + ["https://example.com", "https://different.com", "startsWith", false], + ["https://example.com/page", "page", "endsWith", true], + ["https://example.com/page", "different", "endsWith", false], + ["https://example.com", "https://different.com", "notMatch", true], + ["https://example.com", "https://example.com", "notMatch", false], + ["https://example.com", "different", "notContains", true], + ["https://example.com", "example", "notContains", false], ]; test.each(testCases)("returns %s for %s with rule %s", (testUrl, pageUrlValue, pageUrlRule, expected) => { - expect(testURLmatch(testUrl, pageUrlValue, pageUrlRule)).toBe(expected); + expect(testURLmatch(testUrl, pageUrlValue, pageUrlRule, mockT)).toBe(expected); + }); + + describe("matchesRegex rule", () => { + test("returns true when URL matches regex pattern", () => { + expect(testURLmatch("https://example.com/user/123", "user/\\d+", "matchesRegex", mockT)).toBe(true); + expect(testURLmatch("https://example.com/dashboard", "dashboard$", "matchesRegex", mockT)).toBe(true); + expect(testURLmatch("https://app.example.com", "^https://app", "matchesRegex", mockT)).toBe(true); + }); + + test("returns false when URL does not match regex pattern", () => { + expect(testURLmatch("https://example.com/user/abc", "user/\\d+", "matchesRegex", mockT)).toBe(false); + expect(testURLmatch("https://example.com/settings", "dashboard$", "matchesRegex", mockT)).toBe(false); + expect(testURLmatch("https://api.example.com", "^https://app", "matchesRegex", mockT)).toBe(false); + }); + + test("throws error for invalid regex pattern", () => { + expect(() => testURLmatch("https://example.com", "[invalid-regex", "matchesRegex", mockT)).toThrow( + "Please use a valid regular expression." + ); + + expect(() => testURLmatch("https://example.com", "*invalid", "matchesRegex", mockT)).toThrow( + "Please use a valid regular expression." + ); + }); }); test("throws an error for invalid match type", () => { expect(() => - testURLmatch("https://example.com", "https://example.com", "invalidRule" as TActionClassPageUrlRule) - ).toThrow("Invalid match type"); + testURLmatch( + "https://example.com", + "https://example.com", + "invalidRule" as TActionClassPageUrlRule, + mockT + ) + ).toThrow("The option selected is not available."); }); }); diff --git a/apps/web/lib/utils/url.ts b/apps/web/lib/utils/url.ts index 3730497d95..a82ca26028 100644 --- a/apps/web/lib/utils/url.ts +++ b/apps/web/lib/utils/url.ts @@ -3,23 +3,34 @@ import { TActionClassPageUrlRule } from "@formbricks/types/action-classes"; export const testURLmatch = ( testUrl: string, pageUrlValue: string, - pageUrlRule: TActionClassPageUrlRule -): string => { + pageUrlRule: TActionClassPageUrlRule, + t: (key: string) => string +): boolean => { + let regex: RegExp; + switch (pageUrlRule) { case "exactMatch": - return testUrl === pageUrlValue ? "yes" : "no"; + return testUrl === pageUrlValue; case "contains": - return testUrl.includes(pageUrlValue) ? "yes" : "no"; + return testUrl.includes(pageUrlValue); case "startsWith": - return testUrl.startsWith(pageUrlValue) ? "yes" : "no"; + return testUrl.startsWith(pageUrlValue); case "endsWith": - return testUrl.endsWith(pageUrlValue) ? "yes" : "no"; + return testUrl.endsWith(pageUrlValue); case "notMatch": - return testUrl !== pageUrlValue ? "yes" : "no"; + return testUrl !== pageUrlValue; case "notContains": - return !testUrl.includes(pageUrlValue) ? "yes" : "no"; + return !testUrl.includes(pageUrlValue); + case "matchesRegex": + try { + regex = new RegExp(pageUrlValue); + } catch { + throw new Error(t("environments.actions.invalid_regex")); + } + + return regex.test(testUrl); default: - throw new Error("Invalid match type"); + throw new Error(t("environments.actions.invalid_match_type")); } }; diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index a51170c391..0629ef981e 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -503,6 +503,7 @@ "action_with_key_already_exists": "Aktion mit dem Schlüssel {key} existiert bereits", "action_with_name_already_exists": "Aktion mit dem Namen {name} existiert bereits", "add_css_class_or_id": "CSS-Klasse oder ID hinzufügen", + "add_regular_expression_here": "Fügen Sie hier einen regulären Ausdruck hinzu", "add_url": "URL hinzufügen", "click": "Klicken", "contains": "enthält", @@ -518,6 +519,7 @@ "eg_user_clicked_download_button": "z.B. Benutzer hat auf 'Herunterladen' geklickt", "ends_with": "endet mit", "enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "Teste eine URL, um zu sehen, ob der Nutzer deine Umfrage sehen würde.", + "enter_url": "z.B. https://app.com/dashboard", "exactly_matches": "Stimmt exakt überein", "exit_intent": "Will Seite verlassen", "fifty_percent_scroll": "50% Scroll", @@ -526,9 +528,14 @@ "if_a_user_clicks_a_button_with_a_specific_text": "Wenn ein Benutzer auf einen Button mit einem bestimmten Text klickt", "in_your_code_read_more_in_our": "in deinem Code. Lies mehr in unserem", "inner_text": "Innerer Text", + "invalid_action_type_code": "Ungültiger Aktionstyp für Code-Aktion", + "invalid_action_type_no_code": "Ungültiger Aktionstyp für NoCode-Aktion", "invalid_css_selector": "Ungültiger CSS-Selektor", + "invalid_match_type": "Die ausgewählte Option ist nicht verfügbar.", + "invalid_regex": "Bitte verwenden Sie einen gültigen regulären Ausdruck.", "limit_the_pages_on_which_this_action_gets_captured": "Begrenze die Seiten, auf denen diese Aktion erfasst wird", "limit_to_specific_pages": "Auf bestimmte Seiten beschränken", + "matches_regex": "Entspricht Regex", "on_all_pages": "Auf allen Seiten", "page_filter": "Seitenfilter", "page_view": "Seitenansicht", @@ -548,7 +555,9 @@ "user_clicked_download_button": "Benutzer hat auf 'Herunterladen' geklickt", "what_did_your_user_do": "Was hat dein Nutzer gemacht?", "what_is_the_user_doing": "Was macht der Nutzer?", - "you_can_track_code_action_anywhere_in_your_app_using": "Du kannst Code-Aktionen überall in deiner App tracken mit" + "you_can_track_code_action_anywhere_in_your_app_using": "Du kannst Code-Aktionen überall in deiner App tracken mit", + "your_survey_would_be_shown_on_this_url": "Ihre Umfrage wäre unter dieser URL angezeigt.", + "your_survey_would_not_be_shown": "Ihre Umfrage wäre nicht angezeigt." }, "connect": { "congrats": "Glückwunsch!", @@ -2840,6 +2849,6 @@ "usability_question_8_headline": "Die Nutzung des Systems fühlte sich wie eine Belastung an.", "usability_question_9_headline": "Ich fühlte mich beim Benutzen des Systems sicher.", "usability_rating_description": "Bewerte die wahrgenommene Benutzerfreundlichkeit, indem du die Nutzer bittest, ihre Erfahrung mit deinem Produkt mittels eines standardisierten 10-Fragen-Fragebogens zu bewerten.", - "usability_score_name": "System Usability Scale Survey (SUS)" + "usability_score_name": "System Usability Score Survey (SUS)" } } diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index ade687fc30..b123af8e3f 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -503,6 +503,7 @@ "action_with_key_already_exists": "Action with key {key} already exists", "action_with_name_already_exists": "Action with name {name} already exists", "add_css_class_or_id": "Add CSS class or id", + "add_regular_expression_here": "Add a regular expression here", "add_url": "Add URL", "click": "Click", "contains": "Contains", @@ -518,6 +519,7 @@ "eg_user_clicked_download_button": "E.g. User clicked Download Button", "ends_with": "Ends with", "enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "Enter a URL to see if a user visiting it would be tracked.", + "enter_url": "e.g. https://app.com/dashboard", "exactly_matches": "Exactly matches", "exit_intent": "Exit Intent", "fifty_percent_scroll": "50% Scroll", @@ -526,9 +528,14 @@ "if_a_user_clicks_a_button_with_a_specific_text": "If a user clicks a button with a specific text", "in_your_code_read_more_in_our": "in your code. Read more in our", "inner_text": "Inner Text", + "invalid_action_type_code": "Invalid action type for code action.", + "invalid_action_type_no_code": "Invalid action type for noCode action.", "invalid_css_selector": "Invalid CSS Selector", + "invalid_match_type": "The option selected is not available.", + "invalid_regex": "Please use a valid regular expression.", "limit_the_pages_on_which_this_action_gets_captured": "Limit the pages on which this action gets captured", "limit_to_specific_pages": "Limit to specific pages", + "matches_regex": "Matches regex", "on_all_pages": "On all pages", "page_filter": "Page filter", "page_view": "Page View", @@ -548,7 +555,9 @@ "user_clicked_download_button": "User clicked Download Button", "what_did_your_user_do": "What did your user do?", "what_is_the_user_doing": "What is the user doing?", - "you_can_track_code_action_anywhere_in_your_app_using": "You can track code action anywhere in your app using" + "you_can_track_code_action_anywhere_in_your_app_using": "You can track code action anywhere in your app using", + "your_survey_would_be_shown_on_this_url": "Your survey would be shown on this URL.", + "your_survey_would_not_be_shown": "Your survey would not be shown." }, "connect": { "congrats": "Congrats!", @@ -2840,6 +2849,6 @@ "usability_question_8_headline": "Using the system felt like a hassle.", "usability_question_9_headline": "I felt confident while using the system.", "usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.", - "usability_score_name": "System Usability Scale (SUS)" + "usability_score_name": "System Usability Score (SUS)" } } diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 6fd99af721..429ca3c62a 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -503,6 +503,7 @@ "action_with_key_already_exists": "L'action avec la clé '{'key'}' existe déjà", "action_with_name_already_exists": "L'action avec le nom '{'name'}' existe déjà", "add_css_class_or_id": "Ajouter une classe ou un identifiant CSS", + "add_regular_expression_here": "Ajoutez une expression régulière ici", "add_url": "Ajouter une URL", "click": "Cliquez", "contains": "Contient", @@ -518,6 +519,7 @@ "eg_user_clicked_download_button": "Par exemple, l'utilisateur a cliqué sur le bouton de téléchargement.", "ends_with": "Se termine par", "enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "Saisissez une URL pour voir si un utilisateur la visitant serait suivi.", + "enter_url": "par exemple https://app.com/dashboard", "exactly_matches": "Correspondance exacte", "exit_intent": "Intention de sortie", "fifty_percent_scroll": "50% Défilement", @@ -526,9 +528,14 @@ "if_a_user_clicks_a_button_with_a_specific_text": "Si un utilisateur clique sur un bouton avec un texte spécifique", "in_your_code_read_more_in_our": "dans votre code. En savoir plus dans notre", "inner_text": "Texte interne", + "invalid_action_type_code": "Type d'action invalide pour action code", + "invalid_action_type_no_code": "Type d'action invalide pour action noCode", "invalid_css_selector": "Sélecteur CSS invalide", + "invalid_match_type": "L'option sélectionnée n'est pas disponible.", + "invalid_regex": "Veuillez utiliser une expression régulière valide.", "limit_the_pages_on_which_this_action_gets_captured": "Limiter les pages sur lesquelles cette action est capturée", "limit_to_specific_pages": "Limiter à des pages spécifiques", + "matches_regex": "Correspond à l'expression régulière", "on_all_pages": "Sur toutes les pages", "page_filter": "Filtre de page", "page_view": "Vue de page", @@ -548,7 +555,9 @@ "user_clicked_download_button": "L'utilisateur a cliqué sur le bouton de téléchargement", "what_did_your_user_do": "Que fait votre utilisateur ?", "what_is_the_user_doing": "Que fait l'utilisateur ?", - "you_can_track_code_action_anywhere_in_your_app_using": "Vous pouvez suivre l'action du code partout dans votre application en utilisant" + "you_can_track_code_action_anywhere_in_your_app_using": "Vous pouvez suivre l'action du code partout dans votre application en utilisant", + "your_survey_would_be_shown_on_this_url": "Votre enquête serait affichée sur cette URL.", + "your_survey_would_not_be_shown": "Votre enquête ne serait pas affichée." }, "connect": { "congrats": "Félicitations !", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index e62e6e32d5..7041064899 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -503,6 +503,7 @@ "action_with_key_already_exists": "Ação com a chave {key} já existe", "action_with_name_already_exists": "Ação com o nome {name} já existe", "add_css_class_or_id": "Adicionar classe ou id CSS", + "add_regular_expression_here": "Adicionar uma expressão regular aqui", "add_url": "Adicionar URL", "click": "Clica", "contains": "contém", @@ -518,6 +519,7 @@ "eg_user_clicked_download_button": "Por exemplo, usuário clicou no botão de download", "ends_with": "Termina com", "enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "Digite uma URL para ver se um usuário que a visita seria rastreado.", + "enter_url": "ex.: https://app.com/dashboard", "exactly_matches": "Combina exatamente", "exit_intent": "Intenção de Saída", "fifty_percent_scroll": "Rolar 50%", @@ -526,9 +528,14 @@ "if_a_user_clicks_a_button_with_a_specific_text": "Se um usuário clicar em um botão com um texto específico", "in_your_code_read_more_in_our": "no seu código. Leia mais em nosso", "inner_text": "Texto Interno", + "invalid_action_type_code": "Tipo de ação inválido para ação com código", + "invalid_action_type_no_code": "Tipo de ação inválido para ação noCode", "invalid_css_selector": "Seletor CSS Inválido", + "invalid_match_type": "A opção selecionada não está disponível.", + "invalid_regex": "Por favor, use uma expressão regular válida.", "limit_the_pages_on_which_this_action_gets_captured": "Limite as páginas nas quais essa ação é capturada", "limit_to_specific_pages": "Limitar a páginas específicas", + "matches_regex": "Correspondência regex", "on_all_pages": "Em todas as páginas", "page_filter": "filtro de página", "page_view": "Visualização de Página", @@ -548,7 +555,9 @@ "user_clicked_download_button": "Usuário clicou no botão de download", "what_did_your_user_do": "O que seu usuário fez?", "what_is_the_user_doing": "O que o usuário tá fazendo?", - "you_can_track_code_action_anywhere_in_your_app_using": "Você pode rastrear ações de código em qualquer lugar do seu app usando" + "you_can_track_code_action_anywhere_in_your_app_using": "Você pode rastrear ações de código em qualquer lugar do seu app usando", + "your_survey_would_be_shown_on_this_url": "Sua pesquisa seria exibida neste URL.", + "your_survey_would_not_be_shown": "Sua pesquisa não seria exibida." }, "connect": { "congrats": "Parabéns!", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 9dd52b8162..3f0f7541fa 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -503,6 +503,7 @@ "action_with_key_already_exists": "Ação com a chave {key} já existe", "action_with_name_already_exists": "Ação com o nome {name} já existe", "add_css_class_or_id": "Adicionar classe ou id CSS", + "add_regular_expression_here": "Adicione uma expressão regular aqui", "add_url": "Adicionar URL", "click": "Clique", "contains": "Contém", @@ -518,6 +519,7 @@ "eg_user_clicked_download_button": "Por exemplo, Utilizador clicou no Botão Descarregar", "ends_with": "Termina com", "enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "Introduza um URL para ver se um utilizador que o visita seria rastreado.", + "enter_url": "por exemplo, https://app.com/dashboard", "exactly_matches": "Corresponde exatamente", "exit_intent": "Intenção de Saída", "fifty_percent_scroll": "Rolar 50%", @@ -526,9 +528,14 @@ "if_a_user_clicks_a_button_with_a_specific_text": "Se um utilizador clicar num botão com um texto específico", "in_your_code_read_more_in_our": "no seu código. Leia mais no nosso", "inner_text": "Texto Interno", + "invalid_action_type_code": "Tipo de ação inválido para ação de código", + "invalid_action_type_no_code": "Tipo de ação inválido para ação noCode", "invalid_css_selector": "Seletor CSS inválido", + "invalid_match_type": "A opção selecionada não está disponível.", + "invalid_regex": "Por favor, utilize uma expressão regular válida.", "limit_the_pages_on_which_this_action_gets_captured": "Limitar as páginas nas quais esta ação é capturada", "limit_to_specific_pages": "Limitar a páginas específicas", + "matches_regex": "Coincide com regex", "on_all_pages": "Em todas as páginas", "page_filter": "Filtro de página", "page_view": "Visualização de Página", @@ -548,7 +555,9 @@ "user_clicked_download_button": "Utilizador clicou no Botão Descarregar", "what_did_your_user_do": "O que fez o seu utilizador?", "what_is_the_user_doing": "O que está o utilizador a fazer?", - "you_can_track_code_action_anywhere_in_your_app_using": "Pode rastrear a ação do código em qualquer lugar na sua aplicação usando" + "you_can_track_code_action_anywhere_in_your_app_using": "Pode rastrear a ação do código em qualquer lugar na sua aplicação usando", + "your_survey_would_be_shown_on_this_url": "O seu inquérito seria mostrado neste URL.", + "your_survey_would_not_be_shown": "O seu inquérito não seria mostrado." }, "connect": { "congrats": "Parabéns!", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 7199db6070..6b08d99e9c 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -503,6 +503,7 @@ "action_with_key_already_exists": "金鑰為 '{'key'}' 的操作已存在", "action_with_name_already_exists": "名稱為 '{'name'}' 的操作已存在", "add_css_class_or_id": "新增 CSS 類別或 ID", + "add_regular_expression_here": "新增正則表達式在此", "add_url": "新增網址", "click": "點擊", "contains": "包含", @@ -518,6 +519,7 @@ "eg_user_clicked_download_button": "例如,使用者點擊了下載按鈕", "ends_with": "結尾為", "enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "輸入網址以查看造訪該網址的使用者是否會被追蹤。", + "enter_url": "例如 https://app.com/dashboard", "exactly_matches": "完全相符", "exit_intent": "離開意圖", "fifty_percent_scroll": "50% 捲動", @@ -526,9 +528,14 @@ "if_a_user_clicks_a_button_with_a_specific_text": "如果使用者點擊具有特定文字的按鈕", "in_your_code_read_more_in_our": "在您的程式碼中。在我們的文件中閱讀更多內容", "inner_text": "內部文字", + "invalid_action_type_code": "對程式碼操作的操作類型無效", + "invalid_action_type_no_code": "使用無程式碼操作的操作類型無效", "invalid_css_selector": "無效的 CSS 選取器", + "invalid_match_type": "所選擇的選項不適用。", + "invalid_regex": "請使用有效的正規表示式。", "limit_the_pages_on_which_this_action_gets_captured": "限制擷取此操作的頁面", "limit_to_specific_pages": "限制為特定頁面", + "matches_regex": "符合 正則 表達式", "on_all_pages": "在所有頁面上", "page_filter": "頁面篩選器", "page_view": "頁面檢視", @@ -548,7 +555,9 @@ "user_clicked_download_button": "使用者點擊了下載按鈕", "what_did_your_user_do": "您的使用者做了什麼?", "what_is_the_user_doing": "使用者正在做什麼?", - "you_can_track_code_action_anywhere_in_your_app_using": "您可以使用以下方式在您的應用程式中的任何位置追蹤程式碼操作" + "you_can_track_code_action_anywhere_in_your_app_using": "您可以使用以下方式在您的應用程式中的任何位置追蹤程式碼操作", + "your_survey_would_be_shown_on_this_url": "您的問卷將顯示在此網址。", + "your_survey_would_not_be_shown": "您的問卷將不會顯示。" }, "connect": { "congrats": "恭喜!", diff --git a/apps/web/modules/survey/editor/components/create-new-action-tab.test.tsx b/apps/web/modules/survey/editor/components/create-new-action-tab.test.tsx index 35ee531bba..2a2d8a9c27 100644 --- a/apps/web/modules/survey/editor/components/create-new-action-tab.test.tsx +++ b/apps/web/modules/survey/editor/components/create-new-action-tab.test.tsx @@ -1,16 +1,22 @@ import { ActionClass } from "@prisma/client"; import "@testing-library/jest-dom/vitest"; -import { cleanup, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, test, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useRouter } from "next/navigation"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { createActionClassAction } from "../actions"; import { CreateNewActionTab } from "./create-new-action-tab"; -// Mock the NoCodeActionForm and CodeActionForm components -vi.mock("@/modules/ui/components/no-code-action-form", () => ({ - NoCodeActionForm: () =>
NoCodeActionForm
, +// Mock react-hot-toast +vi.mock("react-hot-toast", () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + }, })); -vi.mock("@/modules/ui/components/code-action-form", () => ({ - CodeActionForm: () =>
CodeActionForm
, +// Mock next/navigation +vi.mock("next/navigation", () => ({ + useRouter: vi.fn(), })); // Mock constants @@ -18,41 +24,291 @@ vi.mock("@/lib/constants", () => ({ IS_FORMBRICKS_CLOUD: false, })); +// Mock CSS selector validation +vi.mock("@/app/lib/actionClass/actionClass", () => ({ + isValidCssSelector: vi.fn(() => true), +})); + // Mock the createActionClassAction function vi.mock("../actions", () => ({ createActionClassAction: vi.fn(), })); +// Mock action-utils functions +vi.mock("../lib/action-utils", () => ({ + useActionClassKeys: vi.fn(() => []), + createActionClassZodResolver: vi.fn(() => () => ({ errors: {}, isValid: true })), + validatePermissions: vi.fn(), +})); + +// Mock action-builder functions +vi.mock("../lib/action-builder", () => ({ + buildActionObject: vi.fn((data) => data), +})); + +// Mock ActionNameDescriptionFields component +vi.mock("@/modules/ui/components/action-name-description-fields", () => ({ + ActionNameDescriptionFields: vi.fn(({ nameInputId, descriptionInputId }) => ( +
+ + + + +
+ )), +})); + +// Mock useTranslate hook +const mockT = vi.fn((key: string, params?: any) => { + const translations: Record = { + "environments.actions.new_action": "New Action", + "common.no_code": "No Code", + "common.code": "Code", + "environments.actions.action_type": "Action Type", + "environments.actions.what_did_your_user_do": "What did your user do?", + "common.description": "Description", + "environments.actions.create_action": "Create Action", + "common.key": "Key", + "common.cancel": "Cancel", + "environments.actions.action_created_successfully": "Action created successfully", + "environments.actions.action_with_name_already_exists": `Action with name "{{name}}" already exists`, + "environments.actions.action_with_key_already_exists": `Action with key "{{key}}" already exists`, + "environments.actions.invalid_css_selector": "Invalid CSS selector", + "environments.actions.invalid_regex": "Invalid regex pattern", + "common.you_are_not_authorised_to_perform_this_action": "You are not authorized to perform this action", + }; + let translation = translations[key] || key; + if (params) { + Object.keys(params).forEach((param) => { + translation = translation.replace(`{{${param}}}`, params[param]); + }); + } + return translation; +}); + +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ t: mockT }), +})); + describe("CreateNewActionTab", () => { + const mockPush = vi.fn(); + const mockRefresh = vi.fn(); + const mockCreateActionClassAction = vi.mocked(createActionClassAction); + + const defaultProps = { + actionClasses: [] as ActionClass[], + setActionClasses: vi.fn(), + setOpen: vi.fn(), + isReadOnly: false, + setLocalSurvey: vi.fn(), + environmentId: "test-env-id", + }; + + beforeEach(async () => { + vi.mocked(useRouter).mockReturnValue({ + push: mockPush, + refresh: mockRefresh, + } as any); + + mockCreateActionClassAction.mockResolvedValue({ + data: { + id: "new-action-id", + name: "Test Action", + type: "noCode", + environmentId: "test-env-id", + description: null, + key: null, + noCodeConfig: {}, + createdAt: new Date(), + updatedAt: new Date(), + } as ActionClass, + }); + + // Import and setup the CSS selector mock + const cssModule = (await vi.importMock("@/app/lib/actionClass/actionClass")) as any; + cssModule.isValidCssSelector.mockReturnValue(true); + + // Setup action-utils mocks + const actionUtilsModule = (await vi.importMock("../lib/action-utils")) as any; + actionUtilsModule.useActionClassKeys.mockReturnValue([]); + actionUtilsModule.createActionClassZodResolver.mockReturnValue(() => ({ errors: {}, isValid: true })); + actionUtilsModule.validatePermissions.mockImplementation(() => {}); + + // Setup action-builder mock + const actionBuilderModule = (await vi.importMock("../lib/action-builder")) as any; + actionBuilderModule.buildActionObject.mockImplementation((data) => data); + }); + afterEach(() => { cleanup(); + vi.clearAllMocks(); }); - test("renders all expected fields and UI elements when provided with valid props", () => { - const actionClasses: ActionClass[] = []; - const setActionClasses = vi.fn(); - const setOpen = vi.fn(); - const isReadOnly = false; - const setLocalSurvey = vi.fn(); - const environmentId = "test-env-id"; + // Basic rendering tests + test("renders all expected fields and UI elements", () => { + render(); - render( - + expect(screen.getByText("Action Type")).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: "No Code" })).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: "Code" })).toBeInTheDocument(); + expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument(); + expect(screen.getByTestId("no-code-action-form")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create Action" })).toBeInTheDocument(); + }); + + test("switches between action forms correctly", async () => { + render(); + + // Initially shows no-code form + expect(screen.getByTestId("no-code-action-form")).toBeInTheDocument(); + expect(screen.queryByTestId("code-action-form")).not.toBeInTheDocument(); + + // Switch to code tab + const codeTab = screen.getByRole("radio", { name: "Code" }); + await act(async () => { + fireEvent.click(codeTab); + }); + + // Should now show code form + await waitFor(() => { + expect(screen.queryByTestId("no-code-action-form")).not.toBeInTheDocument(); + expect(screen.getByTestId("code-action-form")).toBeInTheDocument(); + }); + }); + + test("renders readonly state correctly", () => { + render(); + + // Form should still render but components should receive isReadOnly prop + expect(screen.getByText("Action Type")).toBeInTheDocument(); + expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument(); + }); + + test("renders with existing action classes", () => { + const existingActionClasses: ActionClass[] = [ + { + id: "existing-action", + name: "Existing Action", + environmentId: "test-env-id", + type: "noCode", + description: "Existing description", + key: null, + noCodeConfig: {}, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + render(); + + // Form should render normally regardless of existing actions + expect(screen.getByText("Action Type")).toBeInTheDocument(); + expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument(); + }); + + test("calls useActionClassKeys with correct arguments", async () => { + const actionClasses = [ + { + id: "test-action", + name: "Test Action", + environmentId: "test-env-id", + type: "code", + key: "test-key", + description: null, + noCodeConfig: null, + createdAt: new Date(), + updatedAt: new Date(), + } as ActionClass, + ]; + + const actionUtilsModule = (await vi.importMock("../lib/action-utils")) as any; + render(); + + expect(actionUtilsModule.useActionClassKeys).toHaveBeenCalledWith(actionClasses); + }); + + test("renders form with correct resolver configuration", async () => { + const actionUtilsModule = (await vi.importMock("../lib/action-utils")) as any; + + render(); + + // Verify that the resolver is configured correctly + expect(actionUtilsModule.createActionClassZodResolver).toHaveBeenCalledWith( + [], // actionClassNames + [], // actionClassKeys + mockT ); + }); - // Check for the presence of key UI elements - expect(screen.getByText("environments.actions.action_type")).toBeInTheDocument(); - expect(screen.getByRole("radio", { name: "common.no_code" })).toBeInTheDocument(); - expect(screen.getByRole("radio", { name: "common.code" })).toBeInTheDocument(); - expect(screen.getByLabelText("environments.actions.what_did_your_user_do")).toBeInTheDocument(); - expect(screen.getByLabelText("common.description")).toBeInTheDocument(); - expect(screen.getByTestId("no-code-action-form")).toBeInTheDocument(); // Ensure NoCodeActionForm is rendered by default + test("handles validation errors correctly", async () => { + // Mock form validation to fail + const actionUtilsModule = (await vi.importMock("../lib/action-utils")) as any; + actionUtilsModule.createActionClassZodResolver.mockReturnValue(() => ({ + errors: { name: { message: "Name is required" } }, + isValid: false, + })); + + render(); + + const submitButton = screen.getByRole("button", { name: "Create Action" }); + + await act(async () => { + fireEvent.click(submitButton); + }); + + // Since validation fails, buildActionObject should not be called + const { buildActionObject } = await import("../lib/action-builder"); + expect(buildActionObject).not.toHaveBeenCalled(); + }); + + test("handles readonly permissions correctly", async () => { + const { validatePermissions } = await import("../lib/action-utils"); + const toast = await import("react-hot-toast"); + + // Make validatePermissions throw for readonly + const actionUtilsModule = (await vi.importMock("../lib/action-utils")) as any; + actionUtilsModule.validatePermissions.mockImplementation(() => { + throw new Error("You are not authorized to perform this action"); + }); + + render(); + + const submitButton = screen.getByRole("button", { name: "Create Action" }); + + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(validatePermissions).toHaveBeenCalledWith(true, mockT); + expect(toast.default.error).toHaveBeenCalledWith("You are not authorized to perform this action"); + }); + + test("uses correct action class names and keys for validation", async () => { + const actionClasses = [ + { + id: "action1", + name: "Existing Action", + environmentId: "test-env-id", + type: "code", + key: "existing-key", + description: null, + noCodeConfig: null, + createdAt: new Date(), + updatedAt: new Date(), + } as ActionClass, + ]; + + const actionUtilsModule = (await vi.importMock("../lib/action-utils")) as any; + actionUtilsModule.useActionClassKeys.mockReturnValue(["existing-key"]); + + render(); + + // Verify that the resolver is configured with existing action names and keys + expect(actionUtilsModule.createActionClassZodResolver).toHaveBeenCalledWith( + ["Existing Action"], // actionClassNames + ["existing-key"], // actionClassKeys + mockT + ); }); }); diff --git a/apps/web/modules/survey/editor/components/create-new-action-tab.tsx b/apps/web/modules/survey/editor/components/create-new-action-tab.tsx index 7e9909a5c0..df63dbb5fe 100644 --- a/apps/web/modules/survey/editor/components/create-new-action-tab.tsx +++ b/apps/web/modules/survey/editor/components/create-new-action-tab.tsx @@ -1,28 +1,23 @@ "use client"; -import { isValidCssSelector } from "@/app/lib/actionClass/actionClass"; +import { ActionNameDescriptionFields } from "@/modules/ui/components/action-name-description-fields"; import { Button } from "@/modules/ui/components/button"; import { CodeActionForm } from "@/modules/ui/components/code-action-form"; -import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form"; -import { Input } from "@/modules/ui/components/input"; +import { FormField } from "@/modules/ui/components/form"; import { Label } from "@/modules/ui/components/label"; import { NoCodeActionForm } from "@/modules/ui/components/no-code-action-form"; import { TabToggle } from "@/modules/ui/components/tab-toggle"; -import { zodResolver } from "@hookform/resolvers/zod"; import { ActionClass } from "@prisma/client"; import { useTranslate } from "@tolgee/react"; import { useRouter } from "next/navigation"; import { useMemo } from "react"; import { FormProvider, useForm } from "react-hook-form"; import toast from "react-hot-toast"; -import { z } from "zod"; -import { - TActionClassInput, - TActionClassInputCode, - ZActionClassInput, -} from "@formbricks/types/action-classes"; +import { TActionClassInput } from "@formbricks/types/action-classes"; import { TSurvey } from "@formbricks/types/surveys/types"; import { createActionClassAction } from "../actions"; +import { buildActionObject } from "../lib/action-builder"; +import { createActionClassZodResolver, useActionClassKeys, validatePermissions } from "../lib/action-utils"; interface CreateNewActionTabProps { actionClasses: ActionClass[]; @@ -48,6 +43,8 @@ export const CreateNewActionTab = ({ [actionClasses] ); + const actionClassKeys = useActionClassKeys(actionClasses); + const form = useForm({ defaultValues: { name: "", @@ -63,112 +60,49 @@ export const CreateNewActionTab = ({ urlFilters: [], }, }, - resolver: zodResolver( - ZActionClassInput.superRefine((data, ctx) => { - if (data.name && actionClassNames.includes(data.name)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["name"], - message: t("environments.actions.action_with_name_already_exists", { name: data.name }), - }); - } - }) - ), + resolver: createActionClassZodResolver(actionClassNames, actionClassKeys, t), mode: "onChange", }); const { control, handleSubmit, watch, reset } = form; const { isSubmitting } = form.formState; - const actionClassKeys = useMemo(() => { - const codeActionClasses: TActionClassInputCode[] = actionClasses.filter( - (actionClass) => actionClass.type === "code" - ) as TActionClassInputCode[]; - - return codeActionClasses.map((actionClass) => actionClass.key); - }, [actionClasses]); - const submitHandler = async (data: TActionClassInput) => { - const { type } = data; try { - if (isReadOnly) { - throw new Error(t("common.you_are_not_authorised_to_perform_this_action")); - } - - if (data.name && actionClassNames.includes(data.name)) { - throw new Error(t("environments.actions.action_with_name_already_exists", { name: data.name })); - } - - if (type === "code" && data.key && actionClassKeys.includes(data.key)) { - throw new Error(t("environments.actions.action_with_key_already_exists", { key: data.key })); - } - - if ( - data.type === "noCode" && - data.noCodeConfig?.type === "click" && - data.noCodeConfig.elementSelector.cssSelector && - !isValidCssSelector(data.noCodeConfig.elementSelector.cssSelector) - ) { - throw new Error("Invalid CSS Selector"); - } - - let updatedAction = {}; - - if (type === "noCode") { - updatedAction = { - name: data.name.trim(), - description: data.description, - environmentId, - type: "noCode", - noCodeConfig: { - ...data.noCodeConfig, - ...(data.type === "noCode" && - data.noCodeConfig?.type === "click" && { - elementSelector: { - cssSelector: data.noCodeConfig.elementSelector.cssSelector, - innerHtml: data.noCodeConfig.elementSelector.innerHtml, - }, - }), - }, - }; - } else if (type === "code") { - updatedAction = { - name: data.name.trim(), - description: data.description, - environmentId, - type: "code", - key: data.key, - }; - } - - // const newActionClass: TActionClass = - const createActionClassResposne = await createActionClassAction({ - action: updatedAction as TActionClassInput, - }); - - if (!createActionClassResposne?.data) return; - - const newActionClass = createActionClassResposne.data; - if (setActionClasses) { - setActionClasses((prevActionClasses: ActionClass[]) => [...prevActionClasses, newActionClass]); - } - - if (setLocalSurvey) { - setLocalSurvey((prev) => ({ - ...prev, - triggers: prev.triggers.concat({ actionClass: newActionClass }), - })); - } - - reset(); - resetAllStates(); - router.refresh(); - toast.success(t("environments.actions.action_created_successfully")); + validatePermissions(isReadOnly, t); + const updatedAction = buildActionObject(data, environmentId, t); + await createAndHandleAction(updatedAction); } catch (e: any) { toast.error(e.message); } }; + const createAndHandleAction = async (updatedAction: TActionClassInput) => { + const createActionClassResposne = await createActionClassAction({ + action: updatedAction, + }); + + if (!createActionClassResposne?.data) return; + + const newActionClass = createActionClassResposne.data; + + if (setActionClasses) { + setActionClasses((prevActionClasses: ActionClass[]) => [...prevActionClasses, newActionClass]); + } + + if (setLocalSurvey) { + setLocalSurvey((prev) => ({ + ...prev, + triggers: prev.triggers.concat({ actionClass: newActionClass }), + })); + } + + reset(); + resetAllStates(); + router.refresh(); + toast.success(t("environments.actions.action_created_successfully")); + }; + const resetAllStates = () => { reset(); setOpen(false); @@ -177,7 +111,7 @@ export const CreateNewActionTab = ({ return (
- +
-
-
- ( - - - {t("environments.actions.what_did_your_user_do")} - - - - - - - - - )} - /> -
-
- ( - - {t("common.description")} - - - - - - )} - /> -
-
- -
+ {watch("type") === "code" ? ( diff --git a/apps/web/modules/survey/editor/components/saved-actions-tab.test.tsx b/apps/web/modules/survey/editor/components/saved-actions-tab.test.tsx index d080a0d70d..1c7e16880f 100755 --- a/apps/web/modules/survey/editor/components/saved-actions-tab.test.tsx +++ b/apps/web/modules/survey/editor/components/saved-actions-tab.test.tsx @@ -20,6 +20,13 @@ describe("SavedActionsTab", () => { description: "Description for No Code Action", type: "noCode", environmentId: "env1", + noCodeConfig: { + type: "click", + urlFilters: [{ rule: "exactMatch", value: "https://example.com" }], + elementSelector: { + cssSelector: ".button", + }, + }, } as unknown as ActionClass, { id: "2", @@ -29,6 +36,7 @@ describe("SavedActionsTab", () => { description: "Description for Code Action", type: "code", environmentId: "env1", + key: "code-action-key", } as unknown as ActionClass, ]; @@ -74,6 +82,13 @@ describe("SavedActionsTab", () => { description: "Description for Action One", type: "noCode", environmentId: "env1", + noCodeConfig: { + type: "click", + urlFilters: [{ rule: "contains", value: "/dashboard" }], + elementSelector: { + cssSelector: ".button", + }, + }, } as unknown as ActionClass, ]; @@ -121,6 +136,74 @@ describe("SavedActionsTab", () => { expect(setOpen).toHaveBeenCalledWith(false); }); + test("displays action classes with regex URL filters correctly", () => { + const actionClasses: ActionClass[] = [ + { + id: "1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Regex Action", + description: "Action with regex URL filter", + type: "noCode", + environmentId: "env1", + noCodeConfig: { + type: "pageView", + urlFilters: [{ rule: "matchesRegex", value: "user/\\d+" }], + }, + } as unknown as ActionClass, + { + id: "2", + createdAt: new Date(), + updatedAt: new Date(), + name: "Multiple Filters Action", + description: "Action with multiple URL filters including regex", + type: "noCode", + environmentId: "env1", + noCodeConfig: { + type: "click", + urlFilters: [ + { rule: "startsWith", value: "https://app.example.com" }, + { rule: "matchesRegex", value: "dashboard.*\\?tab=\\w+" }, + ], + elementSelector: { + cssSelector: ".nav-button", + }, + }, + } as unknown as ActionClass, + ]; + + const localSurvey: TSurvey = { + id: "survey1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Test Survey", + questions: [], + triggers: [], + environmentId: "env1", + status: "draft", + } as any; + + const setLocalSurvey = vi.fn(); + const setOpen = vi.fn(); + + render( + + ); + + // Check if actions with regex filters are displayed + expect(screen.getByText("Regex Action")).toBeInTheDocument(); + expect(screen.getByText("Multiple Filters Action")).toBeInTheDocument(); + + // Verify the ActionClassInfo component displays the URL filters + expect(screen.getByText("Action with regex URL filter")).toBeInTheDocument(); + expect(screen.getByText("Action with multiple URL filters including regex")).toBeInTheDocument(); + }); + test("displays 'No saved actions found' message when no actions are available", () => { const actionClasses: ActionClass[] = []; const localSurvey: TSurvey = { @@ -149,6 +232,68 @@ describe("SavedActionsTab", () => { expect(noActionsMessage).toBeInTheDocument(); }); + test("excludes actions that are already used as triggers in the survey", () => { + const actionClasses: ActionClass[] = [ + { + id: "1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Available Action", + description: "This action is available", + type: "noCode", + environmentId: "env1", + noCodeConfig: { + type: "click", + urlFilters: [{ rule: "exactMatch", value: "https://example.com" }], + elementSelector: { + cssSelector: ".button", + }, + }, + } as unknown as ActionClass, + { + id: "2", + createdAt: new Date(), + updatedAt: new Date(), + name: "Used Action", + description: "This action is already used", + type: "code", + environmentId: "env1", + key: "used-action-key", + } as unknown as ActionClass, + ]; + + const localSurvey: TSurvey = { + id: "survey1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Test Survey", + questions: [], + triggers: [ + { actionClass: actionClasses[1] }, // "Used Action" is already a trigger + ], + environmentId: "env1", + status: "draft", + } as any; + + const setLocalSurvey = vi.fn(); + const setOpen = vi.fn(); + + render( + + ); + + // Available action should be displayed + expect(screen.getByText("Available Action")).toBeInTheDocument(); + + // Used action should not be displayed + expect(screen.queryByText("Used Action")).not.toBeInTheDocument(); + }); + test("filters actionClasses correctly with special characters, diacritics, and non-Latin scripts", async () => { const user = userEvent.setup(); const actionClasses: ActionClass[] = [ @@ -160,6 +305,13 @@ describe("SavedActionsTab", () => { description: "Description for Action One", type: "noCode", environmentId: "env1", + noCodeConfig: { + type: "click", + urlFilters: [{ rule: "contains", value: "special" }], + elementSelector: { + cssSelector: ".special", + }, + }, } as unknown as ActionClass, { id: "2", @@ -169,6 +321,7 @@ describe("SavedActionsTab", () => { description: "Description for Action Two", type: "code", environmentId: "env1", + key: "cyrillic-action", } as unknown as ActionClass, { id: "3", @@ -178,6 +331,10 @@ describe("SavedActionsTab", () => { description: "Description for Another Action", type: "noCode", environmentId: "env1", + noCodeConfig: { + type: "pageView", + urlFilters: [{ rule: "matchesRegex", value: "special.*symbols" }], + }, } as unknown as ActionClass, ]; @@ -228,6 +385,13 @@ describe("SavedActionsTab", () => { description: "Description for Action One", type: "noCode", environmentId: "env1", + noCodeConfig: { + type: "click", + urlFilters: [{ rule: "exactMatch", value: "https://example.com" }], + elementSelector: { + cssSelector: ".button-one", + }, + }, } as unknown as ActionClass, { id: "2", @@ -237,6 +401,7 @@ describe("SavedActionsTab", () => { description: "Description for Action Two", type: "code", environmentId: "env1", + key: "action-two-key", } as unknown as ActionClass, { id: "3", @@ -246,6 +411,10 @@ describe("SavedActionsTab", () => { description: "Description for Another Action", type: "noCode", environmentId: "env1", + noCodeConfig: { + type: "pageView", + urlFilters: [{ rule: "matchesRegex", value: "another.*page" }], + }, } as unknown as ActionClass, ]; @@ -310,4 +479,69 @@ describe("SavedActionsTab", () => { expect(screen.queryByText("Action One")).toBeNull(); expect(screen.queryByText("Action Two")).toBeNull(); }); + + test("handles action classes with mixed URL filter rule types including regex", async () => { + const user = userEvent.setup(); + const actionClasses: ActionClass[] = [ + { + id: "1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Mixed Rules Action", + description: "Action with multiple rule types", + type: "noCode", + environmentId: "env1", + noCodeConfig: { + type: "click", + urlFilters: [ + { rule: "startsWith", value: "https://app" }, + { rule: "contains", value: "/dashboard" }, + { rule: "matchesRegex", value: "\\?section=\\w+" }, + { rule: "endsWith", value: "#main" }, + ], + elementSelector: { + cssSelector: ".complex-button", + }, + }, + } as unknown as ActionClass, + ]; + + const localSurvey: TSurvey = { + id: "survey1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Test Survey", + questions: [], + triggers: [], + environmentId: "env1", + status: "draft", + } as any; + + const setLocalSurvey = vi.fn(); + const setOpen = vi.fn(); + + render( + + ); + + // Verify the action is displayed + expect(screen.getByText("Mixed Rules Action")).toBeInTheDocument(); + expect(screen.getByText("Action with multiple rule types")).toBeInTheDocument(); + + // Click on the action to add it as a trigger + const actionElement = screen.getByText("Mixed Rules Action"); + await user.click(actionElement); + + // Verify the action was added to triggers + expect(setLocalSurvey).toHaveBeenCalledTimes(1); + const updateFunction = setLocalSurvey.mock.calls[0][0]; + const result = updateFunction(localSurvey); + expect(result.triggers).toHaveLength(1); + expect(result.triggers[0].actionClass).toEqual(actionClasses[0]); + }); }); diff --git a/apps/web/modules/survey/editor/components/saved-actions-tab.tsx b/apps/web/modules/survey/editor/components/saved-actions-tab.tsx index 7bbc01671f..013c78bea0 100644 --- a/apps/web/modules/survey/editor/components/saved-actions-tab.tsx +++ b/apps/web/modules/survey/editor/components/saved-actions-tab.tsx @@ -1,6 +1,7 @@ "use client"; import { ACTION_TYPE_ICON_LOOKUP } from "@/app/(app)/environments/[environmentId]/actions/utils"; +import { ActionClassInfo } from "@/modules/ui/components/action-class-info"; import { Input } from "@/modules/ui/components/input"; import { ActionClass } from "@prisma/client"; import { useTranslate } from "@tolgee/react"; @@ -79,7 +80,7 @@ export const SavedActionsTab = ({

{action.name}

-

{action.description}

+ ))}
diff --git a/apps/web/modules/survey/editor/components/when-to-send-card.test.tsx b/apps/web/modules/survey/editor/components/when-to-send-card.test.tsx index c73776089b..a0a3d5b4bc 100644 --- a/apps/web/modules/survey/editor/components/when-to-send-card.test.tsx +++ b/apps/web/modules/survey/editor/components/when-to-send-card.test.tsx @@ -136,6 +136,42 @@ const mockActionClasses: ActionClass[] = [ urlFilters: [{ rule: "exactMatch", value: "http://example.com" }], }, }, + { + id: "action3", + createdAt: new Date(), + updatedAt: new Date(), + name: "Regex URL Action", + description: "A no-code action with regex URL filter", + type: "noCode", + environmentId: "env1", + key: null, + noCodeConfig: { + type: "pageView", + urlFilters: [ + { rule: "matchesRegex", value: "^https://app\\.example\\.com/user/\\d+$" }, + { rule: "contains", value: "/dashboard" }, + ], + }, + }, + { + id: "action4", + createdAt: new Date(), + updatedAt: new Date(), + name: "Multiple Filter Action", + description: "Action with multiple URL filter types including regex", + type: "noCode", + environmentId: "env1", + key: null, + noCodeConfig: { + type: "click", + elementSelector: { cssSelector: ".submit-btn" }, + urlFilters: [ + { rule: "startsWith", value: "https://secure" }, + { rule: "matchesRegex", value: "/checkout/\\w+/complete" }, + { rule: "notContains", value: "test" }, + ], + }, + }, ]; describe("WhenToSendCard Component Tests", () => { @@ -276,6 +312,124 @@ describe("WhenToSendCard Component Tests", () => { expect(setLocalSurvey).toHaveBeenCalledWith(expect.objectContaining({ triggers: [] })); }); + test("displays action with regex URL filters correctly", () => { + const regexActionClass = mockActionClasses[2]; // "Regex URL Action" + localSurvey.triggers = [{ actionClass: regexActionClass }]; + + render( + + ); + + // Verify the action name is displayed + expect(screen.getByText("Regex URL Action")).toBeInTheDocument(); + + // Verify the action description is displayed + expect(screen.getByText("A no-code action with regex URL filter")).toBeInTheDocument(); + }); + + test("displays action with multiple URL filter types including regex", () => { + const multipleFilterAction = mockActionClasses[3]; // "Multiple Filter Action" + localSurvey.triggers = [{ actionClass: multipleFilterAction }]; + + render( + + ); + + // Verify the action name is displayed + expect(screen.getByText("Multiple Filter Action")).toBeInTheDocument(); + + // Verify the action description is displayed + expect(screen.getByText("Action with multiple URL filter types including regex")).toBeInTheDocument(); + }); + + test("displays multiple triggers with mixed URL filter types", () => { + const standardAction = mockActionClasses[1]; // "No Code Action" with exactMatch + const regexAction = mockActionClasses[2]; // "Regex URL Action" with matchesRegex + + localSurvey.triggers = [{ actionClass: standardAction }, { actionClass: regexAction }]; + + render( + + ); + + // Verify both actions are displayed + expect(screen.getByText("No Code Action")).toBeInTheDocument(); + expect(screen.getByText("Regex URL Action")).toBeInTheDocument(); + + // Verify the "or" separator is shown between triggers + expect(screen.getByText("or")).toBeInTheDocument(); + }); + + test("removes regex action trigger correctly", async () => { + const regexAction = mockActionClasses[2]; // "Regex URL Action" + localSurvey.triggers = [{ actionClass: regexAction }]; + + const { container } = render( + + ); + + expect(screen.getByText("Regex URL Action")).toBeInTheDocument(); + + const trashIcon = container.querySelector("svg.lucide-trash2"); + if (!trashIcon) + throw new Error( + "Trash icon not found using selector 'svg.lucide-trash2'. Check component's class names." + ); + + await userEvent.click(trashIcon); + expect(setLocalSurvey).toHaveBeenCalledWith(expect.objectContaining({ triggers: [] })); + }); + + test("handles empty triggers array correctly", () => { + localSurvey.triggers = []; + + render( + + ); + + // Should show amber circle indicating empty triggers + const amberIndicator = document.querySelector(".border-amber-500.bg-amber-50"); + expect(amberIndicator).toBeInTheDocument(); + + // Should still show the "Add Action" button + expect(screen.getByText("common.add_action")).toBeInTheDocument(); + }); + describe("Delay functionality", () => { test("toggles delay and updates survey", async () => { const surveyStep0 = { ...localSurvey, delay: 0 }; // Start with delay 0 diff --git a/apps/web/modules/survey/editor/components/when-to-send-card.tsx b/apps/web/modules/survey/editor/components/when-to-send-card.tsx index d06bafa12b..5e9abe8379 100644 --- a/apps/web/modules/survey/editor/components/when-to-send-card.tsx +++ b/apps/web/modules/survey/editor/components/when-to-send-card.tsx @@ -5,6 +5,7 @@ import { getAccessFlags } from "@/lib/membership/utils"; import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team"; import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; import { AddActionModal } from "@/modules/survey/editor/components/add-action-modal"; +import { ActionClassInfo } from "@/modules/ui/components/action-class-info"; import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle"; import { Button } from "@/modules/ui/components/button"; import { Input } from "@/modules/ui/components/input"; @@ -199,48 +200,7 @@ export const WhenToSendCard = ({

{trigger.actionClass.name}

-
- {trigger.actionClass.description && ( - {trigger.actionClass.description} - )} - {trigger.actionClass.type === "code" && ( - - {t("environments.surveys.edit.key")}: {trigger.actionClass.key} - - )} - {trigger.actionClass.type === "noCode" && - trigger.actionClass.noCodeConfig?.type === "click" && - trigger.actionClass.noCodeConfig?.elementSelector.cssSelector && ( - - {t("environments.surveys.edit.css_selector")}:{" "} - {trigger.actionClass.noCodeConfig?.elementSelector.cssSelector} - - )} - {trigger.actionClass.type === "noCode" && - trigger.actionClass.noCodeConfig?.type === "click" && - trigger.actionClass.noCodeConfig?.elementSelector.innerHtml && ( - - {t("environments.surveys.edit.inner_text")}:{" "} - {trigger.actionClass.noCodeConfig?.elementSelector.innerHtml} - - )} - {trigger.actionClass.type === "noCode" && - trigger.actionClass.noCodeConfig?.urlFilters && - trigger.actionClass.noCodeConfig.urlFilters.length > 0 ? ( - - {t("environments.surveys.edit.url_filters")}:{" "} - {trigger.actionClass.noCodeConfig.urlFilters.map((urlFilter, index) => ( - - {urlFilter.rule} {urlFilter.value} - {trigger.actionClass.type === "noCode" && - index !== - (trigger.actionClass.noCodeConfig?.urlFilters?.length || 0) - 1 && - ", "} - - ))} - - ) : null} -
+ { + const translations: Record = { + "environments.actions.invalid_action_type_no_code": "Invalid action type for noCode action", + "environments.actions.invalid_action_type_code": "Invalid action type for code action", + }; + return translations[key] || key; +}) as unknown as TFnType; + +describe("action-builder", () => { + describe("buildActionObject", () => { + test("builds noCode action when type is noCode", () => { + const data: TActionClassInput = { + name: "Click Button", + type: "noCode", + environmentId: "env1", + noCodeConfig: { + type: "click", + urlFilters: [], + elementSelector: { + cssSelector: ".button", + innerHtml: "Click me", + }, + }, + }; + + const result = buildActionObject(data, "env1", mockT); + + expect(result).toEqual({ + name: "Click Button", + type: "noCode", + environmentId: "env1", + noCodeConfig: { + type: "click", + urlFilters: [], + elementSelector: { + cssSelector: ".button", + innerHtml: "Click me", + }, + }, + }); + }); + + test("builds code action when type is code", () => { + const data: TActionClassInput = { + name: "Track Event", + type: "code", + key: "track_event", + environmentId: "env1", + }; + + const result = buildActionObject(data, "env1", mockT); + + expect(result).toEqual({ + name: "Track Event", + type: "code", + key: "track_event", + environmentId: "env1", + }); + }); + }); + + describe("buildNoCodeAction", () => { + test("builds noCode action with click config", () => { + const data: TActionClassInput = { + name: "Click Button", + type: "noCode", + environmentId: "env1", + noCodeConfig: { + type: "click", + urlFilters: [{ rule: "exactMatch", value: "https://example.com" }], + elementSelector: { + cssSelector: ".button", + innerHtml: "Click me", + }, + }, + }; + + const result = buildNoCodeAction(data, "env1", mockT); + + expect(result).toEqual({ + name: "Click Button", + type: "noCode", + environmentId: "env1", + noCodeConfig: { + type: "click", + urlFilters: [{ rule: "exactMatch", value: "https://example.com" }], + elementSelector: { + cssSelector: ".button", + innerHtml: "Click me", + }, + }, + }); + }); + + test("builds noCode action with pageView config", () => { + const data: TActionClassInput = { + name: "Page Visit", + type: "noCode", + environmentId: "env1", + noCodeConfig: { + type: "pageView", + urlFilters: [{ rule: "contains", value: "/dashboard" }], + }, + }; + + const result = buildNoCodeAction(data, "env1", mockT); + + expect(result).toEqual({ + name: "Page Visit", + type: "noCode", + environmentId: "env1", + noCodeConfig: { + type: "pageView", + urlFilters: [{ rule: "contains", value: "/dashboard" }], + }, + }); + }); + + test("throws error for invalid action type", () => { + const data = { + name: "Invalid Action", + type: "code", + environmentId: "env1", + } as any; + + expect(() => buildNoCodeAction(data, "env1", mockT)).toThrow("Invalid action type for noCode action"); + }); + + test("includes optional fields when provided", () => { + const data: TActionClassInput = { + name: "Click Button", + description: "Click the submit button", + type: "noCode", + environmentId: "env1", + noCodeConfig: { + type: "click", + urlFilters: [], + elementSelector: { + cssSelector: ".button", + innerHtml: "Submit", + }, + }, + }; + + const result = buildNoCodeAction(data, "env1", mockT); + + expect(result).toEqual({ + name: "Click Button", + description: "Click the submit button", + type: "noCode", + environmentId: "env1", + noCodeConfig: { + type: "click", + urlFilters: [], + elementSelector: { + cssSelector: ".button", + innerHtml: "Submit", + }, + }, + }); + }); + }); + + describe("buildCodeAction", () => { + test("builds code action with required fields", () => { + const data: TActionClassInput = { + name: "Track Event", + type: "code", + key: "track_event", + environmentId: "env1", + }; + + const result = buildCodeAction(data, "env1", mockT); + + expect(result).toEqual({ + name: "Track Event", + type: "code", + key: "track_event", + environmentId: "env1", + }); + }); + + test("builds code action with optional description", () => { + const data: TActionClassInput = { + name: "Track Purchase", + description: "Track when user makes a purchase", + type: "code", + key: "track_purchase", + environmentId: "env1", + }; + + const result = buildCodeAction(data, "env1", mockT); + + expect(result).toEqual({ + name: "Track Purchase", + description: "Track when user makes a purchase", + type: "code", + key: "track_purchase", + environmentId: "env1", + }); + }); + + test("throws error for invalid action type", () => { + const data = { + name: "Invalid Action", + type: "noCode", + environmentId: "env1", + } as any; + + expect(() => buildCodeAction(data, "env1", mockT)).toThrow("Invalid action type for code action"); + }); + + test("handles null key", () => { + const data: TActionClassInput = { + name: "Track Event", + type: "code", + key: null, + environmentId: "env1", + }; + + const result = buildCodeAction(data, "env1", mockT); + + expect(result).toEqual({ + name: "Track Event", + type: "code", + key: null, + environmentId: "env1", + }); + }); + }); +}); diff --git a/apps/web/modules/survey/editor/lib/action-builder.ts b/apps/web/modules/survey/editor/lib/action-builder.ts new file mode 100644 index 0000000000..4180b51a80 --- /dev/null +++ b/apps/web/modules/survey/editor/lib/action-builder.ts @@ -0,0 +1,52 @@ +import { TFnType } from "@tolgee/react"; +import { TActionClassInput } from "@formbricks/types/action-classes"; + +export const buildActionObject = (data: TActionClassInput, environmentId: string, t: TFnType) => { + if (data.type === "noCode") { + return buildNoCodeAction(data, environmentId, t); + } + return buildCodeAction(data, environmentId, t); +}; + +export const buildNoCodeAction = (data: TActionClassInput, environmentId: string, t: TFnType) => { + if (data.type !== "noCode") { + throw new Error(t("environments.actions.invalid_action_type_no_code")); + } + + const baseAction = { + name: data.name.trim(), + description: data.description, + environmentId, + type: "noCode" as const, + noCodeConfig: data.noCodeConfig, + }; + + if (data.noCodeConfig?.type === "click") { + return { + ...baseAction, + noCodeConfig: { + ...data.noCodeConfig, + elementSelector: { + cssSelector: data.noCodeConfig.elementSelector.cssSelector, + innerHtml: data.noCodeConfig.elementSelector.innerHtml, + }, + }, + }; + } + + return baseAction; +}; + +export const buildCodeAction = (data: TActionClassInput, environmentId: string, t: TFnType) => { + if (data.type !== "code") { + throw new Error(t("environments.actions.invalid_action_type_code")); + } + + return { + name: data.name.trim(), + description: data.description, + environmentId, + type: "code" as const, + key: data.key, + }; +}; diff --git a/apps/web/modules/survey/editor/lib/action-utils.test.ts b/apps/web/modules/survey/editor/lib/action-utils.test.ts new file mode 100644 index 0000000000..9ec7301a5f --- /dev/null +++ b/apps/web/modules/survey/editor/lib/action-utils.test.ts @@ -0,0 +1,406 @@ +/** + * @vitest-environment jsdom + */ +import "@testing-library/jest-dom/vitest"; +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { z } from "zod"; +import { TActionClass } from "@formbricks/types/action-classes"; +import { + createActionClassZodResolver, + useActionClassKeys, + validateActionKeyUniqueness, + validateActionNameUniqueness, + validateCssSelector, + validatePermissions, + validateUrlFilterRegex, +} from "./action-utils"; + +// Mock the CSS selector validation function +vi.mock("@/app/lib/actionClass/actionClass", () => ({ + isValidCssSelector: vi.fn(), +})); + +const { isValidCssSelector } = await import("@/app/lib/actionClass/actionClass"); + +// Mock translation function +const mockT = vi.fn((key: string, params?: any) => { + if (key === "environments.actions.action_with_name_already_exists") { + return `Action with name "${params?.name}" already exists`; + } + if (key === "environments.actions.action_with_key_already_exists") { + return `Action with key "${params?.key}" already exists`; + } + if (key === "environments.actions.invalid_css_selector") { + return "Invalid CSS selector"; + } + if (key === "environments.actions.invalid_regex") { + return "Invalid regex pattern"; + } + if (key === "common.you_are_not_authorised_to_perform_this_action") { + return "You are not authorised to perform this action"; + } + return key; +}) as any; + +// Helper to create mock context +const createMockContext = () => { + const issues: z.ZodIssue[] = []; + return { + addIssue: vi.fn((issue: z.ZodIssue) => issues.push(issue)), + issues, + } as any; +}; + +describe("action-utils", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("useActionClassKeys", () => { + test("should extract keys from code-type action classes", () => { + const actionClasses: TActionClass[] = [ + { + id: "1", + name: "Code Action 1", + description: null, + type: "code", + key: "key1", + noCodeConfig: null, + environmentId: "env1", + createdAt: new Date(), + updatedAt: new Date(), + } as TActionClass, + { + id: "2", + name: "NoCode Action", + description: null, + type: "noCode", + key: null, + noCodeConfig: { + type: "click", + elementSelector: { cssSelector: "button", innerHtml: undefined }, + urlFilters: [], + }, + environmentId: "env1", + createdAt: new Date(), + updatedAt: new Date(), + } as TActionClass, + { + id: "3", + name: "Code Action 2", + description: null, + type: "code", + key: "key2", + noCodeConfig: null, + environmentId: "env1", + createdAt: new Date(), + updatedAt: new Date(), + } as TActionClass, + ]; + + const { result } = renderHook(() => useActionClassKeys(actionClasses)); + + expect(result.current).toEqual(["key1", "key2"]); + }); + + test("should filter out null keys", () => { + const actionClasses: TActionClass[] = [ + { + id: "1", + name: "Code Action 1", + description: null, + type: "code", + key: "key1", + noCodeConfig: null, + environmentId: "env1", + createdAt: new Date(), + updatedAt: new Date(), + } as TActionClass, + { + id: "2", + name: "Code Action 2", + description: null, + type: "code", + key: null, + noCodeConfig: null, + environmentId: "env1", + createdAt: new Date(), + updatedAt: new Date(), + } as TActionClass, + ]; + + const { result } = renderHook(() => useActionClassKeys(actionClasses)); + + expect(result.current).toEqual(["key1"]); + }); + + test("should return empty array when no code actions exist", () => { + const actionClasses: TActionClass[] = [ + { + id: "1", + name: "NoCode Action", + description: null, + type: "noCode", + key: null, + noCodeConfig: { + type: "click", + elementSelector: { cssSelector: "button", innerHtml: undefined }, + urlFilters: [], + }, + environmentId: "env1", + createdAt: new Date(), + updatedAt: new Date(), + } as TActionClass, + ]; + + const { result } = renderHook(() => useActionClassKeys(actionClasses)); + + expect(result.current).toEqual([]); + }); + }); + + describe("validateActionNameUniqueness", () => { + test("should add error when action name already exists", () => { + const ctx = createMockContext(); + const data = { name: "existingAction" }; + + validateActionNameUniqueness(data, ["existingAction"], ctx, mockT); + + expect(ctx.addIssue).toHaveBeenCalledWith({ + code: z.ZodIssueCode.custom, + path: ["name"], + message: 'Action with name "existingAction" already exists', + }); + }); + + test("should not add error when action name is unique", () => { + const ctx = createMockContext(); + const data = { name: "uniqueAction" }; + + validateActionNameUniqueness(data, ["existingAction"], ctx, mockT); + + expect(ctx.addIssue).not.toHaveBeenCalled(); + }); + + test("should not add error when name is undefined", () => { + const ctx = createMockContext(); + const data = { name: undefined }; + + validateActionNameUniqueness(data, ["existingAction"], ctx, mockT); + + expect(ctx.addIssue).not.toHaveBeenCalled(); + }); + }); + + describe("validateActionKeyUniqueness", () => { + test("should add error when code action key already exists", () => { + const ctx = createMockContext(); + const data = { type: "code", key: "existingKey" }; + + validateActionKeyUniqueness(data, ["existingKey"], ctx, mockT); + + expect(ctx.addIssue).toHaveBeenCalledWith({ + code: z.ZodIssueCode.custom, + path: ["key"], + message: 'Action with key "existingKey" already exists', + }); + }); + + test("should not add error when code action key is unique", () => { + const ctx = createMockContext(); + const data = { type: "code", key: "uniqueKey" }; + + validateActionKeyUniqueness(data, ["existingKey"], ctx, mockT); + + expect(ctx.addIssue).not.toHaveBeenCalled(); + }); + + test("should not validate key for non-code actions", () => { + const ctx = createMockContext(); + const data = { type: "noCode", key: "existingKey" }; + + validateActionKeyUniqueness(data, ["existingKey"], ctx, mockT); + + expect(ctx.addIssue).not.toHaveBeenCalled(); + }); + + test("should not add error when key is undefined", () => { + const ctx = createMockContext(); + const data = { type: "code", key: undefined }; + + validateActionKeyUniqueness(data, ["existingKey"], ctx, mockT); + + expect(ctx.addIssue).not.toHaveBeenCalled(); + }); + }); + + describe("validateCssSelector", () => { + test("should add error when CSS selector is invalid", () => { + const ctx = createMockContext(); + const data = { + type: "noCode", + noCodeConfig: { + type: "click", + elementSelector: { cssSelector: "invalid-selector" }, + }, + }; + + vi.mocked(isValidCssSelector).mockReturnValue(false); + + validateCssSelector(data, ctx, mockT); + + expect(ctx.addIssue).toHaveBeenCalledWith({ + code: z.ZodIssueCode.custom, + path: ["noCodeConfig", "elementSelector", "cssSelector"], + message: "Invalid CSS selector", + }); + }); + + test("should not add error when CSS selector is valid", () => { + const ctx = createMockContext(); + const data = { + type: "noCode", + noCodeConfig: { + type: "click", + elementSelector: { cssSelector: "valid-selector" }, + }, + }; + + vi.mocked(isValidCssSelector).mockReturnValue(true); + + validateCssSelector(data, ctx, mockT); + + expect(ctx.addIssue).not.toHaveBeenCalled(); + }); + + test("should not validate CSS selector for non-click noCode actions", () => { + const ctx = createMockContext(); + const data = { + type: "noCode", + noCodeConfig: { type: "pageView" }, + }; + + validateCssSelector(data, ctx, mockT); + + expect(ctx.addIssue).not.toHaveBeenCalled(); + }); + + test("should not validate CSS selector for code actions", () => { + const ctx = createMockContext(); + const data = { type: "code" }; + + validateCssSelector(data, ctx, mockT); + + expect(ctx.addIssue).not.toHaveBeenCalled(); + }); + }); + + describe("validateUrlFilterRegex", () => { + test("should add error when regex pattern is invalid", () => { + const ctx = createMockContext(); + const data = { + type: "noCode", + noCodeConfig: { + urlFilters: [{ rule: "matchesRegex", value: "[invalid-regex" }], + }, + }; + + validateUrlFilterRegex(data, ctx, mockT); + + expect(ctx.addIssue).toHaveBeenCalledWith({ + code: z.ZodIssueCode.custom, + path: ["noCodeConfig", "urlFilters", 0, "value"], + message: "Invalid regex pattern", + }); + }); + + test("should not add error when regex pattern is valid", () => { + const ctx = createMockContext(); + const data = { + type: "noCode", + noCodeConfig: { + urlFilters: [{ rule: "matchesRegex", value: "^https://.*" }], + }, + }; + + validateUrlFilterRegex(data, ctx, mockT); + + expect(ctx.addIssue).not.toHaveBeenCalled(); + }); + + test("should not validate regex for non-regex URL filter rules", () => { + const ctx = createMockContext(); + const data = { + type: "noCode", + noCodeConfig: { + urlFilters: [{ rule: "exactMatch", value: "some-value" }], + }, + }; + + validateUrlFilterRegex(data, ctx, mockT); + + expect(ctx.addIssue).not.toHaveBeenCalled(); + }); + + test("should not validate for code actions", () => { + const ctx = createMockContext(); + const data = { type: "code" }; + + validateUrlFilterRegex(data, ctx, mockT); + + expect(ctx.addIssue).not.toHaveBeenCalled(); + }); + + test("should validate multiple URL filters", () => { + const ctx = createMockContext(); + const data = { + type: "noCode", + noCodeConfig: { + urlFilters: [ + { rule: "matchesRegex", value: "^https://.*" }, + { rule: "matchesRegex", value: "[invalid-regex" }, + ], + }, + }; + + validateUrlFilterRegex(data, ctx, mockT); + + expect(ctx.addIssue).toHaveBeenCalledTimes(1); + expect(ctx.addIssue).toHaveBeenCalledWith({ + code: z.ZodIssueCode.custom, + path: ["noCodeConfig", "urlFilters", 1, "value"], + message: "Invalid regex pattern", + }); + }); + }); + + describe("createActionClassZodResolver", () => { + test("should return a zodResolver function", () => { + const resolver = createActionClassZodResolver([], [], mockT); + expect(typeof resolver).toBe("function"); + }); + + test("should create resolver with correct parameters", () => { + const testResolver = createActionClassZodResolver(["testAction"], ["testKey"], mockT); + expect(testResolver).toBeDefined(); + expect(typeof testResolver).toBe("function"); + }); + + test("should handle empty arrays", () => { + const emptyResolver = createActionClassZodResolver([], [], mockT); + expect(emptyResolver).toBeDefined(); + expect(typeof emptyResolver).toBe("function"); + }); + }); + + describe("validatePermissions", () => { + test("should throw error when user is read-only", () => { + expect(() => validatePermissions(true, mockT)).toThrow("You are not authorised to perform this action"); + }); + + test("should not throw error when user has write permissions", () => { + expect(() => validatePermissions(false, mockT)).not.toThrow(); + }); + }); +}); diff --git a/apps/web/modules/survey/editor/lib/action-utils.ts b/apps/web/modules/survey/editor/lib/action-utils.ts new file mode 100644 index 0000000000..06d873876a --- /dev/null +++ b/apps/web/modules/survey/editor/lib/action-utils.ts @@ -0,0 +1,129 @@ +import { isValidCssSelector } from "@/app/lib/actionClass/actionClass"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { TFnType } from "@tolgee/react"; +import { useMemo } from "react"; +import { z } from "zod"; +import { + TActionClass, + TActionClassInput, + TActionClassInputCode, + ZActionClassInput, +} from "@formbricks/types/action-classes"; + +/** + * Extract action class keys from code-type action classes + */ +export const useActionClassKeys = (actionClasses: TActionClass[]) => { + return useMemo(() => { + const codeActionClasses: TActionClassInputCode[] = actionClasses.filter( + (actionClass) => actionClass.type === "code" + ) as TActionClassInputCode[]; + + return codeActionClasses + .map((actionClass) => actionClass.key) + .filter((key): key is string => key !== null); + }, [actionClasses]); +}; + +/** + * Validate action name uniqueness + */ +export const validateActionNameUniqueness = ( + data: TActionClassInput, + actionClassNames: string[], + ctx: z.RefinementCtx, + t: TFnType +) => { + if (data.name && actionClassNames.includes(data.name)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["name"], + message: t("environments.actions.action_with_name_already_exists", { name: data.name }), + }); + } +}; + +/** + * Validate action key uniqueness for code actions + */ +export const validateActionKeyUniqueness = ( + data: TActionClassInput, + actionClassKeys: string[], + ctx: z.RefinementCtx, + t: TFnType +) => { + if (data.type === "code" && data.key && actionClassKeys.includes(data.key)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["key"], + message: t("environments.actions.action_with_key_already_exists", { key: data.key }), + }); + } +}; + +/** + * Validate CSS selector for noCode click actions + */ +export const validateCssSelector = (data: TActionClassInput, ctx: z.RefinementCtx, t: TFnType) => { + if ( + data.type === "noCode" && + data.noCodeConfig?.type === "click" && + data.noCodeConfig.elementSelector.cssSelector && + !isValidCssSelector(data.noCodeConfig.elementSelector.cssSelector) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["noCodeConfig", "elementSelector", "cssSelector"], + message: t("environments.actions.invalid_css_selector"), + }); + } +}; + +/** + * Validate regex patterns in URL filters + */ +export const validateUrlFilterRegex = (data: TActionClassInput, ctx: z.RefinementCtx, t: TFnType) => { + if (data.type === "noCode" && data.noCodeConfig?.urlFilters) { + for (let i = 0; i < data.noCodeConfig.urlFilters.length; i++) { + const urlFilter = data.noCodeConfig.urlFilters[i]; + if (urlFilter.rule === "matchesRegex") { + try { + new RegExp(urlFilter.value); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["noCodeConfig", "urlFilters", i, "value"], + message: t("environments.actions.invalid_regex"), + }); + } + } + } + } +}; + +/** + * Create a zodResolver with comprehensive validation for action class forms + */ +export const createActionClassZodResolver = ( + actionClassNames: string[], + actionClassKeys: string[], + t: TFnType +) => { + return zodResolver( + ZActionClassInput.superRefine((data, ctx) => { + validateActionNameUniqueness(data, actionClassNames, ctx, t); + validateActionKeyUniqueness(data, actionClassKeys, ctx, t); + validateCssSelector(data, ctx, t); + validateUrlFilterRegex(data, ctx, t); + }) + ); +}; + +/** + * Validate permissions for action class forms + */ +export const validatePermissions = (isReadOnly: boolean, t: TFnType) => { + if (isReadOnly) { + throw new Error(t("common.you_are_not_authorised_to_perform_this_action")); + } +}; diff --git a/apps/web/modules/ui/components/action-class-info/index.test.tsx b/apps/web/modules/ui/components/action-class-info/index.test.tsx new file mode 100644 index 0000000000..6675fe173b --- /dev/null +++ b/apps/web/modules/ui/components/action-class-info/index.test.tsx @@ -0,0 +1,368 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TActionClass } from "@formbricks/types/action-classes"; +import { ActionClassInfo } from "./index"; + +describe("ActionClassInfo", () => { + afterEach(() => { + cleanup(); + }); + + const mockCodeActionClass: TActionClass = { + id: "action-1", + name: "Code Action", + description: "Test code action description", + type: "code", + key: "test-key", + noCodeConfig: null, + environmentId: "env-1", + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockNoCodeClickActionClass: TActionClass = { + id: "action-2", + name: "NoCode Click Action", + description: "Test nocode click action description", + type: "noCode", + key: null, + noCodeConfig: { + type: "click", + urlFilters: [ + { rule: "exactMatch", value: "https://example.com" }, + { rule: "contains", value: "/dashboard" }, + ], + elementSelector: { + cssSelector: ".button-class", + innerHtml: "Click me", + }, + }, + environmentId: "env-1", + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockNoCodePageViewActionClass: TActionClass = { + id: "action-3", + name: "NoCode PageView Action", + description: "Test nocode pageview action description", + type: "noCode", + key: null, + noCodeConfig: { + type: "pageView", + urlFilters: [{ rule: "startsWith", value: "https://app.example.com" }], + }, + environmentId: "env-1", + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockActionClassWithoutDescription: TActionClass = { + id: "action-4", + name: "Action Without Description", + description: null, + type: "code", + key: "no-desc-key", + noCodeConfig: null, + environmentId: "env-1", + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockRegexActionClass: TActionClass = { + id: "action-5", + name: "Regex Action", + description: "Test regex action description", + type: "noCode", + key: null, + noCodeConfig: { + type: "pageView", + urlFilters: [ + { rule: "matchesRegex", value: "^https://app\\.example\\.com/user/\\d+$" }, + { rule: "contains", value: "/dashboard" }, + ], + }, + environmentId: "env-1", + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockMixedFilterActionClass: TActionClass = { + id: "action-6", + name: "Mixed Filter Action", + description: "Test action with mixed filter types", + type: "noCode", + key: null, + noCodeConfig: { + type: "click", + urlFilters: [ + { rule: "startsWith", value: "https://secure" }, + { rule: "matchesRegex", value: "/checkout/\\w+/complete" }, + { rule: "notContains", value: "test" }, + ], + elementSelector: { + cssSelector: ".submit-btn", + innerHtml: "Submit", + }, + }, + environmentId: "env-1", + createdAt: new Date(), + updatedAt: new Date(), + }; + + test("renders description when present", () => { + render(); + + expect(screen.getByText("Test code action description")).toBeInTheDocument(); + }); + + test("does not render description when null", () => { + render(); + + expect(screen.queryByText("Test code action description")).not.toBeInTheDocument(); + }); + + test("renders code action key", () => { + render(); + + expect( + screen.getByText((content) => content.includes("environments.surveys.edit.key")) + ).toBeInTheDocument(); + expect(screen.getByText("test-key")).toBeInTheDocument(); + }); + + test("renders noCode click action with CSS selector", () => { + render(); + + expect( + screen.getByText((content) => content.includes("environments.surveys.edit.css_selector")) + ).toBeInTheDocument(); + expect(screen.getByText(".button-class")).toBeInTheDocument(); + }); + + test("renders noCode click action with inner HTML", () => { + render(); + + expect( + screen.getByText((content) => content.includes("environments.surveys.edit.inner_text")) + ).toBeInTheDocument(); + expect(screen.getByText("Click me")).toBeInTheDocument(); + }); + + test("renders URL filters for noCode actions", () => { + const { container } = render(); + + expect( + screen.getByText((content) => content.includes("environments.surveys.edit.url_filters")) + ).toBeInTheDocument(); + expect(container).toHaveTextContent("exactMatch"); + expect(container).toHaveTextContent("https://example.com"); + expect(container).toHaveTextContent("contains"); + expect(container).toHaveTextContent("/dashboard"); + }); + + test("renders URL filters with comma separation", () => { + const { container } = render(); + + expect(container).toHaveTextContent("exactMatch"); + expect(container).toHaveTextContent("https://example.com"); + expect(container).toHaveTextContent("contains"); + expect(container).toHaveTextContent("/dashboard"); + }); + + test("renders noCode pageView action without element selector", () => { + render(); + + expect(screen.getByText("Test nocode pageview action description")).toBeInTheDocument(); + expect( + screen.getByText((content) => content.includes("environments.surveys.edit.url_filters")) + ).toBeInTheDocument(); + expect(screen.getByText("startsWith")).toBeInTheDocument(); + expect(screen.getByText("https://app.example.com")).toBeInTheDocument(); + + // Should not render CSS selector or inner HTML for pageView + expect( + screen.queryByText((content) => content.includes("environments.surveys.edit.css_selector")) + ).not.toBeInTheDocument(); + expect( + screen.queryByText((content) => content.includes("environments.surveys.edit.inner_text")) + ).not.toBeInTheDocument(); + }); + + test("does not render URL filters when empty", () => { + const actionWithoutUrlFilters: TActionClass = { + ...mockNoCodeClickActionClass, + noCodeConfig: { + type: "click", + urlFilters: [], + elementSelector: { + cssSelector: ".button-class", + innerHtml: "Click me", + }, + }, + }; + + render(); + + expect(screen.queryByText("environments.surveys.edit.url_filters")).not.toBeInTheDocument(); + }); + + test("does not render CSS selector when not present", () => { + const actionWithoutCssSelector: TActionClass = { + ...mockNoCodeClickActionClass, + noCodeConfig: { + type: "click", + urlFilters: [{ rule: "exactMatch", value: "https://example.com" }], + elementSelector: { + innerHtml: "Click me", + }, + }, + }; + + render(); + + expect( + screen.queryByText((content) => content.includes("environments.surveys.edit.css_selector")) + ).not.toBeInTheDocument(); + expect( + screen.getByText((content) => content.includes("environments.surveys.edit.inner_text")) + ).toBeInTheDocument(); + }); + + test("does not render inner HTML when not present", () => { + const actionWithoutInnerHtml: TActionClass = { + ...mockNoCodeClickActionClass, + noCodeConfig: { + type: "click", + urlFilters: [{ rule: "exactMatch", value: "https://example.com" }], + elementSelector: { + cssSelector: ".button-class", + }, + }, + }; + + render(); + + expect( + screen.getByText((content) => content.includes("environments.surveys.edit.css_selector")) + ).toBeInTheDocument(); + expect( + screen.queryByText((content) => content.includes("environments.surveys.edit.inner_text")) + ).not.toBeInTheDocument(); + }); + + test("applies custom className", () => { + const { container } = render( + + ); + + const div = container.querySelector("div"); + expect(div).toHaveClass("custom-class"); + }); + + test("has correct default styling", () => { + const { container } = render(); + + const div = container.querySelector("div"); + expect(div).toHaveClass("mt-1", "text-xs", "text-slate-500"); + }); + + test("renders single URL filter without comma", () => { + const actionWithSingleFilter: TActionClass = { + ...mockNoCodeClickActionClass, + noCodeConfig: { + type: "click", + urlFilters: [{ rule: "exactMatch", value: "https://example.com" }], + elementSelector: { + cssSelector: ".button-class", + innerHtml: "Click me", + }, + }, + }; + + const { container } = render(); + + expect(container).toHaveTextContent("exactMatch"); + expect(container).toHaveTextContent("https://example.com"); + expect(container).not.toHaveTextContent(","); + }); + + test("renders URL filters with regex rule", () => { + const { container } = render(); + + expect( + screen.getByText((content) => content.includes("environments.surveys.edit.url_filters")) + ).toBeInTheDocument(); + expect(container).toHaveTextContent("matchesRegex"); + expect(container).toHaveTextContent("^https://app\\.example\\.com/user/\\d+$"); + expect(container).toHaveTextContent("contains"); + expect(container).toHaveTextContent("/dashboard"); + }); + + test("renders mixed URL filter types including regex", () => { + const { container } = render(); + + expect( + screen.getByText((content) => content.includes("environments.surveys.edit.url_filters")) + ).toBeInTheDocument(); + + // Check all filter types are displayed + expect(container).toHaveTextContent("startsWith"); + expect(container).toHaveTextContent("https://secure"); + expect(container).toHaveTextContent("matchesRegex"); + expect(container).toHaveTextContent("/checkout/\\w+/complete"); + expect(container).toHaveTextContent("notContains"); + expect(container).toHaveTextContent("test"); + + // Check element selector is also displayed + expect( + screen.getByText((content) => content.includes("environments.surveys.edit.css_selector")) + ).toBeInTheDocument(); + expect(screen.getByText(".submit-btn")).toBeInTheDocument(); + expect( + screen.getByText((content) => content.includes("environments.surveys.edit.inner_text")) + ).toBeInTheDocument(); + expect(screen.getByText("Submit")).toBeInTheDocument(); + }); + + test("renders complex regex patterns correctly", () => { + const complexRegexAction: TActionClass = { + ...mockRegexActionClass, + noCodeConfig: { + type: "pageView", + urlFilters: [ + { + rule: "matchesRegex", + value: "^https://(app|admin)\\.example\\.com/(?:user|profile)/\\d+(?:/edit)?$", + }, + ], + }, + }; + + const { container } = render(); + + expect(container).toHaveTextContent("matchesRegex"); + expect(container).toHaveTextContent( + "^https://(app|admin)\\.example\\.com/(?:user|profile)/\\d+(?:/edit)?$" + ); + }); + + test("handles regex with special characters in display", () => { + const specialCharsRegexAction: TActionClass = { + ...mockRegexActionClass, + noCodeConfig: { + type: "click", + urlFilters: [{ rule: "matchesRegex", value: "\\[\\{\\(.*\\)\\}\\]" }], + elementSelector: { + cssSelector: ".btn", + }, + }, + }; + + const { container } = render(); + + expect(container).toHaveTextContent("matchesRegex"); + expect(container).toHaveTextContent("\\[\\{\\(.*\\)\\}\\]"); + }); +}); diff --git a/apps/web/modules/ui/components/action-class-info/index.tsx b/apps/web/modules/ui/components/action-class-info/index.tsx new file mode 100644 index 0000000000..6c2798527a --- /dev/null +++ b/apps/web/modules/ui/components/action-class-info/index.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useTranslate } from "@tolgee/react"; +import { TActionClass } from "@formbricks/types/action-classes"; + +interface ActionClassInfoProps { + actionClass: TActionClass; + className?: string; +} + +const InfoItem = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +export const ActionClassInfo = ({ actionClass, className = "" }: ActionClassInfoProps) => { + const { t } = useTranslate(); + + const renderUrlFilters = () => { + const urlFilters = actionClass.noCodeConfig?.urlFilters; + if (!urlFilters?.length) return null; + + return ( + + {t("environments.surveys.edit.url_filters")}:{" "} + {urlFilters.map((urlFilter, index) => ( + + {urlFilter.rule} {urlFilter.value} + {index !== urlFilters.length - 1 && ", "} + + ))} + + ); + }; + + const isNoCodeClick = actionClass.type === "noCode" && actionClass.noCodeConfig?.type === "click"; + + const clickConfig = isNoCodeClick + ? (actionClass.noCodeConfig as Extract) + : null; + + return ( +
+ {actionClass.description && {actionClass.description}} + + {actionClass.type === "code" && ( + + {t("environments.surveys.edit.key")}: {actionClass.key} + + )} + + {clickConfig?.elementSelector.cssSelector && ( + + {t("environments.surveys.edit.css_selector")}: {clickConfig.elementSelector.cssSelector} + + )} + + {clickConfig?.elementSelector.innerHtml && ( + + {t("environments.surveys.edit.inner_text")}: {clickConfig.elementSelector.innerHtml} + + )} + + {renderUrlFilters()} +
+ ); +}; diff --git a/apps/web/modules/ui/components/action-name-description-fields/index.test.tsx b/apps/web/modules/ui/components/action-name-description-fields/index.test.tsx new file mode 100644 index 0000000000..3c169bad3e --- /dev/null +++ b/apps/web/modules/ui/components/action-name-description-fields/index.test.tsx @@ -0,0 +1,211 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useForm } from "react-hook-form"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TActionClassInput } from "@formbricks/types/action-classes"; +import { ActionNameDescriptionFields } from "./index"; + +// Mock the form components +vi.mock("@/modules/ui/components/form", () => ({ + FormControl: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + FormField: ({ name, render }: any) => { + const field = { + value: "", + onChange: vi.fn(), + onBlur: vi.fn(), + name: name, + ref: vi.fn(), + }; + const fieldState = { error: null }; + return render({ field, fieldState }); + }, + FormItem: ({ children }: { children: React.ReactNode }) =>
{children}
, + FormLabel: ({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) => ( + + ), + FormError: () =>
Form Error
, +})); + +// Mock the Input component +vi.mock("@/modules/ui/components/input", () => ({ + Input: ({ type, id, placeholder, disabled, isInvalid, ...props }: any) => ( + + ), +})); + +// Test wrapper component +const TestWrapper = ({ + isReadOnly = false, + nameInputId = "actionNameInput", + descriptionInputId = "actionDescriptionInput", + showSeparator = false, +}: { + isReadOnly?: boolean; + nameInputId?: string; + descriptionInputId?: string; + showSeparator?: boolean; +}) => { + const { control } = useForm({ + defaultValues: { + name: "", + description: "", + }, + }); + + return ( + + ); +}; + +// Test wrapper with default props +const TestWrapperDefault = () => { + const { control } = useForm({ + defaultValues: { + name: "", + description: "", + }, + }); + + return ; +}; + +describe("ActionNameDescriptionFields", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders name and description fields correctly", () => { + render(); + + expect(screen.getByTestId("input-actionNameInput")).toBeInTheDocument(); + expect(screen.getByTestId("input-actionDescriptionInput")).toBeInTheDocument(); + expect(screen.getByText("environments.actions.what_did_your_user_do")).toBeInTheDocument(); + expect(screen.getByText("common.description")).toBeInTheDocument(); + }); + + test("displays correct placeholders using translation keys", () => { + render(); + + const nameInput = screen.getByTestId("input-actionNameInput"); + const descriptionInput = screen.getByTestId("input-actionDescriptionInput"); + + expect(nameInput).toHaveAttribute("placeholder", "environments.actions.eg_clicked_download"); + expect(descriptionInput).toHaveAttribute( + "placeholder", + "environments.actions.user_clicked_download_button" + ); + }); + + test("renders with custom input IDs", () => { + render(); + + expect(screen.getByTestId("input-customNameId")).toBeInTheDocument(); + expect(screen.getByTestId("input-customDescriptionId")).toBeInTheDocument(); + }); + + test("renders inputs as disabled when isReadOnly is true", () => { + render(); + + const nameInput = screen.getByTestId("input-actionNameInput"); + const descriptionInput = screen.getByTestId("input-actionDescriptionInput"); + + expect(nameInput).toBeDisabled(); + expect(descriptionInput).toBeDisabled(); + }); + + test("renders inputs as enabled when isReadOnly is false", () => { + render(); + + const nameInput = screen.getByTestId("input-actionNameInput"); + const descriptionInput = screen.getByTestId("input-actionDescriptionInput"); + + expect(nameInput).not.toBeDisabled(); + expect(descriptionInput).not.toBeDisabled(); + }); + + test("shows separator when showSeparator is true", () => { + render(); + + const separator = screen.getByRole("separator"); + expect(separator).toBeInTheDocument(); + expect(separator).toHaveClass("border-slate-200"); + }); + + test("renders form structure correctly with two columns", () => { + render(); + + const nameFormItem = screen.getAllByTestId("form-item")[0]; + const descriptionFormItem = screen.getAllByTestId("form-item")[1]; + + expect(nameFormItem).toBeInTheDocument(); + expect(descriptionFormItem).toBeInTheDocument(); + }); + + test("renders form labels correctly", () => { + render(); + + expect(screen.getAllByTestId("form-label")).toHaveLength(2); + expect(screen.getByText("environments.actions.what_did_your_user_do")).toBeInTheDocument(); + expect(screen.getByText("common.description")).toBeInTheDocument(); + }); + + test("renders form controls and items correctly", () => { + render(); + + expect(screen.getAllByTestId("form-control")).toHaveLength(2); + expect(screen.getAllByTestId("form-item")).toHaveLength(2); + }); + + test("renders with default prop values", () => { + render(); + + expect(screen.getByTestId("input-actionNameInput")).toBeInTheDocument(); + expect(screen.getByTestId("input-actionDescriptionInput")).toBeInTheDocument(); + }); + + test("handles user interactions with name field", async () => { + const user = userEvent.setup(); + render(); + + const nameInput = screen.getByTestId("input-actionNameInput"); + await user.click(nameInput); + + expect(nameInput).toBeInTheDocument(); + }); + + test("handles user interactions with description field", async () => { + const user = userEvent.setup(); + render(); + + const descriptionInput = screen.getByTestId("input-actionDescriptionInput"); + await user.click(descriptionInput); + + expect(descriptionInput).toBeInTheDocument(); + }); + + test("description field handles empty value correctly", () => { + render(); + + const descriptionInput = screen.getByTestId("input-actionDescriptionInput"); + expect(descriptionInput).toHaveAttribute("value", ""); + }); +}); diff --git a/apps/web/modules/ui/components/action-name-description-fields/index.tsx b/apps/web/modules/ui/components/action-name-description-fields/index.tsx new file mode 100644 index 0000000000..787a244d96 --- /dev/null +++ b/apps/web/modules/ui/components/action-name-description-fields/index.tsx @@ -0,0 +1,78 @@ +import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form"; +import { Input } from "@/modules/ui/components/input"; +import { useTranslate } from "@tolgee/react"; +import { Control } from "react-hook-form"; +import { TActionClassInput } from "@formbricks/types/action-classes"; + +interface ActionNameDescriptionFieldsProps { + control: Control; + isReadOnly?: boolean; + nameInputId?: string; + descriptionInputId?: string; +} + +export const ActionNameDescriptionFields = ({ + control, + isReadOnly = false, + nameInputId = "actionNameInput", + descriptionInputId = "actionDescriptionInput", +}: ActionNameDescriptionFieldsProps) => { + const { t } = useTranslate(); + + return ( + <> +
+
+ ( + + {t("environments.actions.what_did_your_user_do")} + + + + + + + + )} + /> +
+ +
+ ( + + {t("common.description")} + + + + + + )} + /> +
+
+ +
+ + ); +}; diff --git a/apps/web/modules/ui/components/button/index.test.tsx b/apps/web/modules/ui/components/button/index.test.tsx index e0d77a56ac..fd670524da 100644 --- a/apps/web/modules/ui/components/button/index.test.tsx +++ b/apps/web/modules/ui/components/button/index.test.tsx @@ -35,7 +35,7 @@ describe("Button", () => { }); test("applies correct size classes", () => { - const { rerender } = render(); + const { rerender } = render(); expect(screen.getByRole("button")).toHaveClass("h-9", "px-4", "py-2"); rerender(); @@ -46,6 +46,9 @@ describe("Button", () => { rerender(); expect(screen.getByRole("button")).toHaveClass("h-9", "w-9"); + + rerender(); + expect(screen.getByRole("button")).toHaveClass("h-10", "px-3", "text-xs"); }); test("renders as a different element when asChild is true", () => { diff --git a/apps/web/modules/ui/components/button/index.tsx b/apps/web/modules/ui/components/button/index.tsx index 9498b66d42..06456198e9 100644 --- a/apps/web/modules/ui/components/button/index.tsx +++ b/apps/web/modules/ui/components/button/index.tsx @@ -21,6 +21,7 @@ const buttonVariants = cva( sm: "h-8 rounded-md px-3 text-xs", lg: "h-10 rounded-md px-8", icon: "h-9 w-9", + tall: "h-10 rounded-md px-3 text-xs", }, loading: { true: "cursor-not-allowed opacity-50", diff --git a/apps/web/modules/ui/components/button/stories.tsx b/apps/web/modules/ui/components/button/stories.tsx index 4b6392bb75..843a18df7b 100644 --- a/apps/web/modules/ui/components/button/stories.tsx +++ b/apps/web/modules/ui/components/button/stories.tsx @@ -69,7 +69,7 @@ const meta: Meta = { }, size: { control: "select", - options: ["default", "sm", "lg", "icon"], + options: ["default", "sm", "lg", "icon", "tall"], description: "Size of the button", table: { category: "Appearance", @@ -208,6 +208,20 @@ export const Icon: Story = { }, }; +export const Tall: Story = { + args: { + children: "Tall Button", + size: "tall", + }, + parameters: { + docs: { + description: { + story: "Use for buttons that need more height while maintaining compact padding.", + }, + }, + }, +}; + export const Loading: Story = { args: { children: "Loading...", diff --git a/apps/web/modules/ui/components/code-action-form/index.test.tsx b/apps/web/modules/ui/components/code-action-form/index.test.tsx index ff37a89a87..309d0c495f 100644 --- a/apps/web/modules/ui/components/code-action-form/index.test.tsx +++ b/apps/web/modules/ui/components/code-action-form/index.test.tsx @@ -31,6 +31,7 @@ vi.mock("@/modules/ui/components/form", () => ({ }, FormItem: ({ children }: { children: React.ReactNode }) =>
{children}
, FormLabel: ({ children }: { children: React.ReactNode }) =>
{children}
, + FormError: () =>
Form Error
, })); vi.mock("@/modules/ui/components/input", () => ({ diff --git a/apps/web/modules/ui/components/code-action-form/index.tsx b/apps/web/modules/ui/components/code-action-form/index.tsx index 0f30e87cb1..8c9eddf841 100644 --- a/apps/web/modules/ui/components/code-action-form/index.tsx +++ b/apps/web/modules/ui/components/code-action-form/index.tsx @@ -1,7 +1,7 @@ "use client"; import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert"; -import { FormControl, FormField, FormItem, FormLabel } from "@/modules/ui/components/form"; +import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form"; import { Input } from "@/modules/ui/components/input"; import { useTranslate } from "@tolgee/react"; import { Terminal } from "lucide-react"; @@ -15,7 +15,7 @@ export const CodeActionForm = ({ form, isReadOnly }: CodeActionFormProps) => { const { control, watch } = form; const { t } = useTranslate(); return ( - <> +
{ disabled={isReadOnly} /> + )} /> @@ -52,9 +53,9 @@ export const CodeActionForm = ({ form, isReadOnly }: CodeActionFormProps) => { {t("common.docs")} - . + {"."} - +
); }; diff --git a/apps/web/modules/ui/components/no-code-action-form/components/page-url-selector.test.tsx b/apps/web/modules/ui/components/no-code-action-form/components/page-url-selector.test.tsx index 23e9de93a3..95a8c1b434 100644 --- a/apps/web/modules/ui/components/no-code-action-form/components/page-url-selector.test.tsx +++ b/apps/web/modules/ui/components/no-code-action-form/components/page-url-selector.test.tsx @@ -1,22 +1,32 @@ +import { Select, SelectContent, SelectItem } from "@/modules/ui/components/select"; import "@testing-library/jest-dom/vitest"; import { cleanup, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import React from "react"; import { useForm } from "react-hook-form"; import { afterEach, describe, expect, test, vi } from "vitest"; -import { TActionClassInput } from "@formbricks/types/action-classes"; +import { ACTION_CLASS_PAGE_URL_RULES, TActionClassInput } from "@formbricks/types/action-classes"; import { PageUrlSelector } from "./page-url-selector"; // Mock testURLmatch function vi.mock("@/lib/utils/url", () => ({ - testURLmatch: vi.fn((testUrl, value, rule) => { - // Simple mock implementation - if (rule === "exactMatch" && testUrl === value) return "yes"; - if (rule === "contains" && testUrl.includes(value)) return "yes"; - if (rule === "startsWith" && testUrl.startsWith(value)) return "yes"; - if (rule === "endsWith" && testUrl.endsWith(value)) return "yes"; - if (rule === "notMatch" && testUrl !== value) return "yes"; - if (rule === "notContains" && !testUrl.includes(value)) return "yes"; - return "no"; + testURLmatch: vi.fn((testUrl, value, rule, t) => { + // Updated mock implementation to match new function signature + if (rule === "exactMatch") return testUrl === value; + if (rule === "contains") return testUrl.includes(value); + if (rule === "startsWith") return testUrl.startsWith(value); + if (rule === "endsWith") return testUrl.endsWith(value); + if (rule === "notMatch") return testUrl !== value; + if (rule === "notContains") return !testUrl.includes(value); + if (rule === "matchesRegex") { + try { + const regex = new RegExp(value); + return regex.test(testUrl); + } catch { + throw new Error(t("environments.actions.invalid_regex")); + } + } + throw new Error(t("environments.actions.invalid_match_type")); }), })); @@ -66,7 +76,7 @@ vi.mock("@/modules/ui/components/input", () => ({ placeholder={placeholder} disabled={disabled} value={value || ""} - onChange={(e) => onChange && onChange(e)} + onChange={(e) => onChange?.(e)} data-invalid={isInvalid} autoComplete={autoComplete} {...rest} @@ -95,25 +105,43 @@ vi.mock("@/modules/ui/components/button", () => ({ })); // Mock the Select component -vi.mock("@/modules/ui/components/select", () => ({ - Select: ({ children, onValueChange, value, name, disabled }: any) => ( -
- {children} -
- ), - SelectContent: ({ children }: any) =>
{children}
, - SelectItem: ({ children, value }: any) => ( -
- {children} -
- ), - SelectTrigger: ({ children, className }: any) => ( -
- {children} -
- ), - SelectValue: ({ placeholder }: any) =>
{placeholder}
, -})); +vi.mock("@/modules/ui/components/select", async () => { + const React = await import("react"); + const SelectContext = React.createContext<{ onValueChange?: (value: string) => void }>({}); + + return { + Select: ({ children, value, name, disabled, onValueChange }: any) => { + const contextValue = React.useMemo(() => ({ onValueChange }), [onValueChange]); + return ( + +
+ {children} +
+
+ ); + }, + SelectContent: ({ children }: any) =>
{children}
, + SelectItem: ({ children, value }: any) => { + const context = React.useContext(SelectContext); + return ( + + ); + }, + SelectTrigger: ({ children, className }: any) => ( +
+ {children} +
+ ), + SelectValue: ({ placeholder }: any) =>
{placeholder}
, + }; +}); // Mock the Label component vi.mock("@/modules/ui/components/label", () => ({ @@ -154,6 +182,7 @@ vi.mock("@/modules/ui/components/form", () => ({ fieldState: { error: null }, }), FormItem: ({ children, className }: any) =>
{children}
, + FormError: () =>
Form Error
, })); // Mock the tolgee translation @@ -166,7 +195,7 @@ vi.mock("@tolgee/react", () => ({ // Helper component for the form const TestWrapper = ({ urlFilters = [] as { - rule: "startsWith" | "exactMatch" | "contains" | "endsWith" | "notMatch" | "notContains"; + rule: "startsWith" | "exactMatch" | "contains" | "endsWith" | "notMatch" | "notContains" | "matchesRegex"; value: string; }[], isReadOnly = false, @@ -188,6 +217,7 @@ const TestWrapper = ({ describe("PageUrlSelector", () => { afterEach(() => { cleanup(); + vi.clearAllMocks(); }); test("renders with default values and 'all' filter type", () => { @@ -233,21 +263,267 @@ describe("PageUrlSelector", () => { expect(trashIcons.length).toBe(2); }); - test("test URL match functionality", async () => { + test("test URL match functionality - successful match", async () => { const testUrl = "https://example.com/pricing"; const urlFilters = [{ rule: "contains" as const, value: "pricing" }]; render(); const testInput = screen.getByTestId("input-noCodeConfig.urlFilters.testUrl"); - // Updated testId to match the actual button's testId from our mock const testButton = screen.getByTestId("button-environments.actions.test_match"); await userEvent.type(testInput, testUrl); await userEvent.click(testButton); - // Toast should be called to show match result + // Toast should be called to show successful match const toast = await import("react-hot-toast"); - expect(toast.default.success).toHaveBeenCalled(); + expect(toast.default.success).toHaveBeenCalledWith( + "environments.actions.your_survey_would_be_shown_on_this_url" + ); + }); + + test("test URL match functionality - no match", async () => { + const testUrl = "https://example.com/dashboard"; + const urlFilters = [{ rule: "contains" as const, value: "pricing" }]; + + render(); + + const testInput = screen.getByTestId("input-noCodeConfig.urlFilters.testUrl"); + const testButton = screen.getByTestId("button-environments.actions.test_match"); + + await userEvent.type(testInput, testUrl); + await userEvent.click(testButton); + + // Toast should be called to show no match + const toast = await import("react-hot-toast"); + expect(toast.default.error).toHaveBeenCalledWith("environments.actions.your_survey_would_not_be_shown"); + }); + + test("test URL match functionality with regex - valid regex", async () => { + const testUrl = "https://example.com/user/123"; + const urlFilters = [{ rule: "matchesRegex" as const, value: "/user/\\d+" }]; + + render(); + + const testInput = screen.getByTestId("input-noCodeConfig.urlFilters.testUrl"); + const testButton = screen.getByTestId("button-environments.actions.test_match"); + + await userEvent.type(testInput, testUrl); + await userEvent.click(testButton); + + // Toast should be called to show successful match + const toast = await import("react-hot-toast"); + expect(toast.default.success).toHaveBeenCalledWith( + "environments.actions.your_survey_would_be_shown_on_this_url" + ); + }); + + test("test URL match functionality with regex - invalid regex", async () => { + const testUrl = "https://example.com/user/123"; + const urlFilters = [{ rule: "matchesRegex" as const, value: "[invalid-regex" }]; + + render(); + + const testInput = screen.getByTestId("input-noCodeConfig.urlFilters.testUrl"); + const testButton = screen.getByTestId("button-environments.actions.test_match"); + + await userEvent.type(testInput, testUrl); + await userEvent.click(testButton); + + // Toast should be called to show error + const toast = await import("react-hot-toast"); + expect(toast.default.error).toHaveBeenCalledWith("environments.actions.invalid_regex"); + }); + + test("test URL match functionality with regex - no match", async () => { + const testUrl = "https://example.com/user/abc"; + const urlFilters = [{ rule: "matchesRegex" as const, value: "/user/\\d+" }]; + + render(); + + const testInput = screen.getByTestId("input-noCodeConfig.urlFilters.testUrl"); + const testButton = screen.getByTestId("button-environments.actions.test_match"); + + await userEvent.type(testInput, testUrl); + await userEvent.click(testButton); + + // Toast should be called to show no match + const toast = await import("react-hot-toast"); + expect(toast.default.error).toHaveBeenCalledWith("environments.actions.your_survey_would_not_be_shown"); + }); + + test("handles multiple URL filters with OR logic", async () => { + const testUrl = "https://example.com/pricing"; + const urlFilters = [ + { rule: "contains" as const, value: "dashboard" }, + { rule: "contains" as const, value: "pricing" }, + ]; + + render(); + + const testInput = screen.getByTestId("input-noCodeConfig.urlFilters.testUrl"); + const testButton = screen.getByTestId("button-environments.actions.test_match"); + + await userEvent.type(testInput, testUrl); + await userEvent.click(testButton); + + // Should match because one of the filters matches (OR logic) + const toast = await import("react-hot-toast"); + expect(toast.default.success).toHaveBeenCalledWith( + "environments.actions.your_survey_would_be_shown_on_this_url" + ); + }); + + test("shows correct placeholder for regex input", () => { + const urlFilters = [{ rule: "matchesRegex" as const, value: "" }]; + + render(); + + const input = screen.getByTestId("input-noCodeConfig.urlFilters.0.value"); + expect(input).toHaveAttribute("placeholder", "environments.actions.add_regular_expression_here"); + }); + + test("shows correct placeholder for non-regex input", () => { + const urlFilters = [{ rule: "exactMatch" as const, value: "" }]; + + render(); + + const input = screen.getByTestId("input-noCodeConfig.urlFilters.0.value"); + expect(input).toHaveAttribute("placeholder", "environments.actions.enter_url"); + }); + + test("renders all available rule options from ACTION_CLASS_PAGE_URL_RULES", () => { + render(); + + // Check that all rule options are rendered + ACTION_CLASS_PAGE_URL_RULES.forEach((rule) => { + expect(screen.getByTestId(`select-item-${rule}`)).toBeInTheDocument(); + }); + }); + + test("displays correct translated labels for each rule type", () => { + render(); + + // Test that each rule has the correct translated label + expect(screen.getByTestId("select-item-exactMatch")).toHaveTextContent( + "environments.actions.exactly_matches" + ); + expect(screen.getByTestId("select-item-contains")).toHaveTextContent("environments.actions.contains"); + expect(screen.getByTestId("select-item-startsWith")).toHaveTextContent( + "environments.actions.starts_with" + ); + expect(screen.getByTestId("select-item-endsWith")).toHaveTextContent("environments.actions.ends_with"); + expect(screen.getByTestId("select-item-notMatch")).toHaveTextContent( + "environments.actions.does_not_exactly_match" + ); + expect(screen.getByTestId("select-item-notContains")).toHaveTextContent( + "environments.actions.does_not_contain" + ); + expect(screen.getByTestId("select-item-matchesRegex")).toHaveTextContent( + "environments.actions.matches_regex" + ); + }); + + test("test input styling changes based on match result", async () => { + const testUrl = "https://example.com/pricing"; + const urlFilters = [{ rule: "contains" as const, value: "pricing" }]; + + render(); + + const testInput = screen.getByTestId("input-noCodeConfig.urlFilters.testUrl"); + const testButton = screen.getByTestId("button-environments.actions.test_match"); + + // Test URL that should match + await userEvent.type(testInput, testUrl); + await userEvent.click(testButton); + + // The input should have success styling (this tests the useMemo matchClass logic) + expect(testInput).toHaveClass("border-green-500", "bg-green-50"); + }); + + test("test input styling for no match", async () => { + const testUrl = "https://example.com/dashboard"; + const urlFilters = [{ rule: "contains" as const, value: "pricing" }]; + + render(); + + const testInput = screen.getByTestId("input-noCodeConfig.urlFilters.testUrl"); + const testButton = screen.getByTestId("button-environments.actions.test_match"); + + await userEvent.type(testInput, testUrl); + await userEvent.click(testButton); + + // The input should have error styling + expect(testInput).toHaveClass("border-red-200", "bg-red-50"); + }); + + test("test input has default styling before any test", () => { + const urlFilters = [{ rule: "contains" as const, value: "pricing" }]; + + render(); + + const testInput = screen.getByTestId("input-noCodeConfig.urlFilters.testUrl"); + + // The input should have default styling + expect(testInput).toHaveClass("border-slate-200"); + }); + + test("resets match state when test URL is changed", async () => { + const urlFilters = [{ rule: "contains" as const, value: "pricing" }]; + + render(); + + const testInput = screen.getByTestId("input-noCodeConfig.urlFilters.testUrl"); + const testButton = screen.getByTestId("button-environments.actions.test_match"); + + // First, perform a test that matches + await userEvent.type(testInput, "https://example.com/pricing"); + await userEvent.click(testButton); + + // Verify the input has success styling + expect(testInput).toHaveClass("border-green-500", "bg-green-50"); + + // Clear and type new URL + await userEvent.clear(testInput); + await userEvent.type(testInput, "https://example.com/dashboard"); + + // The styling should reset to default while typing + expect(testInput).toHaveClass("border-slate-200"); + }); + + test("Select mock properly handles different selection values", async () => { + const mockOnValueChange = vi.fn(); + + render( +
+ +
+ ); + + // Test clicking different select items + const exactMatchItem = screen.getByTestId("select-item-exactMatch"); + const containsItem = screen.getByTestId("select-item-contains"); + const startsWithItem = screen.getByTestId("select-item-startsWith"); + + // Click exactMatch + await userEvent.click(exactMatchItem); + expect(mockOnValueChange).toHaveBeenCalledWith("exactMatch"); + + // Click contains + await userEvent.click(containsItem); + expect(mockOnValueChange).toHaveBeenCalledWith("contains"); + + // Click startsWith + await userEvent.click(startsWithItem); + expect(mockOnValueChange).toHaveBeenCalledWith("startsWith"); + + // Verify each call was made with the correct value + expect(mockOnValueChange).toHaveBeenCalledTimes(3); }); }); diff --git a/apps/web/modules/ui/components/no-code-action-form/components/page-url-selector.tsx b/apps/web/modules/ui/components/no-code-action-form/components/page-url-selector.tsx index cd2557fcb4..e9d3bbca5f 100644 --- a/apps/web/modules/ui/components/no-code-action-form/components/page-url-selector.tsx +++ b/apps/web/modules/ui/components/no-code-action-form/components/page-url-selector.tsx @@ -3,7 +3,7 @@ import { cn } from "@/lib/cn"; import { testURLmatch } from "@/lib/utils/url"; import { Button } from "@/modules/ui/components/button"; -import { FormControl, FormField, FormItem } from "@/modules/ui/components/form"; +import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form"; import { Input } from "@/modules/ui/components/input"; import { Label } from "@/modules/ui/components/label"; import { @@ -14,18 +14,44 @@ import { SelectValue, } from "@/modules/ui/components/select"; import { TabToggle } from "@/modules/ui/components/tab-toggle"; -import { useTranslate } from "@tolgee/react"; +import { TFnType, useTranslate } from "@tolgee/react"; import { PlusIcon, TrashIcon } from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { Control, FieldArrayWithId, UseFieldArrayRemove, UseFormReturn, useFieldArray, + useWatch, } from "react-hook-form"; import toast from "react-hot-toast"; -import { TActionClassInput, TActionClassPageUrlRule } from "@formbricks/types/action-classes"; +import { + ACTION_CLASS_PAGE_URL_RULES, + TActionClassInput, + TActionClassPageUrlRule, +} from "@formbricks/types/action-classes"; + +const getRuleLabel = (rule: TActionClassPageUrlRule, t: TFnType): string => { + switch (rule) { + case "exactMatch": + return t("environments.actions.exactly_matches"); + case "contains": + return t("environments.actions.contains"); + case "startsWith": + return t("environments.actions.starts_with"); + case "endsWith": + return t("environments.actions.ends_with"); + case "notMatch": + return t("environments.actions.does_not_exactly_match"); + case "notContains": + return t("environments.actions.does_not_contain"); + case "matchesRegex": + return t("environments.actions.matches_regex"); + default: + return rule; + } +}; interface PageUrlSelectorProps { form: UseFormReturn; @@ -34,29 +60,35 @@ interface PageUrlSelectorProps { export const PageUrlSelector = ({ form, isReadOnly }: PageUrlSelectorProps) => { const [testUrl, setTestUrl] = useState(""); - const [isMatch, setIsMatch] = useState(""); + const [isMatch, setIsMatch] = useState(null); const { t } = useTranslate(); - const filterType = form.watch("noCodeConfig.urlFilters")?.length ? "specific" : "all"; + const urlFilters = form.watch("noCodeConfig.urlFilters"); + const filterType = urlFilters?.length ? "specific" : "all"; const setFilterType = (value: string) => { form.setValue("noCodeConfig.urlFilters", value === "all" ? [] : [{ rule: "exactMatch", value: "" }]); }; const handleMatchClick = () => { - const match = - form.watch("noCodeConfig.urlFilters")?.some((urlFilter) => { - const res = - testURLmatch(testUrl, urlFilter.value, urlFilter.rule as TActionClassPageUrlRule) === "yes"; - return res; - }) || false; + try { + const match = + urlFilters?.some((urlFilter) => { + return testURLmatch(testUrl, urlFilter.value, urlFilter.rule, t); + }) || false; - const isMatch = match ? "yes" : "no"; - - setIsMatch(isMatch); - if (isMatch === "yes") toast.success("Your survey would be shown on this URL."); - if (isMatch === "no") toast.error("Your survey would not be shown."); + setIsMatch(match); + if (match) toast.success(t("environments.actions.your_survey_would_be_shown_on_this_url")); + if (!match) toast.error(t("environments.actions.your_survey_would_not_be_shown")); + } catch (error) { + toast.error(error.message); + } }; + const matchClass = useMemo(() => { + if (isMatch === null) return "border-slate-200"; + return isMatch ? "border-green-500 bg-green-50" : "border-red-200 bg-red-50"; + }, [isMatch]); + const { fields, append: appendUrlRule, @@ -112,11 +144,11 @@ export const PageUrlSelector = ({ form, isReadOnly }: PageUrlSelectorProps) => { {t("environments.actions.add_url")}
-
{t("environments.actions.test_your_url")}
-
+ +

{t("environments.actions.enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked")} -

-
+

+
{ name="noCodeConfig.urlFilters.testUrl" onChange={(e) => { setTestUrl(e.target.value); - setIsMatch("default"); + setIsMatch(null); }} - className={cn( - isMatch === "yes" - ? "border-green-500 bg-green-50" - : isMatch === "no" - ? "border-red-200 bg-red-50" - : isMatch === "default" - ? "border-slate-200" - : "bg-white" - )} + className={cn(matchClass)} placeholder="e.g. https://app.com/dashboard" />
); }; diff --git a/apps/web/scripts/docker/next-start.sh b/apps/web/scripts/docker/next-start.sh old mode 100644 new mode 100755 index 6400259292..03af31ae5c --- a/apps/web/scripts/docker/next-start.sh +++ b/apps/web/scripts/docker/next-start.sh @@ -2,12 +2,64 @@ set -eu export NODE_ENV=production + +# Function to run command with timeout if available, or without timeout as fallback +run_with_timeout() { + _timeout_duration="$1" + _timeout_name="$2" + shift 2 + + if command -v timeout >/dev/null 2>&1; then + # timeout command is available, use it + echo "Using timeout ($_timeout_duration seconds) for $_timeout_name" + if ! timeout "$_timeout_duration" "$@"; then + echo "❌ $_timeout_name timed out after $_timeout_duration seconds" + echo "📋 This might indicate database connectivity issues" + exit 1 + fi + else + # timeout not available, try to install it or run without timeout + echo "⚠️ timeout command not found, attempting to install..." + if command -v apk >/dev/null 2>&1; then + apk add --no-cache coreutils >/dev/null 2>&1 || { + echo "⚠️ Could not install timeout, running $_timeout_name without timeout protection" + echo "📋 Note: Process may hang indefinitely if there are connectivity issues" + } + fi + + # Run the command (either with newly installed timeout or without timeout) + if command -v timeout >/dev/null 2>&1; then + echo "✅ timeout installed, using timeout ($_timeout_duration seconds) for $_timeout_name" + if ! timeout "$_timeout_duration" "$@"; then + echo "❌ $_timeout_name timed out after $_timeout_duration seconds" + echo "📋 This might indicate database connectivity issues" + exit 1 + fi + else + echo "Running $_timeout_name without timeout protection..." + if ! "$@"; then + echo "❌ $_timeout_name failed" + echo "📋 This might indicate database connectivity issues" + exit 1 + fi + fi + fi +} + +# Start cron jobs if enabled 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 +fi + +echo "🗃️ Running database migrations..." +run_with_timeout 300 "database migration" sh -c '(cd packages/database && npm run db:migrate:deploy)' + +echo "🗃️ Running SAML database setup..." +run_with_timeout 60 "SAML database setup" sh -c '(cd packages/database && npm run db:create-saml-database:deploy)' + +echo "✅ Database setup completed" +echo "🚀 Starting Next.js server..." +exec node apps/web/server.js diff --git a/docs/xm-and-surveys/surveys/website-app-surveys/actions.mdx b/docs/xm-and-surveys/surveys/website-app-surveys/actions.mdx index 3bd52af644..1b4d36ef73 100644 --- a/docs/xm-and-surveys/surveys/website-app-surveys/actions.mdx +++ b/docs/xm-and-surveys/surveys/website-app-surveys/actions.mdx @@ -121,6 +121,8 @@ You can limit action tracking to specific subpages of your website or web app by - **notContains**: Activates when the URL does not contain the specified substring. +- **matchesRegex**: Activates when the URL matches the pattern from the specified string. + ## **Setting Up Code Actions** For more granular control, you can implement actions directly in your code: diff --git a/packages/js-core/src/lib/common/utils.ts b/packages/js-core/src/lib/common/utils.ts index 9c33966744..3c3e9f01fc 100644 --- a/packages/js-core/src/lib/common/utils.ts +++ b/packages/js-core/src/lib/common/utils.ts @@ -205,6 +205,8 @@ export const checkUrlMatch = ( pageUrlValue: string, pageUrlRule: TActionClassPageUrlRule ): boolean => { + let regex: RegExp; + switch (pageUrlRule) { case "exactMatch": return url === pageUrlValue; @@ -218,6 +220,15 @@ export const checkUrlMatch = ( return url !== pageUrlValue; case "notContains": return !url.includes(pageUrlValue); + case "matchesRegex": + try { + regex = new RegExp(pageUrlValue); + } catch { + // edge case: fail closed if the regex expression is invalid + return false; + } + + return regex.test(url); default: return false; } diff --git a/packages/js-core/src/types/survey.ts b/packages/js-core/src/types/survey.ts index 6f99cb6ab8..011cb0ed14 100644 --- a/packages/js-core/src/types/survey.ts +++ b/packages/js-core/src/types/survey.ts @@ -40,7 +40,8 @@ export type TActionClassPageUrlRule = | "startsWith" | "endsWith" | "notMatch" - | "notContains"; + | "notContains" + | "matchesRegex"; export type TActionClassNoCodeConfig = | { diff --git a/packages/types/action-classes.ts b/packages/types/action-classes.ts index dd9cc01d9f..89a8d038dc 100644 --- a/packages/types/action-classes.ts +++ b/packages/types/action-classes.ts @@ -10,14 +10,19 @@ export const ZActionClassMatchType = z.union([ z.literal("notContains"), ]); -export const ZActionClassPageUrlRule = z.union([ - z.literal("exactMatch"), - z.literal("contains"), - z.literal("startsWith"), - z.literal("endsWith"), - z.literal("notMatch"), - z.literal("notContains"), -]); +// Define the rule values as a const array to avoid duplication +export const ACTION_CLASS_PAGE_URL_RULES = [ + "exactMatch", + "contains", + "startsWith", + "endsWith", + "notMatch", + "notContains", + "matchesRegex", +] as const; + +// Create Zod schema from the const array +export const ZActionClassPageUrlRule = z.enum(ACTION_CLASS_PAGE_URL_RULES); export type TActionClassPageUrlRule = z.infer;