diff --git a/ClientApp/.gitignore b/ClientApp/.gitignore index e1f679b..56cf08c 100644 --- a/ClientApp/.gitignore +++ b/ClientApp/.gitignore @@ -34,6 +34,7 @@ npm-debug.log yarn-error.log testem.log /typings +.angular/cache # System Files .DS_Store diff --git a/ClientApp/src/app/app.module.ts b/ClientApp/src/app/app.module.ts index f4fdf93..d9f1aa2 100644 --- a/ClientApp/src/app/app.module.ts +++ b/ClientApp/src/app/app.module.ts @@ -1,6 +1,6 @@ import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; -import { FormsModule } from '@angular/forms'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { HttpClientModule } from '@angular/common/http'; import { RouterModule } from '@angular/router'; @@ -10,6 +10,7 @@ import { HomeComponent } from './components/home/home.component'; import { CounterComponent } from './components/counter/counter.component'; import { SanctionedEntitiesComponent } from './components/sanctioned-entities/sanctioned-entities.component'; import { JumbotronCounterComponent } from './components/jumbotron-counter/jumbotron-counter.component'; +import { SanctionedEntityAddComponent } from './components/sanctioned-entity-add/sanctioned-entity-add.component'; @NgModule({ @@ -19,16 +20,20 @@ import { JumbotronCounterComponent } from './components/jumbotron-counter/jumbot HomeComponent, CounterComponent, SanctionedEntitiesComponent, + SanctionedEntityAddComponent, JumbotronCounterComponent ], imports: [ BrowserModule.withServerTransition({ appId: 'ng-cli-universal' }), HttpClientModule, FormsModule, + ReactiveFormsModule, RouterModule.forRoot([ { path: '', component: HomeComponent, pathMatch: 'full' }, { path: 'counter', component: CounterComponent }, { path: 'sanctioned-entities', component: SanctionedEntitiesComponent }, + { path: 'sanctioned-entities/add', component: SanctionedEntityAddComponent }, + { path: '**', redirectTo: '' } // Wildcard route for a 404 page can be added here ]) ], providers: [], diff --git a/ClientApp/src/app/components/counter/counter.component.html b/ClientApp/src/app/components/counter/counter.component.html index 89b9c80..9c01db5 100644 --- a/ClientApp/src/app/components/counter/counter.component.html +++ b/ClientApp/src/app/components/counter/counter.component.html @@ -2,6 +2,6 @@

Counter

This is a simple example of an Angular component.

-

Current count: {{ currentCount }}

+

Current count: {{ currentCount$ | async }}

diff --git a/ClientApp/src/app/components/counter/counter.component.ts b/ClientApp/src/app/components/counter/counter.component.ts index 1f336aa..b0965ed 100644 --- a/ClientApp/src/app/components/counter/counter.component.ts +++ b/ClientApp/src/app/components/counter/counter.component.ts @@ -1,13 +1,17 @@ import { Component } from '@angular/core'; +import { Observable } from 'rxjs'; +import { CounterService } from 'src/app/services/counter.service'; @Component({ selector: 'app-counter-component', templateUrl: './counter.component.html' }) export class CounterComponent { - public currentCount = 0; + public currentCount$: Observable = this.counterService.getCounter(); - public incrementCounter() { - this.currentCount++; + constructor(private readonly counterService: CounterService) {} + + public incrementCounter(): void { + this.counterService.increment(); } } diff --git a/ClientApp/src/app/components/jumbotron-counter/jumbotron-counter.component.html b/ClientApp/src/app/components/jumbotron-counter/jumbotron-counter.component.html index 4d39017..c1b9de9 100644 --- a/ClientApp/src/app/components/jumbotron-counter/jumbotron-counter.component.html +++ b/ClientApp/src/app/components/jumbotron-counter/jumbotron-counter.component.html @@ -1,4 +1,4 @@ -
+

Current count:

-

Please include the counter here

+

{{ currentCount$ | async }}

diff --git a/ClientApp/src/app/components/jumbotron-counter/jumbotron-counter.component.ts b/ClientApp/src/app/components/jumbotron-counter/jumbotron-counter.component.ts index 6884723..e1690d9 100644 --- a/ClientApp/src/app/components/jumbotron-counter/jumbotron-counter.component.ts +++ b/ClientApp/src/app/components/jumbotron-counter/jumbotron-counter.component.ts @@ -1,8 +1,13 @@ import { Component } from '@angular/core'; +import { Observable } from 'rxjs'; +import { CounterService } from 'src/app/services/counter.service'; @Component({ selector: 'app-jumbotron-counter', templateUrl: './jumbotron-counter.component.html' }) export class JumbotronCounterComponent { + public currentCount$: Observable = this.counterService.getCounter(); + + constructor(private readonly counterService: CounterService) {} } diff --git a/ClientApp/src/app/components/sanctioned-entities/sanctioned-entities.component.html b/ClientApp/src/app/components/sanctioned-entities/sanctioned-entities.component.html index 9699666..e953ec3 100644 --- a/ClientApp/src/app/components/sanctioned-entities/sanctioned-entities.component.html +++ b/ClientApp/src/app/components/sanctioned-entities/sanctioned-entities.component.html @@ -1,21 +1,35 @@

Sanctioned Entities

-

Loading...

+ - + +
- + - + - + - + -
Name Domicile Status
{{ entity.name }} {{ entity.domicile }} Accepted Rejected
+ + + + +

Loading...

+
diff --git a/ClientApp/src/app/components/sanctioned-entities/sanctioned-entities.component.ts b/ClientApp/src/app/components/sanctioned-entities/sanctioned-entities.component.ts index ca699d1..7cdafa6 100644 --- a/ClientApp/src/app/components/sanctioned-entities/sanctioned-entities.component.ts +++ b/ClientApp/src/app/components/sanctioned-entities/sanctioned-entities.component.ts @@ -1,17 +1,15 @@ import { Component } from '@angular/core'; import { SanctionedEntity } from '../../models/sanctioned-entity'; import { SanctionedEntitiesService } from '../../services/sanctioned-entities.service'; +import { Observable } from 'rxjs'; @Component({ selector: 'app-sanctioned-entities', - templateUrl: './sanctioned-entities.component.html' + templateUrl: './sanctioned-entities.component.html', }) export class SanctionedEntitiesComponent { - public entities: SanctionedEntity[] = []; + public entities$: Observable = + this.entitiesService.getSanctionedEntities(); - constructor(private entitiesService: SanctionedEntitiesService) { - entitiesService.getSanctionedEntities().subscribe(entities => { - this.entities = entities; - }); - } + constructor(private entitiesService: SanctionedEntitiesService) {} } diff --git a/ClientApp/src/app/components/sanctioned-entity-add/sanctioned-entity-add.component.html b/ClientApp/src/app/components/sanctioned-entity-add/sanctioned-entity-add.component.html new file mode 100644 index 0000000..6eafce3 --- /dev/null +++ b/ClientApp/src/app/components/sanctioned-entity-add/sanctioned-entity-add.component.html @@ -0,0 +1,90 @@ +
+
+ + +
+ Name is required. +
+
+ +
+ + +
+ Domicile is required. +
+
+ +
+ An entity with the same name and domicile already exists. +
+ +
+ + +
+ +
{{ formError }}
+ +
+ + +
+
diff --git a/ClientApp/src/app/components/sanctioned-entity-add/sanctioned-entity-add.component.spec.ts b/ClientApp/src/app/components/sanctioned-entity-add/sanctioned-entity-add.component.spec.ts new file mode 100644 index 0000000..2dcae40 --- /dev/null +++ b/ClientApp/src/app/components/sanctioned-entity-add/sanctioned-entity-add.component.spec.ts @@ -0,0 +1,82 @@ +import { + ComponentFixture, + TestBed, + fakeAsync, + tick, +} from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { of, throwError } from 'rxjs'; + +import { SanctionedEntityAddComponent } from './sanctioned-entity-add.component'; +import { SanctionedEntitiesService } from '../../services/sanctioned-entities.service'; + +describe('SanctionedEntityAddComponent', () => { + let component: SanctionedEntityAddComponent; + let fixture: ComponentFixture; + let mockService: any; + let mockRouter: any; + + beforeEach(() => { + mockService = { + addSanctionedEntity: jasmine.createSpy('addSanctionedEntity'), + getSanctionedEntities: jasmine + .createSpy('getSanctionedEntities') + .and.returnValue(of([])), + }; + + mockRouter = { + navigate: jasmine.createSpy('navigate'), + }; + + TestBed.configureTestingModule({ + imports: [ReactiveFormsModule], + declarations: [SanctionedEntityAddComponent], + providers: [ + { provide: SanctionedEntitiesService, useValue: mockService }, + { provide: Router, useValue: mockRouter }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SanctionedEntityAddComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should mark form as submitted and not call service if invalid', () => { + component.addEntity(); + expect(component.formSubmitted).toBeTrue(); + expect(mockService.addSanctionedEntity).not.toHaveBeenCalled(); + }); + + it('should call service and navigate on successful add', fakeAsync(() => { + const entity = { name: 'Test', domicile: 'Earth', accepted: true }; + component.entityForm.setValue(entity); + mockService.addSanctionedEntity.and.returnValue(of({})); + + component.addEntity(); + tick(); // simulate async + + expect(component.isSaving).toBeFalse(); + expect(mockService.addSanctionedEntity).toHaveBeenCalledWith(entity); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/sanctioned-entities']); + })); + + it('should handle error if service fails', fakeAsync(() => { + const entity = { name: 'Test', domicile: 'Earth', accepted: true }; + component.entityForm.setValue(entity); + const error = { message: 'Duplicate entity' }; + mockService.addSanctionedEntity.and.returnValue(throwError(() => error)); + + component.addEntity(); + tick(); // simulate async + + expect(component.isSaving).toBeFalse(); + expect(component.formError).toBe('Duplicate entity'); + expect(mockRouter.navigate).not.toHaveBeenCalled(); + })); +}); diff --git a/ClientApp/src/app/components/sanctioned-entity-add/sanctioned-entity-add.component.ts b/ClientApp/src/app/components/sanctioned-entity-add/sanctioned-entity-add.component.ts new file mode 100644 index 0000000..0176391 --- /dev/null +++ b/ClientApp/src/app/components/sanctioned-entity-add/sanctioned-entity-add.component.ts @@ -0,0 +1,61 @@ +import { Component, EventEmitter, Output } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { SanctionedEntity } from '../../models/sanctioned-entity'; +import { SanctionedEntitiesService } from '../../services/sanctioned-entities.service'; +import { sanctionedEntityValidator } from 'src/app/validators/sanctioned-entity.validator'; +import { take } from 'rxjs'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'app-sanctioned-entity-add', + templateUrl: './sanctioned-entity-add.component.html', +}) +export class SanctionedEntityAddComponent { + public entityForm: FormGroup; + public formSubmitted = false; + public formError = ''; + public isSaving = false; + + constructor( + private fb: FormBuilder, + private router: Router, + private readonly entitiesService: SanctionedEntitiesService + ) { + this.entityForm = this.fb.group( + { + name: ['', Validators.required], + domicile: ['', Validators.required], + accepted: [false], + }, + { + asyncValidators: [sanctionedEntityValidator(this.entitiesService)], + updateOn: 'blur', + } + ); + } + + public addEntity(): void { + this.formSubmitted = true; + this.formError = ''; + + if (this.entityForm.invalid) return; + + this.isSaving = true; + + const newEntity: SanctionedEntity = this.entityForm.value; + + this.entitiesService + .addSanctionedEntity(newEntity) + .pipe(take(1)) + .subscribe({ + next: () => { + this.isSaving = false; + this.router.navigate(['/sanctioned-entities']); + }, + error: (err) => { + this.isSaving = false; + this.formError = err.message || 'Failed to add entity'; + }, + }); + } +} diff --git a/ClientApp/src/app/services/counter.service.ts b/ClientApp/src/app/services/counter.service.ts new file mode 100644 index 0000000..51b927c --- /dev/null +++ b/ClientApp/src/app/services/counter.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from "@angular/core"; +import { BehaviorSubject, Observable } from "rxjs"; + +@Injectable({ + providedIn: 'root' +}) +export class CounterService { + private counter: BehaviorSubject = new BehaviorSubject(0); + + public getCounter(): Observable { + return this.counter.asObservable(); + } + + public increment(): void { + this.counter.next(this.counter.value + 1); + } +} diff --git a/ClientApp/src/app/services/sanctioned-entities.service.ts b/ClientApp/src/app/services/sanctioned-entities.service.ts index 1a90e11..43c7278 100644 --- a/ClientApp/src/app/services/sanctioned-entities.service.ts +++ b/ClientApp/src/app/services/sanctioned-entities.service.ts @@ -4,10 +4,9 @@ import { SanctionedEntity } from '../models/sanctioned-entity'; import { Observable } from 'rxjs'; @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class SanctionedEntitiesService { - private readonly apiUrl: string; private readonly path = 'sanctioned-entities'; @@ -19,4 +18,9 @@ export class SanctionedEntitiesService { const url = this.apiUrl + this.path; return this.http.get(url); } + + public addSanctionedEntity(entity: SanctionedEntity): Observable { + const url = this.apiUrl + this.path; + return this.http.post(url, entity); + } } diff --git a/ClientApp/src/app/validators/sanctioned-entity.validator.ts b/ClientApp/src/app/validators/sanctioned-entity.validator.ts new file mode 100644 index 0000000..0915862 --- /dev/null +++ b/ClientApp/src/app/validators/sanctioned-entity.validator.ts @@ -0,0 +1,33 @@ +import { + AbstractControl, + AsyncValidatorFn, + ValidationErrors, +} from '@angular/forms'; +import { Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { SanctionedEntitiesService } from '../services/sanctioned-entities.service'; + +export function sanctionedEntityValidator( + service: SanctionedEntitiesService +): AsyncValidatorFn { + return (control: AbstractControl): Observable => { + const name = control.get('name')?.value; + const domicile = control.get('domicile')?.value; + + // Skip validation if name or domicile is missing + if (!name || !domicile) { + return of(null); + } + + return service.getSanctionedEntities().pipe( + map((entities) => { + const exists = entities.some( + (e) => + e.name.toLowerCase() === name.toLowerCase() && + e.domicile.toLowerCase() === domicile.toLowerCase() + ); + return exists ? { duplicateEntity: true } : null; + }) + ); + }; +} diff --git a/Controllers/SanctionedEntitiesController.cs b/Controllers/SanctionedEntitiesController.cs index e6946c6..aa4a98e 100644 --- a/Controllers/SanctionedEntitiesController.cs +++ b/Controllers/SanctionedEntitiesController.cs @@ -1,4 +1,5 @@ -using ajgre_technical_interview.Services; +using ajgre_technical_interview.Models; +using ajgre_technical_interview.Services; using Microsoft.AspNetCore.Mvc; namespace ajgre_technical_interview.Controllers @@ -29,5 +30,19 @@ public async Task GetSanctionedEntities() } } + + [HttpPost] + public async Task CreateSanctionedEntity([FromBody] SanctionedEntity entity) + { + try + { + var created = await _databaseService.CreateSanctionedEntityAsync(entity); + return CreatedAtAction(nameof(GetSanctionedEntities), new { id = created.Id }, created); + } + catch (Exception ex) + { + return Problem(ex.Message); + } + } } } diff --git a/Program.cs b/Program.cs index c43b257..3c3c28b 100644 --- a/Program.cs +++ b/Program.cs @@ -1,4 +1,5 @@ using ajgre_technical_interview.Services; +using ajgre_technical_interview.Validators; var builder = WebApplication.CreateBuilder(args); @@ -6,6 +7,23 @@ builder.Services.AddControllersWithViews(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// Configure CORS +builder.Services.AddCors(options => +{ + if (builder.Environment.IsDevelopment()) + { + // Dev: allow localhost + options.AddPolicy("CorsPolicy", policy => + { + policy + .SetIsOriginAllowed(origin => new Uri(origin).Host == "localhost") + .AllowAnyMethod() + .AllowAnyHeader(); + }); + } +}); var app = builder.Build(); @@ -20,6 +38,8 @@ app.UseStaticFiles(); app.UseRouting(); +// Use the CORS policy +app.UseCors("CorsPolicy"); app.MapControllerRoute( name: "default", diff --git a/README.md b/README.md index 3db4be0..0b85536 100644 --- a/README.md +++ b/README.md @@ -3,20 +3,24 @@ The project is an ASP.NET Core application with Angular to test basic technical skills of the candidates for Software Engineering positions. ## Structure + Application uses .NET Core 6 ClientApp uses Angular 14 The project does not use any database. Sample _database_ items are stored in `SanctionedEntities` list in `DatabaseService`. For the purpose of this exercise this is sufficient, we do not expect candidates to embed ORM or CRUD functionality. However if candidates wish to mock a DAL that is acceptable - but the focus of the time spent here should be on C# and Angular development. ## Requirements + - Node.js version 16 or later - ASP.NET Core 6 - Visual Studio/VS Code IDE ## Getting started + Open the solution in Visual Studio and run IIS Express. This will automatically install the required packages and will open the app in the browser. The Home page will provide you with the details about the content of the application and the tasks you are asked to tackle. ## Scope of exercise + As an insurance organisation, we are prevented from doing business with certain organisations that are subject to sanctions by Government. These sanctioned entities are different across the globe and it is vital we keep track of which organisations are sanctioned in which countries. This is a simple ASP.Net Core application with Angular, its goal is to keep a track of the number of organisations subject to sanctions in various countries and display these to the user. It includes following tabs: @@ -27,13 +31,29 @@ This is a simple ASP.Net Core application with Angular, its goal is to keep a tr Please tackle the following problems - use whatever approaches you'd normally use in your day to day work: 1. Display the counter on the home page -* Number on the Counter page should always match the one on the Home page -* The count should persist when switching between the tabs + +- Number on the Counter page should always match the one on the Home page +- The count should persist when switching between the tabs + 2. Add a capability to add a sanctioned entity -* Add a form to provide details of a new sanctioned entity -* The status should use a Bootstrap switch -* No property of the sanctioned entity can be empty -* The code needs to ensure that there cannot be more than one entity with the same name and domicile + +- Add a form to provide details of a new sanctioned entity +- The status should use a Bootstrap switch +- No property of the sanctioned entity can be empty +- The code needs to ensure that there cannot be more than one entity with the same name and domicile ## Submitting your work -Please create a pull request for the code you have developed and submit no later than 2 hours before your scheduled interview time. If you have any issues submitting your PR, please contact your recruitment partner. \ No newline at end of file + +Please create a pull request for the code you have developed and submit no later than 2 hours before your scheduled interview time. If you have any issues submitting your PR, please contact your recruitment partner. + +## Next Steps / Improvements + +- Increase test coverage for frontend components and backend services/controllers. +- Implement internationalization (i18n): remove hard-coded strings in frontend and backend. +- Improve error handling: show user-friendly messages. +- Create a feature module for sanctioned entities and lazy-load it to improve performance. +- Add structured logging in the backend for requests, errors, and key events. +- Apply Smart / Dumb component pattern to sanctioned entity components (like Home and Counter). +- Add edit Sanctioned Entity functionality with validation and UI updates. +- Upgrade Angular to the latest version +- Consider standalone components in a future Angular upgrade (Angular 14 does not fully support them). diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index 330647d..3f77abd 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -1,9 +1,12 @@ using ajgre_technical_interview.Models; +using ajgre_technical_interview.Validators; namespace ajgre_technical_interview.Services { public class DatabaseService : IDatabaseService { + private readonly ISanctionedEntityValidator _validator; + private static readonly IList SanctionedEntities = new List { new SanctionedEntity { Name = "Forbidden Company", Domicile = "Mars", Accepted = false }, @@ -12,6 +15,11 @@ public class DatabaseService : IDatabaseService new SanctionedEntity { Name = "Evil Plc", Domicile = "Venus", Accepted = false } }; + public DatabaseService(ISanctionedEntityValidator validator) + { + _validator = validator; + } + public async Task> GetSanctionedEntitiesAsync() { var entities = SanctionedEntities @@ -29,6 +37,7 @@ public async Task GetSanctionedEntityByIdAsync(Guid id) public async Task CreateSanctionedEntityAsync(SanctionedEntity sanctionedEntity) { + await _validator.ValidateAsync(sanctionedEntity, SanctionedEntities); SanctionedEntities.Add(sanctionedEntity); return await Task.FromResult(sanctionedEntity); } diff --git a/Validators/ISanctionedEntityValidator.cs b/Validators/ISanctionedEntityValidator.cs new file mode 100644 index 0000000..a2e2468 --- /dev/null +++ b/Validators/ISanctionedEntityValidator.cs @@ -0,0 +1,9 @@ +using ajgre_technical_interview.Models; + +namespace ajgre_technical_interview.Validators +{ + public interface ISanctionedEntityValidator + { + Task ValidateAsync(SanctionedEntity entity, IEnumerable existingEntities); + } +} diff --git a/Validators/SanctionedEntityValidator .cs b/Validators/SanctionedEntityValidator .cs new file mode 100644 index 0000000..38512af --- /dev/null +++ b/Validators/SanctionedEntityValidator .cs @@ -0,0 +1,28 @@ +using ajgre_technical_interview.Models; + +namespace ajgre_technical_interview.Validators +{ + public class SanctionedEntityValidator : ISanctionedEntityValidator + { + public Task ValidateAsync(SanctionedEntity entity, IEnumerable existingEntities) + { + if (entity == null) + throw new ArgumentNullException(nameof(entity)); + + if (string.IsNullOrWhiteSpace(entity.Name)) + throw new ArgumentException("Name is required."); + + if (string.IsNullOrWhiteSpace(entity.Domicile)) + throw new ArgumentException("Domicile is required."); + + if (existingEntities.Any(e => + e.Name.Equals(entity.Name, StringComparison.OrdinalIgnoreCase) && + e.Domicile.Equals(entity.Domicile, StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException("An entity with the same Name and Domicile already exists."); + } + + return Task.CompletedTask; + } + } +}