Compare commits

...

1 Commits

5 changed files with 103 additions and 17 deletions

View File

@@ -38,6 +38,7 @@ export const ActionSettingsTab = ({
setOpen,
isReadOnly,
}: ActionSettingsTabProps) => {
const actionDocsHref = "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions";
const { createdAt, updatedAt, id, ...restActionClass } = actionClass;
const router = useRouter();
const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
@@ -146,7 +147,7 @@ export const ActionSettingsTab = ({
<div className="flex justify-between gap-x-2 border-slate-200 pt-4">
<div className="flex items-center gap-x-2">
{!isReadOnly ? (
{isReadOnly ? null : (
<Button
type="button"
variant="destructive"
@@ -155,22 +156,22 @@ export const ActionSettingsTab = ({
<TrashIcon />
{t("common.delete")}
</Button>
) : null}
)}
<Button variant="secondary" asChild>
<Link href="https://formbricks.com/docs/actions/no-code" target="_blank">
<Link href={actionDocsHref} target="_blank">
{t("common.read_docs")}
</Link>
</Button>
</div>
{!isReadOnly ? (
{isReadOnly ? null : (
<div className="flex space-x-2">
<Button type="submit" loading={isUpdatingAction}>
{t("common.save_changes")}
</Button>
</div>
) : null}
)}
</div>
</form>
</FormProvider>

View File

@@ -4,7 +4,6 @@
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, TActionClassInput } from "@formbricks/types/action-classes";
import {
createActionClassZodResolver,
@@ -45,9 +44,9 @@ const mockT = vi.fn((key: string, params?: any) => {
// Helper to create mock context
const createMockContext = () => {
const issues: z.ZodIssue[] = [];
const issues: unknown[] = [];
return {
addIssue: vi.fn((issue: z.ZodIssue) => issues.push(issue)),
addIssue: vi.fn((issue: unknown) => issues.push(issue)),
issues,
} as any;
};

View File

@@ -50,7 +50,11 @@ export const CodeActionForm = ({ form, isReadOnly }: CodeActionFormProps) => {
formbricks.track(&quot;{watch("key")}&quot;)
</span>{" "}
{t("environments.actions.in_your_code_read_more_in_our")}{" "}
<a href="https://formbricks.com/docs/actions/code" target="_blank" className="underline">
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions"
target="_blank"
rel="noreferrer"
className="underline">
{t("common.docs")}
</a>
{"."}

View File

@@ -0,0 +1,42 @@
import { describe, expect, test } from "vitest";
import { ZActionClassNoCodeConfig, isValidActionClassUrlFilterValue } from "./action-classes";
describe("isValidActionClassUrlFilterValue", () => {
test("accepts valid absolute URLs for URL-like rules", () => {
expect(isValidActionClassUrlFilterValue("https://example.com/dashboard", "exactMatch")).toBe(true);
});
test("rejects hostnames without a valid TLD", () => {
expect(isValidActionClassUrlFilterValue("https://ok", "exactMatch")).toBe(false);
expect(isValidActionClassUrlFilterValue("https://ok.", "exactMatch")).toBe(false);
});
test("accepts localhost and root-relative paths", () => {
expect(isValidActionClassUrlFilterValue("http://localhost:3000/dashboard", "exactMatch")).toBe(true);
expect(isValidActionClassUrlFilterValue("/dashboard", "startsWith")).toBe(true);
});
test("skips URL-shape validation for free-form rules", () => {
expect(isValidActionClassUrlFilterValue("dashboard", "contains")).toBe(true);
});
});
describe("ZActionClassNoCodeConfig", () => {
test("rejects invalid URL-like filters", () => {
const result = ZActionClassNoCodeConfig.safeParse({
type: "pageView",
urlFilters: [{ rule: "exactMatch", value: "https://ok." }],
});
expect(result.success).toBe(false);
});
test("accepts valid URL-like filters", () => {
const result = ZActionClassNoCodeConfig.safeParse({
type: "pageView",
urlFilters: [{ rule: "exactMatch", value: "https://example.com/dashboard" }],
});
expect(result.success).toBe(true);
});
});

View File

@@ -26,16 +26,56 @@ export const ZActionClassPageUrlRule = z.enum(ACTION_CLASS_PAGE_URL_RULES);
export type TActionClassPageUrlRule = z.infer<typeof ZActionClassPageUrlRule>;
const URL_LIKE_FILTER_RULES = new Set<TActionClassPageUrlRule>(["exactMatch", "startsWith", "notMatch"]);
const DOMAIN_HOSTNAME_REGEX = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/;
const isValidAbsoluteUrlFilterValue = (value: string): boolean => {
try {
const parsedUrl = new URL(value);
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
return false;
}
const isIPv6 = parsedUrl.hostname.startsWith("[") && parsedUrl.hostname.endsWith("]");
const isIPv4 = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(parsedUrl.hostname);
return (
DOMAIN_HOSTNAME_REGEX.test(parsedUrl.hostname) || parsedUrl.hostname === "localhost" || isIPv6 || isIPv4
);
} catch {
return false;
}
};
export const isValidActionClassUrlFilterValue = (value: string, rule: TActionClassPageUrlRule): boolean => {
if (!URL_LIKE_FILTER_RULES.has(rule)) {
return true;
}
return value.startsWith("/") || isValidAbsoluteUrlFilterValue(value);
};
const ZActionClassUrlFilter = z
.object({
value: z.string().trim().min(1, {
error: "Value must contain atleast 1 character",
}),
rule: ZActionClassPageUrlRule,
})
.superRefine((data, ctx) => {
if (!isValidActionClassUrlFilterValue(data.value, data.rule)) {
ctx.addIssue({
code: "custom",
path: ["value"],
message: "Please enter a valid URL (e.g., https://example.com)",
});
}
});
const ZActionClassNoCodeConfigBase = z.object({
type: z.enum(["click", "pageView", "exitIntent", "fiftyPercentScroll", "pageDwell"]),
urlFilters: z.array(
z.object({
value: z.string().trim().min(1, {
error: "Value must contain atleast 1 character",
}),
rule: ZActionClassPageUrlRule,
})
),
urlFilters: z.array(ZActionClassUrlFilter),
urlFiltersConnector: z.enum(["or", "and"]).optional(),
});