diff --git a/packages/common/e2e/associations.e2e.ts b/packages/common/e2e/associations.e2e.ts deleted file mode 100644 index 5c9cf0508d9..00000000000 --- a/packages/common/e2e/associations.e2e.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { - IHubInitiative, - fetchAcceptedProjects, - fetchHubEntity, - fetchPendingProjects, -} from "../src"; -import Artifactory from "./helpers/Artifactory"; -import config from "./helpers/config"; -import { - ICreateOutput, - createInitiative, - createProjects, - createScopeGroup, -} from "./helpers/metric-fixtures-crud"; - -const PAIGE_TEST_ITEMS = { - initiative: "14889476c9fd46dbabd694bfd6f65dc4", - projects: [ - "1001b7f5150f4b648e61e8c812037921", - "be83e401f9994b93bb2ead0c96c45c9c", - "56e11f847fbb464282eb990cbd139cbd", - "8cc00de75b82414c8c0761aa4300bae3", - "e4852c6189f342399f2af0b69f2558c8", - "68f0231fe334405d8d863c6afedf8a04", - "e5abc74668714e84bb8c56f6c97e5c95", - "e06d9f783bff4e3595843f432a4957b5", - "c49dbaa2ea6045cbb12cefe739f871b0", - "238352acd82d4b55aa59741bba8c269e", - "30fde25db8884a40acff2c39b1e9d075", - "dc46f405197d4111a7584fbdef14c6c9", - ], -}; - -const TEST_ITEMS = { - initiative: "7496421b25db44178bf8993d4eb368a5", - projects: [ - "93b53647d84540b9ac4f97891723992c", - "674cec049f5a476ba5417fdf92be0e4c", - "4a25fa2d42b74190b6c2ca0ddabced00", - "85b59fedce9f4d44b8aa47eb580eae01", - "2a6a95d2066e4f3986cccfe81defc45b", - "1bf6055230934e92bca7dfa214507cab", - "e70ad618cb174a0181e792a461d9643d", - "9bcce39151544569a0fe1ea850861df0", - "5cad311f404c4af6be6e64bcf547bf16", - "06c9f201111643179ab8029c91baaa2a", - "980f7687a54e49b29f6ab5cc3903eb5e", - "2d13d504997740c1acb50b2b7a131ee7", - ], -}; - -describe("associations development harness:", () => { - let factory: Artifactory; - const orgName = "hubPremiumAlpha"; - beforeAll(() => { - factory = new Artifactory(config); - jasmine.DEFAULT_TIMEOUT_INTERVAL = 200000; - }); - xdescribe("create harness items:", () => { - it("create initative and projects", async () => { - const created: ICreateOutput[] = []; - const ctxMgr = await factory.getContextManager(orgName, "paige"); - const configs = [{ key: "Assoc With Metrics", count: 12, groupId: "" }]; - try { - for (const cfg of configs) { - // create the group that will be the Initaitive's Project Collection Scope - const group = await createScopeGroup(cfg, ctxMgr.context); - cfg.groupId = group.id; - // create initiative with metric definitions and project collection scope - const initiative = await createInitiative(cfg, ctxMgr.context); - // create projets with metric values, shared to the group - const projects = await createProjects( - cfg, - initiative.id, - ctxMgr.context - ); - created.push({ - group, - initiative, - projects, - }); - } - } finally { - for (const items of created) { - const initiative = items.initiative.toJson(); - /* tslint:disable no-console */ - console.info(`Initiative: ${initiative.id} Group: ${items.group.id}`); - items.projects.forEach((project) => { - /* tslint:disable no-console */ - console.log(`Project: ${project.id}`); - }); - // debugger; - // await cleanupItems(items, ctxMgr.context); - } - } - }); - }); - describe("flex the functions:", () => { - it("search for accepted projects", async () => { - const ctxMgr = await factory.getContextManager(orgName, "admin"); - const context = ctxMgr.context; - const entity = (await fetchHubEntity( - "initiative", - TEST_ITEMS.initiative, - context - )) as IHubInitiative; - // debugger; - const projects = await fetchAcceptedProjects( - entity, - context.hubRequestOptions - ); - expect(projects.length).toBe(6); - }); - - it("search for pending projects", async () => { - const ctxMgr = await factory.getContextManager(orgName, "admin"); - const context = ctxMgr.context; - const entity = (await fetchHubEntity( - "initiative", - TEST_ITEMS.initiative, - context - )) as IHubInitiative; - const projects = await fetchPendingProjects( - entity, - context.hubRequestOptions - ); - - expect(projects.length).toBe(6); - }); - }); -}); diff --git a/packages/common/src/associations/addAssociation.ts b/packages/common/src/associations/addAssociation.ts index 69ef6172f36..5de3fb1258f 100644 --- a/packages/common/src/associations/addAssociation.ts +++ b/packages/common/src/associations/addAssociation.ts @@ -2,6 +2,9 @@ import { IWithAssociations } from "../core/traits/IWithAssociations"; import { IAssociationInfo } from "./types"; /** + * ** DEPRECATED: please use requestAssociation instead. + * This will be removed in the next breaking version ** + * * Add an association to an entity * Persisted into the entity's `.typeKeywords` array * @param info diff --git a/packages/common/src/associations/breakAssociation.ts b/packages/common/src/associations/breakAssociation.ts new file mode 100644 index 00000000000..0b5eaaa6f5a --- /dev/null +++ b/packages/common/src/associations/breakAssociation.ts @@ -0,0 +1,70 @@ +import { unshareItemWithGroup } from "@esri/arcgis-rest-portal"; +import { IArcGISContext } from "../ArcGISContext"; +import { getProp } from "../objects"; +import { fetchHubEntity } from "../core"; +import { HubEntity, HubEntityType } from "../core/types"; +import { getTypeFromEntity } from "../core/getTypeFromEntity"; +import { updateHubEntity } from "../core/updateHubEntity"; +import { getAssociationHierarchy } from "./internal/getAssociationHierarchy"; +import { isAssociationSupported } from "./internal/isAssociationSupported"; +import { removeAssociationKeyword } from "./internal/removeAssociationKeyword"; + +/** + * When an entity decides it wants to "disconnect" itself + * from an existing association, half of the association + * "connection" is broken. + * + * from the parent's perspective: the parent removes + * the child from its association group + * + * From the child's perspective: the child removes + * the parent reference (ref||) + * from its typeKeywords + * + * @param entity - entity initiating the disconnection + * @param type - type of the entity the initiating entity wants to disconnect from + * @param id - id of the entity the initiating entity wants to disconnect from + * @param context - contextual portal and auth information + */ +export const breakAssociation = async ( + entity: HubEntity, + associationType: HubEntityType, + id: string, + context: IArcGISContext +): Promise => { + const entityType = getTypeFromEntity(entity); + const isSupported = isAssociationSupported(entityType, associationType); + + if (!isSupported) { + throw new Error( + `breakAssociation: Association between ${entityType} and ${associationType} is not supported.` + ); + } + + const associationHierarchy = getAssociationHierarchy(entityType); + const isParent = associationHierarchy.children.includes(associationType); + + if (isParent) { + const associationGroupId = getProp(entity, "associations.groupId"); + const { owner } = await fetchHubEntity(associationType, id, context); + try { + await unshareItemWithGroup({ + id, + groupId: associationGroupId, + authentication: context.session, + owner, + }); + } catch (error) { + throw new Error( + `breakAssociation: there was an error unsharing ${id} from ${associationGroupId}: ${error}` + ); + } + } else { + entity.typeKeywords = removeAssociationKeyword( + entity.typeKeywords, + associationType, + id + ); + await updateHubEntity(entityType, entity, context); + } +}; diff --git a/packages/common/src/associations/getAssociatedEntitiesQuery.ts b/packages/common/src/associations/getAssociatedEntitiesQuery.ts new file mode 100644 index 00000000000..5ad3fc932bd --- /dev/null +++ b/packages/common/src/associations/getAssociatedEntitiesQuery.ts @@ -0,0 +1,51 @@ +import { getTypeFromEntity } from "../core/getTypeFromEntity"; +import { IQuery } from "../search/types"; +import { HubEntity, HubEntityType } from "../core/types"; +import { getAssociationHierarchy } from "./internal/getAssociationHierarchy"; +import { isAssociationSupported } from "./internal/isAssociationSupported"; +import { getIncludesAndReferencesQuery } from "./internal/getIncludesAndReferencesQuery"; +import { IArcGISContext } from "../ArcGISContext"; + +/** + * Associated entities are those which have mutually + * "agreed" to be connected with one another. They + * require a two-way "connection" between parent/child: + * + * parent: "includes" the child in its association query + * child: "references" the parent via a typeKeyword of + * the form ref|| + * + * The following returns a query to view an entity's + * associations with another entity type + * + * @param entity - Hub entity + * @param associationType - entity type to query for + * @param context - contextual auth and portal information + * @returns {IQuery} + */ +export const getAssociatedEntitiesQuery = async ( + entity: HubEntity, + associationType: HubEntityType, + context: IArcGISContext +): Promise => { + const entityType = getTypeFromEntity(entity); + const isSupported = isAssociationSupported(entityType, associationType); + + if (!isSupported) { + throw new Error( + `getAssociatedEntitiesQuery: Association between ${entityType} and ${associationType} is not supported.` + ); + } + + const associationHierarchy = getAssociationHierarchy(entityType); + const isParent = associationHierarchy.children.includes(associationType); + + const query = await getIncludesAndReferencesQuery( + entity, + associationType, + isParent, + context + ); + + return query; +}; diff --git a/packages/common/src/associations/getAssociationStats.ts b/packages/common/src/associations/getAssociationStats.ts new file mode 100644 index 00000000000..51faeb8a7f8 --- /dev/null +++ b/packages/common/src/associations/getAssociationStats.ts @@ -0,0 +1,76 @@ +import { IArcGISContext } from "../ArcGISContext"; +import { getTypeFromEntity } from "../core/getTypeFromEntity"; +import { HubEntity, HubEntityType } from "../core/types"; +import { IQuery } from "../search/types"; +import { hubSearch } from "../search/hubSearch"; +import { getAssociatedEntitiesQuery } from "./getAssociatedEntitiesQuery"; +import { getPendingEntitiesQuery } from "./getPendingEntitiesQuery"; +import { getRequestingEntitiesQuery } from "./getRequestingEntitiesQuery"; +import { getAssociationHierarchy } from "./internal/getAssociationHierarchy"; +import { isAssociationSupported } from "./internal/isAssociationSupported"; +import { IAssociationStats } from "./types"; + +/** + * get an entity's association stats - # of associated, pending, + * requesting, included/referenced entities + * + * @param entity - Hub entity + * @param associationType - entity type to query for + * @param context - contextual auth and portal information + * @returns + */ +export const getAssociationStats = async ( + entity: HubEntity, + associationType: HubEntityType, + context: IArcGISContext +): Promise => { + let stats: IAssociationStats; + const entityType = getTypeFromEntity(entity); + const isSupported = isAssociationSupported(entityType, associationType); + + if (!isSupported) { + throw new Error( + `getAssociationStats: Association between ${entityType} and ${associationType} is not supported.` + ); + } + + const associationHierarchy = getAssociationHierarchy(entityType); + const isParent = associationHierarchy.children.includes(associationType); + + stats = { + associated: 0, + pending: 0, + requesting: 0, + ...(isParent ? { included: 0 } : { referenced: 0 }), + }; + + try { + const queries = await Promise.all([ + getAssociatedEntitiesQuery(entity, associationType, context), + getPendingEntitiesQuery(entity, associationType, context), + getRequestingEntitiesQuery(entity, associationType, context), + ]); + + const [{ total: associated }, { total: pending }, { total: requesting }] = + await Promise.all( + queries.map((query: IQuery) => { + return hubSearch(query, { + requestOptions: context.hubRequestOptions, + }); + }) + ); + + stats = { + associated, + pending, + requesting, + ...(isParent + ? { included: associated + pending } + : { referenced: associated + pending }), + }; + } catch (error) { + return stats; + } + + return stats; +}; diff --git a/packages/common/src/associations/getAvailableToRequestEntitiesQuery.ts b/packages/common/src/associations/getAvailableToRequestEntitiesQuery.ts new file mode 100644 index 00000000000..06e647b446d --- /dev/null +++ b/packages/common/src/associations/getAvailableToRequestEntitiesQuery.ts @@ -0,0 +1,80 @@ +import { getTypeFromEntity } from "../core/getTypeFromEntity"; +import { IQuery } from "../search/types"; +import { HubEntity, HubEntityType } from "../core/types"; +import { getAssociationHierarchy } from "./internal/getAssociationHierarchy"; +import { isAssociationSupported } from "./internal/isAssociationSupported"; +import { getProp } from "../objects/get-prop"; +import { getTypesFromEntityType } from "../core/getTypesFromEntityType"; +import { getIdsFromKeywords } from "./internal/getIdsFromKeywords"; +import { getTypeByNotIdsQuery } from "./internal/getTypeByNotIdsQuery"; +import { negateGroupPredicates } from "../search/_internal/negateGroupPredicates"; +import { getTypeByIdsQuery } from "./internal/getTypeByIdsQuery"; +import { combineQueries } from "../search/_internal/combineQueries"; + +/** + * An entity can send an "outgoing" request to associate + * itself with another entity. The following query returns + * a set of entities that the requesting entity can still + * request: + * + * from a parent perspective: returns a set of children + * that are NOT "included" in the parent's association group + * + * from a child perspective: returns a set of parents that + * the child does not "reference" with via a typeKeyword of + * the form ref|| + * + * @param entity - entity requesting association + * @param associationType - type of entity the requesting entity wants to associate with + * @param context - contextual auth and portal information + * @returns {IQuery} + */ +export const getAvailableToRequestEntitiesQuery = ( + entity: HubEntity, + associationType: HubEntityType +): IQuery => { + let query: IQuery; + const entityType = getTypeFromEntity(entity); + const isSupported = isAssociationSupported(entityType, associationType); + + if (!isSupported) { + throw new Error( + `getAvailableToRequestEntitiesQuery: Association between ${entityType} and ${associationType} is not supported.` + ); + } + + const associationHierarchy = getAssociationHierarchy(entityType); + const isParent = associationHierarchy.children.includes(associationType); + + if (isParent) { + /** 1. build query that returns child entities */ + const childType = getTypesFromEntityType(associationType); + const childTypeQuery = getTypeByIdsQuery(childType, []); + + /** + * 2. grab the parent's association query and negate + * the group predicate + */ + const notIncludedQuery = negateGroupPredicates( + getProp(entity, "associations.rules.query") + ); + + /** 3. combine queries - will remove null/undefined entries */ + query = combineQueries([notIncludedQuery, childTypeQuery]); + } else { + /** + * 1. iterate over the child's typeKeywords and grab the parent + * ids it references (typeKeyword = |) + */ + const ids = getIdsFromKeywords(entity, associationType); + + /** + * 2. build query that returns parent entities NOT + * "referenced" by the child + */ + const type = getTypesFromEntityType(associationType); + query = getTypeByNotIdsQuery(type, ids); + } + + return query; +}; diff --git a/packages/common/src/associations/getPendingEntitiesQuery.ts b/packages/common/src/associations/getPendingEntitiesQuery.ts new file mode 100644 index 00000000000..23bda573cbf --- /dev/null +++ b/packages/common/src/associations/getPendingEntitiesQuery.ts @@ -0,0 +1,64 @@ +import { getTypeFromEntity } from "../core/getTypeFromEntity"; +import { IQuery } from "../search/types"; +import { HubEntity, HubEntityType } from "../core/types"; +import { getAssociationHierarchy } from "./internal/getAssociationHierarchy"; +import { getReferencesDoesNotIncludeQuery } from "./internal/getReferencesDoesNotIncludeQuery"; +import { getIncludesDoesNotReferenceQuery } from "./internal/getIncludesDoesNotReferenceQuery"; +import { isAssociationSupported } from "./internal/isAssociationSupported"; +import { IArcGISContext } from ".."; + +/** + * Pending entities represent "outgoing" requests that are + * awaiting "approval". They imply a one-way "connection" + * between parent/child. + * + * From the parent's perspective: + * parent: "includes" the child in its association query + * child: does NOT "reference" the parent via a typeKeyword + * + * From the child's perspective: + * parent: does NOT "include" the child in its association query + * child: "references" the parent via a typeKeyword of the + * form ref|| + * + * The following returns a query to view an entity's outgoing + * requests for association with another entity type + * + * @param entity - Hub entity + * @param associationType - entity type to query for + * @param context - contextual auth and portal information + * @returns {IQuery} + */ +export const getPendingEntitiesQuery = async ( + entity: HubEntity, + associationType: HubEntityType, + context: IArcGISContext +): Promise => { + const entityType = getTypeFromEntity(entity); + const isSupported = isAssociationSupported(entityType, associationType); + + if (!isSupported) { + throw new Error( + `getPendingEntitiesQuery: Association between ${entityType} and ${associationType} is not supported.` + ); + } + + const associationHierarchy = getAssociationHierarchy(entityType); + const isParent = associationHierarchy.children.includes(associationType); + + const query = isParent + ? await getIncludesDoesNotReferenceQuery( + entity, + associationType, + isParent, + context + ) + : await getReferencesDoesNotIncludeQuery( + entity, + associationType, + isParent, + context + ); + + return query; +}; diff --git a/packages/common/src/associations/getRequestingEntitiesQuery.ts b/packages/common/src/associations/getRequestingEntitiesQuery.ts new file mode 100644 index 00000000000..0f31d849830 --- /dev/null +++ b/packages/common/src/associations/getRequestingEntitiesQuery.ts @@ -0,0 +1,64 @@ +import { getTypeFromEntity } from "../core/getTypeFromEntity"; +import { IQuery } from "../search/types"; +import { HubEntity, HubEntityType } from "../core/types"; +import { getAssociationHierarchy } from "./internal/getAssociationHierarchy"; +import { getReferencesDoesNotIncludeQuery } from "./internal/getReferencesDoesNotIncludeQuery"; +import { getIncludesDoesNotReferenceQuery } from "./internal/getIncludesDoesNotReferenceQuery"; +import { isAssociationSupported } from "./internal/isAssociationSupported"; +import { IArcGISContext } from ".."; + +/** + * Requesting entities represent "incoming" requests that are + * awaiting "approval". They imply a one-way "connection" + * between parent/child. + * + * From the parent's perspective: + * parent: does NOT "include" the child in its association query + * child: "references" the parent via a typeKeyword of the + * form ref|| + * + * From the child's perspective: + * parent: "includes" the child in its association query + * child: does NOT "reference" the parent via a typeKeyword + * + * The following returns a query to view an entity's incoming + * requests for association with another entity type + * + * @param entity - Hub entity + * @param associationType - entity type to query for + * @param context - contextual auth and portal information + * @returns {IQuery} + */ +export const getRequestingEntitiesQuery = async ( + entity: HubEntity, + associationType: HubEntityType, + context: IArcGISContext +): Promise => { + const entityType = getTypeFromEntity(entity); + const isSupported = isAssociationSupported(entityType, associationType); + + if (!isSupported) { + throw new Error( + `getRequestingEntitiesQuery: Association between ${entityType} and ${associationType} is not supported.` + ); + } + + const associationHierarchy = getAssociationHierarchy(entityType); + const isParent = associationHierarchy.children.includes(associationType); + + const query = isParent + ? await getReferencesDoesNotIncludeQuery( + entity, + associationType, + isParent, + context + ) + : await getIncludesDoesNotReferenceQuery( + entity, + associationType, + isParent, + context + ); + + return query; +}; diff --git a/packages/common/src/associations/getWellKnownAssociationsCatalog.ts b/packages/common/src/associations/getWellKnownAssociationsCatalog.ts new file mode 100644 index 00000000000..167a02dcb90 --- /dev/null +++ b/packages/common/src/associations/getWellKnownAssociationsCatalog.ts @@ -0,0 +1,123 @@ +import { IArcGISContext } from "../ArcGISContext"; +import { getTypeFromEntity } from "../core"; +import { HubEntity, HubEntityType } from "../core/types"; +import { buildCatalog } from "../search/_internal/buildCatalog"; +import { getEntityTypeFromType } from "../search/_internal/getEntityTypeFromType"; +import { getAssociatedEntitiesQuery } from "./getAssociatedEntitiesQuery"; +import { getPendingEntitiesQuery } from "./getPendingEntitiesQuery"; +import { getAvailableToRequestEntitiesQuery } from "./getAvailableToRequestEntitiesQuery"; +import { getRequestingEntitiesQuery } from "./getRequestingEntitiesQuery"; +import { isAssociationSupported } from "./internal/isAssociationSupported"; +import { IHubCatalog, IQuery } from "../search/types"; +import { + WellKnownCollection, + dotifyString, + getWellknownCollection, +} from "../search/wellKnownCatalog"; + +/** + * Supported association catalogs that can be requested. + * These correspond to a specific IHubCatalog definition + * that gets returned. + */ +export type WellKnownAssociationCatalog = + | "associated" + | "pending" + | "requesting" + | "availableToRequest"; + +/** + * There are two primary UI workflows when we consider associations: + * 1. Viewing associations + * 2. Forming associations + * + * Because associations involve a 2-way agreement between parent + * and child, when viewing associations, there are 3 gallery states + * that can be viewed: "associated", "pending", and "requesting" + * entities. Additionally, when forming associations, we need a + * picker experience filtered to entities that can still be + * requested for association. + * + * These define the "well-known" association catalogs that this + * util can return. In turn, these can be passed into the catalog + * and/or gallery-picker components to render the appropriate + * UI experience. + * + * @param i18nScope - translation scope to be interpolated into the catalog + * @param catalogName - name of the well-known catalog requested + * @param entity - primary entity the catalog is being built for + * @param associationType - type of entity the primary entity wants to view associations for + * @param context - contextual auth and portal information + * @returns {IHubCatalog} + */ +export async function getWellKnownAssociationsCatalog( + i18nScope: string, + catalogName: WellKnownAssociationCatalog, + entity: HubEntity, + associationType: HubEntityType, + context: IArcGISContext +): Promise { + let catalog: IHubCatalog; + const entityType = getTypeFromEntity(entity); + const isSupported = isAssociationSupported(entityType, associationType); + + if (!isSupported) { + throw new Error( + `getWellKnownAssociationsCatalog: Association between ${entityType} and ${associationType} is not supported.` + ); + } + + i18nScope = dotifyString(i18nScope); + const targetEntity = getEntityTypeFromType(entity.type); + + /** 1. build a collection based on the provided associationType */ + const collections = [ + getWellknownCollection( + i18nScope, + targetEntity, + associationType as WellKnownCollection + ), + ]; + + /** 2. build a query based on the provided catalogName */ + let query: IQuery; + switch (catalogName) { + case "associated": + query = await getAssociatedEntitiesQuery( + entity, + associationType, + context + ); + break; + case "pending": + query = await getPendingEntitiesQuery(entity, associationType, context); + break; + case "requesting": + query = await getRequestingEntitiesQuery( + entity, + associationType, + context + ); + break; + case "availableToRequest": + query = getAvailableToRequestEntitiesQuery(entity, associationType); + break; + } + + /** 3. build the well-known catalog */ + // if query filters are undefined (e.g. query = null), we assume + // an empty state, and we need to construct a default query + // filter that will return no results + const filters = query?.filters + ? query.filters + : [{ predicates: [{ type: ["Code Attachment"] }] }]; + catalog = buildCatalog( + i18nScope, + catalogName, + filters, + collections, + targetEntity + ); + + return catalog; +} diff --git a/packages/common/src/associations/index.ts b/packages/common/src/associations/index.ts index 6f16f7cf121..3a7ef424e50 100644 --- a/packages/common/src/associations/index.ts +++ b/packages/common/src/associations/index.ts @@ -1,4 +1,16 @@ export * from "./addAssociation"; +export * from "./breakAssociation"; export * from "./listAssociations"; export * from "./removeAssociation"; export * from "./types"; +export * from "./getAssociatedEntitiesQuery"; +export * from "./getAssociationStats"; +export * from "./getAvailableToRequestEntitiesQuery"; +export * from "./getPendingEntitiesQuery"; +export * from "./getRequestingEntitiesQuery"; +export * from "./getWellKnownAssociationsCatalog"; +// Note: we expose "requestAssociation" under 2 names. +// These actions are functionally equivalent, but we want +// to make the intent more clear to the consumer. +export { requestAssociation } from "./requestAssociation"; +export { requestAssociation as acceptAssociation } from "./requestAssociation"; diff --git a/packages/common/src/associations/internal/getAssociationHierarchy.ts b/packages/common/src/associations/internal/getAssociationHierarchy.ts new file mode 100644 index 00000000000..f981f2564aa --- /dev/null +++ b/packages/common/src/associations/internal/getAssociationHierarchy.ts @@ -0,0 +1,40 @@ +import { HubEntityType } from "../../core/types"; +import { IHubAssociationHierarchy } from "../../associations/types"; +import { ProjectAssociationHierarchies } from "../../projects/_internal/ProjectAssociationHierarchies"; +import { InitiativeAssociationHierarchies } from "../../initiatives/_internal/InitiativeAssociationHierarchies"; + +/** + * associations are hierarchical in nature, e.g. + * there is always a parent and a child involved + * in the relationship. + * + * given an entity type, this util returns the + * parent and children entity types that it can + * associate with + * + * @param type - entity type + * @returns {IHubAssociationHierarchy} + */ +export const getAssociationHierarchy = ( + type: HubEntityType +): IHubAssociationHierarchy => { + let hierarchy: IHubAssociationHierarchy = { + children: [], + parents: [], + }; + switch (type) { + case "initiative": + hierarchy = InitiativeAssociationHierarchies; + break; + case "project": + hierarchy = ProjectAssociationHierarchies; + break; + // as we support more entity associations, we'll need to extend this + default: + throw new Error( + `getAssociationHierarchy: Invalid type for associations: ${type}.` + ); + } + + return hierarchy; +}; diff --git a/packages/common/src/associations/internal/getIdsFromAssociationGroups.ts b/packages/common/src/associations/internal/getIdsFromAssociationGroups.ts new file mode 100644 index 00000000000..1fe082cf50e --- /dev/null +++ b/packages/common/src/associations/internal/getIdsFromAssociationGroups.ts @@ -0,0 +1,34 @@ +import { IGroup } from "@esri/arcgis-rest-types"; +import { HubEntityType } from "../../core"; + +/** + * given an array of groups, this util maps over them, + * and for each, determines if it's an association group + * by checking if it has a typeKeyword of the form + * |. If so, it extracts and returns + * the id which corresponds to the parent entity that this + * association group belongs to + * + * @param groups - array of groups + * @param associationType - entity type to extract ids for + * @returns {string[]} + */ +export const getIdsFromAssociationGroups = ( + groups: IGroup[], + associationType: HubEntityType +): string[] => { + return groups.reduce((ids: string[], group: IGroup) => { + // 1. determine if the group is an association group + const associationTypeKeyword = group.typeKeywords.find((keyword: string) => + keyword.startsWith(`${associationType}|`) + ); + + // 2. if so, store the parent id from the typeKeyword to return + if (associationTypeKeyword) { + const id = associationTypeKeyword.split("|")[1]; + ids.push(id); + } + + return ids; + }, []); +}; diff --git a/packages/common/src/associations/internal/getIdsFromKeywords.ts b/packages/common/src/associations/internal/getIdsFromKeywords.ts new file mode 100644 index 00000000000..142da786f93 --- /dev/null +++ b/packages/common/src/associations/internal/getIdsFromKeywords.ts @@ -0,0 +1,29 @@ +import { HubEntity, HubEntityType } from "../../core"; +import { getProp } from "../../objects"; + +/** + * given a hub entity, this util maps over its typeKeywords, + * and for each, determines if it is an association typeKeyword + * by checking whether it has the form ref||. + * If so, it extracts and returns the id which correspond to the + * parent entity that this entity is associated with. + * + * @param entity - hub entity to extract ids from + * @param associationType - entity type to extract ids for + * @returns {string[]} + */ +export const getIdsFromKeywords = ( + entity: HubEntity, + associationType: HubEntityType +): string[] => { + return getProp(entity, "typeKeywords").reduce( + (ids: string[], keyword: string) => { + if (keyword.startsWith(`ref|${associationType}|`)) { + const id = keyword.split("|")[2]; + ids.push(id); + } + return ids; + }, + [] + ); +}; diff --git a/packages/common/src/associations/internal/getIncludesAndReferencesQuery.ts b/packages/common/src/associations/internal/getIncludesAndReferencesQuery.ts new file mode 100644 index 00000000000..6cebb27e4bd --- /dev/null +++ b/packages/common/src/associations/internal/getIncludesAndReferencesQuery.ts @@ -0,0 +1,85 @@ +import { IGroup, getItemGroups } from "@esri/arcgis-rest-portal"; +import { HubEntity, HubEntityType } from "../../core/types"; +import { getTypesFromEntityType } from "../../core/getTypesFromEntityType"; +import { getProp } from "../../objects/get-prop"; +import { IQuery } from "../../search/types"; +import { combineQueries } from "../../search/_internal/combineQueries"; +import { getTypeWithKeywordQuery } from "./getTypeWithKeywordQuery"; +import { IArcGISContext } from "../../ArcGISContext"; +import { getTypeByIdsQuery } from "./getTypeByIdsQuery"; +import { getTypeFromEntity } from "../../core/getTypeFromEntity"; +import { getIdsFromKeywords } from "./getIdsFromKeywords"; +import { getIdsFromAssociationGroups } from "./getIdsFromAssociationGroups"; + +/** + * builds a query that will return entities that are + * "included" AND "referenced" + * + * @param entity - Hub entity + * @param associationType - entity type to query for + * @param isParent - whether the provided Hub entity is the parent in the association relationship + * @param context - contextual auth and portal information + * @returns {IQuery} + */ +export const getIncludesAndReferencesQuery = async ( + entity: HubEntity, + associationType: HubEntityType, + isParent: boolean, + context: IArcGISContext +): Promise => { + if (isParent) { + /** + * 1. build query that returns child entities WITH a + * typeKeyword reference to the parent + */ + const parentType = getTypeFromEntity(entity); + const referencedQuery = getTypeWithKeywordQuery( + getTypesFromEntityType(associationType), + `ref|${parentType}|${entity.id}` + ); + + /** 2. grab the parent's association query */ + const includedQuery = getProp(entity, "associations.rules.query"); + + /** 3. combine queries - will remove null/undefined entries */ + return combineQueries([referencedQuery, includedQuery]); + } else { + /** 1. fetch the groups a child has been shared with */ + const { admin, member, other } = await getItemGroups( + entity.id, + context.requestOptions + ); + const groupsChildIsSharedWith = [...admin, ...member, ...other]; + + /** + * 2. filter the child's groups down to association groups + * (by checking if they have a typeKeyword of the form + * |) and extract parent ids + */ + const parentIdsThatIncludeChild = getIdsFromAssociationGroups( + groupsChildIsSharedWith, + associationType + ); + + /** + * 3. iterate over the child's typeKeywords and grab the parent + * ids it references (typeKeyword = |) + */ + const parentIdsChildReferences = getIdsFromKeywords( + entity, + associationType + ); + + /** + * 4. filter the parent ids down to those that include + * the child AND that the child references + */ + const parentIds = parentIdsThatIncludeChild.filter((id: string) => + parentIdsChildReferences.includes(id) + ); + + /** 5. return a query for the filtered parent ids */ + const type = getTypesFromEntityType(associationType); + return parentIds.length ? getTypeByIdsQuery(type, parentIds) : null; + } +}; diff --git a/packages/common/src/associations/internal/getIncludesDoesNotReferenceQuery.ts b/packages/common/src/associations/internal/getIncludesDoesNotReferenceQuery.ts new file mode 100644 index 00000000000..4516603049d --- /dev/null +++ b/packages/common/src/associations/internal/getIncludesDoesNotReferenceQuery.ts @@ -0,0 +1,85 @@ +import { getItemGroups } from "@esri/arcgis-rest-portal"; +import { HubEntity, HubEntityType } from "../../core/types"; +import { getTypesFromEntityType } from "../../core/getTypesFromEntityType"; +import { getProp } from "../../objects/get-prop"; +import { IQuery } from "../../search/types"; +import { combineQueries } from "../../search/_internal/combineQueries"; +import { getTypeWithoutKeywordQuery } from "./getTypeWithoutKeywordQuery"; +import { IArcGISContext } from "../../ArcGISContext"; +import { getTypeByIdsQuery } from "./getTypeByIdsQuery"; +import { getTypeFromEntity } from "../../core/getTypeFromEntity"; +import { getIdsFromKeywords } from "./getIdsFromKeywords"; +import { getIdsFromAssociationGroups } from "./getIdsFromAssociationGroups"; + +/** + * builds a query that will return entities that are + * "included" but NOT "referenced" + * + * @param entity - Hub entity + * @param associationType - entity type to query for + * @param isParent - whether the provided Hub entity is the parent in the association relationship + * @param context - contextual auth and portal information + * @returns {IQuery} + */ +export const getIncludesDoesNotReferenceQuery = async ( + entity: HubEntity, + associationType: HubEntityType, + isParent: boolean, + context: IArcGISContext +): Promise => { + if (isParent) { + /** + * 1. build query that returns child entities WITHOUT a + * typeKeyword reference to the parent + */ + const parentType = getTypeFromEntity(entity); + const referencedQuery = getTypeWithoutKeywordQuery( + getTypesFromEntityType(associationType), + `ref|${parentType}|${entity.id}` + ); + + /** 2. grab the parent entity's association query */ + const includedQuery = getProp(entity, "associations.rules.query"); + + /** 3. combine queries - will remove null/undefined entries */ + return combineQueries([referencedQuery, includedQuery]); + } else { + /** 1. fetch the groups a child has been shared with */ + const { admin, member, other } = await getItemGroups( + entity.id, + context.requestOptions + ); + const groupsChildIsSharedWith = [...admin, ...member, ...other]; + + /** + * 2. filter the child's groups down to association groups + * (by checking if they have a typeKeyword of the form + * |) and extract parent ids + */ + const parentIdsThatIncludeChild = getIdsFromAssociationGroups( + groupsChildIsSharedWith, + associationType + ); + + /** + * 3. iterate over the child's typeKeywords and grab the parent + * ids it references (typeKeyword = |) + */ + const parentIdsChildReferences = getIdsFromKeywords( + entity, + associationType + ); + + /** + * 4. filter the parent ids down to those that include the + * child, but that the child does NOT reference + */ + const parentIds = parentIdsThatIncludeChild.filter( + (id: string) => !parentIdsChildReferences.includes(id) + ); + + /** 5. return a query for the filtered parent ids */ + const type = getTypesFromEntityType(associationType); + return parentIds.length ? getTypeByIdsQuery(type, parentIds) : null; + } +}; diff --git a/packages/common/src/associations/internal/getReferencesDoesNotIncludeQuery.ts b/packages/common/src/associations/internal/getReferencesDoesNotIncludeQuery.ts new file mode 100644 index 00000000000..e8f372cab75 --- /dev/null +++ b/packages/common/src/associations/internal/getReferencesDoesNotIncludeQuery.ts @@ -0,0 +1,91 @@ +import { getItemGroups } from "@esri/arcgis-rest-portal"; +import { HubEntity, HubEntityType } from "../../core/types"; +import { getTypesFromEntityType } from "../../core/getTypesFromEntityType"; +import { getProp } from "../../objects/get-prop"; +import { IQuery } from "../../search/types"; +import { combineQueries } from "../../search/_internal/combineQueries"; +import { getTypeWithKeywordQuery } from "./getTypeWithKeywordQuery"; +import { negateGroupPredicates } from "../../search/_internal/negateGroupPredicates"; +import { IArcGISContext } from "../../ArcGISContext"; +import { getTypeByIdsQuery } from "./getTypeByIdsQuery"; +import { getTypeFromEntity } from "../../core/getTypeFromEntity"; +import { getIdsFromKeywords } from "./getIdsFromKeywords"; +import { getIdsFromAssociationGroups } from "./getIdsFromAssociationGroups"; + +/** + * builds a query that will return entities that are + * "referenced" but NOT "included" + * + * @param entity - Hub entity + * @param associationType - entity type to query for + * @param isParent - whether the provided Hub entity is the parent in the association relationship + * @param context - contextual auth and portal information + * @returns {IQuery} + */ +export const getReferencesDoesNotIncludeQuery = async ( + entity: HubEntity, + associationType: HubEntityType, + isParent: boolean, + context: IArcGISContext +): Promise => { + if (isParent) { + /** + * 1. build query that returns child entities WITH a + * typeKeyword reference to the parent + */ + const parentType = getTypeFromEntity(entity); + const referencedQuery = getTypeWithKeywordQuery( + getTypesFromEntityType(associationType), + `ref|${parentType}|${entity.id}` + ); + + /** + * 2. grab the parent's association query and negate + * the group predicate + */ + const notIncludedQuery = negateGroupPredicates( + getProp(entity, "associations.rules.query") + ); + + /** 3. combine queries - will remove null/undefined entries */ + return combineQueries([referencedQuery, notIncludedQuery]); + } else { + /** 1. fetch the groups a child has been shared with */ + const { admin, member, other } = await getItemGroups( + entity.id, + context.requestOptions + ); + const groupsChildIsSharedWith = [...admin, ...member, ...other]; + + /** + * 2. filter the child's groups down to association groups + * (by checking if they have a typeKeyword of the form + * |) and extract parent ids + */ + const parentIdsThatIncludeChild = getIdsFromAssociationGroups( + groupsChildIsSharedWith, + associationType + ); + + /** + * 3. iterate over the child's typeKeywords and grab the parent + * ids it references (typeKeyword = |) + */ + const parentIdsChildReferences = getIdsFromKeywords( + entity, + associationType + ); + + /** + * 4. filter the parent ids down to those that the child + * references but that the parent does NOT include + */ + const parentIds = parentIdsChildReferences.filter( + (id: string) => !parentIdsThatIncludeChild.includes(id) + ); + + /** 5. return a query for the filtered parent ids */ + const type = getTypesFromEntityType(associationType); + return parentIds.length ? getTypeByIdsQuery(type, parentIds) : null; + } +}; diff --git a/packages/common/src/associations/internal/getTargetEntityFromAssociationType.ts b/packages/common/src/associations/internal/getTargetEntityFromAssociationType.ts deleted file mode 100644 index a9613d91fd9..00000000000 --- a/packages/common/src/associations/internal/getTargetEntityFromAssociationType.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { EntityType } from "../../search/types/IHubCatalog"; -import { AssociationType } from "../types"; - -/** - * Get the entity item type for an association type - * @param type - * @returns - */ -export function getTargetEntityFromAssociationType( - type: AssociationType -): EntityType { - let entityType: EntityType = "item"; - - switch (type) { - case "initiative": - entityType = "item"; - break; - // as we add more association types we need to extend this hash - default: - throw new Error( - `getTargetEntityFromAssociationType: Invalid association type ${type}.` - ); - } - return entityType; -} diff --git a/packages/common/src/associations/internal/getTypeByIdsQuery.ts b/packages/common/src/associations/internal/getTypeByIdsQuery.ts index 9d9048c9be9..8120a87a516 100644 --- a/packages/common/src/associations/internal/getTypeByIdsQuery.ts +++ b/packages/common/src/associations/internal/getTypeByIdsQuery.ts @@ -2,25 +2,33 @@ import { getEntityTypeFromType } from "../../search/_internal/getEntityTypeFromT 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 + * @private + * Construct an IQuery to fetch a specified set of item id(s) + * by type(s). Note: if an array of types is provided, they + * must be the same underlying target entity type. * - * @param entity - * @param type - * @returns + * @param itemType - a single item type or an array of item types + * @param ids - an array of ids + * @returns {IQuery} */ -export function getTypeByIdsQuery(itemType: string, ids: string[]): IQuery { - const targetEntity = getEntityTypeFromType(itemType); +export function getTypeByIdsQuery( + itemType: string | string[], + ids: string[] +): IQuery { + const targetEntity = + typeof itemType === "string" + ? getEntityTypeFromType(itemType) + : getEntityTypeFromType(itemType[0]); const qry: IQuery = { targetEntity, filters: [ { - operation: "AND", + ...(ids.length && { operation: "AND" }), predicates: [ { type: itemType, - id: [...ids], + ...(ids.length && { id: ids }), }, ], }, diff --git a/packages/common/src/associations/internal/getTypeByNotIdsQuery.ts b/packages/common/src/associations/internal/getTypeByNotIdsQuery.ts new file mode 100644 index 00000000000..b4bfed3384f --- /dev/null +++ b/packages/common/src/associations/internal/getTypeByNotIdsQuery.ts @@ -0,0 +1,38 @@ +import { getEntityTypeFromType } from "../../search/_internal/getEntityTypeFromType"; +import { IQuery } from "../../search/types/IHubCatalog"; + +/** + * @private + * Construct an IQuery to fetch the inverse of a specified set + * of item id(s) by type(s). Note: if an array of types is provided, + * they must be the same underlying target entity type. + * + * @param itemType - a single item type or an array of item types + * @param ids - an array of ids + * @returns {IQuery} + */ +export function getTypeByNotIdsQuery( + itemType: string | string[], + ids: string[] +): IQuery { + const targetEntity = + typeof itemType === "string" + ? getEntityTypeFromType(itemType) + : getEntityTypeFromType(itemType[0]); + + const qry: IQuery = { + targetEntity, + filters: [ + { + ...(ids.length && { operation: "AND" }), + predicates: [ + { + type: itemType, + ...(ids.length && { id: { not: ids } }), + }, + ], + }, + ], + }; + return qry; +} diff --git a/packages/common/src/associations/internal/getTypeFromAssociationType.ts b/packages/common/src/associations/internal/getTypeFromAssociationType.ts deleted file mode 100644 index 3829e20d288..00000000000 --- a/packages/common/src/associations/internal/getTypeFromAssociationType.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { AssociationType } from "../types"; - -/** - * Get the item type for an association type - * @param type - * @returns - */ -export function getTypeFromAssociationType(type: AssociationType) { - let itemType = "Hub Initiative"; - switch (type) { - case "initiative": - itemType = "Hub Initiative"; - break; - // as we add more association types we need to extend this hash - default: - throw new Error( - `getTypeFromAssociationType: Invalid association type ${type}.` - ); - } - return itemType; -} diff --git a/packages/common/src/associations/internal/getTypeWithKeywordQuery.ts b/packages/common/src/associations/internal/getTypeWithKeywordQuery.ts index 7ab2af730f9..6141522a60d 100644 --- a/packages/common/src/associations/internal/getTypeWithKeywordQuery.ts +++ b/packages/common/src/associations/internal/getTypeWithKeywordQuery.ts @@ -3,17 +3,23 @@ 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 + * Construct an IQuery to fetch a set of items by type(s) + * with a specified typeKeyword. Note: if an array of types + * is provided, they must be the same underlying target + * entity type. + * + * @param itemType - The type(s) of item to fetch + * @param keyword - The typeKeyword to filter by * @returns */ export function getTypeWithKeywordQuery( - itemType: string, + itemType: string | string[], keyword: string ): IQuery { - const targetEntity = getEntityTypeFromType(itemType); + const targetEntity = + typeof itemType === "string" + ? getEntityTypeFromType(itemType) + : getEntityTypeFromType(itemType[0]); return { targetEntity, @@ -23,7 +29,7 @@ export function getTypeWithKeywordQuery( predicates: [ { type: itemType, - typekeywords: keyword, + typekeywords: [keyword], }, ], }, diff --git a/packages/common/src/associations/internal/getTypeWithoutKeywordQuery.ts b/packages/common/src/associations/internal/getTypeWithoutKeywordQuery.ts index 84eeb28cb6e..c07db496eab 100644 --- a/packages/common/src/associations/internal/getTypeWithoutKeywordQuery.ts +++ b/packages/common/src/associations/internal/getTypeWithoutKeywordQuery.ts @@ -3,17 +3,23 @@ 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 + * Construct an IQuery to fetch a set of items by type(s) + * withOUT a specified typeKeyword. Note: if an array of + * types is provided, they must be the same underlying target + * entity type. + * + * @param itemType - The type(s) of item to fetch + * @param keyword - The typeKeyword to filter by * @returns */ export function getTypeWithoutKeywordQuery( - itemType: string, + itemType: string | string[], keyword: string ): IQuery { - const targetEntity = getEntityTypeFromType(itemType); + const targetEntity = + typeof itemType === "string" + ? getEntityTypeFromType(itemType) + : getEntityTypeFromType(itemType[0]); return { targetEntity, diff --git a/packages/common/src/associations/internal/isAssociationSupported.ts b/packages/common/src/associations/internal/isAssociationSupported.ts new file mode 100644 index 00000000000..f7616fc2345 --- /dev/null +++ b/packages/common/src/associations/internal/isAssociationSupported.ts @@ -0,0 +1,31 @@ +import { HubEntityType } from "../../core/types"; +import { getAssociationHierarchy } from "./getAssociationHierarchy"; + +/** + * given two entity types, this util returns + * whether or not associations are supported + * between the two + * + * @param type1 - first entity type + * @param type2 - second entity type + * @returns {boolean} + */ +export const isAssociationSupported = ( + type1: HubEntityType, + type2: HubEntityType +): boolean => { + try { + const hierarchy1 = getAssociationHierarchy(type1); + const hierarchy2 = getAssociationHierarchy(type2); + + if (hierarchy1.children.includes(type2)) { + return hierarchy2.parents.includes(type1); + } else if (hierarchy1.parents.includes(type2)) { + return hierarchy2.children.includes(type1); + } else { + return false; + } + } catch (error) { + return false; + } +}; diff --git a/packages/common/src/associations/internal/removeAssociationKeyword.ts b/packages/common/src/associations/internal/removeAssociationKeyword.ts new file mode 100644 index 00000000000..498b0eef2a5 --- /dev/null +++ b/packages/common/src/associations/internal/removeAssociationKeyword.ts @@ -0,0 +1,24 @@ +import { HubEntityType } from "../../core/types"; + +/** + * when a child decides it wants to "disconnect" itself from + * an existing association, the child removes the typeKeyword + * (ref||) that "references" the parent + * + * @param typeKeywords - the child entity's typeKeywords + * @param type - the parent entity's type + * @param id - the parent entity's id + * @returns {string[]} + */ +export function removeAssociationKeyword( + typeKeywords: string[], + type: HubEntityType, + id: string +): string[] { + const associationKeyword = `ref|${type}|${id}`; + const filteredKeywords = typeKeywords.filter( + (keyword) => keyword !== associationKeyword + ); + + return filteredKeywords; +} diff --git a/packages/common/src/associations/internal/setAssociationKeyword.ts b/packages/common/src/associations/internal/setAssociationKeyword.ts new file mode 100644 index 00000000000..830f5f75a14 --- /dev/null +++ b/packages/common/src/associations/internal/setAssociationKeyword.ts @@ -0,0 +1,24 @@ +import { HubEntityType } from "../../core/types"; + +/** + * when a child sends an "outgoing" request or accepts an + * "incoming" request for association, the child "references" + * the parent via a typeKeyword (ref||) + * + * @param typeKeywords - the child entity's typeKeywords + * @param type - the parent entity's type + * @param id - the parent entity's id + * @returns {string[]} + */ +export function setAssociationKeyword( + typeKeywords: string[], + type: HubEntityType, + id: string +): string[] { + const keyword = `ref|${type}|${id}`; + if (!typeKeywords.includes(keyword)) { + typeKeywords = [...typeKeywords, keyword]; + } + + return typeKeywords; +} diff --git a/packages/common/src/associations/listAssociations.ts b/packages/common/src/associations/listAssociations.ts index 3602c443a4a..a9ac309792c 100644 --- a/packages/common/src/associations/listAssociations.ts +++ b/packages/common/src/associations/listAssociations.ts @@ -2,6 +2,10 @@ import { IWithAssociations } from "../core/traits/IWithAssociations"; import { AssociationType, IAssociationInfo } from "./types"; /** + * ** DEPRECATED: please use getAssociatedEntitiesQuery + * to get a query for an entity's associations instead. + * This will be removed in the next breaking version ** + * * Return a list of all associations on an entity for a type * @param entity * @returns diff --git a/packages/common/src/associations/removeAssociation.ts b/packages/common/src/associations/removeAssociation.ts index b428db886c3..d3b4985844c 100644 --- a/packages/common/src/associations/removeAssociation.ts +++ b/packages/common/src/associations/removeAssociation.ts @@ -2,6 +2,9 @@ import { IWithAssociations } from "../core/traits/IWithAssociations"; import { IAssociationInfo } from "./types"; /** + * ** DEPRECATED: please use breakAssociation instead. + * This will be removed in the next breaking version ** + * * Remove an association from an entity * @param info * @param entity diff --git a/packages/common/src/associations/requestAssociation.ts b/packages/common/src/associations/requestAssociation.ts new file mode 100644 index 00000000000..c591c6ed342 --- /dev/null +++ b/packages/common/src/associations/requestAssociation.ts @@ -0,0 +1,73 @@ +import { shareItemWithGroup } from "@esri/arcgis-rest-portal"; +import { IArcGISContext } from "../ArcGISContext"; +import { getTypeFromEntity } from "../core/getTypeFromEntity"; +import { fetchHubEntity } from "../core/fetchHubEntity"; +import { HubEntity, HubEntityType } from "../core/types"; +import { getAssociationHierarchy } from "./internal/getAssociationHierarchy"; +import { isAssociationSupported } from "./internal/isAssociationSupported"; +import { setAssociationKeyword } from "./internal/setAssociationKeyword"; +import { getProp } from "../objects"; +import { updateHubEntity } from "../core/updateHubEntity"; + +/** + * When an entity sends an "outgoing" association request + * or accepts an "incoming" association request, half of + * the association "connection" is made. + * + * from the parent's perspective: the parent "includes" + * the child in its association group + * + * From the child's perspective: the child "references" + * the parent via a typeKeyword of the form ref|| + * + * Note: we export this function under 2 names - requestAssociation + * and acceptAssociation. These actions are functionally equivalent, + * but we want to make the intent more clear to the consumer. + * + * @param entity - entity requesting association + * @param type - type of the entity the requesting entity wants to associate with + * @param id - id of the entity the requesting entity wants to associate with + * @param context - contextual portal and auth information + */ +export const requestAssociation = async ( + entity: HubEntity, + associationType: HubEntityType, + id: string, + context: IArcGISContext +): Promise => { + const entityType = getTypeFromEntity(entity); + const isSupported = isAssociationSupported(entityType, associationType); + + if (!isSupported) { + throw new Error( + `requestAssociation: Association between ${entityType} and ${associationType} is not supported.` + ); + } + + const associationHierarchy = getAssociationHierarchy(entityType); + const isParent = associationHierarchy.children.includes(associationType); + + if (isParent) { + const associationGroupId = getProp(entity, "associations.groupId"); + const { owner } = await fetchHubEntity(associationType, id, context); + try { + await shareItemWithGroup({ + id, + owner, + groupId: associationGroupId, + authentication: context.session, + }); + } catch (error) { + throw new Error( + `requestAssociation: there was an error sharing ${id} to ${associationGroupId}: ${error}` + ); + } + } else { + entity.typeKeywords = setAssociationKeyword( + entity.typeKeywords, + associationType, + id + ); + await updateHubEntity(entityType, entity, context); + } +}; diff --git a/packages/common/src/associations/types.ts b/packages/common/src/associations/types.ts index 1a29a2b5586..0acb18e760e 100644 --- a/packages/common/src/associations/types.ts +++ b/packages/common/src/associations/types.ts @@ -1,4 +1,58 @@ +import { HubEntityType } from "../core"; +import { IQuery } from "../search"; + +/** + * associations are hierarchical in nature e.g. + * there is always a parent and a child in the + * relationship. This interface allows us to + * define these hierarchies on an entity-by-entity + * basis + */ +export interface IHubAssociationHierarchy { + children: HubEntityType[]; + parents: HubEntityType[]; +} + +/** + * association rules stored on the parent entity + * that define what is "included" by the parent + * + * For now, the query will define an association + * group, and "included" simply means it has + * been shared with the association group. In + * the future, the query may contain additional + * conditions + */ +export interface IHubAssociationRules { + /** schema version for migration purposes */ + schemaVersion: number; + /** query that defines what's "included" by a parent */ + query: IQuery; +} + +/** + * associations involve a 2-way agreement between + * parent and child. This interface allows us to + * keep track of an entity's association stats with + * another entity + */ +export interface IAssociationStats { + /** number of full associations */ + associated: number; + /** number of outgoing association requests */ + pending: number; + /** number of incoming association requests */ + requesting: number; + /** number of entity's the child references = associated + pending */ + referenced?: number; + /** number of entities included by the parent = associated + pending */ + included?: number; +} + /** + * ** DEPRECATED: This will be removed in the next + * breaking version ** + * * Definition of an Association * This will be persisted in the item's typekeywords * as `type|id` @@ -15,7 +69,9 @@ export interface IAssociationInfo { } /** + * ** DEPRECATED: This will be removed in the next + * breaking version ** + * * Association type */ export type AssociationType = "initiative"; -// AS WE ADD MORE TYPES, UPDATE THE getItemTypeFromAssociationType FUNCTION diff --git a/packages/common/src/core/HubItemEntity.ts b/packages/common/src/core/HubItemEntity.ts index d6cb2d88dfd..176cd5208e9 100644 --- a/packages/common/src/core/HubItemEntity.ts +++ b/packages/common/src/core/HubItemEntity.ts @@ -455,6 +455,9 @@ export abstract class HubItemEntity } /** + * ** DEPRECATED: This will be removed in the next + * breaking version ** + * * Return a list of IAssociationInfo objects representing * the associations this entity has, to the specified type * @param type @@ -465,6 +468,9 @@ export abstract class HubItemEntity } /** + * ** DEPRECATED: please use requestAssociation instead. + * This will be removed in the next breaking version ** + * * Add an association to this entity * @param info * @returns @@ -474,6 +480,9 @@ export abstract class HubItemEntity } /** + * ** DEPRECATED: please use breakAssociation instead. + * This will be removed in the next breaking version ** + * * Remove an association from this entity * @param info * @returns diff --git a/packages/common/src/core/_internal/getBasePropertyMap.ts b/packages/common/src/core/_internal/getBasePropertyMap.ts index f368f0158df..fdd55b7ab3a 100644 --- a/packages/common/src/core/_internal/getBasePropertyMap.ts +++ b/packages/common/src/core/_internal/getBasePropertyMap.ts @@ -26,7 +26,7 @@ export function getBasePropertyMap(): IPropertyMap[] { "orgId", "protected", ]; - const dataProps = ["display", "geometry", "view"]; + const dataProps = ["display", "geometry", "view", "associations"]; const resourceProps = Object.keys(EntityResourceMap); const map: IPropertyMap[] = []; itemProps.forEach((entry) => { diff --git a/packages/common/src/core/behaviors/IWIthAssociationBehavior.ts b/packages/common/src/core/behaviors/IWIthAssociationBehavior.ts index 9a4a7627006..063f85eb7a0 100644 --- a/packages/common/src/core/behaviors/IWIthAssociationBehavior.ts +++ b/packages/common/src/core/behaviors/IWIthAssociationBehavior.ts @@ -5,18 +5,28 @@ import { AssociationType, IAssociationInfo } from "../../associations/types"; */ export interface IWithAssociationBehavior { /** + * ** DEPRECATED: This will be removed in the next + * breaking version ** + * * Get a list of the associations for an AssociationType * @param type */ listAssociations(type: AssociationType): IAssociationInfo[]; /** + * ** DEPRECATED: please use requestAssociation directly. + * This will be removed in the next breaking version ** + * * Add an association to the entity. * Entity needs to be saved after calling this method * @param info */ addAssociation(info: IAssociationInfo): void; + /** + * ** DEPRECATED: please use breakAssociation directly. + * This will be removed in the next breaking version ** + * * Remove an association to the entity. * Entity needs to be saved after calling this method * @param info diff --git a/packages/common/src/core/getTypesFromEntityType.ts b/packages/common/src/core/getTypesFromEntityType.ts new file mode 100644 index 00000000000..bc433429d03 --- /dev/null +++ b/packages/common/src/core/getTypesFromEntityType.ts @@ -0,0 +1,40 @@ +import { HubEntityType } from "./types"; + +/** + * return the item type(s) associated with a provided + * Hub entity type. This is effectively the reverse of + * getTypeFromEntity + * + * @param entityType - the hub entity type + * @returns {string[]} + */ +export const getTypesFromEntityType = (entityType: HubEntityType): string[] => { + let type = [] as string[]; + switch (entityType) { + case "site": + type = ["Hub Site Application", "Site Application"]; + break; + case "page": + type = ["Hub Page", "Site Page"]; + break; + case "project": + type = ["Hub Project"]; + break; + case "initiative": + type = ["Hub Initiative"]; + break; + case "discussion": + type = ["Discussion"]; + break; + case "template": + type = ["Solution"]; + break; + case "group": + type = ["Group"]; + break; + case "initiativeTemplate": + type = ["Hub Initiative Template"]; + break; + } + return type; +}; diff --git a/packages/common/src/core/index.ts b/packages/common/src/core/index.ts index cbb68606f0f..7bddc69f47f 100644 --- a/packages/common/src/core/index.ts +++ b/packages/common/src/core/index.ts @@ -5,6 +5,7 @@ export * from "./schemas"; export * from "./fetchHubEntity"; export * from "./getEntityDefaultWorkspacePane"; export * from "./getTypeFromEntity"; +export * from "./getTypesFromEntityType"; export * from "./getRelativeWorkspaceUrl"; export * from "./isValidEntityType"; export * from "./processActionLinks"; diff --git a/packages/common/src/core/processActionLinks.ts b/packages/common/src/core/processActionLinks.ts index fae625cea43..947eb74259c 100644 --- a/packages/common/src/core/processActionLinks.ts +++ b/packages/common/src/core/processActionLinks.ts @@ -1,4 +1,5 @@ -import { IQuery, hubSearch } from "../search"; +import { IQuery } from "../search/types"; +import { hubSearch } from "../search/hubSearch"; import { IHubRequestOptions } from "../types"; import { HubActionLink, diff --git a/packages/common/src/core/traits/IWithAssociations.ts b/packages/common/src/core/traits/IWithAssociations.ts index 4361f24764a..67d30b62657 100644 --- a/packages/common/src/core/traits/IWithAssociations.ts +++ b/packages/common/src/core/traits/IWithAssociations.ts @@ -1,4 +1,18 @@ +import { IHubAssociationRules } from "../../associations"; + +/** properties for entities with associations */ export interface IWithAssociations { + associations?: { + /** + * association group id - this is exposed as + * its own property for convenience. It is + * also included in the association rules + * query as a group predicate. + */ + groupId: string; + /** association rules */ + rules: IHubAssociationRules; + }; typeKeywords?: string[]; [key: string]: any; } diff --git a/packages/common/src/core/updateHubEntity.ts b/packages/common/src/core/updateHubEntity.ts index 5f4ec647d4f..d8601ab8e8d 100644 --- a/packages/common/src/core/updateHubEntity.ts +++ b/packages/common/src/core/updateHubEntity.ts @@ -7,6 +7,7 @@ import { updateSite } from "../sites/HubSites"; import { updatePage } from "../pages/HubPages"; import { updateInitiativeTemplate } from "../initiative-templates/edit"; import { updateTemplate } from "../templates/edit"; +import { updateHubGroup } from "../groups/HubGroups"; import { HubEntity, HubEntityType, @@ -18,6 +19,7 @@ import { IHubPage, IHubInitiativeTemplate, IHubTemplate, + IHubGroup, } from "./types"; /** @@ -77,6 +79,12 @@ export const updateHubEntity = async ( context.userRequestOptions ); break; + case "group": + result = await updateHubGroup( + entity as IHubGroup, + context.requestOptions + ); + break; } return result; }; diff --git a/packages/common/src/initiatives/HubInitiatives.ts b/packages/common/src/initiatives/HubInitiatives.ts index af12c9d03d6..16a9f5c9e47 100644 --- a/packages/common/src/initiatives/HubInitiatives.ts +++ b/packages/common/src/initiatives/HubInitiatives.ts @@ -304,6 +304,9 @@ export async function enrichInitiativeSearchResult( } /** + * ** DEPRECATED: Please use the association methods directly. + * This will be removed in the next breaking version ** + * * Fetch the Projects that are "Accepted" with an Initiative. * This is a subset of the "Associated" projects, limited * to those included in the Initiative's Catalog. @@ -325,6 +328,9 @@ export async function fetchAcceptedProjects( } /** + * ** DEPRECATED: Please use the association methods directly. + * This will be removed in the next breaking version ** + * * Fetch the Projects that are "Associated" to the Initiative but are not * "Accepted", meaning they have the keyword but are not included in the Initiative's Catalog. * This is how we can get the list of Projects awaiting Acceptance @@ -346,6 +352,9 @@ export async function fetchPendingProjects( } /** + * ** DEPRECATED: This will be removed in the next + * breaking version ** + * * Execute the query and convert into EntityInfo objects * @param query * @param requestOptions @@ -369,6 +378,9 @@ async function queryAsEntityInfo( } /** + * ** DEPRECATED: Please use the association methods directly. + * This will be removed in the next breaking version ** + * * 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" @@ -390,6 +402,9 @@ export function getAcceptedProjectsQuery(initiative: IHubInitiative): IQuery { } /** + * ** DEPRECATED: Please use the association methods directly. + * This will be removed in the next breaking version ** + * * 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 @@ -412,48 +427,3 @@ export function getPendingProjectsQuery(initiative: IHubInitiative): IQuery { return query; } - -// ALTHOUGH WE DON"T CURRENTLY HAVE A UX THAT NEEDS THIS -// THERE IS SOME DISCUSSION ABOUT IT BEING USEFUL SO I'M LEAVING -// THE CODE HERE, COMMENTED. SAME FOR TESTS -// /** -// * 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 = getUnConnectedProjectsQuery(initiative); -// // combineQueries will purge undefined/null entries -// projectQuery = combineQueries([projectQuery, query]); - -// return queryAsEntityInfo(projectQuery, requestOptions); -// } -// /** -// * 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 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"); - -// // negate the scope, combine that with the base query -// query = combineQueries([query, negateGroupPredicates(qry)]); -// return query; -// } diff --git a/packages/common/src/initiatives/_internal/InitiativeAssociationHierarchies.ts b/packages/common/src/initiatives/_internal/InitiativeAssociationHierarchies.ts new file mode 100644 index 00000000000..8ae5a41ff12 --- /dev/null +++ b/packages/common/src/initiatives/_internal/InitiativeAssociationHierarchies.ts @@ -0,0 +1,6 @@ +import { IHubAssociationHierarchy } from "../../associations/types"; + +export const InitiativeAssociationHierarchies: IHubAssociationHierarchy = { + children: ["project"], + parents: [], +}; diff --git a/packages/common/src/initiatives/_internal/InitiativeBusinessRules.ts b/packages/common/src/initiatives/_internal/InitiativeBusinessRules.ts index 127f2f303c9..7f201974e63 100644 --- a/packages/common/src/initiatives/_internal/InitiativeBusinessRules.ts +++ b/packages/common/src/initiatives/_internal/InitiativeBusinessRules.ts @@ -27,6 +27,9 @@ export const InitiativePermissions = [ "hub:initiative:workspace:overview", "hub:initiative:workspace:dashboard", "hub:initiative:workspace:details", + "hub:initiative:workspace:projects", + "hub:initiative:workspace:projects:member", + "hub:initiative:workspace:projects:manager", "hub:initiative:workspace:settings", "hub:initiative:workspace:collaborators", "hub:initiative:workspace:content", @@ -98,6 +101,32 @@ export const InitiativePermissionPolicies: IPermissionPolicy[] = [ permission: "hub:initiative:workspace:details", dependencies: ["hub:initiative:workspace", "hub:initiative:edit"], }, + { + permission: "hub:initiative:workspace:projects", + dependencies: ["hub:initiative:workspace", "hub:initiative:edit"], + }, + { + permission: "hub:initiative:workspace:projects:member", + dependencies: ["hub:initiative:workspace:projects"], + assertions: [ + { + property: "context:currentUser", + type: "is-group-member", + value: "entity:associations.groupId", + }, + ], + }, + { + permission: "hub:initiative:workspace:projects:manager", + dependencies: ["hub:initiative:workspace:projects"], + assertions: [ + { + property: "context:currentUser", + type: "is-group-admin", + value: "entity:associations.groupId", + }, + ], + }, { permission: "hub:initiative:workspace:settings", dependencies: ["hub:initiative:workspace", "hub:initiative:edit"], diff --git a/packages/common/src/projects/_internal/ProjectAssociationHierarchies.ts b/packages/common/src/projects/_internal/ProjectAssociationHierarchies.ts new file mode 100644 index 00000000000..6151d492eb1 --- /dev/null +++ b/packages/common/src/projects/_internal/ProjectAssociationHierarchies.ts @@ -0,0 +1,6 @@ +import { IHubAssociationHierarchy } from "../../associations/types"; + +export const ProjectAssociationHierarchies: IHubAssociationHierarchy = { + children: [], + parents: ["initiative"], +}; diff --git a/packages/common/src/projects/_internal/ProjectBusinessRules.ts b/packages/common/src/projects/_internal/ProjectBusinessRules.ts index d098f737ef3..27c9b4457ac 100644 --- a/packages/common/src/projects/_internal/ProjectBusinessRules.ts +++ b/packages/common/src/projects/_internal/ProjectBusinessRules.ts @@ -28,6 +28,7 @@ export const ProjectPermissions = [ "hub:project:workspace:overview", "hub:project:workspace:dashboard", "hub:project:workspace:details", + "hub:project:workspace:initiatives", "hub:project:workspace:settings", "hub:project:workspace:collaborators", "hub:project:workspace:content", @@ -103,6 +104,11 @@ export const ProjectPermissionPolicies: IPermissionPolicy[] = [ permission: "hub:project:workspace:details", dependencies: ["hub:project:workspace", "hub:project:edit"], }, + { + permission: "hub:project:workspace:initiatives", + availability: ["alpha"], // gate to just alpha for now + dependencies: ["hub:project:workspace", "hub:project:edit"], + }, { permission: "hub:project:workspace:settings", dependencies: ["hub:project:workspace", "hub:project:edit"], diff --git a/packages/common/src/projects/fetch.ts b/packages/common/src/projects/fetch.ts index c00793c5093..c2a7767c2f2 100644 --- a/packages/common/src/projects/fetch.ts +++ b/packages/common/src/projects/fetch.ts @@ -130,6 +130,9 @@ export async function enrichProjectSearchResult( } /** + * ** DEPRECATED: Please use the association methods directly. + * This will be removed in the next breaking version ** + * * Get a query that will fetch all the initiatives which the project has * chosen to connect to. If project has not defined any associations * to any Initiatives, will return `null`. diff --git a/packages/common/src/search/_internal/buildCatalog.ts b/packages/common/src/search/_internal/buildCatalog.ts new file mode 100644 index 00000000000..03de869d76c --- /dev/null +++ b/packages/common/src/search/_internal/buildCatalog.ts @@ -0,0 +1,32 @@ +import { EntityType, IFilter, IHubCatalog, IHubCollection } from "../types"; + +/** + * Build an IHubCatalog definition JSON object based on a + * well-known catalog name, scope filters, and collections + * + * @param i18nScope - i18n scope for the catalog title + * @param catalogName - well known catalog name + * @param filters - filters to build the catalog scope + * @param collections - collections to include in the catalog + * @returns {IHubCatalog} + */ +export function buildCatalog( + i18nScope: string, + catalogName: string, + filters: IFilter[], + collections: IHubCollection[], + targetEntity: EntityType +): IHubCatalog { + const scopes = { + [targetEntity]: { + targetEntity, + filters, + }, + }; + return { + schemaVersion: 1, + title: `{{${i18nScope}catalog.${catalogName}:translate}}`, + scopes, + collections, + }; +} diff --git a/packages/common/src/search/wellKnownCatalog.ts b/packages/common/src/search/wellKnownCatalog.ts index 7d565724b4b..b663d62ad47 100644 --- a/packages/common/src/search/wellKnownCatalog.ts +++ b/packages/common/src/search/wellKnownCatalog.ts @@ -2,6 +2,7 @@ import { IUser } from "@esri/arcgis-rest-types"; import { getFamilyTypes } from "../content/get-family"; import { HubFamily } from "../types"; import { EntityType, IHubCatalog, IHubCollection } from "./types"; +import { buildCatalog } from "./_internal/buildCatalog"; /** * This is used to determine what IHubCatalog definition JSON object @@ -72,49 +73,6 @@ export function getWellKnownCatalog( } } -/** - * Build an IHubCatalog definition JSON object based on the - * catalog name, predicates and collections we want to use for each catalog - * @param i18nScope - * @param catalogName - * @param predicates Predicates for the catalog - * @param collections Collections to include for the catalog - * @returns An IHubCatalog definition JSON object - */ -function buildCatalog( - i18nScope: string, - catalogName: WellKnownCatalog, - predicates: any[], - collections: IHubCollection[], - entityType: EntityType -): IHubCatalog { - let scopes; - switch (entityType) { - case "item": - scopes = { - item: { - targetEntity: "item" as EntityType, - filters: [{ predicates }], - }, - }; - break; - case "group": - scopes = { - group: { - targetEntity: "group" as EntityType, - filters: [{ predicates }], - }, - }; - break; - } - return { - schemaVersion: 1, - title: `{{${i18nScope}catalog.${catalogName}:translate}}`, - scopes, - collections, - }; -} - /** * Check if user is available in the passed options, throw an error if not * @param catalogName @@ -154,7 +112,7 @@ function getWellknownItemCatalog( catalog = buildCatalog( i18nScope, catalogName, - [{ owner: options.user.username }], + [{ predicates: [{ owner: options.user.username }] }], collections, "item" ); @@ -164,7 +122,7 @@ function getWellknownItemCatalog( catalog = buildCatalog( i18nScope, catalogName, - [{ group: options.user.favGroupId }], + [{ predicates: [{ group: options.user.favGroupId }] }], collections, "item" ); @@ -174,7 +132,7 @@ function getWellknownItemCatalog( catalog = buildCatalog( i18nScope, catalogName, - [{ orgid: options.user.orgId }], + [{ predicates: [{ orgid: options.user.orgId }] }], collections, "item" ); @@ -183,7 +141,7 @@ function getWellknownItemCatalog( catalog = buildCatalog( i18nScope, catalogName, - [{ type: { not: ["code attachment"] } }], + [{ predicates: [{ type: { not: ["code attachment"] } }] }], collections, "item" ); @@ -230,7 +188,7 @@ function getWellknownGroupCatalog( catalog = buildCatalog( i18nScope, catalogName, - [{ capabilities: ["updateitemcontrol"] }], + [{ predicates: [{ capabilities: ["updateitemcontrol"] }] }], collections, "group" ); @@ -240,7 +198,7 @@ function getWellknownGroupCatalog( catalog = buildCatalog( i18nScope, catalogName, - [{ capabilities: { not: ["updateitemcontrol"] } }], + [{ predicates: [{ capabilities: { not: ["updateitemcontrol"] } }] }], collections, "group" ); @@ -250,7 +208,7 @@ function getWellknownGroupCatalog( catalog = buildCatalog( i18nScope, catalogName, - [{ capabilities: [""] }], + [{ predicates: [{ capabilities: [""] }] }], collections, "group" ); @@ -369,6 +327,27 @@ function getAllCollectionsMap(i18nScope: string, entityType: EntityType): any { ], }, } as IHubCollection, + // note: For now, this is not included in the default collection names. + // It would need to be explicitly passed into getWellknownCollections + // to be returned + initiative: { + key: "initiative", + label: `{{${i18nScope}collection.initiatives:translate}}`, + targetEntity: entityType, + include: [], + scope: { + targetEntity: entityType, + filters: [ + { + predicates: [ + { + type: getFamilyTypes("initiative"), + }, + ], + }, + ], + }, + } as IHubCollection, }; } diff --git a/packages/common/test/associations/breakAssociation.test.ts b/packages/common/test/associations/breakAssociation.test.ts new file mode 100644 index 00000000000..bf9c11f1021 --- /dev/null +++ b/packages/common/test/associations/breakAssociation.test.ts @@ -0,0 +1,97 @@ +import { ArcGISContext, cloneObject } from "../../src"; +import { breakAssociation } from "../../src/associations/breakAssociation"; +import { MOCK_CHILD_ENTITY, MOCK_PARENT_ENTITY } from "./fixtures"; +import * as RestPortalModule from "@esri/arcgis-rest-portal"; +import * as CoreModule from "../../src/core"; +import * as UpdateHubEntityModule from "../../src/core/updateHubEntity"; + +describe("breakAssociation", () => { + let unshareItemWithGroupSpy: jasmine.Spy; + let fetchHubEntitySpy: jasmine.Spy; + let updateHubEntitySpy: jasmine.Spy; + + beforeEach(() => { + unshareItemWithGroupSpy = spyOn( + RestPortalModule, + "unshareItemWithGroup" + ).and.returnValue(Promise.resolve()); + fetchHubEntitySpy = spyOn(CoreModule, "fetchHubEntity").and.returnValue( + Promise.resolve({ owner: "mock-owner" }) + ); + updateHubEntitySpy = spyOn( + UpdateHubEntityModule, + "updateHubEntity" + ).and.returnValue(Promise.resolve()); + }); + afterEach(() => { + unshareItemWithGroupSpy.calls.reset(); + fetchHubEntitySpy.calls.reset(); + updateHubEntitySpy.calls.reset(); + }); + + it("parent perspective: unshares the child from its association group", async () => { + await breakAssociation(MOCK_PARENT_ENTITY, "project", "child-00a", { + session: {}, + } as ArcGISContext); + + expect(fetchHubEntitySpy).toHaveBeenCalledTimes(1); + expect(fetchHubEntitySpy).toHaveBeenCalledWith("project", "child-00a", { + session: {}, + }); + expect(unshareItemWithGroupSpy).toHaveBeenCalledTimes(1); + expect(unshareItemWithGroupSpy).toHaveBeenCalledWith({ + id: "child-00a", + owner: "mock-owner", + groupId: "group-00a", + authentication: {}, + }); + }); + it("child perspective: removes the typeKeyword referencing the specified parent", async () => { + const mockChild = cloneObject(MOCK_CHILD_ENTITY); + await breakAssociation( + mockChild, + "initiative", + "parent-00a", + {} as ArcGISContext + ); + + expect(mockChild.typeKeywords?.length).toBe(0); + expect(updateHubEntitySpy).toHaveBeenCalledTimes(1); + expect(updateHubEntitySpy).toHaveBeenCalledWith( + "project", + mockChild, + {} as ArcGISContext + ); + }); + it("throws an error if there is an issue unsharing the item from the association group", async () => { + unshareItemWithGroupSpy.and.returnValue(Promise.reject("unshare error")); + + try { + await breakAssociation(MOCK_PARENT_ENTITY, "project", "child-00a", { + session: {}, + } as ArcGISContext); + } catch (err) { + expect(err).toEqual( + new Error( + "breakAssociation: there was an error unsharing child-00a from group-00a: unshare error" + ) + ); + } + }); + it("throws an error if the association is not supported", async () => { + try { + await breakAssociation( + MOCK_PARENT_ENTITY, + "group", + "group-00a", + {} as ArcGISContext + ); + } catch (err) { + expect(err).toEqual( + new Error( + "breakAssociation: Association between initiative and group is not supported." + ) + ); + } + }); +}); diff --git a/packages/common/test/associations/fixtures.ts b/packages/common/test/associations/fixtures.ts new file mode 100644 index 00000000000..b4f6cf42b24 --- /dev/null +++ b/packages/common/test/associations/fixtures.ts @@ -0,0 +1,22 @@ +import { HubEntity } from "../../src/core/types"; + +export const MOCK_PARENT_ENTITY = { + id: "parent-00a", + type: "Hub Initiative", + associations: { + groupId: "group-00a", + rules: { + schemaVersion: 1, + query: { + targetEntity: "item", + filters: [{ predicates: [{ group: "group-00a" }] }], + }, + }, + }, +} as unknown as HubEntity; + +export const MOCK_CHILD_ENTITY = { + id: "child-00a", + type: "Hub Project", + typeKeywords: ["ref|initiative|parent-00a"], +} as unknown as HubEntity; diff --git a/packages/common/test/associations/getAssociatedEntitiesQuery.test.ts b/packages/common/test/associations/getAssociatedEntitiesQuery.test.ts new file mode 100644 index 00000000000..98d3a2f9e51 --- /dev/null +++ b/packages/common/test/associations/getAssociatedEntitiesQuery.test.ts @@ -0,0 +1,37 @@ +import { IArcGISContext } from "../../src"; +import { getAssociatedEntitiesQuery } from "../../src/associations/getAssociatedEntitiesQuery"; +import { MOCK_PARENT_ENTITY } from "./fixtures"; +import * as AssociationsModule from "../../src/associations/internal/getIncludesAndReferencesQuery"; + +describe("getAssociatedEntitiesQuery:", () => { + it("delegates to getIncludesAndReferencesQuery", async () => { + const getIncludesAndReferencesQuerySpy = spyOn( + AssociationsModule, + "getIncludesAndReferencesQuery" + ).and.returnValue(Promise.resolve()); + await getAssociatedEntitiesQuery(MOCK_PARENT_ENTITY, "project", { + requestOptions: {}, + } as IArcGISContext); + + expect(getIncludesAndReferencesQuerySpy).toHaveBeenCalledTimes(1); + expect(getIncludesAndReferencesQuerySpy).toHaveBeenCalledWith( + MOCK_PARENT_ENTITY, + "project", + true, + { requestOptions: {} } as IArcGISContext + ); + }); + it("throws an error if the association is not supported", async () => { + try { + await getAssociatedEntitiesQuery(MOCK_PARENT_ENTITY, "group", { + requestOptions: {}, + } as IArcGISContext); + } catch (err) { + expect(err).toEqual( + new Error( + "getAssociatedEntitiesQuery: Association between initiative and group is not supported." + ) + ); + } + }); +}); diff --git a/packages/common/test/associations/getAssociationStats.test.ts b/packages/common/test/associations/getAssociationStats.test.ts new file mode 100644 index 00000000000..d272c008a5a --- /dev/null +++ b/packages/common/test/associations/getAssociationStats.test.ts @@ -0,0 +1,119 @@ +import { getAssociationStats } from "../../src/associations/getAssociationStats"; +import { MOCK_CHILD_ENTITY, MOCK_PARENT_ENTITY } from "./fixtures"; +import { ArcGISContext } from "../../src/ArcGISContext"; +import * as SearchModule from "../../src/search/hubSearch"; +import * as GetAssociatedEntitiesQueryModule from "../../src/associations/getAssociatedEntitiesQuery"; +import * as GetPendingEntitiesQueryModule from "../../src/associations/getPendingEntitiesQuery"; +import * as GetRequestingEntitiesQueryModule from "../../src/associations/getRequestingEntitiesQuery"; + +describe("getAssociationStats:", () => { + let hubSearchSpy: jasmine.Spy; + let getAssociatedEntitiesQuerySpy: jasmine.Spy; + let getPendingEntitiesQuerySpy: jasmine.Spy; + let getRequestingEntitiesQuerySpy: jasmine.Spy; + + beforeEach(() => { + getAssociatedEntitiesQuerySpy = spyOn( + GetAssociatedEntitiesQueryModule, + "getAssociatedEntitiesQuery" + ).and.returnValue(Promise.resolve({})); + getPendingEntitiesQuerySpy = spyOn( + GetPendingEntitiesQueryModule, + "getPendingEntitiesQuery" + ).and.returnValue(Promise.resolve({})); + getRequestingEntitiesQuerySpy = spyOn( + GetRequestingEntitiesQueryModule, + "getRequestingEntitiesQuery" + ).and.returnValue(Promise.resolve({})); + }); + + it("delegates to getAssociatedEntitiesQuery, getPendingEntitiesQuery, and getRequestingEntitiesQuery", async () => { + hubSearchSpy = spyOn(SearchModule, "hubSearch").and.returnValue( + Promise.resolve({ total: 0 }) + ); + + await getAssociationStats( + MOCK_PARENT_ENTITY, + "project", + {} as ArcGISContext + ); + + expect(hubSearchSpy).toHaveBeenCalledTimes(3); + expect(getAssociatedEntitiesQuerySpy).toHaveBeenCalledTimes(1); + expect(getPendingEntitiesQuerySpy).toHaveBeenCalledTimes(1); + expect(getRequestingEntitiesQuerySpy).toHaveBeenCalledTimes(1); + }); + it("returns stats for a parent entity", async () => { + hubSearchSpy = spyOn(SearchModule, "hubSearch").and.returnValues( + Promise.resolve({ total: 1 }), + Promise.resolve({ total: 2 }), + Promise.resolve({ total: 3 }) + ); + + const stats = await getAssociationStats( + MOCK_PARENT_ENTITY, + "project", + {} as ArcGISContext + ); + + expect(stats).toEqual({ + associated: 1, + pending: 2, + requesting: 3, + included: 3, + }); + }); + it("returns stats for a child entity", async () => { + hubSearchSpy = spyOn(SearchModule, "hubSearch").and.returnValues( + Promise.resolve({ total: 1 }), + Promise.resolve({ total: 2 }), + Promise.resolve({ total: 3 }) + ); + + const stats = await getAssociationStats( + MOCK_CHILD_ENTITY, + "initiative", + {} as ArcGISContext + ); + + expect(stats).toEqual({ + associated: 1, + pending: 2, + requesting: 3, + referenced: 3, + }); + }); + it("throws an error if the association is not supported", async () => { + try { + await getAssociationStats( + MOCK_PARENT_ENTITY, + "group", + {} as ArcGISContext + ); + } catch (err) { + expect(err).toEqual( + new Error( + "getAssociationStats: Association between initiative and group is not supported." + ) + ); + } + }); + it("returns empty stats if hubSearch throws an error", async () => { + hubSearchSpy = spyOn(SearchModule, "hubSearch").and.returnValue( + Promise.reject({}) + ); + + const stats = await getAssociationStats( + MOCK_PARENT_ENTITY, + "project", + {} as ArcGISContext + ); + + expect(stats).toEqual({ + associated: 0, + pending: 0, + requesting: 0, + included: 0, + }); + }); +}); diff --git a/packages/common/test/associations/getAvailableToRequestEntitiesQuery.test.ts b/packages/common/test/associations/getAvailableToRequestEntitiesQuery.test.ts new file mode 100644 index 00000000000..a8cbc6ecacd --- /dev/null +++ b/packages/common/test/associations/getAvailableToRequestEntitiesQuery.test.ts @@ -0,0 +1,55 @@ +import { getAvailableToRequestEntitiesQuery } from "../../src/associations/getAvailableToRequestEntitiesQuery"; +import { MOCK_CHILD_ENTITY, MOCK_PARENT_ENTITY } from "./fixtures"; + +describe("getAvailableToRequestEntitiesQuery:", () => { + it("returns a valid IQuery for a parent entity", () => { + const query = getAvailableToRequestEntitiesQuery( + MOCK_PARENT_ENTITY, + "project" + ); + + expect(query).toEqual({ + targetEntity: "item", + filters: [ + { + predicates: [{ group: { not: ["group-00a"], any: [], all: [] } }], + }, + { + predicates: [{ type: ["Hub Project"] }], + }, + ], + }); + }); + it("returns a valid IQuery for a child entity", () => { + const query = getAvailableToRequestEntitiesQuery( + MOCK_CHILD_ENTITY, + "initiative" + ); + + expect(query).toEqual({ + targetEntity: "item", + filters: [ + { + operation: "AND", + predicates: [ + { + type: ["Hub Initiative"], + id: { not: ["parent-00a"] }, + }, + ], + }, + ], + }); + }); + it("throws an error if the association is not supported", async () => { + try { + await getAvailableToRequestEntitiesQuery(MOCK_PARENT_ENTITY, "group"); + } catch (err) { + expect(err).toEqual( + new Error( + "getAvailableToRequestEntitiesQuery: Association between initiative and group is not supported." + ) + ); + } + }); +}); diff --git a/packages/common/test/associations/getPendingEntitiesQuery.test.ts b/packages/common/test/associations/getPendingEntitiesQuery.test.ts new file mode 100644 index 00000000000..7e2312f65ac --- /dev/null +++ b/packages/common/test/associations/getPendingEntitiesQuery.test.ts @@ -0,0 +1,55 @@ +import { IArcGISContext } from "../../src"; +import { getPendingEntitiesQuery } from "../../src/associations/getPendingEntitiesQuery"; +import { MOCK_CHILD_ENTITY, MOCK_PARENT_ENTITY } from "./fixtures"; +import * as getIncludesDoesNotReferenceQueryModule from "../../src/associations/internal/getIncludesDoesNotReferenceQuery"; +import * as getReferencesDoesNotIncludeQueryModule from "../../src/associations/internal/getReferencesDoesNotIncludeQuery"; + +describe("getPendingEntitiesQuery:", () => { + it("delegates to getIncludesDoesNotReferenceQuery for parents", async () => { + const getIncludesDoesNotReferenceQuerySpy = spyOn( + getIncludesDoesNotReferenceQueryModule, + "getIncludesDoesNotReferenceQuery" + ).and.returnValue(Promise.resolve()); + await getPendingEntitiesQuery(MOCK_PARENT_ENTITY, "project", { + requestOptions: {}, + } as IArcGISContext); + + expect(getIncludesDoesNotReferenceQuerySpy).toHaveBeenCalledTimes(1); + expect(getIncludesDoesNotReferenceQuerySpy).toHaveBeenCalledWith( + MOCK_PARENT_ENTITY, + "project", + true, + { requestOptions: {} } as IArcGISContext + ); + }); + it("delegates to getReferencesDoesNotIncludeQuery for children", async () => { + const getReferencesDoesNotIncludeQuerySpy = spyOn( + getReferencesDoesNotIncludeQueryModule, + "getReferencesDoesNotIncludeQuery" + ).and.returnValue(Promise.resolve()); + await getPendingEntitiesQuery(MOCK_CHILD_ENTITY, "initiative", { + requestOptions: {}, + } as IArcGISContext); + + expect(getReferencesDoesNotIncludeQuerySpy).toHaveBeenCalledTimes(1); + expect(getReferencesDoesNotIncludeQuerySpy).toHaveBeenCalledWith( + MOCK_CHILD_ENTITY, + "initiative", + false, + { requestOptions: {} } as IArcGISContext + ); + }); + it("throws an error if the association is not supported", async () => { + try { + await getPendingEntitiesQuery(MOCK_PARENT_ENTITY, "group", { + requestOptions: {}, + } as IArcGISContext); + } catch (err) { + expect(err).toEqual( + new Error( + "getPendingEntitiesQuery: Association between initiative and group is not supported." + ) + ); + } + }); +}); diff --git a/packages/common/test/associations/getRequestingEntitiesQuery.test.ts b/packages/common/test/associations/getRequestingEntitiesQuery.test.ts new file mode 100644 index 00000000000..00adac97abd --- /dev/null +++ b/packages/common/test/associations/getRequestingEntitiesQuery.test.ts @@ -0,0 +1,55 @@ +import { IArcGISContext } from "../../src"; +import { getRequestingEntitiesQuery } from "../../src/associations/getRequestingEntitiesQuery"; +import { MOCK_CHILD_ENTITY, MOCK_PARENT_ENTITY } from "./fixtures"; +import * as getIncludesDoesNotReferenceQueryModule from "../../src/associations/internal/getIncludesDoesNotReferenceQuery"; +import * as getReferencesDoesNotIncludeQueryModule from "../../src/associations/internal/getReferencesDoesNotIncludeQuery"; + +describe("getRequestingEntitiesQuery:", () => { + it("delegates to getReferencesDoesNotIncludeQuery for parents", async () => { + const getReferencesDoesNotIncludeQuerySpy = spyOn( + getReferencesDoesNotIncludeQueryModule, + "getReferencesDoesNotIncludeQuery" + ).and.returnValue(Promise.resolve()); + await getRequestingEntitiesQuery(MOCK_PARENT_ENTITY, "project", { + requestOptions: {}, + } as IArcGISContext); + + expect(getReferencesDoesNotIncludeQuerySpy).toHaveBeenCalledTimes(1); + expect(getReferencesDoesNotIncludeQuerySpy).toHaveBeenCalledWith( + MOCK_PARENT_ENTITY, + "project", + true, + { requestOptions: {} } as IArcGISContext + ); + }); + it("delegates to getIncludesDoesNotReferenceQuery for children", async () => { + const getIncludesDoesNotReferenceQuerySpy = spyOn( + getIncludesDoesNotReferenceQueryModule, + "getIncludesDoesNotReferenceQuery" + ).and.returnValue(Promise.resolve()); + await getRequestingEntitiesQuery(MOCK_CHILD_ENTITY, "initiative", { + requestOptions: {}, + } as IArcGISContext); + + expect(getIncludesDoesNotReferenceQuerySpy).toHaveBeenCalledTimes(1); + expect(getIncludesDoesNotReferenceQuerySpy).toHaveBeenCalledWith( + MOCK_CHILD_ENTITY, + "initiative", + false, + { requestOptions: {} } as IArcGISContext + ); + }); + it("throws an error if the association is not supported", async () => { + try { + await getRequestingEntitiesQuery(MOCK_PARENT_ENTITY, "group", { + requestOptions: {}, + } as IArcGISContext); + } catch (err) { + expect(err).toEqual( + new Error( + "getRequestingEntitiesQuery: Association between initiative and group is not supported." + ) + ); + } + }); +}); diff --git a/packages/common/test/associations/getWellKnownAssociationsCatalog.test.ts b/packages/common/test/associations/getWellKnownAssociationsCatalog.test.ts new file mode 100644 index 00000000000..b0418ab9218 --- /dev/null +++ b/packages/common/test/associations/getWellKnownAssociationsCatalog.test.ts @@ -0,0 +1,139 @@ +import { getWellKnownAssociationsCatalog } from "../../src/associations/getWellKnownAssociationsCatalog"; +import * as getAssociatedEntitiesQueryModule from "../../src/associations/getAssociatedEntitiesQuery"; +import * as getPendingEntitiesQueryModule from "../../src/associations/getPendingEntitiesQuery"; +import * as getRequestingEntitiesQueryModule from "../../src/associations/getRequestingEntitiesQuery"; +import * as getAvailableToRequestEntitiesQueryModule from "../../src/associations/getAvailableToRequestEntitiesQuery"; +import * as wellKnownCatalogModule from "../../src/search/wellKnownCatalog"; +import { ArcGISContext, HubEntity, IHubCollection } from "../../src"; + +describe("getWellKnownAssociationsCatalog", () => { + let getAssociatedEntitiesQuerySpy: jasmine.Spy; + let getPendingEntitiesQuerySpy: jasmine.Spy; + let getRequestingEntitiesQuerySpy: jasmine.Spy; + let getAvailableToRequestEntitiesQuerySpy: jasmine.Spy; + let getWellknownCollectionSpy: jasmine.Spy; + + const mockFilters = [{ predicates: [{ owner: "mock-owner" }] }]; + beforeEach(() => { + getAssociatedEntitiesQuerySpy = spyOn( + getAssociatedEntitiesQueryModule, + "getAssociatedEntitiesQuery" + ).and.returnValue(Promise.resolve({ filters: mockFilters })); + getPendingEntitiesQuerySpy = spyOn( + getPendingEntitiesQueryModule, + "getPendingEntitiesQuery" + ).and.returnValue(Promise.resolve({ filters: mockFilters })); + getRequestingEntitiesQuerySpy = spyOn( + getRequestingEntitiesQueryModule, + "getRequestingEntitiesQuery" + ).and.returnValue(Promise.resolve({ filters: mockFilters })); + getAvailableToRequestEntitiesQuerySpy = spyOn( + getAvailableToRequestEntitiesQueryModule, + "getAvailableToRequestEntitiesQuery" + ).and.returnValue(Promise.resolve({ filters: mockFilters })); + getWellknownCollectionSpy = spyOn( + wellKnownCatalogModule, + "getWellknownCollection" + ).and.returnValue({ key: "mock-collection" }); + }); + + it("builds a valid well-known catalog", async () => { + const catalog = await getWellKnownAssociationsCatalog( + "some-scope", + "associated", + { type: "Hub Project" } as HubEntity, + "initiative", + {} as ArcGISContext + ); + + expect(getWellknownCollectionSpy).toHaveBeenCalledTimes(1); + expect(catalog).toEqual({ + schemaVersion: 1, + title: "{{some-scope.catalog.associated:translate}}", + scopes: { + item: { + targetEntity: "item", + filters: mockFilters, + }, + }, + collections: [{ key: "mock-collection" } as IHubCollection], + }); + }); + it("handles empty state filters", async () => { + getAssociatedEntitiesQuerySpy.and.returnValue(Promise.resolve(null)); + + const catalog = await getWellKnownAssociationsCatalog( + "some-scope", + "associated", + { type: "Hub Project" } as HubEntity, + "initiative", + {} as ArcGISContext + ); + + expect(catalog.scopes).toEqual({ + item: { + targetEntity: "item", + filters: [{ predicates: [{ type: ["Code Attachment"] }] }], + }, + }); + }); + it('delegates to getAssociatedEntitiesQuery for the well-known "associated" catalog', async () => { + await getWellKnownAssociationsCatalog( + "some-scope", + "associated", + { type: "Hub Project" } as HubEntity, + "initiative", + {} as ArcGISContext + ); + + expect(getAssociatedEntitiesQuerySpy).toHaveBeenCalledTimes(1); + }); + it('delegates to getPendingEntitiesQuery for the well-known "pending" catalog', async () => { + await getWellKnownAssociationsCatalog( + "some-scope", + "pending", + { type: "Hub Project" } as HubEntity, + "initiative", + {} as ArcGISContext + ); + + expect(getPendingEntitiesQuerySpy).toHaveBeenCalledTimes(1); + }); + it('delegates to getRequestingEntitiesQuery for the well-known "requesting" catalog', async () => { + await getWellKnownAssociationsCatalog( + "some-scope", + "requesting", + { type: "Hub Project" } as HubEntity, + "initiative", + {} as ArcGISContext + ); + + expect(getRequestingEntitiesQuerySpy).toHaveBeenCalledTimes(1); + }); + it('delegates to getAvailableToRequestEntitiesQuery for the well-known "availableToRequest" catalog', async () => { + await getWellKnownAssociationsCatalog( + "some-scope", + "availableToRequest", + { type: "Hub Project" } as HubEntity, + "initiative", + {} as ArcGISContext + ); + + expect(getAvailableToRequestEntitiesQuerySpy).toHaveBeenCalledTimes(1); + }); + it("throws an error if the association is not supported", async () => { + try { + await getWellKnownAssociationsCatalog( + "some-scope", + "associated" as any, + { type: "Hub Initiative" } as HubEntity, + "group", + {} as ArcGISContext + ); + } catch (err) { + expect(err.message).toBe( + "getWellKnownAssociationsCatalog: Association between initiative and group is not supported." + ); + } + }); +}); diff --git a/packages/common/test/associations/internal/getAssociationHierarchy.test.ts b/packages/common/test/associations/internal/getAssociationHierarchy.test.ts new file mode 100644 index 00000000000..b1b579d95db --- /dev/null +++ b/packages/common/test/associations/internal/getAssociationHierarchy.test.ts @@ -0,0 +1,26 @@ +import { HubEntityType } from "../../../src/core/types"; +import { getAssociationHierarchy } from "../../../src/associations/internal/getAssociationHierarchy"; + +describe("getAssociationHierarchy:", () => { + it("returns a valid IHubAssociationHierarchy for initiatives", () => { + const chk = getAssociationHierarchy("initiative"); + + expect(chk.parents.length).toBe(0); + expect(chk.children).toEqual(["project"]); + }); + it("returns a valid IHubAssociationHierarchy for projects", () => { + const chk = getAssociationHierarchy("project"); + + expect(chk.parents).toEqual(["initiative"]); + expect(chk.children.length).toBe(0); + }); + it("throws an error for unsupported entity types", () => { + try { + getAssociationHierarchy("unsupportedEntity" as HubEntityType); + } catch (err) { + expect(err.message).toBe( + "getAssociationHierarchy: Invalid type for associations: unsupportedEntity." + ); + } + }); +}); diff --git a/packages/common/test/associations/internal/getIdsFromAssociationGroups.test.ts b/packages/common/test/associations/internal/getIdsFromAssociationGroups.test.ts new file mode 100644 index 00000000000..330a460a7e4 --- /dev/null +++ b/packages/common/test/associations/internal/getIdsFromAssociationGroups.test.ts @@ -0,0 +1,28 @@ +import { IGroup } from "@esri/arcgis-rest-types"; +import { getIdsFromAssociationGroups } from "../../../src/associations/internal/getIdsFromAssociationGroups"; + +describe("getIdsFromAssociationGroups:", () => { + it("returns an empty array if no groups are passed", () => { + const ids = getIdsFromAssociationGroups([], "initiative"); + expect(ids.length).toBe(0); + }); + it("returns an empty array if no association groups are passed", () => { + const ids = getIdsFromAssociationGroups( + [{ id: "00c", typeKeywords: ["someKeyword"] }] as unknown as IGroup[], + "initiative" + ); + expect(ids.length).toBe(0); + }); + it("returns an array of ids for association groups", () => { + const ids = getIdsFromAssociationGroups( + [ + { id: "00a", typeKeywords: ["someKeyword"] }, + { id: "00b", typeKeywords: ["something", "initiative|00c"] }, + ] as unknown as IGroup[], + "initiative" + ); + + expect(ids.length).toBe(1); + expect(ids[0]).toBe("00c"); + }); +}); diff --git a/packages/common/test/associations/internal/getIdsFromKeywords.test.ts b/packages/common/test/associations/internal/getIdsFromKeywords.test.ts new file mode 100644 index 00000000000..4cbcab77df6 --- /dev/null +++ b/packages/common/test/associations/internal/getIdsFromKeywords.test.ts @@ -0,0 +1,29 @@ +import { HubEntity } from "../../../src"; +import { getIdsFromKeywords } from "../../../src/associations/internal/getIdsFromKeywords"; + +describe("getIdsFromKeywords", () => { + it("returns an empty array if no keywords are passed", () => { + const ids = getIdsFromKeywords( + { typeKeywords: [] } as unknown as HubEntity, + "initiative" + ); + expect(ids.length).toBe(0); + }); + it("returns an empty array if no association keywords are passed", () => { + const ids = getIdsFromKeywords( + { typeKeywords: ["someKeyword"] } as unknown as HubEntity, + "initiative" + ); + expect(ids.length).toBe(0); + }); + it("returns an array of ids for association keywords", () => { + const ids = getIdsFromKeywords( + { + typeKeywords: ["someKeyword", "ref|initiative|00c"], + } as unknown as HubEntity, + "initiative" + ); + expect(ids.length).toBe(1); + expect(ids[0]).toBe("00c"); + }); +}); diff --git a/packages/common/test/associations/internal/getIncludesAndReferencesQuery.test.ts b/packages/common/test/associations/internal/getIncludesAndReferencesQuery.test.ts new file mode 100644 index 00000000000..ff3cbbb1109 --- /dev/null +++ b/packages/common/test/associations/internal/getIncludesAndReferencesQuery.test.ts @@ -0,0 +1,105 @@ +import { cloneObject } from "../../../src"; +import { IArcGISContext } from "../../../src/ArcGISContext"; +import { getIncludesAndReferencesQuery } from "../../../src/associations/internal/getIncludesAndReferencesQuery"; +import { MOCK_PARENT_ENTITY, MOCK_CHILD_ENTITY } from "../fixtures"; +import * as ItemsModule from "@esri/arcgis-rest-portal"; + +describe("getIncludesAndReferencesQuery:", () => { + describe("from the parent entity perspective", () => { + it("returns a valid IQuery to fetch child entities", async () => { + const query = await getIncludesAndReferencesQuery( + MOCK_PARENT_ENTITY, + "project", + true, + {} as IArcGISContext + ); + + expect(query).toEqual({ + targetEntity: "item", + filters: [ + { + operation: "AND", + predicates: [ + { + type: ["Hub Project"], + typekeywords: ["ref|initiative|parent-00a"], + }, + ], + }, + { + predicates: [{ group: "group-00a" }], + }, + ], + }); + }); + }); + describe("from the child entity perspective", () => { + it("returns a valid IQuery to fetch parent entities", async () => { + const getItemGroupsSpy = spyOn( + ItemsModule, + "getItemGroups" + ).and.returnValue( + Promise.resolve({ + admin: [{ id: "group-00a", typeKeywords: ["initiative|parent-00a"] }], + member: [{ id: "group-00b", typeKeywords: [] }], + other: [{ id: "group-00c", typeKeywords: ["initiative|parent-00b"] }], + }) + ); + const query = await getIncludesAndReferencesQuery( + MOCK_CHILD_ENTITY, + "initiative", + false, + { requestOptions: {} } as IArcGISContext + ); + + expect(getItemGroupsSpy).toHaveBeenCalledTimes(1); + expect(getItemGroupsSpy).toHaveBeenCalledWith("child-00a", {}); + expect(query).toEqual({ + targetEntity: "item", + filters: [ + { + operation: "AND", + predicates: [ + { + type: ["Hub Initiative"], + id: ["parent-00a"], + }, + ], + }, + ], + }); + }); + it("returns null when the child is not 'included' by any parents", async () => { + spyOn(ItemsModule, "getItemGroups").and.returnValue( + Promise.resolve({ admin: [], member: [], other: [] }) + ); + const query = await getIncludesAndReferencesQuery( + MOCK_CHILD_ENTITY, + "initiative", + false, + { requestOptions: {} } as IArcGISContext + ); + + expect(query).toBeNull(); + }); + it("returns null when the child does not 'reference' any parents", async () => { + spyOn(ItemsModule, "getItemGroups").and.returnValue( + Promise.resolve({ + admin: [{ id: "group-00a", typeKeywords: ["initiative|parent-00a"] }], + member: [], + other: [], + }) + ); + const child = cloneObject(MOCK_CHILD_ENTITY); + child.typeKeywords = []; + const query = await getIncludesAndReferencesQuery( + child, + "initiative", + false, + { requestOptions: {} } as IArcGISContext + ); + + expect(query).toBeNull(); + }); + }); +}); diff --git a/packages/common/test/associations/internal/getIncludesDoesNotReferenceQuery.test.ts b/packages/common/test/associations/internal/getIncludesDoesNotReferenceQuery.test.ts new file mode 100644 index 00000000000..87c573f8563 --- /dev/null +++ b/packages/common/test/associations/internal/getIncludesDoesNotReferenceQuery.test.ts @@ -0,0 +1,85 @@ +import { IArcGISContext } from "../../../src/ArcGISContext"; +import { getIncludesDoesNotReferenceQuery } from "../../../src/associations/internal/getIncludesDoesNotReferenceQuery"; +import { MOCK_PARENT_ENTITY, MOCK_CHILD_ENTITY } from "../fixtures"; +import * as ItemsModule from "@esri/arcgis-rest-portal"; + +describe("getIncludesDoesNotReferenceQuery:", () => { + describe("from the parent entity perspective", () => { + it("returns a valid IQuery to fetch child entities", async () => { + const query = await getIncludesDoesNotReferenceQuery( + MOCK_PARENT_ENTITY, + "project", + true, + {} as IArcGISContext + ); + + expect(query).toEqual({ + targetEntity: "item", + filters: [ + { + operation: "AND", + predicates: [ + { + type: ["Hub Project"], + typekeywords: { not: ["ref|initiative|parent-00a"] }, + }, + ], + }, + { + predicates: [{ group: "group-00a" }], + }, + ], + }); + }); + }); + describe("from the child entity perspective", () => { + it("returns a valid IQuery to fetch parent entities", async () => { + const getItemGroupsSpy = spyOn( + ItemsModule, + "getItemGroups" + ).and.returnValue( + Promise.resolve({ + admin: [{ id: "group-00a", typeKeywords: ["initiative|parent-00b"] }], + member: [{ id: "group-00b", typeKeywords: [] }], + other: [{ id: "group-00c", typeKeywords: ["initiative|parent-00c"] }], + }) + ); + const query = await getIncludesDoesNotReferenceQuery( + MOCK_CHILD_ENTITY, + "initiative", + false, + { requestOptions: {} } as IArcGISContext + ); + + expect(getItemGroupsSpy).toHaveBeenCalledTimes(1); + expect(getItemGroupsSpy).toHaveBeenCalledWith("child-00a", {}); + expect(query).toEqual({ + targetEntity: "item", + filters: [ + { + operation: "AND", + predicates: [ + { + type: ["Hub Initiative"], + id: ["parent-00b", "parent-00c"], + }, + ], + }, + ], + }); + }); + it("returns null when the child is not 'included' by any parents", async () => { + spyOn(ItemsModule, "getItemGroups").and.returnValue( + Promise.resolve({ admin: [], member: [], other: [] }) + ); + const query = await getIncludesDoesNotReferenceQuery( + MOCK_CHILD_ENTITY, + "initiative", + false, + { requestOptions: {} } as IArcGISContext + ); + + expect(query).toBeNull(); + }); + }); +}); diff --git a/packages/common/test/associations/internal/getReferencesDoesNotIncludeQuery.test.ts b/packages/common/test/associations/internal/getReferencesDoesNotIncludeQuery.test.ts new file mode 100644 index 00000000000..89e82cffecd --- /dev/null +++ b/packages/common/test/associations/internal/getReferencesDoesNotIncludeQuery.test.ts @@ -0,0 +1,89 @@ +import { cloneObject } from "../../../src"; +import { IArcGISContext } from "../../../src/ArcGISContext"; +import { getReferencesDoesNotIncludeQuery } from "../../../src/associations/internal/getReferencesDoesNotIncludeQuery"; +import { MOCK_PARENT_ENTITY, MOCK_CHILD_ENTITY } from "../fixtures"; +import * as ItemsModule from "@esri/arcgis-rest-portal"; + +describe("getReferencesDoesNotIncludeQuery:", () => { + describe("from the parent entity perspective", () => { + it("returns a valid IQuery to fetch child entities", async () => { + const query = await getReferencesDoesNotIncludeQuery( + MOCK_PARENT_ENTITY, + "project", + true, + {} as IArcGISContext + ); + + expect(query).toEqual({ + targetEntity: "item", + filters: [ + { + operation: "AND", + predicates: [ + { + type: ["Hub Project"], + typekeywords: ["ref|initiative|parent-00a"], + }, + ], + }, + { + predicates: [{ group: { not: ["group-00a"], any: [], all: [] } }], + }, + ], + }); + }); + }); + describe("from the child entity perspective", () => { + let getItemGroupsSpy: jasmine.Spy; + beforeEach(() => { + getItemGroupsSpy = spyOn(ItemsModule, "getItemGroups").and.returnValue( + Promise.resolve({ + admin: [{ id: "group-00a", typeKeywords: ["initiative|parent-00b"] }], + member: [{ id: "group-00b", typeKeywords: [] }], + other: [{ id: "group-00c", typeKeywords: ["initiative|parent-00c"] }], + }) + ); + }); + afterEach(() => { + getItemGroupsSpy.calls.reset(); + }); + + it("returns a valid IQuery to fetch parent entities", async () => { + const query = await getReferencesDoesNotIncludeQuery( + MOCK_CHILD_ENTITY, + "initiative", + false, + { requestOptions: {} } as IArcGISContext + ); + + expect(getItemGroupsSpy).toHaveBeenCalledTimes(1); + expect(getItemGroupsSpy).toHaveBeenCalledWith("child-00a", {}); + expect(query).toEqual({ + targetEntity: "item", + filters: [ + { + operation: "AND", + predicates: [ + { + type: ["Hub Initiative"], + id: ["parent-00a"], + }, + ], + }, + ], + }); + }); + it("returns null when the child doesn't reference any parents", async () => { + const child = cloneObject(MOCK_CHILD_ENTITY); + child.typeKeywords = []; + const query = await getReferencesDoesNotIncludeQuery( + child, + "initiative", + false, + { requestOptions: {} } as IArcGISContext + ); + + expect(query).toBeNull(); + }); + }); +}); diff --git a/packages/common/test/associations/internal/getTargetEntityFromAssociationType.test.ts b/packages/common/test/associations/internal/getTargetEntityFromAssociationType.test.ts deleted file mode 100644 index a0fc0688c92..00000000000 --- a/packages/common/test/associations/internal/getTargetEntityFromAssociationType.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -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 index 7293134b629..5807b80e1e3 100644 --- a/packages/common/test/associations/internal/getTypeByIdsQuery.test.ts +++ b/packages/common/test/associations/internal/getTypeByIdsQuery.test.ts @@ -1,13 +1,36 @@ import { getTypeByIdsQuery } from "../../../src/associations/internal/getTypeByIdsQuery"; describe("getTypeByIdsQuery:", () => { - it("verify structure", () => { + it("returns a valid IQuery 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); + }); + it("constructs a query when no ids are provided", () => { + const chk = getTypeByIdsQuery("Hub Project", []); + expect(chk.filters[0].predicates[0].type).toBe("Hub Project"); + expect(chk.filters[0].operation).toBeUndefined(); + expect(chk.filters[0].predicates[0].id).toBeUndefined(); + }); + it("constructs a query for a single type and multiple ids", () => { + const chk = getTypeByIdsQuery("Hub Project", ["a", "b"]); + + expect(chk.filters[0].predicates[0].type).toBe("Hub Project"); + expect(chk.filters[0].predicates[0].id).toEqual(["a", "b"]); + }); + it("constructs a query for multiple types and multiple ids", () => { + const chk = getTypeByIdsQuery( + ["Hub Project", "Hub Initiative"], + ["a", "b"] + ); + + expect(chk.filters[0].predicates[0].type).toEqual([ + "Hub Project", + "Hub Initiative", + ]); expect(chk.filters[0].predicates[0].id).toEqual(["a", "b"]); }); }); diff --git a/packages/common/test/associations/internal/getTypeByNotIdsQuery.test.ts b/packages/common/test/associations/internal/getTypeByNotIdsQuery.test.ts new file mode 100644 index 00000000000..7a1838d0dee --- /dev/null +++ b/packages/common/test/associations/internal/getTypeByNotIdsQuery.test.ts @@ -0,0 +1,36 @@ +import { getTypeByNotIdsQuery } from "../../../src/associations/internal/getTypeByNotIdsQuery"; + +describe("getTypeByNotIdsQuery:", () => { + it("returns a valid IQuery structure", () => { + const chk = getTypeByNotIdsQuery("Hub Project", ["a", "b"]); + + expect(chk.targetEntity).toBe("item"); + expect(chk.filters.length).toBe(1); + expect(chk.filters[0].predicates.length).toBe(1); + }); + it("constructs a query when no ids are provided", () => { + const chk = getTypeByNotIdsQuery("Hub Project", []); + + expect(chk.filters[0].predicates[0].type).toBe("Hub Project"); + expect(chk.filters[0].operation).toBeUndefined(); + expect(chk.filters[0].predicates[0].id).toBeUndefined(); + }); + it("constructs a query for a single type and multiple ids", () => { + const chk = getTypeByNotIdsQuery("Hub Project", ["a", "b"]); + + expect(chk.filters[0].predicates[0].type).toBe("Hub Project"); + expect(chk.filters[0].predicates[0].id).toEqual({ not: ["a", "b"] }); + }); + it("constructs a query for multiple types and multiple ids", () => { + const chk = getTypeByNotIdsQuery( + ["Hub Project", "Hub Initiative"], + ["a", "b"] + ); + + expect(chk.filters[0].predicates[0].type).toEqual([ + "Hub Project", + "Hub Initiative", + ]); + expect(chk.filters[0].predicates[0].id).toEqual({ not: ["a", "b"] }); + }); +}); diff --git a/packages/common/test/associations/internal/getTypeFromAssociationType.test.ts b/packages/common/test/associations/internal/getTypeFromAssociationType.test.ts deleted file mode 100644 index f5ed20e4b41..00000000000 --- a/packages/common/test/associations/internal/getTypeFromAssociationType.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -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 index c2b54110906..c96dd234ed2 100644 --- a/packages/common/test/associations/internal/getTypeWithKeywordQuery.test.ts +++ b/packages/common/test/associations/internal/getTypeWithKeywordQuery.test.ts @@ -1,13 +1,29 @@ import { getTypeWithKeywordQuery } from "../../../src/associations/internal/getTypeWithKeywordQuery"; describe("getTypeWithKeywordQuery:", () => { - it("verify structure", () => { + it("returns a valid IQuery 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); + }); + it("constructs a query for a single type and a single typeKeyword", () => { + const chk = getTypeWithKeywordQuery("Hub Project", "foo|00c"); + expect(chk.filters[0].predicates[0].type).toBe("Hub Project"); - expect(chk.filters[0].predicates[0].typekeywords).toEqual("foo|00c"); + expect(chk.filters[0].predicates[0].typekeywords).toEqual(["foo|00c"]); + }); + it("constructs a query for multiple types and a single typeKeyword", () => { + const chk = getTypeWithKeywordQuery( + ["Hub Project", "Hub Initiative"], + "foo|00c" + ); + + expect(chk.filters[0].predicates[0].type).toEqual([ + "Hub Project", + "Hub Initiative", + ]); + 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 index 7927b1bb9e6..99f5db86090 100644 --- a/packages/common/test/associations/internal/getTypeWithoutKeywordQuery.test.ts +++ b/packages/common/test/associations/internal/getTypeWithoutKeywordQuery.test.ts @@ -1,15 +1,33 @@ import { getTypeWithoutKeywordQuery } from "../../../src/associations/internal/getTypeWithoutKeywordQuery"; describe("getTypeWithoutKeywordQuery:", () => { - it("verify structure", () => { + it("returns a valid IQuery 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); + }); + it("constructs a query for a single type and a single typeKeyword", () => { + const chk = getTypeWithoutKeywordQuery("Hub Project", "foo|00c"); + expect(chk.filters[0].predicates[0].type).toBe("Hub Project"); expect(chk.filters[0].predicates[0].typekeywords).toEqual({ not: ["foo|00c"], }); }); + it("constructs a query for multiple types and a single typeKeyword", () => { + const chk = getTypeWithoutKeywordQuery( + ["Hub Project", "Hub Initiative"], + "foo|00c" + ); + + expect(chk.filters[0].predicates[0].type).toEqual([ + "Hub Project", + "Hub Initiative", + ]); + expect(chk.filters[0].predicates[0].typekeywords).toEqual({ + not: ["foo|00c"], + }); + }); }); diff --git a/packages/common/test/associations/internal/isAssociationSupported.test.ts b/packages/common/test/associations/internal/isAssociationSupported.test.ts new file mode 100644 index 00000000000..7d558cf4ec0 --- /dev/null +++ b/packages/common/test/associations/internal/isAssociationSupported.test.ts @@ -0,0 +1,32 @@ +import { HubEntityType } from "../../../src/core/types"; +import { isAssociationSupported } from "../../../src/associations/internal/isAssociationSupported"; +import * as getAssociationHierarchyModule from "../../../src/associations/internal/getAssociationHierarchy"; + +describe("isAssociationSupported:", () => { + it("returns true for supported associations", () => { + const chk1 = isAssociationSupported("project", "initiative"); + expect(chk1).toBe(true); + + const chk2 = isAssociationSupported("initiative", "project"); + expect(chk2).toBe(true); + }); + it("returns false if one of the provided entities doesn't have an association hierarchy defined", () => { + spyOn( + getAssociationHierarchyModule, + "getAssociationHierarchy" + ).and.returnValues( + { children: ["child1"], parents: [] }, + { children: [], parents: ["parent1"] } + ); + + const chk = isAssociationSupported( + "child1" as HubEntityType, + "parent2" as HubEntityType + ); + expect(chk).toBe(false); + }); + it("returns false for unsupported associations", () => { + const chk = isAssociationSupported("project", "group"); + expect(chk).toBe(false); + }); +}); diff --git a/packages/common/test/associations/internal/removeAssociationKeyword.test.ts b/packages/common/test/associations/internal/removeAssociationKeyword.test.ts new file mode 100644 index 00000000000..65890e3b17b --- /dev/null +++ b/packages/common/test/associations/internal/removeAssociationKeyword.test.ts @@ -0,0 +1,12 @@ +import { removeAssociationKeyword } from "../../../src/associations/internal/removeAssociationKeyword"; + +describe("setAssociationKeyword:", () => { + it("removes an association keyword", () => { + const chk = removeAssociationKeyword( + ["someKeyword", "ref|initiative|123"], + "initiative", + "123" + ); + expect(chk.length).toBe(1); + }); +}); diff --git a/packages/common/test/associations/internal/setAssociationKeyword.test.ts b/packages/common/test/associations/internal/setAssociationKeyword.test.ts new file mode 100644 index 00000000000..6c60063740c --- /dev/null +++ b/packages/common/test/associations/internal/setAssociationKeyword.test.ts @@ -0,0 +1,16 @@ +import { setAssociationKeyword } from "../../../src/associations/internal/setAssociationKeyword"; + +describe("setAssociationKeyword:", () => { + it("adds an association keyword", () => { + const chk = setAssociationKeyword(["someKeyword"], "initiative", "123"); + expect(chk[1]).toBe("ref|initiative|123"); + }); + it("does not add duplicate association keywords", () => { + const chk = setAssociationKeyword( + ["someKeyword", "ref|initiative|123"], + "initiative", + "123" + ); + expect(chk.length).toBe(2); + }); +}); diff --git a/packages/common/test/associations/listAssociations.test.ts b/packages/common/test/associations/listAssociations.test.ts index 112826d7b16..081e8975aa9 100644 --- a/packages/common/test/associations/listAssociations.test.ts +++ b/packages/common/test/associations/listAssociations.test.ts @@ -1,4 +1,5 @@ -import { IWithAssociations, listAssociations } from "../../src"; +import { IWithAssociations } from "../../src/core/traits"; +import { listAssociations } from "../../src/associations/listAssociations"; describe("listAssociations:", () => { it("returns empty array if no keywords prop", () => { diff --git a/packages/common/test/associations/removeAssociation.test.ts b/packages/common/test/associations/removeAssociation.test.ts index 32d80847240..ae982bc329b 100644 --- a/packages/common/test/associations/removeAssociation.test.ts +++ b/packages/common/test/associations/removeAssociation.test.ts @@ -1,4 +1,5 @@ -import { IWithAssociations, removeAssociation } from "../../src"; +import { IWithAssociations } from "../../src/core/traits"; +import { removeAssociation } from "../../src/associations/removeAssociation"; describe("removeAssociation", () => { it("removes the keyword if present", () => { diff --git a/packages/common/test/associations/requestAssociation.test.ts b/packages/common/test/associations/requestAssociation.test.ts new file mode 100644 index 00000000000..6871303be48 --- /dev/null +++ b/packages/common/test/associations/requestAssociation.test.ts @@ -0,0 +1,96 @@ +import { ArcGISContext } from "../../src/ArcGISContext"; +import { requestAssociation } from "../../src/associations/requestAssociation"; +import { MOCK_CHILD_ENTITY, MOCK_PARENT_ENTITY } from "./fixtures"; +import * as RestPortalModule from "@esri/arcgis-rest-portal"; +import * as FetchHubEntityModule from "../../src/core/fetchHubEntity"; +import * as UpdateHubEntityModule from "../../src/core/updateHubEntity"; + +describe("requestAssociation", () => { + let shareItemWithGroupSpy: jasmine.Spy; + let fetchHubEntitySpy: jasmine.Spy; + let updateHubEntitySpy: jasmine.Spy; + + beforeEach(() => { + shareItemWithGroupSpy = spyOn( + RestPortalModule, + "shareItemWithGroup" + ).and.returnValue(Promise.resolve()); + fetchHubEntitySpy = spyOn( + FetchHubEntityModule, + "fetchHubEntity" + ).and.returnValue(Promise.resolve({ owner: "mock-owner" })); + updateHubEntitySpy = spyOn( + UpdateHubEntityModule, + "updateHubEntity" + ).and.returnValue(Promise.resolve()); + }); + + it("parent perspective: shares the child to its association group", async () => { + await requestAssociation(MOCK_PARENT_ENTITY, "project", "child-00a", { + session: {}, + } as ArcGISContext); + + expect(fetchHubEntitySpy).toHaveBeenCalledTimes(1); + expect(fetchHubEntitySpy).toHaveBeenCalledWith("project", "child-00a", { + session: {}, + }); + expect(shareItemWithGroupSpy).toHaveBeenCalledTimes(1); + expect(shareItemWithGroupSpy).toHaveBeenCalledWith({ + id: "child-00a", + owner: "mock-owner", + groupId: "group-00a", + authentication: {}, + }); + }); + it("child perspective: adds a typeKeyword to itself referencing the parent", async () => { + const mockChild = { ...MOCK_CHILD_ENTITY }; + await requestAssociation( + mockChild, + "initiative", + "parent-00b", + {} as ArcGISContext + ); + + expect(mockChild.typeKeywords?.length).toBe(2); + expect(updateHubEntitySpy).toHaveBeenCalledTimes(1); + expect(updateHubEntitySpy).toHaveBeenCalledWith( + "project", + mockChild, + {} as ArcGISContext + ); + }); + it("throws an error if there is an issue sharing the item with the association group", async () => { + shareItemWithGroupSpy.and.returnValue(Promise.reject("error")); + + try { + await requestAssociation( + MOCK_PARENT_ENTITY, + "project", + "child-00a", + {} as ArcGISContext + ); + } catch (err) { + expect(err).toEqual( + new Error( + "requestAssociation: there was an error sharing child-00a to group-00a: error" + ) + ); + } + }); + it("throws an error if the association is not supported", async () => { + try { + await requestAssociation( + MOCK_PARENT_ENTITY, + "group", + "group-00a", + {} as ArcGISContext + ); + } catch (err) { + expect(err).toEqual( + new Error( + "requestAssociation: Association between initiative and group is not supported." + ) + ); + } + }); +}); diff --git a/packages/common/test/core/getTypesFromEntityType.test.ts b/packages/common/test/core/getTypesFromEntityType.test.ts new file mode 100644 index 00000000000..a28c3e490ea --- /dev/null +++ b/packages/common/test/core/getTypesFromEntityType.test.ts @@ -0,0 +1,34 @@ +import { HubEntity, HubEntityType } from "../../src/core/types"; +import { getTypesFromEntityType } from "../../src/core/getTypesFromEntityType"; + +describe("getTypesFromEntityType:", () => { + it("returns the item type(s) for all Hub entity types", () => { + const types = [ + "site", + "page", + "project", + "initiative", + "discussion", + "group", + "template", + "initiativeTemplate", + "content", + ]; + const expected = [ + ["Hub Site Application", "Site Application"], + ["Hub Page", "Site Page"], + ["Hub Project"], + ["Hub Initiative"], + ["Discussion"], + ["Group"], + ["Solution"], + ["Hub Initiative Template"], + [], + ]; + types.forEach((type, idx) => { + const entity = { type } as unknown as HubEntity; + const entityType = getTypesFromEntityType(entity.type as HubEntityType); + expect(entityType).toEqual(expected[idx]); + }); + }); +}); diff --git a/packages/common/test/core/processActionLinks.test.ts b/packages/common/test/core/processActionLinks.test.ts index 2797ca3911d..c14500c1a67 100644 --- a/packages/common/test/core/processActionLinks.test.ts +++ b/packages/common/test/core/processActionLinks.test.ts @@ -7,7 +7,7 @@ import { IHubExternalActionLink, IHubWellKnownActionLink, } from "../../src/core/types/ActionLinks"; -import * as searchModule from "../../src/search"; +import * as searchModule from "../../src/search/hubSearch"; describe("processActionLink", () => { let hubSearchSpy: jasmine.Spy; diff --git a/packages/common/test/core/updateHubEntity.test.ts b/packages/common/test/core/updateHubEntity.test.ts index 32c1161dadf..7339038d7ff 100644 --- a/packages/common/test/core/updateHubEntity.test.ts +++ b/packages/common/test/core/updateHubEntity.test.ts @@ -100,4 +100,15 @@ describe("updateHubEntity:", () => { await updateHubEntity("initiativeTemplate", {} as HubEntity, ctx); expect(spy).toHaveBeenCalledWith({}, "fakeRequestOptions"); }); + it("updates group", async () => { + const ctx = { + requestOptions: "fakeRequestOptions", + } as unknown as IArcGISContext; + const spy = spyOn( + require("../../src/groups/HubGroups"), + "updateHubGroup" + ).and.returnValue(Promise.resolve({})); + await updateHubEntity("group", {} as HubEntity, ctx); + expect(spy).toHaveBeenCalledWith({}, "fakeRequestOptions"); + }); }); diff --git a/packages/common/test/initiatives/HubInitiatives.test.ts b/packages/common/test/initiatives/HubInitiatives.test.ts index 49e6cd4487a..8a93fec0838 100644 --- a/packages/common/test/initiatives/HubInitiatives.test.ts +++ b/packages/common/test/initiatives/HubInitiatives.test.ts @@ -446,7 +446,7 @@ describe("HubInitiatives:", () => { // ensure we have type and keyword in predicate expect(verifyPredicate(chk, { type: "Hub Project" })).toBeTruthy(); expect( - verifyPredicate(chk, { typekeywords: "initiative|00f" }) + verifyPredicate(chk, { typekeywords: ["initiative|00f"] }) ).toBeTruthy("should have keyword"); expect(getPredicateValue(chk, { group: null })).toEqual(["00c", "aa1"]); }); @@ -456,7 +456,7 @@ describe("HubInitiatives:", () => { // ensure we have type and keyword in predicate expect(verifyPredicate(chk, { type: "Hub Project" })).toBeTruthy(); expect( - verifyPredicate(chk, { typekeywords: "initiative|00f" }) + verifyPredicate(chk, { typekeywords: ["initiative|00f"] }) ).toBeTruthy("should have keyword"); expect(getPredicateValue(chk, { group: null })).toEqual({ any: [], diff --git a/packages/common/test/search/_internal/buildCatalog.test.ts b/packages/common/test/search/_internal/buildCatalog.test.ts new file mode 100644 index 00000000000..f3058466ee4 --- /dev/null +++ b/packages/common/test/search/_internal/buildCatalog.test.ts @@ -0,0 +1,27 @@ +import { IHubCollection } from "../../../src"; +import { buildCatalog } from "../../../src/search/_internal/buildCatalog"; +describe("buildCatalog:", () => { + it("returns a valid hub catalog structure", () => { + const catalog = buildCatalog( + "mockI18nScope", + "myContent", + [{ predicates: [{ owner: "vader" }] }], + [ + { key: "myContent" } as IHubCollection, + { key: "favorites" } as IHubCollection, + ], + "item" + ); + + expect(catalog.title).toEqual( + "{{mockI18nScopecatalog.myContent:translate}}" + ); + expect(catalog.scopes?.item?.filters).toEqual([ + { predicates: [{ owner: "vader" }] }, + ]); + expect(catalog.collections?.map((c) => c.key)).toEqual([ + "myContent", + "favorites", + ]); + }); +});