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);
}
}