From f8aaee9ee2665d90e6e8b595478803d524e39955 Mon Sep 17 00:00:00 2001 From: Dave Bouwman Date: Wed, 13 Sep 2023 16:49:18 -0600 Subject: [PATCH] feat: associated, connected and unconnected queries and functions --- packages/common/e2e/associations.e2e.ts | 8 +- .../src/associations/getAssociatedQuery.ts | 44 ---- packages/common/src/associations/index.ts | 1 - .../internal/getTypeByIdsQuery.ts | 30 +++ .../internal/getTypeWithKeywordQuery.ts | 32 +++ .../internal/getTypeWithoutKeywordQuery.ts | 32 +++ packages/common/src/core/HubItemEntity.ts | 36 ++- .../behaviors/IWIthAssociationBehavior.ts | 25 ++ packages/common/src/core/behaviors/index.ts | 1 + .../src/core/traits/IWithAssociations.ts | 1 + .../common/src/initiatives/HubInitiatives.ts | 217 ++++++++-------- packages/common/src/metrics/resolveMetric.ts | 9 +- .../src/permissions/types/Permission.ts | 11 +- packages/common/src/projects/fetch.ts | 24 +- .../src/search/_internal/combineQueries.ts | 2 + .../search/_internal/getEntityTypeFromType.ts | 25 ++ .../search/_internal/negateGroupPredicates.ts | 29 +++ packages/common/src/search/utils.ts | 6 +- packages/common/src/utils/memoize.ts | 6 +- ...getTargetEntityFromAssociationType.test.ts | 15 ++ .../internal/getTypeByIdsQuery.test.ts | 13 + .../getTypeFromAssociationType.test.ts | 15 ++ .../internal/getTypeWithKeywordQuery.test.ts | 13 + .../getTypeWithoutKeywordQuery.test.ts | 15 ++ .../common/test/core/HubItemEntity.test.ts | 67 +++++ .../test/initiatives/HubInitiatives.test.ts | 232 ++++++++++++++++++ .../test/permissions/checkPermission.test.ts | 9 +- packages/common/test/projects/fetch.test.ts | 21 ++ .../search/_internal/getEntityTypeFromType.ts | 25 ++ .../_internal/negateGroupPredicates.test.ts | 107 ++++++++ packages/common/test/search/utils.test.ts | 72 +++++- packages/common/test/utils/memoize.test.ts | 1 - 32 files changed, 959 insertions(+), 185 deletions(-) delete mode 100644 packages/common/src/associations/getAssociatedQuery.ts create mode 100644 packages/common/src/associations/internal/getTypeByIdsQuery.ts create mode 100644 packages/common/src/associations/internal/getTypeWithKeywordQuery.ts create mode 100644 packages/common/src/associations/internal/getTypeWithoutKeywordQuery.ts create mode 100644 packages/common/src/core/behaviors/IWIthAssociationBehavior.ts create mode 100644 packages/common/src/search/_internal/getEntityTypeFromType.ts create mode 100644 packages/common/src/search/_internal/negateGroupPredicates.ts create mode 100644 packages/common/test/associations/internal/getTargetEntityFromAssociationType.test.ts create mode 100644 packages/common/test/associations/internal/getTypeByIdsQuery.test.ts create mode 100644 packages/common/test/associations/internal/getTypeFromAssociationType.test.ts create mode 100644 packages/common/test/associations/internal/getTypeWithKeywordQuery.test.ts create mode 100644 packages/common/test/associations/internal/getTypeWithoutKeywordQuery.test.ts create mode 100644 packages/common/test/search/_internal/getEntityTypeFromType.ts create mode 100644 packages/common/test/search/_internal/negateGroupPredicates.test.ts diff --git a/packages/common/e2e/associations.e2e.ts b/packages/common/e2e/associations.e2e.ts index 3a190d8f080..dc9f41b753c 100644 --- a/packages/common/e2e/associations.e2e.ts +++ b/packages/common/e2e/associations.e2e.ts @@ -1,9 +1,9 @@ import { IHubInitiative, - fetchRelatedProjects, + fetchConnectedProjects, fetchAssociatedProjects, fetchHubEntity, - fetchUnRelatedProjects, + fetchUnConnectedProjects, } from "../src"; import Artifactory from "./helpers/Artifactory"; import config from "./helpers/config"; @@ -122,7 +122,7 @@ fdescribe("associations development harness:", () => { context )) as IHubInitiative; // debugger; - const projects = await fetchUnRelatedProjects( + const projects = await fetchUnConnectedProjects( entity, context.hubRequestOptions ); @@ -138,7 +138,7 @@ fdescribe("associations development harness:", () => { TEST_ITEMS.initiative, context )) as IHubInitiative; - const projects = await fetchRelatedProjects( + const projects = await fetchConnectedProjects( entity, context.hubRequestOptions ); diff --git a/packages/common/src/associations/getAssociatedQuery.ts b/packages/common/src/associations/getAssociatedQuery.ts deleted file mode 100644 index 34d9ef53c5c..00000000000 --- a/packages/common/src/associations/getAssociatedQuery.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { IWithAssociations } from "../core"; -import { IQuery } from "../search/types/IHubCatalog"; -import { getTargetEntityFromAssociationType } from "./internal/getTargetEntityFromAssociationType"; -import { getTypeFromAssociationType } from "./internal/getTypeFromAssociationType"; -import { listAssociations } from "./listAssociations"; -import { AssociationType } from "./types"; - -/** - * Get a query that can be used in a Gallery, and will return the associated - * entities, based on the AssociationType - * - * @param entity - * @param type - * @returns - */ -export function getAssociatedQuery( - entity: IWithAssociations, - type: AssociationType -): IQuery { - const ids = listAssociations(entity, type); - if (!ids.length) { - return null; - } - - // lookup some information by the association type - const itemType = getTypeFromAssociationType(type); - const targetEntity = getTargetEntityFromAssociationType(type); - - const qry: IQuery = { - targetEntity, - filters: [ - { - operation: "AND", - predicates: [ - { - type: itemType, - id: [...ids], - }, - ], - }, - ], - }; - return qry; -} diff --git a/packages/common/src/associations/index.ts b/packages/common/src/associations/index.ts index 61b84139425..6f16f7cf121 100644 --- a/packages/common/src/associations/index.ts +++ b/packages/common/src/associations/index.ts @@ -1,5 +1,4 @@ export * from "./addAssociation"; -export * from "./getAssociatedQuery"; export * from "./listAssociations"; export * from "./removeAssociation"; export * from "./types"; diff --git a/packages/common/src/associations/internal/getTypeByIdsQuery.ts b/packages/common/src/associations/internal/getTypeByIdsQuery.ts new file mode 100644 index 00000000000..9d9048c9be9 --- /dev/null +++ b/packages/common/src/associations/internal/getTypeByIdsQuery.ts @@ -0,0 +1,30 @@ +import { getEntityTypeFromType } from "../../search/_internal/getEntityTypeFromType"; +import { IQuery } from "../../search/types/IHubCatalog"; + +/** + * Get a query that can be used in a Gallery, and will return the associated + * entities, based on the AssociationType + * + * @param entity + * @param type + * @returns + */ +export function getTypeByIdsQuery(itemType: string, ids: string[]): IQuery { + const targetEntity = getEntityTypeFromType(itemType); + + const qry: IQuery = { + targetEntity, + filters: [ + { + operation: "AND", + predicates: [ + { + type: itemType, + id: [...ids], + }, + ], + }, + ], + }; + return qry; +} diff --git a/packages/common/src/associations/internal/getTypeWithKeywordQuery.ts b/packages/common/src/associations/internal/getTypeWithKeywordQuery.ts new file mode 100644 index 00000000000..7ab2af730f9 --- /dev/null +++ b/packages/common/src/associations/internal/getTypeWithKeywordQuery.ts @@ -0,0 +1,32 @@ +import { getEntityTypeFromType } from "../../search/_internal/getEntityTypeFromType"; +import { IQuery } from "../../search/types/IHubCatalog"; + +/** + * @private + * Return an `IQuery` for a specific item type, with a specific typekeyword + * This is used internally to build queries for "Connected" entities + * @param itemType + * @param keyword + * @returns + */ +export function getTypeWithKeywordQuery( + itemType: string, + keyword: string +): IQuery { + const targetEntity = getEntityTypeFromType(itemType); + + return { + targetEntity, + filters: [ + { + operation: "AND", + predicates: [ + { + type: itemType, + typekeywords: keyword, + }, + ], + }, + ], + }; +} diff --git a/packages/common/src/associations/internal/getTypeWithoutKeywordQuery.ts b/packages/common/src/associations/internal/getTypeWithoutKeywordQuery.ts new file mode 100644 index 00000000000..84eeb28cb6e --- /dev/null +++ b/packages/common/src/associations/internal/getTypeWithoutKeywordQuery.ts @@ -0,0 +1,32 @@ +import { getEntityTypeFromType } from "../../search/_internal/getEntityTypeFromType"; +import { IQuery } from "../../search/types/IHubCatalog"; + +/** + * @private + * Return an `IQuery` for a specific item type, without a specific typekeyword + * This is used internally to build queries for "Not Connected" entities + * @param itemType + * @param keyword + * @returns + */ +export function getTypeWithoutKeywordQuery( + itemType: string, + keyword: string +): IQuery { + const targetEntity = getEntityTypeFromType(itemType); + + return { + targetEntity, + filters: [ + { + operation: "AND", + predicates: [ + { + type: itemType, + typekeywords: { not: [keyword] }, + }, + ], + }, + ], + }; +} diff --git a/packages/common/src/core/HubItemEntity.ts b/packages/common/src/core/HubItemEntity.ts index b105155b3c3..d3c555d9e52 100644 --- a/packages/common/src/core/HubItemEntity.ts +++ b/packages/common/src/core/HubItemEntity.ts @@ -26,6 +26,7 @@ import { IWithStoreBehavior, IWithFeaturedImageBehavior, IWithPermissionBehavior, + IWithAssociationBehavior, } from "./behaviors"; import { IWithThumbnailBehavior } from "./behaviors/IWithThumbnailBehavior"; @@ -33,6 +34,10 @@ import { IHubItemEntity, SettableAccessLevel } from "./types"; import { sharedWith } from "./_internal/sharedWith"; import { IWithDiscussionsBehavior } from "./behaviors/IWithDiscussionsBehavior"; import { setDiscussableKeyword } from "../discussions"; +import { AssociationType, IAssociationInfo } from "../associations/types"; +import { listAssociations } from "../associations/listAssociations"; +import { addAssociation } from "../associations/addAssociation"; +import { removeAssociation } from "../associations/removeAssociation"; const FEATURED_IMAGE_FILENAME = "featuredImage.png"; @@ -46,7 +51,8 @@ export abstract class HubItemEntity IWithThumbnailBehavior, IWithFeaturedImageBehavior, IWithPermissionBehavior, - IWithDiscussionsBehavior + IWithDiscussionsBehavior, + IWithAssociationBehavior { protected context: IArcGISContext; protected entity: T; @@ -383,4 +389,32 @@ export abstract class HubItemEntity ); this.update({ typeKeywords, isDiscussable } as Partial); } + + /** + * Return a list of IAssociationInfo objects representing + * the associations this entity has, to the specified type + * @param type + * @returns + */ + listAssociations(type: AssociationType): IAssociationInfo[] { + return listAssociations(this.entity, type); + } + + /** + * Add an association to this entity + * @param info + * @returns + */ + addAssociation(info: IAssociationInfo): void { + return addAssociation(info, this.entity); + } + + /** + * Remove an association from this entity + * @param info + * @returns + */ + removeAssociation(info: IAssociationInfo): void { + return removeAssociation(info, this.entity); + } } diff --git a/packages/common/src/core/behaviors/IWIthAssociationBehavior.ts b/packages/common/src/core/behaviors/IWIthAssociationBehavior.ts new file mode 100644 index 00000000000..9a4a7627006 --- /dev/null +++ b/packages/common/src/core/behaviors/IWIthAssociationBehavior.ts @@ -0,0 +1,25 @@ +import { AssociationType, IAssociationInfo } from "../../associations/types"; + +/** + * Composable behavior that adds permissions to an entity + */ +export interface IWithAssociationBehavior { + /** + * Get a list of the associations for an AssociationType + * @param type + */ + listAssociations(type: AssociationType): IAssociationInfo[]; + + /** + * Add an association to the entity. + * Entity needs to be saved after calling this method + * @param info + */ + addAssociation(info: IAssociationInfo): void; + /** + * Remove an association to the entity. + * Entity needs to be saved after calling this method + * @param info + */ + removeAssociation(info: IAssociationInfo): void; +} diff --git a/packages/common/src/core/behaviors/index.ts b/packages/common/src/core/behaviors/index.ts index d22e68490d8..80a3c8b8f84 100644 --- a/packages/common/src/core/behaviors/index.ts +++ b/packages/common/src/core/behaviors/index.ts @@ -6,3 +6,4 @@ export * from "./IWithEditorBehavior"; export * from "./IWithFeaturedImageBehavior"; export * from "./IWithVersioningBehavior"; export * from "./IWithCardBehavior"; +export * from "./IWIthAssociationBehavior"; diff --git a/packages/common/src/core/traits/IWithAssociations.ts b/packages/common/src/core/traits/IWithAssociations.ts index 92bb2ddbe22..4361f24764a 100644 --- a/packages/common/src/core/traits/IWithAssociations.ts +++ b/packages/common/src/core/traits/IWithAssociations.ts @@ -1,3 +1,4 @@ export interface IWithAssociations { typeKeywords?: string[]; + [key: string]: any; } diff --git a/packages/common/src/initiatives/HubInitiatives.ts b/packages/common/src/initiatives/HubInitiatives.ts index 23b88546c84..a7062923526 100644 --- a/packages/common/src/initiatives/HubInitiatives.ts +++ b/packages/common/src/initiatives/HubInitiatives.ts @@ -27,8 +27,7 @@ import { setDiscussableKeyword, IModel, } from "../index"; -import { Catalog } from "../search/Catalog"; -import { IHubCollection, IQuery } from "../search/types/IHubCatalog"; +import { IQuery } from "../search/types/IHubCatalog"; import { IItem, IUserItemOptions, @@ -49,7 +48,10 @@ import { computeProps } from "./_internal/computeProps"; import { applyInitiativeMigrations } from "./_internal/applyInitiativeMigrations"; import { getRelativeWorkspaceUrl } from "../core/getRelativeWorkspaceUrl"; import { combineQueries } from "../search/_internal/combineQueries"; -import { expandQuery, portalSearchItemsAsItems } from "../search/_internal"; +import { portalSearchItemsAsItems } from "../search/_internal/portalSearchItems"; +import { getTypeWithKeywordQuery } from "../associations/internal/getTypeWithKeywordQuery"; +import { getTypeWithoutKeywordQuery } from "../associations/internal/getTypeWithoutKeywordQuery"; +import { negateGroupPredicates } from "../search/_internal/negateGroupPredicates"; /** * @private @@ -280,8 +282,10 @@ export async function enrichInitiativeSearchResult( } /** - * Fetch the Projects that are approved for an Initiative. This is a subset of the associated projects, limited - * to those that have the initiative typekeyword and are included in the Initiative's Projects collection + * Fetch the Projects that are "Associated" with an Initiative. + * This is a subset of the "Connected" projects, limited + * are included in the Initiative's Catalog. + * This is how we can get the "Approved" Projects * @param initiative * @param requestOptions * @param query: Optional `IQuery` to further filter the results @@ -293,155 +297,140 @@ export async function fetchAssociatedProjects( query?: IQuery ): Promise { let projectQuery = getAssociatedProjectsQuery(initiative); - if (query) { - projectQuery = combineQueries([projectQuery, query]); - } + // combineQueries will purge undefined/null entries + projectQuery = combineQueries([projectQuery, query]); - const response = await portalSearchItemsAsItems(projectQuery, { - requestOptions, - }); - // process into entityInfo objects - return response.results.map((r) => { - return { - id: r.id, - name: r.title, - type: r.type, - } as IEntityInfo; - }); + return queryAsEntityInfo(projectQuery, requestOptions); } -export async function fetchRelatedProjects( +/** + * Fetch the Projects that are "Connected" to the Initiative but are not + * "Associated", meaning they are not in the Initiative's Catalog. + * This is how we can get the list of Projects awaiting Approval + * @param initiative + * @param requestOptions + * @param query + * @returns + */ +export async function fetchConnectedProjects( initiative: IHubInitiative, requestOptions: IHubRequestOptions, query?: IQuery ): Promise { - let projectQuery = getRelatedProjectsQuery(initiative); - if (query) { - projectQuery = combineQueries([projectQuery, query]); - } + let projectQuery = getConnectedProjectsQuery(initiative); + // combineQueries will purge undefined/null entries + projectQuery = combineQueries([projectQuery, query]); - const response = await portalSearchItemsAsItems(projectQuery, { - requestOptions, - }); - // process into entityInfo objects - return response.results.map((r) => { - return { - id: r.id, - name: r.title, - type: r.type, - } as IEntityInfo; - }); + return queryAsEntityInfo(projectQuery, requestOptions); } -export async function fetchUnRelatedProjects( +/** + * Fetch Projects which are not "Connected" and are not in the + * Initiative's Catalog. + * @param initiative + * @param requestOptions + * @param query + * @returns + */ +export async function fetchUnConnectedProjects( initiative: IHubInitiative, requestOptions: IHubRequestOptions, query?: IQuery ): Promise { - let projectQuery = getUnRelatedProjectsQuery(initiative); - if (query) { - projectQuery = combineQueries([projectQuery, query]); - } + let projectQuery = getUnConnectedProjectsQuery(initiative); + // combineQueries will purge undefined/null entries + projectQuery = combineQueries([projectQuery, query]); - const response = await portalSearchItemsAsItems(projectQuery, { + return queryAsEntityInfo(projectQuery, requestOptions); +} + +/** + * Execute the query and convert into EntityInfo objects + * @param query + * @param requestOptions + * @returns + */ +async function queryAsEntityInfo( + query: IQuery, + requestOptions: IHubRequestOptions +) { + const response = await portalSearchItemsAsItems(query, { requestOptions, + num: 100, }); - // process into entityInfo objects - return response.results.map((r) => { + return response.results.map((item) => { return { - id: r.id, - name: r.title, - type: r.type, + id: item.id, + name: item.title, + type: item.type, } as IEntityInfo; }); } /** - * Associated Projects are those that have the Initiative id in the typekeywords + * Associated projects are those with the Initiative id in the typekeywords + * and is included in the Initiative's catalog. + * This is passed into the Gallery showing "Approved Projects" * @param initiative * @returns */ -export function getRelatedProjectsQuery(initiative: IHubInitiative): IQuery { - return { - targetEntity: "item", - filters: [ - { - operation: "AND", - predicates: [ - { - type: "Hub Project", - typekeywords: `initiative|${initiative.id}`, - }, - ], - }, - ], - }; +export function getAssociatedProjectsQuery(initiative: IHubInitiative): IQuery { + // get query that returns Hub Projects with the initiative keyword + let query = getTypeWithKeywordQuery( + "Hub Project", + `initiative|${initiative.id}` + ); + // Get the item scope from the catalog + const qry = getProp(initiative, "catalog.scopes.item"); + // combineQueries will remove null/undefined entries + query = combineQueries([query, qry]); + + return query; } /** - * Approved projects are those with the Initiative id in the typekeywords - * and is included in the Projects collection in the Initiative's catalog. + * Related Projects are those that have the Initiative id in the + * typekeywords but NOT in the catalog. We use this query to show + * Projects which want to be associated but are not yet included in + * the catalog + * This is passed into the Gallery showing "Pending Projects" * @param initiative * @returns */ -export function getAssociatedProjectsQuery(initiative: IHubInitiative): IQuery { - // get the associated projects query - let query = getRelatedProjectsQuery(initiative); - // create a catalog instance, get the projects collection from it if defined - const cat = Catalog.fromJson(initiative.catalog); - const projectCollection = cat.collections.find((c) => c.key === "projects"); - let qry: IQuery; - if (projectCollection) { - // get it by name - this will merge in the base item scope - qry = cat.getCollectionJson("projects").scope; - } else { - // use the catalog's item scope - qry = cat.getScope("item"); - } +export function getConnectedProjectsQuery(initiative: IHubInitiative): IQuery { + // get query that returns Hub Projects with the initiative keyword + let query = getTypeWithKeywordQuery( + "Hub Project", + `initiative|${initiative.id}` + ); + // The the item scope from the catalog... + const qry = getProp(initiative, "catalog.scopes.item"); + + // negate the scope, combine that with the base query + query = combineQueries([query, negateGroupPredicates(qry)]); - if (qry) { - query = combineQueries([query, qry]); - } return query; } /** - * Approved projects are those with the Initiative id in the typekeywords - * and is included in the Projects collection in the Initiative's catalog. + * Un-connected projects are those without Initiative id in the typekeywords + * and is NOT included in the Initiative's catalog. + * This can be used to locate "Other" Projects * @param initiative * @returns */ -export function getUnRelatedProjectsQuery(initiative: IHubInitiative): IQuery { - // get the associated projects query - let query = getRelatedProjectsQuery(initiative); - // create a catalog instance, - const cat = Catalog.fromJson(initiative.catalog); - - // default to the catalog's item scope - let qry: IQuery = cat.getScope("item"); - // Get the projects collection - // Using the instance ensures that the root scope is merged into the returned - // collection.scope - const projectCollection = cat.getCollectionJson("projects"); - if (projectCollection) { - qry = projectCollection.scope; - } - // expand the query so we're working with a consistent IMatchOptions structure - const expanded = expandQuery(qry); - // negate the group predicate on the query - // we opted to be surgical about this vs attempting a `negateQuery(query)` function - expanded.filters.forEach((f) => { - f.predicates.forEach((p) => { - if (p.group) { - p.group.not = [...(p.group.any || []), ...(p.group.all || [])]; - p.group.any = []; - p.group.all = []; - } - }); - }); +export function getUnConnectedProjectsQuery( + initiative: IHubInitiative +): IQuery { + // get query that returns Hub Projects with the initiative keyword + let query = getTypeWithoutKeywordQuery( + "Hub Project", + `initiative|${initiative.id}` + ); + // The the item scope from the catalog... + const qry = getProp(initiative, "catalog.scopes.item"); - if (qry) { - query = combineQueries([expanded, query]); - } + // negate the scope, combine that with the base query + query = combineQueries([query, negateGroupPredicates(qry)]); return query; } diff --git a/packages/common/src/metrics/resolveMetric.ts b/packages/common/src/metrics/resolveMetric.ts index d5a857bd2dc..3cef1ce403f 100644 --- a/packages/common/src/metrics/resolveMetric.ts +++ b/packages/common/src/metrics/resolveMetric.ts @@ -168,10 +168,15 @@ async function resolveItemQueryMetric( requestOptions: context.hubRequestOptions, num: 100, }; + // Memoization is great but we have UI's where we want immediate + // updates as we share more items into the groups, which means + // we can't memoize the search response // create/get memoized version of portalSearchItemsAsItems - const memoizedPortalSearch = memoize(portalSearchItemsAsItems); + // const memoizedPortalSearch = memoize(portalSearchItemsAsItems); // Execute the query - const response = await memoizedPortalSearch(combined, opts); + // const response = await memoizedPortalSearch(combined, opts); + + const response = await portalSearchItemsAsItems(combined, opts); // This next section is all promise based so that a dynamic value // can itself be a dynamic value definition, which then needs to be diff --git a/packages/common/src/permissions/types/Permission.ts b/packages/common/src/permissions/types/Permission.ts index 86be3bf4586..5533d5fca27 100644 --- a/packages/common/src/permissions/types/Permission.ts +++ b/packages/common/src/permissions/types/Permission.ts @@ -19,7 +19,6 @@ const validPermissions = [ ...SitePermissions, ...ProjectPermissions, ...InitiativePermissions, - ...DiscussionPermissions, ...ContentPermissions, ...GroupPermissions, ...PagePermissions, @@ -30,7 +29,15 @@ const validPermissions = [ /** * Defines the possible values for Permissions */ -export type Permission = (typeof validPermissions)[number]; +export type Permission = + | (typeof SitePermissions)[number] + | (typeof ProjectPermissions)[number] + | (typeof InitiativePermissions)[number] + | (typeof ContentPermissions)[number] + | (typeof GroupPermissions)[number] + | (typeof PagePermissions)[number] + | (typeof PlatformPermissions)[number] + | (typeof TempPermissions)[number]; /** * Validate a Permission diff --git a/packages/common/src/projects/fetch.ts b/packages/common/src/projects/fetch.ts index 2e1a2b480c3..91025657b74 100644 --- a/packages/common/src/projects/fetch.ts +++ b/packages/common/src/projects/fetch.ts @@ -22,7 +22,8 @@ import { getItemThumbnailUrl } from "../resources/get-item-thumbnail-url"; import { getItemHomeUrl } from "../urls/get-item-home-url"; import { getItemIdentifier } from "../items"; import { getRelativeWorkspaceUrl } from "../core/getRelativeWorkspaceUrl"; -import { getAssociatedQuery, listAssociations } from "../associations"; +import { listAssociations } from "../associations"; +import { getTypeByIdsQuery } from "../associations/internal/getTypeByIdsQuery"; /** * @private @@ -144,11 +145,24 @@ export async function enrichProjectSearchResult( /** * Get a query that will fetch all the initiatives which the project has - * chosen to associate with. If project has not defined any associations - * to any Initiatives, will return `null` + * chosen to connect to. If project has not defined any associations + * to any Initiatives, will return `null`. + * Currently, we have not implemented a means to get the list of initiatives that have + * "Approved" the Project via inclusion in it's catalog. + * + * If needed, this could be done by getting all the groups the project is shared into + * then cross-walking that into the catalogs of all the Connected Initiatives * @param project * @returns */ -export function getAssociatedInitiativesQuery(project: IHubProject): IQuery { - return getAssociatedQuery(project, "initiative"); +export function getConnectedInitiativesQuery(project: IHubProject): IQuery { + // get the list of ids from the keywords + const ids = listAssociations(project, "initiative").map((a) => a.id); + if (ids.length) { + // get the query + return getTypeByIdsQuery("Hub Initiative", ids); + } else { + // if there are no + return null; + } } diff --git a/packages/common/src/search/_internal/combineQueries.ts b/packages/common/src/search/_internal/combineQueries.ts index a17e4f1538a..d80130169cd 100644 --- a/packages/common/src/search/_internal/combineQueries.ts +++ b/packages/common/src/search/_internal/combineQueries.ts @@ -12,6 +12,8 @@ import { IQuery } from "../types/IHubCatalog"; */ export const combineQueries = (queries: IQuery[]): IQuery => { + // remove any entries that are null or undefined + queries = queries.filter((e) => e); // check tht all queries are for the same entity type const targetEntity = queries[0].targetEntity; if (queries.some((q) => q.targetEntity !== targetEntity)) { diff --git a/packages/common/src/search/_internal/getEntityTypeFromType.ts b/packages/common/src/search/_internal/getEntityTypeFromType.ts new file mode 100644 index 00000000000..81ef190dec7 --- /dev/null +++ b/packages/common/src/search/_internal/getEntityTypeFromType.ts @@ -0,0 +1,25 @@ +import { EntityType } from "../types"; + +/** + * @private + * Given a type (e.g. Hub Site Application) return the appropriate entity type + * that can be used as a `targetEntity` in an `IQuery` + * @param type + * @returns + */ +export function getEntityTypeFromType(type: string): EntityType { + // Default to item, as it's the most common + let etype: EntityType = "item"; + + // Some are just downcased, so we can check them with an array + if (["group", "event", "user", "channel"].includes(type.toLowerCase())) { + etype = type.toLocaleLowerCase() as EntityType; + } + + // Group Member is just weird + if (type.toLowerCase() === "group member") { + etype = "groupMember"; + } + + return etype; +} diff --git a/packages/common/src/search/_internal/negateGroupPredicates.ts b/packages/common/src/search/_internal/negateGroupPredicates.ts new file mode 100644 index 00000000000..3eab86c7e3a --- /dev/null +++ b/packages/common/src/search/_internal/negateGroupPredicates.ts @@ -0,0 +1,29 @@ +import { IQuery } from "../types/IHubCatalog"; +import { expandQuery } from "./portalSearchItems"; + +/** + * @private + * Helper function that locates group predicates and "negates" them + * so we get a query that is `not in groups ...` vs `in groups ...` + * @param query + * @returns + */ +export function negateGroupPredicates(query: IQuery): IQuery { + // if nothing is passed in just return undefined + if (!query) { + return; + } + const expanded = expandQuery(query); + // negate the group predicate on the query + // we opted to be surgical about this vs attempting a `negateQuery(query)` function + expanded.filters.forEach((f) => { + f.predicates.forEach((p) => { + if (p.group) { + p.group.not = [...(p.group.any || []), ...(p.group.all || [])]; + p.group.any = []; + p.group.all = []; + } + }); + }); + return expanded; +} diff --git a/packages/common/src/search/utils.ts b/packages/common/src/search/utils.ts index 0d3963ecef8..bd43d806801 100644 --- a/packages/common/src/search/utils.ts +++ b/packages/common/src/search/utils.ts @@ -298,7 +298,11 @@ export function getScopeGroupPredicate(scope: IQuery): IPredicate { console.warn( `"getScopeGroupPredicate(query)" is deprecated. Please use "getGroupPredicate(qyr)` ); - return getGroupPredicate(scope); + const isGroupPredicate = (predicate: IPredicate) => !!predicate.group; + const groupFilter = scope.filters.find((f) => + f.predicates.find(isGroupPredicate) + ); + return groupFilter && groupFilter.predicates.find(isGroupPredicate); } /** diff --git a/packages/common/src/utils/memoize.ts b/packages/common/src/utils/memoize.ts index 523b83e04c1..560d94edb7c 100644 --- a/packages/common/src/utils/memoize.ts +++ b/packages/common/src/utils/memoize.ts @@ -12,7 +12,7 @@ const createCacheKeyFromArgs = (args: any[]) => "" ); -const memoizedFnCache: Record = {}; +let memoizedFnCache: Record = {}; /** * Wrap a function into a memoized version of itself. Multiple calls for the same function * will return the same memoized function - thus enabling a shared cache of results. @@ -49,9 +49,7 @@ export const memoize = ( */ export const clearMemoizedCache = (fnName?: string) => { if (!fnName) { - Object.keys(memoizedFnCache).forEach((key) => { - delete memoizedFnCache[key]; - }); + memoizedFnCache = {}; return; } else { delete memoizedFnCache[`_${fnName}`]; diff --git a/packages/common/test/associations/internal/getTargetEntityFromAssociationType.test.ts b/packages/common/test/associations/internal/getTargetEntityFromAssociationType.test.ts new file mode 100644 index 00000000000..a0fc0688c92 --- /dev/null +++ b/packages/common/test/associations/internal/getTargetEntityFromAssociationType.test.ts @@ -0,0 +1,15 @@ +import { AssociationType } from "../../../src"; +import { getTargetEntityFromAssociationType } from "../../../src/associations/internal/getTargetEntityFromAssociationType"; + +describe("getTargetEntityFromAssociationType:", () => { + it("throws if passed an invalid type", () => { + try { + getTargetEntityFromAssociationType("INVALID" as AssociationType); + } catch (ex) { + expect(ex.message).toContain("Invalid association type INVALID"); + } + }); + it("returns item for initiative", () => { + expect(getTargetEntityFromAssociationType("initiative")).toEqual("item"); + }); +}); diff --git a/packages/common/test/associations/internal/getTypeByIdsQuery.test.ts b/packages/common/test/associations/internal/getTypeByIdsQuery.test.ts new file mode 100644 index 00000000000..7293134b629 --- /dev/null +++ b/packages/common/test/associations/internal/getTypeByIdsQuery.test.ts @@ -0,0 +1,13 @@ +import { getTypeByIdsQuery } from "../../../src/associations/internal/getTypeByIdsQuery"; + +describe("getTypeByIdsQuery:", () => { + it("verify structure", () => { + const chk = getTypeByIdsQuery("Hub Project", ["a", "b"]); + + expect(chk.targetEntity).toBe("item"); + expect(chk.filters.length).toBe(1); + expect(chk.filters[0].predicates.length).toBe(1); + expect(chk.filters[0].predicates[0].type).toBe("Hub Project"); + expect(chk.filters[0].predicates[0].id).toEqual(["a", "b"]); + }); +}); diff --git a/packages/common/test/associations/internal/getTypeFromAssociationType.test.ts b/packages/common/test/associations/internal/getTypeFromAssociationType.test.ts new file mode 100644 index 00000000000..f5ed20e4b41 --- /dev/null +++ b/packages/common/test/associations/internal/getTypeFromAssociationType.test.ts @@ -0,0 +1,15 @@ +import { AssociationType } from "../../../src"; +import { getTypeFromAssociationType } from "../../../src/associations/internal/getTypeFromAssociationType"; + +describe("getTypeFromAssociationType:", () => { + it("throws if passed an invalid type", () => { + try { + getTypeFromAssociationType("INVALID" as AssociationType); + } catch (ex) { + expect(ex.message).toContain("Invalid association type INVALID"); + } + }); + it("returns item for initiative", () => { + expect(getTypeFromAssociationType("initiative")).toEqual("Hub Initiative"); + }); +}); diff --git a/packages/common/test/associations/internal/getTypeWithKeywordQuery.test.ts b/packages/common/test/associations/internal/getTypeWithKeywordQuery.test.ts new file mode 100644 index 00000000000..c2b54110906 --- /dev/null +++ b/packages/common/test/associations/internal/getTypeWithKeywordQuery.test.ts @@ -0,0 +1,13 @@ +import { getTypeWithKeywordQuery } from "../../../src/associations/internal/getTypeWithKeywordQuery"; + +describe("getTypeWithKeywordQuery:", () => { + it("verify structure", () => { + const chk = getTypeWithKeywordQuery("Hub Project", "foo|00c"); + + expect(chk.targetEntity).toBe("item"); + expect(chk.filters.length).toBe(1); + expect(chk.filters[0].predicates.length).toBe(1); + expect(chk.filters[0].predicates[0].type).toBe("Hub Project"); + expect(chk.filters[0].predicates[0].typekeywords).toEqual("foo|00c"); + }); +}); diff --git a/packages/common/test/associations/internal/getTypeWithoutKeywordQuery.test.ts b/packages/common/test/associations/internal/getTypeWithoutKeywordQuery.test.ts new file mode 100644 index 00000000000..7927b1bb9e6 --- /dev/null +++ b/packages/common/test/associations/internal/getTypeWithoutKeywordQuery.test.ts @@ -0,0 +1,15 @@ +import { getTypeWithoutKeywordQuery } from "../../../src/associations/internal/getTypeWithoutKeywordQuery"; + +describe("getTypeWithoutKeywordQuery:", () => { + it("verify structure", () => { + const chk = getTypeWithoutKeywordQuery("Hub Project", "foo|00c"); + + expect(chk.targetEntity).toBe("item"); + expect(chk.filters.length).toBe(1); + expect(chk.filters[0].predicates.length).toBe(1); + expect(chk.filters[0].predicates[0].type).toBe("Hub Project"); + expect(chk.filters[0].predicates[0].typekeywords).toEqual({ + not: ["foo|00c"], + }); + }); +}); diff --git a/packages/common/test/core/HubItemEntity.test.ts b/packages/common/test/core/HubItemEntity.test.ts index 5027be139df..b821afe256d 100644 --- a/packages/common/test/core/HubItemEntity.test.ts +++ b/packages/common/test/core/HubItemEntity.test.ts @@ -643,4 +643,71 @@ describe("HubItemEntity Class: ", () => { }); }); }); + + describe("with associations behavior", () => { + it("listAssociations delegates", () => { + const spy = spyOn( + require("../../src/associations/listAssociations"), + "listAssociations" + ).and.callThrough(); + + const instance = new TestHarness( + { + id: "00c", + owner: "deke", + isDiscussable: false, + typeKeywords: ["initiative|00c", "initiative|00b"], + }, + authdCtxMgr.context + ); + const chk = instance.listAssociations("initiative"); + expect(chk.length).toBe(2); + // no need to check the response, as listAssociations is tested elsewhere + expect(spy).toHaveBeenCalled(); + }); + + it("addAssociation delegates", () => { + const spy = spyOn( + require("../../src/associations/addAssociation"), + "addAssociation" + ).and.callThrough(); + + const instance = new TestHarness( + { + id: "00c", + owner: "deke", + isDiscussable: false, + typeKeywords: ["initiative|00c", "initiative|00b"], + }, + authdCtxMgr.context + ); + instance.addAssociation({ type: "initiative", id: "00f" }); + const chk = instance.toJson(); + expect(chk.typeKeywords.includes("initiative|00f")).toBeTruthy(); + // no need to check the response, as addAssociations is tested elsewhere + expect(spy).toHaveBeenCalled(); + }); + + it("removeAssociation delegates", () => { + const spy = spyOn( + require("../../src/associations/removeAssociation"), + "removeAssociation" + ).and.callThrough(); + + const instance = new TestHarness( + { + id: "00c", + owner: "deke", + isDiscussable: false, + typeKeywords: ["initiative|00c", "initiative|00b"], + }, + authdCtxMgr.context + ); + instance.removeAssociation({ type: "initiative", id: "00c" }); + const chk = instance.toJson(); + expect(chk.typeKeywords.includes("initiative|00c")).toBeFalsy(); + // no need to check the response, as addAssociations is tested elsewhere + expect(spy).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/common/test/initiatives/HubInitiatives.test.ts b/packages/common/test/initiatives/HubInitiatives.test.ts index a4143320218..f379d23b0d0 100644 --- a/packages/common/test/initiatives/HubInitiatives.test.ts +++ b/packages/common/test/initiatives/HubInitiatives.test.ts @@ -12,9 +12,16 @@ import { fetchInitiative, deleteInitiative, updateInitiative, + getAssociatedProjectsQuery, + getConnectedProjectsQuery, + getUnConnectedProjectsQuery, + fetchAssociatedProjects, + fetchConnectedProjects, + fetchUnConnectedProjects, } from "../../src/initiatives/HubInitiatives"; import { IHubInitiative } from "../../src/core/types/IHubInitiative"; import { cloneObject } from "../../src/util"; +import { IPredicate, IQuery } from "../../src"; const GUID = "9b77674e43cf4bbd9ecad5189b3f1fdc"; const INITIATIVE_ITEM: portalModule.IItem = { @@ -396,4 +403,229 @@ describe("HubInitiatives:", () => { expect(ro).toBe(hubRo); }); }); + + describe("query getters", () => { + let fixture: IHubInitiative; + beforeEach(() => { + // Minimal structure needed for these tests + fixture = { + name: "Fixture Initiative", + id: "00f", + catalog: { + schemaVersion: 1, + scopes: { + item: { + targetEntity: "item", + filters: [ + { + predicates: [ + { + group: ["00c", "aa1"], + }, + ], + }, + ], + }, + }, + }, + } as unknown as IHubInitiative; + }); + it("getAssociatedProjectsQuery", () => { + const chk = getAssociatedProjectsQuery(fixture); + expect(chk.targetEntity).toBe("item"); + // ensure we have type and keyword in predicate + expect(verifyPredicate(chk, { type: "Hub Project" })).toBeTruthy(); + expect( + verifyPredicate(chk, { typekeywords: "initiative|00f" }) + ).toBeTruthy("should have keyword"); + expect(getPredicateValue(chk, { group: null })).toEqual(["00c", "aa1"]); + }); + it("getConnectedProjectsQuery", () => { + const chk = getConnectedProjectsQuery(fixture); + expect(chk.targetEntity).toBe("item"); + // ensure we have type and keyword in predicate + expect(verifyPredicate(chk, { type: "Hub Project" })).toBeTruthy(); + expect( + verifyPredicate(chk, { typekeywords: "initiative|00f" }) + ).toBeTruthy("should have keyword"); + expect(getPredicateValue(chk, { group: null })).toEqual({ + any: [], + all: [], + not: ["00c", "aa1"], + }); + }); + it("getUnConnectedProjectsQuery", () => { + const chk = getUnConnectedProjectsQuery(fixture); + expect(chk.targetEntity).toBe("item"); + // ensure we have type and keyword in predicate + expect(verifyPredicate(chk, { type: "Hub Project" })).toBeTruthy(); + + expect(getPredicateValue(chk, { typekeywords: null })).toEqual( + { + not: ["initiative|00f"], + }, + "should have negated keyword" + ); + expect(getPredicateValue(chk, { group: null })).toEqual({ + any: [], + all: [], + not: ["00c", "aa1"], + }); + }); + }); + + describe("fetchAssociated:", () => { + let searchSpy: jasmine.Spy; + let fixture: IHubInitiative; + beforeEach(() => { + searchSpy = spyOn( + require("../../src/search/_internal/portalSearchItems"), + "portalSearchItemsAsItems" + ).and.callFake(() => + Promise.resolve({ + results: [ + { + id: "3ef", + title: "fake result", + type: "Hub Project", + tags: ["fake"], + }, + ], + }) + ); + // Minimal structure needed for these tests + fixture = { + name: "Fixture Initiative", + id: "00f", + catalog: { + schemaVersion: 1, + scopes: { + item: { + targetEntity: "item", + filters: [ + { + predicates: [ + { + group: ["00c", "aa1"], + }, + ], + }, + ], + }, + }, + }, + } as unknown as IHubInitiative; + }); + it("fetches associated projects", async () => { + const chk = await fetchAssociatedProjects(fixture, MOCK_AUTH); + expect(searchSpy).toHaveBeenCalled(); + // get the query + const qry = searchSpy.calls.argsFor(0)[0]; + // this should have the groups in the predicate + expect(getPredicateValue(qry, { group: null })).toEqual(["00c", "aa1"]); + expect(chk.length).toBe(1); + // verify conversion + expect(chk[0]).toEqual({ + id: "3ef", + name: "fake result", + type: "Hub Project", + }); + }); + it("fetches connected projects", async () => { + const chk = await fetchConnectedProjects(fixture, MOCK_AUTH); + expect(searchSpy).toHaveBeenCalled(); + // get the query + const qry = searchSpy.calls.argsFor(0)[0]; + // this should have the negated groups in the predicate + expect(getPredicateValue(qry, { group: null })).toEqual({ + any: [], + all: [], + not: ["00c", "aa1"], + }); + expect(chk.length).toBe(1); + // verify conversion + expect(chk[0]).toEqual({ + id: "3ef", + name: "fake result", + type: "Hub Project", + }); + }); + it("fetches unconnected projects", async () => { + const chk = await fetchUnConnectedProjects(fixture, MOCK_AUTH); + expect(searchSpy).toHaveBeenCalled(); + // get the query + const qry = searchSpy.calls.argsFor(0)[0]; + // this should have the negated groups in the predicate + expect(getPredicateValue(qry, { group: null })).toEqual({ + any: [], + all: [], + not: ["00c", "aa1"], + }); + expect(getPredicateValue(qry, { typekeywords: null })).toEqual( + { + not: ["initiative|00f"], + }, + "should have negated keyword" + ); + expect(chk.length).toBe(1); + // verify conversion + expect(chk[0]).toEqual({ + id: "3ef", + name: "fake result", + type: "Hub Project", + }); + }); + }); }); + +/** + * Helper to verify that a predicate exists in a query + * NOTE: This is NOT comprehensive! + * @param query + * @param expectedPredicate + */ +function verifyPredicate(query: IQuery, expectedPredicate: IPredicate) { + if (Object.keys(expectedPredicate).length > 1) { + throw new Error( + `verifyPredicate helper expects to check a single prop on the predicate.` + ); + } + // iterate the filtes in the query, looking for a predicate that has the prop + value + let present = false; + query.filters.forEach((filter) => { + filter.predicates.forEach((predicate) => { + // iterate the props on expected, and check if this predicate has the prop + value + Object.keys(expectedPredicate).forEach((key) => { + if (Array.isArray(predicate[key])) { + present = compareArrays(predicate[key], expectedPredicate[key]); + // compare arrays + } else { + // tslint:disable-next-line + if (predicate[key] == expectedPredicate[key]) { + present = true; + } + } + }); + }); + }); + return present; +} + +function getPredicateValue(query: IQuery, expectedPredicate: IPredicate): any { + let result: any; + query.filters.forEach((filter) => { + filter.predicates.forEach((predicate) => { + // iterate the props on expected, and check if this predicate has the prop + value + Object.keys(expectedPredicate).forEach((key) => { + if (predicate[key]) { + result = predicate[key]; + } + }); + }); + }); + return result; +} + +const compareArrays = (a: any[], b: any[]) => + // tslint:disable-next-line + a.length === b.length && a.every((element, index) => element == b[index]); diff --git a/packages/common/test/permissions/checkPermission.test.ts b/packages/common/test/permissions/checkPermission.test.ts index 1b015e7a33c..7517a7463b5 100644 --- a/packages/common/test/permissions/checkPermission.test.ts +++ b/packages/common/test/permissions/checkPermission.test.ts @@ -75,8 +75,15 @@ const TestPermissionPolicies: IPermissionPolicy[] = [ // }, ]; +/** + * FAKE IMPLEMENTATION SO WE DON'T TIE TESTS TO REAL PERMISSIONS + * @param permission + * @returns + */ function getPermissionPolicy(permission: Permission): IPermissionPolicy { - return TestPermissionPolicies.find((p) => p.permission === permission); + return TestPermissionPolicies.find( + (p) => p.permission === permission + ) as unknown as IPermissionPolicy; } describe("checkPermission:", () => { diff --git a/packages/common/test/projects/fetch.test.ts b/packages/common/test/projects/fetch.test.ts index d5db448e18f..fa7725cb2a5 100644 --- a/packages/common/test/projects/fetch.test.ts +++ b/packages/common/test/projects/fetch.test.ts @@ -5,6 +5,7 @@ import { cloneObject, enrichProjectSearchResult, fetchProject, + getConnectedInitiativesQuery, } from "../../src"; import { GUID, PROJECT_DATA, PROJECT_ITEM, PROJECT_LOCATION } from "./fixtures"; import { MOCK_AUTH } from "../mocks/mock-auth"; @@ -180,4 +181,24 @@ describe("project fetch module:", () => { expect(ro).toBe(hubRo); }); }); + + describe("getConnectedInitiativesQuery:", () => { + it("returns query if project is connected", () => { + const p: IHubProject = { + typeKeywords: ["initiative|00c", "initiative|00d"], + } as unknown as IHubProject; + const chk = getConnectedInitiativesQuery(p); + expect(chk.targetEntity).toEqual("item"); + expect(chk.filters[0].predicates[0].type).toBe("Hub Initiative"); + expect(chk.filters[0].predicates[0].id).toEqual(["00c", "00d"]); + }); + + it("returns null if project is not connected to any initatives", () => { + const p: IHubProject = { + typeKeywords: [], + } as unknown as IHubProject; + const chk = getConnectedInitiativesQuery(p); + expect(chk).toBeNull(); + }); + }); }); diff --git a/packages/common/test/search/_internal/getEntityTypeFromType.ts b/packages/common/test/search/_internal/getEntityTypeFromType.ts new file mode 100644 index 00000000000..01a4cf87faa --- /dev/null +++ b/packages/common/test/search/_internal/getEntityTypeFromType.ts @@ -0,0 +1,25 @@ +import { getEntityTypeFromType } from "../../../src/search/_internal/getEntityTypeFromType"; + +describe("getEntityTypeFromType:", () => { + it("check return values", () => { + expect(getEntityTypeFromType("Web Mapping Application")).toEqual( + "item", + "Random type returns item" + ); + expect(getEntityTypeFromType("Group")).toEqual( + "group", + "Group returns group" + ); + expect(getEntityTypeFromType("user")).toEqual( + "user", + "case does not matter" + ); + expect(getEntityTypeFromType("EVENT")).toEqual( + "event", + "case does not matter" + ); + expect(getEntityTypeFromType("Channel")).toEqual("channel"); + + expect(getEntityTypeFromType("GROUP member")).toEqual("groupMember"); + }); +}); diff --git a/packages/common/test/search/_internal/negateGroupPredicates.test.ts b/packages/common/test/search/_internal/negateGroupPredicates.test.ts new file mode 100644 index 00000000000..a1201d412a7 --- /dev/null +++ b/packages/common/test/search/_internal/negateGroupPredicates.test.ts @@ -0,0 +1,107 @@ +import { IQuery } from "../../../src"; +import { negateGroupPredicates } from "../../../src/search/_internal/negateGroupPredicates"; + +describe("negateGroupPredicates:", () => { + it("returns undefined if not passed a query", () => { + expect(negateGroupPredicates(null as unknown as IQuery)).toBeUndefined(); + expect( + negateGroupPredicates(undefined as unknown as IQuery) + ).toBeUndefined(); + }); + it("does nothing if group predicate not present", () => { + const qry: IQuery = { + targetEntity: "item", + filters: [ + { + predicates: [ + { + id: "00c", + }, + ], + }, + ], + }; + const chk = negateGroupPredicates(qry); + expect(chk.filters[0].predicates[0].id).toEqual({ any: ["00c"] }); + }); + it("negates simple group predicate", () => { + const qry: IQuery = { + targetEntity: "item", + filters: [ + { + predicates: [ + { + group: "00c", + }, + ], + }, + ], + }; + const chk = negateGroupPredicates(qry); + expect(chk.filters[0].predicates[0].group).toEqual({ + any: [], + all: [], + not: ["00c"], + }); + }); + it("negates group.any predicate", () => { + const qry: IQuery = { + targetEntity: "item", + filters: [ + { + predicates: [ + { + group: { any: ["00c"] }, + }, + ], + }, + ], + }; + const chk = negateGroupPredicates(qry); + expect(chk.filters[0].predicates[0].group).toEqual({ + any: [], + all: [], + not: ["00c"], + }); + }); + it("negates group.all predicate", () => { + const qry: IQuery = { + targetEntity: "item", + filters: [ + { + predicates: [ + { + group: { all: ["00c"] }, + }, + ], + }, + ], + }; + const chk = negateGroupPredicates(qry); + expect(chk.filters[0].predicates[0].group).toEqual({ + any: [], + all: [], + not: ["00c"], + }); + }); + it("negates group.all && group.all predicate", () => { + const qry: IQuery = { + targetEntity: "item", + filters: [ + { + predicates: [ + { + group: { all: ["00c"], any: ["cc3"] }, + }, + ], + }, + ], + }; + const chk = negateGroupPredicates(qry); + expect(chk.filters[0].predicates[0].group).toEqual({ + any: [], + all: [], + not: ["cc3", "00c"], + }); + }); +}); diff --git a/packages/common/test/search/utils.test.ts b/packages/common/test/search/utils.test.ts index ed8a0254382..efefb21b5fd 100644 --- a/packages/common/test/search/utils.test.ts +++ b/packages/common/test/search/utils.test.ts @@ -1,5 +1,10 @@ import { IGroup, ISearchOptions, IUser } from "@esri/arcgis-rest-portal"; -import { IHubSite, ISearchResponse } from "../../src"; +import { + IHubSite, + IQuery, + ISearchResponse, + getGroupPredicate, +} from "../../src"; import { IHubSearchResult, IRelativeDate } from "../../src/search"; import { expandApis, @@ -344,7 +349,10 @@ describe("Search Utils:", () => { id: "9001", type: "Feature Service", } as IHubSearchResult; - const result = getResultSiteRelativeLink(searchResult, null); + const result = getResultSiteRelativeLink( + searchResult, + null as unknown as IHubSite + ); expect(result).toBeUndefined(); }); it("returns undefined if result.links.siteRelative isn't present", () => { @@ -353,7 +361,10 @@ describe("Search Utils:", () => { type: "Feature Service", links: {}, } as IHubSearchResult; - const result = getResultSiteRelativeLink(searchResult, null); + const result = getResultSiteRelativeLink( + searchResult, + null as unknown as IHubSite + ); expect(result).toBeUndefined(); }); it("returns an unmodified siteRelative link if result isn't a Hub Page", () => { @@ -364,7 +375,10 @@ describe("Search Utils:", () => { siteRelative: "/foo/9001", }, } as IHubSearchResult; - const result = getResultSiteRelativeLink(searchResult, null); + const result = getResultSiteRelativeLink( + searchResult, + null as unknown as IHubSite + ); expect(result).toBe("/foo/9001"); }); it("returns a Hub Page result's unmodified siteRelative link if no site is included", () => { @@ -375,7 +389,10 @@ describe("Search Utils:", () => { siteRelative: "/foo/9001", }, } as IHubSearchResult; - const result = getResultSiteRelativeLink(searchResult, null); + const result = getResultSiteRelativeLink( + searchResult, + null as unknown as IHubSite + ); expect(result).toBe("/foo/9001"); }); it("returns a Hub Page result's unmodified siteRelative link if site has no pages", () => { @@ -418,4 +435,49 @@ describe("Search Utils:", () => { expect(result).toBe("/foo/bar"); }); }); + + describe("getGroupPredicate:", () => { + it("returns undefined if no predicate with group found", () => { + const qry: IQuery = { + targetEntity: "item", + filters: [ + { + predicates: [ + { + id: "00c", + }, + ], + }, + ], + }; + const chk = getGroupPredicate(qry); + expect(chk).toBeUndefined(); + }); + + it("returns expanded group predicate", () => { + const qry: IQuery = { + targetEntity: "item", + filters: [ + { + predicates: [ + { + type: "Funnel Cake", + group: "00c", + }, + ], + }, + ], + }; + const chk = getGroupPredicate(qry); + + expect(chk).toEqual({ + type: { + any: ["Funnel Cake"], + }, + group: { + any: ["00c"], + }, + }); + }); + }); }); diff --git a/packages/common/test/utils/memoize.test.ts b/packages/common/test/utils/memoize.test.ts index 99e20f56e06..fe626fcb3e5 100644 --- a/packages/common/test/utils/memoize.test.ts +++ b/packages/common/test/utils/memoize.test.ts @@ -36,7 +36,6 @@ describe("memoize:", () => { // new call, calls the underlying function expect(memoizedFn(9, 1)).toEqual(10); expect(callCount).toBe(3); - // End test - nuke the entire cache clearMemoizedCache(); });