Reusable Form Components
LightNap demonstrates a powerful pattern for creating reusable form components that combine presentation with data management. This article explores the architectural pattern used throughout the application, showing how to build composable, type-safe components that reduce boilerplate and maintain consistency.
- Reusable Form Components
- Understanding the Component Pattern
- The ListItem Model
- Presentation Layer: SelectListItemComponent
- Control Layer: UserSettingSelectComponent
- Feature Layer: Domain-Specific Components
- Similar Patterns in LightNap
- Creating Your Own Reusable Components
- Benefits of This Pattern
- Additional Resources
- See Also
Understanding the Component Pattern
LightNap uses a layered approach to form components:
- Presentation Layer (
SelectListItemComponent): Renders individual items in consistent ways - Control Layer (
UserSettingSelectComponent): Manages data binding and persistence - Feature Layer (
PreferredLanguageSelectComponent): Combines control with domain logic
This separation creates highly reusable components that can be composed together to build complex forms with minimal code duplication.
The ListItem Model
At the foundation is the ListItem<T> class, which provides a consistent interface for working with PrimeNG collection controls:
/**
* Represents an item in a list to make it easier to work with PrimeNG collection controls.
*
* @template T - The type of the value.
*/
export class ListItem<T> {
constructor(
public value: T,
public label: string,
public description?: string
) {}
}
This simple model provides:
- Type Safety: Generic
Tensures value type consistency - Display Text:
labelfor the primary text shown to users - Optional Details:
descriptionfor additional context - PrimeNG Integration: Works seamlessly with PrimeNG’s
optionLabelandoptionValuebindings
Presentation Layer: SelectListItemComponent
The SelectListItemComponent is a presentation component that renders ListItem data in a consistent format across all dropdowns and list controls.
Component Implementation
@Component({
selector: 'ln-select-list-item',
templateUrl: './select-list-item.component.html',
imports: [],
standalone: true,
})
export class SelectListItemComponent {
@Input() label = signal("");
@Input() description = signal<string | undefined>("");
@Input() set listItem(value: ListItem<any>) {
this.label.set(value.label);
this.description.set(value.description);
}
}
Template
<div class="inline-block mx-1 w-full">
<span class="m-0">{{ label() }}</span>
@if (description(); as description) {
<p class="m-0 whitespace-nowrap text-ellipsis overflow-hidden">
{{ description }}
</p>
}
</div>
Key Features
- Signal-Based Reactivity: Uses Angular signals for efficient change detection
- Optional Description: Shows additional context when available
- Consistent Styling: Provides uniform appearance across the application
- Flexible Input: Accepts either individual properties or a
ListItemobject
Usage in PrimeNG Controls
<p-select
[options]="items()"
optionLabel="label"
optionValue="value"
>
<ng-template let-option #item>
<ln-select-list-item [listItem]="option" />
</ng-template>
</p-select>
Control Layer: UserSettingSelectComponent
The UserSettingSelectComponent is a smart component that combines a select dropdown with automatic persistence to user settings.
TypeScript Implementation
@Component({
selector: "ln-user-setting-select",
standalone: true,
templateUrl: "./user-setting-select.component.html",
imports: [CommonModule, FormsModule, Select, SelectListItemComponent, ApiResponseComponent],
})
export class UserSettingSelectComponent<T> implements OnChanges {
readonly #profileService = inject(ProfileService);
readonly #toast = inject(ToastService);
readonly key = input.required<UserSettingKey>();
readonly label = input.required<string>();
readonly options = input.required<Array<ListItem<T>>>();
readonly setting = signal(new Observable<T>());
readonly defaultValue = input<T>();
ngOnChanges() {
this.setting.set(this.#profileService.getSetting<T>(this.key(), this.defaultValue()));
}
onChange(value: T) {
this.#profileService.setSetting(this.key(), value).subscribe({
next: () => this.#toast.success("Setting updated."),
error: () => this.#toast.error("Failed to update setting."),
});
}
}
HTML Template
<ln-api-response [apiResponse]="setting()">
<ng-template #success let-value>
<p-select
[ngModel]="value"
(ngModelChange)="onChange($event)"
[options]="options()"
optionLabel="label"
optionValue="value"
class="w-full"
>
<ng-template let-option #item>
<ln-select-list-item [listItem]="option" />
</ng-template>
</p-select>
</ng-template>
</ln-api-response>
Component Features
- Generic Type Support: Type parameter
Tensures type safety for different value types - Automatic Loading: Fetches current setting value on initialization
- Automatic Persistence: Saves changes immediately when user selects an option
- Error Handling: Shows toast notifications for success/failure
- Loading States: Uses
ApiResponseComponentto handle loading and error states - Default Values: Supports default values when no setting exists
Usage Example
<ln-user-setting-select
key="PreferredLanguage"
label="Preferred Language"
[options]="languageOptions()"
[defaultValue]="''" />
Feature Layer: Domain-Specific Components
Feature-layer components combine the control layer with domain-specific logic and data sources.
PreferredLanguageSelectComponent Example
This component demonstrates how to create a specialized setting component:
@Component({
selector: "ln-preferred-language-select",
standalone: true,
templateUrl: "./preferred-language-select.component.html",
imports: [CommonModule, UserSettingSelectComponent, ApiResponseComponent],
})
export class PreferredLanguageSelectComponent {
readonly #contentService = inject(ContentService);
readonly supportedLanguages = signal(
this.#contentService
.getSupportedLanguages()
.pipe(map(languages => [
new ListItem("", "Auto-detect"),
...languages.map(lang => new ListItem(lang.languageCode, lang.languageName))
]))
);
}
Component Template
<ln-api-response [apiResponse]="supportedLanguages()">
<ng-template #success let-supportedLanguages>
<ln-user-setting-select
key="PreferredLanguage"
label="Preferred Language"
[options]="supportedLanguages"
[defaultValue]="null" />
</ng-template>
</ln-api-response>
What This Demonstrates
- Service Integration: Fetches available languages from
ContentService - Data Transformation: Converts API response to
ListItemarray - Special Values: Includes “Auto-detect” option with empty string value
- Single Responsibility: Component only handles data preparation, delegates persistence to
UserSettingSelectComponent - Composability: Can be dropped into any form without additional configuration
Usage
<!-- In a profile form -->
<div class="setting-group">
<h3>Language Preferences</h3>
<ln-preferred-language-select />
</div>
Similar Patterns in LightNap
LightNap uses this pattern consistently throughout the codebase. Here are other examples you can reference:
RolePickerComponent
Single role selection for user management:
@Component({
selector: "ln-role-picker",
template: `
<p-select
[ngModel]="value()"
(ngModelChange)="valueChange.emit($event)"
[options]="roles()"
optionLabel="label"
optionValue="value"
placeholder="Select a role...">
<ng-template let-role #item>
<ln-select-list-item [listItem]="role" />
</ng-template>
</p-select>
`,
imports: [CommonModule, SelectModule, FormsModule, SelectListItemComponent],
})
export class RolePickerComponent {
readonly value = input<string>();
readonly valueChange = output<string>();
readonly roles = input<Array<ListItem<string>>>([]);
}
ContentFormatPickerComponent
Selecting content format (Markdown, HTML, etc.):
@Component({
selector: "ln-content-format-picker",
template: `
<p-select
[ngModel]="contentFormat()"
(ngModelChange)="contentFormatChange.emit($event)"
[options]="contentFormatOptions"
optionLabel="label"
optionValue="value">
<ng-template let-option #item>
<ln-select-list-item [listItem]="option" />
</ng-template>
</p-select>
`,
imports: [SelectModule, FormsModule, SelectListItemComponent],
})
export class ContentFormatPickerComponent {
readonly contentFormat = input<ContentFormat>(ContentFormat.Markdown);
readonly contentFormatChange = output<ContentFormat>();
readonly contentFormatOptions = [
new ListItem(ContentFormat.Markdown, "Markdown"),
new ListItem(ContentFormat.Html, "HTML"),
new ListItem(ContentFormat.PlainText, "Plain Text"),
];
}
Creating Your Own Reusable Components
Follow this pattern when building your own form components:
1. Identify Reusable Patterns
Look for forms controls that:
- Appear in multiple places
- Have complex logic (validation, formatting, etc.)
- Integrate with services
- Need consistent styling
2. Choose the Right Layer
- Presentation Layer: Pure display components with no business logic
- Control Layer: Generic controls that manage state and persistence
- Feature Layer: Domain-specific components that combine data and controls
3. Use Generic Types When Appropriate
export class MySelectComponent<T> {
readonly options = input.required<Array<ListItem<T>>>();
readonly value = input<T>();
readonly valueChange = output<T>();
}
4. Leverage Dependency Injection
Inject services at the appropriate layer:
readonly #myService = inject(MyService);
readonly #toast = inject(ToastService);
5. Handle Loading and Error States
Use ApiResponseComponent for observable data:
<ln-api-response [apiResponse]="data()">
<ng-template #success let-data>
<!-- Your control here -->
</ng-template>
</ln-api-response>
6. Keep Components Focused
Each component should have a single, clear purpose:
- ✅
LanguageSelectComponent: Selects a language - ❌
LanguageAndTimeZoneComponent: Too broad, split into two components
7. Make Components Self-Contained
Include all necessary imports in the component’s imports array:
@Component({
selector: "ln-my-component",
standalone: true,
imports: [
CommonModule,
FormsModule,
Select,
SelectListItemComponent,
ApiResponseComponent
],
})
Benefits of This Pattern
Consistency
All form controls across the application look and behave the same way, creating a cohesive user experience.
Reduced Boilerplate
Complex functionality is encapsulated in reusable components:
<!-- Without pattern: ~50 lines of template + component code -->
<!-- With pattern: -->
<ln-preferred-language-select />
Type Safety
Generic types ensure compile-time type checking:
// This would cause a TypeScript error:
const items: Array<ListItem<string>> = [...];
const component: UserSettingSelectComponent<number> = ...;
component.options = items; // Error: Type mismatch
Testability
Each layer can be tested independently:
- Test
SelectListItemComponentfor proper rendering - Test
UserSettingSelectComponentfor persistence logic - Test
PreferredLanguageSelectComponentfor data transformation
Maintainability
Changes to common functionality propagate automatically:
- Update
SelectListItemComponentstyling → affects all dropdowns - Improve
UserSettingSelectComponenterror handling → affects all settings - Add new language → automatically appears in all language selectors
Additional Resources
Related Documentation
- Adding User Settings - Implementing user preferences
- Adding Entities - Creating database entities
- Solution & Project Structure - Understanding the architecture
PrimeNG Documentation
Angular Patterns
See Also
- Adding User Settings - User preferences and settings
- Adding Profile Fields - User-specific data
- Solution & Project Structure - Architecture overview