From 6eb7e5f6548161d28381139e2d3c76cbae175797 Mon Sep 17 00:00:00 2001 From: Julianne Crawford Date: Thu, 1 Feb 2024 12:10:41 -0600 Subject: [PATCH] feat: enhance associations logic to handle forming associations (#1395) - enhance wellKnownCatalog utils to accept optional filters to apply to the catalog scopes - rename getWellKnownAssociationsCatalog module > wellKnownAssociationCatalogs - add new util getAvailableToRequestAssociationCatalogs - fix getAssociationStats to handle hubSearch errors - expose a new util, getReferencedEntityIds - export a constant for ASSOCIATION_REFERENCE_LIMIT - extend ICardActionLink interface to optionally accept disabled and tooltip properties --- .../src/associations/getAssociationStats.ts | 30 +++++-- .../associations/getReferencedEntityIds.ts | 29 +++++++ packages/common/src/associations/index.ts | 3 +- .../internal/getIdsFromKeywords.ts | 5 +- packages/common/src/associations/types.ts | 13 +++ ...log.ts => wellKnownAssociationCatalogs.ts} | 54 ++++++++++++ .../src/core/types/IHubCardViewModel.ts | 2 + .../common/src/search/wellKnownCatalog.ts | 38 ++++++-- .../associations/getAssociationStats.test.ts | 16 ++++ .../getReferencedEntityIds.test.ts | 21 +++++ .../internal/getIdsFromKeywords.test.ts | 14 ++- ...s => wellKnownAssociationCatalogs.test.ts} | 87 ++++++++++++++++++- .../test/search/wellKnownCatalog.test.ts | 10 +++ 13 files changed, 302 insertions(+), 20 deletions(-) create mode 100644 packages/common/src/associations/getReferencedEntityIds.ts rename packages/common/src/associations/{getWellKnownAssociationsCatalog.ts => wellKnownAssociationCatalogs.ts} (69%) create mode 100644 packages/common/test/associations/getReferencedEntityIds.test.ts rename packages/common/test/associations/{getWellKnownAssociationsCatalog.test.ts => wellKnownAssociationCatalogs.test.ts} (61%) diff --git a/packages/common/src/associations/getAssociationStats.ts b/packages/common/src/associations/getAssociationStats.ts index 51faeb8a7f8..6472f41f3ef 100644 --- a/packages/common/src/associations/getAssociationStats.ts +++ b/packages/common/src/associations/getAssociationStats.ts @@ -11,8 +11,22 @@ import { isAssociationSupported } from "./internal/isAssociationSupported"; import { IAssociationStats } from "./types"; /** - * get an entity's association stats - # of associated, pending, - * requesting, included/referenced entities + * get Entity A's association stats with Entity B: + * + * 1. associated: the number of Entity B's that Entity A + * is associated with + * + * 2. pending: the number of outgoing requests Entity A + * has sent to Entity B + * + * 3. requesting: the number of incoming requests Entity A + * has received from Entity B + * + * 4a. included: if Entity A is the parent, the number of + * Entity B's it has included in its association group + * + * 4b. referenced: if Entity A is the child, the number of + * Entity B's it has referenced (via typeKeyword) * * @param entity - Hub entity * @param associationType - entity type to query for @@ -53,10 +67,14 @@ export const getAssociationStats = async ( const [{ total: associated }, { total: pending }, { total: requesting }] = await Promise.all( - queries.map((query: IQuery) => { - return hubSearch(query, { - requestOptions: context.hubRequestOptions, - }); + queries.map(async (query: IQuery) => { + try { + return await hubSearch(query, { + requestOptions: context.hubRequestOptions, + }); + } catch (error) { + return { total: 0 }; + } }) ); diff --git a/packages/common/src/associations/getReferencedEntityIds.ts b/packages/common/src/associations/getReferencedEntityIds.ts new file mode 100644 index 00000000000..730f6dca6ab --- /dev/null +++ b/packages/common/src/associations/getReferencedEntityIds.ts @@ -0,0 +1,29 @@ +import { HubEntity, HubEntityType } from "../core/types"; +import { getIdsFromKeywords } from "./internal/getIdsFromKeywords"; + +/** + * The following util returns an array of ids that the provided + * entity "references" via a typeKeyword of the form ref|| + * + * Note: if a specific associationType is not provided, this util + * will return ALL the ids that the provided entity "references" + * + * Additional context: the model for associations is built around + * platform capabilities. Platform imposes a limit of 128 on the + * number of typeKeywords that can be set on an item. Since "child" + * entities form their half of an association connection via + * typeKeywords, we must limit the number of associations a child + * can request or accept to far fewer than 128. For now, we are + * imposing a limit of 50. From the application, we can then use + * this util to determine if a child has already reached the limit + * + * @param entity - hub entity to extract ids from + * @param associationType - entity type to extract reference ids for + * @returns {string[]} + */ +export const getReferencedEntityIds = ( + entity: HubEntity, + associationType?: HubEntityType +): string[] => { + return getIdsFromKeywords(entity, associationType); +}; diff --git a/packages/common/src/associations/index.ts b/packages/common/src/associations/index.ts index 3a7ef424e50..45a11aafa89 100644 --- a/packages/common/src/associations/index.ts +++ b/packages/common/src/associations/index.ts @@ -8,7 +8,8 @@ export * from "./getAssociationStats"; export * from "./getAvailableToRequestEntitiesQuery"; export * from "./getPendingEntitiesQuery"; export * from "./getRequestingEntitiesQuery"; -export * from "./getWellKnownAssociationsCatalog"; +export * from "./getReferencedEntityIds"; +export * from "./wellKnownAssociationCatalogs"; // Note: we expose "requestAssociation" under 2 names. // These actions are functionally equivalent, but we want // to make the intent more clear to the consumer. diff --git a/packages/common/src/associations/internal/getIdsFromKeywords.ts b/packages/common/src/associations/internal/getIdsFromKeywords.ts index 142da786f93..da297b759fe 100644 --- a/packages/common/src/associations/internal/getIdsFromKeywords.ts +++ b/packages/common/src/associations/internal/getIdsFromKeywords.ts @@ -14,11 +14,12 @@ import { getProp } from "../../objects"; */ export const getIdsFromKeywords = ( entity: HubEntity, - associationType: HubEntityType + associationType?: HubEntityType ): string[] => { return getProp(entity, "typeKeywords").reduce( (ids: string[], keyword: string) => { - if (keyword.startsWith(`ref|${associationType}|`)) { + const refKey = associationType ? `ref|${associationType}|` : "ref|"; + if (keyword.startsWith(refKey)) { const id = keyword.split("|")[2]; ids.push(id); } diff --git a/packages/common/src/associations/types.ts b/packages/common/src/associations/types.ts index 0acb18e760e..6ad54053df9 100644 --- a/packages/common/src/associations/types.ts +++ b/packages/common/src/associations/types.ts @@ -75,3 +75,16 @@ export interface IAssociationInfo { * Association type */ export type AssociationType = "initiative"; + +/** + * The model for associations is built around platform + * capabilities. Platform imposes a limit of 128 on the + * number of typeKeywords that can be set on an item. + * Since "child" entities form their half of an association + * connection via typeKeywords, we must limit the number + * of associations a child can request or accept to far + * fewer than 128. + * + * For now, we are setting this limit to 50 + */ +export const ASSOCIATION_REFERENCE_LIMIT = 50; diff --git a/packages/common/src/associations/getWellKnownAssociationsCatalog.ts b/packages/common/src/associations/wellKnownAssociationCatalogs.ts similarity index 69% rename from packages/common/src/associations/getWellKnownAssociationsCatalog.ts rename to packages/common/src/associations/wellKnownAssociationCatalogs.ts index 167a02dcb90..dd2315edb02 100644 --- a/packages/common/src/associations/getWellKnownAssociationsCatalog.ts +++ b/packages/common/src/associations/wellKnownAssociationCatalogs.ts @@ -10,8 +10,11 @@ import { getRequestingEntitiesQuery } from "./getRequestingEntitiesQuery"; import { isAssociationSupported } from "./internal/isAssociationSupported"; import { IHubCatalog, IQuery } from "../search/types"; import { + IGetWellKnownCatalogOptions, + WellKnownCatalog, WellKnownCollection, dotifyString, + getWellKnownCatalog, getWellknownCollection, } from "../search/wellKnownCatalog"; @@ -121,3 +124,54 @@ export async function getWellKnownAssociationsCatalog( return catalog; } + +/** + * Specific util for building well-known (My content, Favorites, + * Organization, and World) association catalogs to populate + * a gallery picker experience for requesting association. + * + * In addition to the normal filters that define these well-known + * catalogs, we also need to further filter the results to only + * include entities that can still be requested for association. + * + * @param i18nScope - translation scope to be interpolated into the catalog + * @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 const getAvailableToRequestAssociationCatalogs = ( + i18nScope: string, + entity: HubEntity, + associationType: HubEntityType, + context: IArcGISContext +) => { + const entityType = getTypeFromEntity(entity); + const isSupported = isAssociationSupported(entityType, associationType); + + if (!isSupported) { + throw new Error( + `getAvailableToRequestAssociationCatalogs: Association between ${entityType} and ${associationType} is not supported.` + ); + } + + i18nScope = dotifyString(i18nScope); + const filters = getAvailableToRequestEntitiesQuery( + entity, + associationType + )?.filters; + const catalogNames: WellKnownCatalog[] = [ + "myContent", + "favorites", + "organization", + "world", + ]; + return catalogNames.map((name: WellKnownCatalog) => { + const options: IGetWellKnownCatalogOptions = { + user: context.currentUser, + filters, + collectionNames: [associationType as WellKnownCollection], + }; + return getWellKnownCatalog(i18nScope, name, "item", options); + }); +}; diff --git a/packages/common/src/core/types/IHubCardViewModel.ts b/packages/common/src/core/types/IHubCardViewModel.ts index 1245373da4b..0f9dd06b977 100644 --- a/packages/common/src/core/types/IHubCardViewModel.ts +++ b/packages/common/src/core/types/IHubCardViewModel.ts @@ -59,6 +59,8 @@ export interface ICardActionLink { showLabel?: boolean; icon?: string; buttonStyle?: "outline" | "outline-fill" | "solid" | "transparent"; + disabled?: boolean; + tooltip?: string; } export type CardModelTarget = diff --git a/packages/common/src/search/wellKnownCatalog.ts b/packages/common/src/search/wellKnownCatalog.ts index b663d62ad47..591c99e3401 100644 --- a/packages/common/src/search/wellKnownCatalog.ts +++ b/packages/common/src/search/wellKnownCatalog.ts @@ -1,8 +1,9 @@ import { IUser } from "@esri/arcgis-rest-types"; import { getFamilyTypes } from "../content/get-family"; import { HubFamily } from "../types"; -import { EntityType, IHubCatalog, IHubCollection } from "./types"; +import { EntityType, IFilter, IHubCatalog, IHubCollection } from "./types"; import { buildCatalog } from "./_internal/buildCatalog"; +import { getProp } from "../objects"; /** * This is used to determine what IHubCatalog definition JSON object @@ -36,6 +37,8 @@ export type WellKnownCollection = export interface IGetWellKnownCatalogOptions { user?: IUser; collectionNames?: WellKnownCollection[]; + /** additional filters to apply to the catalog scope */ + filters?: IFilter[]; } /** @@ -101,6 +104,7 @@ function getWellknownItemCatalog( ): IHubCatalog { i18nScope = dotifyString(i18nScope); let catalog; + const additionalFilters = getProp(options, "filters") || []; const collections = getWellknownCollections( i18nScope, "item", @@ -112,7 +116,10 @@ function getWellknownItemCatalog( catalog = buildCatalog( i18nScope, catalogName, - [{ predicates: [{ owner: options.user.username }] }], + [ + { predicates: [{ owner: options.user.username }] }, + ...additionalFilters, + ], collections, "item" ); @@ -122,7 +129,10 @@ function getWellknownItemCatalog( catalog = buildCatalog( i18nScope, catalogName, - [{ predicates: [{ group: options.user.favGroupId }] }], + [ + { predicates: [{ group: options.user.favGroupId }] }, + ...additionalFilters, + ], collections, "item" ); @@ -132,7 +142,7 @@ function getWellknownItemCatalog( catalog = buildCatalog( i18nScope, catalogName, - [{ predicates: [{ orgid: options.user.orgId }] }], + [{ predicates: [{ orgid: options.user.orgId }] }, ...additionalFilters], collections, "item" ); @@ -141,7 +151,10 @@ function getWellknownItemCatalog( catalog = buildCatalog( i18nScope, catalogName, - [{ predicates: [{ type: { not: ["code attachment"] } }] }], + [ + { predicates: [{ type: { not: ["code attachment"] } }] }, + ...additionalFilters, + ], collections, "item" ); @@ -164,6 +177,7 @@ function getWellknownGroupCatalog( ): IHubCatalog { i18nScope = dotifyString(i18nScope); let catalog; + const additionalFilters = getProp(options, "filters") || []; // because collections are needed in arcgis-hub-catalog and // "searchGroups" allows 'q: "*"', we use this as the collection const collections = [ @@ -188,7 +202,10 @@ function getWellknownGroupCatalog( catalog = buildCatalog( i18nScope, catalogName, - [{ predicates: [{ capabilities: ["updateitemcontrol"] }] }], + [ + { predicates: [{ capabilities: ["updateitemcontrol"] }] }, + ...additionalFilters, + ], collections, "group" ); @@ -198,7 +215,10 @@ function getWellknownGroupCatalog( catalog = buildCatalog( i18nScope, catalogName, - [{ predicates: [{ capabilities: { not: ["updateitemcontrol"] } }] }], + [ + { predicates: [{ capabilities: { not: ["updateitemcontrol"] } }] }, + ...additionalFilters, + ], collections, "group" ); @@ -208,7 +228,7 @@ function getWellknownGroupCatalog( catalog = buildCatalog( i18nScope, catalogName, - [{ predicates: [{ capabilities: [""] }] }], + [{ predicates: [{ capabilities: [""] }] }, ...additionalFilters], collections, "group" ); @@ -342,6 +362,8 @@ function getAllCollectionsMap(i18nScope: string, entityType: EntityType): any { predicates: [ { type: getFamilyTypes("initiative"), + // only include v2 initiatives + typekeywords: ["hubInitiativeV2"], }, ], }, diff --git a/packages/common/test/associations/getAssociationStats.test.ts b/packages/common/test/associations/getAssociationStats.test.ts index d272c008a5a..8cd31089879 100644 --- a/packages/common/test/associations/getAssociationStats.test.ts +++ b/packages/common/test/associations/getAssociationStats.test.ts @@ -109,6 +109,22 @@ describe("getAssociationStats:", () => { {} as ArcGISContext ); + expect(stats).toEqual({ + associated: 0, + pending: 0, + requesting: 0, + included: 0, + }); + }); + it("returns empty stats if any error is thrown", async () => { + getAssociatedEntitiesQuerySpy.and.returnValue(Promise.reject({})); + + const stats = await getAssociationStats( + MOCK_PARENT_ENTITY, + "project", + {} as ArcGISContext + ); + expect(stats).toEqual({ associated: 0, pending: 0, diff --git a/packages/common/test/associations/getReferencedEntityIds.test.ts b/packages/common/test/associations/getReferencedEntityIds.test.ts new file mode 100644 index 00000000000..0a964cf75b5 --- /dev/null +++ b/packages/common/test/associations/getReferencedEntityIds.test.ts @@ -0,0 +1,21 @@ +import { HubEntity } from "../../src/core/types"; +import { getReferencedEntityIds } from "../../src/associations/getReferencedEntityIds"; +import * as GetIdsFromKeywordsModule from "../../src/associations/internal/getIdsFromKeywords"; + +describe("getReferencedEntityIds", () => { + let getIdsFromKeywordsSpy: jasmine.Spy; + + beforeEach(() => { + getIdsFromKeywordsSpy = spyOn( + GetIdsFromKeywordsModule, + "getIdsFromKeywords" + ).and.returnValue([]); + }); + it("delegates to getIdsFromKeywords", () => { + const entity = { id: "00c", typeKeywords: [] } as unknown as HubEntity; + const associationType = "initiative"; + + getReferencedEntityIds(entity, associationType); + expect(getIdsFromKeywordsSpy).toHaveBeenCalledWith(entity, associationType); + }); +}); diff --git a/packages/common/test/associations/internal/getIdsFromKeywords.test.ts b/packages/common/test/associations/internal/getIdsFromKeywords.test.ts index 4cbcab77df6..1c10ef69b38 100644 --- a/packages/common/test/associations/internal/getIdsFromKeywords.test.ts +++ b/packages/common/test/associations/internal/getIdsFromKeywords.test.ts @@ -16,7 +16,7 @@ describe("getIdsFromKeywords", () => { ); expect(ids.length).toBe(0); }); - it("returns an array of ids for association keywords", () => { + it("returns an array of ids for the association keywords of a specific association type", () => { const ids = getIdsFromKeywords( { typeKeywords: ["someKeyword", "ref|initiative|00c"], @@ -26,4 +26,16 @@ describe("getIdsFromKeywords", () => { expect(ids.length).toBe(1); expect(ids[0]).toBe("00c"); }); + it("returns an array of ids for ALL association keywords if no association type is provided", () => { + const ids = getIdsFromKeywords({ + typeKeywords: [ + "someKeyword", + "ref|some-type-a|00c", + "ref|some-type-b|00d", + ], + } as unknown as HubEntity); + expect(ids.length).toBe(2); + expect(ids[0]).toBe("00c"); + expect(ids[1]).toBe("00d"); + }); }); diff --git a/packages/common/test/associations/getWellKnownAssociationsCatalog.test.ts b/packages/common/test/associations/wellKnownAssociationCatalogs.test.ts similarity index 61% rename from packages/common/test/associations/getWellKnownAssociationsCatalog.test.ts rename to packages/common/test/associations/wellKnownAssociationCatalogs.test.ts index b0418ab9218..a210ed23903 100644 --- a/packages/common/test/associations/getWellKnownAssociationsCatalog.test.ts +++ b/packages/common/test/associations/wellKnownAssociationCatalogs.test.ts @@ -1,4 +1,7 @@ -import { getWellKnownAssociationsCatalog } from "../../src/associations/getWellKnownAssociationsCatalog"; +import { + getWellKnownAssociationsCatalog, + getAvailableToRequestAssociationCatalogs, +} from "../../src/associations/wellKnownAssociationCatalogs"; import * as getAssociatedEntitiesQueryModule from "../../src/associations/getAssociatedEntitiesQuery"; import * as getPendingEntitiesQueryModule from "../../src/associations/getPendingEntitiesQuery"; import * as getRequestingEntitiesQueryModule from "../../src/associations/getRequestingEntitiesQuery"; @@ -30,12 +33,19 @@ describe("getWellKnownAssociationsCatalog", () => { getAvailableToRequestEntitiesQuerySpy = spyOn( getAvailableToRequestEntitiesQueryModule, "getAvailableToRequestEntitiesQuery" - ).and.returnValue(Promise.resolve({ filters: mockFilters })); + ).and.returnValue({ filters: mockFilters }); getWellknownCollectionSpy = spyOn( wellKnownCatalogModule, "getWellknownCollection" ).and.returnValue({ key: "mock-collection" }); }); + afterEach(() => { + getAssociatedEntitiesQuerySpy.calls.reset(); + getPendingEntitiesQuerySpy.calls.reset(); + getRequestingEntitiesQuerySpy.calls.reset(); + getAvailableToRequestEntitiesQuerySpy.calls.reset(); + getWellknownCollectionSpy.calls.reset(); + }); it("builds a valid well-known catalog", async () => { const catalog = await getWellKnownAssociationsCatalog( @@ -137,3 +147,76 @@ describe("getWellKnownAssociationsCatalog", () => { } }); }); + +describe("getAvailableToRequestAssociationCatalogs", () => { + let getAvailableToRequestEntitiesQuerySpy: jasmine.Spy; + let getWellknownCatalogSpy: jasmine.Spy; + + const mockFilters = [{ predicates: [{ id: ["00b", "00c"] }] }]; + beforeEach(() => { + getAvailableToRequestEntitiesQuerySpy = spyOn( + getAvailableToRequestEntitiesQueryModule, + "getAvailableToRequestEntitiesQuery" + ).and.returnValue({ filters: mockFilters }); + getWellknownCatalogSpy = spyOn( + wellKnownCatalogModule, + "getWellKnownCatalog" + ).and.returnValues( + { schemaVersion: 1, title: "mock-myContent" }, + { schemaVersion: 1, title: "mock-favorites" }, + { schemaVersion: 1, title: "mock-organization" }, + { schemaVersion: 1, title: "mock-world" } + ); + }); + afterEach(() => { + getAvailableToRequestEntitiesQuerySpy.calls.reset(); + getWellknownCatalogSpy.calls.reset(); + }); + + it("throws an error if the association is not supported", async () => { + try { + await getAvailableToRequestAssociationCatalogs( + "some-scope", + { type: "Hub Initiative" } as HubEntity, + "group", + {} as ArcGISContext + ); + } catch (err) { + expect(err.message).toBe( + "getAvailableToRequestAssociationCatalogs: Association between initiative and group is not supported." + ); + } + }); + it("does not provide additional filters if the availableToRequestEntitiesQuery comes back empty", async () => { + // overwrite the spy to return null + getAvailableToRequestEntitiesQuerySpy.and.returnValue(null); + + await getAvailableToRequestAssociationCatalogs( + "some-scope", + { type: "Hub Project" } as HubEntity, + "initiative", + {} as ArcGISContext + ); + + const args = getWellknownCatalogSpy.calls.argsFor(1); + expect(args[3].filters).toBeUndefined(); + }); + it('returns an array of valid "availableToRequest" catalogs', async () => { + const catalogs = await getAvailableToRequestAssociationCatalogs( + "some-scope", + { type: "Hub Project" } as HubEntity, + "initiative", + {} as ArcGISContext + ); + + expect(getAvailableToRequestEntitiesQuerySpy).toHaveBeenCalledTimes(1); + expect(getWellknownCatalogSpy).toHaveBeenCalledTimes(4); + expect(catalogs.length).toBe(4); + expect(catalogs).toEqual([ + { schemaVersion: 1, title: "mock-myContent" }, + { schemaVersion: 1, title: "mock-favorites" }, + { schemaVersion: 1, title: "mock-organization" }, + { schemaVersion: 1, title: "mock-world" }, + ]); + }); +}); diff --git a/packages/common/test/search/wellKnownCatalog.test.ts b/packages/common/test/search/wellKnownCatalog.test.ts index 1d1905a7156..844c0f47ffc 100644 --- a/packages/common/test/search/wellKnownCatalog.test.ts +++ b/packages/common/test/search/wellKnownCatalog.test.ts @@ -169,6 +169,16 @@ describe("WellKnownCatalog", () => { ); expect(chk.collections?.length).toBe(0); }); + it("applies provided filters to the catalog scope", () => { + options.filters = [{ predicates: [{ type: ["Hub Project"] }] }]; + const chk = getWellKnownCatalog( + "mockI18nScope", + "organization", + "item", + options + ); + expect(chk.scopes?.item?.filters.length).toBe(2); + }); }); describe("getWellknownCollections", () => {