diff --git a/libs/shared/src/lib/components/filter/filter-row/filter-row.component.html b/libs/shared/src/lib/components/filter/filter-row/filter-row.component.html index 19603c0ef9..2c9a333b33 100644 --- a/libs/shared/src/lib/components/filter/filter-row/filter-row.component.html +++ b/libs/shared/src/lib/components/filter/filter-row/filter-row.component.html @@ -164,6 +164,18 @@ + + +
+ + +
+
+
diff --git a/libs/shared/src/lib/components/filter/filter-row/filter-row.component.ts b/libs/shared/src/lib/components/filter/filter-row/filter-row.component.ts index 36de194d00..d43b8519d9 100644 --- a/libs/shared/src/lib/components/filter/filter-row/filter-row.component.ts +++ b/libs/shared/src/lib/components/filter/filter-row/filter-row.component.ts @@ -51,6 +51,9 @@ export class FilterRowComponent /** Reference to context editor template */ @ViewChild('contextEditor', { static: false }) contextEditor!: TemplateRef; + /** Reference to people editor template */ + @ViewChild('peopleEditor', { static: false }) + peopleEditor!: TemplateRef; /** Current field */ public field?: any; /** Template reference to the editor */ @@ -219,6 +222,10 @@ export class FilterRowComponent this.editor = this.dateEditor; break; } + case 'people': { + this.editor = this.peopleEditor; + break; + } default: { this.editor = this.textEditor; } diff --git a/libs/shared/src/lib/components/filter/filter.module.ts b/libs/shared/src/lib/components/filter/filter.module.ts index 210192a66a..b7a96dc30d 100644 --- a/libs/shared/src/lib/components/filter/filter.module.ts +++ b/libs/shared/src/lib/components/filter/filter.module.ts @@ -12,6 +12,7 @@ import { DateModule, TooltipModule, } from '@oort-front/ui'; +import { PeopleDropdownComponent } from '../../survey/components/people-dropdown/people-dropdown.component'; /** * Composite Filter module. @@ -29,6 +30,7 @@ import { DateModule, FormWrapperModule, TooltipModule, + PeopleDropdownComponent, ], exports: [FilterComponent], }) diff --git a/libs/shared/src/lib/components/ui/core-grid/core-grid.component.ts b/libs/shared/src/lib/components/ui/core-grid/core-grid.component.ts index 3760f199b5..6cc5074e7c 100644 --- a/libs/shared/src/lib/components/ui/core-grid/core-grid.component.ts +++ b/libs/shared/src/lib/components/ui/core-grid/core-grid.component.ts @@ -40,7 +40,7 @@ import { } from '../../../models/record.model'; import { GridLayout } from './models/grid-layout.model'; import { GridActions, GridSettings } from './models/grid-settings.model'; -import { get, isEqual, isNil } from 'lodash'; +import { get, isArray, isEqual, isNil } from 'lodash'; import { GridService } from '../../../services/grid/grid.service'; import { TranslateService } from '@ngx-translate/core'; import { DatePipe } from '../../../pipes/date/date.pipe'; @@ -49,7 +49,7 @@ import { DateTranslateService } from '../../../services/date-translate/date-tran import { ApplicationService } from '../../../services/application/application.service'; import { UnsubscribeComponent } from '../../utils/unsubscribe/unsubscribe.component'; import { debounceTime, filter, takeUntil } from 'rxjs/operators'; -import { firstValueFrom, from, merge, Subject } from 'rxjs'; +import { firstValueFrom, from, lastValueFrom, merge, Subject } from 'rxjs'; import { SnackbarService, UILayoutService } from '@oort-front/ui'; import { ConfirmService } from '../../../services/confirm/confirm.service'; import { ContextService } from '../../../services/context/context.service'; @@ -517,6 +517,7 @@ export class CoreGridComponent } } } + this.getRecords(); }, error: (err: any) => { @@ -1073,8 +1074,9 @@ export class CoreGridComponent }); dialogRef.closed .pipe(takeUntil(this.destroy$)) - .subscribe((value: any) => { + .subscribe(async (value: any) => { if (value) { + await this.loadPeopleChoices(value); this.reloadData(); } }); @@ -1156,6 +1158,64 @@ export class CoreGridComponent } } + /** + * Load more people choices if needed + * + * @param value new updated or added record + */ + public async loadPeopleChoices(value: any) { + const newIds: any[] = []; + + // Loop over metaFields to verify if there are new users + for (const metaField in this.metaFields) { + const choices: string[] = isArray(value.data.data[metaField]) + ? value.data.data[metaField] + : [value.data.data[metaField]]; + if ( + ['people', 'singlepeople'].includes(this.metaFields[metaField]?.type) && + choices + ) { + newIds.push( + ...choices + // Filter new users + .filter( + (choice: any) => + !this.metaFields[metaField].choices.some( + (obj: any) => obj.value === choice + ) + ) + .map((choice: any) => ({ + id: choice, + // Saving the field name to address new choices after fetching + field: metaField, + })) + ); + } + } + + if (newIds.length) { + // Fetch new users + const newChoices = await lastValueFrom( + this.gridService.getNewPeopleChoices(newIds.map((el) => el.id)) + ); + + // Assign the choices to the correct field + newChoices.forEach((choice: any) => { + const fieldName = newIds.find((el) => el.id === choice.value)?.field; + if (fieldName) { + this.metaFields[fieldName].choices.push(choice); + } + }); + + this.fields = this.gridService.getFields( + this.settings?.query?.fields || [], + this.metaFields, + this.defaultLayout.fields || {}, + '' + ); + } + } + /** * Opens the form corresponding to selected row in order to update it * @@ -1174,12 +1234,15 @@ export class CoreGridComponent }, autoFocus: false, }); - dialogRef.closed.pipe(takeUntil(this.destroy$)).subscribe((value: any) => { - if (value) { - this.validateRecords(ids); - this.reloadData(); - } - }); + dialogRef.closed + .pipe(takeUntil(this.destroy$)) + .subscribe(async (value: any) => { + if (value) { + await this.loadPeopleChoices(value); + this.validateRecords(ids); + this.reloadData(); + } + }); } /** diff --git a/libs/shared/src/lib/components/ui/core-grid/grid/grid.component.html b/libs/shared/src/lib/components/ui/core-grid/grid/grid.component.html index 79401f43f7..0ce5727b2d 100644 --- a/libs/shared/src/lib/components/ui/core-grid/grid/grid.component.html +++ b/libs/shared/src/lib/components/ui/core-grid/grid/grid.component.html @@ -132,7 +132,8 @@ - + + + + + + + + + + + +
+
+ {{ dataItem.text[field.name] }} +
+
+
+
+ + diff --git a/libs/shared/src/lib/survey/components/people-dropdown/people-dropdown.component.scss b/libs/shared/src/lib/survey/components/people-dropdown/people-dropdown.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/shared/src/lib/survey/components/people-dropdown/people-dropdown.component.ts b/libs/shared/src/lib/survey/components/people-dropdown/people-dropdown.component.ts new file mode 100644 index 0000000000..80c749e6b4 --- /dev/null +++ b/libs/shared/src/lib/survey/components/people-dropdown/people-dropdown.component.ts @@ -0,0 +1,52 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { GraphQLSelectModule } from '@oort-front/ui'; +import { UnsubscribeComponent } from '../../../components/utils/unsubscribe/unsubscribe.component'; +import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; +import { PeopleSelectComponent } from '../people-select/people-select.component'; + +/** + * Component to pick people from the list of people + */ +@Component({ + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + TranslateModule, + GraphQLSelectModule, + PeopleSelectComponent, + ], + selector: 'shared-people-dropdown', + templateUrl: './people-dropdown.component.html', + styleUrls: ['./people-dropdown.component.scss'], +}) +export class PeopleDropdownComponent + extends UnsubscribeComponent + implements OnInit +{ + /** Placeholder */ + public placeholder = ''; + /** Multiselect */ + @Input() multiselect = true; + /** IDs of the initial people selection */ + @Input() initialSelectionIDs!: string[] | string; + /** Form control that has selected people */ + @Input() control = new FormControl([]); + + /** + * Component to pick people from the list of people + */ + constructor() { + super(); + } + + ngOnInit() { + // Sets the form value + if (this.initialSelectionIDs) { + this.control.setValue(this.initialSelectionIDs); + } + } +} diff --git a/libs/shared/src/lib/survey/components/people-select/graphql/queries.ts b/libs/shared/src/lib/survey/components/people-select/graphql/queries.ts new file mode 100644 index 0000000000..208d017a3b --- /dev/null +++ b/libs/shared/src/lib/survey/components/people-select/graphql/queries.ts @@ -0,0 +1,13 @@ +import { gql } from '@apollo/client'; + +/** Graphql request for getting people */ +export const GET_PEOPLE = gql` + query GetPeople($filter: JSON, $offset: Int, $limitItems: Int) { + people(filter: $filter, offset: $offset, limitItems: $limitItems) { + id + firstname + lastname + emailaddress + } + } +`; diff --git a/libs/shared/src/lib/survey/components/people-select/people-select.component.ts b/libs/shared/src/lib/survey/components/people-select/people-select.component.ts new file mode 100644 index 0000000000..2fc71027e0 --- /dev/null +++ b/libs/shared/src/lib/survey/components/people-select/people-select.component.ts @@ -0,0 +1,241 @@ +import { + ChangeDetectorRef, + Component, + ElementRef, + Input, + OnInit, + Optional, + Renderer2, + Self, +} from '@angular/core'; +import { + ButtonModule, + GraphQLSelectComponent, + SelectMenuModule, + ShadowDomService, + SpinnerModule, + TooltipModule, +} from '@oort-front/ui'; +import { FormsModule, NgControl, ReactiveFormsModule } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; +import { + PeopleQueryResponse, + getPersonLabel, +} from '../../../models/people.model'; +import { Apollo, QueryRef } from 'apollo-angular'; +import { GET_PEOPLE } from './graphql/queries'; +import { takeUntil } from 'rxjs'; +import { CompositeFilterDescriptor } from '@progress/kendo-data-query'; +import { isArray } from 'lodash'; + +/** Number of char required to start the search */ +const MIN_CHAR_TO_SEARCH = 2; + +/** Minimum word size */ +const MIN_WORD_SIZE = 3; + +/** + * Component to pick people from the list of people + */ +@Component({ + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + TranslateModule, + SpinnerModule, + ButtonModule, + SelectMenuModule, + TooltipModule, + ], + selector: 'shared-people-select', + templateUrl: + '../../../../../../ui/src/lib/graphql-select/graphql-select.component.html', + styleUrls: [ + '../../../../../../ui/src/lib/graphql-select/graphql-select.component.scss', + ], +}) +export class PeopleSelectComponent + extends GraphQLSelectComponent + implements OnInit +{ + /** IDs of the initial people selection */ + @Input() initialSelectionIDs!: string[] | string; + /** People query */ + public override query!: QueryRef; + /** Store the previous search value */ + private previousSearchValue: string | null = null; + /** search filters */ + private filters: any[] = []; + /** number of items fetched after each query */ + private limitItems = 10; + /** offset for the scroll loading */ + private offset = 0; + /** boolean to control whether there are more people */ + private hasNextPage = true; + + /** + * Component to pick people from the list of people + * + * @param ngControl form control shared service, + * @param elementRef shared element ref service + * @param renderer - Angular - Renderer2 + * @param changeDetectorRef - Angular - ChangeDetectorRef + * @param shadowDomService shadow dom service to handle the current host of the component + * @param apollo Apollo service + */ + constructor( + @Optional() @Self() public override ngControl: NgControl, + public override elementRef: ElementRef, + protected override renderer: Renderer2, + protected override changeDetectorRef: ChangeDetectorRef, + protected override shadowDomService: ShadowDomService, + private apollo: Apollo + ) { + super(ngControl, elementRef, renderer, changeDetectorRef, shadowDomService); + this.displayValueExpression = getPersonLabel; + this.valueField = 'id'; + this.filterable = true; + this.searchChange.pipe(takeUntil(this.destroy$)).subscribe((event) => { + this.onSearchChange(event); + }); + this.query = this.apollo.watchQuery({ + query: GET_PEOPLE, + }); + } + + override ngOnInit(): void { + super.ngOnInit(); + this.setupInitialSelection(); + } + + /** Fetches already selected people */ + private async setupInitialSelection() { + if (!this.initialSelectionIDs || !this.initialSelectionIDs.length) { + return; + } + this.loading = true; + this.query + .refetch({ + filter: { + logic: 'or', + filters: [ + { + field: 'userid', + operator: 'in', + value: isArray(this.initialSelectionIDs) + ? this.initialSelectionIDs + : [this.initialSelectionIDs], + }, + ], + } as CompositeFilterDescriptor, + }) + .then(({ data }) => { + if (data.people) { + this.selectedElements = data.people; + } + }); + } + + /** + * Load more items on scroll. + * + * @param e scroll event + */ + protected override loadOnScroll(e: any): void { + if ( + e.target.scrollHeight - (e.target.clientHeight + e.target.scrollTop) < + 50 && + !this.loading && + this.hasNextPage + ) { + this.offset += this.limitItems; + this.loading = true; + this.query + .fetchMore({ + variables: { + filter: { + logic: 'or', + filters: this.filters, + } as CompositeFilterDescriptor, + limitItems: this.limitItems, + offset: this.offset, + }, + }) + .then((results) => { + this.updateValues(results.data, results.loading); + this.hasNextPage = results.data.people.length === this.limitItems; + }); + } + } + + /** + * Handles the search events + * + * @param searchValue New search value + */ + public onSearchChange(searchValue: string) { + if ( + searchValue.length >= MIN_CHAR_TO_SEARCH && + searchValue !== this.previousSearchValue + ) { + this.offset = 0; + this.hasNextPage = true; + const searchWords = searchValue + .split(' ') + .filter((word) => word.length >= MIN_WORD_SIZE); + + const filters = [ + { + field: 'userid', + operator: 'in', + value: isArray(this.value) ? this.value : [this.value], + }, + { + field: 'lastname', + operator: 'like', + value: searchValue, + }, + { + field: 'firstname', + operator: 'like', + value: searchValue, + }, + ]; + + if (searchWords.length > 1) { + const fields = ['lastname', 'firstname', 'emailaddress']; + searchWords.slice(0, 2).forEach((word) => { + fields.forEach((field) => { + filters.push({ + field: field, + operator: 'like', + value: word, + }); + }); + }); + } else { + filters.push({ + field: 'emailaddress', + operator: 'like', + value: searchValue, + }); + } + this.filters = filters; + this.query + .refetch({ + filter: { + logic: 'or', + filters: filters, + } as CompositeFilterDescriptor, + limitItems: this.limitItems, + }) + .then((results) => { + this.hasNextPage = results.data.people.length === this.limitItems; + }); + this.previousSearchValue = searchValue; + } + } +} diff --git a/libs/shared/src/lib/survey/components/people.ts b/libs/shared/src/lib/survey/components/people.ts new file mode 100644 index 0000000000..5f7ef91e60 --- /dev/null +++ b/libs/shared/src/lib/survey/components/people.ts @@ -0,0 +1,98 @@ +import { + ComponentCollection, + JsonMetadata, + Serializer, + SvgRegistry, +} from 'survey-core'; +import { registerCustomPropertyEditor } from './utils/component-register'; +import { CustomPropertyGridComponentTypes } from './utils/components.enum'; +import { PeopleDropdownComponent } from './people-dropdown/people-dropdown.component'; +import { DomService } from '../../services/dom/dom.service'; +import { clone } from 'lodash'; + +/** + * Inits the people component. + * + * @param componentCollectionInstance ComponentCollection + * @param domService DOM service + */ +export const init = ( + componentCollectionInstance: ComponentCollection, + domService: DomService +): void => { + // registers icon-people in the SurveyJS library + SvgRegistry.registerIconFromSvg( + 'people', + '' + ); + const component = { + name: 'people', + title: 'People multiselect', + iconName: 'icon-people', + category: 'Custom Questions', + questionJSON: { + name: 'people', + type: 'tagbox', + optionsCaption: 'Select people...', + choicesOrder: 'asc', + choices: [] as any[], + }, + onInit: (): void => { + const serializer: JsonMetadata = Serializer; + registerCustomPropertyEditor( + CustomPropertyGridComponentTypes.applicationsDropdown + ); + serializer.addProperty('people', { + name: 'placeholder', + category: 'general', + isLocalizable: true, + }); + }, + onAfterRender: async (question: any, el: HTMLElement) => { + // Hides the tagbox element + const element = + el.getElementsByTagName('kendo-multiselect')[0].parentElement; + if (element) { + element.style.display = 'none'; + } + + // People that are already selected + const selectedPersonIDs: string[] = Array.isArray(question.value) + ? clone(question.value) + : []; + + // Appends people dropdown to the question html element + const personDropdown = domService.appendComponentToBody( + PeopleDropdownComponent, + el + ); + + const instance: PeopleDropdownComponent = personDropdown.instance; + // Initial selection + instance.initialSelectionIDs = selectedPersonIDs; + if (question.placeholder) { + instance.placeholder = question.placeholder; + } + // Updates the question value when the selection changes + instance.control.valueChanges.subscribe((value) => { + question.value = value; + }); + + if (question.isReadOnly) { + instance.control.disable(); + } + + question.registerFunctionOnPropertyValueChanged( + 'readOnly', + (value: boolean) => { + if (value) { + instance.control.disable(); + } else { + instance.control.enable(); + } + } + ); + }, + }; + componentCollectionInstance.add(component); +}; diff --git a/libs/shared/src/lib/survey/components/singlePeople.ts b/libs/shared/src/lib/survey/components/singlePeople.ts new file mode 100644 index 0000000000..8ed9833fcd --- /dev/null +++ b/libs/shared/src/lib/survey/components/singlePeople.ts @@ -0,0 +1,99 @@ +import { + ComponentCollection, + JsonMetadata, + Serializer, + SvgRegistry, +} from 'survey-core'; +import { registerCustomPropertyEditor } from './utils/component-register'; +import { CustomPropertyGridComponentTypes } from './utils/components.enum'; +import { PeopleDropdownComponent } from './people-dropdown/people-dropdown.component'; +import { DomService } from '../../services/dom/dom.service'; +import { isArray, isObject } from 'lodash'; + +/** + * Inits the people component. + * + * @param componentCollectionInstance ComponentCollection + * @param domService DOM service + */ +export const init = ( + componentCollectionInstance: ComponentCollection, + domService: DomService +): void => { + // registers icon-people in the SurveyJS library + SvgRegistry.registerIconFromSvg( + 'people', + '' + ); + const component = { + name: 'singlepeople', + title: 'People single select', + iconName: 'icon-people', + category: 'Custom Questions', + questionJSON: { + name: 'singlepeople', + type: 'dropdown', + optionsCaption: 'Select people...', + choicesOrder: 'asc', + choices: [] as any[], + }, + onInit: (): void => { + const serializer: JsonMetadata = Serializer; + registerCustomPropertyEditor( + CustomPropertyGridComponentTypes.applicationsDropdown + ); + serializer.addProperty('singlepeople', { + name: 'placeholder', + category: 'general', + isLocalizable: true, + }); + }, + onAfterRender: async (question: any, el: HTMLElement) => { + // Hides the dropdown element + const element = + el.getElementsByTagName('kendo-combobox')[0].parentElement; + if (element) { + element.style.display = 'none'; + } + + // People that are already selected + const selectedPersonID: string = question.value; + + // Appends people dropdown to the question html element + const personDropdown = domService.appendComponentToBody( + PeopleDropdownComponent, + el + ); + + const instance: PeopleDropdownComponent = personDropdown.instance; + // Initial selection + instance.initialSelectionIDs = selectedPersonID; + instance.multiselect = false; + if (question.placeholder) { + instance.placeholder = question.placeholder; + } + // Updates the question value when the selection changes + instance.control.valueChanges.subscribe((value) => { + if (!isObject(value) && !isArray(value)) { + question.value = value; + } + }); + + if (question.isReadOnly) { + instance.control.disable(); + } + + question.registerFunctionOnPropertyValueChanged( + 'readOnly', + (value: boolean) => { + if (value) { + instance.control.disable(); + } else { + instance.control.enable(); + } + } + ); + }, + }; + componentCollectionInstance.add(component); +}; diff --git a/libs/shared/src/lib/survey/init.ts b/libs/shared/src/lib/survey/init.ts index b247e1d8c7..ec2b018c10 100644 --- a/libs/shared/src/lib/survey/init.ts +++ b/libs/shared/src/lib/survey/init.ts @@ -11,6 +11,8 @@ import * as ResourceComponent from './components/resource'; import * as ResourcesComponent from './components/resources'; import * as OwnerComponent from './components/owner'; import * as UsersComponent from './components/users'; +import * as PeopleComponent from './components/people'; +import * as singlepeopleComponent from './components/singlePeople'; import * as GeospatialComponent from './components/geospatial'; import * as TextWidget from './widgets/text-widget'; import * as CommentWidget from './widgets/comment-widget'; @@ -125,6 +127,8 @@ export const initCustomSurvey = ( OwnerComponent.init(apollo, ComponentCollection.Instance); UsersComponent.init(ComponentCollection.Instance, domService); GeospatialComponent.init(domService, ComponentCollection.Instance); + PeopleComponent.init(ComponentCollection.Instance, domService); + singlepeopleComponent.init(ComponentCollection.Instance, domService); } // load global properties diff --git a/libs/shared/src/lib/utils/parser/utils.ts b/libs/shared/src/lib/utils/parser/utils.ts index ce73916d5c..17dfff277b 100644 --- a/libs/shared/src/lib/utils/parser/utils.ts +++ b/libs/shared/src/lib/utils/parser/utils.ts @@ -168,7 +168,6 @@ const replaceRecordFields = ( } } ); - const links = formattedHtml.match(`href=["]?[^" >]+`); // We check for LIST fields and duplicate their only element for each subfield @@ -374,11 +373,31 @@ const replaceRecordFields = ( } } break; + case 'people': + convertedValue = `${get( + fieldsValue, + field.name + ) + .map( + (peopleId: string) => + field.choices?.find((x: any) => x.value === peopleId)?.text + ) + .join(',')} + `; + break; + case 'singlepeople': + convertedValue = `${ + field.choices?.find( + (x: any) => x.value === get(fieldsValue, field.name) + )?.text + } + `; + break; case 'owner': case 'users': case 'resources': convertedValue = `${ - value ? value.length : 0 + value ? get(fieldsValue, field.name).length : 0 } items`; break; case 'matrixdropdown': diff --git a/libs/ui/src/lib/graphql-select/graphql-select.component.ts b/libs/ui/src/lib/graphql-select/graphql-select.component.ts index 4621f961a1..fea499cb54 100644 --- a/libs/ui/src/lib/graphql-select/graphql-select.component.ts +++ b/libs/ui/src/lib/graphql-select/graphql-select.component.ts @@ -53,6 +53,8 @@ export class GraphQLSelectComponent @Input() valueField = ''; /** Input decorator for textField */ @Input() textField = ''; + /** Display value expression (replaces textField) */ + @Input() displayValueExpression: any = null; /** Input decorator for path */ @Input() path = ''; /** Whether you can select multiple items or not */ @@ -450,7 +452,7 @@ export class GraphQLSelectComponent * * @param e scroll event. */ - private loadOnScroll(e: any): void { + protected loadOnScroll(e: any): void { if ( e.target.scrollHeight - (e.target.clientHeight + e.target.scrollTop) < 50 @@ -558,6 +560,10 @@ export class GraphQLSelectComponent * @returns the display value */ public getDisplayValue(element: any) { - return get(element, this.textField); + if (this.displayValueExpression) { + return this.displayValueExpression(element); + } else { + return get(element, this.textField); + } } }