From cd18cb7a01c1cb34cacca55e031dd5a0e50a7eba Mon Sep 17 00:00:00 2001 From: Romuald Caplier Date: Mon, 26 Aug 2024 14:36:45 +0200 Subject: [PATCH] feat(editor): Added field contacts for metadata --- .../editor/src/lib/+state/editor.reducer.ts | 2 +- ...field-contacts-for-resource.component.html | 13 +- .../form-field-contacts.component.css | 0 .../form-field-contacts.component.html | 71 ++++++ .../form-field-contacts.component.spec.ts | 224 ++++++++++++++++++ .../form-field-contacts.component.ts | 217 +++++++++++++++++ .../form-field/form-field.component.html | 5 + .../form-field/form-field.component.ts | 14 +- libs/feature/editor/src/lib/fields.config.ts | 9 +- 9 files changed, 545 insertions(+), 10 deletions(-) create mode 100644 libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts/form-field-contacts.component.css create mode 100644 libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts/form-field-contacts.component.html create mode 100644 libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts/form-field-contacts.component.spec.ts create mode 100644 libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts/form-field-contacts.component.ts diff --git a/libs/feature/editor/src/lib/+state/editor.reducer.ts b/libs/feature/editor/src/lib/+state/editor.reducer.ts index d2b4e96a5c..dfb4835ae5 100644 --- a/libs/feature/editor/src/lib/+state/editor.reducer.ts +++ b/libs/feature/editor/src/lib/+state/editor.reducer.ts @@ -38,7 +38,7 @@ export const initialEditorState: EditorState = { saveError: null, changedSinceSave: false, editorConfig: DEFAULT_CONFIGURATION, - currentPage: 0, + currentPage: 2, //todo: remove before merge } const reducer = createReducer( diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts-for-resource/form-field-contacts-for-resource.component.html b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts-for-resource/form-field-contacts-for-resource.component.html index 69d8a2f22e..adf7d78296 100644 --- a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts-for-resource/form-field-contacts-for-resource.component.html +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts-for-resource/form-field-contacts-for-resource.component.html @@ -47,12 +47,6 @@ - - - + + + +
diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts/form-field-contacts.component.css b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts/form-field-contacts.component.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts/form-field-contacts.component.html b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts/form-field-contacts.component.html new file mode 100644 index 0000000000..86db0afdf0 --- /dev/null +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts/form-field-contacts.component.html @@ -0,0 +1,71 @@ +
+
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ editor.record.form.field.contacts.noContact +
+
diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts/form-field-contacts.component.spec.ts b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts/form-field-contacts.component.spec.ts new file mode 100644 index 0000000000..0f21913bb4 --- /dev/null +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts/form-field-contacts.component.spec.ts @@ -0,0 +1,224 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { FormFieldContactsComponent } from './form-field-contacts.component' +import { PlatformServiceInterface } from '@geonetwork-ui/common/domain/platform.service.interface' +import { BehaviorSubject } from 'rxjs' +import { + Individual, + Organization, + Role, +} from '@geonetwork-ui/common/domain/model/record' +import { ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core' +import { UserModel } from '@geonetwork-ui/common/domain/model/user' +import { CommonModule } from '@angular/common' +import { TranslateModule } from '@ngx-translate/core' +import { ContactCardComponent } from '../../../contact-card/contact-card.component' +import { + AutocompleteComponent, + DropdownSelectorComponent, + UiInputsModule, +} from '@geonetwork-ui/ui/inputs' +import { OrganizationsServiceInterface } from '@geonetwork-ui/common/domain/organizations.service.interface' +import { FormControl } from '@angular/forms' + +const organizationBarbie: Organization = { + name: 'Barbie Inc.', +} + +const organizationGoogle: Organization = { + name: 'Google', +} + +class MockPlatformServiceInterface { + getUsers = jest.fn(() => new BehaviorSubject([])) +} + +class MockOrganizationsServiceInterface { + organisations$ = new BehaviorSubject([organizationBarbie, organizationGoogle]) +} + +describe('FormFieldContactsForResourceComponent', () => { + let component: FormFieldContactsComponent + let fixture: ComponentFixture + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + FormFieldContactsComponent, + CommonModule, + TranslateModule.forRoot(), + UiInputsModule, + ContactCardComponent, + DropdownSelectorComponent, + ], + providers: [ + { + provide: PlatformServiceInterface, + useClass: MockPlatformServiceInterface, + }, + { + provide: OrganizationsServiceInterface, + useClass: MockOrganizationsServiceInterface, + }, + ChangeDetectorRef, + ], + }) + .overrideComponent(AutocompleteComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default }, + }) + .compileComponents() + + fixture = TestBed.createComponent(FormFieldContactsComponent) + component = fixture.componentInstance + component.control = new FormControl([]) + fixture.detectChanges() + }) + + it('should create the component', () => { + expect(component).toBeTruthy() + }) + + describe('ngOnInit', () => { + it('should initialize organizations', async () => { + await component.ngOnInit() + + expect(component.allOrganizations.size).toBe(2) + }) + }) + + describe('addRoleToDisplay', () => { + it('should add role to display and filter roles to pick', () => { + const initialRolesToPick = [...component.rolesToPick] + const roleToAdd = initialRolesToPick[0] + + component.addRoleToDisplay(roleToAdd) + + expect(component.roleSectionsToDisplay).toContain(roleToAdd) + expect(component.rolesToPick).not.toContain(roleToAdd) + }) + }) + + describe('filterRolesToPick', () => { + it('should filter roles already in roleSectionsToDisplay', () => { + component.rolesToPick = ['custodian', 'owner'] as Role[] + component.roleSectionsToDisplay = ['custodian'] as Role[] + + component.filterRolesToPick() + + expect(component.rolesToPick).toEqual(['owner']) + }) + }) + + describe('updateContactsForRessource', () => { + it('should update contactsForRessourceByRole and contactsAsDynElemByRole', () => { + const mockContact: Individual = { + role: 'owner', + organization: { name: 'Org1' } as Organization, + } as Individual + + component.allOrganizations.set('Org1', { name: 'Org1' } as Organization) + component.control.setValue([mockContact]) + + component.updateContacts() + + expect(component.contactsForRessourceByRole.get('owner')).toEqual([ + mockContact, + ]) + expect(component.contactsAsDynElemByRole.get('owner').length).toBe(1) + }) + }) + + describe('manageRoleSectionsToDisplay', () => { + it('should add new roles to roleSectionsToDisplay', () => { + const mockContact: Individual = { + role: 'owner', + organization: { name: 'Org1' } as Organization, + } as Individual + + component.manageRoleSectionsToDisplay([mockContact]) + + expect(component.roleSectionsToDisplay).toContain('owner') + }) + }) + + describe('removeContact', () => { + it('should remove contact at specified index', () => { + const mockContacts: Individual[] = [ + { + role: 'owner', + organization: { name: 'Org1' } as Organization, + } as Individual, + { + role: 'custodian', + organization: { name: 'Org2' } as Organization, + } as Individual, + ] + + component.control.setValue(mockContacts) + component.removeContact(0) + + expect(component.control.value.length).toBe(1) + expect(component.control.value[0]).toEqual(mockContacts[1]) + }) + }) + + describe('handleContactsChanged', () => { + it('should update contacts based on reordered dynamic elements', () => { + const mockContacts: Individual[] = [ + { + role: 'owner', + organization: { name: 'Org1' } as Organization, + } as Individual, + { + role: 'owner', + organization: { name: 'Org2' } as Organization, + } as Individual, + ] + + component.contactsForRessourceByRole.set('owner', [mockContacts[0]]) + component.contactsForRessourceByRole.set('owner', [mockContacts[1]]) + + const reorderedElements = [ + { inputs: { contact: mockContacts[1] } } as any, + { inputs: { contact: mockContacts[0] } } as any, + ] + + component.handleContactsChanged(reorderedElements) + + const newControlValue = component.control.value + expect(newControlValue[0]).toEqual(mockContacts[1]) + expect(newControlValue[1]).toEqual(mockContacts[0]) + }) + }) + + describe('addContact', () => { + it('should add a new contact to the control value', () => { + const mockUser: UserModel = { + username: 'user1', + name: 'John', + surname: 'Doe', + organisation: 'Org1', + } as UserModel + + component.allOrganizations.set('Org1', { name: 'Org1' } as Organization) + const initialContacts = component.control.value.length + + component.addContact(mockUser, 'owner') + + expect(component.control.value.length).toBe(initialContacts + 1) + expect(component.control.value[initialContacts].role).toBe('owner') + expect(component.control.value[initialContacts].organization.name).toBe( + 'Org1' + ) + }) + }) + + describe('ngOnDestroy', () => { + it('should unsubscribe from all subscriptions', () => { + const subscriptionSpy = jest.spyOn(component.subscription, 'unsubscribe') + + component.ngOnDestroy() + + expect(subscriptionSpy).toHaveBeenCalled() + }) + }) +}) diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts/form-field-contacts.component.ts b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts/form-field-contacts.component.ts new file mode 100644 index 0000000000..371379b611 --- /dev/null +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts/form-field-contacts.component.ts @@ -0,0 +1,217 @@ +import { CommonModule } from '@angular/common' +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Input, + OnChanges, + OnDestroy, + OnInit, + SimpleChanges, +} from '@angular/core' +import { FormControl } from '@angular/forms' +import { + AutocompleteComponent, + DropdownSelectorComponent, + UiInputsModule, +} from '@geonetwork-ui/ui/inputs' +import { UiWidgetsModule } from '@geonetwork-ui/ui/widgets' +import { + Individual, + Organization, + Role, +} from '@geonetwork-ui/common/domain/model/record' +import { TranslateModule } from '@ngx-translate/core' +import { + debounceTime, + distinctUntilChanged, + firstValueFrom, + Observable, + Subscription, + switchMap, +} from 'rxjs' +import { UserModel } from '@geonetwork-ui/common/domain/model/user' +import { PlatformServiceInterface } from '@geonetwork-ui/common/domain/platform.service.interface' +import { OrganizationsServiceInterface } from '@geonetwork-ui/common/domain/organizations.service.interface' +import { ContactCardComponent } from '../../../contact-card/contact-card.component' +import { + DynamicElement, + SortableListComponent, +} from '@geonetwork-ui/ui/elements' +import { createFuzzyFilter } from '@geonetwork-ui/util/shared' +import { map } from 'rxjs/operators' + +@Component({ + selector: 'gn-ui-form-field-contacts', + templateUrl: './form-field-contacts.component.html', + styleUrls: ['./form-field-contacts.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + DropdownSelectorComponent, + UiInputsModule, + CommonModule, + UiWidgetsModule, + AutocompleteComponent, + TranslateModule, + ContactCardComponent, + SortableListComponent, + ], +}) +export class FormFieldContactsComponent + implements OnInit, OnDestroy, OnChanges +{ + @Input() control: FormControl + + contacts: Individual[] = [] + contactsAsDynElem: DynamicElement[] = [] + + subscription: Subscription = new Subscription() + + allUsers$: Observable + + rolesToPick: Role[] = ['point_of_contact'] + + allOrganizations: Map = new Map() + + constructor( + private platformServiceInterface: PlatformServiceInterface, + private organizationsServiceInterface: OrganizationsServiceInterface, + private changeDetectorRef: ChangeDetectorRef + ) { + this.allUsers$ = this.platformServiceInterface.getUsers() + } + + ngOnChanges(changes: SimpleChanges): void { + console.log(changes['control']) + } + + async ngOnInit(): Promise { + this.allOrganizations = new Map( + ( + await firstValueFrom(this.organizationsServiceInterface.organisations$) + ).map((organization) => [organization.name, organization]) + ) + + this.updateContacts() + + this.changeDetectorRef.markForCheck() + + this.subscription.add( + this.control.valueChanges.subscribe((contacts) => { + console.log('new contacts (valueChange): ', contacts) + this.updateContacts() + this.changeDetectorRef.markForCheck() + }) + ) + } + + updateContacts() { + this.contacts = this.control.value.reduce((acc, contact) => { + const completeOrganization = this.allOrganizations.get( + contact.organization.name + ) + + const updatedContact = { + ...contact, + organization: + completeOrganization ?? + ({ name: contact.organization.name } as Organization), + } + + acc.push(updatedContact) + + return acc + }, [] as Individual[]) + + this.contactsAsDynElem = this.control.value.reduce((acc, contact) => { + const completeOrganization = this.allOrganizations.get( + contact.organization.name + ) + + const updatedContact = { + ...contact, + organization: + completeOrganization ?? + ({ name: contact.organization.name } as Organization), + } + + const contactAsDynElem = { + component: ContactCardComponent, + inputs: { + contact: updatedContact, + removable: false, + }, + } as DynamicElement + + acc.push(contactAsDynElem) + + return acc + }, [] as DynamicElement[]) + + this.changeDetectorRef.markForCheck() + } + + removeContact() { + this.control.setValue([]) + } + + handleContactsChanged(event: DynamicElement[]) { + const newContactsOrdered = event.map( + (contactAsDynElem) => contactAsDynElem.inputs['contact'] + ) as Individual[] + + console.log('newContactsOrdered :', newContactsOrdered) + + this.control.setValue(newContactsOrdered) + } + + /** + * gn-ui-autocomplete + */ + displayWithFn: (user: UserModel) => string = (user) => + `${user.name} ${user.surname} ${ + user.organisation ? `(${user.organisation})` : '' + }` + + /** + * gn-ui-autocomplete + */ + autoCompleteAction = (query: string) => { + const fuzzyFilter = createFuzzyFilter(query) + return this.allUsers$.pipe( + switchMap((users) => [ + users.filter((user) => fuzzyFilter(user.username)), + ]), + map((results) => results.slice(0, 10)), + debounceTime(300), + distinctUntilChanged() + ) + } + + /** + * gn-ui-autocomplete + */ + addContact(contact: UserModel) { + const newContacts = { + firstName: contact.name ?? '', + lastName: contact.surname ?? '', + organization: + this.allOrganizations.get(contact.organisation) ?? + ({ name: contact.organisation } as Organization), + email: contact.email ?? '', + role: 'point_of_contact', + address: '', + phone: '', + position: '', + } as Individual + + const newControlValue = [...this.control.value, newContacts] + + this.control.setValue(newControlValue) + } + + ngOnDestroy(): void { + this.subscription.unsubscribe() + } +} diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field.component.html b/libs/feature/editor/src/lib/components/record-form/form-field/form-field.component.html index 15ed8ae688..a392d7928f 100644 --- a/libs/feature/editor/src/lib/components/record-form/form-field/form-field.component.html +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field.component.html @@ -98,4 +98,9 @@ [control]="formControl" > + + + diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field.component.ts b/libs/feature/editor/src/lib/components/record-form/form-field/form-field.component.ts index 94bdea9d67..c6456f5dbd 100644 --- a/libs/feature/editor/src/lib/components/record-form/form-field/form-field.component.ts +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field.component.ts @@ -3,7 +3,6 @@ import { ChangeDetectionStrategy, Component, ElementRef, - EventEmitter, Input, Output, ViewChild, @@ -35,6 +34,7 @@ import { EditorFacade } from '../../../+state/editor.facade' import { FormFieldConfig } from '../../../models' import { FormFieldContactsForResourceComponent } from './form-field-contacts-for-resource/form-field-contacts-for-resource.component' import { FormFieldOpenDataComponent } from './form-field-open-data/form-field-open-data.component' +import { FormFieldContactsComponent } from './form-field-contacts/form-field-contacts.component' @Component({ selector: 'gn-ui-form-field', @@ -64,6 +64,7 @@ import { FormFieldOpenDataComponent } from './form-field-open-data/form-field-op FormFieldOverviewsComponent, FormFieldContactsForResourceComponent, FormFieldOpenDataComponent, + FormFieldContactsComponent, ], }) export class FormFieldComponent { @@ -136,8 +137,17 @@ export class FormFieldComponent { return this.model === 'contactsForResource' } + get isContacts() { + return this.model === 'contacts' + } + get withoutWrapper() { - return this.model === 'title' || this.model === 'abstract' + return ( + this.model === 'title' || + this.model === 'abstract' || + this.model === 'contactsForResource' || + this.model === 'contacts' + ) } get isOpenData() { diff --git a/libs/feature/editor/src/lib/fields.config.ts b/libs/feature/editor/src/lib/fields.config.ts index 49de292256..5729147dbe 100644 --- a/libs/feature/editor/src/lib/fields.config.ts +++ b/libs/feature/editor/src/lib/fields.config.ts @@ -90,6 +90,13 @@ export const CONTACTS_FOR_RESOURCE_FIELD: EditorField = { }, } +export const CONTACTS: EditorField = { + model: 'contacts', + formFieldConfig: { + labelKey: '', + }, +} + export const RECORD_GRAPHICAL_OVERVIEW_FIELD: EditorField = { model: 'overviews', formFieldConfig: { @@ -173,7 +180,7 @@ export const DATA_POINT_OF_CONTACT_SECTION: EditorSection = { 'editor.record.form.section.dataPointOfContact.description' ), hidden: false, - fields: [], + fields: [CONTACTS], } /************************************************************