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..c68728426c4 --- /dev/null +++ b/packages/common/e2e/associations.e2e.ts @@ -0,0 +1,132 @@ +import { + IHubInitiative, + fetchAcceptedProjects, + fetchHubEntity, + fetchPendingProjects, +} 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 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/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..69ef6172f36 --- /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( + entity: IWithAssociations, + info: IAssociationInfo +): 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/index.ts b/packages/common/src/associations/index.ts new file mode 100644 index 00000000000..6f16f7cf121 --- /dev/null +++ b/packages/common/src/associations/index.ts @@ -0,0 +1,4 @@ +export * from "./addAssociation"; +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..a9613d91fd9 --- /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 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 new file mode 100644 index 00000000000..9d9048c9be9 --- /dev/null +++ b/packages/common/src/associations/internal/getTypeByIdsQuery.ts @@ -0,0 +1,30 @@ +import { getEntityTypeFromType } from "../../search/_internal/getEntityTypeFromType"; +import { IQuery } from "../../search/types/IHubCatalog"; + +/** + * Get a query that can be used in a Gallery, and will return the associated + * entities, based on the AssociationType + * + * @param entity + * @param type + * @returns + */ +export function getTypeByIdsQuery(itemType: string, ids: string[]): IQuery { + const targetEntity = getEntityTypeFromType(itemType); + + const qry: IQuery = { + targetEntity, + filters: [ + { + operation: "AND", + predicates: [ + { + type: itemType, + id: [...ids], + }, + ], + }, + ], + }; + return qry; +} diff --git a/packages/common/src/associations/internal/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/internal/getTypeWithKeywordQuery.ts b/packages/common/src/associations/internal/getTypeWithKeywordQuery.ts new file mode 100644 index 00000000000..7ab2af730f9 --- /dev/null +++ b/packages/common/src/associations/internal/getTypeWithKeywordQuery.ts @@ -0,0 +1,32 @@ +import { getEntityTypeFromType } from "../../search/_internal/getEntityTypeFromType"; +import { IQuery } from "../../search/types/IHubCatalog"; + +/** + * @private + * Return an `IQuery` for a specific item type, with a specific typekeyword + * This is used internally to build queries for "Connected" entities + * @param itemType + * @param keyword + * @returns + */ +export function getTypeWithKeywordQuery( + itemType: string, + keyword: string +): IQuery { + const targetEntity = getEntityTypeFromType(itemType); + + return { + targetEntity, + filters: [ + { + operation: "AND", + predicates: [ + { + type: itemType, + typekeywords: keyword, + }, + ], + }, + ], + }; +} diff --git a/packages/common/src/associations/internal/getTypeWithoutKeywordQuery.ts b/packages/common/src/associations/internal/getTypeWithoutKeywordQuery.ts new file mode 100644 index 00000000000..84eeb28cb6e --- /dev/null +++ b/packages/common/src/associations/internal/getTypeWithoutKeywordQuery.ts @@ -0,0 +1,32 @@ +import { getEntityTypeFromType } from "../../search/_internal/getEntityTypeFromType"; +import { IQuery } from "../../search/types/IHubCatalog"; + +/** + * @private + * Return an `IQuery` for a specific item type, without a specific typekeyword + * This is used internally to build queries for "Not Connected" entities + * @param itemType + * @param keyword + * @returns + */ +export function getTypeWithoutKeywordQuery( + itemType: string, + keyword: string +): IQuery { + const targetEntity = getEntityTypeFromType(itemType); + + return { + targetEntity, + filters: [ + { + operation: "AND", + predicates: [ + { + type: itemType, + typekeywords: { not: [keyword] }, + }, + ], + }, + ], + }; +} diff --git a/packages/common/src/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..b428db886c3 --- /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( + entity: IWithAssociations, + info: IAssociationInfo +): 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/HubItemEntity.ts b/packages/common/src/core/HubItemEntity.ts index d8aae196f9d..1a0a1152112 100644 --- a/packages/common/src/core/HubItemEntity.ts +++ b/packages/common/src/core/HubItemEntity.ts @@ -28,6 +28,7 @@ import { IWithStoreBehavior, IWithFeaturedImageBehavior, IWithPermissionBehavior, + IWithAssociationBehavior, } from "./behaviors"; import { IWithThumbnailBehavior } from "./behaviors/IWithThumbnailBehavior"; @@ -36,6 +37,10 @@ import { sharedWith } from "./_internal/sharedWith"; import { IWithDiscussionsBehavior } from "./behaviors/IWithDiscussionsBehavior"; import { setDiscussableKeyword } from "../discussions"; import { IWithFollowersBehavior } from "./behaviors/IWithFollowersBehavior"; +import { AssociationType, IAssociationInfo } from "../associations/types"; +import { listAssociations } from "../associations/listAssociations"; +import { addAssociation } from "../associations/addAssociation"; +import { removeAssociation } from "../associations/removeAssociation"; const FEATURED_IMAGE_FILENAME = "featuredImage.png"; @@ -50,7 +55,8 @@ export abstract class HubItemEntity IWithFeaturedImageBehavior, IWithPermissionBehavior, IWithDiscussionsBehavior, - IWithFollowersBehavior + IWithFollowersBehavior, + IWithAssociationBehavior { protected context: IArcGISContext; protected entity: T; @@ -411,4 +417,32 @@ export abstract class HubItemEntity ); this.update({ typeKeywords, isDiscussable } as Partial); } + + /** + * Return a list of IAssociationInfo objects representing + * the associations this entity has, to the specified type + * @param type + * @returns + */ + listAssociations(type: AssociationType): IAssociationInfo[] { + return listAssociations(this.entity, type); + } + + /** + * Add an association to this entity + * @param info + * @returns + */ + addAssociation(info: IAssociationInfo): void { + return addAssociation(this.entity, info); + } + + /** + * Remove an association from this entity + * @param info + * @returns + */ + removeAssociation(info: IAssociationInfo): void { + return removeAssociation(this.entity, info); + } } diff --git a/packages/common/src/core/behaviors/IWIthAssociationBehavior.ts b/packages/common/src/core/behaviors/IWIthAssociationBehavior.ts new file mode 100644 index 00000000000..9a4a7627006 --- /dev/null +++ b/packages/common/src/core/behaviors/IWIthAssociationBehavior.ts @@ -0,0 +1,25 @@ +import { AssociationType, IAssociationInfo } from "../../associations/types"; + +/** + * Composable behavior that adds permissions to an entity + */ +export interface IWithAssociationBehavior { + /** + * Get a list of the associations for an AssociationType + * @param type + */ + listAssociations(type: AssociationType): IAssociationInfo[]; + + /** + * Add an association to the entity. + * Entity needs to be saved after calling this method + * @param info + */ + addAssociation(info: IAssociationInfo): void; + /** + * Remove an association to the entity. + * Entity needs to be saved after calling this method + * @param info + */ + removeAssociation(info: IAssociationInfo): void; +} diff --git a/packages/common/src/core/behaviors/index.ts b/packages/common/src/core/behaviors/index.ts index d22e68490d8..80a3c8b8f84 100644 --- a/packages/common/src/core/behaviors/index.ts +++ b/packages/common/src/core/behaviors/index.ts @@ -6,3 +6,4 @@ export * from "./IWithEditorBehavior"; export * from "./IWithFeaturedImageBehavior"; export * from "./IWithVersioningBehavior"; export * from "./IWithCardBehavior"; +export * from "./IWIthAssociationBehavior"; diff --git a/packages/common/src/core/traits/IWithAssociations.ts b/packages/common/src/core/traits/IWithAssociations.ts new file mode 100644 index 00000000000..4361f24764a --- /dev/null +++ b/packages/common/src/core/traits/IWithAssociations.ts @@ -0,0 +1,4 @@ +export interface IWithAssociations { + typeKeywords?: string[]; + [key: string]: any; +} 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 3d41cee0717..15ba9c2f51f 100644 --- a/packages/common/src/core/types/IHubItemEntity.ts +++ b/packages/common/src/core/types/IHubItemEntity.ts @@ -8,6 +8,7 @@ import { } from "../traits"; import { IHubLocation } from "./IHubLocation"; import { IWithFollowers } from "../traits/IWithFollowers"; +import { IWithAssociations } from "../traits/IWithAssociations"; /** * Properties exposed by Entities that are backed by Items @@ -16,7 +17,8 @@ export interface IHubItemEntity extends IHubEntityBase, IWithPermissions, IWithDiscussions, - IWithFollowers { + IWithFollowers, + IWithAssociations { /** * Access level of the item ("private" | "org" | "public") */ diff --git a/packages/common/src/groups/HubGroups.ts b/packages/common/src/groups/HubGroups.ts index fc0c909bef9..621e840313d 100644 --- a/packages/common/src/groups/HubGroups.ts +++ b/packages/common/src/groups/HubGroups.ts @@ -1,7 +1,7 @@ import { IGroup } from "@esri/arcgis-rest-types"; import { fetchGroupEnrichments } from "./_internal/enrichments"; import { getProp, setProp } from "../objects"; -import { getGroupThumbnailUrl, IHubSearchResult } from "../search"; +import { getGroupThumbnailUrl } from "../search/utils"; import { parseInclude } from "../search/_internal/parseInclude"; import { IHubRequestOptions } from "../types"; import { getGroupHomeUrl } from "../urls"; @@ -20,6 +20,7 @@ import { DEFAULT_GROUP } from "./defaults"; import { convertHubGroupToGroup } from "./_internal/convertHubGroupToGroup"; import { convertGroupToHubGroup } from "./_internal/convertGroupToHubGroup"; import { getRelativeWorkspaceUrl } from "../core/getRelativeWorkspaceUrl"; +import { IHubSearchResult } from "../search/types/IHubSearchResult"; /** * Enrich a generic search result diff --git a/packages/common/src/groups/_internal/computeProps.ts b/packages/common/src/groups/_internal/computeProps.ts index edeb7b0497a..783f01e1584 100644 --- a/packages/common/src/groups/_internal/computeProps.ts +++ b/packages/common/src/groups/_internal/computeProps.ts @@ -4,7 +4,7 @@ import { UserSession } from "@esri/arcgis-rest-auth"; import { IHubGroup } from "../../core/types/IHubGroup"; import { IGroup } from "@esri/arcgis-rest-types"; import { isDiscussable } from "../../discussions"; -import { getGroupThumbnailUrl } from "../../search"; +import { getGroupThumbnailUrl } from "../../search/utils"; /** * Given a model and a group, set various computed properties that can't be directly mapped 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..53665e0eb39 100644 --- a/packages/common/src/initiatives/HubInitiatives.ts +++ b/packages/common/src/initiatives/HubInitiatives.ts @@ -27,6 +27,7 @@ import { setDiscussableKeyword, IModel, } from "../index"; +import { IQuery } from "../search/types/IHubCatalog"; import { IItem, IUserItemOptions, @@ -36,7 +37,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 +47,11 @@ 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 { portalSearchItemsAsItems } from "../search/_internal/portalSearchItems"; +import { getTypeWithKeywordQuery } from "../associations/internal/getTypeWithKeywordQuery"; +import { getTypeWithoutKeywordQuery } from "../associations/internal/getTypeWithoutKeywordQuery"; +import { negateGroupPredicates } from "../search/_internal/negateGroupPredicates"; /** * @private @@ -274,3 +280,158 @@ export async function enrichInitiativeSearchResult( return result; } + +/** + * 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. + * @param initiative + * @param requestOptions + * @param query: Optional `IQuery` to further filter the results + * @returns + */ +export async function fetchAcceptedProjects( + initiative: IHubInitiative, + requestOptions: IHubRequestOptions, + query?: IQuery +): Promise { + let projectQuery = getAcceptedProjectsQuery(initiative); + // combineQueries will purge undefined/null entries + projectQuery = combineQueries([projectQuery, query]); + + return queryAsEntityInfo(projectQuery, requestOptions); +} + +/** + * 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 + * @param initiative + * @param requestOptions + * @param query + * @returns + */ +export async function fetchPendingProjects( + initiative: IHubInitiative, + requestOptions: IHubRequestOptions, + query?: IQuery +): Promise { + let projectQuery = getPendingProjectsQuery(initiative); + // combineQueries will purge undefined/null entries + projectQuery = combineQueries([projectQuery, query]); + + return queryAsEntityInfo(projectQuery, requestOptions); +} + +/** + * Execute the query and convert into EntityInfo objects + * @param query + * @param requestOptions + * @returns + */ +async function queryAsEntityInfo( + query: IQuery, + requestOptions: IHubRequestOptions +) { + const response = await portalSearchItemsAsItems(query, { + requestOptions, + num: 100, + }); + return response.results.map((item) => { + return { + id: item.id, + name: item.title, + type: item.type, + } as IEntityInfo; + }); +} + +/** + * Associated projects are those with the Initiative id in the typekeywords + * and is included in the Initiative's catalog. + * This is passed into the Gallery showing "Approved Projects" + * @param initiative + * @returns + */ +export function getAcceptedProjectsQuery(initiative: IHubInitiative): IQuery { + // get query that returns Hub Projects with the initiative keyword + let query = getTypeWithKeywordQuery( + "Hub Project", + `initiative|${initiative.id}` + ); + // Get the item scope from the catalog + const qry = getProp(initiative, "catalog.scopes.item"); + // combineQueries will remove null/undefined entries + query = combineQueries([query, qry]); + + return query; +} + +/** + * Related Projects are those that have the Initiative id in the + * typekeywords but NOT in the catalog. We use this query to show + * Projects which want to be associated but are not yet included in + * the catalog + * This is passed into the Gallery showing "Pending Projects" + * @param initiative + * @returns + */ +export function getPendingProjectsQuery(initiative: IHubInitiative): IQuery { + // get query that returns Hub Projects with the initiative keyword + let query = getTypeWithKeywordQuery( + "Hub Project", + `initiative|${initiative.id}` + ); + // The the item scope from the catalog... + const qry = getProp(initiative, "catalog.scopes.item"); + + // negate the scope, combine that with the base query + query = combineQueries([query, negateGroupPredicates(qry)]); + + 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/metrics/resolveMetric.ts b/packages/common/src/metrics/resolveMetric.ts index d5a857bd2dc..3cef1ce403f 100644 --- a/packages/common/src/metrics/resolveMetric.ts +++ b/packages/common/src/metrics/resolveMetric.ts @@ -168,10 +168,15 @@ async function resolveItemQueryMetric( requestOptions: context.hubRequestOptions, num: 100, }; + // Memoization is great but we have UI's where we want immediate + // updates as we share more items into the groups, which means + // we can't memoize the search response // create/get memoized version of portalSearchItemsAsItems - const memoizedPortalSearch = memoize(portalSearchItemsAsItems); + // const memoizedPortalSearch = memoize(portalSearchItemsAsItems); // Execute the query - const response = await memoizedPortalSearch(combined, opts); + // const response = await memoizedPortalSearch(combined, opts); + + const response = await portalSearchItemsAsItems(combined, opts); // This next section is all promise based so that a dynamic value // can itself be a dynamic value definition, which then needs to be diff --git a/packages/common/src/permissions/types/Permission.ts b/packages/common/src/permissions/types/Permission.ts index 86be3bf4586..5533d5fca27 100644 --- a/packages/common/src/permissions/types/Permission.ts +++ b/packages/common/src/permissions/types/Permission.ts @@ -19,7 +19,6 @@ const validPermissions = [ ...SitePermissions, ...ProjectPermissions, ...InitiativePermissions, - ...DiscussionPermissions, ...ContentPermissions, ...GroupPermissions, ...PagePermissions, @@ -30,7 +29,15 @@ const validPermissions = [ /** * Defines the possible values for Permissions */ -export type Permission = (typeof validPermissions)[number]; +export type Permission = + | (typeof SitePermissions)[number] + | (typeof ProjectPermissions)[number] + | (typeof InitiativePermissions)[number] + | (typeof ContentPermissions)[number] + | (typeof GroupPermissions)[number] + | (typeof PagePermissions)[number] + | (typeof PlatformPermissions)[number] + | (typeof TempPermissions)[number]; /** * Validate a Permission diff --git a/packages/common/src/projects/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..ef640a69cf0 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,8 @@ import { getItemThumbnailUrl } from "../resources/get-item-thumbnail-url"; import { getItemHomeUrl } from "../urls/get-item-home-url"; import { getItemIdentifier } from "../items"; import { getRelativeWorkspaceUrl } from "../core/getRelativeWorkspaceUrl"; +import { listAssociations } from "../associations"; +import { getTypeByIdsQuery } from "../associations/internal/getTypeByIdsQuery"; /** * @private @@ -139,3 +142,27 @@ export async function enrichProjectSearchResult( return result; } + +/** + * 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`. + * Currently, we have not implemented a means to get the list of initiatives that have + * "Accepted" the Project via inclusion in it's catalog. + * + * If needed, this could be done by getting all the groups the project is shared into + * then cross-walking that into the catalogs of all the Associated Initiatives + * @param project + * @returns + */ +export function getAssociatedInitiativesQuery(project: IHubProject): IQuery { + // get the list of ids from the keywords + const ids = listAssociations(project, "initiative").map((a) => a.id); + if (ids.length) { + // get the query + return getTypeByIdsQuery("Hub Initiative", ids); + } else { + // if there are no + return null; + } +} diff --git a/packages/common/src/search/_internal/combineQueries.ts b/packages/common/src/search/_internal/combineQueries.ts index a17e4f1538a..d80130169cd 100644 --- a/packages/common/src/search/_internal/combineQueries.ts +++ b/packages/common/src/search/_internal/combineQueries.ts @@ -12,6 +12,8 @@ import { IQuery } from "../types/IHubCatalog"; */ export const combineQueries = (queries: IQuery[]): IQuery => { + // remove any entries that are null or undefined + queries = queries.filter((e) => e); // check tht all queries are for the same entity type const targetEntity = queries[0].targetEntity; if (queries.some((q) => q.targetEntity !== targetEntity)) { diff --git a/packages/common/src/search/_internal/getEntityTypeFromType.ts b/packages/common/src/search/_internal/getEntityTypeFromType.ts new file mode 100644 index 00000000000..81ef190dec7 --- /dev/null +++ b/packages/common/src/search/_internal/getEntityTypeFromType.ts @@ -0,0 +1,25 @@ +import { EntityType } from "../types"; + +/** + * @private + * Given a type (e.g. Hub Site Application) return the appropriate entity type + * that can be used as a `targetEntity` in an `IQuery` + * @param type + * @returns + */ +export function getEntityTypeFromType(type: string): EntityType { + // Default to item, as it's the most common + let etype: EntityType = "item"; + + // Some are just downcased, so we can check them with an array + if (["group", "event", "user", "channel"].includes(type.toLowerCase())) { + etype = type.toLocaleLowerCase() as EntityType; + } + + // Group Member is just weird + if (type.toLowerCase() === "group member") { + etype = "groupMember"; + } + + return etype; +} diff --git a/packages/common/src/search/_internal/negateGroupPredicates.ts b/packages/common/src/search/_internal/negateGroupPredicates.ts new file mode 100644 index 00000000000..3eab86c7e3a --- /dev/null +++ b/packages/common/src/search/_internal/negateGroupPredicates.ts @@ -0,0 +1,29 @@ +import { IQuery } from "../types/IHubCatalog"; +import { expandQuery } from "./portalSearchItems"; + +/** + * @private + * Helper function that locates group predicates and "negates" them + * so we get a query that is `not in groups ...` vs `in groups ...` + * @param query + * @returns + */ +export function negateGroupPredicates(query: IQuery): IQuery { + // if nothing is passed in just return undefined + if (!query) { + return; + } + const expanded = expandQuery(query); + // negate the group predicate on the query + // we opted to be surgical about this vs attempting a `negateQuery(query)` function + expanded.filters.forEach((f) => { + f.predicates.forEach((p) => { + if (p.group) { + p.group.not = [...(p.group.any || []), ...(p.group.all || [])]; + p.group.any = []; + p.group.all = []; + } + }); + }); + return expanded; +} diff --git a/packages/common/src/search/hubSearch.ts b/packages/common/src/search/hubSearch.ts index 8cf6d8fc9c0..4a93be80b72 100644 --- a/packages/common/src/search/hubSearch.ts +++ b/packages/common/src/search/hubSearch.ts @@ -8,14 +8,13 @@ import { IQuery, } from "./types"; import { getApi } from "./_internal/commonHelpers/getApi"; -import { - hubSearchItems, - portalSearchItems, - portalSearchGroups, - portalSearchUsers, - hubSearchChannels, -} from "./_internal"; + import { portalSearchGroupMembers } from "./_internal/portalSearchGroupMembers"; +import { portalSearchItems } from "./_internal/portalSearchItems"; +import { portalSearchGroups } from "./_internal/portalSearchGroups"; +import { portalSearchUsers } from "./_internal/portalSearchUsers"; +import { hubSearchItems } from "./_internal/hubSearchItems"; +import { hubSearchChannels } from "./_internal/hubSearchChannels"; /** * Main entrypoint for searching via Hub diff --git a/packages/common/src/search/utils.ts b/packages/common/src/search/utils.ts index 5f10204fe45..25888f2555b 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/portalSearchItems"; /** * 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,6 +294,10 @@ 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)` + ); const isGroupPredicate = (predicate: IPredicate) => !!predicate.group; const groupFilter = scope.filters.find((f) => f.predicates.find(isGroupPredicate) @@ -299,6 +305,21 @@ export function getScopeGroupPredicate(scope: IQuery): IPredicate { return groupFilter && groupFilter.predicates.find(isGroupPredicate); } +/** + * 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 = expandedQuery.filters.find((f) => + f.predicates.find(isGroupPredicate) + ); + return groupFilter && groupFilter.predicates.find(isGroupPredicate); +} + /** * Determines the canonical siteRelative link for a search result. * diff --git a/packages/common/src/users/HubUsers.ts b/packages/common/src/users/HubUsers.ts index d941da796ef..f9d06669b67 100644 --- a/packages/common/src/users/HubUsers.ts +++ b/packages/common/src/users/HubUsers.ts @@ -1,13 +1,14 @@ import { IUser } from "@esri/arcgis-rest-types"; import { fetchUserEnrichments } from "./_internal/enrichments"; import { getProp } from "../objects"; -import { getUserThumbnailUrl, IHubSearchResult } from "../search"; +import { getUserThumbnailUrl } from "../search/utils"; import { parseInclude } from "../search/_internal/parseInclude"; import { IHubRequestOptions } from "../types"; import { getUserHomeUrl } from "../urls"; import { unique } from "../util"; import { mapBy } from "../utils"; import { AccessLevel } from "../core"; +import { IHubSearchResult } from "../search/types/IHubSearchResult"; /** * Enrich a User object diff --git a/packages/common/src/utils/memoize.ts b/packages/common/src/utils/memoize.ts index 523b83e04c1..560d94edb7c 100644 --- a/packages/common/src/utils/memoize.ts +++ b/packages/common/src/utils/memoize.ts @@ -12,7 +12,7 @@ const createCacheKeyFromArgs = (args: any[]) => "" ); -const memoizedFnCache: Record = {}; +let memoizedFnCache: Record = {}; /** * Wrap a function into a memoized version of itself. Multiple calls for the same function * will return the same memoized function - thus enabling a shared cache of results. @@ -49,9 +49,7 @@ export const memoize = ( */ export const clearMemoizedCache = (fnName?: string) => { if (!fnName) { - Object.keys(memoizedFnCache).forEach((key) => { - delete memoizedFnCache[key]; - }); + memoizedFnCache = {}; return; } else { delete memoizedFnCache[`_${fnName}`]; diff --git a/packages/common/test/associations/addAssociation.test.ts b/packages/common/test/associations/addAssociation.test.ts new file mode 100644 index 00000000000..d14d5858be7 --- /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(entity, { type: "initiative", id: "123" }); + expect(entity.typeKeywords).toEqual(["initiative|123"]); + }); + + it("works if keyword already present", () => { + const entity = { + typeKeywords: ["initiative|123"], + } as unknown as IWithAssociations; + addAssociation(entity, { type: "initiative", id: "123" }); + expect(entity.typeKeywords).toEqual(["initiative|123"]); + }); + + it("adds the typekeywords if not present", () => { + const entity = {} as unknown as IWithAssociations; + addAssociation(entity, { type: "initiative", id: "123" }); + expect(entity.typeKeywords).toEqual(["initiative|123"]); + }); +}); diff --git a/packages/common/test/associations/internal/getTargetEntityFromAssociationType.test.ts b/packages/common/test/associations/internal/getTargetEntityFromAssociationType.test.ts new file mode 100644 index 00000000000..a0fc0688c92 --- /dev/null +++ b/packages/common/test/associations/internal/getTargetEntityFromAssociationType.test.ts @@ -0,0 +1,15 @@ +import { AssociationType } from "../../../src"; +import { getTargetEntityFromAssociationType } from "../../../src/associations/internal/getTargetEntityFromAssociationType"; + +describe("getTargetEntityFromAssociationType:", () => { + it("throws if passed an invalid type", () => { + try { + getTargetEntityFromAssociationType("INVALID" as AssociationType); + } catch (ex) { + expect(ex.message).toContain("Invalid association type INVALID"); + } + }); + it("returns item for initiative", () => { + expect(getTargetEntityFromAssociationType("initiative")).toEqual("item"); + }); +}); diff --git a/packages/common/test/associations/internal/getTypeByIdsQuery.test.ts b/packages/common/test/associations/internal/getTypeByIdsQuery.test.ts new file mode 100644 index 00000000000..7293134b629 --- /dev/null +++ b/packages/common/test/associations/internal/getTypeByIdsQuery.test.ts @@ -0,0 +1,13 @@ +import { getTypeByIdsQuery } from "../../../src/associations/internal/getTypeByIdsQuery"; + +describe("getTypeByIdsQuery:", () => { + it("verify structure", () => { + const chk = getTypeByIdsQuery("Hub Project", ["a", "b"]); + + expect(chk.targetEntity).toBe("item"); + expect(chk.filters.length).toBe(1); + expect(chk.filters[0].predicates.length).toBe(1); + expect(chk.filters[0].predicates[0].type).toBe("Hub Project"); + expect(chk.filters[0].predicates[0].id).toEqual(["a", "b"]); + }); +}); diff --git a/packages/common/test/associations/internal/getTypeFromAssociationType.test.ts b/packages/common/test/associations/internal/getTypeFromAssociationType.test.ts new file mode 100644 index 00000000000..f5ed20e4b41 --- /dev/null +++ b/packages/common/test/associations/internal/getTypeFromAssociationType.test.ts @@ -0,0 +1,15 @@ +import { AssociationType } from "../../../src"; +import { getTypeFromAssociationType } from "../../../src/associations/internal/getTypeFromAssociationType"; + +describe("getTypeFromAssociationType:", () => { + it("throws if passed an invalid type", () => { + try { + getTypeFromAssociationType("INVALID" as AssociationType); + } catch (ex) { + expect(ex.message).toContain("Invalid association type INVALID"); + } + }); + it("returns item for initiative", () => { + expect(getTypeFromAssociationType("initiative")).toEqual("Hub Initiative"); + }); +}); diff --git a/packages/common/test/associations/internal/getTypeWithKeywordQuery.test.ts b/packages/common/test/associations/internal/getTypeWithKeywordQuery.test.ts new file mode 100644 index 00000000000..c2b54110906 --- /dev/null +++ b/packages/common/test/associations/internal/getTypeWithKeywordQuery.test.ts @@ -0,0 +1,13 @@ +import { getTypeWithKeywordQuery } from "../../../src/associations/internal/getTypeWithKeywordQuery"; + +describe("getTypeWithKeywordQuery:", () => { + it("verify structure", () => { + const chk = getTypeWithKeywordQuery("Hub Project", "foo|00c"); + + expect(chk.targetEntity).toBe("item"); + expect(chk.filters.length).toBe(1); + expect(chk.filters[0].predicates.length).toBe(1); + expect(chk.filters[0].predicates[0].type).toBe("Hub Project"); + expect(chk.filters[0].predicates[0].typekeywords).toEqual("foo|00c"); + }); +}); diff --git a/packages/common/test/associations/internal/getTypeWithoutKeywordQuery.test.ts b/packages/common/test/associations/internal/getTypeWithoutKeywordQuery.test.ts new file mode 100644 index 00000000000..7927b1bb9e6 --- /dev/null +++ b/packages/common/test/associations/internal/getTypeWithoutKeywordQuery.test.ts @@ -0,0 +1,15 @@ +import { getTypeWithoutKeywordQuery } from "../../../src/associations/internal/getTypeWithoutKeywordQuery"; + +describe("getTypeWithoutKeywordQuery:", () => { + it("verify structure", () => { + const chk = getTypeWithoutKeywordQuery("Hub Project", "foo|00c"); + + expect(chk.targetEntity).toBe("item"); + expect(chk.filters.length).toBe(1); + expect(chk.filters[0].predicates.length).toBe(1); + expect(chk.filters[0].predicates[0].type).toBe("Hub Project"); + expect(chk.filters[0].predicates[0].typekeywords).toEqual({ + not: ["foo|00c"], + }); + }); +}); diff --git a/packages/common/test/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..32d80847240 --- /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(entity, { type: "initiative", id: "123" }); + expect(entity.typeKeywords).toEqual(["other"]); + }); + + it("works if keyword not present", () => { + const entity = { + typeKeywords: ["other"], + } as unknown as IWithAssociations; + removeAssociation(entity, { type: "initiative", id: "123" }); + expect(entity.typeKeywords).toEqual(["other"]); + }); + + it("works if keywords not present", () => { + const entity = {} as unknown as IWithAssociations; + removeAssociation(entity, { type: "initiative", id: "123" }); + expect(entity.typeKeywords).not.toBeDefined(); + }); +}); diff --git a/packages/common/test/core/HubItemEntity.test.ts b/packages/common/test/core/HubItemEntity.test.ts index e90191d2437..edf664acf90 100644 --- a/packages/common/test/core/HubItemEntity.test.ts +++ b/packages/common/test/core/HubItemEntity.test.ts @@ -686,4 +686,71 @@ describe("HubItemEntity Class: ", () => { }); }); }); + + describe("with associations behavior", () => { + it("listAssociations delegates", () => { + const spy = spyOn( + require("../../src/associations/listAssociations"), + "listAssociations" + ).and.callThrough(); + + const instance = new TestHarness( + { + id: "00c", + owner: "deke", + isDiscussable: false, + typeKeywords: ["initiative|00c", "initiative|00b"], + }, + authdCtxMgr.context + ); + const chk = instance.listAssociations("initiative"); + expect(chk.length).toBe(2); + // no need to check the response, as listAssociations is tested elsewhere + expect(spy).toHaveBeenCalled(); + }); + + it("addAssociation delegates", () => { + const spy = spyOn( + require("../../src/associations/addAssociation"), + "addAssociation" + ).and.callThrough(); + + const instance = new TestHarness( + { + id: "00c", + owner: "deke", + isDiscussable: false, + typeKeywords: ["initiative|00c", "initiative|00b"], + }, + authdCtxMgr.context + ); + instance.addAssociation({ type: "initiative", id: "00f" }); + const chk = instance.toJson(); + expect(chk.typeKeywords.includes("initiative|00f")).toBeTruthy(); + // no need to check the response, as addAssociations is tested elsewhere + expect(spy).toHaveBeenCalled(); + }); + + it("removeAssociation delegates", () => { + const spy = spyOn( + require("../../src/associations/removeAssociation"), + "removeAssociation" + ).and.callThrough(); + + const instance = new TestHarness( + { + id: "00c", + owner: "deke", + isDiscussable: false, + typeKeywords: ["initiative|00c", "initiative|00b"], + }, + authdCtxMgr.context + ); + instance.removeAssociation({ type: "initiative", id: "00c" }); + const chk = instance.toJson(); + expect(chk.typeKeywords.includes("initiative|00c")).toBeFalsy(); + // no need to check the response, as addAssociations is tested elsewhere + expect(spy).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/common/test/initiatives/HubInitiatives.test.ts b/packages/common/test/initiatives/HubInitiatives.test.ts index a4143320218..a55ba9c54ea 100644 --- a/packages/common/test/initiatives/HubInitiatives.test.ts +++ b/packages/common/test/initiatives/HubInitiatives.test.ts @@ -12,9 +12,14 @@ import { fetchInitiative, deleteInitiative, updateInitiative, + getPendingProjectsQuery, + getAcceptedProjectsQuery, + fetchAcceptedProjects, + fetchPendingProjects, } from "../../src/initiatives/HubInitiatives"; import { IHubInitiative } from "../../src/core/types/IHubInitiative"; import { cloneObject } from "../../src/util"; +import { IPredicate, IQuery } from "../../src"; const GUID = "9b77674e43cf4bbd9ecad5189b3f1fdc"; const INITIATIVE_ITEM: portalModule.IItem = { @@ -396,4 +401,232 @@ describe("HubInitiatives:", () => { expect(ro).toBe(hubRo); }); }); + + describe("query getters", () => { + let fixture: IHubInitiative; + beforeEach(() => { + // Minimal structure needed for these tests + fixture = { + name: "Fixture Initiative", + id: "00f", + catalog: { + schemaVersion: 1, + scopes: { + item: { + targetEntity: "item", + filters: [ + { + predicates: [ + { + group: ["00c", "aa1"], + }, + ], + }, + ], + }, + }, + }, + } as unknown as IHubInitiative; + }); + it("getAssociatedProjectsQuery", () => { + const chk = getAcceptedProjectsQuery(fixture); + expect(chk.targetEntity).toBe("item"); + // ensure we have type and keyword in predicate + expect(verifyPredicate(chk, { type: "Hub Project" })).toBeTruthy(); + expect( + verifyPredicate(chk, { typekeywords: "initiative|00f" }) + ).toBeTruthy("should have keyword"); + expect(getPredicateValue(chk, { group: null })).toEqual(["00c", "aa1"]); + }); + it("getConnectedProjectsQuery", () => { + const chk = getPendingProjectsQuery(fixture); + expect(chk.targetEntity).toBe("item"); + // ensure we have type and keyword in predicate + expect(verifyPredicate(chk, { type: "Hub Project" })).toBeTruthy(); + expect( + verifyPredicate(chk, { typekeywords: "initiative|00f" }) + ).toBeTruthy("should have keyword"); + expect(getPredicateValue(chk, { group: null })).toEqual({ + any: [], + all: [], + not: ["00c", "aa1"], + }); + }); + // it("getUnConnectedProjectsQuery", () => { + // const chk = getUnConnectedProjectsQuery(fixture); + // expect(chk.targetEntity).toBe("item"); + // // ensure we have type and keyword in predicate + // expect(verifyPredicate(chk, { type: "Hub Project" })).toBeTruthy(); + + // expect(getPredicateValue(chk, { typekeywords: null })).toEqual( + // { + // not: ["initiative|00f"], + // }, + // "should have negated keyword" + // ); + // expect(getPredicateValue(chk, { group: null })).toEqual({ + // any: [], + // all: [], + // not: ["00c", "aa1"], + // }); + // }); + }); + + describe("fetchAccepted:", () => { + let searchSpy: jasmine.Spy; + let fixture: IHubInitiative; + beforeEach(() => { + searchSpy = spyOn( + require("../../src/search/_internal/portalSearchItems"), + "portalSearchItemsAsItems" + ).and.callFake(() => + Promise.resolve({ + results: [ + { + id: "3ef", + title: "fake result", + type: "Hub Project", + tags: ["fake"], + }, + ], + }) + ); + // Minimal structure needed for these tests + fixture = { + name: "Fixture Initiative", + id: "00f", + catalog: { + schemaVersion: 1, + scopes: { + item: { + targetEntity: "item", + filters: [ + { + predicates: [ + { + group: ["00c", "aa1"], + }, + ], + }, + ], + }, + }, + }, + } as unknown as IHubInitiative; + }); + it("fetches associated projects", async () => { + const chk = await fetchAcceptedProjects(fixture, MOCK_AUTH); + expect(searchSpy).toHaveBeenCalled(); + // get the query + const qry = searchSpy.calls.argsFor(0)[0]; + // this should have the groups in the predicate + expect(getPredicateValue(qry, { group: null })).toEqual(["00c", "aa1"]); + expect(chk.length).toBe(1); + // verify conversion + expect(chk[0]).toEqual({ + id: "3ef", + name: "fake result", + type: "Hub Project", + }); + }); + it("fetches pending projects", async () => { + const chk = await fetchPendingProjects(fixture, MOCK_AUTH); + expect(searchSpy).toHaveBeenCalled(); + // get the query + const qry = searchSpy.calls.argsFor(0)[0]; + // this should have the negated groups in the predicate + expect(getPredicateValue(qry, { group: null })).toEqual({ + any: [], + all: [], + not: ["00c", "aa1"], + }); + expect(chk.length).toBe(1); + // verify conversion + expect(chk[0]).toEqual({ + id: "3ef", + name: "fake result", + type: "Hub Project", + }); + }); + // 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. + // it("fetches unconnected projects", async () => { + // const chk = await fetchUnConnectedProjects(fixture, MOCK_AUTH); + // expect(searchSpy).toHaveBeenCalled(); + // // get the query + // const qry = searchSpy.calls.argsFor(0)[0]; + // // this should have the negated groups in the predicate + // expect(getPredicateValue(qry, { group: null })).toEqual({ + // any: [], + // all: [], + // not: ["00c", "aa1"], + // }); + // expect(getPredicateValue(qry, { typekeywords: null })).toEqual( + // { + // not: ["initiative|00f"], + // }, + // "should have negated keyword" + // ); + // expect(chk.length).toBe(1); + // // verify conversion + // expect(chk[0]).toEqual({ + // id: "3ef", + // name: "fake result", + // type: "Hub Project", + // }); + // }); + }); }); + +/** + * Helper to verify that a predicate exists in a query + * NOTE: This is NOT comprehensive! + * @param query + * @param expectedPredicate + */ +function verifyPredicate(query: IQuery, expectedPredicate: IPredicate) { + if (Object.keys(expectedPredicate).length > 1) { + throw new Error( + `verifyPredicate helper expects to check a single prop on the predicate.` + ); + } + // iterate the filtes in the query, looking for a predicate that has the prop + value + let present = false; + query.filters.forEach((filter) => { + filter.predicates.forEach((predicate) => { + // iterate the props on expected, and check if this predicate has the prop + value + Object.keys(expectedPredicate).forEach((key) => { + if (Array.isArray(predicate[key])) { + present = compareArrays(predicate[key], expectedPredicate[key]); + // compare arrays + } else { + // tslint:disable-next-line + if (predicate[key] == expectedPredicate[key]) { + present = true; + } + } + }); + }); + }); + return present; +} + +function getPredicateValue(query: IQuery, expectedPredicate: IPredicate): any { + let result: any; + query.filters.forEach((filter) => { + filter.predicates.forEach((predicate) => { + // iterate the props on expected, and check if this predicate has the prop + value + Object.keys(expectedPredicate).forEach((key) => { + if (predicate[key]) { + result = predicate[key]; + } + }); + }); + }); + return result; +} + +const compareArrays = (a: any[], b: any[]) => + // tslint:disable-next-line + a.length === b.length && a.every((element, index) => element == b[index]); diff --git a/packages/common/test/permissions/checkPermission.test.ts b/packages/common/test/permissions/checkPermission.test.ts index 1b015e7a33c..7517a7463b5 100644 --- a/packages/common/test/permissions/checkPermission.test.ts +++ b/packages/common/test/permissions/checkPermission.test.ts @@ -75,8 +75,15 @@ const TestPermissionPolicies: IPermissionPolicy[] = [ // }, ]; +/** + * FAKE IMPLEMENTATION SO WE DON'T TIE TESTS TO REAL PERMISSIONS + * @param permission + * @returns + */ function getPermissionPolicy(permission: Permission): IPermissionPolicy { - return TestPermissionPolicies.find((p) => p.permission === permission); + return TestPermissionPolicies.find( + (p) => p.permission === permission + ) as unknown as IPermissionPolicy; } describe("checkPermission:", () => { diff --git a/packages/common/test/projects/fetch.test.ts b/packages/common/test/projects/fetch.test.ts index d5db448e18f..0423210db8d 100644 --- a/packages/common/test/projects/fetch.test.ts +++ b/packages/common/test/projects/fetch.test.ts @@ -5,6 +5,7 @@ import { cloneObject, enrichProjectSearchResult, fetchProject, + getAssociatedInitiativesQuery, } from "../../src"; import { GUID, PROJECT_DATA, PROJECT_ITEM, PROJECT_LOCATION } from "./fixtures"; import { MOCK_AUTH } from "../mocks/mock-auth"; @@ -180,4 +181,24 @@ describe("project fetch module:", () => { expect(ro).toBe(hubRo); }); }); + + describe("getAssociatedInitiativesQuery:", () => { + it("returns query if project is associated", () => { + const p: IHubProject = { + typeKeywords: ["initiative|00c", "initiative|00d"], + } as unknown as IHubProject; + const chk = getAssociatedInitiativesQuery(p); + expect(chk.targetEntity).toEqual("item"); + expect(chk.filters[0].predicates[0].type).toBe("Hub Initiative"); + expect(chk.filters[0].predicates[0].id).toEqual(["00c", "00d"]); + }); + + it("returns null if project is not connected to any initatives", () => { + const p: IHubProject = { + typeKeywords: [], + } as unknown as IHubProject; + const chk = getAssociatedInitiativesQuery(p); + expect(chk).toBeNull(); + }); + }); }); diff --git a/packages/common/test/search/_internal/getEntityTypeFromType.ts b/packages/common/test/search/_internal/getEntityTypeFromType.ts new file mode 100644 index 00000000000..01a4cf87faa --- /dev/null +++ b/packages/common/test/search/_internal/getEntityTypeFromType.ts @@ -0,0 +1,25 @@ +import { getEntityTypeFromType } from "../../../src/search/_internal/getEntityTypeFromType"; + +describe("getEntityTypeFromType:", () => { + it("check return values", () => { + expect(getEntityTypeFromType("Web Mapping Application")).toEqual( + "item", + "Random type returns item" + ); + expect(getEntityTypeFromType("Group")).toEqual( + "group", + "Group returns group" + ); + expect(getEntityTypeFromType("user")).toEqual( + "user", + "case does not matter" + ); + expect(getEntityTypeFromType("EVENT")).toEqual( + "event", + "case does not matter" + ); + expect(getEntityTypeFromType("Channel")).toEqual("channel"); + + expect(getEntityTypeFromType("GROUP member")).toEqual("groupMember"); + }); +}); diff --git a/packages/common/test/search/_internal/negateGroupPredicates.test.ts b/packages/common/test/search/_internal/negateGroupPredicates.test.ts new file mode 100644 index 00000000000..a1201d412a7 --- /dev/null +++ b/packages/common/test/search/_internal/negateGroupPredicates.test.ts @@ -0,0 +1,107 @@ +import { IQuery } from "../../../src"; +import { negateGroupPredicates } from "../../../src/search/_internal/negateGroupPredicates"; + +describe("negateGroupPredicates:", () => { + it("returns undefined if not passed a query", () => { + expect(negateGroupPredicates(null as unknown as IQuery)).toBeUndefined(); + expect( + negateGroupPredicates(undefined as unknown as IQuery) + ).toBeUndefined(); + }); + it("does nothing if group predicate not present", () => { + const qry: IQuery = { + targetEntity: "item", + filters: [ + { + predicates: [ + { + id: "00c", + }, + ], + }, + ], + }; + const chk = negateGroupPredicates(qry); + expect(chk.filters[0].predicates[0].id).toEqual({ any: ["00c"] }); + }); + it("negates simple group predicate", () => { + const qry: IQuery = { + targetEntity: "item", + filters: [ + { + predicates: [ + { + group: "00c", + }, + ], + }, + ], + }; + const chk = negateGroupPredicates(qry); + expect(chk.filters[0].predicates[0].group).toEqual({ + any: [], + all: [], + not: ["00c"], + }); + }); + it("negates group.any predicate", () => { + const qry: IQuery = { + targetEntity: "item", + filters: [ + { + predicates: [ + { + group: { any: ["00c"] }, + }, + ], + }, + ], + }; + const chk = negateGroupPredicates(qry); + expect(chk.filters[0].predicates[0].group).toEqual({ + any: [], + all: [], + not: ["00c"], + }); + }); + it("negates group.all predicate", () => { + const qry: IQuery = { + targetEntity: "item", + filters: [ + { + predicates: [ + { + group: { all: ["00c"] }, + }, + ], + }, + ], + }; + const chk = negateGroupPredicates(qry); + expect(chk.filters[0].predicates[0].group).toEqual({ + any: [], + all: [], + not: ["00c"], + }); + }); + it("negates group.all && group.all predicate", () => { + const qry: IQuery = { + targetEntity: "item", + filters: [ + { + predicates: [ + { + group: { all: ["00c"], any: ["cc3"] }, + }, + ], + }, + ], + }; + const chk = negateGroupPredicates(qry); + expect(chk.filters[0].predicates[0].group).toEqual({ + any: [], + all: [], + not: ["cc3", "00c"], + }); + }); +}); diff --git a/packages/common/test/search/hubSearch.test.ts b/packages/common/test/search/hubSearch.test.ts index c84d36b350c..8fcf6c7d308 100644 --- a/packages/common/test/search/hubSearch.test.ts +++ b/packages/common/test/search/hubSearch.test.ts @@ -1,8 +1,6 @@ import { IHubSearchOptions, IQuery } from "../../src"; import { hubSearch } from "../../src/search/hubSearch"; -import * as SearchFunctionModule from "../../src/search/_internal"; - describe("hubSearch Module:", () => { describe("hubSearch:", () => { describe("guards:", () => { @@ -95,7 +93,7 @@ describe("hubSearch Module:", () => { // we are only interested in verifying that the fn was called with specific args // so all the responses are fake portalSearchItemsSpy = spyOn( - SearchFunctionModule, + require("../../src/search/_internal/portalSearchItems"), "portalSearchItems" ).and.callFake(() => { return Promise.resolve({ @@ -105,7 +103,7 @@ describe("hubSearch Module:", () => { }); }); portalSearchGroupsSpy = spyOn( - SearchFunctionModule, + require("../../src/search/_internal/portalSearchGroups"), "portalSearchGroups" ).and.callFake(() => { return Promise.resolve({ @@ -115,7 +113,7 @@ describe("hubSearch Module:", () => { }); }); hubSearchItemsSpy = spyOn( - SearchFunctionModule, + require("../../src/search/_internal/hubSearchItems"), "hubSearchItems" ).and.callFake(() => { return Promise.resolve({ @@ -125,7 +123,7 @@ describe("hubSearch Module:", () => { }); }); hubSearchChannelsSpy = spyOn( - SearchFunctionModule, + require("../../src/search/_internal/hubSearchChannels"), "hubSearchChannels" ).and.callFake(() => { return Promise.resolve({ diff --git a/packages/common/test/search/utils.test.ts b/packages/common/test/search/utils.test.ts index ed8a0254382..e47494b76a1 100644 --- a/packages/common/test/search/utils.test.ts +++ b/packages/common/test/search/utils.test.ts @@ -1,5 +1,5 @@ import { IGroup, ISearchOptions, IUser } from "@esri/arcgis-rest-portal"; -import { IHubSite, ISearchResponse } from "../../src"; +import { IHubSite, IQuery, ISearchResponse } from "../../src"; import { IHubSearchResult, IRelativeDate } from "../../src/search"; import { expandApis, @@ -10,6 +10,7 @@ import { getNextFunction, migrateToCollectionKey, getResultSiteRelativeLink, + getGroupPredicate, } from "../../src/search/utils"; import { MOCK_AUTH } from "../mocks/mock-auth"; import { mockUserSession } from "../test-helpers/fake-user-session"; @@ -344,7 +345,10 @@ describe("Search Utils:", () => { id: "9001", type: "Feature Service", } as IHubSearchResult; - const result = getResultSiteRelativeLink(searchResult, null); + const result = getResultSiteRelativeLink( + searchResult, + null as unknown as IHubSite + ); expect(result).toBeUndefined(); }); it("returns undefined if result.links.siteRelative isn't present", () => { @@ -353,7 +357,10 @@ describe("Search Utils:", () => { type: "Feature Service", links: {}, } as IHubSearchResult; - const result = getResultSiteRelativeLink(searchResult, null); + const result = getResultSiteRelativeLink( + searchResult, + null as unknown as IHubSite + ); expect(result).toBeUndefined(); }); it("returns an unmodified siteRelative link if result isn't a Hub Page", () => { @@ -364,7 +371,10 @@ describe("Search Utils:", () => { siteRelative: "/foo/9001", }, } as IHubSearchResult; - const result = getResultSiteRelativeLink(searchResult, null); + const result = getResultSiteRelativeLink( + searchResult, + null as unknown as IHubSite + ); expect(result).toBe("/foo/9001"); }); it("returns a Hub Page result's unmodified siteRelative link if no site is included", () => { @@ -375,7 +385,10 @@ describe("Search Utils:", () => { siteRelative: "/foo/9001", }, } as IHubSearchResult; - const result = getResultSiteRelativeLink(searchResult, null); + const result = getResultSiteRelativeLink( + searchResult, + null as unknown as IHubSite + ); expect(result).toBe("/foo/9001"); }); it("returns a Hub Page result's unmodified siteRelative link if site has no pages", () => { @@ -418,4 +431,49 @@ describe("Search Utils:", () => { expect(result).toBe("/foo/bar"); }); }); + + describe("getGroupPredicate:", () => { + it("returns undefined if no predicate with group found", () => { + const qry: IQuery = { + targetEntity: "item", + filters: [ + { + predicates: [ + { + id: "00c", + }, + ], + }, + ], + }; + const chk = getGroupPredicate(qry); + expect(chk).toBeUndefined(); + }); + + it("returns expanded group predicate", () => { + const qry: IQuery = { + targetEntity: "item", + filters: [ + { + predicates: [ + { + type: "Funnel Cake", + group: "00c", + }, + ], + }, + ], + }; + const chk = getGroupPredicate(qry); + + expect(chk).toEqual({ + type: { + any: ["Funnel Cake"], + }, + group: { + any: ["00c"], + }, + }); + }); + }); }); diff --git a/packages/common/test/utils/memoize.test.ts b/packages/common/test/utils/memoize.test.ts index 99e20f56e06..fe626fcb3e5 100644 --- a/packages/common/test/utils/memoize.test.ts +++ b/packages/common/test/utils/memoize.test.ts @@ -36,7 +36,6 @@ describe("memoize:", () => { // new call, calls the underlying function expect(memoizedFn(9, 1)).toEqual(10); expect(callCount).toBe(3); - // End test - nuke the entire cache clearMemoizedCache(); });