Skip to content

Collection item fields emit the HTML5 Required attribute, contradicting the project's validation convention #190

Description

@phmatray

Problem / motivation

The two render paths disagree about the HTML5 Required attribute, and the collection path is the
one that breaks the project's stated convention.

CLAUDE.md records the rule explicitly:

Required() adds validation but NOT HTML5 required attribute · Browser validation disabled via
novalidate · All validation through FluentValidation · MudBlazor components don't include
Required attribute

Measured with a probe rendering the same .Required("…") field two ways:

Render path MudTextField.Required
Ordinary field False ✅ — matches the convention
Inside .WithItemForm(...) True ❌

CollectionFieldComponent.AddCommonFieldAttributes emits builder.AddAttribute(…, "Required", field.IsRequired). No component-path renderer emits Required at all — grep -r "Required" FormCraft.ForMudBlazor/**/*.razor returns nothing.

Consequence. Inside a collection, MudBlazor's own required validation runs alongside
FluentValidation, so a blank required item field can produce two error messages with different
wording for one problem — and the message MudBlazor generates is not the one the developer wrote in
.Required("Product name is required").

Found while reviewing #188, which added a render-path parity test. Required was initially in that
test's compared set but was never configured by the test, so it asserted nothing; removing it
surfaced this genuine divergence, which the test now documents rather than hides.

Proposed solution

Stop emitting Required from AddCommonFieldAttributes, matching the component path and the
documented convention, then move Required into the parity test's compared set with the shared
Configure delegate actually calling .Required(...).

Validation behaviour is unaffected: Required() still registers its validator, and FluentValidation
still produces the developer's message. What disappears is MudBlazor's second, differently-worded
browser-style error.

Alternatives considered

  • Emit Required on the component path too, making both paths agree in the other direction.
    Rejected: it contradicts the documented convention, re-enables browser-flavoured validation the
    project deliberately disables with novalidate, and would change behaviour on every existing form
    rather than only on collection item fields.
  • Leave the divergence and document it. Rejected — the parity test would have to carry a permanent
    exception, and duplicate error messages are a real user-visible defect.
  • Make it configurable. Disproportionate for an attribute the project has already decided about.

Area

FormCraft.ForMudBlazor — collection field rendering


Related: #184, #188 (where this was found), #189 (the other half of the same render-path drift)

🧠 Brainstorm

Problem / context

This is the same two-render-path drift as #146 (Variant), #177 (ShrinkLabel), #184 (adornments)
and #189 (InputType) — with one difference worth naming: in every previous case the collection path
was doing too little. Here it does too much. That inverts the fix, and it is why the parity test
in #188 could not simply be extended: Presentation() compared Required while the test never
configured it, so the assertion passed vacuously over a real disagreement.

The project's validation stance is deliberate and documented: server-side only, novalidate on the
form, messages from FluentValidation. The collection path predates or ignores that.

Approaches

A. Drop Required from the collection path.
Pros: aligns with the documented convention and with every component-path renderer; one line
removed; makes the parity test's compared set honest. Cons: it is a behaviour change for anyone
relying (knowingly or not) on MudBlazor's native asterisk/validation inside collections.

B. Add Required to the component path instead.
Pros: also achieves parity. Cons: contradicts CLAUDE.md, re-introduces browser-style validation
project-wide, and changes far more forms. Wrong direction.

C. Leave it; carry a permanent exception in the parity test.
Pros: zero risk. Cons: leaves duplicate, differently-worded error messages in place and
permanently weakens the guard.

Recommendation

A. It is the only option consistent with the documented convention, and it is the smaller
behaviour change of the two that achieve parity.

Worth stating the cost honestly: MudBlazor renders a * on Required fields, so dropping it removes
that visual marker from collection item fields — which is exactly what ordinary fields already look
like. Anyone who wants the asterisk can set it explicitly via .WithAttribute("Required", true), and
that escape hatch should be mentioned in the release note.

📋 Spec

Goal

A .Required(...) field renders identically inside and outside a collection item form — validation
enforced by FluentValidation, no HTML5 Required attribute, one error message — and the parity test
compares Required for real.

Scope

  • Remove the Required attribute emission from CollectionFieldComponent.AddCommonFieldAttributes.
  • Configure .Required(...) in RenderPipelineParityTests' shared Configure delegate and add
    Required to Presentation(), removing it from that method's documented divergence list.
  • Release-note the change, including the .WithAttribute("Required", true) opt-in.

Non-goals

  • Any change to the component path's validation behaviour.
  • Any change to how Required() registers its validator in the FormCraft core.
  • RenderBooleanField, which bypasses AddCommonFieldAttributes entirely and never emitted
    Required — tracked separately as part of that path's wider gap.
  • The other divergent attributes (InputType, Lines, MaxLength, Autocomplete) — see Password fields render in clear text inside collection item forms #189.

Behaviour

flowchart TD
    A["field.Required(\"Product is required\")"] --> B{which render path?}
    B -->|ordinary field| C["no Required attribute<br/>FluentValidation only<br/><b>one message ✅</b>"]
    B -->|inside .WithItemForm| D["Required = true<br/>MudBlazor validation + FluentValidation<br/><b>two messages ❌ — today</b>"]
    D --> E["<b>after this issue:</b><br/>no Required attribute, one message"]
Loading

Key files

  • FormCraft.ForMudBlazor/Features/CollectionField/CollectionFieldComponent.razor.cs — AddCommonFieldAttributes.
  • FormCraft.ForMudBlazor.UnitTests/Components/RenderPipelineParityTests.cs — Presentation() and the shared Configure delegate.
  • FormCraft.ForMudBlazor/Fields/TextField/MudBlazorTextFieldComponent.razor — the reference behaviour (emits no Required).

Validation rules

  • A .Required(...) item field renders Required == false on its MudBlazor component.
  • A blank .Required(...) item field still fails validation, with the message passed to Required(...).
  • Exactly one validation message is surfaced for a blank required item field, not two.
  • .WithAttribute("Required", true) on an item field still renders Required == true — the escape hatch works.
  • The parity test configures .Required(...) and compares Required across both paths.

Edge cases

  • ReadOnly / Disabled are in the same emission block and are not in scope — they agree
    across paths today. Remove only the Required line.
  • Sequence numbers. AddCommonFieldAttributes uses the reserved block starting at
    CommonAttributeStart, with callers starting at CallerAttributeStart. Removing an attribute
    shortens the block, which is safe, but the numbers must stay source-position constants — never
    computed (see fix(mudblazor): render adornments on collection item fields (#184) #188).
  • The escape hatch must survive. If the removal is implemented by simply deleting the line, a
    field that set "Required" through raw WithAttribute(...) also stops working. Prefer resolving it
    through GetItemFieldAttribute<bool>(field, "Required", false) so an explicit opt-in is still honoured
    while IsRequired no longer drives it.

Assumptions

  • No repo issue-form template exists (.github/ISSUE_TEMPLATE/ is absent), so the fields above follow the shape recent issues use.
  • Target is a patch/minor. Base branch is dev.
  • Removing MudBlazor's * marker from collection item fields is acceptable, because it matches what every ordinary field already renders.

🛠️ Implementation plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: make Required behave the same on both render paths, in the direction the project has
already documented, and let the parity test prove it.

Architecture: all changes in FormCraft.ForMudBlazor; the UI-agnostic FormCraft core is
untouched. Required()'s validator registration is core behaviour and must not change.

Tech stack: .NET 8 / 10 multi-target, Blazor, MudBlazor 9, xUnit + bUnit + Shouldly (MudBlazorTestBase).

Global constraints:

  • Base branch dev; commit as Philippe Matray <phmatray@gmail.com>; conventional commits.
  • TreatWarningsAsErrors=true and CA analyzers are enforced — the build fails on any warning.
  • dotnet test --filter is silently ignored here (MTP0001, Microsoft.Testing.Platform) — it runs the whole suite. Use the full run; it takes seconds.
  • The documented convention is binding: validation is server-side, forms render novalidate, MudBlazor components carry no Required attribute.
  • RenderTreeBuilder sequence numbers must stay source-position constants.

Task 1: Stop driving Required from IsRequired on the collection path

Files: modify FormCraft.ForMudBlazor/Features/CollectionField/CollectionFieldComponent.razor.cs (AddCommonFieldAttributes); create FormCraft.ForMudBlazor.UnitTests/Fields/CollectionRequiredTests.cs.

Interfaces: Required is resolved from the field's explicit "Required" attribute (default false) instead of from field.IsRequired.

  • Step 1: Write failing tests in CollectionRequiredTests.cs: a .Required("…") item field renders Required == false; an item field with .WithAttribute("Required", true) renders Required == true.
  • Step 2: Run dotnet test -c Release → FAIL (the first case renders True).
  • Step 3: Resolve Required via GetItemFieldAttribute<bool>(field, "Required", false) rather than field.IsRequired, so the explicit opt-in survives.
  • Step 4: Run dotnet test -c Release → PASS, with CollectionShrinkLabelTests, CollectionAdornmentTests and ShrinkLabelDiagnosticsTests green with no edits.
  • Step 5: Commit: fix(mudblazor): stop emitting the HTML5 Required attribute on collection item fields.

Task 2: Prove validation still works, and only once

Files: modify CollectionRequiredTests.cs.

Interfaces: none new.

  • Step 1: Write a test that submits a form whose required item field is blank and asserts the validation message is the one passed to .Required("…").
  • Step 2: Write a test asserting exactly one validation message is surfaced for that field, not two.
  • Step 3: Run the suite → PASS (Task 1 already produced this behaviour; this task pins it). If either fails, fix the cause rather than the assertion.
  • Step 4: Commit: test(mudblazor): pin single-message validation for required collection item fields.

Task 3: Fold Required into the parity test and release-note it

Files: modify FormCraft.ForMudBlazor.UnitTests/Components/RenderPipelineParityTests.cs; modify README.md.

Interfaces: none new.

  • Step 1: Call .Required("…") in the parity test's shared Configure delegate and add Required to Presentation(), removing it from that method's documented divergence list.
  • Step 2: Run the suite → PASS. Then verify the guard bites: temporarily restore the field.IsRequired emission and confirm the parity test goes red, and restore.
  • Step 3: Add a release-note bullet under the README's ## 🎉 Unreleased section: collection item fields no longer carry the HTML5 Required attribute, matching ordinary fields; validation is unchanged; .WithAttribute("Required", true) opts back in.
  • Step 4: Run dotnet build -c Release and dotnet test -c Release → both green.
  • Step 5: Commit: test(mudblazor): compare Required across both render paths.

Metadata

Metadata

Assignees

No one assigned

    Labels

    priority:mediumFix when possiblestatus:triagedClassified and ready for analysis/worktype:bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions