Validate custom modes schema before creation from the UI

This commit is contained in:
Matt Rubens 2025-02-14 08:40:35 -05:00
parent 998beeed52
commit 7bbf097fa8
5 changed files with 93 additions and 67 deletions

View file

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

View file

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

View file

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

View file

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

View file

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