From 7ae44e36693f06101a5a41d05f7a39a5567a5b87 Mon Sep 17 00:00:00 2001 From: Gertjan van Oosten Date: Thu, 23 May 2024 16:04:01 +0200 Subject: [PATCH 01/18] AB#1085 Add locationRelationships Allow locations to have parents and children. --- client/src/components/LocationTable.js | 10 +++ client/src/models/Location.js | 17 +++- client/src/pages/locations/Edit.js | 44 +-------- client/src/pages/locations/Form.js | 49 ++++++++++ client/src/pages/locations/Show.js | 34 ++++++- insertBaseData-psql.sql | 90 +++++++++++++------ .../java/mil/dds/anet/beans/Location.java | 43 +++++++++ .../mil/dds/anet/database/LocationDao.java | 74 +++++++++++++++ .../dds/anet/resources/LocationResource.java | 18 ++++ .../mil/dds/anet/utils/BatchingUtils.java | 12 +++ .../mil/dds/anet/utils/FkDataLoaderKey.java | 2 + src/main/resources/migrations.xml | 44 +++++++++ src/test/resources/anet.graphql | 3 + 13 files changed, 366 insertions(+), 74 deletions(-) diff --git a/client/src/components/LocationTable.js b/client/src/components/LocationTable.js index 1d54fe170f..c2d8e4a63a 100644 --- a/client/src/components/LocationTable.js +++ b/client/src/components/LocationTable.js @@ -6,6 +6,7 @@ import { PageDispatchersPropType, useBoilerplate } from "components/Page" +import RemoveButton from "components/RemoveButton" import UltimatePaginationTopDown from "components/UltimatePaginationTopDown" import _get from "lodash/get" import { Location } from "models" @@ -115,6 +116,7 @@ const BaseLocationTable = ({ Name Type + {showDelete && } @@ -124,6 +126,14 @@ const BaseLocationTable = ({ {Location.humanNameOfType(loc.type)} + {showDelete && ( + + onDelete(loc)} + /> + + )} ))} diff --git a/client/src/models/Location.js b/client/src/models/Location.js index 8c12f15c92..2ddd2c5d12 100644 --- a/client/src/models/Location.js +++ b/client/src/models/Location.js @@ -119,6 +119,8 @@ export default class Location extends Model { } }) .default(null), + parentLocations: yup.array().nullable().default([]), + childrenLocations: yup.array().nullable().default([]), // FIXME: resolve code duplication in yup schema for approval steps planningApprovalSteps: yup .array() @@ -211,6 +213,16 @@ export default class Location extends Model { } } } + parentLocations { + uuid + name + type + } + childrenLocations { + uuid + name + type + } customFields ${GRAPHQL_NOTES_FIELDS} ` @@ -280,7 +292,10 @@ export default class Location extends Model { return this.name } - static FILTERED_CLIENT_SIDE_FIELDS = ["displayedCoordinate"] + static FILTERED_CLIENT_SIDE_FIELDS = [ + "childrenLocations", + "displayedCoordinate" + ] static filterClientSideFields(obj, ...additionalFields) { return Model.filterClientSideFields( diff --git a/client/src/pages/locations/Edit.js b/client/src/pages/locations/Edit.js index 29357b201b..0a0812e4a3 100644 --- a/client/src/pages/locations/Edit.js +++ b/client/src/pages/locations/Edit.js @@ -9,9 +9,7 @@ import { useBoilerplate, usePageTitle } from "components/Page" -import RelatedObjectNotes, { - GRAPHQL_NOTES_FIELDS -} from "components/RelatedObjectNotes" +import RelatedObjectNotes from "components/RelatedObjectNotes" import { Attachment, Location } from "models" import React from "react" import { connect } from "react-redux" @@ -23,48 +21,10 @@ import LocationForm from "./Form" const GQL_GET_LOCATION = gql` query($uuid: String!) { location(uuid: $uuid) { - uuid - name - digram - trigram - description - status - type - lat - lng - planningApprovalSteps { - uuid - name - approvers { - uuid - name - person { - uuid - name - rank - avatarUuid - } - } - } - approvalSteps { - uuid - name - approvers { - uuid - name - person { - uuid - name - rank - avatarUuid - } - } - } + ${Location.allFieldsQuery} attachments { ${Attachment.basicFieldsQuery} } - customFields - ${GRAPHQL_NOTES_FIELDS} } } ` diff --git a/client/src/pages/locations/Form.js b/client/src/pages/locations/Form.js index e1c0416660..76e1fa68db 100644 --- a/client/src/pages/locations/Form.js +++ b/client/src/pages/locations/Form.js @@ -2,6 +2,8 @@ import { gql } from "@apollo/client" import { Icon, IconSize, Intent } from "@blueprintjs/core" import { IconNames } from "@blueprintjs/icons" import API from "api" +import AdvancedMultiSelect from "components/advancedSelectWidget/AdvancedMultiSelect" +import { LocationOverlayRow } from "components/advancedSelectWidget/AdvancedSelectOverlayRow" import AppContext from "components/AppContext" import ApprovalsDefinition from "components/approvals/ApprovalsDefinition" import UploadAttachment from "components/Attachment/UploadAttachment" @@ -14,6 +16,7 @@ import * as FieldHelper from "components/FieldHelper" import Fieldset from "components/Fieldset" import GeoLocation from "components/GeoLocation" import Leaflet from "components/Leaflet" +import LocationTable from "components/LocationTable" import Messages from "components/Messages" import Model from "components/Model" import NavigationWarning from "components/NavigationWarning" @@ -29,8 +32,10 @@ import PropTypes from "prop-types" import React, { useContext, useEffect, useState } from "react" import { Button, FormSelect } from "react-bootstrap" import { useNavigate } from "react-router-dom" +import LOCATIONS_ICON from "resources/locations.png" import Settings from "settings" import { useDebouncedCallback } from "use-debounce" +import utils from "utils" const GQL_CREATE_LOCATION = gql` mutation ($location: LocationInput!) { @@ -70,6 +75,14 @@ const LOCATION_TYPES_SUPERUSER = const LOCATION_TYPES_REGULARUSER = Settings?.fields?.location?.regularuserTypeOptions +const locationFilters = { + allLocations: { + label: "All locations", + queryVars: {} + } +} +const locationSearchQuery = { status: Model.STATUS.ACTIVE } + const LocationForm = ({ edit, title, @@ -290,6 +303,38 @@ const LocationForm = ({ )} + { + // validation will be done by setFieldValue + setFieldTouched("parentLocations", true, false) // onBlur doesn't work when selecting an option + setFieldValue("parentLocations", value) + }} + widget={ + + } + overlayColumns={["Name"]} + overlayRenderRow={LocationOverlayRow} + filterDefs={locationFilters} + objectType={Location} + queryParams={locationSearchQuery} + fields={Location.autocompleteQuery} + addon={LOCATIONS_ICON} + /> + } + /> + + utils.getReference(l) + ) location.customFields = customFieldsJSONString(values) return API.mutation(edit ? GQL_UPDATE_LOCATION : GQL_CREATE_LOCATION, { location diff --git a/client/src/pages/locations/Show.js b/client/src/pages/locations/Show.js index 4365caed16..122206094d 100644 --- a/client/src/pages/locations/Show.js +++ b/client/src/pages/locations/Show.js @@ -11,6 +11,7 @@ import Fieldset from "components/Fieldset" import GeoLocation, { GEO_LOCATION_DISPLAY_TYPE } from "components/GeoLocation" import Leaflet from "components/Leaflet" import LinkTo from "components/LinkTo" +import LocationTable from "components/LocationTable" import Messages from "components/Messages" import { DEFAULT_CUSTOM_FIELDS_PARENT } from "components/Model" import { @@ -27,6 +28,7 @@ import RichTextEditor from "components/RichTextEditor" import { Field, Form, Formik } from "formik" import { convertLatLngToMGRS } from "geoUtils" import _escape from "lodash/escape" +import _isEmpty from "lodash/isEmpty" import { Attachment, Location } from "models" import React, { useContext, useState } from "react" import { connect } from "react-redux" @@ -198,14 +200,42 @@ const LocationShow = ({ pageDispatchers }) => { )} - {values.description && ( + {!_isEmpty(location.parentLocations) && ( + + } + /> + )} + + {!_isEmpty(location.childrenLocations) && ( + + } + /> + )} + + {location.description && ( + } /> )} diff --git a/insertBaseData-psql.sql b/insertBaseData-psql.sql index 1314cdb973..3e7024ecf0 100644 --- a/insertBaseData-psql.sql +++ b/insertBaseData-psql.sql @@ -10,6 +10,7 @@ TRUNCATE TABLE "comments" CASCADE; TRUNCATE TABLE "customSensitiveInformation" CASCADE; TRUNCATE TABLE "emailAddresses" CASCADE; TRUNCATE TABLE "jobHistory" CASCADE; +TRUNCATE TABLE "locationRelationships" CASCADE; TRUNCATE TABLE "noteRelatedObjects" CASCADE; TRUNCATE TABLE "notes" CASCADE; TRUNCATE TABLE "organizationAdministrativePositions" CASCADE; @@ -153,36 +154,67 @@ INSERT INTO "emailAddresses" (network, address, "relatedObjectType", "relatedObj -- Create locations INSERT INTO locations (uuid, type, name, lat, lng, "createdAt", "updatedAt") VALUES - (N'e5b3a4b9-acf7-4c79-8224-f248b9a7215d', 'PA', 'Antarctica', -90, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'cc49bb27-4d8f-47a8-a9ee-af2b68b992ac', 'PP', 'St Johns Airport', 47.613442, -52.740936, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'8c138750-91ce-41bf-9b4c-9f0ddc73608b', 'PP', 'Murray''s Hotel', 47.561517, -52.708760, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'9c982685-5946-4dad-a7ee-0f5a12f5e170', 'PP', 'Wishingwells Park', 47.560040, -52.736962, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'0855fb0a-995e-4a79-a132-4024ee2983ff', 'PP', 'General Hospital', 47.571772, -52.741935, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'95446f93-249b-4aa9-b98a-7bd2c4680718', 'PP', 'Portugal Cove Ferry Terminal', 47.626718, -52.857241, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'c8fdb53f-6f93-46fc-b0fa-f005c7b49667', 'PP', 'Cabot Tower', 47.570010, -52.681770, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'c7a9f420-457a-490c-a810-b504c022cf1e', 'PP', 'Fort Amherst', 47.563763, -52.680590, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'7339f9e3-99d1-497a-9e3b-1269c4c287fe', 'PP', 'Harbour Grace Police Station', 47.705133, -53.214422, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'f2207d9b-204b-4cb5-874d-3fe6bc6f8acd', 'PP', 'Conception Bay South Police Station', 47.526784, -52.954739, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + ('e5b3a4b9-acf7-4c79-8224-f248b9a7215d', 'PA', 'Antarctica', -90, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('cc49bb27-4d8f-47a8-a9ee-af2b68b992ac', 'PP', 'St Johns Airport', 47.613442, -52.740936, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('8c138750-91ce-41bf-9b4c-9f0ddc73608b', 'PP', 'Murray''s Hotel', 47.561517, -52.708760, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('9c982685-5946-4dad-a7ee-0f5a12f5e170', 'PP', 'Wishingwells Park', 47.560040, -52.736962, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('0855fb0a-995e-4a79-a132-4024ee2983ff', 'PP', 'General Hospital', 47.571772, -52.741935, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('95446f93-249b-4aa9-b98a-7bd2c4680718', 'PP', 'Portugal Cove Ferry Terminal', 47.626718, -52.857241, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('c8fdb53f-6f93-46fc-b0fa-f005c7b49667', 'PP', 'Cabot Tower', 47.570010, -52.681770, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('c7a9f420-457a-490c-a810-b504c022cf1e', 'PP', 'Fort Amherst', 47.563763, -52.680590, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('7339f9e3-99d1-497a-9e3b-1269c4c287fe', 'PP', 'Harbour Grace Police Station', 47.705133, -53.214422, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('f2207d9b-204b-4cb5-874d-3fe6bc6f8acd', 'PP', 'Conception Bay South Police Station', 47.526784, -52.954739, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); INSERT INTO locations (uuid, type, name, "createdAt", "updatedAt") VALUES - (N'283797ec-7077-49b2-87b8-9afd5499b6f3', 'V', 'VTC', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'e0ff0d6c-e663-4639-a44d-b075bf1e690d', 'PP', 'MoD Headquarters Kabul', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'5046a870-6c2a-40a7-9681-61a1d6eeaa07', 'PP', 'MoI Headquarters Kabul', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'c15eb29e-2965-401e-9f36-6ac8b9cc3842', 'PP', 'President''s Palace', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'0585f158-5121-46a2-b099-799fe980aa9c', 'PP', 'Kabul Police Academy', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'053ab2ad-132a-4a62-8cbb-20827f50ec34', 'PP', 'Police HQ Training Facility', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'e87f145b-32e9-47ec-a0f4-e0dcf18e8a8c', 'PP', 'Kabul Hospital', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'6465dd40-9fec-41db-a3b9-652fa52c7d21', 'PP', 'MoD Army Training Base 123', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'2a59dd78-0c29-4b3f-bc94-7c98ff80b197', 'PP', 'MoD Location the Second', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'18c9be38-bf68-40e2-80d8-aac47f5ff7cf', 'PP', 'MoI Office Building ABC', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'8a34768c-aa15-41e4-ab79-6cf2740d555e', 'PP', 'MoI Training Center', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'9f364c59-953e-4c17-919c-648ea3a74e36', 'PP', 'MoI Adminstrative Office', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'dfc3918d-c2e3-4308-b161-2445cde77b3f', 'PP', 'MoI Senior Executive Suite', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'3652e114-ad16-43f0-b179-cc1bce6958d5', 'PP', 'MoI Coffee Shop', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'5ac4078d-d445-416a-a93e-5941562359bb', 'PP', 'MoI Herat Office', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'22b0137c-4d89-43eb-ac95-a9f68aba884f', 'PP', 'MoI Jalalabad Office', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'60f4084f-3304-4cd5-89df-353edef07d18', 'PP', 'MoI Kandahar Office', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'c136bf89-cc24-43a5-8f51-0f41dfc9ab77', 'PP', 'MoI Mazar-i-Sharif', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (N'b0979678-0ed0-4b42-9b26-9976fcfa1b81', 'PP', 'MoI Office Building ABC', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + ('283797ec-7077-49b2-87b8-9afd5499b6f3', 'V', 'VTC', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('e0ff0d6c-e663-4639-a44d-b075bf1e690d', 'PP', 'MoD Headquarters Kabul', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('5046a870-6c2a-40a7-9681-61a1d6eeaa07', 'PP', 'MoI Headquarters Kabul', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('c15eb29e-2965-401e-9f36-6ac8b9cc3842', 'PP', 'President''s Palace', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('0585f158-5121-46a2-b099-799fe980aa9c', 'PP', 'Kabul Police Academy', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('053ab2ad-132a-4a62-8cbb-20827f50ec34', 'PP', 'Police HQ Training Facility', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('e87f145b-32e9-47ec-a0f4-e0dcf18e8a8c', 'PP', 'Kabul Hospital', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('6465dd40-9fec-41db-a3b9-652fa52c7d21', 'PP', 'MoD Army Training Base 123', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('2a59dd78-0c29-4b3f-bc94-7c98ff80b197', 'PP', 'MoD Location the Second', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('18c9be38-bf68-40e2-80d8-aac47f5ff7cf', 'PP', 'MoD Office Building', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('8a34768c-aa15-41e4-ab79-6cf2740d555e', 'PP', 'MoI Training Center', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('9f364c59-953e-4c17-919c-648ea3a74e36', 'PP', 'MoI Adminstrative Office', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('dfc3918d-c2e3-4308-b161-2445cde77b3f', 'PP', 'MoI Senior Executive Suite', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('3652e114-ad16-43f0-b179-cc1bce6958d5', 'PP', 'MoI Coffee Shop', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('5ac4078d-d445-416a-a93e-5941562359bb', 'PP', 'MoI Herat Office', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('22b0137c-4d89-43eb-ac95-a9f68aba884f', 'PP', 'MoI Jalalabad Office', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('60f4084f-3304-4cd5-89df-353edef07d18', 'PP', 'MoI Kandahar Office', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('c136bf89-cc24-43a5-8f51-0f41dfc9ab77', 'PP', 'MoI Mazar-i-Sharif', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('b0979678-0ed0-4b42-9b26-9976fcfa1b81', 'PP', 'MoI Office Building ABC', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + +-- Set up locationRelationships +INSERT INTO "locationRelationships" ("childLocationUuid", "parentLocationUuid") VALUES + ('e5b3a4b9-acf7-4c79-8224-f248b9a7215d', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Antarctica')), + ('cc49bb27-4d8f-47a8-a9ee-af2b68b992ac', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Canada')), + ('8c138750-91ce-41bf-9b4c-9f0ddc73608b', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Canada')), + ('9c982685-5946-4dad-a7ee-0f5a12f5e170', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Canada')), + ('0855fb0a-995e-4a79-a132-4024ee2983ff', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Canada')), + ('95446f93-249b-4aa9-b98a-7bd2c4680718', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Canada')), + ('c8fdb53f-6f93-46fc-b0fa-f005c7b49667', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Canada')), + ('c7a9f420-457a-490c-a810-b504c022cf1e', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Canada')), + ('7339f9e3-99d1-497a-9e3b-1269c4c287fe', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Canada')), + ('f2207d9b-204b-4cb5-874d-3fe6bc6f8acd', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Canada')), + ('e0ff0d6c-e663-4639-a44d-b075bf1e690d', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Afghanistan')), + ('5046a870-6c2a-40a7-9681-61a1d6eeaa07', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Afghanistan')), + ('c15eb29e-2965-401e-9f36-6ac8b9cc3842', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Afghanistan')), + ('0585f158-5121-46a2-b099-799fe980aa9c', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Afghanistan')), + ('053ab2ad-132a-4a62-8cbb-20827f50ec34', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Afghanistan')), + ('e87f145b-32e9-47ec-a0f4-e0dcf18e8a8c', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Afghanistan')), + ('6465dd40-9fec-41db-a3b9-652fa52c7d21', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Afghanistan')), + ('2a59dd78-0c29-4b3f-bc94-7c98ff80b197', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Afghanistan')), + ('18c9be38-bf68-40e2-80d8-aac47f5ff7cf', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Afghanistan')), + ('8a34768c-aa15-41e4-ab79-6cf2740d555e', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Afghanistan')), + ('9f364c59-953e-4c17-919c-648ea3a74e36', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Afghanistan')), + ('dfc3918d-c2e3-4308-b161-2445cde77b3f', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Afghanistan')), + ('3652e114-ad16-43f0-b179-cc1bce6958d5', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Afghanistan')), + ('5ac4078d-d445-416a-a93e-5941562359bb', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Afghanistan')), + ('22b0137c-4d89-43eb-ac95-a9f68aba884f', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Afghanistan')), + ('60f4084f-3304-4cd5-89df-353edef07d18', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Afghanistan')), + ('c136bf89-cc24-43a5-8f51-0f41dfc9ab77', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Afghanistan')), + ('b0979678-0ed0-4b42-9b26-9976fcfa1b81', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Afghanistan')); UPDATE locations SET "customFields"='{"invisibleCustomFields":["formCustomFields.textareaFieldName","formCustomFields.numberFieldName"],"arrayFieldName":[],"nlt_dt":null,"nlt":null,"colourOptions":"","inputFieldName":"consectetur adipisici elit","multipleButtons":[]}' diff --git a/src/main/java/mil/dds/anet/beans/Location.java b/src/main/java/mil/dds/anet/beans/Location.java index f2bf841ab4..7d97e091ec 100644 --- a/src/main/java/mil/dds/anet/beans/Location.java +++ b/src/main/java/mil/dds/anet/beans/Location.java @@ -82,6 +82,10 @@ public String toString() { List planningApprovalSteps; /* Planning approval process for this Task */ // annotated below List approvalSteps; /* Approval process for this Task */ + // annotated below + List childrenLocations; + // annotated below + List parentLocations; @Override @AllowUnverifiedUsers @@ -203,6 +207,45 @@ public void setApprovalSteps(List steps) { this.approvalSteps = steps; } + @GraphQLQuery(name = "childrenLocations") + public CompletableFuture> loadChildrenLocations( + @GraphQLRootContext Map context) { + if (childrenLocations != null) { + return CompletableFuture.completedFuture(childrenLocations); + } + return AnetObjectEngine.getInstance().getLocationDao().getChildrenLocations(context, uuid) + .thenApply(o -> { + childrenLocations = o; + return o; + }); + } + + public List getChildrenLocations() { + return childrenLocations; + } + + @GraphQLQuery(name = "parentLocations") + public CompletableFuture> loadParentLocations( + @GraphQLRootContext Map context) { + if (parentLocations != null) { + return CompletableFuture.completedFuture(parentLocations); + } + return AnetObjectEngine.getInstance().getLocationDao().getParentLocations(context, uuid) + .thenApply(o -> { + parentLocations = o; + return o; + }); + } + + public List getParentLocations() { + return parentLocations; + } + + @GraphQLInputField(name = "parentLocations") + public void setParentLocations(List parentLocations) { + this.parentLocations = parentLocations; + } + @Override public boolean equals(Object o) { if (!(o instanceof Location other)) { diff --git a/src/main/java/mil/dds/anet/database/LocationDao.java b/src/main/java/mil/dds/anet/database/LocationDao.java index 1ccff307a4..0153ad4bf4 100644 --- a/src/main/java/mil/dds/anet/database/LocationDao.java +++ b/src/main/java/mil/dds/anet/database/LocationDao.java @@ -3,6 +3,8 @@ import java.time.Instant; import java.util.Arrays; import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; import mil.dds.anet.AnetObjectEngine; import mil.dds.anet.beans.Location; import mil.dds.anet.beans.MergedEntity; @@ -10,6 +12,8 @@ import mil.dds.anet.beans.search.LocationSearchQuery; import mil.dds.anet.database.mappers.LocationMapper; import mil.dds.anet.utils.DaoUtils; +import mil.dds.anet.utils.FkDataLoaderKey; +import mil.dds.anet.views.ForeignKeyFetcher; import ru.vyarus.guicey.jdbi3.tx.InTransaction; public class LocationDao extends AnetSubscribableObjectDao { @@ -122,4 +126,74 @@ public int mergeLocations(Location loserLocation, Location winnerLocation) { public SubscriptionUpdateGroup getSubscriptionUpdate(Location obj) { return getCommonSubscriptionUpdate(obj, TABLE_NAME, "locations.uuid"); } + + public CompletableFuture> getChildrenLocations(Map context, + String parentLocationUuid) { + return new ForeignKeyFetcher().load(context, + FkDataLoaderKey.LOCATION_CHILDREN_LOCATIONS, parentLocationUuid); + } + + static class ChildrenLocationsBatcher extends ForeignKeyBatcher { + private static final String sql = "/* batch.getChildrenLocationsForLocation */ SELECT " + + LOCATION_FIELDS + ", \"locationRelationships\".\"parentLocationUuid\" " + + "FROM locations, \"locationRelationships\" " + + "WHERE locations.uuid = \"locationRelationships\".\"childLocationUuid\" " + + " AND \"locationRelationships\".\"parentLocationUuid\" IN ( ) " + + "ORDER BY locations.name, locations.uuid"; + + public ChildrenLocationsBatcher() { + super(sql, "foreignKeys", new LocationMapper(), "parentLocationUuid"); + } + } + + public List> getChildrenLocationsForLocation(List foreignKeys) { + final ForeignKeyBatcher childrenLocationsBatcher = + AnetObjectEngine.getInstance().getInjector().getInstance(ChildrenLocationsBatcher.class); + return childrenLocationsBatcher.getByForeignKeys(foreignKeys); + } + + public CompletableFuture> getParentLocations(Map context, + String parentLocationUuid) { + return new ForeignKeyFetcher().load(context, + FkDataLoaderKey.LOCATION_PARENT_LOCATIONS, parentLocationUuid); + } + + static class ParentLocationsBatcher extends ForeignKeyBatcher { + private static final String sql = "/* batch.getParentLocationsForLocation */ SELECT " + + LOCATION_FIELDS + ", \"locationRelationships\".\"childLocationUuid\" " + + "FROM locations, \"locationRelationships\" " + + "WHERE locations.uuid = \"locationRelationships\".\"parentLocationUuid\" " + + " AND \"locationRelationships\".\"childLocationUuid\" IN ( ) " + + "ORDER BY locations.name, locations.uuid"; + + public ParentLocationsBatcher() { + super(sql, "foreignKeys", new LocationMapper(), "childLocationUuid"); + } + } + + public List> getParentLocationsForLocation(List foreignKeys) { + final ForeignKeyBatcher parentLocationsBatcher = + AnetObjectEngine.getInstance().getInjector().getInstance(ParentLocationsBatcher.class); + return parentLocationsBatcher.getByForeignKeys(foreignKeys); + } + + @InTransaction + public int addLocationRelationship(Location parentLocation, Location childLocation) { + return getDbHandle() + .createUpdate("/* addLocationRelationship */ INSERT INTO \"locationRelationships\"" + + " (\"parentLocationUuid\", \"childLocationUuid\") " + + "VALUES (:parentLocationUuid, :childLocationUuid)") + .bind("parentLocationUuid", parentLocation.getUuid()) + .bind("childLocationUuid", childLocation.getUuid()).execute(); + } + + @InTransaction + public int removeLocationRelationship(Location parentLocation, Location childLocation) { + return getDbHandle() + .createUpdate("/* removeLocationRelationship*/ DELETE FROM \"locationRelationships\" " + + "WHERE \"parentLocationUuid\" = :parentLocationUuid " + + "AND \"childLocationUuid\" = :childLocationUuid") + .bind("parentLocationUuid", parentLocation.getUuid()) + .bind("childLocationUuid", childLocation.getUuid()).execute(); + } } diff --git a/src/main/java/mil/dds/anet/resources/LocationResource.java b/src/main/java/mil/dds/anet/resources/LocationResource.java index 9a4331576d..02d20913f8 100644 --- a/src/main/java/mil/dds/anet/resources/LocationResource.java +++ b/src/main/java/mil/dds/anet/resources/LocationResource.java @@ -97,6 +97,13 @@ public Location createLocation(@GraphQLRootContext Map context, } } + // Update parent locations: + if (l.getParentLocations() != null) { + for (final Location parentLocation : l.getParentLocations()) { + dao.addLocationRelationship(parentLocation, l); + } + } + DaoUtils.saveCustomSensitiveInformation(user, LocationDao.TABLE_NAME, created.getUuid(), l.getCustomSensitiveInformation()); @@ -119,6 +126,8 @@ public Integer updateLocation(@GraphQLRootContext Map context, // Load the existing location, so we can check for differences. final Location existing = dao.getByUuid(l.getUuid()); + + // Update approval steps: final List existingPlanningApprovalSteps = existing.loadPlanningApprovalSteps(engine.getContext()).join(); final List existingApprovalSteps = @@ -126,6 +135,15 @@ public Integer updateLocation(@GraphQLRootContext Map context, Utils.updateApprovalSteps(l, l.getPlanningApprovalSteps(), existingPlanningApprovalSteps, l.getApprovalSteps(), existingApprovalSteps); + // Update parent locations: + if (l.getParentLocations() != null) { + final List existingParentLocations = + existing.loadParentLocations(engine.getContext()).join(); + Utils.addRemoveElementsByUuid(existingParentLocations, l.getParentLocations(), + newParentLocation -> dao.addLocationRelationship(newParentLocation, l), + oldParentLocation -> dao.removeLocationRelationship(oldParentLocation, l)); + } + DaoUtils.saveCustomSensitiveInformation(user, LocationDao.TABLE_NAME, l.getUuid(), l.getCustomSensitiveInformation()); diff --git a/src/main/java/mil/dds/anet/utils/BatchingUtils.java b/src/main/java/mil/dds/anet/utils/BatchingUtils.java index 8cbf1ffcd0..4e8a2d99e5 100644 --- a/src/main/java/mil/dds/anet/utils/BatchingUtils.java +++ b/src/main/java/mil/dds/anet/utils/BatchingUtils.java @@ -128,6 +128,18 @@ private void registerDataLoaders(AnetObjectEngine engine) { () -> engine.getEmailAddressDao().getEmailAddressesForRelatedObjects(foreignKeys), dispatcherService), dataLoaderOptions)); + dataLoaderRegistry.register(FkDataLoaderKey.LOCATION_CHILDREN_LOCATIONS.toString(), + DataLoaderFactory.newDataLoader( + (BatchLoader>) foreignKeys -> CompletableFuture.supplyAsync( + () -> engine.getLocationDao().getChildrenLocationsForLocation(foreignKeys), + dispatcherService), + dataLoaderOptions)); + dataLoaderRegistry.register(FkDataLoaderKey.LOCATION_PARENT_LOCATIONS.toString(), + DataLoaderFactory.newDataLoader( + (BatchLoader>) foreignKeys -> CompletableFuture.supplyAsync( + () -> engine.getLocationDao().getParentLocationsForLocation(foreignKeys), + dispatcherService), + dataLoaderOptions)); dataLoaderRegistry.register(IdDataLoaderKey.LOCATIONS.toString(), DataLoaderFactory.newDataLoader( (BatchLoader) keys -> CompletableFuture diff --git a/src/main/java/mil/dds/anet/utils/FkDataLoaderKey.java b/src/main/java/mil/dds/anet/utils/FkDataLoaderKey.java index 5d897d2017..dc7ea33085 100644 --- a/src/main/java/mil/dds/anet/utils/FkDataLoaderKey.java +++ b/src/main/java/mil/dds/anet/utils/FkDataLoaderKey.java @@ -8,6 +8,8 @@ public enum FkDataLoaderKey { AUTHORIZATION_GROUP_ADMINISTRATIVE_POSITIONS, // authorizationGroup.administrativePositions AUTHORIZATION_GROUP_AUTHORIZATION_GROUP_RELATED_OBJECTS, // authorizationGroup.authorizationGroupRelatedObjects EMAIL_ADDRESSES_FOR_RELATED_OBJECT, // .emailAddresses + LOCATION_CHILDREN_LOCATIONS, // location.childrenLocations + LOCATION_PARENT_LOCATIONS, // location.parentLocations NOTE_NOTE_RELATED_OBJECTS, // note.noteRelatedObjects NOTE_RELATED_OBJECT_NOTES, // noteRelatedObject.notes ORGANIZATION_ADMINISTRATIVE_POSITIONS, // organization.responsiblePositions diff --git a/src/main/resources/migrations.xml b/src/main/resources/migrations.xml index 58ca8064bf..191cda5c3d 100644 --- a/src/main/resources/migrations.xml +++ b/src/main/resources/migrations.xml @@ -5778,4 +5778,48 @@ /> + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/resources/anet.graphql b/src/test/resources/anet.graphql index 03aca765b5..a5f1a71b9f 100644 --- a/src/test/resources/anet.graphql +++ b/src/test/resources/anet.graphql @@ -373,6 +373,7 @@ scalar Instant type Location { approvalSteps: [ApprovalStep] attachments: [Attachment] + childrenLocations: [Location] createdAt: Instant customFields: String customSensitiveInformation: [CustomSensitiveInformation] @@ -383,6 +384,7 @@ type Location { lng: Float name: String notes: [Note] + parentLocations: [Location] planningApprovalSteps: [ApprovalStep] status: Status trigram: String @@ -402,6 +404,7 @@ input LocationInput { lat: Float lng: Float name: String + parentLocations: [LocationInput] planningApprovalSteps: [ApprovalStepInput] status: Status trigram: String From 40f948cbb816ca1149ece5bd6f7ca55481c7aa03 Mon Sep 17 00:00:00 2001 From: Gertjan van Oosten Date: Mon, 27 May 2024 14:02:12 +0200 Subject: [PATCH 02/18] AB#1085 Add recursive location search for locations --- client/src/components/SearchFilters.js | 13 ++ .../advancedSearch/LocationFilter.js | 166 ++++++++++++++++++ .../beans/search/LocationSearchQuery.java | 23 +++ .../anet/search/AbstractLocationSearcher.java | 18 ++ .../anet/search/AbstractReportSearcher.java | 5 +- .../search/AbstractSearchQueryBuilder.java | 44 +++-- .../pg/PostgresqlUserActivitySearcher.java | 3 +- src/test/resources/anet.graphql | 2 + 8 files changed, 254 insertions(+), 20 deletions(-) create mode 100644 client/src/components/advancedSearch/LocationFilter.js diff --git a/client/src/components/SearchFilters.js b/client/src/components/SearchFilters.js index 6d18616452..c65413e95a 100644 --- a/client/src/components/SearchFilters.js +++ b/client/src/components/SearchFilters.js @@ -10,6 +10,10 @@ import CheckboxFilter, { import DateRangeFilter, { deserialize as deserializeDateRangeFilter } from "components/advancedSearch/DateRangeFilter" +import { + deserializeMulti as deserializeLocationMultiFilter, + LocationMultiFilter +} from "components/advancedSearch/LocationFilter" import { deserializeMulti as deserializeOrganizationMultiFilter, OrganizationMultiFilter @@ -588,6 +592,15 @@ export const searchFilters = function(includeAdminFilters) { options: locationTypeOptions, labels: locationTypeOptions.map(lt => Location.humanNameOfType(lt)) } + }, + "Within Location": { + component: LocationMultiFilter, + deserializer: deserializeLocationMultiFilter, + props: { + queryKey: "locationUuid", + queryRecurseStrategyKey: "locationRecurseStrategy", + fixedRecurseStrategy: RECURSE_STRATEGY.CHILDREN + } } } } diff --git a/client/src/components/advancedSearch/LocationFilter.js b/client/src/components/advancedSearch/LocationFilter.js new file mode 100644 index 0000000000..63e3736720 --- /dev/null +++ b/client/src/components/advancedSearch/LocationFilter.js @@ -0,0 +1,166 @@ +import { gql } from "@apollo/client" +import API from "api" +import useSearchFilter from "components/advancedSearch/hooks" +import AdvancedMultiSelect from "components/advancedSelectWidget/AdvancedMultiSelect" +import { LocationOverlayRow } from "components/advancedSelectWidget/AdvancedSelectOverlayRow" +import AdvancedSingleSelect from "components/advancedSelectWidget/AdvancedSingleSelect" +import LocationTable from "components/LocationTable" +import { Location } from "models" +import PropTypes from "prop-types" +import React from "react" +import LOCATIONS_ICON from "resources/locations.png" + +const GQL_GET_LOCATION = gql` + query ($uuid: String!) { + location(uuid: $uuid) { + uuid + name + type + } + } +` + +const GQL_GET_LOCATIONS = gql` + query ($uuids: [String]) { + locations(uuids: $uuids) { + uuid + name + type + } + } +` + +const LocationFilter = ({ + asFormField, + queryKey, + queryRecurseStrategyKey, + fixedRecurseStrategy, + value: inputValue, + multi, + onChange, + locFilterQueryParams, + ...advancedSelectProps +}) => { + const defaultValue = { + value: inputValue.value || (multi ? [] : {}) + } + const toQuery = val => { + return { + [queryKey]: multi ? val.value?.map(v => v.uuid) : val.value?.uuid, + [queryRecurseStrategyKey]: fixedRecurseStrategy + } + } + const [value, setValue] = useSearchFilter( + asFormField, + onChange, + inputValue, + defaultValue, + toQuery + ) + + const advancedSelectFilters = { + all: { + label: "All", + queryVars: locFilterQueryParams + } + } + + const AdvancedSelectComponent = multi + ? AdvancedMultiSelect + : AdvancedSingleSelect + const valueKey = multi ? "uuid" : "name" + return !asFormField ? ( + <> + {multi ? value.value?.map(v => v.name).join(" or ") : value.value?.name} + + ) : ( + } + /> + ) + + function handleChangeLoc(event) { + if (typeof event === "object" || Array.isArray(event)) { + setValue(prevValue => ({ + ...prevValue, + value: event + })) + } + } +} +LocationFilter.propTypes = { + queryKey: PropTypes.string.isRequired, + queryRecurseStrategyKey: PropTypes.string.isRequired, + fixedRecurseStrategy: PropTypes.string.isRequired, + value: PropTypes.any, + multi: PropTypes.bool, + onChange: PropTypes.func, + locFilterQueryParams: PropTypes.object, + asFormField: PropTypes.bool +} +LocationFilter.defaultProps = { + asFormField: true +} + +export const LocationMultiFilter = ({ ...props }) => ( + +) + +export const deserialize = ({ queryKey }, query, key) => { + if (query[queryKey]) { + return API.query(GQL_GET_LOCATION, { + uuid: query[queryKey] + }).then(data => { + if (data.location) { + return { + key, + value: { + value: data.location, + toQuery: { ...query } + } + } + } else { + return null + } + }) + } + return null +} + +export const deserializeMulti = ({ queryKey }, query, key) => { + if (query[queryKey]) { + return API.query(GQL_GET_LOCATIONS, { + uuids: query[queryKey] + }).then(data => { + if (data.locations) { + return { + key, + value: { + value: data.locations, + toQuery: { ...query } + } + } + } else { + return null + } + }) + } + return null +} + +export default LocationFilter diff --git a/src/main/java/mil/dds/anet/beans/search/LocationSearchQuery.java b/src/main/java/mil/dds/anet/beans/search/LocationSearchQuery.java index cd9a68508e..067e432483 100644 --- a/src/main/java/mil/dds/anet/beans/search/LocationSearchQuery.java +++ b/src/main/java/mil/dds/anet/beans/search/LocationSearchQuery.java @@ -2,6 +2,7 @@ import io.leangen.graphql.annotations.GraphQLInputField; import io.leangen.graphql.annotations.GraphQLQuery; +import java.util.List; import mil.dds.anet.beans.Location.LocationType; public class LocationSearchQuery extends SubscribableObjectSearchQuery { @@ -9,6 +10,12 @@ public class LocationSearchQuery extends SubscribableObjectSearchQuery locationUuid; + @GraphQLQuery + @GraphQLInputField + private RecurseStrategy locationRecurseStrategy; public LocationSearchQuery() { super(LocationSearchSortBy.NAME); @@ -22,6 +29,22 @@ public void setType(LocationType type) { this.type = type; } + public List getLocationUuid() { + return locationUuid; + } + + public void setLocationUuid(List locationUuid) { + this.locationUuid = locationUuid; + } + + public RecurseStrategy getLocationRecurseStrategy() { + return locationRecurseStrategy; + } + + public void setLocationRecurseStrategy(RecurseStrategy locationRecurseStrategy) { + this.locationRecurseStrategy = locationRecurseStrategy; + } + @Override public LocationSearchQuery clone() throws CloneNotSupportedException { return (LocationSearchQuery) super.clone(); diff --git a/src/main/java/mil/dds/anet/search/AbstractLocationSearcher.java b/src/main/java/mil/dds/anet/search/AbstractLocationSearcher.java index 72fdd97631..31cae04279 100644 --- a/src/main/java/mil/dds/anet/search/AbstractLocationSearcher.java +++ b/src/main/java/mil/dds/anet/search/AbstractLocationSearcher.java @@ -3,11 +3,13 @@ import mil.dds.anet.AnetObjectEngine; import mil.dds.anet.beans.Location; import mil.dds.anet.beans.lists.AnetBeanList; +import mil.dds.anet.beans.search.ISearchQuery; import mil.dds.anet.beans.search.ISearchQuery.SortOrder; import mil.dds.anet.beans.search.LocationSearchQuery; import mil.dds.anet.database.LocationDao; import mil.dds.anet.database.mappers.LocationMapper; import mil.dds.anet.utils.DaoUtils; +import mil.dds.anet.utils.Utils; import ru.vyarus.guicey.jdbi3.tx.InTransaction; public abstract class AbstractLocationSearcher @@ -35,6 +37,10 @@ protected void buildQuery(LocationSearchQuery query) { addTextQuery(query); } + if (!Utils.isEmptyOrNull(query.getLocationUuid())) { + addLocationUuidQuery(query); + } + if (query.getUser() != null && query.getSubscribed()) { qb.addWhereClause(Searcher.getSubscriptionReferences(query.getUser(), qb.getSqlArgs(), AnetObjectEngine.getInstance().getLocationDao().getSubscriptionUpdate(null))); @@ -60,6 +66,18 @@ protected void buildQuery(LocationSearchQuery query) { addOrderByClauses(qb, query); } + protected void addLocationUuidQuery(LocationSearchQuery query) { + if (ISearchQuery.RecurseStrategy.CHILDREN.equals(query.getLocationRecurseStrategy()) + || ISearchQuery.RecurseStrategy.PARENTS.equals(query.getLocationRecurseStrategy())) { + qb.addRecursiveClause(null, "locations", new String[] {"uuid"}, "parent_locations", + "\"locationRelationships\"", "\"childLocationUuid\"", "\"parentLocationUuid\"", + "locationUuid", query.getLocationUuid(), + ISearchQuery.RecurseStrategy.CHILDREN.equals(query.getLocationRecurseStrategy()), true); + } else { + qb.addInListClause("locationUuid", "locations.uuid", query.getLocationUuid()); + } + } + protected void addOrderByClauses(AbstractSearchQueryBuilder qb, LocationSearchQuery query) { switch (query.getSortBy()) { case CREATED_AT: diff --git a/src/main/java/mil/dds/anet/search/AbstractReportSearcher.java b/src/main/java/mil/dds/anet/search/AbstractReportSearcher.java index 7a700611c6..cb1df55e55 100644 --- a/src/main/java/mil/dds/anet/search/AbstractReportSearcher.java +++ b/src/main/java/mil/dds/anet/search/AbstractReportSearcher.java @@ -353,8 +353,9 @@ protected void addOrgUuidQuery(AbstractSearchQueryBuilder )" + " OR reports.\"interlocutorOrganizationUuid\" IN ( ))"); diff --git a/src/main/java/mil/dds/anet/search/AbstractSearchQueryBuilder.java b/src/main/java/mil/dds/anet/search/AbstractSearchQueryBuilder.java index fc37088b33..0c760f41d8 100644 --- a/src/main/java/mil/dds/anet/search/AbstractSearchQueryBuilder.java +++ b/src/main/java/mil/dds/anet/search/AbstractSearchQueryBuilder.java @@ -231,15 +231,15 @@ public final void addRecursiveClause(AbstractSearchQueryBuilder outerQb, S String foreignKey, String withTableName, String recursiveTableName, String recursiveForeignKey, String paramName, String fieldValue, boolean findChildren) { addRecursiveClause(outerQb, tableName, new String[] {foreignKey}, withTableName, - recursiveTableName, recursiveForeignKey, paramName, Collections.singletonList(fieldValue), - findChildren); + recursiveTableName, "uuid", recursiveForeignKey, paramName, + Collections.singletonList(fieldValue), findChildren, false); } public final void addRecursiveClause(AbstractSearchQueryBuilder outerQb, String tableName, String[] foreignKeys, String withTableName, String recursiveTableName, String recursiveForeignKey, String paramName, String fieldValue, boolean findChildren) { - addRecursiveClause(outerQb, tableName, foreignKeys, withTableName, recursiveTableName, - recursiveForeignKey, paramName, Collections.singletonList(fieldValue), findChildren); + addRecursiveClause(outerQb, tableName, foreignKeys, withTableName, recursiveTableName, "uuid", + recursiveForeignKey, paramName, Collections.singletonList(fieldValue), findChildren, false); } public final void addRecursiveClause(AbstractSearchQueryBuilder outerQb, String tableName, @@ -247,7 +247,8 @@ public final void addRecursiveClause(AbstractSearchQueryBuilder outerQb, S String recursiveForeignKey, String paramName, List fieldValues, boolean findChildren) { addRecursiveClause(outerQb, tableName, new String[] {foreignKey}, withTableName, - recursiveTableName, recursiveForeignKey, paramName, fieldValues, findChildren); + recursiveTableName, "uuid", recursiveForeignKey, paramName, fieldValues, findChildren, + false); } public final void addRecursiveBatchClause(AbstractSearchQueryBuilder outerQb, @@ -255,38 +256,47 @@ public final void addRecursiveBatchClause(AbstractSearchQueryBuilder outer String recursiveForeignKey, String paramName, List fieldValues, RecurseStrategy recurseStrategy) { final boolean findChildren = RecurseStrategy.CHILDREN.equals(recurseStrategy); - addRecursiveClause(outerQb, tableName, foreignKeys, withTableName, recursiveTableName, - recursiveForeignKey, paramName, fieldValues, findChildren); + addRecursiveClause(outerQb, tableName, foreignKeys, withTableName, recursiveTableName, "uuid", + recursiveForeignKey, paramName, fieldValues, findChildren, false); addSelectClause(String.format("%1$s.%2$s AS \"batchUuid\"", withTableName, findChildren ? "parent_uuid" : "uuid")); } public final void addRecursiveClause(AbstractSearchQueryBuilder outerQb, String tableName, - String[] foreignKeys, String withTableName, String recursiveTableName, - String recursiveForeignKey, String paramName, List fieldValues, - boolean findChildren) { - createWithClause(outerQb, withTableName, recursiveTableName, recursiveForeignKey, true); + String[] foreignKeys, String withTableName, String recursiveTableName, String baseKey, + String recursiveForeignKey, String paramName, List fieldValues, boolean findChildren, + boolean includeSelf) { + createWithClause(outerQb, withTableName, recursiveTableName, baseKey, recursiveForeignKey, + true); final List orClauses = new ArrayList<>(); for (final String foreignKey : foreignKeys) { orClauses.add(String.format("%1$s.%2$s = %3$s.%4$s", tableName, foreignKey, withTableName, findChildren ? "uuid" : "parent_uuid")); } - addWhereClause( + final var recursiveWhereClauses = new ArrayList<>(); + recursiveWhereClauses.add( String.format("( (%1$s) AND %2$s.%3$s IN ( <%4$s> ) )", Joiner.on(" OR ").join(orClauses), withTableName, findChildren ? "parent_uuid" : "uuid", paramName)); + if (includeSelf) { + for (final String foreignKey : foreignKeys) { + recursiveWhereClauses + .add(String.format("(%1$s.%2$s IN ( <%3$s> ))", tableName, foreignKey, paramName)); + } + } + addWhereClause(String.format("(%1$s)", Joiner.on(" OR ").join(recursiveWhereClauses))); addListArg(paramName, fieldValues); } public void createWithClause(AbstractSearchQueryBuilder outerQb, String withTableName, - String recursiveTableName, String recursiveForeignKey, boolean addFrom) { + String recursiveTableName, String baseKey, String recursiveForeignKey, boolean addFrom) { if (outerQb == null) { outerQb = this; } outerQb.addWithClause(String.format( - "%1$s(uuid, parent_uuid) AS (SELECT uuid, uuid as parent_uuid FROM %2$s UNION ALL" - + " SELECT pt.uuid, bt.%3$s FROM %2$s bt INNER JOIN" - + " %1$s pt ON bt.uuid = pt.parent_uuid)", - withTableName, recursiveTableName, recursiveForeignKey)); + "%1$s(uuid, parent_uuid) AS (SELECT %3$s AS uuid, %3$s AS parent_uuid FROM %2$s UNION ALL" + + " SELECT pt.uuid, bt.%4$s FROM %2$s bt INNER JOIN" + + " %1$s pt ON bt.%3$s = pt.parent_uuid)", + withTableName, recursiveTableName, baseKey, recursiveForeignKey)); if (addFrom) { addAdditionalFromClause(withTableName); } diff --git a/src/main/java/mil/dds/anet/search/pg/PostgresqlUserActivitySearcher.java b/src/main/java/mil/dds/anet/search/pg/PostgresqlUserActivitySearcher.java index 5eacb7426d..ab7f899f09 100644 --- a/src/main/java/mil/dds/anet/search/pg/PostgresqlUserActivitySearcher.java +++ b/src/main/java/mil/dds/anet/search/pg/PostgresqlUserActivitySearcher.java @@ -78,7 +78,8 @@ private void queryByOrganization(final UserActivitySearchQuery query, private void queryByTopLevelOrganization(final UserActivitySearchQuery query, final PostgresqlSearchQueryBuilder withQb) { - withQb.createWithClause(null, "parent_orgs", "organizations", "\"parentOrgUuid\"", false); + withQb.createWithClause(null, "parent_orgs", "organizations", "uuid", "\"parentOrgUuid\"", + false); switch (query.getAggregationType()) { case BY_OBJECT: withQb.addSelectClause( diff --git a/src/test/resources/anet.graphql b/src/test/resources/anet.graphql index a5f1a71b9f..d73791ffb0 100644 --- a/src/test/resources/anet.graphql +++ b/src/test/resources/anet.graphql @@ -417,6 +417,8 @@ input LocationInput { input LocationSearchQueryInput { emailNetwork: String inMyReports: Boolean + locationRecurseStrategy: RecurseStrategy + locationUuid: [String] pageNum: Int pageSize: Int sortBy: LocationSearchSortBy From 95dcdbaf36997c8123e71d9aae0436f7808611d5 Mon Sep 17 00:00:00 2001 From: Gertjan van Oosten Date: Mon, 27 May 2024 14:54:17 +0200 Subject: [PATCH 03/18] AB#1085 Add recursive location search for reports --- client/src/components/SearchFilters.js | 18 +++++------- .../anet/beans/search/ReportSearchQuery.java | 28 ++++++++++++++---- .../anet/search/AbstractReportSearcher.java | 29 ++++++++++++++----- .../search/pg/PostgresqlReportSearcher.java | 5 ++++ .../test/resources/ReportResourceTest.java | 5 ++-- src/test/resources/anet.graphql | 3 +- 6 files changed, 62 insertions(+), 26 deletions(-) diff --git a/client/src/components/SearchFilters.js b/client/src/components/SearchFilters.js index c65413e95a..2778fcd425 100644 --- a/client/src/components/SearchFilters.js +++ b/client/src/components/SearchFilters.js @@ -204,7 +204,6 @@ export const searchFilters = function(includeAdminFilters) { queryVars: { type: Location.LOCATION_TYPES.COUNTRY } } } - const reportLocationWidgetFilters = Location.getReportLocationFilters() const positionLocationWidgetFilters = Location.getPositionLocationFilters() const organizationLocationFilters = Location.getOrganizationLocationFilters() const classificationOptions = Object.keys(Settings.classification.choices) @@ -305,15 +304,14 @@ export const searchFilters = function(includeAdminFilters) { queryKey: "updatedAt" } }, - Location: { - component: AdvancedSelectFilter, - dictProps: Settings.fields.report.location, - deserializer: deserializeAdvancedSelectFilter, - props: Object.assign({}, advancedSelectFilterLocationProps, { - filterDefs: reportLocationWidgetFilters, - placeholder: "Filter reports by location...", - queryKey: "locationUuid" - }) + "Within Location": { + component: LocationMultiFilter, + deserializer: deserializeLocationMultiFilter, + props: { + queryKey: "locationUuid", + queryRecurseStrategyKey: "locationRecurseStrategy", + fixedRecurseStrategy: RECURSE_STRATEGY.CHILDREN + } }, State: { component: ReportStateFilter, diff --git a/src/main/java/mil/dds/anet/beans/search/ReportSearchQuery.java b/src/main/java/mil/dds/anet/beans/search/ReportSearchQuery.java index 378de1b889..a95ee1e439 100644 --- a/src/main/java/mil/dds/anet/beans/search/ReportSearchQuery.java +++ b/src/main/java/mil/dds/anet/beans/search/ReportSearchQuery.java @@ -61,7 +61,10 @@ public class ReportSearchQuery extends SubscribableObjectSearchQuery locationUuid; + @GraphQLQuery + @GraphQLInputField + private RecurseStrategy locationRecurseStrategy; @GraphQLQuery @GraphQLInputField String taskUuid; @@ -224,14 +227,22 @@ public void setOrgRecurseStrategy(RecurseStrategy orgRecurseStrategy) { this.orgRecurseStrategy = orgRecurseStrategy; } - public String getLocationUuid() { + public List getLocationUuid() { return locationUuid; } - public void setLocationUuid(String locationUuid) { + public void setLocationUuid(List locationUuid) { this.locationUuid = locationUuid; } + public RecurseStrategy getLocationRecurseStrategy() { + return locationRecurseStrategy; + } + + public void setLocationRecurseStrategy(RecurseStrategy locationRecurseStrategy) { + this.locationRecurseStrategy = locationRecurseStrategy; + } + public String getTaskUuid() { return taskUuid; } @@ -333,9 +344,10 @@ public int hashCode() { return Objects.hash(super.hashCode(), authorUuid, engagementDateStart, engagementDateEnd, engagementDayOfWeek, includeEngagementDayOfWeek, createdAtStart, createdAtEnd, updatedAtStart, updatedAtEnd, releasedAtStart, releasedAtEnd, attendeeUuid, atmosphere, - orgUuid, orgRecurseStrategy, locationUuid, taskUuid, pendingApprovalOf, state, - includeAllDrafts, engagementStatus, cancelledReason, authorPositionUuid, - attendeePositionUuid, authorizationGroupUuid, sensitiveInfo, classification, systemSearch); + orgUuid, orgRecurseStrategy, locationUuid, locationRecurseStrategy, taskUuid, + pendingApprovalOf, state, includeAllDrafts, engagementStatus, cancelledReason, + authorPositionUuid, attendeePositionUuid, authorizationGroupUuid, sensitiveInfo, + classification, systemSearch); } @Override @@ -360,6 +372,7 @@ public boolean equals(Object obj) { && Objects.equals(getOrgUuid(), other.getOrgUuid()) && Objects.equals(getOrgRecurseStrategy(), other.getOrgRecurseStrategy()) && Objects.equals(getLocationUuid(), other.getLocationUuid()) + && Objects.equals(getLocationRecurseStrategy(), other.getLocationRecurseStrategy()) && Objects.equals(getTaskUuid(), other.getTaskUuid()) && Objects.equals(getPendingApprovalOf(), other.getPendingApprovalOf()) && Objects.equals(getState(), other.getState()) @@ -389,6 +402,9 @@ public ReportSearchQuery clone() throws CloneNotSupportedException { if (orgUuid != null) { clone.setOrgUuid(new ArrayList<>(orgUuid)); } + if (locationUuid != null) { + clone.setLocationUuid(new ArrayList<>(locationUuid)); + } return clone; } diff --git a/src/main/java/mil/dds/anet/search/AbstractReportSearcher.java b/src/main/java/mil/dds/anet/search/AbstractReportSearcher.java index cb1df55e55..31ebfa34ce 100644 --- a/src/main/java/mil/dds/anet/search/AbstractReportSearcher.java +++ b/src/main/java/mil/dds/anet/search/AbstractReportSearcher.java @@ -22,6 +22,7 @@ import mil.dds.anet.beans.WithStatus; import mil.dds.anet.beans.lists.AnetBeanList; import mil.dds.anet.beans.search.AbstractBatchParams; +import mil.dds.anet.beans.search.ISearchQuery; import mil.dds.anet.beans.search.ISearchQuery.RecurseStrategy; import mil.dds.anet.beans.search.ISearchQuery.SortOrder; import mil.dds.anet.beans.search.ReportSearchQuery; @@ -186,13 +187,8 @@ protected void buildQuery(Set subFields, ReportSearchQuery query) { addOrgUuidQuery(query); } - if (query.getLocationUuid() != null) { - if (Location.DUMMY_LOCATION_UUID.equals(query.getLocationUuid())) { - qb.addWhereClause("reports.\"locationUuid\" IS NULL"); - } else { - qb.addStringEqualsClause("locationUuid", "reports.\"locationUuid\"", - query.getLocationUuid()); - } + if (!Utils.isEmptyOrNull(query.getLocationUuid())) { + addLocationUuidQuery(query); } if (query.getPendingApprovalOf() != null) { @@ -363,6 +359,25 @@ protected void addOrgUuidQuery(AbstractSearchQueryBuilder outerQb, + ReportSearchQuery query) { + if (query.getLocationUuid().size() == 1 + && Location.DUMMY_LOCATION_UUID.equals(query.getLocationUuid().get(0))) { + qb.addWhereClause("reports.\"locationUuid\" IS NULL"); + } else if (ISearchQuery.RecurseStrategy.CHILDREN.equals(query.getLocationRecurseStrategy()) + || ISearchQuery.RecurseStrategy.PARENTS.equals(query.getLocationRecurseStrategy())) { + qb.addRecursiveClause(outerQb, "reports", new String[] {"\"locationUuid\""}, + "parent_locations", "\"locationRelationships\"", "\"childLocationUuid\"", + "\"parentLocationUuid\"", "locationUuid", query.getLocationUuid(), + ISearchQuery.RecurseStrategy.CHILDREN.equals(query.getLocationRecurseStrategy()), true); + } else { + qb.addInListClause("locationUuid", "reports.\"locationUuid\"", query.getLocationUuid()); + } + } + protected void addOrderByClauses(AbstractSearchQueryBuilder qb, ReportSearchQuery query) { // Beware of the sort field names, they have to match what's in the selected fields of the inner // query! diff --git a/src/main/java/mil/dds/anet/search/pg/PostgresqlReportSearcher.java b/src/main/java/mil/dds/anet/search/pg/PostgresqlReportSearcher.java index 5cc63b569e..7f555018b0 100644 --- a/src/main/java/mil/dds/anet/search/pg/PostgresqlReportSearcher.java +++ b/src/main/java/mil/dds/anet/search/pg/PostgresqlReportSearcher.java @@ -66,6 +66,11 @@ protected void addOrgUuidQuery(ReportSearchQuery query) { addOrgUuidQuery(qb, query); } + @Override + protected void addLocationUuidQuery(ReportSearchQuery query) { + addLocationUuidQuery(qb, query); + } + @Override protected void addOrderByClauses(AbstractSearchQueryBuilder qb, ReportSearchQuery query) { if (hasTextQuery(query) && !query.isSortByPresent()) { diff --git a/src/test/java/mil/dds/anet/test/resources/ReportResourceTest.java b/src/test/java/mil/dds/anet/test/resources/ReportResourceTest.java index 0318d127b1..203798d49d 100644 --- a/src/test/java/mil/dds/anet/test/resources/ReportResourceTest.java +++ b/src/test/java/mil/dds/anet/test/resources/ReportResourceTest.java @@ -1346,8 +1346,9 @@ void searchTest() { assertThat(locSearchResults.getList()).isNotEmpty(); Location cabot = locSearchResults.getList().get(0); - final ReportSearchQueryInput query3 = ReportSearchQueryInput.builder() - .withState(List.of(ReportState.values())).withLocationUuid(cabot.getUuid()).build(); + final ReportSearchQueryInput query3 = + ReportSearchQueryInput.builder().withState(List.of(ReportState.values())) + .withLocationUuid(List.of(cabot.getUuid())).build(); searchResults = withCredentials(jackUser, t -> queryExecutor.reportList(getListFields(FIELDS), query3)); assertThat(searchResults.getList()).isNotEmpty(); diff --git a/src/test/resources/anet.graphql b/src/test/resources/anet.graphql index d73791ffb0..97a219b138 100644 --- a/src/test/resources/anet.graphql +++ b/src/test/resources/anet.graphql @@ -1067,7 +1067,8 @@ input ReportSearchQueryInput { inMyReports: Boolean includeAllDrafts: Boolean includeEngagementDayOfWeek: Boolean - locationUuid: String + locationRecurseStrategy: RecurseStrategy + locationUuid: [String] orgRecurseStrategy: RecurseStrategy orgUuid: [String] pageNum: Int From 11d764291b237ede09c0293429a936c4e20eef37 Mon Sep 17 00:00:00 2001 From: Gertjan van Oosten Date: Mon, 27 May 2024 15:16:28 +0200 Subject: [PATCH 04/18] AB#1085 Add recursive location search for organizations --- client/src/components/SearchFilters.js | 18 ++++----- insertBaseData-psql.sql | 10 ++--- .../beans/search/OrganizationSearchQuery.java | 38 +++++++++++++------ .../search/AbstractOrganizationSearcher.java | 19 +++++++++- .../resources/OrganizationResourceTest.java | 2 +- src/test/resources/anet.graphql | 3 +- 6 files changed, 59 insertions(+), 31 deletions(-) diff --git a/client/src/components/SearchFilters.js b/client/src/components/SearchFilters.js index 2778fcd425..13d8cc658a 100644 --- a/client/src/components/SearchFilters.js +++ b/client/src/components/SearchFilters.js @@ -205,7 +205,6 @@ export const searchFilters = function(includeAdminFilters) { } } const positionLocationWidgetFilters = Location.getPositionLocationFilters() - const organizationLocationFilters = Location.getOrganizationLocationFilters() const classificationOptions = Object.keys(Settings.classification.choices) const classificationLabels = Object.values(Settings.classification.choices) // Allow explicit search for "no classification" @@ -489,15 +488,14 @@ export const searchFilters = function(includeAdminFilters) { fixedRecurseStrategy: RECURSE_STRATEGY.CHILDREN } }, - Location: { - component: AdvancedSelectFilter, - dictProps: Settings.fields.organization.location, - deserializer: deserializeAdvancedSelectFilter, - props: Object.assign({}, advancedSelectFilterLocationProps, { - filterDefs: organizationLocationFilters, - placeholder: "Filter by location...", - queryKey: "locationUuid" - }) + "Within Location": { + component: LocationMultiFilter, + deserializer: deserializeLocationMultiFilter, + props: { + queryKey: "locationUuid", + queryRecurseStrategyKey: "locationRecurseStrategy", + fixedRecurseStrategy: RECURSE_STRATEGY.CHILDREN + } }, [`Has ${Settings.fields.organization.profile?.label}?`]: { component: RadioButtonFilter, diff --git a/insertBaseData-psql.sql b/insertBaseData-psql.sql index 3e7024ecf0..0b057f7471 100644 --- a/insertBaseData-psql.sql +++ b/insertBaseData-psql.sql @@ -628,13 +628,13 @@ INSERT INTO approvers ("approvalStepUuid", "positionUuid") VALUES ((SELECT uuid from "approvalSteps" where name = 'Location approval'), (SELECT uuid from positions where name = 'ANET Administrator')); -- Top-level organizations -INSERT INTO organizations (uuid, "shortName", "longName", "identificationCode", app6context, "app6standardIdentity", "app6symbolSet", "createdAt", "updatedAt") VALUES - (uuid_generate_v4(), 'MoD', 'Ministry of Defense', 'Z12345', '0', '4', '11', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - (uuid_generate_v4(), 'MoI', 'Ministry of Interior', 'P12345', '0', '4', '11', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); +INSERT INTO organizations (uuid, "shortName", "longName", "identificationCode", "locationUuid", app6context, "app6standardIdentity", "app6symbolSet", "createdAt", "updatedAt") VALUES + (uuid_generate_v4(), 'MoD', 'Ministry of Defense', 'Z12345', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Afghanistan'), '0', '4', '11', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (uuid_generate_v4(), 'MoI', 'Ministry of Interior', 'P12345', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Afghanistan'), '0', '4', '11', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); -- Sub-organizations -INSERT INTO organizations (uuid, "shortName", "longName", "parentOrgUuid", "identificationCode", "app6symbolSet", "createdAt", "updatedAt") VALUES - (uuid_generate_v4(), 'MOD-F', 'Ministry of Defense Finances', (SELECT uuid from organizations where "shortName" = 'MoD'), NULL, '11', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); +INSERT INTO organizations (uuid, "shortName", "longName", "parentOrgUuid", "identificationCode", "locationUuid", "app6symbolSet", "createdAt", "updatedAt") VALUES + (uuid_generate_v4(), 'MOD-F', 'Ministry of Defense Finances', (SELECT uuid from organizations where "shortName" = 'MoD'), NULL, (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Afghanistan'), '11', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); -- Assign responsible positions for organizations INSERT INTO "organizationAdministrativePositions" ("organizationUuid", "positionUuid") VALUES diff --git a/src/main/java/mil/dds/anet/beans/search/OrganizationSearchQuery.java b/src/main/java/mil/dds/anet/beans/search/OrganizationSearchQuery.java index a8344082bf..be282aaf9a 100644 --- a/src/main/java/mil/dds/anet/beans/search/OrganizationSearchQuery.java +++ b/src/main/java/mil/dds/anet/beans/search/OrganizationSearchQuery.java @@ -17,13 +17,15 @@ public class OrganizationSearchQuery @GraphQLQuery @GraphQLInputField private List parentOrgUuid; - // @GraphQLQuery @GraphQLInputField - String locationUuid; + private RecurseStrategy orgRecurseStrategy; @GraphQLQuery @GraphQLInputField - private RecurseStrategy orgRecurseStrategy; + private List locationUuid; + @GraphQLQuery + @GraphQLInputField + private RecurseStrategy locationRecurseStrategy; // Find organizations who (don't) have the profile filled in @GraphQLQuery @GraphQLInputField @@ -49,21 +51,28 @@ public void setParentOrgUuid(List parentOrgUuid) { this.parentOrgUuid = parentOrgUuid; } - public String getLocationUuid() { + public RecurseStrategy getOrgRecurseStrategy() { + return orgRecurseStrategy; + } + + public void setOrgRecurseStrategy(RecurseStrategy orgRecurseStrategy) { + this.orgRecurseStrategy = orgRecurseStrategy; + } + + public List getLocationUuid() { return locationUuid; } - public void setLocationUuid(String locationUuid) { + public void setLocationUuid(List locationUuid) { this.locationUuid = locationUuid; } - - public RecurseStrategy getOrgRecurseStrategy() { - return orgRecurseStrategy; + public RecurseStrategy getLocationRecurseStrategy() { + return locationRecurseStrategy; } - public void setOrgRecurseStrategy(RecurseStrategy orgRecurseStrategy) { - this.orgRecurseStrategy = orgRecurseStrategy; + public void setLocationRecurseStrategy(RecurseStrategy locationRecurseStrategy) { + this.locationRecurseStrategy = locationRecurseStrategy; } public Boolean getHasProfile() { @@ -88,8 +97,10 @@ public boolean equals(Object obj) { final OrganizationSearchQuery other = (OrganizationSearchQuery) obj; return super.equals(obj) && Objects.equals(getHasParentOrg(), other.getHasParentOrg()) && Objects.equals(getParentOrgUuid(), other.getParentOrgUuid()) - && Objects.equals(getHasProfile(), other.getHasProfile()) - && Objects.equals(getOrgRecurseStrategy(), other.getOrgRecurseStrategy()); + && Objects.equals(getOrgRecurseStrategy(), other.getOrgRecurseStrategy()) + && Objects.equals(getLocationUuid(), other.getLocationUuid()) + && Objects.equals(getLocationRecurseStrategy(), other.getLocationRecurseStrategy()) + && Objects.equals(getHasProfile(), other.getHasProfile()); } @Override @@ -98,6 +109,9 @@ public OrganizationSearchQuery clone() throws CloneNotSupportedException { if (parentOrgUuid != null) { clone.setParentOrgUuid(new ArrayList<>(parentOrgUuid)); } + if (locationUuid != null) { + clone.setLocationUuid(new ArrayList<>(locationUuid)); + } return clone; } diff --git a/src/main/java/mil/dds/anet/search/AbstractOrganizationSearcher.java b/src/main/java/mil/dds/anet/search/AbstractOrganizationSearcher.java index 49b88175fa..1dc710cf72 100644 --- a/src/main/java/mil/dds/anet/search/AbstractOrganizationSearcher.java +++ b/src/main/java/mil/dds/anet/search/AbstractOrganizationSearcher.java @@ -4,6 +4,7 @@ import mil.dds.anet.beans.Organization; import mil.dds.anet.beans.lists.AnetBeanList; import mil.dds.anet.beans.search.AbstractBatchParams; +import mil.dds.anet.beans.search.ISearchQuery; import mil.dds.anet.beans.search.ISearchQuery.RecurseStrategy; import mil.dds.anet.beans.search.ISearchQuery.SortOrder; import mil.dds.anet.beans.search.OrganizationSearchQuery; @@ -46,8 +47,6 @@ protected void buildQuery(OrganizationSearchQuery query) { } qb.addEnumEqualsClause("status", "organizations.status", query.getStatus()); - qb.addStringEqualsClause("locationUuid", "organizations.\"locationUuid\"", - query.getLocationUuid()); if (query.getHasParentOrg() != null) { if (query.getHasParentOrg()) { @@ -69,6 +68,10 @@ protected void buildQuery(OrganizationSearchQuery query) { addParentOrgUuidQuery(query); } + if (!Utils.isEmptyOrNull(query.getLocationUuid())) { + addLocationUuidQuery(query); + } + if (query.getEmailNetwork() != null) { qb.addFromClause("JOIN \"emailAddresses\" \"orgEmail\"" + " ON \"orgEmail\".\"relatedObjectType\" = '" + OrganizationDao.TABLE_NAME + "'" @@ -98,6 +101,18 @@ protected void addParentOrgUuidQuery(OrganizationSearchQuery query) { } } + protected void addLocationUuidQuery(OrganizationSearchQuery query) { + if (ISearchQuery.RecurseStrategy.CHILDREN.equals(query.getLocationRecurseStrategy()) + || ISearchQuery.RecurseStrategy.PARENTS.equals(query.getLocationRecurseStrategy())) { + qb.addRecursiveClause(null, "organizations", new String[] {"\"locationUuid\""}, + "parent_locations", "\"locationRelationships\"", "\"childLocationUuid\"", + "\"parentLocationUuid\"", "locationUuid", query.getLocationUuid(), + ISearchQuery.RecurseStrategy.CHILDREN.equals(query.getLocationRecurseStrategy()), true); + } else { + qb.addInListClause("locationUuid", "organizations.\"locationUuid\"", query.getLocationUuid()); + } + } + protected void addOrderByClauses(AbstractSearchQueryBuilder qb, OrganizationSearchQuery query) { switch (query.getSortBy()) { diff --git a/src/test/java/mil/dds/anet/test/resources/OrganizationResourceTest.java b/src/test/java/mil/dds/anet/test/resources/OrganizationResourceTest.java index 1f2313b928..500108b49b 100644 --- a/src/test/java/mil/dds/anet/test/resources/OrganizationResourceTest.java +++ b/src/test/java/mil/dds/anet/test/resources/OrganizationResourceTest.java @@ -384,7 +384,7 @@ void searchTest() { .isEqualTo(1); // Search by location - query.setLocationUuid(getGeneralHospital().getUuid()); + query.setLocationUuid(List.of(getGeneralHospital().getUuid())); orgs = withCredentials(jackUser, t -> queryExecutor.organizationList(getListFields(FIELDS), query)); assertThat(orgs.getList()).isEmpty(); // Should be empty! diff --git a/src/test/resources/anet.graphql b/src/test/resources/anet.graphql index 97a219b138..e5039a12f8 100644 --- a/src/test/resources/anet.graphql +++ b/src/test/resources/anet.graphql @@ -603,7 +603,8 @@ input OrganizationSearchQueryInput { hasParentOrg: Boolean hasProfile: Boolean inMyReports: Boolean - locationUuid: String + locationRecurseStrategy: RecurseStrategy + locationUuid: [String] orgRecurseStrategy: RecurseStrategy pageNum: Int pageSize: Int From 3cd13dae41a4c2a4cb2ee0b3263f8263c4adb73e Mon Sep 17 00:00:00 2001 From: Gertjan van Oosten Date: Mon, 27 May 2024 15:26:12 +0200 Subject: [PATCH 05/18] AB#1085 Add recursive location search for positions --- client/src/components/SearchFilters.js | 17 +++++++-------- .../beans/search/PositionSearchQuery.java | 21 ++++++++++++++++--- .../anet/search/AbstractPositionSearcher.java | 18 +++++++++++++++- src/test/resources/anet.graphql | 3 ++- 4 files changed, 45 insertions(+), 14 deletions(-) diff --git a/client/src/components/SearchFilters.js b/client/src/components/SearchFilters.js index 13d8cc658a..9f40e0239e 100644 --- a/client/src/components/SearchFilters.js +++ b/client/src/components/SearchFilters.js @@ -539,15 +539,14 @@ export const searchFilters = function(includeAdminFilters) { fixedRecurseStrategy: RECURSE_STRATEGY.CHILDREN } }, - Location: { - component: AdvancedSelectFilter, - dictProps: Settings.fields.position.location, - deserializer: deserializeAdvancedSelectFilter, - props: Object.assign({}, advancedSelectFilterLocationProps, { - filterDefs: positionLocationWidgetFilters, - placeholder: "Filter by location...", - queryKey: "locationUuid" - }) + "Within Location": { + component: LocationMultiFilter, + deserializer: deserializeLocationMultiFilter, + props: { + queryKey: "locationUuid", + queryRecurseStrategyKey: "locationRecurseStrategy", + fixedRecurseStrategy: RECURSE_STRATEGY.CHILDREN + } }, "Is Filled?": { component: RadioButtonFilter, diff --git a/src/main/java/mil/dds/anet/beans/search/PositionSearchQuery.java b/src/main/java/mil/dds/anet/beans/search/PositionSearchQuery.java index f7aaec0e93..507f8b119b 100644 --- a/src/main/java/mil/dds/anet/beans/search/PositionSearchQuery.java +++ b/src/main/java/mil/dds/anet/beans/search/PositionSearchQuery.java @@ -26,7 +26,10 @@ public class PositionSearchQuery extends SubscribableObjectSearchQuery locationUuid; + @GraphQLQuery + @GraphQLInputField + private RecurseStrategy locationRecurseStrategy; @GraphQLQuery @GraphQLInputField private String authorizationGroupUuid; @@ -82,14 +85,22 @@ public void setIsFilled(Boolean isFilled) { this.isFilled = isFilled; } - public String getLocationUuid() { + public List getLocationUuid() { return locationUuid; } - public void setLocationUuid(String locationUuid) { + public void setLocationUuid(List locationUuid) { this.locationUuid = locationUuid; } + public RecurseStrategy getLocationRecurseStrategy() { + return locationRecurseStrategy; + } + + public void setLocationRecurseStrategy(RecurseStrategy locationRecurseStrategy) { + this.locationRecurseStrategy = locationRecurseStrategy; + } + public String getAuthorizationGroupUuid() { return authorizationGroupUuid; } @@ -133,6 +144,7 @@ public boolean equals(Object obj) { && Objects.equals(getType(), other.getType()) && Objects.equals(getIsFilled(), other.getIsFilled()) && Objects.equals(getLocationUuid(), other.getLocationUuid()) + && Objects.equals(getLocationRecurseStrategy(), other.getLocationRecurseStrategy()) && Objects.equals(getAuthorizationGroupUuid(), other.getAuthorizationGroupUuid()) && Objects.equals(getHasCounterparts(), other.getHasCounterparts()) && Objects.equals(getHasPendingAssessments(), other.getHasPendingAssessments()); @@ -147,6 +159,9 @@ public PositionSearchQuery clone() throws CloneNotSupportedException { if (organizationUuid != null) { clone.setOrganizationUuid(new ArrayList<>(organizationUuid)); } + if (locationUuid != null) { + clone.setLocationUuid(new ArrayList<>(locationUuid)); + } return clone; } diff --git a/src/main/java/mil/dds/anet/search/AbstractPositionSearcher.java b/src/main/java/mil/dds/anet/search/AbstractPositionSearcher.java index d5abdf34c1..4a9d476bf1 100644 --- a/src/main/java/mil/dds/anet/search/AbstractPositionSearcher.java +++ b/src/main/java/mil/dds/anet/search/AbstractPositionSearcher.java @@ -8,6 +8,7 @@ import mil.dds.anet.beans.Position; import mil.dds.anet.beans.lists.AnetBeanList; import mil.dds.anet.beans.search.AbstractBatchParams; +import mil.dds.anet.beans.search.ISearchQuery; import mil.dds.anet.beans.search.ISearchQuery.RecurseStrategy; import mil.dds.anet.beans.search.ISearchQuery.SortOrder; import mil.dds.anet.beans.search.PositionSearchQuery; @@ -100,7 +101,10 @@ protected void buildQuery(PositionSearchQuery query) { } } - qb.addStringEqualsClause("locationUuid", "positions.\"locationUuid\"", query.getLocationUuid()); + if (!Utils.isEmptyOrNull(query.getLocationUuid())) { + addLocationUuidQuery(query); + } + qb.addEnumEqualsClause("status", "positions.status", query.getStatus()); if (query.getAuthorizationGroupUuid() != null) { @@ -133,6 +137,18 @@ protected void buildQuery(PositionSearchQuery query) { addOrderByClauses(qb, query); } + protected void addLocationUuidQuery(PositionSearchQuery query) { + if (ISearchQuery.RecurseStrategy.CHILDREN.equals(query.getLocationRecurseStrategy()) + || ISearchQuery.RecurseStrategy.PARENTS.equals(query.getLocationRecurseStrategy())) { + qb.addRecursiveClause(null, "positions", new String[] {"\"locationUuid\""}, + "parent_locations", "\"locationRelationships\"", "\"childLocationUuid\"", + "\"parentLocationUuid\"", "locationUuid", query.getLocationUuid(), + ISearchQuery.RecurseStrategy.CHILDREN.equals(query.getLocationRecurseStrategy()), true); + } else { + qb.addInListClause("locationUuid", "positions.\"locationUuid\"", query.getLocationUuid()); + } + } + @SuppressWarnings("unchecked") protected void addBatchClause(PositionSearchQuery query) { qb.addBatchClause((AbstractBatchParams) query.getBatchParams()); diff --git a/src/test/resources/anet.graphql b/src/test/resources/anet.graphql index e5039a12f8..125ad7b85b 100644 --- a/src/test/resources/anet.graphql +++ b/src/test/resources/anet.graphql @@ -799,7 +799,8 @@ input PositionSearchQueryInput { hasPendingAssessments: Boolean inMyReports: Boolean isFilled: Boolean - locationUuid: String + locationRecurseStrategy: RecurseStrategy + locationUuid: [String] matchPersonName: Boolean orgRecurseStrategy: RecurseStrategy organizationUuid: [String] From c2fc91ece4b80725747efefcec99854674b472f2 Mon Sep 17 00:00:00 2001 From: Gertjan van Oosten Date: Mon, 27 May 2024 15:43:44 +0200 Subject: [PATCH 06/18] AB#1085 Add recursive location search for people --- client/src/components/SearchFilters.js | 27 ++++++------------- .../anet/beans/search/PersonSearchQuery.java | 20 +++++++++++--- .../anet/search/AbstractPersonSearcher.java | 19 +++++++++++-- src/test/resources/anet.graphql | 3 ++- 4 files changed, 44 insertions(+), 25 deletions(-) diff --git a/client/src/components/SearchFilters.js b/client/src/components/SearchFilters.js index 9f40e0239e..e3fd1ffaba 100644 --- a/client/src/components/SearchFilters.js +++ b/client/src/components/SearchFilters.js @@ -32,7 +32,6 @@ import TaskFilter, { } from "components/advancedSearch/TaskFilter" import { CountryOverlayRow, - LocationOverlayRow, PersonDetailedOverlayRow, PositionOverlayRow, TaskOverlayRow @@ -46,7 +45,6 @@ import _pickBy from "lodash/pickBy" import { Location, Person, Position, Report, Task } from "models" import PropTypes from "prop-types" import React, { useContext } from "react" -import LOCATIONS_ICON from "resources/locations.png" import PEOPLE_ICON from "resources/people.png" import POSITIONS_ICON from "resources/positions.png" import TASKS_ICON from "resources/tasks.png" @@ -143,14 +141,6 @@ const advancedSelectFilterPositionProps = { fields: Position.autocompleteQuery, addon: POSITIONS_ICON } -const advancedSelectFilterLocationProps = { - overlayColumns: ["Name"], - overlayRenderRow: LocationOverlayRow, - objectType: Location, - valueKey: "name", - fields: Location.autocompleteQuery, - addon: LOCATIONS_ICON -} const advancedSelectFilterTaskProps = { overlayColumns: ["Name"], overlayRenderRow: TaskOverlayRow, @@ -204,7 +194,6 @@ export const searchFilters = function(includeAdminFilters) { queryVars: { type: Location.LOCATION_TYPES.COUNTRY } } } - const positionLocationWidgetFilters = Location.getPositionLocationFilters() const classificationOptions = Object.keys(Settings.classification.choices) const classificationLabels = Object.values(Settings.classification.choices) // Allow explicit search for "no classification" @@ -406,14 +395,14 @@ export const searchFilters = function(includeAdminFilters) { fixedRecurseStrategy: RECURSE_STRATEGY.CHILDREN } }, - Location: { - component: AdvancedSelectFilter, - deserializer: deserializeAdvancedSelectFilter, - props: Object.assign({}, advancedSelectFilterLocationProps, { - filterDefs: positionLocationWidgetFilters, - placeholder: "Filter by location...", - queryKey: "locationUuid" - }) + "Within Location": { + component: LocationMultiFilter, + deserializer: deserializeLocationMultiFilter, + props: { + queryKey: "locationUuid", + queryRecurseStrategyKey: "locationRecurseStrategy", + fixedRecurseStrategy: RECURSE_STRATEGY.CHILDREN + } }, Rank: { component: SelectFilter, diff --git a/src/main/java/mil/dds/anet/beans/search/PersonSearchQuery.java b/src/main/java/mil/dds/anet/beans/search/PersonSearchQuery.java index 2401a1c9be..450da876ad 100644 --- a/src/main/java/mil/dds/anet/beans/search/PersonSearchQuery.java +++ b/src/main/java/mil/dds/anet/beans/search/PersonSearchQuery.java @@ -31,7 +31,10 @@ public class PersonSearchQuery extends SubscribableObjectSearchQuery locationUuid; + @GraphQLQuery + @GraphQLInputField + private RecurseStrategy locationRecurseStrategy; // Also match on positions whose name or code matches text. @GraphQLQuery @@ -91,14 +94,22 @@ public void setCountryUuid(String countryUuid) { this.countryUuid = countryUuid; } - public String getLocationUuid() { + public List getLocationUuid() { return locationUuid; } - public void setLocationUuid(String locationUuid) { + public void setLocationUuid(List locationUuid) { this.locationUuid = locationUuid; } + public RecurseStrategy getLocationRecurseStrategy() { + return locationRecurseStrategy; + } + + public void setLocationRecurseStrategy(RecurseStrategy locationRecurseStrategy) { + this.locationRecurseStrategy = locationRecurseStrategy; + } + public boolean getMatchPositionName() { return Boolean.TRUE.equals(matchPositionName); } @@ -153,6 +164,9 @@ public PersonSearchQuery clone() throws CloneNotSupportedException { if (orgUuid != null) { clone.setOrgUuid(new ArrayList<>(orgUuid)); } + if (locationUuid != null) { + clone.setLocationUuid(new ArrayList<>(locationUuid)); + } return clone; } diff --git a/src/main/java/mil/dds/anet/search/AbstractPersonSearcher.java b/src/main/java/mil/dds/anet/search/AbstractPersonSearcher.java index 317439902c..9ad09301e0 100644 --- a/src/main/java/mil/dds/anet/search/AbstractPersonSearcher.java +++ b/src/main/java/mil/dds/anet/search/AbstractPersonSearcher.java @@ -6,6 +6,7 @@ import mil.dds.anet.AnetObjectEngine; import mil.dds.anet.beans.Person; import mil.dds.anet.beans.lists.AnetBeanList; +import mil.dds.anet.beans.search.ISearchQuery; import mil.dds.anet.beans.search.ISearchQuery.RecurseStrategy; import mil.dds.anet.beans.search.ISearchQuery.SortOrder; import mil.dds.anet.beans.search.PersonSearchQuery; @@ -48,7 +49,7 @@ protected void buildQuery(Set subFields, PersonSearchQuery query) { qb.addSelectClause(getTableFields(subFields)); qb.addFromClause("people"); - if (!Utils.isEmptyOrNull(query.getOrgUuid()) || query.getLocationUuid() != null + if (!Utils.isEmptyOrNull(query.getOrgUuid()) || !Utils.isEmptyOrNull(query.getLocationUuid()) || query.getMatchPositionName() || !Utils.isEmptyOrNull(query.getPositionType())) { qb.addFromClause("LEFT JOIN positions ON people.uuid = positions.\"currentPersonUuid\""); } @@ -82,7 +83,9 @@ protected void buildQuery(Set subFields, PersonSearchQuery query) { } } - qb.addStringEqualsClause("locationUuid", "positions.\"locationUuid\"", query.getLocationUuid()); + if (!Utils.isEmptyOrNull(query.getLocationUuid())) { + addLocationUuidQuery(query); + } if (query.getHasBiography() != null) { if (query.getHasBiography()) { @@ -120,6 +123,18 @@ protected void buildQuery(Set subFields, PersonSearchQuery query) { addOrderByClauses(qb, query); } + protected void addLocationUuidQuery(PersonSearchQuery query) { + if (ISearchQuery.RecurseStrategy.CHILDREN.equals(query.getLocationRecurseStrategy()) + || ISearchQuery.RecurseStrategy.PARENTS.equals(query.getLocationRecurseStrategy())) { + qb.addRecursiveClause(null, "positions", new String[] {"\"locationUuid\""}, + "parent_locations", "\"locationRelationships\"", "\"childLocationUuid\"", + "\"parentLocationUuid\"", "locationUuid", query.getLocationUuid(), + ISearchQuery.RecurseStrategy.CHILDREN.equals(query.getLocationRecurseStrategy()), true); + } else { + qb.addInListClause("locationUuid", "positions.\"locationUuid\"", query.getLocationUuid()); + } + } + protected void addOrderByClauses(AbstractSearchQueryBuilder qb, PersonSearchQuery query) { switch (query.getSortBy()) { case CREATED_AT: diff --git a/src/test/resources/anet.graphql b/src/test/resources/anet.graphql index 125ad7b85b..e6e2bedde5 100644 --- a/src/test/resources/anet.graphql +++ b/src/test/resources/anet.graphql @@ -711,7 +711,8 @@ input PersonSearchQueryInput { endOfTourDateStart: Instant hasBiography: Boolean inMyReports: Boolean - locationUuid: String + locationRecurseStrategy: RecurseStrategy + locationUuid: [String] matchPositionName: Boolean orgRecurseStrategy: RecurseStrategy orgUuid: [String] From a610a8061d48cf07ac055f23e61f357cd76d70b8 Mon Sep 17 00:00:00 2001 From: Gertjan van Oosten Date: Mon, 27 May 2024 15:46:40 +0200 Subject: [PATCH 07/18] Replace triple-dots in strings with proper ellipsis character --- .../AdvisorReports/OrganizationAdvisorsTable.js | 2 +- client/src/components/AdvisorReports/Toolbar.js | 2 +- .../components/EditAssociatedPositionsModal.js | 2 +- .../src/components/PlanningConflictForPerson.js | 2 +- client/src/components/SearchFilters.js | 16 ++++++++-------- .../components/advancedSearch/LocationFilter.js | 2 +- .../advancedSearch/OrganizationFilter.js | 2 +- .../src/components/advancedSearch/TaskFilter.js | 2 +- .../components/approvals/ApprovalsDefinition.js | 2 +- client/src/index-auth.js | 2 +- client/src/pages/organizations/Form.js | 2 +- client/src/pages/reports/Form.js | 6 +++--- client/tests/sim/Simulator.js | 8 ++++---- client/tests/util/test.js | 2 +- .../webdriver/baseSpecs/createNewPerson.spec.js | 4 ++-- .../webdriver/baseSpecs/myCounterparts.spec.js | 2 +- client/tests/webdriver/baseSpecs/myTasks.spec.js | 2 +- .../baseSpecs/previewRollupEmail.spec.js | 2 +- .../webdriver/customFieldsSpecs/myOrg.spec.js | 2 +- .../customFieldsSpecs/notifications.spec.js | 2 +- .../noCustomFieldsSpecs/anetObjectForms.spec.js | 2 +- 21 files changed, 34 insertions(+), 34 deletions(-) diff --git a/client/src/components/AdvisorReports/OrganizationAdvisorsTable.js b/client/src/components/AdvisorReports/OrganizationAdvisorsTable.js index 712e736866..c2aa5f5c02 100644 --- a/client/src/components/AdvisorReports/OrganizationAdvisorsTable.js +++ b/client/src/components/AdvisorReports/OrganizationAdvisorsTable.js @@ -105,7 +105,7 @@ const OrganizationAdvisorsTable = ({ function filterRows(rows, filterText) { const nothingFound = ( - No organizations found... + No organizations found… ) const filterResult = rows.filter(element => { diff --git a/client/src/components/AdvisorReports/Toolbar.js b/client/src/components/AdvisorReports/Toolbar.js index 9545606078..e70ccce955 100644 --- a/client/src/components/AdvisorReports/Toolbar.js +++ b/client/src/components/AdvisorReports/Toolbar.js @@ -13,7 +13,7 @@ const Toolbar = ({ onFilterTextInput, onExportButtonClick }) => ( className="form-control" id="advisorSearch" type="text" - placeholder="Search organizations..." + placeholder="Search organizations…" onChange={e => onFilterTextInput(e.target.value)} /> diff --git a/client/src/components/EditAssociatedPositionsModal.js b/client/src/components/EditAssociatedPositionsModal.js index d92685930f..7b2293f009 100644 --- a/client/src/components/EditAssociatedPositionsModal.js +++ b/client/src/components/EditAssociatedPositionsModal.js @@ -117,7 +117,7 @@ const EditAssociatedPositionsModal = ({ widget={ { if (loading) { return ( - + ) diff --git a/client/src/components/SearchFilters.js b/client/src/components/SearchFilters.js index e3fd1ffaba..52c77d5f7f 100644 --- a/client/src/components/SearchFilters.js +++ b/client/src/components/SearchFilters.js @@ -214,7 +214,7 @@ export const searchFilters = function(includeAdminFilters) { deserializer: deserializeAdvancedSelectFilter, props: Object.assign({}, advancedSelectFilterPersonProps, { filterDefs: authorWidgetFilters, - placeholder: "Filter reports by author...", + placeholder: "Filter reports by author…", queryKey: "authorUuid" }) }, @@ -223,7 +223,7 @@ export const searchFilters = function(includeAdminFilters) { deserializer: deserializeAdvancedSelectFilter, props: Object.assign({}, advancedSelectFilterPersonProps, { filterDefs: attendeeWidgetFilters, - placeholder: "Filter reports by attendee...", + placeholder: "Filter reports by attendee…", queryKey: "attendeeUuid" }) }, @@ -232,7 +232,7 @@ export const searchFilters = function(includeAdminFilters) { deserializer: deserializeAdvancedSelectFilter, props: Object.assign({}, advancedSelectFilterPersonProps, { filterDefs: pendingApprovalOfWidgetFilters, - placeholder: "Filter reports pending approval of...", + placeholder: "Filter reports pending approval of…", queryKey: "pendingApprovalOf" }) }, @@ -241,7 +241,7 @@ export const searchFilters = function(includeAdminFilters) { deserializer: deserializeAdvancedSelectFilter, props: Object.assign({}, advancedSelectFilterPositionProps, { filterDefs: authorPositionWidgetFilters, - placeholder: "Filter reports by author position...", + placeholder: "Filter reports by author position…", queryKey: "authorPositionUuid" }) }, @@ -250,7 +250,7 @@ export const searchFilters = function(includeAdminFilters) { deserializer: deserializeAdvancedSelectFilter, props: Object.assign({}, advancedSelectFilterPositionProps, { filterDefs: attendeePositionWidgetFilters, - placeholder: "Filter reports by attendee position...", + placeholder: "Filter reports by attendee position…", queryKey: "attendeePositionUuid" }) }, @@ -376,7 +376,7 @@ export const searchFilters = function(includeAdminFilters) { deserializer: deserializeAdvancedSelectFilter, props: Object.assign({}, advancedSelectFilterTaskProps, { filterDefs: taskWidgetFilters, - placeholder: `Filter reports by ${taskShortLabel}...`, + placeholder: `Filter reports by ${taskShortLabel}…`, queryKey: "taskUuid" }) } @@ -420,7 +420,7 @@ export const searchFilters = function(includeAdminFilters) { deserializer: deserializeAdvancedSelectFilter, props: Object.assign({}, advancedSelectFilterCountryProps, { filterDefs: countryWidgetFilters, - placeholder: "Filter by country...", + placeholder: "Filter by country…", queryKey: "countryUuid" }) }, @@ -668,7 +668,7 @@ export const searchFilters = function(includeAdminFilters) { props: { ...advancedSelectFilterPersonProps, filterDefs: authorWidgetFilters, - placeholder: "Filter attachments by owner...", + placeholder: "Filter attachments by owner…", queryKey: "authorUuid" } } diff --git a/client/src/components/advancedSearch/LocationFilter.js b/client/src/components/advancedSearch/LocationFilter.js index 63e3736720..7fe6762ff2 100644 --- a/client/src/components/advancedSearch/LocationFilter.js +++ b/client/src/components/advancedSearch/LocationFilter.js @@ -86,7 +86,7 @@ const LocationFilter = ({ objectType={Location} valueKey={valueKey} fields={Location.autocompleteQuery} - placeholder="Filter by location..." + placeholder="Filter by location…" addon={LOCATIONS_ICON} onChange={handleChangeLoc} value={value.value} diff --git a/client/src/components/advancedSearch/OrganizationFilter.js b/client/src/components/advancedSearch/OrganizationFilter.js index 2448a4d9e8..109fc840bb 100644 --- a/client/src/components/advancedSearch/OrganizationFilter.js +++ b/client/src/components/advancedSearch/OrganizationFilter.js @@ -90,7 +90,7 @@ const OrganizationFilter = ({ objectType={Organization} valueKey={valueKey} fields={Organization.autocompleteQuery} - placeholder="Filter by organization..." + placeholder="Filter by organization…" addon={ORGANIZATIONS_ICON} onChange={handleChangeOrg} value={value.value} diff --git a/client/src/components/advancedSearch/TaskFilter.js b/client/src/components/advancedSearch/TaskFilter.js index 6f9671041e..c74caefeee 100644 --- a/client/src/components/advancedSearch/TaskFilter.js +++ b/client/src/components/advancedSearch/TaskFilter.js @@ -78,7 +78,7 @@ const TaskFilter = ({ valueFunc={(v, k) => getBreadcrumbTrailAsText(v, v?.ascendantTasks, parentKey, k)} fields={Task.autocompleteQuery} - placeholder="Filter by task..." + placeholder="Filter by task…" addon={TASKS_ICON} onChange={handleChangeTask} value={value.value} diff --git a/client/src/components/approvals/ApprovalsDefinition.js b/client/src/components/approvals/ApprovalsDefinition.js index f251fa0f22..ad6796c31e 100644 --- a/client/src/components/approvals/ApprovalsDefinition.js +++ b/client/src/components/approvals/ApprovalsDefinition.js @@ -198,7 +198,7 @@ const ApprovalsDefinition = ({ widget={ } overlayColumns={["Name", "Position"]} diff --git a/client/src/index-auth.js b/client/src/index-auth.js index eaa36b62c3..762d4df60d 100644 --- a/client/src/index-auth.js +++ b/client/src/index-auth.js @@ -17,7 +17,7 @@ import { initOptions, keycloak } from "keycloak" try { const authenticated = await keycloak.init({ onLoad: initOptions.onLoad }) if (!authenticated) { - console.info("Keycloak client not authenticated, reloading page...") + console.info("Keycloak client not authenticated, reloading page…") window.location.reload() return } diff --git a/client/src/pages/organizations/Form.js b/client/src/pages/organizations/Form.js index 5a2a186326..da4a03dfe9 100644 --- a/client/src/pages/organizations/Form.js +++ b/client/src/pages/organizations/Form.js @@ -544,7 +544,7 @@ const OrganizationForm = ({ edit, title, initialValues, notesComponent }) => { fieldName="tasks" placeholder={`Search for ${pluralize( Settings.fields.task.shortLabel - )}...`} + )}…`} value={values.tasks} renderSelected={ { if (!defaultScenario) { console.log( colors.red( - "No scenario name given, and no default scenario found. Aborting..." + "No scenario name given, and no default scenario found. Aborting…" ) ) return } console.log( - colors.yellow("No scenario name given, using default scenario...") + colors.yellow("No scenario name given, using default scenario…") ) scenario = defaultScenario } else { @@ -56,13 +56,13 @@ const simulate = async args => { colors.red( `Scenario with name ${givenScenarioName} not found; possible scenarios are: ${Object.keys( scenarioMapping - )}. Aborting...` + )}. Aborting…` ) ) return } console.log( - colors.green(`Reading from scenario with name "${givenScenarioName}"...`) + colors.green(`Reading from scenario with name "${givenScenarioName}"…`) ) scenario = givenScenario } diff --git a/client/tests/util/test.js b/client/tests/util/test.js index 05fba7321e..66e845fb41 100644 --- a/client/tests/util/test.js +++ b/client/tests/util/test.js @@ -205,7 +205,7 @@ test.beforeEach(t => { // For debugging purposes. t.context.waitForever = async() => { - console.log("Waiting forever so you can debug...") + console.log("Waiting forever so you can debug…") await t.context.driver.wait(() => {}) } diff --git a/client/tests/webdriver/baseSpecs/createNewPerson.spec.js b/client/tests/webdriver/baseSpecs/createNewPerson.spec.js index 9a76c8c3c9..f962306f1a 100644 --- a/client/tests/webdriver/baseSpecs/createNewPerson.spec.js +++ b/client/tests/webdriver/baseSpecs/createNewPerson.spec.js @@ -24,13 +24,13 @@ const SIMILAR_PERSON_ADVISOR = { describe("Create new Person form page", () => { describe("When creating a non-user", () => { - beforeEach("On the create person page...", async() => { + beforeEach("On the create person page…", async() => { await CreatePerson.openAsSuperuser() await (await CreatePerson.getForm()).waitForExist() await (await CreatePerson.getForm()).waitForDisplayed() }) - afterEach("On the create person page...", async() => { + afterEach("On the create person page…", async() => { await CreatePerson.logout() }) diff --git a/client/tests/webdriver/baseSpecs/myCounterparts.spec.js b/client/tests/webdriver/baseSpecs/myCounterparts.spec.js index 8fdacac67b..c8cac637c2 100644 --- a/client/tests/webdriver/baseSpecs/myCounterparts.spec.js +++ b/client/tests/webdriver/baseSpecs/myCounterparts.spec.js @@ -25,7 +25,7 @@ describe("Home page", () => { }) describe("My counterparts page", () => { - afterEach("On the my counterparts page...", async() => { + afterEach("On the my counterparts page…", async() => { await MyCounterparts.logout() }) diff --git a/client/tests/webdriver/baseSpecs/myTasks.spec.js b/client/tests/webdriver/baseSpecs/myTasks.spec.js index c002d5ffeb..782f5a3951 100644 --- a/client/tests/webdriver/baseSpecs/myTasks.spec.js +++ b/client/tests/webdriver/baseSpecs/myTasks.spec.js @@ -28,7 +28,7 @@ describe("My tasks page", () => { await MyTasks.open() }) - afterEach("On the my tasks page...", async() => { + afterEach("On the my tasks page…", async() => { await MyTasks.logout() }) diff --git a/client/tests/webdriver/baseSpecs/previewRollupEmail.spec.js b/client/tests/webdriver/baseSpecs/previewRollupEmail.spec.js index b95d71ec11..3f851ffad5 100644 --- a/client/tests/webdriver/baseSpecs/previewRollupEmail.spec.js +++ b/client/tests/webdriver/baseSpecs/previewRollupEmail.spec.js @@ -7,7 +7,7 @@ describe("Preview rollup page", () => { await (await Rollup.getRollup()).waitForDisplayed() }) - afterEach("On the rollup page...", async() => { + afterEach("On the rollup page…", async() => { await Rollup.logout() }) diff --git a/client/tests/webdriver/customFieldsSpecs/myOrg.spec.js b/client/tests/webdriver/customFieldsSpecs/myOrg.spec.js index ea6d3e6603..31da8ab22d 100644 --- a/client/tests/webdriver/customFieldsSpecs/myOrg.spec.js +++ b/client/tests/webdriver/customFieldsSpecs/myOrg.spec.js @@ -13,7 +13,7 @@ describe("My Organization page", () => { await MyOrg.openAsAdminUser(myOrgUrl) }) - afterEach("On the My Organization page...", async() => { + afterEach("On the My Organization page…", async() => { await MyOrg.logout() }) diff --git a/client/tests/webdriver/customFieldsSpecs/notifications.spec.js b/client/tests/webdriver/customFieldsSpecs/notifications.spec.js index 7ad742bdad..379e17c271 100644 --- a/client/tests/webdriver/customFieldsSpecs/notifications.spec.js +++ b/client/tests/webdriver/customFieldsSpecs/notifications.spec.js @@ -4,7 +4,7 @@ import Home from "../pages/home.page" describe("Home page", () => { describe("When checking the notification numbers", () => { - afterEach("Should logout...", async() => { + afterEach("Should logout…", async() => { await Home.logout() }) diff --git a/client/tests/webdriver/noCustomFieldsSpecs/anetObjectForms.spec.js b/client/tests/webdriver/noCustomFieldsSpecs/anetObjectForms.spec.js index 321874d27e..2c28cea905 100644 --- a/client/tests/webdriver/noCustomFieldsSpecs/anetObjectForms.spec.js +++ b/client/tests/webdriver/noCustomFieldsSpecs/anetObjectForms.spec.js @@ -10,7 +10,7 @@ import CreateNewLocation from "../pages/location/createNewLocation.page" // Forms should work just fine without custom fields describe("When looking at anet object forms with dictionary that doesn't include custom fields", () => { - afterEach("On the form page...", async() => { + afterEach("On the form page…", async() => { await CreateReport.logout() }) From 66f46bbd5ad760fd7c23ea52747d64205102f357 Mon Sep 17 00:00:00 2001 From: Gertjan van Oosten Date: Mon, 27 May 2024 16:51:30 +0200 Subject: [PATCH 08/18] AB#1085 Allow any response size during testing --- .../java/mil/dds/anet/test/GraphQLPluginConfiguration.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/test/java/mil/dds/anet/test/GraphQLPluginConfiguration.java b/src/test/java/mil/dds/anet/test/GraphQLPluginConfiguration.java index 4c911ed010..10df4a6aa9 100644 --- a/src/test/java/mil/dds/anet/test/GraphQLPluginConfiguration.java +++ b/src/test/java/mil/dds/anet/test/GraphQLPluginConfiguration.java @@ -18,6 +18,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ExchangeStrategies; import org.springframework.web.reactive.function.client.WebClient; /** @@ -85,6 +86,11 @@ WebClient webClient(String graphqlEndpoint, AuthenticationInjector authenticatio return WebClient.builder().baseUrl(graphqlEndpoint) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .defaultUriVariables(Map.of("url", graphqlEndpoint)) + .exchangeStrategies(ExchangeStrategies.builder() + // Some GraphQL responses are larger than the allowed default of 256KB + // (e.g. the one for introspection used in one of the tests) + // Override this to allow unlimited responses: + .codecs(codecs -> codecs.defaultCodecs().maxInMemorySize(-1)).build()) .filter((request, next) -> next.exchange( ClientRequest.from(request).headers(authenticationInjector::setBasicAuth).build())) .build(); From b2c40e0b2ddfcb25249af4d921d10b3b07064636 Mon Sep 17 00:00:00 2001 From: Gertjan van Oosten Date: Tue, 28 May 2024 09:33:52 +0200 Subject: [PATCH 09/18] AB#1085 Improve message when no locations/organizations are selected in the search filter --- .../src/components/EditOrganizationsAdministratedModal.js | 1 + client/src/components/LocationTable.js | 8 +++++++- client/src/components/OrganizationTable.js | 8 +++++++- client/src/components/advancedSearch/LocationFilter.js | 8 +++++++- .../src/components/advancedSearch/OrganizationFilter.js | 6 +++++- client/src/pages/locations/Form.js | 1 + client/src/pages/positions/Show.js | 1 + client/src/pages/tasks/Form.js | 1 + 8 files changed, 30 insertions(+), 4 deletions(-) diff --git a/client/src/components/EditOrganizationsAdministratedModal.js b/client/src/components/EditOrganizationsAdministratedModal.js index b4d5a388c0..69029aabac 100644 --- a/client/src/components/EditOrganizationsAdministratedModal.js +++ b/client/src/components/EditOrganizationsAdministratedModal.js @@ -91,6 +91,7 @@ const EditOrganizationsAdministratedModal = ({ organizations={ values.organizationsAdministrated || [] } + noOrganizationsMessage="No organizations selected" showDelete /> } diff --git a/client/src/components/LocationTable.js b/client/src/components/LocationTable.js index c2d8e4a63a..f1dd240bed 100644 --- a/client/src/components/LocationTable.js +++ b/client/src/components/LocationTable.js @@ -92,13 +92,14 @@ const BaseLocationTable = ({ showDelete, onDelete, locations, + noLocationsMessage, pageSize, pageNum, totalCount, goToPage }) => { if (_get(locations, "length", 0) === 0) { - return No locations found + return {noLocationsMessage} } return ( @@ -149,6 +150,7 @@ BaseLocationTable.propTypes = { onDelete: PropTypes.func, // list of locations: locations: PropTypes.array.isRequired, + noLocationsMessage: PropTypes.string, // fill these when pagination wanted: totalCount: PropTypes.number, pageNum: PropTypes.number, @@ -156,4 +158,8 @@ BaseLocationTable.propTypes = { goToPage: PropTypes.func } +BaseLocationTable.defaultProps = { + noLocationsMessage: "No locations found" +} + export default connect(null, mapPageDispatchersToProps)(LocationTable) diff --git a/client/src/components/OrganizationTable.js b/client/src/components/OrganizationTable.js index 5e848c0107..fa91af3a6f 100644 --- a/client/src/components/OrganizationTable.js +++ b/client/src/components/OrganizationTable.js @@ -95,6 +95,7 @@ const BaseOrganizationTable = ({ showDelete, onDelete, organizations, + noOrganizationsMessage, pageSize, pageNum, totalCount, @@ -107,7 +108,7 @@ const BaseOrganizationTable = ({ toggleSelection }) => { if (_get(organizations, "length", 0) === 0) { - return No organizations found + return {noOrganizationsMessage} } return ( @@ -190,6 +191,7 @@ BaseOrganizationTable.propTypes = { onDelete: PropTypes.func, // list of organizations: organizations: PropTypes.array, + noOrganizationsMessage: PropTypes.string, // fill these when pagination wanted: totalCount: PropTypes.number, pageNum: PropTypes.number, @@ -204,4 +206,8 @@ BaseOrganizationTable.propTypes = { toggleSelection: PropTypes.func } +BaseOrganizationTable.defaultProps = { + noOrganizationsMessage: "No organizations found" +} + export default connect(null, mapPageDispatchersToProps)(OrganizationTable) diff --git a/client/src/components/advancedSearch/LocationFilter.js b/client/src/components/advancedSearch/LocationFilter.js index 7fe6762ff2..ea6f36ff77 100644 --- a/client/src/components/advancedSearch/LocationFilter.js +++ b/client/src/components/advancedSearch/LocationFilter.js @@ -90,7 +90,13 @@ const LocationFilter = ({ addon={LOCATIONS_ICON} onChange={handleChangeLoc} value={value.value} - renderSelected={} + renderSelected={ + + } /> ) diff --git a/client/src/components/advancedSearch/OrganizationFilter.js b/client/src/components/advancedSearch/OrganizationFilter.js index 109fc840bb..137e3c4a97 100644 --- a/client/src/components/advancedSearch/OrganizationFilter.js +++ b/client/src/components/advancedSearch/OrganizationFilter.js @@ -95,7 +95,11 @@ const OrganizationFilter = ({ onChange={handleChangeOrg} value={value.value} renderSelected={ - + } /> ) diff --git a/client/src/pages/locations/Form.js b/client/src/pages/locations/Form.js index 76e1fa68db..04bac1e8c2 100644 --- a/client/src/pages/locations/Form.js +++ b/client/src/pages/locations/Form.js @@ -321,6 +321,7 @@ const LocationForm = ({ } diff --git a/client/src/pages/positions/Show.js b/client/src/pages/positions/Show.js index 4feff19df1..05f77483af 100644 --- a/client/src/pages/positions/Show.js +++ b/client/src/pages/positions/Show.js @@ -432,6 +432,7 @@ const PositionShow = ({ pageDispatchers }) => { > { renderSelected={ } From f0f40b32e9b445aa739aecd03b142e0129a066ed Mon Sep 17 00:00:00 2001 From: Gertjan van Oosten Date: Tue, 28 May 2024 11:31:46 +0200 Subject: [PATCH 10/18] AB#1085 Refactor loop detection code Make code DRY'er and easier to re-use. Extend the loop detection tests. --- .../java/mil/dds/anet/AnetObjectEngine.java | 28 ++++-- .../java/mil/dds/anet/database/ReportDao.java | 2 +- .../mil/dds/anet/emails/DailyRollupEmail.java | 2 +- .../anet/resources/OrganizationResource.java | 10 +- .../mil/dds/anet/resources/TaskResource.java | 10 +- src/main/java/mil/dds/anet/utils/Utils.java | 98 +++++++++---------- .../resources/OrganizationResourceTest.java | 38 +++++++ .../anet/test/resources/TaskResourceTest.java | 35 +++++-- 8 files changed, 146 insertions(+), 77 deletions(-) diff --git a/src/main/java/mil/dds/anet/AnetObjectEngine.java b/src/main/java/mil/dds/anet/AnetObjectEngine.java index cec6afe148..ef0b8bdae9 100644 --- a/src/main/java/mil/dds/anet/AnetObjectEngine.java +++ b/src/main/java/mil/dds/anet/AnetObjectEngine.java @@ -3,7 +3,6 @@ import com.codahale.metrics.MetricRegistry; import com.google.inject.Injector; import io.dropwizard.core.Application; -import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; @@ -367,31 +366,40 @@ public Map buildTopLevelOrgHash() { orgQuery.setPageSize(0); List orgs = getOrganizationDao().search(orgQuery).getList(); - return Utils.buildParentOrgMapping(orgs, null); + return Utils.buildOrgToParentOrgMapping(orgs, null); } /** * Helper function to build a map of organization UUIDs to their top parent capped at a certain - * point in the hierarchy. parentOrg will map to parentOrg, and all children will map to the - * highest parent that is NOT the parentOrgUuid. + * point in the hierarchy. The parentOrgUuid will map to parentOrg, and all children will map to + * the highest parent that is NOT the parentOrgUuid. */ - public Map buildTopLevelOrgHash(String parentOrgUuid) { + public Map buildTopLevelOrgHash(String parentOrgUuid) { final OrganizationSearchQuery query = new OrganizationSearchQuery(); - query.setParentOrgUuid(Collections.singletonList(parentOrgUuid)); + query.setParentOrgUuid(List.of(parentOrgUuid)); query.setOrgRecurseStrategy(RecurseStrategy.CHILDREN); query.setPageSize(0); final List orgList = orgDao.search(query).getList(); return Utils.buildParentOrgMapping(orgList, parentOrgUuid); } + public Map buildTopLevelOrgToOrgHash(String parentOrgUuid) { + final OrganizationSearchQuery query = new OrganizationSearchQuery(); + query.setParentOrgUuid(List.of(parentOrgUuid)); + query.setOrgRecurseStrategy(RecurseStrategy.CHILDREN); + query.setPageSize(0); + final List orgList = orgDao.search(query).getList(); + return Utils.buildOrgToParentOrgMapping(orgList, parentOrgUuid); + } + /** * Helper function to build a map of task UUIDs to their top parent capped at a certain point in - * the hierarchy. parentTask will map to parentTask, and all children will map to the highest - * parent that is NOT the parentTaskUuid. + * the hierarchy. The parentTaskUuid will map to parentTask, and all children will map to the + * highest parent that is NOT the parentTaskUuid. */ - public Map buildTopLevelTaskHash(String parentTaskUuid) { + public Map buildTopLevelTaskHash(String parentTaskUuid) { final TaskSearchQuery query = new TaskSearchQuery(); - query.setParentTaskUuid(Collections.singletonList(parentTaskUuid)); + query.setParentTaskUuid(List.of(parentTaskUuid)); query.setParentTaskRecurseStrategy(RecurseStrategy.CHILDREN); query.setPageSize(0); final List taskList = taskDao.search(query).getList(); diff --git a/src/main/java/mil/dds/anet/database/ReportDao.java b/src/main/java/mil/dds/anet/database/ReportDao.java index d185e7c598..c7aff2d16b 100644 --- a/src/main/java/mil/dds/anet/database/ReportDao.java +++ b/src/main/java/mil/dds/anet/database/ReportDao.java @@ -453,7 +453,7 @@ public List getDailyRollupGraph(Instant start, Instant end, String throw new WebApplicationException("No such organization with uuid " + parentOrgUuid, Status.NOT_FOUND); } - orgMap = Utils.buildParentOrgMapping(orgList, parentOrgUuid); + orgMap = Utils.buildOrgToParentOrgMapping(orgList, parentOrgUuid); } else { orgMap = new HashMap<>(); // guaranteed to match no orgs! } diff --git a/src/main/java/mil/dds/anet/emails/DailyRollupEmail.java b/src/main/java/mil/dds/anet/emails/DailyRollupEmail.java index 4940f18177..045ca879bb 100644 --- a/src/main/java/mil/dds/anet/emails/DailyRollupEmail.java +++ b/src/main/java/mil/dds/anet/emails/DailyRollupEmail.java @@ -136,7 +136,7 @@ public List getByGrouping(RollupGraphType orgType) { public List getGroupingForParent(String parentOrgUuid, RollupGraphType orgType) { final Map orgUuidToTopOrg = - AnetObjectEngine.getInstance().buildTopLevelOrgHash(parentOrgUuid); + AnetObjectEngine.getInstance().buildTopLevelOrgToOrgHash(parentOrgUuid); return groupReports(orgUuidToTopOrg, orgType); } diff --git a/src/main/java/mil/dds/anet/resources/OrganizationResource.java b/src/main/java/mil/dds/anet/resources/OrganizationResource.java index 5b5b02cc5a..591e5580fd 100644 --- a/src/main/java/mil/dds/anet/resources/OrganizationResource.java +++ b/src/main/java/mil/dds/anet/resources/OrganizationResource.java @@ -135,10 +135,12 @@ public Integer updateOrganization(@GraphQLRootContext Map contex final Organization existing = dao.getByUuid(org.getUuid()); // Check for loops in the hierarchy - final Map children = - AnetObjectEngine.getInstance().buildTopLevelOrgHash(DaoUtils.getUuid(org)); - if (org.getParentOrgUuid() != null && children.containsKey(org.getParentOrgUuid())) { - throw new WebApplicationException("Organization can not be its own (grand…)parent"); + if (org.getParentOrgUuid() != null) { + final Map children = + AnetObjectEngine.getInstance().buildTopLevelOrgHash(DaoUtils.getUuid(org)); + if (children.containsKey(org.getParentOrgUuid())) { + throw new WebApplicationException("Organization can not be its own (grand…)parent"); + } } if (!AuthUtils.isAdmin(user)) { diff --git a/src/main/java/mil/dds/anet/resources/TaskResource.java b/src/main/java/mil/dds/anet/resources/TaskResource.java index 777bfbf57f..680e93b609 100644 --- a/src/main/java/mil/dds/anet/resources/TaskResource.java +++ b/src/main/java/mil/dds/anet/resources/TaskResource.java @@ -105,10 +105,12 @@ public Integer updateTask(@GraphQLRootContext Map context, } // Check for loops in the hierarchy - final Map children = - AnetObjectEngine.getInstance().buildTopLevelTaskHash(DaoUtils.getUuid(t)); - if (t.getParentTaskUuid() != null && children.containsKey(t.getParentTaskUuid())) { - throw new WebApplicationException("Task can not be its own (grand…)parent"); + if (t.getParentTaskUuid() != null) { + final Map children = + AnetObjectEngine.getInstance().buildTopLevelTaskHash(DaoUtils.getUuid(t)); + if (children.containsKey(t.getParentTaskUuid())) { + throw new WebApplicationException("Task can not be its own (grand…)parent"); + } } try { diff --git a/src/main/java/mil/dds/anet/utils/Utils.java b/src/main/java/mil/dds/anet/utils/Utils.java index c1942f1177..b356ab98b0 100644 --- a/src/main/java/mil/dds/anet/utils/Utils.java +++ b/src/main/java/mil/dds/anet/utils/Utils.java @@ -146,72 +146,72 @@ public static T orIfNull(T value, T ifNull) { * loops, or to generate graphs/tables that bubble things up to their highest parent. This is used * in the daily rollup graphs. */ - public static Map buildParentOrgMapping(List orgs, + public static Map buildParentOrgMapping(List orgs, @Nullable String topParentUuid) { - final Map result = new HashMap<>(); - final Map orgMap = new HashMap<>(); - - for (final Organization o : orgs) { - orgMap.put(o.getUuid(), o); - } - - for (final Organization o : orgs) { - final Set seenUuids = new HashSet<>(); - String curr = o.getUuid(); - seenUuids.add(curr); - String parentUuid = o.getParentOrgUuid(); - while (!Objects.equals(parentUuid, topParentUuid) && orgMap.containsKey(parentUuid)) { - curr = parentUuid; - if (seenUuids.contains(curr)) { - final String errorMsg = String.format( - "Loop detected in organization hierarchy: %1$s is its own (grand…)parent!", curr); - logger.error(errorMsg); - throw new IllegalArgumentException(errorMsg); - } - seenUuids.add(curr); - parentUuid = orgMap.get(parentUuid).getParentOrgUuid(); - } - result.put(o.getUuid(), orgMap.get(curr)); - } + // Can't use Collectors.toMap as parent may be null + final Map orgMap = orgs.stream().collect(HashMap::new, + (m, v) -> m.put(v.getUuid(), v.getParentOrgUuid()), HashMap::putAll); + return buildParentMapping(orgMap, topParentUuid); + } - return result; + public static Map buildOrgToParentOrgMapping(List orgs, + @Nullable String topParentUuid) { + final Map orgToOrgMap = + orgs.stream().collect(Collectors.toMap(Organization::getUuid, Function.identity())); + // Can't use Collectors.toMap as parent may be null + final Map orgMap = orgs.stream().collect(HashMap::new, + (m, v) -> m.put(v.getUuid(), v.getParentOrgUuid()), HashMap::putAll); + final Map orgParentMap = buildParentMapping(orgMap, topParentUuid); + // Can't use Collectors.toMap as parent may be null + return orgs.stream().collect(HashMap::new, + (m, v) -> m.put(v.getUuid(), orgToOrgMap.get(orgParentMap.get(v.getUuid()))), + HashMap::putAll); } /** * Given a list of tasks and a topParentUuid, this function maps all of the tasks to their highest * parent within this list excluding the topParent. This can be used to check for loops. */ - public static Map buildParentTaskMapping(List tasks, + public static Map buildParentTaskMapping(List tasks, @Nullable String topParentUuid) { - final Map result = new HashMap<>(); - final Map taskMap = new HashMap<>(); + // Can't use Collectors.toMap as parent may be null + final Map taskMap = tasks.stream().collect(HashMap::new, + (m, v) -> m.put(v.getUuid(), v.getParentTaskUuid()), HashMap::putAll); + return buildParentMapping(taskMap, topParentUuid); + } - for (final Task t : tasks) { - taskMap.put(t.getUuid(), t); - } + private static Map buildParentMapping(Map uuidToParentUuidMap, + @Nullable String topParentUuid) { + final Map result = new HashMap<>(); - for (final Task t : tasks) { + for (final Map.Entry e : uuidToParentUuidMap.entrySet()) { final Set seenUuids = new HashSet<>(); - String curr = t.getUuid(); - seenUuids.add(curr); - String parentUuid = t.getParentTaskUuid(); - while (!Objects.equals(parentUuid, topParentUuid) && taskMap.containsKey(parentUuid)) { - curr = parentUuid; - if (seenUuids.contains(curr)) { - final String errorMsg = String - .format("Loop detected in task hierarchy: %1$s is its own (grand…)parent!", curr); - logger.error(errorMsg); - throw new IllegalArgumentException(errorMsg); - } - seenUuids.add(curr); - parentUuid = taskMap.get(parentUuid).getParentTaskUuid(); - } - result.put(t.getUuid(), taskMap.get(curr)); + seenUuids.add(e.getKey()); + final String topLevelParent = + uuidToParentUuidMap.get(recursivelyDetermineParent(uuidToParentUuidMap, topParentUuid, + e.getKey(), e.getValue(), seenUuids)); + result.put(e.getKey(), topLevelParent); } return result; } + private static String recursivelyDetermineParent(Map uuidToParentUuidMap, + @Nullable String topParentUuid, String uuid, String parentUuid, Set seenUuids) { + if (!Objects.equals(parentUuid, topParentUuid) && uuidToParentUuidMap.containsKey(parentUuid)) { + if (seenUuids.contains(parentUuid)) { + final String errorMsg = String + .format("Loop detected in hierarchy: %1$s is its own (grand…)parent!", parentUuid); + logger.error(errorMsg); + throw new IllegalArgumentException(errorMsg); + } + seenUuids.add(parentUuid); + return recursivelyDetermineParent(uuidToParentUuidMap, topParentUuid, parentUuid, + uuidToParentUuidMap.get(parentUuid), seenUuids); + } + return uuid; + } + public static final PolicyFactory HTML_POLICY_DEFINITION = new HtmlPolicyBuilder() .allowStandardUrlProtocols() // Allow ANET links like "urn:anet:people:uuid" diff --git a/src/test/java/mil/dds/anet/test/resources/OrganizationResourceTest.java b/src/test/java/mil/dds/anet/test/resources/OrganizationResourceTest.java index 500108b49b..941ce0c688 100644 --- a/src/test/java/mil/dds/anet/test/resources/OrganizationResourceTest.java +++ b/src/test/java/mil/dds/anet/test/resources/OrganizationResourceTest.java @@ -531,6 +531,44 @@ void changeParentOrganizationAsSuperuserTest() { succeedUpdateOrganization(superuser.getDomainUsername(), getOrganizationInput(createdChildOrg)); } + @Test + void illegalParentOrganizationTest() { + final String testTopOrgUuid = "9a35caa7-a095-4963-ac7b-b784fde4d583"; // EF 1 + final Organization topOrg = + withCredentials(adminUser, t -> queryExecutor.organization(FIELDS, testTopOrgUuid)); + assertThat(topOrg).isNotNull(); + assertThat(topOrg.getUuid()).isEqualTo(testTopOrgUuid); + + final String testSubOrgUuid = "04614b0f-7e8e-4bf1-8bc5-13abaffeab8a"; // EF 1.1 + final Organization subOrg = + withCredentials(adminUser, t -> queryExecutor.organization(FIELDS, testSubOrgUuid)); + assertThat(subOrg).isNotNull(); + assertThat(subOrg.getUuid()).isEqualTo(testSubOrgUuid); + + // Set self as parent + final OrganizationInput topOrgInput = getOrganizationInput(topOrg); + final OrganizationInput parentTopOrgInput = getOrganizationInput(topOrg); + topOrgInput.setParentOrg(parentTopOrgInput); + try { + // Should fail, as it would create a loop + withCredentials(adminUser, t -> mutationExecutor.updateOrganization("", topOrgInput)); + fail("Expected an Exception"); + } catch (Exception expectedException) { + // OK + } + + // Set subOrg as parent + final OrganizationInput parentSubOrgInput = getOrganizationInput(subOrg); + topOrgInput.setParentOrg(parentSubOrgInput); + try { + // Should fail, as it would create a loop + withCredentials(adminUser, t -> mutationExecutor.updateOrganization("", topOrgInput)); + fail("Expected an Exception"); + } catch (Exception expectedException) { + // OK + } + } + @Test void organizationCreateRegularUserPermissionTest() { final OrganizationInput orgInput = diff --git a/src/test/java/mil/dds/anet/test/resources/TaskResourceTest.java b/src/test/java/mil/dds/anet/test/resources/TaskResourceTest.java index 12ad5f5845..fb6827e4cc 100644 --- a/src/test/java/mil/dds/anet/test/resources/TaskResourceTest.java +++ b/src/test/java/mil/dds/anet/test/resources/TaskResourceTest.java @@ -281,17 +281,36 @@ void duplicateTaskTest() { @Test void illegalParentTaskTest() { - final String testTopTaskUuid = "cd35abe7-a5c9-4b3e-885b-4c72bf564ed7"; - final Task task = withCredentials(adminUser, t -> queryExecutor.task(FIELDS, testTopTaskUuid)); - assertThat(task).isNotNull(); - assertThat(task.getUuid()).isEqualTo(testTopTaskUuid); + final String testTopTaskUuid = "cd35abe7-a5c9-4b3e-885b-4c72bf564ed7"; // EF 1 + final Task topTask = + withCredentials(adminUser, t -> queryExecutor.task(FIELDS, testTopTaskUuid)); + assertThat(topTask).isNotNull(); + assertThat(topTask.getUuid()).isEqualTo(testTopTaskUuid); + + final String testSubTaskUuid = "cd35abe7-a5c9-4b3e-885b-4c72bf564ed7"; // 1.1 + final Task subTask = + withCredentials(adminUser, t -> queryExecutor.task(FIELDS, testSubTaskUuid)); + assertThat(subTask).isNotNull(); + assertThat(subTask.getUuid()).isEqualTo(testSubTaskUuid); + // Set self as parent - final TaskInput taskInput = getTaskInput(task); - final TaskInput parentTaskInput = getTaskInput(task); - taskInput.setParentTask(parentTaskInput); + final TaskInput topTaskInput = getTaskInput(topTask); + final TaskInput parentTopTaskInput = getTaskInput(topTask); + topTaskInput.setParentTask(parentTopTaskInput); + try { + // Should fail, as it would create a loop + withCredentials(adminUser, t -> mutationExecutor.updateTask("", topTaskInput)); + fail("Expected an Exception"); + } catch (Exception expectedException) { + // OK + } + + // Set subTask as parent + final TaskInput parentSubTaskInput = getTaskInput(subTask); + topTaskInput.setParentTask(parentSubTaskInput); try { // Should fail, as it would create a loop - withCredentials(adminUser, t -> mutationExecutor.updateTask("", taskInput)); + withCredentials(adminUser, t -> mutationExecutor.updateTask("", topTaskInput)); fail("Expected an Exception"); } catch (Exception expectedException) { // OK From 08a2bfafdf25ad8aa93f8309e74746234ab51fe7 Mon Sep 17 00:00:00 2001 From: Gertjan van Oosten Date: Tue, 28 May 2024 15:45:08 +0200 Subject: [PATCH 11/18] AB#1085 Add loop detection when updating locations --- .../java/mil/dds/anet/AnetObjectEngine.java | 17 ++ .../dds/anet/resources/LocationResource.java | 15 ++ src/main/java/mil/dds/anet/utils/Utils.java | 67 ++++++++ .../test/resources/LocationResourceTest.java | 147 +++++++++++++++++- 4 files changed, 245 insertions(+), 1 deletion(-) diff --git a/src/main/java/mil/dds/anet/AnetObjectEngine.java b/src/main/java/mil/dds/anet/AnetObjectEngine.java index ef0b8bdae9..86041a64da 100644 --- a/src/main/java/mil/dds/anet/AnetObjectEngine.java +++ b/src/main/java/mil/dds/anet/AnetObjectEngine.java @@ -13,11 +13,13 @@ import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; import mil.dds.anet.beans.ApprovalStep; +import mil.dds.anet.beans.Location; import mil.dds.anet.beans.Organization; import mil.dds.anet.beans.Person; import mil.dds.anet.beans.Position; import mil.dds.anet.beans.Task; import mil.dds.anet.beans.search.ISearchQuery.RecurseStrategy; +import mil.dds.anet.beans.search.LocationSearchQuery; import mil.dds.anet.beans.search.OrganizationSearchQuery; import mil.dds.anet.beans.search.TaskSearchQuery; import mil.dds.anet.config.AnetConfiguration; @@ -406,6 +408,21 @@ public Map buildTopLevelTaskHash(String parentTaskUuid) { return Utils.buildParentTaskMapping(taskList, parentTaskUuid); } + /** + * Helper function to build a map of location UUIDs to their top parent capped at a certain point + * in the hierarchy. The locationUuid will map to parentLocation, and all children will map to the + * highest parent that is NOT the locationUuid. + */ + public Map> buildLocationHash(String locationUuid, boolean findChildren) { + final LocationSearchQuery query = new LocationSearchQuery(); + query.setLocationUuid(List.of(locationUuid)); + query.setLocationRecurseStrategy( + findChildren ? RecurseStrategy.CHILDREN : RecurseStrategy.PARENTS); + query.setPageSize(0); + final List locationList = locationDao.search(query).getList(); + return Utils.buildParentLocationMapping(locationList, locationUuid); + } + public static AnetObjectEngine getInstance() { return instance; } diff --git a/src/main/java/mil/dds/anet/resources/LocationResource.java b/src/main/java/mil/dds/anet/resources/LocationResource.java index 02d20913f8..5db17c3a7b 100644 --- a/src/main/java/mil/dds/anet/resources/LocationResource.java +++ b/src/main/java/mil/dds/anet/resources/LocationResource.java @@ -8,6 +8,8 @@ import jakarta.ws.rs.core.Response.Status; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; import mil.dds.anet.AnetObjectEngine; import mil.dds.anet.beans.ApprovalStep; import mil.dds.anet.beans.Location; @@ -119,6 +121,19 @@ public Integer updateLocation(@GraphQLRootContext Map context, Utils.isEmptyHtml(l.getDescription()) ? null : Utils.sanitizeHtml(l.getDescription())); final Person user = DaoUtils.getUserFromContext(context); assertPermission(user, DaoUtils.getUuid(l)); + + // Check for loops in the hierarchy + if (!Utils.isEmptyOrNull(l.getParentLocations())) { + final Set parentLocationUuids = + l.getParentLocations().stream().map(Location::getUuid).collect(Collectors.toSet()); + final Map> children = + AnetObjectEngine.getInstance().buildLocationHash(DaoUtils.getUuid(l), true); + children.keySet().retainAll(parentLocationUuids); + if (!children.isEmpty()) { + throw new WebApplicationException("Location can not be its own (grand…)parent"); + } + } + final int numRows = dao.update(l); if (numRows == 0) { throw new WebApplicationException("Couldn't process location update", Status.NOT_FOUND); diff --git a/src/main/java/mil/dds/anet/utils/Utils.java b/src/main/java/mil/dds/anet/utils/Utils.java index b356ab98b0..78bf57cdbf 100644 --- a/src/main/java/mil/dds/anet/utils/Utils.java +++ b/src/main/java/mil/dds/anet/utils/Utils.java @@ -37,6 +37,7 @@ import mil.dds.anet.AnetObjectEngine; import mil.dds.anet.beans.ApprovalStep; import mil.dds.anet.beans.ApprovalStep.ApprovalStepType; +import mil.dds.anet.beans.Location; import mil.dds.anet.beans.Organization; import mil.dds.anet.beans.Task; import mil.dds.anet.database.ApprovalStepDao; @@ -212,6 +213,72 @@ private static String recursivelyDetermineParent(Map uuidToParen return uuid; } + /** + * Given a list of locations and a topParentUuid, this function maps all of the locations to their + * highest parent within this list excluding the topParent. This can be used to check for loops. + */ + public static Map> buildParentLocationMapping(List locations, + @Nullable String topParentUuid) { + // Can't use Collectors.toMap as parents may be null + final Map> locationMap = + locations.stream().collect(HashMap::new, (m, v) -> { + if (Utils.isEmptyOrNull(v.getParentLocations())) { + m.put(v.getUuid(), null); + } else { + m.put(v.getUuid(), + v.getParentLocations().stream().map(Location::getUuid).collect(Collectors.toSet())); + } + }, HashMap::putAll); + return buildParentsMapping(locationMap, topParentUuid); + } + + private static Map> buildParentsMapping( + Map> uuidToParentUuidsMap, @Nullable String topParentUuid) { + final Map> result = new HashMap<>(); + + for (final Map.Entry> e : uuidToParentUuidsMap.entrySet()) { + final Set seenUuids = new HashSet<>(); + seenUuids.add(e.getKey()); + final Set topLevelParents = recursivelyDetermineParentsFromSet(uuidToParentUuidsMap, + topParentUuid, e.getKey(), e.getValue(), seenUuids).stream() + .map(uuidToParentUuidsMap::get).flatMap(Collection::stream).collect(Collectors.toSet()); + result.put(e.getKey(), topLevelParents); + } + + return result; + } + + private static Set recursivelyDetermineParentsFromSet( + Map> uuidToParentUuidsMap, @Nullable String topParentUuid, String uuid, + Set parentUuids, Set seenUuids) { + if (Utils.isEmptyOrNull(parentUuids)) { + return Set.of(); + } else { + return parentUuids.stream() + .map(parentUuid -> recursivelyDetermineParentsFromUuid(uuidToParentUuidsMap, + topParentUuid, uuid, parentUuid, seenUuids)) + .flatMap(Collection::stream).collect(Collectors.toSet()); + } + } + + private static Set recursivelyDetermineParentsFromUuid( + Map> uuidToParentUuidsMap, @Nullable String topParentUuid, String uuid, + String parentUuid, Set seenUuids) { + if (!Objects.equals(parentUuid, topParentUuid) + && uuidToParentUuidsMap.containsKey(parentUuid)) { + if (seenUuids.contains(parentUuid)) { + final String errorMsg = String + .format("Loop detected in hierarchy: %1$s is its own (grand…)parent!", parentUuid); + logger.error(errorMsg); + throw new IllegalArgumentException(errorMsg); + } + seenUuids.add(parentUuid); + return recursivelyDetermineParentsFromSet(uuidToParentUuidsMap, topParentUuid, parentUuid, + uuidToParentUuidsMap.get(parentUuid), seenUuids); + } + return Set.of(uuid); + } + public static final PolicyFactory HTML_POLICY_DEFINITION = new HtmlPolicyBuilder() .allowStandardUrlProtocols() // Allow ANET links like "urn:anet:people:uuid" diff --git a/src/test/java/mil/dds/anet/test/resources/LocationResourceTest.java b/src/test/java/mil/dds/anet/test/resources/LocationResourceTest.java index 4165016526..4680c939c7 100644 --- a/src/test/java/mil/dds/anet/test/resources/LocationResourceTest.java +++ b/src/test/java/mil/dds/anet/test/resources/LocationResourceTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.fail; import java.util.HashMap; +import java.util.List; import java.util.Map; import mil.dds.anet.config.AnetConfiguration; import mil.dds.anet.test.TestData; @@ -11,6 +12,7 @@ import mil.dds.anet.test.client.Location; import mil.dds.anet.test.client.LocationInput; import mil.dds.anet.test.client.LocationSearchQueryInput; +import mil.dds.anet.test.client.LocationType; import mil.dds.anet.test.client.Person; import mil.dds.anet.test.client.Position; import mil.dds.anet.test.client.PositionType; @@ -19,7 +21,10 @@ public class LocationResourceTest extends AbstractResourceTest { - public static final String FIELDS = "{ uuid name type description status lat lng customFields }"; + public static final String _LOCATION_FIELDS = + "uuid name type description status lat lng customFields"; + public static final String FIELDS = String + .format("{ %1$s parentLocations { %1$s } childrenLocations { %1$s } }", _LOCATION_FIELDS); @Test void locationTestGraphQL() { @@ -155,4 +160,144 @@ void shouldBeSearchableViaCustomFields() { assertThat(searchObjects.getList()).allSatisfy( searchResult -> assertThat(searchResult.getCustomFields()).contains(searchText)); } + + @Test + void illegalParentLocationTest() { + final LocationSearchQueryInput query = LocationSearchQueryInput.builder() + .withType(LocationType.COUNTRY).withText("Canada").build(); + final AnetBeanList_Location searchObjects = + withCredentials(adminUser, t -> queryExecutor.locationList(getListFields(FIELDS), query)); + assertThat(searchObjects).isNotNull(); + assertThat(searchObjects.getList()).isNotEmpty(); + final Location topLoc = searchObjects.getList().get(0); + + final LocationSearchQueryInput query2 = LocationSearchQueryInput.builder() + .withType(LocationType.COUNTRY).withText("Australia").build(); + final AnetBeanList_Location searchObjects2 = + withCredentials(adminUser, t -> queryExecutor.locationList(getListFields(FIELDS), query2)); + assertThat(searchObjects2).isNotNull(); + assertThat(searchObjects2.getList()).isNotEmpty(); + final Location topLoc2 = searchObjects2.getList().get(0); + + final String testSubLocUuid = "0855fb0a-995e-4a79-a132-4024ee2983ff"; // General Hospital + final Location subLoc = + withCredentials(adminUser, t -> queryExecutor.location(FIELDS, testSubLocUuid)); + assertThat(subLoc).isNotNull(); + assertThat(subLoc.getUuid()).isEqualTo(testSubLocUuid); + + // Set self, topLoc2 as parents + final LocationInput topLocInput = getLocationInput(topLoc); + topLocInput.setParentLocations(List.of(getLocationInput(topLoc), getLocationInput(topLoc2))); + try { + // Should fail, as it would create a loop + withCredentials(adminUser, t -> mutationExecutor.updateLocation("", topLocInput)); + fail("Expected an Exception"); + } catch (Exception expectedException) { + // OK + } + + // Set topLoc2, self as parents + topLocInput.setParentLocations(List.of(getLocationInput(topLoc2), getLocationInput(topLoc))); + try { + // Should fail, as it would create a loop + withCredentials(adminUser, t -> mutationExecutor.updateLocation("", topLocInput)); + fail("Expected an Exception"); + } catch (Exception expectedException) { + // OK + } + + // Set subLoc, topLoc2 as parents + topLocInput.setParentLocations(List.of(getLocationInput(subLoc), getLocationInput(topLoc2))); + try { + // Should fail, as it would create a loop + withCredentials(adminUser, t -> mutationExecutor.updateLocation("", topLocInput)); + fail("Expected an Exception"); + } catch (Exception expectedException) { + // OK + } + + // Set topLoc2, subLoc as parents + topLocInput.setParentLocations(List.of(getLocationInput(topLoc2), getLocationInput(subLoc))); + try { + // Should fail, as it would create a loop + withCredentials(adminUser, t -> mutationExecutor.updateLocation("", topLocInput)); + fail("Expected an Exception"); + } catch (Exception expectedException) { + // OK + } + } + + @Test + void validParentLocationTest() { + final LocationSearchQueryInput query = LocationSearchQueryInput.builder() + .withType(LocationType.COUNTRY).withText("Antarctica").build(); + final AnetBeanList_Location searchObjects = + withCredentials(adminUser, t -> queryExecutor.locationList(getListFields(FIELDS), query)); + assertThat(searchObjects).isNotNull(); + assertThat(searchObjects.getList()).isNotEmpty(); + final Location topLoc = searchObjects.getList().get(0); + + final LocationSearchQueryInput query2 = LocationSearchQueryInput.builder() + .withType(LocationType.COUNTRY).withText("Australia").build(); + final AnetBeanList_Location searchObjects2 = + withCredentials(adminUser, t -> queryExecutor.locationList(getListFields(FIELDS), query2)); + assertThat(searchObjects2).isNotNull(); + assertThat(searchObjects2.getList()).isNotEmpty(); + final Location topLoc2 = searchObjects2.getList().get(0); + + final LocationSearchQueryInput query3 = LocationSearchQueryInput.builder() + .withType(LocationType.COUNTRY).withText("New Zealand").build(); + final AnetBeanList_Location searchObjects3 = + withCredentials(adminUser, t -> queryExecutor.locationList(getListFields(FIELDS), query3)); + assertThat(searchObjects3).isNotNull(); + assertThat(searchObjects3.getList()).isNotEmpty(); + final Location topLoc3 = searchObjects3.getList().get(0); + + final String testSubLocUuid = "e5b3a4b9-acf7-4c79-8224-f248b9a7215d"; // Antarctica + final Location subLoc = + withCredentials(adminUser, t -> queryExecutor.location(FIELDS, testSubLocUuid)); + assertThat(subLoc).isNotNull(); + assertThat(subLoc.getUuid()).isEqualTo(testSubLocUuid); + + // Set topLoc, topLoc2, topLoc3 as parents (where topLoc is already a parent) + final LocationInput subLocInput = getLocationInput(subLoc); + subLocInput.setParentLocations( + List.of(getLocationInput(topLoc), getLocationInput(topLoc2), getLocationInput(topLoc3))); + final Integer nrResults = + withCredentials(adminUser, t -> mutationExecutor.updateLocation("", subLocInput)); + assertThat(nrResults).isOne(); + + final Location updatedSubLoc = + withCredentials(adminUser, t -> queryExecutor.location(FIELDS, subLoc.getUuid())); + assertThat(updatedSubLoc).isNotNull(); + final List parentLocationUuids = + subLocInput.getParentLocations().stream().map(LocationInput::getUuid).toList(); + final List updatedParentLocationUuids = + updatedSubLoc.getParentLocations().stream().map(Location::getUuid).toList(); + assertThat(updatedParentLocationUuids).hasSameElementsAs(parentLocationUuids); + + // Remove all parents + subLocInput.setParentLocations(List.of()); + final Integer nrResults2 = + withCredentials(adminUser, t -> mutationExecutor.updateLocation("", subLocInput)); + assertThat(nrResults2).isOne(); + + final Location updatedSubLoc2 = + withCredentials(adminUser, t -> queryExecutor.location(FIELDS, subLoc.getUuid())); + assertThat(updatedSubLoc2.getParentLocations()).isEmpty(); + + // Restore original parent + subLocInput.setParentLocations(List.of(getLocationInput(topLoc))); + final Integer nrResults3 = + withCredentials(adminUser, t -> mutationExecutor.updateLocation("", subLocInput)); + assertThat(nrResults3).isOne(); + + final Location updatedSubLoc3 = + withCredentials(adminUser, t -> queryExecutor.location(FIELDS, subLoc.getUuid())); + final List parentLocationUuids3 = + subLocInput.getParentLocations().stream().map(LocationInput::getUuid).toList(); + final List updatedParentLocationUuids3 = + updatedSubLoc3.getParentLocations().stream().map(Location::getUuid).toList(); + assertThat(updatedParentLocationUuids3).hasSameElementsAs(parentLocationUuids3); + } } From a1aaade7cde1ddb6cde3aadac86b5e5f255198d4 Mon Sep 17 00:00:00 2001 From: Gertjan van Oosten Date: Wed, 29 May 2024 09:34:21 +0200 Subject: [PATCH 12/18] AB#1085 Add parentLocations to location merge --- .../src/pages/admin/merge/MergeLocations.js | 39 +++++++++++++++++++ .../mil/dds/anet/database/LocationDao.java | 15 +++++++ 2 files changed, 54 insertions(+) diff --git a/client/src/pages/admin/merge/MergeLocations.js b/client/src/pages/admin/merge/MergeLocations.js index 4239c0641b..1548b47d9f 100644 --- a/client/src/pages/admin/merge/MergeLocations.js +++ b/client/src/pages/admin/merge/MergeLocations.js @@ -9,6 +9,7 @@ import ApprovalSteps from "components/ApprovalSteps" import { customFieldsJSONString } from "components/CustomFields" import DictionaryField from "components/DictionaryField" import BaseGeoLocation from "components/GeoLocation" +import LocationTable from "components/LocationTable" import MergeField from "components/MergeField" import Messages from "components/Messages" import { @@ -216,6 +217,23 @@ const MergeLocations = ({ pageDispatchers }) => { mergeState={mergeState} dispatchMergeActions={dispatchMergeActions} /> + + } + align={ALIGN_OPTIONS.CENTER} + action={getClearButton(() => + dispatchMergeActions( + setAMergedField("parentLocations", [], null) + ) + )} + mergeState={mergeState} + dispatchMergeActions={dispatchMergeActions} + /> + } + align={align} + action={getActionButton( + () => + dispatchMergeActions( + setAMergedField( + "parentLocations", + location.parentLocations, + align + ) + ), + align, + mergeState, + "parentLocations" + )} + mergeState={mergeState} + dispatchMergeActions={dispatchMergeActions} + /> search(LocationSearchQuery query) { public int mergeLocations(Location loserLocation, Location winnerLocation) { final String loserLocationUuid = loserLocation.getUuid(); final String winnerLocationUuid = winnerLocation.getUuid(); + final Location existingWinnerLoc = getByUuid(winnerLocationUuid); + final Map context = AnetObjectEngine.getInstance().getContext(); // Update location update(winnerLocation); @@ -105,6 +108,18 @@ public int mergeLocations(Location loserLocation, Location winnerLocation) { updateM2mForMerge("attachmentRelatedObjects", "attachmentUuid", "relatedObjectUuid", winnerLocationUuid, loserLocationUuid); + // Update parentLocations: + // - delete locationRelationships where loser was the child + deleteForMerge("locationRelationships", "childLocationUuid", loserLocationUuid); + // - update the winner's parents from the input + Utils.addRemoveElementsByUuid(existingWinnerLoc.loadParentLocations(context).join(), + Utils.orIfNull(winnerLocation.getParentLocations(), List.of()), + newOrg -> addLocationRelationship(newOrg, winnerLocation), + oldOrg -> removeLocationRelationship(oldOrg, winnerLocation)); + // - update the loser's children to the winner + updateForMerge("locationRelationships", "parentLocationUuid", winnerLocationUuid, + loserLocationUuid); + // Update customSensitiveInformation for winner DaoUtils.saveCustomSensitiveInformation(null, LocationDao.TABLE_NAME, winnerLocationUuid, winnerLocation.getCustomSensitiveInformation()); From 1866b022847e65c863f9dfbda5cf70248e0d9acc Mon Sep 17 00:00:00 2001 From: Gertjan van Oosten Date: Wed, 29 May 2024 10:56:44 +0200 Subject: [PATCH 13/18] Fix approvalSteps merge when merging locations --- client/src/components/ApprovalSteps.js | 6 +++++- client/src/components/approvals/Approvals.js | 4 ++-- .../src/pages/admin/merge/MergeLocations.js | 10 +++++++++ .../mil/dds/anet/database/LocationDao.java | 21 +++++++++++++++++-- 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/client/src/components/ApprovalSteps.js b/client/src/components/ApprovalSteps.js index 542f48dd18..012698e1ef 100644 --- a/client/src/components/ApprovalSteps.js +++ b/client/src/components/ApprovalSteps.js @@ -26,7 +26,11 @@ function ApprovalSteps({ approvalSteps }) { return ( - + diff --git a/client/src/components/approvals/Approvals.js b/client/src/components/approvals/Approvals.js index 168721aa49..aa24458f36 100644 --- a/client/src/components/approvals/Approvals.js +++ b/client/src/components/approvals/Approvals.js @@ -28,7 +28,7 @@ const Approvals = ({ restrictedApprovalLabel, relatedObject }) => { - + @@ -76,7 +76,7 @@ const Approvals = ({ restrictedApprovalLabel, relatedObject }) => {
NamePerson Position
- + diff --git a/client/src/pages/admin/merge/MergeLocations.js b/client/src/pages/admin/merge/MergeLocations.js index 1548b47d9f..d3980b2c51 100644 --- a/client/src/pages/admin/merge/MergeLocations.js +++ b/client/src/pages/admin/merge/MergeLocations.js @@ -257,6 +257,11 @@ const MergeLocations = ({ pageDispatchers }) => { /> } align={ALIGN_OPTIONS.CENTER} + action={getClearButton(() => + dispatchMergeActions( + setAMergedField("planningApprovalSteps", [], null) + ) + )} mergeState={mergeState} dispatchMergeActions={dispatchMergeActions} /> @@ -267,6 +272,11 @@ const MergeLocations = ({ pageDispatchers }) => { } align={ALIGN_OPTIONS.CENTER} + action={getClearButton(() => + dispatchMergeActions( + setAMergedField("approvalSteps", [], null) + ) + )} mergeState={mergeState} dispatchMergeActions={dispatchMergeActions} /> diff --git a/src/main/java/mil/dds/anet/database/LocationDao.java b/src/main/java/mil/dds/anet/database/LocationDao.java index 2e302ce949..68428a5f03 100644 --- a/src/main/java/mil/dds/anet/database/LocationDao.java +++ b/src/main/java/mil/dds/anet/database/LocationDao.java @@ -6,6 +6,7 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; import mil.dds.anet.AnetObjectEngine; +import mil.dds.anet.beans.ApprovalStep; import mil.dds.anet.beans.Location; import mil.dds.anet.beans.MergedEntity; import mil.dds.anet.beans.lists.AnetBeanList; @@ -79,14 +80,30 @@ public AnetBeanList search(LocationSearchQuery query) { public int mergeLocations(Location loserLocation, Location winnerLocation) { final String loserLocationUuid = loserLocation.getUuid(); final String winnerLocationUuid = winnerLocation.getUuid(); + final Location existingLoserLoc = getByUuid(loserLocationUuid); final Location existingWinnerLoc = getByUuid(winnerLocationUuid); final Map context = AnetObjectEngine.getInstance().getContext(); // Update location update(winnerLocation); - // Update approvalSteps - updateForMerge("approvalSteps", "relatedObjectUuid", winnerLocationUuid, loserLocationUuid); + // Update approvalSteps (note that this may fail if reports are currently pending at one of the + // approvalSteps that are going to be deleted): + // - delete approvalSteps of loser + final List existingLoserPlanningApprovalSteps = + existingLoserLoc.loadPlanningApprovalSteps(context).join(); + final List existingLoserApprovalSteps = + existingLoserLoc.loadApprovalSteps(context).join(); + Utils.updateApprovalSteps(loserLocation, List.of(), existingLoserPlanningApprovalSteps, + List.of(), existingLoserApprovalSteps); + // - update approvalSteps of winner + final List existingWinnerPlanningApprovalSteps = + existingWinnerLoc.loadPlanningApprovalSteps(context).join(); + final List existingWinnerApprovalSteps = + existingWinnerLoc.loadApprovalSteps(context).join(); + Utils.updateApprovalSteps(winnerLocation, winnerLocation.getPlanningApprovalSteps(), + existingWinnerPlanningApprovalSteps, winnerLocation.getApprovalSteps(), + existingWinnerApprovalSteps); // Update reports updateForMerge("reports", "locationUuid", winnerLocationUuid, loserLocationUuid); From 7d969164899337693fc801ac50dfc17e58e57081 Mon Sep 17 00:00:00 2001 From: Gertjan van Oosten Date: Wed, 29 May 2024 13:11:40 +0200 Subject: [PATCH 14/18] AB#1085 Extend wdio tests for location merge --- .../baseSpecs/mergeLocations.spec.js | 157 ++++++++++++++++-- .../webdriver/pages/mergeLocations.page.js | 26 ++- insertBaseData-psql.sql | 26 ++- 3 files changed, 187 insertions(+), 22 deletions(-) diff --git a/client/tests/webdriver/baseSpecs/mergeLocations.spec.js b/client/tests/webdriver/baseSpecs/mergeLocations.spec.js index 5446fc1f8f..a077d591cd 100644 --- a/client/tests/webdriver/baseSpecs/mergeLocations.spec.js +++ b/client/tests/webdriver/baseSpecs/mergeLocations.spec.js @@ -3,14 +3,28 @@ import MergeLocations from "../pages/mergeLocations.page" const EXAMPLE_LOCATIONS = { left: { - search: "Cabot", - name: "Cabot Tower", - fullName: "Cabot Tower 47.57001,-52.68177" + search: "Location Winner", + name: "Merge Location Winner", + type: "Point location", + fullName: "Merge Location Winner 38.58809,-28.71611", + latLon: "38.58809, -28.71611", + parentLocations: "Name Type\nPortugal Country", + planningApprovalSteps: + "Location planning approval for merge winner\nPerson Position\nOF-2 ELIZAWELL, Elizabeth EF 1.1 Advisor A", + approvalSteps: + "Location publication approval for merge winner\nPerson Position\nUnfilled EF 1.1 Advisor B" }, right: { - search: "Fort", - name: "Fort Amherst", - fullName: "Fort Amherst 47.563763,-52.68059" + search: "Location Loser", + type: "Point location", + name: "Merge Location Loser", + fullName: "Merge Location Loser -46.4035948,51.69093", + latLon: "-46.4035948, 51.69093", + parentLocations: "Name Type\nFrench Southern Territories Country", + planningApprovalSteps: + "Location planning approval for merge loser\nPerson Position\nCIV REINTON, Reina EF 2.2 Advisor C", + approvalSteps: + "Location publication approval for merge loser\nPerson Position\nCIV ERINSON, Erin EF 2.2 Advisor D" } } @@ -30,12 +44,16 @@ describe("Merge locations page", () => { await browser.pause(500) // wait for the rendering of custom fields await MergeLocations.waitForColumnToChange( EXAMPLE_LOCATIONS.left.name, - "left" + "left", + "Name" ) expect( - await (await MergeLocations.getColumnLocationName("left")).getText() + await (await MergeLocations.getColumnContent("left", "Name")).getText() ).to.eq(EXAMPLE_LOCATIONS.left.name) + expect( + await (await MergeLocations.getColumnContent("left", "Type")).getText() + ).to.eq(EXAMPLE_LOCATIONS.left.type) await ( await MergeLocations.getRightLocationField() @@ -47,12 +65,16 @@ describe("Merge locations page", () => { await browser.pause(500) // wait for the rendering of custom fields await MergeLocations.waitForColumnToChange( EXAMPLE_LOCATIONS.right.name, - "right" + "right", + "Name" ) expect( - await (await MergeLocations.getColumnLocationName("right")).getText() + await (await MergeLocations.getColumnContent("right", "Name")).getText() ).to.eq(EXAMPLE_LOCATIONS.right.name) + expect( + await (await MergeLocations.getColumnContent("right", "Type")).getText() + ).to.eq(EXAMPLE_LOCATIONS.right.type) }) it("Should be able to select all fields from left location", async() => { @@ -60,12 +82,16 @@ describe("Merge locations page", () => { await browser.pause(500) // wait for the rendering of custom fields await MergeLocations.waitForColumnToChange( EXAMPLE_LOCATIONS.left.name, - "mid" + "mid", + "Name" ) expect( - await (await MergeLocations.getColumnLocationName("mid")).getText() + await (await MergeLocations.getColumnContent("mid", "Name")).getText() ).to.eq(EXAMPLE_LOCATIONS.left.name) + expect( + await (await MergeLocations.getColumnContent("mid", "Type")).getText() + ).to.eq(EXAMPLE_LOCATIONS.left.type) }) it("Should be able to select all fields from right location", async() => { @@ -73,24 +99,123 @@ describe("Merge locations page", () => { await browser.pause(500) // wait for the rendering of custom fields await MergeLocations.waitForColumnToChange( EXAMPLE_LOCATIONS.right.name, - "mid" + "mid", + "Name" ) expect( - await (await MergeLocations.getColumnLocationName("mid")).getText() + await (await MergeLocations.getColumnContent("mid", "Name")).getText() ).to.eq(EXAMPLE_LOCATIONS.right.name) + expect( + await (await MergeLocations.getColumnContent("mid", "Type")).getText() + ).to.eq(EXAMPLE_LOCATIONS.right.type) }) - it("Should be able to merge both locations when winner is left location", async() => { + it("Should be able to select from both left and right side.", async() => { + await (await MergeLocations.getSelectButton("left", "Name")).click() + await MergeLocations.waitForColumnToChange( + EXAMPLE_LOCATIONS.left.name, + "mid", + "Name" + ) + expect( + await (await MergeLocations.getColumnContent("mid", "Name")).getText() + ).to.eq(EXAMPLE_LOCATIONS.left.name) + + await ( + await MergeLocations.getSelectButton("left", "Latitude, Longitude") + ).click() + await MergeLocations.waitForColumnToChange( + EXAMPLE_LOCATIONS.left.latLon, + "mid", + "Latitude, Longitude" + ) + expect( + await ( + await MergeLocations.getColumnContent("mid", "Latitude, Longitude") + ).getText() + ).to.equal(EXAMPLE_LOCATIONS.left.latLon) + + await ( + await MergeLocations.getSelectButton("left", "Parent locations") + ).click() + await MergeLocations.waitForColumnToChange( + EXAMPLE_LOCATIONS.left.parentLocations, + "mid", + "Parent locations" + ) + expect( + await ( + await MergeLocations.getColumnContent("mid", "Parent locations") + ).getText() + ).to.equal(EXAMPLE_LOCATIONS.left.parentLocations) + + await ( + await MergeLocations.getSelectButton("right", "Planning Approval Steps") + ).click() + await MergeLocations.waitForColumnToChange( + EXAMPLE_LOCATIONS.right.planningApprovalSteps, + "mid", + "Planning Approval Steps" + ) + expect( + await ( + await MergeLocations.getColumnContent("mid", "Planning Approval Steps") + ).getText() + ).to.equal(EXAMPLE_LOCATIONS.right.planningApprovalSteps) + + await ( + await MergeLocations.getSelectButton("left", "Approval Steps") + ).click() + await MergeLocations.waitForColumnToChange( + EXAMPLE_LOCATIONS.left.approvalSteps, + "mid", + "Approval Steps" + ) + expect( + await ( + await MergeLocations.getColumnContent("mid", "Approval Steps") + ).getText() + ).to.equal(EXAMPLE_LOCATIONS.left.approvalSteps) + }) + + it("Should be able to merge both locations", async() => { await (await MergeLocations.getUseAllButton("left")).click() await browser.pause(500) // wait for the rendering of custom fields await MergeLocations.waitForColumnToChange( EXAMPLE_LOCATIONS.left.name, - "mid" + "mid", + "Name" + ) + + await ( + await MergeLocations.getSelectButton("right", "Planning Approval Steps") + ).click() + await MergeLocations.waitForColumnToChange( + EXAMPLE_LOCATIONS.right.planningApprovalSteps, + "mid", + "Planning Approval Steps" ) await (await MergeLocations.getMergeLocationsButton()).click() await MergeLocations.waitForSuccessAlert() + + // Check the results of the merge + expect(await (await MergeLocations.getField("name")).getText()).to.equal( + EXAMPLE_LOCATIONS.left.name + ) + expect( + await (await MergeLocations.getField("location")).getText() + ).to.equal(EXAMPLE_LOCATIONS.left.latLon) + expect( + await (await MergeLocations.getField("parentLocations")).getText() + ).to.equal(EXAMPLE_LOCATIONS.left.parentLocations) + expect( + await (await MergeLocations.getFieldset("planningApprovals")).getText() + ).to.equal(`Step 1: ${EXAMPLE_LOCATIONS.right.planningApprovalSteps}`) + expect( + await (await MergeLocations.getFieldset("approvals")).getText() + ).to.equal(`Step 1: ${EXAMPLE_LOCATIONS.left.approvalSteps}`) }) }) diff --git a/client/tests/webdriver/pages/mergeLocations.page.js b/client/tests/webdriver/pages/mergeLocations.page.js index ebbf31f3ee..6346fce8d5 100644 --- a/client/tests/webdriver/pages/mergeLocations.page.js +++ b/client/tests/webdriver/pages/mergeLocations.page.js @@ -42,10 +42,12 @@ class MergeLocations extends Page { return browser.$(`#mid-merge-loc-col ${button} > button`) } - async getColumnLocationName(side) { - return browser.$( - `//div[@id="${side}-merge-loc-col"]//div[text()="Name"]/following-sibling::div` + async getSelectButton(side, text) { + const buttonDiv = await browser.$( + `//div[@id="${side}-merge-loc-col"]//div[text()="${text}"]` ) + const button = await (await buttonDiv.$("..")).$("..") + return button.$("small > button") } async waitForAdvancedSelectLoading(compareStr) { @@ -65,8 +67,14 @@ class MergeLocations extends Page { ) } - async waitForColumnToChange(compareStr, side) { - const field = await this.getColumnLocationName(side) + async getColumnContent(side, text) { + return browser.$( + `//div[@id="${side}-merge-loc-col"]//div[text()="${text}"]/following-sibling::div` + ) + } + + async waitForColumnToChange(compareStr, side, text) { + const field = await this.getColumnContent(side, text) await browser.waitUntil( async() => { @@ -93,6 +101,14 @@ class MergeLocations extends Page { } ) } + + async getField(fieldName) { + return browser.$(`div[id="${fieldName}"]`) + } + + async getFieldset(fieldName) { + return (await this.getField(fieldName)).$("fieldset") + } } export default new MergeLocations() diff --git a/insertBaseData-psql.sql b/insertBaseData-psql.sql index 0b057f7471..dcde139673 100644 --- a/insertBaseData-psql.sql +++ b/insertBaseData-psql.sql @@ -154,6 +154,8 @@ INSERT INTO "emailAddresses" (network, address, "relatedObjectType", "relatedObj -- Create locations INSERT INTO locations (uuid, type, name, lat, lng, "createdAt", "updatedAt") VALUES + ('64795e03-ba83-4bc3-b647-d37fcb1c0694', 'PP', 'Merge Location Winner', 38.58809, -28.71611, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('4694bb3c-275a-4e74-9197-033e8e9c53ed', 'PP', 'Merge Location Loser', -46.4035948, 51.69093, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), ('e5b3a4b9-acf7-4c79-8224-f248b9a7215d', 'PA', 'Antarctica', -90, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), ('cc49bb27-4d8f-47a8-a9ee-af2b68b992ac', 'PP', 'St Johns Airport', 47.613442, -52.740936, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), ('8c138750-91ce-41bf-9b4c-9f0ddc73608b', 'PP', 'Murray''s Hotel', 47.561517, -52.708760, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), @@ -187,6 +189,8 @@ INSERT INTO locations (uuid, type, name, "createdAt", "updatedAt") VALUES -- Set up locationRelationships INSERT INTO "locationRelationships" ("childLocationUuid", "parentLocationUuid") VALUES + ('64795e03-ba83-4bc3-b647-d37fcb1c0694', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Portugal')), + ('4694bb3c-275a-4e74-9197-033e8e9c53ed', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'French Southern Territories')), ('e5b3a4b9-acf7-4c79-8224-f248b9a7215d', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Antarctica')), ('cc49bb27-4d8f-47a8-a9ee-af2b68b992ac', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Canada')), ('8c138750-91ce-41bf-9b4c-9f0ddc73608b', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Canada')), @@ -621,12 +625,32 @@ INSERT INTO approvers ("approvalStepUuid", "positionUuid") WHERE "approvalSteps".name = 'Task Owner approval' AND "approvalSteps".type = 1; --- Create a location approval process for a location +-- Create a location approval process for some locations INSERT INTO "approvalSteps" (uuid, "relatedObjectUuid", name, type) SELECT uuid_generate_v4(), (SELECT uuid FROM locations WHERE name = 'Portugal Cove Ferry Terminal'), 'Location approval', 1; INSERT INTO approvers ("approvalStepUuid", "positionUuid") VALUES ((SELECT uuid from "approvalSteps" where name = 'Location approval'), (SELECT uuid from positions where name = 'ANET Administrator')); +INSERT INTO "approvalSteps" (uuid, "relatedObjectUuid", name, type) + SELECT uuid_generate_v4(), '64795e03-ba83-4bc3-b647-d37fcb1c0694', 'Location planning approval for merge winner', 0; +INSERT INTO approvers ("approvalStepUuid", "positionUuid") VALUES + ((SELECT uuid from "approvalSteps" where name = 'Location planning approval for merge winner'), (SELECT uuid from positions where name = 'EF 1.1 Advisor A')); + +INSERT INTO "approvalSteps" (uuid, "relatedObjectUuid", name, type) + SELECT uuid_generate_v4(), '64795e03-ba83-4bc3-b647-d37fcb1c0694', 'Location publication approval for merge winner', 1; +INSERT INTO approvers ("approvalStepUuid", "positionUuid") VALUES + ((SELECT uuid from "approvalSteps" where name = 'Location publication approval for merge winner'), (SELECT uuid from positions where name = 'EF 1.1 Advisor B')); + +INSERT INTO "approvalSteps" (uuid, "relatedObjectUuid", name, type) + SELECT uuid_generate_v4(), '4694bb3c-275a-4e74-9197-033e8e9c53ed', 'Location planning approval for merge loser', 0; +INSERT INTO approvers ("approvalStepUuid", "positionUuid") VALUES + ((SELECT uuid from "approvalSteps" where name = 'Location planning approval for merge loser'), (SELECT uuid from positions where name = 'EF 2.2 Advisor C')); + +INSERT INTO "approvalSteps" (uuid, "relatedObjectUuid", name, type) + SELECT uuid_generate_v4(), '4694bb3c-275a-4e74-9197-033e8e9c53ed', 'Location publication approval for merge loser', 1; +INSERT INTO approvers ("approvalStepUuid", "positionUuid") VALUES + ((SELECT uuid from "approvalSteps" where name = 'Location publication approval for merge loser'), (SELECT uuid from positions where name = 'EF 2.2 Advisor D')); + -- Top-level organizations INSERT INTO organizations (uuid, "shortName", "longName", "identificationCode", "locationUuid", app6context, "app6standardIdentity", "app6symbolSet", "createdAt", "updatedAt") VALUES (uuid_generate_v4(), 'MoD', 'Ministry of Defense', 'Z12345', (SELECT uuid FROM locations WHERE type = 'PAC' AND name = 'Afghanistan'), '0', '4', '11', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), From 0a89da9d8018bdf96aaec92c710402131470628e Mon Sep 17 00:00:00 2001 From: Raimund Klein <770876+Chessray@users.noreply.github.com> Date: Thu, 6 Jun 2024 14:00:26 +0200 Subject: [PATCH 15/18] AB#1110 Add alpha-2 and alpha-3 codes to location merge --- .../src/pages/admin/merge/MergeLocations.js | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/client/src/pages/admin/merge/MergeLocations.js b/client/src/pages/admin/merge/MergeLocations.js index d3980b2c51..565dd31f25 100644 --- a/client/src/pages/admin/merge/MergeLocations.js +++ b/client/src/pages/admin/merge/MergeLocations.js @@ -248,6 +248,34 @@ const MergeLocations = ({ pageDispatchers }) => { mergeState={mergeState} dispatchMergeActions={dispatchMergeActions} /> + {mergeState?.merged?.type === Location.LOCATION_TYPES.COUNTRY && ( + <> + + dispatchMergeActions(setAMergedField("digram", "", null)) + )} + fieldName="digram" + mergeState={mergeState} + dispatchMergeActions={dispatchMergeActions} + /> + + dispatchMergeActions(setAMergedField("trigram", "", null)) + )} + fieldName="trigram" + mergeState={mergeState} + dispatchMergeActions={dispatchMergeActions} + /> + + )} + {mergeState?.merged?.type === Location.LOCATION_TYPES.COUNTRY && ( + <> + { + dispatchMergeActions( + setAMergedField("digram", location.digram, align) + ) + }, + align, + mergeState, + "digram" + )} + mergeState={mergeState} + dispatchMergeActions={dispatchMergeActions} + /> + { + dispatchMergeActions( + setAMergedField("trigram", location.trigram, align) + ) + }, + align, + mergeState, + "trigram" + )} + mergeState={mergeState} + dispatchMergeActions={dispatchMergeActions} + /> + + )} Date: Tue, 11 Jun 2024 10:15:28 +0200 Subject: [PATCH 16/18] AB#1083 Clear out alpha-2 and alpha-3 codes when saving a non-country location --- client/src/pages/admin/merge/MergeLocations.js | 4 ++++ client/src/pages/locations/Form.js | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/client/src/pages/admin/merge/MergeLocations.js b/client/src/pages/admin/merge/MergeLocations.js index 565dd31f25..55ba93c2a0 100644 --- a/client/src/pages/admin/merge/MergeLocations.js +++ b/client/src/pages/admin/merge/MergeLocations.js @@ -374,6 +374,10 @@ const MergeLocations = ({ pageDispatchers }) => { function mergeLocations() { const loser = mergedLocation.uuid === location1.uuid ? location2 : location1 + if (mergedLocation.type !== Location.LOCATION_TYPES.COUNTRY) { + mergedLocation.digram = null + mergedLocation.trigram = null + } mergedLocation.customFields = customFieldsJSONString(mergedLocation) const winnerLocation = Location.filterClientSideFields(mergedLocation) diff --git a/client/src/pages/locations/Form.js b/client/src/pages/locations/Form.js index 04bac1e8c2..6eb8b09091 100644 --- a/client/src/pages/locations/Form.js +++ b/client/src/pages/locations/Form.js @@ -538,6 +538,10 @@ const LocationForm = ({ location.parentLocations = values.parentLocations?.map(l => utils.getReference(l) ) + if (location.type !== Location.LOCATION_TYPES.COUNTRY) { + location.digram = null + location.trigram = null + } location.customFields = customFieldsJSONString(values) return API.mutation(edit ? GQL_UPDATE_LOCATION : GQL_CREATE_LOCATION, { location From f68af0f57576763684cda9386f6b19fa0f1c5ac3 Mon Sep 17 00:00:00 2001 From: Gertjan van Oosten Date: Tue, 11 Jun 2024 13:47:52 +0200 Subject: [PATCH 17/18] AB#1110 Add wdio tests for alpha-2 and alpha-3 codes in location merge --- .../baseSpecs/mergeLocations.spec.js | 127 ++++++++++++++++-- 1 file changed, 118 insertions(+), 9 deletions(-) diff --git a/client/tests/webdriver/baseSpecs/mergeLocations.spec.js b/client/tests/webdriver/baseSpecs/mergeLocations.spec.js index a077d591cd..17980e2f4a 100644 --- a/client/tests/webdriver/baseSpecs/mergeLocations.spec.js +++ b/client/tests/webdriver/baseSpecs/mergeLocations.spec.js @@ -14,6 +14,18 @@ const EXAMPLE_LOCATIONS = { approvalSteps: "Location publication approval for merge winner\nPerson Position\nUnfilled EF 1.1 Advisor B" }, + leftCountry: { + search: "Andorra", + name: "Andorra", + type: "Country", + fullName: "Andorra", + latLon: "", + parentLocations: "No locations found", + digram: "AN", + trigram: "AND", + planningApprovalSteps: "", + approvalSteps: "" + }, right: { search: "Location Loser", type: "Point location", @@ -29,31 +41,31 @@ const EXAMPLE_LOCATIONS = { } describe("Merge locations page", () => { - it("Should be able to select to locations to merge", async() => { + it("Should be able to select to incompatible locations to merge", async() => { await MergeLocations.open() await (await MergeLocations.getTitle()).waitForExist() await (await MergeLocations.getTitle()).waitForDisplayed() await ( await MergeLocations.getLeftLocationField() - ).setValue(EXAMPLE_LOCATIONS.left.search) + ).setValue(EXAMPLE_LOCATIONS.leftCountry.search) await MergeLocations.waitForAdvancedSelectLoading( - EXAMPLE_LOCATIONS.left.fullName + EXAMPLE_LOCATIONS.leftCountry.fullName ) await (await MergeLocations.getFirstItemFromAdvancedSelect()).click() await browser.pause(500) // wait for the rendering of custom fields await MergeLocations.waitForColumnToChange( - EXAMPLE_LOCATIONS.left.name, + EXAMPLE_LOCATIONS.leftCountry.name, "left", "Name" ) expect( await (await MergeLocations.getColumnContent("left", "Name")).getText() - ).to.eq(EXAMPLE_LOCATIONS.left.name) + ).to.eq(EXAMPLE_LOCATIONS.leftCountry.name) expect( await (await MergeLocations.getColumnContent("left", "Type")).getText() - ).to.eq(EXAMPLE_LOCATIONS.left.type) + ).to.eq(EXAMPLE_LOCATIONS.leftCountry.type) await ( await MergeLocations.getRightLocationField() @@ -75,23 +87,60 @@ describe("Merge locations page", () => { expect( await (await MergeLocations.getColumnContent("right", "Type")).getText() ).to.eq(EXAMPLE_LOCATIONS.right.type) + // should show an alert + await MergeLocations.waitForAlertWarningToLoad() + expect(await (await MergeLocations.getAlertWarning()).getText()).to.eq( + "Locations you are about to merge have different types. " + + "Before continuing, please be aware that this merge operation might cause problems in the future!" + ) }) it("Should be able to select all fields from left location", async() => { await (await MergeLocations.getUseAllButton("left")).click() await browser.pause(500) // wait for the rendering of custom fields await MergeLocations.waitForColumnToChange( - EXAMPLE_LOCATIONS.left.name, + EXAMPLE_LOCATIONS.leftCountry.name, "mid", "Name" ) expect( await (await MergeLocations.getColumnContent("mid", "Name")).getText() - ).to.eq(EXAMPLE_LOCATIONS.left.name) + ).to.eq(EXAMPLE_LOCATIONS.leftCountry.name) expect( await (await MergeLocations.getColumnContent("mid", "Type")).getText() - ).to.eq(EXAMPLE_LOCATIONS.left.type) + ).to.eq(EXAMPLE_LOCATIONS.leftCountry.type) + // the digram and trigram fields should now be visible, and selected for the merged location + expect( + await ( + await MergeLocations.getColumnContent("left", "Alpha-2 code") + ).getText() + ).to.eq(EXAMPLE_LOCATIONS.leftCountry.digram) + expect( + await ( + await MergeLocations.getColumnContent("left", "Alpha-3 code") + ).getText() + ).to.eq(EXAMPLE_LOCATIONS.leftCountry.trigram) + expect( + await ( + await MergeLocations.getColumnContent("mid", "Alpha-2 code") + ).getText() + ).to.eq(EXAMPLE_LOCATIONS.leftCountry.digram) + expect( + await ( + await MergeLocations.getColumnContent("mid", "Alpha-3 code") + ).getText() + ).to.eq(EXAMPLE_LOCATIONS.leftCountry.trigram) + expect( + await ( + await MergeLocations.getColumnContent("right", "Alpha-2 code") + ).getText() + ).to.eq("") + expect( + await ( + await MergeLocations.getColumnContent("right", "Alpha-3 code") + ).getText() + ).to.eq("") }) it("Should be able to select all fields from right location", async() => { @@ -109,6 +158,66 @@ describe("Merge locations page", () => { expect( await (await MergeLocations.getColumnContent("mid", "Type")).getText() ).to.eq(EXAMPLE_LOCATIONS.right.type) + // the digram and trigram fields should now no longer be visible + /* eslint-disable no-unused-expressions */ + expect( + await ( + await MergeLocations.getColumnContent("left", "Alpha-2 code") + ).isExisting() + ).to.be.false + expect( + await ( + await MergeLocations.getColumnContent("left", "Alpha-3 code") + ).isExisting() + ).to.be.false + expect( + await ( + await MergeLocations.getColumnContent("mid", "Alpha-2 code") + ).isExisting() + ).to.be.false + expect( + await ( + await MergeLocations.getColumnContent("mid", "Alpha-3 code") + ).isExisting() + ).to.be.false + expect( + await ( + await MergeLocations.getColumnContent("right", "Alpha-2 code") + ).isExisting() + ).to.be.false + expect( + await ( + await MergeLocations.getColumnContent("right", "Alpha-3 code") + ).isExisting() + ).to.be.false + /* eslint-enable no-unused-expressions */ + }) + + it("Should be able to select to compatible locations to merge", async() => { + await ( + await MergeLocations.getLeftLocationField() + ).setValue(EXAMPLE_LOCATIONS.left.search) + await MergeLocations.waitForAdvancedSelectLoading( + EXAMPLE_LOCATIONS.left.fullName + ) + await (await MergeLocations.getFirstItemFromAdvancedSelect()).click() + await browser.pause(500) // wait for the rendering of custom fields + await MergeLocations.waitForColumnToChange( + EXAMPLE_LOCATIONS.left.name, + "left", + "Name" + ) + + expect( + await (await MergeLocations.getColumnContent("left", "Name")).getText() + ).to.eq(EXAMPLE_LOCATIONS.left.name) + expect( + await (await MergeLocations.getColumnContent("left", "Type")).getText() + ).to.eq(EXAMPLE_LOCATIONS.left.type) + // alert should be gone now + // eslint-disable-next-line no-unused-expressions + expect(await (await MergeLocations.getAlertWarning()).isExisting()).to.be + .false }) it("Should be able to select from both left and right side.", async() => { From 02dc6e1799a5fb57efe606aa888fd2399a15c53d Mon Sep 17 00:00:00 2001 From: Gertjan van Oosten Date: Wed, 12 Jun 2024 12:05:10 +0200 Subject: [PATCH 18/18] AB#1111 Optionally hide the map for location and position merge Hide the map when neither side of the merge has location coordinates. --- client/src/components/Leaflet.js | 10 +++++++--- client/src/mergeUtils.js | 10 +++++++--- .../src/pages/admin/merge/MergeLocations.js | 17 ++++++++++++++-- .../src/pages/admin/merge/MergePositions.js | 20 ++++++++++++++++--- 4 files changed, 46 insertions(+), 11 deletions(-) diff --git a/client/src/components/Leaflet.js b/client/src/components/Leaflet.js index 1d03cf8d77..4a064450f6 100644 --- a/client/src/components/Leaflet.js +++ b/client/src/components/Leaflet.js @@ -29,6 +29,12 @@ import MARKER_ICON from "resources/leaflet/marker-icon.png" import MARKER_SHADOW from "resources/leaflet/marker-shadow.png" import Settings from "settings" +export const DEFAULT_MAP_STYLE = { + width: "100%", + height: "500px", + marginBottom: "18px" +} + const css = { zIndex: 1 } @@ -289,9 +295,7 @@ Leaflet.propTypes = { onMapClick: PropTypes.func } Leaflet.defaultProps = { - width: "100%", - height: "500px", - marginBottom: "18px" + ...DEFAULT_MAP_STYLE } export default Leaflet diff --git a/client/src/mergeUtils.js b/client/src/mergeUtils.js index a3e74171eb..44f7cb170c 100644 --- a/client/src/mergeUtils.js +++ b/client/src/mergeUtils.js @@ -1,6 +1,6 @@ import { Icon, Intent, Tooltip } from "@blueprintjs/core" import { IconNames } from "@blueprintjs/icons" -import Leaflet from "components/Leaflet" +import Leaflet, { DEFAULT_MAP_STYLE } from "components/Leaflet" import { DEFAULT_CUSTOM_FIELDS_PARENT, MODEL_TO_OBJECT_TYPE @@ -356,8 +356,10 @@ export function getActionButton( ) } -export function getLeafletMap(mapId, location) { - return ( +const HIDDEN_STYLE = { visibility: "hidden" } + +export function getLeafletMap(mapId, location, hideWhenEmpty) { + return Location.hasCoordinates(location) ? ( + ) : ( +
) } diff --git a/client/src/pages/admin/merge/MergeLocations.js b/client/src/pages/admin/merge/MergeLocations.js index 55ba93c2a0..590fe7ac34 100644 --- a/client/src/pages/admin/merge/MergeLocations.js +++ b/client/src/pages/admin/merge/MergeLocations.js @@ -75,6 +75,8 @@ const MergeLocations = ({ pageDispatchers }) => { const location1 = mergeState[MERGE_SIDES.LEFT] const location2 = mergeState[MERGE_SIDES.RIGHT] const mergedLocation = mergeState.merged + const hideWhenEmpty = + !Location.hasCoordinates(location1) && !Location.hasCoordinates(location2) useEffect(() => { if (location1 && location2 && location1.type !== location2.type) { @@ -193,7 +195,11 @@ const MergeLocations = ({ pageDispatchers }) => { mergeState={mergeState} dispatchMergeActions={dispatchMergeActions} /> - {getLeafletMap("merged-location-map", mergedLocation)} + {getLeafletMap( + "merged-location-map", + mergedLocation, + hideWhenEmpty + )} { const location = mergeState[align] + const hideWhenEmpty = + !Location.hasCoordinates(mergeState[MERGE_SIDES.LEFT]) && + !Location.hasCoordinates(mergeState[MERGE_SIDES.RIGHT]) const idForLocation = label.replace(/\s+/g, "") return (
@@ -552,7 +561,11 @@ const LocationColumn = ({ mergeState={mergeState} dispatchMergeActions={dispatchMergeActions} /> - {getLeafletMap(`merge-location-map-${align}`, location)} + {getLeafletMap( + `merge-location-map-${align}`, + location, + hideWhenEmpty + )} { const position1 = mergeState[MERGE_SIDES.LEFT] const position2 = mergeState[MERGE_SIDES.RIGHT] const mergedPosition = mergeState.merged + const hideWhenEmpty = + !Location.hasCoordinates(position1?.location) && + !Location.hasCoordinates(position2?.location) return ( @@ -388,7 +391,11 @@ const MergePositions = ({ pageDispatchers }) => { mergeState={mergeState} dispatchMergeActions={dispatchMergeActions} /> - {getLeafletMap("merged-location", mergedPosition.location)} + {getLeafletMap( + "merged-location", + mergedPosition.location, + hideWhenEmpty + )} )} @@ -489,6 +496,9 @@ function getPositionFilters(mergeState, align) { const PositionColumn = ({ align, label, mergeState, dispatchMergeActions }) => { const position = mergeState[align] + const hideWhenEmpty = + !Location.hasCoordinates(mergeState[MERGE_SIDES.LEFT]?.location) && + !Location.hasCoordinates(mergeState[MERGE_SIDES.RIGHT]?.location) const idForPosition = label.replace(/\s+/g, "") return ( @@ -795,7 +805,11 @@ const PositionColumn = ({ align, label, mergeState, dispatchMergeActions }) => { mergeState={mergeState} dispatchMergeActions={dispatchMergeActions} /> - {getLeafletMap(`merge-position-map-${align}`, position.location)} + {getLeafletMap( + `merge-position-map-${align}`, + position.location, + hideWhenEmpty + )} )}
NamePerson Position