feat: Add Regex No Code Action Page Filter (#6305)

Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
Victor Hugo dos Santos
2025-07-31 12:48:12 +07:00
committed by GitHub
parent da652bd860
commit 23c2d3dce9
40 changed files with 3198 additions and 585 deletions

View File

@@ -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

View File

@@ -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}

View File

@@ -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()}
/>
);
};

View File

@@ -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();
});
});

View File

@@ -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">

View File

@@ -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.");
});
});

View File

@@ -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"));
}
};

View File

@@ -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)"
}
}

View File

@@ -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)"
}
}

View File

@@ -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 !",

View File

@@ -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!",

View File

@@ -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!",

View File

@@ -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": "恭喜!",

View File

@@ -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
);
});
});

View File

@@ -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} />

View File

@@ -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]);
});
});

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View 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",
});
});
});
});

View 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,
};
};

View 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();
});
});
});

View 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"));
}
};

View 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("\\[\\{\\(.*\\)\\}\\]");
});
});

View 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>
);
};

View File

@@ -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", "");
});
});

View File

@@ -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" />
</>
);
};

View File

@@ -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", () => {

View File

@@ -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",

View File

@@ -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...",

View File

@@ -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", () => ({

View File

@@ -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>
);
};

View File

@@ -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);
});
});

View File

@@ -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);

View File

@@ -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
View 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

View File

@@ -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:

View File

@@ -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;
}

View File

@@ -40,7 +40,8 @@ export type TActionClassPageUrlRule =
| "startsWith"
| "endsWith"
| "notMatch"
| "notContains";
| "notContains"
| "matchesRegex";
export type TActionClassNoCodeConfig =
| {

View File

@@ -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>;