From efa8e3f34808949f1e330097d986948a1440c862 Mon Sep 17 00:00:00 2001 From: Jose Garcia Date: Fri, 31 Oct 2025 15:24:24 +0100 Subject: [PATCH] AB#68534 - People Picker for forms #68534 #68534 #68534 #68534 #68534 #68534 #68534 --- libs/shared/src/lib/models/people.model.ts | 24 +++ .../people-dropdown/graphql/queries.ts | 34 ++++ .../people-dropdown.component.html | 14 ++ .../people-dropdown.component.scss | 2 + .../people-dropdown.component.ts | 163 ++++++++++++++++++ .../src/lib/survey/components/people.ts | 94 ++++++++++ libs/shared/src/lib/survey/init.ts | 3 + .../graphql-select.component.ts | 112 +++++++----- 8 files changed, 406 insertions(+), 40 deletions(-) create mode 100644 libs/shared/src/lib/models/people.model.ts create mode 100644 libs/shared/src/lib/survey/components/people-dropdown/graphql/queries.ts create mode 100644 libs/shared/src/lib/survey/components/people-dropdown/people-dropdown.component.html create mode 100644 libs/shared/src/lib/survey/components/people-dropdown/people-dropdown.component.scss create mode 100644 libs/shared/src/lib/survey/components/people-dropdown/people-dropdown.component.ts create mode 100644 libs/shared/src/lib/survey/components/people.ts 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..d33694e778 --- /dev/null +++ b/libs/shared/src/lib/models/people.model.ts @@ -0,0 +1,24 @@ +/** Model for a person returned by Common Services users query */ +export interface People { + userid: string; + firstname?: string; + lastname?: string; + emailaddress?: string; +} + +/** Variables for people search */ +export interface SearchPeopleVars { + filter?: any; + first?: number; + skip?: number; +} + +/** Search people GraphQL response */ +export interface SearchPeopleQueryResponse { + users: People[]; +} + +/** Get people by id GraphQL response */ +export interface GetPeopleByIdResponse { + users: People[]; +} diff --git a/libs/shared/src/lib/survey/components/people-dropdown/graphql/queries.ts b/libs/shared/src/lib/survey/components/people-dropdown/graphql/queries.ts new file mode 100644 index 0000000000..74ca61a92c --- /dev/null +++ b/libs/shared/src/lib/survey/components/people-dropdown/graphql/queries.ts @@ -0,0 +1,34 @@ +import { gql } from 'apollo-angular'; + +/** + * Search people in Common Services + */ +export const SEARCH_PEOPLE = gql` + query SearchPeople($filter: JSON, $first: Int, $skip: Int) { + users( + limitItems: $first + offset: $skip + sortBy: { field: "firstname", direction: "ASC" } + filter: $filter + ) { + userid + firstname + lastname + emailaddress + } + } +`; + +/** + * Fetch people by userid + */ +export const GET_PEOPLE_BY_ID = gql` + query GetPeopleById($ids: [String!]) { + users(filter: { userid_in: $ids }) { + userid + firstname + lastname + emailaddress + } + } +`; 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..388fc4d871 --- /dev/null +++ b/libs/shared/src/lib/survey/components/people-dropdown/people-dropdown.component.html @@ -0,0 +1,14 @@ + + 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..139597f9cb --- /dev/null +++ b/libs/shared/src/lib/survey/components/people-dropdown/people-dropdown.component.scss @@ -0,0 +1,2 @@ + + 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..3bf5414af1 --- /dev/null +++ b/libs/shared/src/lib/survey/components/people-dropdown/people-dropdown.component.ts @@ -0,0 +1,163 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, +} from '@angular/core'; +import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; +import { GraphQLSelectModule } from '@oort-front/ui'; +import { Apollo, ApolloBase, QueryRef } from 'apollo-angular'; +import { Subject, takeUntil } from 'rxjs'; +import { GET_PEOPLE_BY_ID, SEARCH_PEOPLE } from './graphql/queries'; +import { + GetPeopleByIdResponse, + People, + SearchPeopleQueryResponse, + SearchPeopleVars, +} from '../../../models/people.model'; + +/** Default placeholder text */ +const DEFAULT_PLACEHOLDER = 'Begin typing and select'; +/** Default page size */ +const ITEMS_PER_PAGE = 10; +/** Default characters to trigger a search */ +const MIN_SEARCH_LENGTH = 2; +/** Debounce time for search */ +const DEBOUNCE_TIME = 500; + +/** + * Dropdown component to search/select a person + */ +@Component({ + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + TranslateModule, + GraphQLSelectModule, + ], + selector: 'shared-people-dropdown', + templateUrl: './people-dropdown.component.html', + styleUrls: ['./people-dropdown.component.scss'], +}) +export class PeopleDropdownComponent implements OnInit, OnDestroy { + /** Initial selected userid */ + @Input() initialSelectionID: string | null = null; + /** Emits selected userid when selection changes */ + @Output() selectionChange = new EventEmitter(); + + /** Backing control for selected value */ + public control = new FormControl(null); + /** GraphQLSelect query */ + public query!: QueryRef; + /** Initial selection */ + public initialSelection: People[] = []; + /** CS named client */ + private csClient: ApolloBase; + /** Destroy notifier */ + private destroy$ = new Subject(); + + /** Placeholder, debounce, page size, and minimum search length (configurable via People Settings) */ + public placeholder = DEFAULT_PLACEHOLDER; + /** Debounce in ms before triggering search */ + public searchDebounce = DEBOUNCE_TIME; + /** Page size per request */ + public pageSize = ITEMS_PER_PAGE; + /** Minimum characters required to trigger search */ + public minSearchLength = MIN_SEARCH_LENGTH; + + /** + * Display formatter for the select + * + * @param p Person item + * @returns The visible option label + */ + public displayFormatter = (p: People): string => { + const name = [p.firstname, p.lastname].filter(Boolean).join(' ').trim(); + if (p.emailaddress) { + return name ? `${name} (${p.emailaddress})` : p.emailaddress; + } + return name; + }; + + /** + * Component to pick users from the list of users + * + * @param apollo Apollo client + */ + constructor(private apollo: Apollo) { + this.csClient = this.apollo.use('csClient'); + } + + ngOnInit(): void { + // Emit selection changes + this.control.valueChanges + ?.pipe(takeUntil(this.destroy$)) + .subscribe((value) => { + this.selectionChange.emit(value ?? null); + }); + + // Load initial selection if provided + if (this.initialSelectionID) { + this.csClient + .query({ + query: GET_PEOPLE_BY_ID, + variables: { ids: [this.initialSelectionID] }, + fetchPolicy: 'no-cache', + }) + .pipe(takeUntil(this.destroy$)) + .subscribe(({ data }) => { + const user = (data.users ?? [])[0]; + if (user) { + this.initialSelection = [user]; + this.control.setValue(user.userid, { emitEvent: false }); + } + }); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Handles search updates from the text input. + * + * @param searchValue New search value + */ + public onSearchChange(searchValue: string): void { + const trimmed = (searchValue || '').trim(); + if (trimmed.length < this.minSearchLength) { + if (this.query) { + const emptyFilter = JSON.stringify({ OR: [{ firstname_like: '' }] }); + this.query.refetch({ filter: emptyFilter, first: 1, skip: 0 }); + } + return; + } + const filter = JSON.stringify({ + OR: [ + { firstname_like: trimmed }, + { lastname_like: trimmed }, + { emailaddress_like: trimmed }, + ], + }); + if (!this.query) { + this.query = this.csClient.watchQuery< + SearchPeopleQueryResponse, + SearchPeopleVars + >({ + query: SEARCH_PEOPLE, + variables: { filter, first: this.pageSize, skip: 0 }, + fetchPolicy: 'no-cache', + }); + } else { + this.query.refetch({ filter, first: this.pageSize, skip: 0 }); + } + } +} 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..3f3c92a956 --- /dev/null +++ b/libs/shared/src/lib/survey/components/people.ts @@ -0,0 +1,94 @@ +import { ComponentCollection, Serializer, SvgRegistry } from 'survey-core'; +import { DomService } from '../../services/dom/dom.service'; +import { Question } from '../types'; +import { PeopleDropdownComponent } from './people-dropdown/people-dropdown.component'; + +/** + * 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', + iconName: 'icon-people', + category: 'Custom Questions', + questionJSON: { + name: 'people', + type: 'dropdown', + placeholder: 'Begin typing and select', + optionsCaption: 'Begin typing and select', + choices: [] as any[], + }, + onInit: (): void => { + Serializer.addProperty('people', { + name: 'placeholder', + category: 'People Settings', + visibleIndex: 3, + }); + Serializer.addProperty('people', { + name: 'minSearchCharactersLength:number', + category: 'People Settings', + visibleIndex: 4, + }); + Serializer.addProperty('people', { + name: 'pageSize:number', + category: 'People Settings', + visibleIndex: 5, + }); + }, + onAfterRender: (question: Question, el: HTMLElement) => { + const defaultDropdown = el.querySelector('kendo-combobox')?.parentElement; + if (defaultDropdown) { + defaultDropdown.style.display = 'none'; + } + + const peopleDropdown = domService.appendComponentToBody( + PeopleDropdownComponent, + el + ); + const instance: PeopleDropdownComponent = peopleDropdown.instance; + + instance.placeholder = + (question as any).placeholder || 'Begin typing and select'; + instance.searchDebounce = (question as any).searchDebounce || 500; + instance.minSearchLength = (question as any).minSearchLength || 2; + if ( + typeof (question as any).pageSize === 'number' && + (question as any).pageSize > 0 + ) { + instance.pageSize = (question as any).pageSize; + } + if (question.value) instance.initialSelectionID = question.value as any; + + instance.selectionChange.subscribe((userid: string | null) => { + (question as any).value = userid ?? null; + }); + + if ((question as any).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 3aab1530c1..451eb9cc91 100644 --- a/libs/shared/src/lib/survey/init.ts +++ b/libs/shared/src/lib/survey/init.ts @@ -13,6 +13,7 @@ import * as OwnerComponent from './components/owner'; import * as ResourceComponent from './components/resource'; import * as ResourcesComponent from './components/resources'; import * as UsersComponent from './components/users'; +import * as PeopleComponent from './components/people'; import * as CsApiDocsProperties from './global-properties/cs-api-docs'; import * as OtherProperties from './global-properties/others'; import * as CommentWidget from './widgets/comment-widget'; @@ -44,6 +45,7 @@ const CUSTOM_COMPONENTS = [ 'resources', 'owner', 'users', + 'people', 'geospatial', 'editor', ]; @@ -129,6 +131,7 @@ export const initCustomSurvey = ( ); OwnerComponent.init(apollo, ComponentCollection.Instance); UsersComponent.init(ComponentCollection.Instance, domService); + PeopleComponent.init(ComponentCollection.Instance, domService); GeospatialComponent.init(domService, ComponentCollection.Instance); EditorComponent.init(injector, ComponentCollection.Instance); } 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 bee6a61af6..cdc088b725 100644 --- a/libs/ui/src/lib/graphql-select/graphql-select.component.ts +++ b/libs/ui/src/lib/graphql-select/graphql-select.component.ts @@ -53,8 +53,12 @@ export class GraphQLSelectComponent @Input() valueField = ''; /** Input decorator for textField */ @Input() textField = ''; + /** Optional display formatter */ + @Input() displayFormatter?: (element: any) => string; /** Input decorator for path */ @Input() path = ''; + /** Debounce time (ms) for emitting searchChange */ + @Input() searchDebounce = 500; /** Whether you can select multiple items or not */ @Input() multiselect = false; /** Whether it is a survey question or not */ @@ -333,7 +337,11 @@ export class GraphQLSelectComponent }); // this way we can wait for 0.5s before sending an update this.searchControl.valueChanges - .pipe(debounceTime(500), distinctUntilChanged(), takeUntil(this.destroy$)) + .pipe( + debounceTime(this.searchDebounce), + distinctUntilChanged(), + takeUntil(this.destroy$) + ) .subscribe((value) => { this.cachedElements = []; this.elementSelect.resetSubscriptions(); @@ -342,9 +350,10 @@ export class GraphQLSelectComponent } ngOnChanges(changes: SimpleChanges): void { - if (changes['query'] && changes['query'].previousValue) { - // Unsubscribe from the old query - this.queryChange$.next(); + if (changes['query']) { + if (changes['query'].previousValue) { + this.queryChange$.next(); + } // Reset the loading and pageInfo states this.loading = true; @@ -353,42 +362,31 @@ export class GraphQLSelectComponent hasNextPage: true, }; - // Clear the cached elements + // Clear the cached elements and visible list this.cachedElements = []; - - // Clear the selected elements - this.selectedElements = []; - - // Clear the elements this.elements.next([]); - // Clear the search control - this.searchControl.setValue(''); - - // Clear the form control - this.ngControl?.control?.setValue(null); - - // Emit the selection change - this.selectionChange.emit(null); - // Subscribe to the new query - this.query.valueChanges - .pipe(takeUntil(this.queryChange$), takeUntil(this.destroy$)) - .subscribe(({ data, loading }) => { - this.queryName = Object.keys(data)[0]; - this.updateValues(data, loading); - }); - } else { - const elements = this.elements.getValue(); - const selectedElements = this.selectedElements.filter( - (selectedElement) => - selectedElement && - !elements.find( - (node) => node[this.valueField] === selectedElement[this.valueField] - ) - ); - this.elements.next([...selectedElements, ...elements]); + if (changes['query'].currentValue) { + this.query.valueChanges + .pipe(takeUntil(this.queryChange$), takeUntil(this.destroy$)) + .subscribe(({ data, loading }) => { + this.queryName = Object.keys(data)[0]; + this.updateValues(data, loading); + }); + } + return; } + + const elements = this.elements.getValue(); + const selectedElements = this.selectedElements.filter( + (selectedElement) => + selectedElement && + !elements.find( + (node) => node[this.valueField] === selectedElement[this.valueField] + ) + ); + this.elements.next([...selectedElements, ...elements]); } ngOnDestroy(): void { @@ -540,13 +538,40 @@ export class GraphQLSelectComponent (node) => node[this.valueField] === selectedElement[this.valueField] ) ); - this.cachedElements = updateQueryUniqueValues(this.cachedElements, [ - ...selectedElements, - ...elements, - ]); + const prevLength = this.cachedElements.length; + this.cachedElements = updateQueryUniqueValues( + this.cachedElements, + [...selectedElements, ...elements], + this.valueField + ); this.elements.next(this.cachedElements); this.queryElements = this.cachedElements; - this.pageInfo = get(data, path).pageInfo; + const resultPageInfo = get(data, path)?.pageInfo; + if (resultPageInfo) { + this.pageInfo = resultPageInfo; + } else { + // If pageInfo is missing and query uses skip, assume hasNextPage true + const queryDefinition = this.query?.options?.query + ?.definitions?.[0] as any; + const isSkip = + queryDefinition?.kind === 'OperationDefinition' && + !!queryDefinition.variableDefinitions?.find( + (x: any) => x?.variable?.name?.value === 'skip' + ); + if (isSkip) { + // If no new items were added, we reached the end + const addedCount = this.cachedElements.length - prevLength; + this.pageInfo = { + endCursor: this.pageInfo?.endCursor || '', + hasNextPage: addedCount > 0, + }; + } else { + this.pageInfo = { + endCursor: this.pageInfo?.endCursor || '', + hasNextPage: false, + }; + } + } this.loading = loading; // If it's used as a survey question, then change detector have to be manually triggered if (this.isSurveyQuestion) { @@ -561,6 +586,13 @@ export class GraphQLSelectComponent * @returns the display value */ public getDisplayValue(element: any) { + if (this.displayFormatter) { + try { + return this.displayFormatter(element); + } catch { + return get(element, this.textField); + } + } return get(element, this.textField); } }