diff --git a/package-lock.json b/package-lock.json index cf2e84c2aaf..25d2e56a23e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65010,7 +65010,7 @@ }, "packages/common": { "name": "@esri/hub-common", - "version": "14.119.1", + "version": "14.122.0", "license": "Apache-2.0", "dependencies": { "@terraformer/arcgis": "^2.1.2", diff --git a/packages/common/src/content/_internal/internalContentUtils.ts b/packages/common/src/content/_internal/internalContentUtils.ts index 3f99c4d9506..89b771428a2 100644 --- a/packages/common/src/content/_internal/internalContentUtils.ts +++ b/packages/common/src/content/_internal/internalContentUtils.ts @@ -248,6 +248,7 @@ export const getHubRelativeUrl = ( "project", "initiative", "discussion", + "event", ]; // default to the catchall content route let path = "/content"; diff --git a/packages/common/src/content/get-family.ts b/packages/common/src/content/get-family.ts index 30266138faa..1b9f6e63c6e 100644 --- a/packages/common/src/content/get-family.ts +++ b/packages/common/src/content/get-family.ts @@ -46,6 +46,9 @@ export function getFamily(type: string) { case "discussion": family = "discussion"; break; + case "event": + family = "event"; + break; case "hub initiative": family = "initiative"; break; diff --git a/packages/common/src/events/HubEvent.ts b/packages/common/src/events/HubEvent.ts index 77e5b65f43e..88ac817fe9c 100644 --- a/packages/common/src/events/HubEvent.ts +++ b/packages/common/src/events/HubEvent.ts @@ -61,10 +61,6 @@ export class HubEvent partialEvent: Partial, context: IArcGISContext ): IHubEvent { - // TODO: Figure out how to approach slugs for Events - // TODO: remove orgUrlKey if either: - // 1. back-end generates the slug at time of create/update - // 2. slug is derived on client from title & ID appears, e.g. `my-event-clu34rsub00003b6thiioms4a` // ensure we have the orgUrlKey if (!partialEvent.orgUrlKey) { partialEvent.orgUrlKey = context.portal.urlKey; diff --git a/packages/common/src/events/_internal/PropertyMapper.ts b/packages/common/src/events/_internal/PropertyMapper.ts index f41deb10f21..5f0d9bfdb17 100644 --- a/packages/common/src/events/_internal/PropertyMapper.ts +++ b/packages/common/src/events/_internal/PropertyMapper.ts @@ -17,6 +17,8 @@ import { IOnlineMeeting, } from "../api/orval/api/orval-events"; import { HubEventAttendanceType, HubEventOnlineCapacityType } from "../types"; +import { computeLinks } from "./computeLinks"; +import { getEventSlug } from "./getEventSlug"; /** * @private @@ -92,6 +94,8 @@ export class EventPropertyMapper extends PropertyMapper< obj.createdDateSource = "createdAt"; obj.updatedDate = new Date(store.updatedAt); obj.updatedDateSource = "updatedAt"; + obj.links = computeLinks(store as IEvent); + obj.slug = getEventSlug(store as IEvent); return obj; } diff --git a/packages/common/src/events/_internal/computeLinks.ts b/packages/common/src/events/_internal/computeLinks.ts new file mode 100644 index 00000000000..685d41204be --- /dev/null +++ b/packages/common/src/events/_internal/computeLinks.ts @@ -0,0 +1,23 @@ +import { IHubEntityLinks } from "../../core/types"; +import { getRelativeWorkspaceUrl } from "../../core/getRelativeWorkspaceUrl"; +import { getHubRelativeUrl } from "../../content/_internal/internalContentUtils"; +import { IEvent } from "../api/orval/api/orval-events"; +import { getEventSlug } from "./getEventSlug"; + +/** + * Compute the links that get appended to a Hub Event + * search result and entity + * + * @param item + * @param requestOptions + */ +export function computeLinks(event: IEvent): IHubEntityLinks { + const siteRelative = getHubRelativeUrl("event", getEventSlug(event)); + return { + self: siteRelative, + siteRelative, + workspaceRelative: getRelativeWorkspaceUrl("Event", event.id), + // TODO + // thumbnail: "", + }; +} diff --git a/packages/common/src/events/_internal/getEventSlug.ts b/packages/common/src/events/_internal/getEventSlug.ts new file mode 100644 index 00000000000..193d0046e80 --- /dev/null +++ b/packages/common/src/events/_internal/getEventSlug.ts @@ -0,0 +1,18 @@ +import { slugify } from "../../utils/slugify"; +import { IEvent } from "../api/orval/api/orval-events"; + +/** + * Builds a slug for the given IEvent record. + * @param event An IEvent record + * @returns the slug for the given IEvent record + */ +export function getEventSlug(event: IEvent): string { + return ( + [slugify(event.title), event.id] + .join("-") + // remove double hyphens + .split("-") + .filter(Boolean) + .join("-") + ); +} diff --git a/packages/common/src/events/edit.ts b/packages/common/src/events/edit.ts index 998844110ff..125b069cf30 100644 --- a/packages/common/src/events/edit.ts +++ b/packages/common/src/events/edit.ts @@ -28,7 +28,6 @@ export async function createHubEvent( // so set endDate to startDate event.endDate = event.startDate; - // TODO: how to handle slugs // TODO: how to handle events being discussable vs non-discussable const mapper = new EventPropertyMapper(getPropertyMap()); @@ -79,7 +78,6 @@ export async function updateHubEvent( ): Promise { const eventUpdates = { ...buildDefaultEventEntity(), ...event }; - // TODO: how to handle slugs // TODO: how to handle events being discussable vs non-discussable const mapper = new EventPropertyMapper(getPropertyMap()); diff --git a/packages/common/src/events/fetch.ts b/packages/common/src/events/fetch.ts index 3511b9d2894..1fa7a83f929 100644 --- a/packages/common/src/events/fetch.ts +++ b/packages/common/src/events/fetch.ts @@ -16,8 +16,10 @@ export function fetchEvent( eventId: string, requestOptions: IHubRequestOptions ): Promise { + const spl = eventId.split("-"); + const id = spl[spl.length - 1]; return getEvent({ - eventId, + eventId: id, ...requestOptions, }) .then((event) => convertClientEventToHubEvent(event, requestOptions)) diff --git a/packages/common/src/search/_internal/commonHelpers/getApi.ts b/packages/common/src/search/_internal/commonHelpers/getApi.ts index a1c2db4c329..de195db202e 100644 --- a/packages/common/src/search/_internal/commonHelpers/getApi.ts +++ b/packages/common/src/search/_internal/commonHelpers/getApi.ts @@ -5,6 +5,7 @@ import { expandApi } from "../../utils"; import { shouldUseOgcApi } from "./shouldUseOgcApi"; import { getOgcApiDefinition } from "./getOgcApiDefinition"; import { shouldUseDiscussionsApi } from "./shouldUseDiscussionsApi"; +import { shouldUseEventsApi } from "./shouldUseEventsApi"; import { getDiscussionsApiDefinition } from "./getDiscussionsApiDefinition"; /** @@ -32,6 +33,11 @@ export function getApi( result = expandApi(api); } else if (shouldUseDiscussionsApi(targetEntity, options)) { result = getDiscussionsApiDefinition(); + } else if (shouldUseEventsApi(targetEntity, options)) { + // Currently, url is null because this is handled internally by the + // events request method called by getEvents, which relies on + // the URL defined in the request options.hubApiUrl + result = { type: "arcgis-hub", url: null }; } else if (shouldUseOgcApi(targetEntity, options)) { result = getOgcApiDefinition(targetEntity, options); } else { diff --git a/packages/common/src/search/_internal/commonHelpers/shouldUseEventsApi.ts b/packages/common/src/search/_internal/commonHelpers/shouldUseEventsApi.ts new file mode 100644 index 00000000000..e1a9226f8d6 --- /dev/null +++ b/packages/common/src/search/_internal/commonHelpers/shouldUseEventsApi.ts @@ -0,0 +1,20 @@ +import { EntityType } from "../../types/IHubCatalog"; +import { IHubSearchOptions } from "../../types/IHubSearchOptions"; + +/** + * @private + * Determines if the Events API can be targeted with the given + * search parameters + * @param targetEntity + * @param options + * @returns boolean + */ +export function shouldUseEventsApi( + targetEntity: EntityType, + options: IHubSearchOptions +): boolean { + const { + requestOptions: { isPortal }, + } = options; + return targetEntity === "event" && !isPortal; +} diff --git a/packages/common/src/search/_internal/hubEventsHelpers/eventToSearchResult.ts b/packages/common/src/search/_internal/hubEventsHelpers/eventToSearchResult.ts new file mode 100644 index 00000000000..4683c22f307 --- /dev/null +++ b/packages/common/src/search/_internal/hubEventsHelpers/eventToSearchResult.ts @@ -0,0 +1,42 @@ +import { getUser } from "@esri/arcgis-rest-portal"; +import { IHubSearchOptions } from "../../types/IHubSearchOptions"; +import { IHubSearchResult } from "../../types/IHubSearchResult"; +import { IEvent } from "../../../events/api/orval/api/orval-events"; +import { AccessLevel } from "../../../core/types/types"; +import { HubFamily } from "../../../types"; +import { computeLinks } from "../../../events/_internal/computeLinks"; + +/** + * Resolves an IHubSearchResult for the given IEvent record + * @param event An IEvent record + * @param options An IHubSearchOptions object + * @returns a IHubSearchResult for the given IEvent record + */ +export async function eventToSearchResult( + event: IEvent, + options: IHubSearchOptions +): Promise { + const ownerUser = await getUser({ + username: event.creator.username, + ...options.requestOptions, + }); + const result = { + access: event.access.toLowerCase() as AccessLevel, + id: event.id, + type: "Event", + name: event.title, + owner: event.creator.username, + ownerUser, + summary: event.summary || event.description, + createdDate: new Date(event.createdAt), + createdDateSource: "event.createdAt", + updatedDate: new Date(event.updatedAt), + updatedDateSource: "event.updatedAt", + family: "event" as HubFamily, + links: computeLinks(event), + tags: event.tags, + categories: event.categories, + rawResult: event, + }; + return result; +} diff --git a/packages/common/src/search/_internal/hubEventsHelpers/processFilters.ts b/packages/common/src/search/_internal/hubEventsHelpers/processFilters.ts new file mode 100644 index 00000000000..acf42daebf2 --- /dev/null +++ b/packages/common/src/search/_internal/hubEventsHelpers/processFilters.ts @@ -0,0 +1,83 @@ +import { IFilter, IPredicate } from "../../types/IHubCatalog"; +import { + EventStatus, + GetEventsParams, +} from "../../../events/api/orval/api/orval-events"; +import { unique } from "../../../util"; + +const getPredicateValuesByKey = ( + filters: IFilter[], + predicateKey: string +): any[] => { + const toPredicateValuesByKey = (a1: any[], filter: IFilter): any[] => + filter.predicates.reduce( + (a2, predicate) => + Object.entries(predicate).reduce( + (a3, [key, val]) => (key === predicateKey ? [...a3, val] : a3), + a2 + ), + a1 + ); + return filters.reduce(toPredicateValuesByKey, []); +}; + +const getOptionalPredicateStringsByKey = ( + filters: IFilter[], + predicateKey: string +): string => { + const predicateValues = getPredicateValuesByKey(filters, predicateKey); + const str = predicateValues.filter(unique).join(","); + if (str) { + return str; + } +}; + +/** + * Builds a Partial given an Array of IFilter objects + * @param filters An Array of IFilter + * @returns a Partial for the given Array of IFilter objects + */ +export function processFilters(filters: IFilter[]): Partial { + const processedFilters: Partial = {}; + const access = getOptionalPredicateStringsByKey(filters, "access"); + if (access?.length) { + // TODO: remove ts-ignore once GetEventsParams supports filtering by access + // @ts-ignore + processedFilters.access = access; + } + const term = getPredicateValuesByKey(filters, "term"); + if (term.length) { + processedFilters.title = term[0]; + } + const categories = getOptionalPredicateStringsByKey(filters, "categories"); + if (categories?.length) { + processedFilters.categories = categories; + } + const tags = getOptionalPredicateStringsByKey(filters, "tags"); + if (tags?.length) { + processedFilters.tags = tags; + } + const attendanceType = getOptionalPredicateStringsByKey( + filters, + "attendanceType" + ); + if (attendanceType?.length) { + processedFilters.attendanceTypes = attendanceType; + } + const status = getOptionalPredicateStringsByKey(filters, "status"); + processedFilters.status = status?.length + ? status + : [EventStatus.PLANNED, EventStatus.CANCELED] + .map((val) => val.toLowerCase()) + .join(","); + const startDateRange = getPredicateValuesByKey(filters, "startDateRange"); + if (startDateRange.length) { + processedFilters.startDateTimeBefore = new Date( + startDateRange[0].to + ).toISOString(); + processedFilters.startDateTimeAfter = new Date( + startDateRange[0].from + ).toISOString(); + } + return processedFilters; +} diff --git a/packages/common/src/search/_internal/hubEventsHelpers/processOptions.ts b/packages/common/src/search/_internal/hubEventsHelpers/processOptions.ts new file mode 100644 index 00000000000..a5d8138c6bc --- /dev/null +++ b/packages/common/src/search/_internal/hubEventsHelpers/processOptions.ts @@ -0,0 +1,33 @@ +import { IHubSearchOptions } from "../../types/IHubSearchOptions"; +import { + EventSort, + GetEventsParams, + SortOrder, +} from "../../../events/api/orval/api/orval-events"; + +/** + * Builds a Partial for the given IHubSearchOptions + * @param options An IHubSearchOptions object + * @returns a Partial for the given IHubSearchOptions + */ +export function processOptions( + options: IHubSearchOptions +): Partial { + const processedOptions: Partial = {}; + if (options.num > 0) { + processedOptions.num = options.num.toString(); + } + if (options.start > 1) { + processedOptions.start = options.start.toString(); + } + if (options.sortField === "modified") { + processedOptions.sortBy = EventSort.updatedAt; + } else if (options.sortField === "created") { + processedOptions.sortBy = EventSort.createdAt; + } else if (options.sortField === "title") { + processedOptions.sortBy = EventSort.title; + } + processedOptions.sortOrder = + options.sortOrder === "desc" ? SortOrder.desc : SortOrder.asc; + return processedOptions; +} diff --git a/packages/common/src/search/_internal/hubSearchEvents.ts b/packages/common/src/search/_internal/hubSearchEvents.ts new file mode 100644 index 00000000000..306a8a13606 --- /dev/null +++ b/packages/common/src/search/_internal/hubSearchEvents.ts @@ -0,0 +1,50 @@ +import { IQuery } from "../types/IHubCatalog"; +import { IHubSearchOptions } from "../types/IHubSearchOptions"; +import { IHubSearchResponse } from "../types/IHubSearchResponse"; +import { IHubSearchResult } from "../types/IHubSearchResult"; +import { getEvents } from "../../events/api/events"; +import { GetEventsParams } from "../../events/api/orval/api/orval-events"; +import { eventToSearchResult } from "./hubEventsHelpers/eventToSearchResult"; +import { processOptions } from "./hubEventsHelpers/processOptions"; +import { processFilters } from "./hubEventsHelpers/processFilters"; + +/** + * Searches for events against the Events 3 API using the given `query` and `options` + * @param query An IQuery object + * @param options An IHubSearchOptions object + * @returns a promise that resolves a object + */ +export async function hubSearchEvents( + query: IQuery, + options: IHubSearchOptions +): Promise> { + const processedFilters = processFilters(query.filters); + const processedOptions = processOptions(options); + const data: GetEventsParams = { + ...processedFilters, + ...processedOptions, + include: "creator,registrations", + }; + const { items, nextStart, total } = await getEvents({ + ...options.requestOptions, + data, + }); + const results = await Promise.all( + items.map((event) => eventToSearchResult(event, options)) + ); + const hasNext = nextStart > -1; + return { + total, + results, + hasNext, + next: () => { + if (!hasNext) { + throw new Error("No more hub events for the given query and options"); + } + return hubSearchEvents(query, { + ...options, + start: nextStart, + }); + }, + }; +} diff --git a/packages/common/src/search/_internal/index.ts b/packages/common/src/search/_internal/index.ts index 98011b56379..a2deb8cadc8 100644 --- a/packages/common/src/search/_internal/index.ts +++ b/packages/common/src/search/_internal/index.ts @@ -3,3 +3,4 @@ export * from "./hubSearchItems"; export * from "./portalSearchGroups"; export * from "./portalSearchUsers"; export * from "./hubSearchChannels"; +export * from "./hubSearchEvents"; diff --git a/packages/common/src/search/hubSearch.ts b/packages/common/src/search/hubSearch.ts index 36e163d627d..5b4222176ba 100644 --- a/packages/common/src/search/hubSearch.ts +++ b/packages/common/src/search/hubSearch.ts @@ -18,6 +18,7 @@ import { } from "./_internal/portalSearchUsers"; import { hubSearchItems } from "./_internal/hubSearchItems"; import { hubSearchChannels } from "./_internal/hubSearchChannels"; +import { hubSearchEvents } from "./_internal/hubSearchEvents"; /** * Main entrypoint for searching via Hub @@ -86,6 +87,7 @@ export async function hubSearch( item: hubSearchItems, channel: hubSearchChannels, discussionPost: hubSearchItems, + event: hubSearchEvents, }, }; diff --git a/packages/common/src/search/types/IHubCatalog.ts b/packages/common/src/search/types/IHubCatalog.ts index 4497581b408..d87c24e73cb 100644 --- a/packages/common/src/search/types/IHubCatalog.ts +++ b/packages/common/src/search/types/IHubCatalog.ts @@ -62,7 +62,8 @@ export type EntityType = | "groupMember" | "event" | "channel" - | "discussionPost"; + | "discussionPost" + | "event"; /** * @private * diff --git a/packages/common/src/search/types/IHubSearchResult.ts b/packages/common/src/search/types/IHubSearchResult.ts index 5db13e78921..d16239b3e8d 100644 --- a/packages/common/src/search/types/IHubSearchResult.ts +++ b/packages/common/src/search/types/IHubSearchResult.ts @@ -3,6 +3,7 @@ import { AccessLevel, IHubEntityBase, IHubLocation } from "../../core"; import { HubFamily, IHubGeography } from "../../types"; import { IOgcItem } from "../_internal/hubSearchItemsHelpers/interfaces"; import { IChannel } from "../../discussions/api/types"; +import { IEvent } from "../../events/api/orval/api/orval-events"; /** * Standardized light-weight search result structure, applicable to all @@ -67,7 +68,7 @@ export interface IHubSearchResult extends IHubEntityBase { * Note: We will need to cast to the approproate type * in order to access the properties */ - rawResult?: IItem | IGroup | IUser | IOgcItem | IChannel; + rawResult?: IItem | IGroup | IUser | IOgcItem | IChannel | IEvent; /** Allow any additional properties to be added */ [key: string]: any; diff --git a/packages/common/test/events/_internal/PropertyMapper.test.ts b/packages/common/test/events/_internal/PropertyMapper.test.ts index 254865ec0bb..d723692fee7 100644 --- a/packages/common/test/events/_internal/PropertyMapper.test.ts +++ b/packages/common/test/events/_internal/PropertyMapper.test.ts @@ -128,6 +128,12 @@ describe("PropertyMapper", () => { canChangeStatusRemoved: true, readGroupIds: ["readGroup1"], editGroupIds: ["editGroup1"], + links: { + self: "/events/event-title-31c", + siteRelative: "/events/event-title-31c", + workspaceRelative: "/workspace/events/31c", + }, + slug: "event-title-31c", }); }); diff --git a/packages/common/test/events/_internal/computeLinks.test.ts b/packages/common/test/events/_internal/computeLinks.test.ts new file mode 100644 index 00000000000..db6a0b9ea7f --- /dev/null +++ b/packages/common/test/events/_internal/computeLinks.test.ts @@ -0,0 +1,17 @@ +import { IEvent } from "../../../src/events/api/orval/api/orval-events"; +import { computeLinks } from "../../../src/events/_internal/computeLinks"; + +describe("computeLinks", () => { + it("should compute links for an event", () => { + const event = { + id: "31c", + title: "My Event's are awesome! 123 - ", + } as IEvent; + const results = computeLinks(event); + expect(results).toEqual({ + self: "/events/my-events-are-awesome-123-31c", + siteRelative: "/events/my-events-are-awesome-123-31c", + workspaceRelative: "/workspace/events/31c", + }); + }); +}); diff --git a/packages/common/test/events/fetch.test.ts b/packages/common/test/events/fetch.test.ts index 32b70a7de33..bd7a08466ac 100644 --- a/packages/common/test/events/fetch.test.ts +++ b/packages/common/test/events/fetch.test.ts @@ -12,7 +12,7 @@ import { fetchEvent } from "../../src/events/fetch"; describe("HubEvent fetch module:", () => { describe("fetchEvent", () => { - it("should fetch the event", async () => { + it("should fetch the event by id", async () => { const authdCtxMgr = await ArcGISContextManager.create({ authentication: MOCK_AUTH, currentUser: { @@ -65,6 +65,59 @@ describe("HubEvent fetch module:", () => { }); expect(res.name).toEqual("my event"); }); + it("should fetch the event by slug", async () => { + const authdCtxMgr = await ArcGISContextManager.create({ + authentication: MOCK_AUTH, + currentUser: { + username: "casey", + } as unknown as PortalModule.IUser, + portal: { + name: "DC R&D Center", + id: "BRXFAKE", + urlKey: "fake-org", + } as unknown as PortalModule.IPortal, + portalUrl: "https://myserver.com", + }); + const event = { + id: "123", + access: EventAccess.PRIVATE, + allDay: false, + allowRegistration: true, + attendanceType: [EventAttendanceType.IN_PERSON], + categories: [], + editGroups: [], + endDateTime: new Date().toISOString(), + notifyAttendees: true, + readGroups: [], + startDateTime: new Date().toISOString(), + status: EventStatus.PLANNED, + tags: [], + title: "my event", + permission: { + canDelete: true, + canSetAccessToOrg: true, + canSetAccessToPrivate: true, + canSetStatusToCancelled: true, + canEdit: true, + canSetAccessToPublic: true, + canSetStatusToRemoved: true, + }, + timeZone: "America/New_York", + } as unknown as IEvent; + const getEventSpy = spyOn(eventModule, "getEvent").and.returnValue( + new Promise((resolve) => resolve(event)) + ); + const res = await fetchEvent( + "my-event-123", + authdCtxMgr.context.hubRequestOptions + ); + expect(getEventSpy).toHaveBeenCalledTimes(1); + expect(getEventSpy).toHaveBeenCalledWith({ + eventId: "123", + ...authdCtxMgr.context.hubRequestOptions, + }); + expect(res.name).toEqual("my event"); + }); it("should throw when an error occurs", async () => { const authdCtxMgr = await ArcGISContextManager.create({ authentication: MOCK_AUTH, diff --git a/packages/common/test/search/_internal/getApi.test.ts b/packages/common/test/search/_internal/getApi.test.ts index fe561554f80..a4266ababb2 100644 --- a/packages/common/test/search/_internal/getApi.test.ts +++ b/packages/common/test/search/_internal/getApi.test.ts @@ -18,7 +18,7 @@ describe("getApi", () => { } as unknown as IHubSearchOptions; expect(getApi(targetEntity, options)).toBe(SEARCH_APIS.hubQA); }); - it("otherwise returns reference to OGC API if possible", () => { + it("returns reference to OGC API", () => { const options = { site, requestOptions: { @@ -31,7 +31,7 @@ describe("getApi", () => { url: `${hubApiUrl}/api/search/v1`, }); }); - it("otherwise returns reference to Discussions API if possible", () => { + it("returns reference to Discussions API", () => { const options = { requestOptions: { hubApiUrl, @@ -43,6 +43,18 @@ describe("getApi", () => { url: null, } as any as IApiDefinition); }); + it("returns reference to Events API if targetEntity is event", () => { + const options = { + requestOptions: { + hubApiUrl, + isPortal: false, + }, + } as unknown as IHubSearchOptions; + expect(getApi("event", options)).toEqual({ + type: "arcgis-hub", + url: null, + } as any as IApiDefinition); + }); it("otherwise returns a reference to the Portal API from requestOptions", () => { const portal = "https://my-enterprise-server.com/sharing/rest"; const options = { @@ -57,7 +69,7 @@ describe("getApi", () => { url: portal, }); }); - it("otherwise returns reference to OGC API V2 API if targetEntity is discussionPost", () => { + it("returns reference to OGC API V2 API if targetEntity is discussionPost", () => { const options = { site, requestOptions: { diff --git a/packages/common/test/search/_internal/hubEventsHelpers/eventToSearchResult.test.ts b/packages/common/test/search/_internal/hubEventsHelpers/eventToSearchResult.test.ts new file mode 100644 index 00000000000..2e1da8317a3 --- /dev/null +++ b/packages/common/test/search/_internal/hubEventsHelpers/eventToSearchResult.test.ts @@ -0,0 +1,105 @@ +import * as restPortal from "@esri/arcgis-rest-portal"; +import { AccessLevel } from "../../../../src/core/types/types"; +import { EventAccess, IEvent } from "../../../../src/events/api/types"; +import { eventToSearchResult } from "../../../../src/search/_internal/hubEventsHelpers/eventToSearchResult"; +import { IHubSearchOptions } from "../../../../src/search/types/IHubSearchOptions"; + +describe("eventToSearchResult", () => { + const options = { + options: true, + requestOptions: { requestOptions: true }, + } as unknown as IHubSearchOptions; + const user = { + id: "user1", + username: "jdoe", + } as restPortal.IUser; + let event: IEvent; + let getUserSpy: jasmine.Spy; + + beforeEach(() => { + event = { + access: EventAccess.PRIVATE, + id: "31c", + title: "My event title", + creator: { + username: user.username, + }, + summary: "My event summary", + description: "My event description", + createdAt: "2024-04-22T12:56:00.189Z", + updatedAt: "2024-04-22T12:57:00.189Z", + tags: ["tag1"], + categories: ["category1"], + } as IEvent; + getUserSpy = spyOn(restPortal, "getUser").and.returnValue( + Promise.resolve(user) + ); + }); + + it("should return an IHubSearchResult for the event", async () => { + const result = await eventToSearchResult(event, options); + expect(getUserSpy).toHaveBeenCalledTimes(1); + expect(getUserSpy).toHaveBeenCalledWith({ + username: event.creator?.username, + ...options.requestOptions, + }); + expect(result).toEqual({ + access: event.access.toLowerCase() as AccessLevel, + id: event.id, + type: "Event", + name: event.title, + owner: event.creator?.username, + ownerUser: user, + summary: event.summary as string, + createdDate: jasmine.any(Date) as any, + createdDateSource: "event.createdAt", + updatedDate: jasmine.any(Date) as any, + updatedDateSource: "event.updatedAt", + family: "event", + links: { + self: `/events/my-event-title-${event.id}`, + siteRelative: `/events/my-event-title-${event.id}`, + workspaceRelative: `/workspace/events/${event.id}`, + }, + tags: event.tags, + categories: event.categories, + rawResult: event, + }); + expect(result.createdDate.toISOString()).toEqual(event.createdAt); + expect(result.updatedDate.toISOString()).toEqual(event.updatedAt); + }); + + it("should set summary to event.description when event.summary is falsey", async () => { + event.summary = null; + const result = await eventToSearchResult(event, options); + expect(getUserSpy).toHaveBeenCalledTimes(1); + expect(getUserSpy).toHaveBeenCalledWith({ + username: event.creator?.username, + ...options.requestOptions, + }); + expect(result).toEqual({ + access: event.access.toLowerCase() as AccessLevel, + id: event.id, + type: "Event", + name: event.title, + owner: event.creator?.username, + ownerUser: user, + summary: event.description as string, + createdDate: jasmine.any(Date) as any, + createdDateSource: "event.createdAt", + updatedDate: jasmine.any(Date) as any, + updatedDateSource: "event.updatedAt", + family: "event", + links: { + self: `/events/my-event-title-${event.id}`, + siteRelative: `/events/my-event-title-${event.id}`, + workspaceRelative: `/workspace/events/${event.id}`, + }, + tags: event.tags, + categories: event.categories, + rawResult: event, + }); + expect(result.createdDate.toISOString()).toEqual(event.createdAt); + expect(result.updatedDate.toISOString()).toEqual(event.updatedAt); + }); +}); diff --git a/packages/common/test/search/_internal/hubEventsHelpers/processFilters.test.ts b/packages/common/test/search/_internal/hubEventsHelpers/processFilters.test.ts new file mode 100644 index 00000000000..b4e87f24d26 --- /dev/null +++ b/packages/common/test/search/_internal/hubEventsHelpers/processFilters.test.ts @@ -0,0 +1,181 @@ +import { processFilters } from "../../../../src/search/_internal/hubEventsHelpers/processFilters"; +import { IFilter } from "../../../../src/search/types/IHubCatalog"; + +const MULTI_SELECT_FILTERS: IFilter[] = [ + { + predicates: [ + { + term: "abc", + }, + ], + }, + { + operation: "OR", + predicates: [ + { + status: "planned", + }, + { + status: "canceled", + }, + ], + }, + { + operation: "OR", + predicates: [ + { + access: "public", + }, + { + access: "org", + }, + { + access: "private", + }, + ], + }, + { + operation: "OR", + predicates: [ + { + attendanceType: "online", + }, + { + attendanceType: "in_person", + }, + ], + }, + { + operation: "OR", + predicates: [ + { + categories: "category1", + }, + { + categories: "category2", + }, + ], + }, + { + operation: "OR", + predicates: [ + { + tags: "tag1", + }, + { + tags: "tag2", + }, + ], + }, + { + operation: "OR", + predicates: [ + { + startDateRange: { + from: 1714276800000, + to: 1714363199999, + }, + }, + ], + }, +]; + +const SINGLE_SELECT_FILTERS: IFilter[] = [ + { + predicates: [ + { + term: "abc", + }, + ], + }, + { + operation: "OR", + predicates: [ + { + status: "planned", + }, + ], + }, + { + operation: "OR", + predicates: [ + { + access: "public", + }, + ], + }, + { + operation: "OR", + predicates: [ + { + attendanceType: "online", + }, + ], + }, + { + operation: "OR", + predicates: [ + { + categories: "category1", + }, + ], + }, + { + operation: "OR", + predicates: [ + { + tags: "tag1", + }, + ], + }, + { + operation: "OR", + predicates: [ + { + startDateRange: { + from: 1714276800000, + to: 1714363199999, + }, + }, + ], + }, +]; + +describe("processFilters", () => { + it("should process multi-select filters", () => { + const results = processFilters(MULTI_SELECT_FILTERS); + expect(results).toEqual({ + title: "abc", + categories: "category1,category2", + tags: "tag1,tag2", + // TODO: remove ts-ignore once GetEventsParams supports filtering by access + // @ts-ignore + access: "public,org,private", + attendanceTypes: "online,in_person", + status: "planned,canceled", + startDateTimeAfter: "2024-04-28T04:00:00.000Z", + startDateTimeBefore: "2024-04-29T03:59:59.999Z", + }); + }); + it("should process single-select filters", () => { + const results = processFilters(SINGLE_SELECT_FILTERS); + expect(results).toEqual({ + title: "abc", + categories: "category1", + tags: "tag1", + // TODO: remove ts-ignore once GetEventsParams supports filtering by access + // @ts-ignore + access: "public", + attendanceTypes: "online", + status: "planned", + startDateTimeAfter: "2024-04-28T04:00:00.000Z", + startDateTimeBefore: "2024-04-29T03:59:59.999Z", + }); + }); + it("should set some defaults", () => { + const results = processFilters([]); + expect(results).toEqual({ + status: "planned,canceled", + }); + }); +}); diff --git a/packages/common/test/search/_internal/hubEventsHelpers/processOptions.test.ts b/packages/common/test/search/_internal/hubEventsHelpers/processOptions.test.ts new file mode 100644 index 00000000000..6f8267599b1 --- /dev/null +++ b/packages/common/test/search/_internal/hubEventsHelpers/processOptions.test.ts @@ -0,0 +1,50 @@ +import { + EventSort, + SortOrder, +} from "../../../../src/events/api/orval/api/orval-events"; +import { processOptions } from "../../../../src/search/_internal/hubEventsHelpers/processOptions"; + +describe("processOptions", () => { + it("should process num", () => { + expect(processOptions({})).toEqual({ sortOrder: SortOrder.asc }); + expect(processOptions({ num: -1 })).toEqual({ sortOrder: SortOrder.asc }); + expect(processOptions({ num: 2 })).toEqual({ + num: "2", + sortOrder: SortOrder.asc, + }); + }); + it("should process start", () => { + expect(processOptions({})).toEqual({ sortOrder: SortOrder.asc }); + expect(processOptions({ start: 0 })).toEqual({ sortOrder: SortOrder.asc }); + expect(processOptions({ start: 2 })).toEqual({ + start: "2", + sortOrder: SortOrder.asc, + }); + }); + it("should process sortField", () => { + expect(processOptions({})).toEqual({ sortOrder: SortOrder.asc }); + expect(processOptions({ sortField: "other" })).toEqual({ + sortOrder: SortOrder.asc, + }); + expect(processOptions({ sortField: "created" })).toEqual({ + sortBy: EventSort.createdAt, + sortOrder: SortOrder.asc, + }); + expect(processOptions({ sortField: "modified" })).toEqual({ + sortBy: EventSort.updatedAt, + sortOrder: SortOrder.asc, + }); + expect(processOptions({ sortField: "title" })).toEqual({ + sortBy: EventSort.title, + sortOrder: SortOrder.asc, + }); + }); + it("should process sortOrder", () => { + expect(processOptions({ sortOrder: "desc" })).toEqual({ + sortOrder: SortOrder.desc, + }); + expect(processOptions({ sortOrder: "asc" })).toEqual({ + sortOrder: SortOrder.asc, + }); + }); +}); diff --git a/packages/common/test/search/_internal/hubSearchEvents.test.ts b/packages/common/test/search/_internal/hubSearchEvents.test.ts new file mode 100644 index 00000000000..c2a1d339180 --- /dev/null +++ b/packages/common/test/search/_internal/hubSearchEvents.test.ts @@ -0,0 +1,401 @@ +import { hubSearchEvents } from "../../../src/search/_internal/hubSearchEvents"; +import * as processFiltersModule from "../../../src/search/_internal/hubEventsHelpers/processFilters"; +import * as processOptionsModule from "../../../src/search/_internal/hubEventsHelpers/processOptions"; +import * as eventToSearchResultModule from "../../../src/search/_internal/hubEventsHelpers/eventToSearchResult"; +import * as eventsModule from "../../../src/events/api/events"; +import { + EventAccess, + EventAttendanceType, + EventStatus, + GetEventsParams, + IEvent, + IPagedEventResponse, + IUser, + RegistrationRole, + RegistrationStatus, +} from "../../../src/events/api/orval/api/orval-events"; +import { IQuery } from "../../../src/search/types/IHubCatalog"; +import { IHubSearchOptions } from "../../../src/search/types/IHubSearchOptions"; +import { IHubSearchResult } from "../../../src/search/types/IHubSearchResult"; + +describe("hubSearchEvents", () => { + const USER_1: IUser = { + agoId: "user1AgoId", + createdAt: "2023-06-01T16:00:00.000Z", + deleted: false, + email: "user1@esri.com", + firstName: "John", + lastName: "Doe", + optedOut: false, + updatedAt: "2023-06-01T16:00:00.000Z", + username: "j_doe", + }; + + const USER_2: IUser = { + agoId: "user2AgoId", + createdAt: "2023-05-01T16:00:00.000Z", + deleted: false, + email: "user2@esri.com", + firstName: "Betsy", + lastName: "Reagan", + optedOut: false, + updatedAt: "2023-05-01T16:00:00.000Z", + username: "b_reagan", + }; + + const USER_3: IUser = { + agoId: "user3AgoId", + createdAt: "2023-05-02T16:00:00.000Z", + deleted: false, + email: "user3@esri.com", + firstName: "Kurt", + lastName: "Florence", + optedOut: false, + updatedAt: "2023-05-02T16:00:00.000Z", + username: "k_florence", + }; + + const PAGE_1: IPagedEventResponse = { + items: [ + { + access: EventAccess.PUBLIC, + // addresses: [], // TODO + allDay: false, + allowRegistration: true, + attendanceType: [EventAttendanceType.IN_PERSON], + catalog: null, + categories: ["category1"], + createdAt: "2024-04-18T20:23:07.149Z", + createdById: "user1Id", + creator: USER_1, + description: "Event 1 description", + editGroups: ["editGroup1Id"], + endDateTime: "2040-07-15T18:00:00.000Z", + endDate: "2040-07-15", + endTime: "14:00:00", + geometry: {}, + id: "event1Id", + notifyAttendees: true, + orgId: "org1Id", + permission: { + canDelete: false, + canEdit: false, + canSetAccessToOrg: false, + canSetAccessToPrivate: false, + canSetAccessToPublic: false, + canSetStatusToCancelled: false, + canSetStatusToRemoved: false, + }, + readGroups: ["readGroup1Id"], + recurrence: null, + registrations: [ + { + createdAt: "2024-04-19T12:15:07.222Z", + createdById: "t_miller", + eventId: "event1Id", + id: 52123, + permission: { + canDelete: false, + canEdit: false, + }, + role: RegistrationRole.ATTENDEE, + status: RegistrationStatus.ACCEPTED, + type: EventAttendanceType.IN_PERSON, + updatedAt: "2024-04-19T12:15:07.222Z", + userId: "a_brown", + }, + ], + startDateTime: "2040-07-15T17:00:00.000Z", + startDate: "2040-07-15", + startTime: "13:00:00", + status: EventStatus.PLANNED, + summary: "Event 1 summary", + tags: ["tag1"], + timeZone: "America/New_York", + title: "Event 1 title", + updatedAt: "2024-04-18T20:23:08.000Z", + }, + { + access: EventAccess.PUBLIC, + // addresses: [], // TODO + allDay: false, + allowRegistration: true, + attendanceType: [EventAttendanceType.VIRTUAL], + catalog: null, + categories: ["category2"], + createdAt: "2024-04-19T20:23:07.149Z", + createdById: "user2Id", + creator: USER_2, + description: "Event 2 description", + editGroups: ["editGroup2Id"], + endDateTime: "2030-07-15T18:00:00.000Z", + endDate: "2030-07-15", + endTime: "11:00:00", + geometry: {}, + id: "event2Id", + notifyAttendees: true, + orgId: "org1Id", + permission: { + canDelete: false, + canEdit: false, + canSetAccessToOrg: false, + canSetAccessToPrivate: false, + canSetAccessToPublic: false, + canSetStatusToCancelled: false, + canSetStatusToRemoved: false, + }, + readGroups: ["readGroup2Id"], + recurrence: null, + registrations: [ + { + createdAt: "2024-04-21T11:15:07.222Z", + createdById: "t_miller", + eventId: "event2Id", + id: 52124, + permission: { + canDelete: false, + canEdit: false, + }, + role: RegistrationRole.ATTENDEE, + status: RegistrationStatus.ACCEPTED, + type: EventAttendanceType.VIRTUAL, + updatedAt: "2024-04-21T11:15:07.222Z", + userId: "b_arnold", + }, + ], + startDateTime: "2030-07-15T17:00:00.000Z", + startDate: "2030-07-15", + startTime: "10:00:00", + status: EventStatus.PLANNED, + summary: "Event 2 summary", + tags: ["tag2"], + timeZone: "America/Los_Angeles", + title: "Event 2 title", + updatedAt: "2024-04-19T20:23:07.149Z", + }, + ], + nextStart: 3, + total: 3, + }; + + const PAGE_2: IPagedEventResponse = { + items: [ + { + access: EventAccess.PRIVATE, + // addresses: [], // TODO + allDay: false, + allowRegistration: true, + attendanceType: [ + EventAttendanceType.VIRTUAL, + EventAttendanceType.IN_PERSON, + ], + catalog: null, + categories: ["category3"], + createdAt: "2024-02-25T10:10:10.120", + createdById: "user3Id", + creator: USER_3, + description: "Event 3 description", + editGroups: ["editGroup3Id"], + endDateTime: "2030-05-15T18:00:00.000Z", + endDate: "2030-05-15", + endTime: "12:00:00", + geometry: {}, + id: "event3Id", + notifyAttendees: true, + orgId: "org1Id", + permission: { + canDelete: false, + canEdit: false, + canSetAccessToOrg: false, + canSetAccessToPrivate: false, + canSetAccessToPublic: false, + canSetStatusToCancelled: false, + canSetStatusToRemoved: false, + }, + readGroups: ["readGroup3Id"], + recurrence: null, + registrations: [ + { + createdAt: "2024-06-21T11:15:07.222Z", + createdById: "c_boyd", + eventId: "event3Id", + id: 52125, + permission: { + canDelete: false, + canEdit: false, + }, + role: RegistrationRole.ATTENDEE, + status: RegistrationStatus.ACCEPTED, + type: EventAttendanceType.VIRTUAL, + updatedAt: "2024-06-21T11:15:07.222Z", + userId: "c_boyd", + }, + { + createdAt: "2024-07-21T11:15:07.222Z", + createdById: "a_burns", + eventId: "event3Id", + id: 52126, + permission: { + canDelete: false, + canEdit: false, + }, + role: RegistrationRole.ATTENDEE, + status: RegistrationStatus.ACCEPTED, + type: EventAttendanceType.IN_PERSON, + updatedAt: "2024-07-21T11:15:07.222Z", + userId: "a_burns", + }, + ], + startDateTime: "2030-05-15T16:00:00.000Z", + startDate: "2030-05-15", + startTime: "10:00:00", + status: EventStatus.PLANNED, + summary: "Event 3 summary", + tags: ["tag3"], + timeZone: "America/Denver", + title: "Event 3 title", + updatedAt: "2024-02-25T10:10:10.120", + }, + ], + nextStart: -1, + total: 3, + }; + + const query: IQuery = { + query: true, + filters: [{ predicates: [{ predicate: true }] }], + } as unknown as IQuery; + const options = { + options: true, + requestOptions: { requestOptions: true }, + } as unknown as IHubSearchOptions; + const options2 = { + options: true, + start: PAGE_1.nextStart, + requestOptions: { requestOptions: true }, + } as unknown as IHubSearchOptions; + const processedFilters = { + processedFilters: true, + } as unknown as Partial; + const processedOptions = { + processedOptions: true, + } as unknown as Partial; + const processedOptions2 = { + processedOptions: true, + start: PAGE_1.nextStart, + } as unknown as Partial; + let getEventsSpy: jasmine.Spy; + let processFiltersSpy: jasmine.Spy; + let processOptionsSpy: jasmine.Spy; + let eventToSearchResultSpy: jasmine.Spy; + + beforeEach(() => { + getEventsSpy = spyOn(eventsModule, "getEvents").and.returnValues( + Promise.resolve(PAGE_1), + Promise.resolve(PAGE_2) + ); + processFiltersSpy = spyOn( + processFiltersModule, + "processFilters" + ).and.returnValue(processedFilters); + processOptionsSpy = spyOn( + processOptionsModule, + "processOptions" + ).and.returnValues(processedOptions, processedOptions2); + eventToSearchResultSpy = spyOn( + eventToSearchResultModule, + "eventToSearchResult" + ).and.callFake((event: IEvent) => + Promise.resolve({ id: event.id } as IHubSearchResult) + ); + }); + + it("should call events and resolve with an IHubSearchResponse", async () => { + const response = await hubSearchEvents(query, options); + expect(processFiltersSpy).toHaveBeenCalledTimes(1); + expect(processFiltersSpy).toHaveBeenCalledWith(query.filters); + expect(processOptionsSpy).toHaveBeenCalledTimes(1); + expect(processOptionsSpy).toHaveBeenCalledWith(options); + expect(getEventsSpy).toHaveBeenCalledTimes(1); + expect(getEventsSpy).toHaveBeenCalledWith({ + ...options.requestOptions, + data: { + ...processedFilters, + ...processedOptions, + include: "creator,registrations", + }, + }); + expect(eventToSearchResultSpy).toHaveBeenCalledTimes(2); + expect(eventToSearchResultSpy).toHaveBeenCalledWith( + PAGE_1.items[0], + options + ); + expect(eventToSearchResultSpy).toHaveBeenCalledWith( + PAGE_1.items[1], + options + ); + expect(response).toEqual( + { + total: PAGE_1.total, + results: [ + { id: PAGE_1.items[0].id }, + { id: PAGE_1.items[1].id }, + ] as unknown as IHubSearchResult[], + hasNext: true, + next: jasmine.any(Function), + }, + "response" + ); + + // verify fetches next page of results + const results2 = await response.next(); + expect(processFiltersSpy).toHaveBeenCalledTimes(2); + expect(processFiltersSpy.calls.argsFor(1)).toEqual( + [query.filters], + "processFiltersSpy.calls.argsFor(1)" + ); + expect(processOptionsSpy).toHaveBeenCalledTimes(2); + expect(processOptionsSpy.calls.argsFor(1)).toEqual( + [options2], + "processOptionsSpy.calls.argsFor(1)" + ); + expect(getEventsSpy).toHaveBeenCalledTimes(2); + expect(getEventsSpy.calls.argsFor(1)).toEqual( + [ + { + ...options2.requestOptions, + data: { + ...processedFilters, + ...processedOptions2, + include: "creator,registrations", + }, + }, + ], + "getEventsSpy.calls.argsFor(1)" + ); + expect(eventToSearchResultSpy).toHaveBeenCalledTimes(3); + expect(eventToSearchResultSpy.calls.argsFor(2)).toEqual( + [PAGE_2.items[0], options2], + "eventToSearchResultSpy.calls.argsFor(0)" + ); + expect(results2).toEqual( + { + total: PAGE_2.total, + results: [{ id: PAGE_2.items[0].id }] as unknown as IHubSearchResult[], + hasNext: false, + next: jasmine.any(Function), + }, + "results2" + ); + + // verify throws when no more results + try { + await results2.next(); + fail("did not reject"); + } catch (e) { + expect(e.message).toEqual( + "No more hub events for the given query and options" + ); + } + }); +});