Skip to main content
Published / updated

Guided Angular Project

Before you start

You need: all of Articles 01–10, and a running API from Track 10.

Time: 12–16 hours across two weeks.

Goal

Demonstrate that you can build a structured Angular application over a secured API — one complete CRUD workflow, routed, lazy-loaded, validated, accessible, with every request state handled and no subscription leaks.

Assignment

Build the NexCoding School Portal frontend against the Web API from the ASP.NET Core track.

The deliverable is one complete CRUD screen — list, view, create, update and deactivate a student — running Angular against ASP.NET Core Web API against SQL Server. Depth on one entity beats four half-finished ones.

DeliverableContents
src/app/core/Services, interceptors, guards, models
src/app/shared/Reusable components, pipes, directives
src/app/features/One folder per area, lazy-loaded
src/environments/API URL per environment
README.mdHow to run it against the API
DECISIONS.mdChoices, with reasons

Required screens

RouteBehaviour
/loginAnonymous, returnUrl, one message for any credential failure
/studentsSearch, class filter, paging — all four states
/students/newReactive form, full validation
/students/:publicIdShell with Overview, Results and Fees child routes
/students/:publicId/editPre-filled, unsaved-changes guard
/feesOutstanding fees, Admin and Staff only
/exams/:examId/resultsFormArray bulk entry
**Not found

Non-negotiable requirements

  • Every feature area lazy-loaded; verified in the Network tab
  • schoolId never sent from the client — the API reads it from the token
  • Filters, paging and sort in query parameters, so a refresh and a shared link work
  • Every list handles loading, success, empty and error
  • Live search debounced and cancelled with switchMap
  • No leaked subscriptionsasync pipe, toSignal, or takeUntilDestroyed
  • Token attached by an interceptor, 401 handled centrally
  • Reactive forms, typed, with nonNullable
  • Server 400 errors mapped to individual controls
  • Fully keyboard operable, focus visible, errors announced
  • Works at 320px and at 200% zoom
  • No any, strict: true, ng build with no warnings

Worked example: the list screen

Where most of the track's requirements meet.

type ListState =
| { status: 'loading' }
| { status: 'success'; result: PagedResult<StudentListItem> }
| { status: 'empty'; term: string }
| { status: 'error'; message: string };

@Component({
selector: 'app-student-list',
standalone: true,
imports: [CommonModule, RouterLink, StudentTableComponent, PagerComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './student-list.component.html'
})
export class StudentListComponent {
private readonly studentService = inject(StudentService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);

readonly searchTerm = signal('');
readonly className = signal('');
readonly page = signal(1);

readonly state = toSignal(
combineLatest([
toObservable(this.searchTerm).pipe(debounceTime(300), distinctUntilChanged()),
toObservable(this.className),
toObservable(this.page)
]).pipe(
switchMap(([term, className, page]) =>
this.studentService.search({ term, className, page, pageSize: 20 }).pipe(
map((result): ListState => result.items.length === 0
? { status: 'empty', term }
: { status: 'success', result }),
startWith<ListState>({ status: 'loading' }),
catchError((err: HttpErrorResponse) => of<ListState>({
status: 'error',
message: err.status >= 500
? 'The server is not responding. Please try again shortly.'
: 'Could not load students.'
}))
))
),
{ initialValue: { status: 'loading' } as ListState });

onSearch(term: string): void {
this.searchTerm.set(term);
this.page.set(1);

this.router.navigate([], {
relativeTo: this.route,
queryParams: { term: term || null, page: 1 },
queryParamsHandling: 'merge',
replaceUrl: true
});
}
}

Five decisions a reviewer will check:

DetailWhat breaks without it
debounceTime + distinctUntilChangedFour requests for "Ravi"
switchMapOut-of-order results — the list shows "Rav"
catchError inside switchMapOne failure and search is dead until reload
startWith({ status: 'loading' })No spinner between keystroke and result
replaceUrl: trueEvery keystroke is a history entry; Back is unusable
<h1>Students</h1>

<form role="search" (submit)="$event.preventDefault()">
<label for="search">Search students</label>
<input id="search" type="search" [value]="searchTerm()"
(input)="onSearch($any($event.target).value)"
placeholder="Name or roll number">
</form>

<p role="status" aria-live="polite" class="visually-hidden">{{ announcement() }}</p>

@switch (state().status) {
@case ('loading') {
<p class="muted">Loading students…</p>
}
@case ('empty') {
<p class="muted">No students match “{{ $any(state()).term }}”.</p>
}
@case ('error') {
<p class="error" role="alert">{{ $any(state()).message }}</p>
<button type="button" (click)="retry()">Try again</button>
}
@case ('success') {
<app-student-table [result]="$any(state()).result" />
<app-pager [result]="$any(state()).result" (pageChange)="page.set($event)" />
}
}

aria-live="polite" is what makes the state change perceivable to a screen-reader user. A visual-only spinner tells them nothing, and the list silently changing under them is worse.

The error state has a retry button. An error with no way forward is a dead end.

Worked example: the edit form

export class StudentFormComponent implements CanComponentDeactivate {
private readonly fb = inject(FormBuilder);
private readonly studentService = inject(StudentService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);

readonly form = this.fb.nonNullable.group({
name: ['', [Validators.required, Validators.maxLength(100)]],
rollNumber: ['', [Validators.required, Validators.pattern(/^NCA-\d{4}-\d{4}$/)]],
className: ['', Validators.required],
section: ['', Validators.required],
dateOfBirth: ['', [Validators.required, minAgeValidator(3)]],
parentName: ['', [Validators.required, Validators.maxLength(100)]],
parentPhone: ['', [Validators.required, Validators.pattern(/^[6-9]\d{9}$/)]],
address: ['']
});

readonly saving = signal(false);
readonly saved = signal(false);
readonly submitted = signal(false);
readonly serverError = signal<string | null>(null);

private publicId: string | null = null;

ngOnInit(): void {
this.publicId = this.route.snapshot.paramMap.get('publicId');

if (!this.publicId) {
return;
}

this.studentService.getByPublicId(this.publicId).subscribe(student => {
this.form.setValue({
name: student.name,
rollNumber: student.rollNumber,
className: student.className,
section: student.section,
dateOfBirth: student.dateOfBirth,
parentName: student.parentName,
parentPhone: student.parentPhone,
address: student.address ?? ''
});

this.form.markAsPristine();
});
}

onSubmit(): void {
this.submitted.set(true);

if (this.form.invalid) {
this.form.markAllAsTouched();
this.focusFirstInvalid();
return;
}

this.saving.set(true);
this.serverError.set(null);

const request = this.form.getRawValue();

const save$ = this.publicId
? this.studentService.update(this.publicId, request)
: this.studentService.create(request);

save$.pipe(finalize(() => this.saving.set(false))).subscribe({
next: () => {
this.saved.set(true);
this.router.navigate(['/students'], { queryParams: { saved: '1' } });
},
error: (err: HttpErrorResponse) => {
if (err.status === 400 && err.error?.errors) {
this.applyServerErrors(err.error.errors);
return;
}

this.serverError.set(err.status === 409
? 'This roll number is already in use.'
: 'Could not save. Please try again.');
}
});
}

canDeactivate(): boolean {
return this.form.pristine || this.saved()
|| confirm('You have unsaved changes. Leave this page?');
}
}

Six things a reviewer will check:

DetailWhat breaks without it
setValue, not patchValueA field added later loads silently blank
markAsPristine() after loadingThe unsaved-changes guard fires immediately
getRawValue()Disabled controls are missing from the payload
finalizeThe saving state sticks after an error
markAllAsTouched() + focusClicking Save on an untouched form appears to do nothing
saved() in canDeactivatePrompts after a successful save

[disabled]="saving()" on the submit button prevents a double-click creating two students.

Worked example: structure

src/app/
├── core/
│ ├── services/ auth.service.ts, student.service.ts, fee.service.ts
│ ├── interceptors/ auth.interceptor.ts, error.interceptor.ts
│ ├── guards/ auth.guard.ts, role.guard.ts, unsaved-changes.guard.ts
│ ├── models/ student.ts, paged-result.ts
│ └── validators/ min-age.validator.ts, marks-or-absent.validator.ts
├── shared/
│ ├── components/ pager, empty-state, error-message, confirm-dialog
│ ├── pipes/ class-section.pipe.ts, marks-display.pipe.ts
│ └── directives/ has-role.directive.ts, auto-focus.directive.ts
└── features/
├── auth/ login.component.ts, auth.routes.ts
├── students/ list, detail shell, form, students.routes.ts
├── exams/ results entry, exams.routes.ts
└── fees/ outstanding report, fees.routes.ts
// app.routes.ts
export const routes: Routes = [
{ path: '', redirectTo: 'students', pathMatch: 'full' },

{ path: 'login', loadComponent: () => import('./features/auth/login.component')
.then(m => m.LoginComponent), title: 'Sign in' },

{
path: 'students',
canActivate: [authGuard],
loadChildren: () => import('./features/students/students.routes')
.then(m => m.STUDENT_ROUTES)
},
{
path: 'fees',
canMatch: [roleMatchGuard('Admin', 'Staff')],
loadChildren: () => import('./features/fees/fees.routes').then(m => m.FEE_ROUTES)
},

{ path: 'access-denied', loadComponent: () => import('./access-denied.component') },
{ path: '**', loadComponent: () => import('./not-found.component') }
];

canMatch on the fees route means a Teacher never downloads the chunk — better than canActivate, which downloads it and then blocks.

Submission template

DECISIONS.md

Structure:
core / shared / features split, and what belongs where:
Which features are lazy-loaded, and the chunk sizes:

State:
Signals vs RxJS, and why each was chosen where it was:
Where state is shared, and the provider scope:
Where asReadonly() is used:

Routing:
Route table and guard placement:
canActivate vs canMatch, and why:
What lives in query parameters and why:

Forms:
Reactive vs template-driven, per form, with the reason:
Custom validators written:
Cross-field rules and where they attach:
How server 400 errors reach individual controls:

API integration:
Interceptors and their order:
How each of 0, 400, 401, 403, 404, 409, 500 is handled:
Debounce and cancellation approach:
The four states, per list screen:

Subscriptions:
Every long-lived subscription and its teardown mechanism:

Accessibility:
Keyboard walkthrough result:
Screen-reader test result:
Live regions used:
Focus management on validation failure:

Security boundary:
What guards and hidden UI actually achieve:
What the server enforces independently:

Performance:
OnPush usage:
Bundle size, initial and lazy:
Any trackBy or track choices that mattered:

Deliberately not done, and why:

Verification

Runs from a clean clone. npm install, set the API URL, ng serve. Every screen works.

Lazy loading. Open the Network tab, navigate to each feature, and confirm a new chunk downloads. ng build and record the initial bundle size.

No schoolId from the client. Search every request in the Network tab. It must appear nowhere. Then sign in as a school 2 user and confirm you cannot see school 1's data.

Four states. Force each on every list: a valid search, a search matching nothing, the API stopped, and Slow 3G throttling.

No request races. Throttle to Slow 3G, type quickly in the search box. Confirm only the last result renders and earlier requests show as cancelled.

No leaks. Navigate into and out of a screen ten times. Trigger the event its subscription listens for and confirm the handler fires once, not ten times.

Refresh safety. Search, filter, page 3, then refresh. Confirm the same view. Copy the URL into a new tab and confirm it opens identically.

401 handling. Clear the token mid-session and act. Confirm a redirect to login with returnUrl, then that signing in returns you to where you were.

Partial update preserved. Edit only the name and save. Confirm dateOfBirth, parentPhone and the rest are unchanged in the database.

Server errors reach fields. Submit a duplicate roll number and confirm the message appears beside that field, not as a generic banner.

Keyboard only. Put the mouse away and complete a full create-student flow. Every control reachable, focus always visible, no trap, and validation failure moves focus to the first invalid field.

Screen reader. NVDA or VoiceOver. Confirm route titles announce on navigation, list state changes announce, and form errors are read.

320px and 200% zoom. Every screen usable, no horizontal page scroll.

Security boundary. Edit the token payload in DevTools to change your role. Confirm the UI changes and the API still refuses the action.

Build clean. ng build with no warnings, strict: true, no any.

AI practice

Three AI exercises from this track's syllabus. Do each after the project works, and apply Track 18's discipline — every answer is a hypothesis until you have run it.

  1. Ask AI to explain generated component flow. Paste a student list component and ask what runs in what order — constructor, ngOnInit, the subscription, the template render — and when the template first sees data. Then verify with a log in each. Rendering before data arrives is the most common bug here, and the explanation is where you catch it.
  2. Review AI-created forms for validation. Ask for a reactive fee payment form. Check whether it validates the amount above zero, whether it disables submit while pending, and whether it shows errors only after the field is touched. Then bypass the whole form from Postman and confirm the API still rejects the payment — client-side validation is a convenience, never a control.
  3. Use AI to propose, then verify, a reusable component. Ask how to extract a StudentCard used by both the list and the search results. Ask for the approach and its trade-offs, not the code, then write it yourself and compare.

Also check every generated service for a hardcoded API URL. It belongs in environment configuration, and anything shipped to the browser is not a secret.

Track 18 — Reviewing AI-generated code — has the full checklist.

Self-assessment

Your submission is complete when someone can clone it, point it at the API, use every screen with a keyboard alone, and read DECISIONS.md to see which choices were deliberate.

Four specific tests of quality:

  • Does the search survive Slow 3G with rapid typing? This is the race switchMap exists for, and it is invisible on a fast connection.
  • Does the handler fire once after ten navigations? A leak is silent — the application just gets slower — and only this test finds it.
  • Do all four states appear, including empty? A blank screen for "no results" is indistinguishable from a bug.
  • Does DECISIONS.md state where guards stop being security? Explaining that a hidden button is interface, not access control, is what separates understanding the framework from copying it.

Track completion criteria

You can build structured Angular applications, use forms, routing and RxJS basics, integrate secured ASP.NET Core REST APIs, and debug common Angular issues.

Specifically, you can:

  • Create and structure a project with core / shared / features
  • Build components with scoped styles, typed inputs and event outputs
  • Use control flow correctly, including why track matters
  • Move logic into services and choose the right provider scope
  • Build lazy-loaded routes whose URLs are shareable and refresh-safe
  • Build typed reactive forms with custom and cross-field validation
  • Choose the right RxJS flattening operator, and prevent leaks
  • Call an API handling every status code and all four states
  • Attach tokens with an interceptor and protect routes with guards
  • State precisely which parts of the frontend are security and which are interface

The syllabus recommends Track 01 — Microsoft .NET Full Stack Guided Path or Track 16 — Git & Source Control next.