mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 10:19:51 -06:00
feat: Add Regex No Code Action Page Filter (#6305)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
committed by
GitHub
parent
da652bd860
commit
23c2d3dce9
101
.github/workflows/docker-build-validation.yml
vendored
101
.github/workflows/docker-build-validation.yml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -24,14 +24,17 @@ export const ActionClassesTable = ({
|
||||
otherEnvActionClasses,
|
||||
otherEnvironment,
|
||||
}: ActionClassesTableProps) => {
|
||||
const [isActionDetailModalOpen, setActionDetailModalOpen] = useState(false);
|
||||
const [isActionDetailModalOpen, setIsActionDetailModalOpen] = useState(false);
|
||||
|
||||
const [activeActionClass, setActiveActionClass] = useState<TActionClass>();
|
||||
|
||||
const handleOpenActionDetailModalClick = (e, actionClass: TActionClass) => {
|
||||
const handleOpenActionDetailModalClick = (
|
||||
e: React.MouseEvent<HTMLButtonElement>,
|
||||
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) => (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
handleOpenActionDetailModalClick(e, actionClass);
|
||||
}}
|
||||
className="w-full"
|
||||
@@ -63,7 +66,7 @@ export const ActionClassesTable = ({
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
open={isActionDetailModalOpen}
|
||||
setOpen={setActionDetailModalOpen}
|
||||
setOpen={setIsActionDetailModalOpen}
|
||||
actionClasses={actionClasses}
|
||||
actionClass={activeActionClass}
|
||||
isReadOnly={isReadOnly}
|
||||
|
||||
@@ -70,15 +70,13 @@ export const ActionDetailModal = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalWithTabs
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
tabs={tabs}
|
||||
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
|
||||
label={actionClass.name}
|
||||
description={typeDescription()}
|
||||
/>
|
||||
</>
|
||||
<ModalWithTabs
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
tabs={tabs}
|
||||
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
|
||||
label={actionClass.name}
|
||||
description={typeDescription()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,21 @@ vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
|
||||
updateActionClassAction: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock action utils
|
||||
vi.mock("@/modules/survey/editor/lib/action-utils", () => ({
|
||||
useActionClassKeys: vi.fn(() => ["existing-key"]),
|
||||
createActionClassZodResolver: vi.fn(() => vi.fn()),
|
||||
validatePermissions: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock action builder
|
||||
vi.mock("@/modules/survey/editor/lib/action-builder", () => ({
|
||||
buildActionObject: vi.fn((data, environmentId, t) => ({
|
||||
...data,
|
||||
environmentId,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock utils
|
||||
vi.mock("@/app/lib/actionClass/actionClass", () => ({
|
||||
isValidCssSelector: vi.fn((selector) => selector !== "invalid-selector"),
|
||||
@@ -24,6 +39,7 @@ vi.mock("@/modules/ui/components/button", () => ({
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/code-action-form", () => ({
|
||||
CodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
|
||||
<div data-testid="code-action-form" data-readonly={isReadOnly}>
|
||||
@@ -31,6 +47,7 @@ vi.mock("@/modules/ui/components/code-action-form", () => ({
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
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", () => ({
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/action-name-description-fields", () => ({
|
||||
ActionNameDescriptionFields: ({ isReadOnly, nameInputId, descriptionInputId }: any) => (
|
||||
<div data-testid="action-name-description-fields">
|
||||
<input
|
||||
data-testid={`name-input-${nameInputId}`}
|
||||
placeholder="environments.actions.eg_clicked_download"
|
||||
disabled={isReadOnly}
|
||||
defaultValue="Test Action"
|
||||
/>
|
||||
<input
|
||||
data-testid={`description-input-${descriptionInputId}`}
|
||||
placeholder="environments.actions.user_clicked_download_button"
|
||||
disabled={isReadOnly}
|
||||
defaultValue="Test Description"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/no-code-action-form", () => ({
|
||||
NoCodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
|
||||
<div data-testid="no-code-action-form" data-readonly={isReadOnly}>
|
||||
@@ -56,6 +93,23 @@ vi.mock("lucide-react", () => ({
|
||||
TrashIcon: () => <div data-testid="trash-icon">Trash</div>,
|
||||
}));
|
||||
|
||||
// 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) => <div>{children}</div>,
|
||||
};
|
||||
});
|
||||
|
||||
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(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
// 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(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
@@ -238,12 +373,6 @@ describe("ActionSettingsTab", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
// 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(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<TActionClassInput>({
|
||||
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 (
|
||||
<>
|
||||
<CodeActionForm form={form} isReadOnly={true} />
|
||||
<p className="text-sm text-slate-600">
|
||||
{t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (actionClass.type === "noCode") {
|
||||
return <NoCodeActionForm form={form} isReadOnly={isReadOnly} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="text-sm text-slate-600">
|
||||
{t("environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it")}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
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 = ({
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="max-h-[400px] w-full space-y-4 overflow-y-auto">
|
||||
<div className="grid w-full grid-cols-2 gap-x-4">
|
||||
<div className="col-span-1">
|
||||
<FormField
|
||||
control={control}
|
||||
name="name"
|
||||
disabled={isReadOnly}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel htmlFor="actionNameSettingsInput">
|
||||
{actionClass.type === "noCode"
|
||||
? t("environments.actions.what_did_your_user_do")
|
||||
: t("environments.actions.display_name")}
|
||||
</FormLabel>
|
||||
<ActionNameDescriptionFields
|
||||
control={control}
|
||||
isReadOnly={isReadOnly}
|
||||
nameInputId="actionNameSettingsInput"
|
||||
descriptionInputId="actionDescriptionSettingsInput"
|
||||
/>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
id="actionNameSettingsInput"
|
||||
{...field}
|
||||
placeholder={t("environments.actions.eg_clicked_download")}
|
||||
isInvalid={!!error?.message}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1">
|
||||
<FormField
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel htmlFor="actionDescriptionSettingsInput">
|
||||
{t("common.description")}
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
id="actionDescriptionSettingsInput"
|
||||
{...field}
|
||||
placeholder={t("environments.actions.user_clicked_download_button")}
|
||||
value={field.value ?? ""}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actionClass.type === "code" ? (
|
||||
<>
|
||||
<CodeActionForm form={form} isReadOnly={true} />
|
||||
<p className="text-sm text-slate-600">
|
||||
{t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")}
|
||||
</p>
|
||||
</>
|
||||
) : actionClass.type === "noCode" ? (
|
||||
<NoCodeActionForm form={form} isReadOnly={isReadOnly} />
|
||||
) : (
|
||||
<p className="text-sm text-slate-600">
|
||||
{t(
|
||||
"environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it"
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{renderActionForm()}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-x-2 border-slate-200 pt-4">
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"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.");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 !",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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": "恭喜!",
|
||||
|
||||
@@ -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: () => <div data-testid="no-code-action-form">NoCodeActionForm</div>,
|
||||
// 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: () => <div data-testid="code-action-form">CodeActionForm</div>,
|
||||
// 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 }) => (
|
||||
<div data-testid="action-name-description-fields">
|
||||
<label htmlFor={nameInputId}>What did your user do?</label>
|
||||
<input id={nameInputId} name="name" data-testid="name-input" />
|
||||
<label htmlFor={descriptionInputId}>Description</label>
|
||||
<input id={descriptionInputId} name="description" data-testid="description-input" />
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock useTranslate hook
|
||||
const mockT = vi.fn((key: string, params?: any) => {
|
||||
const translations: Record<string, string> = {
|
||||
"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(<CreateNewActionTab {...defaultProps} />);
|
||||
|
||||
render(
|
||||
<CreateNewActionTab
|
||||
actionClasses={actionClasses}
|
||||
setActionClasses={setActionClasses}
|
||||
setOpen={setOpen}
|
||||
isReadOnly={isReadOnly}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
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(<CreateNewActionTab {...defaultProps} />);
|
||||
|
||||
// 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(<CreateNewActionTab {...defaultProps} isReadOnly={true} />);
|
||||
|
||||
// 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(<CreateNewActionTab {...defaultProps} actionClasses={existingActionClasses} />);
|
||||
|
||||
// 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(<CreateNewActionTab {...defaultProps} actionClasses={actionClasses} />);
|
||||
|
||||
expect(actionUtilsModule.useActionClassKeys).toHaveBeenCalledWith(actionClasses);
|
||||
});
|
||||
|
||||
test("renders form with correct resolver configuration", async () => {
|
||||
const actionUtilsModule = (await vi.importMock("../lib/action-utils")) as any;
|
||||
|
||||
render(<CreateNewActionTab {...defaultProps} />);
|
||||
|
||||
// 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(<CreateNewActionTab {...defaultProps} />);
|
||||
|
||||
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(<CreateNewActionTab {...defaultProps} isReadOnly={true} />);
|
||||
|
||||
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(<CreateNewActionTab {...defaultProps} actionClasses={actionClasses} />);
|
||||
|
||||
// Verify that the resolver is configured with existing action names and keys
|
||||
expect(actionUtilsModule.createActionClassZodResolver).toHaveBeenCalledWith(
|
||||
["Existing Action"], // actionClassNames
|
||||
["existing-key"], // actionClassKeys
|
||||
mockT
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<TActionClassInput>({
|
||||
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 (
|
||||
<div>
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(submitHandler)}>
|
||||
<form onSubmit={handleSubmit(submitHandler)} aria-label="create-action-form">
|
||||
<div className="w-full space-y-4">
|
||||
<div className="w-3/5">
|
||||
<FormField
|
||||
@@ -200,56 +134,12 @@ export const CreateNewActionTab = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full grid-cols-2 gap-x-4">
|
||||
<div className="col-span-1">
|
||||
<FormField
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel htmlFor="actionNameInput">
|
||||
{t("environments.actions.what_did_your_user_do")}
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
id="actionNameInput"
|
||||
{...field}
|
||||
placeholder={t("environments.actions.eg_clicked_download")}
|
||||
isInvalid={!!error?.message}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<FormField
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel htmlFor="actionDescriptionInput">{t("common.description")}</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
id="actionDescriptionInput"
|
||||
{...field}
|
||||
placeholder={t("environments.actions.eg_user_clicked_download_button")}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-slate-200" />
|
||||
<ActionNameDescriptionFields
|
||||
control={control}
|
||||
isReadOnly={isReadOnly}
|
||||
nameInputId="actionNameInput"
|
||||
descriptionInputId="actionDescriptionInput"
|
||||
/>
|
||||
|
||||
{watch("type") === "code" ? (
|
||||
<CodeActionForm form={form} isReadOnly={isReadOnly} />
|
||||
|
||||
@@ -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(
|
||||
<SavedActionsTab
|
||||
actionClasses={actionClasses}
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
// 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(
|
||||
<SavedActionsTab
|
||||
actionClasses={actionClasses}
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
// 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(
|
||||
<SavedActionsTab
|
||||
actionClasses={actionClasses}
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
// 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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = ({
|
||||
</div>
|
||||
<h4 className="text-sm font-semibold text-slate-600">{action.name}</h4>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500">{action.description}</p>
|
||||
<ActionClassInfo actionClass={action} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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(
|
||||
<WhenToSendCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
environmentId="env1"
|
||||
propActionClasses={mockActionClasses}
|
||||
membershipRole={OrganizationRole.owner}
|
||||
projectPermission={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// 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(
|
||||
<WhenToSendCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
environmentId="env1"
|
||||
propActionClasses={mockActionClasses}
|
||||
membershipRole={OrganizationRole.owner}
|
||||
projectPermission={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// 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(
|
||||
<WhenToSendCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
environmentId="env1"
|
||||
propActionClasses={mockActionClasses}
|
||||
membershipRole={OrganizationRole.owner}
|
||||
projectPermission={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// 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(
|
||||
<WhenToSendCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
environmentId="env1"
|
||||
propActionClasses={mockActionClasses}
|
||||
membershipRole={OrganizationRole.owner}
|
||||
projectPermission={null}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<WhenToSendCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
environmentId="env1"
|
||||
propActionClasses={mockActionClasses}
|
||||
membershipRole={OrganizationRole.owner}
|
||||
projectPermission={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
<h4 className="text-sm font-semibold text-slate-600">{trigger.actionClass.name}</h4>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
{trigger.actionClass.description && (
|
||||
<span className="mr-1">{trigger.actionClass.description}</span>
|
||||
)}
|
||||
{trigger.actionClass.type === "code" && (
|
||||
<span className="mr-1 border-l border-slate-400 pl-1 first:border-l-0 first:pl-0">
|
||||
{t("environments.surveys.edit.key")}: <b>{trigger.actionClass.key}</b>
|
||||
</span>
|
||||
)}
|
||||
{trigger.actionClass.type === "noCode" &&
|
||||
trigger.actionClass.noCodeConfig?.type === "click" &&
|
||||
trigger.actionClass.noCodeConfig?.elementSelector.cssSelector && (
|
||||
<span className="mr-1 border-l border-slate-400 pl-1 first:border-l-0 first:pl-0">
|
||||
{t("environments.surveys.edit.css_selector")}:{" "}
|
||||
<b>{trigger.actionClass.noCodeConfig?.elementSelector.cssSelector}</b>
|
||||
</span>
|
||||
)}
|
||||
{trigger.actionClass.type === "noCode" &&
|
||||
trigger.actionClass.noCodeConfig?.type === "click" &&
|
||||
trigger.actionClass.noCodeConfig?.elementSelector.innerHtml && (
|
||||
<span className="mr-1 border-l border-slate-400 pl-1 first:border-l-0 first:pl-0">
|
||||
{t("environments.surveys.edit.inner_text")}:{" "}
|
||||
<b>{trigger.actionClass.noCodeConfig?.elementSelector.innerHtml}</b>
|
||||
</span>
|
||||
)}
|
||||
{trigger.actionClass.type === "noCode" &&
|
||||
trigger.actionClass.noCodeConfig?.urlFilters &&
|
||||
trigger.actionClass.noCodeConfig.urlFilters.length > 0 ? (
|
||||
<span className="mr-1 border-l border-slate-400 pl-1 first:border-l-0 first:pl-0">
|
||||
{t("environments.surveys.edit.url_filters")}:{" "}
|
||||
{trigger.actionClass.noCodeConfig.urlFilters.map((urlFilter, index) => (
|
||||
<span key={index}>
|
||||
{urlFilter.rule} <b>{urlFilter.value}</b>
|
||||
{trigger.actionClass.type === "noCode" &&
|
||||
index !==
|
||||
(trigger.actionClass.noCodeConfig?.urlFilters?.length || 0) - 1 &&
|
||||
", "}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<ActionClassInfo actionClass={trigger.actionClass} />
|
||||
</div>
|
||||
</div>
|
||||
<Trash2Icon
|
||||
|
||||
236
apps/web/modules/survey/editor/lib/action-builder.test.ts
Normal file
236
apps/web/modules/survey/editor/lib/action-builder.test.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { TFnType } from "@tolgee/react";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { buildActionObject, buildCodeAction, buildNoCodeAction } from "./action-builder";
|
||||
|
||||
const mockT = vi.fn((key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
52
apps/web/modules/survey/editor/lib/action-builder.ts
Normal file
52
apps/web/modules/survey/editor/lib/action-builder.ts
Normal file
@@ -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,
|
||||
};
|
||||
};
|
||||
406
apps/web/modules/survey/editor/lib/action-utils.test.ts
Normal file
406
apps/web/modules/survey/editor/lib/action-utils.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
129
apps/web/modules/survey/editor/lib/action-utils.ts
Normal file
129
apps/web/modules/survey/editor/lib/action-utils.ts
Normal file
@@ -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"));
|
||||
}
|
||||
};
|
||||
368
apps/web/modules/ui/components/action-class-info/index.test.tsx
Normal file
368
apps/web/modules/ui/components/action-class-info/index.test.tsx
Normal file
@@ -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(<ActionClassInfo actionClass={mockCodeActionClass} />);
|
||||
|
||||
expect(screen.getByText("Test code action description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not render description when null", () => {
|
||||
render(<ActionClassInfo actionClass={mockActionClassWithoutDescription} />);
|
||||
|
||||
expect(screen.queryByText("Test code action description")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders code action key", () => {
|
||||
render(<ActionClassInfo actionClass={mockCodeActionClass} />);
|
||||
|
||||
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(<ActionClassInfo actionClass={mockNoCodeClickActionClass} />);
|
||||
|
||||
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(<ActionClassInfo actionClass={mockNoCodeClickActionClass} />);
|
||||
|
||||
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(<ActionClassInfo actionClass={mockNoCodeClickActionClass} />);
|
||||
|
||||
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(<ActionClassInfo actionClass={mockNoCodeClickActionClass} />);
|
||||
|
||||
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(<ActionClassInfo actionClass={mockNoCodePageViewActionClass} />);
|
||||
|
||||
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(<ActionClassInfo actionClass={actionWithoutUrlFilters} />);
|
||||
|
||||
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(<ActionClassInfo actionClass={actionWithoutCssSelector} />);
|
||||
|
||||
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(<ActionClassInfo actionClass={actionWithoutInnerHtml} />);
|
||||
|
||||
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(
|
||||
<ActionClassInfo actionClass={mockCodeActionClass} className="custom-class" />
|
||||
);
|
||||
|
||||
const div = container.querySelector("div");
|
||||
expect(div).toHaveClass("custom-class");
|
||||
});
|
||||
|
||||
test("has correct default styling", () => {
|
||||
const { container } = render(<ActionClassInfo actionClass={mockCodeActionClass} />);
|
||||
|
||||
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(<ActionClassInfo actionClass={actionWithSingleFilter} />);
|
||||
|
||||
expect(container).toHaveTextContent("exactMatch");
|
||||
expect(container).toHaveTextContent("https://example.com");
|
||||
expect(container).not.toHaveTextContent(",");
|
||||
});
|
||||
|
||||
test("renders URL filters with regex rule", () => {
|
||||
const { container } = render(<ActionClassInfo actionClass={mockRegexActionClass} />);
|
||||
|
||||
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(<ActionClassInfo actionClass={mockMixedFilterActionClass} />);
|
||||
|
||||
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(<ActionClassInfo actionClass={complexRegexAction} />);
|
||||
|
||||
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(<ActionClassInfo actionClass={specialCharsRegexAction} />);
|
||||
|
||||
expect(container).toHaveTextContent("matchesRegex");
|
||||
expect(container).toHaveTextContent("\\[\\{\\(.*\\)\\}\\]");
|
||||
});
|
||||
});
|
||||
66
apps/web/modules/ui/components/action-class-info/index.tsx
Normal file
66
apps/web/modules/ui/components/action-class-info/index.tsx
Normal file
@@ -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 }) => (
|
||||
<span className="mr-1 border-l border-slate-400 pl-1 first:border-l-0 first:pl-0">{children}</span>
|
||||
);
|
||||
|
||||
export const ActionClassInfo = ({ actionClass, className = "" }: ActionClassInfoProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
const renderUrlFilters = () => {
|
||||
const urlFilters = actionClass.noCodeConfig?.urlFilters;
|
||||
if (!urlFilters?.length) return null;
|
||||
|
||||
return (
|
||||
<InfoItem>
|
||||
{t("environments.surveys.edit.url_filters")}:{" "}
|
||||
{urlFilters.map((urlFilter, index) => (
|
||||
<span key={urlFilter.rule + index}>
|
||||
{urlFilter.rule} <b>{urlFilter.value}</b>
|
||||
{index !== urlFilters.length - 1 && ", "}
|
||||
</span>
|
||||
))}
|
||||
</InfoItem>
|
||||
);
|
||||
};
|
||||
|
||||
const isNoCodeClick = actionClass.type === "noCode" && actionClass.noCodeConfig?.type === "click";
|
||||
|
||||
const clickConfig = isNoCodeClick
|
||||
? (actionClass.noCodeConfig as Extract<typeof actionClass.noCodeConfig, { type: "click" }>)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className={`mt-1 text-xs text-slate-500 ${className}`}>
|
||||
{actionClass.description && <span className="mr-1">{actionClass.description}</span>}
|
||||
|
||||
{actionClass.type === "code" && (
|
||||
<InfoItem>
|
||||
{t("environments.surveys.edit.key")}: <b>{actionClass.key}</b>
|
||||
</InfoItem>
|
||||
)}
|
||||
|
||||
{clickConfig?.elementSelector.cssSelector && (
|
||||
<InfoItem>
|
||||
{t("environments.surveys.edit.css_selector")}: <b>{clickConfig.elementSelector.cssSelector}</b>
|
||||
</InfoItem>
|
||||
)}
|
||||
|
||||
{clickConfig?.elementSelector.innerHtml && (
|
||||
<InfoItem>
|
||||
{t("environments.surveys.edit.inner_text")}: <b>{clickConfig.elementSelector.innerHtml}</b>
|
||||
</InfoItem>
|
||||
)}
|
||||
|
||||
{renderUrlFilters()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 }) => (
|
||||
<div data-testid="form-control">{children}</div>
|
||||
),
|
||||
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 }) => <div data-testid="form-item">{children}</div>,
|
||||
FormLabel: ({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) => (
|
||||
<label data-testid="form-label" htmlFor={htmlFor}>
|
||||
{children}
|
||||
</label>
|
||||
),
|
||||
FormError: () => <div data-testid="form-error">Form Error</div>,
|
||||
}));
|
||||
|
||||
// Mock the Input component
|
||||
vi.mock("@/modules/ui/components/input", () => ({
|
||||
Input: ({ type, id, placeholder, disabled, isInvalid, ...props }: any) => (
|
||||
<input
|
||||
data-testid={`input-${id}`}
|
||||
type={type}
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
data-invalid={isInvalid}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
// Test wrapper component
|
||||
const TestWrapper = ({
|
||||
isReadOnly = false,
|
||||
nameInputId = "actionNameInput",
|
||||
descriptionInputId = "actionDescriptionInput",
|
||||
showSeparator = false,
|
||||
}: {
|
||||
isReadOnly?: boolean;
|
||||
nameInputId?: string;
|
||||
descriptionInputId?: string;
|
||||
showSeparator?: boolean;
|
||||
}) => {
|
||||
const { control } = useForm<TActionClassInput>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ActionNameDescriptionFields
|
||||
control={control}
|
||||
isReadOnly={isReadOnly}
|
||||
nameInputId={nameInputId}
|
||||
descriptionInputId={descriptionInputId}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Test wrapper with default props
|
||||
const TestWrapperDefault = () => {
|
||||
const { control } = useForm<TActionClassInput>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
|
||||
return <ActionNameDescriptionFields control={control} />;
|
||||
};
|
||||
|
||||
describe("ActionNameDescriptionFields", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders name and description fields correctly", () => {
|
||||
render(<TestWrapper />);
|
||||
|
||||
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(<TestWrapper />);
|
||||
|
||||
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(<TestWrapper nameInputId="customNameId" descriptionInputId="customDescriptionId" />);
|
||||
|
||||
expect(screen.getByTestId("input-customNameId")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("input-customDescriptionId")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders inputs as disabled when isReadOnly is true", () => {
|
||||
render(<TestWrapper isReadOnly={true} />);
|
||||
|
||||
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(<TestWrapper isReadOnly={false} />);
|
||||
|
||||
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(<TestWrapper showSeparator={true} />);
|
||||
|
||||
const separator = screen.getByRole("separator");
|
||||
expect(separator).toBeInTheDocument();
|
||||
expect(separator).toHaveClass("border-slate-200");
|
||||
});
|
||||
|
||||
test("renders form structure correctly with two columns", () => {
|
||||
render(<TestWrapper />);
|
||||
|
||||
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(<TestWrapper />);
|
||||
|
||||
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(<TestWrapper />);
|
||||
|
||||
expect(screen.getAllByTestId("form-control")).toHaveLength(2);
|
||||
expect(screen.getAllByTestId("form-item")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("renders with default prop values", () => {
|
||||
render(<TestWrapperDefault />);
|
||||
|
||||
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(<TestWrapper />);
|
||||
|
||||
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(<TestWrapper />);
|
||||
|
||||
const descriptionInput = screen.getByTestId("input-actionDescriptionInput");
|
||||
await user.click(descriptionInput);
|
||||
|
||||
expect(descriptionInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("description field handles empty value correctly", () => {
|
||||
render(<TestWrapper />);
|
||||
|
||||
const descriptionInput = screen.getByTestId("input-actionDescriptionInput");
|
||||
expect(descriptionInput).toHaveAttribute("value", "");
|
||||
});
|
||||
});
|
||||
@@ -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<TActionClassInput>;
|
||||
isReadOnly?: boolean;
|
||||
nameInputId?: string;
|
||||
descriptionInputId?: string;
|
||||
}
|
||||
|
||||
export const ActionNameDescriptionFields = ({
|
||||
control,
|
||||
isReadOnly = false,
|
||||
nameInputId = "actionNameInput",
|
||||
descriptionInputId = "actionDescriptionInput",
|
||||
}: ActionNameDescriptionFieldsProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid w-full grid-cols-2 gap-x-4">
|
||||
<div className="col-span-1">
|
||||
<FormField
|
||||
control={control}
|
||||
name="name"
|
||||
disabled={isReadOnly}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel htmlFor={nameInputId}>{t("environments.actions.what_did_your_user_do")}</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
id={nameInputId}
|
||||
{...field}
|
||||
placeholder={t("environments.actions.eg_clicked_download")}
|
||||
isInvalid={!!error?.message}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1">
|
||||
<FormField
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel htmlFor={descriptionInputId}>{t("common.description")}</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
id={descriptionInputId}
|
||||
{...field}
|
||||
placeholder={t("environments.actions.user_clicked_download_button")}
|
||||
value={field.value ?? ""}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-slate-200" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -35,7 +35,7 @@ describe("Button", () => {
|
||||
});
|
||||
|
||||
test("applies correct size classes", () => {
|
||||
const { rerender } = render(<Button size="default">Default Size</Button>);
|
||||
const { rerender } = render(<Button>Default</Button>);
|
||||
expect(screen.getByRole("button")).toHaveClass("h-9", "px-4", "py-2");
|
||||
|
||||
rerender(<Button size="sm">Small</Button>);
|
||||
@@ -46,6 +46,9 @@ describe("Button", () => {
|
||||
|
||||
rerender(<Button size="icon">Icon</Button>);
|
||||
expect(screen.getByRole("button")).toHaveClass("h-9", "w-9");
|
||||
|
||||
rerender(<Button size="tall">Tall</Button>);
|
||||
expect(screen.getByRole("button")).toHaveClass("h-10", "px-3", "text-xs");
|
||||
});
|
||||
|
||||
test("renders as a different element when asChild is true", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -69,7 +69,7 @@ const meta: Meta<typeof Button> = {
|
||||
},
|
||||
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...",
|
||||
|
||||
@@ -31,6 +31,7 @@ vi.mock("@/modules/ui/components/form", () => ({
|
||||
},
|
||||
FormItem: ({ children }: { children: React.ReactNode }) => <div data-testid="form-item">{children}</div>,
|
||||
FormLabel: ({ children }: { children: React.ReactNode }) => <div data-testid="form-label">{children}</div>,
|
||||
FormError: () => <div data-testid="form-error">Form Error</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/input", () => ({
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div data-testid="code-action-form" className="space-y-4">
|
||||
<div className="col-span-1">
|
||||
<FormField
|
||||
control={control}
|
||||
@@ -36,6 +36,7 @@ export const CodeActionForm = ({ form, isReadOnly }: CodeActionFormProps) => {
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -52,9 +53,9 @@ export const CodeActionForm = ({ form, isReadOnly }: CodeActionFormProps) => {
|
||||
<a href="https://formbricks.com/docs/actions/code" target="_blank" className="underline">
|
||||
{t("common.docs")}
|
||||
</a>
|
||||
.
|
||||
{"."}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) => (
|
||||
<div data-testid={`select-${name}`} data-value={value} data-disabled={disabled}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
SelectContent: ({ children }: any) => <div data-testid="select-content">{children}</div>,
|
||||
SelectItem: ({ children, value }: any) => (
|
||||
<div data-testid={`select-item-${value}`} data-value={value}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
SelectTrigger: ({ children, className }: any) => (
|
||||
<div data-testid="select-trigger" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
SelectValue: ({ placeholder }: any) => <div data-testid="select-value">{placeholder}</div>,
|
||||
}));
|
||||
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 (
|
||||
<SelectContext.Provider value={contextValue}>
|
||||
<div data-testid={`select-${name}`} data-value={value} data-disabled={disabled}>
|
||||
{children}
|
||||
</div>
|
||||
</SelectContext.Provider>
|
||||
);
|
||||
},
|
||||
SelectContent: ({ children }: any) => <div data-testid="select-content">{children}</div>,
|
||||
SelectItem: ({ children, value }: any) => {
|
||||
const context = React.useContext(SelectContext);
|
||||
return (
|
||||
<button // NOSONAR // This is a mocked component to test the logic
|
||||
type="button"
|
||||
data-testid={`select-item-${value}`}
|
||||
data-value={value}
|
||||
onClick={() => context.onValueChange?.(value)}
|
||||
style={{ cursor: "pointer" }}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
SelectTrigger: ({ children, className }: any) => (
|
||||
<div data-testid="select-trigger" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
SelectValue: ({ placeholder }: any) => <div data-testid="select-value">{placeholder}</div>,
|
||||
};
|
||||
});
|
||||
|
||||
// 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) => <div className={className}>{children}</div>,
|
||||
FormError: () => <div>Form Error</div>,
|
||||
}));
|
||||
|
||||
// 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(<TestWrapper urlFilters={urlFilters} />);
|
||||
|
||||
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(<TestWrapper urlFilters={urlFilters} />);
|
||||
|
||||
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(<TestWrapper urlFilters={urlFilters} />);
|
||||
|
||||
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(<TestWrapper urlFilters={urlFilters} />);
|
||||
|
||||
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(<TestWrapper urlFilters={urlFilters} />);
|
||||
|
||||
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(<TestWrapper urlFilters={urlFilters} />);
|
||||
|
||||
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(<TestWrapper urlFilters={urlFilters} />);
|
||||
|
||||
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(<TestWrapper urlFilters={urlFilters} />);
|
||||
|
||||
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(<TestWrapper urlFilters={[{ rule: "exactMatch", value: "https://example.com" }]} />);
|
||||
|
||||
// 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(<TestWrapper urlFilters={[{ rule: "exactMatch", value: "https://example.com" }]} />);
|
||||
|
||||
// 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(<TestWrapper urlFilters={urlFilters} />);
|
||||
|
||||
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(<TestWrapper urlFilters={urlFilters} />);
|
||||
|
||||
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(<TestWrapper urlFilters={urlFilters} />);
|
||||
|
||||
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(<TestWrapper urlFilters={urlFilters} />);
|
||||
|
||||
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(
|
||||
<div>
|
||||
<Select name="test-select" onValueChange={mockOnValueChange}>
|
||||
<SelectContent>
|
||||
<SelectItem value="exactMatch">Exact Match</SelectItem>
|
||||
<SelectItem value="contains">Contains</SelectItem>
|
||||
<SelectItem value="startsWith">Starts With</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<TActionClassInput>;
|
||||
@@ -34,29 +60,35 @@ interface PageUrlSelectorProps {
|
||||
|
||||
export const PageUrlSelector = ({ form, isReadOnly }: PageUrlSelectorProps) => {
|
||||
const [testUrl, setTestUrl] = useState("");
|
||||
const [isMatch, setIsMatch] = useState("");
|
||||
const [isMatch, setIsMatch] = useState<boolean | null>(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")}
|
||||
</Button>
|
||||
<div className="mt-4">
|
||||
<div className="text-sm text-slate-900">{t("environments.actions.test_your_url")}</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
<Label className="font-semibold">{t("environments.actions.test_your_url")}</Label>
|
||||
<p className="text-sm font-normal text-slate-500">
|
||||
{t("environments.actions.enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked")}
|
||||
</div>
|
||||
<div className="rounded bg-slate-50">
|
||||
</p>
|
||||
<div className="rounded">
|
||||
<div className="mt-1 flex items-end">
|
||||
<Input
|
||||
type="text"
|
||||
@@ -124,17 +156,9 @@ export const PageUrlSelector = ({ form, isReadOnly }: PageUrlSelectorProps) => {
|
||||
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"
|
||||
/>
|
||||
<Button
|
||||
@@ -167,10 +191,16 @@ const UrlInput = ({
|
||||
disabled: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
// Watch all rule values to determine placeholders
|
||||
const ruleValues = useWatch({
|
||||
control,
|
||||
name: "noCodeConfig.urlFilters",
|
||||
});
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-center space-x-2">
|
||||
<div key={field.id} className="ml-1 flex items-start space-x-2">
|
||||
{index !== 0 && <p className="ml-1 text-sm font-bold text-slate-700">or</p>}
|
||||
<FormField
|
||||
name={`noCodeConfig.urlFilters.${index}.rule`}
|
||||
@@ -179,20 +209,15 @@ const UrlInput = ({
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Select onValueChange={onChange} value={value} name={name} disabled={disabled}>
|
||||
<SelectTrigger className="w-[250px] bg-white">
|
||||
<SelectTrigger className="h-[40px] w-[250px] bg-white">
|
||||
<SelectValue placeholder={t("environments.actions.select_match_type")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-white">
|
||||
<SelectItem value="exactMatch">{t("environments.actions.exactly_matches")}</SelectItem>
|
||||
<SelectItem value="contains">{t("environments.actions.contains")}</SelectItem>
|
||||
<SelectItem value="startsWith">{t("environments.actions.starts_with")}</SelectItem>
|
||||
<SelectItem value="endsWith">{t("environments.actions.ends_with")}</SelectItem>
|
||||
<SelectItem value="notMatch">
|
||||
{t("environments.actions.does_not_exactly_match")}
|
||||
</SelectItem>
|
||||
<SelectItem value="notContains">
|
||||
{t("environments.actions.does_not_contain")}
|
||||
</SelectItem>
|
||||
{ACTION_CLASS_PAGE_URL_RULES.map((rule) => (
|
||||
<SelectItem key={rule} value={rule}>
|
||||
{getRuleLabel(rule, t)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
@@ -202,27 +227,37 @@ const UrlInput = ({
|
||||
<FormField
|
||||
control={control}
|
||||
name={`noCodeConfig.urlFilters.${index}.value`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
className="bg-white"
|
||||
disabled={disabled}
|
||||
{...field}
|
||||
placeholder="e.g. https://app.com/dashboard"
|
||||
autoComplete="off"
|
||||
isInvalid={!!error?.message}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
render={({ field, fieldState: { error } }) => {
|
||||
const ruleValue = ruleValues[index]?.rule;
|
||||
|
||||
return (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
className="bg-white"
|
||||
disabled={disabled}
|
||||
{...field}
|
||||
placeholder={
|
||||
ruleValue === "matchesRegex"
|
||||
? t("environments.actions.add_regular_expression_here")
|
||||
: t("environments.actions.enter_url")
|
||||
}
|
||||
autoComplete="off"
|
||||
isInvalid={!!error?.message}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormError />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{fields.length > 1 && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
size="tall"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
removeUrlRule(index);
|
||||
|
||||
@@ -21,7 +21,7 @@ export const NoCodeActionForm = ({ form, isReadOnly }: NoCodeActionFormProps) =>
|
||||
const { control, watch } = form;
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<>
|
||||
<div data-testid="no-code-action-form">
|
||||
<FormField
|
||||
name={`noCodeConfig.type`}
|
||||
control={control}
|
||||
@@ -97,6 +97,6 @@ export const NoCodeActionForm = ({ form, isReadOnly }: NoCodeActionFormProps) =>
|
||||
)}
|
||||
<PageUrlSelector form={form} isReadOnly={isReadOnly} />
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
60
apps/web/scripts/docker/next-start.sh
Normal file → Executable file
60
apps/web/scripts/docker/next-start.sh
Normal file → Executable file
@@ -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
|
||||
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,8 @@ export type TActionClassPageUrlRule =
|
||||
| "startsWith"
|
||||
| "endsWith"
|
||||
| "notMatch"
|
||||
| "notContains";
|
||||
| "notContains"
|
||||
| "matchesRegex";
|
||||
|
||||
export type TActionClassNoCodeConfig =
|
||||
| {
|
||||
|
||||
@@ -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<typeof ZActionClassPageUrlRule>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user