From 147855170d80d5f7b50780a9452b6365b8d0c914 Mon Sep 17 00:00:00 2001 From: Francisco del Castillo Date: Fri, 5 Jul 2024 10:13:54 +0200 Subject: [PATCH] AB#1064 events --- anet-dictionary.yml | 50 ++ client/src/actions/index.js | 9 +- client/src/components/CreateButton.js | 8 +- client/src/components/CustomDateInput.js | 9 +- client/src/components/EventCalendar.js | 74 +++ client/src/components/EventCollection.js | 150 +++++ client/src/components/EventMap.js | 67 ++ .../src/components/EventSeriesCollection.js | 96 +++ client/src/components/EventSeriesTable.js | 129 ++++ client/src/components/EventSummary.js | 201 ++++++ client/src/components/EventTable.js | 148 +++++ client/src/components/Nav.js | 16 +- client/src/components/SearchFilters.js | 47 +- .../advancedSearch/EventSeriesFilter.js | 104 +++ .../advancedSelectWidget/AdvancedSelect.js | 4 +- .../AdvancedSelectOverlayRow.js | 19 + .../aggregations/EventsMapWidget.js | 65 ++ client/src/components/aggregations/utils.js | 31 +- .../src/components/previews/EventPreview.js | 85 +++ .../components/previews/EventSeriesPreview.js | 52 ++ .../previews/RegisterPreviewComponents.js | 4 + client/src/exportUtils.js | 54 ++ client/src/index.css | 13 + client/src/models.js | 2 + client/src/models/Event.js | 328 ++++++++++ client/src/models/EventSeries.js | 165 +++++ client/src/models/Report.js | 3 +- client/src/pages/Routing.js | 22 + client/src/pages/eventSeries/Edit.js | 53 ++ client/src/pages/eventSeries/Form.js | 256 ++++++++ client/src/pages/eventSeries/New.js | 38 ++ client/src/pages/eventSeries/Show.js | 187 ++++++ client/src/pages/events/Edit.js | 49 ++ client/src/pages/events/Form.js | 601 ++++++++++++++++++ client/src/pages/events/MyEvents.js | 99 +++ client/src/pages/events/New.js | 107 ++++ client/src/pages/events/Show.js | 257 ++++++++ .../src/pages/locations/CreateNewLocation.js | 44 ++ client/src/pages/locations/Show.js | 8 + client/src/pages/organizations/Show.js | 18 +- client/src/pages/reports/Edit.js | 5 + client/src/pages/reports/Form.js | 85 +-- client/src/pages/reports/New.js | 79 ++- client/src/pages/reports/Show.js | 17 + client/src/pages/searches/Search.js | 127 +++- client/src/pages/util.js | 2 + client/src/resources/events.png | Bin 0 -> 1501 bytes .../baseSpecs/advancedSearch.spec.js | 3 + .../java/mil/dds/anet/AnetApplication.java | 8 +- .../java/mil/dds/anet/AnetObjectEngine.java | 14 + src/main/java/mil/dds/anet/beans/Event.java | 173 +++++ .../java/mil/dds/anet/beans/EventSeries.java | 139 ++++ src/main/java/mil/dds/anet/beans/Report.java | 47 +- .../anet/beans/search/EventSearchQuery.java | 118 ++++ .../anet/beans/search/EventSearchSortBy.java | 5 + .../beans/search/EventSeriesSearchQuery.java | 55 ++ .../beans/search/EventSeriesSearchSortBy.java | 5 + .../anet/beans/search/ReportSearchQuery.java | 14 +- .../dds/anet/beans/search/SavedSearch.java | 3 +- .../java/mil/dds/anet/database/EventDao.java | 150 +++++ .../mil/dds/anet/database/EventSeriesDao.java | 81 +++ .../java/mil/dds/anet/database/ReportDao.java | 11 +- .../anet/database/mappers/EventMapper.java | 35 + .../database/mappers/EventSeriesMapper.java | 29 + .../anet/database/mappers/ReportMapper.java | 2 + .../mil/dds/anet/resources/EventResource.java | 139 ++++ .../anet/resources/EventSeriesResource.java | 109 ++++ .../anet/search/AbstractEventSearcher.java | 101 +++ .../search/AbstractEventSeriesSearcher.java | 50 ++ .../anet/search/AbstractReportSearcher.java | 7 +- .../mil/dds/anet/search/IEventSearcher.java | 9 + .../dds/anet/search/IEventSeriesSearcher.java | 9 + .../java/mil/dds/anet/search/ISearcher.java | 3 + .../search/pg/PostgresqlEventSearcher.java | 16 + .../pg/PostgresqlEventSeriesSearcher.java | 16 + .../anet/search/pg/PostgresqlSearcher.java | 12 + .../mil/dds/anet/utils/BatchingUtils.java | 17 + .../mil/dds/anet/utils/FkDataLoaderKey.java | 2 +- .../mil/dds/anet/utils/IdDataLoaderKey.java | 4 + src/main/resources/anet-schema.yml | 51 ++ src/main/resources/migrations.xml | 143 ++++- src/test/resources/anet.graphql | 155 ++++- testDictionaries/no-custom-fields.yml | 50 ++ 83 files changed, 5689 insertions(+), 83 deletions(-) create mode 100644 client/src/components/EventCalendar.js create mode 100644 client/src/components/EventCollection.js create mode 100644 client/src/components/EventMap.js create mode 100644 client/src/components/EventSeriesCollection.js create mode 100644 client/src/components/EventSeriesTable.js create mode 100644 client/src/components/EventSummary.js create mode 100644 client/src/components/EventTable.js create mode 100644 client/src/components/advancedSearch/EventSeriesFilter.js create mode 100644 client/src/components/aggregations/EventsMapWidget.js create mode 100644 client/src/components/previews/EventPreview.js create mode 100644 client/src/components/previews/EventSeriesPreview.js create mode 100644 client/src/models/Event.js create mode 100644 client/src/models/EventSeries.js create mode 100644 client/src/pages/eventSeries/Edit.js create mode 100644 client/src/pages/eventSeries/Form.js create mode 100644 client/src/pages/eventSeries/New.js create mode 100644 client/src/pages/eventSeries/Show.js create mode 100644 client/src/pages/events/Edit.js create mode 100644 client/src/pages/events/Form.js create mode 100644 client/src/pages/events/MyEvents.js create mode 100644 client/src/pages/events/New.js create mode 100644 client/src/pages/events/Show.js create mode 100644 client/src/pages/locations/CreateNewLocation.js create mode 100644 client/src/resources/events.png create mode 100644 src/main/java/mil/dds/anet/beans/Event.java create mode 100644 src/main/java/mil/dds/anet/beans/EventSeries.java create mode 100644 src/main/java/mil/dds/anet/beans/search/EventSearchQuery.java create mode 100644 src/main/java/mil/dds/anet/beans/search/EventSearchSortBy.java create mode 100644 src/main/java/mil/dds/anet/beans/search/EventSeriesSearchQuery.java create mode 100644 src/main/java/mil/dds/anet/beans/search/EventSeriesSearchSortBy.java create mode 100644 src/main/java/mil/dds/anet/database/EventDao.java create mode 100644 src/main/java/mil/dds/anet/database/EventSeriesDao.java create mode 100644 src/main/java/mil/dds/anet/database/mappers/EventMapper.java create mode 100644 src/main/java/mil/dds/anet/database/mappers/EventSeriesMapper.java create mode 100644 src/main/java/mil/dds/anet/resources/EventResource.java create mode 100644 src/main/java/mil/dds/anet/resources/EventSeriesResource.java create mode 100644 src/main/java/mil/dds/anet/search/AbstractEventSearcher.java create mode 100644 src/main/java/mil/dds/anet/search/AbstractEventSeriesSearcher.java create mode 100644 src/main/java/mil/dds/anet/search/IEventSearcher.java create mode 100644 src/main/java/mil/dds/anet/search/IEventSeriesSearcher.java create mode 100644 src/main/java/mil/dds/anet/search/pg/PostgresqlEventSearcher.java create mode 100644 src/main/java/mil/dds/anet/search/pg/PostgresqlEventSeriesSearcher.java diff --git a/anet-dictionary.yml b/anet-dictionary.yml index 1932ab318e..2e69b0789e 100644 --- a/anet-dictionary.yml +++ b/anet-dictionary.yml @@ -345,6 +345,52 @@ fields: authorizationGroupRelatedObjects: label: Members + eventSeries: + hostOrg: + label: Host Organization + placeholder: Search for the organization hosting the event series... + adminOrg: + label: Admin Organization + placeholder: Search for the organization that will manage the event series in ANET... + name: + label: Name + placeholder: The name of the event series + description: + label: Description + placeholder: The description of the event series + + event: + eventSeries: + label: Event Series this event belongs to + placeholder: Search for an event series + hostOrg: + label: Host Organization + placeholder: Search for the organization hosting the event... + adminOrg: + label: Admin Organization + placeholder: Search for the organization that will manage the event in ANET... + location: + label: Location where the event takes place + placeholder: Search for a location… + type: + label: Type + placeholder: The type of the event + name: + label: Name + placeholder: The name of the event + description: + label: Description + placeholder: The description of the event + startDate: + label: Start Date + placeholder: The start date of the event + endDate: + label: End Date + placeholder: The end date of the event + outcomes: + label: Outcomes + placeholder: The outcomes of the event + report: canUnpublishReports: true intent: @@ -392,6 +438,10 @@ fields: - label: Linguists filter: orgUuid: 70193ee9-05b4-4aac-80b5-75609825db9f + event: + label: Event + placeholder: Was the engagement part of an event? + filter: [CONFERENCE, EXERCISE, VISIT_BAN, OTHER] customFields: gridLocation: type: geo_location diff --git a/client/src/actions/index.js b/client/src/actions/index.js index 83176dfabe..7ccf2516e3 100644 --- a/client/src/actions/index.js +++ b/client/src/actions/index.js @@ -25,7 +25,8 @@ export const SEARCH_OBJECT_TYPES = { LOCATIONS: "LOCATIONS", TASKS: "TASKS", AUTHORIZATION_GROUPS: "AUTHORIZATION_GROUPS", - ATTACHMENTS: "ATTACHMENTS" + ATTACHMENTS: "ATTACHMENTS", + EVENTS: "EVENTS" } export const SEARCH_OBJECT_LABELS = { @@ -36,7 +37,8 @@ export const SEARCH_OBJECT_LABELS = { [SEARCH_OBJECT_TYPES.LOCATIONS]: "Locations", [SEARCH_OBJECT_TYPES.TASKS]: pluralize(Settings.fields.task.shortLabel), [SEARCH_OBJECT_TYPES.AUTHORIZATION_GROUPS]: "Authorization Groups", - [SEARCH_OBJECT_TYPES.ATTACHMENTS]: "Attachments" + [SEARCH_OBJECT_TYPES.ATTACHMENTS]: "Attachments", + [SEARCH_OBJECT_TYPES.EVENTS]: "Events" } export const DEFAULT_SEARCH_PROPS = { @@ -49,7 +51,8 @@ export const DEFAULT_SEARCH_PROPS = { SEARCH_OBJECT_TYPES.LOCATIONS, SEARCH_OBJECT_TYPES.TASKS, SEARCH_OBJECT_TYPES.AUTHORIZATION_GROUPS, - SEARCH_OBJECT_TYPES.ATTACHMENTS + SEARCH_OBJECT_TYPES.ATTACHMENTS, + SEARCH_OBJECT_TYPES.EVENTS ] } export const DEFAULT_SEARCH_QUERY = { diff --git a/client/src/components/CreateButton.js b/client/src/components/CreateButton.js index 7b2e7e3d5c..baf04b192d 100644 --- a/client/src/components/CreateButton.js +++ b/client/src/components/CreateButton.js @@ -6,7 +6,13 @@ import { useNavigate } from "react-router-dom" const DEFAULT_ACTIONS = [Models.Report] -const SUPERUSER_ACTIONS = [Models.Person, Models.Position, Models.Location] +const SUPERUSER_ACTIONS = [ + Models.Person, + Models.Position, + Models.Location, + Models.Event, + Models.EventSeries +] const ADMIN_ACTIONS = [ Models.Organization, diff --git a/client/src/components/CustomDateInput.js b/client/src/components/CustomDateInput.js index 4a0509bf94..7e12d2bc3e 100644 --- a/client/src/components/CustomDateInput.js +++ b/client/src/components/CustomDateInput.js @@ -27,6 +27,7 @@ const CustomDateInput = ({ className, disabled, showIcon, + minDate, maxDate, placement, withTime, @@ -73,8 +74,10 @@ const CustomDateInput = ({ return dt.isValid() ? dt.toDate() : false }} placeholder={inputFormat} - maxDate={maxDate} - minDate={moment().subtract(100, "years").startOf("year").toDate()} + maxDate={maxDate || moment().add(20, "years").endOf("year").toDate()} + minDate={ + minDate || moment().subtract(100, "years").startOf("year").toDate() + } canClearSelection={canClearSelection} showActionsBar closeOnSelection={!withTime} @@ -92,6 +95,7 @@ CustomDateInput.propTypes = { disabled: PropTypes.bool, showIcon: PropTypes.bool, maxDate: PropTypes.instanceOf(Date), + minDate: PropTypes.instanceOf(Date), placement: PropTypes.string, withTime: PropTypes.bool, value: PropTypes.oneOfType([ @@ -107,6 +111,7 @@ CustomDateInput.defaultProps = { disabled: false, showIcon: true, maxDate: moment().add(20, "years").endOf("year").toDate(), + minDate: moment().subtract(100, "years").startOf("year").toDate(), placement: "auto", withTime: false, canClearSelection: false diff --git a/client/src/components/EventCalendar.js b/client/src/components/EventCalendar.js new file mode 100644 index 0000000000..d245fe5ddc --- /dev/null +++ b/client/src/components/EventCalendar.js @@ -0,0 +1,74 @@ +import API from "api" +import { eventToCalendarEvents } from "components/aggregations/utils" +import Calendar from "components/Calendar" +import Model from "components/Model" +import { PageDispatchersPropType } from "components/Page" +import _isEqual from "lodash/isEqual" +import { Event } from "models" +import moment from "moment" +import PropTypes from "prop-types" +import React, { useRef } from "react" +import { useNavigate } from "react-router-dom" + +const EventCalendar = ({ + pageDispatchers: { showLoading, hideLoading }, + queryParams, + setTotalCount +}) => { + const navigate = useNavigate() + const prevEventQuery = useRef(null) + const apiPromise = useRef(null) + const calendarComponentRef = useRef(null) + return ( + { + navigate(info.event.url) + // Prevent browser navigation to the url + info.jsEvent.preventDefault() + }} + calendarComponentRef={calendarComponentRef} + /> + ) + + function getEvents(fetchInfo, successCallback, failureCallback) { + const eventQuery = Object.assign({}, queryParams, { + status: Model.STATUS.ACTIVE, + pageSize: 0, + startDate: moment(fetchInfo.start).startOf("day"), + endDate: moment(fetchInfo.end).endOf("day") + }) + if (_isEqual(prevEventQuery.current, eventQuery)) { + // Optimise, return API promise instead of calling API.query again + return apiPromise.current + } + prevEventQuery.current = eventQuery + if (setTotalCount) { + // Reset the total count + setTotalCount(null) + } + // Store API promise to use in optimised case + showLoading() + apiPromise.current = API.query(Event.getEventListQuery, { + eventQuery + }).then(data => { + const events = data ? data.eventList.list : [] + if (setTotalCount) { + const { totalCount } = data.eventList + setTotalCount(totalCount) + } + const results = eventToCalendarEvents(events) + hideLoading() + return results + }) + return apiPromise.current + } +} + +EventCalendar.propTypes = { + pageDispatchers: PageDispatchersPropType, + queryParams: PropTypes.object, + setTotalCount: PropTypes.func +} + +export default EventCalendar diff --git a/client/src/components/EventCollection.js b/client/src/components/EventCollection.js new file mode 100644 index 0000000000..93591a6af3 --- /dev/null +++ b/client/src/components/EventCollection.js @@ -0,0 +1,150 @@ +import { setPagination } from "actions" +import ButtonToggleGroup from "components/ButtonToggleGroup" +import EventCalendar from "components/EventCalendar" +import EventMap from "components/EventMap" +import EventSummary from "components/EventSummary" +import EventTable from "components/EventTable" +import { + mapPageDispatchersToProps, + PageDispatchersPropType +} from "components/Page" +import PropTypes from "prop-types" +import React, { useState } from "react" +import { Button } from "react-bootstrap" +import { connect } from "react-redux" + +export const FORMAT_CALENDAR = "calendar" +export const FORMAT_MAP = "map" +export const FORMAT_SUMMARY = "summary" +export const FORMAT_TABLE = "table" + +const EventCollection = ({ + pageDispatchers, + paginationKey, + pagination, + setPagination, + viewFormats, + queryParams, + setTotalCount, + mapId, + width, + height, + marginBottom +}) => { + const [viewFormat, setViewFormat] = useState(viewFormats[0]) + const showHeader = viewFormats.length > 1 + return ( +
+
+ {showHeader && ( +
+ {viewFormats.length > 1 && ( + <> + + {viewFormats.includes(FORMAT_TABLE) && ( + + )} + {viewFormats.includes(FORMAT_SUMMARY) && ( + + )} + {viewFormats.includes(FORMAT_CALENDAR) && ( + + )} + {viewFormats.includes(FORMAT_MAP) && ( + + )} + + + )} +
+ )} + +
+ {viewFormat === FORMAT_TABLE && ( + + )} + {viewFormat === FORMAT_SUMMARY && ( + + )} + {viewFormat === FORMAT_CALENDAR && ( + + )} + {viewFormat === FORMAT_MAP && ( + + )} +
+
+
+ ) +} + +EventCollection.propTypes = { + pageDispatchers: PageDispatchersPropType, + paginationKey: PropTypes.string, + pagination: PropTypes.object.isRequired, + setPagination: PropTypes.func.isRequired, + viewFormats: PropTypes.arrayOf(PropTypes.string), + queryParams: PropTypes.object, + setTotalCount: PropTypes.func, + mapId: PropTypes.string, + width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + marginBottom: PropTypes.oneOfType([PropTypes.number, PropTypes.string]) +} + +EventCollection.defaultProps = { + viewFormats: [FORMAT_TABLE, FORMAT_SUMMARY, FORMAT_CALENDAR, FORMAT_MAP] +} + +const mapDispatchToProps = (dispatch, ownProps) => { + const pageDispatchers = mapPageDispatchersToProps(dispatch, ownProps) + return { + setPagination: (pageKey, pageNum) => + dispatch(setPagination(pageKey, pageNum)), + ...pageDispatchers + } +} + +const mapStateToProps = (state, ownProps) => ({ + pagination: state.pagination +}) + +export default connect(mapStateToProps, mapDispatchToProps)(EventCollection) diff --git a/client/src/components/EventMap.js b/client/src/components/EventMap.js new file mode 100644 index 0000000000..5146607c36 --- /dev/null +++ b/client/src/components/EventMap.js @@ -0,0 +1,67 @@ +import API from "api" +import EventsMapWidget from "components/aggregations/EventsMapWidget" +import { + mapPageDispatchersToProps, + PageDispatchersPropType, + useBoilerplate +} from "components/Page" +import { Event } from "models" +import PropTypes from "prop-types" +import React, { useEffect } from "react" +import { connect } from "react-redux" + +const EventMap = ({ + pageDispatchers, + queryParams, + setTotalCount, + mapId, + width, + height, + marginBottom +}) => { + const eventQuery = Object.assign({}, queryParams, { pageSize: 0 }) + const { loading, error, data } = API.useApiQuery(Event.getEventListQuery, { + eventQuery + }) + const { done, result } = useBoilerplate({ + loading, + error, + pageDispatchers + }) + // Update the total count + const totalCount = done ? null : data?.eventList?.totalCount + useEffect( + () => setTotalCount && setTotalCount(totalCount), + [setTotalCount, totalCount] + ) + if (done) { + return result + } + const events = data ? data.eventList.list : [] + return ( + No events with a location found} + /> + ) +} + +EventMap.propTypes = { + pageDispatchers: PageDispatchersPropType, + queryParams: PropTypes.object, + setTotalCount: PropTypes.func, + // pass mapId explicitly when you have more than one map on a page (else the default is fine): + mapId: PropTypes.string.isRequired, + width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + marginBottom: PropTypes.oneOfType([PropTypes.number, PropTypes.string]) +} + +EventMap.defaultProps = { + mapId: "events" +} +export default connect(null, mapPageDispatchersToProps)(EventMap) diff --git a/client/src/components/EventSeriesCollection.js b/client/src/components/EventSeriesCollection.js new file mode 100644 index 0000000000..4ad87c5a0f --- /dev/null +++ b/client/src/components/EventSeriesCollection.js @@ -0,0 +1,96 @@ +import { setPagination } from "actions" +import ButtonToggleGroup from "components/ButtonToggleGroup" +import EventSeriesTable from "components/EventSeriesTable" +import { + mapPageDispatchersToProps, + PageDispatchersPropType +} from "components/Page" +import PropTypes from "prop-types" +import React, { useState } from "react" +import { Button } from "react-bootstrap" +import { connect } from "react-redux" + +export const FORMAT_TABLE = "table" + +const EventSeriesCollection = ({ + pageDispatchers, + paginationKey, + pagination, + setPagination, + viewFormats, + queryParams, + setTotalCount +}) => { + const [viewFormat, setViewFormat] = useState(viewFormats[0]) + const showHeader = viewFormats.length > 1 + return ( +
+
+ {showHeader && ( +
+ {viewFormats.length > 1 && ( + <> + + {viewFormats.includes(FORMAT_TABLE) && ( + + )} + + + )} +
+ )} + +
+ {viewFormat === FORMAT_TABLE && ( + + )} +
+
+
+ ) +} + +EventSeriesCollection.propTypes = { + pageDispatchers: PageDispatchersPropType, + paginationKey: PropTypes.string, + pagination: PropTypes.object.isRequired, + setPagination: PropTypes.func.isRequired, + viewFormats: PropTypes.arrayOf(PropTypes.string), + queryParams: PropTypes.object, + setTotalCount: PropTypes.func +} + +EventSeriesCollection.defaultProps = { + viewFormats: [FORMAT_TABLE] +} + +const mapDispatchToProps = (dispatch, ownProps) => { + const pageDispatchers = mapPageDispatchersToProps(dispatch, ownProps) + return { + setPagination: (pageKey, pageNum) => + dispatch(setPagination(pageKey, pageNum)), + ...pageDispatchers + } +} + +const mapStateToProps = (state, ownProps) => ({ + pagination: state.pagination +}) + +export default connect( + mapStateToProps, + mapDispatchToProps +)(EventSeriesCollection) diff --git a/client/src/components/EventSeriesTable.js b/client/src/components/EventSeriesTable.js new file mode 100644 index 0000000000..f153a0d49a --- /dev/null +++ b/client/src/components/EventSeriesTable.js @@ -0,0 +1,129 @@ +import API from "api" +import LinkTo from "components/LinkTo" +import { PageDispatchersPropType, useBoilerplate } from "components/Page" +import UltimatePaginationTopDown from "components/UltimatePaginationTopDown" +import _get from "lodash/get" +import _isEqual from "lodash/isEqual" +import { EventSeries } from "models" +import PropTypes from "prop-types" +import React, { useEffect, useRef, useState } from "react" +import { Table } from "react-bootstrap" + +const DEFAULT_PAGESIZE = 10 + +const EventSeriesTable = ({ + pageDispatchers, + queryParams, + setTotalCount, + paginationKey, + pagination, + setPagination +}) => { + // (Re)set pageNum to 0 if the queryParams change, and make sure we retrieve page 0 in that case + const latestQueryParams = useRef(queryParams) + const queryParamsUnchanged = _isEqual(latestQueryParams.current, queryParams) + const [pageNum, setPageNum] = useState( + queryParamsUnchanged && pagination[paginationKey] + ? pagination[paginationKey].pageNum + : 0 + ) + useEffect(() => { + if (!queryParamsUnchanged) { + latestQueryParams.current = queryParams + setPagination(paginationKey, 0) + setPageNum(0) + } + }, [queryParams, setPagination, paginationKey, queryParamsUnchanged]) + const eventSeriesQuery = Object.assign({}, queryParams, { + pageNum: queryParamsUnchanged ? pageNum : 0, + pageSize: queryParams.pageSize || DEFAULT_PAGESIZE + }) + const { loading, error, data } = API.useApiQuery( + EventSeries.getEventSeriesListQuery, + { + eventSeriesQuery + } + ) + const { done, result } = useBoilerplate({ + loading, + error, + pageDispatchers + }) + // Update the total count + const totalCount = done ? null : data?.eventSeriesList?.totalCount + useEffect( + () => setTotalCount && setTotalCount(totalCount), + [setTotalCount, totalCount] + ) + if (done) { + return result + } + + const eventSeries = data + ? EventSeries.fromArray(data.eventSeriesList.list) + : [] + if (_get(eventSeries, "length", 0) === 0) { + return No event series found + } + + const { pageSize } = data.eventSeriesList + + return ( +
+ + + + + + + + + + + {eventSeries.map(eventSeries => ( + + + + + + ))} + +
NameHost OrganizationAdmin Organization
+ + + + + +
+
+
+ ) + + function setPage(pageNum) { + setPagination(paginationKey, pageNum) + setPageNum(pageNum) + } +} + +EventSeriesTable.propTypes = { + pageDispatchers: PageDispatchersPropType, + queryParams: PropTypes.object, + setTotalCount: PropTypes.func, + paginationKey: PropTypes.string.isRequired, + pagination: PropTypes.object.isRequired, + setPagination: PropTypes.func.isRequired +} + +export default EventSeriesTable diff --git a/client/src/components/EventSummary.js b/client/src/components/EventSummary.js new file mode 100644 index 0000000000..d18624bd18 --- /dev/null +++ b/client/src/components/EventSummary.js @@ -0,0 +1,201 @@ +import API from "api" +import LinkTo from "components/LinkTo" +import { PageDispatchersPropType, useBoilerplate } from "components/Page" +import UltimatePaginationTopDown from "components/UltimatePaginationTopDown" +import _get from "lodash/get" +import _isEmpty from "lodash/isEmpty" +import _isEqual from "lodash/isEqual" +import { Event, Location } from "models" +import moment from "moment" +import PropTypes from "prop-types" +import React, { useEffect, useRef, useState } from "react" +import { Badge, Col, Container, Row } from "react-bootstrap" +import Settings from "settings" + +const DEFAULT_PAGESIZE = 10 + +const EventSummary = ({ + pageDispatchers, + queryParams, + setTotalCount, + paginationKey, + pagination, + setPagination +}) => { + // (Re)set pageNum to 0 if the queryParams change, and make sure we retrieve page 0 in that case + const latestQueryParams = useRef(queryParams) + const queryParamsUnchanged = _isEqual(latestQueryParams.current, queryParams) + const [pageNum, setPageNum] = useState( + queryParamsUnchanged && pagination[paginationKey] + ? pagination[paginationKey].pageNum + : 0 + ) + useEffect(() => { + if (!queryParamsUnchanged) { + latestQueryParams.current = queryParams + setPagination(paginationKey, 0) + setPageNum(0) + } + }, [queryParams, setPagination, paginationKey, queryParamsUnchanged]) + const eventQuery = Object.assign({}, queryParams, { + pageNum: queryParamsUnchanged ? pageNum : 0, + pageSize: queryParams.pageSize || DEFAULT_PAGESIZE + }) + const { loading, error, data } = API.useApiQuery(Event.getEventListQuery, { + eventQuery + }) + const { done, result } = useBoilerplate({ + loading, + error, + pageDispatchers + }) + // Update the total count + const totalCount = done ? null : data?.eventList?.totalCount + useEffect( + () => setTotalCount && setTotalCount(totalCount), + [setTotalCount, totalCount] + ) + if (done) { + return result + } + + const events = data ? data.eventList.list : [] + if (_get(events, "length", 0) === 0) { + return No events found + } + + const { pageSize } = data.eventList + + return ( +
+ + {events.map(event => ( + + ))} + +
+ ) + + function setPage(pageNum) { + setPagination(paginationKey, pageNum) + setPageNum(pageNum) + } +} + +EventSummary.propTypes = { + pageDispatchers: PageDispatchersPropType, + queryParams: PropTypes.object, + setTotalCount: PropTypes.func, + paginationKey: PropTypes.string.isRequired, + setPagination: PropTypes.func.isRequired, + pagination: PropTypes.object.isRequired +} + +const EventSummaryRow = ({ event }) => { + event = new Event(event) + + return ( + + + + + {Settings.fields.event.name.label}: + {event.name} + + + + + + + {Settings.fields.event.type.label}: + {Event.humanNameOfType(event.type)} + + + + + + {Settings.fields.event.startDate.label}: + + {moment(event.startDate).format(Event.getEventDateFormat())} + + + + + + {Settings.fields.event.endDate.label}: + + {moment(event.endDate).format(Event.getEventDateFormat())} + + + + {!_isEmpty(event.hostOrg) && ( + + + + {Settings.fields.event.hostOrg.label}: + + + + + )} + {!_isEmpty(event.adminOrg) && ( + + + + {Settings.fields.event.adminOrg.label}: + + + + + )} + {!_isEmpty(event.eventSeries) && ( + + + + {Settings.fields.event.eventSeries.label}: + + + + + )} + {!_isEmpty(event.location) && ( + + + + {Settings.fields.event.location.label}: + + {" "} + + {Location.humanNameOfType(event.location.type)} + + + + + )} + + + + View Event + + + + + ) +} + +EventSummaryRow.propTypes = { + event: PropTypes.object.isRequired +} + +export default EventSummary diff --git a/client/src/components/EventTable.js b/client/src/components/EventTable.js new file mode 100644 index 0000000000..d5081ed5a7 --- /dev/null +++ b/client/src/components/EventTable.js @@ -0,0 +1,148 @@ +import API from "api" +import LinkTo from "components/LinkTo" +import { + mapPageDispatchersToProps, + PageDispatchersPropType, + useBoilerplate +} from "components/Page" +import UltimatePaginationTopDown from "components/UltimatePaginationTopDown" +import _get from "lodash/get" +import { Event } from "models" +import moment from "moment/moment" +import PropTypes from "prop-types" +import React, { useState } from "react" +import { Table } from "react-bootstrap" +import { connect } from "react-redux" + +const EventTable = props => { + if (props.queryParams) { + return + } + return +} + +EventTable.propTypes = { + // query variables for events, when query & pagination wanted: + queryParams: PropTypes.object +} + +const PaginatedEvents = ({ queryParams, pageDispatchers, ...otherProps }) => { + const [pageNum, setPageNum] = useState(0) + const eventQuery = Object.assign({}, queryParams, { pageNum }) + const { loading, error, data } = API.useApiQuery(Event.getEventListQuery, { + eventQuery + }) + const { done, result } = useBoilerplate({ + loading, + error, + pageDispatchers + }) + if (done) { + return result + } + + const { + pageSize, + pageNum: curPage, + totalCount, + list: events + } = data.eventList + + return ( + + ) +} + +PaginatedEvents.propTypes = { + pageDispatchers: PageDispatchersPropType, + queryParams: PropTypes.object +} + +const BaseEventTable = ({ + id, + events, + noEventsMessage, + pageSize, + pageNum, + totalCount, + goToPage +}) => { + if (_get(events, "length", 0) === 0) { + return {noEventsMessage} + } + + return ( +
+ + + + + + + + + + + + + + {events.map(event => ( + + + + + + + + + ))} + +
NameSeriesHost OrganizationLocationStart DateEnd Date
+ + + + + + + + + {moment(event.startDate).format(Event.getEventDateFormat())} + + {moment(event.endDate).format(Event.getEventDateFormat())} +
+
+
+ ) +} + +BaseEventTable.propTypes = { + id: PropTypes.string, + // list of events: + events: PropTypes.array.isRequired, + noEventsMessage: PropTypes.string, + // fill these when pagination wanted: + totalCount: PropTypes.number, + pageNum: PropTypes.number, + pageSize: PropTypes.number, + goToPage: PropTypes.func +} + +BaseEventTable.defaultProps = { + noEventsMessage: "No events found" +} + +export default connect(null, mapPageDispatchersToProps)(EventTable) diff --git a/client/src/components/Nav.js b/client/src/components/Nav.js index 5942b1ec19..404b0a91bd 100644 --- a/client/src/components/Nav.js +++ b/client/src/components/Nav.js @@ -156,6 +156,7 @@ const Navigation = ({ allOrganizations, resetPages, clearSearchQuery }) => { const inInsights = path.startsWith("/insights") const inDashboards = path.startsWith("/dashboards") const inMySavedSearches = path.startsWith("/search/mine") + const inMyEvents = path.startsWith("/events/mine") const allOrganizationUuids = allOrganizations.map(o => o.uuid) @@ -169,7 +170,8 @@ const Navigation = ({ allOrganizations, resetPages, clearSearchQuery }) => { inMyTasks || inMyAuthorizationGroups || inMySubscriptions || - inMySavedSearches + inMySavedSearches || + inMyEvents ) { setIsMenuLinksOpened(true) } @@ -180,7 +182,8 @@ const Navigation = ({ allOrganizations, resetPages, clearSearchQuery }) => { inMyTasks, inMyAuthorizationGroups, inMySubscriptions, - inMySavedSearches + inMySavedSearches, + inMyEvents ]) return ( @@ -301,6 +304,15 @@ const Navigation = ({ allOrganizations, resetPages, clearSearchQuery }) => { My Authorization Groups )} + {!_isEmpty(currentUser?.position?.organizationsAdministrated) && ( + + My Events + + )} diff --git a/client/src/components/SearchFilters.js b/client/src/components/SearchFilters.js index e8cd976a14..0fd48eed28 100644 --- a/client/src/components/SearchFilters.js +++ b/client/src/components/SearchFilters.js @@ -42,7 +42,7 @@ import DictionaryField from "components/DictionaryField" import Model from "components/Model" import _isEmpty from "lodash/isEmpty" import _pickBy from "lodash/pickBy" -import { Location, Person, Position, Report, Task } from "models" +import { Event, Location, Person, Position, Report, Task } from "models" import PropTypes from "prop-types" import React, { useContext } from "react" import PEOPLE_ICON from "resources/people.png" @@ -50,6 +50,9 @@ import POSITIONS_ICON from "resources/positions.png" import TASKS_ICON from "resources/tasks.png" import { RECURSE_STRATEGY } from "searchUtils" import Settings from "settings" +import EventSeriesFilter, { + deserialize as deserializeEventSeriesFilter +} from "./advancedSearch/EventSeriesFilter" export const SearchQueryPropType = PropTypes.shape({ text: PropTypes.string, @@ -684,6 +687,48 @@ export const searchFilters = function(includeAdminFilters) { } } } + const eventTypeOptions = [ + Event.EVENT_TYPES.EXERCISE, + Event.EVENT_TYPES.CONFERENCE, + Event.EVENT_TYPES.VISIT_BAN, + Event.EVENT_TYPES.OTHER + ] + + filters[SEARCH_OBJECT_TYPES.EVENTS] = { + filters: { + "Event Type": { + component: SelectFilter, + dictProps: Settings.fields.event.type, + deserializer: deserializeSelectFilter, + props: { + queryKey: "type", + options: eventTypeOptions, + labels: eventTypeOptions.map(lt => Event.humanNameOfType(lt)) + } + }, + "Event Series": { + component: EventSeriesFilter, + deserializer: deserializeEventSeriesFilter, + props: { + queryKey: "eventSeriesUuid" + } + }, + "Within Organization": { + component: OrganizationMultiFilter, + deserializer: deserializeOrganizationMultiFilter, + props: { + queryKey: "hostOrgUuid" + } + }, + "Within Location": { + component: LocationMultiFilter, + deserializer: deserializeLocationMultiFilter, + props: { + queryKey: "locationUuid" + } + } + } + } for (const filtersForType of Object.values(filters)) { filtersForType.filters.Status = StatusFilter diff --git a/client/src/components/advancedSearch/EventSeriesFilter.js b/client/src/components/advancedSearch/EventSeriesFilter.js new file mode 100644 index 0000000000..2b4f3e2963 --- /dev/null +++ b/client/src/components/advancedSearch/EventSeriesFilter.js @@ -0,0 +1,104 @@ +import API from "api" +import useSearchFilter from "components/advancedSearch/hooks" +import { EventSeriesOverlayRow } from "components/advancedSelectWidget/AdvancedSelectOverlayRow" +import AdvancedSingleSelect from "components/advancedSelectWidget/AdvancedSingleSelect" +import { EventSeries } from "models" +import PropTypes from "prop-types" +import React from "react" +import EVENTS_ICON from "resources/events.png" + +const EventSeriesFilter = ({ + asFormField, + queryKey, + value: inputValue, + onChange, + eventSeriesFilterQueryParams, + ...advancedSelectProps +}) => { + const defaultValue = { + value: inputValue.value || {} + } + const toQuery = val => { + return { + [queryKey]: val.value?.uuid + } + } + const [value, setValue] = useSearchFilter( + asFormField, + onChange, + inputValue, + defaultValue, + toQuery + ) + + const advancedSelectFilters = { + all: { + label: "All", + queryVars: eventSeriesFilterQueryParams + } + } + + return !asFormField ? ( + <>{value.value?.name} + ) : ( + + ) + + function handleChangeEventSeries(event) { + if (typeof event === "object") { + setValue(prevValue => ({ + ...prevValue, + value: event + })) + } + } +} +EventSeriesFilter.propTypes = { + queryKey: PropTypes.string.isRequired, + value: PropTypes.any, + onChange: PropTypes.func, + eventSeriesFilterQueryParams: PropTypes.object, + asFormField: PropTypes.bool +} +EventSeriesFilter.defaultProps = { + asFormField: true +} + +export const deserialize = ({ queryKey }, query, key) => { + if (query[queryKey]) { + return API.query(EventSeries.getEventSeriesQuery, { + uuid: query[queryKey] + }).then(data => { + if (data.eventSeries) { + return { + key, + value: { + value: data.eventSeries, + toQuery: { ...query } + } + } + } else { + return null + } + }) + } + return null +} + +export default EventSeriesFilter diff --git a/client/src/components/advancedSelectWidget/AdvancedSelect.js b/client/src/components/advancedSelectWidget/AdvancedSelect.js index 7a02616c5e..b0f7909e7b 100644 --- a/client/src/components/advancedSelectWidget/AdvancedSelect.js +++ b/client/src/components/advancedSelectWidget/AdvancedSelect.js @@ -31,7 +31,7 @@ AdvancedSelectTarget.propTypes = { const FilterAsNav = ({ items, currentFilter, handleOnClick }) => hasMultipleItems(items) && ( - +
    {Object.entries(items).map(([filterType, filter]) => (
  • - + ( {item.trigram} ) +export const EventSeriesOverlayRow = item => ( + + + + + + + +) + +export const EventOverlayRow = item => ( + + + + + + + +) export const LocationOverlayRow = item => ( diff --git a/client/src/components/aggregations/EventsMapWidget.js b/client/src/components/aggregations/EventsMapWidget.js new file mode 100644 index 0000000000..2af4a8e3ec --- /dev/null +++ b/client/src/components/aggregations/EventsMapWidget.js @@ -0,0 +1,65 @@ +import { + aggregationWidgetDefaultProps, + aggregationWidgetPropTypes +} from "components/aggregations/utils" +import Leaflet, { ICON_TYPES } from "components/Leaflet" +import _escape from "lodash/escape" +import _isEmpty from "lodash/isEmpty" +import { Location } from "models" +import PropTypes from "prop-types" +import React, { useMemo } from "react" + +const EventsMapWidget = ({ + values, + widgetId, + width, + height, + whenUnspecified, + ...otherWidgetProps +}) => { + const markers = useMemo(() => { + if (!values.length) { + return [] + } + const markerArray = [] + values.forEach(event => { + if (Location.hasCoordinates(event.location)) { + let label = _escape(event.name || "") // escape HTML in intent! + label += `
    @ ${_escape(event.location.name)}` // escape HTML in locationName! + markerArray.push({ + id: event.uuid, + icon: ICON_TYPES.GREEN, + lat: event.location.lat, + lng: event.location.lng, + name: label + }) + } + }) + return markerArray + }, [values]) + if (_isEmpty(markers)) { + return whenUnspecified + } + return ( +
    + +
    + ) +} +EventsMapWidget.propTypes = { + ...aggregationWidgetPropTypes, + width: PropTypes.number, + height: PropTypes.number +} +EventsMapWidget.defaultProps = { + values: [], + ...aggregationWidgetDefaultProps +} + +export default EventsMapWidget diff --git a/client/src/components/aggregations/utils.js b/client/src/components/aggregations/utils.js index a61b3d3ab2..7073410a1e 100644 --- a/client/src/components/aggregations/utils.js +++ b/client/src/components/aggregations/utils.js @@ -2,7 +2,7 @@ import RichTextEditor from "components/RichTextEditor" import _clone from "lodash/clone" import _cloneDeep from "lodash/cloneDeep" import _isEmpty from "lodash/isEmpty" -import { Person, Report } from "models" +import { Event, Person, Report } from "models" import moment from "moment" import { AssessmentPeriodPropType, PeriodPropType } from "periodUtils" import PropTypes from "prop-types" @@ -256,3 +256,32 @@ export function reportsToEvents(reports, showInterlocutors) { r1.title.localeCompare(r2.title) ) } + +export function eventToCalendarEvents(events) { + return events + .map(event => { + // If no other data available title is the location name + const title = `${event.name}@${event.location.name}` + const start = new Date(event.startDate) + start.setSeconds(0, 0) // truncate at the minute part + const end = new Date(event.endDate) + end.setSeconds(0, 0) // truncate at the minute part + return { + title, + start, + end, + url: Event.pathFor(event), + extendedProps: { ...event }, + allDay: true + } + }) + .sort( + (e1, e2) => + // ascending by start date + e1.start - e2.start || + // ascending by end date + e1.end - e1.end || + // and finally ascending by title + e1.title.localeCompare(e2.title) + ) +} diff --git a/client/src/components/previews/EventPreview.js b/client/src/components/previews/EventPreview.js new file mode 100644 index 0000000000..f77f96d08e --- /dev/null +++ b/client/src/components/previews/EventPreview.js @@ -0,0 +1,85 @@ +import API from "api" +import DictionaryField from "components/DictionaryField" +import { PreviewField } from "components/FieldHelper" +import LinkTo from "components/LinkTo" +import { Event } from "models" +import moment from "moment" +import PropTypes from "prop-types" +import React from "react" +import Settings from "settings" + +const EventPreview = ({ className, uuid }) => { + const { data, error } = API.useApiQuery(Event.getEventQuery, { + uuid + }) + + if (!data) { + if (error) { + return

    Could not load the preview

    + } + return null + } + + const event = new Event(data.event) + + const eventTitle = event.name || `#${event.uuid}` + return ( +
    +

    Event {eventTitle}

    +
    + + + + + } + /> + + } + /> + + + ) + } + /> +
    +
    + ) +} + +EventPreview.propTypes = { + className: PropTypes.string, + uuid: PropTypes.string +} + +export default EventPreview diff --git a/client/src/components/previews/EventSeriesPreview.js b/client/src/components/previews/EventSeriesPreview.js new file mode 100644 index 0000000000..57aca615c9 --- /dev/null +++ b/client/src/components/previews/EventSeriesPreview.js @@ -0,0 +1,52 @@ +import API from "api" +import { PreviewField } from "components/FieldHelper" +import LinkTo from "components/LinkTo" +import { EventSeries } from "models" +import PropTypes from "prop-types" +import React from "react" +import Settings from "settings" + +const EventSeriesPreview = ({ className, uuid }) => { + const { data, error } = API.useApiQuery(EventSeries.getEventSeriesQuery, { + uuid + }) + + if (!data) { + if (error) { + return

    Could not load the preview

    + } + return null + } + + const eventSeries = new EventSeries(data.eventSeries) + + const eventSeriesTitle = eventSeries.name || `#${eventSeries.uuid}` + return ( +
    +

    Event Series {eventSeriesTitle}

    +
    + + } + /> + + } + /> +
    +
    + ) +} + +EventSeriesPreview.propTypes = { + className: PropTypes.string, + uuid: PropTypes.string +} + +export default EventSeriesPreview diff --git a/client/src/components/previews/RegisterPreviewComponents.js b/client/src/components/previews/RegisterPreviewComponents.js index 8189dd1c81..aa2b044517 100644 --- a/client/src/components/previews/RegisterPreviewComponents.js +++ b/client/src/components/previews/RegisterPreviewComponents.js @@ -1,5 +1,7 @@ import AttachmentPreview from "./AttachmentPreview" import AuthorizationGroupPreview from "./AuthorizationGroupPreview" +import EventPreview from "./EventPreview" +import EventSeriesPreview from "./EventSeriesPreview" import LocationPreview from "./LocationPreview" import OrganizationPreview from "./OrganizationPreview" import PersonPreview from "./PersonPreview" @@ -10,6 +12,8 @@ import TaskPreview from "./TaskPreview" registerPreviewComponent("Attachment", AttachmentPreview) registerPreviewComponent("AuthorizationGroup", AuthorizationGroupPreview) +registerPreviewComponent("Event", EventPreview) +registerPreviewComponent("EventSeries", EventSeriesPreview) registerPreviewComponent("Location", LocationPreview) registerPreviewComponent("Organization", OrganizationPreview) registerPreviewComponent("Person", PersonPreview) diff --git a/client/src/exportUtils.js b/client/src/exportUtils.js index 6f03fe3795..0afa1ba55b 100644 --- a/client/src/exportUtils.js +++ b/client/src/exportUtils.js @@ -261,6 +261,46 @@ const GQL_GET_AUTHORIZATION_GROUP_LIST = gql` } ` +const GQL_GET_EVENT_LIST = gql` + fragment events on Query { + events: eventList(query: $eventQuery) { + pageNum + pageSize + totalCount + list { + uuid + type + name + startDate + endDate + hostOrg { + uuid + shortName + longName + identificationCode + } + adminOrg { + uuid + shortName + longName + identificationCode + } + eventSeries { + uuid + name + } + location { + uuid + name + lat + lng + } + updatedAt + } + } + } +` + const GQL_GET_DATA = gql` query ( $includeOrganizations: Boolean! @@ -277,6 +317,8 @@ const GQL_GET_DATA = gql` $reportQuery: ReportSearchQueryInput $includeAuthorizationGroups: Boolean! $authorizationGroupQuery: AuthorizationGroupSearchQueryInput + $includeEvents: Boolean! + $eventQuery: EventSearchQueryInput $emailNetwork: String ) { ...organizations @include(if: $includeOrganizations) @@ -286,6 +328,7 @@ const GQL_GET_DATA = gql` ...locations @include(if: $includeLocations) ...reports @include(if: $includeReports) ...authorizationGroups @include(if: $includeAuthorizationGroups) + ...events @include(if: $includeEvents) } ${GQL_GET_ORGANIZATION_LIST} @@ -295,6 +338,7 @@ const GQL_GET_DATA = gql` ${GQL_GET_LOCATION_LIST} ${GQL_GET_REPORT_LIST} ${GQL_GET_AUTHORIZATION_GROUP_LIST} + ${GQL_GET_EVENT_LIST} ` export const exportResults = ( searchQueryParams, @@ -314,6 +358,7 @@ export const exportResults = ( const includeAuthorizationGroups = queryTypes.includes( SEARCH_OBJECT_TYPES.AUTHORIZATION_GROUPS ) + const includeEvents = queryTypes.includes(SEARCH_OBJECT_TYPES.EVENTS) const organizationQuery = !includeOrganizations ? {} : Object.assign({}, searchQueryParams, { @@ -363,6 +408,13 @@ export const exportResults = ( sortBy: "NAME", sortOrder: "DESC" }) + const eventQuery = !includeEvents + ? {} + : Object.assign({}, searchQueryParams, { + pageSize: maxNumberResults, + sortBy: "NAME", + sortOrder: "DESC" + }) const { emailNetwork } = searchQueryParams const variables = { includeOrganizations, @@ -376,9 +428,11 @@ export const exportResults = ( includeLocations, locationQuery, includeReports, + includeEvents, reportQuery, includeAuthorizationGroups, authorizationGroupQuery, + eventQuery, emailNetwork } return API.queryExport(GQL_GET_DATA, variables, exportType) diff --git a/client/src/index.css b/client/src/index.css index 7157bc86ce..cfe7ca5252 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1239,4 +1239,17 @@ div[id*='fg-entityAssessment'] { .form-horizontal .form-label { text-align: left; } + .event-collection header { + margin-bottom: 18px; + } + + .event-collection footer { + margin-top: 18px; + } + + .event-summary { + margin-top: 25px; + padding-top: 15px; + box-shadow: 0 1px 3px hsla(0, 0%, 0%, 0.15); + } } diff --git a/client/src/models.js b/client/src/models.js index cb65024238..947603142e 100644 --- a/client/src/models.js +++ b/client/src/models.js @@ -1,6 +1,8 @@ export Attachment from "models/Attachment" export AuthorizationGroup from "models/AuthorizationGroup" export Comment from "models/Comment" +export Event from "models/Event" +export EventSeries from "models/EventSeries" export Location from "models/Location" export Organization from "models/Organization" export Person from "models/Person" diff --git a/client/src/models/Event.js b/client/src/models/Event.js new file mode 100644 index 0000000000..004cce0216 --- /dev/null +++ b/client/src/models/Event.js @@ -0,0 +1,328 @@ +import { gql } from "@apollo/client" +import Model, { yupDate } from "components/Model" +import _isEmpty from "lodash/isEmpty" +import Settings from "settings" +import utils from "utils" +import * as yup from "yup" + +export default class Event extends Model { + static resourceName = "Event" + static listName = "eventList" + static getInstanceName = "Event" + static relatedObjectType = "Event" + + static displayName() { + return "Event" + } + + static EVENT_TYPES = { + OTHER: "OTHER", + CONFERENCE: "CONFERENCE", + EXERCISE: "EXERCISE", + VISIT_BAN: "VISIT_BAN" + } + + static schema = {} + + static yupSchema = yup.object().shape({ + status: yup + .string() + .required() + .default(() => Model.STATUS.ACTIVE), + type: yup.string().required().default(""), + name: yup.string().required().default(""), + description: yup.string().required().default(""), + startDate: yupDate.required().default(null), + endDate: yupDate.required().default(null), + outcomes: yup.string().default(""), + hostOrg: yup + .object() + .test("hostOrg", "host org error", (hostOrg, testContext) => + _isEmpty(hostOrg) + ? testContext.createError({ + message: `You must provide the ${Settings.fields.eventSeries.hostOrg.label}` + }) + : true + ) + .default({}), + adminOrg: yup + .object() + .test("adminOrg", "admin org error", (adminOrg, testContext) => + _isEmpty(adminOrg) + ? testContext.createError({ + message: `You must provide the ${Settings.fields.eventSeries.adminOrg.label}` + }) + : true + ) + .default({}), + eventSeries: yup.object().nullable().default({}), + location: yup.object().nullable().default({}), + tasks: yup.array().nullable().default([]) + }) + + static autocompleteQuery = ` + uuid + name + description + startDate + endDate + outcomes + hostOrg { + uuid + shortName + } + adminOrg { + uuid + shortName + } + location { + uuid + name + } + location { + uuid + name + } + tasks { + uuid + shortName + longName + parentTask { + uuid + shortName + } + ascendantTasks { + uuid + shortName + parentTask { + uuid + } + } + taskedOrganizations { + uuid + shortName + longName + identificationCode + } + customFields + } + ` + + static basicFieldsQuery = + "uuid type name description hostOrg adminOrg eventSeries location startDate endDate outcomes tasks" + + static getEventQueryNoIsSubscribed = gql` + query ($uuid: String) { + event(uuid: $uuid) { + uuid + status + type + name + description + startDate + endDate + outcomes + updatedAt + hostOrg { + uuid + shortName + longName + } + adminOrg { + uuid + shortName + longName + } + eventSeries { + uuid + name + description + } + location { + uuid + name + lat + lng + } + tasks { + uuid + shortName + longName + parentTask { + uuid + shortName + } + ascendantTasks { + uuid + shortName + parentTask { + uuid + } + } + taskedOrganizations { + uuid + shortName + longName + identificationCode + } + customFields + } + } + } + ` + + static getEventQuery = gql` + query ($uuid: String) { + event(uuid: $uuid) { + uuid + status + type + name + description + startDate + endDate + outcomes + isSubscribed + updatedAt + hostOrg { + uuid + shortName + longName + } + adminOrg { + uuid + shortName + longName + } + eventSeries { + uuid + name + description + } + location { + uuid + name + lat + lng + } + tasks { + uuid + shortName + longName + parentTask { + uuid + shortName + } + ascendantTasks { + uuid + shortName + parentTask { + uuid + } + } + taskedOrganizations { + uuid + shortName + longName + identificationCode + } + customFields + } + } + } + ` + static getEventListQuery = gql` + query ($eventQuery: EventSearchQueryInput) { + eventList(query: $eventQuery) { + pageNum + pageSize + totalCount + list { + uuid + status + type + name + description + startDate + endDate + hostOrg { + uuid + shortName + longName + identificationCode + } + adminOrg { + uuid + shortName + longName + identificationCode + } + eventSeries { + uuid + name + } + location { + uuid + name + lat + lng + } + updatedAt + } + } + } + ` + static getCreateEventMutation = gql` + mutation ($event: EventInput!) { + createEvent(event: $event) { + uuid + } + } + ` + static getUpdateEventMutation = gql` + mutation ($event: EventInput!) { + updateEvent(event: $event) + } + ` + constructor(props) { + super(Model.fillObject(props, Event.yupSchema)) + } + + static FILTERED_CLIENT_SIDE_FIELDS = [] + + static filterClientSideFields(obj, ...additionalFields) { + return Model.filterClientSideFields( + obj, + ...Event.FILTERED_CLIENT_SIDE_FIELDS, + ...additionalFields + ) + } + + filterClientSideFields(...additionalFields) { + return Event.filterClientSideFields(this, ...additionalFields) + } + + static getEventDateFormat() { + return Settings.engagementsIncludeTimeAndDuration + ? Settings.dateFormats.forms.displayLong.withTime + : Settings.dateFormats.forms.displayLong.date + } + + static humanNameOfType(type) { + return utils.sentenceCase(type) + } + + static getEventFilters(filterDefs) { + return filterDefs?.reduce((accumulator, filter) => { + accumulator[filter] = { + label: Event.humanNameOfType(filter), + queryVars: { type: filter } + } + return accumulator + }, {}) + } + + static getReportEventFilters() { + return Event.getEventFilters(Settings?.fields?.report?.event?.filter) + } +} diff --git a/client/src/models/EventSeries.js b/client/src/models/EventSeries.js new file mode 100644 index 0000000000..36592521b4 --- /dev/null +++ b/client/src/models/EventSeries.js @@ -0,0 +1,165 @@ +import { gql } from "@apollo/client" +import Model from "components/Model" +import _isEmpty from "lodash/isEmpty" +import Settings from "settings" +import * as yup from "yup" + +export default class EventSeries extends Model { + static resourceName = "EventSeries" + static listName = "eventSeriesList" + static getInstanceName = "Event Series" + static relatedObjectType = "Event Series" + + static displayName() { + return "Event Series" + } + + static schema = {} + + static yupSchema = yup.object().shape({ + status: yup + .string() + .required() + .default(() => Model.STATUS.ACTIVE), + name: yup.string().required().default(""), + description: yup.string().required().default(""), + hostOrg: yup + .object() + .test("hostOrg", "host org error", (hostOrg, testContext) => + _isEmpty(hostOrg) + ? testContext.createError({ + message: `You must provide the ${Settings.fields.eventSeries.hostOrg.label}` + }) + : true + ) + .default({}), + adminOrg: yup + .object() + .test("adminOrg", "admin org error", (adminOrg, testContext) => + _isEmpty(adminOrg) + ? testContext.createError({ + message: `You must provide the ${Settings.fields.eventSeries.adminOrg.label}` + }) + : true + ) + .default({}) + }) + + static basicFieldsQuery = "uuid name description hostOrg adminOrg" + + static autocompleteQuery = ` + uuid + status + name + description + hostOrg { + uuid + shortName + } + adminOrg { + uuid + shortName + } + ` + + static getEventSeriesQueryMin = gql` + query ($uuid: String) { + eventSeries(uuid: $uuid) { + uuid + name + hostOrg { + uuid + shortName + longName + } + adminOrg { + uuid + shortName + longName + } + } + } + ` + + static getEventSeriesQuery = gql` + query ($uuid: String) { + eventSeries(uuid: $uuid) { + uuid + name + description + updatedAt + isSubscribed + hostOrg { + uuid + shortName + longName + } + adminOrg { + uuid + shortName + longName + } + } + } + ` + + static getEventSeriesListQuery = gql` + query ($eventSeriesQuery: EventSeriesSearchQueryInput) { + eventSeriesList(query: $eventSeriesQuery) { + pageNum + pageSize + totalCount + list { + uuid + name + description + isSubscribed + hostOrg { + uuid + shortName + longName + identificationCode + } + adminOrg { + uuid + shortName + longName + identificationCode + } + updatedAt + } + } + } + ` + + static getCreateEventSeriesMutation = gql` + mutation ($eventSeries: EventSeriesInput!) { + createEventSeries(eventSeries: $eventSeries) { + uuid + } + } + ` + + static getUpdateEventSeriesMutation = gql` + mutation ($eventSeries: EventSeriesInput!) { + updateEventSeries(eventSeries: $eventSeries) + } + ` + constructor(props) { + super(Model.fillObject(props, EventSeries.yupSchema)) + } + + static FILTERED_CLIENT_SIDE_FIELDS = [] + + static filterClientSideFields(obj, ...additionalFields) { + return Model.filterClientSideFields( + obj, + ...EventSeries.FILTERED_CLIENT_SIDE_FIELDS, + ...additionalFields + ) + } + + filterClientSideFields(...additionalFields) { + return EventSeries.filterClientSideFields(this, ...additionalFields) + } +} diff --git a/client/src/models/Report.js b/client/src/models/Report.js index 64a712dcde..83c59ccdab 100644 --- a/client/src/models/Report.js +++ b/client/src/models/Report.js @@ -261,7 +261,8 @@ export default class Report extends Model { .nullable() .default({ uuid: null, text: null }), authorizationGroups: yup.array().nullable().default([]), - classification: yup.string().nullable().default(null) + classification: yup.string().nullable().default(null), + event: yup.object().nullable() }) // not actually in the database, the database contains the JSON customFields .concat(Report.customFieldsSchema) diff --git a/client/src/pages/Routing.js b/client/src/pages/Routing.js index e4575c3f1a..77f29af040 100644 --- a/client/src/pages/Routing.js +++ b/client/src/pages/Routing.js @@ -18,6 +18,13 @@ import AuthorizationGroupShow from "pages/authorizationGroups/Show" import BoardDashboard from "pages/dashboards/BoardDashboard" import DecisivesDashboard from "pages/dashboards/DecisivesDashboard" import KanbanDashboard from "pages/dashboards/KanbanDashboard" +import EventEdit from "pages/events/Edit" +import MyEvents from "pages/events/MyEvents" +import EventNew from "pages/events/New" +import EventShow from "pages/events/Show" +import EventSeriesEdit from "pages/eventSeries/Edit" +import EventSeriesNew from "pages/eventSeries/New" +import EventSeriesShow from "pages/eventSeries/Show" import GraphiQL from "pages/GraphiQL" import Help from "pages/Help" import Home from "pages/Home" @@ -207,6 +214,21 @@ const Routing = () => { } /> } /> + + } /> + + } /> + } /> + + + + } /> + } /> + + } /> + } /> + + ) } diff --git a/client/src/pages/eventSeries/Edit.js b/client/src/pages/eventSeries/Edit.js new file mode 100644 index 0000000000..a803abf3bb --- /dev/null +++ b/client/src/pages/eventSeries/Edit.js @@ -0,0 +1,53 @@ +import { DEFAULT_SEARCH_PROPS, PAGE_PROPS_NO_NAV } from "actions" +import API from "api" +import { + mapPageDispatchersToProps, + PageDispatchersPropType, + useBoilerplate, + usePageTitle +} from "components/Page" +import { EventSeries } from "models" +import React from "react" +import { connect } from "react-redux" +import { useParams } from "react-router-dom" +import EventSeriesForm from "./Form" + +const EventSeriesEdit = ({ pageDispatchers }) => { + const { uuid } = useParams() + const { loading, error, data } = API.useApiQuery( + EventSeries.getEventSeriesQuery, + { + uuid + } + ) + const { done, result } = useBoilerplate({ + loading, + error, + modelName: "EventSeries", + uuid, + pageProps: PAGE_PROPS_NO_NAV, + searchProps: DEFAULT_SEARCH_PROPS, + pageDispatchers + }) + usePageTitle(data?.eventSeries?.name && `Edit | ${data.eventSeries.name}`) + if (done) { + return result + } + + const eventSeries = new EventSeries(data ? data.eventSeries : {}) + return ( +
    + +
    + ) +} + +EventSeriesEdit.propTypes = { + pageDispatchers: PageDispatchersPropType +} + +export default connect(null, mapPageDispatchersToProps)(EventSeriesEdit) diff --git a/client/src/pages/eventSeries/Form.js b/client/src/pages/eventSeries/Form.js new file mode 100644 index 0000000000..8c7de2c891 --- /dev/null +++ b/client/src/pages/eventSeries/Form.js @@ -0,0 +1,256 @@ +import API from "api" +import { OrganizationOverlayRow } from "components/advancedSelectWidget/AdvancedSelectOverlayRow" +import AdvancedSingleSelect from "components/advancedSelectWidget/AdvancedSingleSelect" +import AppContext from "components/AppContext" +import DictionaryField from "components/DictionaryField" +import * as FieldHelper from "components/FieldHelper" +import Fieldset from "components/Fieldset" +import Messages from "components/Messages" +import Model from "components/Model" +import NavigationWarning from "components/NavigationWarning" +import { jumpToTop } from "components/Page" +import RichTextEditor from "components/RichTextEditor" +import { FastField, Field, Form, Formik } from "formik" +import _isEqual from "lodash/isEqual" +import { EventSeries, Organization } from "models" +import PropTypes from "prop-types" +import React, { useContext, useState } from "react" +import { Button } from "react-bootstrap" +import { useNavigate } from "react-router-dom" +import ORGANIZATIONS_ICON from "resources/organizations.png" +import { RECURSE_STRATEGY } from "searchUtils" +import Settings from "settings" +import utils from "utils" + +const organizationAutocompleteQuery = `${Organization.autocompleteQuery} ascendantOrgs { uuid app6context app6standardIdentity parentOrg { uuid } }` + +const EventSeriesForm = ({ edit, title, initialValues, notesComponent }) => { + const { loadAppData, currentUser } = useContext(AppContext) + const navigate = useNavigate() + const [error, setError] = useState(null) + + return ( + + {({ + isSubmitting, + dirty, + setFieldValue, + setFieldTouched, + values, + validateForm, + submitForm + }) => { + const isAdmin = currentUser && currentUser.isAdmin() + const hostOrgSearchQuery = { status: Model.STATUS.ACTIVE } + const adminOrgSearchQuery = { status: Model.STATUS.ACTIVE } + // Superusers can select parent organizations among the ones their position is administrating + if (!isAdmin) { + const orgsAdministratedUuids = + currentUser.position.organizationsAdministrated.map(org => org.uuid) + adminOrgSearchQuery.parentOrgUuid = [ + currentUser.position.organization.uuid, + ...orgsAdministratedUuids + ] + adminOrgSearchQuery.orgRecurseStrategy = RECURSE_STRATEGY.CHILDREN + } + + const action = ( + <> + + {notesComponent} + + ) + const organizationFilters = { + allOrganizations: { + label: "All organizations", + queryVars: {} + } + } + + return ( +
    + + +
    +
    +
    + + { + // prevent initial unnecessary render of RichTextEditor + if (!_isEqual(values.description, value)) { + setFieldValue("description", value, true) + } + }} + onHandleBlur={() => { + // validation will be done by setFieldValue + setFieldTouched("description", true, false) + }} + widget={ + + } + /> + { + // validation will be done by setFieldValue + setFieldTouched("hostOrg", true, false) // onBlur doesn't work when selecting an option + setFieldValue("hostOrg", value) + }} + widget={ + + } + /> + { + // validation will be done by setFieldValue + setFieldTouched("adminOrg", true, false) // onBlur doesn't work when selecting an option + setFieldValue("adminOrg", value) + }} + widget={ + + } + /> +
    +
    +
    + +
    +
    + +
    +
    + +
    + ) + }} +
    + ) + + function onCancel() { + navigate(-1) + } + + function onSubmit(values, form) { + return save(values, form) + .then(response => onSubmitSuccess(response, values, form)) + .catch(error => { + setError(error) + form.setSubmitting(false) + jumpToTop() + }) + } + + function onSubmitSuccess(response, values, form) { + const operation = edit ? "updateEventSeries" : "createEventSeries" + const eventSeries = new EventSeries({ + uuid: response[operation].uuid + ? response[operation].uuid + : initialValues.uuid + }) + // reset the form to latest values + // to avoid unsaved changes prompt if it somehow becomes dirty + form.resetForm({ values, isSubmitting: true }) + loadAppData() + if (!edit) { + navigate(EventSeries.pathForEdit(eventSeries), { replace: true }) + } + navigate(EventSeries.pathFor(eventSeries), { + state: { success: "Event series saved" } + }) + } + + function save(values, form) { + const eventSeries = EventSeries.filterClientSideFields( + new EventSeries(values) + ) + // strip tasks fields not in data model + eventSeries.hostOrg = utils.getReference(eventSeries.hostOrg) + eventSeries.adminOrg = utils.getReference(eventSeries.adminOrg) + return API.mutation( + edit + ? EventSeries.getUpdateEventSeriesMutation + : EventSeries.getCreateEventSeriesMutation, + { eventSeries } + ).then() + } +} + +EventSeriesForm.propTypes = { + initialValues: PropTypes.instanceOf(EventSeries).isRequired, + title: PropTypes.string, + edit: PropTypes.bool, + notesComponent: PropTypes.node +} + +EventSeriesForm.defaultProps = { + title: "", + edit: false +} + +export default EventSeriesForm diff --git a/client/src/pages/eventSeries/New.js b/client/src/pages/eventSeries/New.js new file mode 100644 index 0000000000..5d17c27f33 --- /dev/null +++ b/client/src/pages/eventSeries/New.js @@ -0,0 +1,38 @@ +import { DEFAULT_SEARCH_PROPS, PAGE_PROPS_NO_NAV } from "actions" +import { initInvisibleFields } from "components/CustomFields" +import { + mapPageDispatchersToProps, + PageDispatchersPropType, + useBoilerplate, + usePageTitle +} from "components/Page" +import { EventSeries } from "models" +import React from "react" +import { connect } from "react-redux" +import Settings from "settings" +import EventSeriesForm from "./Form" + +const EventSeriesNew = ({ pageDispatchers }) => { + useBoilerplate({ + pageProps: PAGE_PROPS_NO_NAV, + searchProps: DEFAULT_SEARCH_PROPS, + pageDispatchers + }) + usePageTitle("New Event Series") + + const eventSeries = new EventSeries() + // mutates the object + initInvisibleFields(location, Settings.fields.location.customFields) + return ( + + ) +} + +EventSeriesNew.propTypes = { + pageDispatchers: PageDispatchersPropType +} + +export default connect(null, mapPageDispatchersToProps)(EventSeriesNew) diff --git a/client/src/pages/eventSeries/Show.js b/client/src/pages/eventSeries/Show.js new file mode 100644 index 0000000000..3370a83825 --- /dev/null +++ b/client/src/pages/eventSeries/Show.js @@ -0,0 +1,187 @@ +import { DEFAULT_PAGE_PROPS, DEFAULT_SEARCH_PROPS } from "actions" +import API from "api" +import AppContext from "components/AppContext" +import DictionaryField from "components/DictionaryField" +import EventCollection from "components/EventCollection" +import * as FieldHelper from "components/FieldHelper" +import Fieldset from "components/Fieldset" +import LinkTo from "components/LinkTo" +import Messages from "components/Messages" +import { + jumpToTop, + mapPageDispatchersToProps, + PageDispatchersPropType, + SubscriptionIcon, + useBoilerplate, + usePageTitle +} from "components/Page" +import RichTextEditor from "components/RichTextEditor" +import { Field, Form, Formik } from "formik" +import { Event, EventSeries } from "models" +import React, { useContext, useState } from "react" +import { connect } from "react-redux" +import { useLocation, useParams } from "react-router-dom" +import Settings from "settings" + +const EventSeriesShow = ({ pageDispatchers }) => { + const { currentUser } = useContext(AppContext) + const routerLocation = useLocation() + const stateSuccess = routerLocation.state && routerLocation.state.success + const [stateError, setStateError] = useState( + routerLocation.state && routerLocation.state.error + ) + const { uuid } = useParams() + const { loading, error, data, refetch } = API.useApiQuery( + EventSeries.getEventSeriesQuery, + { + uuid + } + ) + const { done, result } = useBoilerplate({ + loading, + error, + modelName: "EventSeries", + uuid, + pageProps: DEFAULT_PAGE_PROPS, + searchProps: DEFAULT_SEARCH_PROPS, + pageDispatchers + }) + usePageTitle(data?.eventSeries?.name) + if (done) { + return result + } + + const eventSeries = new EventSeries(data ? data.eventSeries : {}) + + const canAdministrateOrg = + currentUser && + currentUser.hasAdministrativePermissionsForOrganization( + eventSeries.adminOrg + ) + const eventQueryParams = { + eventSeriesUuid: uuid + } + + return ( + + {({ values }) => { + const action = ( + <> + {canAdministrateOrg && ( + + Edit + + )} + + ) + return ( +
    + +
    +
    + { + { + setStateError(error) + jumpToTop() + }} + persistent + /> + }{" "} + Event Series {eventSeries.name} + + } + action={action} + /> +
    + + ) + } + /> + + ) + } + /> +
    +
    + +
    +
    + Create event + + ) + } + > + +
    + +
    + ) + }} +
    + ) +} + +EventSeriesShow.propTypes = { + pageDispatchers: PageDispatchersPropType +} + +const mapStateToProps = (state, ownProps) => ({ + pagination: state.pagination +}) + +export default connect( + mapStateToProps, + mapPageDispatchersToProps +)(EventSeriesShow) diff --git a/client/src/pages/events/Edit.js b/client/src/pages/events/Edit.js new file mode 100644 index 0000000000..de1804200b --- /dev/null +++ b/client/src/pages/events/Edit.js @@ -0,0 +1,49 @@ +import { DEFAULT_SEARCH_PROPS, PAGE_PROPS_NO_NAV } from "actions" +import API from "api" +import { + mapPageDispatchersToProps, + PageDispatchersPropType, + useBoilerplate, + usePageTitle +} from "components/Page" +import { Event } from "models" +import React from "react" +import { connect } from "react-redux" +import { useParams } from "react-router-dom" +import EventForm from "./Form" + +const EventEdit = ({ pageDispatchers }) => { + const { uuid } = useParams() + const { loading, error, data } = API.useApiQuery( + Event.getEventQueryNoIsSubscribed, + { + uuid + } + ) + const { done, result } = useBoilerplate({ + loading, + error, + modelName: "Event", + uuid, + pageProps: PAGE_PROPS_NO_NAV, + searchProps: DEFAULT_SEARCH_PROPS, + pageDispatchers + }) + usePageTitle(data?.event?.name && `Edit | ${data.event.name}`) + if (done) { + return result + } + + const event = new Event(data ? data.event : {}) + return ( +
    + +
    + ) +} + +EventEdit.propTypes = { + pageDispatchers: PageDispatchersPropType +} + +export default connect(null, mapPageDispatchersToProps)(EventEdit) diff --git a/client/src/pages/events/Form.js b/client/src/pages/events/Form.js new file mode 100644 index 0000000000..94c4026295 --- /dev/null +++ b/client/src/pages/events/Form.js @@ -0,0 +1,601 @@ +import { gql } from "@apollo/client" +import API from "api" +import AdvancedMultiSelect from "components/advancedSelectWidget/AdvancedMultiSelect" +import { + EventSeriesOverlayRow, + LocationOverlayRow, + OrganizationOverlayRow +} from "components/advancedSelectWidget/AdvancedSelectOverlayRow" +import AdvancedSingleSelect from "components/advancedSelectWidget/AdvancedSingleSelect" +import AppContext from "components/AppContext" +import CustomDateInput from "components/CustomDateInput" +import DictionaryField from "components/DictionaryField" +import * as FieldHelper from "components/FieldHelper" +import Fieldset from "components/Fieldset" +import Messages from "components/Messages" +import Model from "components/Model" +import NavigationWarning from "components/NavigationWarning" +import NoPaginationTaskTable from "components/NoPaginationTaskTable" +import { + jumpToTop, + mapPageDispatchersToProps, + PageDispatchersPropType, + useBoilerplate +} from "components/Page" +import RichTextEditor from "components/RichTextEditor" +import { FastField, Field, Form, Formik } from "formik" +import _isEmpty from "lodash/isEmpty" +import _isEqual from "lodash/isEqual" +import { Event, EventSeries, Location, Organization, Task } from "models" +import CreateNewLocation from "pages/locations/CreateNewLocation" +import pluralize from "pluralize" +import PropTypes from "prop-types" +import React, { useContext, useState } from "react" +import { Button, FormSelect } from "react-bootstrap" +import { connect } from "react-redux" +import { useNavigate } from "react-router-dom" +import LOCATIONS_ICON from "resources/locations.png" +import ORGANIZATIONS_ICON from "resources/organizations.png" +import TASKS_ICON from "resources/tasks.png" +import { RECURSE_STRATEGY } from "searchUtils" +import Settings from "settings" +import utils from "utils" +import { TaskOverlayRow } from "../../components/advancedSelectWidget/AdvancedSelectOverlayRow" + +const EVENT_TYPES = [ + Event.EVENT_TYPES.CONFERENCE, + Event.EVENT_TYPES.EXERCISE, + Event.EVENT_TYPES.VISIT_BAN, + Event.EVENT_TYPES.OTHER +] + +const GQL_GET_RECENTS = gql` + query($taskQuery: TaskSearchQueryInput) { + locationList( + query: { + pageSize: 6 + status: ACTIVE + inMyReports: true + sortBy: RECENT + sortOrder: DESC + } + ) { + list { + ${Location.autocompleteQuery} + } + } + taskList(query: $taskQuery) { + list { + ${Task.autocompleteQuery} + } + } + } +` + +const organizationAutocompleteQuery = `${Organization.autocompleteQuery} ascendantOrgs { uuid app6context app6standardIdentity parentOrg { uuid } }` +const eventSeriesAutocompleteQuery = EventSeries.autocompleteQuery + +const EventForm = ({ + pageDispatchers, + edit, + title, + initialValues, + notesComponent +}) => { + const { loadAppData, currentUser } = useContext(AppContext) + const navigate = useNavigate() + const [saveError, setSaveError] = useState(null) + const [minDate, setMinDate] = useState( + initialValues ? initialValues.startDate : null + ) + const [maxDate, setMaxDate] = useState( + initialValues ? initialValues.endDate : null + ) + const tasksLabel = pluralize(Settings.fields.task.shortLabel) + + const recentTasksVarCommon = { + pageSize: 6, + status: Model.STATUS.ACTIVE, + selectable: true, + sortBy: "RECENT", + sortOrder: "DESC" + } + + let recentTasksVarUser + if (currentUser.isAdmin()) { + recentTasksVarUser = recentTasksVarCommon + } else { + recentTasksVarUser = { + ...recentTasksVarCommon, + inMyReports: true + } + } + const { loading, error, data } = API.useApiQuery(GQL_GET_RECENTS, { + taskQuery: recentTasksVarUser + }) + const { done, result } = useBoilerplate({ + loading, + error, + pageDispatchers + }) + if (done) { + return result + } + + let recents = [] + if (data) { + recents = { + locations: data.locationList.list, + tasks: data.taskList.list + } + } + return ( + + {({ + isSubmitting, + dirty, + setFieldValue, + setFieldTouched, + values, + validateForm, + submitForm + }) => { + const isAdmin = currentUser && currentUser.isAdmin() + const canCreateLocation = + Settings.regularUsersCanCreateLocations || currentUser.isSuperuser() + + const hostOrgSearchQuery = { status: Model.STATUS.ACTIVE } + const adminOrgSearchQuery = { status: Model.STATUS.ACTIVE } + const eventSeriesSearchQuery = {} + + // Superusers can select parent organizations among the ones their position is administrating + if (!isAdmin) { + const orgsAdministratedUuids = + currentUser.position.organizationsAdministrated.map(org => org.uuid) + adminOrgSearchQuery.parentOrgUuid = [ + currentUser.position.organization.uuid, + ...orgsAdministratedUuids + ] + adminOrgSearchQuery.orgRecurseStrategy = RECURSE_STRATEGY.CHILDREN + + eventSeriesSearchQuery.adminOrgUuid = [ + currentUser.position.organization.uuid, + ...orgsAdministratedUuids + ] + } + + const currentOrg = + currentUser.position && currentUser.position.organization + + const locationFilters = Location.getReportLocationFilters() + + const action = ( + <> + + {notesComponent} + + ) + const organizationFilters = { + allOrganizations: { + label: "All organizations", + queryVars: {} + } + } + + const eventSeriesFilters = { + allEventSeries: { + label: "All event series", + queryVars: {} + } + } + + const tasksFilters = {} + + if (currentOrg) { + tasksFilters.assignedToMyOrg = { + label: `Assigned to ${currentOrg.shortName}`, + queryVars: { + taskedOrgUuid: currentOrg.uuid, + orgRecurseStrategy: RECURSE_STRATEGY.PARENTS, + selectable: true + } + } + } + + tasksFilters.allUnassignedTasks = { + label: `All unassigned ${tasksLabel}`, + queryVars: { selectable: true, isAssigned: false } + } + + return ( +
    + + +
    +
    +
    + { + // validation will be done by setFieldValue + setFieldTouched("eventSeries", true, false) // onBlur doesn't work when selecting an option + setFieldValue("eventSeries", value) + setFieldValue("hostOrg", value?.hostOrg) + setFieldValue("adminOrg", value?.adminOrg) + }} + widget={ + + } + /> + { + // validation will be done by setFieldValue + setFieldTouched("hostOrg", true, false) // onBlur doesn't work when selecting an option + setFieldValue("hostOrg", value) + }} + widget={ + + } + /> + { + // validation will be done by setFieldValue + setFieldTouched("adminOrg", true, false) // onBlur doesn't work when selecting an option + setFieldValue("adminOrg", value) + }} + widget={ + + } + /> + { + // validation will be done by setFieldValue + setFieldTouched("location", true, false) // onBlur doesn't work when selecting an option + setFieldValue("location", value, true) + }} + widget={ + ( + + ) + } + /> + } + /> + {!_isEmpty(tasksFilters) && ( +
    + { + // validation will be done by setFieldValue + setFieldTouched("tasks", true, false) // onBlur doesn't work when selecting an option + setFieldValue("tasks", value, true) + }} + widget={ + + } + overlayColumns={[Settings.fields.task.shortLabel]} + overlayRenderRow={TaskOverlayRow} + filterDefs={tasksFilters} + objectType={Task} + queryParams={{ status: Model.STATUS.ACTIVE }} + fields={Task.autocompleteQuery} + addon={TASKS_ICON} + /> + } + extraColElem={ + <> + + !values.tasks?.find( + task => task.uuid === rt.uuid + ) + )} + fieldName="tasks" + objectType={Task} + curValue={values.tasks} + onChange={value => { + // validation will be done by setFieldValue + setFieldTouched("tasks", true, false) // onBlur doesn't work when selecting an option + setFieldValue("tasks", value, true) + }} + handleAddItem={FieldHelper.handleMultiSelectAddItem} + /> + + } + /> +
    + )} + { + // validation will be done by setFieldValue + setFieldValue("type", event.target.value, true) + }} + widget={ + + + {EVENT_TYPES.map(type => ( + + ))} + + } + /> + + { + // prevent initial unnecessary render of RichTextEditor + if (!_isEqual(values.description, value)) { + setFieldValue("description", value, true) + } + }} + onHandleBlur={() => { + // validation will be done by setFieldValue + setFieldTouched("description", true, false) + }} + widget={ + + } + /> + { + setFieldTouched("startDate", true, false) // onBlur doesn't work when selecting a date + setFieldValue("startDate", value, true) + setMinDate(new Date(value)) + }} + onBlur={() => setFieldTouched("startDate")} + widget={ + + } + /> + { + setFieldTouched("endDate", true, false) // onBlur doesn't work when selecting a date + setFieldValue("endDate", value, true) + setMaxDate(new Date(value)) + }} + onBlur={() => setFieldTouched("endDate")} + widget={ + + } + /> + { + // prevent initial unnecessary render of RichTextEditor + if (!_isEqual(values.outcomes, value)) { + setFieldValue("outcomes", value, true) + } + }} + onHandleBlur={() => { + // validation will be done by setFieldValue + setFieldTouched("outcomes", true, false) + }} + widget={ + + } + /> +
    +
    +
    + +
    +
    + +
    +
    + +
    + ) + }} +
    + ) + + function onCancel() { + navigate(-1) + } + + function onSubmit(values, form) { + return save(values) + .then(response => onSubmitSuccess(response, values, form)) + .catch(error => { + setSaveError(error) + form.setSubmitting(false) + jumpToTop() + }) + } + + function onSubmitSuccess(response, values, form) { + const operation = edit ? "updateEvent" : "createEvent" + const event = new Event({ + uuid: response[operation].uuid + ? response[operation].uuid + : initialValues.uuid + }) + // reset the form to latest values + // to avoid unsaved changes prompt if it somehow becomes dirty + form.resetForm({ values, isSubmitting: true }) + loadAppData() + if (!edit) { + navigate(Event.pathForEdit(event), { replace: true }) + } + navigate(Event.pathFor(event), { + state: { success: "Event saved" } + }) + } + + function save(values) { + const event = Event.filterClientSideFields(new Event(values)) + // strip tasks fields not in data model + event.tasks = values.tasks.map(t => utils.getReference(t)) + event.hostOrg = utils.getReference(event.hostOrg) + event.adminOrg = utils.getReference(event.adminOrg) + event.location = utils.getReference(event.location) + return API.mutation( + edit ? Event.getUpdateEventMutation : Event.getCreateEventMutation, + { + event + } + ).then() + } +} + +EventForm.propTypes = { + pageDispatchers: PageDispatchersPropType, + initialValues: PropTypes.instanceOf(Event).isRequired, + title: PropTypes.string, + edit: PropTypes.bool, + notesComponent: PropTypes.node +} + +EventForm.defaultProps = { + title: "", + edit: false +} + +export default connect(null, mapPageDispatchersToProps)(EventForm) diff --git a/client/src/pages/events/MyEvents.js b/client/src/pages/events/MyEvents.js new file mode 100644 index 0000000000..f3cd39feab --- /dev/null +++ b/client/src/pages/events/MyEvents.js @@ -0,0 +1,99 @@ +import { DEFAULT_PAGE_PROPS } from "actions" +import AppContext from "components/AppContext" +import EventCollection from "components/EventCollection" +import EventSeriesCollection from "components/EventSeriesCollection" +import Fieldset from "components/Fieldset" +import { AnchorNavItem } from "components/Nav" +import { + mapPageDispatchersToProps, + PageDispatchersPropType, + useBoilerplate, + usePageTitle +} from "components/Page" +import { getSearchQuery, SearchQueryPropType } from "components/SearchFilters" +import SubNav from "components/SubNav" +import React, { useContext, useMemo } from "react" +import { Nav } from "react-bootstrap" +import { connect } from "react-redux" + +const MyEvents = ({ pageDispatchers, searchQuery }) => { + // Make sure we have a navigation menu + useBoilerplate({ + pageProps: DEFAULT_PAGE_PROPS, + pageDispatchers + }) + usePageTitle("My Events") + const { currentUser } = useContext(AppContext) + + // Memorize the search query parameters we use to prevent unnecessary re-renders + const searchQueryParams = useMemo( + () => getSearchQuery(searchQuery), + [searchQuery] + ) + const eventSearchQueryParams = useMemo( + () => + Object.assign({}, searchQueryParams, { + sortBy: "NAME", + sortOrder: "ASC", + adminOrgUuid: currentUser.position.organizationsAdministrated.map( + org => org.uuid + ) + }), + [currentUser, searchQueryParams] + ) + + return ( +
    + + + + + + + {renderEventSeriesSection()} + {renderEventsSection()} +
    + ) + + function renderEventSeriesSection() { + return ( +
    + +
    + ) + } + + function renderEventsSection() { + return ( +
    + +
    + ) + } +} + +MyEvents.propTypes = { + pageDispatchers: PageDispatchersPropType, + searchQuery: SearchQueryPropType +} + +const mapStateToProps = (state, ownProps) => ({ + searchQuery: state.searchQuery +}) + +export default connect(mapStateToProps, mapPageDispatchersToProps)(MyEvents) diff --git a/client/src/pages/events/New.js b/client/src/pages/events/New.js new file mode 100644 index 0000000000..f799e94263 --- /dev/null +++ b/client/src/pages/events/New.js @@ -0,0 +1,107 @@ +import { DEFAULT_SEARCH_PROPS, PAGE_PROPS_NO_NAV } from "actions" +import API from "api" +import { initInvisibleFields } from "components/CustomFields" +import { + mapPageDispatchersToProps, + PageDispatchersPropType, + useBoilerplate, + usePageTitle +} from "components/Page" +import { Event, EventSeries } from "models" +import PropTypes from "prop-types" +import React from "react" +import { connect } from "react-redux" +import { useLocation } from "react-router-dom" +import Settings from "settings" +import utils from "utils" +import EventForm from "./Form" + +const EventNew = ({ pageDispatchers }) => { + console.log(pageDispatchers) + const routerLocation = useLocation() + useBoilerplate({ + pageProps: PAGE_PROPS_NO_NAV, + searchProps: DEFAULT_SEARCH_PROPS, + pageDispatchers + }) + usePageTitle("New Event") + + const qs = utils.parseQueryString(routerLocation.search) + if (qs.get("eventSeriesUuid")) { + return ( + + ) + } + return +} + +const EventNewFetchEventSeries = ({ eventSeriesUuid, pageDispatchers }) => { + const queryResult = API.useApiQuery(EventSeries.getEventSeriesQueryMin, { + uuid: eventSeriesUuid + }) + return ( + + ) +} + +EventNewFetchEventSeries.propTypes = { + eventSeriesUuid: PropTypes.string.isRequired, + pageDispatchers: PageDispatchersPropType +} + +const EventNewConditional = ({ + loading, + error, + data, + eventSeriesUuid, + pageDispatchers +}) => { + const { done, result } = useBoilerplate({ + loading, + error, + modelName: "EventSeries", + uuid: eventSeriesUuid, + pageProps: PAGE_PROPS_NO_NAV, + searchProps: DEFAULT_SEARCH_PROPS, + pageDispatchers + }) + if (done) { + return result + } + + const event = new Event() + if (data) { + event.eventSeries = new EventSeries(data.eventSeries) + event.hostOrg = data.eventSeries.hostOrg + event.adminOrg = data.eventSeries.adminOrg + } + // mutates the object + initInvisibleFields(event, Settings.fields.organization.customFields) + return ( + + ) +} + +EventNewConditional.propTypes = { + loading: PropTypes.bool, + error: PropTypes.object, + data: PropTypes.object, + eventSeriesUuid: PropTypes.string, + pageDispatchers: PageDispatchersPropType +} +EventNew.propTypes = { + pageDispatchers: PageDispatchersPropType +} + +export default connect(null, mapPageDispatchersToProps)(EventNew) diff --git a/client/src/pages/events/Show.js b/client/src/pages/events/Show.js new file mode 100644 index 0000000000..9e3b39ae82 --- /dev/null +++ b/client/src/pages/events/Show.js @@ -0,0 +1,257 @@ +import { DEFAULT_PAGE_PROPS, DEFAULT_SEARCH_PROPS } from "actions" +import API from "api" +import AppContext from "components/AppContext" +import DictionaryField from "components/DictionaryField" +import * as FieldHelper from "components/FieldHelper" +import Fieldset from "components/Fieldset" +import LinkTo from "components/LinkTo" +import Messages from "components/Messages" +import NoPaginationTaskTable from "components/NoPaginationTaskTable" +import { + jumpToTop, + mapPageDispatchersToProps, + PageDispatchersPropType, + SubscriptionIcon, + useBoilerplate, + usePageTitle +} from "components/Page" +import ReportCollection from "components/ReportCollection" +import RichTextEditor from "components/RichTextEditor" +import { Field, Form, Formik } from "formik" +import { Event, Report, Task } from "models" +import moment from "moment/moment" +import pluralize from "pluralize" +import React, { useContext, useState } from "react" +import { connect } from "react-redux" +import { useLocation, useParams } from "react-router-dom" +import Settings from "settings" + +const EventShow = ({ pageDispatchers }) => { + const { currentUser } = useContext(AppContext) + const routerLocation = useLocation() + const stateSuccess = routerLocation.state && routerLocation.state.success + const [stateError, setStateError] = useState( + routerLocation.state && routerLocation.state.error + ) + const { uuid } = useParams() + const { loading, error, data, refetch } = API.useApiQuery( + Event.getEventQuery, + { + uuid + } + ) + const { done, result } = useBoilerplate({ + loading, + error, + modelName: "Event", + uuid, + pageProps: DEFAULT_PAGE_PROPS, + searchProps: DEFAULT_SEARCH_PROPS, + pageDispatchers + }) + usePageTitle(data?.event?.name) + if (done) { + return result + } + + let event + if (!data) { + event = new Event() + } else { + data.event.tasks = Task.fromArray(data.event.tasks) + event = new Event(data.event) + } + + const canAdministrateOrg = + currentUser && + currentUser.hasAdministrativePermissionsForOrganization(event.adminOrg) + + const reportQueryParams = { + state: [Report.STATE.PUBLISHED], + eventUuid: uuid + } + + const tasksLabel = pluralize(Settings.fields.task.shortLabel) + + return ( + + {({ values }) => { + const action = ( + <> + {canAdministrateOrg && ( + + Edit + + )} + + ) + return ( +
    + +
    +
    + { + { + setStateError(error) + jumpToTop() + }} + persistent + /> + }{" "} + Event {event.name} + + } + action={action} + /> +
    + + ) + } + /> + + ) + } + /> + {event.eventSeries && event.eventSeries.uuid && ( + + ) + } + /> + )} + {event.location && event.location.uuid && ( + + ) + } + /> + )} + + + {event.startDate && + moment(event.startDate).format( + Event.getEventDateFormat() + )} + + } + /> + + {event.endDate && + moment(event.endDate).format( + Event.getEventDateFormat() + )} + + } + /> +
    +
    + +
    +
    + +
    +
    + +
    +
    + Create report + + } + > + +
    + +
    + ) + }} +
    + ) +} + +EventShow.propTypes = { + pageDispatchers: PageDispatchersPropType +} + +const mapStateToProps = (state, ownProps) => ({ + pagination: state.pagination +}) + +export default connect(mapStateToProps, mapPageDispatchersToProps)(EventShow) diff --git a/client/src/pages/locations/CreateNewLocation.js b/client/src/pages/locations/CreateNewLocation.js new file mode 100644 index 0000000000..45d40bf3a2 --- /dev/null +++ b/client/src/pages/locations/CreateNewLocation.js @@ -0,0 +1,44 @@ +import { initInvisibleFields } from "components/CustomFields" +import { mapPageDispatchersToProps } from "components/Page" +import { Location } from "models" +import LocationForm from "pages/locations/Form" +import PropTypes from "prop-types" +import React from "react" +import { connect } from "react-redux" +import { toast } from "react-toastify" +import Settings from "settings" + +const CreateNewLocation = ({ + name, + setFieldTouched, + setFieldValue, + setDoReset +}) => { + const location = new Location({ name }) + // mutates the object + initInvisibleFields(location, Settings.fields.location.customFields) + return ( + { + // validation will be done by setFieldValue + setFieldTouched("location", true, false) // onBlur doesn't work when selecting an option + setFieldValue("location", value, true) + setDoReset(true) + toast.success("The location has been saved") + }} + afterCancelActions={() => { + setDoReset(true) + }} + /> + ) +} + +CreateNewLocation.propTypes = { + name: PropTypes.string, + setFieldTouched: PropTypes.func.isRequired, + setFieldValue: PropTypes.func.isRequired, + setDoReset: PropTypes.func.isRequired +} +export default connect(null, mapPageDispatchersToProps)(CreateNewLocation) diff --git a/client/src/pages/locations/Show.js b/client/src/pages/locations/Show.js index 885d836ff0..4fd909fc2e 100644 --- a/client/src/pages/locations/Show.js +++ b/client/src/pages/locations/Show.js @@ -6,6 +6,7 @@ import Approvals from "components/approvals/Approvals" import AttachmentCard from "components/Attachment/AttachmentCard" import { ReadonlyCustomFields } from "components/CustomFields" import DictionaryField from "components/DictionaryField" +import EventCollection from "components/EventCollection" import * as FieldHelper from "components/FieldHelper" import Fieldset from "components/Fieldset" import GeoLocation, { GEO_LOCATION_DISPLAY_TYPE } from "components/GeoLocation" @@ -301,6 +302,13 @@ const LocationShow = ({ pageDispatchers }) => { mapId="reports" /> +
    + +
    ) }} diff --git a/client/src/pages/organizations/Show.js b/client/src/pages/organizations/Show.js index bdd9da6307..d8eba740ad 100644 --- a/client/src/pages/organizations/Show.js +++ b/client/src/pages/organizations/Show.js @@ -9,6 +9,7 @@ import AuthorizationGroupTable from "components/AuthorizationGroupTable" import { ReadonlyCustomFields } from "components/CustomFields" import DictionaryField from "components/DictionaryField" import EmailAddressTable from "components/EmailAddressTable" +import EventCollection from "components/EventCollection" import * as FieldHelper from "components/FieldHelper" import Fieldset from "components/Fieldset" import GuidedTour from "components/GuidedTour" @@ -266,6 +267,9 @@ const OrganizationShow = ({ pageDispatchers }) => { )} + + Events + Reports @@ -275,6 +279,9 @@ const OrganizationShow = ({ pageDispatchers }) => { const reportQueryParams = { orgUuid: uuid } + const eventQueryParams = { + hostOrgUuid: uuid + } if (filterPendingApproval) { reportQueryParams.state = Report.STATE.PENDING_APPROVAL } @@ -637,7 +644,16 @@ const OrganizationShow = ({ pageDispatchers }) => { }} /> )} - +
    + +
    { - const location = new Location({ name }) - // mutates the object - initInvisibleFields(location, Settings.fields.location.customFields) - return ( - { - // validation will be done by setFieldValue - setFieldTouched("location", true, false) // onBlur doesn't work when selecting an option - setFieldValue("location", value, true) - setDoReset(true) - toast.success("The location has been saved") - }} - afterCancelActions={() => { - setDoReset(true) - }} - /> - ) -} - -CreateNewLocation.propTypes = { - name: PropTypes.string, - setFieldTouched: PropTypes.func.isRequired, - setFieldValue: PropTypes.func.isRequired, - setDoReset: PropTypes.func.isRequired -} - const ReportForm = ({ pageDispatchers, edit, @@ -220,6 +187,9 @@ const ReportForm = ({ const [showCustomFields, setShowCustomFields] = useState( !!Settings.fields.report.customFields ) + // If this report is linked to an Event restrict the dates that can be selected for engagementDate + const [minDate, setMinDate] = useState(initialValues.event?.startDate) + const [maxDate, setMaxDate] = useState(initialValues.event?.endDate) // some autosave settings const defaultTimeout = moment.duration(AUTOSAVE_TIMEOUT, "seconds") const autoSaveSettings = useRef({ @@ -431,6 +401,8 @@ const ReportForm = ({ queryVars: { selectable: true, isAssigned: false } } + const eventFilters = Event.getReportEventFilters() + if (currentUser.isAdmin()) { tasksFilters.allTasks = { label: `All ${tasksLabel}`, @@ -540,7 +512,7 @@ const ReportForm = ({ /> } > @@ -587,7 +561,36 @@ const ReportForm = ({ )} { + // validation will be done by setFieldValue + setFieldTouched("event", true, false) // onBlur doesn't work when selecting an option + setFieldValue("event", value, true) + setFieldValue("location", value?.location) + setMinDate(value?.startDate) + setMaxDate(value?.endDate) + }} + widget={ + + } + /> + + { - const { currentUser } = useContext(AppContext) + const routerLocation = useLocation() useBoilerplate({ pageProps: PAGE_PROPS_NO_NAV, searchProps: DEFAULT_SEARCH_PROPS, @@ -24,8 +28,68 @@ const ReportNew = ({ pageDispatchers }) => { }) usePageTitle("New Report") - const report = new Report() + const qs = utils.parseQueryString(routerLocation.search) + if (qs.get("eventUuid")) { + return ( + + ) + } + return +} + +const ReportNewFetchEvent = ({ eventUuid, pageDispatchers }) => { + const queryResult = API.useApiQuery(Event.getEventQueryNoIsSubscribed, { + uuid: eventUuid + }) + return ( + + ) +} + +ReportNewFetchEvent.propTypes = { + eventUuid: PropTypes.string.isRequired, + pageDispatchers: PageDispatchersPropType +} +const ReportNewConditional = ({ + loading, + error, + data, + eventUuid, + pageDispatchers +}) => { + const { currentUser } = useContext(AppContext) + const { done, result } = useBoilerplate({ + loading, + error, + modelName: "EventSeries", + uuid: eventUuid, + pageProps: PAGE_PROPS_NO_NAV, + searchProps: DEFAULT_SEARCH_PROPS, + pageDispatchers + }) + if (done) { + return result + } + + const report = new Report() + if (data) { + const event = new Event(data.event) + const tasks = [] + event.tasks.forEach(task => tasks.push(new Task(task))) + // We do not want tasks to go with event Probably there is a better way to do this? + event.tasks = [] + report.event = event + report.location = event.location + report.tasks = tasks + } // mutates the object initInvisibleFields(report, Settings.fields.report.customFields) @@ -42,7 +106,6 @@ const ReportNew = ({ pageDispatchers }) => { report.getTasksEngagementAssessments(), report.getAttendeesEngagementAssessments() ) - return (
    @@ -65,6 +128,14 @@ const ReportNew = ({ pageDispatchers }) => { ) } +ReportNewConditional.propTypes = { + loading: PropTypes.bool, + error: PropTypes.object, + data: PropTypes.object, + eventUuid: PropTypes.string, + pageDispatchers: PageDispatchersPropType +} + ReportNew.propTypes = { pageDispatchers: PageDispatchersPropType } diff --git a/client/src/pages/reports/Show.js b/client/src/pages/reports/Show.js index 7ab641a823..7248d0bd81 100644 --- a/client/src/pages/reports/Show.js +++ b/client/src/pages/reports/Show.js @@ -233,6 +233,11 @@ const GQL_GET_REPORT = gql` attachments { ${Attachment.basicFieldsQuery} } + event { + uuid + name + description + } customFields ${GRAPHQL_NOTES_FIELDS} } @@ -649,6 +654,18 @@ const ReportShow = ({ setSearchQuery, pageDispatchers }) => { } /> + + ) + } + /> + { + // (Re)set pageNum to 0 if the queryParams change, and make sure we retrieve page 0 in that case + const latestQueryParams = useRef(queryParams) + const queryParamsUnchanged = _isEqual(latestQueryParams.current, queryParams) + const [pageNum, setPageNum] = useState( + queryParamsUnchanged && pagination[paginationKey] + ? pagination[paginationKey].pageNum + : 0 + ) + useEffect(() => { + if (!queryParamsUnchanged) { + latestQueryParams.current = queryParams + setPagination(paginationKey, 0) + setPageNum(0) + } + }, [queryParams, setPagination, paginationKey, queryParamsUnchanged]) + const eventQuery = { + ...queryParams, + pageNum: queryParamsUnchanged ? pageNum : 0, + pageSize: queryParams.pageSize || DEFAULT_PAGESIZE + } + const { loading, error, data } = API.useApiQuery(Event.getEventListQuery, { + eventQuery + }) + const { done, result } = useBoilerplate({ + loading, + error, + pageDispatchers + }) + // Update the total count + const totalCount = done ? null : data?.eventList?.totalCount + useEffect(() => setTotalCount?.(totalCount), [setTotalCount, totalCount]) + if (done) { + return result + } + + const paginatedEvents = data ? data.eventList : [] + const { pageSize, pageNum: curPage, list: events } = paginatedEvents + + return ( + + ) + + function setPage(pageNum) { + setPagination(paginationKey, pageNum) + setPageNum(pageNum) + } +} + +Events.propTypes = { + pageDispatchers: PageDispatchersPropType, + queryParams: PropTypes.object, + setTotalCount: PropTypes.func, + paginationKey: PropTypes.string.isRequired, + pagination: PropTypes.object.isRequired, + setPagination: PropTypes.func.isRequired +} + const sum = (...args) => { return args.reduce((prev, curr) => (curr === null ? prev : prev + curr)) } @@ -1115,6 +1189,7 @@ const Search = ({ const [numReports, setNumReports] = useState(null) const [numAuthorizationGroups, setNumAuthorizationGroups] = useState(null) const [numAttachments, setNumAttachments] = useState(null) + const [numEvents, setNumEvents] = useState(null) const [recipients, setRecipients] = useState({ ...DEFAULT_RECIPIENTS }) usePageTitle("Search") const numResultsThatCanBeEmailed = sum( @@ -1128,7 +1203,8 @@ const Search = ({ numTasks, numLocations, numReports, - numAttachments + numAttachments, + numEvents ) const taskShortLabel = Settings.fields.task.shortLabel // Memo'ize the search query parameters we use to prevent unnecessary re-renders @@ -1155,6 +1231,15 @@ const Search = ({ }), [searchQueryParams, pageSize] ) + const eventSearchQueryParams = useMemo( + () => ({ + ...searchQueryParams, + pageSize, + sortBy: "NAME", + sortOrder: "ASC" + }), + [searchQueryParams, pageSize] + ) const reportsSearchQueryParams = useMemo( () => ({ ...searchQueryParams, @@ -1187,6 +1272,7 @@ const Search = ({ latestQuery.current = { queryTypes, searchQueryParams } setNumAttachments(0) setNumAuthorizationGroups(0) + setNumEvents(0) setNumLocations(0) setNumOrganizations(0) setNumPeople(0) @@ -1202,6 +1288,7 @@ const Search = ({ setRecipients, setNumAttachments, setNumAuthorizationGroups, + setNumEvents, setNumLocations, setNumOrganizations, setNumPeople, @@ -1227,6 +1314,8 @@ const Search = ({ numAuthorizationGroups > 0 const hasAttachmentsResults = queryTypes.includes(SEARCH_OBJECT_TYPES.ATTACHMENTS) && numAttachments > 0 + const hasEventsResults = + queryTypes.includes(SEARCH_OBJECT_TYPES.EVENTS) && numEvents > 0 useBoilerplate({ pageProps: DEFAULT_PAGE_PROPS, searchProps: DEFAULT_SEARCH_PROPS, @@ -1336,6 +1425,16 @@ const Search = ({ )} + + + {" "} + {SEARCH_OBJECT_LABELS[SEARCH_OBJECT_TYPES.EVENTS]}{" "} + {hasEventsResults && ( + + {numEvents} + + )} + @@ -1651,6 +1750,30 @@ const Search = ({ />
    )} + {queryTypes.includes(SEARCH_OBJECT_TYPES.EVENTS) && ( +
    + Events + {hasEventsResults && ( + + {numEvents} + + )} + + } + > + +
    + )} {renderSaveModal()} ) diff --git a/client/src/pages/util.js b/client/src/pages/util.js index 564d10141a..bcabdaf1ed 100644 --- a/client/src/pages/util.js +++ b/client/src/pages/util.js @@ -20,5 +20,7 @@ export const PAGE_URLS = { DASHBOARDS: "/dashboards", ONBOARDING: "/onboarding", SUBSCRIPTIONS: "/subscriptions", + EVENTS: "/events", + EVENT_SERIES: "/eventSeries", MISSING: "*" } diff --git a/client/src/resources/events.png b/client/src/resources/events.png new file mode 100644 index 0000000000000000000000000000000000000000..fa5add24c5112f5423d19fc9a41e5cefccbf0bf5 GIT binary patch literal 1501 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD4M^IaWitX&jKx9jP7LeL$-HD>V4d#i;uuoF z`1X$N>ex^Lwg=L}flVDsZd(E_9&%%0)m5G9DplXewax4wgLbsH_Qvpo=Z{^wbjZnI zu0T_S#6>rsr|FM!LWELoq|e`-GI!>an;TA~e!G=^w({B8lY1vTd2zzEfq{{Ug+oBW zfliG7+YfYB91KsM+h1yV?fJ%YM~mL7fAGlpZ*Z-Dn zzhBhcS$y%&r^FBO$GHP{Fa9k3`r7ZyCk6h=o;qLnzGS<3$$~oj$ikYx4z|4V)u%sb zpQ&$55AWM!{LXe~(fzgAOxb8Hc8P_tabCllj!Gsb+w-?Pg;`jtm^LXtnJwJ~Yp8o2T zQ&oQYm}IrzS&Nj%lDiws_no;Y^doiWF_vvoVZQ=m{w|Sz{K2G=nPbi0i?t19GM))O z4hxR|v55sz-MlW=$v{^;d;T(si77Tm;g+PZzzaire{mp{#C-AGj0T7PpOw3Sl(=+9 z*)gC?&z!$}l!ay24TmkpY6=&U>*nbJsiSO*<{C0K%6;f472o9^soyD#?FuX!!~ zMc4iM$(Pom0xzbxZd~2qP(F9gzF@{izethYTpT5%QiC8g4y<(Q{V%om&bEc~rzf4i z$@@FUa^HcMwsNu0!k(=XdGX%)=|f&4{$uk}cV=<%{o{)nS><+J^I=C+{@luo0}D=AXTAUG|4tZI-u+<{o(b zajXBUs>ZmwW0g;LS^g~IZ`_^qUV39)=hMr-KCsO{Dl@Mu^p{oOuHE}*?>4WC+!tT1 zcg=a@yYA4J?Kd-?x9zFxDlgT%Cce?G?`x=zINi#z3r-L8e|OwEJkQmt1Xx}(c)I$z JtaD0e0sx%peRlu= literal 0 HcmV?d00001 diff --git a/client/tests/webdriver/baseSpecs/advancedSearch.spec.js b/client/tests/webdriver/baseSpecs/advancedSearch.spec.js index 6e720876f2..8b2f6d9e39 100644 --- a/client/tests/webdriver/baseSpecs/advancedSearch.spec.js +++ b/client/tests/webdriver/baseSpecs/advancedSearch.spec.js @@ -27,6 +27,9 @@ const ANET_OBJECT_TYPES = { }, Attachments: { sampleFilters: ["Mime Type"] + }, + Events: { + sampleFilters: ["Type"] } } const COMMON_FILTER_TEXT = "Status" diff --git a/src/main/java/mil/dds/anet/AnetApplication.java b/src/main/java/mil/dds/anet/AnetApplication.java index 5bb5985e34..584c7f8839 100644 --- a/src/main/java/mil/dds/anet/AnetApplication.java +++ b/src/main/java/mil/dds/anet/AnetApplication.java @@ -42,6 +42,8 @@ import mil.dds.anet.resources.ApprovalStepResource; import mil.dds.anet.resources.AttachmentResource; import mil.dds.anet.resources.AuthorizationGroupResource; +import mil.dds.anet.resources.EventResource; +import mil.dds.anet.resources.EventSeriesResource; import mil.dds.anet.resources.GraphQlResource; import mil.dds.anet.resources.HomeResource; import mil.dds.anet.resources.LocationResource; @@ -414,11 +416,13 @@ public void run(AnetConfiguration configuration, Environment environment) new SubscriptionUpdateResource(engine); final AttachmentResource attachmentResource = new AttachmentResource(engine); final GraphQlResource graphQlResource = injector.getInstance(GraphQlResource.class); + final EventSeriesResource eventSeriesResource = new EventSeriesResource(engine); + final EventResource eventResource = new EventResource(engine); graphQlResource.initialise(engine, configuration, List.of(reportResource, personResource, positionResource, locationResource, orgResource, taskResource, adminResource, savedSearchResource, authorizationGroupResource, noteResource, approvalStepResource, subscriptionResource, subscriptionUpdateResource, - attachmentResource), + attachmentResource, eventSeriesResource, eventResource), metricRegistry); // Register all of the HTTP Resources @@ -427,6 +431,8 @@ public void run(AnetConfiguration configuration, Environment environment) environment.jersey().register(homeResource); environment.jersey().register(graphQlResource); environment.jersey().register(attachmentResource); + environment.jersey().register(eventSeriesResource); + environment.jersey().register(eventResource); environment.jersey().register(new RequestLoggingFilter(engine)); environment.jersey().register(ViewRequestFilter.class); environment.jersey().register(ViewResponseFilter.class); diff --git a/src/main/java/mil/dds/anet/AnetObjectEngine.java b/src/main/java/mil/dds/anet/AnetObjectEngine.java index 86041a64da..61d2145c9e 100644 --- a/src/main/java/mil/dds/anet/AnetObjectEngine.java +++ b/src/main/java/mil/dds/anet/AnetObjectEngine.java @@ -32,6 +32,8 @@ import mil.dds.anet.database.CustomSensitiveInformationDao; import mil.dds.anet.database.EmailAddressDao; import mil.dds.anet.database.EmailDao; +import mil.dds.anet.database.EventDao; +import mil.dds.anet.database.EventSeriesDao; import mil.dds.anet.database.JobHistoryDao; import mil.dds.anet.database.LocationDao; import mil.dds.anet.database.NoteDao; @@ -80,6 +82,8 @@ public class AnetObjectEngine { private final SubscriptionUpdateDao subscriptionUpdateDao; private final UserActivityDao userActivityDao; private final EmailAddressDao emailAddressDao; + private final EventSeriesDao eventSeriesDao; + private final EventDao eventDao; private final MetricRegistry metricRegistry; private ThreadLocal> context; @@ -117,6 +121,8 @@ public AnetObjectEngine(String dbUrl, Application application, AnetConfigurat subscriptionUpdateDao = injector.getInstance(SubscriptionUpdateDao.class); userActivityDao = injector.getInstance(UserActivityDao.class); emailAddressDao = injector.getInstance(EmailAddressDao.class); + eventSeriesDao = injector.getInstance(EventSeriesDao.class); + eventDao = injector.getInstance(EventDao.class); this.metricRegistry = metricRegistry; searcher = Searcher.getSearcher(injector); configuration = config; @@ -219,6 +225,14 @@ public EmailDao getEmailDao() { return emailDao; } + public EventSeriesDao getEventSeriesDao() { + return eventSeriesDao; + } + + public EventDao getEventDao() { + return eventDao; + } + public MetricRegistry getMetricRegistry() { return metricRegistry; } diff --git a/src/main/java/mil/dds/anet/beans/Event.java b/src/main/java/mil/dds/anet/beans/Event.java new file mode 100644 index 0000000000..2d068805b6 --- /dev/null +++ b/src/main/java/mil/dds/anet/beans/Event.java @@ -0,0 +1,173 @@ +package mil.dds.anet.beans; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.leangen.graphql.annotations.GraphQLInputField; +import io.leangen.graphql.annotations.GraphQLQuery; +import io.leangen.graphql.annotations.GraphQLRootContext; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import mil.dds.anet.AnetObjectEngine; +import mil.dds.anet.utils.IdDataLoaderKey; +import mil.dds.anet.views.UuidFetcher; + +public class Event extends EventSeries { + @GraphQLQuery + @GraphQLInputField + String type; + @GraphQLQuery + @GraphQLInputField + Instant startDate; + @GraphQLQuery + @GraphQLInputField + Instant endDate; + @GraphQLQuery + @GraphQLInputField + String outcomes; + + // Lazy Loaded + // annotated below + List tasks; + + private ForeignObjectHolder eventSeries = new ForeignObjectHolder<>(); + private ForeignObjectHolder location = new ForeignObjectHolder<>(); + + @GraphQLQuery(name = "eventSeries") + public CompletableFuture loadEventSeries( + @GraphQLRootContext Map context) { + if (eventSeries.hasForeignObject()) { + return CompletableFuture.completedFuture(eventSeries.getForeignObject()); + } + return new UuidFetcher() + .load(context, IdDataLoaderKey.EVENT_SERIES, eventSeries.getForeignUuid()).thenApply(o -> { + eventSeries.setForeignObject(o); + return o; + }); + } + + @JsonIgnore + public void setEventSeriesUuid(String eventSeriesUuid) { + this.eventSeries = new ForeignObjectHolder<>(eventSeriesUuid); + } + + @JsonIgnore + public String getEventSeriesUuid() { + return eventSeries.getForeignUuid(); + } + + @GraphQLInputField(name = "eventSeries") + public void setEventSeries(EventSeries es) { + this.eventSeries = new ForeignObjectHolder<>(es); + } + + public EventSeries getEventSeries() { + return eventSeries.getForeignObject(); + } + + @GraphQLQuery(name = "location") + public CompletableFuture loadLocation(@GraphQLRootContext Map context) { + if (location.hasForeignObject()) { + return CompletableFuture.completedFuture(location.getForeignObject()); + } + return new UuidFetcher() + .load(context, IdDataLoaderKey.LOCATIONS, location.getForeignUuid()).thenApply(o -> { + location.setForeignObject(o); + return o; + }); + } + + @JsonIgnore + public void setLocationUuid(String locationUuid) { + this.location = new ForeignObjectHolder<>(locationUuid); + } + + @JsonIgnore + public String getLocationUuid() { + return location.getForeignUuid(); + } + + @GraphQLInputField(name = "location") + public void setLocation(Location location) { + this.location = new ForeignObjectHolder<>(location); + } + + public Location getLocation() { + return location.getForeignObject(); + } + + @GraphQLQuery(name = "tasks") + public CompletableFuture> loadTasks(@GraphQLRootContext Map context) { + if (tasks != null) { + return CompletableFuture.completedFuture(tasks); + } + return AnetObjectEngine.getInstance().getEventDao().getTasksForEvent(context, uuid) + .thenApply(o -> { + tasks = o; + return o; + }); + } + + @GraphQLInputField(name = "tasks") + public void setTasks(List tasks) { + this.tasks = tasks; + } + + public List getTasks() { + return tasks; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public Instant getStartDate() { + return startDate; + } + + public void setStartDate(Instant startDate) { + this.startDate = startDate; + } + + public Instant getEndDate() { + return endDate; + } + + public void setEndDate(Instant endDate) { + this.endDate = endDate; + } + + public String getOutcomes() { + return outcomes; + } + + public void setOutcomes(String outcomes) { + this.outcomes = outcomes; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + if (!super.equals(o)) + return false; + Event event = (Event) o; + return Objects.equals(type, event.type) && Objects.equals(startDate, event.startDate) + && Objects.equals(endDate, event.endDate) && Objects.equals(outcomes, event.outcomes) + && Objects.equals(tasks, event.tasks) && Objects.equals(eventSeries, event.eventSeries) + && Objects.equals(location, event.location); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), type, startDate, endDate, outcomes, tasks, eventSeries, + location); + } +} diff --git a/src/main/java/mil/dds/anet/beans/EventSeries.java b/src/main/java/mil/dds/anet/beans/EventSeries.java new file mode 100644 index 0000000000..b8f02bae5a --- /dev/null +++ b/src/main/java/mil/dds/anet/beans/EventSeries.java @@ -0,0 +1,139 @@ +package mil.dds.anet.beans; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.leangen.graphql.annotations.GraphQLInputField; +import io.leangen.graphql.annotations.GraphQLQuery; +import io.leangen.graphql.annotations.GraphQLRootContext; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import mil.dds.anet.utils.IdDataLoaderKey; +import mil.dds.anet.views.AbstractCustomizableAnetBean; +import mil.dds.anet.views.UuidFetcher; + +public class EventSeries extends AbstractCustomizableAnetBean + implements RelatableObject, SubscribableObject, WithStatus { + @GraphQLQuery + @GraphQLInputField + private Status status; + @GraphQLQuery + @GraphQLInputField + String name; + @GraphQLQuery + @GraphQLInputField + String description; + // Lazy Loaded + // annotated below + private ForeignObjectHolder hostOrg = new ForeignObjectHolder<>(); + // Lazy Loaded + // annotated below + private ForeignObjectHolder adminOrg = new ForeignObjectHolder<>(); + + @GraphQLQuery(name = "hostOrg") + public CompletableFuture loadHostOrg( + @GraphQLRootContext Map context) { + if (hostOrg.hasForeignObject()) { + return CompletableFuture.completedFuture(hostOrg.getForeignObject()); + } + return new UuidFetcher() + .load(context, IdDataLoaderKey.ORGANIZATIONS, hostOrg.getForeignUuid()).thenApply(o -> { + hostOrg.setForeignObject(o); + return o; + }); + } + + @JsonIgnore + public void setHostOrgUuid(String hostOrgUuid) { + this.hostOrg = new ForeignObjectHolder<>(hostOrgUuid); + } + + @JsonIgnore + public String getHostOrgUuid() { + return hostOrg.getForeignUuid(); + } + + @GraphQLInputField(name = "hostOrg") + public void setHostOrg(Organization hostOrg) { + this.hostOrg = new ForeignObjectHolder<>(hostOrg); + } + + public Organization getHostOrg() { + return hostOrg.getForeignObject(); + } + + @GraphQLQuery(name = "adminOrg") + public CompletableFuture loadAdminOrg( + @GraphQLRootContext Map context) { + if (adminOrg.hasForeignObject()) { + return CompletableFuture.completedFuture(adminOrg.getForeignObject()); + } + return new UuidFetcher() + .load(context, IdDataLoaderKey.ORGANIZATIONS, adminOrg.getForeignUuid()).thenApply(o -> { + adminOrg.setForeignObject(o); + return o; + }); + } + + @JsonIgnore + public void setAdminOrgUuid(String adminOrgUuid) { + this.adminOrg = new ForeignObjectHolder<>(adminOrgUuid); + } + + @JsonIgnore + public String getAdminOrgUuid() { + return adminOrg.getForeignUuid(); + } + + @GraphQLInputField(name = "adminOrg") + public void setAdminOrg(Organization adminOrg) { + this.adminOrg = new ForeignObjectHolder<>(adminOrg); + } + + public Organization getAdminOrg() { + return adminOrg.getForeignObject(); + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + @Override + public Status getStatus() { + return status; + } + + @Override + public void setStatus(Status status) { + this.status = status; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + if (!super.equals(o)) + return false; + EventSeries that = (EventSeries) o; + return status == that.status && Objects.equals(name, that.name) + && Objects.equals(description, that.description) && Objects.equals(hostOrg, that.hostOrg) + && Objects.equals(adminOrg, that.adminOrg); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), status, name, description, hostOrg, adminOrg); + } +} diff --git a/src/main/java/mil/dds/anet/beans/Report.java b/src/main/java/mil/dds/anet/beans/Report.java index d3011d3682..5599571c70 100644 --- a/src/main/java/mil/dds/anet/beans/Report.java +++ b/src/main/java/mil/dds/anet/beans/Report.java @@ -47,8 +47,6 @@ public enum ReportCancelledReason { NO_REASON_GIVEN, CANCELLED_DUE_TO_AVAILABILITY_OF_INTERPRETERS, CANCELLED_DUE_TO_NETWORK_ISSUES } - // annotated below - private ForeignObjectHolder approvalStep = new ForeignObjectHolder<>(); @GraphQLQuery @GraphQLInputField ReportState state; @@ -64,8 +62,6 @@ public enum ReportCancelledReason { @GraphQLQuery @GraphQLInputField private Integer duration; - // annotated below - private ForeignObjectHolder location = new ForeignObjectHolder<>(); @GraphQLQuery @GraphQLInputField String intent; @@ -92,11 +88,16 @@ public enum ReportCancelledReason { @GraphQLQuery @GraphQLInputField String reportText; - @GraphQLQuery @GraphQLInputField private String classification; + // annotated below + private ForeignObjectHolder approvalStep = new ForeignObjectHolder<>(); + // annotated below + private ForeignObjectHolder location = new ForeignObjectHolder<>(); + // annotated below + private ForeignObjectHolder event = new ForeignObjectHolder<>(); // annotated below private List reportPeople; // annotated below @@ -793,6 +794,37 @@ public boolean isAuthor(Person user) { .anyMatch(p -> Objects.equals(p.getUuid(), user.getUuid())); } + @GraphQLQuery(name = "event") + public CompletableFuture loadEvent(@GraphQLRootContext Map context) { + if (event.hasForeignObject()) { + return CompletableFuture.completedFuture(event.getForeignObject()); + } + return new UuidFetcher().load(context, IdDataLoaderKey.EVENTS, event.getForeignUuid()) + .thenApply(o -> { + event.setForeignObject(o); + return o; + }); + } + + @JsonIgnore + public void setEventUuid(String eventUuid) { + this.event = new ForeignObjectHolder<>(eventUuid); + } + + @JsonIgnore + public String getEventUuid() { + return event.getForeignUuid(); + } + + @GraphQLInputField(name = "event") + public void setEvent(Event event) { + this.event = new ForeignObjectHolder<>(event); + } + + public Event getEvent() { + return event.getForeignObject(); + } + @Override public boolean equals(Object o) { if (!(o instanceof final Report r)) { @@ -814,7 +846,8 @@ public boolean equals(Object o) { && Objects.equals(r.getReportText(), reportText) && Objects.equals(r.getNextSteps(), nextSteps) && Objects.equals(r.getComments(), comments) && Objects.equals(r.getReportSensitiveInformation(), reportSensitiveInformation) - && Objects.equals(r.getAuthorizationGroups(), authorizationGroups); + && Objects.equals(r.getAuthorizationGroups(), authorizationGroups) + && Objects.equals(r.getEvent(), event); } @Override @@ -822,7 +855,7 @@ public int hashCode() { return Objects.hash(super.hashCode(), uuid, state, approvalStep, createdAt, updatedAt, location, intent, exsum, reportPeople, tasks, reportText, nextSteps, comments, atmosphere, atmosphereDetails, engagementDate, duration, reportSensitiveInformation, - authorizationGroups); + authorizationGroups, event); } public static Report createWithUuid(String uuid) { diff --git a/src/main/java/mil/dds/anet/beans/search/EventSearchQuery.java b/src/main/java/mil/dds/anet/beans/search/EventSearchQuery.java new file mode 100644 index 0000000000..b08d78e412 --- /dev/null +++ b/src/main/java/mil/dds/anet/beans/search/EventSearchQuery.java @@ -0,0 +1,118 @@ +package mil.dds.anet.beans.search; + +import io.leangen.graphql.annotations.GraphQLInputField; +import io.leangen.graphql.annotations.GraphQLQuery; +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +public class EventSearchQuery extends EventSeriesSearchQuery { + @GraphQLQuery + @GraphQLInputField + private String eventSeriesUuid; + @GraphQLQuery + @GraphQLInputField + private List locationUuid; + @GraphQLQuery + @GraphQLInputField + String taskUuid; + @GraphQLQuery + @GraphQLInputField + Instant includeDate; + @GraphQLQuery + @GraphQLInputField + Instant startDate; + @GraphQLQuery + @GraphQLInputField + Instant endDate; + @GraphQLQuery + @GraphQLInputField + String type; + + public EventSearchQuery() { + super(); + } + + public String getEventSeriesUuid() { + return eventSeriesUuid; + } + + public void setEventSeriesUuid(String eventSeriesUuid) { + this.eventSeriesUuid = eventSeriesUuid; + } + + public List getLocationUuid() { + return locationUuid; + } + + public void setLocationUuid(List locationUuid) { + this.locationUuid = locationUuid; + } + + public String getTaskUuid() { + return taskUuid; + } + + public void setTaskUuid(String taskUuid) { + this.taskUuid = taskUuid; + } + + public Instant getIncludeDate() { + return includeDate; + } + + public void setIncludeDate(Instant includeDate) { + this.includeDate = includeDate; + } + + public Instant getStartDate() { + return startDate; + } + + public void setStartDate(Instant startDate) { + this.startDate = startDate; + } + + public Instant getEndDate() { + return endDate; + } + + public void setEndDate(Instant endDate) { + this.endDate = endDate; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), getHostOrgUuid(), getAdminOrgUuid(), eventSeriesUuid, + locationUuid, taskUuid, includeDate, startDate, endDate, type); + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof EventSearchQuery other)) { + return false; + } + return super.equals(obj) && Objects.equals(getHostOrgUuid(), other.getHostOrgUuid()) + && Objects.equals(getAdminOrgUuid(), other.getAdminOrgUuid()) + && Objects.equals(getEventSeriesUuid(), other.getEventSeriesUuid()) + && Objects.equals(getLocationUuid(), other.getLocationUuid()) + && Objects.equals(getTaskUuid(), other.getTaskUuid()) + && Objects.equals(getIncludeDate(), other.getIncludeDate()) + && Objects.equals(getStartDate(), other.getStartDate()) + && Objects.equals(getEndDate(), other.getEndDate()) + && Objects.equals(getType(), other.getType()); + } + + @Override + public EventSearchQuery clone() throws CloneNotSupportedException { + return (EventSearchQuery) super.clone(); + } +} diff --git a/src/main/java/mil/dds/anet/beans/search/EventSearchSortBy.java b/src/main/java/mil/dds/anet/beans/search/EventSearchSortBy.java new file mode 100644 index 0000000000..0c46b2dccd --- /dev/null +++ b/src/main/java/mil/dds/anet/beans/search/EventSearchSortBy.java @@ -0,0 +1,5 @@ +package mil.dds.anet.beans.search; + +public enum EventSearchSortBy implements ISortBy { + NAME +} diff --git a/src/main/java/mil/dds/anet/beans/search/EventSeriesSearchQuery.java b/src/main/java/mil/dds/anet/beans/search/EventSeriesSearchQuery.java new file mode 100644 index 0000000000..98d1b82161 --- /dev/null +++ b/src/main/java/mil/dds/anet/beans/search/EventSeriesSearchQuery.java @@ -0,0 +1,55 @@ +package mil.dds.anet.beans.search; + +import io.leangen.graphql.annotations.GraphQLInputField; +import io.leangen.graphql.annotations.GraphQLQuery; +import java.util.List; +import java.util.Objects; + +public class EventSeriesSearchQuery extends SubscribableObjectSearchQuery { + + @GraphQLQuery + @GraphQLInputField + private List hostOrgUuid; + @GraphQLQuery + @GraphQLInputField + private List adminOrgUuid; + + public EventSeriesSearchQuery() { + super(EventSeriesSearchSortBy.NAME); + } + + public List getHostOrgUuid() { + return hostOrgUuid; + } + + public void setHostOrgUuid(List hostOrgUuid) { + this.hostOrgUuid = hostOrgUuid; + } + + public List getAdminOrgUuid() { + return adminOrgUuid; + } + + public void setAdminOrgUuid(List adminOrgUuid) { + this.adminOrgUuid = adminOrgUuid; + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), hostOrgUuid, adminOrgUuid); + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof EventSeriesSearchQuery other)) { + return false; + } + return super.equals(obj) && Objects.equals(getHostOrgUuid(), other.getHostOrgUuid()) + && Objects.equals(getAdminOrgUuid(), other.getAdminOrgUuid()); + } + + @Override + public EventSeriesSearchQuery clone() throws CloneNotSupportedException { + return (EventSeriesSearchQuery) super.clone(); + } +} diff --git a/src/main/java/mil/dds/anet/beans/search/EventSeriesSearchSortBy.java b/src/main/java/mil/dds/anet/beans/search/EventSeriesSearchSortBy.java new file mode 100644 index 0000000000..de69c89df0 --- /dev/null +++ b/src/main/java/mil/dds/anet/beans/search/EventSeriesSearchSortBy.java @@ -0,0 +1,5 @@ +package mil.dds.anet.beans.search; + +public enum EventSeriesSearchSortBy implements ISortBy { + NAME +} 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 a95ee1e439..afa9c39140 100644 --- a/src/main/java/mil/dds/anet/beans/search/ReportSearchQuery.java +++ b/src/main/java/mil/dds/anet/beans/search/ReportSearchQuery.java @@ -98,6 +98,9 @@ public class ReportSearchQuery extends SubscribableObjectSearchQuery { + + private static final String[] fields = {"uuid", "status", "type", "name", "description", + "hostOrgUuid", "adminOrgUuid", "eventSeriesUuid", "locationUuid", "startDate", "endDate", + "outcomes", "createdAt", "updatedAt"}; + public static final String TABLE_NAME = "events"; + public static final String EVENT_FIELDS = DaoUtils.buildFieldAliases(TABLE_NAME, fields, true); + + @Override + public Event getByUuid(String uuid) { + return getByIds(Collections.singletonList(uuid)).get(0); + } + + static class SelfIdBatcher extends IdBatcher { + private static final String sql = "/* batch.getEventsByUuids */ SELECT " + EVENT_FIELDS + + " from events where uuid IN ( )"; + + public SelfIdBatcher() { + super(sql, "uuids", new EventMapper()); + } + } + + static class TasksBatcher extends ForeignKeyBatcher { + private static final String sql = "/* batch.getTasksForEvent */ SELECT " + TaskDao.TASK_FIELDS + + ", \"eventTasks\".\"eventUuid\" FROM tasks, \"eventTasks\" " + + "WHERE \"eventTasks\".\"eventUuid\" IN ( ) " + + "AND \"eventTasks\".\"taskUuid\" = tasks.uuid ORDER BY uuid"; + + public TasksBatcher() { + super(sql, "foreignKeys", new TaskMapper(), "eventUuid"); + } + } + + public List> getTasks(List foreignKeys) { + final ForeignKeyBatcher tasksBatcher = + AnetObjectEngine.getInstance().getInjector().getInstance(EventDao.TasksBatcher.class); + return tasksBatcher.getByForeignKeys(foreignKeys); + } + + @Override + public List getByIds(List uuids) { + final IdBatcher idBatcher = + AnetObjectEngine.getInstance().getInjector().getInstance(SelfIdBatcher.class); + return idBatcher.getByIds(uuids); + } + + @Override + public Event insertInternal(Event event) { + getDbHandle().createUpdate( + "/* insertEvents */ INSERT INTO events (uuid, status, type, name, description, " + + "\"startDate\", \"endDate\", outcomes, " + + "\"hostOrgUuid\",\"adminOrgUuid\", \"eventSeriesUuid\", \"locationUuid\", " + + "\"createdAt\", \"updatedAt\") " + + "VALUES (:uuid, :status, :type, :name, :description, :startDate, :endDate, :outcomes, " + + ":hostOrgUuid, :adminOrgUuid, :eventSeriesUuid, :locationUuid, :createdAt, :updatedAt)") + .bindBean(event).bind("createdAt", DaoUtils.asLocalDateTime(event.getCreatedAt())) + .bind("updatedAt", DaoUtils.asLocalDateTime(event.getUpdatedAt())) + .bind("startDate", DaoUtils.asLocalDateTime(event.getStartDate())) + .bind("endDate", DaoUtils.asLocalDateTime(event.getEndDate())) + .bind("status", DaoUtils.getEnumId(event.getStatus())) + .bind("hostOrgUuid", DaoUtils.getUuid(event.getHostOrg())) + .bind("adminOrgUuid", DaoUtils.getUuid(event.getAdminOrg())) + .bind("eventSeriesUuid", DaoUtils.getUuid(event.getEventSeries())) + .bind("locationId", DaoUtils.getUuid(event.getLocation())).execute(); + + final EventDao.EventBatch rb = getDbHandle().attach(EventDao.EventBatch.class); + + if (event.getTasks() != null) { + rb.insertEventTasks(event.getUuid(), event.getTasks()); + } + + return event; + } + + public interface EventBatch { + @SqlBatch("INSERT INTO \"eventTasks\" (\"eventUuid\", \"taskUuid\") VALUES (:eventUuid, :uuid)") + void insertEventTasks(@Bind("eventUuid") String eventUuid, @BindBean List tasks); + } + + @Override + public int updateInternal(Event event) { + return getDbHandle().createUpdate("/* updateEvent */ UPDATE events " + + "SET status = :status, type = :type, name = :name, description = :description, " + + "\"startDate\" = :startDate, \"endDate\" = :endDate, outcomes = :outcomes, " + + "\"hostOrgUuid\" = :hostOrgUuid, \"adminOrgUuid\" = :adminOrgUuid, \"eventSeriesUuid\" = :eventSeriesUuid, " + + "\"locationUuid\" = :locationUuid, \"updatedAt\" = :updatedAt " + " WHERE uuid = :uuid") + .bindBean(event).bind("updatedAt", DaoUtils.asLocalDateTime(event.getUpdatedAt())) + .bind("startDate", DaoUtils.asLocalDateTime(event.getStartDate())) + .bind("endDate", DaoUtils.asLocalDateTime(event.getEndDate())) + .bind("status", DaoUtils.getEnumId(event.getStatus())) + .bind("hostOrgUuid", DaoUtils.getUuid(event.getHostOrg())) + .bind("adminOrgUuid", DaoUtils.getUuid(event.getAdminOrg())) + .bind("eventSeriesUuid", DaoUtils.getUuid(event.getEventSeries())) + .bind("eventSeriesUuid", DaoUtils.getUuid(event.getEventSeries())) + .bind("locationUuid", DaoUtils.getUuid(event.getLocation())).execute(); + } + + @InTransaction + public int addTaskToEvent(Task p, Event e) { + return getDbHandle() + .createUpdate( + "/* addTaskToEvent */ INSERT INTO \"eventTasks\" (\"taskUuid\", \"eventUuid\") " + + "VALUES (:taskUuid, :eventUuid)") + .bind("eventUuid", e.getUuid()).bind("taskUuid", p.getUuid()).execute(); + } + + @InTransaction + public int removeTaskFromEvent(String taskUuid, Event e) { + return getDbHandle() + .createUpdate("/* removeTaskFromEvent*/ DELETE FROM \"eventTasks\" " + + "WHERE \"eventUuid\" = :eventUuid AND \"taskUuid\" = :taskUuid") + .bind("eventUuid", e.getUuid()).bind("taskUuid", taskUuid).execute(); + } + + public CompletableFuture> getTasksForEvent( + @GraphQLRootContext Map context, String eventUuid) { + return new ForeignKeyFetcher().load(context, FkDataLoaderKey.EVENT_TASKS, eventUuid); + } + + @Override + public AnetBeanList search(EventSearchQuery query) { + return AnetObjectEngine.getInstance().getSearcher().getEventSearcher().runSearch(query); + } + + @Override + public SubscriptionUpdateGroup getSubscriptionUpdate(Event obj) { + return getCommonSubscriptionUpdate(obj, TABLE_NAME, "events.uuid"); + } +} diff --git a/src/main/java/mil/dds/anet/database/EventSeriesDao.java b/src/main/java/mil/dds/anet/database/EventSeriesDao.java new file mode 100644 index 0000000000..faa676bd8a --- /dev/null +++ b/src/main/java/mil/dds/anet/database/EventSeriesDao.java @@ -0,0 +1,81 @@ +package mil.dds.anet.database; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import mil.dds.anet.AnetObjectEngine; +import mil.dds.anet.beans.EventSeries; +import mil.dds.anet.beans.lists.AnetBeanList; +import mil.dds.anet.beans.search.EventSeriesSearchQuery; +import mil.dds.anet.database.mappers.EventSeriesMapper; +import mil.dds.anet.utils.DaoUtils; + +public class EventSeriesDao extends AnetSubscribableObjectDao { + + private static final String[] fields = {"uuid", "status", "name", "description", "hostOrgUuid", + "adminOrgUuid", "createdAt", "updatedAt"}; + public static final String TABLE_NAME = "eventSeries"; + public static final String EVENT_SERIES_FIELDS = + DaoUtils.buildFieldAliases(TABLE_NAME, fields, true); + + @Override + public EventSeries getByUuid(String uuid) { + return getByIds(Collections.singletonList(uuid)).get(0); + } + + static class SelfIdBatcher extends IdBatcher { + private static final String sql = "/* batch.getEventSeriesByUuids */ SELECT " + + EVENT_SERIES_FIELDS + " from \"eventSeries\" where uuid IN ( )"; + + public SelfIdBatcher() { + super(sql, "uuids", new EventSeriesMapper()); + } + } + + @Override + public List getByIds(List uuids) { + final IdBatcher idBatcher = + AnetObjectEngine.getInstance().getInjector().getInstance(SelfIdBatcher.class); + return idBatcher.getByIds(uuids); + } + + @Override + public EventSeries insertInternal(EventSeries eventSeries) { + getDbHandle() + .createUpdate( + "/* insertEventSeries */ INSERT INTO \"eventSeries\" (uuid, status, name, description, " + + "\"hostOrgUuid\", \"adminOrgUuid\", \"createdAt\", \"updatedAt\") " + + "VALUES (:uuid, :status, :name, :description, :hostOrgUuid, :adminOrgUuid, " + + ":createdAt, :updatedAt)") + .bindBean(eventSeries).bind("status", DaoUtils.getEnumId(eventSeries.getStatus())) + .bind("createdAt", DaoUtils.asLocalDateTime(eventSeries.getCreatedAt())) + .bind("updatedAt", DaoUtils.asLocalDateTime(eventSeries.getUpdatedAt())) + .bind("hostOrgUuid", DaoUtils.getUuid(eventSeries.getHostOrg())) + .bind("adminOrgUuid", DaoUtils.getUuid(eventSeries.getAdminOrg())).execute(); + + return eventSeries; + } + + @Override + public int updateInternal(EventSeries eventSeries) { + return getDbHandle().createUpdate("/* updateEventSeries */ UPDATE \"eventSeries\" " + + "SET name = :name, status = :status, description = :description, " + + "\"hostOrgUuid\" = :hostOrgUuid, \"adminOrgUuid\" = :adminOrgUuid, \"updatedAt\" = :updatedAt " + + " WHERE uuid = :uuid").bindBean(eventSeries) + .bind("status", DaoUtils.getEnumId(eventSeries.getStatus())) + .bind("updatedAt", DaoUtils.asLocalDateTime(eventSeries.getUpdatedAt())) + .bind("hostOrgUuid", DaoUtils.getUuid(eventSeries.getHostOrg())) + .bind("adminOrgUuid", DaoUtils.getUuid(eventSeries.getAdminOrg())).execute(); + } + + + @Override + public AnetBeanList search(EventSeriesSearchQuery query) { + return AnetObjectEngine.getInstance().getSearcher().getEventSeriesSearcher().runSearch(query); + } + + @Override + public SubscriptionUpdateGroup getSubscriptionUpdate(EventSeries obj) { + return getCommonSubscriptionUpdate(obj, TABLE_NAME, "eventSeries.uuid"); + } +} diff --git a/src/main/java/mil/dds/anet/database/ReportDao.java b/src/main/java/mil/dds/anet/database/ReportDao.java index c7aff2d16b..a7f569812a 100644 --- a/src/main/java/mil/dds/anet/database/ReportDao.java +++ b/src/main/java/mil/dds/anet/database/ReportDao.java @@ -80,7 +80,7 @@ public class ReportDao extends AnetSubscribableObjectDao { + + @Override + public Event map(ResultSet r, StatementContext ctx) throws SQLException { + Event event = new Event(); + MapperUtils.setCustomizableBeanFields(event, r, "events"); + event.setType(r.getString("events_type")); + event.setName(r.getString("events_name")); + event.setDescription(r.getString("events_description")); + event.setStartDate(getInstantAsLocalDateTime(r, "events_startDate")); + event.setEndDate(getInstantAsLocalDateTime(r, "events_endDate")); + event.setOutcomes(r.getString("events_outcomes")); + event.setHostOrgUuid(r.getString("events_hostOrgUuid")); + event.setAdminOrgUuid(r.getString("events_adminOrgUuid")); + event.setEventSeriesUuid(r.getString("events_eventSeriesUuid")); + event.setLocationUuid(r.getString("events_locationUuid")); + + if (MapperUtils.containsColumnNamed(r, "totalCount")) { + ctx.define("totalCount", r.getInt("totalCount")); + } + + return event; + } + +} diff --git a/src/main/java/mil/dds/anet/database/mappers/EventSeriesMapper.java b/src/main/java/mil/dds/anet/database/mappers/EventSeriesMapper.java new file mode 100644 index 0000000000..2bfe99ca81 --- /dev/null +++ b/src/main/java/mil/dds/anet/database/mappers/EventSeriesMapper.java @@ -0,0 +1,29 @@ +package mil.dds.anet.database.mappers; + +import java.sql.ResultSet; +import java.sql.SQLException; +import mil.dds.anet.beans.EventSeries; +import org.jdbi.v3.core.mapper.RowMapper; +import org.jdbi.v3.core.statement.StatementContext; + +public class EventSeriesMapper implements RowMapper { + + @Override + public EventSeries map(ResultSet r, StatementContext ctx) throws SQLException { + EventSeries eventSeries = new EventSeries(); + MapperUtils.setCustomizableBeanFields(eventSeries, r, "eventSeries"); + eventSeries + .setStatus(MapperUtils.getEnumIdx(r, "eventSeries_status", EventSeries.Status.class)); + eventSeries.setName(r.getString("eventSeries_name")); + eventSeries.setDescription(r.getString("eventSeries_description")); + eventSeries.setHostOrgUuid(r.getString("eventSeries_hostOrgUuid")); + eventSeries.setAdminOrgUuid(r.getString("eventSeries_adminOrgUuid")); + + if (MapperUtils.containsColumnNamed(r, "totalCount")) { + ctx.define("totalCount", r.getInt("totalCount")); + } + + return eventSeries; + } + +} diff --git a/src/main/java/mil/dds/anet/database/mappers/ReportMapper.java b/src/main/java/mil/dds/anet/database/mappers/ReportMapper.java index 63f3fdfda0..ed45484874 100644 --- a/src/main/java/mil/dds/anet/database/mappers/ReportMapper.java +++ b/src/main/java/mil/dds/anet/database/mappers/ReportMapper.java @@ -38,6 +38,8 @@ public Report map(ResultSet rs, StatementContext ctx) throws SQLException { r.setAdvisorOrgUuid(getOptionalString(rs, "reports_advisorOrganizationUuid")); r.setInterlocutorOrgUuid(getOptionalString(rs, "reports_interlocutorOrganizationUuid")); + r.setEventUuid(getOptionalString(rs, "reports_eventUuid")); + if (MapperUtils.containsColumnNamed(rs, "totalCount")) { ctx.define("totalCount", rs.getInt("totalCount")); } diff --git a/src/main/java/mil/dds/anet/resources/EventResource.java b/src/main/java/mil/dds/anet/resources/EventResource.java new file mode 100644 index 0000000000..4a261859be --- /dev/null +++ b/src/main/java/mil/dds/anet/resources/EventResource.java @@ -0,0 +1,139 @@ +package mil.dds.anet.resources; + +import io.leangen.graphql.annotations.GraphQLArgument; +import io.leangen.graphql.annotations.GraphQLMutation; +import io.leangen.graphql.annotations.GraphQLQuery; +import io.leangen.graphql.annotations.GraphQLRootContext; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response.Status; +import java.util.List; +import java.util.Map; +import mil.dds.anet.AnetObjectEngine; +import mil.dds.anet.beans.*; +import mil.dds.anet.beans.lists.AnetBeanList; +import mil.dds.anet.beans.search.EventSearchQuery; +import mil.dds.anet.database.EventDao; +import mil.dds.anet.graphql.AllowUnverifiedUsers; +import mil.dds.anet.utils.*; + +@Path("/api/event") +public class EventResource { + + private final AnetObjectEngine engine; + private final EventDao dao; + + public EventResource(AnetObjectEngine engine) { + this.engine = engine; + this.dao = engine.getEventDao(); + } + + public static void assertPermission(final Person user, final String orgUuid) { + if (!AuthUtils.canAdministrateOrg(user, orgUuid)) { + throw new WebApplicationException(AuthUtils.UNAUTH_MESSAGE, Status.FORBIDDEN); + } + } + + @GraphQLQuery(name = "event") + public Event getByUuid(@GraphQLArgument(name = "uuid") String uuid) { + Event es = dao.getByUuid(uuid); + if (es == null) { + throw new WebApplicationException("Event not found", Status.NOT_FOUND); + } + return es; + } + + @GraphQLQuery(name = "eventList") + @AllowUnverifiedUsers + public AnetBeanList search(@GraphQLRootContext Map context, + @GraphQLArgument(name = "query") EventSearchQuery query) { + query.setUser(DaoUtils.getUserFromContext(context)); + return dao.search(query); + } + + @GraphQLMutation(name = "createEvent") + public Event createEvent(@GraphQLRootContext Map context, + @GraphQLArgument(name = "event") Event event) { + final Person user = DaoUtils.getUserFromContext(context); + validateEvent(user, event); + + event.setDescription(Utils.isEmptyHtml(event.getDescription()) ? null + : Utils.sanitizeHtml(event.getDescription())); + final Event created = dao.insert(event); + + AnetAuditLogger.log("Event {} created by {}", created, user); + return created; + } + + @GraphQLMutation(name = "updateEvent") + public Integer updateEvent(@GraphQLRootContext Map context, + @GraphQLArgument(name = "event") Event event) { + final Person user = DaoUtils.getUserFromContext(context); + validateEvent(user, event); + // perform all modifications to the event and its tasks in a single transaction, + return executeEventUpdates(event); + } + + /** + * Perform all modifications to the event and its tasks, returning the original state of the + * event. Should be wrapped in a single transaction to ensure consistency. + * + * @param event Event object with the desired modifications + * @return number of rows of the update + */ + private Integer executeEventUpdates(Event event) { + // Verify this person has access to edit this report + // Either they are an author, or an approver for the current step. + final Event existing = dao.getByUuid(event.getUuid()); + if (existing == null) { + throw new WebApplicationException("Event not found", Status.NOT_FOUND); + } + event.setDescription(Utils.isEmptyHtml(event.getDescription()) ? null + : Utils.sanitizeHtml(event.getDescription())); + + // begin DB modifications + final int numRows = dao.update(event); + if (numRows == 0) { + throw new WebApplicationException("Couldn't process event update", Status.NOT_FOUND); + } + + // Update Tasks: + if (event.getTasks() != null) { + final List existingTasks = + dao.getTasksForEvent(engine.getContext(), event.getUuid()).join(); + Utils.addRemoveElementsByUuid(existingTasks, event.getTasks(), + newTask -> dao.addTaskToEvent(newTask, event), + oldTask -> dao.removeTaskFromEvent(DaoUtils.getUuid(oldTask), event)); + } + + return numRows; + } + + private void validateEvent(final Person user, final Event event) { + if (event.getType() == null || event.getType().trim().isEmpty()) { + throw new WebApplicationException("Event type must not be empty", Status.BAD_REQUEST); + } + if (event.getName() == null || event.getName().trim().isEmpty()) { + throw new WebApplicationException("Event name must not be empty", Status.BAD_REQUEST); + } + if (event.getDescription() == null || event.getDescription().trim().isEmpty()) { + throw new WebApplicationException("Event description must not be empty", Status.BAD_REQUEST); + } + if (event.getStartDate() == null) { + throw new WebApplicationException("Event start date must not be empty", Status.BAD_REQUEST); + } + if (event.getEndDate() == null) { + throw new WebApplicationException("Event end date must not be empty", Status.BAD_REQUEST); + } + if (event.getHostOrgUuid() == null || event.getHostOrgUuid().trim().isEmpty()) { + throw new WebApplicationException("Event Host Organization must not be empty", + Status.BAD_REQUEST); + } + if (event.getAdminOrgUuid() == null || event.getAdminOrgUuid().trim().isEmpty()) { + throw new WebApplicationException("Event Admin Organization must not be empty", + Status.BAD_REQUEST); + } + assertPermission(user, event.getAdminOrgUuid()); + } + +} diff --git a/src/main/java/mil/dds/anet/resources/EventSeriesResource.java b/src/main/java/mil/dds/anet/resources/EventSeriesResource.java new file mode 100644 index 0000000000..25b7450238 --- /dev/null +++ b/src/main/java/mil/dds/anet/resources/EventSeriesResource.java @@ -0,0 +1,109 @@ +package mil.dds.anet.resources; + +import io.leangen.graphql.annotations.GraphQLArgument; +import io.leangen.graphql.annotations.GraphQLMutation; +import io.leangen.graphql.annotations.GraphQLQuery; +import io.leangen.graphql.annotations.GraphQLRootContext; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response.Status; +import java.util.Map; +import mil.dds.anet.AnetObjectEngine; +import mil.dds.anet.beans.EventSeries; +import mil.dds.anet.beans.Person; +import mil.dds.anet.beans.lists.AnetBeanList; +import mil.dds.anet.beans.search.EventSeriesSearchQuery; +import mil.dds.anet.database.EventSeriesDao; +import mil.dds.anet.graphql.AllowUnverifiedUsers; +import mil.dds.anet.utils.AnetAuditLogger; +import mil.dds.anet.utils.AuthUtils; +import mil.dds.anet.utils.DaoUtils; +import mil.dds.anet.utils.Utils; + +@Path("/api/eventSeries") +public class EventSeriesResource { + + private final EventSeriesDao dao; + + public EventSeriesResource(AnetObjectEngine engine) { + this.dao = engine.getEventSeriesDao(); + } + + public static void assertPermission(final Person user, final String orgUuid) { + if (!AuthUtils.canAdministrateOrg(user, orgUuid)) { + throw new WebApplicationException(AuthUtils.UNAUTH_MESSAGE, Status.FORBIDDEN); + } + } + + @GraphQLQuery(name = "eventSeries") + public EventSeries getByUuid(@GraphQLArgument(name = "uuid") String uuid) { + EventSeries es = dao.getByUuid(uuid); + if (es == null) { + throw new WebApplicationException("Event series not found", Status.NOT_FOUND); + } + return es; + } + + @GraphQLQuery(name = "eventSeriesList") + @AllowUnverifiedUsers + public AnetBeanList search(@GraphQLRootContext Map context, + @GraphQLArgument(name = "query") EventSeriesSearchQuery query) { + final Person user = DaoUtils.getUserFromContext(context); + query.setUser(user); + return dao.search(query); + } + + @GraphQLMutation(name = "createEventSeries") + public EventSeries createEventSeries(@GraphQLRootContext Map context, + @GraphQLArgument(name = "eventSeries") EventSeries eventSeries) { + final Person user = DaoUtils.getUserFromContext(context); + validateEventSeries(user, eventSeries); + + eventSeries.setDescription(Utils.isEmptyHtml(eventSeries.getDescription()) ? null + : Utils.sanitizeHtml(eventSeries.getDescription())); + + final EventSeries created = dao.insert(eventSeries); + + AnetAuditLogger.log("Event Series {} created by {}", created, user); + return created; + } + + @GraphQLMutation(name = "updateEventSeries") + public Integer updateEventSeries(@GraphQLRootContext Map context, + @GraphQLArgument(name = "eventSeries") EventSeries eventSeries) { + final Person user = DaoUtils.getUserFromContext(context); + validateEventSeries(user, eventSeries); + + eventSeries.setDescription(Utils.isEmptyHtml(eventSeries.getDescription()) ? null + : Utils.sanitizeHtml(eventSeries.getDescription())); + + final int numRows = dao.update(eventSeries); + if (numRows == 0) { + throw new WebApplicationException("Couldn't process event series update", Status.NOT_FOUND); + } + + AnetAuditLogger.log("EventSeries {} updated by {}", eventSeries, user); + // GraphQL mutations *have* to return something, so we return the number of updated rows + return numRows; + } + + private void validateEventSeries(final Person user, final EventSeries eventSeries) { + if (eventSeries.getName() == null || eventSeries.getName().trim().isEmpty()) { + throw new WebApplicationException("Event Series name must not be empty", Status.BAD_REQUEST); + } + if (eventSeries.getDescription() == null || eventSeries.getDescription().trim().isEmpty()) { + throw new WebApplicationException("Event Series description must not be empty", + Status.BAD_REQUEST); + } + if (eventSeries.getHostOrgUuid() == null || eventSeries.getHostOrgUuid().trim().isEmpty()) { + throw new WebApplicationException("Event Series Host Organization must not be empty", + Status.BAD_REQUEST); + } + if (eventSeries.getAdminOrgUuid() == null || eventSeries.getAdminOrgUuid().trim().isEmpty()) { + throw new WebApplicationException("Event Series Admin Organization must not be empty", + Status.BAD_REQUEST); + } + assertPermission(user, eventSeries.getAdminOrgUuid()); + } + +} diff --git a/src/main/java/mil/dds/anet/search/AbstractEventSearcher.java b/src/main/java/mil/dds/anet/search/AbstractEventSearcher.java new file mode 100644 index 0000000000..e3d3695353 --- /dev/null +++ b/src/main/java/mil/dds/anet/search/AbstractEventSearcher.java @@ -0,0 +1,101 @@ +package mil.dds.anet.search; + +import mil.dds.anet.beans.Event; +import mil.dds.anet.beans.Location; +import mil.dds.anet.beans.Organization; +import mil.dds.anet.beans.lists.AnetBeanList; +import mil.dds.anet.beans.search.EventSearchQuery; +import mil.dds.anet.database.EventDao; +import mil.dds.anet.database.mappers.EventMapper; +import mil.dds.anet.utils.DaoUtils; +import mil.dds.anet.utils.Utils; +import ru.vyarus.guicey.jdbi3.tx.InTransaction; + +public abstract class AbstractEventSearcher extends AbstractSearcher + implements IEventSearcher { + + public AbstractEventSearcher(AbstractSearchQueryBuilder qb) { + super(qb); + } + + @InTransaction + @Override + public AnetBeanList runSearch(EventSearchQuery query) { + buildQuery(query); + return qb.buildAndRun(getDbHandle(), query, new EventMapper()); + } + + @Override + protected void buildQuery(EventSearchQuery query) { + qb.addSelectClause(EventDao.EVENT_FIELDS); + qb.addFromClause("\"events\""); + qb.addWhereClause("TRUE"); + + if (!Utils.isEmptyOrNull(query.getAdminOrgUuid())) { + addAdminOrgQuery(qb, query); + } + if (!Utils.isEmptyOrNull(query.getEventSeriesUuid())) { + qb.addWhereClause("events.\"eventSeriesUuid\" = :eventSeriesUuid"); + qb.addSqlArg("eventSeriesUuid", query.getEventSeriesUuid()); + } + if (!Utils.isEmptyOrNull(query.getHostOrgUuid())) { + addHostOrgQuery(qb, query); + } + if (!Utils.isEmptyOrNull(query.getLocationUuid())) { + addLocationQuery(qb, query); + } + if (!Utils.isEmptyOrNull(query.getType())) { + qb.addWhereClause("events.type = :type"); + qb.addSqlArg("type", query.getType()); + } + if (query.getIncludeDate() != null) { + qb.addWhereClause(":date >= events.\"startDate\" AND :date <= events.\"endDate\""); + DaoUtils.addInstantAsLocalDateTime(qb.sqlArgs, "date", query.getIncludeDate()); + } + if (query.getStartDate() != null) { + qb.addWhereClause("events.\"startDate\" >= :startDate"); + DaoUtils.addInstantAsLocalDateTime(qb.sqlArgs, "startDate", query.getStartDate()); + } + if (query.getEndDate() != null) { + qb.addWhereClause("events.\"startDate\" <= :endDate"); + DaoUtils.addInstantAsLocalDateTime(qb.sqlArgs, "endDate", query.getEndDate()); + } + qb.addAllOrderByClauses(getOrderBy(query.getSortOrder(), "events_name")); + } + + protected void addLocationQuery(AbstractSearchQueryBuilder outerQb, + EventSearchQuery query) { + if (query.getLocationUuid().size() == 1 + && Location.DUMMY_LOCATION_UUID.equals(query.getLocationUuid().get(0))) { + qb.addWhereClause("event.\"locationUuid\" IS NULL"); + } else { + qb.addRecursiveClause(outerQb, "events", new String[] {"\"locationUuid\""}, + "parent_locations", "\"locationRelationships\"", "\"childLocationUuid\"", + "\"parentLocationUuid\"", "locationUuid", query.getLocationUuid(), true, true); + } + } + + protected void addHostOrgQuery(AbstractSearchQueryBuilder outerQb, + EventSearchQuery query) { + if (query.getHostOrgUuid().size() == 1 + && Organization.DUMMY_ORG_UUID.equals(query.getHostOrgUuid().get(0))) { + qb.addWhereClause("event.\"hostOrgUuid\" IS NULL"); + } else { + qb.addRecursiveClause(outerQb, "events", new String[] {"\"hostOrgUuid\""}, "parent_orgs", + "organizations", "uuid", "\"parentOrgUuid\"", "orgUuid", query.getHostOrgUuid(), true, + true); + } + } + + protected void addAdminOrgQuery(AbstractSearchQueryBuilder outerQb, + EventSearchQuery query) { + if (query.getAdminOrgUuid().size() == 1 + && Organization.DUMMY_ORG_UUID.equals(query.getAdminOrgUuid().get(0))) { + qb.addWhereClause("event.\"adminOrgUuid\" IS NULL"); + } else { + qb.addRecursiveClause(outerQb, "events", new String[] {"\"adminOrgUuid\""}, "parent_orgs", + "organizations", "uuid", "\"parentOrgUuid\"", "orgUuid", query.getAdminOrgUuid(), true, + true); + } + } +} diff --git a/src/main/java/mil/dds/anet/search/AbstractEventSeriesSearcher.java b/src/main/java/mil/dds/anet/search/AbstractEventSeriesSearcher.java new file mode 100644 index 0000000000..6e0aa6965c --- /dev/null +++ b/src/main/java/mil/dds/anet/search/AbstractEventSeriesSearcher.java @@ -0,0 +1,50 @@ +package mil.dds.anet.search; + +import mil.dds.anet.beans.EventSeries; +import mil.dds.anet.beans.Organization; +import mil.dds.anet.beans.lists.AnetBeanList; +import mil.dds.anet.beans.search.EventSeriesSearchQuery; +import mil.dds.anet.database.EventSeriesDao; +import mil.dds.anet.database.mappers.EventSeriesMapper; +import mil.dds.anet.utils.Utils; +import ru.vyarus.guicey.jdbi3.tx.InTransaction; + +public abstract class AbstractEventSeriesSearcher + extends AbstractSearcher implements IEventSeriesSearcher { + + public AbstractEventSeriesSearcher( + AbstractSearchQueryBuilder qb) { + super(qb); + } + + @InTransaction + @Override + public AnetBeanList runSearch(EventSeriesSearchQuery query) { + buildQuery(query); + return qb.buildAndRun(getDbHandle(), query, new EventSeriesMapper()); + } + + @Override + protected void buildQuery(EventSeriesSearchQuery query) { + qb.addSelectClause(EventSeriesDao.EVENT_SERIES_FIELDS); + qb.addFromClause("\"eventSeries\""); + qb.addWhereClause("TRUE"); + if (!Utils.isEmptyOrNull(query.getAdminOrgUuid())) { + addAdminOrgQuery(qb, query); + } + qb.addAllOrderByClauses(getOrderBy(query.getSortOrder(), "eventSeries_name")); + } + + protected void addAdminOrgQuery( + AbstractSearchQueryBuilder outerQb, + EventSeriesSearchQuery query) { + if (query.getAdminOrgUuid().size() == 1 + && Organization.DUMMY_ORG_UUID.equals(query.getAdminOrgUuid().get(0))) { + qb.addWhereClause("event.\"adminOrgUuid\" IS NULL"); + } else { + qb.addRecursiveClause(outerQb, "\"eventSeries\"", new String[] {"\"adminOrgUuid\""}, + "parent_orgs", "organizations", "uuid", "\"parentOrgUuid\"", "orgUuid", + query.getAdminOrgUuid(), true, true); + } + } +} diff --git a/src/main/java/mil/dds/anet/search/AbstractReportSearcher.java b/src/main/java/mil/dds/anet/search/AbstractReportSearcher.java index 31ebfa34ce..927548253d 100644 --- a/src/main/java/mil/dds/anet/search/AbstractReportSearcher.java +++ b/src/main/java/mil/dds/anet/search/AbstractReportSearcher.java @@ -41,7 +41,7 @@ public abstract class AbstractReportSearcher extends AbstractSearcher FIELD_MAPPING = ImmutableMap.builder() .put("reportText", "text").put("location", "locationUuid") .put("approvalStep", "approvalStepUuid").put("advisorOrg", "advisorOrganizationUuid") - .put("interlocutorOrg", "interlocutorOrganizationUuid").build(); + .put("interlocutorOrg", "interlocutorOrganizationUuid").put("event", "eventUuid").build(); public AbstractReportSearcher(AbstractSearchQueryBuilder qb) { super(qb); @@ -191,6 +191,11 @@ protected void buildQuery(Set subFields, ReportSearchQuery query) { addLocationUuidQuery(query); } + if (!Utils.isEmptyOrNull(query.getEventUuid())) { + qb.addWhereClause("reports.\"eventUuid\" = :eventUuid"); + qb.addSqlArg("eventUuid", query.getEventUuid()); + } + if (query.getPendingApprovalOf() != null) { qb.addWhereClause("reports.\"approvalStepUuid\" IN" + " (SELECT \"approvalStepUuid\" FROM approvers WHERE \"positionUuid\" IN" diff --git a/src/main/java/mil/dds/anet/search/IEventSearcher.java b/src/main/java/mil/dds/anet/search/IEventSearcher.java new file mode 100644 index 0000000000..89ea5e1fd9 --- /dev/null +++ b/src/main/java/mil/dds/anet/search/IEventSearcher.java @@ -0,0 +1,9 @@ +package mil.dds.anet.search; + +import mil.dds.anet.beans.Event; +import mil.dds.anet.beans.lists.AnetBeanList; +import mil.dds.anet.beans.search.EventSearchQuery; + +public interface IEventSearcher { + AnetBeanList runSearch(EventSearchQuery query); +} diff --git a/src/main/java/mil/dds/anet/search/IEventSeriesSearcher.java b/src/main/java/mil/dds/anet/search/IEventSeriesSearcher.java new file mode 100644 index 0000000000..62f0e18358 --- /dev/null +++ b/src/main/java/mil/dds/anet/search/IEventSeriesSearcher.java @@ -0,0 +1,9 @@ +package mil.dds.anet.search; + +import mil.dds.anet.beans.EventSeries; +import mil.dds.anet.beans.lists.AnetBeanList; +import mil.dds.anet.beans.search.EventSeriesSearchQuery; + +public interface IEventSeriesSearcher { + AnetBeanList runSearch(EventSeriesSearchQuery query); +} diff --git a/src/main/java/mil/dds/anet/search/ISearcher.java b/src/main/java/mil/dds/anet/search/ISearcher.java index ad00f56e37..a483caf786 100644 --- a/src/main/java/mil/dds/anet/search/ISearcher.java +++ b/src/main/java/mil/dds/anet/search/ISearcher.java @@ -24,4 +24,7 @@ public interface ISearcher { public IAttachmentSearcher getAttachmentSearcher(); + public IEventSeriesSearcher getEventSeriesSearcher(); + + public IEventSearcher getEventSearcher(); } diff --git a/src/main/java/mil/dds/anet/search/pg/PostgresqlEventSearcher.java b/src/main/java/mil/dds/anet/search/pg/PostgresqlEventSearcher.java new file mode 100644 index 0000000000..2e220ff55b --- /dev/null +++ b/src/main/java/mil/dds/anet/search/pg/PostgresqlEventSearcher.java @@ -0,0 +1,16 @@ +package mil.dds.anet.search.pg; + +import mil.dds.anet.beans.search.EventSearchQuery; +import mil.dds.anet.search.AbstractEventSearcher; + +public class PostgresqlEventSearcher extends AbstractEventSearcher { + + public PostgresqlEventSearcher() { + super(new PostgresqlSearchQueryBuilder<>("PostgresqlEventSearch")); + } + + @Override + protected void addTextQuery(EventSearchQuery query) { + addFullTextSearch("events", query.getText(), query.isSortByPresent()); + } +} diff --git a/src/main/java/mil/dds/anet/search/pg/PostgresqlEventSeriesSearcher.java b/src/main/java/mil/dds/anet/search/pg/PostgresqlEventSeriesSearcher.java new file mode 100644 index 0000000000..c32116d481 --- /dev/null +++ b/src/main/java/mil/dds/anet/search/pg/PostgresqlEventSeriesSearcher.java @@ -0,0 +1,16 @@ +package mil.dds.anet.search.pg; + +import mil.dds.anet.beans.search.EventSeriesSearchQuery; +import mil.dds.anet.search.AbstractEventSeriesSearcher; + +public class PostgresqlEventSeriesSearcher extends AbstractEventSeriesSearcher { + + public PostgresqlEventSeriesSearcher() { + super(new PostgresqlSearchQueryBuilder<>("PostgresqlEventSeriesSearch")); + } + + @Override + protected void addTextQuery(EventSeriesSearchQuery query) { + addFullTextSearch("eventSeries", query.getText(), query.isSortByPresent()); + } +} diff --git a/src/main/java/mil/dds/anet/search/pg/PostgresqlSearcher.java b/src/main/java/mil/dds/anet/search/pg/PostgresqlSearcher.java index 11a052438a..205f5e6f81 100644 --- a/src/main/java/mil/dds/anet/search/pg/PostgresqlSearcher.java +++ b/src/main/java/mil/dds/anet/search/pg/PostgresqlSearcher.java @@ -3,6 +3,8 @@ import com.google.inject.Injector; import mil.dds.anet.search.IAttachmentSearcher; import mil.dds.anet.search.IAuthorizationGroupSearcher; +import mil.dds.anet.search.IEventSearcher; +import mil.dds.anet.search.IEventSeriesSearcher; import mil.dds.anet.search.ILocationSearcher; import mil.dds.anet.search.IOrganizationSearcher; import mil.dds.anet.search.IPersonSearcher; @@ -75,4 +77,14 @@ public IAttachmentSearcher getAttachmentSearcher() { return injector.getInstance(PostgresqlAttachmentSearcher.class); } + @Override + public IEventSearcher getEventSearcher() { + return injector.getInstance(PostgresqlEventSearcher.class); + } + + @Override + public IEventSeriesSearcher getEventSeriesSearcher() { + return injector.getInstance(PostgresqlEventSeriesSearcher.class); + } + } diff --git a/src/main/java/mil/dds/anet/utils/BatchingUtils.java b/src/main/java/mil/dds/anet/utils/BatchingUtils.java index 4e8a2d99e5..d27952d4b1 100644 --- a/src/main/java/mil/dds/anet/utils/BatchingUtils.java +++ b/src/main/java/mil/dds/anet/utils/BatchingUtils.java @@ -12,6 +12,8 @@ import mil.dds.anet.beans.Comment; import mil.dds.anet.beans.CustomSensitiveInformation; import mil.dds.anet.beans.EmailAddress; +import mil.dds.anet.beans.Event; +import mil.dds.anet.beans.EventSeries; import mil.dds.anet.beans.GenericRelatedObject; import mil.dds.anet.beans.Location; import mil.dds.anet.beans.Note; @@ -128,6 +130,21 @@ private void registerDataLoaders(AnetObjectEngine engine) { () -> engine.getEmailAddressDao().getEmailAddressesForRelatedObjects(foreignKeys), dispatcherService), dataLoaderOptions)); + dataLoaderRegistry.register(IdDataLoaderKey.EVENTS.toString(), + DataLoaderFactory.newDataLoader( + (BatchLoader) keys -> CompletableFuture + .supplyAsync(() -> engine.getEventDao().getByIds(keys), dispatcherService), + dataLoaderOptions)); + dataLoaderRegistry.register(FkDataLoaderKey.EVENT_TASKS.toString(), + DataLoaderFactory.newDataLoader( + (BatchLoader>) foreignKeys -> CompletableFuture + .supplyAsync(() -> engine.getEventDao().getTasks(foreignKeys), dispatcherService), + dataLoaderOptions)); + dataLoaderRegistry.register(IdDataLoaderKey.EVENT_SERIES.toString(), + DataLoaderFactory.newDataLoader( + (BatchLoader) keys -> CompletableFuture + .supplyAsync(() -> engine.getEventSeriesDao().getByIds(keys), dispatcherService), + dataLoaderOptions)); dataLoaderRegistry.register(FkDataLoaderKey.LOCATION_CHILDREN_LOCATIONS.toString(), DataLoaderFactory.newDataLoader( (BatchLoader>) foreignKeys -> CompletableFuture.supplyAsync( diff --git a/src/main/java/mil/dds/anet/utils/FkDataLoaderKey.java b/src/main/java/mil/dds/anet/utils/FkDataLoaderKey.java index dc7ea33085..da890fc516 100644 --- a/src/main/java/mil/dds/anet/utils/FkDataLoaderKey.java +++ b/src/main/java/mil/dds/anet/utils/FkDataLoaderKey.java @@ -8,7 +8,7 @@ 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 + EVENT_TASKS, LOCATION_CHILDREN_LOCATIONS, // location.childrenLocations LOCATION_PARENT_LOCATIONS, // location.parentLocations NOTE_NOTE_RELATED_OBJECTS, // note.noteRelatedObjects NOTE_RELATED_OBJECT_NOTES, // noteRelatedObject.notes diff --git a/src/main/java/mil/dds/anet/utils/IdDataLoaderKey.java b/src/main/java/mil/dds/anet/utils/IdDataLoaderKey.java index a19f53102d..71314c5b9d 100644 --- a/src/main/java/mil/dds/anet/utils/IdDataLoaderKey.java +++ b/src/main/java/mil/dds/anet/utils/IdDataLoaderKey.java @@ -5,6 +5,8 @@ import mil.dds.anet.database.ApprovalStepDao; import mil.dds.anet.database.AuthorizationGroupDao; import mil.dds.anet.database.CommentDao; +import mil.dds.anet.database.EventDao; +import mil.dds.anet.database.EventSeriesDao; import mil.dds.anet.database.LocationDao; import mil.dds.anet.database.OrganizationDao; import mil.dds.anet.database.PersonDao; @@ -18,6 +20,8 @@ public enum IdDataLoaderKey { APPROVAL_STEPS(ApprovalStepDao.TABLE_NAME), // - AUTHORIZATION_GROUPS(AuthorizationGroupDao.TABLE_NAME), // - COMMENTS(CommentDao.TABLE_NAME), // - + EVENTS(EventDao.TABLE_NAME), // - + EVENT_SERIES(EventSeriesDao.TABLE_NAME), // - LOCATIONS(LocationDao.TABLE_NAME), // - ORGANIZATIONS(OrganizationDao.TABLE_NAME), // - PEOPLE(PersonDao.TABLE_NAME), // - diff --git a/src/main/resources/anet-schema.yml b/src/main/resources/anet-schema.yml index 1945448d62..a4875bc09b 100644 --- a/src/main/resources/anet-schema.yml +++ b/src/main/resources/anet-schema.yml @@ -685,6 +685,45 @@ properties: authorizationGroupRelatedObjects: "$ref": "#/$defs/labeledField" + eventSeries: + type: object + additionalProperties: false + required: [name, description, hostOrg, adminOrg] + properties: + name: + "$ref": "#/$defs/inputField" + description: + "$ref": "#/$defs/inputField" + hostOrg: + "$ref": "#/$defs/inputField" + adminOrg: + "$ref": "#/$defs/inputField" + event: + type: object + additionalProperties: false + required: [name, description, startDate, endDate, hostOrg, adminOrg] + properties: + name: + "$ref": "#/$defs/inputField" + type: + "$ref": "#/$defs/inputField" + description: + "$ref": "#/$defs/inputField" + startDate: + "$ref": "#/$defs/inputField" + endDate: + "$ref": "#/$defs/inputField" + outcomes: + "$ref": "#/$defs/inputField" + hostOrg: + "$ref": "#/$defs/inputField" + adminOrg: + "$ref": "#/$defs/inputField" + eventSeries: + "$ref": "#/$defs/inputField" + location: + "$ref": "#/$defs/inputField" + report: type: object additionalProperties: false @@ -762,6 +801,18 @@ properties: orgUuid: type: string title: UUID of organisation membership to form + event: + required: [filter] + allOf: + - "$ref": "#/$defs/extensibleInputField" + - properties: + filter: + type: array + uniqueItems: true + items: + type: string + enum: + [CONFERENCE, EXERCISE, VISIT_BAN, OTHER] customFields: type: object additionalProperties: diff --git a/src/main/resources/migrations.xml b/src/main/resources/migrations.xml index 2d6ff1a5fc..06de3efa5b 100644 --- a/src/main/resources/migrations.xml +++ b/src/main/resources/migrations.xml @@ -5861,4 +5861,145 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ALTER TABLE reports add column "eventUuid" ${uuid_type}; + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/resources/anet.graphql b/src/test/resources/anet.graphql index 73c3be1bbf..df3cc5aa80 100644 --- a/src/test/resources/anet.graphql +++ b/src/test/resources/anet.graphql @@ -1,6 +1,3 @@ -"""Indicates an Input Object is a OneOf Input Object.""" -directive @oneOf on INPUT_OBJECT - """""" enum ActionType { APPROVE @@ -76,6 +73,22 @@ type AnetBeanList_AuthorizationGroup { totalCount: Int } +"""""" +type AnetBeanList_Event { + list: [Event] + pageNum: Int + pageSize: Int + totalCount: Int +} + +"""""" +type AnetBeanList_EventSeries { + list: [EventSeries] + pageNum: Int + pageSize: Int + totalCount: Int +} + """""" type AnetBeanList_Location { list: [Location] @@ -352,6 +365,124 @@ enum EngagementStatus { HAPPENED } +"""""" +type Event { + adminOrg: Organization + attachments: [Attachment] + createdAt: Instant + customFields: String + customSensitiveInformation: [CustomSensitiveInformation] + description: String + endDate: Instant + eventSeries: EventSeries + hostOrg: Organization + isSubscribed: Boolean + location: Location + name: String + notes: [Note] + outcomes: String + startDate: Instant + status: Status + tasks: [Task] + type: String + updatedAt: Instant + uuid: String +} + +"""""" +input EventInput { + adminOrg: OrganizationInput + createdAt: Instant + customFields: String + customSensitiveInformation: [CustomSensitiveInformationInput] + description: String + endDate: Instant + eventSeries: EventSeriesInput + hostOrg: OrganizationInput + location: LocationInput + name: String + outcomes: String + startDate: Instant + status: Status + tasks: [TaskInput] + type: String + updatedAt: Instant + uuid: String +} + +"""""" +input EventSearchQueryInput { + adminOrgUuid: [String] + emailNetwork: String + endDate: Instant + eventSeriesUuid: String + hostOrgUuid: [String] + inMyReports: Boolean + includeDate: Instant + locationUuid: [String] + pageNum: Int + pageSize: Int + sortBy: EventSeriesSearchSortBy + sortOrder: SortOrder + startDate: Instant + status: Status + subscribed: Boolean + taskUuid: String + text: String + type: String +} + +"""""" +type EventSeries { + adminOrg: Organization + attachments: [Attachment] + createdAt: Instant + customFields: String + customSensitiveInformation: [CustomSensitiveInformation] + description: String + hostOrg: Organization + isSubscribed: Boolean + name: String + notes: [Note] + status: Status + updatedAt: Instant + uuid: String +} + +"""""" +input EventSeriesInput { + adminOrg: OrganizationInput + createdAt: Instant + customFields: String + customSensitiveInformation: [CustomSensitiveInformationInput] + description: String + hostOrg: OrganizationInput + name: String + status: Status + updatedAt: Instant + uuid: String +} + +"""""" +input EventSeriesSearchQueryInput { + adminOrgUuid: [String] + emailNetwork: String + hostOrgUuid: [String] + inMyReports: Boolean + pageNum: Int + pageSize: Int + sortBy: EventSeriesSearchSortBy + sortOrder: SortOrder + status: Status + subscribed: Boolean + text: String +} + +"""""" +enum EventSeriesSearchSortBy { + NAME +} + """""" type GenericRelatedObject { objectUuid: String @@ -458,6 +589,8 @@ type Mutation { clearCache: String createAttachment(attachment: AttachmentInput): String createAuthorizationGroup(authorizationGroup: AuthorizationGroupInput): AuthorizationGroup + createEvent(event: EventInput): Event + createEventSeries(eventSeries: EventSeriesInput): EventSeries createLocation(location: LocationInput): Location createNote(note: NoteInput): Note createOrganization(organization: OrganizationInput): Organization @@ -492,6 +625,8 @@ type Mutation { updateAssociatedPosition(position: PositionInput): Int updateAttachment(attachment: AttachmentInput): String updateAuthorizationGroup(authorizationGroup: AuthorizationGroupInput): Int + updateEvent(event: EventInput): Int + updateEventSeries(eventSeries: EventSeriesInput): Int updateLocation(location: LocationInput): Int updateMe(person: PersonInput): Int updateNote(note: NoteInput): Note @@ -840,6 +975,10 @@ type Query { attachmentList(query: AttachmentSearchQueryInput): AnetBeanList_Attachment authorizationGroup(uuid: String): AuthorizationGroup authorizationGroupList(query: AuthorizationGroupSearchQueryInput): AnetBeanList_AuthorizationGroup + event(uuid: String): Event + eventList(query: EventSearchQueryInput): AnetBeanList_Event + eventSeries(uuid: String): EventSeries + eventSeriesList(query: EventSeriesSearchQueryInput): AnetBeanList_EventSeries location(uuid: String): Location locationList(query: LocationSearchQueryInput): AnetBeanList_Location me: Person @@ -884,7 +1023,7 @@ enum RecurseStrategy { } """""" -union RelatableObject = AuthorizationGroup | Location | Organization | Person | Position | Report | ReportPerson | Task +union RelatableObject = AuthorizationGroup | Event | EventSeries | Location | Organization | Person | Position | Report | ReportPerson | Task """""" type Report { @@ -906,6 +1045,7 @@ type Report { engagementDate: Instant engagementDayOfWeek: Int engagementStatus: [EngagementStatus] + event: Event exsum: String intent: String interlocutorOrg: Organization @@ -967,6 +1107,7 @@ input ReportInput { duration: Int engagementDate: Instant engagementDayOfWeek: Int + event: EventInput exsum: String intent: String interlocutorOrg: OrganizationInput @@ -1068,6 +1209,7 @@ input ReportSearchQueryInput { engagementDateStart: Instant engagementDayOfWeek: Int engagementStatus: [EngagementStatus] + eventUuid: String inMyReports: Boolean includeAllDrafts: Boolean includeEngagementDayOfWeek: Boolean @@ -1164,6 +1306,9 @@ input SavedSearchInput { """""" enum SearchObjectType { + ATTACHMENTS + AUTHORIZATION_GROUPS + EVENTS LOCATIONS ORGANIZATIONS PEOPLE @@ -1192,7 +1337,7 @@ enum Status { } """""" -union SubscribableObject = AuthorizationGroup | Location | Organization | Person | Position | Report | ReportPerson | Task +union SubscribableObject = AuthorizationGroup | Event | EventSeries | Location | Organization | Person | Position | Report | ReportPerson | Task """""" type Subscription { diff --git a/testDictionaries/no-custom-fields.yml b/testDictionaries/no-custom-fields.yml index 2ef2f7c392..f6d546b2e3 100644 --- a/testDictionaries/no-custom-fields.yml +++ b/testDictionaries/no-custom-fields.yml @@ -329,6 +329,52 @@ fields: authorizationGroupRelatedObjects: label: Members + eventSeries: + hostOrg: + label: Host Organization + placeholder: Search for the organization hosting the event series... + adminOrg: + label: Admin Organization + placeholder: Search for the organization that will manage the event series in ANET... + name: + label: Name + placeholder: The name of the event series + description: + label: Description + placeholder: The description of the event series + + event: + eventSeries: + label: Event Series this event belongs to + placeholder: Search for an event series + hostOrg: + label: Host Organization + placeholder: Search for the organization hosting the event... + adminOrg: + label: Admin Organization + placeholder: Search for the organization that will manage the event in ANET... + location: + label: Location where the event takes place + placeholder: Search for a location… + type: + label: Type + placeholder: The type of the event + name: + label: Name + placeholder: The name of the event + description: + label: Description + placeholder: The description of the event + startDate: + label: Start Date + placeholder: The start date of the event + endDate: + label: End Date + placeholder: The end date of the event + outcomes: + label: Outcomes + placeholder: The outcomes of the event + report: canUnpublishReports: true intent: @@ -376,6 +422,10 @@ fields: - label: Linguists filter: orgUuid: 70193ee9-05b4-4aac-80b5-75609825db9f + event: + label: Event + placeholder: Was the engagement part of an event? + filter: [CONFERENCE, EXERCISE, VISIT_BAN, OTHER] person: status: