Browse Source

Validate custom modes schema before creation from the UI

Matt Rubens 1 year ago
parent
commit
7bbf097fa8

+ 13 - 16
src/core/config/CustomModesSchema.ts

@@ -29,22 +29,19 @@ const GroupOptionsSchema = z.object({
 const GroupEntrySchema = z.union([ToolGroupSchema, z.tuple([ToolGroupSchema, GroupOptionsSchema])])
 
 // Schema for array of groups
-const GroupsArraySchema = z
-	.array(GroupEntrySchema)
-	.min(1, "At least one tool group is required")
-	.refine(
-		(groups) => {
-			const seen = new Set()
-			return groups.every((group) => {
-				// For tuples, check the group name (first element)
-				const groupName = Array.isArray(group) ? group[0] : group
-				if (seen.has(groupName)) return false
-				seen.add(groupName)
-				return true
-			})
-		},
-		{ message: "Duplicate groups are not allowed" },
-	)
+const GroupsArraySchema = z.array(GroupEntrySchema).refine(
+	(groups) => {
+		const seen = new Set()
+		return groups.every((group) => {
+			// For tuples, check the group name (first element)
+			const groupName = Array.isArray(group) ? group[0] : group
+			if (seen.has(groupName)) return false
+			seen.add(groupName)
+			return true
+		})
+	},
+	{ message: "Duplicate groups are not allowed" },
+)
 
 // Schema for mode configuration
 export const CustomModeSchema = z.object({

+ 0 - 22
src/core/config/__tests__/CustomModesSchema.test.ts

@@ -95,17 +95,6 @@ describe("CustomModeSchema", () => {
 			expect(() => validateCustomMode(invalidGroupMode)).toThrow(ZodError)
 		})
 
-		test("rejects empty groups array", () => {
-			const invalidMode = {
-				slug: "123e4567-e89b-12d3-a456-426614174000",
-				name: "Test Mode",
-				roleDefinition: "Test role definition",
-				groups: [] as const,
-			} satisfies ModeConfig
-
-			expect(() => validateCustomMode(invalidMode)).toThrow("At least one tool group is required")
-		})
-
 		test("handles null and undefined gracefully", () => {
 			expect(() => validateCustomMode(null)).toThrow(ZodError)
 			expect(() => validateCustomMode(undefined)).toThrow(ZodError)
@@ -179,16 +168,5 @@ describe("CustomModeSchema", () => {
 
 			expect(() => CustomModeSchema.parse(modeWithDuplicates)).toThrow(/Duplicate groups/)
 		})
-
-		it("requires at least one group", () => {
-			const modeWithNoGroups = {
-				slug: "test",
-				name: "Test",
-				roleDefinition: "Test",
-				groups: [],
-			}
-
-			expect(() => CustomModeSchema.parse(modeWithNoGroups)).toThrow(/At least one tool group is required/)
-		})
 	})
 })

+ 0 - 9
src/core/config/__tests__/GroupConfigSchema.test.ts

@@ -45,15 +45,6 @@ describe("GroupConfigSchema", () => {
 			expect(() => CustomModeSchema.parse(mode)).toThrow()
 		})
 
-		test("rejects empty groups array", () => {
-			const mode = {
-				...validBaseMode,
-				groups: [] as const,
-			} satisfies ModeConfig
-
-			expect(() => CustomModeSchema.parse(mode)).toThrow("At least one tool group is required")
-		})
-
 		test("rejects invalid group names", () => {
 			const mode = {
 				...validBaseMode,

+ 79 - 20
webview-ui/src/components/prompts/PromptsView.tsx

@@ -19,6 +19,7 @@ import {
 	ModeConfig,
 	GroupEntry,
 } from "../../../../src/shared/modes"
+import { CustomModeSchema } from "../../../../src/core/config/CustomModesSchema"
 import {
 	supportPrompt,
 	SupportPromptType,
@@ -157,15 +158,34 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 	const [newModeGroups, setNewModeGroups] = useState<GroupEntry[]>(availableGroups)
 	const [newModeSource, setNewModeSource] = useState<ModeSource>("global")
 
+	// Field-specific error states
+	const [nameError, setNameError] = useState<string>("")
+	const [slugError, setSlugError] = useState<string>("")
+	const [roleDefinitionError, setRoleDefinitionError] = useState<string>("")
+	const [groupsError, setGroupsError] = useState<string>("")
+
+	// Helper to reset form state
+	const resetFormState = useCallback(() => {
+		// Reset form fields
+		setNewModeName("")
+		setNewModeSlug("")
+		setNewModeGroups(availableGroups)
+		setNewModeRoleDefinition("")
+		setNewModeCustomInstructions("")
+		setNewModeSource("global")
+		// Reset error states
+		setNameError("")
+		setSlugError("")
+		setRoleDefinitionError("")
+		setGroupsError("")
+	}, [])
+
 	// Reset form fields when dialog opens
 	useEffect(() => {
 		if (isCreateModeDialogOpen) {
-			setNewModeGroups(availableGroups)
-			setNewModeRoleDefinition("")
-			setNewModeCustomInstructions("")
-			setNewModeSource("global")
+			resetFormState()
 		}
-	}, [isCreateModeDialogOpen])
+	}, [isCreateModeDialogOpen, resetFormState])
 
 	// Helper function to generate a unique slug from a name
 	const generateSlug = useCallback((name: string, attempt = 0): string => {
@@ -186,26 +206,52 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 	)
 
 	const handleCreateMode = useCallback(() => {
-		if (!newModeName.trim() || !newModeSlug.trim()) return
+		// Clear previous errors
+		setNameError("")
+		setSlugError("")
+		setRoleDefinitionError("")
+		setGroupsError("")
 
 		const source = newModeSource
 		const newMode: ModeConfig = {
 			slug: newModeSlug,
 			name: newModeName,
-			roleDefinition: newModeRoleDefinition.trim() || "",
+			roleDefinition: newModeRoleDefinition.trim(),
 			customInstructions: newModeCustomInstructions.trim() || undefined,
 			groups: newModeGroups,
 			source,
 		}
+
+		// Validate the mode against the schema
+		const result = CustomModeSchema.safeParse(newMode)
+		if (!result.success) {
+			// Map Zod errors to specific fields
+			result.error.errors.forEach((error) => {
+				const field = error.path[0] as string
+				const message = error.message
+
+				switch (field) {
+					case "name":
+						setNameError(message)
+						break
+					case "slug":
+						setSlugError(message)
+						break
+					case "roleDefinition":
+						setRoleDefinitionError(message)
+						break
+					case "groups":
+						setGroupsError(message)
+						break
+				}
+			})
+			return
+		}
+
 		updateCustomMode(newModeSlug, newMode)
 		switchMode(newModeSlug)
 		setIsCreateModeDialogOpen(false)
-		setNewModeName("")
-		setNewModeSlug("")
-		setNewModeRoleDefinition("")
-		setNewModeCustomInstructions("")
-		setNewModeGroups(availableGroups)
-		setNewModeSource("global")
+		resetFormState()
 		// eslint-disable-next-line react-hooks/exhaustive-deps
 	}, [
 		newModeName,
@@ -431,7 +477,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 
 				<div className="mt-5">
 					<div onClick={(e) => e.stopPropagation()} className="flex justify-between items-center mb-3">
-						<h3 className="text-vscode-foreground m-0">Mode-Specific Prompts</h3>
+						<h3 className="text-vscode-foreground m-0">Modes</h3>
 						<div className="flex gap-2">
 							<VSCodeButton appearance="icon" onClick={openCreateModeDialog} title="Create new mode">
 								<span className="codicon codicon-add"></span>
@@ -727,7 +773,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 								alignItems: "center",
 								marginBottom: "4px",
 							}}>
-							<div style={{ fontWeight: "bold" }}>Mode-specific Custom Instructions</div>
+							<div style={{ fontWeight: "bold" }}>Mode-specific Custom Instructions (optional)</div>
 							{!findModeBySlug(mode, customModes) && (
 								<VSCodeButton
 									appearance="icon"
@@ -1069,6 +1115,9 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 									}}
 									style={{ width: "100%" }}
 								/>
+								{nameError && (
+									<div className="text-xs text-vscode-errorForeground mt-1">{nameError}</div>
+								)}
 							</div>
 							<div style={{ marginBottom: "16px" }}>
 								<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Slug</div>
@@ -1091,6 +1140,9 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 									The slug is used in URLs and file names. It should be lowercase and contain only
 									letters, numbers, and hyphens.
 								</div>
+								{slugError && (
+									<div className="text-xs text-vscode-errorForeground mt-1">{slugError}</div>
+								)}
 							</div>
 							<div style={{ marginBottom: "16px" }}>
 								<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Save Location</div>
@@ -1147,6 +1199,11 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 									resize="vertical"
 									style={{ width: "100%" }}
 								/>
+								{roleDefinitionError && (
+									<div className="text-xs text-vscode-errorForeground mt-1">
+										{roleDefinitionError}
+									</div>
+								)}
 							</div>
 							<div style={{ marginBottom: "16px" }}>
 								<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Available Tools</div>
@@ -1184,9 +1241,14 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 										</VSCodeCheckbox>
 									))}
 								</div>
+								{groupsError && (
+									<div className="text-xs text-vscode-errorForeground mt-1">{groupsError}</div>
+								)}
 							</div>
 							<div style={{ marginBottom: "16px" }}>
-								<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Custom Instructions</div>
+								<div style={{ fontWeight: "bold", marginBottom: "4px" }}>
+									Custom Instructions (optional)
+								</div>
 								<div
 									style={{
 										fontSize: "13px",
@@ -1219,10 +1281,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 								backgroundColor: "var(--vscode-editor-background)",
 							}}>
 							<VSCodeButton onClick={() => setIsCreateModeDialogOpen(false)}>Cancel</VSCodeButton>
-							<VSCodeButton
-								appearance="primary"
-								onClick={handleCreateMode}
-								disabled={!newModeName.trim() || !newModeSlug.trim()}>
+							<VSCodeButton appearance="primary" onClick={handleCreateMode}>
 								Create Mode
 							</VSCodeButton>
 						</div>

+ 1 - 0
webview-ui/src/index.css

@@ -84,6 +84,7 @@
 	--color-vscode-notifications-background: var(--vscode-notifications-background);
 	--color-vscode-notifications-border: var(--vscode-notifications-border);
 	--color-vscode-descriptionForeground: var(--vscode-descriptionForeground);
+	--color-vscode-errorForeground: var(--vscode-errorForeground);
 }
 
 @layer base {