diff --git a/packages/core/src/canvas/channelName.test.ts b/packages/core/src/canvas/channelName.test.ts new file mode 100644 index 000000000..9f9ad1873 --- /dev/null +++ b/packages/core/src/canvas/channelName.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { validateChannelName } from "./channelName"; + +describe("validateChannelName", () => { + it.each([ + "mobile", + "web-analytics", + "team-1", + "a", + "123", + "a-b-c", + " mobile ", // surrounding whitespace is trimmed before validating + ])("returns null for valid name %j", (name) => { + expect(validateChannelName(name)).toBeNull(); + }); + + it.each(["", " "])("returns null for empty/blank name %j", (name) => { + expect(validateChannelName(name)).toBeNull(); + }); + + it.each(["Mobile", "my channel", "team_1", "café", "a.b", "a/b", "emoji🚀"])( + "returns an error for invalid name %j", + (name) => { + expect(validateChannelName(name)).toBe( + "Use only lowercase letters, numbers, and hyphens.", + ); + }, + ); +}); diff --git a/packages/core/src/canvas/channelName.ts b/packages/core/src/canvas/channelName.ts new file mode 100644 index 000000000..6d1af1fc9 --- /dev/null +++ b/packages/core/src/canvas/channelName.ts @@ -0,0 +1,15 @@ +// A channel's name is used verbatim as its server-side filesystem path segment, +// so it must be directory-safe: lowercase letters, numbers, and hyphens only. +export const CHANNEL_NAME_PATTERN = /^[a-z0-9-]+$/; + +// Returns an error message for an invalid name, or null when valid. Empty is +// treated as valid here — callers already gate on a non-empty trimmed value, so +// this validator only judges the character set. +export function validateChannelName(name: string): string | null { + const trimmed = name.trim(); + if (!trimmed) return null; + if (!CHANNEL_NAME_PATTERN.test(trimmed)) { + return "Use only lowercase letters, numbers, and hyphens."; + } + return null; +} diff --git a/packages/ui/src/features/canvas/components/CreateChannelModal.tsx b/packages/ui/src/features/canvas/components/CreateChannelModal.tsx index c8659a4ca..d65e2be1e 100644 --- a/packages/ui/src/features/canvas/components/CreateChannelModal.tsx +++ b/packages/ui/src/features/canvas/components/CreateChannelModal.tsx @@ -1,4 +1,5 @@ import { HashIcon, XIcon } from "@phosphor-icons/react"; +import { validateChannelName } from "@posthog/core/canvas/channelName"; import { Button } from "@posthog/quill"; import { useChannelMutations } from "@posthog/ui/features/canvas/hooks/useChannels"; import { toast } from "@posthog/ui/primitives/toast"; @@ -33,9 +34,10 @@ export function CreateChannelModal({ const trimmed = name.trim(); const remaining = MAX_CHANNEL_NAME_LENGTH - name.length; + const validationError = validateChannelName(trimmed); const submit = async () => { - if (!trimmed || isCreating) return; + if (!trimmed || validationError || isCreating) return; try { const channel = await createChannel(trimmed); onOpenChange(false); @@ -108,6 +110,11 @@ export function CreateChannelModal({ + {validationError && ( + + {validationError} + + )} Each channel gets its own dashboards, tasks, and settings. Use a name that's easy to find. @@ -117,7 +124,7 @@ export function CreateChannelModal({