Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/app/models/demi/organization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* A native DEMI `Organization` row (`GET <DEMI_API_PATH>/organizations`). `id` is the Eagle Organization `_id`.
* Optional fields are staff-side: DEMI leaves them out for a request with no staff credential.
*/
export interface Organization {
id: string;
eagleId: string;
name: string;
companyType: string;
province: string;
country: string;
address1: string;
city: string;
postal: string;
website: string;
isPublished: boolean;
description?: string;
address2?: string;
company?: string;
companyLegal?: string;
/** Eagle `_id` of the parent organization. */
parentCompany?: string | null;
addedBy?: string;
updatedBy?: string;
dateAdded?: string | null;
dateUpdated?: string | null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { Observable, of, throwError } from 'rxjs';

import { AddEditOrganizationComponent } from './add-edit-organization.component';
import { OrgService } from 'src/app/services/org.service';
import { StorageService } from 'src/app/services/storage.service';
import { ToastService } from 'src/app/services/toast.service';
import { NavigationStackUtils } from 'src/app/shared/utils/navigation-stack-utils';
import { Organization } from 'src/app/models/demi/organization';

const CHILD = { id: 'child1', name: 'Child Co', companyType: 'Consultant', parentCompany: 'parent1' } as Organization;
const PARENT = { id: 'parent1', name: 'Parent Co' } as Organization;

describe('AddEditOrganizationComponent edit load', () => {
let toast: jasmine.SpyObj<ToastService>;
let save: jasmine.Spy;

// Runs ngOnInit without rendering, so the TinyMCE editor never loads.
function load(reads: Record<string, Observable<Organization>>): AddEditOrganizationComponent {
toast = jasmine.createSpyObj('ToastService', ['error', 'success']);
save = jasmine.createSpy('save').and.returnValue(of({}));
TestBed.configureTestingModule({
imports: [AddEditOrganizationComponent],
providers: [
provideRouter([]),
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: convertToParamMap({ orgId: 'child1' }) } } },
{ provide: OrgService, useValue: { getOrganization: (id: string) => reads[id], save } },
{ provide: StorageService, useValue: { state: {} } },
{ provide: ToastService, useValue: toast },
{ provide: NavigationStackUtils, useValue: { getNavigationStack: () => null } }
]
});
const component = TestBed.createComponent(AddEditOrganizationComponent).componentInstance;
component.ngOnInit();
return component;
}

it('fills the form and the parent name from DEMI', () => {
const component = load({ child1: of(CHILD), parent1: of(PARENT) });

expect(component.orgForm.controls.name.value).toBe('Child Co');
expect(component.parentOrganizationName).toBe('Parent Co');
expect(component.orgForm.controls.parentCompany.value).toBe('parent1');
});

it('keeps the parent link and stays quiet when DEMI cannot return the parent', () => {
const component = load({ child1: of(CHILD), parent1: throwError(() => new Error('Organization not found')) });

expect(component.orgForm.controls.parentCompany.value).toBe('parent1');
expect(component.parentOrganizationName).toBe('parent1');
expect(toast.error).not.toHaveBeenCalled();
});

it('shows an error when the organization itself cannot be read', () => {
load({ child1: throwError(() => new Error('Organization not found')) });

expect(toast.error).toHaveBeenCalled();
});

it('saves the edited row as native input keyed by the route id', () => {
const component = load({ child1: of(CHILD), parent1: of(PARENT) });
component.orgForm.controls.name.setValue('Renamed Co');

component.onSubmit();

const body = save.calls.mostRecent().args[0];
expect(body.id).toBe('child1');
expect(body.name).toBe('Renamed Co');
expect('_id' in body).toBeFalse();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import { FormGroup, FormControl } from '@angular/forms';
import { ReactiveFormsModule } from '@angular/forms';
import { EditorModule } from '@tinymce/tinymce-angular';
import { of } from 'rxjs';
import { map, mergeMap, finalize } from 'rxjs/operators';
import { Org } from 'src/app/models/org';
import { catchError, map, mergeMap, finalize } from 'rxjs/operators';
import { Organization } from 'src/app/models/demi/organization';
import { OrgService } from 'src/app/services/org.service';
import { SearchService } from 'src/app/services/search.service';
import { StorageService } from 'src/app/services/storage.service';
import { ToastService } from 'src/app/services/toast.service';
import { NavigationStackUtils } from 'src/app/shared/utils/navigation-stack-utils';

@Component({
Expand All @@ -26,9 +26,9 @@ export class AddEditOrganizationComponent implements OnInit {
protected navigationStackUtils = inject(NavigationStackUtils);
private orgService = inject(OrgService);
private storageService = inject(StorageService);
private searchService = inject(SearchService);
private destroyRef = inject(DestroyRef);
private cdr = inject(ChangeDetectorRef);
private toast = inject(ToastService);

public isEditing = false;
public loading = false;
Expand Down Expand Up @@ -106,45 +106,28 @@ export class AddEditOrganizationComponent implements OnInit {
this.loading = false;
} else {
this.loading = true;
this.searchService.getItem(orgId, 'Organization').pipe(
this.orgService.getOrganization(orgId).pipe(
takeUntilDestroyed(this.destroyRef),
map((res: any) => res.data),
mergeMap((org: any) => {
if (!org) { return of(null); }
if (!org.parentCompany || org.parentCompany === '') { return of(org); }
return this.searchService.getItem(org.parentCompany, 'Organization').pipe(
map(parentCompany => {
org.parentCompany = parentCompany;
return org;
})
mergeMap(org => {
if (!org.parentCompany) { return of({ org, parent: null }); }
// A parent DEMI cannot return must not block editing the child.
return this.orgService.getOrganization(org.parentCompany).pipe(
map(parent => ({ org, parent })),
catchError(() => of({ org, parent: null }))
);
}),
finalize(() => { this.loading = false; this.cdr.markForCheck(); })
).subscribe({
next: (org: any) => {
if (org) {
if (!this.storageService.state.selectedOrganization && org.parentCompany && org.parentCompany !== '') {
this.parentOrganizationName = org.parentCompany.data.name;
this.parentOrgId = org.parentCompany.data._id;
}
this.buildForm(org);
} else {
this.buildForm({
description: '',
name: '',
country: '',
postal: '',
province: '',
city: '',
address1: '',
address2: '',
companyType: '',
parentCompany: this.parentOrgId,
companyLegal: '',
company: ''
});
next: ({ org, parent }) => {
if (!this.storageService.state.selectedOrganization && org.parentCompany) {
// Show the bare id when the parent is unreadable, so the kept link stays visible.
this.parentOrganizationName = parent?.name ?? org.parentCompany;
this.parentOrgId = org.parentCompany;
}
}
this.buildForm(org);
},
// ApiService.handleError has already logged it.
error: () => this.toast.error('Failed to load organization.')
});
}
} else {
Expand Down Expand Up @@ -250,20 +233,11 @@ export class AddEditOrganizationComponent implements OnInit {
return;
}

const org = new Org({
description: this.orgForm.controls.description.value,
name: this.orgForm.controls.name.value,
country: this.orgForm.controls.country.value,
postal: this.orgForm.controls.postal.value,
province: this.orgForm.controls.province.value,
city: this.orgForm.controls.city.value,
address1: this.orgForm.controls.address1.value,
address2: this.orgForm.controls.address2.value,
companyType: this.orgForm.controls.companyType.value,
parentCompany: this.orgForm.controls.parentCompany.value || null,
companyLegal: this.orgForm.controls.companyLegal.value,
company: this.orgForm.controls.company.value
});
// Form controls carry the native field names; OrgService maps the body to the eagle-api shape.
const org: Partial<Organization> = {
...this.orgForm.getRawValue(),
parentCompany: this.orgForm.controls.parentCompany.value || null
};

this.clearStorageService();
if (!this.isEditing) {
Expand All @@ -276,8 +250,7 @@ export class AddEditOrganizationComponent implements OnInit {
}
});
} else {
org._id = this.orgId;
this.orgService.save(org)
this.orgService.save({ ...org, id: this.orgId })
.subscribe(() => {
this.router.navigate(['orgs']);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,6 @@ export class OrganizationsTableRowsComponent implements OnInit {
this.navigationStackUtils.clearNavigationStack();

this.storageService.state.orgTableParams = this.data().paginationData;
this.router.navigate(['o', organization._id, 'edit']);
this.router.navigate(['o', organization.id, 'edit']);
}
}
62 changes: 62 additions & 0 deletions src/app/organizations/organizations.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { of } from 'rxjs';

import { OrganizationsComponent } from './organizations.component';
import { OrgService } from '../services/org.service';
import { StorageService } from '../services/storage.service';
import { DemiListParams } from '../models/demi/demi-list';

const ROW = { id: 'org1', name: 'Acme Mining', companyType: 'Proponent/Certificate Holder', companyLegal: 'Acme Mining Ltd.' };

describe('OrganizationsComponent', () => {
let fixture: ComponentFixture<OrganizationsComponent>;
let listOrganizations: jasmine.Spy;

function create(routeParams: object): void {
// Keep the Karma page URL untouched when the table utils rewrite the address bar.
spyOn(window.history, 'replaceState');
listOrganizations = jasmine.createSpy('listOrganizations').and.returnValue(of({ rows: [ROW], total: 41 }));
TestBed.configureTestingModule({
imports: [OrganizationsComponent],
providers: [
provideRouter([]),
{ provide: ActivatedRoute, useValue: { params: of(routeParams) } },
{ provide: OrgService, useValue: { listOrganizations } },
{ provide: StorageService, useValue: { state: {} } }
]
});
fixture = TestBed.createComponent(OrganizationsComponent);
fixture.detectChanges();
}

function sentParams(): DemiListParams {
return listOrganizations.calls.mostRecent().args[0];
}

it('sends the checked organization types as DEMI companyType values', () => {
create({ companyType: 'proponent,ministry' });

expect(sentParams().filters.companyType).toEqual(['Proponent/Certificate Holder', 'Ministry']);
});

it('sends no companyType when no type is checked', () => {
create({});

expect(sentParams().filters.companyType).toBeUndefined();
});

it('sorts on name when the URL carries a column DEMI cannot sort on', () => {
create({ sortBy: '+companyType', keywords: 'acme' });

expect(sentParams().sort).toBe('name');
expect(sentParams().q).toBe('acme');
});

it('shows the native rows and the total from DEMI', () => {
create({});

expect(fixture.nativeElement.querySelector('td[data-label="Name"]').textContent).toContain('Acme Mining');
expect(fixture.componentInstance.tableParams.totalListItems).toBe(41);
});
});
Loading
Loading