Skip to content

Commit

Permalink
feat: enhance associations logic to handle forming associations (#1395)
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
juliannemarik authored Feb 1, 2024
1 parent fe02c09 commit 6eb7e5f
Show file tree
Hide file tree
Showing 13 changed files with 302 additions and 20 deletions.
30 changes: 24 additions & 6 deletions packages/common/src/associations/getAssociationStats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 };
}
})
);

Expand Down
29 changes: 29 additions & 0 deletions packages/common/src/associations/getReferencedEntityIds.ts
Original file line number Diff line number Diff line change
@@ -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|<associationType>|<id>
*
* 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);
};
3 changes: 2 additions & 1 deletion packages/common/src/associations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
13 changes: 13 additions & 0 deletions packages/common/src/associations/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
});
};
2 changes: 2 additions & 0 deletions packages/common/src/core/types/IHubCardViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export interface ICardActionLink {
showLabel?: boolean;
icon?: string;
buttonStyle?: "outline" | "outline-fill" | "solid" | "transparent";
disabled?: boolean;
tooltip?: string;
}

export type CardModelTarget =
Expand Down
38 changes: 30 additions & 8 deletions packages/common/src/search/wellKnownCatalog.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -36,6 +37,8 @@ export type WellKnownCollection =
export interface IGetWellKnownCatalogOptions {
user?: IUser;
collectionNames?: WellKnownCollection[];
/** additional filters to apply to the catalog scope */
filters?: IFilter[];
}

/**
Expand Down Expand Up @@ -101,6 +104,7 @@ function getWellknownItemCatalog(
): IHubCatalog {
i18nScope = dotifyString(i18nScope);
let catalog;
const additionalFilters = getProp(options, "filters") || [];
const collections = getWellknownCollections(
i18nScope,
"item",
Expand All @@ -112,7 +116,10 @@ function getWellknownItemCatalog(
catalog = buildCatalog(
i18nScope,
catalogName,
[{ predicates: [{ owner: options.user.username }] }],
[
{ predicates: [{ owner: options.user.username }] },
...additionalFilters,
],
collections,
"item"
);
Expand All @@ -122,7 +129,10 @@ function getWellknownItemCatalog(
catalog = buildCatalog(
i18nScope,
catalogName,
[{ predicates: [{ group: options.user.favGroupId }] }],
[
{ predicates: [{ group: options.user.favGroupId }] },
...additionalFilters,
],
collections,
"item"
);
Expand All @@ -132,7 +142,7 @@ function getWellknownItemCatalog(
catalog = buildCatalog(
i18nScope,
catalogName,
[{ predicates: [{ orgid: options.user.orgId }] }],
[{ predicates: [{ orgid: options.user.orgId }] }, ...additionalFilters],
collections,
"item"
);
Expand All @@ -141,7 +151,10 @@ function getWellknownItemCatalog(
catalog = buildCatalog(
i18nScope,
catalogName,
[{ predicates: [{ type: { not: ["code attachment"] } }] }],
[
{ predicates: [{ type: { not: ["code attachment"] } }] },
...additionalFilters,
],
collections,
"item"
);
Expand All @@ -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 = [
Expand All @@ -188,7 +202,10 @@ function getWellknownGroupCatalog(
catalog = buildCatalog(
i18nScope,
catalogName,
[{ predicates: [{ capabilities: ["updateitemcontrol"] }] }],
[
{ predicates: [{ capabilities: ["updateitemcontrol"] }] },
...additionalFilters,
],
collections,
"group"
);
Expand All @@ -198,7 +215,10 @@ function getWellknownGroupCatalog(
catalog = buildCatalog(
i18nScope,
catalogName,
[{ predicates: [{ capabilities: { not: ["updateitemcontrol"] } }] }],
[
{ predicates: [{ capabilities: { not: ["updateitemcontrol"] } }] },
...additionalFilters,
],
collections,
"group"
);
Expand All @@ -208,7 +228,7 @@ function getWellknownGroupCatalog(
catalog = buildCatalog(
i18nScope,
catalogName,
[{ predicates: [{ capabilities: [""] }] }],
[{ predicates: [{ capabilities: [""] }] }, ...additionalFilters],
collections,
"group"
);
Expand Down Expand Up @@ -342,6 +362,8 @@ function getAllCollectionsMap(i18nScope: string, entityType: EntityType): any {
predicates: [
{
type: getFamilyTypes("initiative"),
// only include v2 initiatives
typekeywords: ["hubInitiativeV2"],
},
],
},
Expand Down
16 changes: 16 additions & 0 deletions packages/common/test/associations/getAssociationStats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions packages/common/test/associations/getReferencedEntityIds.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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");
});
});
Loading

0 comments on commit 6eb7e5f

Please sign in to comment.