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..81dc3807816 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,9 @@ export const ContentPermissionPolicies: IPermissionPolicy[] = [ availability: ["alpha"], environments: ["qaext", "devext"], }, + { + permission: "temp:hub:content:downloads:unifiedList", + availability: ["alpha"], + environments: ["qaext", "devext"], + }, ]; 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/internalContentUtils.ts b/packages/common/src/content/_internal/internalContentUtils.ts index 8f8ed80237a..3f99c4d9506 100644 --- a/packages/common/src/content/_internal/internalContentUtils.ts +++ b/packages/common/src/content/_internal/internalContentUtils.ts @@ -41,6 +41,7 @@ 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 { fetchItemEnrichments } from "../../items/_enrichments"; /** * Hashmap of Hub environment and application url surfix diff --git a/packages/common/src/content/fetch.ts b/packages/common/src/content/fetch.ts index f55d382bcd6..38645b6e84f 100644 --- a/packages/common/src/content/fetch.ts +++ b/packages/common/src/content/fetch.ts @@ -251,8 +251,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 +266,13 @@ export const fetchHubContent = async ( const model = { item }; const enrichments: IHubEditableContentEnrichments = {}; + + enrichments.metadata = await fetchItemEnrichments( + item, + ["metadata"], + requestOptions as IHubRequestOptions + ); + if (isHostedFeatureServiceItem(item)) { enrichments.server = await getService({ ...requestOptions, 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..83d8acb04eb --- /dev/null +++ b/packages/common/src/downloads/_internal/_types.ts @@ -0,0 +1,63 @@ +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. + */ +export const HUB_PAGING_JOB_FORMATS = [ + ServiceDownloadFormat.CSV, + ServiceDownloadFormat.GEOJSON, + ServiceDownloadFormat.KML, + ServiceDownloadFormat.SHAPEFILE, +] as const; +export type HubPagingJobFormat = (typeof HUB_PAGING_JOB_FORMATS)[number]; + +/** + * Known formats supported by the /createReplica endpoint of the Hub Download API. + * NOTE: this is may be incomplete and should be updated as needed. + */ +export const CREATE_REPLICA_FORMATS = [ + ServiceDownloadFormat.CSV, + ServiceDownloadFormat.EXCEL, + ServiceDownloadFormat.FEATURE_COLLECTION, + ServiceDownloadFormat.FILE_GDB, + ServiceDownloadFormat.GEOJSON, + ServiceDownloadFormat.GEO_PACKAGE, + ServiceDownloadFormat.JSON, + ServiceDownloadFormat.SHAPEFILE, + ServiceDownloadFormat.SQLITE, +] 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..61ffa5b214b --- /dev/null +++ b/packages/common/src/downloads/_internal/format-fetchers/getCreateReplicaFormats.ts @@ -0,0 +1,19 @@ +import { IHubEditableContent } from "../../../core/types/IHubEditableContent"; +import { IDynamicDownloadFormat } from "../../types"; +import { CreateReplicaFormat } 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[] { + return (entity.serverExtractFormats || []).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/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/fetch.test.ts b/packages/common/test/content/fetch.test.ts index 223683ae80e..81633dcf44e 100644 --- a/packages/common/test/content/fetch.test.ts +++ b/packages/common/test/content/fetch.test.ts @@ -680,6 +680,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 +699,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 +713,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 +730,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 +746,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 +760,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); }); 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..ef6951f391c --- /dev/null +++ b/packages/common/test/downloads/_internal/format-fetchers/getCreateReplicaFormats.test.ts @@ -0,0 +1,24 @@ +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 available createReplica formats", () => { + const entity = { + serverExtractFormats: [ + ServiceDownloadFormat.JSON, + ServiceDownloadFormat.GEOJSON, + ], + } as unknown as IHubEditableContent; + const result = getCreateReplicaFormats(entity); + expect(result.map((r) => r.format)).toEqual([ + ServiceDownloadFormat.JSON, + ServiceDownloadFormat.GEOJSON, + ]); + }); + it("should return an empty array if there are no formats", () => { + const entity = {} as unknown as IHubEditableContent; + const result = getCreateReplicaFormats(entity); + expect(result).toEqual([]); + }); +}); 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/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/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,