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 @@ + + + + {{ 'common.value.one' | translate }} + + + + 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] }} + + + + + (); + /** @returns whether card is still loading */ + get loading(): boolean { + return this.metadataLoading || this.dataLoading; + } + /** @returns Get query filter */ get queryFilter(): CompositeFilterDescriptor { let filter: CompositeFilterDescriptor | undefined; @@ -471,6 +478,8 @@ export class SummaryCardComponent await this.createDynamicQueryFromLayout(card); } } else if (this.useReferenceData) { + // No need to fetch meta data + this.metadataLoading = false; // Using reference data this.refData = await this.referenceDataService.loadReferenceData( card.referenceData as string @@ -526,7 +535,7 @@ export class SummaryCardComponent this.cards = this.sortedCachedCards.slice(0, this.pageInfo.pageSize); this.pageInfo.length = this.sortedCachedCards.length; } else if (this.useLayout) { - this.loading = true; + this.dataLoading = true; from( this.dataQuery?.refetch({ skip: 0, @@ -639,7 +648,7 @@ export class SummaryCardComponent ); this.scrolling = false; this.triggerRefreshCardList = false; - this.loading = loading; + this.dataLoading = loading; } /** @@ -774,7 +783,7 @@ export class SummaryCardComponent } this.scrolling = false; - this.loading = false; + this.dataLoading = false; } /** @@ -905,7 +914,7 @@ export class SummaryCardComponent // Build meta query to add information to fields this.metaQuery = this.queryBuilder.buildMetaQuery(this.layout.query); if (this.metaQuery) { - this.loading = true; + this.metadataLoading = true; const { data } = await this.metaQuery .pipe(takeUntil(this.destroy$)) .toPromise(); @@ -929,6 +938,13 @@ export class SummaryCardComponent rows: metaData.rows, }; } + //add choices for people questions + if (metaData.choices) { + return { + ...field, + choices: metaData.choices, + }; + } } return field; }); @@ -943,6 +959,7 @@ export class SummaryCardComponent ...c, metadata: this.fields, })); + this.metadataLoading = false; } } }); @@ -1005,7 +1022,9 @@ export class SummaryCardComponent private async getCardsFromAggregation( card: NonNullable ) { - this.loading = true; + // No meta data loading + this.metadataLoading = false; + this.dataLoading = true; this.dataQuery = this.aggregationService.aggregationDataWatchQuery( card.resource as string, card.aggregation as string, @@ -1039,7 +1058,6 @@ export class SummaryCardComponent e.target.scrollHeight - (e.target.clientHeight + e.target.scrollTop) < 50; if (isScrollNearBottom) { if (!this.scrolling && this.pageInfo.length > this.cards.length) { - this.cards.length; this.scrolling = true; if (this.useReferenceData) { if (!this.refData?.pageInfo?.strategy) { @@ -1082,7 +1100,7 @@ export class SummaryCardComponent this.pageInfo.pageIndex = event.pageIndex; if (this.dataQuery) { - this.loading = true; + this.dataLoading = true; const layoutQuery = this.layout?.query; from( this.dataQuery.refetch({ @@ -1104,7 +1122,7 @@ export class SummaryCardComponent .subscribe(this.updateRecordCards.bind(this)); } else if (this.useReferenceData && this.refData) { // Only set loading state if using pagination, not infinite scroll - this.loading = !this.scrolling; + this.dataLoading = !this.scrolling; const variables = this.queryPaginationVariables(event.pageIndex); from( @@ -1116,7 +1134,7 @@ export class SummaryCardComponent .pipe(takeUntil(merge(this.cancelRefresh$, this.destroy$))) .subscribe(({ items, pageInfo }) => { this.updateReferenceDataCards(items, pageInfo); - this.loading = false; + this.dataLoading = false; }); } } @@ -1219,12 +1237,12 @@ export class SummaryCardComponent }) ) .pipe(takeUntil(merge(this.cancelRefresh$, this.destroy$))) - .subscribe(() => (this.loading = false)); + .subscribe(() => (this.dataLoading = false)); } else if (this.useReferenceData) { if (this.refData?.pageInfo?.strategy) { this.refresh(); } else { - this.loading = true; + this.dataLoading = true; this.pageInfo.pageIndex = 0; this.pageInfo.skip = 0; if (e) { @@ -1255,7 +1273,7 @@ export class SummaryCardComponent this.pageInfo.pageSize ); } - this.loading = false; + this.dataLoading = false; } } } diff --git a/libs/shared/src/lib/models/people.model.ts b/libs/shared/src/lib/models/people.model.ts new file mode 100644 index 0000000000..36e21cd71c --- /dev/null +++ b/libs/shared/src/lib/models/people.model.ts @@ -0,0 +1,26 @@ +/** Model for Person object. */ +export interface Person { + id?: string; + firstname?: string; + lastname?: string; + emailaddress?: string; +} + +/** Query response for people */ +export interface PeopleQueryResponse { + people: Array; +} + +/** + * Displayed value expression for a person + * + * @param person Displayed person + * @returns Display value for the person + */ +export const getPersonLabel = (person: Person) => { + const fullname = + person.firstname && person.lastname + ? `${person.firstname}, ${person.lastname}` + : person.firstname || person.lastname; + return `${fullname} (${person.emailaddress})`; +}; diff --git a/libs/shared/src/lib/services/grid/grid.service.ts b/libs/shared/src/lib/services/grid/grid.service.ts index d6dfdb3008..22caf50533 100644 --- a/libs/shared/src/lib/services/grid/grid.service.ts +++ b/libs/shared/src/lib/services/grid/grid.service.ts @@ -18,6 +18,13 @@ import { ResourceQueryResponse } from '../../models/resource.model'; import { GET_RESOURCE_FIELDS } from './graphql/queries'; import { map } from 'rxjs'; import jsonpath from 'jsonpath'; +import { + PeopleQueryResponse, + Person, + getPersonLabel, +} from '../../models/people.model'; +import { GET_PEOPLE } from '../../survey/components/people-select/graphql/queries'; +import { CompositeFilterDescriptor } from '@progress/kendo-data-query'; /** List of disabled fields */ const DISABLED_FIELDS = [ @@ -29,6 +36,9 @@ const DISABLED_FIELDS = [ 'lastUpdateForm', ]; +/** List of disabled fields types */ +const DISABLED_FIELD_TYPES = ['people']; + /** Interface of field meta */ interface IMeta { choicesByUrl?: { @@ -222,6 +232,7 @@ export class GridService { disabled: disabled || DISABLED_FIELDS.includes(f.name) || + DISABLED_FIELD_TYPES.includes(get(metaData, 'type')) || get(metaData, 'readOnly', false) || get(metaData, 'isCalculated', false), hidden: hidden || cachedField?.hidden || false, @@ -542,4 +553,41 @@ export class GridService { }) ); } + + /** + * Get new choices for people question + * + * @param ids new user ids to fetch + * @returns users choices + */ + public getNewPeopleChoices(ids: string[]) { + return this.apollo + .query({ + query: GET_PEOPLE, + variables: { + filter: { + logic: 'or', + filters: [ + { + field: 'userid', + operator: 'in', + value: ids, + }, + ], + } as CompositeFilterDescriptor, + }, + }) + .pipe( + map(({ data }) => { + const choices: any[] = []; + data.people.forEach((person: Person) => { + choices.push({ + value: person.id, + text: getPersonLabel(person), + }); + }); + return choices; + }) + ); + } } diff --git a/libs/shared/src/lib/services/widget/widget.service.ts b/libs/shared/src/lib/services/widget/widget.service.ts index e81a64461b..b8b2742470 100644 --- a/libs/shared/src/lib/services/widget/widget.service.ts +++ b/libs/shared/src/lib/services/widget/widget.service.ts @@ -142,7 +142,7 @@ export class WidgetService { // Check parent node if contains the dataset for filtering until we hit the host node or find the node with the filter dataset while (currentNode.localName !== widgetNodeName && !ruleButtonIsClicked) { currentNode = this.renderer.parentNode(currentNode); - ruleButtonIsClicked = !!currentNode.dataset?.ruleTarget; + ruleButtonIsClicked = !!currentNode?.dataset?.ruleTarget; } } if (ruleButtonIsClicked && automationRules) { diff --git a/libs/shared/src/lib/survey/components/people-dropdown/people-dropdown.component.html b/libs/shared/src/lib/survey/components/people-dropdown/people-dropdown.component.html new file mode 100644 index 0000000000..2c7d02531d --- /dev/null +++ b/libs/shared/src/lib/survey/components/people-dropdown/people-dropdown.component.html @@ -0,0 +1,7 @@ + + 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); + } } }