Claude-skill-registry kramme:connect-modernize-legacy-angular-component
Use this Skill when working in the Connect monorepo and needing to modernize legacy Angular components.
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/kramme-connect-modernize-legacy-angular-component" ~/.claude/skills/majiayu000-claude-skill-registry-kramme-connect-modernize-legacy-angular-compone && rm -rf "$T"
skills/data/kramme-connect-modernize-legacy-angular-component/SKILL.mdConnect - Modernize Legacy Angular Component
Instructions
When to use this skill:
- You're working in the Connect monorepo
- You need to refactor legacy Angular components to modern patterns
- Component extends legacy
orFormComponentBaseComponent - Component uses
decorators for state management@Select - Component uses
instead of typedFormNodeFormGroup - Component doesn't use
ChangeDetectionStrategy.OnPush - Component has manual subscription management
- Component dispatches actions directly instead of using ComponentStore
Context: Connect's frontend is modernizing Angular components to use NgRx ComponentStore for state management, OnPush change detection, standalone components, and proper TypeScript typing. This provides better type safety, performance, and maintainability.
Guideline Keywords
- ALWAYS — Mandatory requirement, exceptions are very rare and must be explicitly approved
- NEVER — Strong prohibition, exceptions are very rare and must be explicitly approved
- PREFER — Strong recommendation, exceptions allowed with justification
- CAN — Optional, developer's discretion
- NOTE — Context, rationale, or clarification
- EXAMPLE — Illustrative example
Strictness hierarchy: ALWAYS/NEVER > PREFER > CAN > NOTE/EXAMPLE
Reference Implementation
- ALWAYS refer to the Q&A components refactoring as the reference implementation:
- Edit topic component with form managementlibs/connect/cms/qa/feature/src/lib/edit-topic/
- Settings page with conditional form logiclibs/connect/cms/qa/feature/src/lib/settings-page/
- Topics page with complex statelibs/connect/cms/qa/feature/src/lib/topics-page/
Migration Process
Phase 1: Assessment
-
ALWAYS read all component files before starting:
- Component TypeScript file
- Component template
- Component styles (if any)
- Related store/state files
-
ALWAYS identify patterns to migrate:
- Legacy base class usage (
,extends FormComponent
)extends BaseComponent
decorators for state@Select
usageFormNode- Manual subscriptions (
,subscribe()
)takeUntil() - Direct action dispatching
- Lifecycle hooks (
vsonInit()
)ngOnInit()
- Legacy base class usage (
-
ALWAYS identify business logic:
- Form management
- State updates
- API calls
- Conditional field logic
- User interactions
Phase 2: Create ComponentStore
- ALWAYS create the store file in the same directory as the component (e.g.,
)component-name.store.ts - ALWAYS define form controls interface separately from state
- ALWAYS define forms as class properties, NOT in state
- ALWAYS extract
as a constantinitialState - ALWAYS use
for immutabilityreadonly - ALWAYS use ECMAScript
for encapsulation#privateFields - ALWAYS use proper type narrowing in effects with filter:
(tuple): tuple is [void, DataType] => tuple[1] !== null - ALWAYS use
directly in effects:pipe()
notthis.effect<Type>(pipe(...))this.effect<Type>((param$) => param$.pipe(...))
EXAMPLE:
import { inject, Injectable } from "@angular/core"; import { ComponentStore } from "@ngrx/component-store"; import { Store } from "@ngrx/store"; import { FormControl, FormGroup, Validators } from "@angular/forms"; import { filter, pipe, switchMap, tap, withLatestFrom } from "rxjs"; // Define form controls interface export interface ComponentNameFormControls { field1: FormControl<string>; field2: FormControl<boolean>; } // Define state interface interface ComponentNameState { readonly currentData: DataType | null; } const initialState: ComponentNameState = { currentData: null, }; @Injectable() export class ComponentNameStore extends ComponentStore<ComponentNameState> { readonly #store = inject(Store); // Selectors readonly currentData$ = this.select((state) => state.currentData); readonly externalData$ = this.#store.select(getExternalData.selector); // Form definition readonly form = new FormGroup<ComponentNameFormControls>({ field1: new FormControl<string>("", { validators: [Validators.required], nonNullable: true, }), field2: new FormControl<boolean>(false, { nonNullable: true }), }); // Updaters readonly setCurrentData = this.updater<DataType>( (state, data): ComponentNameState => ({ ...state, currentData: data, }) ); // Effects - use pipe() directly readonly initializeForm = this.effect<DataType>( pipe( tap((data: DataType) => { this.setCurrentData(data); this.form.reset(data); this.#applyConditionalLogic(this.form); }) ) ); readonly saveChanges = this.effect<void>( pipe( tap(() => { this.#store.dispatch(updateAction.start(this.form.getRawValue())); }) ) ); readonly cancelChanges = this.effect<void>( pipe( withLatestFrom(this.currentData$), filter((tuple): tuple is [void, DataType] => tuple[1] !== null), tap(([, data]) => { this.form.reset(data); this.#applyConditionalLogic(this.form); }) ) ); // Private methods #applyConditionalLogic(form: FormGroup<ComponentNameFormControls>): void { // Conditional enabling/disabling logic } constructor() { super(initialState); // Initialize effects that don't take parameters this.applyConditionalDisabling(); } }
Phase 3: Refactor Component
- ALWAYS add
ChangeDetectionStrategy.OnPush - ALWAYS add
standalone: true - ALWAYS add ComponentStore to
arrayproviders - ALWAYS use
for dependency injectioninject() - ALWAYS place all
calls first in the class as readonly fieldsinject() - ALWAYS use ECMAScript
syntax for private members#privateField - NEVER use the
orpublic
keywords in TypeScriptprivate - ALWAYS remove base class extensions
- ALWAYS remove
decorators@Select - ALWAYS remove manual subscriptions
- ALWAYS remove
andDestroyRef
(ComponentStore handles cleanup)takeUntilDestroyed
EXAMPLE:
import { ChangeDetectionStrategy, Component, inject, Input, } from "@angular/core"; import { animate, style, transition, trigger } from "@angular/animations"; import { ComponentNameStore } from "./component-name.store"; @Component({ selector: "co-component-name", templateUrl: "./component-name.component.html", standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, providers: [ComponentNameStore], imports: [ // Only what you need ], animations: [ trigger("slideDown", [ transition(":enter", [ style({ height: "0", opacity: 0, overflow: "hidden" }), animate("300ms ease-out", style({ height: "*", opacity: 1 })), ]), transition(":leave", [ style({ height: "*", opacity: 1, overflow: "hidden" }), animate("300ms ease-in", style({ height: "0", opacity: 0 })), ]), ]), ], }) export class ComponentNameComponent { readonly #componentStore = inject(ComponentNameStore); @Input() set data(data: DataType) { if (data) { this.#componentStore.initializeForm(data); } } readonly form = this.#componentStore.form; readonly data$ = this.#componentStore.currentData$; saveChanges(): void { this.#componentStore.saveChanges(); } cancelChanges(): void { this.#componentStore.cancelChanges(); } }
Phase 4: Update Template
- ALWAYS use native control flow (
,@if
,@for
) instead of@switch
,*ngIf
,*ngFor*ngSwitch - ALWAYS use the
directive or*ngrxLet
pipe to handle ObservablesngrxPush- ALWAYS prefer the
pipe overngrxPush
for one-off async bindingsasync - PREFER not using
or*ngrxLet
multiple times for the same Observable; instead assign it to a template variable usingngrxPush@let
- ALWAYS prefer the
- PREFER adding animations for conditional UI
- ALWAYS use form bindings with proper type checking
EXAMPLE - Native control flow with animation:
@if (form.controls.parentField.value) { <div @slideDown> <mat-slide-toggle formControlName="childField"> Child Field </mat-slide-toggle> </div> }
EXAMPLE - Form bindings:
<form [formGroup]="form" class="tw-space-y-4"> <mat-form-field class="tw-w-full" subscriptSizing="fixed"> <mat-label>Field Name</mat-label> <input matInput formControlName="fieldName" required /> @if (form.controls.fieldName.hasError("required")) { <mat-error>Field is required</mat-error> } </mat-form-field> </form>
Phase 5: UX Enhancements
Confirmation Modals
- ALWAYS add confirmation modals for destructive actions
- ALWAYS use MatDialog to open modals
- ALWAYS subscribe to
and only proceed if confirmedafterClosed()
EXAMPLE:
deleteItem(): void { this.#dialog .open(ConfirmDeleteModalComponent, { data: { itemName: this.data$.value?.name }, }) .afterClosed() .subscribe((confirmed) => { if (confirmed) { this.#componentStore.deleteItem(); } }); }
User Feedback
- ALWAYS use
in ApiAction definitions (not manual toasts in stores)successMessage - ALWAYS use
only for local operations (cancel, info messages)CoSnackService - NEVER show success before API call completes
EXAMPLE - Success Messages:
// ❌ WRONG - shows before API completes readonly saveChanges = this.effect<void>( pipe( tap(() => { this.#store.dispatch(updateAction.start(this.form.getRawValue())); this.#snacks.success('Saved!'); // ← BAD }) ) ); // ✅ CORRECT - shows only on actual success export const updateAction = new ApiAction<State, Input, Output>( 'Entity', 'Update', 'Feature', { showErrors: true, successMessage: 'Saved!', // ← GOOD } );
Copy to Clipboard
- PREFER adding copy-to-clipboard buttons for IDs
EXAMPLE:
<button matSuffix mat-icon-button matTooltip="Copy to clipboard" [cdkCopyToClipboard]="form.controls.id.value" (cdkCopyToClipboardCopied)="onIdCopied($event)" > <fa-icon [icon]="copyIcon" /> </button>
Phase 6: Verification
- ALWAYS run lint:
corepack yarn nx lint <library-name> - ALWAYS check for:
- No manual subscriptions in components
- All effects use
directlypipe() - Forms have proper type annotations
- No
typesany - Proper change detection strategy
- ALWAYS test:
- Form initialization
- Save/cancel flows
- Conditional field logic
- Error handling
- User feedback
Common Patterns
Conditional Field Disabling
- ALWAYS create a private method for conditional logic
- ALWAYS use
when programmatically enabling/disabling controls{ emitEvent: false } - ALWAYS call after form reset and in an effect watching the parent field
EXAMPLE:
#applyConditionalDisabling(form: FormGroup<FormControls>): void { const parentValue = form.controls.parentField.value; if (!parentValue) { form.controls.childField.disable({ emitEvent: false }); } else { form.controls.childField.enable({ emitEvent: false }); } } // Call after form reset and in an effect watching the parent field readonly applyConditionalDisabling = this.effect<void>( pipe( switchMap(() => this.form.controls.parentField.valueChanges), tap(() => { this.#applyConditionalDisabling(this.form); }) ) );
Form Controls with nonNullable
- ALWAYS add
to form controls to ensure type safetynonNullable: true - NOTE: This prevents the form control value from being
after resetnull
EXAMPLE:
readonly form = new FormGroup<ComponentNameFormControls>({ field1: new FormControl<string>('', { validators: [Validators.required], nonNullable: true // ← ALWAYS include this }), field2: new FormControl<boolean>(false, { nonNullable: true }), });
Critical Rules
Forms
- NEVER store forms in ComponentStore state
- ALWAYS define forms as class properties in the store
- ALWAYS add
to form controlsnonNullable: true - ALWAYS use typed
andFormGroup
(notFormControl
)FormNode - ALWAYS define form controls interface
Effects
- ALWAYS use
directly:pipe()this.effect<Type>(pipe(...)) - NEVER use arrow functions:
this.effect<Type>((param$) => param$.pipe(...)) - ALWAYS use proper type narrowing with filter
Subscriptions
- NEVER use manual subscriptions in components
- NOTE: ComponentStore handles cleanup automatically
- NEVER use
andDestroyRef
for ComponentStore subscriptionstakeUntilDestroyed - ALWAYS wire observables directly to updaters/effects
User Feedback
- NEVER show success toasts before API calls complete
- ALWAYS use
in ApiAction definitionssuccessMessage - ALWAYS use
only for local operationsCoSnackService
TypeScript
- NEVER use
typesany- ALWAYS use
when type is uncertainunknown
- ALWAYS use
- ALWAYS use ECMAScript
syntax for encapsulation#privateField - NEVER use the
orpublic
keywords in TypeScript class membersprivate
State Management
- NEVER use
ComponentStore.get()- ALWAYS read state via selectors
- NEVER keep empty effects
Migration Checklist
- Phase 1: Read all component files and identify patterns
- Phase 2: Create ComponentStore
- Defined state interface with
propertiesreadonly - Defined form controls interface
- Created form as class property (not in state)
- All selectors use
suffix$ - All effects use
directlypipe() - Proper type narrowing in effects
- Defined state interface with
- Phase 3: Refactor component
- Component uses
change detectionOnPush - Component is
standalone: true - ComponentStore in providers array
- Removed base class extensions
- Removed
decorators@Select - Removed manual subscriptions
- All
calls first in classinject() - Using
syntax#privateField
- Component uses
- Phase 4: Update template
- Updated template to use native control flow
- Added animations for conditional UI
- Proper form bindings
- Phase 5: UX enhancements
- Added confirmation dialogs for destructive actions
- Success messages in ApiAction (not manual toasts)
- Copy-to-clipboard for IDs (if applicable)
- Phase 6: Verification
- Lint passes
- No manual subscriptions in components
- Forms have proper type annotations
- No
typesany - Tested all workflows
Additional Best Practices from AGENTS.md
- ALWAYS check AGENTS.md for the latest definite best practices
Angular Components
- ALWAYS set
inchangeDetection: ChangeDetectionStrategy.OnPush
decorator for new components@Component - ALWAYS use separate HTML files (do NOT use inline templates)
- ALWAYS place all
calls first in the class as readonly fieldsinject() - ALWAYS place
and@Input
properties second in the class@Output - ALWAYS use
bindings instead ofclassngClass - ALWAYS use
bindings instead ofstylengStyle - ALWAYS use pipes for data transformation in templates, not methods in the component class
UI and Styling
- PREFER Angular Material/CDK for complex, interactive UI
- NEVER override internal APIs in Angular Material components
- ALWAYS use Tailwind for layout, spacing, and simple styling
- ALWAYS use
prefix (enforced intw-
)libs/co/ui-tailwind-preset/tailwind.config.js - ALWAYS define repeated patterns in CSS layer using
directive@apply
FontAwesome Icons
- ALWAYS use FontAwesome icons via the
package@fortawesome/angular-fontawesome - ALWAYS use
component, not<fa-icon>
tags with CSS classes<i> - ALWAYS import from
(not free packages)@fortawesome/pro-*-svg-icons - ALWAYS store icons as readonly component properties; prefer regular style by default
Before Submitting Code Review
- ALWAYS ensure all affected tests pass locally
- ALWAYS run formatting:
(fromyarn run format
)Connect/ng-app-monolith - ALWAYS run linting:
yarn exec nx affected --targets=lint,test --skip-nx-cache - ALWAYS verify no linting errors are present
- ALWAYS ensure code follows established patterns as outlined in AGENTS.md
Reference Files
ALWAYS refer to these files for complete examples:
libs/connect/cms/qa/feature/src/lib/edit-topic/cms-qa-edit-topic.store.tslibs/connect/cms/qa/feature/src/lib/settings-page/cms-qa-settings-page.store.tslibs/connect/cms/qa/feature/src/lib/topics-page/cms-qa-topics-page.store.ts
- Angular Development Patterns sectionAGENTS.md
Examples
See the Instructions section.