From ecadf951f8780869bd2244e14f1e9e4078841af4 Mon Sep 17 00:00:00 2001 From: Daniel Truong Date: Wed, 14 Oct 2026 02:40:35 +0000 Subject: [PATCH 1/3] feat(orgs): read organizations from DEMI - The organization list, the link-organization picker and the edit form read DEMI /organizations instead of the eagle-api search endpoint. - DEMI sorts organizations on name only, so the Organization Type and Company Legal columns are no longer sortable. - Writes still go to eagle-api. A native row passed to save or add is converted to Eagle field names first. - getByCompanyType stays for its callers outside the organization screens. --- src/app/models/demi/organization.ts | 27 ++++ .../add-edit-organization.component.ts | 48 ++---- .../organizations-table-rows.component.ts | 2 +- .../organizations.component.spec.ts | 62 ++++++++ .../organizations/organizations.component.ts | 51 +++--- src/app/services/org.service.spec.ts | 145 ++++++++++++++++++ src/app/services/org.service.ts | 61 +++++++- .../link-organization.component.spec.ts | 49 ++++++ .../link-organization.component.ts | 43 +++--- 9 files changed, 400 insertions(+), 88 deletions(-) create mode 100644 src/app/models/demi/organization.ts create mode 100644 src/app/organizations/organizations.component.spec.ts create mode 100644 src/app/services/org.service.spec.ts create mode 100644 src/app/shared/components/link-organization/link-organization.component.spec.ts diff --git a/src/app/models/demi/organization.ts b/src/app/models/demi/organization.ts new file mode 100644 index 000000000..a8b1b3e67 --- /dev/null +++ b/src/app/models/demi/organization.ts @@ -0,0 +1,27 @@ +/** + * A native DEMI `Organization` row (`GET /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; +} diff --git a/src/app/organizations/add-edit-organization/add-edit-organization.component.ts b/src/app/organizations/add-edit-organization/add-edit-organization.component.ts index 5c11f7843..6736fafe2 100644 --- a/src/app/organizations/add-edit-organization/add-edit-organization.component.ts +++ b/src/app/organizations/add-edit-organization/add-edit-organization.component.ts @@ -6,10 +6,9 @@ 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 { catchError, map, mergeMap, finalize } from 'rxjs/operators'; import { Org } from 'src/app/models/org'; 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 { NavigationStackUtils } from 'src/app/shared/utils/navigation-stack-utils'; @@ -26,7 +25,6 @@ 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); @@ -106,44 +104,24 @@ 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) { + this.parentOrganizationName = parent?.name ?? ''; + this.parentOrgId = org.parentCompany; } + this.buildForm(org); } }); } diff --git a/src/app/organizations/organizations-table-rows/organizations-table-rows.component.ts b/src/app/organizations/organizations-table-rows/organizations-table-rows.component.ts index d4b7be171..4f1e61ca7 100644 --- a/src/app/organizations/organizations-table-rows/organizations-table-rows.component.ts +++ b/src/app/organizations/organizations-table-rows/organizations-table-rows.component.ts @@ -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']); } } diff --git a/src/app/organizations/organizations.component.spec.ts b/src/app/organizations/organizations.component.spec.ts new file mode 100644 index 000000000..0982e5822 --- /dev/null +++ b/src/app/organizations/organizations.component.spec.ts @@ -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; + 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); + }); +}); diff --git a/src/app/organizations/organizations.component.ts b/src/app/organizations/organizations.component.ts index f8ee7c43f..e5bb9b6b8 100644 --- a/src/app/organizations/organizations.component.ts +++ b/src/app/organizations/organizations.component.ts @@ -3,14 +3,14 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Router, ActivatedRoute } from '@angular/router'; import { switchMap } from 'rxjs/operators'; import { OrganizationsTableRowsComponent } from './organizations-table-rows/organizations-table-rows.component'; -import { Org } from '../models/org'; +import { Organization } from '../models/demi/organization'; import { SearchTerms } from '../models/search'; import { StorageService } from '../services/storage.service'; import { TableObject, TableColumn } from '../shared/components/table-template/table-object'; import { TableParamsObject } from '../shared/components/table-template/table-params-object'; import { NavigationStackUtils } from '../shared/utils/navigation-stack-utils'; import { TableTemplateUtils, nextSortBy } from '../shared/utils/table-template-utils'; -import { SearchService } from '../services/search.service'; +import { OrgService, ORG_LIST_LOADING, orgSort } from '../services/org.service'; import { LoadingStateService } from '../services/loading-state.service'; import { FormsModule } from '@angular/forms'; @@ -36,12 +36,12 @@ export class OrganizationsComponent implements OnInit { private navigationStackUtils = inject(NavigationStackUtils); private tableTemplateUtils = inject(TableTemplateUtils); private storageService = inject(StorageService); - private searchService = inject(SearchService); + private orgService = inject(OrgService); public loadingState = inject(LoadingStateService); - public loading = this.loadingState.getOperationState('search-results'); + public loading = this.loadingState.getOperationState(ORG_LIST_LOADING); public terms = new SearchTerms(); - public organizations: Org[] = null; + public organizations: Organization[] = null; public tableData: TableObject; public tableColumns: TableColumn[] = [ @@ -53,12 +53,14 @@ export class OrganizationsComponent implements OnInit { { name: 'Organization Type', value: 'companyType', - width: '20%' + width: '20%', + nosort: true }, { name: 'Company Legal', value: 'companyLegal', - width: '35%' + width: '35%', + nosort: true }, { name: 'Action', @@ -107,29 +109,27 @@ export class OrganizationsComponent implements OnInit { this.setFilterButtons(); } - const filterObj = this.tableParams.filter?.companyType + const companyType = this.tableParams.filter?.companyType ? this.getFiltersForApi(this.tableParams.filter.companyType) - : {}; - - return this.searchService.getSearchResults( - this.tableParams.keywords || '', 'Organization', null, - this.tableParams.currentPage, this.tableParams.pageSize, this.tableParams.sortBy, - {}, false, filterObj, '' - ); + : []; + + return this.orgService.listOrganizations({ + page: this.tableParams.currentPage, + pageSize: this.tableParams.pageSize, + count: true, + sort: orgSort(this.tableParams.sortBy), + q: this.tableParams.keywords || undefined, + filters: { companyType: companyType.length ? companyType : undefined } + }); }) - ).subscribe((res: any) => { - if (res && res[0].data.meta && res[0].data.meta.length > 0) { - this.tableParams.totalListItems = res[0].data.meta[0].searchResultsTotal; - this.organizations = res[0].data.searchResults; - } else { - this.tableParams.totalListItems = 0; - this.organizations = []; - } + ).subscribe(res => { + this.tableParams.totalListItems = res.total ?? 0; + this.organizations = res.rows; this.setRowData(); }); } - private getFiltersForApi(typeFilterString: string): object { + private getFiltersForApi(typeFilterString: string): string[] { const typeFiltersFromRoute = typeFilterString.split(','); const typeFiltersForApi = []; if (typeFiltersFromRoute.includes('indigenousGroup')) { typeFiltersForApi.push('Indigenous Group'); } @@ -142,7 +142,7 @@ export class OrganizationsComponent implements OnInit { if (typeFiltersFromRoute.includes('otherGovernment')) { typeFiltersForApi.push('Other Government'); } if (typeFiltersFromRoute.includes('communityGroup')) { typeFiltersForApi.push('Community Group'); } if (typeFiltersFromRoute.includes('other')) { typeFiltersForApi.push('Other'); } - return { companyType: typeFiltersForApi.toString() }; + return typeFiltersForApi; } public onSubmit(currentPage = 1) { @@ -156,7 +156,6 @@ export class OrganizationsComponent implements OnInit { // Reset page. const params = this.terms.getParams(); params['ms'] = new Date().getMilliseconds(); - params['dataset'] = this.terms.dataset; params['currentPage'] = this.tableParams.currentPage = currentPage; params['pageSize'] = this.tableParams.pageSize; params['keywords'] = this.tableParams.keywords; diff --git a/src/app/services/org.service.spec.ts b/src/app/services/org.service.spec.ts new file mode 100644 index 000000000..2b16f97cb --- /dev/null +++ b/src/app/services/org.service.spec.ts @@ -0,0 +1,145 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { firstValueFrom } from 'rxjs'; + +import { OrgService, orgSort } from './org.service'; +import { ConfigService } from './config.service'; +import { KeycloakService } from './keycloak.service'; +import { LoggingService } from './logging.service'; +import { ToastService } from './toast.service'; +import { Org } from '../models/org'; +import { Organization } from '../models/demi/organization'; + +const NATIVE_ORG: Organization = { + id: '58850f68aaecd9001b808530', + eagleId: '58850f68aaecd9001b808530', + name: '445026 BC Limited', + companyType: 'Proponent/Certificate Holder', + province: 'British Columbia', + country: 'Canada', + address1: '1 Main St', + city: 'Victoria', + postal: 'V8V 1A1', + website: '', + isPublished: true, + companyLegal: '445026 BC Limited Ltd.', + parentCompany: '58850f68aaecd9001b8085aa' +}; + +describe('OrgService', () => { + let service: OrgService; + let http: HttpTestingController; + let toast: jasmine.SpyObj; + + beforeEach(() => { + toast = jasmine.createSpyObj('ToastService', ['error', 'success']); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + { provide: ConfigService, useValue: { getApiPath: () => '/api', getDemiApiPath: () => '/demi/api' } }, + { provide: KeycloakService, useValue: { keycloakEnabled: true } }, + { provide: LoggingService, useValue: jasmine.createSpyObj('LoggingService', ['error', 'warn', 'info', 'debug']) }, + { provide: ToastService, useValue: toast } + ] + }); + service = TestBed.inject(OrgService); + http = TestBed.inject(HttpTestingController); + }); + + afterEach(() => http.verify()); + + describe('listOrganizations', () => { + it('asks DEMI for one company type sorted by name and returns rows with the total', async () => { + const page = firstValueFrom(service.listOrganizations({ + page: 2, pageSize: 25, count: true, sort: 'name', + filters: { companyType: ['Indigenous Group'] } + })); + + const req = http.expectOne(r => r.url === '/demi/api/organizations'); + expect(req.request.params.getAll('companyType')).toEqual(['Indigenous Group']); + expect(req.request.params.get('sort')).toBe('name'); + expect(req.request.params.get('page')).toBe('2'); + req.flush([NATIVE_ORG], { headers: { 'x-total-count': '296' } }); + + const res = await page; + expect(res.total).toBe(296); + expect(res.rows[0].id).toBe(NATIVE_ORG.id); + }); + + it('shows an error and yields an empty page when DEMI refuses the query', async () => { + const page = firstValueFrom(service.listOrganizations({ filters: { companyType: 'x' } })); + + http.expectOne(r => r.url === '/demi/api/organizations') + .flush({ error: 'parameter given more than once: companyType' }, { status: 400, statusText: 'Bad Request' }); + + expect(await page).toEqual({ rows: [], total: 0 }); + expect(toast.error).toHaveBeenCalled(); + }); + }); + + describe('getOrganization', () => { + it('reads one row from DEMI by id', async () => { + const row = firstValueFrom(service.getOrganization(NATIVE_ORG.id)); + + http.expectOne('/demi/api/organizations/58850f68aaecd9001b808530').flush(NATIVE_ORG); + + expect((await row).name).toBe(NATIVE_ORG.name); + }); + + it('passes a missing row on as an error', async () => { + const row = firstValueFrom(service.getOrganization('gone')); + + http.expectOne('/demi/api/organizations/gone') + .flush({ error: 'Organization not found' }, { status: 404, statusText: 'Not Found' }); + + await expectAsync(row).toBeRejectedWithError('Organization not found'); + }); + }); + + describe('orgSort', () => { + it('keeps a descending name sort', () => { + expect(orgSort('-name')).toBe('-name'); + }); + + it('falls back to name for a column DEMI cannot sort on', () => { + expect(orgSort('+companyType')).toBe('name'); + }); + }); + + describe('writes', () => { + it('sends an Eagle-shaped body unchanged on save', () => { + const legacy = new Org({ _id: 'abc123', name: 'Acme', companyType: 'Ministry' }); + + service.save(legacy).subscribe(); + + const req = http.expectOne('/api/organization/abc123'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toBe(legacy); + req.flush({}); + }); + + it('converts a native row to Eagle field names on save', () => { + service.save(NATIVE_ORG).subscribe(); + + const req = http.expectOne('/api/organization/58850f68aaecd9001b808530'); + expect(req.request.body._id).toBe(NATIVE_ORG.id); + expect(req.request.body.companyLegal).toBe(NATIVE_ORG.companyLegal); + expect(req.request.body.parentCompany).toBe(NATIVE_ORG.parentCompany); + expect('eagleId' in req.request.body).toBeFalse(); + req.flush({}); + }); + + it('posts a new Eagle-shaped org unchanged on add', () => { + const created = new Org({ name: 'New Org' }); + + service.add(created).subscribe(); + + const req = http.expectOne('/api/organization/'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toBe(created); + req.flush({}); + }); + }); +}); diff --git a/src/app/services/org.service.ts b/src/app/services/org.service.ts index 41b80f0fa..0f3314cf7 100644 --- a/src/app/services/org.service.ts +++ b/src/app/services/org.service.ts @@ -1,21 +1,45 @@ import { Injectable, inject } from '@angular/core'; import { ApiService } from './api'; -import { Observable } from 'rxjs'; +import { DemiApiService } from './demi-api.service'; +import { Observable, of, throwError } from 'rxjs'; import { Org } from '../models/org'; +import { Organization } from '../models/demi/organization'; +import { DemiList, DemiListParams } from '../models/demi/demi-list'; import { map, catchError } from 'rxjs/operators'; import { LoadingStateService } from './loading-state.service'; import { ToastService } from './toast.service'; import { LoggingService } from './logging.service'; import { withLoading } from 'src/app/shared/utils/rxjs-operators'; +/** Loading-state key for {@link OrgService.listOrganizations}. */ +export const ORG_LIST_LOADING = 'orgs-list'; + +/** DEMI sorts organizations on `name` only, so any other table sort falls back to name ascending. */ +export function orgSort(sortBy: string | null | undefined): string { + return sortBy === '-name' ? '-name' : 'name'; +} + +/** + * Eagle write body for an organization. A body with `_id` is already Eagle-shaped and goes unchanged; + * a native DEMI row (`id`, no `_id`) is converted. Temporary until writes move off eagle-api. + */ +export function toEagleOrg(body: Org | Organization): Org { + if ('_id' in body || !('id' in body)) { + return body as Org; + } + return new Org({ ...body, _id: body.id }); +} + @Injectable({ providedIn: 'root' }) export class OrgService { private api = inject(ApiService); + private demiApi = inject(DemiApiService); private loadingState = inject(LoadingStateService); private toast = inject(ToastService); private logger = inject(LoggingService); - save(org: Org): Observable { + save(input: Org | Organization): Observable { + const org = toEagleOrg(input); return this.api.put(`organization/${org._id}`, org).pipe( catchError(error => { this.logger.error('Failed to save org', 'OrgService', error); @@ -25,8 +49,8 @@ export class OrgService { ); } - add(org: Org): Observable { - return this.api.post('organization/', org).pipe( + add(org: Org | Organization): Observable { + return this.api.post('organization/', toEagleOrg(org)).pipe( catchError(error => { this.logger.error('Failed to add org', 'OrgService', error); this.toast.error('Failed to add organization.'); @@ -51,4 +75,33 @@ export class OrgService { }) ); } + + /** + * One page of organizations from DEMI. Filters: `companyType`; sort: `name` only. + * A failed read shows an error and yields an empty page, so table route subscriptions stay alive. + */ + listOrganizations(params: DemiListParams): Observable> { + return this.demiApi.list('organizations', params).pipe( + withLoading(this.loadingState, ORG_LIST_LOADING), + catchError(error => { + this.reportReadFailure(error); + return of({ rows: [], total: 0 }); + }) + ); + } + + getOrganization(id: string): Observable { + return this.demiApi.get('organizations', id).pipe( + withLoading(this.loadingState, `org-${id}`), + catchError(error => { + this.reportReadFailure(error); + return throwError(() => error); + }) + ); + } + + private reportReadFailure(error: Error): void { + this.logger.error('Failed to load organizations', 'OrgService', error); + this.toast.error('Failed to load organizations.'); + } } diff --git a/src/app/shared/components/link-organization/link-organization.component.spec.ts b/src/app/shared/components/link-organization/link-organization.component.spec.ts new file mode 100644 index 000000000..16931fad8 --- /dev/null +++ b/src/app/shared/components/link-organization/link-organization.component.spec.ts @@ -0,0 +1,49 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, provideRouter } from '@angular/router'; +import { of } from 'rxjs'; + +import { LinkOrganizationComponent } from './link-organization.component'; +import { OrgService } from 'src/app/services/org.service'; +import { StorageService } from 'src/app/services/storage.service'; +import { NavigationStackUtils } from '../../utils/navigation-stack-utils'; + +const ROW = { id: 'org1', name: 'Tsawout First Nation', companyType: 'Indigenous Group' }; + +describe('LinkOrganizationComponent', () => { + let listOrganizations: jasmine.Spy; + + function create(routeData: object): LinkOrganizationComponent { + // 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: 1 })); + const navigationObject = { backUrl: ['p', 'x'], breadcrumbs: [{ label: 'Project', route: ['p', 'x'] }] }; + TestBed.configureTestingModule({ + imports: [LinkOrganizationComponent], + providers: [ + provideRouter([]), + { provide: ActivatedRoute, useValue: { params: of({}), snapshot: { data: routeData } } }, + { provide: OrgService, useValue: { listOrganizations } }, + { provide: StorageService, useValue: { state: {} } }, + { + provide: NavigationStackUtils, + useValue: { getNavigationStack: () => [navigationObject], getLastNavigationObject: () => navigationObject } + } + ] + }); + const fixture = TestBed.createComponent(LinkOrganizationComponent); + fixture.detectChanges(); + return fixture.componentInstance; + } + + it('filters on the route companyTypeFilter (pins pick Indigenous Groups)', () => { + create({ companyTypeFilter: 'Indigenous Group' }); + + expect(listOrganizations.calls.mostRecent().args[0].filters.companyType).toBe('Indigenous Group'); + }); + + it('hands pickers the Eagle _id, which is the native id', () => { + const component = create({}); + + expect(component.tableData.data[0]._id).toBe('org1'); + }); +}); diff --git a/src/app/shared/components/link-organization/link-organization.component.ts b/src/app/shared/components/link-organization/link-organization.component.ts index 60ba4773a..dd8590102 100644 --- a/src/app/shared/components/link-organization/link-organization.component.ts +++ b/src/app/shared/components/link-organization/link-organization.component.ts @@ -7,7 +7,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { RouterModule } from '@angular/router'; import { LinkOrganizationTableRowsComponent } from './link-organization-table-rows/link-organization-table-rows.component'; -import { Org } from 'src/app/models/org'; +import { Organization } from 'src/app/models/demi/organization'; import { SearchTerms } from 'src/app/models/search'; import { StorageService } from 'src/app/services/storage.service'; import { NavigationStackUtils } from '../../utils/navigation-stack-utils'; @@ -17,7 +17,7 @@ import { TableParamsObject } from '../table-template/table-params-object'; import { TableTemplateComponent } from '../table-template/table-template.component'; import { LoggingService } from 'src/app/services/logging.service'; import { LoadingStateService } from 'src/app/services/loading-state.service'; -import { SearchService } from 'src/app/services/search.service'; +import { OrgService, ORG_LIST_LOADING, orgSort } from 'src/app/services/org.service'; @Component({ @@ -40,12 +40,12 @@ export class LinkOrganizationComponent implements OnInit, OnDestroy { protected navigationStackUtils = inject(NavigationStackUtils); private tableTemplateUtils = inject(TableTemplateUtils); private logger = inject(LoggingService); - private searchService = inject(SearchService); + private orgService = inject(OrgService); private loadingState = inject(LoadingStateService); public terms = new SearchTerms(); - public organizations: Org[] = null; - public loading = this.loadingState.getOperationState('search-results'); + public organizations: Organization[] = null; + public loading = this.loadingState.getOperationState(ORG_LIST_LOADING); public isEditing = false; @@ -65,7 +65,8 @@ export class LinkOrganizationComponent implements OnInit, OnDestroy { { name: 'Company Type', value: 'companyType', - width: '45%' + width: '45%', + nosort: true } ]; @@ -88,8 +89,7 @@ export class LinkOrganizationComponent implements OnInit, OnDestroy { } // Detect pins context from static route data; pins routes filter by Indigenous Group - const companyTypeFilter = this.route.snapshot.data.companyTypeFilter; - const filterForApi = companyTypeFilter ? { companyType: companyTypeFilter } : {}; + const companyType: string | undefined = this.route.snapshot.data.companyTypeFilter; this.route.params.pipe( takeUntilDestroyed(this.destroyRef), @@ -103,20 +103,18 @@ export class LinkOrganizationComponent implements OnInit, OnDestroy { this.tableParams.sortBy = '+name'; this.tableTemplateUtils.updateUrl(this.tableParams.sortBy, this.tableParams.currentPage, this.tableParams.pageSize, null, this.tableParams.keywords); } - return this.searchService.getSearchResults( - this.tableParams.keywords || '', 'Organization', null, - this.tableParams.currentPage, this.tableParams.pageSize, this.tableParams.sortBy, - {}, false, filterForApi, '' - ); + return this.orgService.listOrganizations({ + page: this.tableParams.currentPage, + pageSize: this.tableParams.pageSize, + count: true, + sort: orgSort(this.tableParams.sortBy), + q: this.tableParams.keywords || undefined, + filters: { companyType } + }); }) - ).subscribe((res: any) => { - if (res && res[0].data.meta && res[0].data.meta.length > 0) { - this.tableParams.totalListItems = res[0].data.meta[0].searchResultsTotal; - this.organizations = res[0].data.searchResults; - } else { - this.tableParams.totalListItems = 0; - this.organizations = []; - } + ).subscribe(res => { + this.tableParams.totalListItems = res.total ?? 0; + this.organizations = res.rows; this.setRowData(); this.selectedCount = 0; }); @@ -188,7 +186,8 @@ export class LinkOrganizationComponent implements OnInit, OnDestroy { { name: organization.name, companyType: organization.companyType, - _id: organization._id, + // Pickers downstream (contacts, project pins) read the Eagle `_id`. + _id: organization.id, isEditing: this.isEditing, contactId: this.contactId } From 60ed1e3229047707e9f1effbb287d3fc8106af3b Mon Sep 17 00:00:00 2001 From: Daniel Truong Date: Wed, 14 Oct 2026 02:51:01 +0000 Subject: [PATCH 2/3] fix(orgs): keep edit form quiet on a missing parent A parent organization DEMI cannot return no longer shows an error toast on a form that loads fine. The form keeps the parent link and shows its id as the name. Only a failed read of the organization being edited shows an error. --- .../add-edit-organization.component.spec.ts | 58 +++++++++++++++++++ .../add-edit-organization.component.ts | 9 ++- src/app/services/org.service.spec.ts | 3 +- src/app/services/org.service.ts | 17 ++---- 4 files changed, 72 insertions(+), 15 deletions(-) create mode 100644 src/app/organizations/add-edit-organization/add-edit-organization.component.spec.ts diff --git a/src/app/organizations/add-edit-organization/add-edit-organization.component.spec.ts b/src/app/organizations/add-edit-organization/add-edit-organization.component.spec.ts new file mode 100644 index 000000000..e0fe61e01 --- /dev/null +++ b/src/app/organizations/add-edit-organization/add-edit-organization.component.spec.ts @@ -0,0 +1,58 @@ +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; + + // Runs ngOnInit without rendering, so the TinyMCE editor never loads. + function load(reads: Record>): AddEditOrganizationComponent { + toast = jasmine.createSpyObj('ToastService', ['error', 'success']); + TestBed.configureTestingModule({ + imports: [AddEditOrganizationComponent], + providers: [ + provideRouter([]), + { provide: ActivatedRoute, useValue: { snapshot: { paramMap: convertToParamMap({ orgId: 'child1' }) } } }, + { provide: OrgService, useValue: { getOrganization: (id: string) => reads[id] } }, + { 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(); + }); +}); diff --git a/src/app/organizations/add-edit-organization/add-edit-organization.component.ts b/src/app/organizations/add-edit-organization/add-edit-organization.component.ts index 6736fafe2..c14c3a87f 100644 --- a/src/app/organizations/add-edit-organization/add-edit-organization.component.ts +++ b/src/app/organizations/add-edit-organization/add-edit-organization.component.ts @@ -10,6 +10,7 @@ import { catchError, map, mergeMap, finalize } from 'rxjs/operators'; import { Org } from 'src/app/models/org'; 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'; @Component({ @@ -27,6 +28,7 @@ export class AddEditOrganizationComponent implements OnInit { private storageService = inject(StorageService); private destroyRef = inject(DestroyRef); private cdr = inject(ChangeDetectorRef); + private toast = inject(ToastService); public isEditing = false; public loading = false; @@ -118,11 +120,14 @@ export class AddEditOrganizationComponent implements OnInit { ).subscribe({ next: ({ org, parent }) => { if (!this.storageService.state.selectedOrganization && org.parentCompany) { - this.parentOrganizationName = parent?.name ?? ''; + // 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 { diff --git a/src/app/services/org.service.spec.ts b/src/app/services/org.service.spec.ts index 2b16f97cb..1885b4774 100644 --- a/src/app/services/org.service.spec.ts +++ b/src/app/services/org.service.spec.ts @@ -88,13 +88,14 @@ describe('OrgService', () => { expect((await row).name).toBe(NATIVE_ORG.name); }); - it('passes a missing row on as an error', async () => { + it('passes a missing row on as an error and leaves the toast to the caller', async () => { const row = firstValueFrom(service.getOrganization('gone')); http.expectOne('/demi/api/organizations/gone') .flush({ error: 'Organization not found' }, { status: 404, statusText: 'Not Found' }); await expectAsync(row).toBeRejectedWithError('Organization not found'); + expect(toast.error).not.toHaveBeenCalled(); }); }); diff --git a/src/app/services/org.service.ts b/src/app/services/org.service.ts index 0f3314cf7..79573d433 100644 --- a/src/app/services/org.service.ts +++ b/src/app/services/org.service.ts @@ -1,7 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { ApiService } from './api'; import { DemiApiService } from './demi-api.service'; -import { Observable, of, throwError } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { Org } from '../models/org'; import { Organization } from '../models/demi/organization'; import { DemiList, DemiListParams } from '../models/demi/demi-list'; @@ -84,24 +84,17 @@ export class OrgService { return this.demiApi.list('organizations', params).pipe( withLoading(this.loadingState, ORG_LIST_LOADING), catchError(error => { - this.reportReadFailure(error); + this.logger.error('Failed to load organizations', 'OrgService', error); + this.toast.error('Failed to load organizations.'); return of({ rows: [], total: 0 }); }) ); } + /** One organization from DEMI. Errors pass through untoasted: some callers can carry on without the row. */ getOrganization(id: string): Observable { return this.demiApi.get('organizations', id).pipe( - withLoading(this.loadingState, `org-${id}`), - catchError(error => { - this.reportReadFailure(error); - return throwError(() => error); - }) + withLoading(this.loadingState, `org-${id}`) ); } - - private reportReadFailure(error: Error): void { - this.logger.error('Failed to load organizations', 'OrgService', error); - this.toast.error('Failed to load organizations.'); - } } From ee778e3c6408b654d0aba6cc4273648cb0005510 Mon Sep 17 00:00:00 2001 From: Daniel Truong Date: Wed, 14 Oct 2026 04:01:28 +0000 Subject: [PATCH 3/3] refactor(orgs): build org write body through mapper The add/edit form sends native-shaped input and OrgService converts it to the eagle-api body, so the native-to-Eagle mapper is the one path for organization writes. A new row with no id maps to an Eagle body with a null _id, as before. --- .../add-edit-organization.component.spec.ts | 16 ++++++++++++- .../add-edit-organization.component.ts | 24 ++++++------------- src/app/services/org.service.spec.ts | 9 +++++++ src/app/services/org.service.ts | 12 +++++----- 4 files changed, 37 insertions(+), 24 deletions(-) diff --git a/src/app/organizations/add-edit-organization/add-edit-organization.component.spec.ts b/src/app/organizations/add-edit-organization/add-edit-organization.component.spec.ts index e0fe61e01..9cbbf588c 100644 --- a/src/app/organizations/add-edit-organization/add-edit-organization.component.spec.ts +++ b/src/app/organizations/add-edit-organization/add-edit-organization.component.spec.ts @@ -14,16 +14,18 @@ const PARENT = { id: 'parent1', name: 'Parent Co' } as Organization; describe('AddEditOrganizationComponent edit load', () => { let toast: jasmine.SpyObj; + let save: jasmine.Spy; // Runs ngOnInit without rendering, so the TinyMCE editor never loads. function load(reads: Record>): 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] } }, + { provide: OrgService, useValue: { getOrganization: (id: string) => reads[id], save } }, { provide: StorageService, useValue: { state: {} } }, { provide: ToastService, useValue: toast }, { provide: NavigationStackUtils, useValue: { getNavigationStack: () => null } } @@ -55,4 +57,16 @@ describe('AddEditOrganizationComponent edit load', () => { 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(); + }); }); diff --git a/src/app/organizations/add-edit-organization/add-edit-organization.component.ts b/src/app/organizations/add-edit-organization/add-edit-organization.component.ts index c14c3a87f..897b7f3a3 100644 --- a/src/app/organizations/add-edit-organization/add-edit-organization.component.ts +++ b/src/app/organizations/add-edit-organization/add-edit-organization.component.ts @@ -7,7 +7,7 @@ import { ReactiveFormsModule } from '@angular/forms'; import { EditorModule } from '@tinymce/tinymce-angular'; import { of } from 'rxjs'; import { catchError, map, mergeMap, finalize } from 'rxjs/operators'; -import { Org } from 'src/app/models/org'; +import { Organization } from 'src/app/models/demi/organization'; import { OrgService } from 'src/app/services/org.service'; import { StorageService } from 'src/app/services/storage.service'; import { ToastService } from 'src/app/services/toast.service'; @@ -233,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 = { + ...this.orgForm.getRawValue(), + parentCompany: this.orgForm.controls.parentCompany.value || null + }; this.clearStorageService(); if (!this.isEditing) { @@ -259,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']); }); diff --git a/src/app/services/org.service.spec.ts b/src/app/services/org.service.spec.ts index 1885b4774..fac665382 100644 --- a/src/app/services/org.service.spec.ts +++ b/src/app/services/org.service.spec.ts @@ -132,6 +132,15 @@ describe('OrgService', () => { req.flush({}); }); + it('converts new native input to an Eagle body with no _id on add', () => { + service.add({ name: 'New Org', companyType: 'Ministry' }).subscribe(); + + const req = http.expectOne('/api/organization/'); + expect(req.request.body._id).toBeNull(); + expect(req.request.body.companyType).toBe('Ministry'); + req.flush({}); + }); + it('posts a new Eagle-shaped org unchanged on add', () => { const created = new Org({ name: 'New Org' }); diff --git a/src/app/services/org.service.ts b/src/app/services/org.service.ts index 79573d433..a8d82ee90 100644 --- a/src/app/services/org.service.ts +++ b/src/app/services/org.service.ts @@ -21,13 +21,13 @@ export function orgSort(sortBy: string | null | undefined): string { /** * Eagle write body for an organization. A body with `_id` is already Eagle-shaped and goes unchanged; - * a native DEMI row (`id`, no `_id`) is converted. Temporary until writes move off eagle-api. + * native input (`id` or, for a new row, no id at all) is converted. Temporary until writes move off eagle-api. */ -export function toEagleOrg(body: Org | Organization): Org { - if ('_id' in body || !('id' in body)) { +export function toEagleOrg(body: Org | Partial): Org { + if ('_id' in body) { return body as Org; } - return new Org({ ...body, _id: body.id }); + return new Org({ ...body, _id: body.id ?? null }); } @Injectable({ providedIn: 'root' }) @@ -38,7 +38,7 @@ export class OrgService { private toast = inject(ToastService); private logger = inject(LoggingService); - save(input: Org | Organization): Observable { + save(input: Org | Partial): Observable { const org = toEagleOrg(input); return this.api.put(`organization/${org._id}`, org).pipe( catchError(error => { @@ -49,7 +49,7 @@ export class OrgService { ); } - add(org: Org | Organization): Observable { + add(org: Org | Partial): Observable { return this.api.post('organization/', toEagleOrg(org)).pipe( catchError(error => { this.logger.error('Failed to add org', 'OrgService', error);