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.
| Deliverable | Contents |
|---|---|
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.md | How to run it against the API |
DECISIONS.md | Choices, with reasons |
Required screens
| Route | Behaviour |
|---|---|
/login | Anonymous, returnUrl, one message for any credential failure |
/students | Search, class filter, paging — all four states |
/students/new | Reactive form, full validation |
/students/:publicId | Shell with Overview, Results and Fees child routes |
/students/:publicId/edit | Pre-filled, unsaved-changes guard |
/fees | Outstanding fees, Admin and Staff only |
/exams/:examId/results | FormArray bulk entry |
** | Not found |
Non-negotiable requirements
- Every feature area lazy-loaded; verified in the Network tab
schoolIdnever 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 subscriptions —
asyncpipe,toSignal, ortakeUntilDestroyed - Token attached by an interceptor, 401 handled centrally
- Reactive forms, typed, with
nonNullable - Server 400
errorsmapped to individual controls - Fully keyboard operable, focus visible, errors announced
- Works at 320px and at 200% zoom
- No
any,strict: true,ng buildwith 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:
| Detail | What breaks without it |
|---|---|
debounceTime + distinctUntilChanged | Four requests for "Ravi" |
switchMap | Out-of-order results — the list shows "Rav" |
catchError inside switchMap | One failure and search is dead until reload |
startWith({ status: 'loading' }) | No spinner between keystroke and result |
replaceUrl: true | Every 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:
| Detail | What breaks without it |
|---|---|
setValue, not patchValue | A field added later loads silently blank |
markAsPristine() after loading | The unsaved-changes guard fires immediately |
getRawValue() | Disabled controls are missing from the payload |
finalize | The saving state sticks after an error |
markAllAsTouched() + focus | Clicking Save on an untouched form appears to do nothing |
saved() in canDeactivate | Prompts 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.
- 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. - 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.
- Use AI to propose, then verify, a reusable component. Ask how to extract a
StudentCardused 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
switchMapexists 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.mdstate 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
trackmatters - 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.