diff --git a/docs/specs/examples/AlertComponent.html b/docs/specs/examples/AlertComponent.html new file mode 100644 index 0000000..13746e1 --- /dev/null +++ b/docs/specs/examples/AlertComponent.html @@ -0,0 +1,97 @@ + + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/specs/examples/AlertComponent.java b/docs/specs/examples/AlertComponent.java new file mode 100644 index 0000000..3ea32c3 --- /dev/null +++ b/docs/specs/examples/AlertComponent.java @@ -0,0 +1,75 @@ +package de.tschuehly.example.fragments; + +import de.tschuehly.spring.viewcomponent.core.component.ViewComponent; +import de.tschuehly.spring.viewcomponent.thymeleaf.ViewContext; + +/** + * Advanced example showing fragment rendering for alert messages. + * + * Demonstrates: + * - Different data requirements for different contexts (e.g., ErrorAlert has stackTrace) + * - Compile-time safety (can't create ErrorAlert without stackTrace) + * - Single template with multiple presentation variants + */ +@ViewComponent +public class AlertComponent { + + /** + * Informational alert - simple message display + */ + public record InfoAlert(String message) implements ViewContext {} + + /** + * Warning alert - message with additional details + */ + public record WarningAlert(String message, String details) implements ViewContext {} + + /** + * Error alert - message with stack trace + */ + public record ErrorAlert(String message, String stackTrace) implements ViewContext {} + + /** + * Success alert - simple message for successful operations + */ + public record SuccessAlert(String message) implements ViewContext {} + + public InfoAlert info(String message) { + return new InfoAlert(message); + } + + public WarningAlert warning(String message, String details) { + return new WarningAlert(message, details); + } + + public ErrorAlert error(String message, String stackTrace) { + return new ErrorAlert(message, stackTrace); + } + + public SuccessAlert success(String message) { + return new SuccessAlert(message); + } + + /** + * Convenience method to create error alerts from exceptions + */ + public ErrorAlert error(String message, Exception exception) { + var stackTrace = buildStackTrace(exception); + return new ErrorAlert(message, stackTrace); + } + + private String buildStackTrace(Exception exception) { + var sb = new StringBuilder(); + sb.append(exception.getClass().getName()).append(": ").append(exception.getMessage()).append("\n"); + + for (var element : exception.getStackTrace()) { + sb.append(" at ").append(element.toString()).append("\n"); + } + + if (exception.getCause() != null) { + sb.append("Caused by: ").append(buildStackTrace((Exception) exception.getCause())); + } + + return sb.toString(); + } +} diff --git a/docs/specs/examples/ButtonComponent.html b/docs/specs/examples/ButtonComponent.html new file mode 100644 index 0000000..bf4e1a2 --- /dev/null +++ b/docs/specs/examples/ButtonComponent.html @@ -0,0 +1,35 @@ + + +
+ + + + + + + + + + +
diff --git a/docs/specs/examples/ButtonComponent.java b/docs/specs/examples/ButtonComponent.java new file mode 100644 index 0000000..4f4e0dc --- /dev/null +++ b/docs/specs/examples/ButtonComponent.java @@ -0,0 +1,61 @@ +package de.tschuehly.example.fragments; + +import de.tschuehly.spring.viewcomponent.core.component.ViewComponent; +import de.tschuehly.spring.viewcomponent.thymeleaf.ViewContext; + +/** + * Example ViewComponent demonstrating fragment rendering with multiple ViewContext types. + * + * This single component can render different button variants (primary, secondary, danger) + * based on the ViewContext type returned from the render methods. + */ +@ViewComponent +public class ButtonComponent { + + /** + * ViewContext for primary action buttons. + */ + public record PrimaryButton( + String label, + String action + ) implements ViewContext {} + + /** + * ViewContext for secondary/alternative action buttons. + */ + public record SecondaryButton( + String label, + String action + ) implements ViewContext {} + + /** + * ViewContext for dangerous/destructive action buttons. + * Includes a confirmation message that will be shown before execution. + */ + public record DangerButton( + String label, + String action, + String confirmMessage + ) implements ViewContext {} + + /** + * Renders a primary button for main actions. + */ + public PrimaryButton primary(String label, String action) { + return new PrimaryButton(label, action); + } + + /** + * Renders a secondary button for alternative actions. + */ + public SecondaryButton secondary(String label, String action) { + return new SecondaryButton(label, action); + } + + /** + * Renders a danger button for destructive actions. + */ + public DangerButton danger(String label, String action, String confirmMessage) { + return new DangerButton(label, action, confirmMessage); + } +} diff --git a/docs/specs/examples/ButtonComponent.jte b/docs/specs/examples/ButtonComponent.jte new file mode 100644 index 0000000..07742ce --- /dev/null +++ b/docs/specs/examples/ButtonComponent.jte @@ -0,0 +1,32 @@ +@import de.tschuehly.example.fragments.ButtonComponent.* + +<%-- + ButtonComponent JTE template with type-based conditional rendering. + + JTE natively supports instanceof checks with pattern matching, + providing compile-time type safety. +--%> + +@if(model instanceof PrimaryButton primaryButton) + +@elseif(model instanceof SecondaryButton secondaryButton) + +@elseif(model instanceof DangerButton dangerButton) + +@else + <%-- Fallback for unknown context types --%> +
Unknown button type: ${model.getClass().getSimpleName()}
+@endif diff --git a/docs/specs/examples/ExampleController.java b/docs/specs/examples/ExampleController.java new file mode 100644 index 0000000..4e351c4 --- /dev/null +++ b/docs/specs/examples/ExampleController.java @@ -0,0 +1,68 @@ +package de.tschuehly.example.fragments; + +import de.tschuehly.spring.viewcomponent.core.IViewContext; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; + +/** + * Example controller demonstrating fragment rendering usage. + * + * Different endpoints return different ViewContext types from the same ViewComponent, + * causing different fragments to be rendered. + */ +@Controller +public class ExampleController { + + private final ButtonComponent buttonComponent; + + public ExampleController(ButtonComponent buttonComponent) { + this.buttonComponent = buttonComponent; + } + + /** + * Returns a primary button - renders the PrimaryButton fragment + */ + @GetMapping("/button/submit") + IViewContext submitButton() { + return buttonComponent.primary("Submit Form", "/api/submit"); + } + + /** + * Returns a secondary button - renders the SecondaryButton fragment + */ + @GetMapping("/button/cancel") + IViewContext cancelButton() { + return buttonComponent.secondary("Cancel", "/api/cancel"); + } + + /** + * Returns a danger button - renders the DangerButton fragment + */ + @GetMapping("/button/delete") + IViewContext deleteButton() { + return buttonComponent.danger( + "Delete Account", + "/api/delete-account", + "Are you sure you want to delete your account? This action cannot be undone." + ); + } + + /** + * Example showing nested component rendering with fragments. + * + * The page component can receive different button variants + * as child components. + */ + @GetMapping("/page/submit-form") + IViewContext submitFormPage() { + var submitButton = buttonComponent.primary("Submit", "/submit"); + var cancelButton = buttonComponent.secondary("Cancel", "/cancel"); + + // In a real application, you might have a PageComponent that accepts + // button components as parameters + // return pageComponent.render(submitButton, cancelButton); + + return submitButton; // Simplified for this example + } +} diff --git a/docs/specs/examples/README.md b/docs/specs/examples/README.md new file mode 100644 index 0000000..a143fcb --- /dev/null +++ b/docs/specs/examples/README.md @@ -0,0 +1,442 @@ +# Fragment Rendering Examples + +This directory contains example implementations demonstrating the proposed fragment rendering feature for Spring View Component. + +## Overview + +Fragment rendering allows a single ViewComponent to have **multiple ViewContext implementations**, with templates that conditionally render different fragments based on the ViewContext type. + +## Examples + +### 1. ButtonComponent + +**File:** `ButtonComponent.java`, `ButtonComponent.html`, `ButtonComponent.jte` + +Demonstrates basic fragment rendering with button variants: +- `PrimaryButton` - Main action buttons +- `SecondaryButton` - Alternative action buttons +- `DangerButton` - Destructive action buttons with confirmation + +**Key Concepts:** +- Multiple ViewContext records in one component +- Type-safe fragment selection +- Different template syntax for Thymeleaf vs JTE + +### 2. AlertComponent + +**File:** `AlertComponent.java`, `AlertComponent.html` + +Demonstrates advanced fragment rendering with alert messages: +- `InfoAlert` - Informational messages +- `WarningAlert` - Warnings with expandable details +- `ErrorAlert` - Errors with stack traces +- `SuccessAlert` - Success messages + +**Key Concepts:** +- Different data requirements per context type +- Compile-time safety (ErrorAlert requires stackTrace) +- Complex template structures with expandable sections + +### 3. ExampleController + +**File:** `ExampleController.java` + +Demonstrates controller integration: +- Different endpoints returning different ViewContext types +- Same ViewComponent rendering different fragments +- Nested component composition + +## How It Works + +### Thymeleaf Syntax + +```html +
+ + + +
+``` + +**Behavior:** +1. The `view:context-root` marks the fragment container +2. Each child element with `view:context` is a fragment +3. Only the fragment matching the current ViewContext type is rendered +4. Non-matching fragments are removed from the output + +### JTE Syntax + +```java +@import de.example.ButtonComponent.* + +@if(model instanceof PrimaryButton primaryButton) + +@elseif(model instanceof SecondaryButton secondaryButton) + +@endif +``` + +**Behavior:** +1. Standard JTE `instanceof` checks with pattern matching +2. Compile-time type safety +3. Native Java control flow + +## Benefits + +### 1. Reduced Code Duplication + +**Before:** +```java +@ViewComponent +public class PrimaryButtonComponent { ... } + +@ViewComponent +public class SecondaryButtonComponent { ... } + +@ViewComponent +public class DangerButtonComponent { ... } +``` + +**After:** +```java +@ViewComponent +public class ButtonComponent { + public record PrimaryButton(...) implements ViewContext {} + public record SecondaryButton(...) implements ViewContext {} + public record DangerButton(...) implements ViewContext {} +} +``` + +### 2. Type Safety + +**Before (error-prone):** +```java +public record ButtonContext(String variant, ...) { } + +// Easy to make typos +buttonComponent.render("primry", ...); // No compile error! +``` + +**After (compile-time checked):** +```java +// Typo caught at compile time +buttonComponent.primary(...); // ✓ Type-safe +buttonComponent.primry(...); // ✗ Compile error +``` + +### 3. Better IDE Support + +- Autocomplete for ViewContext types +- Refactoring support (rename ViewContext → updates templates) +- Type checking in templates (JTE) + +### 4. Clearer Intent + +```java +// Clear intent: this is a danger button +var deleteButton = buttonComponent.danger( + "Delete", + "/delete", + "Are you sure?" +); + +// vs unclear intent with generic context +var deleteButton = buttonComponent.render( + "danger", // What does "danger" mean? + "Delete", + "/delete", + "Are you sure?" +); +``` + +## Comparison with Current Approach + +| Aspect | Current Approach | Fragment Rendering | +|--------|-----------------|-------------------| +| **Components** | One ViewContext per ViewComponent | Multiple ViewContexts per ViewComponent | +| **Type Safety** | Separate components or string flags | Type-based fragment selection | +| **Templates** | One template per component OR complex conditionals | One template with multiple fragments | +| **Refactoring** | Rename component class | Rename ViewContext record | +| **Testing** | Test each component separately | Test each ViewContext type | +| **Code Volume** | Higher (more component files) | Lower (one component, multiple contexts) | + +## Migration Example + +### Before (Multiple Components) + +```java +// Three separate components +@ViewComponent +public class PrimaryButtonComponent { + public record Context(String label, String action) implements ViewContext {} + + public Context render(String label, String action) { + return new Context(label, action); + } +} + +@ViewComponent +public class SecondaryButtonComponent { + public record Context(String label, String action) implements ViewContext {} + + public Context render(String label, String action) { + return new Context(label, action); + } +} + +// Controller +@GetMapping("/submit") +ViewContext submitButton() { + return primaryButtonComponent.render("Submit", "/submit"); +} + +@GetMapping("/cancel") +ViewContext cancelButton() { + return secondaryButtonComponent.render("Cancel", "/cancel"); +} +``` + +### After (Fragment Rendering) + +```java +// One component with multiple contexts +@ViewComponent +public class ButtonComponent { + public record PrimaryButton(String label, String action) implements ViewContext {} + public record SecondaryButton(String label, String action) implements ViewContext {} + + public PrimaryButton primary(String label, String action) { + return new PrimaryButton(label, action); + } + + public SecondaryButton secondary(String label, String action) { + return new SecondaryButton(label, action); + } +} + +// Controller +@GetMapping("/submit") +ViewContext submitButton() { + return buttonComponent.primary("Submit", "/submit"); +} + +@GetMapping("/cancel") +ViewContext cancelButton() { + return buttonComponent.secondary("Cancel", "/cancel"); +} +``` + +## Testing + +### Testing Individual Fragments + +```java +@SpringBootTest +class ButtonComponentTest { + + @Autowired + private ButtonComponent buttonComponent; + + @Autowired + private TemplateRenderer templateRenderer; // Hypothetical renderer + + @Test + void shouldRenderPrimaryButton() { + var button = buttonComponent.primary("Submit", "/submit"); + var html = templateRenderer.render(button); + + assertThat(html).contains("btn-primary"); + assertThat(html).contains("Submit"); + assertThat(html).doesNotContain("btn-secondary"); + } + + @Test + void shouldRenderDangerButtonWithConfirmation() { + var button = buttonComponent.danger("Delete", "/delete", "Are you sure?"); + var html = templateRenderer.render(button); + + assertThat(html).contains("btn-danger"); + assertThat(html).contains("Delete"); + assertThat(html).contains("confirm('Are you sure?')"); + } +} +``` + +### Integration Testing + +```java +@SpringBootTest +@AutoConfigureMockMvc +class ButtonControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void shouldRenderPrimaryButtonFragment() throws Exception { + mockMvc.perform(get("/button/submit")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("btn-primary"))) + .andExpect(content().string(not(containsString("btn-secondary")))); + } + + @Test + void shouldRenderDangerButtonFragment() throws Exception { + mockMvc.perform(get("/button/delete")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("btn-danger"))) + .andExpect(content().string(containsString("confirm("))); + } +} +``` + +## Advanced Patterns + +### 1. Nested Fragment Components + +```java +@ViewComponent +public class PageComponent { + public record Page(ViewContext header, ViewContext content, ViewContext footer) + implements ViewContext {} + + public Page render(ViewContext header, ViewContext content, ViewContext footer) { + return new Page(header, content, footer); + } +} + +// Usage +pageComponent.render( + headerComponent.admin("Admin Panel"), + contentComponent.dashboard(), + footerComponent.standard() +) +``` + +### 2. Sealed Interfaces (Java 17+) + +```java +@ViewComponent +public class ButtonComponent { + + // Exhaustiveness checking with sealed types + public sealed interface Button extends ViewContext + permits PrimaryButton, SecondaryButton, DangerButton {} + + public record PrimaryButton(String label, String action) implements Button {} + public record SecondaryButton(String label, String action) implements Button {} + public record DangerButton(String label, String action, String confirm) implements Button {} + + // Compiler ensures all cases are handled + public Button create(String variant, String label, String action) { + return switch (variant) { + case "primary" -> new PrimaryButton(label, action); + case "secondary" -> new SecondaryButton(label, action); + case "danger" -> new DangerButton(label, action, "Are you sure?"); + // No default needed - compiler knows all cases are covered + }; + } +} +``` + +### 3. Fragment with Shared Logic + +```java +@ViewComponent +public class FormFieldComponent { + + // Base interface for shared properties + sealed interface Field extends ViewContext { + String name(); + String label(); + String value(); + } + + public record TextField(String name, String label, String value) + implements Field {} + + public record TextFieldWithError(String name, String label, String value, String error) + implements Field {} + + public record TextArea(String name, String label, String value, int rows) + implements Field {} +} +``` + +## Best Practices + +### 1. Keep Fragments Focused + +Each fragment should represent a distinct presentation variant, not just minor differences: + +**Good:** +```java +public record PrimaryButton(...) implements ViewContext {} +public record DangerButton(...) implements ViewContext {} +``` + +**Bad (use template conditionals instead):** +```java +public record ButtonWithIcon(...) implements ViewContext {} +public record ButtonWithoutIcon(...) implements ViewContext {} +``` + +### 2. Use Descriptive Names + +ViewContext names become part of the template syntax, so use clear, descriptive names: + +**Good:** +```java +public record ErrorAlert(String message, String stackTrace) implements ViewContext {} +``` + +**Bad:** +```java +public record Alert1(String message, String stackTrace) implements ViewContext {} +``` + +### 3. Leverage Type Safety + +Use different fields for different contexts to enforce correct usage: + +**Good:** +```java +public record DangerButton(String label, String action, String confirmMessage) + implements ViewContext {} +``` + +**Bad:** +```java +public record Button(String label, String action, String confirmMessage /* nullable */) + implements ViewContext {} +``` + +### 4. Document Fragment Purpose + +Add Javadoc to explain when each context should be used: + +```java +/** + * Renders a danger button for destructive actions. + * + * @param confirmMessage Message shown in confirmation dialog before action + */ +public DangerButton danger(String label, String action, String confirmMessage) { + return new DangerButton(label, action, confirmMessage); +} +``` + +## See Also + +- [Fragment Rendering Specification](../fragment-rendering-spec.md) - Full technical specification +- [Spring View Component Documentation](https://github.com/tschuehly/spring-view-component) +- [Thymeleaf Fragments](https://www.thymeleaf.org/doc/articles/layouts.html) +- [JTE Documentation](https://jte.gg/) diff --git a/docs/specs/fragment-rendering-spec.md b/docs/specs/fragment-rendering-spec.md new file mode 100644 index 0000000..35751d9 --- /dev/null +++ b/docs/specs/fragment-rendering-spec.md @@ -0,0 +1,1229 @@ +# Fragment Rendering Specification + +**Version:** 2.0-DRAFT +**Date:** 2026-01-01 +**Author:** Spring View Component Team + +## Table of Contents + +1. [Overview](#overview) +2. [Problem Statement](#problem-statement) +3. [Goals](#goals) +4. [Non-Goals](#non-goals) +5. [Proposed Solution](#proposed-solution) +6. [Technical Design](#technical-design) +7. [Examples](#examples) +8. [Implementation Considerations](#implementation-considerations) +9. [Migration Path](#migration-path) +10. [Alternatives Considered](#alternatives-considered) +11. [Open Questions](#open-questions) + +--- + +## Overview + +This specification proposes adding **fragment rendering** capabilities to Spring View Component. The goal is to enable: + +- **Multiple ViewContext implementations** for a single ViewComponent +- **Presence-based conditional rendering** within templates +- **Flexible composition** with `MultiViewContext` +- **Reduced code duplication** for components with variations + +Two complementary patterns are introduced: + +1. **Type-Based Variants** - One ViewContext selected, one fragment renders (e.g., button variants) +2. **Composition with MultiViewContext** - Multiple ViewContexts, multiple fragments render (e.g., page layouts) + +--- + +## Problem Statement + +### Current Limitations + +1. **One ViewContext per ViewComponent**: Each ViewComponent currently supports one ViewContext implementation, leading to: + - Code duplication when creating similar components with slight variations + - Proliferation of ViewComponent classes for related functionality + - Difficulty managing component families (e.g., buttons: primary, secondary, danger) + +2. **Optional Sections Require Optional<>**: Conditional sections need verbose Optional handling: + ```java + record Page(ViewContext header, Optional footer) implements ViewContext {} + ``` + + Template: + ```html + + ``` + +3. **No Template-Level Conditional Rendering**: Conditional logic must be: + - Implemented in template engine syntax (`th:if`, JTE conditionals) + - Difficult to type-check at compile time + +### Real-World Use Cases + +#### Use Case 1: Button Component with Variants + +**Current approach:** +```java +@ViewComponent +public class PrimaryButtonComponent { ... } + +@ViewComponent +public class SecondaryButtonComponent { ... } + +@ViewComponent +public class DangerButtonComponent { ... } +``` + +**Desired:** One `ButtonComponent` with multiple ViewContext types for variants. + +#### Use Case 2: Page Layout with Optional Sections + +**Current approach:** +```java +record Page( + ViewContext header, + ViewContext content, + Optional footer // Verbose! +) implements ViewContext {} +``` + +**Desired:** Clean composition without `Optional<>`. + +--- + +## Goals + +1. **Enable Multiple ViewContext Implementations** - Allow a single ViewComponent to return different ViewContext types +2. **Presence-Based Fragment Selection** - Render fragments based on which ViewContexts are present in the model +3. **Clean Composition API** - `MultiViewContext` for combining multiple sections +4. **Type Safety** - Leverage Java/Kotlin type system +5. **Template Engine Agnostic** - Support JTE, KTE, and Thymeleaf +6. **Backward Compatibility** - Existing ViewComponents continue to work unchanged +7. **Runtime Validation** - Clear error messages when constraints are violated + +--- + +## Non-Goals + +1. **Cross-Component Fragments** - ViewContexts from different components in one MultiViewContext (use nested components) +2. **Template Scanning/Startup Validation** - Runtime validation is sufficient +3. **Compile-Time Validation** - Annotation processor complexity not justified +4. **Fragment Inheritance** - Can be added in future if needed +5. **Fragment Parameters** - Use ViewContext properties instead + +--- + +## Proposed Solution + +### Pattern 1: Type-Based Variants + +**One ViewContext in model → One fragment renders** + +```java +@ViewComponent +public class ButtonComponent { + + public record PrimaryButton(String label, String action) implements ViewContext {} + public record SecondaryButton(String label, String action) implements ViewContext {} + public record DangerButton(String label, String action, String confirmMsg) implements ViewContext {} + + public PrimaryButton primary(String label, String action) { + return new PrimaryButton(label, action); + } + + public SecondaryButton secondary(String label, String action) { + return new SecondaryButton(label, action); + } + + public DangerButton danger(String label, String action, String confirmMsg) { + return new DangerButton(label, action, confirmMsg); + } +} +``` + +**Thymeleaf Template:** +```html + + + + + + + +``` + +**Rendering:** +- If controller returns `PrimaryButton` → only first fragment renders +- If controller returns `DangerButton` → only third fragment renders + +### Pattern 2: Composition with MultiViewContext + +**Multiple ViewContexts in model → Multiple fragments render** + +```java +@ViewComponent +public class PageComponent { + + public record Header(String title) implements ViewContext {} + public record Content(String body) implements ViewContext {} + public record Footer(String text) implements ViewContext {} + + public ViewContext withFooter(String title, String body, String footer) { + return MultiViewContext.of( + new Header(title), + new Content(body), + new Footer(footer) + ); + } + + public ViewContext withoutFooter(String title, String body) { + return MultiViewContext.of( + new Header(title), + new Content(body) + // No Footer - won't render! + ); + } +} +``` + +**Template:** +```html + + +
+

Title

+
+ +
+

Content

+
+ +
+ Footer +
+``` + +**Rendering:** +- `withFooter()` → all three fragments render +- `withoutFooter()` → only header and content render (footer removed) + +### Pattern 3: Shared Properties with Sealed Interfaces + +**Best practice for fragments with common properties:** + +```java +@ViewComponent +public class AlertComponent { + + // Base interface for shared properties + sealed interface Alert extends ViewContext { + String message(); + } + + public record InfoAlert(String message) implements Alert {} + public record WarningAlert(String message, String details) implements Alert {} + public record ErrorAlert(String message, String stackTrace) implements Alert {} + + public InfoAlert info(String message) { + return new InfoAlert(message); + } + + public WarningAlert warning(String message, String details) { + return new WarningAlert(message, details); + } + + public ErrorAlert error(String message, String stackTrace) { + return new ErrorAlert(message, stackTrace); + } +} +``` + +**Template:** +```html + + + + +
+ Info +
+ +
+ Warning +
Details
+
+ +
+ Error +
Stack
+
+``` + +**Benefits:** +- Shared properties accessible via `${alert.message}` (works in all fragments) +- Specific properties via `${errorAlert.stackTrace}` (type-safe) +- Sealed interface enables exhaustiveness checking (Java 17+) + +--- + +## Technical Design + +### 1. Core: MultiViewContext + +Framework-provided class for composition: + +```java +package de.tschuehly.spring.viewcomponent.core; + +public final class MultiViewContext implements IViewContext { + private final List contexts; + + private MultiViewContext(IViewContext... contexts) { + this.contexts = List.of(contexts); + } + + /** + * Creates a MultiViewContext from multiple ViewContext instances. + * + * @param contexts ViewContext instances (nulls are filtered out) + * @return MultiViewContext containing all non-null contexts + * @throws ViewComponentException if contexts are from different components + */ + public static MultiViewContext of(IViewContext... contexts) { + // Filter out nulls for easier conditional composition + IViewContext[] filtered = Arrays.stream(contexts) + .filter(Objects::nonNull) + .toArray(IViewContext[]::new); + + validateSameComponent(filtered); + return new MultiViewContext(filtered); + } + + public List getContexts() { + return contexts; + } + + private static void validateSameComponent(IViewContext... contexts) { + if (contexts.length == 0) { + throw new ViewComponentException( + "MultiViewContext requires at least one non-null ViewContext" + ); + } + + Class expectedComponent = contexts[0].getClass().getEnclosingClass(); + + if (expectedComponent == null) { + throw new ViewComponentException( + "ViewContext " + contexts[0].getClass().getSimpleName() + + " must be an inner class of a @ViewComponent. " + + "Did you forget to define it as a nested record/class?" + ); + } + + // Validate all contexts are from the same component + for (IViewContext ctx : contexts) { + Class actualComponent = ctx.getClass().getEnclosingClass(); + + if (actualComponent != expectedComponent) { + throw new ViewComponentException( + "All ViewContexts in MultiViewContext must be from the same ViewComponent. " + + "Expected: " + expectedComponent.getSimpleName() + ", " + + "found: " + actualComponent.getSimpleName() + " " + + "for ViewContext: " + ctx.getClass().getSimpleName() + ". " + + "To compose ViewContexts from different components, use nested components instead." + ); + } + } + } +} +``` + +**Why same component requirement:** +- Simple template resolution (one template) +- Clear ownership and organization +- Cross-component composition uses existing `view:component` directive + +### 2. Template Resolution + +**Updated ViewContextMethodReturnValueHandler:** + +```kotlin +@Component +class ViewContextMethodReturnValueHandler : HandlerMethodReturnValueHandler { + + override fun supportsReturnType(returnType: MethodParameter): Boolean { + return IViewContext::class.java.isAssignableFrom(returnType.parameterType) + } + + override fun handleReturnValue( + returnValue: Any?, + returnType: MethodParameter, + mavContainer: ModelAndViewContainer, + webRequest: NativeWebRequest + ) { + val viewContext = returnValue as IViewContext + + if (viewContext is MultiViewContext) { + // Use first context to resolve template (all from same component) + val firstContext = viewContext.getContexts().first() + mavContainer.view = IViewContext.getViewComponentTemplateWithoutSuffix(firstContext) + + // Add each context to model with lowercase simple name + viewContext.getContexts().forEach { ctx -> + val variableName = ctx.javaClass.simpleName + .replaceFirstChar { it.lowercase() } + mavContainer.addAttribute(variableName, ctx) + } + } else { + // Single context - existing behavior + mavContainer.view = IViewContext.getViewComponentTemplateWithoutSuffix(viewContext) + val variableName = viewContext.javaClass.simpleName + .replaceFirstChar { it.lowercase() } + mavContainer.addAttribute(variableName, viewContext) + } + } +} +``` + +**Model attributes:** +- `PrimaryButton` → added to model as `primaryButton` +- `Header`, `Content`, `Footer` → added as `header`, `content`, `footer` + +### 3. Thymeleaf Integration + +**ThymeleafViewContextFragmentProcessor:** + +```kotlin +class ThymeleafViewContextFragmentProcessor( + dialectPrefix: String, + private val applicationContext: ApplicationContext +) : AbstractAttributeTagProcessor( + TemplateMode.HTML, + dialectPrefix, + null, // Any element + false, + "context", // Attribute name: view:context + true, + PRECEDENCE, + true // Remove attribute +) { + + override fun doProcess( + context: ITemplateContext, + tag: IProcessableElementTag, + attributeName: AttributeName, + attributeValue: String, // e.g., "Header" + structureHandler: IElementTagStructureHandler + ) { + val webContext = context as WebEngineContext + + // Check if a ViewContext of this type exists in the model + val variableName = attributeValue.replaceFirstChar { it.lowercase() } + val hasContext = webContext.getVariable(variableName) != null + + if (!hasContext) { + // No matching context in model, remove this fragment + structureHandler.removeElement() + } + // Otherwise render normally (attribute is removed automatically) + } +} +``` + +**Register in dialect:** + +```kotlin +class ThymeleafViewComponentDialect( + private val applicationContext: ApplicationContext +) : AbstractProcessorDialect(NAME, PREFIX, PRECEDENCE) { + + override fun getProcessors(dialectPrefix: String): Set { + return setOf( + ThymeleafViewComponentTagProcessor(dialectPrefix, applicationContext), + ThymeleafViewContextFragmentProcessor(dialectPrefix, applicationContext) // NEW + ) + } + + companion object { + const val NAME = "ViewComponent Dialect" + const val PREFIX = "view" + const val PRECEDENCE = 1000 + } +} +``` + +### 4. JTE/KTE Integration + +JTE already supports type-based conditionals via `instanceof`: + +```java +@import de.example.ButtonComponent.* + +@if(model instanceof PrimaryButton primaryButton) + +@elseif(model instanceof SecondaryButton secondaryButton) + +@elseif(model instanceof DangerButton dangerButton) + +@endif +``` + +**For MultiViewContext with JTE:** + +```java +@import de.example.PageComponent.* + +<%-- Access each context if present --%> +@if(header != null) +
+

${header.title()}

+
+@endif + +@if(content != null) +
+

${content.body()}

+
+@endif + +@if(footer != null) +
+ ${footer.text()} +
+@endif +``` + +**No changes needed to JTE/KTE integrations** - existing features support both patterns. + +### 5. Fragment Rendering Rules + +**Rule 1: No `view:context` attribute → Always render** + +```html +
+

Always visible

+
+``` + +**Rule 2: Has `view:context` attribute → Render only if matching ViewContext in model** + +```html +
+ Only renders if errorAlert is in model +
+``` + +**Rule 3: Multiple fragments can render** + +```html +
Header
+
Content
+
Footer
+``` + +With `MultiViewContext.of(new Header(...), new Content(...))`: +- Header fragment renders +- Content fragment renders +- Footer fragment removed (not in model) + +--- + +## Examples + +### Example 1: Button Variants + +```java +@ViewComponent +public class ButtonComponent { + + public record PrimaryButton(String label, String action) implements ViewContext {} + public record SecondaryButton(String label, String action) implements ViewContext {} + public record DangerButton(String label, String action, String confirmMsg) implements ViewContext {} + + public PrimaryButton primary(String label, String action) { + return new PrimaryButton(label, action); + } + + public SecondaryButton secondary(String label, String action) { + return new SecondaryButton(label, action); + } + + public DangerButton danger(String label, String action, String confirmMsg) { + return new DangerButton(label, action, confirmMsg); + } +} +``` + +**Template (Thymeleaf):** +```html + + + + + + + +``` + +**Controller:** +```java +@Controller +public class ButtonController { + + @Autowired + private ButtonComponent buttonComponent; + + @GetMapping("/button/submit") + ViewContext submitButton() { + return buttonComponent.primary("Submit", "/submit"); + } + + @GetMapping("/button/delete") + ViewContext deleteButton() { + return buttonComponent.danger("Delete", "/delete", "Are you sure?"); + } +} +``` + +### Example 2: Page Layout with Optional Footer + +```java +@ViewComponent +public class PageComponent { + + public record Header(String title) implements ViewContext {} + public record Content(String body) implements ViewContext {} + public record Footer(String text) implements ViewContext {} + + public ViewContext render(String title, String body, boolean includeFooter) { + return MultiViewContext.of( + new Header(title), + new Content(body), + includeFooter ? new Footer("© 2026") : null + ); + } +} +``` + +**Template:** +```html + + +
+

Title

+
+ +
+

Content

+
+ +
+ Footer +
+``` + +### Example 3: Alert with Sealed Interface + +```java +@ViewComponent +public class AlertComponent { + + sealed interface Alert extends ViewContext { + String message(); + } + + public record InfoAlert(String message) implements Alert {} + public record WarningAlert(String message, String details) implements Alert {} + public record ErrorAlert(String message, String stackTrace) implements Alert {} + + public InfoAlert info(String message) { + return new InfoAlert(message); + } + + public WarningAlert warning(String message, String details) { + return new WarningAlert(message, details); + } + + public ErrorAlert error(String message, String stackTrace) { + return new ErrorAlert(message, stackTrace); + } +} +``` + +**Template:** +```html + + + + +
+ + Info +
+ +
+ + Warning +
+ Details +
Details
+
+
+ +
+ + Error +
+ Stack Trace +
Stack trace
+
+
+``` + +### Example 4: Dashboard with Conditional Sections + +```java +@ViewComponent +public class DashboardComponent { + + public record Stats(int users, int orders) implements ViewContext {} + public record Chart(List data) implements ViewContext {} + public record Notifications(List messages) implements ViewContext {} + + public ViewContext render(User user) { + var contexts = new ArrayList(); + + // Always show stats + contexts.add(new Stats( + userService.count(), + orderService.count() + )); + + // Premium users get charts + if (user.isPremium()) { + contexts.add(new Chart(analyticsService.getData())); + } + + // Show notifications if any + var messages = notificationService.getUnread(user); + if (!messages.isEmpty()) { + contexts.add(new Notifications(messages)); + } + + return MultiViewContext.of(contexts.toArray(ViewContext[]::new)); + } +} +``` + +**Template:** +```html + + + +
+
+ + 0 +
+
+ + 0 +
+
+ + +
+ +
+ + +
+

Notifications

+
    +
  • Message
  • +
+
+``` + +### Example 5: Layout Variants + +```java +@ViewComponent +public class LayoutComponent { + + public record AdminNav(String username) implements ViewContext {} + public record UserNav(String username) implements ViewContext {} + public record GuestNav() implements ViewContext {} + public record Content(ViewContext body) implements ViewContext {} + + public ViewContext adminLayout(String username, ViewContext body) { + return MultiViewContext.of( + new AdminNav(username), + new Content(body) + ); + } + + public ViewContext userLayout(String username, ViewContext body) { + return MultiViewContext.of( + new UserNav(username), + new Content(body) + ); + } + + public ViewContext guestLayout(ViewContext body) { + return MultiViewContext.of( + new GuestNav(), + new Content(body) + ); + } +} +``` + +**Template:** +```html + + + +Application + + + + + + + + + + +
+
+
+ + + +``` + +--- + +## Implementation Considerations + +### 1. thymeVar Comments for IDE Autocomplete + +**Pattern for Thymeleaf templates:** + +```html + + + +``` + +**With sealed interfaces:** +```html + + + + + +``` + +**Best practice:** +- Add thymeVar comment for each ViewContext type +- Use sealed interface variable for shared properties +- Use specific type variable for unique properties + +### 2. Validation Strategy + +**Runtime validation only** - no template scanning or compile-time validation. + +**Why:** +- Runtime validation catches the critical issue (mixed components) +- Template errors are caught during development/testing +- Template engines validate property access +- IDE plugins are better place for template validation + +**Validation in MultiViewContext.of():** +- ✅ Validates all ViewContexts from same component +- ✅ Clear error messages +- ✅ Filters out nulls automatically +- ✅ Zero overhead (runs once per request) + +### 3. Error Handling + +**Clear error messages:** + +```java +// Wrong: mixing components +MultiViewContext.of( + new PageComponent.Header("Title"), + new FooterComponent.Footer("Footer") // Different component! +) + +// Error message: +"All ViewContexts in MultiViewContext must be from the same ViewComponent. +Expected: PageComponent, found: FooterComponent for ViewContext: Footer. +To compose ViewContexts from different components, use nested components instead." +``` + +```java +// Wrong: not an inner class +public record Header(String title) implements ViewContext {} // Top-level! + +MultiViewContext.of(new Header("Title")) + +// Error message: +"ViewContext Header must be an inner class of a @ViewComponent. +Did you forget to define it as a nested record/class?" +``` + +### 4. Performance + +**Fragment Selection:** +- Thymeleaf: One model lookup per fragment (`webContext.getVariable(variableName)`) +- JTE/KTE: Compiled to native if-else statements (zero overhead) + +**MultiViewContext:** +- Validation runs once per MultiViewContext.of() call +- Model population: one addAttribute per context +- Negligible overhead + +### 5. Template Organization + +**Best practices:** + +1. **Group related fragments together:** + ```html + + + + + + +
...
+ ``` + +2. **Use comments to document fragments:** + ```html + +
...
+ + + + ``` + +3. **Keep always-visible content without view:context:** + ```html +
+ +

© 2026 Company

+
+ ``` + +### 6. Testing + +**Unit testing fragments:** + +```java +@SpringBootTest +class ButtonComponentTest { + + @Autowired + private ButtonComponent buttonComponent; + + @Test + void primaryButtonShouldHavePrimaryClass() { + var button = buttonComponent.primary("Submit", "/submit"); + + assertThat(button).isInstanceOf(ButtonComponent.PrimaryButton.class); + assertThat(button.label()).isEqualTo("Submit"); + assertThat(button.action()).isEqualTo("/submit"); + } + + @Test + void dangerButtonShouldIncludeConfirmMessage() { + var button = buttonComponent.danger("Delete", "/delete", "Sure?"); + + assertThat(button.confirmMsg()).isEqualTo("Sure?"); + } +} +``` + +**Integration testing with MockMvc:** + +```java +@SpringBootTest +@AutoConfigureMockMvc +class PageControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void pageWithFooterShouldRenderFooter() throws Exception { + mockMvc.perform(get("/page?footer=true")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString(" +
...
+
...
+ +``` + +**Decision:** Rejected - unnecessary boilerplate. Template is already scoped to the component. + +### Alternative 2: String-Based Fragment Selection + +**Approach:** +```java +public record ButtonContext(String variant, ...) implements ViewContext {} +``` + +```html +
...
+``` + +**Decision:** Rejected - no type safety, error-prone. + +### Alternative 3: Allow Cross-Component ViewContexts in MultiViewContext + +**Approach:** +```java +MultiViewContext.of( + headerComponent.render(), // HeaderComponent + contentComponent.render() // ContentComponent +) +``` + +**Decision:** Rejected for v0.10 - complex template resolution, unclear ownership. Use nested components for cross-component composition. + +### Alternative 4: Compile-Time Validation with Annotation Processor + +**Approach:** Annotation processor validates MultiViewContext.of() calls at compile time. + +**Decision:** Deferred - complexity not justified. Runtime validation is sufficient. + +### Alternative 5: Startup Template Scanning + +**Approach:** Scan templates at startup, validate view:context references exist. + +**Decision:** Rejected - runtime validation sufficient. Template errors caught during development. + +--- + +## Open Questions + +### 1. Should we support wildcard matching? + +**Question:** Should `view:context="*"` match any ViewContext (always render)? + +**Options:** +- A. No wildcard - use absence of `view:context` for always-render +- B. Support `view:context="*"` for clarity + +**Recommendation:** Option A - simpler, no attribute = always render is intuitive. + +### 2. Should we support negation? + +**Question:** Should `view:context="!Footer"` render when Footer is NOT in model? + +**Example:** +```html +

No footer available

+``` + +**Recommendation:** No - use template engine conditionals (`th:if`) for complex logic. + +### 3. Should sealed interfaces be required or optional? + +**Question:** Recommend sealed interfaces as best practice or make them optional? + +**Recommendation:** Optional but recommended. Document as best practice for shared properties. + +### 4. Should we provide a fluent builder for MultiViewContext? + +**Question:** Would a builder API improve ergonomics? + +```java +return MultiViewContext.builder() + .with(new Header("Title")) + .with(new Content("Body")) + .withIf(showFooter, new Footer("Footer")) + .build(); +``` + +**Recommendation:** Defer to v0.11 - `of()` with nulls is sufficient for now. + +### 5. How to handle empty MultiViewContext? + +**Question:** What if all ViewContexts are null? + +```java +MultiViewContext.of( + condition1 ? new Header("Title") : null, + condition2 ? new Footer("Footer") : null +) +// Both null! +``` + +**Current behavior:** Throws exception "requires at least one non-null ViewContext" + +**Alternative:** Render empty content? + +**Recommendation:** Keep exception - forces explicit handling. + +--- + +## Summary + +This specification proposes adding **fragment rendering** to Spring View Component via two complementary patterns: + +1. **Type-Based Variants** - One ViewContext, one fragment (button variants, alert types) +2. **MultiViewContext Composition** - Multiple ViewContexts, multiple fragments (page layouts, optional sections) + +**Key Features:** +- ✅ Framework-provided `MultiViewContext` class (zero boilerplate) +- ✅ Presence-based rendering (`view:context` attribute) +- ✅ Same-component requirement (simple template resolution) +- ✅ Runtime validation with clear error messages +- ✅ Sealed interfaces for shared properties +- ✅ Null filtering for conditional composition +- ✅ Works with all template engines (Thymeleaf, JTE, KTE) +- ✅ Backward compatible + +**Benefits:** +- Reduces code duplication (one component instead of many) +- Type-safe (compile-time checks for ViewContext types) +- Clean composition (no `Optional`) +- Better developer experience (clear intent, less boilerplate) + +**Next Steps:** +1. Review and gather feedback +2. Implement Phase 1 (Core Infrastructure) +3. Release as experimental feature in v0.10.0 +4. Iterate based on community feedback + +--- + +## References + +- **Thymeleaf Fragments**: https://www.thymeleaf.org/doc/articles/layouts.html +- **Spring View Component**: https://github.com/tschuehly/spring-view-component +- **JTE Documentation**: https://jte.gg/ +- **Sealed Classes (Java)**: https://openjdk.org/jeps/409 + +--- + +**End of Specification**