diff --git a/.env.example b/.env.example index 848ed08d803..efe5b1169be 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ ENV='qaext' LOCATION='qaext' QACREDS_PSW='ADD-THE-REAL-PSW' +QACREDS_USER_PSW='ADD-THE-REAL-PSW' QA_PORTAL_CREDS_PSW='ADD-THE-REAL-PSW' \ No newline at end of file diff --git a/karma.e2e.conf.js b/karma.e2e.conf.js index 4e44a78d1a0..e3610f90dee 100644 --- a/karma.e2e.conf.js +++ b/karma.e2e.conf.js @@ -12,7 +12,6 @@ module.exports = function(config) { browserDisconnectTimeout: 120000, pingTimeout: 1200000, - // base path that will be used to resolve all patterns (eg. files, exclude) basePath: "", @@ -23,7 +22,13 @@ module.exports = function(config) { // list of files / patterns to load in the browser files: [ "packages/*/{src,e2e}/**/*.ts", - { pattern: 'e2e/test-images/*.jpg', watched: false, included: false, served: true, nocache: false } + { + pattern: "e2e/test-images/*.jpg", + watched: false, + included: false, + served: true, + nocache: false, + }, ], // list of files to exclude @@ -33,11 +38,7 @@ module.exports = function(config) { coverageOptions: { // Exclude all files - we don't want code coverage on e2e type tests // also critical so that we can debug in the code - exclude: [ - /\.ts$/i, - /fixture*/, - /expected*/ - ], + exclude: [/\.ts$/i, /fixture*/, /expected*/], threshold: { global: { statements: 0, @@ -45,39 +46,40 @@ module.exports = function(config) { functions: 0, lines: 0, excludes: [ - 'packages/*/examples/**/*.ts', - 'packages/*/test/**/*.ts', - 'packages/*/e2e/**/*.ts', - ] - } - } + "packages/*/examples/**/*.ts", + "packages/*/test/**/*.ts", + "packages/*/e2e/**/*.ts", + ], + }, + }, }, reports: { - "json": { - "directory": "coverage", - "filename": "coverage.json" + json: { + directory: "coverage", + filename: "coverage.json", }, - "html": "coverage" + html: "coverage", }, compilerOptions: { module: "commonjs", - importHelpers: true + importHelpers: true, }, tsconfig: "./tsconfig.json", bundlerOptions: { // validateSyntax: false, transforms: [ - require("karma-typescript-es6-transform")( - { - presets: [ - ["@babel/preset-env", { - targets: { - chrome: "94" - } - }] - ] - } - ) + require("karma-typescript-es6-transform")({ + presets: [ + [ + "@babel/preset-env", + { + targets: { + chrome: "94", + }, + }, + ], + ], + }), ], exclude: ["@esri/arcgis-rest-types"], resolve: { @@ -85,13 +87,13 @@ module.exports = function(config) { // so we need to manually alias each package here. alias: fs .readdirSync("packages") - .filter(p => p[0] !== ".") + .filter((p) => p[0] !== ".") .reduce((alias, p) => { alias[`@esri/templates-${p}`] = `packages/${p}/src/index.ts`; return alias; - }, {}) - } - } + }, {}), + }, + }, }, // preprocess matching files before serving them to the browser @@ -99,26 +101,22 @@ module.exports = function(config) { preprocessors: { "packages/*/src/**/*.ts": ["karma-typescript"], "packages/*/e2e/**/*.ts": ["karma-typescript"], - "packages/*/e2e/**/helpers/config.ts": ["karma-typescript", "env"] + "packages/*/e2e/**/helpers/config.ts": ["karma-typescript", "env"], }, // Expose this as `window.__env__.QACREDS_PSW // Used in config files in e2e folders - envPreprocessor: [ - "QACREDS_PSW" - ], + envPreprocessor: ["QACREDS_PSW", "QACREDS_USER_PSW"], // test results reporter to use // possible values: 'dots', 'progress' // available reporters: https://npmjs.org/browse/keyword/karma-reporter // reporters: ["spec", "karma-typescript", "coverage"], - reporters: ["dots", "karma-typescript"], + reporters: ["dots", "karma-typescript"], coverageReporter: { // specify a common output directory - dir: 'coverage', - reporters: [ - { type: 'lcov', subdir: 'lcov' } - ] + dir: "coverage", + reporters: [{ type: "lcov", subdir: "lcov" }], }, // web server port @@ -136,24 +134,19 @@ module.exports = function(config) { // start these browsers // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher - browsers: [ - 'Chrome', - 'Edge', - 'Firefox' - ], + browsers: ["Chrome", "Edge", "Firefox"], plugins: [ - require('karma-env-preprocessor'), - require('@chiragrupani/karma-chromium-edge-launcher'), - require('karma-chrome-launcher'), - require('karma-coverage'), - require('karma-firefox-launcher'), - require('karma-jasmine'), - require('karma-jasmine-diff-reporter'), - require('karma-safari-launcher'), - require('karma-spec-reporter'), - require('karma-typescript'), - require('karma-typescript-es6-transform') - + require("karma-env-preprocessor"), + require("@chiragrupani/karma-chromium-edge-launcher"), + require("karma-chrome-launcher"), + require("karma-coverage"), + require("karma-firefox-launcher"), + require("karma-jasmine"), + require("karma-jasmine-diff-reporter"), + require("karma-safari-launcher"), + require("karma-spec-reporter"), + require("karma-typescript"), + require("karma-typescript-es6-transform"), ], // Continuous Integration mode @@ -165,13 +158,13 @@ module.exports = function(config) { concurrency: Infinity, customLaunchers: { ChromeHeadlessCI: { - base: 'ChromeHeadless', - flags: ['--no-sandbox'] + base: "ChromeHeadless", + flags: ["--no-sandbox"], }, ChromeDevTools: { - base: 'Chrome', - flags: ['--auto-open-devtools-for-tabs'] - } + base: "Chrome", + flags: ["--auto-open-devtools-for-tabs"], + }, }, }); }; diff --git a/packages/common/e2e/associations.e2e.ts b/packages/common/e2e/associations.e2e.ts new file mode 100644 index 00000000000..3a190d8f080 --- /dev/null +++ b/packages/common/e2e/associations.e2e.ts @@ -0,0 +1,149 @@ +import { + IHubInitiative, + fetchRelatedProjects, + fetchAssociatedProjects, + fetchHubEntity, + fetchUnRelatedProjects, +} from "../src"; +import Artifactory from "./helpers/Artifactory"; +import config from "./helpers/config"; +import { + ICreateOutput, + cleanupItems, + 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", + ], +}; + +fdescribe("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 associated 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 fetchAssociatedProjects( + entity, + context.hubRequestOptions + ); + expect(projects.length).toBe(6); + }); + it("search for un-related 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 fetchUnRelatedProjects( + entity, + context.hubRequestOptions + ); + // need to update this so we fetch all pages + expect(projects.length).toBe(10); + }); + + it("search for related 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 fetchRelatedProjects( + entity, + context.hubRequestOptions + ); + + expect(projects.length).toBe(6); + }); + }); +}); diff --git a/packages/common/e2e/helpers/config.ts b/packages/common/e2e/helpers/config.ts index a79a11492bc..8e9be2ca452 100644 --- a/packages/common/e2e/helpers/config.ts +++ b/packages/common/e2e/helpers/config.ts @@ -7,21 +7,27 @@ import { getProp } from "../../src"; let PWD; +let USER_PWD; if (typeof window === "undefined" && process.env) { - if (!process.env.QACREDS_PSW) { + if (!process.env.QACREDS_PSW || !process.env.QACREDS_USER_PSW) { throw new Error( - "QACREDS_PSW Could not be read! Please ensure you have a .env file configured! Use the .env-example file and ask others on the team where to get the values!" + "QACREDS_PSW or QACREDS_USER_PSW Could not be read! Please ensure you have a .env file configured! Use the .env-example file and ask others on the team where to get the values!" ); } else { PWD = process.env.QACREDS_PSW; + USER_PWD = process.env.QACREDS_USER_PSW; } } else { - if (!getProp(window, "__env__.QACREDS_PSW")) { + if ( + !getProp(window, "__env__.QACREDS_PSW") || + !getProp(window, "__env__.QACREDS_USER_PSW") + ) { throw new Error( - "QACREDS_PSW Could not be read! Please ensure you have a .env file configured! Use the .env-example file and ask others on the team where to get the values!" + "QACREDS_PSW or QACREDS_USER_PSW Could not be read! Please ensure you have a .env file configured! Use the .env-example file and ask others on the team where to get the values!" ); } else { PWD = getProp(window, "__env__.QACREDS_PSW"); + USER_PWD = getProp(window, "__env__.QACREDS_USER_PSW"); } } @@ -58,13 +64,17 @@ const config = { orgShort: "qa-pre-a-hub", orgUrl: "https://qa-pre-a-hub.mapsqa.arcgis.com", admin: { - username: "paige_pa", + username: "e2e_pre_a_pub_admin", password: PWD, }, user: { username: "e2e_pre_a_pub_publisher", password: PWD, }, + paige: { + username: "paige_pa", + password: USER_PWD, + }, fixtures: { items: { sitePageMapViewsLayersTable: "5741debb4bd9476e9511035126c7edb6", diff --git a/packages/common/e2e/helpers/metric-fixtures-crud.ts b/packages/common/e2e/helpers/metric-fixtures-crud.ts index e2cbc6fd755..2c223a16e93 100644 --- a/packages/common/e2e/helpers/metric-fixtures-crud.ts +++ b/packages/common/e2e/helpers/metric-fixtures-crud.ts @@ -29,7 +29,8 @@ export async function createScopeGroup( export async function createInitiative( config: { key: string; count: number; groupId: string }, - context: IArcGISContext + context: IArcGISContext, + skipMetrics: boolean = false ): Promise { const init: Partial = { name: `Test ${config.key} Initiative with ${config.count} Projects`, @@ -42,63 +43,65 @@ export async function createInitiative( const initiativeId = instance.id; // construct metrics const metrics: IMetric[] = []; - // add metrics to the initiative + if (!skipMetrics) { + // add metrics to the initiative - // Simple Static Metric - metrics.push({ - id: "budget", - name: "Initiative Budget", - description: "Total budget for the initiative", - units: "$", - source: { - type: "static-value", - value: 203000, - }, - }); - // Item Query Metric that will be resolved with static values - metrics.push({ - id: "countyFunding", - name: "County Funding", - description: "Total funding from Larimer County", - units: "USD", - source: { - type: "item-query", - propertyPath: `properties.metrics[findBy(id,countyFunding_${initiativeId})]`, - keywords: [`initiative|${initiativeId}`], - itemTypes: ["Hub Project"], - collectionKey: "projects", - }, - }); - // Item Query Metric that will be resolved with dynamic values - metrics.push({ - id: "surveysCompleted", - name: "Surveys Completed", - description: "Total number of surveys completed", - source: { - type: "item-query", - propertyPath: `properties.metrics[findBy(id,surveysCompleted_${initiativeId})]`, - keywords: [`initiative|${initiativeId}`], - itemTypes: ["Hub Project"], - collectionKey: "projects", - }, - }); - - metrics.push({ - id: "contractor", - name: "Project Constractor", - description: "Contractor for the project", - source: { - type: "item-query", - propertyPath: `properties.metrics[findBy(id,contractor_${initiativeId})]`, - keywords: [`initiative|${initiativeId}`], - itemTypes: ["Hub Project"], - collectionKey: "projects", - }, - }); + // Simple Static Metric + metrics.push({ + id: "budget", + name: "Initiative Budget", + description: "Total budget for the initiative", + units: "$", + source: { + type: "static-value", + value: 203000, + }, + }); + // Item Query Metric that will be resolved with static values + metrics.push({ + id: "countyFunding", + name: "County Funding", + description: "Total funding from Larimer County", + units: "USD", + source: { + type: "item-query", + propertyPath: `properties.metrics[findBy(id,countyFunding_${initiativeId})]`, + keywords: [`initiative|${initiativeId}`], + itemTypes: ["Hub Project"], + collectionKey: "projects", + }, + }); + // Item Query Metric that will be resolved with dynamic values + metrics.push({ + id: "surveysCompleted", + name: "Surveys Completed", + description: "Total number of surveys completed", + source: { + type: "item-query", + propertyPath: `properties.metrics[findBy(id,surveysCompleted_${initiativeId})]`, + keywords: [`initiative|${initiativeId}`], + itemTypes: ["Hub Project"], + collectionKey: "projects", + }, + }); + metrics.push({ + id: "contractor", + name: "Project Constractor", + description: "Contractor for the project", + source: { + type: "item-query", + propertyPath: `properties.metrics[findBy(id,contractor_${initiativeId})]`, + keywords: [`initiative|${initiativeId}`], + itemTypes: ["Hub Project"], + collectionKey: "projects", + }, + }); + } // add metrics to the initiative const json = instance.toJson(); json.metrics = metrics; + // cfreate the projects collection with the scope pointing to the group const projectCollection: IHubCollection = { key: "projects", @@ -125,16 +128,22 @@ export async function createInitiative( export async function createProjects( config: { key: string; count: number; groupId: string }, parentId: string, - context: IArcGISContext + context: IArcGISContext, + skipMetrics: boolean = false ): Promise { const projects: HubProject[] = []; for (let i = 0; i < config.count; i++) { + // share every other project so we have some + // that are associated but no approved + const shouldShare: boolean = !!(i % 2); const project = await createChildProject( config.key, i, parentId, config.groupId, - context + context, + shouldShare, + skipMetrics ); projects.push(project); @@ -147,7 +156,9 @@ export async function createChildProject( num: number, parentId: string, groupId: string, - context: IArcGISContext + context: IArcGISContext, + shareToGroup: boolean, + skipMetrics: boolean = false ): Promise { const project: Partial = { name: `Test ${key} Project ${num}`, @@ -158,6 +169,23 @@ export async function createChildProject( const instance = await HubProject.create(project, context, true); // construct metrics + if (!skipMetrics) { + const metrics = getMetrics(parentId); + const json = instance.toJson(); + json.metrics = metrics; + instance.update(json); + } + + await instance.save(); + await instance.setAccess("public"); + if (shareToGroup) { + await instance.shareWithGroup(groupId); + } + + return instance; +} + +function getMetrics(parentId: string): IMetric[] { const metrics: IMetric[] = []; /** @@ -209,15 +237,7 @@ export async function createChildProject( statistic: "count", }, }); - - const json = instance.toJson(); - json.metrics = metrics; - instance.update(json); - await instance.save(); - await instance.setAccess("public"); - await instance.shareWithGroup(groupId); - - return instance; + return metrics; } export interface ICreateOutput { diff --git a/packages/common/src/associations/addAssociation.ts b/packages/common/src/associations/addAssociation.ts new file mode 100644 index 00000000000..2b2af6402bf --- /dev/null +++ b/packages/common/src/associations/addAssociation.ts @@ -0,0 +1,21 @@ +import { IWithAssociations } from "../core/traits/IWithAssociations"; +import { IAssociationInfo } from "./types"; + +/** + * Add an association to an entity + * Persisted into the entity's `.typeKeywords` array + * @param info + * @param entity + */ +export function addAssociation( + info: IAssociationInfo, + entity: IWithAssociations +): void { + if (!entity.typeKeywords) { + entity.typeKeywords = []; + } + const association = `${info.type}|${info.id}`; + if (!entity.typeKeywords.includes(association)) { + entity.typeKeywords.push(association); + } +} diff --git a/packages/common/src/associations/getAssociatedQuery.ts b/packages/common/src/associations/getAssociatedQuery.ts new file mode 100644 index 00000000000..34d9ef53c5c --- /dev/null +++ b/packages/common/src/associations/getAssociatedQuery.ts @@ -0,0 +1,44 @@ +import { IWithAssociations } from "../core"; +import { IQuery } from "../search/types/IHubCatalog"; +import { getTargetEntityFromAssociationType } from "./internal/getTargetEntityFromAssociationType"; +import { getTypeFromAssociationType } from "./internal/getTypeFromAssociationType"; +import { listAssociations } from "./listAssociations"; +import { AssociationType } from "./types"; + +/** + * Get a query that can be used in a Gallery, and will return the associated + * entities, based on the AssociationType + * + * @param entity + * @param type + * @returns + */ +export function getAssociatedQuery( + entity: IWithAssociations, + type: AssociationType +): IQuery { + const ids = listAssociations(entity, type); + if (!ids.length) { + return null; + } + + // lookup some information by the association type + const itemType = getTypeFromAssociationType(type); + const targetEntity = getTargetEntityFromAssociationType(type); + + const qry: IQuery = { + targetEntity, + filters: [ + { + operation: "AND", + predicates: [ + { + type: itemType, + id: [...ids], + }, + ], + }, + ], + }; + return qry; +} diff --git a/packages/common/src/associations/index.ts b/packages/common/src/associations/index.ts new file mode 100644 index 00000000000..61b84139425 --- /dev/null +++ b/packages/common/src/associations/index.ts @@ -0,0 +1,5 @@ +export * from "./addAssociation"; +export * from "./getAssociatedQuery"; +export * from "./listAssociations"; +export * from "./removeAssociation"; +export * from "./types"; diff --git a/packages/common/src/associations/internal/getTargetEntityFromAssociationType.ts b/packages/common/src/associations/internal/getTargetEntityFromAssociationType.ts new file mode 100644 index 00000000000..1394e7eb935 --- /dev/null +++ b/packages/common/src/associations/internal/getTargetEntityFromAssociationType.ts @@ -0,0 +1,25 @@ +import { EntityType } from "../../search/types/IHubCatalog"; +import { AssociationType } from "../types"; + +/** + * Get the 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/getTypeFromAssociationType.ts b/packages/common/src/associations/internal/getTypeFromAssociationType.ts new file mode 100644 index 00000000000..3829e20d288 --- /dev/null +++ b/packages/common/src/associations/internal/getTypeFromAssociationType.ts @@ -0,0 +1,21 @@ +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/listAssociations.ts b/packages/common/src/associations/listAssociations.ts new file mode 100644 index 00000000000..3602c443a4a --- /dev/null +++ b/packages/common/src/associations/listAssociations.ts @@ -0,0 +1,22 @@ +import { IWithAssociations } from "../core/traits/IWithAssociations"; +import { AssociationType, IAssociationInfo } from "./types"; + +/** + * Return a list of all associations on an entity for a type + * @param entity + * @returns + */ +export function listAssociations( + entity: IWithAssociations, + type: AssociationType +): IAssociationInfo[] { + if (!entity.typeKeywords) { + return []; + } + return entity.typeKeywords + .filter((tk) => tk.indexOf(`${type}|`) > -1) + .map((tk) => { + const [t, id] = tk.split("|"); + return { type: t, id } as IAssociationInfo; + }); +} diff --git a/packages/common/src/associations/removeAssociation.ts b/packages/common/src/associations/removeAssociation.ts new file mode 100644 index 00000000000..0a3b574f4a0 --- /dev/null +++ b/packages/common/src/associations/removeAssociation.ts @@ -0,0 +1,22 @@ +import { IWithAssociations } from "../core/traits/IWithAssociations"; +import { IAssociationInfo } from "./types"; + +/** + * Remove an association from an entity + * @param info + * @param entity + * @returns + */ +export function removeAssociation( + info: IAssociationInfo, + entity: IWithAssociations +): void { + if (!entity.typeKeywords) { + return; + } + const association = `${info.type}|${info.id}`; + const index = entity.typeKeywords.indexOf(association); + if (index > -1) { + entity.typeKeywords.splice(index, 1); + } +} diff --git a/packages/common/src/associations/types.ts b/packages/common/src/associations/types.ts new file mode 100644 index 00000000000..1a29a2b5586 --- /dev/null +++ b/packages/common/src/associations/types.ts @@ -0,0 +1,21 @@ +/** + * Definition of an Association + * This will be persisted in the item's typekeywords + * as `type|id` + */ +export interface IAssociationInfo { + /** + * Type of the association. Currently only initiative is supported + */ + type: AssociationType; + /** + * Id of the associated item + */ + id: string; +} + +/** + * Association type + */ +export type AssociationType = "initiative"; +// AS WE ADD MORE TYPES, UPDATE THE getItemTypeFromAssociationType FUNCTION diff --git a/packages/common/src/core/traits/IWithAssociations.ts b/packages/common/src/core/traits/IWithAssociations.ts new file mode 100644 index 00000000000..92bb2ddbe22 --- /dev/null +++ b/packages/common/src/core/traits/IWithAssociations.ts @@ -0,0 +1,3 @@ +export interface IWithAssociations { + typeKeywords?: string[]; +} diff --git a/packages/common/src/core/traits/index.ts b/packages/common/src/core/traits/index.ts index 8bf8780956e..90928faac65 100644 --- a/packages/common/src/core/traits/index.ts +++ b/packages/common/src/core/traits/index.ts @@ -1,3 +1,4 @@ +export * from "./IWithAssociations"; export * from "./IWithBannerImage"; export * from "./IWithLayout"; export * from "./IWithSlug"; diff --git a/packages/common/src/core/types/IHubItemEntity.ts b/packages/common/src/core/types/IHubItemEntity.ts index 29bddabb923..77cdec9cde3 100644 --- a/packages/common/src/core/types/IHubItemEntity.ts +++ b/packages/common/src/core/types/IHubItemEntity.ts @@ -7,6 +7,7 @@ import { IWithDiscussions, } from "../traits"; import { IHubLocation } from "./IHubLocation"; +import { IWithAssociations } from "../traits/IWithAssociations"; /** * Properties exposed by Entities that are backed by Items @@ -14,6 +15,7 @@ import { IHubLocation } from "./IHubLocation"; export interface IHubItemEntity extends IHubEntityBase, IWithPermissions, + IWithAssociations, IWithDiscussions { /** * Access level of the item ("private" | "org" | "public") diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index ab00c6b1c45..f80e73fa0a0 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -2,6 +2,7 @@ * Apache-2.0 */ /* istanbul ignore file */ +export * from "./associations"; export * from "./access"; export * from "./api"; export * from "./ArcGISContext"; diff --git a/packages/common/src/initiatives/HubInitiative.ts b/packages/common/src/initiatives/HubInitiative.ts index d60be586ee3..c4543d551dc 100644 --- a/packages/common/src/initiatives/HubInitiative.ts +++ b/packages/common/src/initiatives/HubInitiative.ts @@ -9,7 +9,7 @@ import { updateInitiative, } from "./HubInitiatives"; -import { Catalog } from "../search"; +import { Catalog } from "../search/Catalog"; import { IArcGISContext } from "../ArcGISContext"; import { HubItemEntity } from "../core/HubItemEntity"; import { InitiativeEditorType } from "./_internal/InitiativeSchema"; diff --git a/packages/common/src/initiatives/HubInitiatives.ts b/packages/common/src/initiatives/HubInitiatives.ts index d9c5f1d5813..23b88546c84 100644 --- a/packages/common/src/initiatives/HubInitiatives.ts +++ b/packages/common/src/initiatives/HubInitiatives.ts @@ -27,6 +27,8 @@ import { setDiscussableKeyword, IModel, } from "../index"; +import { Catalog } from "../search/Catalog"; +import { IHubCollection, IQuery } from "../search/types/IHubCatalog"; import { IItem, IUserItemOptions, @@ -36,7 +38,7 @@ import { import { IRequestOptions } from "@esri/arcgis-rest-request"; import { PropertyMapper } from "../core/_internal/PropertyMapper"; -import { IHubInitiative } from "../core/types"; +import { IEntityInfo, IHubInitiative } from "../core/types"; import { IHubSearchResult } from "../search"; import { parseInclude } from "../search/_internal/parseInclude"; import { fetchItemEnrichments } from "../items/_enrichments"; @@ -46,6 +48,8 @@ import { getPropertyMap } from "./_internal/getPropertyMap"; import { computeProps } from "./_internal/computeProps"; import { applyInitiativeMigrations } from "./_internal/applyInitiativeMigrations"; import { getRelativeWorkspaceUrl } from "../core/getRelativeWorkspaceUrl"; +import { combineQueries } from "../search/_internal/combineQueries"; +import { expandQuery, portalSearchItemsAsItems } from "../search/_internal"; /** * @private @@ -274,3 +278,170 @@ export async function enrichInitiativeSearchResult( return result; } + +/** + * Fetch the Projects that are approved for an Initiative. This is a subset of the associated projects, limited + * to those that have the initiative typekeyword and are included in the Initiative's Projects collection + * @param initiative + * @param requestOptions + * @param query: Optional `IQuery` to further filter the results + * @returns + */ +export async function fetchAssociatedProjects( + initiative: IHubInitiative, + requestOptions: IHubRequestOptions, + query?: IQuery +): Promise { + let projectQuery = getAssociatedProjectsQuery(initiative); + if (query) { + projectQuery = combineQueries([projectQuery, query]); + } + + const response = await portalSearchItemsAsItems(projectQuery, { + requestOptions, + }); + // process into entityInfo objects + return response.results.map((r) => { + return { + id: r.id, + name: r.title, + type: r.type, + } as IEntityInfo; + }); +} + +export async function fetchRelatedProjects( + initiative: IHubInitiative, + requestOptions: IHubRequestOptions, + query?: IQuery +): Promise { + let projectQuery = getRelatedProjectsQuery(initiative); + if (query) { + projectQuery = combineQueries([projectQuery, query]); + } + + const response = await portalSearchItemsAsItems(projectQuery, { + requestOptions, + }); + // process into entityInfo objects + return response.results.map((r) => { + return { + id: r.id, + name: r.title, + type: r.type, + } as IEntityInfo; + }); +} + +export async function fetchUnRelatedProjects( + initiative: IHubInitiative, + requestOptions: IHubRequestOptions, + query?: IQuery +): Promise { + let projectQuery = getUnRelatedProjectsQuery(initiative); + if (query) { + projectQuery = combineQueries([projectQuery, query]); + } + + const response = await portalSearchItemsAsItems(projectQuery, { + requestOptions, + }); + // process into entityInfo objects + return response.results.map((r) => { + return { + id: r.id, + name: r.title, + type: r.type, + } as IEntityInfo; + }); +} + +/** + * Associated Projects are those that have the Initiative id in the typekeywords + * @param initiative + * @returns + */ +export function getRelatedProjectsQuery(initiative: IHubInitiative): IQuery { + return { + targetEntity: "item", + filters: [ + { + operation: "AND", + predicates: [ + { + type: "Hub Project", + typekeywords: `initiative|${initiative.id}`, + }, + ], + }, + ], + }; +} + +/** + * Approved projects are those with the Initiative id in the typekeywords + * and is included in the Projects collection in the Initiative's catalog. + * @param initiative + * @returns + */ +export function getAssociatedProjectsQuery(initiative: IHubInitiative): IQuery { + // get the associated projects query + let query = getRelatedProjectsQuery(initiative); + // create a catalog instance, get the projects collection from it if defined + const cat = Catalog.fromJson(initiative.catalog); + const projectCollection = cat.collections.find((c) => c.key === "projects"); + let qry: IQuery; + if (projectCollection) { + // get it by name - this will merge in the base item scope + qry = cat.getCollectionJson("projects").scope; + } else { + // use the catalog's item scope + qry = cat.getScope("item"); + } + + if (qry) { + query = combineQueries([query, qry]); + } + return query; +} + +/** + * Approved projects are those with the Initiative id in the typekeywords + * and is included in the Projects collection in the Initiative's catalog. + * @param initiative + * @returns + */ +export function getUnRelatedProjectsQuery(initiative: IHubInitiative): IQuery { + // get the associated projects query + let query = getRelatedProjectsQuery(initiative); + // create a catalog instance, + const cat = Catalog.fromJson(initiative.catalog); + + // default to the catalog's item scope + let qry: IQuery = cat.getScope("item"); + // Get the projects collection + // Using the instance ensures that the root scope is merged into the returned + // collection.scope + const projectCollection = cat.getCollectionJson("projects"); + if (projectCollection) { + qry = projectCollection.scope; + } + // expand the query so we're working with a consistent IMatchOptions structure + const expanded = expandQuery(qry); + // negate the group predicate on the query + // we opted to be surgical about this vs attempting a `negateQuery(query)` function + expanded.filters.forEach((f) => { + f.predicates.forEach((p) => { + if (p.group) { + p.group.not = [...(p.group.any || []), ...(p.group.all || [])]; + p.group.any = []; + p.group.all = []; + } + }); + }); + + if (qry) { + query = combineQueries([expanded, query]); + } + return query; +} diff --git a/packages/common/src/projects/HubProject.ts b/packages/common/src/projects/HubProject.ts index 57efbcbb57b..00bc6ef5307 100644 --- a/packages/common/src/projects/HubProject.ts +++ b/packages/common/src/projects/HubProject.ts @@ -11,7 +11,7 @@ import { SettableAccessLevel, } from "../core"; -import { Catalog } from "../search"; +import { Catalog } from "../search/Catalog"; import { IArcGISContext } from "../ArcGISContext"; import { HubItemEntity } from "../core/HubItemEntity"; import { diff --git a/packages/common/src/projects/fetch.ts b/packages/common/src/projects/fetch.ts index 3354fbd8a3d..2e1a2b480c3 100644 --- a/packages/common/src/projects/fetch.ts +++ b/packages/common/src/projects/fetch.ts @@ -8,8 +8,9 @@ import { PropertyMapper } from "../core/_internal/PropertyMapper"; import { getItemBySlug } from "../items/slugs"; import { fetchItemEnrichments } from "../items/_enrichments"; -import { fetchModelFromItem, fetchModelResources } from "../models"; +import { fetchModelFromItem } from "../models"; import { IHubSearchResult } from "../search"; +import { IQuery } from "../search/types/IHubCatalog"; import { parseInclude } from "../search/_internal/parseInclude"; import { IHubRequestOptions, IModel } from "../types"; import { isGuid, mapBy } from "../utils"; @@ -21,6 +22,7 @@ import { getItemThumbnailUrl } from "../resources/get-item-thumbnail-url"; import { getItemHomeUrl } from "../urls/get-item-home-url"; import { getItemIdentifier } from "../items"; import { getRelativeWorkspaceUrl } from "../core/getRelativeWorkspaceUrl"; +import { getAssociatedQuery, listAssociations } from "../associations"; /** * @private @@ -139,3 +141,14 @@ export async function enrichProjectSearchResult( return result; } + +/** + * Get a query that will fetch all the initiatives which the project has + * chosen to associate with. If project has not defined any associations + * to any Initiatives, will return `null` + * @param project + * @returns + */ +export function getAssociatedInitiativesQuery(project: IHubProject): IQuery { + return getAssociatedQuery(project, "initiative"); +} diff --git a/packages/common/src/search/utils.ts b/packages/common/src/search/utils.ts index 5f10204fe45..0d3963ecef8 100644 --- a/packages/common/src/search/utils.ts +++ b/packages/common/src/search/utils.ts @@ -30,6 +30,7 @@ import { LegacySearchCategory, } from "./_internal/commonHelpers/isLegacySearchCategory"; import { toCollectionKey } from "./_internal/commonHelpers/toCollectionKey"; +import { expandQuery } from "./_internal"; /** * Well known APIs @@ -285,6 +286,7 @@ export function migrateToCollectionKey( } /** + * DEPRECATED: Please use `getGroupPredicate` * Searches through a catalog scope and retrieves the predicate responsible * for determining group sharing requirements. * @@ -292,8 +294,23 @@ export function migrateToCollectionKey( * @returns The first predicate with a `group` field (if present) */ export function getScopeGroupPredicate(scope: IQuery): IPredicate { + /* tslint:disable no-console */ + console.warn( + `"getScopeGroupPredicate(query)" is deprecated. Please use "getGroupPredicate(qyr)` + ); + return getGroupPredicate(scope); +} + +/** + * Searches through an `IQuery` and retrieves the predicate with a `group` definition. + * If there is no group predicate, returns `null` + * @param query IQuery to search + * @returns + */ +export function getGroupPredicate(query: IQuery): IPredicate { + const expandedQuery = expandQuery(query); const isGroupPredicate = (predicate: IPredicate) => !!predicate.group; - const groupFilter = scope.filters.find((f) => + const groupFilter = expandedQuery.filters.find((f) => f.predicates.find(isGroupPredicate) ); return groupFilter && groupFilter.predicates.find(isGroupPredicate); diff --git a/packages/common/test/associations/addAssociation.test.ts b/packages/common/test/associations/addAssociation.test.ts new file mode 100644 index 00000000000..ec3751ebe09 --- /dev/null +++ b/packages/common/test/associations/addAssociation.test.ts @@ -0,0 +1,25 @@ +import { IWithAssociations, addAssociation } from "../../src"; + +describe("addAssociation:", () => { + it("adds the typekeyword", () => { + const entity = { + typeKeywords: [], + } as unknown as IWithAssociations; + addAssociation({ type: "initiative", id: "123" }, entity); + expect(entity.typeKeywords).toEqual(["initiative|123"]); + }); + + it("works if keyword already present", () => { + const entity = { + typeKeywords: ["initiative|123"], + } as unknown as IWithAssociations; + addAssociation({ type: "initiative", id: "123" }, entity); + expect(entity.typeKeywords).toEqual(["initiative|123"]); + }); + + it("adds the typekeywords if not present", () => { + const entity = {} as unknown as IWithAssociations; + addAssociation({ type: "initiative", id: "123" }, entity); + expect(entity.typeKeywords).toEqual(["initiative|123"]); + }); +}); diff --git a/packages/common/test/associations/listAssociations.test.ts b/packages/common/test/associations/listAssociations.test.ts new file mode 100644 index 00000000000..112826d7b16 --- /dev/null +++ b/packages/common/test/associations/listAssociations.test.ts @@ -0,0 +1,30 @@ +import { IWithAssociations, listAssociations } from "../../src"; + +describe("listAssociations:", () => { + it("returns empty array if no keywords prop", () => { + const entity = {} as unknown as IWithAssociations; + const list = listAssociations(entity, "initiative"); + expect(list).toBeDefined(); + expect(list.length).toBe(0); + }); + + it("returns empty array if none found", () => { + const entity = { + typeKeywords: ["other"], + } as unknown as IWithAssociations; + const list = listAssociations(entity, "initiative"); + expect(list).toBeDefined(); + expect(list.length).toBe(0); + }); + + it("returns all entries", () => { + const entity = { + typeKeywords: ["other", "initiative|00c", "initiative|00d"], + } as unknown as IWithAssociations; + const list = listAssociations(entity, "initiative"); + expect(list).toBeDefined(); + expect(list.length).toBe(2); + expect(list[0].id).toBe("00c"); + expect(list[1].id).toBe("00d"); + }); +}); diff --git a/packages/common/test/associations/removeAssociation.test.ts b/packages/common/test/associations/removeAssociation.test.ts new file mode 100644 index 00000000000..4dd4a748ec6 --- /dev/null +++ b/packages/common/test/associations/removeAssociation.test.ts @@ -0,0 +1,25 @@ +import { IWithAssociations, removeAssociation } from "../../src"; + +describe("removeAssociation", () => { + it("removes the keyword if present", () => { + const entity = { + typeKeywords: ["other", "initiative|123"], + } as unknown as IWithAssociations; + removeAssociation({ type: "initiative", id: "123" }, entity); + expect(entity.typeKeywords).toEqual(["other"]); + }); + + it("works if keyword not present", () => { + const entity = { + typeKeywords: ["other"], + } as unknown as IWithAssociations; + removeAssociation({ type: "initiative", id: "123" }, entity); + expect(entity.typeKeywords).toEqual(["other"]); + }); + + it("works if keywords not present", () => { + const entity = {} as unknown as IWithAssociations; + removeAssociation({ type: "initiative", id: "123" }, entity); + expect(entity.typeKeywords).not.toBeDefined(); + }); +});