Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/components/CharacterNew.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SkillProficiencySelector } from "@src/components/SkillProficiencySelect
import { Select } from "@src/components/ui/Select"
import { ClassNames, type ClassNameType, getTraits, type Trait } from "@src/lib/dnd"
import { getRuleset, RULESETS, type RulesetId } from "@src/lib/dnd/rulesets"
import { ignoreCheckEmptyErrors } from "@src/lib/formErrors"
import { toTitleCase } from "@src/lib/strings"
import clsx from "clsx"

Expand Down Expand Up @@ -43,6 +44,9 @@ const TraitList = ({ traits, title }: TraitListProps) => {
}

export const CharacterNew = ({ values = {}, errors = {} }: CharacterNewProps) => {
// Filter out errors for empty fields during is_check (live validation)
errors = ignoreCheckEmptyErrors(values, errors)

// Get ruleset based on selection, default to first ruleset
const rulesetId = (values?.ruleset as RulesetId) || RULESETS[0]!.id
const ruleset = getRuleset(rulesetId)
Expand Down
19 changes: 10 additions & 9 deletions src/lib/formErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,31 +16,32 @@ export function humanizeEnumError(error: string): string {
}

// biome-ignore lint/suspicious/noExplicitAny: Zod error structure is complex and varies by error type
function flattenZodIssues(issue: any, errors: FormErrors) {
function flattenZodIssues(issue: any, errors: FormErrors, parentPath: string[] = []) {
// Combine parent path with issue path
const fullPath = [...parentPath, ...(issue.path || [])]

// If this is a union error with nested errors, recursively process them
if (issue.code === "invalid_union" && issue.unionErrors) {
for (const unionError of issue.unionErrors) {
for (const nestedIssue of unionError.issues) {
flattenZodIssues(nestedIssue, errors)
flattenZodIssues(nestedIssue, errors, fullPath)
}
}
return
}

// Also handle the "errors" property (array of arrays of issues)
if (issue.code === "invalid_union" && issue.errors && Array.isArray(issue.errors)) {
for (const errorGroup of issue.errors) {
if (Array.isArray(errorGroup)) {
for (const nestedIssue of errorGroup) {
flattenZodIssues(nestedIssue, errors)
}
}
// For union errors, use the union-level error message with the path
const fieldName = fullPath.join(".")
if (fieldName && !errors[fieldName]) {
errors[fieldName] = humanizeEnumError(issue.message)
}
return
}

// Convert path array like ["dice", 0, "roll"] to "dice.0.roll"
const fieldName = issue.path.join(".")
const fieldName = fullPath.join(".")
const message = humanizeEnumError(issue.message)

// Skip empty field names (root-level union errors)
Expand Down
18 changes: 15 additions & 3 deletions src/services/createCharacter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,18 @@ describe("createCharacter", () => {
})
})

describe("with no class specified", () => {
it("shows a friendly error message", async () => {
const user = await userFactory.create({}, testCtx.db)
const data = buildCharacterData({ class: undefined })

const result = await createCharacter(testCtx.db, user, data)
expect(result.complete).toBe(false)
if (result.complete) return
expect(result.errors.class).toBe("Class is required")
})
})

describe("with no linage specified", () => {
it("should error out", async () => {
const user = await userFactory.create({}, testCtx.db)
Expand Down Expand Up @@ -1176,7 +1188,7 @@ describe("createCharacter", () => {
expect(strengthRecords[0]?.score).toBe(10) // Base
})

it("rejects bonuses to only one ability", async () => {
it("rejects bonuses exceeding max per ability", async () => {
const user = await userFactory.create({}, testCtx.db)
const data = buildCharacterData({
ruleset: SRD52_ID,
Expand All @@ -1185,14 +1197,14 @@ describe("createCharacter", () => {
background_ability_dex_bonus: "0",
background_ability_con_bonus: "0",
background_ability_int_bonus: "0",
background_ability_wis_bonus: "3", // All 3 to one ability!
background_ability_wis_bonus: "3", // Exceeds max of 2!
background_ability_cha_bonus: "0",
})

const result = await createCharacter(testCtx.db, user, data)
expect(result.complete).toBe(false)
if (result.complete) return
expect(result.errors.background).toContain("at least 2 different abilities")
expect(result.errors.background_ability_wis_bonus).toContain("exceed")
})

it("rejects bonuses to abilities not allowed by background", async () => {
Expand Down
Loading