From 61400bf6500b744d3dc24287cf89d58ece893c93 Mon Sep 17 00:00:00 2001 From: Joshua Tanner Date: Wed, 12 Jul 2023 11:27:28 -0700 Subject: [PATCH 01/13] feat(): teach hubSearch channels --- .../src/discussions/api/channels/channels.ts | 16 + .../src/discussions/api/channels/index.ts | 1 + packages/common/src/discussions/api/index.ts | 2 + .../common/src/discussions/api/request.ts | 24 + packages/common/src/discussions/api/types.ts | 1027 +++++++++++++++ .../src/discussions/api/utils/request.ts | 101 ++ packages/common/src/discussions/index.ts | 1 + .../_internal/discussionsSearchChannels.ts | 26 + .../discussionsSearchChannelsHelpers/index.ts | 86 ++ packages/common/src/search/_internal/index.ts | 1 + packages/common/src/search/hubSearch.ts | 4 + .../common/src/search/types/IHubCatalog.ts | 8 +- .../src/search/types/IHubSearchOptions.ts | 2 +- packages/common/src/search/types/types.ts | 2 +- .../test/discussions/api/channels.test.ts | 39 + .../discussionsSearchChannels.test.ts | 59 + .../_internal/mocks/searchChannelsResponse.ts | 27 + packages/common/test/search/hubSearch.test.ts | 36 + packages/discussions/src/channels/channels.ts | 16 +- packages/discussions/src/types.ts | 1109 ++--------------- packages/discussions/test/channels.test.ts | 18 +- 21 files changed, 1544 insertions(+), 1061 deletions(-) create mode 100644 packages/common/src/discussions/api/channels/channels.ts create mode 100644 packages/common/src/discussions/api/channels/index.ts create mode 100644 packages/common/src/discussions/api/index.ts create mode 100644 packages/common/src/discussions/api/request.ts create mode 100644 packages/common/src/discussions/api/types.ts create mode 100644 packages/common/src/discussions/api/utils/request.ts create mode 100644 packages/common/src/search/_internal/discussionsSearchChannels.ts create mode 100644 packages/common/src/search/_internal/discussionsSearchChannelsHelpers/index.ts create mode 100644 packages/common/test/discussions/api/channels.test.ts create mode 100644 packages/common/test/search/_internal/discussionsSearchChannels.test.ts create mode 100644 packages/common/test/search/_internal/mocks/searchChannelsResponse.ts diff --git a/packages/common/src/discussions/api/channels/channels.ts b/packages/common/src/discussions/api/channels/channels.ts new file mode 100644 index 00000000000..c01d0e5bded --- /dev/null +++ b/packages/common/src/discussions/api/channels/channels.ts @@ -0,0 +1,16 @@ +import { request } from "../request"; +import { IChannel, IPagedResponse, ISearchChannelsParams } from "../types"; + +/** + * search channels + * + * @export + * @param {ISearchChannelsParams} options + * @return {*} {Promise>} + */ +export function searchChannels( + options: ISearchChannelsParams +): Promise> { + options.httpMethod = "GET"; + return request(`/channels`, options); +} diff --git a/packages/common/src/discussions/api/channels/index.ts b/packages/common/src/discussions/api/channels/index.ts new file mode 100644 index 00000000000..612cc76c9b0 --- /dev/null +++ b/packages/common/src/discussions/api/channels/index.ts @@ -0,0 +1 @@ +export * from "./channels"; diff --git a/packages/common/src/discussions/api/index.ts b/packages/common/src/discussions/api/index.ts new file mode 100644 index 00000000000..b4b9a287a06 --- /dev/null +++ b/packages/common/src/discussions/api/index.ts @@ -0,0 +1,2 @@ +export * from "./channels"; +export * from "./types"; diff --git a/packages/common/src/discussions/api/request.ts b/packages/common/src/discussions/api/request.ts new file mode 100644 index 00000000000..8e933667aa3 --- /dev/null +++ b/packages/common/src/discussions/api/request.ts @@ -0,0 +1,24 @@ +import { IDiscussionsRequestOptions } from "./types"; +import { apiRequest, authenticateRequest } from "./utils/request"; + +/** + * method that authenticates and makes requests to Discussions API + * + * @export + * @template T + * @param {string} url + * @param {IDiscussionsRequestOptions} options + * @return {*} {Promise} + */ +// NOTE: feasibly this could be replaced with @esi/hub-common hubApiRequest, +// if that method didn't prepend `/api/v3` to the supplied path. Additionally, +// there is the difference that hubApiRequest sets Authorization header without `Bearer` +// https://github.com/Esri/hub.js/blob/f35b1a0a868916bd07e1dfd84cb084bc2c876267/packages/common/src/request.ts#L62 +export function request( + url: string, + options: IDiscussionsRequestOptions +): Promise { + return authenticateRequest(options).then((token) => { + return apiRequest(url, options, token); + }); +} diff --git a/packages/common/src/discussions/api/types.ts b/packages/common/src/discussions/api/types.ts new file mode 100644 index 00000000000..20461a85018 --- /dev/null +++ b/packages/common/src/discussions/api/types.ts @@ -0,0 +1,1027 @@ +import { + IPagingParams, + IPagedResponse as IRestPagedResponse, + IUser, +} from "@esri/arcgis-rest-types"; +import { Geometry } from "geojson"; +import { IHubRequestOptions } from "../../types"; + +/** + * sort orders + * + * @export + * @enum {string} + */ +export enum SortOrder { + ASC = "ASC", + DESC = "DESC", +} + +/** + * reactions to posts + * + * @export + * @enum {string} + */ +export enum PostReaction { + CLAPPING_HANDS = "clapping_hands", + CONFUSED = "confused", + DOWN_ARROW = "down_arrow", + EYES = "eyes", + FACE_WITH_TEARS_OF_JOY = "face_with_tears_of_joy", + FIRE = "fire", + GRINNING = "grinning", + HEART = "heart", + LAUGH = "laugh", + ONE_HUNDRED = "one_hundred", + PARTYING = "partying", + PARTY_POPPER = "party_popper", + RAISING_HANDS = "raising_hands", + ROCKET = "rocket", + SAD = "sad", + SLIGHTLY_SMILING = "slightly_smiling", + SURPRISED = "surprised", + THINKING = "thinking", + THUMBS_UP = "thumbs_up", + THUMBS_DOWN = "thumbs_down", + TROPHY = "trophy", + UP_ARROW = "up_arrow", + WAVING_HAND = "waving_hand", + WINKING = "winking", + WORLD_MAP = "world_map", +} + +/** + * platform sharing access values + * + * @export + * @enum {string} + */ +export enum SharingAccess { + PUBLIC = "public", + ORG = "org", + PRIVATE = "private", +} +/** + * representation of AGOL platform sharing ACL + * NOTE: orgs is an array to enable future org-org sharing/discussion + * + * @export + * @interface IPlatformSharing + */ +export interface IPlatformSharing { + groups: string[]; + orgs: string[]; + access: SharingAccess; +} + +/** + * possible statuses of a post + * + * @export + * @enum {string} + */ +export enum PostStatus { + PENDING = "pending", + APPROVED = "approved", + REJECTED = "rejected", + DELETED = "deleted", + HIDDEN = "hidden", +} + +/** + * possible discussionn content types, i.e. a post can be about an item, dataset, or group + * + * @export + * @enum {string} + */ +export enum DiscussionType { + GROUP = "group", + CONTENT = "content", + BOARD = "board", +} + +/** + * source of a post, i.e. app context + * + * @export + * @enum {string} + */ +export enum DiscussionSource { + HUB = "hub", + AGO = "ago", + URBAN = "urban", +} + +/** + * named parts of a discussion URI, follows this convention: + * ${source}://${type}/${id}_${layer}?features=${...features}&attribute=${attribute} + * + * coarse-grained uri - hub://item/ab3 -- commenting from hub about item ab3 + * -- + * fine-grained uri - hub://dataset/3ef_0?features=10,32&attribute=species -- commenting from + * hub about species attribute of features id 10 & 32 in dataset 3ef layer 0 + * + * @export + * @interface IDiscussionParams + */ +export interface IDiscussionParams { + source: string | null; + type: string | null; + id: string | null; + layer: string | null; + features: string[] | null; + attribute: string | null; +} + +/** + * relations of post entity + * + * @export + * @enum {string} + */ +export enum PostRelation { + REPLIES = "replies", + REACTIONS = "reactions", + PARENT = "parent", + CHANNEL = "channel", +} + +/** + * relations of reaction entity + * + * @export + * @enum {string} + */ +export enum ReactionRelation { + POST = "post", +} + +/** + * filters of channel entity + * + * @export + * @enum {string} + */ +export enum ChannelFilter { + HAS_USER_POSTS = "has_user_posts", +} + +// sorting + +/** + * Common sorting fields + */ +export enum CommonSort { + CREATED_AT = "createdAt", + CREATOR = "creator", + EDITOR = "editor", + ID = "id", + UPDATED_AT = "updatedAt", +} + +/** + * creator property + * + * @export + * @interface IWithAuthor + */ +export interface IWithAuthor { + creator: string; +} + +/** + * editor property + * + * @export + * @interface IWithEditor + */ +export interface IWithEditor { + editor: string; +} + +/** + * sorting properties + * + * @export + * @interface IWithSorting + */ +export interface IWithSorting { + sortBy: SortEnum; + sortOrder: SortOrder; +} + +/** + * filtering properties + * + * @export + * @interface IWithFiltering + */ +export interface IWithFiltering { + filterBy: FilterEnum; +} + +/** + * properties that enable temporal querying + * + * @export + * @interface IWithTimeQueries + */ +export interface IWithTimeQueries { + createdBefore: Date; + createdAfter: Date; + updatedBefore: Date; + updatedAfter: Date; +} + +/** + * temporal properties + * + * @export + * @interface IWithTimestamps + */ +export interface IWithTimestamps { + createdAt: Date; + updatedAt: Date; +} + +/** + * paginated response properties + * + * @export + * @interface IPagedResponse + * @extends {IRestPagedResponse} + * @template PaginationObject + */ +export interface IPagedResponse extends IRestPagedResponse { + items: PaginationObject[]; +} + +/** + * delete notifications opt out response properties + * + * @export + * @interface IRemoveChannelNotificationOptOutResult + */ +export interface IRemoveChannelNotificationOptOutResult { + success: boolean; + channelId: string; + username: string; +} + +/** + * delete channel activity response properties + * + * @export + * @interface IRemoveChannelActivityResult + */ +export interface IRemoveChannelActivityResult { + success: boolean; + channelId: string; + username: string; +} + +/** + * opt out response properties + * + * @export + * @interface IChannelNotificationOptOut + */ +export interface IChannelNotificationOptOut { + channelId: string; + username: string; +} + +/** + * options for making requests against Discussion API + * + * @export + * @interface IRequestOptions + * @extends {RequestInit} + */ +// NOTE: this is as close to implementing @esri/hub-common IHubRequestOptions as possible +// only real exception is needing to extend httpMethod to include PATCH and DELETE +// also making isPortal optional for convenience +// picking fields from requestInit for development against local api +export interface IDiscussionsRequestOptions + extends Omit, + Pick { + httpMethod?: "GET" | "POST" | "PATCH" | "DELETE"; + isPortal?: boolean; + token?: string; + data?: { [key: string]: any }; +} + +/** + * Role types + * + * @export + * @enum {string} + */ +export enum Role { + READ = "read", + WRITE = "write", + READWRITE = "readWrite", + MODERATE = "moderate", + MANAGE = "manage", + OWNER = "owner", +} + +/** + * Interface representing the meta data associated with a discussions + * mention email + */ +export interface IDiscussionsMentionMeta { + channelId: string; + discussion: string; + postId: string; + replyId?: string; +} + +export interface IDiscussionsUser extends IUser { + username?: string | null; +} + +/** + * representation of reaction from the service + * + * @export + * @interface IReaction + * @extends {IWithAuthor} + * @extends {IWithEditor} + * @extends {IWithTimestamps} + */ +export interface IReaction extends IWithAuthor, IWithEditor, IWithTimestamps { + id: string; + value: PostReaction; + postId?: string; + post?: IPost; +} + +/** + * dto for creating a reaction + * + * @export + * @interface ICreateReaction + */ +export interface ICreateReaction { + postId: string; + value: PostReaction; +} + +/** + * request options for creating a reaction to a post + * + * @export + * @interface ICreateReactionOptions + * @extends {IHubRequestOptions} + */ +export interface ICreateReactionOptions extends IDiscussionsRequestOptions { + data: ICreateReaction; +} + +/** + * request options for deleting a reaction + * + * @export + * @interface IRemoveReactionOptions + * @extends {IHubRequestOptions} + */ +export interface IRemoveReactionOptions extends IDiscussionsRequestOptions { + reactionId: string; +} + +/** + * delete reaction response properties + * + * @export + * @interface IRemoveReactionResponse + */ +export interface IRemoveReactionResponse { + success: boolean; + reactionId: string; +} + +/** + * Post sorting fields + * + * @enum {string} + */ +export enum PostSort { + BODY = "body", + CHANNEL_ID = "channelId", + CREATED_AT = "createdAt", + CREATOR = "creator", + DISCUSSION = "discussion", + EDITOR = "editor", + ID = "id", + PARENT_ID = "parentId", + STATUS = "status", + TITLE = "title", + UPDATED_AT = "updatedAt", +} + +/** + * Post types + * + * @enum{string} + */ +export enum PostType { + Text = "text", + Announcement = "announcement", + Poll = "poll", + Question = "question", +} + +/** + * representation of post from service + * + * @export + * @interface IPost + * @extends {IWithAuthor} + * @extends {IWithEditor} + * @extends {IWithTimestamps} + */ +export interface IPost + extends Partial, + Partial, + IWithTimestamps { + id: string; + title: string | null; + body: string; + status: PostStatus; + appInfo: string | null; // this is a catch-all field for app-specific information about a post, added for Urban + discussion: string | null; + geometry: Geometry | null; + featureGeometry: Geometry | null; + postType: PostType; + channelId?: string; + channel?: IChannel; + parentId?: string; + parent?: IPost | null; + replies?: IPost[] | IPagedResponse; + replyCount?: number; + reactions?: IReaction[]; +} + +/** + * base parameters for creating a post + * + * @export + * @interface IPostOptions + */ +export interface IPostOptions { + body: string; + title?: string; + discussion?: string; + geometry?: Geometry; + featureGeometry?: Geometry; + appInfo?: string; + asAnonymous?: boolean; +} + +/** + * dto for creating a post in a known channel + * + * @export + * @interface ICreatePost + * @extends {IPostOptions} + */ +export interface ICreatePost extends IPostOptions { + channelId: string; +} + +/** + * dto for creating a post in a unknown or not yet created channel + * + * @export + * @interface ICreateChannelPost + * @extends {IPostOptions} + * @extends {ICreateChannel} + */ +export interface ICreateChannelPost extends IPostOptions, ICreateChannel {} + +/** + * request options for creating post + * + * @export + * @interface ICreatePostParams + * @extends {IHubRequestOptions} + */ +export interface ICreatePostParams extends IDiscussionsRequestOptions { + data: ICreatePost | ICreateChannelPost; + mentionUrl?: string; +} + +/** + * request options for creating reply to post + * + * @export + * @interface ICreateReplyParams + * @extends {IHubRequestOptions} + */ +export interface ICreateReplyParams extends IDiscussionsRequestOptions { + postId: string; + data: IPostOptions; + mentionUrl?: string; +} + +/** + * dto for decorating found post with relations + * + * @export + * @interface IFetchPost + */ +export interface IFetchPost { + relations?: PostRelation[]; +} + +/** + * dto for querying posts in a single channel + * + * @export + * @interface ISearchChannelPosts + * @extends {Partial} + * @extends {Partial} + * @extends {Partial} + * @extends {Partial>} + * @extends {Partial} + */ +export interface ISearchPosts + extends Partial, + Partial, + Partial, + Partial>, + Partial { + title?: string; + body?: string; + discussion?: string; + geometry?: Geometry; + featureGeometry?: Geometry; + parents?: Array; + status?: PostStatus[]; + relations?: PostRelation[]; + groups?: string[]; + access?: SharingAccess[]; + channels?: string[]; +} + +/** + * dto for updating a post's status + * + * @export + * @interface IUpdatePostStatus + */ +export interface IUpdatePostStatus { + status: PostStatus; +} + +/** + * dto for updating a post's content + * + * @export + * @interface IUpdatePost + */ +export interface IUpdatePost { + title?: string; + body?: string; + discussion?: string | null; + geometry?: Geometry | null; + featureGeometry?: Geometry | null; + appInfo?: string | null; +} + +/** + * request options for querying posts + * + * @export + * @interface ISearchPostsParams + * @extends {IHubRequestOptions} + */ +export interface ISearchPostsParams extends IDiscussionsRequestOptions { + data?: ISearchPosts; +} + +/** + * request params for getting post + * + * @export + * @interface IFetchPostParams + * @extends {IHubRequestOptions} + */ +export interface IFetchPostParams extends IDiscussionsRequestOptions { + postId: string; + data?: IFetchPost; +} + +/** + * request options for updating post + * + * @export + * @interface IUpdatePostParams + * @extends {IHubRequestOptions} + */ +export interface IUpdatePostParams extends IDiscussionsRequestOptions { + postId: string; + data: IUpdatePost; + mentionUrl?: string; +} + +/** + * request options for updating a post's status + * + * @export + * @interface IUpdatePostStatusParams + * @extends {IHubRequestOptions} + */ +export interface IUpdatePostStatusParams extends IDiscussionsRequestOptions { + postId: string; + data: IUpdatePostStatus; +} + +/** + * request options for deleting a post + * + * @export + * @interface IRemovePostParams + * @extends {IHubRequestOptions} + */ +export interface IRemovePostParams extends IDiscussionsRequestOptions { + postId: string; +} + +/** + * delete post response properties + * + * @export + * @interface IRemovePostResponse + */ +export interface IRemovePostResponse { + success: boolean; + postId: string; +} + +/** + * Channel sorting fields + * + * @export + * @enum {string} + */ +export enum ChannelSort { + ACCESS = "access", + CREATED_AT = "createdAt", + CREATOR = "creator", + EDITOR = "editor", + ID = "id", + LAST_ACTIVITY = "last_activity", + UPDATED_AT = "updatedAt", +} + +/** + * relations of channel entity + * + * @export + * @enum {string} + */ +export enum ChannelRelation { + CHANNEL_ACL = "channelAcl", +} + +export enum AclCategory { + GROUP = "group", + ORG = "org", + USER = "user", + ANONYMOUS_USER = "anonymousUser", + AUTHENTICATED_USER = "authenticatedUser", +} + +export enum AclSubCategory { + ADMIN = "admin", + MEMBER = "member", +} + +/** + * request option for creating a channel ACL permission + */ +export interface IChannelAclPermissionDefinition { + category: AclCategory; + subCategory?: AclSubCategory; + key?: string; + role: Role; + restrictedBefore?: string; +} + +/** + * request option for updating a channel ACL permission + */ +export interface IChannelAclPermissionUpdateDefinition + extends IChannelAclPermissionDefinition { + channelId: string; +} + +/** + * representation of channel Acl permission from service + * + * @export + * @interface IChannelAclPermission + * @extends {IChannelAclDefinition} + * @extends {IWithAuthor} + * @extends {IWithEditor} + * @extends {IWithTimestamps} + */ +export interface IChannelAclPermission + extends Omit, + IWithAuthor, + IWithEditor, + IWithTimestamps { + id: string; + restrictedBefore: string; +} + +/** + * settings parameters for creating a channel + * + * @export + * @interface ICreateChannelSettings + */ +export interface ICreateChannelSettings { + allowedReactions?: PostReaction[]; + allowReaction?: boolean; + allowReply?: boolean; + blockWords?: string[]; + defaultPostStatus?: PostStatus; + metadata?: IChannelMetadata; + name?: string; + softDelete?: boolean; +} + +export interface IChannelMetadata { + guidelineUrl?: string | null; +} + +/** + * permissions parameters for creating a channel + * + * @export + * @interface ICreateChannelPermissions + */ +export interface ICreateChannelPermissions { + access?: SharingAccess; + allowAnonymous?: boolean; + groups?: string[]; + orgs?: string[]; + /** + * Not available until the V2 Api is released + * @hidden + */ + channelAclDefinition?: IChannelAclPermissionDefinition[]; +} + +/** + * permissions parameters for updating a channel + * + * @export + * @interface IUpdateChannelPermissions + */ +export interface IUpdateChannelPermissions { + access?: SharingAccess; + allowAnonymous?: boolean; +} + +/** + * permissions and settings options for creating a channel + * + * @export + * @interface ICreateChannel + * @extends {ICreateChannelSettings} + * @extends {ICreateChannelPermissions} + */ +export interface ICreateChannel + extends ICreateChannelSettings, + ICreateChannelPermissions {} + +/** + * representation of channel from service + * + * @export + * @interface IChannel + * @extends {IWithAuthor} + * @extends {IWithEditor} + * @extends {IWithTimestamps} + */ +export interface IChannel extends IWithAuthor, IWithEditor, IWithTimestamps { + access: SharingAccess; + allowAnonymous: boolean; + allowedReactions: PostReaction[] | null; + allowReaction: boolean; + allowReply: boolean; + blockWords: string[] | null; + channelAcl?: IChannelAclPermission[]; + defaultPostStatus: PostStatus; + groups: string[]; + metadata: IChannelMetadata | null; + id: string; + name: string | null; + orgs: string[]; + posts?: IPost[]; + softDelete: boolean; +} + +/** + * parameters/options for updating channel settings + * + * @export + * @interface IUpdateChannel + * @extends {ICreateChannelSettings} + * @extends { IUpdateChannelPermissions} + * @extends {Partial} + */ +export interface IUpdateChannel + extends ICreateChannelSettings, + IUpdateChannelPermissions, + Partial {} + +/** + * dto for decorating found channel with relations + * + * @export + * @interface IFetchChannel + */ +export interface IFetchChannel { + relations?: ChannelRelation[]; +} + +/** + * dto for querying channels + * + * @export + * @interface ISearchChannels + * @extends {Partial} + * @extends {Partial>} + * @extends {Partial} + * @extends {Partial>} + */ +export interface ISearchChannels + extends Partial, + Partial>, + Partial, + Partial> { + groups?: string[]; + access?: SharingAccess[]; + relations?: ChannelRelation[]; +} + +/** + * request params for creating a channel + * + * @export + * @interface ICreateChannelParams + * @extends {IDiscussionsRequestOptions} + */ +export interface ICreateChannelParams extends IDiscussionsRequestOptions { + data: ICreateChannel; +} + +/** + * request params for getting a channel + * + * @export + * @interface IFetchChannelParams + * @extends {IDiscussionsRequestOptions} + */ +export interface IFetchChannelParams extends IDiscussionsRequestOptions { + channelId: string; + data?: IFetchChannel; +} + +/** + * request params for searching channels + * + * @export + * @interface ISearchChannelsParams + * @extends {IDiscussionsRequestOptions} + */ +export interface ISearchChannelsParams extends IDiscussionsRequestOptions { + data?: ISearchChannels; +} +/** + * request params for updating a channel's settings + * + * @export + * @interface IUpdateChannelParams + * @extends {IDiscussionsRequestOptions} + */ +export interface IUpdateChannelParams extends IDiscussionsRequestOptions { + channelId: string; + data: IUpdateChannel; +} + +/** + * request params for deleting a channel + * + * @export + * @interface IRemoveChannelParams + * @extends {IDiscussionsRequestOptions} + */ +export interface IRemoveChannelParams extends IDiscussionsRequestOptions { + channelId: string; +} + +/** + * delete channel response properties + * + * @export + * @interface IRemoveChannelResponse + */ +export interface IRemoveChannelResponse { + success: boolean; + channelId: string; +} + +/** + * request params for fetching opt out status + * + * @export + * @interface IFetchChannelNotificationOptOutParams + * @extends {IDiscussionsRequestOptions} + */ +export interface IFetchChannelNotificationOptOutParams + extends IDiscussionsRequestOptions { + channelId: string; +} + +/** + * request params for opting out + * + * @export + * @interface ICreateChannelNotificationOptOutParams + * @extends {IDiscussionsRequestOptions} + */ +export interface ICreateChannelNotificationOptOutParams + extends IDiscussionsRequestOptions { + channelId: string; +} + +/** + * request params for opting back in + * + * @export + * @interface IRemoveChannelNotificationOptOutParams + * @extends {IDiscussionsRequestOptions} + */ +export interface IRemoveChannelNotificationOptOutParams + extends IDiscussionsRequestOptions { + channelId: string; +} + +/** + * request params for deleting channel activity + * + * @export + * @interface IRemoveChannelActivityParams + * @extends {IDiscussionsRequestOptions} + */ +export interface IRemoveChannelActivityParams + extends IDiscussionsRequestOptions { + channelId: string; +} + +/** + * representation of a discussion setting record from the service + * + * @export + * @interface IDiscussionSetting + * @extends {IWithAuthor} + * @extends {IWithEditor} + * @extends {IWithTimestamps} + */ +export interface IDiscussionSetting + extends IWithAuthor, + IWithEditor, + IWithTimestamps { + id: string; + type: DiscussionSettingType; + settings: ISettings; +} + +export enum DiscussionSettingType { + CONTENT = "content", +} + +export interface ISettings { + allowedChannelIds: string[] | null; +} + +/** + * parameters for creating a discussionSetting + */ +export interface ICreateDiscussionSetting { + id: string; + type: DiscussionSettingType; + settings: ISettings; +} + +export interface ICreateDiscussionSettingParams + extends IDiscussionsRequestOptions { + data: ICreateDiscussionSetting; +} diff --git a/packages/common/src/discussions/api/utils/request.ts b/packages/common/src/discussions/api/utils/request.ts new file mode 100644 index 00000000000..bbfd791cf71 --- /dev/null +++ b/packages/common/src/discussions/api/utils/request.ts @@ -0,0 +1,101 @@ +import { buildUrl } from "../../../urls"; +import { RemoteServerError as _RemoteServerError } from "../../../request"; +import { IDiscussionsRequestOptions } from "../types"; + +export class RemoteServerError extends _RemoteServerError { + error: string; + + constructor(message: string, url: string, status: number, error: string) { + super(message, url, status); + this.error = error; + } +} + +/** + * returns Promise that resolves token to use in Discussions API requests + * + * @export + * @param {IDiscussionsRequestOptions} options + * @return {*} {Promise} + */ +export function authenticateRequest( + options: IDiscussionsRequestOptions +): Promise { + const { token, authentication } = options; + + let tokenPromise = () => { + return Promise.resolve(token); + }; + + if (authentication) { + tokenPromise = authentication.getToken.bind( + authentication, + authentication.portal + ); + } + + return tokenPromise(); +} + +/** + * parses IHubRequestOptions and makes request against Discussions API + * + * @export + * @template T + * @param {string} route + * @param {IDiscussionsRequestOptions} options + * @param {string} [token] + * @return {*} {Promise} + */ +export function apiRequest( + route: string, + options: IDiscussionsRequestOptions, + token?: string +): Promise { + const headers = new Headers(options.headers); + headers.append("Content-Type", "application/json"); + if (token) { + headers.append("Authorization", `Bearer ${token}`); + } + + const opts: RequestInit = { + headers, + method: options.httpMethod || "GET", + mode: options.mode, + cache: options.cache, + credentials: options.credentials, + }; + + const apiBase = buildUrl({ + // TODO: we _want_ to use getHubApiUrl(), + // but have to deal w/ the fact that this package overwrites IHubRequestOptions + host: options.hubApiUrl || "https://hub.arcgis.com", + path: "/api/discussions/v1", + }); + + if (options.data) { + if (options.httpMethod === "GET") { + const queryParams = new URLSearchParams(options.data).toString(); + route += `?${queryParams}`; + } else { + opts.body = JSON.stringify(options.data); + } + } + + const url = [apiBase.replace(/\/$/, ""), route.replace(/^\//, "")].join("/"); + return fetch(url, opts).then((res) => { + if (res.ok) { + return res.json(); + } else { + const { statusText, status } = res; + return res.json().then((err) => { + throw new RemoteServerError( + statusText, + url, + status, + JSON.stringify(err.message) + ); + }); + } + }); +} diff --git a/packages/common/src/discussions/index.ts b/packages/common/src/discussions/index.ts index e59a7d47c39..90ce590fbe2 100644 --- a/packages/common/src/discussions/index.ts +++ b/packages/common/src/discussions/index.ts @@ -3,3 +3,4 @@ export * from "./edit"; export * from "./fetch"; export * from "./constants"; export * from "./utils"; +export * from "./api"; diff --git a/packages/common/src/search/_internal/discussionsSearchChannels.ts b/packages/common/src/search/_internal/discussionsSearchChannels.ts new file mode 100644 index 00000000000..27fd8aaabd5 --- /dev/null +++ b/packages/common/src/search/_internal/discussionsSearchChannels.ts @@ -0,0 +1,26 @@ +import { IHubSearchOptions, IHubSearchResponse, IQuery } from "../types"; +import { searchChannels } from "../../discussions/api/channels"; +import { IChannel } from "../../discussions"; +import { + processSearchParams, + toHubSearchResult, +} from "./discussionsSearchChannelsHelpers"; + +/** + * @private + * Execute channel search against the Discussions API + * @param query + * @param options + * @returns + */ +export async function discussionsSearchChannels( + query: IQuery, + options: IHubSearchOptions +): Promise> { + // Pull useful info out of query + const searchOptions = processSearchParams(options, query); + // Call to searchChannels + const channelsResponse = await searchChannels(searchOptions); + // Parse into > + return toHubSearchResult(channelsResponse, query, options); +} diff --git a/packages/common/src/search/_internal/discussionsSearchChannelsHelpers/index.ts b/packages/common/src/search/_internal/discussionsSearchChannelsHelpers/index.ts new file mode 100644 index 00000000000..5f1e85140f4 --- /dev/null +++ b/packages/common/src/search/_internal/discussionsSearchChannelsHelpers/index.ts @@ -0,0 +1,86 @@ +import { IHubSearchOptions, IHubSearchResponse, IQuery } from "../../types"; +import HubError from "../../../HubError"; +import { + IChannel, + IPagedResponse, + ISearchChannels, + ISearchChannelsParams, +} from "../../../discussions"; +import { discussionsSearchChannels } from ".."; + +// Given IHubSearchOptions and IQuery, restructure into ISearchChannelsParams +export function processSearchParams( + options: IHubSearchOptions, + query: IQuery +): ISearchChannelsParams { + if (!options.requestOptions) { + throw new HubError( + "discussionsSearchChannels", + "options.requestOptions is required" + ); + } + // Array of properties we want to copy over from IHubSeachOptions to ISearchChannels + const paginationProps: Partial> = {}; + const allowedPaginationProps: Array = [ + "num", + "start", + "sortField", + "sortOrder", + ]; + // Map IHubSearchOptions field to ISearchChannels field + const standardizeKeyName = ( + key: keyof IHubSearchOptions + ): keyof ISearchChannels => { + if (key === "sortField") { + return "sortBy"; + } + return key as keyof ISearchChannels; + }; + allowedPaginationProps.forEach((prop) => { + if (options.hasOwnProperty(prop)) { + const key = standardizeKeyName(prop); + paginationProps[key] = options[prop]; + } + }); + // Acceptable fields to use as filters + const filterProps: Record = {}; + const allowedFilterProps: Array = ["access", "groups"]; + // Find predicates that match acceptable filter fields + query.filters.forEach((filter) => { + filter.predicates.forEach((predicate) => { + Object.keys(predicate).forEach((key: any) => { + if (allowedFilterProps.includes(key)) { + filterProps[key] = predicate[key]; + } + }); + }); + }); + // Return as ISearchChannelsParams + return { + ...options.requestOptions, + data: { + ...paginationProps, + ...filterProps, + }, + }; +} + +// Given an IPagedResponse, return an IHubSearchResponse +export function toHubSearchResult( + channelsResponse: IPagedResponse, + query: IQuery, + options: IHubSearchOptions +): IHubSearchResponse { + const { total, items, nextStart } = channelsResponse; + return { + total, + results: items, + hasNext: nextStart > -1, + next: () => { + return discussionsSearchChannels(query, { + ...options, + start: nextStart, + }); + }, + }; +} diff --git a/packages/common/src/search/_internal/index.ts b/packages/common/src/search/_internal/index.ts index fe7b16fadbf..6ec0b4cdbcb 100644 --- a/packages/common/src/search/_internal/index.ts +++ b/packages/common/src/search/_internal/index.ts @@ -2,3 +2,4 @@ export * from "./portalSearchItems"; export * from "./hubSearchItems"; export * from "./portalSearchGroups"; export * from "./portalSearchUsers"; +export * from "./discussionsSearchChannels"; diff --git a/packages/common/src/search/hubSearch.ts b/packages/common/src/search/hubSearch.ts index 0cb97311012..aae17f09810 100644 --- a/packages/common/src/search/hubSearch.ts +++ b/packages/common/src/search/hubSearch.ts @@ -13,6 +13,7 @@ import { portalSearchItems, portalSearchGroups, portalSearchUsers, + discussionsSearchChannels, } from "./_internal"; import { portalSearchGroupMembers } from "./_internal/portalSearchGroupMembers"; @@ -80,6 +81,9 @@ export async function hubSearch( "arcgis-hub": { item: hubSearchItems, }, + discussions: { + channel: discussionsSearchChannels, + }, }; const fn = getProp(fnHash, `${formattedOptions.api.type}.${filterType}`); diff --git a/packages/common/src/search/types/IHubCatalog.ts b/packages/common/src/search/types/IHubCatalog.ts index a56f4910574..bbdfaa938ac 100644 --- a/packages/common/src/search/types/IHubCatalog.ts +++ b/packages/common/src/search/types/IHubCatalog.ts @@ -53,7 +53,13 @@ export interface IHubCollection { targetEntity: EntityType; } -export type EntityType = "item" | "group" | "user" | "groupMember" | "event"; +export type EntityType = + | "item" + | "group" + | "user" + | "groupMember" + | "event" + | "channel"; /** * IQuery is the fundamental unit used to execute a search. By composing diff --git a/packages/common/src/search/types/IHubSearchOptions.ts b/packages/common/src/search/types/IHubSearchOptions.ts index 92f3cf568f7..bf1d41f835f 100644 --- a/packages/common/src/search/types/IHubSearchOptions.ts +++ b/packages/common/src/search/types/IHubSearchOptions.ts @@ -73,7 +73,7 @@ export interface IHubSearchOptions { /** * Sort direction */ - sortOrder?: "desc" | "asc"; + sortOrder?: "desc" | "asc" | "DESC" | "ASC"; /** * The result number of the first entry in the result set response. The start parameter, along with the num parameter, can be used to paginate the search results. */ diff --git a/packages/common/src/search/types/types.ts b/packages/common/src/search/types/types.ts index 6387599ee6d..1de7ff08951 100644 --- a/packages/common/src/search/types/types.ts +++ b/packages/common/src/search/types/types.ts @@ -82,7 +82,7 @@ export interface IApiDefinition { // - for "arcgis-hub", the /v3/search will be added url: string; // We can add types as we add support for more - type: "arcgis" | "arcgis-hub"; + type: "arcgis" | "arcgis-hub" | "discussions"; } /** * Base options when checking catalog containment diff --git a/packages/common/test/discussions/api/channels.test.ts b/packages/common/test/discussions/api/channels.test.ts new file mode 100644 index 00000000000..1b4a9648ec1 --- /dev/null +++ b/packages/common/test/discussions/api/channels.test.ts @@ -0,0 +1,39 @@ +import { + IDiscussionsRequestOptions, + SharingAccess, +} from "../../../src/discussions/api/types"; +import { searchChannels } from "../../../src/discussions/api/channels"; +import * as req from "../../../src/discussions/api/request"; + +describe("channels", () => { + let requestSpy: any; + const response = new Response("ok", { status: 200 }); + const baseOpts: IDiscussionsRequestOptions = { + hubApiUrl: "https://hub.arcgis.com/api", + authentication: null, + } as unknown as IDiscussionsRequestOptions; + + beforeEach(() => { + requestSpy = spyOn(req, "request").and.returnValue( + Promise.resolve(response) + ); + }); + + it("queries channels", (done) => { + const query = { + access: [SharingAccess.PUBLIC], + groups: ["foo"], + }; + + const options = { ...baseOpts, data: query }; + searchChannels(options) + .then(() => { + expect(requestSpy.calls.count()).toEqual(1); + const [url, opts] = requestSpy.calls.argsFor(0); + expect(url).toEqual(`/channels`); + expect(opts).toEqual({ ...options, httpMethod: "GET" }); + done(); + }) + .catch(() => fail()); + }); +}); diff --git a/packages/common/test/search/_internal/discussionsSearchChannels.test.ts b/packages/common/test/search/_internal/discussionsSearchChannels.test.ts new file mode 100644 index 00000000000..bd2d599a6f5 --- /dev/null +++ b/packages/common/test/search/_internal/discussionsSearchChannels.test.ts @@ -0,0 +1,59 @@ +import { discussionsSearchChannels } from "../../../src/search/_internal"; +import * as helpers from "../../../src/search/_internal/discussionsSearchChannelsHelpers"; +import * as API from "../../../src/discussions/api/channels"; +import { IQuery, IHubSearchOptions } from "../../../src"; +import SEARCH_CHANNELS_RESPONSE from "./mocks/searchChannelsResponse"; + +describe("discussionsSearchItems Module |", () => { + let processSearchParamsSpy: jasmine.Spy; + let toHubSearchResultSpy: jasmine.Spy; + let searchChannelsSpy: jasmine.Spy; + + beforeEach(() => { + processSearchParamsSpy = spyOn( + helpers, + "processSearchParams" + ).and.callThrough(); + toHubSearchResultSpy = spyOn( + helpers, + "toHubSearchResult" + ).and.callThrough(); + searchChannelsSpy = spyOn(API, "searchChannels").and.callFake(() => { + return Promise.resolve(SEARCH_CHANNELS_RESPONSE); + }); + }); + + it("calls searchChannels", async () => { + const qry: IQuery = { + targetEntity: "channel", + filters: [ + { + predicates: [ + { + access: "private", + groups: ["cb0ddfc90f4f45b899c076c88d3fdc84"], + }, + ], + }, + ], + }; + const opts: IHubSearchOptions = { + num: 10, + sortField: "createdAt", + sortOrder: "DESC", + api: { + type: "discussions", + url: "/api/discussions/v1/channels", + }, + requestOptions: { + hubApiUrl: "https://hubqa.arcgis.com/api", + token: "my-secret-token", + } as any, + }; + const result = await discussionsSearchChannels(qry, opts); + expect(processSearchParamsSpy).toHaveBeenCalledTimes(1); + expect(toHubSearchResultSpy).toHaveBeenCalledTimes(1); + expect(searchChannelsSpy).toHaveBeenCalledTimes(1); + expect(result).toBeTruthy(); + }); +}); diff --git a/packages/common/test/search/_internal/mocks/searchChannelsResponse.ts b/packages/common/test/search/_internal/mocks/searchChannelsResponse.ts new file mode 100644 index 00000000000..94337a20169 --- /dev/null +++ b/packages/common/test/search/_internal/mocks/searchChannelsResponse.ts @@ -0,0 +1,27 @@ +const response = { + items: [ + { + groups: ["cb0ddfc90f4f45b899c076c88d3fdc84"], + id: "69d034b4db8d4dd6bc26ac64169bcbfe", + access: "private", + allowAnonymous: false, + allowedReactions: null, + allowReaction: true, + allowReply: true, + blockWords: null, + createdAt: "2023-04-10T04:17:30.498Z", + creator: "juliana_pa", + defaultPostStatus: "approved", + editor: "carla_pa", + metadata: null, + name: null, + orgs: ["Xj56SBi2udA78cC9"], + softDelete: true, + updatedAt: "2023-04-10T04:17:32.704Z", + }, + ], + total: 1, + nextStart: -1, +}; + +export default response; diff --git a/packages/common/test/search/hubSearch.test.ts b/packages/common/test/search/hubSearch.test.ts index bf8fa679376..ab4b55a9188 100644 --- a/packages/common/test/search/hubSearch.test.ts +++ b/packages/common/test/search/hubSearch.test.ts @@ -90,6 +90,7 @@ describe("hubSearch Module:", () => { let portalSearchItemsSpy: jasmine.Spy; let portalSearchGroupsSpy: jasmine.Spy; let hubSearchItemsSpy: jasmine.Spy; + let discussionsSearchChannelsSpy: jasmine.Spy; beforeEach(() => { // we are only interested in verifying that the fn was called with specific args // so all the responses are fake @@ -123,6 +124,16 @@ describe("hubSearch Module:", () => { total: 99, }); }); + discussionsSearchChannelsSpy = spyOn( + SearchFunctionModule, + "discussionsSearchChannels" + ).and.callFake(() => { + return Promise.resolve({ + hasNext: false, + results: [], + total: 99, + }); + }); }); it("items: portalSearchItems", async () => { const qry: IQuery = { @@ -246,6 +257,31 @@ describe("hubSearch Module:", () => { // Any cloning of auth can break downstream functions expect(options.requestOptions).toBe(opts.requestOptions); }); + it("channels + discussions: discussionsSearchChannels", async () => { + const qry: IQuery = { + targetEntity: "channel", + filters: [ + { + predicates: [{ term: "water" }], + }, + ], + }; + const opts: IHubSearchOptions = { + requestOptions: { + hubApiUrl: "https://hubqa.arcgis.com/api", + }, + api: { + type: "discussions", + url: "/api/discussions/v1/channels", + }, + }; + const chk = await hubSearch(qry, opts); + expect(chk.total).toBe(99); + expect(discussionsSearchChannelsSpy.calls.count()).toBe(1); + const [query, options] = discussionsSearchChannelsSpy.calls.argsFor(0); + expect(query).toEqual(qry); + expect(options).toEqual(opts); + }); }); }); }); diff --git a/packages/discussions/src/channels/channels.ts b/packages/discussions/src/channels/channels.ts index 71e7fccfe36..df8c2e27243 100644 --- a/packages/discussions/src/channels/channels.ts +++ b/packages/discussions/src/channels/channels.ts @@ -1,6 +1,5 @@ import { request } from "../request"; import { - ISearchChannelsParams, ICreateChannelParams, IFetchChannelParams, IUpdateChannelParams, @@ -11,25 +10,12 @@ import { ICreateChannelNotificationOptOutParams, IRemoveChannelNotificationOptOutParams, IRemoveChannelActivityParams, - IPagedResponse, IRemoveChannelNotificationOptOutResult, IRemoveChannelActivityResult, IChannelNotificationOptOut, } from "../types"; -/** - * search channels - * - * @export - * @param {ISearchChannelsParams} options - * @return {*} {Promise>} - */ -export function searchChannels( - options: ISearchChannelsParams -): Promise> { - options.httpMethod = "GET"; - return request(`/channels`, options); -} +export { searchChannels } from "@esri/hub-common"; /** * create channel diff --git a/packages/discussions/src/types.ts b/packages/discussions/src/types.ts index f1504830c27..8533505bcda 100644 --- a/packages/discussions/src/types.ts +++ b/packages/discussions/src/types.ts @@ -1,1027 +1,82 @@ -import { - IPagingParams, - IPagedResponse as IRestPagedResponse, - IUser, -} from "@esri/arcgis-rest-types"; -import { IHubRequestOptions } from "@esri/hub-common"; -import { Geometry } from "geojson"; - -/** - * sort orders - * - * @export - * @enum {string} - */ -export enum SortOrder { - ASC = "ASC", - DESC = "DESC", -} - -/** - * reactions to posts - * - * @export - * @enum {string} - */ -export enum PostReaction { - CLAPPING_HANDS = "clapping_hands", - CONFUSED = "confused", - DOWN_ARROW = "down_arrow", - EYES = "eyes", - FACE_WITH_TEARS_OF_JOY = "face_with_tears_of_joy", - FIRE = "fire", - GRINNING = "grinning", - HEART = "heart", - LAUGH = "laugh", - ONE_HUNDRED = "one_hundred", - PARTYING = "partying", - PARTY_POPPER = "party_popper", - RAISING_HANDS = "raising_hands", - ROCKET = "rocket", - SAD = "sad", - SLIGHTLY_SMILING = "slightly_smiling", - SURPRISED = "surprised", - THINKING = "thinking", - THUMBS_UP = "thumbs_up", - THUMBS_DOWN = "thumbs_down", - TROPHY = "trophy", - UP_ARROW = "up_arrow", - WAVING_HAND = "waving_hand", - WINKING = "winking", - WORLD_MAP = "world_map", -} - -/** - * platform sharing access values - * - * @export - * @enum {string} - */ -export enum SharingAccess { - PUBLIC = "public", - ORG = "org", - PRIVATE = "private", -} -/** - * representation of AGOL platform sharing ACL - * NOTE: orgs is an array to enable future org-org sharing/discussion - * - * @export - * @interface IPlatformSharing - */ -export interface IPlatformSharing { - groups: string[]; - orgs: string[]; - access: SharingAccess; -} - -/** - * possible statuses of a post - * - * @export - * @enum {string} - */ -export enum PostStatus { - PENDING = "pending", - APPROVED = "approved", - REJECTED = "rejected", - DELETED = "deleted", - HIDDEN = "hidden", -} - -/** - * possible discussionn content types, i.e. a post can be about an item, dataset, or group - * - * @export - * @enum {string} - */ -export enum DiscussionType { - GROUP = "group", - CONTENT = "content", - BOARD = "board", -} - -/** - * source of a post, i.e. app context - * - * @export - * @enum {string} - */ -export enum DiscussionSource { - HUB = "hub", - AGO = "ago", - URBAN = "urban", -} - -/** - * named parts of a discussion URI, follows this convention: - * ${source}://${type}/${id}_${layer}?features=${...features}&attribute=${attribute} - * - * coarse-grained uri - hub://item/ab3 -- commenting from hub about item ab3 - * -- - * fine-grained uri - hub://dataset/3ef_0?features=10,32&attribute=species -- commenting from - * hub about species attribute of features id 10 & 32 in dataset 3ef layer 0 - * - * @export - * @interface IDiscussionParams - */ -export interface IDiscussionParams { - source: string | null; - type: string | null; - id: string | null; - layer: string | null; - features: string[] | null; - attribute: string | null; -} - -/** - * relations of post entity - * - * @export - * @enum {string} - */ -export enum PostRelation { - REPLIES = "replies", - REACTIONS = "reactions", - PARENT = "parent", - CHANNEL = "channel", -} - -/** - * relations of reaction entity - * - * @export - * @enum {string} - */ -export enum ReactionRelation { - POST = "post", -} - -/** - * filters of channel entity - * - * @export - * @enum {string} - */ -export enum ChannelFilter { - HAS_USER_POSTS = "has_user_posts", -} - -// sorting - -/** - * Common sorting fields - */ -export enum CommonSort { - CREATED_AT = "createdAt", - CREATOR = "creator", - EDITOR = "editor", - ID = "id", - UPDATED_AT = "updatedAt", -} - -/** - * creator property - * - * @export - * @interface IWithAuthor - */ -export interface IWithAuthor { - creator: string; -} - -/** - * editor property - * - * @export - * @interface IWithEditor - */ -export interface IWithEditor { - editor: string; -} - -/** - * sorting properties - * - * @export - * @interface IWithSorting - */ -export interface IWithSorting { - sortBy: SortEnum; - sortOrder: SortOrder; -} - -/** - * filtering properties - * - * @export - * @interface IWithFiltering - */ -export interface IWithFiltering { - filterBy: FilterEnum; -} - -/** - * properties that enable temporal querying - * - * @export - * @interface IWithTimeQueries - */ -export interface IWithTimeQueries { - createdBefore: Date; - createdAfter: Date; - updatedBefore: Date; - updatedAfter: Date; -} - -/** - * temporal properties - * - * @export - * @interface IWithTimestamps - */ -export interface IWithTimestamps { - createdAt: Date; - updatedAt: Date; -} - -/** - * paginated response properties - * - * @export - * @interface IPagedResponse - * @extends {IRestPagedResponse} - * @template PaginationObject - */ -export interface IPagedResponse extends IRestPagedResponse { - items: PaginationObject[]; -} - -/** - * delete notifications opt out response properties - * - * @export - * @interface IRemoveChannelNotificationOptOutResult - */ -export interface IRemoveChannelNotificationOptOutResult { - success: boolean; - channelId: string; - username: string; -} - -/** - * delete channel activity response properties - * - * @export - * @interface IRemoveChannelActivityResult - */ -export interface IRemoveChannelActivityResult { - success: boolean; - channelId: string; - username: string; -} - -/** - * opt out response properties - * - * @export - * @interface IChannelNotificationOptOut - */ -export interface IChannelNotificationOptOut { - channelId: string; - username: string; -} - -/** - * options for making requests against Discussion API - * - * @export - * @interface IRequestOptions - * @extends {RequestInit} - */ -// NOTE: this is as close to implementing @esri/hub-common IHubRequestOptions as possible -// only real exception is needing to extend httpMethod to include PATCH and DELETE -// also making isPortal optional for convenience -// picking fields from requestInit for development against local api -export interface IDiscussionsRequestOptions - extends Omit, - Pick { - httpMethod?: "GET" | "POST" | "PATCH" | "DELETE"; - isPortal?: boolean; - token?: string; - data?: { [key: string]: any }; -} - -/** - * Role types - * - * @export - * @enum {string} - */ -export enum Role { - READ = "read", - WRITE = "write", - READWRITE = "readWrite", - MODERATE = "moderate", - MANAGE = "manage", - OWNER = "owner", -} - -/** - * Interface representing the meta data associated with a discussions - * mention email - */ -export interface IDiscussionsMentionMeta { - channelId: string; - discussion: string; - postId: string; - replyId?: string; -} - -export interface IDiscussionsUser extends IUser { - username?: string | null; -} - -/** - * representation of reaction from the service - * - * @export - * @interface IReaction - * @extends {IWithAuthor} - * @extends {IWithEditor} - * @extends {IWithTimestamps} - */ -export interface IReaction extends IWithAuthor, IWithEditor, IWithTimestamps { - id: string; - value: PostReaction; - postId?: string; - post?: IPost; -} - -/** - * dto for creating a reaction - * - * @export - * @interface ICreateReaction - */ -export interface ICreateReaction { - postId: string; - value: PostReaction; -} - -/** - * request options for creating a reaction to a post - * - * @export - * @interface ICreateReactionOptions - * @extends {IHubRequestOptions} - */ -export interface ICreateReactionOptions extends IDiscussionsRequestOptions { - data: ICreateReaction; -} - -/** - * request options for deleting a reaction - * - * @export - * @interface IRemoveReactionOptions - * @extends {IHubRequestOptions} - */ -export interface IRemoveReactionOptions extends IDiscussionsRequestOptions { - reactionId: string; -} - -/** - * delete reaction response properties - * - * @export - * @interface IRemoveReactionResponse - */ -export interface IRemoveReactionResponse { - success: boolean; - reactionId: string; -} - -/** - * Post sorting fields - * - * @enum {string} - */ -export enum PostSort { - BODY = "body", - CHANNEL_ID = "channelId", - CREATED_AT = "createdAt", - CREATOR = "creator", - DISCUSSION = "discussion", - EDITOR = "editor", - ID = "id", - PARENT_ID = "parentId", - STATUS = "status", - TITLE = "title", - UPDATED_AT = "updatedAt", -} - -/** - * Post types - * - * @enum{string} - */ -export enum PostType { - Text = "text", - Announcement = "announcement", - Poll = "poll", - Question = "question", -} - -/** - * representation of post from service - * - * @export - * @interface IPost - * @extends {IWithAuthor} - * @extends {IWithEditor} - * @extends {IWithTimestamps} - */ -export interface IPost - extends Partial, - Partial, - IWithTimestamps { - id: string; - title: string | null; - body: string; - status: PostStatus; - appInfo: string | null; // this is a catch-all field for app-specific information about a post, added for Urban - discussion: string | null; - geometry: Geometry | null; - featureGeometry: Geometry | null; - postType: PostType; - channelId?: string; - channel?: IChannel; - parentId?: string; - parent?: IPost | null; - replies?: IPost[] | IPagedResponse; - replyCount?: number; - reactions?: IReaction[]; -} - -/** - * base parameters for creating a post - * - * @export - * @interface IPostOptions - */ -export interface IPostOptions { - body: string; - title?: string; - discussion?: string; - geometry?: Geometry; - featureGeometry?: Geometry; - appInfo?: string; - asAnonymous?: boolean; -} - -/** - * dto for creating a post in a known channel - * - * @export - * @interface ICreatePost - * @extends {IPostOptions} - */ -export interface ICreatePost extends IPostOptions { - channelId: string; -} - -/** - * dto for creating a post in a unknown or not yet created channel - * - * @export - * @interface ICreateChannelPost - * @extends {IPostOptions} - * @extends {ICreateChannel} - */ -export interface ICreateChannelPost extends IPostOptions, ICreateChannel {} - -/** - * request options for creating post - * - * @export - * @interface ICreatePostParams - * @extends {IHubRequestOptions} - */ -export interface ICreatePostParams extends IDiscussionsRequestOptions { - data: ICreatePost | ICreateChannelPost; - mentionUrl?: string; -} - -/** - * request options for creating reply to post - * - * @export - * @interface ICreateReplyParams - * @extends {IHubRequestOptions} - */ -export interface ICreateReplyParams extends IDiscussionsRequestOptions { - postId: string; - data: IPostOptions; - mentionUrl?: string; -} - -/** - * dto for decorating found post with relations - * - * @export - * @interface IFetchPost - */ -export interface IFetchPost { - relations?: PostRelation[]; -} - -/** - * dto for querying posts in a single channel - * - * @export - * @interface ISearchChannelPosts - * @extends {Partial} - * @extends {Partial} - * @extends {Partial} - * @extends {Partial>} - * @extends {Partial} - */ -export interface ISearchPosts - extends Partial, - Partial, - Partial, - Partial>, - Partial { - title?: string; - body?: string; - discussion?: string; - geometry?: Geometry; - featureGeometry?: Geometry; - parents?: Array; - status?: PostStatus[]; - relations?: PostRelation[]; - groups?: string[]; - access?: SharingAccess[]; - channels?: string[]; -} - -/** - * dto for updating a post's status - * - * @export - * @interface IUpdatePostStatus - */ -export interface IUpdatePostStatus { - status: PostStatus; -} - -/** - * dto for updating a post's content - * - * @export - * @interface IUpdatePost - */ -export interface IUpdatePost { - title?: string; - body?: string; - discussion?: string | null; - geometry?: Geometry | null; - featureGeometry?: Geometry | null; - appInfo?: string | null; -} - -/** - * request options for querying posts - * - * @export - * @interface ISearchPostsParams - * @extends {IHubRequestOptions} - */ -export interface ISearchPostsParams extends IDiscussionsRequestOptions { - data?: ISearchPosts; -} - -/** - * request params for getting post - * - * @export - * @interface IFetchPostParams - * @extends {IHubRequestOptions} - */ -export interface IFetchPostParams extends IDiscussionsRequestOptions { - postId: string; - data?: IFetchPost; -} - -/** - * request options for updating post - * - * @export - * @interface IUpdatePostParams - * @extends {IHubRequestOptions} - */ -export interface IUpdatePostParams extends IDiscussionsRequestOptions { - postId: string; - data: IUpdatePost; - mentionUrl?: string; -} - -/** - * request options for updating a post's status - * - * @export - * @interface IUpdatePostStatusParams - * @extends {IHubRequestOptions} - */ -export interface IUpdatePostStatusParams extends IDiscussionsRequestOptions { - postId: string; - data: IUpdatePostStatus; -} - -/** - * request options for deleting a post - * - * @export - * @interface IRemovePostParams - * @extends {IHubRequestOptions} - */ -export interface IRemovePostParams extends IDiscussionsRequestOptions { - postId: string; -} - -/** - * delete post response properties - * - * @export - * @interface IRemovePostResponse - */ -export interface IRemovePostResponse { - success: boolean; - postId: string; -} - -/** - * Channel sorting fields - * - * @export - * @enum {string} - */ -export enum ChannelSort { - ACCESS = "access", - CREATED_AT = "createdAt", - CREATOR = "creator", - EDITOR = "editor", - ID = "id", - LAST_ACTIVITY = "last_activity", - UPDATED_AT = "updatedAt", -} - -/** - * relations of channel entity - * - * @export - * @enum {string} - */ -export enum ChannelRelation { - CHANNEL_ACL = "channelAcl", -} - -export enum AclCategory { - GROUP = "group", - ORG = "org", - USER = "user", - ANONYMOUS_USER = "anonymousUser", - AUTHENTICATED_USER = "authenticatedUser", -} - -export enum AclSubCategory { - ADMIN = "admin", - MEMBER = "member", -} - -/** - * request option for creating a channel ACL permission - */ -export interface IChannelAclPermissionDefinition { - category: AclCategory; - subCategory?: AclSubCategory; - key?: string; - role: Role; - restrictedBefore?: string; -} - -/** - * request option for updating a channel ACL permission - */ -export interface IChannelAclPermissionUpdateDefinition - extends IChannelAclPermissionDefinition { - channelId: string; -} - -/** - * representation of channel Acl permission from service - * - * @export - * @interface IChannelAclPermission - * @extends {IChannelAclDefinition} - * @extends {IWithAuthor} - * @extends {IWithEditor} - * @extends {IWithTimestamps} - */ -export interface IChannelAclPermission - extends Omit, - IWithAuthor, - IWithEditor, - IWithTimestamps { - id: string; - restrictedBefore: string; -} - -/** - * settings parameters for creating a channel - * - * @export - * @interface ICreateChannelSettings - */ -export interface ICreateChannelSettings { - allowedReactions?: PostReaction[]; - allowReaction?: boolean; - allowReply?: boolean; - blockWords?: string[]; - defaultPostStatus?: PostStatus; - metadata?: IChannelMetadata; - name?: string; - softDelete?: boolean; -} - -export interface IChannelMetadata { - guidelineUrl?: string | null; -} - -/** - * permissions parameters for creating a channel - * - * @export - * @interface ICreateChannelPermissions - */ -export interface ICreateChannelPermissions { - access?: SharingAccess; - allowAnonymous?: boolean; - groups?: string[]; - orgs?: string[]; - /** - * Not available until the V2 Api is released - * @hidden - */ - channelAclDefinition?: IChannelAclPermissionDefinition[]; -} - -/** - * permissions parameters for updating a channel - * - * @export - * @interface IUpdateChannelPermissions - */ -export interface IUpdateChannelPermissions { - access?: SharingAccess; - allowAnonymous?: boolean; -} - -/** - * permissions and settings options for creating a channel - * - * @export - * @interface ICreateChannel - * @extends {ICreateChannelSettings} - * @extends {ICreateChannelPermissions} - */ -export interface ICreateChannel - extends ICreateChannelSettings, - ICreateChannelPermissions {} - -/** - * representation of channel from service - * - * @export - * @interface IChannel - * @extends {IWithAuthor} - * @extends {IWithEditor} - * @extends {IWithTimestamps} - */ -export interface IChannel extends IWithAuthor, IWithEditor, IWithTimestamps { - access: SharingAccess; - allowAnonymous: boolean; - allowedReactions: PostReaction[] | null; - allowReaction: boolean; - allowReply: boolean; - blockWords: string[] | null; - channelAcl?: IChannelAclPermission[]; - defaultPostStatus: PostStatus; - groups: string[]; - metadata: IChannelMetadata | null; - id: string; - name: string | null; - orgs: string[]; - posts?: IPost[]; - softDelete: boolean; -} - -/** - * parameters/options for updating channel settings - * - * @export - * @interface IUpdateChannel - * @extends {ICreateChannelSettings} - * @extends { IUpdateChannelPermissions} - * @extends {Partial} - */ -export interface IUpdateChannel - extends ICreateChannelSettings, - IUpdateChannelPermissions, - Partial {} - -/** - * dto for decorating found channel with relations - * - * @export - * @interface IFetchChannel - */ -export interface IFetchChannel { - relations?: ChannelRelation[]; -} - -/** - * dto for querying channels - * - * @export - * @interface ISearchChannels - * @extends {Partial} - * @extends {Partial>} - * @extends {Partial} - * @extends {Partial>} - */ -export interface ISearchChannels - extends Partial, - Partial>, - Partial, - Partial> { - groups?: string[]; - access?: SharingAccess[]; - relations?: ChannelRelation[]; -} - -/** - * request params for creating a channel - * - * @export - * @interface ICreateChannelParams - * @extends {IDiscussionsRequestOptions} - */ -export interface ICreateChannelParams extends IDiscussionsRequestOptions { - data: ICreateChannel; -} - -/** - * request params for getting a channel - * - * @export - * @interface IFetchChannelParams - * @extends {IDiscussionsRequestOptions} - */ -export interface IFetchChannelParams extends IDiscussionsRequestOptions { - channelId: string; - data?: IFetchChannel; -} - -/** - * request params for searching channels - * - * @export - * @interface ISearchChannelsParams - * @extends {IDiscussionsRequestOptions} - */ -export interface ISearchChannelsParams extends IDiscussionsRequestOptions { - data?: ISearchChannels; -} -/** - * request params for updating a channel's settings - * - * @export - * @interface IUpdateChannelParams - * @extends {IDiscussionsRequestOptions} - */ -export interface IUpdateChannelParams extends IDiscussionsRequestOptions { - channelId: string; - data: IUpdateChannel; -} - -/** - * request params for deleting a channel - * - * @export - * @interface IRemoveChannelParams - * @extends {IDiscussionsRequestOptions} - */ -export interface IRemoveChannelParams extends IDiscussionsRequestOptions { - channelId: string; -} - -/** - * delete channel response properties - * - * @export - * @interface IRemoveChannelResponse - */ -export interface IRemoveChannelResponse { - success: boolean; - channelId: string; -} - -/** - * request params for fetching opt out status - * - * @export - * @interface IFetchChannelNotificationOptOutParams - * @extends {IDiscussionsRequestOptions} - */ -export interface IFetchChannelNotificationOptOutParams - extends IDiscussionsRequestOptions { - channelId: string; -} - -/** - * request params for opting out - * - * @export - * @interface ICreateChannelNotificationOptOutParams - * @extends {IDiscussionsRequestOptions} - */ -export interface ICreateChannelNotificationOptOutParams - extends IDiscussionsRequestOptions { - channelId: string; -} - -/** - * request params for opting back in - * - * @export - * @interface IRemoveChannelNotificationOptOutParams - * @extends {IDiscussionsRequestOptions} - */ -export interface IRemoveChannelNotificationOptOutParams - extends IDiscussionsRequestOptions { - channelId: string; -} - -/** - * request params for deleting channel activity - * - * @export - * @interface IRemoveChannelActivityParams - * @extends {IDiscussionsRequestOptions} - */ -export interface IRemoveChannelActivityParams - extends IDiscussionsRequestOptions { - channelId: string; -} - -/** - * representation of a discussion setting record from the service - * - * @export - * @interface IDiscussionSetting - * @extends {IWithAuthor} - * @extends {IWithEditor} - * @extends {IWithTimestamps} - */ -export interface IDiscussionSetting - extends IWithAuthor, - IWithEditor, - IWithTimestamps { - id: string; - type: DiscussionSettingType; - settings: ISettings; -} - -export enum DiscussionSettingType { - CONTENT = "content", -} - -export interface ISettings { - allowedChannelIds: string[] | null; -} - -/** - * parameters for creating a discussionSetting - */ -export interface ICreateDiscussionSetting { - id: string; - type: DiscussionSettingType; - settings: ISettings; -} - -export interface ICreateDiscussionSettingParams - extends IDiscussionsRequestOptions { - data: ICreateDiscussionSetting; -} +export { + SortOrder, + PostReaction, + SharingAccess, + IPlatformSharing, + PostStatus, + DiscussionType, + DiscussionSource, + IDiscussionParams, + PostRelation, + ReactionRelation, + ChannelFilter, + CommonSort, + IWithAuthor, + IWithEditor, + IWithSorting, + IWithFiltering, + IWithTimeQueries, + IWithTimestamps, + IPagedResponse, + IRemoveChannelNotificationOptOutResult, + IRemoveChannelActivityResult, + IChannelNotificationOptOut, + IDiscussionsRequestOptions, + Role, + IDiscussionsMentionMeta, + IDiscussionsUser, + IReaction, + ICreateReaction, + ICreateReactionOptions, + IRemoveReactionOptions, + IRemoveReactionResponse, + PostSort, + PostType, + IPost, + IPostOptions, + ICreatePost, + ICreateChannelPost, + ICreatePostParams, + ICreateReplyParams, + IFetchPost, + ISearchPosts, + IUpdatePostStatus, + IUpdatePost, + ISearchPostsParams, + IFetchPostParams, + IUpdatePostParams, + IUpdatePostStatusParams, + IRemovePostParams, + IRemovePostResponse, + ChannelSort, + ChannelRelation, + AclCategory, + AclSubCategory, + IChannelAclPermissionDefinition, + IChannelAclPermissionUpdateDefinition, + IChannelAclPermission, + ICreateChannelSettings, + IChannelMetadata, + ICreateChannelPermissions, + IUpdateChannelPermissions, + ICreateChannel, + IChannel, + IUpdateChannel, + IFetchChannel, + ISearchChannels, + ICreateChannelParams, + IFetchChannelParams, + ISearchChannelsParams, + IUpdateChannelParams, + IRemoveChannelParams, + IRemoveChannelResponse, + IFetchChannelNotificationOptOutParams, + ICreateChannelNotificationOptOutParams, + IRemoveChannelNotificationOptOutParams, + IRemoveChannelActivityParams, + IDiscussionSetting, + DiscussionSettingType, + ISettings, + ICreateDiscussionSetting, + ICreateDiscussionSettingParams, +} from "@esri/hub-common"; diff --git a/packages/discussions/test/channels.test.ts b/packages/discussions/test/channels.test.ts index b1d03570280..d5f192035ab 100644 --- a/packages/discussions/test/channels.test.ts +++ b/packages/discussions/test/channels.test.ts @@ -35,22 +35,8 @@ describe("channels", () => { ); }); - it("queries channels", (done) => { - const query = { - access: [SharingAccess.PUBLIC], - groups: ["foo"], - }; - - const options = { ...baseOpts, data: query }; - searchChannels(options) - .then(() => { - expect(requestSpy.calls.count()).toEqual(1); - const [url, opts] = requestSpy.calls.argsFor(0); - expect(url).toEqual(`/channels`); - expect(opts).toEqual({ ...options, httpMethod: "GET" }); - done(); - }) - .catch(() => fail()); + it("searchChannels exists", () => { + expect(typeof searchChannels).toBe("function"); }); it("creates channel", (done) => { From 97292e44ea5110699b24dd5ccda7b3933981bd7b Mon Sep 17 00:00:00 2001 From: Joshua Tanner Date: Thu, 13 Jul 2023 09:11:22 -0700 Subject: [PATCH 02/13] refactor(): move discussionSearchChannels helpers --- .../src/discussions/api/channels/channels.ts | 3 +- .../_internal/discussionsSearchChannels.ts | 107 ++++++++++++++++-- .../discussionsSearchChannelsHelpers/index.ts | 86 -------------- .../discussionsSearchChannels.test.ts | 12 +- 4 files changed, 109 insertions(+), 99 deletions(-) delete mode 100644 packages/common/src/search/_internal/discussionsSearchChannelsHelpers/index.ts diff --git a/packages/common/src/discussions/api/channels/channels.ts b/packages/common/src/discussions/api/channels/channels.ts index c01d0e5bded..8d16eb711f9 100644 --- a/packages/common/src/discussions/api/channels/channels.ts +++ b/packages/common/src/discussions/api/channels/channels.ts @@ -2,7 +2,8 @@ import { request } from "../request"; import { IChannel, IPagedResponse, ISearchChannelsParams } from "../types"; /** - * search channels + * Search for Channels in the Discussions API. Channels define the capabilities, + * permissions, and configuration for Discussion posts. * * @export * @param {ISearchChannelsParams} options diff --git a/packages/common/src/search/_internal/discussionsSearchChannels.ts b/packages/common/src/search/_internal/discussionsSearchChannels.ts index 27fd8aaabd5..14d9d98c01d 100644 --- a/packages/common/src/search/_internal/discussionsSearchChannels.ts +++ b/packages/common/src/search/_internal/discussionsSearchChannels.ts @@ -1,10 +1,103 @@ import { IHubSearchOptions, IHubSearchResponse, IQuery } from "../types"; +import HubError from "../../HubError"; import { searchChannels } from "../../discussions/api/channels"; -import { IChannel } from "../../discussions"; import { - processSearchParams, - toHubSearchResult, -} from "./discussionsSearchChannelsHelpers"; + IChannel, + IPagedResponse, + ISearchChannels, + ISearchChannelsParams, +} from "../../discussions"; + +/** + * Convert hubSearch IHubSearchOptions and IQuery interfaces to a + * ISearchChannelsParams structure that is needed for the Discussions API + * searchChannels(searchOptions: ISearchCHannelsParams) function + * @param {IHubSearchOptions} options + * @param {IQuery} query + * @returns ISearchChannelParams + */ +export const processSearchParams = ( + options: IHubSearchOptions, + query: IQuery +): ISearchChannelsParams => { + if (!options.requestOptions) { + throw new HubError( + "discussionsSearchChannels", + "options.requestOptions is required" + ); + } + // Array of properties we want to copy over from IHubSeachOptions to ISearchChannels + const paginationProps: Partial> = {}; + const allowedPaginationProps: Array = [ + "num", + "start", + "sortField", + "sortOrder", + ]; + // Map IHubSearchOptions field to ISearchChannels field + const standardizeKeyName = ( + key: keyof IHubSearchOptions + ): keyof ISearchChannels => { + if (key === "sortField") { + return "sortBy"; + } + return key as keyof ISearchChannels; + }; + allowedPaginationProps.forEach((prop) => { + if (options.hasOwnProperty(prop)) { + const key = standardizeKeyName(prop); + paginationProps[key] = options[prop]; + } + }); + // Acceptable fields to use as filters + const filterProps: Record = {}; + const allowedFilterProps: Array = ["access", "groups"]; + // Find predicates that match acceptable filter fields + query.filters.forEach((filter) => { + filter.predicates.forEach((predicate) => { + Object.keys(predicate).forEach((key: any) => { + if (allowedFilterProps.includes(key)) { + filterProps[key] = predicate[key]; + } + }); + }); + }); + // Return as ISearchChannelsParams + return { + ...options.requestOptions, + data: { + ...paginationProps, + ...filterProps, + }, + }; +}; + +/** + * Convert the Discussions API searchChannels response into an + * IHubSearchResponse necessary for supporting hubSearch results + * @param {IPagedResponse{IChannel}} channelsResponse + * @param {IQuery} query + * @param {IHubSearchOptions} options + * @returns IHubSearchResponse + */ +export const toHubSearchResult = ( + channelsResponse: IPagedResponse, + query: IQuery, + options: IHubSearchOptions +): IHubSearchResponse => { + const { total, items, nextStart } = channelsResponse; + return { + total, + results: items, + hasNext: nextStart > -1, + next: () => { + return discussionsSearchChannels(query, { + ...options, + start: nextStart, + }); + }, + }; +}; /** * @private @@ -13,14 +106,14 @@ import { * @param options * @returns */ -export async function discussionsSearchChannels( +export const discussionsSearchChannels = async ( query: IQuery, options: IHubSearchOptions -): Promise> { +): Promise> => { // Pull useful info out of query const searchOptions = processSearchParams(options, query); // Call to searchChannels const channelsResponse = await searchChannels(searchOptions); // Parse into > return toHubSearchResult(channelsResponse, query, options); -} +}; diff --git a/packages/common/src/search/_internal/discussionsSearchChannelsHelpers/index.ts b/packages/common/src/search/_internal/discussionsSearchChannelsHelpers/index.ts deleted file mode 100644 index 5f1e85140f4..00000000000 --- a/packages/common/src/search/_internal/discussionsSearchChannelsHelpers/index.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { IHubSearchOptions, IHubSearchResponse, IQuery } from "../../types"; -import HubError from "../../../HubError"; -import { - IChannel, - IPagedResponse, - ISearchChannels, - ISearchChannelsParams, -} from "../../../discussions"; -import { discussionsSearchChannels } from ".."; - -// Given IHubSearchOptions and IQuery, restructure into ISearchChannelsParams -export function processSearchParams( - options: IHubSearchOptions, - query: IQuery -): ISearchChannelsParams { - if (!options.requestOptions) { - throw new HubError( - "discussionsSearchChannels", - "options.requestOptions is required" - ); - } - // Array of properties we want to copy over from IHubSeachOptions to ISearchChannels - const paginationProps: Partial> = {}; - const allowedPaginationProps: Array = [ - "num", - "start", - "sortField", - "sortOrder", - ]; - // Map IHubSearchOptions field to ISearchChannels field - const standardizeKeyName = ( - key: keyof IHubSearchOptions - ): keyof ISearchChannels => { - if (key === "sortField") { - return "sortBy"; - } - return key as keyof ISearchChannels; - }; - allowedPaginationProps.forEach((prop) => { - if (options.hasOwnProperty(prop)) { - const key = standardizeKeyName(prop); - paginationProps[key] = options[prop]; - } - }); - // Acceptable fields to use as filters - const filterProps: Record = {}; - const allowedFilterProps: Array = ["access", "groups"]; - // Find predicates that match acceptable filter fields - query.filters.forEach((filter) => { - filter.predicates.forEach((predicate) => { - Object.keys(predicate).forEach((key: any) => { - if (allowedFilterProps.includes(key)) { - filterProps[key] = predicate[key]; - } - }); - }); - }); - // Return as ISearchChannelsParams - return { - ...options.requestOptions, - data: { - ...paginationProps, - ...filterProps, - }, - }; -} - -// Given an IPagedResponse, return an IHubSearchResponse -export function toHubSearchResult( - channelsResponse: IPagedResponse, - query: IQuery, - options: IHubSearchOptions -): IHubSearchResponse { - const { total, items, nextStart } = channelsResponse; - return { - total, - results: items, - hasNext: nextStart > -1, - next: () => { - return discussionsSearchChannels(query, { - ...options, - start: nextStart, - }); - }, - }; -} diff --git a/packages/common/test/search/_internal/discussionsSearchChannels.test.ts b/packages/common/test/search/_internal/discussionsSearchChannels.test.ts index bd2d599a6f5..4236d8924f3 100644 --- a/packages/common/test/search/_internal/discussionsSearchChannels.test.ts +++ b/packages/common/test/search/_internal/discussionsSearchChannels.test.ts @@ -1,5 +1,4 @@ -import { discussionsSearchChannels } from "../../../src/search/_internal"; -import * as helpers from "../../../src/search/_internal/discussionsSearchChannelsHelpers"; +import * as discussionsSearchChannels from "../../../src/search/_internal/discussionsSearchChannels"; import * as API from "../../../src/discussions/api/channels"; import { IQuery, IHubSearchOptions } from "../../../src"; import SEARCH_CHANNELS_RESPONSE from "./mocks/searchChannelsResponse"; @@ -11,11 +10,11 @@ describe("discussionsSearchItems Module |", () => { beforeEach(() => { processSearchParamsSpy = spyOn( - helpers, + discussionsSearchChannels, "processSearchParams" ).and.callThrough(); toHubSearchResultSpy = spyOn( - helpers, + discussionsSearchChannels, "toHubSearchResult" ).and.callThrough(); searchChannelsSpy = spyOn(API, "searchChannels").and.callFake(() => { @@ -50,7 +49,10 @@ describe("discussionsSearchItems Module |", () => { token: "my-secret-token", } as any, }; - const result = await discussionsSearchChannels(qry, opts); + const result = await discussionsSearchChannels.discussionsSearchChannels( + qry, + opts + ); expect(processSearchParamsSpy).toHaveBeenCalledTimes(1); expect(toHubSearchResultSpy).toHaveBeenCalledTimes(1); expect(searchChannelsSpy).toHaveBeenCalledTimes(1); From f7bd480d4f0ff84b95b4edc72e7a7570a6c1ee2c Mon Sep 17 00:00:00 2001 From: Joshua Tanner Date: Thu, 13 Jul 2023 09:32:56 -0700 Subject: [PATCH 03/13] refactor(): handle sortOrder uppercase internal --- .../src/discussions/api/utils/request.ts | 2 +- .../_internal/discussionsSearchChannels.ts | 20 +++++++++++++------ .../src/search/types/IHubSearchOptions.ts | 2 +- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/common/src/discussions/api/utils/request.ts b/packages/common/src/discussions/api/utils/request.ts index bbfd791cf71..b70e406559a 100644 --- a/packages/common/src/discussions/api/utils/request.ts +++ b/packages/common/src/discussions/api/utils/request.ts @@ -38,7 +38,7 @@ export function authenticateRequest( } /** - * parses IHubRequestOptions and makes request against Discussions API + * parses IDiscussionsRequestOptions and makes request against Discussions API * * @export * @template T diff --git a/packages/common/src/search/_internal/discussionsSearchChannels.ts b/packages/common/src/search/_internal/discussionsSearchChannels.ts index 14d9d98c01d..a04f2c6e061 100644 --- a/packages/common/src/search/_internal/discussionsSearchChannels.ts +++ b/packages/common/src/search/_internal/discussionsSearchChannels.ts @@ -34,19 +34,27 @@ export const processSearchParams = ( "sortField", "sortOrder", ]; - // Map IHubSearchOptions field to ISearchChannels field - const standardizeKeyName = ( - key: keyof IHubSearchOptions - ): keyof ISearchChannels => { + // Map ISearchOptions key to ISearchChannels key + const mapKey = (key: keyof IHubSearchOptions): keyof ISearchChannels => { if (key === "sortField") { return "sortBy"; } return key as keyof ISearchChannels; }; + // Map any values that originated from ISearchOptions + // into a correct ISearchChannels value + const mapValue = (key: keyof ISearchChannels, value: any): string => { + let _value = value; + if (key === "sortOrder") { + _value = value.toUpperCase(); + } + return _value; + }; allowedPaginationProps.forEach((prop) => { if (options.hasOwnProperty(prop)) { - const key = standardizeKeyName(prop); - paginationProps[key] = options[prop]; + const key = mapKey(prop); + const value = mapValue(key, options[prop]); + paginationProps[key] = value; } }); // Acceptable fields to use as filters diff --git a/packages/common/src/search/types/IHubSearchOptions.ts b/packages/common/src/search/types/IHubSearchOptions.ts index bf1d41f835f..92f3cf568f7 100644 --- a/packages/common/src/search/types/IHubSearchOptions.ts +++ b/packages/common/src/search/types/IHubSearchOptions.ts @@ -73,7 +73,7 @@ export interface IHubSearchOptions { /** * Sort direction */ - sortOrder?: "desc" | "asc" | "DESC" | "ASC"; + sortOrder?: "desc" | "asc"; /** * The result number of the first entry in the result set response. The start parameter, along with the num parameter, can be used to paginate the search results. */ From 92da19ce6920138c5262336367081d311d3fe5ba Mon Sep 17 00:00:00 2001 From: Joshua Tanner Date: Thu, 13 Jul 2023 10:25:31 -0700 Subject: [PATCH 04/13] refactor(): pr feedback, move into arcgis-hub --- .../search/_internal/commonHelpers/getApi.ts | 4 +++ .../getDiscussionsApiDefinition.ts | 8 +++++ .../commonHelpers/shouldUseDiscussionsApi.ts | 20 ++++++++++++ ...SearchChannels.ts => hubSearchChannels.ts} | 8 +++-- packages/common/src/search/_internal/index.ts | 2 +- packages/common/src/search/hubSearch.ts | 6 ++-- .../getDiscussionsApiDefinition.test.ts | 12 +++++++ ...nels.test.ts => hubSearchChannels.test.ts} | 22 +++++-------- .../_internal/shouldUseDiscussionsApi.test.ts | 32 +++++++++++++++++++ packages/common/test/search/hubSearch.test.ts | 18 +++++------ 10 files changed, 101 insertions(+), 31 deletions(-) create mode 100644 packages/common/src/search/_internal/commonHelpers/getDiscussionsApiDefinition.ts create mode 100644 packages/common/src/search/_internal/commonHelpers/shouldUseDiscussionsApi.ts rename packages/common/src/search/_internal/{discussionsSearchChannels.ts => hubSearchChannels.ts} (96%) create mode 100644 packages/common/test/search/_internal/getDiscussionsApiDefinition.test.ts rename packages/common/test/search/_internal/{discussionsSearchChannels.test.ts => hubSearchChannels.test.ts} (74%) create mode 100644 packages/common/test/search/_internal/shouldUseDiscussionsApi.test.ts diff --git a/packages/common/src/search/_internal/commonHelpers/getApi.ts b/packages/common/src/search/_internal/commonHelpers/getApi.ts index c09919b5c6f..fa81d64c37a 100644 --- a/packages/common/src/search/_internal/commonHelpers/getApi.ts +++ b/packages/common/src/search/_internal/commonHelpers/getApi.ts @@ -4,6 +4,8 @@ import { IApiDefinition } from "../../types/types"; import { expandApi } from "../../utils"; import { shouldUseOgcApi } from "./shouldUseOgcApi"; import { getOgcApiDefinition } from "./getOgcApiDefinition"; +import { shouldUseDiscussionsApi } from "./shouldUseDiscussionsApi"; +import { getDiscussionsApiDefinition } from "./getDiscussionsApiDefinition"; /** * @private @@ -28,6 +30,8 @@ export function getApi( let result: IApiDefinition; if (api) { result = expandApi(api); + } else if (shouldUseDiscussionsApi(targetEntity, options)) { + result = getDiscussionsApiDefinition(); } else if (shouldUseOgcApi(targetEntity, options)) { result = getOgcApiDefinition(options); } else { diff --git a/packages/common/src/search/_internal/commonHelpers/getDiscussionsApiDefinition.ts b/packages/common/src/search/_internal/commonHelpers/getDiscussionsApiDefinition.ts new file mode 100644 index 00000000000..fcc2e30e5d4 --- /dev/null +++ b/packages/common/src/search/_internal/commonHelpers/getDiscussionsApiDefinition.ts @@ -0,0 +1,8 @@ +import { IApiDefinition } from "../../types/types"; + +export function getDiscussionsApiDefinition(): IApiDefinition { + return { + type: "arcgis-hub", + url: null, + }; +} diff --git a/packages/common/src/search/_internal/commonHelpers/shouldUseDiscussionsApi.ts b/packages/common/src/search/_internal/commonHelpers/shouldUseDiscussionsApi.ts new file mode 100644 index 00000000000..9a9971163ec --- /dev/null +++ b/packages/common/src/search/_internal/commonHelpers/shouldUseDiscussionsApi.ts @@ -0,0 +1,20 @@ +import { EntityType } from "../../types/IHubCatalog"; +import { IHubSearchOptions } from "../../types/IHubSearchOptions"; + +/** + * @private + * Determines if the Discussions API can be targeted with the given + * search parameters + * @param targetEntity + * @param options + * @returns boolean + */ +export function shouldUseDiscussionsApi( + targetEntity: EntityType, + options: IHubSearchOptions +): boolean { + const { + requestOptions: { isPortal }, + } = options; + return targetEntity === "channel" && !isPortal; +} diff --git a/packages/common/src/search/_internal/discussionsSearchChannels.ts b/packages/common/src/search/_internal/hubSearchChannels.ts similarity index 96% rename from packages/common/src/search/_internal/discussionsSearchChannels.ts rename to packages/common/src/search/_internal/hubSearchChannels.ts index a04f2c6e061..ebc08b6ddd8 100644 --- a/packages/common/src/search/_internal/discussionsSearchChannels.ts +++ b/packages/common/src/search/_internal/hubSearchChannels.ts @@ -9,6 +9,7 @@ import { } from "../../discussions"; /** + * @private * Convert hubSearch IHubSearchOptions and IQuery interfaces to a * ISearchChannelsParams structure that is needed for the Discussions API * searchChannels(searchOptions: ISearchCHannelsParams) function @@ -22,7 +23,7 @@ export const processSearchParams = ( ): ISearchChannelsParams => { if (!options.requestOptions) { throw new HubError( - "discussionsSearchChannels", + "hubSearchChannels", "options.requestOptions is required" ); } @@ -81,6 +82,7 @@ export const processSearchParams = ( }; /** + * @private * Convert the Discussions API searchChannels response into an * IHubSearchResponse necessary for supporting hubSearch results * @param {IPagedResponse{IChannel}} channelsResponse @@ -99,7 +101,7 @@ export const toHubSearchResult = ( results: items, hasNext: nextStart > -1, next: () => { - return discussionsSearchChannels(query, { + return hubSearchChannels(query, { ...options, start: nextStart, }); @@ -114,7 +116,7 @@ export const toHubSearchResult = ( * @param options * @returns */ -export const discussionsSearchChannels = async ( +export const hubSearchChannels = async ( query: IQuery, options: IHubSearchOptions ): Promise> => { diff --git a/packages/common/src/search/_internal/index.ts b/packages/common/src/search/_internal/index.ts index 6ec0b4cdbcb..98011b56379 100644 --- a/packages/common/src/search/_internal/index.ts +++ b/packages/common/src/search/_internal/index.ts @@ -2,4 +2,4 @@ export * from "./portalSearchItems"; export * from "./hubSearchItems"; export * from "./portalSearchGroups"; export * from "./portalSearchUsers"; -export * from "./discussionsSearchChannels"; +export * from "./hubSearchChannels"; diff --git a/packages/common/src/search/hubSearch.ts b/packages/common/src/search/hubSearch.ts index aae17f09810..8cf6d8fc9c0 100644 --- a/packages/common/src/search/hubSearch.ts +++ b/packages/common/src/search/hubSearch.ts @@ -13,7 +13,7 @@ import { portalSearchItems, portalSearchGroups, portalSearchUsers, - discussionsSearchChannels, + hubSearchChannels, } from "./_internal"; import { portalSearchGroupMembers } from "./_internal/portalSearchGroupMembers"; @@ -80,9 +80,7 @@ export async function hubSearch( }, "arcgis-hub": { item: hubSearchItems, - }, - discussions: { - channel: discussionsSearchChannels, + channel: hubSearchChannels, }, }; diff --git a/packages/common/test/search/_internal/getDiscussionsApiDefinition.test.ts b/packages/common/test/search/_internal/getDiscussionsApiDefinition.test.ts new file mode 100644 index 00000000000..be0d6b71162 --- /dev/null +++ b/packages/common/test/search/_internal/getDiscussionsApiDefinition.test.ts @@ -0,0 +1,12 @@ +import { IApiDefinition } from "../../../src"; +import { getDiscussionsApiDefinition } from "../../../src/search/_internal/commonHelpers/getDiscussionsApiDefinition"; + +describe("getDiscussionsApiDefinition", () => { + it("should return expected api definition", () => { + const result = { + type: "arcgis-hub", + url: null, + } as unknown as IApiDefinition; + expect(getDiscussionsApiDefinition()).toEqual(result); + }); +}); diff --git a/packages/common/test/search/_internal/discussionsSearchChannels.test.ts b/packages/common/test/search/_internal/hubSearchChannels.test.ts similarity index 74% rename from packages/common/test/search/_internal/discussionsSearchChannels.test.ts rename to packages/common/test/search/_internal/hubSearchChannels.test.ts index 4236d8924f3..bfb18fe73a4 100644 --- a/packages/common/test/search/_internal/discussionsSearchChannels.test.ts +++ b/packages/common/test/search/_internal/hubSearchChannels.test.ts @@ -1,6 +1,6 @@ -import * as discussionsSearchChannels from "../../../src/search/_internal/discussionsSearchChannels"; +import * as hubSearchChannels from "../../../src/search/_internal/hubSearchChannels"; import * as API from "../../../src/discussions/api/channels"; -import { IQuery, IHubSearchOptions } from "../../../src"; +import { IQuery, IHubSearchOptions, IHubRequestOptions } from "../../../src"; import SEARCH_CHANNELS_RESPONSE from "./mocks/searchChannelsResponse"; describe("discussionsSearchItems Module |", () => { @@ -10,11 +10,11 @@ describe("discussionsSearchItems Module |", () => { beforeEach(() => { processSearchParamsSpy = spyOn( - discussionsSearchChannels, + hubSearchChannels, "processSearchParams" ).and.callThrough(); toHubSearchResultSpy = spyOn( - discussionsSearchChannels, + hubSearchChannels, "toHubSearchResult" ).and.callThrough(); searchChannelsSpy = spyOn(API, "searchChannels").and.callFake(() => { @@ -39,20 +39,14 @@ describe("discussionsSearchItems Module |", () => { const opts: IHubSearchOptions = { num: 10, sortField: "createdAt", - sortOrder: "DESC", - api: { - type: "discussions", - url: "/api/discussions/v1/channels", - }, + sortOrder: "desc", requestOptions: { + isPortal: false, hubApiUrl: "https://hubqa.arcgis.com/api", token: "my-secret-token", - } as any, + } as IHubRequestOptions, }; - const result = await discussionsSearchChannels.discussionsSearchChannels( - qry, - opts - ); + const result = await hubSearchChannels.hubSearchChannels(qry, opts); expect(processSearchParamsSpy).toHaveBeenCalledTimes(1); expect(toHubSearchResultSpy).toHaveBeenCalledTimes(1); expect(searchChannelsSpy).toHaveBeenCalledTimes(1); diff --git a/packages/common/test/search/_internal/shouldUseDiscussionsApi.test.ts b/packages/common/test/search/_internal/shouldUseDiscussionsApi.test.ts new file mode 100644 index 00000000000..2cef576cf65 --- /dev/null +++ b/packages/common/test/search/_internal/shouldUseDiscussionsApi.test.ts @@ -0,0 +1,32 @@ +import { IHubSearchOptions } from "../../../src/search/types/IHubSearchOptions"; +import { shouldUseDiscussionsApi } from "../../../src/search/_internal/commonHelpers/shouldUseDiscussionsApi"; + +describe("shouldUseDiscussionsApi", () => { + it("returns false when targetEntity isn't a channel", () => { + const targetEntity = "item"; + const options = { + requestOptions: { + isPortal: false, + }, + } as unknown as IHubSearchOptions; + expect(shouldUseDiscussionsApi(targetEntity, options)).toBeFalsy(); + }); + it("returns false when in an enterprise environment", () => { + const targetEntity = "channel"; + const options = { + requestOptions: { + isPortal: true, + }, + } as unknown as IHubSearchOptions; + expect(shouldUseDiscussionsApi(targetEntity, options)).toBeFalsy(); + }); + it("returns true otherwise", () => { + const targetEntity = "channel"; + const options = { + requestOptions: { + isPortal: false, + }, + } as unknown as IHubSearchOptions; + expect(shouldUseDiscussionsApi(targetEntity, options)).toBeTruthy(); + }); +}); diff --git a/packages/common/test/search/hubSearch.test.ts b/packages/common/test/search/hubSearch.test.ts index ab4b55a9188..c84d36b350c 100644 --- a/packages/common/test/search/hubSearch.test.ts +++ b/packages/common/test/search/hubSearch.test.ts @@ -90,7 +90,7 @@ describe("hubSearch Module:", () => { let portalSearchItemsSpy: jasmine.Spy; let portalSearchGroupsSpy: jasmine.Spy; let hubSearchItemsSpy: jasmine.Spy; - let discussionsSearchChannelsSpy: jasmine.Spy; + let hubSearchChannelsSpy: jasmine.Spy; beforeEach(() => { // we are only interested in verifying that the fn was called with specific args // so all the responses are fake @@ -124,9 +124,9 @@ describe("hubSearch Module:", () => { total: 99, }); }); - discussionsSearchChannelsSpy = spyOn( + hubSearchChannelsSpy = spyOn( SearchFunctionModule, - "discussionsSearchChannels" + "hubSearchChannels" ).and.callFake(() => { return Promise.resolve({ hasNext: false, @@ -257,7 +257,7 @@ describe("hubSearch Module:", () => { // Any cloning of auth can break downstream functions expect(options.requestOptions).toBe(opts.requestOptions); }); - it("channels + discussions: discussionsSearchChannels", async () => { + it("channels + discussions: hubSearchChannels", async () => { const qry: IQuery = { targetEntity: "channel", filters: [ @@ -271,14 +271,14 @@ describe("hubSearch Module:", () => { hubApiUrl: "https://hubqa.arcgis.com/api", }, api: { - type: "discussions", - url: "/api/discussions/v1/channels", - }, + type: "arcgis-hub", + url: null, + } as any, }; const chk = await hubSearch(qry, opts); expect(chk.total).toBe(99); - expect(discussionsSearchChannelsSpy.calls.count()).toBe(1); - const [query, options] = discussionsSearchChannelsSpy.calls.argsFor(0); + expect(hubSearchChannelsSpy.calls.count()).toBe(1); + const [query, options] = hubSearchChannelsSpy.calls.argsFor(0); expect(query).toEqual(qry); expect(options).toEqual(opts); }); From 600cf1e7f40b34974b8866caa1ee1abb42a4550b Mon Sep 17 00:00:00 2001 From: Joshua Tanner Date: Thu, 13 Jul 2023 10:29:04 -0700 Subject: [PATCH 05/13] refactor(): remove discussion API type --- packages/common/src/search/types/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/common/src/search/types/types.ts b/packages/common/src/search/types/types.ts index 1de7ff08951..6387599ee6d 100644 --- a/packages/common/src/search/types/types.ts +++ b/packages/common/src/search/types/types.ts @@ -82,7 +82,7 @@ export interface IApiDefinition { // - for "arcgis-hub", the /v3/search will be added url: string; // We can add types as we add support for more - type: "arcgis" | "arcgis-hub" | "discussions"; + type: "arcgis" | "arcgis-hub"; } /** * Base options when checking catalog containment From c383c35b324fb9e9d38fd9b0ca3b7cbfcd4e7d6e Mon Sep 17 00:00:00 2001 From: Joshua Tanner Date: Thu, 13 Jul 2023 10:52:29 -0700 Subject: [PATCH 06/13] docs(): add comment for null url in discussions api def --- .../_internal/commonHelpers/getDiscussionsApiDefinition.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/common/src/search/_internal/commonHelpers/getDiscussionsApiDefinition.ts b/packages/common/src/search/_internal/commonHelpers/getDiscussionsApiDefinition.ts index fcc2e30e5d4..4525e879279 100644 --- a/packages/common/src/search/_internal/commonHelpers/getDiscussionsApiDefinition.ts +++ b/packages/common/src/search/_internal/commonHelpers/getDiscussionsApiDefinition.ts @@ -1,6 +1,9 @@ import { IApiDefinition } from "../../types/types"; export function getDiscussionsApiDefinition(): IApiDefinition { + // Currently, url is null because this is handled internally by the + // discussions request method called by searchChannels, which relies on + // the URL defined in the request options.hubApiUrl return { type: "arcgis-hub", url: null, From 29bc1f67c362b81c69eef99160416ad76461f990 Mon Sep 17 00:00:00 2001 From: Joshua Tanner Date: Thu, 13 Jul 2023 11:28:58 -0700 Subject: [PATCH 07/13] fix(): return IHubSearchResult --- .../src/search/_internal/hubSearchChannels.ts | 31 ++++++++++++++++--- packages/common/src/types.ts | 3 +- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/common/src/search/_internal/hubSearchChannels.ts b/packages/common/src/search/_internal/hubSearchChannels.ts index ebc08b6ddd8..c92d4c5757c 100644 --- a/packages/common/src/search/_internal/hubSearchChannels.ts +++ b/packages/common/src/search/_internal/hubSearchChannels.ts @@ -1,4 +1,9 @@ -import { IHubSearchOptions, IHubSearchResponse, IQuery } from "../types"; +import { + IHubSearchOptions, + IHubSearchResponse, + IHubSearchResult, + IQuery, +} from "../types"; import HubError from "../../HubError"; import { searchChannels } from "../../discussions/api/channels"; import { @@ -88,17 +93,33 @@ export const processSearchParams = ( * @param {IPagedResponse{IChannel}} channelsResponse * @param {IQuery} query * @param {IHubSearchOptions} options - * @returns IHubSearchResponse + * @returns IHubSearchResponse */ export const toHubSearchResult = ( channelsResponse: IPagedResponse, query: IQuery, options: IHubSearchOptions -): IHubSearchResponse => { +): IHubSearchResponse => { const { total, items, nextStart } = channelsResponse; + // Convert IChannel to IHubSearchResult + const channelToSearchResult = (channel: IChannel): IHubSearchResult => { + return { + id: channel.id, + name: channel.name, + createdDate: channel.createdAt, + createdDateSource: "channel", + updatedDate: channel.updatedAt, + updatedDateSource: "channel", + type: "channel", + access: channel.access, + family: "channel", + owner: channel.creator, + ...channel, + }; + }; return { total, - results: items, + results: items.map(channelToSearchResult), hasNext: nextStart > -1, next: () => { return hubSearchChannels(query, { @@ -119,7 +140,7 @@ export const toHubSearchResult = ( export const hubSearchChannels = async ( query: IQuery, options: IHubSearchOptions -): Promise> => { +): Promise> => { // Pull useful info out of query const searchOptions = processSearchParams(options, query); // Call to searchChannels diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index 3e8f163365f..9b8de9b9c8f 100644 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -175,7 +175,8 @@ export type HubFamily = | "site" | "team" | "template" - | "project"; + | "project" + | "channel"; /** * Visibility levels of a Hub resource From e9cac353a379e6b6601e84b0b93bad05b9f0e6d7 Mon Sep 17 00:00:00 2001 From: Joshua Tanner Date: Thu, 13 Jul 2023 11:31:57 -0700 Subject: [PATCH 08/13] fix(): spread --- packages/common/src/search/_internal/hubSearchChannels.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/common/src/search/_internal/hubSearchChannels.ts b/packages/common/src/search/_internal/hubSearchChannels.ts index c92d4c5757c..21d5d57b2f2 100644 --- a/packages/common/src/search/_internal/hubSearchChannels.ts +++ b/packages/common/src/search/_internal/hubSearchChannels.ts @@ -104,6 +104,7 @@ export const toHubSearchResult = ( // Convert IChannel to IHubSearchResult const channelToSearchResult = (channel: IChannel): IHubSearchResult => { return { + ...channel, id: channel.id, name: channel.name, createdDate: channel.createdAt, @@ -114,7 +115,6 @@ export const toHubSearchResult = ( access: channel.access, family: "channel", owner: channel.creator, - ...channel, }; }; return { From 45fe5932268548048858c136d0379375c2e252ff Mon Sep 17 00:00:00 2001 From: Joshua Tanner Date: Thu, 13 Jul 2023 13:07:47 -0700 Subject: [PATCH 09/13] feat(): add link placeholders --- packages/common/src/search/_internal/hubSearchChannels.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/common/src/search/_internal/hubSearchChannels.ts b/packages/common/src/search/_internal/hubSearchChannels.ts index 21d5d57b2f2..97834b0def2 100644 --- a/packages/common/src/search/_internal/hubSearchChannels.ts +++ b/packages/common/src/search/_internal/hubSearchChannels.ts @@ -115,6 +115,12 @@ export const toHubSearchResult = ( access: channel.access, family: "channel", owner: channel.creator, + links: { + // TODO: add links + thumbnail: null, + self: null, + siteRelative: null, + }, }; }; return { From adf0d24d35db0141e11e0a0fb8cd5d09ff7d601c Mon Sep 17 00:00:00 2001 From: Joshua Tanner Date: Mon, 17 Jul 2023 08:58:06 -0700 Subject: [PATCH 10/13] feat(): reexport changes --- packages/common/src/discussions/api/types.ts | 99 +++++++++++++++++--- 1 file changed, 88 insertions(+), 11 deletions(-) diff --git a/packages/common/src/discussions/api/types.ts b/packages/common/src/discussions/api/types.ts index 20461a85018..9c05b316269 100644 --- a/packages/common/src/discussions/api/types.ts +++ b/packages/common/src/discussions/api/types.ts @@ -167,10 +167,9 @@ export enum ChannelFilter { HAS_USER_POSTS = "has_user_posts", } -// sorting - /** - * Common sorting fields + * @export + * @enum {string} */ export enum CommonSort { CREATED_AT = "createdAt", @@ -296,7 +295,7 @@ export interface IChannelNotificationOptOut { * options for making requests against Discussion API * * @export - * @interface IRequestOptions + * @interface IDiscussionsRequestOptions * @extends {RequestInit} */ // NOTE: this is as close to implementing @esri/hub-common IHubRequestOptions as possible @@ -330,6 +329,9 @@ export enum Role { /** * Interface representing the meta data associated with a discussions * mention email + * + * @export + * @interface IDiscussionsMentionMeta */ export interface IDiscussionsMentionMeta { channelId: string; @@ -338,6 +340,11 @@ export interface IDiscussionsMentionMeta { replyId?: string; } +/** + * @export + * @interface IDiscussionsUser + * @extends {IUser} + */ export interface IDiscussionsUser extends IUser { username?: string | null; } @@ -405,6 +412,7 @@ export interface IRemoveReactionResponse { /** * Post sorting fields * + * @export * @enum {string} */ export enum PostSort { @@ -424,6 +432,7 @@ export enum PostSort { /** * Post types * + * @export * @enum{string} */ export enum PostType { @@ -596,7 +605,7 @@ export interface IUpdatePost { * * @export * @interface ISearchPostsParams - * @extends {IHubRequestOptions} + * @extends {IDiscussionsRequestOptions} */ export interface ISearchPostsParams extends IDiscussionsRequestOptions { data?: ISearchPosts; @@ -607,7 +616,7 @@ export interface ISearchPostsParams extends IDiscussionsRequestOptions { * * @export * @interface IFetchPostParams - * @extends {IHubRequestOptions} + * @extends {IDiscussionsRequestOptions} */ export interface IFetchPostParams extends IDiscussionsRequestOptions { postId: string; @@ -619,7 +628,7 @@ export interface IFetchPostParams extends IDiscussionsRequestOptions { * * @export * @interface IUpdatePostParams - * @extends {IHubRequestOptions} + * @extends {IDiscussionsRequestOptions} */ export interface IUpdatePostParams extends IDiscussionsRequestOptions { postId: string; @@ -632,7 +641,7 @@ export interface IUpdatePostParams extends IDiscussionsRequestOptions { * * @export * @interface IUpdatePostStatusParams - * @extends {IHubRequestOptions} + * @extends {IDiscussionsRequestOptions} */ export interface IUpdatePostStatusParams extends IDiscussionsRequestOptions { postId: string; @@ -644,7 +653,7 @@ export interface IUpdatePostStatusParams extends IDiscussionsRequestOptions { * * @export * @interface IRemovePostParams - * @extends {IHubRequestOptions} + * @extends {IDiscussionsRequestOptions} */ export interface IRemovePostParams extends IDiscussionsRequestOptions { postId: string; @@ -687,6 +696,10 @@ export enum ChannelRelation { CHANNEL_ACL = "channelAcl", } +/** + * @export + * @enum {string} + */ export enum AclCategory { GROUP = "group", ORG = "org", @@ -695,6 +708,10 @@ export enum AclCategory { AUTHENTICATED_USER = "authenticatedUser", } +/** + * @export + * @enum {string} + */ export enum AclSubCategory { ADMIN = "admin", MEMBER = "member", @@ -702,6 +719,9 @@ export enum AclSubCategory { /** * request option for creating a channel ACL permission + * + * @export + * @interface IChannelAclPermissionDefinition */ export interface IChannelAclPermissionDefinition { category: AclCategory; @@ -713,6 +733,10 @@ export interface IChannelAclPermissionDefinition { /** * request option for updating a channel ACL permission + * + * @export + * @interface IChannelAclPermissionUpdateDefinition + * @extends {IChannelAclPermissionDefinition} */ export interface IChannelAclPermissionUpdateDefinition extends IChannelAclPermissionDefinition { @@ -755,6 +779,10 @@ export interface ICreateChannelSettings { softDelete?: boolean; } +/** + * @export + * @interface IChannelMetadata + */ export interface IChannelMetadata { guidelineUrl?: string | null; } @@ -833,7 +861,7 @@ export interface IChannel extends IWithAuthor, IWithEditor, IWithTimestamps { * @export * @interface IUpdateChannel * @extends {ICreateChannelSettings} - * @extends { IUpdateChannelPermissions} + * @extends {IUpdateChannelPermissions} * @extends {Partial} */ export interface IUpdateChannel @@ -1004,16 +1032,34 @@ export interface IDiscussionSetting settings: ISettings; } +/** + * @export + * @enum {string} + */ export enum DiscussionSettingType { CONTENT = "content", } +/** + * @export + * @interface ISettings + */ export interface ISettings { allowedChannelIds: string[] | null; } /** - * parameters for creating a discussionSetting + * @export + * @interface IRemoveDiscussionSettingResponse + */ +export interface IRemoveDiscussionSettingResponse { + id: string; + success: boolean; +} + +/** + * @export + * @interface ICreateDiscussionSetting */ export interface ICreateDiscussionSetting { id: string; @@ -1021,7 +1067,38 @@ export interface ICreateDiscussionSetting { settings: ISettings; } +/** + * parameters for creating a discussionSetting + * + * @export + * @interface ICreateDiscussionSettingParams + * @extends {IDiscussionsRequestOptions} + */ export interface ICreateDiscussionSettingParams extends IDiscussionsRequestOptions { data: ICreateDiscussionSetting; } + +/** + * parameters for fetching a discussionSetting + * + * @export + * @interface IFetchDiscussionSettingParams + * @extends {IDiscussionsRequestOptions} + */ +export interface IFetchDiscussionSettingParams + extends IDiscussionsRequestOptions { + id: string; +} + +/** + * parameters for removing a discussionSetting + * + * @export + * @interface IRemoveDiscussionSettingParams + * @extends {IDiscussionsRequestOptions} + */ +export interface IRemoveDiscussionSettingParams + extends IDiscussionsRequestOptions { + id: string; +} From 0f38d30d3e517458d9ef0edf794a4f33dc3910f6 Mon Sep 17 00:00:00 2001 From: Joshua Tanner Date: Mon, 17 Jul 2023 09:32:18 -0700 Subject: [PATCH 11/13] test(): update mock type --- .../test/search/_internal/mocks/searchChannelsResponse.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/common/test/search/_internal/mocks/searchChannelsResponse.ts b/packages/common/test/search/_internal/mocks/searchChannelsResponse.ts index 94337a20169..8c13474a45d 100644 --- a/packages/common/test/search/_internal/mocks/searchChannelsResponse.ts +++ b/packages/common/test/search/_internal/mocks/searchChannelsResponse.ts @@ -18,7 +18,7 @@ const response = { orgs: ["Xj56SBi2udA78cC9"], softDelete: true, updatedAt: "2023-04-10T04:17:32.704Z", - }, + } as any, ], total: 1, nextStart: -1, From 06ccda4559bc50462b377dc391c9afdce57dadc7 Mon Sep 17 00:00:00 2001 From: Joshua Tanner Date: Mon, 17 Jul 2023 10:37:53 -0700 Subject: [PATCH 12/13] test(): coverage 100 percent --- .../src/search/_internal/hubSearchChannels.ts | 2 +- .../test/discussions/api/request.test.ts | 222 ++++++++++++++++++ .../test/search/_internal/getApi.test.ts | 14 +- .../_internal/hubSearchChannels.test.ts | 28 +++ 4 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 packages/common/test/discussions/api/request.test.ts diff --git a/packages/common/src/search/_internal/hubSearchChannels.ts b/packages/common/src/search/_internal/hubSearchChannels.ts index 97834b0def2..0526c91fc7d 100644 --- a/packages/common/src/search/_internal/hubSearchChannels.ts +++ b/packages/common/src/search/_internal/hubSearchChannels.ts @@ -116,7 +116,7 @@ export const toHubSearchResult = ( family: "channel", owner: channel.creator, links: { - // TODO: add links + // TODO: add links? thumbnail: null, self: null, siteRelative: null, diff --git a/packages/common/test/discussions/api/request.test.ts b/packages/common/test/discussions/api/request.test.ts new file mode 100644 index 00000000000..b3866abb042 --- /dev/null +++ b/packages/common/test/discussions/api/request.test.ts @@ -0,0 +1,222 @@ +import { request } from "../../../src/discussions/api/request"; +import * as utils from "../../../src/discussions/api/utils/request"; +import * as fetchMock from "fetch-mock"; +import { IAuthenticationManager } from "@esri/arcgis-rest-request"; +import { IDiscussionsRequestOptions } from "../../../src/discussions/api/types"; + +describe("request", () => { + const url = "foo"; + const options = { params: { foo: "bar" } }; + it("resolves token before making api request", (done) => { + const token = "thisisatoken"; + const authenticateRequestSpy = spyOn( + utils, + "authenticateRequest" + ).and.callFake(async () => token); + const apiRequestSpy = spyOn(utils, "apiRequest"); + + request(url, options as unknown as IDiscussionsRequestOptions) + .then(() => { + expect(authenticateRequestSpy).toHaveBeenCalledWith(options); + expect(apiRequestSpy).toHaveBeenCalledWith(url, options, token); + done(); + }) + .catch(() => fail()); + }); +}); + +describe("authenticateRequest", () => { + const portal = "https://foo.com"; + const token = "thisisatoken"; + const authentication: IAuthenticationManager = { + portal, + getToken() { + return Promise.resolve(token); + }, + }; + + let getTokenSpy: any; + beforeEach(() => { + getTokenSpy = spyOn(authentication, "getToken").and.callThrough(); + }); + + it("returns promise resolving token if token provided in request options", (done) => { + const options = { token }; + utils + .authenticateRequest(options as IDiscussionsRequestOptions) + .then(() => { + expect(getTokenSpy).not.toHaveBeenCalled(); + done(); + }) + .catch(() => fail()); + }); + + it("resolves token from authentication", (done) => { + const options = { authentication }; + utils + .authenticateRequest(options as IDiscussionsRequestOptions) + .then(() => { + expect(getTokenSpy).toHaveBeenCalledWith(portal); + done(); + }) + .catch(() => fail()); + }); +}); + +describe("apiRequest", () => { + const response = { ok: true }; + + const hubApiUrl = "https://hub.arcgis.com/api/discussions/v1"; + const url = "foo"; + + let expectedOpts: RequestInit; + let opts: IDiscussionsRequestOptions; + + beforeEach(() => { + fetchMock.mock("*", { status: 200, body: response }); + + const headers = new Headers(); + headers.append("Content-Type", "application/json"); + expectedOpts = { + headers, + method: "GET", + mode: undefined, + cache: undefined, + credentials: undefined, + } as RequestInit; + + opts = { hubApiUrl } as IDiscussionsRequestOptions; + }); + + afterEach(fetchMock.restore); + + it("handles failed requests", (done) => { + const status = 400; + const message = ["go do this", "go do that"]; + fetchMock.reset(); + fetchMock.mock("*", { status, body: { message } }); + + utils.apiRequest(url, opts).catch((e) => { + expect(e.message).toBe("Bad Request"); + expect(e.status).toBe(status); + expect(e.url).toBe(`${hubApiUrl}/${url}`); + expect(e.error).toBe(JSON.stringify(message)); + done(); + }); + }); + + it("appends headers to request options", async () => { + const result = await utils.apiRequest(url, opts); + + expect(result).toEqual(response); + + const [calledUrl, calledOpts] = fetchMock.calls()[0]; + expect(calledUrl).toEqual([hubApiUrl, url].join("/")); + expect(calledOpts).toEqual(expectedOpts); + }); + + it("appends additional headers to request options", async () => { + const expectedHeaders = new Headers(expectedOpts.headers); + expectedHeaders.append("mention-url", "https://some.hub.arcgis.com"); + expectedOpts = { + ...expectedOpts, + headers: expectedHeaders, + }; + const result = await utils.apiRequest(url, { + ...opts, + headers: { "mention-url": "https://some.hub.arcgis.com" }, + }); + + expect(result).toEqual(response); + + const [calledUrl, calledOpts] = fetchMock.calls()[0]; + expect(calledUrl).toEqual([hubApiUrl, url].join("/")); + expect(calledOpts).toEqual(expectedOpts); + }); + + it(`appends token header to request options if supplied`, async () => { + const token = "bar"; + + const result = await utils.apiRequest(url, opts, token); + + const headers = new Headers(); + headers.append("Content-Type", "application/json"); + headers.append("Authorization", `Bearer ${token}`); + expectedOpts.headers = headers; + + expect(result).toEqual(response); + + const [calledUrl, calledOpts] = fetchMock.calls()[0]; + expect(calledUrl).toEqual([hubApiUrl, url].join("/")); + expect(calledOpts).toEqual(expectedOpts); + }); + + it(`appends query params to url if GET`, async () => { + const query = { + bar: "baz", + }; + const options = { ...opts, data: query, httpMethod: "GET" }; + + const result = await utils.apiRequest( + url, + options as IDiscussionsRequestOptions + ); + + expect(result).toEqual(response); + const queryParams = new URLSearchParams(query).toString(); + const baseUrl = [hubApiUrl, url].join("/"); + + const [calledUrl, calledOpts] = fetchMock.calls()[0]; + expect(calledUrl).toEqual(baseUrl + `?${queryParams}`); + expect(calledOpts).toEqual(expectedOpts); + }); + + it(`stringifies and appends body to request options !GET`, async () => { + const body = { + bar: "baz", + }; + const options = { ...opts, data: body, httpMethod: "POST" }; + + const result = await utils.apiRequest( + url, + options as IDiscussionsRequestOptions + ); + + expectedOpts.method = "POST"; + expectedOpts.body = JSON.stringify(body); + + expect(result).toEqual(response); + + const [calledUrl, calledOpts] = fetchMock.calls()[0]; + expect(calledUrl).toEqual([hubApiUrl, url].join("/")); + expect(calledOpts).toEqual(expectedOpts); + }); + + it(`cleans up baseUrl and enpoint`, async () => { + const options = { ...opts, hubApiUrl: `${hubApiUrl}/` }; + const result = await utils.apiRequest( + `/${url}`, + options as IDiscussionsRequestOptions + ); + + expect(result).toEqual(response); + + const [calledUrl, calledOpts] = fetchMock.calls()[0]; + expect(calledUrl).toEqual([hubApiUrl, url].join("/")); + expect(calledOpts).toEqual(expectedOpts); + }); + + it(`uses default hubApiUrl if none provided`, async () => { + const options = {}; + const result = await utils.apiRequest( + url, + options as IDiscussionsRequestOptions + ); + + expect(result).toEqual(response); + + const [calledUrl, calledOpts] = fetchMock.calls()[0]; + expect(calledUrl).toEqual([hubApiUrl, url].join("/")); + expect(calledOpts).toEqual(expectedOpts); + }); +}); diff --git a/packages/common/test/search/_internal/getApi.test.ts b/packages/common/test/search/_internal/getApi.test.ts index 9d0d6e602c8..41b35c7a66e 100644 --- a/packages/common/test/search/_internal/getApi.test.ts +++ b/packages/common/test/search/_internal/getApi.test.ts @@ -1,5 +1,5 @@ import { IHubSearchOptions } from "../../../src/search/types/IHubSearchOptions"; -import { NamedApis } from "../../../src/search/types/types"; +import { IApiDefinition, NamedApis } from "../../../src/search/types/types"; import { SEARCH_APIS } from "../../../src/search/utils"; import { getApi } from "../../../src/search/_internal/commonHelpers/getApi"; @@ -31,6 +31,18 @@ describe("getApi", () => { url: `${hubApiUrl}/api/search/v1`, }); }); + it("otherwise returns reference to Discussions API if possible", () => { + const options = { + requestOptions: { + hubApiUrl, + isPortal: false, + }, + } as unknown as IHubSearchOptions; + expect(getApi("channel", options)).toEqual({ + type: "arcgis-hub", + url: null, + } as any as IApiDefinition); + }); it("otherwise returns a reference to the Portal API from requestOptions", () => { const portal = "https://my-enterprise-server.com/sharing/rest"; const options = { diff --git a/packages/common/test/search/_internal/hubSearchChannels.test.ts b/packages/common/test/search/_internal/hubSearchChannels.test.ts index bfb18fe73a4..82fc3f5dc6a 100644 --- a/packages/common/test/search/_internal/hubSearchChannels.test.ts +++ b/packages/common/test/search/_internal/hubSearchChannels.test.ts @@ -31,6 +31,7 @@ describe("discussionsSearchItems Module |", () => { { access: "private", groups: ["cb0ddfc90f4f45b899c076c88d3fdc84"], + foo: "bar", }, ], }, @@ -51,5 +52,32 @@ describe("discussionsSearchItems Module |", () => { expect(toHubSearchResultSpy).toHaveBeenCalledTimes(1); expect(searchChannelsSpy).toHaveBeenCalledTimes(1); expect(result).toBeTruthy(); + const nextResult = await result.next(); + expect(nextResult).toBeTruthy(); + }); + it("throws error if requestOptions not provided", async () => { + const qry: IQuery = { + targetEntity: "channel", + filters: [ + { + predicates: [ + { + access: "private", + groups: ["cb0ddfc90f4f45b899c076c88d3fdc84"], + }, + ], + }, + ], + }; + const opts: IHubSearchOptions = { + num: 10, + sortField: "createdAt", + sortOrder: "desc", + }; + try { + await hubSearchChannels.hubSearchChannels(qry, opts); + } catch (err) { + expect(err.name).toBe("HubError"); + } }); }); From 9ff8624970aea37d62eab663ef7cf20c9b0127f4 Mon Sep 17 00:00:00 2001 From: Joshua Tanner Date: Wed, 19 Jul 2023 11:16:57 -0700 Subject: [PATCH 13/13] feat(): support value mapping --- .../src/search/_internal/hubSearchChannels.ts | 16 +++++++--- .../_internal/hubSearchChannels.test.ts | 30 +++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/common/src/search/_internal/hubSearchChannels.ts b/packages/common/src/search/_internal/hubSearchChannels.ts index 0526c91fc7d..e5721e003ec 100644 --- a/packages/common/src/search/_internal/hubSearchChannels.ts +++ b/packages/common/src/search/_internal/hubSearchChannels.ts @@ -52,7 +52,13 @@ export const processSearchParams = ( const mapValue = (key: keyof ISearchChannels, value: any): string => { let _value = value; if (key === "sortOrder") { - _value = value.toUpperCase(); + _value = value?.toUpperCase(); + } else if (key === "sortBy") { + _value = { + created: "createdAt", + modified: "updatedAt", + title: null, + }[value as "created" | "modified" | "title"]; } return _value; }; @@ -60,7 +66,9 @@ export const processSearchParams = ( if (options.hasOwnProperty(prop)) { const key = mapKey(prop); const value = mapValue(key, options[prop]); - paginationProps[key] = value; + if (key && value) { + paginationProps[key] = value; + } } }); // Acceptable fields to use as filters @@ -107,9 +115,9 @@ export const toHubSearchResult = ( ...channel, id: channel.id, name: channel.name, - createdDate: channel.createdAt, + createdDate: new Date(channel.createdAt), createdDateSource: "channel", - updatedDate: channel.updatedAt, + updatedDate: new Date(channel.updatedAt), updatedDateSource: "channel", type: "channel", access: channel.access, diff --git a/packages/common/test/search/_internal/hubSearchChannels.test.ts b/packages/common/test/search/_internal/hubSearchChannels.test.ts index 82fc3f5dc6a..59b009f5e34 100644 --- a/packages/common/test/search/_internal/hubSearchChannels.test.ts +++ b/packages/common/test/search/_internal/hubSearchChannels.test.ts @@ -80,4 +80,34 @@ describe("discussionsSearchItems Module |", () => { expect(err.name).toBe("HubError"); } }); + it("handles undefined values", async () => { + const qry: IQuery = { + targetEntity: "channel", + filters: [ + { + predicates: [ + { + access: "private", + groups: ["cb0ddfc90f4f45b899c076c88d3fdc84"], + }, + ], + }, + ], + }; + const opts: IHubSearchOptions = { + num: 10, + sortField: undefined, + sortOrder: undefined, + requestOptions: { + isPortal: false, + hubApiUrl: "https://hubqa.arcgis.com/api", + token: "my-secret-token", + } as IHubRequestOptions, + }; + const result = await hubSearchChannels.hubSearchChannels(qry, opts); + expect(processSearchParamsSpy).toHaveBeenCalledTimes(1); + expect(toHubSearchResultSpy).toHaveBeenCalledTimes(1); + expect(searchChannelsSpy).toHaveBeenCalledTimes(1); + expect(result).toBeTruthy(); + }); });