diff --git a/package-lock.json b/package-lock.json index cf2e84c2aaf..1f774623db6 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.126.3", "license": "Apache-2.0", "dependencies": { "@terraformer/arcgis": "^2.1.2", diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index 9ec097e1f0d..c56ef7d7702 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -1,3 +1,108 @@ +## @esri/hub-common [14.127.1](https://github.com/Esri/hub.js/compare/@esri/hub-common@14.127.0...@esri/hub-common@14.127.1) (2024-05-09) + + +### Bug Fixes + +* add content to site propertyMap, allow more props on ICapabilityConfig ([#1508](https://github.com/Esri/hub.js/issues/1508)) ([c947301](https://github.com/Esri/hub.js/commit/c947301d2090e9e3a8a520e92a2026544cf18e15)) + +# @esri/hub-common [14.127.0](https://github.com/Esri/hub.js/compare/@esri/hub-common@14.126.4...@esri/hub-common@14.127.0) (2024-05-09) + + +### Features + +* **hub-common:** add attendees settings pane ([#1475](https://github.com/Esri/hub.js/issues/1475)) ([0c49423](https://github.com/Esri/hub.js/commit/0c49423ff09d9d540f3598c2daddae00ed805c1a)) + +## @esri/hub-common [14.126.4](https://github.com/Esri/hub.js/compare/@esri/hub-common@14.126.3...@esri/hub-common@14.126.4) (2024-05-08) + + +### Bug Fixes + +* **hub-common:** get-events dto enum array types ([#1507](https://github.com/Esri/hub.js/issues/1507)) ([fb2e67a](https://github.com/Esri/hub.js/commit/fb2e67a5eff93773412e3019e5c674e78c93f008)) + +## @esri/hub-common [14.126.3](https://github.com/Esri/hub.js/compare/@esri/hub-common@14.126.2...@esri/hub-common@14.126.3) (2024-05-08) + + +### Bug Fixes + +* **hub-common:** fix undefined token when getting schedule ([#1506](https://github.com/Esri/hub.js/issues/1506)) ([7f8c782](https://github.com/Esri/hub.js/commit/7f8c78214f99401979bcc9c31507f85c2002fff5)) + +## @esri/hub-common [14.126.2](https://github.com/Esri/hub.js/compare/@esri/hub-common@14.126.1...@esri/hub-common@14.126.2) (2024-05-08) + + +### Bug Fixes + +* **hub-common:** fix undefined token when getSchedule is called ([#1505](https://github.com/Esri/hub.js/issues/1505)) ([9be1d5f](https://github.com/Esri/hub.js/commit/9be1d5fa13471e3715ea3106f75df042e0e6b1c0)) + +## @esri/hub-common [14.126.1](https://github.com/Esri/hub.js/compare/@esri/hub-common@14.126.0...@esri/hub-common@14.126.1) (2024-05-08) + + +### Bug Fixes + +* updates well known types to fix gallery query ([#1492](https://github.com/Esri/hub.js/issues/1492)) ([24f0f97](https://github.com/Esri/hub.js/commit/24f0f9710f1392c8942d6ef664cfb7877c39c5d8)) + +# @esri/hub-common [14.126.0](https://github.com/Esri/hub.js/compare/@esri/hub-common@14.125.2...@esri/hub-common@14.126.0) (2024-05-07) + + +### Features + +* **hub-common:** add token to schedule api endpoints ([#1502](https://github.com/Esri/hub.js/issues/1502)) ([3206757](https://github.com/Esri/hub.js/commit/32067577788c1142f9f01e611e0abd6e469d5d27)) + +## @esri/hub-common [14.125.2](https://github.com/Esri/hub.js/compare/@esri/hub-common@14.125.1...@esri/hub-common@14.125.2) (2024-05-07) + + +### Bug Fixes + +* **hub-common:** add IAssociation, update IEvent with associations, a… ([#1498](https://github.com/Esri/hub.js/issues/1498)) ([2562bd2](https://github.com/Esri/hub.js/commit/2562bd275740b49307375c6013e590fb5e9c28f9)) + +## @esri/hub-common [14.125.1](https://github.com/Esri/hub.js/compare/@esri/hub-common@14.125.0...@esri/hub-common@14.125.1) (2024-05-06) + + +### Bug Fixes + +* **hub-common:** fix unified list feature flag, format order, and additional resource calculation ([#1501](https://github.com/Esri/hub.js/issues/1501)) ([46d4715](https://github.com/Esri/hub.js/commit/46d4715bc5f330dc15fda57ee12601a62d80d782)) + +# @esri/hub-common [14.125.0](https://github.com/Esri/hub.js/compare/@esri/hub-common@14.124.0...@esri/hub-common@14.125.0) (2024-05-06) + + +### Features + +* scheduler force update ([#1494](https://github.com/Esri/hub.js/issues/1494)) ([5a8c47b](https://github.com/Esri/hub.js/commit/5a8c47b5499bab2da494003082c38bd8b95aafdf)) + +# @esri/hub-common [14.124.0](https://github.com/Esri/hub.js/compare/@esri/hub-common@14.123.1...@esri/hub-common@14.124.0) (2024-05-06) + + +### Features + +* remove default groups ([#1483](https://github.com/Esri/hub.js/issues/1483)) ([61a48f4](https://github.com/Esri/hub.js/commit/61a48f4b6082e93862d1e751e482190dfa577044)) + +## @esri/hub-common [14.123.1](https://github.com/Esri/hub.js/compare/@esri/hub-common@14.123.0...@esri/hub-common@14.123.1) (2024-05-03) + + +### Bug Fixes + +* **hub-common:** fix createHubEvent method and property mappings ([#1496](https://github.com/Esri/hub.js/issues/1496)) ([944dcb8](https://github.com/Esri/hub.js/commit/944dcb8de601368e914e2fa1b077ba265f4820c9)) + +# @esri/hub-common [14.123.0](https://github.com/Esri/hub.js/compare/@esri/hub-common@14.122.1...@esri/hub-common@14.123.0) (2024-05-02) + + +### Features + +* **hub-common:** add support for searching for Events3 events from h… ([#1476](https://github.com/Esri/hub.js/issues/1476)) ([4afe9a4](https://github.com/Esri/hub.js/commit/4afe9a4e2bf366ebbca39923818cc29ec4121c0f)) + +## @esri/hub-common [14.122.1](https://github.com/Esri/hub.js/compare/@esri/hub-common@14.122.0...@esri/hub-common@14.122.1) (2024-05-02) + + +### Bug Fixes + +* **hub-common:** restrict temp:hub:content:downloads:unifiedList to be behind a feature flag ([#1495](https://github.com/Esri/hub.js/issues/1495)) ([d53f50f](https://github.com/Esri/hub.js/commit/d53f50fef63edbb63ba3e74a8ccd068799288e54)) + +# @esri/hub-common [14.122.0](https://github.com/Esri/hub.js/compare/@esri/hub-common@14.121.2...@esri/hub-common@14.122.0) (2024-05-01) + + +### Features + +* **hub-common:** add new utils for a unified download flow ([#1482](https://github.com/Esri/hub.js/issues/1482)) ([7f45d61](https://github.com/Esri/hub.js/commit/7f45d61bec8c8b79227834df6ac02ea4ae7f4328)) + ## @esri/hub-common [14.121.2](https://github.com/Esri/hub.js/compare/@esri/hub-common@14.121.1...@esri/hub-common@14.121.2) (2024-04-30) diff --git a/packages/common/package-lock.json b/packages/common/package-lock.json index c9e599d1633..1a8ed9ce336 100644 --- a/packages/common/package-lock.json +++ b/packages/common/package-lock.json @@ -1,12 +1,12 @@ { "name": "@esri/hub-common", - "version": "14.121.2", + "version": "14.127.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@esri/hub-common", - "version": "14.121.2", + "version": "14.127.1", "license": "Apache-2.0", "dependencies": { "@terraformer/arcgis": "^2.1.2", diff --git a/packages/common/package.json b/packages/common/package.json index 163361dd168..4fc147c76f3 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@esri/hub-common", - "version": "14.121.2", + "version": "14.127.1", "description": "Common TypeScript types and utility functions for @esri/hub.js.", "main": "dist/node/index.js", "module": "dist/esm/index.js", diff --git a/packages/common/src/ArcGISContextManager.ts b/packages/common/src/ArcGISContextManager.ts index 0a3766a1f0a..8ac7943fbf6 100644 --- a/packages/common/src/ArcGISContextManager.ts +++ b/packages/common/src/ArcGISContextManager.ts @@ -739,6 +739,7 @@ const HUB_SERVICE_STATUS: HubServiceStatus = { notifications: "online", "hub-search": "online", domains: "online", + "hub-downloads": "online", }; const ENTERPRISE_SITES_SERVICE_STATUS: HubServiceStatus = { @@ -749,6 +750,7 @@ const ENTERPRISE_SITES_SERVICE_STATUS: HubServiceStatus = { notifications: "not-available", "hub-search": "not-available", domains: "not-available", + "hub-downloads": "not-available", }; const DEV_ALPHA_ORGS = [ diff --git a/packages/common/src/content/_internal/ContentBusinessRules.ts b/packages/common/src/content/_internal/ContentBusinessRules.ts index a032eedec2b..ad38de07c2e 100644 --- a/packages/common/src/content/_internal/ContentBusinessRules.ts +++ b/packages/common/src/content/_internal/ContentBusinessRules.ts @@ -29,6 +29,7 @@ export const ContentPermissions = [ "hub:content:manage", "hub:content:canRecordDownloadErrors", "hub:content:downloads:displayErrors", + "temp:hub:content:downloads:unifiedList", ] as const; /** @@ -134,4 +135,8 @@ export const ContentPermissionPolicies: IPermissionPolicy[] = [ availability: ["alpha"], environments: ["qaext", "devext"], }, + { + permission: "temp:hub:content:downloads:unifiedList", + availability: ["flag"], + }, ]; diff --git a/packages/common/src/content/_internal/ContentSchema.ts b/packages/common/src/content/_internal/ContentSchema.ts index 323fc625cae..dfde2dd2bcc 100644 --- a/packages/common/src/content/_internal/ContentSchema.ts +++ b/packages/common/src/content/_internal/ContentSchema.ts @@ -24,5 +24,12 @@ export const ContentSchema: IConfigurationSchema = { schedule: { type: "object", }, + _forceUpdate: { + type: "array", + items: { + type: "boolean", + enum: [true], + }, + }, }, } as IConfigurationSchema; diff --git a/packages/common/src/content/_internal/ContentUiSchemaSettings.ts b/packages/common/src/content/_internal/ContentUiSchemaSettings.ts index b91e6b5766a..da42bffdbc8 100644 --- a/packages/common/src/content/_internal/ContentUiSchemaSettings.ts +++ b/packages/common/src/content/_internal/ContentUiSchemaSettings.ts @@ -1,7 +1,7 @@ import { checkPermission } from "../.."; import { IArcGISContext } from "../../ArcGISContext"; import { EntityEditorOptions } from "../../core/schemas/internal/EditorOptions"; -import { IUiSchema } from "../../core/schemas/types"; +import { IUiSchema, UiSchemaRuleEffects } from "../../core/schemas/types"; import { IHubEditableContent } from "../../core/types/IHubEditableContent"; import { isHostedFeatureServiceEntity } from "../hostedServiceUtils"; @@ -45,13 +45,26 @@ export const buildUiSchema = async ( { type: "weekly" }, { type: "monthly" }, { type: "yearly" }, - // uncomment this when the manual option is available - // { - // label: `option.manual.label`, - // type: "manual", - // helperActionIcon: "information-f", - // helperActionText: "option.manual.helperActionText", - // }, + { + type: "manual", + helperActionIcon: "information-f", + helperActionText: `{{${i18nScope}.fields.schedule.manual.helperActionText:translate}}`, + }, + ], + }, + }, + // force update checkbox -- TODO: replace with button once available + { + type: "Control", + scope: "/properties/_forceUpdate", + options: { + control: "hub-field-input-tile-select", + type: "checkbox", + labels: [ + `{{${i18nScope}.fields.schedule.forceUpdateButton.label:translate}}`, + ], + descriptions: [ + `{{${i18nScope}.fields.schedule.forceUpdateButton.description:translate}}`, ], }, }, diff --git a/packages/common/src/content/_internal/computeProps.ts b/packages/common/src/content/_internal/computeProps.ts index f5505686924..b777c573299 100644 --- a/packages/common/src/content/_internal/computeProps.ts +++ b/packages/common/src/content/_internal/computeProps.ts @@ -1,9 +1,13 @@ import { IRequestOptions } from "@esri/arcgis-rest-request"; import { UserSession } from "@esri/arcgis-rest-auth"; import { getItemThumbnailUrl } from "../../resources"; -import { IModel } from "../../types"; +import { IHubRequestOptions, IModel } from "../../types"; import { getItemHomeUrl } from "../../urls/get-item-home-url"; -import { getContentEditUrl, getHubRelativeUrl } from "./internalContentUtils"; +import { + getAdditionalResources, + getContentEditUrl, + getHubRelativeUrl, +} from "./internalContentUtils"; import { IHubEditableContent } from "../../core/types/IHubEditableContent"; import { getRelativeWorkspaceUrl } from "../../core/getRelativeWorkspaceUrl"; import { isDiscussable } from "../../discussions"; @@ -12,6 +16,7 @@ import { ServiceCapabilities, } from "../hostedServiceUtils"; import { computeBaseProps } from "../../core/_internal/computeBaseProps"; +import { getProp } from "../../objects"; import { IHubEditableContentEnrichments } from "../../items/_enrichments"; export function computeProps( @@ -58,6 +63,20 @@ export function computeProps( ServiceCapabilities.EXTRACT, enrichments.server ); + const extractFormatsList: string = getProp( + enrichments, + "server.supportedExportFormats" + ); + content.serverExtractFormats = + extractFormatsList && extractFormatsList.split(","); + } + + if (enrichments.metadata) { + content.additionalResources = getAdditionalResources( + model.item, + enrichments.metadata, + requestOptions as IHubRequestOptions + ); } return content as IHubEditableContent; diff --git a/packages/common/src/content/_internal/getSchedulerApiUrl.ts b/packages/common/src/content/_internal/getSchedulerApiUrl.ts deleted file mode 100644 index 7919d1f515c..00000000000 --- a/packages/common/src/content/_internal/getSchedulerApiUrl.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { IRequestOptions } from "@esri/arcgis-rest-request"; -import { getHubApiUrl } from "../../api"; - -export function getSchedulerApiUrl( - itemId: string, - requestOptions: IRequestOptions -): string { - // sometimes the url has /api/v3 at the end, so we need to remove it - const hubApiUrlWithVersion = getHubApiUrl(requestOptions); - const hubApiUrlRoot = hubApiUrlWithVersion.replace(/\/api\/v3$/, ""); - return `${hubApiUrlRoot}/api/download/v1/items/${itemId}/schedule`; -} diff --git a/packages/common/src/content/_internal/internalContentUtils.ts b/packages/common/src/content/_internal/internalContentUtils.ts index 8f8ed80237a..6006578d5d6 100644 --- a/packages/common/src/content/_internal/internalContentUtils.ts +++ b/packages/common/src/content/_internal/internalContentUtils.ts @@ -41,6 +41,8 @@ import { _getHubUrlFromPortalHostname } from "../../urls/_get-hub-url-from-porta import { IRequestOptions } from "@esri/arcgis-rest-request"; import { geojsonToArcGIS } from "@terraformer/arcgis"; import { Polygon } from "geojson"; +import { getHubApiUrl } from "../../api"; +import { IUserRequestOptions } from "@esri/arcgis-rest-auth"; /** * Hashmap of Hub environment and application url surfix @@ -247,6 +249,7 @@ export const getHubRelativeUrl = ( "project", "initiative", "discussion", + "event", ]; // default to the catchall content route let path = "/content"; @@ -965,3 +968,35 @@ const getUrbanModelEditUrl = (item: IItem, requestOptions: IRequestOptions) => { const isPortalFromUrl = (portalUrl: string): boolean => { return portalUrl.indexOf("arcgis.com") === -1; }; + +export function getSchedulerApiUrl( + itemId: string, + requestOptions: IUserRequestOptions +): string { + const hubApiUrlRoot = getHubApiUrlRoot(requestOptions); + return `${hubApiUrlRoot}/api/download/v1/items/${itemId}/schedule?token=${requestOptions.authentication.token}`; +} + +export function getHubApiUrlRoot(requestOptions: IRequestOptions): string { + // sometimes the url has /api/v3 at the end, so we need to remove it + const hubApiUrlWithVersion = getHubApiUrl(requestOptions); + return hubApiUrlWithVersion.replace(/\/api\/v3$/, ""); +} + +export const forceUpdateContent = async ( + itemId: string, + requestOptions: IUserRequestOptions +) => { + const hubApiUrlRoot = getHubApiUrl(requestOptions); + const url = `${hubApiUrlRoot}/api/v3/jobs/item/${itemId}/harvest`; + const options = { + method: "POST", + headers: { + "content-type": "application/json", + authorization: requestOptions.authentication.token, + }, + }; + + const response = await fetch(url, options); + return response.ok; +}; diff --git a/packages/common/src/content/edit.ts b/packages/common/src/content/edit.ts index 0f01c990da6..bfee5cc7e1d 100644 --- a/packages/common/src/content/edit.ts +++ b/packages/common/src/content/edit.ts @@ -39,6 +39,8 @@ import { isDownloadSchedulingAvailable, maybeUpdateSchedule, } from "./manageSchedule"; +import { forceUpdateContent } from "./_internal/internalContentUtils"; +import { deepEqual } from "../objects"; // TODO: move this to defaults? const DEFAULT_CONTENT_MODEL: IModel = { @@ -174,6 +176,14 @@ export async function updateContent( } if (isDownloadSchedulingAvailable(requestOptions, content.access)) { + // if schedule has "Force Update" checked and clicked save, initiate an update + if (deepEqual(content._forceUpdate, [true])) { + // [true] + await forceUpdateContent(item.id, requestOptions); + } + + delete content._forceUpdate; + await maybeUpdateSchedule(content, requestOptions); } diff --git a/packages/common/src/content/fetch.ts b/packages/common/src/content/fetch.ts index f55d382bcd6..53868ca50f3 100644 --- a/packages/common/src/content/fetch.ts +++ b/packages/common/src/content/fetch.ts @@ -36,6 +36,7 @@ import { computeProps } from "./_internal/computeProps"; import { isHostedFeatureServiceItem } from "./hostedServiceUtils"; import { setProp } from "../objects"; import { getSchedule, isDownloadSchedulingAvailable } from "./manageSchedule"; +import { IUserRequestOptions } from "@esri/arcgis-rest-auth"; const hasFeatures = (contentType: string) => ["Feature Layer", "Table"].includes(contentType); @@ -251,8 +252,7 @@ export const fetchHubContent = async ( requestOptions: IRequestOptions ): Promise => { // NOTE: b/c we have to support slugs we use fetchContent() to get the item - // by telling it to not fetch any enrichments - // which we then fetch as needed after we have the item + // by telling it to not fetch any enrichments which we then fetch as needed after we have the item const options = { ...requestOptions, enrichments: [], @@ -267,6 +267,15 @@ export const fetchHubContent = async ( const model = { item }; const enrichments: IHubEditableContentEnrichments = {}; + + const { metadata } = await fetchItemEnrichments( + item, + ["metadata"], + requestOptions as IHubRequestOptions + ); + + enrichments.metadata = metadata; + if (isHostedFeatureServiceItem(item)) { enrichments.server = await getService({ ...requestOptions, @@ -274,10 +283,13 @@ export const fetchHubContent = async ( }); } - if (isDownloadSchedulingAvailable(requestOptions, access)) { + if ( + isDownloadSchedulingAvailable(requestOptions as IHubRequestOptions, access) + ) { // fetch schedule and add it to enrichments if it exists in schedule API - enrichments.schedule = (await getSchedule(item.id, requestOptions)) - .schedule || { mode: "automatic" }; + enrichments.schedule = ( + await getSchedule(item.id, requestOptions as IUserRequestOptions) + ).schedule || { mode: "automatic" }; } return modelToHubEditableContent(model, requestOptions, enrichments); 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/content/manageSchedule.ts b/packages/common/src/content/manageSchedule.ts index 0aaff9fe259..96596e77c3c 100644 --- a/packages/common/src/content/manageSchedule.ts +++ b/packages/common/src/content/manageSchedule.ts @@ -1,9 +1,10 @@ -import { IRequestOptions } from "@esri/arcgis-rest-request"; import { IHubSchedule, IHubScheduleResponse } from "../core/types/IHubSchedule"; import { cloneObject } from "../util"; import { deepEqual } from "../objects/deepEqual"; import { AccessLevel, IHubEditableContent } from "../core"; -import { getSchedulerApiUrl } from "./_internal/getSchedulerApiUrl"; +import { getSchedulerApiUrl } from "./_internal/internalContentUtils"; +import { IUserRequestOptions } from "@esri/arcgis-rest-auth"; +import { IHubRequestOptions } from "../types"; // Any code referencing these functions must first pass isDownloadSchedulingAvailable @@ -15,7 +16,7 @@ import { getSchedulerApiUrl } from "./_internal/getSchedulerApiUrl"; */ export const getSchedule = async ( itemId: string, - requestOptions: IRequestOptions + requestOptions: IUserRequestOptions ): Promise => { const fetchResponse = await fetch(getSchedulerApiUrl(itemId, requestOptions)); const schedule = await fetchResponse.json(); @@ -27,10 +28,20 @@ export const getSchedule = async ( } as IHubScheduleResponse; } + // if the schedule mode is set to manual, return it + if (schedule.mode === "manual") { + return { + schedule: { + mode: "manual", + }, + message: `Download schedule found for item ${itemId}`, + statusCode: 200, + } as IHubScheduleResponse; + } + // if the schedule is set, return it with added mode delete schedule.itemId; switch (schedule.cadence) { - // TODO: add manual option here when option is viable case "daily": case "weekly": case "monthly": @@ -55,10 +66,13 @@ export const getSchedule = async ( export const setSchedule = async ( itemId: string, schedule: IHubSchedule, - requestOptions: IRequestOptions + requestOptions: IUserRequestOptions ): Promise => { const body = cloneObject(schedule); - delete body.mode; + if (body.mode !== "manual") { + // remove mode if not manual + delete body.mode; + } const url = getSchedulerApiUrl(itemId, requestOptions); const options = { method: "POST", @@ -85,7 +99,7 @@ export const setSchedule = async ( */ export const deleteSchedule = async ( itemId: string, - requestOptions: IRequestOptions + requestOptions: IUserRequestOptions ): Promise => { const url = getSchedulerApiUrl(itemId, requestOptions); const options = { @@ -108,7 +122,7 @@ export const deleteSchedule = async ( */ export const maybeUpdateSchedule = async ( content: IHubEditableContent, - requestOptions: IRequestOptions + requestOptions: IUserRequestOptions ): Promise => { const scheduleResponse = await getSchedule(content.id, requestOptions); @@ -146,8 +160,13 @@ export const maybeUpdateSchedule = async ( * @returns Whether or not the scheduling feature is available */ export const isDownloadSchedulingAvailable = ( - requestOptions: IRequestOptions, + requestOptions: IHubRequestOptions, access: AccessLevel ): boolean => { - return requestOptions.portal?.includes("arcgis.com") && access === "public"; + const token = requestOptions.authentication?.token; + return ( + requestOptions.portal?.includes("arcgis.com") && + access === "public" && + !!token + ); }; diff --git a/packages/common/src/core/schemas/internal/getEditorSchemas.ts b/packages/common/src/core/schemas/internal/getEditorSchemas.ts index 1f6000cc43f..b29ff59a9e6 100644 --- a/packages/common/src/core/schemas/internal/getEditorSchemas.ts +++ b/packages/common/src/core/schemas/internal/getEditorSchemas.ts @@ -353,12 +353,16 @@ export async function getEditorSchemas( import("../../../events/_internal/EventSchemaCreate"), "hub:event:edit": () => import("../../../events/_internal/EventSchemaEdit"), + "hub:event:attendees": () => + import("../../../events/_internal/EventSchemaAttendeesSettings"), }[type as EventEditorType](); const eventUiSchemaModule = await { "hub:event:create": () => import("../../../events/_internal/EventUiSchemaCreate"), "hub:event:edit": () => import("../../../events/_internal/EventUiSchemaEdit"), + "hub:event:attendees": () => + import("../../../events/_internal/EventUiSchemaAttendeesSettings"), }[type as EventEditorType](); schema = eventSchemaModule.buildSchema(); uiSchema = await eventUiSchemaModule.buildUiSchema( diff --git a/packages/common/src/core/traits/ICapabilityConfig.ts b/packages/common/src/core/traits/ICapabilityConfig.ts index 13ccb7477ae..b715761cfd3 100644 --- a/packages/common/src/core/traits/ICapabilityConfig.ts +++ b/packages/common/src/core/traits/ICapabilityConfig.ts @@ -1,4 +1,4 @@ -import { IFilter, IHubCatalog } from "../../search/types/IHubCatalog"; +import { IHubCatalog } from "../../search/types/IHubCatalog"; export type HubCapability = | "events" @@ -14,16 +14,16 @@ export type HubCapability = */ export interface ICapabilityConfig { enabled: boolean; - /** - * If defined, the filter will be injected into - * the entitie's content catalog's scope when the gallery is - * rendered - */ - filter?: IFilter; + /** * If defined, this catalog is used to render the gallery */ catalog?: IHubCatalog; + + /** + * Enable extensibiliy as we prototype + */ + [key: string]: any; } /** * We intentionally want these interfaces to exist even if they are empty diff --git a/packages/common/src/core/types/HubEntityEditor.ts b/packages/common/src/core/types/HubEntityEditor.ts index 955a9c69048..5d007b2bc3f 100644 --- a/packages/common/src/core/types/HubEntityEditor.ts +++ b/packages/common/src/core/types/HubEntityEditor.ts @@ -7,11 +7,6 @@ export type HubEntityEditor = Record; // IHubProjectEditor; // | IH * Additional context that can be passed to `toEditor()` functions */ export interface IEntityEditorContext { - // the concept of core/content groups might be going away in - // the future, but we'll include for now - collaborationGroupId?: string; - contentGroupId?: string; - // represents the current metric id being edited in the editor experience. metricId?: string; } diff --git a/packages/common/src/core/types/IHubEditableContent.ts b/packages/common/src/core/types/IHubEditableContent.ts index c6ce476c3a9..ffc9ff84bfe 100644 --- a/packages/common/src/core/types/IHubEditableContent.ts +++ b/packages/common/src/core/types/IHubEditableContent.ts @@ -1,4 +1,5 @@ import { IWithPermissions, IWithSlug } from "../traits/index"; +import { IHubAdditionalResource } from "./IHubAdditionalResource"; import { IHubItemEntity, IHubItemEntityEditor } from "./IHubItemEntity"; import { IHubSchedule } from "./IHubSchedule"; @@ -19,6 +20,15 @@ export interface IHubEditableContent * capability enabled. This is a pre-requisite for Hosted Downloads to work. */ serverExtractCapability?: boolean; + /** + * If the item represents a hosted feature service with "Extract enabled", shows the formats that + * can be extracted from the service via the "createReplica" operation. + */ + serverExtractFormats?: string[]; + /** + * links to additional resources specified in the formal item metadata + */ + additionalResources?: IHubAdditionalResource[]; /** * The schedule at which the reharvest of the item will occur */ diff --git a/packages/common/src/core/types/ISystemStatus.ts b/packages/common/src/core/types/ISystemStatus.ts index 96d0109146a..5c4fca441e4 100644 --- a/packages/common/src/core/types/ISystemStatus.ts +++ b/packages/common/src/core/types/ISystemStatus.ts @@ -22,6 +22,7 @@ const validServices = [ "notifications", "hub-search", "domains", + "hub-downloads", ] as const; export type HubService = (typeof validServices)[number]; diff --git a/packages/common/src/downloads/_internal/_types.ts b/packages/common/src/downloads/_internal/_types.ts new file mode 100644 index 00000000000..69d5dc93153 --- /dev/null +++ b/packages/common/src/downloads/_internal/_types.ts @@ -0,0 +1,65 @@ +import { ServiceDownloadFormat } from "../types"; + +/** + * Formats supported by the /export endpoint of the Portal API. + */ +export const EXPORT_ITEM_FORMATS = [ + ServiceDownloadFormat.CSV, + ServiceDownloadFormat.KML, + ServiceDownloadFormat.SHAPEFILE, + ServiceDownloadFormat.FILE_GDB, + ServiceDownloadFormat.GEOJSON, + ServiceDownloadFormat.EXCEL, + ServiceDownloadFormat.FEATURE_COLLECTION, +] as const; + +export type ExportItemFormat = (typeof EXPORT_ITEM_FORMATS)[number]; + +/** + * Formats supported by the /exportImage endpoint of Image Services. + */ +export const EXPORT_IMAGE_FORMATS = [ + ServiceDownloadFormat.BIP, + ServiceDownloadFormat.BMP, + ServiceDownloadFormat.BSQ, + ServiceDownloadFormat.GIF, + ServiceDownloadFormat.JPG, + ServiceDownloadFormat.JPG_PNG, + ServiceDownloadFormat.LERC, + ServiceDownloadFormat.PNG, + ServiceDownloadFormat.PNG24, + ServiceDownloadFormat.PNG32, + ServiceDownloadFormat.PNG8, + ServiceDownloadFormat.TIFF, +] as const; +export type ExportImageFormat = (typeof EXPORT_IMAGE_FORMATS)[number]; + +/** + * Formats supported by the paging operation endpoint of the Hub Download API. + * Listed in the default order of appearance in the UI. + */ +export const HUB_PAGING_JOB_FORMATS = [ + ServiceDownloadFormat.CSV, + ServiceDownloadFormat.SHAPEFILE, + ServiceDownloadFormat.GEOJSON, + ServiceDownloadFormat.KML, +] as const; +export type HubPagingJobFormat = (typeof HUB_PAGING_JOB_FORMATS)[number]; + +/** + * Known formats supported by the /createReplica endpoint of the Hub Download API. + * Listed in the default order of appearance in the UI. + * NOTE: this is may be incomplete and should be updated as needed. + */ +export const CREATE_REPLICA_FORMATS = [ + ServiceDownloadFormat.CSV, + ServiceDownloadFormat.SHAPEFILE, + ServiceDownloadFormat.GEOJSON, + ServiceDownloadFormat.FILE_GDB, + ServiceDownloadFormat.FEATURE_COLLECTION, + ServiceDownloadFormat.EXCEL, + ServiceDownloadFormat.GEO_PACKAGE, + ServiceDownloadFormat.SQLITE, + ServiceDownloadFormat.JSON, +] as const; +export type CreateReplicaFormat = (typeof CREATE_REPLICA_FORMATS)[number]; diff --git a/packages/common/src/downloads/_internal/canCreateExportItem.ts b/packages/common/src/downloads/_internal/canCreateExportItem.ts new file mode 100644 index 00000000000..a6d4e2c0bcf --- /dev/null +++ b/packages/common/src/downloads/_internal/canCreateExportItem.ts @@ -0,0 +1,20 @@ +import { IArcGISContext } from "../../ArcGISContext"; +import { IHubEditableContent } from "../../core/types/IHubEditableContent"; + +/** + * @private + * Determines if the current user can create an export item for the given entity. + * + * NOTE: This function is a placeholder. Various permissions and logic branches are not yet implemented. + * + * @param _entity + * @param _context + * @returns + */ +export function canCreateExportItem( + _entity: IHubEditableContent, + _context: IArcGISContext +) { + // TODO: port over logic from the download-service + return true; +} diff --git a/packages/common/src/downloads/_internal/canUseExportImageFlow.ts b/packages/common/src/downloads/_internal/canUseExportImageFlow.ts new file mode 100644 index 00000000000..e06e14c9bb5 --- /dev/null +++ b/packages/common/src/downloads/_internal/canUseExportImageFlow.ts @@ -0,0 +1,11 @@ +import { IHubEditableContent } from "../../core/types/IHubEditableContent"; + +/** + * @private + * Determines if the export image flow can be used for the given entity. + * @param entity entity to check if export image flow can be used + * @returns whether the export image flow can be used + */ +export function canUseExportImageFlow(entity: IHubEditableContent): boolean { + return entity.type === "Image Service"; +} diff --git a/packages/common/src/downloads/_internal/canUseExportItemFlow.ts b/packages/common/src/downloads/_internal/canUseExportItemFlow.ts new file mode 100644 index 00000000000..9b327ba78c1 --- /dev/null +++ b/packages/common/src/downloads/_internal/canUseExportItemFlow.ts @@ -0,0 +1,12 @@ +import { isHostedFeatureServiceEntity } from "../../content/hostedServiceUtils"; +import { IHubEditableContent } from "../../core/types/IHubEditableContent"; + +/** + * @private + * Determines if the export item flow can be used for the given entity. + * @param entity entity to check if export item flow can be used + * @returns whether the export item flow can be used + */ +export function canUseExportItemFlow(entity: IHubEditableContent): boolean { + return isHostedFeatureServiceEntity(entity); +} diff --git a/packages/common/src/downloads/_internal/file-url-fetchers/fetchExportImageDownloadFileUrl.ts b/packages/common/src/downloads/_internal/file-url-fetchers/fetchExportImageDownloadFileUrl.ts new file mode 100644 index 00000000000..4b0e9e0143a --- /dev/null +++ b/packages/common/src/downloads/_internal/file-url-fetchers/fetchExportImageDownloadFileUrl.ts @@ -0,0 +1,57 @@ +import { request } from "@esri/arcgis-rest-request"; +import { + DownloadOperationStatus, + IFetchDownloadFileUrlOptions, +} from "../../types"; + +/** + * @private + * Fetches a download file url from an Image Service via the exportImage endpoint + * + * NOTE: This function is incomplete and needs various parameters to be validated + * and implemented. It is a work in progress. + * + * @param options options for refining / filtering the resulting download file + * @returns a url to download the file + */ +export async function fetchExportImageDownloadFileUrl( + options: IFetchDownloadFileUrlOptions +): Promise { + const { entity, format, context, geometry, progressCallback } = options; + progressCallback && progressCallback(DownloadOperationStatus.PENDING); + + // TODO: validate layers, geometry, where, etc. I don't think all of them are applicable in every permutation + + const requestOptions = { ...context.requestOptions }; + requestOptions.httpMethod = "GET"; + requestOptions.params = { + format, + mosaicRule: + '{"ascending":true,"mosaicMethod":"esriMosaicNorthwest","mosaicOperation":"MT_FIRST"}', + }; + + if (geometry && geometry.type === "extent") { + const { xmin, xmax, ymin, ymax } = geometry as __esri.Extent; + const { wkid } = geometry.spatialReference; + requestOptions.params.bbox = `${xmin},${ymin},${xmax},${ymax}`; + requestOptions.params.bboxSR = `${wkid}`; + requestOptions.params.imageSR = `${wkid}`; + } + // Note: validate where "extent" and "layer" are coming from in the old ember code, + // check if they are still applicable here + // else { + // const coords = entity.extent; + // requestOptions.params.bbox = `${coords[0][0]},${coords[0][1]},${coords[1][0]},${coords[1][1]}`; + // requestOptions.params.bboxSR = "4326"; + // requestOptions.params.imageSR = "4326"; + // } + + // const { maxImageHeight, maxImageWidth } = this.args.model.layer || {}; + // if (maxImageWidth && maxImageHeight) { + // requestOptions.params.size = `${maxImageWidth},${maxImageHeight}`; + // } + + const { href } = await request(`${entity.url}/exportImage`, requestOptions); + progressCallback && progressCallback(DownloadOperationStatus.COMPLETED); + return href; +} diff --git a/packages/common/src/downloads/_internal/file-url-fetchers/fetchExportItemDownloadFileUrl.ts b/packages/common/src/downloads/_internal/file-url-fetchers/fetchExportItemDownloadFileUrl.ts new file mode 100644 index 00000000000..6d869af7450 --- /dev/null +++ b/packages/common/src/downloads/_internal/file-url-fetchers/fetchExportItemDownloadFileUrl.ts @@ -0,0 +1,139 @@ +import { + IExportItemRequestOptions, + IExportParameters, + exportItem, + getItemStatus, +} from "@esri/arcgis-rest-portal"; +import { + DownloadOperationStatus, + IFetchDownloadFileUrlOptions, + LegacyExportItemFormat, + PORTAL_EXPORT_TYPES, + ServiceDownloadFormat, + downloadProgressCallback, +} from "../../types"; +import { getExportItemDataUrl } from "../getExportItemDataUrl"; +import HubError from "../../../HubError"; +import { IArcGISContext } from "../../../ArcGISContext"; +import { ExportItemFormat } from "../_types"; +import { getProp } from "../../../objects/get-prop"; + +/** + * @private + * Fetches a download file url the Portal API via the item /export endpoint. + * + * NOTE: This function is incomplete and various permissions / branching paths need to be + * validated and implemented. It is a work in progress. + * + * NOTE: This is a last resort approach for current Enterprise environments, but it will be replaced + * by calling the service's /createReplica endpoint directly in the future (i.e., once the Enterprise + * team achieves feature parity with the Online team's implementation). + * + * This is because The item /export endpoint can only be used on Hosted Feature Services + * with the "Extract" capability enabled, which means the service will also have the /createReplica + * endpoint available. As /createReplica is a more flexible operation, /export becomes obsolete. + * + * @param options options for refining / filtering the resulting download file + * @returns a url to download the file + */ +export async function fetchExportItemDownloadFileUrl( + options: IFetchDownloadFileUrlOptions +): Promise { + validateOptions(options); + const { entity, format, context, progressCallback, pollInterval } = options; + progressCallback && progressCallback(DownloadOperationStatus.PENDING); + const { exportItemId, jobId } = await exportItem({ + id: entity.id, + exportFormat: getExportFormatParam(format as ExportItemFormat), + exportParameters: getExportParameters(options), + authentication: context.hubRequestOptions.authentication, + }); + + await pollForJobCompletion( + exportItemId, + jobId, + context, + pollInterval, + progressCallback + ); + + // TODO: Once the job is completed, we still need to set the special typekeywords needed to find the item later. + // Also, I _think_ we can only do one layer at a time (at least with the current typeKeywords schema we're using) + progressCallback && progressCallback(DownloadOperationStatus.COMPLETED); + return getExportItemDataUrl(exportItemId, context); +} + +function validateOptions(options: IFetchDownloadFileUrlOptions) { + const { geometry, where } = options; + + if (geometry) { + throw new HubError( + "fetchExportItemDownloadFileUrl", + "Geometric filters are not supported for this type of download" + ); + } + + if (where) { + throw new HubError( + "fetchExportItemDownloadFileUrl", + "Attribute filters are not supported for this type of download" + ); + } +} + +function getExportFormatParam( + format: ExportItemFormat +): IExportItemRequestOptions["exportFormat"] { + const legacyFormat = getLegacyExportItemFormat(format); + return getProp( + PORTAL_EXPORT_TYPES, + `${legacyFormat}.name` + ) as IExportItemRequestOptions["exportFormat"]; +} + +function getLegacyExportItemFormat( + format: ExportItemFormat +): LegacyExportItemFormat { + return format === ServiceDownloadFormat.FILE_GDB ? "fileGeodatabase" : format; +} + +function getExportParameters( + options: IFetchDownloadFileUrlOptions +): IExportParameters { + const { layers } = options; + const result: IExportParameters = { + layers: layers.map((id) => ({ id })), + }; + return result; +} + +async function pollForJobCompletion( + exportedItemId: string, + jobId: string, + context: IArcGISContext, + pollInterval: number, + progressCallback?: downloadProgressCallback +): Promise { + const { status } = await getItemStatus({ + id: exportedItemId, + jobId, + jobType: "export", + authentication: context.hubRequestOptions.authentication, + }); + + if (status === "failed") { + throw new HubError("fetchExportItemDownloadFileUrl", "Export job failed"); + } + + if (status !== "completed") { + progressCallback && progressCallback(DownloadOperationStatus.PROCESSING); + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + return pollForJobCompletion( + exportedItemId, + jobId, + context, + pollInterval, + progressCallback + ); + } +} diff --git a/packages/common/src/downloads/_internal/file-url-fetchers/fetchHubApiDownloadFileUrl.ts b/packages/common/src/downloads/_internal/file-url-fetchers/fetchHubApiDownloadFileUrl.ts new file mode 100644 index 00000000000..bfac77fbdea --- /dev/null +++ b/packages/common/src/downloads/_internal/file-url-fetchers/fetchHubApiDownloadFileUrl.ts @@ -0,0 +1,222 @@ +import HubError from "../../../HubError"; +import { getProp } from "../../../objects/get-prop"; +import { + ArcgisHubDownloadError, + DownloadOperationStatus, + IFetchDownloadFileUrlOptions, + ServiceDownloadFormat, + downloadProgressCallback, +} from "../../types"; + +/** + * @private + * Fetches a download file url from the Hub Download API + * + * NOTE: The Hub Download API only works with a certain subset of Feature and Map services + * and performs different operations (i.e., calling createReplica or paging the service's + * features) depending on the service type and capabilities. + * + * This function does it's best to abstract those differences and provide a consistent + * interface for downloading data from any service supported by the Hub Download API. + * + * @param options options for refining / filtering the resulting download file + * @returns a url to download the file + */ +export async function fetchHubApiDownloadFileUrl( + options: IFetchDownloadFileUrlOptions +): Promise { + validateOptions(options); + const requestUrl = getDownloadApiRequestUrl(options); + const { pollInterval, progressCallback } = options; + return pollDownloadApi(requestUrl, pollInterval, progressCallback); +} + +function validateOptions(options: IFetchDownloadFileUrlOptions) { + const { layers = [] } = options; + + // The Hub Download API currently requires a target layer to be specified + if (layers.length === 0) { + throw new HubError( + "fetchHubApiDownloadFileUrl", + "No layers provided for download" + ); + } + + // The Hub Download API currently only supports downloading one + // layer at a time, though it could allow multiple in the future + if (layers.length > 1) { + throw new HubError( + "fetchHubApiDownloadFileUrl", + "Multiple layer downloads are not yet supported" + ); + } +} + +/** + * @private + * Generates a URL to the Hub Download API that can be polled until the download is ready + * + * @param options options for refining / filtering the resulting download file + * @returns a download api url that can be polled + */ +function getDownloadApiRequestUrl(options: IFetchDownloadFileUrlOptions) { + const { entity, format, context, layers, geometry, where } = options; + + const searchParams = new URLSearchParams({ + redirect: "false", // Needed to get the download URL instead of the file itself + layers: layers[0].toString(), + }); + + if (geometry) { + const geometryJSON = geometry.toJSON(); + // Not sure why type isn't included in the toJSON() output, but our API expects it + geometryJSON.type = geometry.type; + searchParams.append("geometry", JSON.stringify(geometryJSON)); + } + + // GeoJSON and KML are only supported in WGS84, so we need to specify the spatial reference here + if ( + [ServiceDownloadFormat.GEOJSON, ServiceDownloadFormat.KML].includes(format) + ) { + searchParams.append("spatialRefId", "4326"); + } + + where && searchParams.append("where", where); + + const token = getProp(context, "hubRequestOptions.authentication.token"); + token && searchParams.append("token", token); + + return `${context.hubUrl}/api/download/v1/items/${ + entity.id + }/${format}?${searchParams.toString()}`; +} + +/** + * @private + * Polls the Hub Download API until the download is ready, then returns the download file URL + * + * @param requestUrl Hub Download Api URL to poll + * @param progressCallback an optional callback to report download generation progress + * @returns the final file URL + */ +async function pollDownloadApi( + requestUrl: string, + pollInterval: number, + progressCallback?: downloadProgressCallback +): Promise { + const response = await fetch(requestUrl); + if (!response.ok) { + const errorBody = await response.json(); + // TODO: Add standarized messageId when available + throw new ArcgisHubDownloadError({ + rawMessage: errorBody.message, + }); + } + const { status, progressInPercent, resultUrl }: IHubDownloadApiResponse = + await response.json(); + const operationStatus = toDownloadOperationStatus(status); + if (operationStatus === DownloadOperationStatus.FAILED) { + throw new HubError( + "fetchHubApiDownloadFileUrl", + "Download operation failed with a 200" + ); + } + progressCallback && progressCallback(operationStatus, progressInPercent); + + // Operation complete, return the download URL + if (resultUrl) { + return resultUrl; + } + + // Operation still in progress, poll again + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + return pollDownloadApi(requestUrl, pollInterval, progressCallback); +} + +/** + * @private + * Returns a standardized status string based on what is returned by the Hub Download API. + * This is necessary because the Hub Download API returns a variety of statuses that are too + * technical in nature that need to be translated into a more user-friendly status. + * + * @param status status returned by the Hub Download API + * @returns a standardized download operation status + */ +function toDownloadOperationStatus( + status: HubDownloadApiStatus +): DownloadOperationStatus { + // Statuses that come back if the Download API uses createReplica under the hood + const createReplicaStatusMap: Record< + CreateReplicaStatus, + DownloadOperationStatus + > = { + // Statuses that we expect to see (listed in the order they could occur) + Pending: DownloadOperationStatus.PENDING, // Job hasn't started yet + InProgress: DownloadOperationStatus.PROCESSING, // Job is in progress + ExportingData: DownloadOperationStatus.PROCESSING, // Features are being exported, progress available + ExportAttachments: DownloadOperationStatus.PROCESSING, // Reported by Khaled Hassen, unsure when this actually happens + Completed: DownloadOperationStatus.COMPLETED, + CompletedWithErrors: DownloadOperationStatus.FAILED, // Reported by Khaled Hassen, unsure when this actually happens + Failed: DownloadOperationStatus.FAILED, + + // These statuses are not expected to be returned by the API, but are included in the documentation + ProvisioningReplica: DownloadOperationStatus.PROCESSING, // NOTE: This used to occur before ExportingData, but according to Khalid Hassen we shouldn't see it anymore + ImportChanges: DownloadOperationStatus.PROCESSING, + ExportChanges: DownloadOperationStatus.PROCESSING, + ExportingSnapshot: DownloadOperationStatus.PROCESSING, + ImportAttachments: DownloadOperationStatus.PROCESSING, + UnRegisteringReplica: DownloadOperationStatus.PROCESSING, + }; + + // Statuses that come back if the Download API pages the service's features + // under the hood They are listed in the order they are expected to occur + const pagingJobStatusMap: Record = { + Pending: DownloadOperationStatus.PENDING, + InProgress: DownloadOperationStatus.PROCESSING, + PagingData: DownloadOperationStatus.PROCESSING, + ConvertingData: DownloadOperationStatus.CONVERTING, + Failed: DownloadOperationStatus.FAILED, + Completed: DownloadOperationStatus.COMPLETED, + }; + + return ( + createReplicaStatusMap[status as CreateReplicaStatus] || + pagingJobStatusMap[status as PagingJobStatus] + ); +} + +type CreateReplicaStatus = + | "Pending" + | "InProgress" + | "Completed" + | "Failed" + | "ImportChanges" + | "ExportChanges" + | "ExportingData" + | "ExportingSnapshot" + | "ExportAttachments" + | "ImportAttachments" + | "ProvisioningReplica" + | "UnRegisteringReplica" + | "CompletedWithErrors"; + +type PagingJobStatus = + | "Pending" + | "InProgress" + | "PagingData" + | "ConvertingData" + | "Completed" + | "Failed"; + +type HubDownloadApiStatus = CreateReplicaStatus | PagingJobStatus; + +/** + * @private + * Interface for the raw response from the Hub Download API + */ +interface IHubDownloadApiResponse { + status: HubDownloadApiStatus; + resultUrl?: string; + recordCount?: number; + progressInPercent?: number; +} diff --git a/packages/common/src/downloads/_internal/format-fetchers/fetchAvailableExportItemFormats.ts b/packages/common/src/downloads/_internal/format-fetchers/fetchAvailableExportItemFormats.ts new file mode 100644 index 00000000000..5464a35b6a2 --- /dev/null +++ b/packages/common/src/downloads/_internal/format-fetchers/fetchAvailableExportItemFormats.ts @@ -0,0 +1,82 @@ +import { buildExistingExportsPortalQuery } from "../../build-existing-exports-portal-query"; +import { + IStaticDownloadFormat, + LegacyExportItemFormat, + PORTAL_EXPORT_TYPES, + ServiceDownloadFormat, +} from "../../types"; +import { IItem, searchItems } from "@esri/arcgis-rest-portal"; +import { getExportItemDataUrl } from "../getExportItemDataUrl"; +import { IHubEditableContent } from "../../../core/types/IHubEditableContent"; +import { IArcGISContext } from "../../../ArcGISContext"; +import HubError from "../../../HubError"; +import { fetchAllPages } from "../../../items/fetch-all-pages"; +import { ExportItemFormat } from "../_types"; + +/** + * @private + * Fetches an entity's available download formats that were previously created by the Portal API's item /export + * endpoint. This is useful for anonymous enterprise users who need to download data from a Hosted Feature Service, + * but do not have the privileges to create their own item export. As the exports have been previously created, they + * can be downloaded statically by anyone with the URL. + * + * NOTE: This function is a work-in-progress. Various permissions and logic branches are not yet implemented. + * + * NOTE: This is a last resort approach for current Enterprise environments, but it will be replaced + * with using the formats defined the service's /createReplica endpoint directly in the future (i.e., + * once the Enterprise team achieves feature parity with the Online team's implementation). + * + * This is because The item /export endpoint can only be used on Hosted Feature Services with the "Extract" capability + * enabled, which means the service will also have the /createReplica endpoint available. As /createReplica is a more + * flexible operation that can be invoked by anonymous users, /export becomes obsolete. + * + * @param entity Hosted Feature Service entity to fetch download formats for + * @param context ArcGIS application context + * @param layers target layers that the download will be filtered to + * @returns available download formats for the entity + */ +export async function fetchAvailableExportItemFormats( + entity: IHubEditableContent, + context: IArcGISContext, + layers: number[] +): Promise { + if (layers.length > 1) { + throw new HubError( + "fetchAvailableExportItemFormats", + "Multi-layer downloads are not supported for this item" + ); + } + // NOTE: we _need_ to pass the spatialRefId otherwise we're going to default to 4326 + // Dang it, we'll need to pass the layerId as well... should we support multiple layers? + const q = buildExistingExportsPortalQuery(entity.id, { layerId: layers[0] }); + const exportItems = (await fetchAllPages(searchItems, { + q, + ...context.requestOptions, + })) as IItem[]; + + // TODO: Do we need to worry about duplicates here? + + return exportItems.map((item) => ({ + type: "static", + label: null, + format: getExportItemFormat(item.type), + url: getExportItemDataUrl(item.id, context), + })) as any[]; // TODO: change export formats to use the ServiceDownloadFormat type +} + +function getExportItemFormat(itemType: string): ExportItemFormat { + const legacyExportItemFormat = ( + Object.keys(PORTAL_EXPORT_TYPES) as LegacyExportItemFormat[] + ).find((format: LegacyExportItemFormat) => { + return PORTAL_EXPORT_TYPES[format].itemTypes.includes(itemType); + }) as LegacyExportItemFormat; + return migrateExportItemFormat(legacyExportItemFormat); +} + +function migrateExportItemFormat( + format: LegacyExportItemFormat +): ExportItemFormat { + return format === "fileGeodatabase" + ? ServiceDownloadFormat.FILE_GDB + : (format as ExportItemFormat); +} diff --git a/packages/common/src/downloads/_internal/format-fetchers/fetchExportItemFormats.ts b/packages/common/src/downloads/_internal/format-fetchers/fetchExportItemFormats.ts new file mode 100644 index 00000000000..8c560c6c421 --- /dev/null +++ b/packages/common/src/downloads/_internal/format-fetchers/fetchExportItemFormats.ts @@ -0,0 +1,35 @@ +import { IArcGISContext } from "../../../ArcGISContext"; +import { IHubEditableContent } from "../../../core/types/IHubEditableContent"; +import { IDownloadFormat } from "../../types"; + +/** + * @private + * Fetches an entity's available download formats. Owners of the entity can create all formats supported by + * the Portal API's item /export endpoint, while users that don't have privileges to the item /export endpoint + * can only download formats that were previously exported by the entity's owner. + * + * NOTE: This function is a work-in-progress. Various permissions and logic branches are not yet implemented. + * + * NOTE: This is a last resort approach for current Enterprise environments, but it will be replaced + * with using the formats defined the service's /createReplica endpoint directly in the future (i.e., + * once the Enterprise team achieves feature parity with the Online team's implementation). + * + * This is because The item /export endpoint can only be used on Hosted Feature Services with the "Extract" capability + * enabled, which means the service will also have the /createReplica endpoint available. As /createReplica is a more + * flexible operation that can be invoked by anonymous users, /export becomes obsolete. + * + * @param entity Hosted Feature Service entity to fetch download formats for + * @param context ArcGIS application context + * @param layers target layers that the download will be filtered to + * @returns available download formats for the entity + */ +export async function fetchExportItemFormats( + _entity: IHubEditableContent, + _context: IArcGISContext, + _layers?: number[] +): Promise { + throw new Error("Not implemented"); + // return canCreateExport(entity, context) + // ? getAllExportItemFormats() + // : fetchAvailableExportItemFormats(entity, context, layers); +} diff --git a/packages/common/src/downloads/_internal/format-fetchers/getAllExportItemFormats.ts b/packages/common/src/downloads/_internal/format-fetchers/getAllExportItemFormats.ts new file mode 100644 index 00000000000..6c6df2efaab --- /dev/null +++ b/packages/common/src/downloads/_internal/format-fetchers/getAllExportItemFormats.ts @@ -0,0 +1,13 @@ +import { IDynamicDownloadFormat } from "../../types"; +import { EXPORT_ITEM_FORMATS } from "../_types"; + +/** + * @private + * Returns all the download formats that are available via the Portal API's item /export endpoint. + */ +export function getAllExportItemFormats(): IDynamicDownloadFormat[] { + return EXPORT_ITEM_FORMATS.map((format) => ({ + type: "dynamic", + format, + })); +} diff --git a/packages/common/src/downloads/_internal/format-fetchers/getCreateReplicaFormats.ts b/packages/common/src/downloads/_internal/format-fetchers/getCreateReplicaFormats.ts new file mode 100644 index 00000000000..c3c255a964d --- /dev/null +++ b/packages/common/src/downloads/_internal/format-fetchers/getCreateReplicaFormats.ts @@ -0,0 +1,30 @@ +import { IHubEditableContent } from "../../../core/types/IHubEditableContent"; +import { IDynamicDownloadFormat } from "../../types"; +import { CreateReplicaFormat, CREATE_REPLICA_FORMATS } from "../_types"; + +/** + * @private + * Returns all the download formats that are defined by the service's /createReplica endpoint. + * + * @param entity Hosted Feature Service entity to return download formats for + * @returns available download formats for the entity + */ +export function getCreateReplicaFormats( + entity: IHubEditableContent +): IDynamicDownloadFormat[] { + const allFormats = entity.serverExtractFormats || []; + // List recognized formats in the order they are defined in CREATE_REPLICA_FORMATS + const recognizedFormats: CreateReplicaFormat[] = + CREATE_REPLICA_FORMATS.filter((format) => allFormats.includes(format)); + // List any unrecognized formats (we'll append these to the end of the final array) + const unrecognizedFormats = allFormats.filter( + (format) => !CREATE_REPLICA_FORMATS.includes(format as CreateReplicaFormat) + ); + + return [...recognizedFormats, ...unrecognizedFormats].map( + (format: string) => ({ + type: "dynamic", + format: format as CreateReplicaFormat, + }) + ); +} diff --git a/packages/common/src/downloads/_internal/format-fetchers/getExportImageFormats.ts b/packages/common/src/downloads/_internal/format-fetchers/getExportImageFormats.ts new file mode 100644 index 00000000000..1b540a0b9b4 --- /dev/null +++ b/packages/common/src/downloads/_internal/format-fetchers/getExportImageFormats.ts @@ -0,0 +1,12 @@ +import { IDynamicDownloadFormat } from "../../types"; + +/** + * @private + * Returns all the download formats that are exposed by Image Services via the /exportImage operation. + * + * NOTE: This function is a work-in-progress. Various permissions and logic branches are not yet implemented. + */ +export function getExportImageFormats(): IDynamicDownloadFormat[] { + throw new Error("Not implemented"); + // return EXPORT_IMAGE_FORMATS.map((format) => ({ type: "dynamic", format })); +} diff --git a/packages/common/src/downloads/_internal/format-fetchers/getHubDownloadApiFormats.ts b/packages/common/src/downloads/_internal/format-fetchers/getHubDownloadApiFormats.ts new file mode 100644 index 00000000000..110bf3913a2 --- /dev/null +++ b/packages/common/src/downloads/_internal/format-fetchers/getHubDownloadApiFormats.ts @@ -0,0 +1,22 @@ +import { getCreateReplicaFormats } from "./getCreateReplicaFormats"; +import { getPagingJobFormats } from "./getPagingJobFormats"; +import { IDynamicDownloadFormat } from "../../types"; +import { IHubEditableContent } from "../../../core/types/IHubEditableContent"; +import { canUseCreateReplica } from "../../canUseCreateReplica"; + +/** + * @private + * Returns all the formats that are available for download via the Hub Download API for a given entity. + * Formats will vary from entity to entity depending on actual operation that the Hub Download API will + * perform under the hood (e.g., hitting /createReplica or paging through the service's features). + * + * @param entity Service entity to return download formats for + * @returns available download formats for the entity + */ +export function getHubDownloadApiFormats( + entity: IHubEditableContent +): IDynamicDownloadFormat[] { + return canUseCreateReplica(entity) + ? getCreateReplicaFormats(entity) + : getPagingJobFormats(); +} diff --git a/packages/common/src/downloads/_internal/format-fetchers/getPagingJobFormats.ts b/packages/common/src/downloads/_internal/format-fetchers/getPagingJobFormats.ts new file mode 100644 index 00000000000..6363d006e48 --- /dev/null +++ b/packages/common/src/downloads/_internal/format-fetchers/getPagingJobFormats.ts @@ -0,0 +1,11 @@ +import { IDynamicDownloadFormat } from "../../types"; +import { HUB_PAGING_JOB_FORMATS } from "../_types"; + +/** + * @private + * Returns all the download formats that are available for the Hub Download API's paging job operation. + * @returns available download formats for the paging job operation + */ +export function getPagingJobFormats(): IDynamicDownloadFormat[] { + return HUB_PAGING_JOB_FORMATS.map((format) => ({ type: "dynamic", format })); +} diff --git a/packages/common/src/downloads/_internal/getExportItemDataUrl.ts b/packages/common/src/downloads/_internal/getExportItemDataUrl.ts new file mode 100644 index 00000000000..f5788bf7f9c --- /dev/null +++ b/packages/common/src/downloads/_internal/getExportItemDataUrl.ts @@ -0,0 +1,18 @@ +import { IArcGISContext } from "../../ArcGISContext"; +import { getProp } from "../../objects"; + +/** + * @private + * Generates a URL to download the data of an export item. + * @param exportItemId ID of the export item + * @param context ArcGIS application context + * @returns URL to download the data of the export item + */ +export function getExportItemDataUrl( + exportItemId: string, + context: IArcGISContext +): string { + const baseUrl = `${context.portalUrl}/sharing/rest/content/items/${exportItemId}/data`; + const token = getProp(context, "hubRequestOptions.authentication.token"); + return token ? `${baseUrl}?token=${token}` : baseUrl; +} diff --git a/packages/common/src/downloads/build-existing-exports-portal-query.ts b/packages/common/src/downloads/build-existing-exports-portal-query.ts index 71aebf19068..09a5e33f984 100644 --- a/packages/common/src/downloads/build-existing-exports-portal-query.ts +++ b/packages/common/src/downloads/build-existing-exports-portal-query.ts @@ -2,47 +2,10 @@ import { SearchQueryBuilder } from "@esri/arcgis-rest-portal"; import { ISpatialReference } from "@esri/arcgis-rest-types"; import { btoa } from "abab"; import { flattenArray } from "../util"; +import { PORTAL_EXPORT_TYPES } from "./types"; export const WGS84_WKID = "4326"; -export const PORTAL_EXPORT_TYPES = { - csv: { - name: "CSV", - itemTypes: ["CSV", "CSV Collection"], - supportsProjection: true, - }, - kml: { - name: "KML", - itemTypes: ["KML", "KML Collection"], - supportsProjection: false, - }, - shapefile: { - name: "Shapefile", - itemTypes: ["Shapefile"], - supportsProjection: true, - }, - fileGeodatabase: { - name: "File Geodatabase", - itemTypes: ["File Geodatabase"], - supportsProjection: true, - }, - geojson: { - name: "GeoJson", - itemTypes: ["GeoJson"], - supportsProjection: false, - }, - excel: { - name: "Excel", - itemTypes: ["Microsoft Excel"], - supportsProjection: true, - }, - featureCollection: { - name: "Feature Collection", - itemTypes: ["Feature Collection"], - supportsProjection: true, - }, -}; - interface IExistingExportsPortalQueryOptions { layerId?: number | string; onlyTypes?: string[]; diff --git a/packages/common/src/downloads/canUseCreateReplica.ts b/packages/common/src/downloads/canUseCreateReplica.ts new file mode 100644 index 00000000000..f557dbb13bf --- /dev/null +++ b/packages/common/src/downloads/canUseCreateReplica.ts @@ -0,0 +1,16 @@ +import { isHostedFeatureServiceEntity } from "../content/hostedServiceUtils"; +import { IHubEditableContent } from "../core/types/IHubEditableContent"; + +/** + * Determines whether Hub can perform the /createReplica operation on a given service entity. + * @param entity entity to check + * @returns whether the /createReplica operation can be used + */ +export function canUseCreateReplica(entity: IHubEditableContent): boolean { + // NOTE: We currently do not allow Hub to perform the /createReplica operation on non-hosted + // feature services due to known limitations with the enterprise implementation of /createReplica. + // This is a temporary restriction until the enterprise implementation is improved. + return ( + isHostedFeatureServiceEntity(entity) && !!entity.serverExtractCapability + ); +} diff --git a/packages/common/src/downloads/canUseHubDownloadApi.ts b/packages/common/src/downloads/canUseHubDownloadApi.ts new file mode 100644 index 00000000000..6224d4ab16a --- /dev/null +++ b/packages/common/src/downloads/canUseHubDownloadApi.ts @@ -0,0 +1,24 @@ +import { IArcGISContext } from "../ArcGISContext"; +import { IHubEditableContent } from "../core/types/IHubEditableContent"; +import { canUseCreateReplica } from "./canUseCreateReplica"; + +/** + * Determines if the Hub Download API can be used for the given entity. + * @param entity entity to check if Hub Download API can be used + * @param context ArcGIS context + * @returns whether the Hub Download API can be used + */ +export function canUseHubDownloadApi( + entity: IHubEditableContent, + context: IArcGISContext +): boolean { + const isDownloadApiAvailable = + context.serviceStatus?.["hub-downloads"] === "online"; + const canUsePagingJobs = + ["Feature Service", "Map Service"].includes(entity.type) && + entity.access === "public"; + + return ( + isDownloadApiAvailable && (canUsePagingJobs || canUseCreateReplica(entity)) + ); +} diff --git a/packages/common/src/downloads/fetchDownloadFileUrl.ts b/packages/common/src/downloads/fetchDownloadFileUrl.ts new file mode 100644 index 00000000000..25bd0970d29 --- /dev/null +++ b/packages/common/src/downloads/fetchDownloadFileUrl.ts @@ -0,0 +1,45 @@ +import HubError from "../HubError"; +import { cloneObject } from "../util"; +import { canUseExportImageFlow } from "./_internal/canUseExportImageFlow"; +import { canUseExportItemFlow } from "./_internal/canUseExportItemFlow"; +import { canUseHubDownloadApi } from "./canUseHubDownloadApi"; +import { IFetchDownloadFileUrlOptions } from "./types"; + +/** + * Fetches a download file URL for the given entity and format. + * @param options options to refine / filter the results of the fetchDownloadFileUrl operation + * @returns a promise that resolves with the download file URL + * @throws {ArcgisHubDownloadError} if the download file URL cannot be fetched for a well-known reason + */ +export async function fetchDownloadFileUrl( + options: IFetchDownloadFileUrlOptions +): Promise { + // If the pollInterval is not set, default to 3 seconds + const withPollInterval = + options.pollInterval == null ? { ...options, pollInterval: 3000 } : options; + + let fetchingFn; + if (canUseHubDownloadApi(withPollInterval.entity, withPollInterval.context)) { + fetchingFn = ( + await import("./_internal/file-url-fetchers/fetchHubApiDownloadFileUrl") + ).fetchHubApiDownloadFileUrl; + } else if (canUseExportItemFlow(withPollInterval.entity)) { + fetchingFn = ( + await import( + "./_internal/file-url-fetchers/fetchExportItemDownloadFileUrl" + ) + ).fetchExportItemDownloadFileUrl; + } else if (canUseExportImageFlow(withPollInterval.entity)) { + fetchingFn = ( + await import( + "./_internal/file-url-fetchers/fetchExportImageDownloadFileUrl" + ) + ).fetchExportImageDownloadFileUrl; + } else { + throw new HubError( + "fetchDownloadFileUrl", + "Downloads are not supported for this item in this environment" + ); + } + return fetchingFn(withPollInterval); +} diff --git a/packages/common/src/downloads/fetchDownloadFormats.ts b/packages/common/src/downloads/fetchDownloadFormats.ts new file mode 100644 index 00000000000..f5b948618c8 --- /dev/null +++ b/packages/common/src/downloads/fetchDownloadFormats.ts @@ -0,0 +1,56 @@ +import { IHubAdditionalResource } from "../core/types/IHubAdditionalResource"; +import { canUseExportImageFlow } from "./_internal/canUseExportImageFlow"; +import { canUseExportItemFlow } from "./_internal/canUseExportItemFlow"; +import { canUseHubDownloadApi } from "./canUseHubDownloadApi"; +import { + IDownloadFormat, + IFetchDownloadFormatsOptions, + IStaticDownloadFormat, +} from "./types"; + +/** + * Fetches download formats for the given entity. Also folds in any additional resources defined on the entity. + * @param options options to refine / filter the results of the fetchDownloadFormats operation + * @returns a promise that resolves with the download formats + */ +export async function fetchDownloadFormats( + options: IFetchDownloadFormatsOptions +): Promise { + const { entity, context, layers } = options; + // fetch base formats for the item + let baseFormats: IDownloadFormat[] = []; + if (canUseHubDownloadApi(entity, context)) { + const { getHubDownloadApiFormats } = await import( + "./_internal/format-fetchers/getHubDownloadApiFormats" + ); + baseFormats = getHubDownloadApiFormats(entity); + } else if (canUseExportItemFlow(entity)) { + const { fetchExportItemFormats } = await import( + "./_internal/format-fetchers/fetchExportItemFormats" + ); + baseFormats = await fetchExportItemFormats(entity, context, layers); + } else if (canUseExportImageFlow(entity)) { + const { getExportImageFormats } = await import( + "./_internal/format-fetchers/getExportImageFormats" + ); + baseFormats = getExportImageFormats(); + } + + // add additional resource links as static formats + const additionalFormats = (entity.additionalResources || []).map( + toStaticFormat + ); + + // combine formats into single list + return [...baseFormats, ...additionalFormats]; +} + +function toStaticFormat( + resource: IHubAdditionalResource +): IStaticDownloadFormat { + return { + type: "static", + label: resource.name, + url: resource.url, + }; +} diff --git a/packages/common/src/downloads/index.ts b/packages/common/src/downloads/index.ts index 8ee52c699ea..d146912f903 100644 --- a/packages/common/src/downloads/index.ts +++ b/packages/common/src/downloads/index.ts @@ -1 +1,6 @@ export * from "./build-existing-exports-portal-query"; +export * from "./types"; +export * from "./fetchDownloadFileUrl"; +export * from "./fetchDownloadFormats"; +export * from "./canUseCreateReplica"; +export * from "./canUseHubDownloadApi"; diff --git a/packages/common/src/downloads/types.ts b/packages/common/src/downloads/types.ts new file mode 100644 index 00000000000..0966eb0800c --- /dev/null +++ b/packages/common/src/downloads/types.ts @@ -0,0 +1,183 @@ +import { IArcGISContext } from "../ArcGISContext"; +import { IHubEditableContent } from "../core/types/IHubEditableContent"; + +/** + * This hash map was defined to support the previous implementation of the export item flow. + * We are currently working on a new implementation that will replace this hash map, but we + * need to keep this around for now to support the existing implementation. + */ +export const PORTAL_EXPORT_TYPES = { + csv: { + name: "CSV", + itemTypes: ["CSV", "CSV Collection"], + supportsProjection: true, + }, + kml: { + name: "KML", + itemTypes: ["KML", "KML Collection"], + supportsProjection: false, + }, + shapefile: { + name: "Shapefile", + itemTypes: ["Shapefile"], + supportsProjection: true, + }, + fileGeodatabase: { + name: "File Geodatabase", + itemTypes: ["File Geodatabase"], + supportsProjection: true, + }, + geojson: { + name: "GeoJson", + itemTypes: ["GeoJson"], + supportsProjection: false, + }, + excel: { + name: "Excel", + itemTypes: ["Microsoft Excel"], + supportsProjection: true, + }, + featureCollection: { + name: "Feature Collection", + itemTypes: ["Feature Collection"], + supportsProjection: true, + }, + // Do we want to support Scene Packages? + // scenePackage: { + // name: "Scene Package", + // itemTypes: ["Scene Package"], + // supportsProjection: ???, + // }, +}; + +// Keys that the legacy implementation of the export item flow uses to identify the export format. +export type LegacyExportItemFormat = keyof typeof PORTAL_EXPORT_TYPES; + +/** + * Comprehensive enum of all the download formats that are supported by service-backed items across the ArcGIS platform. + */ +export enum ServiceDownloadFormat { + // Image Service Formats + BIP = "bip", // 10.3+ + BMP = "bmp", + BSQ = "bsq", // 10.3+ + GIF = "gif", + JPG = "jpg", + JPG_PNG = "jpgpng", + LERC = "lerc", // 10.3+ + PNG = "png", + PNG8 = "png8", + PNG24 = "png24", + PNG32 = "png32", // 10.2+ + TIFF = "tiff", + + // Map & Feature Service Formats + CSV = "csv", + EXCEL = "excel", + FEATURE_COLLECTION = "featureCollection", + FILE_GDB = "filegdb", + GEOJSON = "geojson", + GEO_PACKAGE = "geoPackage", + JSON = "json", + KML = "kml", + SHAPEFILE = "shapefile", + SQLITE = "sqlite", +} + +/** + * Represents a file format related to a content entity that can be downloaded. + * Formats can either be dynamic (i.e., generated on-the-fly) or static (i.e., pre-generated) + */ +export interface IDownloadFormat { + type: "static" | "dynamic"; +} + +/** + * Represents a static download format that is pre-generated and available for download. + * If the format is a service-backed format, the `format` property will be set to the corresponding + * service format. If the format is to an arbitrary static file, the `format` property should be undefined. + */ +export interface IStaticDownloadFormat extends IDownloadFormat { + type: "static"; + format?: ServiceDownloadFormat; + label: string; + url: string; +} + +/** + * Represents a dynamic download format that is generated on-the-fly when requested. + * The `format` property will be set to the corresponding service format. + */ +export interface IDynamicDownloadFormat extends IDownloadFormat { + type: "dynamic"; + format: ServiceDownloadFormat; +} + +/** + * A callback function that is invoked to report the progress of a download operation. + */ +export type downloadProgressCallback = ( + status: DownloadOperationStatus, + percent?: number // integer between 0 and 100 +) => void; + +/** + * Options for refining / filtering the results of the fetchDownloadFileUrl operation. + */ +export interface IFetchDownloadFileUrlOptions { + entity: IHubEditableContent; + format: ServiceDownloadFormat; + context: IArcGISContext; + layers?: number[]; // layers to download; when not specified, all layers will be downloaded + geometry?: __esri.Geometry; // geometry to filter results by + where?: string; // where clause to filter results by + progressCallback?: downloadProgressCallback; + pollInterval?: number; // interval in milliseconds to poll for job completion +} + +/** + * Human-readable status of a download operation. Operation specific statuses + * should be converted to one of these statuses before being reported to the user. + */ +export enum DownloadOperationStatus { + PENDING = "pending", + PROCESSING = "processing", + CONVERTING = "converting", + COMPLETED = "completed", + FAILED = "failed", +} + +/** + * Options for fetching download formats for an entity. + */ +export interface IFetchDownloadFormatsOptions { + entity: IHubEditableContent; + context: IArcGISContext; + layers?: number[]; +} + +/** + * Options for instantiating an ArcgisHubDownloadError object. + */ +interface IArcgisHubDownloadErrorOptions { + rawMessage: string; // raw error message + messageId?: string; // well-known error message ID + operation?: string; // operation that the error occurred in +} + +/** + * Error class for reporting well-known download errors that occur during the download process. + */ +export class ArcgisHubDownloadError extends Error { + public messageId?: string; // well-known error message ID + + public operation?: string; // operation that the error occurred in + + constructor(options: IArcgisHubDownloadErrorOptions) { + super(options.rawMessage); + this.name = "ArcgisHubDownloadError"; + this.message = options.rawMessage; + this.messageId = options.messageId; + this.operation = options.operation; + } +} 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/EventSchemaAttendeesSettings.ts b/packages/common/src/events/_internal/EventSchemaAttendeesSettings.ts new file mode 100644 index 00000000000..a95294b3eb5 --- /dev/null +++ b/packages/common/src/events/_internal/EventSchemaAttendeesSettings.ts @@ -0,0 +1,18 @@ +import { IConfigurationSchema } from "../../core/schemas/types"; + +export const buildSchema = (): IConfigurationSchema => { + return { + properties: { + allowRegistration: { + type: "boolean", + enum: [true, false], + default: true, + }, + notifyAttendees: { + type: "boolean", + enum: [true, false], + default: true, + }, + }, + } as IConfigurationSchema; +}; diff --git a/packages/common/src/events/_internal/EventSchemaCreate.ts b/packages/common/src/events/_internal/EventSchemaCreate.ts index 7e0c2de7c47..f55ad09d062 100644 --- a/packages/common/src/events/_internal/EventSchemaCreate.ts +++ b/packages/common/src/events/_internal/EventSchemaCreate.ts @@ -8,7 +8,11 @@ import { } from "./validations"; export type EventEditorType = (typeof EventEditorTypes)[number]; -export const EventEditorTypes = ["hub:event:create", "hub:event:edit"] as const; +export const EventEditorTypes = [ + "hub:event:create", + "hub:event:edit", + "hub:event:attendees", +] as const; /** * @private diff --git a/packages/common/src/events/_internal/EventUiSchemaAttendeesSettings.ts b/packages/common/src/events/_internal/EventUiSchemaAttendeesSettings.ts new file mode 100644 index 00000000000..d91ad5fc56b --- /dev/null +++ b/packages/common/src/events/_internal/EventUiSchemaAttendeesSettings.ts @@ -0,0 +1,64 @@ +import { IArcGISContext } from "../../ArcGISContext"; +import { EntityEditorOptions } from "../../core/schemas/internal/EditorOptions"; +import { IUiSchema } from "../../core/schemas/types"; + +export const buildUiSchema = async ( + i18nScope: string, + options: EntityEditorOptions, + context: IArcGISContext +): Promise => { + return { + type: "Layout", + elements: [ + { + type: "Section", + elements: [ + { + labelKey: `${i18nScope}.fields.allowRegistration.label`, + scope: "/properties/allowRegistration", + type: "Control", + options: { + control: "hub-field-input-tile-select", + type: "radio", + helperText: { + labelKey: `${i18nScope}.fields.allowRegistration.helperText`, + }, + labels: [ + `{{${i18nScope}.fields.allowRegistration.enabled.label:translate}}`, + `{{${i18nScope}.fields.allowRegistration.disabled.label:translate}}`, + ], + descriptions: [ + `{{${i18nScope}.fields.allowRegistration.enabled.description:translate}}`, + `{{${i18nScope}.fields.allowRegistration.disabled.description:translate}}`, + ], + icons: ["user-calendar", "circle-disallowed"], + layout: "horizontal", + }, + }, + { + labelKey: `${i18nScope}.fields.notifyAttendees.label`, + scope: "/properties/notifyAttendees", + type: "Control", + options: { + control: "hub-field-input-tile-select", + type: "radio", + helperText: { + labelKey: `${i18nScope}.fields.notifyAttendees.helperText`, + }, + labels: [ + `{{${i18nScope}.fields.notifyAttendees.enabled.label:translate}}`, + `{{${i18nScope}.fields.notifyAttendees.disabled.label:translate}}`, + ], + descriptions: [ + `{{${i18nScope}.fields.notifyAttendees.enabled.description:translate}}`, + `{{${i18nScope}.fields.notifyAttendees.disabled.description:translate}}`, + ], + icons: ["envelope", "circle-disallowed"], + layout: "horizontal", + }, + }, + ], + }, + ], + }; +}; diff --git a/packages/common/src/events/_internal/PropertyMapper.ts b/packages/common/src/events/_internal/PropertyMapper.ts index f41deb10f21..610beb601ed 100644 --- a/packages/common/src/events/_internal/PropertyMapper.ts +++ b/packages/common/src/events/_internal/PropertyMapper.ts @@ -6,9 +6,6 @@ import { import { IHubEvent } from "../../core/types/IHubEvent"; import { SettableAccessLevel } from "../../core/types/types"; import { cloneObject } from "../../util"; -import { getDatePickerDate } from "../../utils/date/getDatePickerDate"; -import { getTimePickerTime } from "../../utils/date/getTimePickerTime"; -import { getISOStringFromClientDateTime } from "./getISOStringFromClientDateTime"; import { EventAccess, EventAttendanceType, @@ -17,6 +14,8 @@ import { IOnlineMeeting, } from "../api/orval/api/orval-events"; import { HubEventAttendanceType, HubEventOnlineCapacityType } from "../types"; +import { computeLinks } from "./computeLinks"; +import { getEventSlug } from "./getEventSlug"; /** * @private @@ -85,13 +84,11 @@ export class EventPropertyMapper extends PropertyMapper< obj.createdDate = new Date(store.createdAt); obj.startDateTime = new Date(store.startDateTime); obj.endDateTime = new Date(store.endDateTime); - obj.startDate = getDatePickerDate(store.startDateTime, store.timeZone); - obj.endDate = getDatePickerDate(store.endDateTime, store.timeZone); - obj.startTime = getTimePickerTime(store.startDateTime, store.timeZone); - obj.endTime = getTimePickerTime(store.endDateTime, store.timeZone); 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; } @@ -163,16 +160,6 @@ export class EventPropertyMapper extends PropertyMapper< clonedEntity.endTime = "23:59:59"; } - // build start & end date/time iso strings, adjusted for desired time zone - obj.startDateTime = getISOStringFromClientDateTime( - clonedEntity.startDate, - clonedEntity.startTime - ); - obj.endDateTime = getISOStringFromClientDateTime( - clonedEntity.endDate, - clonedEntity.endTime - ); - 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/_internal/getISOStringFromClientDateTime.ts b/packages/common/src/events/_internal/getISOStringFromClientDateTime.ts deleted file mode 100644 index 470a1d181ca..00000000000 --- a/packages/common/src/events/_internal/getISOStringFromClientDateTime.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * @private - * A utility method to aid in generating an ISO-8601 UTC date/time string from separate date & time - * inputs for the client's current locale. - * - * // a user in the America/New_York timeZone - * getTimeZoneISOStringFromLocalDateTime('2024-03-29', '13:00:00') - * // => `2024-03-29T17:00:00.000Z` - * - * // a user in America/Los_Angeles timeZone - * getTimeZoneISOStringFromLocalDateTime('2024-03-29', '13:00:00') - * // => `2024-03-29T20:00:00.000Z` - * - * @param date A date string in the format of `2024-03-29` - * @param time A time string in 24-hour format of `13:00:00` - * @returns an ISO-8601 UTC date/time string, e.g. `2024-03-29T16:00:00.000Z` - */ -export function getISOStringFromClientDateTime( - date: string, - time: string -): string { - const [yr, mon, day] = date - .split("-") - .map((segment) => parseInt(segment, 10)); - const [hr, min, sec] = time - .split(":") - .map((segment) => parseInt(segment, 10)); - const timeZoneDateTime = new Date(yr, mon - 1, day, hr, min, sec, 0); - return timeZoneDateTime.toISOString(); -} diff --git a/packages/common/src/events/_internal/getPropertyMap.ts b/packages/common/src/events/_internal/getPropertyMap.ts index 13be4786db9..2919eb6a79f 100644 --- a/packages/common/src/events/_internal/getPropertyMap.ts +++ b/packages/common/src/events/_internal/getPropertyMap.ts @@ -17,6 +17,10 @@ export function getPropertyMap(): IPropertyMap[] { "summary", "notifyAttendees", "allowRegistration", + "startDate", + "startTime", + "endDate", + "endTime", ]; return commonPropNames.reduce( (acc, propName) => [...acc, { entityKey: propName, storeKey: propName }], diff --git a/packages/common/src/events/api/orval/api/orval-events.ts b/packages/common/src/events/api/orval/api/orval-events.ts index 5cb7210f55e..cf126fe3325 100644 --- a/packages/common/src/events/api/orval/api/orval-events.ts +++ b/packages/common/src/events/api/orval/api/orval-events.ts @@ -5,8 +5,18 @@ * Hub Events Service * OpenAPI spec version: 0.0.1 */ -import { Awaited } from "../awaited-type"; import { customClient } from "../custom-client"; +import { Awaited } from "../awaited-type"; + +export interface IUpdateRegistration { + /** Role of the user in the event */ + role?: RegistrationRole; + /** Status of the registration */ + status?: RegistrationStatus; + /** Attendance type for this registration */ + type?: EventAttendanceType; +} + export interface IPagedRegistrationResponse { items: IRegistration[]; nextStart: number; @@ -49,6 +59,10 @@ export type GetRegistrationsParams = { * earliest ISO8601 updatedAt for the registrations */ updatedAtAfter?: string; + /** + * filter to be matched to firstName, lastName, or username + */ + name?: string; /** * the max amount of registrations to return */ @@ -67,6 +81,69 @@ export type GetRegistrationsParams = { sortOrder?: SortOrder; }; +export type GetEventsParams = { + /** + * Comma separated string list of EventAccess. Example: PRIVATE,ORG,PUBLIC + */ + access?: string; + /** + * Comma separated string list of associated entityIds + */ + entityIds?: string; + /** + * Comma separated string list of associated entity types. Example: associations,registrations,creator,addresses,onlineMeetings + */ + entityTypes?: string; + /** + * Comma separated string list of relation fields to include in response. Example: associations,registrations,creator,addresses,onlineMeetings + */ + include?: string; + /** + * latest ISO8601 start date-time for the events + */ + startDateTimeBefore?: string; + /** + * earliest ISO8601 start date-time for the events + */ + startDateTimeAfter?: string; + /** + * Comma separated string list of AttendanceTypes. Example: VIRTUAL,IN_PERSON + */ + attendanceTypes?: string; + /** + * Comma separated string list of categories + */ + categories?: string; + /** + * comma separated string list of event statuses. Example: PRIVATE,ORG,PUBLIC + */ + status?: string; + /** + * Comma separated string list of tags + */ + tags?: string; + /** + * string to match within an event title + */ + title?: string; + /** + * the max amount of events to return + */ + num?: string; + /** + * the index to start at + */ + start?: string; + /** + * Event property to sort results by + */ + sortBy?: EventSort; + /** + * sort results order desc or asc + */ + sortOrder?: SortOrder; +}; + export interface ICreateRegistration { /** ArcGIS Online id for a user. Will always be extracted from the token unless service token is used. */ agoId?: string; @@ -100,6 +177,8 @@ export interface IUpdateEvent { allDay?: boolean; /** Boolean to indicate if users can register for an event */ allowRegistration?: boolean; + /** Items associated with the event */ + associations?: ICreateAssociation[]; /** Valid ways to attend the event */ attendanceType?: EventAttendanceType[]; /** categories for the event */ @@ -136,12 +215,6 @@ export interface IUpdateEvent { title?: string; } -export interface IPagedEventResponse { - items: IEvent[]; - nextStart: number; - total: number; -} - export enum SortOrder { asc = "asc", desc = "desc", @@ -152,61 +225,6 @@ export enum EventSort { createdAt = "createdAt", updatedAt = "updatedAt", } -export type GetEventsParams = { - /** - * Comma separated string list of EventAccess - */ - access?: string; - /** - * Comma separated string list of relation fields to include in response - */ - include?: string; - /** - * latest ISO8601 start date-time for the events - */ - startDateTimeBefore?: string; - /** - * earliest ISO8601 start date-time for the events - */ - startDateTimeAfter?: string; - /** - * Comma separated string list of AttendanceTypes - */ - attendanceTypes?: string; - /** - * Comma separated string list of categories - */ - categories?: string; - /** - * comma separated string list of event statuses - */ - status?: string; - /** - * Comma separated string list of tags - */ - tags?: string; - /** - * string to match within an event title - */ - title?: string; - /** - * the max amount of events to return - */ - num?: string; - /** - * the index to start at - */ - start?: string; - /** - * Event property to sort results by - */ - sortBy?: EventSort; - /** - * sort results order desc or asc - */ - sortOrder?: SortOrder; -}; - export interface IRegistrationPermission { canDelete: boolean; canEdit: boolean; @@ -226,11 +244,55 @@ export interface IEventPermission { canSetStatusToRemoved: boolean; } +export enum RegistrationStatus { + PENDING = "PENDING", + ACCEPTED = "ACCEPTED", + DECLINED = "DECLINED", + BLOCKED = "BLOCKED", +} +export enum RegistrationRole { + OWNER = "OWNER", + ORGANIZER = "ORGANIZER", + ATTENDEE = "ATTENDEE", +} +export enum EventStatus { + PLANNED = "PLANNED", + CANCELED = "CANCELED", + REMOVED = "REMOVED", +} +export interface IOnlineMeeting { + capacity: number | null; + createdAt: string; + details: string | null; + eventId: string; + updatedAt: string; + url: string; +} + +export interface IUser { + agoId: string; + createdAt: string; + deleted: boolean; + email: string; + firstName: string; + lastName: string; + optedOut: boolean; + updatedAt: string; + username: string; +} + +export interface IAssociation { + entityId: string; + entityType: AssociationEntityType; + eventId: string; +} + export interface IEvent { access: EventAccess; addresses?: IAddress[]; allDay: boolean; allowRegistration: boolean; + associations?: IAssociation[]; attendanceType: EventAttendanceType[]; catalog: IEventCatalogItem[] | null; categories: string[]; @@ -262,50 +324,10 @@ export interface IEvent { updatedAt: string; } -export enum RegistrationStatus { - PENDING = "PENDING", - ACCEPTED = "ACCEPTED", - DECLINED = "DECLINED", - BLOCKED = "BLOCKED", -} -export enum RegistrationRole { - OWNER = "OWNER", - ORGANIZER = "ORGANIZER", - ATTENDEE = "ATTENDEE", -} -export interface IUpdateRegistration { - /** Role of the user in the event */ - role?: RegistrationRole; - /** Status of the registration */ - status?: RegistrationStatus; - /** Attendance type for this registration */ - type?: EventAttendanceType; -} - -export enum EventStatus { - PLANNED = "PLANNED", - CANCELED = "CANCELED", - REMOVED = "REMOVED", -} -export interface IOnlineMeeting { - capacity: number | null; - createdAt: string; - details: string | null; - eventId: string; - updatedAt: string; - url: string; -} - -export interface IUser { - agoId: string; - createdAt: string; - deleted: boolean; - email: string; - firstName: string; - lastName: string; - optedOut: boolean; - updatedAt: string; - username: string; +export interface IPagedEventResponse { + items: IEvent[]; + nextStart: number; + total: number; } export interface IRegistration { @@ -362,24 +384,6 @@ export enum EventAttendanceType { VIRTUAL = "VIRTUAL", IN_PERSON = "IN_PERSON", } -export enum EventAccess { - PRIVATE = "PRIVATE", - ORG = "ORG", - PUBLIC = "PUBLIC", -} -export interface ICreateAddress { - /** Street address */ - address: string; - /** Secondary address information (room, etc) */ - address2?: string; - /** Capacity of this location. Minimum value is 1 */ - capacity?: number; - /** Description for the address */ - description?: string; - /** Venue information for the address */ - venue?: string; -} - export interface ICreateEvent { /** Access level of the event */ access?: EventAccess; @@ -391,6 +395,8 @@ export interface ICreateEvent { allDay?: boolean; /** Boolean to indicate if users can register for an event */ allowRegistration?: boolean; + /** Items associated with the event */ + associations?: ICreateAssociation[]; /** Valid ways to attend the event */ attendanceType?: EventAttendanceType[]; /** categories for the event */ @@ -433,6 +439,36 @@ export interface ICreateEvent { username?: string; } +export enum AssociationEntityType { + Hub_Site_Application = "Hub Site Application", + Hub_Initiative = "Hub Initiative", + Hub_Project = "Hub Project", +} +export interface ICreateAssociation { + /** Entity Id */ + entityId: string; + /** Entity type */ + entityType: AssociationEntityType; +} + +export enum EventAccess { + PRIVATE = "PRIVATE", + ORG = "ORG", + PUBLIC = "PUBLIC", +} +export interface ICreateAddress { + /** Street address */ + address: string; + /** Secondary address information (room, etc) */ + address2?: string; + /** Capacity of this location. Minimum value is 1 */ + capacity?: number; + /** Description for the address */ + description?: string; + /** Venue information for the address */ + venue?: string; +} + type SecondParameter any> = Parameters[1]; export const createEvent = ( diff --git a/packages/common/src/events/api/types.ts b/packages/common/src/events/api/types.ts index b2981d14115..eb5ebd6dc04 100644 --- a/packages/common/src/events/api/types.ts +++ b/packages/common/src/events/api/types.ts @@ -1,4 +1,5 @@ export { + AssociationEntityType, EventAccess, EventAttendanceType, EventStatus, @@ -6,6 +7,8 @@ export { IAddress, IAddressExtent, IAddressLocation, + IAssociation, + ICreateAssociation, IOnlineMeeting, ICreateOnlineMeeting, ICreateAddress, diff --git a/packages/common/src/events/edit.ts b/packages/common/src/events/edit.ts index 998844110ff..f24f8e68523 100644 --- a/packages/common/src/events/edit.ts +++ b/packages/common/src/events/edit.ts @@ -7,6 +7,7 @@ import { createEvent as createEventApi, updateEvent as updateEventApi, } from "./api/events"; +import { deleteRegistration } from "./api"; /** * @private @@ -28,7 +29,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 +79,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()); @@ -118,3 +117,17 @@ export async function updateHubEvent( return mapper.storeToEntity(model, {}) as IHubEvent; } + +/** + * @private + * Remove an Event Attendee + * @param id event attendee id + * @param requestOptions + * @returns Promise + */ +export async function deleteHubEventAttendee( + id: number, + requestOptions: IHubRequestOptions +): Promise { + await deleteRegistration({ registrationId: id, ...requestOptions }); +} 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/initiatives/HubInitiative.ts b/packages/common/src/initiatives/HubInitiative.ts index 9f0b01c6b0b..32b2e6ec267 100644 --- a/packages/common/src/initiatives/HubInitiative.ts +++ b/packages/common/src/initiatives/HubInitiative.ts @@ -279,34 +279,7 @@ export class HubInitiative )) as IHubInitiativeEditor) : (cloneObject(this.entity) as IHubInitiativeEditor); - // 2. on initiative creation, pre-populate the sharing field - // with the core + collaobration groups if the user has the - // appropriate shareToGroup portal privilege - editor._groups = []; - const { access: canShare } = this.checkPermission( - "platform:portal:user:shareToGroup" - ); - if (!editor.id && canShare) { - const currentUserGroups: IGroup[] = - getProp(this.context, "currentUser.groups") || []; - const defaultShareWithGroups = [ - editorContext.contentGroupId, - editorContext.collaborationGroupId, - ].reduce((acc, groupId) => { - const group = currentUserGroups.find((g: IGroup) => g.id === groupId); - const canShareToGroup = - !!group && - (!group.isViewOnly || - (group.isViewOnly && - ["owner", "admin"].includes(group.memberType))); - - canShareToGroup && acc.push(groupId); - return acc; - }, []); - editor._groups = [...editor._groups, ...defaultShareWithGroups]; - } - - // 3. handle metrics + // 2. handle metrics const metrics = getEntityMetrics(this.entity); const metric = metrics.find((m) => m.id === editorContext.metricId); const displays = getWithDefault(this.entity, "view.metricDisplays", []); @@ -317,7 +290,7 @@ export class HubInitiative ) || {}; editor._metric = metricToEditor(metric, displayConfig); - // 4. handle association group + // 3. handle association group const assocGroupId = getProp(this.entity, "associations.groupId"); if (assocGroupId) { diff --git a/packages/common/src/projects/HubProject.ts b/packages/common/src/projects/HubProject.ts index 8e196a7894b..5a7aefd6c22 100644 --- a/packages/common/src/projects/HubProject.ts +++ b/packages/common/src/projects/HubProject.ts @@ -198,32 +198,8 @@ export class HubProject )) as IHubProjectEditor) : (cloneObject(this.entity) as IHubProjectEditor); - // 2. on project creation, pre-populate the sharing field - // with the core + collaobration groups if the user has the - // appropriate shareToGroup portal privilege + // 2. editor._groups handling editor._groups = []; - const { access: canShare } = this.checkPermission( - "platform:portal:user:shareToGroup" - ); - if (!editor.id && canShare) { - const currentUserGroups: IGroup[] = - getProp(this.context, "currentUser.groups") || []; - const defaultShareWithGroups = [ - editorContext.contentGroupId, - editorContext.collaborationGroupId, - ].reduce((acc, groupId) => { - const group = currentUserGroups.find((g: IGroup) => g.id === groupId); - const canShareToGroup = - !!group && - (!group.isViewOnly || - (group.isViewOnly && - ["owner", "admin"].includes(group.memberType))); - - canShareToGroup && acc.push(groupId); - return acc; - }, []); - editor._groups = [...editor._groups, ...defaultShareWithGroups]; - } // 3. handle metrics const metrics = getEntityMetrics(this.entity); diff --git a/packages/common/src/search/_internal/commonHelpers/getApi.ts b/packages/common/src/search/_internal/commonHelpers/getApi.ts index a1c2db4c329..5b8096e3e4d 100644 --- a/packages/common/src/search/_internal/commonHelpers/getApi.ts +++ b/packages/common/src/search/_internal/commonHelpers/getApi.ts @@ -6,6 +6,7 @@ import { shouldUseOgcApi } from "./shouldUseOgcApi"; import { getOgcApiDefinition } from "./getOgcApiDefinition"; import { shouldUseDiscussionsApi } from "./shouldUseDiscussionsApi"; import { getDiscussionsApiDefinition } from "./getDiscussionsApiDefinition"; +import { shouldUseEventsApi } from "./shouldUseEventsApi"; /** * @private @@ -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..9a2e5a9e8de --- /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 ["event", "eventAttendee"].includes(targetEntity) && !isPortal; +} diff --git a/packages/common/src/search/_internal/hubEventsHelpers/eventAttendeeToSearchResult.ts b/packages/common/src/search/_internal/hubEventsHelpers/eventAttendeeToSearchResult.ts new file mode 100644 index 00000000000..b5e0ff9f26c --- /dev/null +++ b/packages/common/src/search/_internal/hubEventsHelpers/eventAttendeeToSearchResult.ts @@ -0,0 +1,52 @@ +import { IHubSearchResult } from "../../types/IHubSearchResult"; +import { IRegistration } from "../../../events/api/orval/api/orval-events"; +import { AccessLevel } from "../../../core"; +import { getUser, IUser } from "@esri/arcgis-rest-portal"; +import { IHubSearchOptions } from "../../types/IHubSearchOptions"; +import { getUserHomeUrl } from "../../../urls/getUserHomeUrl"; +import { getUserThumbnailUrl } from "../../utils"; + +/** + * Transforms a given event attendee into a IHubSearchResult + * @param attendee + * @returns + */ +export async function eventAttendeeToSearchResult( + attendee: IRegistration, + options: IHubSearchOptions +): Promise { + const [user, creator] = await Promise.all([ + getUser({ + username: attendee.userId, + ...options.requestOptions, + }), + getUser({ + username: attendee.createdById, + ...options.requestOptions, + }), + ]); + return { + id: attendee.id.toString(), + access: user.access as AccessLevel, + name: user.fullName, + createdDate: new Date(attendee.createdAt), + createdDateSource: "attendee.createdAt", + updatedDate: new Date(attendee.updatedAt), + updatedDateSource: "attendee.updatedAt", + type: "Event Attendee", + family: "eventAttendee", + owner: creator.username, + rawResult: attendee, + links: { + self: getUserHomeUrl(user.username, options.requestOptions), + siteRelative: `/people/${user.username}`, + thumbnail: user.thumbnail + ? getUserThumbnailUrl( + options.requestOptions.portal, + user, + options.requestOptions.authentication?.token + ) + : null, + }, + }; +} 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/getOptionalPredicateStringsByKey.ts b/packages/common/src/search/_internal/hubEventsHelpers/getOptionalPredicateStringsByKey.ts new file mode 100644 index 00000000000..d012e292faa --- /dev/null +++ b/packages/common/src/search/_internal/hubEventsHelpers/getOptionalPredicateStringsByKey.ts @@ -0,0 +1,14 @@ +import { unique } from "../../../util"; +import { IFilter } from "../../types/IHubCatalog"; +import { getPredicateValuesByKey } from "./getPredicateValuesByKey"; + +export const getOptionalPredicateStringsByKey = ( + filters: IFilter[], + predicateKey: string +): string => { + const predicateValues = getPredicateValuesByKey(filters, predicateKey); + const str = predicateValues.filter(unique).join(","); + if (str) { + return str; + } +}; diff --git a/packages/common/src/search/_internal/hubEventsHelpers/getPredicateValuesByKey.ts b/packages/common/src/search/_internal/hubEventsHelpers/getPredicateValuesByKey.ts new file mode 100644 index 00000000000..e302845247c --- /dev/null +++ b/packages/common/src/search/_internal/hubEventsHelpers/getPredicateValuesByKey.ts @@ -0,0 +1,17 @@ +import { IFilter } from "../../types/IHubCatalog"; + +export 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, []); +}; diff --git a/packages/common/src/search/_internal/hubEventsHelpers/processAttendeeFilters.ts b/packages/common/src/search/_internal/hubEventsHelpers/processAttendeeFilters.ts new file mode 100644 index 00000000000..2beee9652f5 --- /dev/null +++ b/packages/common/src/search/_internal/hubEventsHelpers/processAttendeeFilters.ts @@ -0,0 +1,65 @@ +import { + EventAttendanceType, + GetRegistrationsParams, + RegistrationRole, + RegistrationStatus, +} from "../../../events/api/types"; +import { IQuery } from "../../types"; +import { getOptionalPredicateStringsByKey } from "./getOptionalPredicateStringsByKey"; +import { getPredicateValuesByKey } from "./getPredicateValuesByKey"; + +export function processAttendeeFilters( + query: IQuery +): Partial { + const processedFilters: Partial = { + eventId: query.properties.eventId, + }; + const useElseJoin = (value: string, defaults: string[]): string => + value?.length ? value : defaults.map((val) => val.toLowerCase()).join(","); + + const term = getPredicateValuesByKey(query.filters, "term"); + if (term.length) { + // TODO: remove ts-ignore once GetEventsParams supports filtering by username, firstName, lastName https://devtopia.esri.com/dc/hub/issues/10153 + // @ts-ignore + processedFilters.name = term[0]; + } + + processedFilters.type = useElseJoin( + getOptionalPredicateStringsByKey(query.filters, "attendanceType"), + [EventAttendanceType.VIRTUAL, EventAttendanceType.IN_PERSON] + ); + + processedFilters.role = useElseJoin( + getOptionalPredicateStringsByKey(query.filters, "role"), + [ + RegistrationRole.OWNER, + RegistrationRole.ORGANIZER, + RegistrationRole.ATTENDEE, + ] + ); + + processedFilters.status = useElseJoin( + getOptionalPredicateStringsByKey(query.filters, "status"), + [ + RegistrationStatus.PENDING, + RegistrationStatus.ACCEPTED, + RegistrationStatus.DECLINED, + RegistrationStatus.BLOCKED, + ] + ); + + const updatedDateRange = getPredicateValuesByKey( + query.filters, + "updatedDateRange" + ); + if (updatedDateRange.length) { + processedFilters.updatedAtBefore = new Date( + updatedDateRange[0].to + ).toISOString(); + processedFilters.updatedAtAfter = new Date( + updatedDateRange[0].from + ).toISOString(); + } + + return processedFilters; +} diff --git a/packages/common/src/search/_internal/hubEventsHelpers/processAttendeeOptions.ts b/packages/common/src/search/_internal/hubEventsHelpers/processAttendeeOptions.ts new file mode 100644 index 00000000000..43726d3564e --- /dev/null +++ b/packages/common/src/search/_internal/hubEventsHelpers/processAttendeeOptions.ts @@ -0,0 +1,32 @@ +import { + GetRegistrationsParams, + RegistrationSort, + SortOrder, +} from "../../../events/api/types"; +import { IHubSearchOptions } from "../../types/IHubSearchOptions"; + +export function processAttendeeOptions( + 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 = RegistrationSort.updatedAt; + } else if (options.sortField === "created") { + processedOptions.sortBy = RegistrationSort.createdAt; + } else if (options.sortField === "username") { + processedOptions.sortBy = RegistrationSort.username; + } else if (options.sortField === "firstName") { + processedOptions.sortBy = RegistrationSort.firstName; + } else if (options.sortField === "lastName") { + processedOptions.sortBy = RegistrationSort.lastName; + } + processedOptions.sortOrder = + options.sortOrder === "desc" ? SortOrder.desc : SortOrder.asc; + return processedOptions; +} 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..b20a0d66b7a --- /dev/null +++ b/packages/common/src/search/_internal/hubEventsHelpers/processFilters.ts @@ -0,0 +1,57 @@ +import { IFilter } from "../../types/IHubCatalog"; +import { + EventStatus, + GetEventsParams, +} from "../../../events/api/orval/api/orval-events"; +import { getOptionalPredicateStringsByKey } from "./getOptionalPredicateStringsByKey"; +import { getPredicateValuesByKey } from "./getPredicateValuesByKey"; + +/** + * 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/hubSearchChannels.ts b/packages/common/src/search/_internal/hubSearchChannels.ts index 6633c6b521c..0e451c53173 100644 --- a/packages/common/src/search/_internal/hubSearchChannels.ts +++ b/packages/common/src/search/_internal/hubSearchChannels.ts @@ -14,7 +14,7 @@ import { ISearchChannelsParams, channelToSearchResult, } from "../../discussions"; -import { IGroup, getGroup } from "@esri/arcgis-rest-portal"; +import { getGroup } from "@esri/arcgis-rest-portal"; /** * @private diff --git a/packages/common/src/search/_internal/hubSearchEventAttendees.ts b/packages/common/src/search/_internal/hubSearchEventAttendees.ts new file mode 100644 index 00000000000..54fcb8d21d6 --- /dev/null +++ b/packages/common/src/search/_internal/hubSearchEventAttendees.ts @@ -0,0 +1,56 @@ +import { getRegistrations } from "../../events/api/registrations"; +import { eventAttendeeToSearchResult } from "./hubEventsHelpers/eventAttendeeToSearchResult"; +import { IPredicate, IQuery } from "../types/IHubCatalog"; +import { IHubSearchOptions } from "../types/IHubSearchOptions"; +import { IHubSearchResponse } from "../types/IHubSearchResponse"; +import { IHubSearchResult } from "../types/IHubSearchResult"; +import { + GetRegistrationsParams, + IGetRegistrationsParams, + IPagedRegistrationResponse, +} from "../../events/api/types"; +import { processAttendeeOptions } from "./hubEventsHelpers/processAttendeeOptions"; +import { processAttendeeFilters } from "./hubEventsHelpers/processAttendeeFilters"; + +/** + * @private + * Execute event attendees search against the Events API + * @param query + * @param options + * @returns + */ +export async function hubSearchEventAttendees( + query: IQuery, + options: IHubSearchOptions +): Promise> { + const processedFilters = processAttendeeFilters(query); + const processedOptions = processAttendeeOptions(options); + const data: GetRegistrationsParams = { + ...processedFilters, + ...processedOptions, + }; + const { items, nextStart, total } = await getRegistrations({ + ...options.requestOptions, + data, + }); + const results = await Promise.all( + items.map((eventAttendee) => + eventAttendeeToSearchResult(eventAttendee, 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 hubSearchEventAttendees(query, { + ...options, + start: nextStart, + }); + }, + }; +} 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..0eb5a3e9668 100644 --- a/packages/common/src/search/_internal/index.ts +++ b/packages/common/src/search/_internal/index.ts @@ -3,3 +3,5 @@ export * from "./hubSearchItems"; export * from "./portalSearchGroups"; export * from "./portalSearchUsers"; export * from "./hubSearchChannels"; +export * from "./hubSearchEvents"; +export * from "./hubSearchEventAttendees"; diff --git a/packages/common/src/search/_internal/portalSearchItems.ts b/packages/common/src/search/_internal/portalSearchItems.ts index ca6c320436c..d4c686d619d 100644 --- a/packages/common/src/search/_internal/portalSearchItems.ts +++ b/packages/common/src/search/_internal/portalSearchItems.ts @@ -347,6 +347,7 @@ export const WellKnownItemPredicates: IWellKnownItemPredicates = { $initiative: [ { type: "Hub Initiative", + typekeywords: "hubInitiativeV2", }, ], $experience: [ diff --git a/packages/common/src/search/hubSearch.ts b/packages/common/src/search/hubSearch.ts index 36e163d627d..d108133a4c2 100644 --- a/packages/common/src/search/hubSearch.ts +++ b/packages/common/src/search/hubSearch.ts @@ -18,6 +18,8 @@ import { } from "./_internal/portalSearchUsers"; import { hubSearchItems } from "./_internal/hubSearchItems"; import { hubSearchChannels } from "./_internal/hubSearchChannels"; +import { hubSearchEvents } from "./_internal/hubSearchEvents"; +import { hubSearchEventAttendees } from "./_internal/hubSearchEventAttendees"; /** * Main entrypoint for searching via Hub @@ -86,6 +88,8 @@ export async function hubSearch( item: hubSearchItems, channel: hubSearchChannels, discussionPost: hubSearchItems, + event: hubSearchEvents, + eventAttendee: hubSearchEventAttendees, }, }; diff --git a/packages/common/src/search/types/IHubCatalog.ts b/packages/common/src/search/types/IHubCatalog.ts index 4497581b408..ca2fcbb7cd3 100644 --- a/packages/common/src/search/types/IHubCatalog.ts +++ b/packages/common/src/search/types/IHubCatalog.ts @@ -62,7 +62,10 @@ export type EntityType = | "groupMember" | "event" | "channel" - | "discussionPost"; + | "discussionPost" + | "event" + | "eventAttendee"; + /** * @private * diff --git a/packages/common/src/search/types/IHubSearchResult.ts b/packages/common/src/search/types/IHubSearchResult.ts index 5db13e78921..5d7187617cb 100644 --- a/packages/common/src/search/types/IHubSearchResult.ts +++ b/packages/common/src/search/types/IHubSearchResult.ts @@ -3,6 +3,8 @@ import { AccessLevel, IHubEntityBase, IHubLocation } from "../../core"; import { HubFamily, IHubGeography } from "../../types"; import { IOgcItem } from "../_internal/hubSearchItemsHelpers/interfaces"; import { IChannel } from "../../discussions/api/types"; +import { IRegistration } from "../../events/api"; +import { IEvent } from "../../events/api/orval/api/orval-events"; /** * Standardized light-weight search result structure, applicable to all @@ -67,7 +69,14 @@ 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 + | IRegistration; /** Allow any additional properties to be added */ [key: string]: any; diff --git a/packages/common/src/search/types/types.ts b/packages/common/src/search/types/types.ts index a421f68c416..25aa32c4f39 100644 --- a/packages/common/src/search/types/types.ts +++ b/packages/common/src/search/types/types.ts @@ -24,7 +24,9 @@ export type SortOption = | "modified" | "username" | "joined" - | "memberType"; + | "memberType" + | "firstName" + | "lastName"; /** * Defines a span of time by specifying a `from` and `to` Date diff --git a/packages/common/src/sites/_internal/getPropertyMap.ts b/packages/common/src/sites/_internal/getPropertyMap.ts index 3856842cb02..dcb20d89d2c 100644 --- a/packages/common/src/sites/_internal/getPropertyMap.ts +++ b/packages/common/src/sites/_internal/getPropertyMap.ts @@ -35,6 +35,7 @@ export function getPropertyMap(): IPropertyMap[] { map.push({ entityKey: "events", storeKey: "data.events" }); map.push({ entityKey: "initiatives", storeKey: "data.initiatives" }); map.push({ entityKey: "projects", storeKey: "data.projects" }); + map.push({ entityKey: "content", storeKey: "data.content" }); // Deeper/Indirect mappings map.push({ diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index 5f9cc646bb1..551e8828a3a 100644 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -192,7 +192,8 @@ export type HubFamily = | "template" | "project" | "channel" - | "discussion"; + | "discussion" + | "eventAttendee"; /** * Visibility levels of a Hub resource diff --git a/packages/common/test/content/_internal/ContentUiSchemaSettings.test.ts b/packages/common/test/content/_internal/ContentUiSchemaSettings.test.ts index fa7cbc18ae9..bfe27181176 100644 --- a/packages/common/test/content/_internal/ContentUiSchemaSettings.test.ts +++ b/packages/common/test/content/_internal/ContentUiSchemaSettings.test.ts @@ -43,13 +43,25 @@ describe("buildUiSchema: content settings", () => { { type: "weekly" }, { type: "monthly" }, { type: "yearly" }, - // uncomment this when the manual option is available - // { - // label: `option.manual.label`, - // type: "manual", - // helperActionIcon: "information-f", - // helperActionText: "option.manual.helperActionText", - // }, + { + type: "manual", + helperActionIcon: "information-f", + helperActionText: `{{some.scope.fields.schedule.manual.helperActionText:translate}}`, + }, + ], + }, + }, + { + type: "Control", + scope: "/properties/_forceUpdate", + options: { + control: "hub-field-input-tile-select", + type: "checkbox", + labels: [ + `{{some.scope.fields.schedule.forceUpdateButton.label:translate}}`, + ], + descriptions: [ + `{{some.scope.fields.schedule.forceUpdateButton.description:translate}}`, ], }, }, diff --git a/packages/common/test/content/_internal/internalContentUtils.test.ts b/packages/common/test/content/_internal/internalContentUtils.test.ts index 245bde19ce1..d9ece282630 100644 --- a/packages/common/test/content/_internal/internalContentUtils.test.ts +++ b/packages/common/test/content/_internal/internalContentUtils.test.ts @@ -9,6 +9,8 @@ import { IHubRequestOptions } from "../../../src/types"; import { cloneObject } from "../../../src/util"; import { MOCK_HUB_REQOPTS } from "../../mocks/mock-auth"; import { IHubLocation } from "../../../src"; +import * as _enrichmentsModule from "../../../src/items/_enrichments"; +import { IHubAdditionalResource } from "../../../src/core/types/IHubAdditionalResource"; describe("getContentEditUrl", () => { let requestOptions: IHubRequestOptions; diff --git a/packages/common/test/content/computeProps.test.ts b/packages/common/test/content/computeProps.test.ts index 30d27fa09cc..ae5bea95a92 100644 --- a/packages/common/test/content/computeProps.test.ts +++ b/packages/common/test/content/computeProps.test.ts @@ -1,6 +1,8 @@ +import * as internalContentUtilsModule from "../../src/content/_internal/internalContentUtils"; import { computeProps } from "../../src/content/_internal/computeProps"; +import { IHubAdditionalResource } from "../../src/core/types/IHubAdditionalResource"; import { IHubEditableContent } from "../../src/core/types/IHubEditableContent"; -import { IItemAndIServerEnrichments } from "../../src/items/_enrichments"; +import { IHubEditableContentEnrichments } from "../../src/items/_enrichments"; import { IHubRequestOptions, IModel } from "../../src/types"; import { cloneObject } from "../../src/util"; import { MOCK_HUB_REQOPTS } from "../mocks/mock-auth"; @@ -156,12 +158,55 @@ describe("content computeProps", () => { type: "Feature Service", id: "9001", }; - const enrichments: IItemAndIServerEnrichments = { - server: { capabilities: "Extract" }, + const enrichments: IHubEditableContentEnrichments = { + server: { + capabilities: "Extract", + supportedExportFormats: "csv,geojson", + } as unknown as IHubEditableContentEnrichments["server"], }; const chk = computeProps(model, content, requestOptions, enrichments); expect(chk.serverExtractCapability).toBeTruthy(); + expect(chk.serverExtractFormats).toEqual(["csv", "geojson"]); + }); + + it("calculates additionalResources if available", () => { + const metadata = { key: "value" } as any; + const enrichments: IHubEditableContentEnrichments = { metadata }; + const additionalResources: IHubAdditionalResource[] = [ + { + name: "My Resource", + url: "https://example.com/my-resource", + isDataSource: false, + }, + ]; + const getAdditionalResourcesSpy = spyOn( + internalContentUtilsModule, + "getAdditionalResources" + ).and.returnValue(additionalResources); + + const model: IModel = { + item: { + type: "Feature Service", + id: "9001", + created: new Date().getTime(), + modified: new Date().getTime(), + properties: {}, + }, + } as IModel; + const content: Partial = { + type: "Feature Service", + id: "9001", + }; + + const chk = computeProps(model, content, requestOptions, enrichments); + expect(chk.additionalResources).toEqual(additionalResources); + expect(getAdditionalResourcesSpy).toHaveBeenCalledTimes(1); + expect(getAdditionalResourcesSpy).toHaveBeenCalledWith( + model.item, + metadata, + requestOptions + ); }); it("handles when authentication isn't defined", () => { diff --git a/packages/common/test/content/edit.test.ts b/packages/common/test/content/edit.test.ts index 935115f0073..6e9ee264e2c 100644 --- a/packages/common/test/content/edit.test.ts +++ b/packages/common/test/content/edit.test.ts @@ -200,6 +200,8 @@ describe("content editing:", () => { // Since it already is, nothing should change serverExtractCapability: true, schedule: { mode: "automatic" }, + _forceUpdate: [], + access: "public", }; const chk = await updateContent(content, { ...MOCK_HUB_REQOPTS, @@ -245,6 +247,7 @@ describe("content editing:", () => { // Since it currently isn't, the service will be updated serverExtractCapability: true, schedule: { mode: "automatic" }, + _forceUpdate: [true], access: "public", }; const chk = await updateContent(content, { diff --git a/packages/common/test/content/fetch.test.ts b/packages/common/test/content/fetch.test.ts index 223683ae80e..89c31258ca8 100644 --- a/packages/common/test/content/fetch.test.ts +++ b/packages/common/test/content/fetch.test.ts @@ -11,6 +11,7 @@ import { } from "../../src"; import * as _enrichmentsModule from "../../src/items/_enrichments"; import * as _fetchModule from "../../src/content/_fetch"; +import * as scheduleModule from "../../src/content/manageSchedule"; import * as documentItem from "../mocks/items/document.json"; import * as multiLayerFeatureServiceItem from "../mocks/items/multi-layer-feature-service.json"; import { @@ -23,7 +24,7 @@ import { PDF_GUID, PDF_ITEM, } from "./fixtures"; -import { MOCK_AUTH } from "../mocks/mock-auth"; +import { MOCK_AUTH, MOCK_NOAUTH_HUB_REQOPTS } from "../mocks/mock-auth"; // mock the item enrichments that would be returned for a multi-layer service const getMultiLayerItemEnrichments = () => { @@ -680,6 +681,10 @@ describe("fetchHubContent", () => { featureLayerModule, "getService" ).and.returnValue(HOSTED_FEATURE_SERVICE_DEFINITION); + const fetchItemEnrichmentsSpy = spyOn( + _enrichmentsModule, + "fetchItemEnrichments" + ).and.returnValue({ metadata: null }); const chk = await fetchHubContent(HOSTED_FEATURE_SERVICE_GUID, { portal: MOCK_AUTH.portal, @@ -695,6 +700,13 @@ describe("fetchHubContent", () => { expect(getServiceSpy.calls.argsFor(0)[0].url).toBe( HOSTED_FEATURE_SERVICE_URL ); + // NOTE: the first call to fetchItemEnrichments is done by fetchContent under the hood, + // while the second call is done by fetchHubContent. We only care about the second call here + expect(fetchItemEnrichmentsSpy.calls.count()).toBe(2); + expect(fetchItemEnrichmentsSpy.calls.argsFor(1)[0]).toBe( + HOSTED_FEATURE_SERVICE_ITEM + ); + expect(fetchItemEnrichmentsSpy.calls.argsFor(1)[1]).toEqual(["metadata"]); }); it("gets non-hosted feature service", async () => { @@ -702,6 +714,10 @@ describe("fetchHubContent", () => { Promise.resolve(NON_HOSTED_FEATURE_SERVICE_ITEM) ); const getServiceSpy = spyOn(featureLayerModule, "getService"); + const fetchItemEnrichmentsSpy = spyOn( + _enrichmentsModule, + "fetchItemEnrichments" + ).and.returnValue({ metadata: null }); const chk = await fetchHubContent(NON_HOSTED_FEATURE_SERVICE_GUID, { portal: MOCK_AUTH.portal, @@ -715,6 +731,13 @@ describe("fetchHubContent", () => { expect(getItemSpy.calls.argsFor(0)[0]).toBe( NON_HOSTED_FEATURE_SERVICE_GUID ); + // NOTE: the first call to fetchItemEnrichments is done by fetchContent under the hood, + // while the second call is done by fetchHubContent. We only care about the second call here + expect(fetchItemEnrichmentsSpy.calls.count()).toBe(2); + expect(fetchItemEnrichmentsSpy.calls.argsFor(1)[0]).toBe( + NON_HOSTED_FEATURE_SERVICE_ITEM + ); + expect(fetchItemEnrichmentsSpy.calls.argsFor(1)[1]).toEqual(["metadata"]); // Service definition isn't fetched for non-hosted feature services expect(getServiceSpy.calls.count()).toBe(0); }); @@ -724,6 +747,10 @@ describe("fetchHubContent", () => { Promise.resolve(PDF_ITEM) ); const getServiceSpy = spyOn(featureLayerModule, "getService"); + const fetchItemEnrichmentsSpy = spyOn( + _enrichmentsModule, + "fetchItemEnrichments" + ).and.returnValue({ metadata: null }); const chk = await fetchHubContent(PDF_GUID, { authentication: MOCK_AUTH, @@ -734,6 +761,11 @@ describe("fetchHubContent", () => { expect(getItemSpy.calls.count()).toBe(1); expect(getItemSpy.calls.argsFor(0)[0]).toBe(PDF_GUID); + // NOTE: the first call to fetchItemEnrichments is done by fetchContent under the hood, + // while the second call is done by fetchHubContent. We only care about the second call here + expect(fetchItemEnrichmentsSpy.calls.count()).toBe(2); + expect(fetchItemEnrichmentsSpy.calls.argsFor(1)[0]).toBe(PDF_ITEM); + expect(fetchItemEnrichmentsSpy.calls.argsFor(1)[1]).toEqual(["metadata"]); // Service definition isn't fetched items that aren't hosted feature services expect(getServiceSpy.calls.count()).toBe(0); }); @@ -753,4 +785,98 @@ describe("fetchHubContent", () => { expect(chk.type).toBe("Hub Site Application"); }); + + it("should get schedule for request with a token", async () => { + const getItemSpy = spyOn(portalModule, "getItem").and.returnValue( + Promise.resolve(HOSTED_FEATURE_SERVICE_ITEM) + ); + const getServiceSpy = spyOn( + featureLayerModule, + "getService" + ).and.returnValue(HOSTED_FEATURE_SERVICE_DEFINITION); + const fetchItemEnrichmentsSpy = spyOn( + _enrichmentsModule, + "fetchItemEnrichments" + ).and.returnValue({ metadata: null }); + + const getScheduleSpy = spyOn(scheduleModule, "getSchedule").and.returnValue( + Promise.resolve({ + mode: "manual", + }) + ); + + const chk = await fetchHubContent(HOSTED_FEATURE_SERVICE_GUID, { + portal: MOCK_AUTH.portal, + authentication: MOCK_AUTH, + }); + + // test for schedule + expect(chk.schedule).toBeDefined(); + expect(getScheduleSpy.calls.count()).toBe(1); + + expect(chk.id).toBe(HOSTED_FEATURE_SERVICE_GUID); + expect(chk.owner).toBe(HOSTED_FEATURE_SERVICE_ITEM.owner); + expect(chk.serverExtractCapability).toBeTruthy(); + + expect(getItemSpy.calls.count()).toBe(1); + expect(getItemSpy.calls.argsFor(0)[0]).toBe(HOSTED_FEATURE_SERVICE_GUID); + expect(getServiceSpy.calls.count()).toBe(1); + expect(getServiceSpy.calls.argsFor(0)[0].url).toBe( + HOSTED_FEATURE_SERVICE_URL + ); + // NOTE: the first call to fetchItemEnrichments is done by fetchContent under the hood, + // while the second call is done by fetchHubContent. We only care about the second call here + expect(fetchItemEnrichmentsSpy.calls.count()).toBe(2); + expect(fetchItemEnrichmentsSpy.calls.argsFor(1)[0]).toBe( + HOSTED_FEATURE_SERVICE_ITEM + ); + expect(fetchItemEnrichmentsSpy.calls.argsFor(1)[1]).toEqual(["metadata"]); + }); + + it("should not get schedule for request without a token", async () => { + const getItemSpy = spyOn(portalModule, "getItem").and.returnValue( + Promise.resolve(HOSTED_FEATURE_SERVICE_ITEM) + ); + const getServiceSpy = spyOn( + featureLayerModule, + "getService" + ).and.returnValue(HOSTED_FEATURE_SERVICE_DEFINITION); + const fetchItemEnrichmentsSpy = spyOn( + _enrichmentsModule, + "fetchItemEnrichments" + ).and.returnValue({ metadata: null }); + + const getScheduleSpy = spyOn(scheduleModule, "getSchedule").and.returnValue( + Promise.resolve({ + mode: "manual", + }) + ); + + const chk = await fetchHubContent( + HOSTED_FEATURE_SERVICE_GUID, + MOCK_NOAUTH_HUB_REQOPTS + ); + + // test for schedule + expect(chk.schedule).not.toBeDefined(); + expect(getScheduleSpy.calls.count()).toBe(0); + + expect(chk.id).toBe(HOSTED_FEATURE_SERVICE_GUID); + expect(chk.owner).toBe(HOSTED_FEATURE_SERVICE_ITEM.owner); + expect(chk.serverExtractCapability).toBeTruthy(); + + expect(getItemSpy.calls.count()).toBe(1); + expect(getItemSpy.calls.argsFor(0)[0]).toBe(HOSTED_FEATURE_SERVICE_GUID); + expect(getServiceSpy.calls.count()).toBe(1); + expect(getServiceSpy.calls.argsFor(0)[0].url).toBe( + HOSTED_FEATURE_SERVICE_URL + ); + // NOTE: the first call to fetchItemEnrichments is done by fetchContent under the hood, + // while the second call is done by fetchHubContent. We only care about the second call here + expect(fetchItemEnrichmentsSpy.calls.count()).toBe(2); + expect(fetchItemEnrichmentsSpy.calls.argsFor(1)[0]).toBe( + HOSTED_FEATURE_SERVICE_ITEM + ); + expect(fetchItemEnrichmentsSpy.calls.argsFor(1)[1]).toEqual(["metadata"]); + }); }); diff --git a/packages/common/test/content/manageSchedule.test.ts b/packages/common/test/content/manageSchedule.test.ts index 41d3c086c42..fe82c5346e0 100644 --- a/packages/common/test/content/manageSchedule.test.ts +++ b/packages/common/test/content/manageSchedule.test.ts @@ -11,16 +11,20 @@ import { import { MOCK_HUB_REQOPTS } from "../mocks/mock-auth"; import { IHubEditableContent } from "../../src/core/types/IHubEditableContent"; import * as fetchMock from "fetch-mock"; -import { getSchedulerApiUrl } from "../../src/content/_internal/getSchedulerApiUrl"; +import { getSchedulerApiUrl } from "../../src/content/_internal/internalContentUtils"; +import { IUserRequestOptions } from "@esri/arcgis-rest-auth"; describe("manageSchedule", () => { afterEach(() => { fetchMock.restore(); }); it("getSchedulerApiUrl: returns the correct url when no version is attached on requestOptions", () => { - const url = getSchedulerApiUrl("123", MOCK_HUB_REQOPTS); + const url = getSchedulerApiUrl( + "123", + MOCK_HUB_REQOPTS as IUserRequestOptions + ); expect(url).toEqual( - "https://hubqa.arcgis.com/api/download/v1/items/123/schedule" + "https://hubqa.arcgis.com/api/download/v1/items/123/schedule?token=fake-token" ); }); it("getSchedulerApiUrl: returns the correct url when v3 is attached on requestOptions", () => { @@ -29,16 +33,18 @@ describe("manageSchedule", () => { hubApiUrl: "https://hubqa.arcgis.com/api/v3", }; - const url = getSchedulerApiUrl("123", requestOptions); + const url = getSchedulerApiUrl( + "123", + requestOptions as IUserRequestOptions + ); expect(url).toEqual( - "https://hubqa.arcgis.com/api/download/v1/items/123/schedule" + "https://hubqa.arcgis.com/api/download/v1/items/123/schedule?token=fake-token" ); }); - it("getSchedule: returns an error if no schedule is set", async () => { const item = { id: "123" }; fetchMock.once( - `https://hubqa.arcgis.com/api/download/v1/items/${item.id}/schedule`, + `https://hubqa.arcgis.com/api/download/v1/items/${item.id}/schedule?token=fake-token`, { error: "Not Found", message: `Download schedule for the item ${item.id} is not found.`, @@ -47,27 +53,27 @@ describe("manageSchedule", () => { ); const response: IHubScheduleResponse = await getSchedule( item.id, - MOCK_HUB_REQOPTS + MOCK_HUB_REQOPTS as IUserRequestOptions ); expect(response.message).toEqual( `Download schedule not found for item ${item.id}` ); expect(fetchMock.calls().length).toBe(1); }); - - it("getSchedule: returns schedule if set", async () => { + it("getSchedule: returns schedule of mode 'scheduled' if set", async () => { const item = { id: "123" }; fetchMock.once( - `https://hubqa.arcgis.com/api/download/v1/items/${item.id}/schedule`, + `https://hubqa.arcgis.com/api/download/v1/items/${item.id}/schedule?token=fake-token`, { cadence: "daily", hour: 0, timezone: "America/New_York", + itemId: "123", } ); const response: IHubScheduleResponse = await getSchedule( item.id, - MOCK_HUB_REQOPTS + MOCK_HUB_REQOPTS as IUserRequestOptions ); expect(response.schedule).toEqual({ mode: "scheduled", @@ -77,8 +83,25 @@ describe("manageSchedule", () => { }); expect(fetchMock.calls().length).toBe(1); }); - - it("setSchedule: sets the item's schedule", async () => { + it("getSchedule: returns schedule of mode 'manual' if set", async () => { + const item = { id: "123" }; + fetchMock.once( + `https://hubqa.arcgis.com/api/download/v1/items/${item.id}/schedule?token=fake-token`, + { + mode: "manual", + itemId: "123", + } + ); + const response: IHubScheduleResponse = await getSchedule( + item.id, + MOCK_HUB_REQOPTS as IUserRequestOptions + ); + expect(response.schedule).toEqual({ + mode: "manual", + }); + expect(fetchMock.calls().length).toBe(1); + }); + it("setSchedule: sets the item's schedule of mode 'scheduled'", async () => { const item = { id: "123" }; const schedule = { mode: "scheduled", @@ -88,17 +111,41 @@ describe("manageSchedule", () => { } as IHubSchedule; fetchMock.post( - `https://hubqa.arcgis.com/api/download/v1/items/${item.id}/schedule`, + `https://hubqa.arcgis.com/api/download/v1/items/${item.id}/schedule?token=fake-token`, { message: "Download schedule set successfully.", } ); - const response = await setSchedule(item.id, schedule, MOCK_HUB_REQOPTS); + const response = await setSchedule( + item.id, + schedule, + MOCK_HUB_REQOPTS as IUserRequestOptions + ); expect(response.message).toEqual("Download schedule set successfully."); expect(fetchMock.calls().length).toBe(1); }); + it("setSchedule: sets the item's schedule of mode 'manual'", async () => { + const item = { id: "123" }; + const schedule = { + mode: "manual", + } as IHubSchedule; + + fetchMock.post( + `https://hubqa.arcgis.com/api/download/v1/items/${item.id}/schedule?token=fake-token`, + { + message: "Download schedule set successfully.", + } + ); + const response = await setSchedule( + item.id, + schedule, + MOCK_HUB_REQOPTS as IUserRequestOptions + ); + expect(response.message).toEqual("Download schedule set successfully."); + expect(fetchMock.calls().length).toBe(1); + }); it("setSchedule: attempts to set an invalid schedule", async () => { const item = { id: "123" }; const schedule = { @@ -109,7 +156,7 @@ describe("manageSchedule", () => { } as IHubSchedule; fetchMock.post( - `https://hubqa.arcgis.com/api/download/v1/items/${item.id}/schedule`, + `https://hubqa.arcgis.com/api/download/v1/items/${item.id}/schedule?token=fake-token`, { title: "unit out of range", message: @@ -117,28 +164,33 @@ describe("manageSchedule", () => { } ); - const response = await setSchedule(item.id, schedule, MOCK_HUB_REQOPTS); + const response = await setSchedule( + item.id, + schedule, + MOCK_HUB_REQOPTS as IUserRequestOptions + ); expect(response.message).toEqual( "you specified 26 (of type number) as a hour, which is invalid" ); expect(fetchMock.calls().length).toBe(1); }); - it("deleteSchedule: tries to delete an item's schedule", async () => { const item = { id: "123" }; fetchMock.delete( - `https://hubqa.arcgis.com/api/download/v1/items/${item.id}/schedule`, + `https://hubqa.arcgis.com/api/download/v1/items/${item.id}/schedule?token=fake-token`, { message: "Download schedule deleted successfully.", } ); - const response = await deleteSchedule(item.id, MOCK_HUB_REQOPTS); + const response = await deleteSchedule( + item.id, + MOCK_HUB_REQOPTS as IUserRequestOptions + ); expect(response.message).toEqual("Download schedule deleted successfully."); expect(fetchMock.calls().length).toBe(1); }); - it("maybeUpdateSchedule: no schedule is set, and updating to automatic is not needed", async () => { const item = { id: "123" }; const content = { @@ -147,7 +199,7 @@ describe("manageSchedule", () => { } as IHubEditableContent; fetchMock.get( - `https://hubqa.arcgis.com/api/download/v1/items/${item.id}/schedule`, + `https://hubqa.arcgis.com/api/download/v1/items/${item.id}/schedule?token=fake-token`, { error: "Not Found", message: `Download schedule for the item ${item.id} is not found.`, @@ -155,13 +207,15 @@ describe("manageSchedule", () => { } ); - const response = await maybeUpdateSchedule(content, MOCK_HUB_REQOPTS); + const response = await maybeUpdateSchedule( + content, + MOCK_HUB_REQOPTS as IUserRequestOptions + ); expect(response.message).toEqual( `No schedule set, and incoming schedule is automatic.` ); expect(fetchMock.calls().length).toBe(1); }); - it("maybeUpdateSchedule: no schedule is set, and updating to scheduled is needed", async () => { const item = { id: "123" }; const content = { @@ -176,7 +230,7 @@ describe("manageSchedule", () => { fetchMock .get( - `https://hubqa.arcgis.com/api/download/v1/items/${item.id}/schedule`, + `https://hubqa.arcgis.com/api/download/v1/items/${item.id}/schedule?token=fake-token`, { error: "Not Found", message: `Download schedule for the item ${item.id} is not found.`, @@ -184,17 +238,19 @@ describe("manageSchedule", () => { } ) .post( - `https://hubqa.arcgis.com/api/download/v1/items/${item.id}/schedule`, + `https://hubqa.arcgis.com/api/download/v1/items/${item.id}/schedule?token=fake-token`, { message: "Download schedule set successfully.", } ); - const response = await maybeUpdateSchedule(content, MOCK_HUB_REQOPTS); + const response = await maybeUpdateSchedule( + content, + MOCK_HUB_REQOPTS as IUserRequestOptions + ); expect(response.message).toEqual("Download schedule set successfully."); expect(fetchMock.calls().length).toBe(2); }); - it("maybeUpdateSchedule: schedule is set, and updating to automatic requires deleting the schedule", async () => { const item = { id: "123" }; const content = { @@ -204,7 +260,7 @@ describe("manageSchedule", () => { fetchMock .get( - `https://hubqa.arcgis.com/api/download/v1/items/${item.id}/schedule`, + `https://hubqa.arcgis.com/api/download/v1/items/${item.id}/schedule?token=fake-token`, { cadence: "daily", hour: 0, @@ -212,17 +268,19 @@ describe("manageSchedule", () => { } ) .delete( - `https://hubqa.arcgis.com/api/download/v1/items/${item.id}/schedule`, + `https://hubqa.arcgis.com/api/download/v1/items/${item.id}/schedule?token=fake-token`, { message: "Download schedule deleted successfully.", } ); - const response = await maybeUpdateSchedule(content, MOCK_HUB_REQOPTS); + const response = await maybeUpdateSchedule( + content, + MOCK_HUB_REQOPTS as IUserRequestOptions + ); expect(response.message).toEqual("Download schedule deleted successfully."); expect(fetchMock.calls().length).toBe(2); }); - it("maybeUpdateSchedule: schedule is set, and no action is needed as schedules deepEqual each other", async () => { const item = { id: "123" }; const content = { @@ -236,7 +294,7 @@ describe("manageSchedule", () => { } as IHubEditableContent; fetchMock.get( - `https://hubqa.arcgis.com/api/download/v1/items/${item.id}/schedule`, + `https://hubqa.arcgis.com/api/download/v1/items/${item.id}/schedule?token=fake-token`, { cadence: "daily", hour: 0, @@ -244,7 +302,10 @@ describe("manageSchedule", () => { } ); - const response = await maybeUpdateSchedule(content, MOCK_HUB_REQOPTS); + const response = await maybeUpdateSchedule( + content, + MOCK_HUB_REQOPTS as IUserRequestOptions + ); expect(response.message).toEqual( "No action needed as schedules deepEqual each other." ); diff --git a/packages/common/test/core/schemas/internal/getEditorSchemas.test.ts b/packages/common/test/core/schemas/internal/getEditorSchemas.test.ts index 12c770a6359..af0433d96ad 100644 --- a/packages/common/test/core/schemas/internal/getEditorSchemas.test.ts +++ b/packages/common/test/core/schemas/internal/getEditorSchemas.test.ts @@ -61,6 +61,7 @@ import * as checkPermissionModule from "../../../../src/permissions/checkPermiss import { EventEditorTypes } from "../../../../src/events/_internal/EventSchemaCreate"; import * as EventBuildCreateUiSchema from "../../../../src/events/_internal/EventUiSchemaCreate"; import * as EventBuildEditUiSchema from "../../../../src/events/_internal/EventUiSchemaEdit"; +import * as EventAttendeesSettingsUiSchema from "../../../../src/events/_internal/EventUiSchemaAttendeesSettings"; describe("getEditorSchemas: ", () => { let uiSchemaBuildFnSpy: any; @@ -115,6 +116,7 @@ describe("getEditorSchemas: ", () => { { type: SurveyEditorTypes[1], module: SurveyBuildSettingsUiSchema }, { type: EventEditorTypes[0], module: EventBuildCreateUiSchema }, { type: EventEditorTypes[1], module: EventBuildEditUiSchema }, + { type: EventEditorTypes[2], module: EventAttendeesSettingsUiSchema }, { type: validCardEditorTypes[0], module: statUiSchemaModule }, ]; diff --git a/packages/common/test/downloads/_internal/canCreateExportItem.test.ts b/packages/common/test/downloads/_internal/canCreateExportItem.test.ts new file mode 100644 index 00000000000..e1c7b9de649 --- /dev/null +++ b/packages/common/test/downloads/_internal/canCreateExportItem.test.ts @@ -0,0 +1,26 @@ +import { IArcGISContext } from "../../../src/ArcGISContext"; +import { IHubEditableContent } from "../../../src/core/types/IHubEditableContent"; +import { canCreateExportItem } from "../../../src/downloads/_internal/canCreateExportItem"; + +describe("canCreateExportItem", () => { + let entity: IHubEditableContent; + let context: IArcGISContext; + + beforeEach(() => { + // Initialize the entity and context for each test case + entity = {} as unknown as IHubEditableContent; + context = {} as unknown as IArcGISContext; + }); + + // NOTE: canCreateExportItem always returns true for now, update this test once the function is fully implemented + it("should return true if the user has permission to create an export item", () => { + const result = canCreateExportItem(entity, context); + expect(result).toBe(true); + }); + + // NOTE: Uncomment and implement once this function is fully implemented + // it("should return false if the user does not have permission to create an export item", () => { + // const result = canCreateExportItem(entity, context); + // expect(result).toBe(false); + // }); +}); diff --git a/packages/common/test/downloads/_internal/canUseExportImageFlow.test.ts b/packages/common/test/downloads/_internal/canUseExportImageFlow.test.ts new file mode 100644 index 00000000000..bbe17e3d723 --- /dev/null +++ b/packages/common/test/downloads/_internal/canUseExportImageFlow.test.ts @@ -0,0 +1,18 @@ +import { IHubEditableContent } from "../../../src/core/types/IHubEditableContent"; +import { canUseExportImageFlow } from "../../../src/downloads/_internal/canUseExportImageFlow"; + +describe("canUseExportImageFlow", () => { + it('should return true when entity type is "Image Service"', () => { + const entity = { type: "Image Service" } as unknown as IHubEditableContent; + const result = canUseExportImageFlow(entity); + expect(result).toBe(true); + }); + + it('should return false when entity type is not "Image Service"', () => { + const entity = { + type: "Feature Service", + } as unknown as IHubEditableContent; + const result = canUseExportImageFlow(entity); + expect(result).toBe(false); + }); +}); diff --git a/packages/common/test/downloads/_internal/canUseExportItemFlow.test.ts b/packages/common/test/downloads/_internal/canUseExportItemFlow.test.ts new file mode 100644 index 00000000000..e07759c8885 --- /dev/null +++ b/packages/common/test/downloads/_internal/canUseExportItemFlow.test.ts @@ -0,0 +1,23 @@ +import * as hostedServiceUtils from "../../../src/content/hostedServiceUtils"; +import { IHubEditableContent } from "../../../src/core/types/IHubEditableContent"; +import { canUseExportItemFlow } from "../../../src/downloads/_internal/canUseExportItemFlow"; + +describe("canUseExportItemFlow", () => { + it("should return true when isHostedFeatureServiceEntity returns true", () => { + spyOn(hostedServiceUtils, "isHostedFeatureServiceEntity").and.returnValue( + true + ); + const entity: IHubEditableContent = {} as unknown as IHubEditableContent; + const result = canUseExportItemFlow(entity); + expect(result).toBe(true); + }); + + it("should return false when isHostedFeatureServiceEntity returns false", () => { + spyOn(hostedServiceUtils, "isHostedFeatureServiceEntity").and.returnValue( + false + ); + const entity: IHubEditableContent = {} as unknown as IHubEditableContent; + const result = canUseExportItemFlow(entity); + expect(result).toBe(false); + }); +}); diff --git a/packages/common/test/downloads/_internal/file-url-fetchers/fetchExportImageDownloadFileUrl.test.ts b/packages/common/test/downloads/_internal/file-url-fetchers/fetchExportImageDownloadFileUrl.test.ts new file mode 100644 index 00000000000..ace75640fab --- /dev/null +++ b/packages/common/test/downloads/_internal/file-url-fetchers/fetchExportImageDownloadFileUrl.test.ts @@ -0,0 +1,144 @@ +import * as requestModule from "@esri/arcgis-rest-request"; +import { + DownloadOperationStatus, + IFetchDownloadFileUrlOptions, +} from "../../../../src/downloads/types"; +import { fetchExportImageDownloadFileUrl } from "../../../../src/downloads/_internal/file-url-fetchers/fetchExportImageDownloadFileUrl"; + +describe("fetchExportImageDownloadFileUrl", () => { + it("should call progressCallback with PENDING and COMPLETED statuses", async () => { + const requestSpy = spyOn(requestModule, "request").and.returnValue( + Promise.resolve({ href: "result-url" }) + ); + const progressCallback = jasmine + .createSpy("progressCallback") + .and.callFake((_status: DownloadOperationStatus): any => null); + + const options = { + entity: { type: "Image Service", url: "http://example-service.com" }, + format: "png", + context: { requestOptions: {} }, + progressCallback, + } as unknown as IFetchDownloadFileUrlOptions; + + const result = await fetchExportImageDownloadFileUrl(options); + expect(result).toBe("result-url"); + + expect(progressCallback).toHaveBeenCalledTimes(2); + expect(progressCallback).toHaveBeenCalledWith( + DownloadOperationStatus.PENDING + ); + expect(progressCallback).toHaveBeenCalledWith( + DownloadOperationStatus.COMPLETED + ); + + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(requestSpy).toHaveBeenCalledWith( + "http://example-service.com/exportImage", + { + httpMethod: "GET", + params: { + format: "png", + mosaicRule: + '{"ascending":true,"mosaicMethod":"esriMosaicNorthwest","mosaicOperation":"MT_FIRST"}', + }, + } + ); + }); + + it("handles when no progressCallback is passed", async () => { + const requestSpy = spyOn(requestModule, "request").and.returnValue( + Promise.resolve({ href: "result-url" }) + ); + + const options = { + entity: { type: "Image Service", url: "http://example-service.com" }, + format: "png", + context: { requestOptions: {} }, + } as unknown as IFetchDownloadFileUrlOptions; + + const result = await fetchExportImageDownloadFileUrl(options); + expect(result).toBe("result-url"); + + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(requestSpy).toHaveBeenCalledWith( + "http://example-service.com/exportImage", + { + httpMethod: "GET", + params: { + format: "png", + mosaicRule: + '{"ascending":true,"mosaicMethod":"esriMosaicNorthwest","mosaicOperation":"MT_FIRST"}', + }, + } + ); + }); + + it("handles a non-extent geometry", async () => { + const requestSpy = spyOn(requestModule, "request").and.returnValue( + Promise.resolve({ href: "result-url" }) + ); + + const options = { + entity: { type: "Image Service", url: "http://example-service.com" }, + format: "png", + context: { requestOptions: {} }, + geometry: { type: "point" }, + } as unknown as IFetchDownloadFileUrlOptions; + + const result = await fetchExportImageDownloadFileUrl(options); + expect(result).toBe("result-url"); + + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(requestSpy).toHaveBeenCalledWith( + "http://example-service.com/exportImage", + { + httpMethod: "GET", + params: { + format: "png", + mosaicRule: + '{"ascending":true,"mosaicMethod":"esriMosaicNorthwest","mosaicOperation":"MT_FIRST"}', + }, + } + ); + }); + + it("handles an extent geometry", async () => { + const requestSpy = spyOn(requestModule, "request").and.returnValue( + Promise.resolve({ href: "result-url" }) + ); + + const options = { + entity: { type: "Image Service", url: "http://example-service.com" }, + format: "png", + context: { requestOptions: {} }, + geometry: { + type: "extent", + xmin: 0, + xmax: 1, + ymin: 0, + ymax: 1, + spatialReference: { wkid: 4326 }, + }, + } as unknown as IFetchDownloadFileUrlOptions; + + const result = await fetchExportImageDownloadFileUrl(options); + expect(result).toBe("result-url"); + + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(requestSpy).toHaveBeenCalledWith( + "http://example-service.com/exportImage", + { + httpMethod: "GET", + params: { + format: "png", + mosaicRule: + '{"ascending":true,"mosaicMethod":"esriMosaicNorthwest","mosaicOperation":"MT_FIRST"}', + bbox: "0,0,1,1", + bboxSR: "4326", + imageSR: "4326", + }, + } + ); + }); +}); diff --git a/packages/common/test/downloads/_internal/file-url-fetchers/fetchExportItemDownloadFileUrl.test.ts b/packages/common/test/downloads/_internal/file-url-fetchers/fetchExportItemDownloadFileUrl.test.ts new file mode 100644 index 00000000000..a987902d012 --- /dev/null +++ b/packages/common/test/downloads/_internal/file-url-fetchers/fetchExportItemDownloadFileUrl.test.ts @@ -0,0 +1,202 @@ +import * as arcgisRestPortalModule from "@esri/arcgis-rest-portal"; +import * as getExportItemDataUrlModule from "../../../../src/downloads/_internal/getExportItemDataUrl"; +import { fetchExportItemDownloadFileUrl } from "../../../../src/downloads/_internal/file-url-fetchers/fetchExportItemDownloadFileUrl"; +import { + DownloadOperationStatus, + IArcGISContext, + IHubEditableContent, + ServiceDownloadFormat, +} from "../../../../src"; + +describe("fetchExportItemDownloadFileUrl", () => { + let exportItemSpy: jasmine.Spy; + let getItemStatusSpy: jasmine.Spy; + let getExportItemDataUrlSpy: jasmine.Spy; + let mockContext: IArcGISContext; + + beforeEach(() => { + exportItemSpy = spyOn(arcgisRestPortalModule, "exportItem"); + getItemStatusSpy = spyOn(arcgisRestPortalModule, "getItemStatus"); + getExportItemDataUrlSpy = spyOn( + getExportItemDataUrlModule, + "getExportItemDataUrl" + ); + mockContext = { + hubRequestOptions: { + authentication: { + portal: "https://some-portal.com", + }, + }, + } as unknown as IArcGISContext; + }); + + it("should throw an error if a geometry is provided", async () => { + try { + await fetchExportItemDownloadFileUrl({ + entity: { id: "some-id" } as IHubEditableContent, + layers: [0], + format: ServiceDownloadFormat.CSV, + context: mockContext, + geometry: { + x: 1, + y: 2, + spatialReference: { wkid: 4326 }, + } as unknown as __esri.Geometry, + }); + expect(true).toBe( + false, + "fetchExportItemDownloadFileUrl should have thrown an error" + ); + } catch (error) { + expect(error.message).toBe( + "Geometric filters are not supported for this type of download" + ); + } + }); + + it("should throw an error if a where clause is provided", async () => { + try { + await fetchExportItemDownloadFileUrl({ + entity: { id: "some-id" } as IHubEditableContent, + layers: [0], + format: ServiceDownloadFormat.CSV, + context: mockContext, + where: "1=1", + }); + expect(true).toBe( + false, + "fetchExportItemDownloadFileUrl should have thrown an error" + ); + } catch (error) { + expect(error.message).toBe( + "Attribute filters are not supported for this type of download" + ); + } + }); + + it("should throw error if status is returned as failed", async () => { + exportItemSpy.and.callFake(async () => ({ + jobId: "some-job-id", + exportItemId: "some-export-id", + })); + getItemStatusSpy.and.callFake(async () => { + return { status: "failed" }; + }); + + try { + await fetchExportItemDownloadFileUrl({ + entity: { id: "some-id" } as IHubEditableContent, + layers: [0], + format: ServiceDownloadFormat.FILE_GDB, + context: mockContext, + pollInterval: 0, + }); + expect(true).toBe( + false, + "fetchExportItemDownloadFileUrl should have thrown an error" + ); + } catch (error) { + expect(error.message).toBe("Export job failed"); + expect(exportItemSpy).toHaveBeenCalledTimes(1); + expect(exportItemSpy).toHaveBeenCalledWith({ + id: "some-id", + exportFormat: "File Geodatabase", // legacy format + exportParameters: { layers: [{ id: 0 }] }, + authentication: { + portal: "https://some-portal.com", + }, + }); + expect(getItemStatusSpy).toHaveBeenCalledTimes(1); + expect(getItemStatusSpy).toHaveBeenCalledWith({ + id: "some-export-id", + jobId: "some-job-id", + jobType: "export", + authentication: { + portal: "https://some-portal.com", + }, + }); + expect(getExportItemDataUrlSpy).toHaveBeenCalledTimes(0); + } + }); + + it("should update progress on an FGDB download", async () => { + let pollCount = 0; + exportItemSpy.and.callFake(async () => ({ + jobId: "some-job-id", + exportItemId: "some-export-id", + })); + getItemStatusSpy.and.callFake(async () => { + pollCount++; + if (pollCount === 1) { + return { status: "processing" }; + } else if (pollCount === 2) { + return { status: "completed" }; + } + }); + getExportItemDataUrlSpy.and.callFake(() => "https://some-url.com"); + const progressCallback = jasmine + .createSpy("progressCallback") + .and.callFake((_status: DownloadOperationStatus): any => null); + const result = await fetchExportItemDownloadFileUrl({ + entity: { id: "some-id" } as IHubEditableContent, + layers: [0], + format: ServiceDownloadFormat.FILE_GDB, + context: mockContext, + progressCallback, + pollInterval: 0, + }); + expect(result).toBe("https://some-url.com"); + + expect(progressCallback).toHaveBeenCalledTimes(3); + expect(progressCallback).toHaveBeenCalledWith( + DownloadOperationStatus.PENDING + ); + expect(progressCallback).toHaveBeenCalledWith( + DownloadOperationStatus.PROCESSING + ); + expect(progressCallback).toHaveBeenCalledWith( + DownloadOperationStatus.COMPLETED + ); + + expect(exportItemSpy).toHaveBeenCalledTimes(1); + expect(exportItemSpy).toHaveBeenCalledWith({ + id: "some-id", + exportFormat: "File Geodatabase", // legacy format + exportParameters: { layers: [{ id: 0 }] }, + authentication: { + portal: "https://some-portal.com", + }, + }); + expect(getItemStatusSpy).toHaveBeenCalledTimes(2); + expect(getItemStatusSpy).toHaveBeenCalledWith({ + id: "some-export-id", + jobId: "some-job-id", + jobType: "export", + authentication: { + portal: "https://some-portal.com", + }, + }); + expect(getExportItemDataUrlSpy).toHaveBeenCalledTimes(1); + expect(getExportItemDataUrlSpy).toHaveBeenCalledWith( + "some-export-id", + mockContext + ); + }); + + it("should fetch export item data url for a CSV download", async () => { + exportItemSpy.and.callFake(async () => ({ + jobId: "some-job-id", + exportItemId: "some-export-id", + })); + getItemStatusSpy.and.callFake(async () => ({ status: "completed" })); + getExportItemDataUrlSpy.and.callFake(() => "https://some-url.com"); + const result = await fetchExportItemDownloadFileUrl({ + entity: { id: "some-id" } as IHubEditableContent, + layers: [0], + format: ServiceDownloadFormat.CSV, + context: mockContext, + pollInterval: 0, + }); + expect(result).toBe("https://some-url.com"); + }); +}); diff --git a/packages/common/test/downloads/_internal/file-url-fetchers/fetchHubApiDownloadFileUrl.test.ts b/packages/common/test/downloads/_internal/file-url-fetchers/fetchHubApiDownloadFileUrl.test.ts new file mode 100644 index 00000000000..e4eb09591c8 --- /dev/null +++ b/packages/common/test/downloads/_internal/file-url-fetchers/fetchHubApiDownloadFileUrl.test.ts @@ -0,0 +1,290 @@ +import { + ArcgisHubDownloadError, + DownloadOperationStatus, + IArcGISContext, + IHubEditableContent, + ServiceDownloadFormat, +} from "../../../../src"; +import { fetchHubApiDownloadFileUrl } from "../../../../src/downloads/_internal/file-url-fetchers/fetchHubApiDownloadFileUrl"; +import * as fetchMock from "fetch-mock"; + +describe("fetchHubApiDownloadFileUrl", () => { + afterEach(fetchMock.restore); + it("throws an error if no layers are provided", async () => { + try { + await fetchHubApiDownloadFileUrl({ + entity: { id: "123" } as unknown as IHubEditableContent, + format: ServiceDownloadFormat.CSV, + context: { + hubUrl: "https://hubqa.arcgis.com", + hubRequestOptions: {}, + } as unknown as IArcGISContext, + }); + expect(true).toBe(false); + } catch (error) { + expect(error.message).toBe("No layers provided for download"); + } + }); + it("throws an error if empty layers array is provided", async () => { + try { + await fetchHubApiDownloadFileUrl({ + entity: { id: "123" } as unknown as IHubEditableContent, + format: ServiceDownloadFormat.CSV, + context: { + hubUrl: "https://hubqa.arcgis.com", + hubRequestOptions: {}, + } as unknown as IArcGISContext, + layers: [], + }); + expect(true).toBe(false); + } catch (error) { + expect(error.message).toBe("No layers provided for download"); + } + }); + it("throws an error if multiple layers are provided", async () => { + try { + await fetchHubApiDownloadFileUrl({ + entity: { id: "123" } as unknown as IHubEditableContent, + format: ServiceDownloadFormat.CSV, + context: { + hubUrl: "https://hubqa.arcgis.com", + hubRequestOptions: {}, + } as unknown as IArcGISContext, + layers: [0, 1], + }); + expect(true).toBe(false); + } catch (error) { + expect(error.message).toBe( + "Multiple layer downloads are not yet supported" + ); + } + }); + it("throws an ArcgisHubDownloadError if the api returns an error during polling", async () => { + fetchMock.once( + "https://hubqa.arcgis.com/api/download/v1/items/123/csv?redirect=false&layers=0", + { + status: 500, + body: { message: "Special Server Error" }, + } + ); + try { + await fetchHubApiDownloadFileUrl({ + entity: { id: "123" } as unknown as IHubEditableContent, + format: ServiceDownloadFormat.CSV, + context: { + hubUrl: "https://hubqa.arcgis.com", + hubRequestOptions: {}, + } as unknown as IArcGISContext, + layers: [0], + pollInterval: 0, + }); + expect(true).toBe(false); + } catch (error) { + expect(error instanceof ArcgisHubDownloadError).toBeTruthy(); + expect(error.message).toBe("Special Server Error"); + } + }); + it('throws an error when the api returns a status of "Failed"', async () => { + fetchMock.once( + "https://hubqa.arcgis.com/api/download/v1/items/123/csv?redirect=false&layers=0", + { body: { status: "Failed" } } + ); + try { + await fetchHubApiDownloadFileUrl({ + entity: { id: "123" } as unknown as IHubEditableContent, + format: ServiceDownloadFormat.CSV, + context: { + hubUrl: "https://hubqa.arcgis.com", + hubRequestOptions: {}, + } as unknown as IArcGISContext, + layers: [0], + pollInterval: 0, + }); + expect(true).toBe(false); + } catch (error) { + expect(error.message).toBe("Download operation failed with a 200"); + } + }); + it("polls without a progress callback", async () => { + fetchMock.once( + "https://hubqa.arcgis.com/api/download/v1/items/123/csv?redirect=false&layers=0", + { + body: { + status: "Pending", + }, + } + ); + fetchMock.once( + "https://hubqa.arcgis.com/api/download/v1/items/123/csv?redirect=false&layers=0", + { + body: { + status: "InProgress", + recordCount: 100, + progressInPercent: 50, + }, + }, + { overwriteRoutes: false } + ); + fetchMock.once( + "https://hubqa.arcgis.com/api/download/v1/items/123/csv?redirect=false&layers=0", + { + body: { + status: "Completed", + resultUrl: "fake-url", + }, + }, + { overwriteRoutes: false } + ); + + const result = await fetchHubApiDownloadFileUrl({ + entity: { id: "123" } as unknown as IHubEditableContent, + format: ServiceDownloadFormat.CSV, + context: { + hubUrl: "https://hubqa.arcgis.com", + hubRequestOptions: {}, + } as unknown as IArcGISContext, + layers: [0], + pollInterval: 0, + }); + + expect(result).toBe("fake-url"); + }); + it("polls with a progress callback", async () => { + fetchMock.once( + "https://hubqa.arcgis.com/api/download/v1/items/123/csv?redirect=false&layers=0", + { + body: { + status: "Pending", + }, + } + ); + fetchMock.once( + "https://hubqa.arcgis.com/api/download/v1/items/123/csv?redirect=false&layers=0", + { + body: { + status: "PagingData", + recordCount: 100, + progressInPercent: 50, + }, + }, + { overwriteRoutes: false } + ); + fetchMock.once( + "https://hubqa.arcgis.com/api/download/v1/items/123/csv?redirect=false&layers=0", + { + body: { + status: "Completed", + resultUrl: "fake-url", + }, + }, + { overwriteRoutes: false } + ); + + const progressCallback = jasmine + .createSpy("progressCallback") + .and.callFake((status: any, percent: any): any => null); + const result = await fetchHubApiDownloadFileUrl({ + entity: { id: "123" } as unknown as IHubEditableContent, + format: ServiceDownloadFormat.CSV, + context: { + hubUrl: "https://hubqa.arcgis.com", + hubRequestOptions: {}, + } as unknown as IArcGISContext, + layers: [0], + pollInterval: 0, + progressCallback, + }); + + expect(result).toBe("fake-url"); + expect(progressCallback).toHaveBeenCalledTimes(3); + expect(progressCallback).toHaveBeenCalledWith( + DownloadOperationStatus.PENDING, + undefined + ); + expect(progressCallback).toHaveBeenCalledWith( + DownloadOperationStatus.PROCESSING, + 50 + ); + expect(progressCallback).toHaveBeenCalledWith( + DownloadOperationStatus.COMPLETED, + undefined + ); + }); + it("handles geometry, token and where parameters", async () => { + fetchMock.once( + "https://hubqa.arcgis.com/api/download/v1/items/123/csv?redirect=false&layers=0&geometry=%7B%22type%22%3A%22point%22%2C%22coordinates%22%3A%5B1%2C2%5D%7D&where=1%3D1&token=fake-token", + { + body: { + status: "Completed", + resultUrl: "fake-url", + }, + } + ); + + const result = await fetchHubApiDownloadFileUrl({ + entity: { id: "123" } as unknown as IHubEditableContent, + format: ServiceDownloadFormat.CSV, + context: { + hubUrl: "https://hubqa.arcgis.com", + hubRequestOptions: { + authentication: { + token: "fake-token", + }, + }, + } as unknown as IArcGISContext, + layers: [0], + geometry: { + type: "point", + toJSON: () => ({ type: "point", coordinates: [1, 2] }), + } as unknown as __esri.Point, + where: "1=1", + }); + + expect(result).toBe("fake-url"); + }); + it("Explicitly sets the spatialRefId to 4326 for GeoJSON and KML", async () => { + fetchMock.once( + "https://hubqa.arcgis.com/api/download/v1/items/123/geojson?redirect=false&layers=0&spatialRefId=4326", + { + body: { + status: "Completed", + resultUrl: "fake-url", + }, + } + ); + + const result = await fetchHubApiDownloadFileUrl({ + entity: { id: "123" } as unknown as IHubEditableContent, + format: ServiceDownloadFormat.GEOJSON, + context: { + hubUrl: "https://hubqa.arcgis.com", + hubRequestOptions: {}, + } as unknown as IArcGISContext, + layers: [0], + }); + + expect(result).toBe("fake-url"); + + fetchMock.once( + "https://hubqa.arcgis.com/api/download/v1/items/123/kml?redirect=false&layers=0&spatialRefId=4326", + { + body: { + status: "Completed", + resultUrl: "fake-url-2", + }, + } + ); + + const result2 = await fetchHubApiDownloadFileUrl({ + entity: { id: "123" } as unknown as IHubEditableContent, + format: ServiceDownloadFormat.KML, + context: { + hubUrl: "https://hubqa.arcgis.com", + hubRequestOptions: {}, + } as unknown as IArcGISContext, + layers: [0], + }); + + expect(result2).toBe("fake-url-2"); + }); +}); diff --git a/packages/common/test/downloads/_internal/format-fetchers/fetchAvailableExportItemFormats.test.ts b/packages/common/test/downloads/_internal/format-fetchers/fetchAvailableExportItemFormats.test.ts new file mode 100644 index 00000000000..5081bc23b69 --- /dev/null +++ b/packages/common/test/downloads/_internal/format-fetchers/fetchAvailableExportItemFormats.test.ts @@ -0,0 +1,85 @@ +import { IArcGISContext } from "../../../../src/ArcGISContext"; +import { IHubEditableContent } from "../../../../src/core/types/IHubEditableContent"; +import * as buildExistingExportsPortalQueryModule from "../../../../src/downloads/build-existing-exports-portal-query"; +import * as fetchAllPagesModule from "../../../../src/items/fetch-all-pages"; +import * as getExportItemDataUrlModule from "../../../../src/downloads/_internal/getExportItemDataUrl"; +import { IItem, searchItems } from "@esri/arcgis-rest-portal"; +import { fetchAvailableExportItemFormats } from "../../../../src/downloads/_internal/format-fetchers/fetchAvailableExportItemFormats"; +import { IStaticDownloadFormat, ServiceDownloadFormat } from "../../../../src"; +describe("fetchAvailableExportItemFormats", () => { + it("should throw an error if multiple layers are provided", async () => { + const entity = { id: "123" } as unknown as IHubEditableContent; + const context = { + hubUrl: "https://hubqa.arcgis.com", + hubRequestOptions: {}, + } as unknown as IArcGISContext; + const layers = [0, 1]; + try { + await fetchAvailableExportItemFormats(entity, context, layers); + expect(true).toBe(false); + } catch (error) { + expect(error.message).toBe( + "Multi-layer downloads are not supported for this item" + ); + } + }); + it("should fetch previous export items and return their formats", async () => { + const entity = { id: "123" } as unknown as IHubEditableContent; + const context = { + hubUrl: "https://hubqa.arcgis.com", + hubRequestOptions: {}, + } as unknown as IArcGISContext; + const layers = [0]; + const buildExistingExportsPortalQuerySpy = spyOn( + buildExistingExportsPortalQueryModule, + "buildExistingExportsPortalQuery" + ).and.returnValue("query"); + const fetchAllPagesSpy = spyOn( + fetchAllPagesModule, + "fetchAllPages" + ).and.returnValue( + Promise.resolve([ + { id: "456", type: "File Geodatabase" } as IItem, + { id: "789", type: "CSV" } as IItem, + ]) + ); + const getExportItemDataUrlSpy = spyOn( + getExportItemDataUrlModule, + "getExportItemDataUrl" + ).and.callFake((id: string, _context: any): any => { + return `data-url-${id}`; + }); + + const result = await fetchAvailableExportItemFormats( + entity, + context, + layers + ); + const expected = [ + { + type: "static", + label: null, + format: ServiceDownloadFormat.FILE_GDB, + url: "data-url-456", + }, + { + type: "static", + label: null, + format: ServiceDownloadFormat.CSV, + url: "data-url-789", + }, + ] as unknown as IStaticDownloadFormat[]; + + expect(result).toEqual(expected); + expect(buildExistingExportsPortalQuerySpy).toHaveBeenCalledWith("123", { + layerId: 0, + }); + expect(fetchAllPagesSpy).toHaveBeenCalledWith(searchItems, { + q: "query", + ...context.hubRequestOptions, + }); + expect(getExportItemDataUrlSpy).toHaveBeenCalledTimes(2); + expect(getExportItemDataUrlSpy).toHaveBeenCalledWith("456", context); + expect(getExportItemDataUrlSpy).toHaveBeenCalledWith("789", context); + }); +}); diff --git a/packages/common/test/downloads/_internal/format-fetchers/fetchExportItemFormats.test.ts b/packages/common/test/downloads/_internal/format-fetchers/fetchExportItemFormats.test.ts new file mode 100644 index 00000000000..e7edc348afb --- /dev/null +++ b/packages/common/test/downloads/_internal/format-fetchers/fetchExportItemFormats.test.ts @@ -0,0 +1,18 @@ +import { IArcGISContext } from "../../../../src/ArcGISContext"; +import { IHubEditableContent } from "../../../../src/core/types/IHubEditableContent"; +import { fetchExportItemFormats } from "../../../../src/downloads/_internal/format-fetchers/fetchExportItemFormats"; + +describe("fetchExportItemFormats", () => { + // TODO: Flesh out this test once the function is implemented + it("should throw a not implemented error", async () => { + try { + const entity = { id: "123" } as unknown as IHubEditableContent; + const context = {} as unknown as IArcGISContext; + const layers = [0]; + await fetchExportItemFormats(entity, context, layers); + expect(true).toBe(false); + } catch (error) { + expect(error.message).toBe("Not implemented"); + } + }); +}); diff --git a/packages/common/test/downloads/_internal/format-fetchers/getAllExportItemFormats.test.ts b/packages/common/test/downloads/_internal/format-fetchers/getAllExportItemFormats.test.ts new file mode 100644 index 00000000000..232dd73ed32 --- /dev/null +++ b/packages/common/test/downloads/_internal/format-fetchers/getAllExportItemFormats.test.ts @@ -0,0 +1,8 @@ +import { getAllExportItemFormats } from "../../../../src/downloads/_internal/format-fetchers/getAllExportItemFormats"; +import { EXPORT_ITEM_FORMATS } from "../../../../src/downloads/_internal/_types"; +describe("getAllExportItemFormats", () => { + it("should return all export item formats", () => { + const result = getAllExportItemFormats(); + expect(result.map((r) => r.format)).toEqual(EXPORT_ITEM_FORMATS); + }); +}); diff --git a/packages/common/test/downloads/_internal/format-fetchers/getCreateReplicaFormats.test.ts b/packages/common/test/downloads/_internal/format-fetchers/getCreateReplicaFormats.test.ts new file mode 100644 index 00000000000..8bb6e6ba9fa --- /dev/null +++ b/packages/common/test/downloads/_internal/format-fetchers/getCreateReplicaFormats.test.ts @@ -0,0 +1,40 @@ +import { ServiceDownloadFormat } from "../../../../src"; +import { IHubEditableContent } from "../../../../src/core/types/IHubEditableContent"; +import { getCreateReplicaFormats } from "../../../../src/downloads/_internal/format-fetchers/getCreateReplicaFormats"; + +describe("getCreateReplicaFormats", () => { + it("should return an empty array if there are no formats", () => { + const entity = {} as unknown as IHubEditableContent; + const result = getCreateReplicaFormats(entity); + expect(result).toEqual([]); + }); + it("should return recognized createReplica formats in a predefined order", () => { + const entity = { + serverExtractFormats: [ + // Out of order + ServiceDownloadFormat.JSON, + ServiceDownloadFormat.GEOJSON, + ], + } as unknown as IHubEditableContent; + const result = getCreateReplicaFormats(entity); + expect(result.map((r) => r.format)).toEqual([ + ServiceDownloadFormat.GEOJSON, + ServiceDownloadFormat.JSON, + ]); + }); + it("should return unrecognized createReplica formats at the end", () => { + const entity = { + serverExtractFormats: [ + ServiceDownloadFormat.GEOJSON, + "unknown-format", + ServiceDownloadFormat.JSON, + ], + } as unknown as IHubEditableContent; + const result = getCreateReplicaFormats(entity); + expect(result.map((r) => r.format)).toEqual([ + ServiceDownloadFormat.GEOJSON, + ServiceDownloadFormat.JSON, + "unknown-format" as ServiceDownloadFormat, + ]); + }); +}); diff --git a/packages/common/test/downloads/_internal/format-fetchers/getExportImageFormats.test.ts b/packages/common/test/downloads/_internal/format-fetchers/getExportImageFormats.test.ts new file mode 100644 index 00000000000..6059969aad7 --- /dev/null +++ b/packages/common/test/downloads/_internal/format-fetchers/getExportImageFormats.test.ts @@ -0,0 +1,13 @@ +import { getExportImageFormats } from "../../../../src/downloads/_internal/format-fetchers/getExportImageFormats"; + +describe("getExportImageDownloadFormats", () => { + // TODO: flesh out this test once the function is implemented + it("should throw a non-implemented error", () => { + try { + getExportImageFormats(); + expect(true).toBe(false); + } catch (error) { + expect(error.message).toBe("Not implemented"); + } + }); +}); diff --git a/packages/common/test/downloads/_internal/format-fetchers/getHubDownloadApiFormats.test.ts b/packages/common/test/downloads/_internal/format-fetchers/getHubDownloadApiFormats.test.ts new file mode 100644 index 00000000000..2fe8cf85c0b --- /dev/null +++ b/packages/common/test/downloads/_internal/format-fetchers/getHubDownloadApiFormats.test.ts @@ -0,0 +1,54 @@ +import { IHubEditableContent } from "../../../../src/core/types/IHubEditableContent"; +import * as canUseCreateReplicaModule from "../../../../src/downloads/canUseCreateReplica"; +import * as getCreateReplicaFormatsModule from "../../../../src/downloads/_internal/format-fetchers/getCreateReplicaFormats"; +import * as getPagingJobFormatsModule from "../../../../src/downloads/_internal/format-fetchers/getPagingJobFormats"; +import { getHubDownloadApiFormats } from "../../../../src/downloads/_internal/format-fetchers/getHubDownloadApiFormats"; +import { ServiceDownloadFormat } from "../../../../src"; +describe("getHubDownloadApiFormats", () => { + it("should return create replica formats if supported by entity", () => { + const entity = { + serverExtractCapability: true, + } as unknown as IHubEditableContent; + const createReplicaFormats = [{ format: ServiceDownloadFormat.JSON }]; + spyOn(canUseCreateReplicaModule, "canUseCreateReplica").and.returnValue( + true + ); + const getCreateReplicaFormatsSpy = spyOn( + getCreateReplicaFormatsModule, + "getCreateReplicaFormats" + ).and.returnValue(createReplicaFormats); + const getPagingJobFormatsSpy = spyOn( + getPagingJobFormatsModule, + "getPagingJobFormats" + ); + const result = getHubDownloadApiFormats(entity); + expect(result.map((r) => r.format)).toEqual( + createReplicaFormats.map((r) => r.format) + ); + expect(getCreateReplicaFormatsSpy).toHaveBeenCalledTimes(1); + expect(getCreateReplicaFormatsSpy).toHaveBeenCalledWith(entity); + expect(getPagingJobFormatsSpy).not.toHaveBeenCalled(); + }); + it("else should return paging job formats", () => { + spyOn(canUseCreateReplicaModule, "canUseCreateReplica").and.returnValue( + false + ); + const pagingJobFormats = [{ format: ServiceDownloadFormat.JSON }]; + const getCreateReplicaFormatsSpy = spyOn( + getCreateReplicaFormatsModule, + "getCreateReplicaFormats" + ); + const getPagingJobFormatsSpy = spyOn( + getPagingJobFormatsModule, + "getPagingJobFormats" + ).and.returnValue(pagingJobFormats); + const result = getHubDownloadApiFormats( + {} as unknown as IHubEditableContent + ); + expect(result.map((r) => r.format)).toEqual( + pagingJobFormats.map((r) => r.format) + ); + expect(getCreateReplicaFormatsSpy).not.toHaveBeenCalled(); + expect(getPagingJobFormatsSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/common/test/downloads/_internal/format-fetchers/getPagingJobFormats.test.ts b/packages/common/test/downloads/_internal/format-fetchers/getPagingJobFormats.test.ts new file mode 100644 index 00000000000..415b7f76702 --- /dev/null +++ b/packages/common/test/downloads/_internal/format-fetchers/getPagingJobFormats.test.ts @@ -0,0 +1,9 @@ +import { HUB_PAGING_JOB_FORMATS } from "../../../../src/downloads/_internal/_types"; +import { getPagingJobFormats } from "../../../../src/downloads/_internal/format-fetchers/getPagingJobFormats"; + +describe("getPagingJobFormats", () => { + it("should return paging job formats", () => { + const result = getPagingJobFormats(); + expect(result.map((r) => r.format)).toEqual(HUB_PAGING_JOB_FORMATS); + }); +}); diff --git a/packages/common/test/downloads/_internal/getExportItemDataUrl.test.ts b/packages/common/test/downloads/_internal/getExportItemDataUrl.test.ts new file mode 100644 index 00000000000..66a7af66e71 --- /dev/null +++ b/packages/common/test/downloads/_internal/getExportItemDataUrl.test.ts @@ -0,0 +1,30 @@ +import { IArcGISContext } from "../../../src/ArcGISContext"; +import { getExportItemDataUrl } from "../../../src/downloads/_internal/getExportItemDataUrl"; + +describe("getExportItemDataUrl", () => { + it("should return the correct data url for the export item", () => { + const itemId = "abc123"; + const context = { + portalUrl: "https://www.my-portal.com", + } as unknown as IArcGISContext; + const result = getExportItemDataUrl(itemId, context); + expect(result).toBe( + `https://www.my-portal.com/sharing/rest/content/items/${itemId}/data` + ); + }); + it("should return the correct data url for the export item with a token", () => { + const itemId = "abc123"; + const context = { + portalUrl: "https://www.my-portal.com", + hubRequestOptions: { + authentication: { + token: "my-token", + }, + }, + } as unknown as IArcGISContext; + const result = getExportItemDataUrl(itemId, context); + expect(result).toBe( + `https://www.my-portal.com/sharing/rest/content/items/${itemId}/data?token=my-token` + ); + }); +}); diff --git a/packages/common/test/downloads/canUseCreateReplica.test.ts b/packages/common/test/downloads/canUseCreateReplica.test.ts new file mode 100644 index 00000000000..607a48b2d59 --- /dev/null +++ b/packages/common/test/downloads/canUseCreateReplica.test.ts @@ -0,0 +1,53 @@ +import { canUseCreateReplica } from "../../src/downloads/canUseCreateReplica"; +import * as hostedServiceUtils from "../../src/content/hostedServiceUtils"; +import { IHubEditableContent } from "../../src/core/types/IHubEditableContent"; + +describe("canUseCreateReplica", () => { + it("should return true if entity is a hosted feature service with serverExtractCapability", () => { + const entity = { + serverExtractCapability: true, + } as unknown as IHubEditableContent; + + const isHostedFeatureServiceEntitySpy = spyOn( + hostedServiceUtils, + "isHostedFeatureServiceEntity" + ).and.returnValue(true); + + const result = canUseCreateReplica(entity); + + expect(result).toBe(true); + expect(isHostedFeatureServiceEntitySpy).toHaveBeenCalled(); + }); + + it("should return false if entity is not a hosted feature service", () => { + const entity = { + serverExtractCapability: true, + } as unknown as IHubEditableContent; + + const isHostedFeatureServiceEntitySpy = spyOn( + hostedServiceUtils, + "isHostedFeatureServiceEntity" + ).and.returnValue(false); + + const result = canUseCreateReplica(entity); + + expect(result).toBe(false); + expect(isHostedFeatureServiceEntitySpy).toHaveBeenCalled(); + }); + + it("should return false if entity does not have serverExtractCapability", () => { + const entity = { + serverExtractCapability: false, + } as unknown as IHubEditableContent; + + const isHostedFeatureServiceEntitySpy = spyOn( + hostedServiceUtils, + "isHostedFeatureServiceEntity" + ).and.returnValue(true); + + const result = canUseCreateReplica(entity); + + expect(result).toBe(false); + expect(isHostedFeatureServiceEntitySpy).toHaveBeenCalled(); + }); +}); diff --git a/packages/common/test/downloads/canUseHubDownloadApi.test.ts b/packages/common/test/downloads/canUseHubDownloadApi.test.ts new file mode 100644 index 00000000000..377365ddff0 --- /dev/null +++ b/packages/common/test/downloads/canUseHubDownloadApi.test.ts @@ -0,0 +1,125 @@ +import { IArcGISContext } from "../../src/ArcGISContext"; +import { IHubEditableContent } from "../../src/core/types/IHubEditableContent"; +import { canUseHubDownloadApi } from "../../src/downloads/canUseHubDownloadApi"; +import * as canUseCreateReplicaModule from "../../src/downloads/canUseCreateReplica"; + +describe("canUseHubDownloadApi", () => { + it("should return false if download API status is not available", () => { + const canUseCreateReplicaSpy = spyOn( + canUseCreateReplicaModule, + "canUseCreateReplica" + ).and.returnValue(true); + const entity = { + type: "Feature Service", + access: "public", + } as unknown as IHubEditableContent; + const context = {} as unknown as IArcGISContext; + + const result = canUseHubDownloadApi(entity, context); + + expect(result).toBe(false); + expect(canUseCreateReplicaSpy).not.toHaveBeenCalled(); + }); + it("should return false if download API status is not online", () => { + const canUseCreateReplicaSpy = spyOn( + canUseCreateReplicaModule, + "canUseCreateReplica" + ).and.returnValue(true); + const entity = { + type: "Feature Service", + access: "public", + } as unknown as IHubEditableContent; + const context = { + serviceStatus: { + "hub-downloads": "offline", + }, + } as unknown as IArcGISContext; + + const result = canUseHubDownloadApi(entity, context); + + expect(result).toBe(false); + expect(canUseCreateReplicaSpy).not.toHaveBeenCalled(); + }); + + it("should return false if the entity is not a service entity", () => { + const canUseCreateReplicaSpy = spyOn( + canUseCreateReplicaModule, + "canUseCreateReplica" + ).and.returnValue(false); + const entity = { + type: "Web Map", + access: "public", + } as unknown as IHubEditableContent; + const context = { + serviceStatus: { + "hub-downloads": "online", + }, + } as unknown as IArcGISContext; + + const result = canUseHubDownloadApi(entity, context); + + expect(result).toBe(false); + expect(canUseCreateReplicaSpy).toHaveBeenCalledTimes(1); + }); + + it("should return false if the service entity cannot use paging jobs or createReplica ", () => { + const canUseCreateReplicaSpy = spyOn( + canUseCreateReplicaModule, + "canUseCreateReplica" + ).and.returnValue(false); + const entity = { + type: "Feature Service", + access: "private", + } as unknown as IHubEditableContent; + const context = { + serviceStatus: { + "hub-downloads": "online", + }, + } as unknown as IArcGISContext; + + const result = canUseHubDownloadApi(entity, context); + + expect(result).toBe(false); + expect(canUseCreateReplicaSpy).toHaveBeenCalledTimes(1); + }); + + it("should return true if the service entity cannot use paging jobs but can use createReplica ", () => { + const canUseCreateReplicaSpy = spyOn( + canUseCreateReplicaModule, + "canUseCreateReplica" + ).and.returnValue(true); + const entity = { + type: "Feature Service", + access: "private", + } as unknown as IHubEditableContent; + const context = { + serviceStatus: { + "hub-downloads": "online", + }, + } as unknown as IArcGISContext; + + const result = canUseHubDownloadApi(entity, context); + + expect(result).toBe(true); + expect(canUseCreateReplicaSpy).toHaveBeenCalledTimes(1); + }); + + it("should return true if the service entity can use paging jobs", () => { + spyOn(canUseCreateReplicaModule, "canUseCreateReplica").and.returnValue( + false + ); + const entity = { + type: "Map Service", + access: "public", + } as unknown as IHubEditableContent; + const context = { + serviceStatus: { + "hub-downloads": "online", + }, + } as unknown as IArcGISContext; + + const result = canUseHubDownloadApi(entity, context); + + expect(result).toBe(true); + }); +}); diff --git a/packages/common/test/downloads/fetchDownloadFileUrl.test.ts b/packages/common/test/downloads/fetchDownloadFileUrl.test.ts new file mode 100644 index 00000000000..4ae823728f0 --- /dev/null +++ b/packages/common/test/downloads/fetchDownloadFileUrl.test.ts @@ -0,0 +1,206 @@ +import * as canUseHubDownloadApiModule from "../../src/downloads/canUseHubDownloadApi"; +import * as fetchHubApiDownloadFileUrlModule from "../../src/downloads/_internal/file-url-fetchers/fetchHubApiDownloadFileUrl"; +import * as canUseExportItemFlowModule from "../../src/downloads/_internal/canUseExportItemFlow"; +import * as fetchExportItemDownloadFileUrlModule from "../../src/downloads/_internal/file-url-fetchers/fetchExportItemDownloadFileUrl"; +import * as canUseExportImageFlowModule from "../../src/downloads/_internal/canUseExportImageFlow"; +import * as fetchExportImageDownloadFileUrlModule from "../../src/downloads/_internal/file-url-fetchers/fetchExportImageDownloadFileUrl"; +import { IHubEditableContent } from "../../src/core/types/IHubEditableContent"; +import { IArcGISContext, ServiceDownloadFormat } from "../../src"; +import { fetchDownloadFileUrl } from "../../src/downloads/fetchDownloadFileUrl"; + +describe("fetchDownloadFileUrl", () => { + let canUseHubDownloadApiSpy: jasmine.Spy; + let fetchHubApiDownloadFileUrlSpy: jasmine.Spy; + let canUseExportItemFlowSpy: jasmine.Spy; + let fetchExportItemDownloadFileUrlSpy: jasmine.Spy; + let canUseExportImageFlowSpy: jasmine.Spy; + let fetchExportImageDownloadFileUrlSpy: jasmine.Spy; + + beforeEach(() => { + canUseHubDownloadApiSpy = spyOn( + canUseHubDownloadApiModule, + "canUseHubDownloadApi" + ); + fetchHubApiDownloadFileUrlSpy = spyOn( + fetchHubApiDownloadFileUrlModule, + "fetchHubApiDownloadFileUrl" + ); + canUseExportItemFlowSpy = spyOn( + canUseExportItemFlowModule, + "canUseExportItemFlow" + ); + fetchExportItemDownloadFileUrlSpy = spyOn( + fetchExportItemDownloadFileUrlModule, + "fetchExportItemDownloadFileUrl" + ); + canUseExportImageFlowSpy = spyOn( + canUseExportImageFlowModule, + "canUseExportImageFlow" + ); + fetchExportImageDownloadFileUrlSpy = spyOn( + fetchExportImageDownloadFileUrlModule, + "fetchExportImageDownloadFileUrl" + ); + }); + + it("should throw an error if no download flow can be used", async () => { + canUseHubDownloadApiSpy.and.returnValue(false); + canUseExportItemFlowSpy.and.returnValue(false); + canUseExportImageFlowSpy.and.returnValue(false); + try { + await fetchDownloadFileUrl({ + entity: {} as unknown as IHubEditableContent, + context: {} as unknown as IArcGISContext, + format: ServiceDownloadFormat.CSV, + layers: [0], + }); + expect(true).toBe(false); + } catch (err) { + expect(err.message).toBe( + "Downloads are not supported for this item in this environment" + ); + } + expect(canUseHubDownloadApiSpy).toHaveBeenCalledTimes(1); + expect(fetchHubApiDownloadFileUrlSpy).not.toHaveBeenCalled(); + expect(canUseExportItemFlowSpy).toHaveBeenCalledTimes(1); + expect(fetchExportItemDownloadFileUrlSpy).not.toHaveBeenCalled(); + expect(canUseExportImageFlowSpy).toHaveBeenCalledTimes(1); + expect(fetchExportImageDownloadFileUrlSpy).not.toHaveBeenCalled(); + }); + + it("should delegate to fetchHubApiDownloadFileUrl when the Hub Download API can be used", async () => { + canUseHubDownloadApiSpy.and.returnValue(true); + canUseExportImageFlowSpy.and.returnValue(false); + canUseExportItemFlowSpy.and.returnValue(false); + + fetchHubApiDownloadFileUrlSpy.and.returnValue( + Promise.resolve("hub-api-download-url") + ); + + const entity = { + id: "123", + type: "Map Service", + } as unknown as IHubEditableContent; + const context = {} as unknown as IArcGISContext; + const result = await fetchDownloadFileUrl({ + entity, + context, + format: ServiceDownloadFormat.CSV, + layers: [0], + pollInterval: 1000, + }); + expect(result).toBe("hub-api-download-url"); + expect(canUseHubDownloadApiSpy).toHaveBeenCalledTimes(1); + expect(fetchHubApiDownloadFileUrlSpy).toHaveBeenCalledTimes(1); + expect(fetchHubApiDownloadFileUrlSpy).toHaveBeenCalledWith({ + entity, + context, + format: ServiceDownloadFormat.CSV, + layers: [0], + pollInterval: 1000, + }); + expect(canUseExportItemFlowSpy).not.toHaveBeenCalled(); + expect(fetchExportItemDownloadFileUrlSpy).not.toHaveBeenCalled(); + expect(canUseExportImageFlowSpy).not.toHaveBeenCalled(); + expect(fetchExportImageDownloadFileUrlSpy).not.toHaveBeenCalled(); + }); + + it("should delegate to fetchExportItemDownloadFileUrl when the Hub Download API cannot be used but export item flow can be", async () => { + canUseHubDownloadApiSpy.and.returnValue(false); + canUseExportItemFlowSpy.and.returnValue(true); + canUseExportImageFlowSpy.and.returnValue(false); + + fetchExportItemDownloadFileUrlSpy.and.returnValue( + Promise.resolve("export-item-download-url") + ); + + const entity = { + id: "123", + type: "Feature Service", + } as unknown as IHubEditableContent; + const context = {} as unknown as IArcGISContext; + const result = await fetchDownloadFileUrl({ + entity, + context, + format: ServiceDownloadFormat.CSV, + layers: [0], + pollInterval: 1000, + }); + expect(result).toBe("export-item-download-url"); + expect(canUseHubDownloadApiSpy).toHaveBeenCalledTimes(1); + expect(fetchHubApiDownloadFileUrlSpy).not.toHaveBeenCalled(); + expect(canUseExportItemFlowSpy).toHaveBeenCalledTimes(1); + expect(fetchExportItemDownloadFileUrlSpy).toHaveBeenCalledTimes(1); + expect(fetchExportItemDownloadFileUrlSpy).toHaveBeenCalledWith({ + entity, + context, + format: ServiceDownloadFormat.CSV, + layers: [0], + pollInterval: 1000, + }); + expect(canUseExportImageFlowSpy).not.toHaveBeenCalled(); + expect(fetchExportImageDownloadFileUrlSpy).not.toHaveBeenCalled(); + }); + + it("should delegate to fetchExportImageDownloadFileUrl when the Export Image flow can be used", async () => { + canUseHubDownloadApiSpy.and.returnValue(false); + canUseExportItemFlowSpy.and.returnValue(false); + canUseExportImageFlowSpy.and.returnValue(true); + + fetchExportImageDownloadFileUrlSpy.and.returnValue( + Promise.resolve("export-image-download-url") + ); + + const entity = { + id: "123", + type: "Image Service", + } as unknown as IHubEditableContent; + const context = {} as unknown as IArcGISContext; + const result = await fetchDownloadFileUrl({ + entity, + context, + format: ServiceDownloadFormat.PNG, + pollInterval: 1000, + }); + expect(result).toBe("export-image-download-url"); + expect(canUseHubDownloadApiSpy).toHaveBeenCalledTimes(1); + expect(fetchHubApiDownloadFileUrlSpy).not.toHaveBeenCalled(); + expect(canUseExportItemFlowSpy).toHaveBeenCalledTimes(1); + expect(fetchExportItemDownloadFileUrlSpy).not.toHaveBeenCalled(); + expect(canUseExportImageFlowSpy).toHaveBeenCalledTimes(1); + expect(fetchExportImageDownloadFileUrlSpy).toHaveBeenCalledTimes(1); + expect(fetchExportImageDownloadFileUrlSpy).toHaveBeenCalledWith({ + entity, + context, + format: ServiceDownloadFormat.PNG, + pollInterval: 1000, + }); + }); + + it("should set the pollInterval to 3000 if not provided", async () => { + canUseHubDownloadApiSpy.and.returnValue(true); + + fetchHubApiDownloadFileUrlSpy.and.returnValue( + Promise.resolve("hub-api-download-url") + ); + + const entity = { + id: "123", + type: "Map Service", + } as unknown as IHubEditableContent; + const context = {} as unknown as IArcGISContext; + await fetchDownloadFileUrl({ + entity, + context, + format: ServiceDownloadFormat.CSV, + layers: [0], + }); + expect(fetchHubApiDownloadFileUrlSpy).toHaveBeenCalledWith({ + entity, + context, + format: ServiceDownloadFormat.CSV, + layers: [0], + pollInterval: 3000, + }); + }); +}); diff --git a/packages/common/test/downloads/fetchDownloadFormats.test.ts b/packages/common/test/downloads/fetchDownloadFormats.test.ts new file mode 100644 index 00000000000..501f9b4eba8 --- /dev/null +++ b/packages/common/test/downloads/fetchDownloadFormats.test.ts @@ -0,0 +1,238 @@ +import * as canUseHubDownloadApiModule from "../../src/downloads/canUseHubDownloadApi"; +import * as getHubDownloadApiFormatsModule from "../../src/downloads/_internal/format-fetchers/getHubDownloadApiFormats"; +import * as canUseExportItemFlowModule from "../../src/downloads/_internal/canUseExportItemFlow"; +import * as fetchExportItemFormatsModule from "../../src/downloads/_internal/format-fetchers/fetchExportItemFormats"; +import * as canUseExportImageFlowModule from "../../src/downloads/_internal/canUseExportImageFlow"; +import * as getExportImageFormatsModule from "../../src/downloads/_internal/format-fetchers/getExportImageFormats"; +import { fetchDownloadFormats } from "../../src/downloads/fetchDownloadFormats"; +import { IHubEditableContent } from "../../src/core/types/IHubEditableContent"; +import { IArcGISContext } from "../../src/ArcGISContext"; +import { + IDownloadFormat, + IDynamicDownloadFormat, + IStaticDownloadFormat, + ServiceDownloadFormat, +} from "../../src"; + +describe("fetchDownloadFormats", () => { + let canUseHubDownloadApiSpy: jasmine.Spy; + let getHubDownloadApiFormatsSpy: jasmine.Spy; + let canUseExportItemFlowSpy: jasmine.Spy; + let fetchExportItemFormatsSpy: jasmine.Spy; + let canUseExportImageFlowSpy: jasmine.Spy; + let getExportImageFormatsSpy: jasmine.Spy; + + beforeEach(() => { + canUseHubDownloadApiSpy = spyOn( + canUseHubDownloadApiModule, + "canUseHubDownloadApi" + ); + getHubDownloadApiFormatsSpy = spyOn( + getHubDownloadApiFormatsModule, + "getHubDownloadApiFormats" + ); + canUseExportItemFlowSpy = spyOn( + canUseExportItemFlowModule, + "canUseExportItemFlow" + ); + fetchExportItemFormatsSpy = spyOn( + fetchExportItemFormatsModule, + "fetchExportItemFormats" + ); + canUseExportImageFlowSpy = spyOn( + canUseExportImageFlowModule, + "canUseExportImageFlow" + ); + getExportImageFormatsSpy = spyOn( + getExportImageFormatsModule, + "getExportImageFormats" + ); + }); + + it("returns an empty array if no download formats can be used", async () => { + canUseHubDownloadApiSpy.and.returnValue(false); + canUseExportItemFlowSpy.and.returnValue(false); + canUseExportImageFlowSpy.and.returnValue(false); + + const results = await fetchDownloadFormats({ + entity: { type: "Web Map" } as unknown as IHubEditableContent, + context: {} as unknown as IArcGISContext, + }); + expect(results).toEqual([]); + + expect(canUseHubDownloadApiSpy).toHaveBeenCalledTimes(1); + expect(getHubDownloadApiFormatsSpy).not.toHaveBeenCalled(); + expect(canUseExportItemFlowSpy).toHaveBeenCalledTimes(1); + expect(fetchExportItemFormatsSpy).not.toHaveBeenCalled(); + expect(canUseExportImageFlowSpy).toHaveBeenCalledTimes(1); + expect(getExportImageFormatsSpy).not.toHaveBeenCalled(); + }); + + it("returns additional resources as static formats", async () => { + canUseHubDownloadApiSpy.and.returnValue(false); + canUseExportItemFlowSpy.and.returnValue(false); + canUseExportImageFlowSpy.and.returnValue(false); + + const entity = { + type: "Web Map", + additionalResources: [ + { name: "Resource 1", url: "resource-1-url" }, + { name: "Resource 2", url: "resource-2-url" }, + ], + } as unknown as IHubEditableContent; + + const results = await fetchDownloadFormats({ + entity, + context: {} as unknown as IArcGISContext, + }); + + const expected = [ + { type: "static", label: "Resource 1", url: "resource-1-url" }, + { type: "static", label: "Resource 2", url: "resource-2-url" }, + ] as unknown as IDownloadFormat[]; + + expect(results).toEqual(expected); + + expect(canUseHubDownloadApiSpy).toHaveBeenCalledTimes(1); + expect(getHubDownloadApiFormatsSpy).not.toHaveBeenCalled(); + expect(canUseExportItemFlowSpy).toHaveBeenCalledTimes(1); + expect(fetchExportItemFormatsSpy).not.toHaveBeenCalled(); + expect(canUseExportImageFlowSpy).toHaveBeenCalledTimes(1); + expect(getExportImageFormatsSpy).not.toHaveBeenCalled(); + }); + + it("returns base formats for an entity that can use the hub download api", async () => { + canUseHubDownloadApiSpy.and.returnValue(true); + canUseExportItemFlowSpy.and.returnValue(false); + canUseExportImageFlowSpy.and.returnValue(false); + + const baseFormats = [ + { type: "dynamic", format: ServiceDownloadFormat.CSV }, + { type: "dynamic", format: ServiceDownloadFormat.GEOJSON }, + ] as unknown as IDynamicDownloadFormat[]; + + getHubDownloadApiFormatsSpy.and.returnValue(baseFormats); + + const results = await fetchDownloadFormats({ + entity: { type: "Feature Service" } as unknown as IHubEditableContent, + context: {} as unknown as IArcGISContext, + layers: [0], + }); + + const expected = [...baseFormats] as unknown as IDownloadFormat[]; + + expect(results).toEqual(expected); + + expect(canUseHubDownloadApiSpy).toHaveBeenCalledTimes(1); + expect(getHubDownloadApiFormatsSpy).toHaveBeenCalledTimes(1); + expect(canUseExportItemFlowSpy).not.toHaveBeenCalled(); + expect(fetchExportItemFormatsSpy).not.toHaveBeenCalled(); + expect(canUseExportImageFlowSpy).not.toHaveBeenCalled(); + expect(getExportImageFormatsSpy).not.toHaveBeenCalled(); + }); + + it("returns base formats for an entity that cannot use the download api but can use the export item flow", async () => { + canUseHubDownloadApiSpy.and.returnValue(false); + canUseExportItemFlowSpy.and.returnValue(true); + canUseExportImageFlowSpy.and.returnValue(false); + + const baseFormats = [ + { type: "dynamic", format: ServiceDownloadFormat.CSV }, + { type: "dynamic", format: ServiceDownloadFormat.GEOJSON }, + ] as unknown as IDynamicDownloadFormat[]; + + fetchExportItemFormatsSpy.and.returnValue(baseFormats); + + const results = await fetchDownloadFormats({ + entity: { type: "Feature Service" } as unknown as IHubEditableContent, + context: {} as unknown as IArcGISContext, + layers: [0], + }); + + const expected = [...baseFormats] as unknown as IDownloadFormat[]; + + expect(results).toEqual(expected); + + expect(canUseHubDownloadApiSpy).toHaveBeenCalledTimes(1); + expect(getHubDownloadApiFormatsSpy).not.toHaveBeenCalled(); + expect(canUseExportItemFlowSpy).toHaveBeenCalledTimes(1); + expect(fetchExportItemFormatsSpy).toHaveBeenCalledTimes(1); + expect(canUseExportImageFlowSpy).not.toHaveBeenCalled(); + expect(getExportImageFormatsSpy).not.toHaveBeenCalled(); + }); + + it("returns base formats for an entity that can use the export image flow", async () => { + canUseHubDownloadApiSpy.and.returnValue(false); + canUseExportItemFlowSpy.and.returnValue(false); + canUseExportImageFlowSpy.and.returnValue(true); + + const baseFormats = [ + { type: "dynamic", format: ServiceDownloadFormat.PNG }, + { type: "dynamic", format: ServiceDownloadFormat.JPG }, + ] as unknown as IDynamicDownloadFormat[]; + + getExportImageFormatsSpy.and.returnValue(baseFormats); + + const results = await fetchDownloadFormats({ + entity: { type: "Feature Service" } as unknown as IHubEditableContent, + context: {} as unknown as IArcGISContext, + }); + + const expected = [...baseFormats] as unknown as IDownloadFormat[]; + + expect(results).toEqual(expected); + + expect(canUseHubDownloadApiSpy).toHaveBeenCalledTimes(1); + expect(getHubDownloadApiFormatsSpy).not.toHaveBeenCalled(); + expect(canUseExportItemFlowSpy).toHaveBeenCalledTimes(1); + expect(fetchExportItemFormatsSpy).not.toHaveBeenCalled(); + expect(canUseExportImageFlowSpy).toHaveBeenCalledTimes(1); + expect(getExportImageFormatsSpy).toHaveBeenCalledTimes(1); + }); + + it("combines base formats and additional resources", async () => { + canUseHubDownloadApiSpy.and.returnValue(true); + canUseExportItemFlowSpy.and.returnValue(false); + canUseExportImageFlowSpy.and.returnValue(false); + + const baseFormats = [ + { type: "dynamic", format: ServiceDownloadFormat.CSV }, + { type: "dynamic", format: ServiceDownloadFormat.GEOJSON }, + ] as unknown as IDynamicDownloadFormat[]; + + getHubDownloadApiFormatsSpy.and.returnValue(baseFormats); + + const entity = { + type: "Feature Service", + additionalResources: [ + { name: "Resource 1", url: "resource-1-url" }, + { name: "Resource 2", url: "resource-2-url" }, + ], + } as unknown as IHubEditableContent; + + const results = await fetchDownloadFormats({ + entity, + context: {} as unknown as IArcGISContext, + layers: [0], + }); + + const additionalFormats = [ + { type: "static", label: "Resource 1", url: "resource-1-url" }, + { type: "static", label: "Resource 2", url: "resource-2-url" }, + ] as unknown as IStaticDownloadFormat[]; + + const expected = [ + ...baseFormats, + ...additionalFormats, + ] as unknown as IDownloadFormat[]; + + expect(results).toEqual(expected); + + expect(canUseHubDownloadApiSpy).toHaveBeenCalledTimes(1); + expect(getHubDownloadApiFormatsSpy).toHaveBeenCalledTimes(1); + expect(canUseExportItemFlowSpy).not.toHaveBeenCalled(); + expect(fetchExportItemFormatsSpy).not.toHaveBeenCalled(); + expect(canUseExportImageFlowSpy).not.toHaveBeenCalled(); + expect(getExportImageFormatsSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/common/test/events/_internal/EventUiSchemaAttendeesSettings.test.ts b/packages/common/test/events/_internal/EventUiSchemaAttendeesSettings.test.ts new file mode 100644 index 00000000000..28cc0030859 --- /dev/null +++ b/packages/common/test/events/_internal/EventUiSchemaAttendeesSettings.test.ts @@ -0,0 +1,115 @@ +import * as PortalModule from "@esri/arcgis-rest-portal"; +import { ArcGISContextManager } from "../../../src/ArcGISContextManager"; +import { IHubEvent } from "../../../src/core/types/IHubEvent"; +import { buildUiSchema } from "../../../src/events/_internal/EventUiSchemaAttendeesSettings"; +import { MOCK_AUTH } from "../../mocks/mock-auth"; +import { HubEventAttendanceType } from "../../../src/events/types"; + +describe("EventUiSchemaAttendeesSettings", () => { + describe("buildUiSchema", () => { + it("should return the expected ui schema", 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 datesAndTimes = { + startDate: "2024-03-31", + startDateTime: new Date(), + startTime: "12:00:00", + endDate: "2024-03-31", + endDateTime: new Date(), + endTime: "14:00:00", + timeZone: "America/New_York", + }; + const entity = { + access: "private", + allowRegistration: true, + attendanceType: HubEventAttendanceType.InPerson, + categories: [], + inPersonCapacity: null, + isAllDay: false, + isCanceled: false, + isDiscussable: true, + isPlanned: true, + isRemoved: false, + name: "", + notifyAttendees: true, + onlineCapacity: null, + onlineDetails: null, + onlineUrl: null, + permissions: [], + references: [], + schemaVersion: 1, + tags: [], + ...datesAndTimes, + } as unknown as IHubEvent; + const res = await buildUiSchema( + "myI18nScope", + entity, + authdCtxMgr.context + ); + expect(res).toEqual({ + type: "Layout", + elements: [ + { + type: "Section", + elements: [ + { + labelKey: `myI18nScope.fields.allowRegistration.label`, + scope: "/properties/allowRegistration", + type: "Control", + options: { + control: "hub-field-input-tile-select", + type: "radio", + helperText: { + labelKey: `myI18nScope.fields.allowRegistration.helperText`, + }, + labels: [ + `{{myI18nScope.fields.allowRegistration.enabled.label:translate}}`, + `{{myI18nScope.fields.allowRegistration.disabled.label:translate}}`, + ], + descriptions: [ + `{{myI18nScope.fields.allowRegistration.enabled.description:translate}}`, + `{{myI18nScope.fields.allowRegistration.disabled.description:translate}}`, + ], + icons: ["user-calendar", "circle-disallowed"], + layout: "horizontal", + }, + }, + { + labelKey: `myI18nScope.fields.notifyAttendees.label`, + scope: "/properties/notifyAttendees", + type: "Control", + options: { + control: "hub-field-input-tile-select", + type: "radio", + helperText: { + labelKey: `myI18nScope.fields.notifyAttendees.helperText`, + }, + labels: [ + `{{myI18nScope.fields.notifyAttendees.enabled.label:translate}}`, + `{{myI18nScope.fields.notifyAttendees.disabled.label:translate}}`, + ], + descriptions: [ + `{{myI18nScope.fields.notifyAttendees.enabled.description:translate}}`, + `{{myI18nScope.fields.notifyAttendees.disabled.description:translate}}`, + ], + icons: ["envelope", "circle-disallowed"], + layout: "horizontal", + }, + }, + ], + }, + ], + }); + }); + }); +}); diff --git a/packages/common/test/events/_internal/PropertyMapper.test.ts b/packages/common/test/events/_internal/PropertyMapper.test.ts index 254865ec0bb..7c6e5825b8e 100644 --- a/packages/common/test/events/_internal/PropertyMapper.test.ts +++ b/packages/common/test/events/_internal/PropertyMapper.test.ts @@ -22,8 +22,13 @@ describe("PropertyMapper", () => { describe("storeToEntity", () => { let eventRecord: IEvent; + let start: Date; + let end: Date; beforeEach(() => { + const now = new Date(); + start = new Date(now.valueOf() + 1000 * 60 * 60); + end = new Date(start.valueOf() + 1000 * 60 * 60); eventRecord = { access: EventAccess.PRIVATE, addresses: [ @@ -48,14 +53,18 @@ describe("PropertyMapper", () => { attendanceType: [EventAttendanceType.IN_PERSON], catalog: null, categories: ["category1"], - createdAt: new Date().toISOString(), + createdAt: now.toISOString(), createdById: "12345", creator: { username: "jdoe", }, description: "event description", editGroups: ["editGroup1"], - endDateTime: new Date().toISOString(), + endDate: [end.getFullYear(), end.getMonth() + 1, end.getDate()].join( + "-" + ), + endTime: [end.getHours(), end.getMinutes(), end.getSeconds()].join(":"), + endDateTime: end.toISOString(), geometry: null, id: "31c", notifyAttendees: false, @@ -71,13 +80,23 @@ describe("PropertyMapper", () => { }, readGroups: ["readGroup1"], recurrence: null, - startDateTime: new Date().toISOString(), + startDate: [ + start.getFullYear(), + start.getMonth() + 1, + start.getDate(), + ].join("-"), + startTime: [ + start.getHours(), + start.getMinutes(), + start.getSeconds(), + ].join(":"), + startDateTime: start.toISOString(), status: EventStatus.PLANNED, summary: "event summary", tags: ["tag1"], timeZone: "America/New_York", title: "event title", - updatedAt: new Date().toISOString(), + updatedAt: now.toISOString(), } as IEvent; }); @@ -128,6 +147,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", }); }); @@ -173,8 +198,13 @@ describe("PropertyMapper", () => { describe("entityToStore", () => { let eventEntity: IHubEvent; + let start: Date; + let end: Date; beforeEach(() => { + const now = new Date(); + start = new Date(now.valueOf() + 1000 * 60 * 60); + end = new Date(start.valueOf() + 1000 * 60 * 60); eventEntity = { isAllDay: false, name: "event title", @@ -206,16 +236,26 @@ describe("PropertyMapper", () => { onlineDetails: null, onlineUrl: null, canChangeAccess: true, - createdDate: jasmine.any(Date) as unknown as Date, - startDateTime: jasmine.any(Date) as unknown as Date, - endDateTime: jasmine.any(Date) as unknown as Date, + createdDate: now, + startDateTime: start, + endDateTime: end, createdDateSource: "createdAt", updatedDate: jasmine.any(Date) as unknown as Date, updatedDateSource: "updatedAt", - startDate: "2024-04-01", - endDate: "2024-04-01", - startTime: "12:00:00", - endTime: "01:00:00", + startDate: [ + start.getFullYear(), + start.getMonth() + 1, + start.getDate(), + ].join("-"), + startTime: [ + start.getHours(), + start.getMinutes(), + start.getSeconds(), + ].join(":"), + endDate: [end.getFullYear(), end.getMonth() + 1, end.getDate()].join( + "-" + ), + endTime: [end.getHours(), end.getMinutes(), end.getSeconds()].join(":"), timeZone: "America/New_York", } as IHubEvent; }); @@ -246,8 +286,10 @@ describe("PropertyMapper", () => { access: EventAccess.PRIVATE, status: EventStatus.PLANNED, attendanceType: [EventAttendanceType.IN_PERSON], - startDateTime: jasmine.any(String) as unknown as string, - endDateTime: jasmine.any(String) as unknown as string, + startDate: jasmine.any(String) as unknown as string, + startTime: jasmine.any(String) as unknown as string, + endDate: jasmine.any(String) as unknown as string, + endTime: jasmine.any(String) as unknown as string, } as IEvent); }); @@ -278,8 +320,10 @@ describe("PropertyMapper", () => { access: EventAccess.PRIVATE, status: EventStatus.PLANNED, attendanceType: [EventAttendanceType.IN_PERSON], - startDateTime: jasmine.any(String) as unknown as string, - endDateTime: jasmine.any(String) as unknown as string, + startDate: jasmine.any(String) as unknown as string, + startTime: jasmine.any(String) as unknown as string, + endDate: jasmine.any(String) as unknown as string, + endTime: jasmine.any(String) as unknown as string, } as IEvent); }); @@ -321,8 +365,10 @@ describe("PropertyMapper", () => { url: "https://somewhere.com/", } as IOnlineMeeting, ], - startDateTime: jasmine.any(String) as unknown as string, - endDateTime: jasmine.any(String) as unknown as string, + startDate: jasmine.any(String) as unknown as string, + startTime: jasmine.any(String) as unknown as string, + endDate: jasmine.any(String) as unknown as string, + endTime: jasmine.any(String) as unknown as string, } as IEvent); }); @@ -364,8 +410,10 @@ describe("PropertyMapper", () => { url: "https://somewhere.com/", } as IOnlineMeeting, ], - startDateTime: jasmine.any(String) as unknown as string, - endDateTime: jasmine.any(String) as unknown as string, + startDate: jasmine.any(String) as unknown as string, + startTime: jasmine.any(String) as unknown as string, + endDate: jasmine.any(String) as unknown as string, + endTime: jasmine.any(String) as unknown as string, } as IEvent); }); @@ -410,8 +458,10 @@ describe("PropertyMapper", () => { url: "https://somewhere.com/", } as IOnlineMeeting, ], - startDateTime: jasmine.any(String) as unknown as string, - endDateTime: jasmine.any(String) as unknown as string, + startDate: jasmine.any(String) as unknown as string, + startTime: jasmine.any(String) as unknown as string, + endDate: jasmine.any(String) as unknown as string, + endTime: jasmine.any(String) as unknown as string, } as IEvent); }); @@ -442,8 +492,10 @@ describe("PropertyMapper", () => { access: EventAccess.PRIVATE, status: EventStatus.CANCELED, attendanceType: [EventAttendanceType.IN_PERSON], - startDateTime: jasmine.any(String) as unknown as string, - endDateTime: jasmine.any(String) as unknown as string, + startDate: jasmine.any(String) as unknown as string, + startTime: jasmine.any(String) as unknown as string, + endDate: jasmine.any(String) as unknown as string, + endTime: jasmine.any(String) as unknown as string, } as IEvent); }); @@ -474,8 +526,10 @@ describe("PropertyMapper", () => { access: EventAccess.PRIVATE, status: EventStatus.REMOVED, attendanceType: [EventAttendanceType.IN_PERSON], - startDateTime: jasmine.any(String) as unknown as string, - endDateTime: jasmine.any(String) as unknown as string, + startDate: jasmine.any(String) as unknown as string, + startTime: jasmine.any(String) as unknown as string, + endDate: jasmine.any(String) as unknown as string, + endTime: jasmine.any(String) as unknown as string, } as IEvent); }); }); 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/_internal/getISOStringFromClientDateTime.test.ts b/packages/common/test/events/_internal/getISOStringFromClientDateTime.test.ts deleted file mode 100644 index 73cef190b46..00000000000 --- a/packages/common/test/events/_internal/getISOStringFromClientDateTime.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { getISOStringFromClientDateTime } from "../../../src/events/_internal/getISOStringFromClientDateTime"; - -describe("getISOStringFromLocalDateTime", () => { - it("it returns the expected UTC strings", () => { - expect(getISOStringFromClientDateTime("2024-03-29", "12:00:00")).toMatch( - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}\:\d{2}\.\d{3}Z$/ - ); - }); -}); diff --git a/packages/common/test/events/edit.test.ts b/packages/common/test/events/edit.test.ts index 38397029e41..4e941134541 100644 --- a/packages/common/test/events/edit.test.ts +++ b/packages/common/test/events/edit.test.ts @@ -3,13 +3,18 @@ import { ArcGISContextManager } from "../../src/ArcGISContextManager"; import { MOCK_AUTH } from "../mocks/mock-auth"; import * as defaultsModule from "../../src/events/defaults"; import * as eventsModule from "../../src/events/api/events"; +import * as registrationModule from "../../src/events/api"; import { EventAccess, EventAttendanceType, EventStatus, IEvent, } from "../../src/events/api/types"; -import { createHubEvent, updateHubEvent } from "../../src/events/edit"; +import { + createHubEvent, + deleteHubEventAttendee, + updateHubEvent, +} from "../../src/events/edit"; import { IHubEvent } from "../../src/core/types/IHubEvent"; import { HubEventAttendanceType } from "../../src/events/types"; @@ -280,4 +285,32 @@ describe("HubEvents edit module", () => { expect(res.name).toEqual("my event"); }); }); + + describe("deleteHubEventAttendee", () => { + it("calls deleteRegistration", 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 deleteRegistrationSpy = spyOn( + registrationModule, + "deleteRegistration" + ).and.callFake(() => { + return Promise.resolve(); + }); + await deleteHubEventAttendee(0o1, authdCtxMgr.context.hubRequestOptions); + expect(deleteRegistrationSpy).toHaveBeenCalledWith({ + registrationId: 0o1, + ...authdCtxMgr.context.hubRequestOptions, + }); + }); + }); }); 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/groups/getWellKnownGroup.test.ts b/packages/common/test/groups/getWellKnownGroup.test.ts index d4cbe69df13..e819b3fc307 100644 --- a/packages/common/test/groups/getWellKnownGroup.test.ts +++ b/packages/common/test/groups/getWellKnownGroup.test.ts @@ -28,6 +28,7 @@ describe("getWellKnownGroup: ", () => { notifications: "online", "hub-search": "online", domains: "online", + "hub-downloads": "online", }, userHubSettings: { schemaVersion: 1, diff --git a/packages/common/test/initiatives/HubInitiative.test.ts b/packages/common/test/initiatives/HubInitiative.test.ts index b99445d8a13..1c8d5dcc979 100644 --- a/packages/common/test/initiatives/HubInitiative.test.ts +++ b/packages/common/test/initiatives/HubInitiative.test.ts @@ -379,7 +379,6 @@ describe("HubInitiative Class:", () => { expect(result.thumbnailUrl).toEqual( "https://myserver.com/thumbnail.png" ); - expect(result._groups).toEqual([]); expect(result._associations).toEqual({ groupAccess: "public", membershipAccess: "anyone", @@ -443,36 +442,6 @@ describe("HubInitiative Class:", () => { expect(result._metric).toEqual(mockMetric); }); }); - describe('auto-populating "shareWith" groups', () => { - let projectInstance: any; - beforeEach(async () => { - const _authdCtxMgr = await initContextManager({ - currentUser: { - groups: [ - { id: "00a", isViewOnly: false }, - { id: "00b", isViewOnly: true, memberType: "admin" }, - { id: "00d", isViewOnly: false }, - ] as PortalModule.IGroup[], - privileges: ["portal:user:shareToGroup"], - }, - }); - projectInstance = HubInitiative.fromJson({}, _authdCtxMgr.context); - }); - it('handles auto-populating "shareWith" groups that the current user can share to', async () => { - const result = await projectInstance.toEditor({ - contentGroupId: "00a", - collaborationGroupId: "00b", - }); - expect(result._groups).toEqual(["00a", "00b"]); - }); - it('does not auto-populate "shareWith" gruops that the current user cannot share to', async () => { - const result = await projectInstance.toEditor({ - contentGroupId: "00e", - collaborationGroupId: "00f", - }); - expect(result._groups).toEqual([]); - }); - }); }); describe("fromEditor:", () => { diff --git a/packages/common/test/mocks/mock-auth.ts b/packages/common/test/mocks/mock-auth.ts index 81cfbab19e7..099c8e864b9 100644 --- a/packages/common/test/mocks/mock-auth.ts +++ b/packages/common/test/mocks/mock-auth.ts @@ -98,6 +98,7 @@ export function getMockContextWithPrivilenges( notifications: "online", "hub-search": "online", domains: "online", + "hub-downloads": "online", }, userHubSettings: { schemaVersion: 1, @@ -130,6 +131,7 @@ export const MOCK_CONTEXT = new ArcGISContext({ notifications: "online", "hub-search": "online", domains: "online", + "hub-downloads": "online", }, userHubSettings: { schemaVersion: 1, @@ -138,10 +140,10 @@ export const MOCK_CONTEXT = new ArcGISContext({ export const MOCK_ANON_CONTEXT = new ArcGISContext({ id: 123, - currentUser: null, + currentUser: undefined, portalUrl: "https://qaext.arcgis.com", hubUrl: "https://hubqa.arcgis.com", - authentication: null, + authentication: undefined, portalSelf: { id: "123", name: "My org", @@ -156,6 +158,7 @@ export const MOCK_ANON_CONTEXT = new ArcGISContext({ notifications: "online", "hub-search": "online", domains: "online", + "hub-downloads": "online", }, userHubSettings: { schemaVersion: 1, @@ -187,6 +190,7 @@ export function createMockContext(): ArcGISContext { notifications: "online", "hub-search": "online", domains: "online", + "hub-downloads": "online", }, userHubSettings: { schemaVersion: 1, @@ -197,10 +201,10 @@ export function createMockContext(): ArcGISContext { export function createMockAnonContext(): ArcGISContext { return new ArcGISContext({ id: 123, - currentUser: null, + currentUser: undefined, portalUrl: "https://qaext.arcgis.com", hubUrl: "https://hubqa.arcgis.com", - authentication: null, + authentication: undefined, portalSelf: { id: "123", name: "My org", @@ -215,6 +219,7 @@ export function createMockAnonContext(): ArcGISContext { notifications: "online", "hub-search": "online", domains: "online", + "hub-downloads": "online", }, userHubSettings: { schemaVersion: 1, diff --git a/packages/common/test/projects/HubProject.test.ts b/packages/common/test/projects/HubProject.test.ts index 1fa57be5729..5a3e1c12bf6 100644 --- a/packages/common/test/projects/HubProject.test.ts +++ b/packages/common/test/projects/HubProject.test.ts @@ -408,35 +408,6 @@ describe("HubProject Class:", () => { ); const result = await chk.toEditor({ metricId: "metric123" }); }); - describe('auto-populating "shareWith" groups', () => { - let projectInstance: any; - beforeEach(async () => { - const _authdCtxMgr = await initContextManager({ - currentUser: { - groups: [ - { id: "00a", isViewOnly: false }, - { id: "00b", isViewOnly: true, memberType: "admin" }, - { id: "00d", isViewOnly: false }, - ] as PortalModule.IGroup[], - }, - }); - projectInstance = HubProject.fromJson({}, _authdCtxMgr.context); - }); - it('handles auto-populating "shareWith" groups that the current user can share to', async () => { - const result = await projectInstance.toEditor({ - contentGroupId: "00a", - collaborationGroupId: "00b", - }); - expect(result._groups).toEqual(["00a", "00b"]); - }); - it('does not auto-populate "shareWith" gruops that the current user cannot share to', async () => { - const result = await projectInstance.toEditor({ - contentGroupId: "00e", - collaborationGroupId: "00f", - }); - expect(result._groups).toEqual([]); - }); - }); }); describe("fromEditor:", () => { 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/eventAttendeeToSearchResult.test.ts b/packages/common/test/search/_internal/hubEventsHelpers/eventAttendeeToSearchResult.test.ts new file mode 100644 index 00000000000..bac669ace83 --- /dev/null +++ b/packages/common/test/search/_internal/hubEventsHelpers/eventAttendeeToSearchResult.test.ts @@ -0,0 +1,116 @@ +import { IUser } from "@esri/arcgis-rest-portal"; +import { IHubSearchOptions } from "../../../../src"; +import { AccessLevel } from "../../../../src/core/types/types"; +import { eventAttendeeToSearchResult } from "../../../../src/search/_internal/hubEventsHelpers/eventAttendeeToSearchResult"; +import * as arcgisRestPortal from "@esri/arcgis-rest-portal"; +import { + EventAttendanceType, + IPagedRegistrationResponse, + RegistrationRole, + RegistrationStatus, +} from "../../../../src/events/api/types"; + +describe("event search utils", () => { + describe("eventAttendeeToSearchResult", () => { + const registration: IPagedRegistrationResponse = { + items: [ + { + createdAt: "2024-04-17T15:30:42+0000", + createdById: "a creator id", + eventId: "an event id", + id: 0, + permission: { + canDelete: true, + canEdit: true, + }, + role: RegistrationRole.OWNER, + status: RegistrationStatus.PENDING, + type: EventAttendanceType.VIRTUAL, + updatedAt: "2024-04-17T15:30:42+0000", + userId: "a user id", + }, + ], + nextStart: -1, + total: 1, + }; + + const user: IUser = { + access: "private", + email: "anemail@server.com", + firstName: "John", + lastName: "Green", + fullName: "John Green", + username: "fishingboatproceeds", + thumbnail: "a_thumbnail_id", + }; + + it("should convert attendee to search result", async () => { + spyOn(arcgisRestPortal, "getUser").and.returnValue(user); + const attendee = registration.items[0]; + const options: IHubSearchOptions = { + requestOptions: { + portal: "https://www.arcgis.com", + authentication: { + token: "abc", + }, + }, + } as any; + const result = await eventAttendeeToSearchResult(attendee, options); + expect(result).toEqual({ + id: attendee.id.toString(), + access: user.access as AccessLevel, + name: user.fullName as any, + createdDate: new Date(attendee.createdAt), + createdDateSource: "attendee.createdAt", + updatedDate: new Date(attendee.updatedAt), + updatedDateSource: "attendee.updatedAt", + type: "Event Attendee", + family: "eventAttendee", + owner: user.username, + rawResult: attendee, + links: { + self: "https://www.arcgis.com/home/user.html?user=fishingboatproceeds", + siteRelative: `/people/${user.username}`, + thumbnail: + "https://www.arcgis.com/community/users/fishingboatproceeds/info/a_thumbnail_id?token=abc", + }, + }); + }); + + it("should handle thumbnail when token is null", async () => { + spyOn(arcgisRestPortal, "getUser").and.returnValue(user); + const options: IHubSearchOptions = { + requestOptions: { + portal: "https://www.arcgis.com", + }, + } as any; + const result = await eventAttendeeToSearchResult( + registration.items[0], + options + ); + expect(result.links?.thumbnail).toEqual( + "https://www.arcgis.com/community/users/fishingboatproceeds/info/a_thumbnail_id" + ); + }); + + it("should handle when thumbnail is null", async () => { + spyOn(arcgisRestPortal, "getUser").and.returnValue({ + ...user, + thumbnail: undefined, + }); + const options: IHubSearchOptions = { + requestOptions: { + portal: "https://www.arcgis.com", + authentication: { + token: "abc", + }, + }, + } as any; + const result = await eventAttendeeToSearchResult( + registration.items[0], + options + ); + expect(result.links?.thumbnail).toBeFalsy(); + }); + }); +}); 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/processAttendeeFilters.test.ts b/packages/common/test/search/_internal/hubEventsHelpers/processAttendeeFilters.test.ts new file mode 100644 index 00000000000..fe348fe02bc --- /dev/null +++ b/packages/common/test/search/_internal/hubEventsHelpers/processAttendeeFilters.test.ts @@ -0,0 +1,102 @@ +import { processAttendeeFilters } from "../../../../src/search/_internal/hubEventsHelpers/processAttendeeFilters"; +import { IFilter } from "../../../../src/search/types/IHubCatalog"; + +const FILTERS: IFilter[] = [ + { + predicates: [ + { + term: "abc", + }, + ], + }, + { + operation: "OR", + predicates: [ + { + role: "owner", + }, + { + role: "organizer", + }, + { + role: "attendee", + }, + ], + }, + { + operation: "OR", + predicates: [ + { + status: "pending", + }, + { + status: "accepted", + }, + { + status: "declined", + }, + { + status: "blocked", + }, + ], + }, + { + operation: "OR", + predicates: [ + { + attendanceType: "virtual", + }, + { + attendanceType: "in_person", + }, + ], + }, + { + operation: "OR", + predicates: [ + { + updatedDateRange: { + from: 1714276800000, + to: 1714363199999, + }, + }, + ], + }, +]; + +describe("processAttendeeFilters", () => { + it("should process filters", () => { + const results = processAttendeeFilters({ + targetEntity: "eventAttendee", + filters: FILTERS, + properties: { + eventId: "an event id", + }, + }); + expect(results).toEqual({ + eventId: "an event id", + // @ts-ignore + name: "abc", + role: "owner,organizer,attendee", + type: "virtual,in_person", + status: "pending,accepted,declined,blocked", + updatedAtAfter: "2024-04-28T04:00:00.000Z", + updatedAtBefore: "2024-04-29T03:59:59.999Z", + }); + }); + it("should set some defaults", () => { + const results = processAttendeeFilters({ + targetEntity: "eventAttendee", + filters: [], + properties: { + eventId: "an event id", + }, + }); + expect(results).toEqual({ + eventId: "an event id", + role: "owner,organizer,attendee", + type: "virtual,in_person", + status: "pending,accepted,declined,blocked", + }); + }); +}); diff --git a/packages/common/test/search/_internal/hubEventsHelpers/processAttendeeOptions.test.ts b/packages/common/test/search/_internal/hubEventsHelpers/processAttendeeOptions.test.ts new file mode 100644 index 00000000000..056780dddc4 --- /dev/null +++ b/packages/common/test/search/_internal/hubEventsHelpers/processAttendeeOptions.test.ts @@ -0,0 +1,63 @@ +import { + EventSort, + RegistrationSort, + SortOrder, +} from "../../../../src/events/api/orval/api/orval-events"; +import { processAttendeeOptions } from "../../../../src/search/_internal/hubEventsHelpers/processAttendeeOptions"; + +describe("processAttendeeOptions", () => { + it("should process num", () => { + expect(processAttendeeOptions({})).toEqual({ sortOrder: SortOrder.asc }); + expect(processAttendeeOptions({ num: -1 })).toEqual({ + sortOrder: SortOrder.asc, + }); + expect(processAttendeeOptions({ num: 2 })).toEqual({ + num: "2", + sortOrder: SortOrder.asc, + }); + }); + it("should process start", () => { + expect(processAttendeeOptions({})).toEqual({ sortOrder: SortOrder.asc }); + expect(processAttendeeOptions({ start: 0 })).toEqual({ + sortOrder: SortOrder.asc, + }); + expect(processAttendeeOptions({ start: 2 })).toEqual({ + start: "2", + sortOrder: SortOrder.asc, + }); + }); + it("should process sortField", () => { + expect(processAttendeeOptions({})).toEqual({ sortOrder: SortOrder.asc }); + expect(processAttendeeOptions({ sortField: "other" })).toEqual({ + sortOrder: SortOrder.asc, + }); + expect(processAttendeeOptions({ sortField: "created" })).toEqual({ + sortBy: RegistrationSort.createdAt, + sortOrder: SortOrder.asc, + }); + expect(processAttendeeOptions({ sortField: "modified" })).toEqual({ + sortBy: RegistrationSort.updatedAt, + sortOrder: SortOrder.asc, + }); + expect(processAttendeeOptions({ sortField: "firstName" })).toEqual({ + sortBy: RegistrationSort.firstName, + sortOrder: SortOrder.asc, + }); + expect(processAttendeeOptions({ sortField: "lastName" })).toEqual({ + sortBy: RegistrationSort.lastName, + sortOrder: SortOrder.asc, + }); + expect(processAttendeeOptions({ sortField: "username" })).toEqual({ + sortBy: RegistrationSort.username, + sortOrder: SortOrder.asc, + }); + }); + it("should process sortOrder", () => { + expect(processAttendeeOptions({ sortOrder: "desc" })).toEqual({ + sortOrder: SortOrder.desc, + }); + expect(processAttendeeOptions({ sortOrder: "asc" })).toEqual({ + sortOrder: SortOrder.asc, + }); + }); +}); 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/hubSearchEventAttendees.test.ts b/packages/common/test/search/_internal/hubSearchEventAttendees.test.ts new file mode 100644 index 00000000000..68d6d484049 --- /dev/null +++ b/packages/common/test/search/_internal/hubSearchEventAttendees.test.ts @@ -0,0 +1,237 @@ +import * as eventsAPI from "../../../src/events/api/registrations"; +import { + EventAttendanceType, + GetRegistrationsParams, + IPagedRegistrationResponse, + IRegistration, + RegistrationRole, + RegistrationStatus, +} from "../../../src/events/api/types"; +import * as eventAttendeeToSearchResultModule from "../../../src/search/_internal/hubEventsHelpers/eventAttendeeToSearchResult"; +import { IQuery } from "../../../src/search/types/IHubCatalog"; +import { IHubSearchOptions } from "../../../src/search/types/IHubSearchOptions"; +import * as processFiltersModule from "../../../src/search/_internal/hubEventsHelpers/processAttendeeFilters"; +import * as processOptionsModule from "../../../src/search/_internal/hubEventsHelpers/processAttendeeOptions"; +import { IHubSearchResult } from "../../../src/search/types/IHubSearchResult"; +import { hubSearchEventAttendees } from "../../../src/search/_internal/hubSearchEventAttendees"; + +describe("hubSearchEventAttendees", () => { + describe("hubSearchEventAttendees", () => { + const PAGE_1: IPagedRegistrationResponse = { + items: [ + { + 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", + }, + { + 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", + }, + ], + nextStart: 3, + total: 4, + }; + + const PAGE_2: IPagedRegistrationResponse = { + items: [ + { + 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", + }, + ], + nextStart: -1, + total: 4, + }; + + 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 getRegistrationsSpy: jasmine.Spy; + let processAttendeeFiltersSpy: jasmine.Spy; + let processAttendeeOptionsSpy: jasmine.Spy; + let eventAttendeeToSearchResultSpy: jasmine.Spy; + + beforeEach(() => { + getRegistrationsSpy = spyOn( + eventsAPI, + "getRegistrations" + ).and.returnValues(Promise.resolve(PAGE_1), Promise.resolve(PAGE_2)); + processAttendeeFiltersSpy = spyOn( + processFiltersModule, + "processAttendeeFilters" + ).and.returnValue(processedFilters); + processAttendeeOptionsSpy = spyOn( + processOptionsModule, + "processAttendeeOptions" + ).and.returnValues(processedOptions, processedOptions2); + eventAttendeeToSearchResultSpy = spyOn( + eventAttendeeToSearchResultModule, + "eventAttendeeToSearchResult" + ).and.callFake((attendee: IRegistration, _options: IHubSearchOptions) => + Promise.resolve({ id: attendee.id.toString() } as IHubSearchResult) + ); + }); + + it("should call getRegistrations and resolve with an IHubSearchResponse", async () => { + const response = await hubSearchEventAttendees(query, options); + expect(processAttendeeFiltersSpy).toHaveBeenCalledTimes(1); + expect(processAttendeeFiltersSpy).toHaveBeenCalledWith(query); + expect(processAttendeeOptionsSpy).toHaveBeenCalledTimes(1); + expect(processAttendeeOptionsSpy).toHaveBeenCalledWith(options); + expect(getRegistrationsSpy).toHaveBeenCalledTimes(1); + expect(getRegistrationsSpy).toHaveBeenCalledWith({ + ...options.requestOptions, + data: { + ...processedFilters, + ...processedOptions, + }, + }); + expect(eventAttendeeToSearchResultSpy).toHaveBeenCalledTimes(2); + expect(eventAttendeeToSearchResultSpy).toHaveBeenCalledWith( + PAGE_1.items[0], + options + ); + expect(eventAttendeeToSearchResultSpy).toHaveBeenCalledWith( + PAGE_1.items[1], + options + ); + expect(response).toEqual( + { + total: PAGE_1.total, + results: [ + { id: PAGE_1.items[0].id.toString() }, + { id: PAGE_1.items[1].id.toString() }, + ] as unknown as IHubSearchResult[], + hasNext: true, + next: jasmine.any(Function), + }, + "response" + ); + + // verify fetches next page of results + const results2 = await response.next(); + expect(processAttendeeFiltersSpy).toHaveBeenCalledTimes(2); + expect(processAttendeeFiltersSpy.calls.argsFor(1)).toEqual( + [query], + "processFiltersSpy.calls.argsFor(1)" + ); + expect(processAttendeeOptionsSpy).toHaveBeenCalledTimes(2); + expect(processAttendeeOptionsSpy.calls.argsFor(1)).toEqual( + [options2], + "processOptionsSpy.calls.argsFor(1)" + ); + expect(getRegistrationsSpy).toHaveBeenCalledTimes(2); + expect(getRegistrationsSpy.calls.argsFor(1)).toEqual( + [ + { + ...options2.requestOptions, + data: { + ...processedFilters, + ...processedOptions2, + }, + }, + ], + "getEventsSpy.calls.argsFor(1)" + ); + expect(eventAttendeeToSearchResultSpy).toHaveBeenCalledTimes(4); + expect(eventAttendeeToSearchResultSpy.calls.argsFor(2)).toEqual( + [PAGE_2.items[0], options2], + "eventToSearchResultSpy.calls.argsFor(0)" + ); + expect(eventAttendeeToSearchResultSpy.calls.argsFor(3)).toEqual( + [PAGE_2.items[1], options2], + "eventToSearchResultSpy.calls.argsFor(0)" + ); + expect(results2).toEqual( + { + total: PAGE_2.total, + results: [ + { id: PAGE_2.items[0].id.toString() }, + { id: PAGE_2.items[1].id.toString() }, + ] 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" + ); + } + }); + }); +}); 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" + ); + } + }); +});