diff --git a/apps/metadata-editor-e2e/src/e2e/edit.cy.ts b/apps/metadata-editor-e2e/src/e2e/edit.cy.ts index 99d17c6149..d0b5ff0033 100644 --- a/apps/metadata-editor-e2e/src/e2e/edit.cy.ts +++ b/apps/metadata-editor-e2e/src/e2e/edit.cy.ts @@ -15,6 +15,18 @@ describe('editor form', () => { cy.get('[data-cy=save-status]') .invoke('attr', 'data-cy-value') .as('saveStatus') + cy.get('[data-test=pageSelectorButtons]') + .find('gn-ui-button') + .eq(0) + .as('resourceDescriptionPageSelectorButton') + cy.get('[data-test=pageSelectorButtons]') + .find('gn-ui-button') + .eq(1) + .as('resourcePageSelectorButton') + cy.get('[data-test=pageSelectorButtons]') + .find('gn-ui-button') + .eq(2) + .as('accessAndContactPageSelectorButton') }) it('form shows correctly', () => { @@ -58,6 +70,72 @@ describe('editor form', () => { cy.get('md-editor-publish-button').click() }) + describe('record fields', () => { + describe('contacts for resources', () => { + beforeEach(() => { + cy.login('admin', 'admin', false) + + // Alpine convention record + cy.visit('/edit/accroche_velos') + + cy.get('@accessAndContactPageSelectorButton').click() + }) + + it('show the contacts for resource of the dataset', () => { + cy.get('[data-test=displayedRoles]').children().should('have.length', 1) + }) + + it('show the 5 roles available to add', () => { + cy.get('[data-test=rolesToPick]').children().should('have.length', 5) + }) + + it('click on a role adds it to the list of displayed role', () => { + cy.get('[data-test="rolesToPick"]').children().eq(2).click() + + cy.get('[data-test=rolesToPick]').children().should('have.length', 4) + + cy.get('[data-test=displayedRoles]').children().should('have.length', 2) + }) + + it('add a contact for resource', () => { + cy.get('[data-test=displayedRoles]') + .children() + .find('gn-ui-contact-card') + .should('have.length', 1) + + cy.get('[data-test=displayedRoles]') + .find('gn-ui-autocomplete') + .type('bar') + + cy.get('mat-option') + .should('have.text', ' Barbara Roberts (Barbie Inc.) ') + .click() + + cy.get('[data-test=displayedRoles]') + .children() + .find('gn-ui-contact-card') + .should('have.length', 2) + }) + + it('delete a contact for resource', () => { + cy.get('[data-test=displayedRoles]') + .children() + .find('gn-ui-contact-card') + .should('have.length', 1) + + cy.get('[data-test=displayedRoles]') + .children() + .get('[data-test=removeContactButton]') + .click() + + cy.get('[data-test=displayedRoles]') + .children() + .find('gn-ui-contact-card') + .should('not.exist') + }) + }) + }) + describe('date range in sortable list', () => { it('should keep the date picker open when selecting the start date of a range', () => { // add a date range diff --git a/apps/metadata-editor/src/app/edit/components/page-selector/page-selector.component.html b/apps/metadata-editor/src/app/edit/components/page-selector/page-selector.component.html index 279daf25d9..98cbaa381b 100644 --- a/apps/metadata-editor/src/app/edit/components/page-selector/page-selector.component.html +++ b/apps/metadata-editor/src/app/edit/components/page-selector/page-selector.component.html @@ -1,4 +1,7 @@ -
+
diff --git a/libs/common/domain/src/lib/model/record/contact.model.ts b/libs/common/domain/src/lib/model/record/contact.model.ts index ebe025902e..0be5481e4d 100644 --- a/libs/common/domain/src/lib/model/record/contact.model.ts +++ b/libs/common/domain/src/lib/model/record/contact.model.ts @@ -1,4 +1,5 @@ import { Organization } from './organization.model' +import { marker } from '@biesbjerg/ngx-translate-extract-marker' export const RoleValues = [ 'unspecified', @@ -24,6 +25,33 @@ export const RoleValues = [ 'user', // Party who uses the resource ] +export const RoleLabels = new Map([ + ['unspecified', marker('domain.contact.role.unspecified')], + ['other', marker('domain.contact.role.other')], + ['author', marker('domain.contact.role.author')], + ['collaborator', marker('domain.contact.role.collaborator')], + ['contributor', marker('domain.contact.role.contributor')], + ['custodian', marker('domain.contact.role.custodian')], + ['distributor', marker('domain.contact.role.distributor')], + ['editor', marker('domain.contact.role.editor')], + ['funder', marker('domain.contact.role.funder')], + ['mediator', marker('domain.contact.role.mediator')], + ['originator', marker('domain.contact.role.originator')], + ['owner', marker('domain.contact.role.owner')], + ['point_of_contact', marker('domain.contact.role.point_of_contact')], + [ + 'principal_investigator', + marker('domain.contact.role.principal_investigator'), + ], + ['processor', marker('domain.contact.role.processor')], + ['publisher', marker('domain.contact.role.publisher')], + ['resource_provider', marker('domain.contact.role.resource_provider')], + ['rights_holder', marker('domain.contact.role.rights_holder')], + ['sponsor', marker('domain.contact.role.sponsor')], + ['stakeholder', marker('domain.contact.role.stakeholder')], + ['user', marker('domain.contact.role.user')], +]) + export type Role = typeof RoleValues[number] export interface Individual { diff --git a/libs/feature/editor/src/lib/components/contact-card/contact-card.component.css b/libs/feature/editor/src/lib/components/contact-card/contact-card.component.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/feature/editor/src/lib/components/contact-card/contact-card.component.html b/libs/feature/editor/src/lib/components/contact-card/contact-card.component.html new file mode 100644 index 0000000000..a205e4c41f --- /dev/null +++ b/libs/feature/editor/src/lib/components/contact-card/contact-card.component.html @@ -0,0 +1,25 @@ +
+
+ +
+
+ {{ contact.firstName }} {{ contact.lastName }} +
+
{{ contact.email }}
+
+
+ close + +
diff --git a/libs/feature/editor/src/lib/components/contact-card/contact-card.component.spec.ts b/libs/feature/editor/src/lib/components/contact-card/contact-card.component.spec.ts new file mode 100644 index 0000000000..320fc59d43 --- /dev/null +++ b/libs/feature/editor/src/lib/components/contact-card/contact-card.component.spec.ts @@ -0,0 +1,61 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { ContactCardComponent } from './contact-card.component' +import { + Individual, + Organization, +} from '@geonetwork-ui/common/domain/model/record' +import { MatIconModule } from '@angular/material/icon' +import { CommonModule } from '@angular/common' +import { + AutocompleteComponent, + ButtonComponent, +} from '@geonetwork-ui/ui/inputs' +import { ChangeDetectionStrategy } from '@angular/core' + +describe('ContactCardComponent', () => { + let component: ContactCardComponent + let fixture: ComponentFixture + + const mockContact: Individual = { + firstName: 'John', + lastName: 'Doe', + organization: { name: 'Org1' } as Organization, + email: 'john.doe@example.com', + role: 'admin', + address: '', + phone: '', + position: '', + } + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + CommonModule, + MatIconModule, + ButtonComponent, + ContactCardComponent, + ], + }) + .overrideComponent(AutocompleteComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default }, + }) + .compileComponents() + }) + + beforeEach(() => { + fixture = TestBed.createComponent(ContactCardComponent) + component = fixture.componentInstance + component.contact = mockContact + fixture.detectChanges() + }) + + it('should create the component', () => { + expect(component).toBeTruthy() + }) + + it('should emit contactRemoved event with the correct contact', () => { + const contactRemovedSpy = jest.spyOn(component.contactRemoved, 'emit') + component.removeContact(mockContact) + expect(contactRemovedSpy).toHaveBeenCalledWith(mockContact) + }) +}) diff --git a/libs/feature/editor/src/lib/components/contact-card/contact-card.component.stories.ts b/libs/feature/editor/src/lib/components/contact-card/contact-card.component.stories.ts new file mode 100644 index 0000000000..3c75b032e8 --- /dev/null +++ b/libs/feature/editor/src/lib/components/contact-card/contact-card.component.stories.ts @@ -0,0 +1,54 @@ +import { + applicationConfig, + componentWrapperDecorator, + Meta, + moduleMetadata, + StoryObj, +} from '@storybook/angular' +import { ContactCardComponent } from './contact-card.component' +import { BrowserAnimationsModule } from '@angular/platform-browser/animations' +import { importProvidersFrom } from '@angular/core' +import { MatIconModule } from '@angular/material/icon' +import { CommonModule } from '@angular/common' +import { ButtonComponent } from '@geonetwork-ui/ui/inputs' + +export default { + title: 'Elements/ContactCardComponent', + component: ContactCardComponent, + decorators: [ + moduleMetadata({ + imports: [ + CommonModule, + MatIconModule, + ButtonComponent, + ContactCardComponent, + ], + }), + applicationConfig({ + providers: [importProvidersFrom(BrowserAnimationsModule)], + }), + componentWrapperDecorator( + (story) => `
${story}
` + ), + ], +} as Meta + +export const Primary: StoryObj = { + args: { + contact: { + firstName: 'John', + lastName: 'Doe', + organization: { + name: 'Example Organization', + }, + email: 'john.doe@example.com', + role: 'Developer', + address: '123 Main St', + phone: '123-456-7890', + position: 'Senior Developer', + }, + organization: { + name: 'Example Organization', + }, + }, +} diff --git a/libs/feature/editor/src/lib/components/contact-card/contact-card.component.ts b/libs/feature/editor/src/lib/components/contact-card/contact-card.component.ts new file mode 100644 index 0000000000..10fb9c6ffa --- /dev/null +++ b/libs/feature/editor/src/lib/components/contact-card/contact-card.component.ts @@ -0,0 +1,30 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output, +} from '@angular/core' +import { Individual } from '@geonetwork-ui/common/domain/model/record' +import { MatIconModule } from '@angular/material/icon' +import { CommonModule } from '@angular/common' +import { ButtonComponent } from '@geonetwork-ui/ui/inputs' +import { ThumbnailComponent } from '@geonetwork-ui/ui/elements' + +@Component({ + selector: 'gn-ui-contact-card', + templateUrl: './contact-card.component.html', + styleUrls: ['./contact-card.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [CommonModule, MatIconModule, ButtonComponent, ThumbnailComponent], +}) +export class ContactCardComponent { + @Input() contact: Individual + @Input() removable = true + @Output() contactRemoved = new EventEmitter() + + removeContact(contact: Individual) { + this.contactRemoved.emit(contact) + } +} diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts-for-resource/form-field-contacts-for-resource.component.css b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts-for-resource/form-field-contacts-for-resource.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-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 new file mode 100644 index 0000000000..69d8a2f22e --- /dev/null +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts-for-resource/form-field-contacts-for-resource.component.html @@ -0,0 +1,76 @@ +
+
+ + +
+ + {{ roleToLabel(role) }} +
+
+
+
+
+
+
+ {{ + roleToLabel(role) + }} +
+ + + + + + + + + + + + + +
+
+
+ +
+ editor.record.form.field.contactsForResource.noContact +
+
+
diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts-for-resource/form-field-contacts-for-resource.component.spec.ts b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts-for-resource/form-field-contacts-for-resource.component.spec.ts new file mode 100644 index 0000000000..97f597a4c0 --- /dev/null +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts-for-resource/form-field-contacts-for-resource.component.spec.ts @@ -0,0 +1,224 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { FormFieldContactsForResourceComponent } from './form-field-contacts-for-resource.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: FormFieldContactsForResourceComponent + let fixture: ComponentFixture + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + FormFieldContactsForResourceComponent, + 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(FormFieldContactsForResourceComponent) + 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.updateContactsForRessource() + + 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-for-resource/form-field-contacts-for-resource.component.ts b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts-for-resource/form-field-contacts-for-resource.component.ts new file mode 100644 index 0000000000..9890c61fae --- /dev/null +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts-for-resource/form-field-contacts-for-resource.component.ts @@ -0,0 +1,271 @@ +import { CommonModule } from '@angular/common' +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Input, + OnDestroy, + OnInit, +} 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, + RoleLabels, +} 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-for-resource', + templateUrl: './form-field-contacts-for-resource.component.html', + styleUrls: ['./form-field-contacts-for-resource.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + DropdownSelectorComponent, + UiInputsModule, + CommonModule, + UiWidgetsModule, + AutocompleteComponent, + TranslateModule, + ContactCardComponent, + SortableListComponent, + ], +}) +export class FormFieldContactsForResourceComponent + implements OnInit, OnDestroy +{ + @Input() control: FormControl + + subscription: Subscription = new Subscription() + + allUsers$: Observable + + contactsForRessourceByRole: Map = new Map() + + contactsAsDynElemByRole: Map = new Map() + + rolesToPick: Role[] = [ + 'resource_provider', + 'custodian', + 'owner', + 'point_of_contact', + 'author', + ] + + roleSectionsToDisplay: Role[] = [] + + allOrganizations: Map = new Map() + + constructor( + private platformServiceInterface: PlatformServiceInterface, + private organizationsServiceInterface: OrganizationsServiceInterface, + private changeDetectorRef: ChangeDetectorRef + ) { + this.allUsers$ = this.platformServiceInterface.getUsers() + } + + async ngOnInit(): Promise { + this.allOrganizations = new Map( + ( + await firstValueFrom(this.organizationsServiceInterface.organisations$) + ).map((organization) => [organization.name, organization]) + ) + this.updateContactsForRessource() + this.manageRoleSectionsToDisplay(this.control.value) + this.filterRolesToPick() + + this.changeDetectorRef.markForCheck() + + this.subscription.add( + this.control.valueChanges.subscribe((contactsForResource) => { + this.updateContactsForRessource() + this.manageRoleSectionsToDisplay(contactsForResource) + this.filterRolesToPick() + + this.changeDetectorRef.markForCheck() + }) + ) + } + + addRoleToDisplay(roleToAdd: string) { + this.roleSectionsToDisplay.push(roleToAdd) + this.filterRolesToPick() + } + + filterRolesToPick() { + this.rolesToPick = this.rolesToPick.filter( + (role) => !this.roleSectionsToDisplay.includes(role) + ) + } + + updateContactsForRessource() { + this.contactsForRessourceByRole = 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), + } + + if (!acc.has(contact.role)) { + acc.set(contact.role, []) + } + + acc.get(contact.role).push(updatedContact) + + return acc + }, + new Map() + ) + + this.contactsAsDynElemByRole = 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 + + if (!acc.has(contact.role)) { + acc.set(contact.role, []) + } + + acc.get(contact.role).push(contactAsDynElem) + + return acc + }, new Map()) + + this.changeDetectorRef.markForCheck() + } + + manageRoleSectionsToDisplay(contactsForResource: Individual[]) { + const roles = contactsForResource.map( + (contact: Individual) => contact.role + ) as Role[] + + roles.forEach((role: Role) => { + if (!this.roleSectionsToDisplay.includes(role)) { + this.roleSectionsToDisplay.push(role) + } + }) + } + + removeContact(index: number) { + const newContactsforRessource = this.control.value.filter( + (_, i) => i !== index + ) + this.control.setValue(newContactsforRessource) + } + + handleContactsChanged(event: DynamicElement[]) { + const newContactsOrdered = event.map( + (contactAsDynElem) => contactAsDynElem.inputs['contact'] + ) as Individual[] + + const role = newContactsOrdered[0].role + + this.contactsForRessourceByRole.set(role, newContactsOrdered) + + const newControlValue = Array.from( + this.contactsForRessourceByRole.values() + ).flat() + + this.control.setValue(newControlValue) + } + + protected roleToLabel(role: string): string { + return RoleLabels.get(role) + } + + /** + * 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, role: string) { + const newContactsForRessource = { + firstName: contact.name ?? '', + lastName: contact.surname ?? '', + organization: + this.allOrganizations.get(contact.organisation) ?? + ({ name: contact.organisation } as Organization), + email: contact.email ?? '', + role, + address: '', + phone: '', + position: '', + } as Individual + + const newControlValue = [...this.control.value, newContactsForRessource] + + this.control.setValue(newControlValue) + } + + ngOnDestroy(): void { + this.subscription.unsubscribe() + } +} diff --git a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-keywords/form-field-keywords.component.html b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-keywords/form-field-keywords.component.html index ce21d8e1b7..a2ea32005e 100644 --- a/libs/feature/editor/src/lib/components/record-form/form-field/form-field-keywords/form-field-keywords.component.html +++ b/libs/feature/editor/src/lib/components/record-form/form-field/form-field-keywords/form-field-keywords.component.html @@ -4,7 +4,7 @@ [displayWithFn]="displayWithFn" [action]="autoCompleteAction" (itemSelected)="handleItemSelection($event)" - [clearOnSelection]="true" + [preventCompleteOnSelection]="true" [minCharacterCount]="0" [allowSubmit]="false" > 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 f6fa1f9ee2..c3b2b25d6f 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 @@ -87,4 +87,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 db576ccb2d..68c84caca4 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 @@ -32,6 +32,7 @@ import { FormFieldOverviewsComponent } from './form-field-overviews/form-field-o import { map, take } from 'rxjs/operators' import { EditorFacade } from '../../../+state/editor.facade' import { FormFieldConfig } from '../../../models' +import { FormFieldContactsForResourceComponent } from './form-field-contacts-for-resource/form-field-contacts-for-resource.component' @Component({ selector: 'gn-ui-form-field', @@ -59,6 +60,7 @@ import { FormFieldConfig } from '../../../models' FormFieldKeywordsComponent, TranslateModule, FormFieldOverviewsComponent, + FormFieldContactsForResourceComponent, ], }) export class FormFieldComponent { @@ -122,6 +124,9 @@ export class FormFieldComponent { get isKeywords() { return this.model === 'keywords' } + get isContactsForResource() { + return this.model === 'contactsForResource' + } get withoutWrapper() { return this.model === 'title' || this.model === 'abstract' diff --git a/libs/feature/editor/src/lib/components/record-form/record-form.component.html b/libs/feature/editor/src/lib/components/record-form/record-form.component.html index 277b79d049..208206241e 100644 --- a/libs/feature/editor/src/lib/components/record-form/record-form.component.html +++ b/libs/feature/editor/src/lib/components/record-form/record-form.component.html @@ -14,7 +14,7 @@ >
{{ section.labelKey }} diff --git a/libs/feature/editor/src/lib/fields.config.ts b/libs/feature/editor/src/lib/fields.config.ts index 1614eb1e05..549506ba31 100644 --- a/libs/feature/editor/src/lib/fields.config.ts +++ b/libs/feature/editor/src/lib/fields.config.ts @@ -83,6 +83,13 @@ export const RECORD_ABSTRACT_FIELD: EditorField = { }, } +export const CONTACTS_FOR_RESOURCE_FIELD: EditorField = { + model: 'contactsForResource', + formFieldConfig: { + labelKey: '', + }, +} + export const RECORD_GRAPHICAL_OVERVIEW_FIELD: EditorField = { model: 'overviews', formFieldConfig: { @@ -157,7 +164,7 @@ export const DATA_MANAGERS_SECTION: EditorSection = { labelKey: marker('editor.record.form.section.dataManagers.label'), descriptionKey: marker('editor.record.form.section.dataManagers.description'), hidden: false, - fields: [], + fields: [CONTACTS_FOR_RESOURCE_FIELD], } export const DATA_POINT_OF_CONTACT_SECTION: EditorSection = { diff --git a/libs/feature/search/src/lib/fuzzy-search/fuzzy-search.component.html b/libs/feature/search/src/lib/fuzzy-search/fuzzy-search.component.html index 11bf550ee3..7c11bb2881 100644 --- a/libs/feature/search/src/lib/fuzzy-search/fuzzy-search.component.html +++ b/libs/feature/search/src/lib/fuzzy-search/fuzzy-search.component.html @@ -6,6 +6,6 @@ (inputSubmitted)="handleInputSubmission($event)" (inputCleared)="handleInputCleared()" [value]="searchInputValue$ | async" - [clearOnSelection]="true" + [preventCompleteOnSelection]="true" [autoFocus]="autoFocus" > diff --git a/libs/ui/elements/src/lib/sortable-list/sortable-list.component.ts b/libs/ui/elements/src/lib/sortable-list/sortable-list.component.ts index 11acd5c480..fd318e1b68 100644 --- a/libs/ui/elements/src/lib/sortable-list/sortable-list.component.ts +++ b/libs/ui/elements/src/lib/sortable-list/sortable-list.component.ts @@ -17,7 +17,7 @@ import { import { MatIconModule } from '@angular/material/icon' import { ButtonComponent } from '@geonetwork-ui/ui/inputs' -type DynamicElement = { +export type DynamicElement = { component: Type inputs: Record } @@ -40,7 +40,7 @@ type DynamicElement = { }) export class SortableListComponent { @Input() elements: Array - @Input() addOptions: Array<{ buttonLabel: string; eventName: string }> + @Input() addOptions: Array<{ buttonLabel: string; eventName: string }> = [] @Output() elementsChange = new EventEmitter>() @Output() add = new EventEmitter() diff --git a/libs/ui/inputs/src/lib/autocomplete/autocomplete.component.spec.ts b/libs/ui/inputs/src/lib/autocomplete/autocomplete.component.spec.ts index 138118521c..f97d73fc49 100644 --- a/libs/ui/inputs/src/lib/autocomplete/autocomplete.component.spec.ts +++ b/libs/ui/inputs/src/lib/autocomplete/autocomplete.component.spec.ts @@ -302,7 +302,6 @@ describe('AutocompleteComponent', () => { } describe('when true', () => { beforeEach(() => { - component.clearOnSelection = true component.itemSelected.subscribe((event) => (selectionEmitted = event)) fixture.detectChanges() component.handleSelection(selectionEvent) @@ -313,8 +312,11 @@ describe('AutocompleteComponent', () => { it('emits selection event', () => { expect(selectionEmitted).toEqual('first') }) - describe('if clear on selection', () => { + describe('if preventCompleteOnSelection on selection', () => { it('set input value to last entered text', () => { + component.clearOnSelection = false + component.preventCompleteOnSelection = true + component.control.setValue('second') component.handleSelection(selectionEvent) expect(component.inputRef.nativeElement.value).toEqual('second') @@ -323,6 +325,16 @@ describe('AutocompleteComponent', () => { expect(component.inputRef.nativeElement.value).toEqual('second') }) }) + describe('if clearOnSelection on selection', () => { + it('set input value to empty string', () => { + component.clearOnSelection = true + component.preventCompleteOnSelection = false + + component.control.setValue('second') + component.handleSelection(selectionEvent) + expect(component.inputRef.nativeElement.value).toEqual('') + }) + }) }) }) diff --git a/libs/ui/inputs/src/lib/autocomplete/autocomplete.component.ts b/libs/ui/inputs/src/lib/autocomplete/autocomplete.component.ts index 02761e908d..3d6a2114c3 100644 --- a/libs/ui/inputs/src/lib/autocomplete/autocomplete.component.ts +++ b/libs/ui/inputs/src/lib/autocomplete/autocomplete.component.ts @@ -61,6 +61,7 @@ export class AutocompleteComponent @Input() action: (value: string) => Observable @Input() value?: AutocompleteItem @Input() clearOnSelection = false + @Input() preventCompleteOnSelection = false @Input() autoFocus = false @Input() minCharacterCount? = 3 @Input() allowSubmit = true @@ -206,13 +207,24 @@ export class AutocompleteComponent this.inputSubmitted.emit(this.inputRef.nativeElement.value) } + /** + * This function is triggered when an item is selected in the list of displayed items. + * If preventCompleteOnSelection is true then the input will be left as entered by the user. + * If preventCompleteOnSelection is false (by default) then the input will be completed with the item selected by the user. + * If clearOnSelection is true then the input will be cleared upon selection. + * @param event + */ handleSelection(event: MatAutocompleteSelectedEvent) { this.cancelEnter = true this.itemSelected.emit(event.option.value) - if (this.clearOnSelection) { - this.lastInputValue$.pipe(first()).subscribe((any) => { - this.inputRef.nativeElement.value = any + if (this.preventCompleteOnSelection) { + this.lastInputValue$.pipe(first()).subscribe((lastInputValue) => { + this.inputRef.nativeElement.value = lastInputValue }) + return + } + if (this.clearOnSelection) { + this.inputRef.nativeElement.value = '' } } } diff --git a/translations/de.json b/translations/de.json index 680ebf6d45..9fbb918c90 100644 --- a/translations/de.json +++ b/translations/de.json @@ -147,6 +147,27 @@ "dataset.error.parse": "Die Daten wurden geladen, konnten aber nicht gelesen werden: \"{ info }\"", "dataset.error.unknown": "Die Daten können nicht angezeigt werden: \"{ info }\"", "dataset.error.unsupportedType": "Der folgende Inhaltstyp wird nicht unterstützt: \"{ info }\"", + "domain.contact.role.author": "", + "domain.contact.role.collaborator": "", + "domain.contact.role.contributor": "", + "domain.contact.role.custodian": "", + "domain.contact.role.distributor": "", + "domain.contact.role.editor": "", + "domain.contact.role.funder": "", + "domain.contact.role.mediator": "", + "domain.contact.role.originator": "", + "domain.contact.role.other": "", + "domain.contact.role.owner": "", + "domain.contact.role.point_of_contact": "", + "domain.contact.role.principal_investigator": "", + "domain.contact.role.processor": "", + "domain.contact.role.publisher": "", + "domain.contact.role.resource_provider": "", + "domain.contact.role.rights_holder": "", + "domain.contact.role.sponsor": "", + "domain.contact.role.stakeholder": "", + "domain.contact.role.unspecified": "", + "domain.contact.role.user": "", "domain.record.status.completed": "Abgeschlossen", "domain.record.status.deprecated": "Veraltet", "domain.record.status.ongoing": "Kontinuierliche Aktualisierung", @@ -178,6 +199,7 @@ "editor.record.form.bottomButtons.next": "", "editor.record.form.bottomButtons.previous": "", "editor.record.form.field.abstract": "Kurzbeschreibung", + "editor.record.form.field.contactsForResource.noContact": "", "editor.record.form.field.keywords": "Schlagwörter", "editor.record.form.field.license": "Lizenz", "editor.record.form.field.overviews": "", diff --git a/translations/en.json b/translations/en.json index 526162dc6b..658432cba6 100644 --- a/translations/en.json +++ b/translations/en.json @@ -147,6 +147,27 @@ "dataset.error.parse": "The data was loaded but could not be parsed: \"{ info }\"", "dataset.error.unknown": "The data cannot be displayed: \"{ info }\"", "dataset.error.unsupportedType": "The following content type is unsupported: \"{ info }\"", + "domain.contact.role.author": "Author", + "domain.contact.role.collaborator": "Collaborator", + "domain.contact.role.contributor": "Contributor", + "domain.contact.role.custodian": "Custodian", + "domain.contact.role.distributor": "Distributor", + "domain.contact.role.editor": "Editor", + "domain.contact.role.funder": "Funder", + "domain.contact.role.mediator": "Mediator", + "domain.contact.role.originator": "Originator", + "domain.contact.role.other": "Other", + "domain.contact.role.owner": "Owner", + "domain.contact.role.point_of_contact": "Point of contact", + "domain.contact.role.principal_investigator": "Principal investigator", + "domain.contact.role.processor": "Processor", + "domain.contact.role.publisher": "Publisher", + "domain.contact.role.resource_provider": "Resource provider", + "domain.contact.role.rights_holder": "Rights holder", + "domain.contact.role.sponsor": "Sponsor", + "domain.contact.role.stakeholder": "Stakeholder", + "domain.contact.role.unspecified": "Unspecified", + "domain.contact.role.user": "User", "domain.record.status.completed": "Completed", "domain.record.status.deprecated": "Deprecated", "domain.record.status.ongoing": "On going", @@ -178,6 +199,7 @@ "editor.record.form.bottomButtons.next": "Next", "editor.record.form.bottomButtons.previous": "Previous", "editor.record.form.field.abstract": "Abstract", + "editor.record.form.field.contactsForResource.noContact": "Please provide at least one point of contact responsible for the data.", "editor.record.form.field.keywords": "Keywords", "editor.record.form.field.license": "License", "editor.record.form.field.overviews": "Overviews", diff --git a/translations/es.json b/translations/es.json index e5ca041e48..3a20085291 100644 --- a/translations/es.json +++ b/translations/es.json @@ -147,6 +147,27 @@ "dataset.error.parse": "", "dataset.error.unknown": "", "dataset.error.unsupportedType": "", + "domain.contact.role.author": "", + "domain.contact.role.collaborator": "", + "domain.contact.role.contributor": "", + "domain.contact.role.custodian": "", + "domain.contact.role.distributor": "", + "domain.contact.role.editor": "", + "domain.contact.role.funder": "", + "domain.contact.role.mediator": "", + "domain.contact.role.originator": "", + "domain.contact.role.other": "", + "domain.contact.role.owner": "", + "domain.contact.role.point_of_contact": "", + "domain.contact.role.principal_investigator": "", + "domain.contact.role.processor": "", + "domain.contact.role.publisher": "", + "domain.contact.role.resource_provider": "", + "domain.contact.role.rights_holder": "", + "domain.contact.role.sponsor": "", + "domain.contact.role.stakeholder": "", + "domain.contact.role.unspecified": "", + "domain.contact.role.user": "", "domain.record.status.completed": "", "domain.record.status.deprecated": "", "domain.record.status.ongoing": "", @@ -178,6 +199,7 @@ "editor.record.form.bottomButtons.next": "", "editor.record.form.bottomButtons.previous": "", "editor.record.form.field.abstract": "", + "editor.record.form.field.contactsForResource.noContact": "", "editor.record.form.field.keywords": "", "editor.record.form.field.license": "", "editor.record.form.field.overviews": "", diff --git a/translations/fr.json b/translations/fr.json index cf1a8552d8..bfb59572ed 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -147,6 +147,27 @@ "dataset.error.parse": "Les données ont été chargées mais leur décodage a échoué: \"{ info }\"", "dataset.error.unknown": "Les données ne peuvent être affichées: \"{ info }\"", "dataset.error.unsupportedType": "Le type de contenu suivant n'est pas pris en charge: \"{ info }\"", + "domain.contact.role.author": "Auteur", + "domain.contact.role.collaborator": "Collaborateur", + "domain.contact.role.contributor": "Contributeur", + "domain.contact.role.custodian": "Gestionnaire", + "domain.contact.role.distributor": "Distributeur", + "domain.contact.role.editor": "Éditeur", + "domain.contact.role.funder": "Financeur", + "domain.contact.role.mediator": "Médiateur", + "domain.contact.role.originator": "Créateur", + "domain.contact.role.other": "Autre", + "domain.contact.role.owner": "Propriétaire", + "domain.contact.role.point_of_contact": "Point de contact", + "domain.contact.role.principal_investigator": "Chercheur principal", + "domain.contact.role.processor": "Processeur", + "domain.contact.role.publisher": "Éditeur", + "domain.contact.role.resource_provider": "Fournisseur", + "domain.contact.role.rights_holder": "Détenteur des droits", + "domain.contact.role.sponsor": "Sponsor", + "domain.contact.role.stakeholder": "Partie prenante", + "domain.contact.role.unspecified": "Non spécifié", + "domain.contact.role.user": "Utilisateur", "domain.record.status.completed": "Finalisé", "domain.record.status.deprecated": "Obsolète", "domain.record.status.ongoing": "Mise à jour continue", @@ -178,13 +199,14 @@ "editor.record.form.bottomButtons.next": "Suivant", "editor.record.form.bottomButtons.previous": "Précédent", "editor.record.form.field.abstract": "Résumé", + "editor.record.form.field.contactsForResource.noContact": "Veuillez renseigner au moins un point de contact responsable de la donnée.", "editor.record.form.field.keywords": "Mots-clés", "editor.record.form.field.license": "Licence", "editor.record.form.field.overviews": "Aperçus", "editor.record.form.field.recordUpdated": "Date de dernière révision", "editor.record.form.field.resourceUpdated": "Date de dernière révision", "editor.record.form.field.temporalExtents": "Étendue temporelle", - "editor.record.form.field.title": "Titre", + "editor.record.form.field.title": "", "editor.record.form.field.uniqueIdentifier": "Identifiant unique", "editor.record.form.field.updateFrequency": "Fréquence de mise à jour", "editor.record.form.license.cc-by": "", diff --git a/translations/it.json b/translations/it.json index bee062e3f1..d48108d20b 100644 --- a/translations/it.json +++ b/translations/it.json @@ -147,6 +147,27 @@ "dataset.error.parse": "I dati sono stati caricati ma la decodifica non è riuscita: \"{info}\"", "dataset.error.unknown": "Impossibile visualizzare i dati: \"{info}\"", "dataset.error.unsupportedType": "Il seguente tipo di contenuto non è supportato: \"{info}\"", + "domain.contact.role.author": "", + "domain.contact.role.collaborator": "", + "domain.contact.role.contributor": "", + "domain.contact.role.custodian": "", + "domain.contact.role.distributor": "", + "domain.contact.role.editor": "", + "domain.contact.role.funder": "", + "domain.contact.role.mediator": "", + "domain.contact.role.originator": "", + "domain.contact.role.other": "", + "domain.contact.role.owner": "", + "domain.contact.role.point_of_contact": "", + "domain.contact.role.principal_investigator": "", + "domain.contact.role.processor": "", + "domain.contact.role.publisher": "", + "domain.contact.role.resource_provider": "", + "domain.contact.role.rights_holder": "", + "domain.contact.role.sponsor": "", + "domain.contact.role.stakeholder": "", + "domain.contact.role.unspecified": "", + "domain.contact.role.user": "", "domain.record.status.completed": "Completato", "domain.record.status.deprecated": "Deprecato", "domain.record.status.ongoing": "Aggiornamento continuo", @@ -178,6 +199,7 @@ "editor.record.form.bottomButtons.next": "", "editor.record.form.bottomButtons.previous": "", "editor.record.form.field.abstract": "", + "editor.record.form.field.contactsForResource.noContact": "", "editor.record.form.field.keywords": "", "editor.record.form.field.license": "Licenza", "editor.record.form.field.overviews": "", diff --git a/translations/nl.json b/translations/nl.json index 77207ebb78..d17a7136c7 100644 --- a/translations/nl.json +++ b/translations/nl.json @@ -147,6 +147,27 @@ "dataset.error.parse": "", "dataset.error.unknown": "", "dataset.error.unsupportedType": "", + "domain.contact.role.author": "", + "domain.contact.role.collaborator": "", + "domain.contact.role.contributor": "", + "domain.contact.role.custodian": "", + "domain.contact.role.distributor": "", + "domain.contact.role.editor": "", + "domain.contact.role.funder": "", + "domain.contact.role.mediator": "", + "domain.contact.role.originator": "", + "domain.contact.role.other": "", + "domain.contact.role.owner": "", + "domain.contact.role.point_of_contact": "", + "domain.contact.role.principal_investigator": "", + "domain.contact.role.processor": "", + "domain.contact.role.publisher": "", + "domain.contact.role.resource_provider": "", + "domain.contact.role.rights_holder": "", + "domain.contact.role.sponsor": "", + "domain.contact.role.stakeholder": "", + "domain.contact.role.unspecified": "", + "domain.contact.role.user": "", "domain.record.status.completed": "", "domain.record.status.deprecated": "", "domain.record.status.ongoing": "", @@ -178,6 +199,7 @@ "editor.record.form.bottomButtons.next": "", "editor.record.form.bottomButtons.previous": "", "editor.record.form.field.abstract": "", + "editor.record.form.field.contactsForResource.noContact": "", "editor.record.form.field.keywords": "", "editor.record.form.field.license": "", "editor.record.form.field.overviews": "", diff --git a/translations/pt.json b/translations/pt.json index d0307afc9e..652b6adacf 100644 --- a/translations/pt.json +++ b/translations/pt.json @@ -147,6 +147,27 @@ "dataset.error.parse": "", "dataset.error.unknown": "", "dataset.error.unsupportedType": "", + "domain.contact.role.author": "", + "domain.contact.role.collaborator": "", + "domain.contact.role.contributor": "", + "domain.contact.role.custodian": "", + "domain.contact.role.distributor": "", + "domain.contact.role.editor": "", + "domain.contact.role.funder": "", + "domain.contact.role.mediator": "", + "domain.contact.role.originator": "", + "domain.contact.role.other": "", + "domain.contact.role.owner": "", + "domain.contact.role.point_of_contact": "", + "domain.contact.role.principal_investigator": "", + "domain.contact.role.processor": "", + "domain.contact.role.publisher": "", + "domain.contact.role.resource_provider": "", + "domain.contact.role.rights_holder": "", + "domain.contact.role.sponsor": "", + "domain.contact.role.stakeholder": "", + "domain.contact.role.unspecified": "", + "domain.contact.role.user": "", "domain.record.status.completed": "", "domain.record.status.deprecated": "", "domain.record.status.ongoing": "", @@ -178,6 +199,7 @@ "editor.record.form.bottomButtons.next": "", "editor.record.form.bottomButtons.previous": "", "editor.record.form.field.abstract": "", + "editor.record.form.field.contactsForResource.noContact": "", "editor.record.form.field.keywords": "", "editor.record.form.field.license": "", "editor.record.form.field.overviews": "", diff --git a/translations/sk.json b/translations/sk.json index 7abb992c59..60da63833e 100644 --- a/translations/sk.json +++ b/translations/sk.json @@ -147,6 +147,27 @@ "dataset.error.parse": "Dáta boli načítané, ale nedajú sa analyzovať: \"{ info }\"", "dataset.error.unknown": "Dáta nie je možné zobraziť: \"{ info }\"", "dataset.error.unsupportedType": "Nepodporovaný typ obsahu: \"{ info }\"", + "domain.contact.role.author": "", + "domain.contact.role.collaborator": "", + "domain.contact.role.contributor": "", + "domain.contact.role.custodian": "", + "domain.contact.role.distributor": "", + "domain.contact.role.editor": "", + "domain.contact.role.funder": "", + "domain.contact.role.mediator": "", + "domain.contact.role.originator": "", + "domain.contact.role.other": "", + "domain.contact.role.owner": "", + "domain.contact.role.point_of_contact": "", + "domain.contact.role.principal_investigator": "", + "domain.contact.role.processor": "", + "domain.contact.role.publisher": "", + "domain.contact.role.resource_provider": "", + "domain.contact.role.rights_holder": "", + "domain.contact.role.sponsor": "", + "domain.contact.role.stakeholder": "", + "domain.contact.role.unspecified": "", + "domain.contact.role.user": "", "domain.record.status.completed": "Dokončené", "domain.record.status.deprecated": "Zastarané", "domain.record.status.ongoing": "Prebiehajúce", @@ -178,6 +199,7 @@ "editor.record.form.bottomButtons.next": "", "editor.record.form.bottomButtons.previous": "", "editor.record.form.field.abstract": "", + "editor.record.form.field.contactsForResource.noContact": "", "editor.record.form.field.keywords": "", "editor.record.form.field.license": "Licencia", "editor.record.form.field.overviews": "",