diff --git a/anet-dictionary.yml b/anet-dictionary.yml index 2e40458cf7..3826d3a3f5 100644 --- a/anet-dictionary.yml +++ b/anet-dictionary.yml @@ -5,6 +5,7 @@ SUPPORT_EMAIL_ADDR: support@example.com regularUsersCanCreateLocations: true engagementsIncludeTimeAndDuration: true +eventsIncludeStartAndEndTime: true calendarOptions: attendeesType: advisor @@ -359,6 +360,61 @@ 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 + organizations: + label: Organizations attending + placeholder: Organizations attending the event + people: + label: People attending + placeholder: People attending the event + tasks: + label: Objectives + placeholder: Objectives of the event + report: canUnpublishReports: true intent: @@ -406,6 +462,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..a2e444104a 100644 --- a/client/src/components/CustomDateInput.js +++ b/client/src/components/CustomDateInput.js @@ -28,6 +28,7 @@ const CustomDateInput = ({ disabled, showIcon, maxDate, + minDate, placement, withTime, value, @@ -74,7 +75,7 @@ const CustomDateInput = ({ }} placeholder={inputFormat} maxDate={maxDate} - minDate={moment().subtract(100, "years").startOf("year").toDate()} + minDate={minDate} canClearSelection={canClearSelection} showActionsBar closeOnSelection={!withTime} @@ -92,6 +93,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 +109,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..c0c302e126 --- /dev/null +++ b/client/src/components/EventCalendar.js @@ -0,0 +1,74 @@ +import API from "api" +import { eventsToCalendarEvents } 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 = eventsToCalendarEvents(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..b5468551f5 --- /dev/null +++ b/client/src/components/EventCollection.js @@ -0,0 +1,173 @@ +import { setPagination } from "actions" +import ButtonToggleGroup from "components/ButtonToggleGroup" +import EventCalendar from "components/EventCalendar" +import EventMap from "components/EventMap" +import EventMatrix from "components/EventMatrix" +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" +export const FORMAT_MATRIX = "matrix" + +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) && ( + + )} + {viewFormats.includes(FORMAT_MATRIX) && ( + + )} + + + )} +
+ )} + +
+ {viewFormat === FORMAT_TABLE && ( + + )} + {viewFormat === FORMAT_SUMMARY && ( + + )} + {viewFormat === FORMAT_CALENDAR && ( + + )} + {viewFormat === FORMAT_MAP && ( + + )} + {viewFormat === FORMAT_MATRIX && ( + + )} +
+
+
+ ) +} + +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, + FORMAT_MATRIX + ] +} + +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/EventMatrix.js b/client/src/components/EventMatrix.js new file mode 100644 index 0000000000..c53645ea93 --- /dev/null +++ b/client/src/components/EventMatrix.js @@ -0,0 +1,224 @@ +import { Icon } from "@blueprintjs/core" +import { IconNames } from "@blueprintjs/icons" +import API from "api" +import { BreadcrumbTrail } from "components/BreadcrumbTrail" +import LinkTo from "components/LinkTo" +import Messages from "components/Messages" +import { mapPageDispatchersToProps } from "components/Page" +import { Event } from "models" +import moment from "moment/moment" +import PropTypes from "prop-types" +import React, { useEffect, useState } from "react" +import { Button, Table } from "react-bootstrap" +import { connect } from "react-redux" +import Settings from "settings" + +const dayNames = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday" +] + +const EventMatrix = props => { + const [weekNumber, setWeekNumber] = useState(null) + const [startDay, setStartDay] = useState(getFirstDayOfCurrentWeek()) + const [events, setEvents] = useState([]) + const [tasks, setTasks] = useState([]) + const [weekDays, setWeekDays] = useState([]) + const [error, setError] = useState(null) + + useEffect(() => { + async function fetchEvents(eventQuery) { + try { + return await API.query(Event.getEventListQuery, { + eventQuery + }) + } catch (error) { + setError(error) + } + } + + // Determine date range + const week = [] + for (let i = 0; i <= 6; i++) { + week.push(moment(startDay).add(i, "days").toDate()) + } + setWeekDays(week) + setWeekNumber(moment(startDay).week()) + + // Get the events + const eventQuery = Object.assign({}, props.queryParams) + eventQuery.startDate = week[0] + eventQuery.endDate = week[6] + eventQuery.onlyWithTasks = true + fetchEvents(eventQuery).then(response => + setEvents(response?.eventList?.list) + ) + }, [startDay, props.queryParams]) + + useEffect(() => { + const tasksSet = new Set() + const tasksArray = [] + events + .map(event => event.tasks) + .flat() + .forEach(task => { + if (!tasksSet.has(task.uuid)) { + tasksSet.add(task.uuid) + tasksArray.push(task) + } + tasksSet.add(task) + }) + tasksArray.sort((a, b) => a.shortName.localeCompare(b.shortName)) + setTasks(tasksArray) + }, [events]) + + function getFirstDayOfCurrentWeek() { + const today = new Date() + const day = today.getDay() + const diff = today.getDate() - day + (day === 0 ? -6 : 1) // adjust when day is sunday + return new Date(today.setDate(diff)) + } + + function isEventIncluded(event, dateToCheck) { + return ( + dateToCheck.getTime() >= event.startDate && + dateToCheck.getTime() <= event.endDate + ) + } + + function showEventTitle(event, dateToCheck) { + // True if event starts on this date or Monday + return ( + new Date(event.startDate).toDateString() === dateToCheck.toDateString() || + dateToCheck.getDay() === 1 + ) + } + + function getEvent(task, dayOfWeek) { + // Get the date + const dateToCheck = new Date(weekDays[dayOfWeek]) + return ( + <> + + + {events + .filter( + event => + event.tasks.filter(t => t.uuid === task.uuid).length > 0 + ) + .map(event => ( + + {isEventIncluded(event, dateToCheck) && ( + + )} + {!isEventIncluded(event, dateToCheck) && ( + + ))} + +
+ {showEventTitle(event, dateToCheck) && ( + + )} + + )} +
+ + ) + } + + function showPreviousPeriod() { + setStartDay(moment(startDay).subtract(7, "days").toDate()) + } + + function showNextPeriod() { + setStartDay(moment(startDay).add(7, "days").toDate()) + } + + return ( + <> + +
+
+ + Events in week {weekNumber} + +
+
+
+ + + + + ))} + + + + {weekDays.map(weekDay => ( + + ))} + + + + {tasks.map(task => ( + + + + + + + + + + + ))} + +
+ {weekDays.map(weekDay => ( + {weekDay.toISOString().slice(0, 10)}
{Settings.fields.task.shortLabel}{dayNames[weekDay.getDay()]}
+ + {getEvent(task, 0)}{getEvent(task, 1)}{getEvent(task, 2)}{getEvent(task, 3)}{getEvent(task, 4)}{getEvent(task, 5)}{getEvent(task, 6)}
+ {events.length === 0 && No events in this week } +
+ + ) +} + +EventMatrix.propTypes = { + // query variables for events, when query & pagination wanted: + queryParams: PropTypes.object +} +export default connect(null, mapPageDispatchersToProps)(EventMatrix) 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..0b212299e2 --- /dev/null +++ b/client/src/components/EventSummary.js @@ -0,0 +1,265 @@ +import API from "api" +import { BreadcrumbTrail } from "components/BreadcrumbTrail" +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 ORGANIZATIONS_ICON from "resources/organizations.png" +import PEOPLE_ICON from "resources/people.png" +import TASKS_ICON from "resources/tasks.png" +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)} + + + + + )} + {!_isEmpty(event.tasks) && ( + + + + {Settings.fields.event.tasks.label}:{" "} + {event.tasks.map((task, i) => ( + + {i > 0 && ( + ★ + )} + + + ))} + + + + )} + {!_isEmpty(event.organizations) && ( + + + + {Settings.fields.event.organizations.label}:{" "} + {event.organizations.map((organization, i) => ( + + {i > 0 && ( + ★ + )} + + + ))} + + + + )} + {!_isEmpty(event.people) && ( + + + + {Settings.fields.event.people.label}:{" "} + {event.people.map((person, i) => ( + + {i > 0 && ( + ★ + )} + + + ))} + + + + )} + + + + 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 f979f0815e..8156a78bc6 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,16 @@ const Navigation = ({ allOrganizations, resetPages, clearSearchQuery }) => { My Authorization Groups )} + {(currentUser.isAdmin() || + !_isEmpty(currentUser?.position?.organizationsAdministrated)) && ( + + My Events + + )} diff --git a/client/src/components/NoPaginationOrganizationTable.js b/client/src/components/NoPaginationOrganizationTable.js new file mode 100644 index 0000000000..c1b1a601ae --- /dev/null +++ b/client/src/components/NoPaginationOrganizationTable.js @@ -0,0 +1,78 @@ +import LinkTo from "components/LinkTo" +import RemoveButton from "components/RemoveButton" +import _get from "lodash/get" +import { Organization } from "models" +import PropTypes from "prop-types" +import React from "react" +import { Table } from "react-bootstrap" + +const NoPaginationOrganizationTable = ({ + id, + organizations, + showDelete, + onDelete, + noOrganizationsMessage +}) => { + const organizationExists = _get(organizations, "length", 0) > 0 + + return ( +
+ {organizationExists ? ( + + + + + + + {showDelete && + + + {Organization.map(organizations, organization => { + return ( + + + + + {showDelete && ( + + )} + + ) + })} + +
NameDescriptionLocation} +
+ + {organization.description} + + + onDelete(organization)} + /> +
+ ) : ( + {noOrganizationsMessage} + )} +
+ ) +} + +NoPaginationOrganizationTable.propTypes = { + id: PropTypes.string, + organizations: PropTypes.array, + showDelete: PropTypes.bool, + onDelete: PropTypes.func, + noOrganizationsMessage: PropTypes.string +} + +NoPaginationOrganizationTable.defaultProps = { + showDelete: false, + noOrganizationsMessage: "No organizations found" +} + +export default NoPaginationOrganizationTable diff --git a/client/src/components/NoPaginationPersonTable.js b/client/src/components/NoPaginationPersonTable.js new file mode 100644 index 0000000000..05d1b56d4b --- /dev/null +++ b/client/src/components/NoPaginationPersonTable.js @@ -0,0 +1,93 @@ +import LinkTo from "components/LinkTo" +import RemoveButton from "components/RemoveButton" +import _get from "lodash/get" +import { Person } from "models" +import PropTypes from "prop-types" +import React from "react" +import { Table } from "react-bootstrap" + +const NoPaginationPeopleTable = ({ + id, + people, + showDelete, + onDelete, + noPeopleMessage +}) => { + const peopleExists = _get(people, "length", 0) > 0 + + return ( +
+ {peopleExists ? ( + + + + + + + + {showDelete && + + + {Person.map(people, person => { + return ( + + + + + + {showDelete && ( + + )} + + ) + })} + +
NameOrganizationPositionLocation} +
+ + + {person.position && person.position.organization && ( + + )} + + + {person.position && person.position.code + ? `, ${person.position.code}` + : ""} + + + + onDelete(person)} + /> +
+ ) : ( + {noPeopleMessage} + )} +
+ ) +} + +NoPaginationPeopleTable.propTypes = { + id: PropTypes.string, + people: PropTypes.array, + showDelete: PropTypes.bool, + onDelete: PropTypes.func, + noPeopleMessage: PropTypes.string +} + +NoPaginationPeopleTable.defaultProps = { + showDelete: false, + showOrganization: false, + noPeopleMessage: "No people found" +} + +export default NoPaginationPeopleTable diff --git a/client/src/components/SearchFilters.js b/client/src/components/SearchFilters.js index 0ebe335f68..b00db3d3c1 100644 --- a/client/src/components/SearchFilters.js +++ b/client/src/components/SearchFilters.js @@ -13,6 +13,9 @@ import CheckboxFilter, { import DateRangeFilter, { deserialize as deserializeDateRangeFilter } from "components/advancedSearch/DateRangeFilter" +import EventSeriesFilter, { + deserialize as deserializeEventSeriesFilter +} from "components/advancedSearch/EventSeriesFilter" import { deserializeMulti as deserializeLocationMultiFilter, LocationMultiFilter @@ -45,7 +48,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" @@ -713,6 +716,55 @@ 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 Host Organization": { + component: OrganizationMultiFilter, + deserializer: deserializeOrganizationMultiFilter, + props: { + queryKey: "hostOrgUuid" + } + }, + "Within Admin Organization": { + component: OrganizationMultiFilter, + deserializer: deserializeOrganizationMultiFilter, + props: { + queryKey: "adminOrgUuid" + } + }, + "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..0854bec657 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,34 @@ export function reportsToEvents(reports, showInterlocutors) { r1.title.localeCompare(r2.title) ) } + +export function eventsToCalendarEvents(events) { + return events + .map(event => { + let title = `${event.name}` + if (event.location) { + title = `${title}@${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: !Settings.eventsIncludeStartAndEndTime + } + }) + .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..e96438bf27 --- /dev/null +++ b/client/src/components/previews/EventPreview.js @@ -0,0 +1,129 @@ +import API from "api" +import { BreadcrumbTrail } from "components/BreadcrumbTrail" +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 { ListGroup, ListGroupItem } from "react-bootstrap" +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}

    +
    + + + + } + /> + } + /> + + ) + } + /> + {event?.organizations?.length > 0 && ( + + {event.organizations.map(org => ( + + + + ))} + + } + /> + )} + {event?.people?.length > 0 && ( + + {event.people.map(person => ( + + + + ))} + + } + /> + )} + {event?.tasks?.length > 0 && ( + + {event.tasks.map(task => ( + + + + ))} + + } + /> + )} +
    +
    + ) +} + +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 7dc35ba06f..3d69f462ba 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, @@ -315,6 +359,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, { @@ -364,6 +409,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, @@ -377,9 +429,11 @@ export const exportResults = ( includeLocations, locationQuery, includeReports, + includeEvents, reportQuery, includeAuthorizationGroups, authorizationGroupQuery, + eventQuery, emailNetwork } return API.queryExport(GQL_GET_DATA, variables, exportType, contentType) diff --git a/client/src/index.css b/client/src/index.css index 6e26ef98c6..0252a2ccec 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1182,6 +1182,45 @@ div[id*='fg-entityAssessment'] { margin-bottom: 1rem; } +.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); +} + +.event-cell-color { + background-color: var(--anet-blue) !important; +} + +.event-cell-height { + height: 55px; +} + +table.event-matrix > :not(caption) > * > * { + padding: 0.5rem 0.0rem; +} + +table.event-matrix thead > *, table.event-matrix thead > * > * { + border-top-style: none; +} + +table.event-matrix thead > *:not(:last-child), table.event-matrix thead > *:not(:last-child) > * { + border-bottom-style: none; +} + +table.event-matrix-cell { + border-collapse: separate; + border-spacing: 0 0.25rem; +} + @media screen and (max-width: 768px) { #main-viewport { padding-top: 8px; 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..537f115631 --- /dev/null +++ b/client/src/models/Event.js @@ -0,0 +1,465 @@ +import { gql } from "@apollo/client" +import Model, { GRAPHQL_ENTITY_AVATAR_FIELDS, 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([]), + organizations: yup.array().nullable().default([]), + people: yup.array().nullable().default([]) + }) + + static autocompleteQuery = ` + uuid + name + description + startDate + endDate + outcomes + hostOrg { + uuid + shortName + } + adminOrg { + uuid + shortName + } + location { + uuid + name + } + tasks { + uuid + shortName + longName + parentTask { + uuid + shortName + } + ascendantTasks { + uuid + shortName + parentTask { + uuid + } + } + taskedOrganizations { + uuid + shortName + longName + identificationCode + } + customFields + } + organizations { + uuid + shortName + } + people { + uuid + name + } + ` + + static getEventQueryNoIsSubscribed = gql` + query ($uuid: String) { + event(uuid: $uuid) { + uuid + status + type + name + description + startDate + endDate + outcomes + updatedAt + hostOrg { + uuid + shortName + longName + identificationCode + ${GRAPHQL_ENTITY_AVATAR_FIELDS} + } + adminOrg { + uuid + shortName + longName + identificationCode + ${GRAPHQL_ENTITY_AVATAR_FIELDS} + } + 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 + ${GRAPHQL_ENTITY_AVATAR_FIELDS} + } + customFields + } + organizations { + uuid + shortName + longName + identificationCode + ${GRAPHQL_ENTITY_AVATAR_FIELDS} + location { + uuid + name + lat + lng + } + } + people { + uuid + name + rank + status + user + endOfTourDate + ${GRAPHQL_ENTITY_AVATAR_FIELDS} + position { + uuid + name + type + code + status + organization { + uuid + shortName + longName + identificationCode + ${GRAPHQL_ENTITY_AVATAR_FIELDS} + } + location { + uuid + name + } + } + } + } + } + ` + + static getEventQuery = gql` + query ($uuid: String) { + event(uuid: $uuid) { + uuid + status + type + name + description + startDate + endDate + outcomes + isSubscribed + updatedAt + hostOrg { + uuid + shortName + longName + identificationCode + ${GRAPHQL_ENTITY_AVATAR_FIELDS} + } + adminOrg { + uuid + shortName + longName + identificationCode + ${GRAPHQL_ENTITY_AVATAR_FIELDS} + } + 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 + ${GRAPHQL_ENTITY_AVATAR_FIELDS} + } + customFields + } + organizations { + uuid + shortName + longName + identificationCode + ${GRAPHQL_ENTITY_AVATAR_FIELDS} + location { + uuid + name + lat + lng + } + } + people { + uuid + name + rank + status + user + endOfTourDate + ${GRAPHQL_ENTITY_AVATAR_FIELDS} + position { + uuid + name + type + code + status + organization { + uuid + shortName + longName + identificationCode + ${GRAPHQL_ENTITY_AVATAR_FIELDS} + } + location { + uuid + name + } + } + } + } + } + ` + + 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 + ${GRAPHQL_ENTITY_AVATAR_FIELDS} + } + adminOrg { + uuid + shortName + longName + identificationCode + ${GRAPHQL_ENTITY_AVATAR_FIELDS} + } + eventSeries { + uuid + name + } + location { + uuid + name + lat + lng + } + tasks { + uuid + shortName + longName + parentTask { + uuid + shortName + } + ascendantTasks { + uuid + shortName + parentTask { + uuid + } + } + taskedOrganizations { + uuid + shortName + longName + identificationCode + } + customFields + } + organizations { + uuid + shortName + longName + identificationCode + ${GRAPHQL_ENTITY_AVATAR_FIELDS} + } + people { + uuid + name + rank + status + user + endOfTourDate + ${GRAPHQL_ENTITY_AVATAR_FIELDS} + } + 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 = ["tasks", "organizations", "people"] + + 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.eventsIncludeTimeAndDuration + ? 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..2ada2065f4 --- /dev/null +++ b/client/src/models/EventSeries.js @@ -0,0 +1,179 @@ +import { gql } from "@apollo/client" +import Model, { GRAPHQL_ENTITY_AVATAR_FIELDS } 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 autocompleteQuery = ` + uuid + status + name + description + hostOrg { + uuid + shortName + longName + identificationCode + ${GRAPHQL_ENTITY_AVATAR_FIELDS} + } + adminOrg { + uuid + shortName + longName + identificationCode + ${GRAPHQL_ENTITY_AVATAR_FIELDS} + } + ` + + static getEventSeriesQueryMin = gql` + query ($uuid: String) { + eventSeries(uuid: $uuid) { + uuid + name + hostOrg { + uuid + shortName + longName + identificationCode + ${GRAPHQL_ENTITY_AVATAR_FIELDS} + } + adminOrg { + uuid + shortName + longName + identificationCode + ${GRAPHQL_ENTITY_AVATAR_FIELDS} + } + } + } + ` + + static getEventSeriesQuery = gql` + query ($uuid: String) { + eventSeries(uuid: $uuid) { + uuid + name + description + updatedAt + isSubscribed + hostOrg { + uuid + shortName + longName + identificationCode + ${GRAPHQL_ENTITY_AVATAR_FIELDS} + } + adminOrg { + uuid + shortName + longName + identificationCode + ${GRAPHQL_ENTITY_AVATAR_FIELDS} + } + } + } + ` + + static getEventSeriesListQuery = gql` + query ($eventSeriesQuery: EventSeriesSearchQueryInput) { + eventSeriesList(query: $eventSeriesQuery) { + pageNum + pageSize + totalCount + list { + uuid + name + description + isSubscribed + hostOrg { + uuid + shortName + longName + identificationCode + ${GRAPHQL_ENTITY_AVATAR_FIELDS} + } + adminOrg { + uuid + shortName + longName + identificationCode + ${GRAPHQL_ENTITY_AVATAR_FIELDS} + } + 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 2241a607a3..940ed36bfe 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 0552e5ded2..19371f94f9 100644 --- a/client/src/pages/Routing.js +++ b/client/src/pages/Routing.js @@ -19,6 +19,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" @@ -209,6 +216,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..7bc59697e2 --- /dev/null +++ b/client/src/pages/eventSeries/Form.js @@ -0,0 +1,255 @@ +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, + submitForm + }) => { + const isAdmin = 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 } + ) + } +} + +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..3b966e0017 --- /dev/null +++ b/client/src/pages/eventSeries/Show.js @@ -0,0 +1,184 @@ +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?.success + const [stateError, setStateError] = useState(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?.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..16fa7b0c1e --- /dev/null +++ b/client/src/pages/events/Form.js @@ -0,0 +1,596 @@ +import API from "api" +import AdvancedMultiSelect from "components/advancedSelectWidget/AdvancedMultiSelect" +import { + EventSeriesOverlayRow, + LocationOverlayRow, + OrganizationOverlayRow, + PersonSimpleOverlayRow, + TaskOverlayRow +} 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 NoPaginationOrganizationTable from "components/NoPaginationOrganizationTable" +import NoPaginationPersonTable from "components/NoPaginationPersonTable" +import NoPaginationTaskTable from "components/NoPaginationTaskTable" +import { jumpToTop } 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, + Person, + Task +} from "models" +import moment from "moment/moment" +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 { useNavigate } from "react-router-dom" +import LOCATIONS_ICON from "resources/locations.png" +import ORGANIZATIONS_ICON from "resources/organizations.png" +import PEOPLE_ICON from "resources/people.png" +import TASKS_ICON from "resources/tasks.png" +import { RECURSE_STRATEGY } from "searchUtils" +import Settings from "settings" +import utils from "utils" + +const EVENT_TYPES = [ + Event.EVENT_TYPES.CONFERENCE, + Event.EVENT_TYPES.EXERCISE, + Event.EVENT_TYPES.VISIT_BAN, + Event.EVENT_TYPES.OTHER +] + +const organizationAutocompleteQuery = `${Organization.autocompleteQuery} ascendantOrgs { uuid app6context app6standardIdentity parentOrg { uuid } }` +const eventSeriesAutocompleteQuery = EventSeries.autocompleteQuery + +const EventForm = ({ edit, title, initialValues, notesComponent }) => { + const { loadAppData, currentUser } = useContext(AppContext) + const navigate = useNavigate() + const [saveError, setSaveError] = useState(null) + const [minDate, setMinDate] = useState( + initialValues?.startDate + ? initialValues.startDate + : moment().subtract(100, "years").startOf("year").toDate() + ) + const [maxDate, setMaxDate] = useState( + initialValues?.endDate + ? initialValues.endDate + : moment().add(20, "years").endOf("year").toDate() + ) + const tasksLabel = pluralize(Settings.fields.task.shortLabel) + + return ( + + {({ + isSubmitting, + dirty, + setFieldValue, + setFieldTouched, + values, + 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 peopleFilters = { + allOrganizations: { + label: "All people", + queryVars: { + status: Model.STATUS.ACTIVE + } + } + } + + const eventSeriesFilters = { + allEventSeries: { + label: "All event series", + queryVars: { + status: Model.STATUS.ACTIVE + } + } + } + + 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={ + ( + + ) + } + /> + } + /> + { + // 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={ + + } + /> + { + // validation will be done by setFieldValue + setFieldTouched("organizations", true, false) // onBlur doesn't work when selecting an option + setFieldValue("organizations", value, true) + }} + widget={ + + } + overlayColumns={[Settings.fields.organization.shortLabel]} + overlayRenderRow={OrganizationOverlayRow} + filterDefs={organizationFilters} + objectType={Organization} + queryParams={{ status: Model.STATUS.ACTIVE }} + fields={Organization.autocompleteQuery} + addon={ORGANIZATIONS_ICON} + /> + } + /> + { + // validation will be done by setFieldValue + setFieldTouched("people", true, false) // onBlur doesn't work when selecting an option + setFieldValue("people", value, true) + }} + widget={ + + } + overlayColumns={[Settings.fields.person.shortLabel]} + overlayRenderRow={PersonSimpleOverlayRow} + filterDefs={peopleFilters} + objectType={Person} + queryParams={{ status: Model.STATUS.ACTIVE }} + fields={Person.autocompleteQuery} + addon={PEOPLE_ICON} + /> + } + /> + {!_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} + /> + } + /> + )} + { + // 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)) + // strip organization fields not in data model + event.organizations = values.organizations.map(t => utils.getReference(t)) + // strip person fields not in data model + event.people = values.people.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 + } + ) + } +} + +EventForm.propTypes = { + initialValues: PropTypes.instanceOf(Event).isRequired, + title: PropTypes.string, + edit: PropTypes.bool, + notesComponent: PropTypes.node +} + +EventForm.defaultProps = { + title: "", + edit: false +} + +export default 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..cdf2c3d35d --- /dev/null +++ b/client/src/pages/events/Show.js @@ -0,0 +1,284 @@ +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 NoPaginationOrganizationTable from "components/NoPaginationOrganizationTable" +import NoPaginationPersonTable from "components/NoPaginationPersonTable" +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?.success + const [stateError, setStateError] = useState(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?.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?.uuid && ( + + ) + } + /> + )} + {event.location?.uuid && ( + + ) + } + /> + )} + + + {event.startDate && + moment(event.startDate).format( + Event.getEventDateFormat() + )} + + } + /> + + {event.endDate && + moment(event.endDate).format( + Event.getEventDateFormat() + )} + + } + /> +
    +
    + +
    + {event.organizations.length > 0 && ( +
    + +
    + )} + {event.people.length > 0 && ( +
    + +
    + )} + {event.tasks.length > 0 && ( +
    + +
    + )} + {event.outcomes && ( +
    + +
    + )} +
    + 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 6a649d8747..d16f790638 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 FindObjectsButton from "components/FindObjectsButton" @@ -308,6 +309,13 @@ const LocationShow = ({ pageDispatchers }) => { mapId="reports" /> +
    + +
    ) }} diff --git a/client/src/pages/organizations/Show.js b/client/src/pages/organizations/Show.js index 45dc8682ac..32e1a396a0 100644 --- a/client/src/pages/organizations/Show.js +++ b/client/src/pages/organizations/Show.js @@ -10,6 +10,7 @@ import EntityAvatarDisplay from "components/avatar/EntityAvatarDisplay" 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 FindObjectsButton from "components/FindObjectsButton" @@ -276,6 +277,9 @@ const OrganizationShow = ({ pageDispatchers }) => { )} + + Events + Reports @@ -285,6 +289,9 @@ const OrganizationShow = ({ pageDispatchers }) => { const reportQueryParams = { orgUuid: uuid } + const eventQueryParams = { + hostOrgUuid: uuid + } if (filterPendingApproval) { reportQueryParams.state = Report.STATE.PENDING_APPROVAL } @@ -701,7 +708,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 +197,15 @@ 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) + // To check if there is a visit ban in the location + const [locationUuid, setLocationUuid] = useState(initialValues?.locationUuid) + const [engagementDate, setEngagementDate] = useState( + initialValues?.engagementDate + ) + const [visitBan, setVisitBan] = useState(false) // some autosave settings const defaultTimeout = moment.duration(AUTOSAVE_TIMEOUT, "seconds") const autoSaveSettings = useRef({ @@ -238,6 +224,37 @@ const ReportForm = ({ } }) + useEffect(() => { + async function checkPotentiallyUnavailableLocation( + engagementDate, + locationUuid + ) { + // When engagement date or location uuid changes we need to call the back-end to figure out if there is a VISIT BAN event + // that applies to the location in the engagement date. If so we need to warn the user. + if (engagementDate && locationUuid) { + const eventQuery = { + pageSize: 1, + type: Event.EVENT_TYPES.VISIT_BAN, + locationUuid, + includeDate: engagementDate + } + try { + const response = await API.query(GQL_GET_EVENT_COUNT, { + eventQuery + }) + setVisitBan(response?.eventList.totalCount > 0) + } catch (error) { + setSaveError(error) + setVisitBan(false) + jumpToTop() + } + } else { + setVisitBan(false) + } + } + checkPotentiallyUnavailableLocation(engagementDate, locationUuid) + }, [engagementDate, locationUuid]) + const recentTasksVarCommon = { pageSize: 6, status: Model.STATUS.ACTIVE, @@ -431,6 +448,8 @@ const ReportForm = ({ queryVars: { selectable: true, isAssigned: false } } + const eventFilters = Event.getReportEventFilters() + if (currentUser.isAdmin()) { tasksFilters.allTasks = { label: `All ${tasksLabel}`, @@ -540,19 +559,22 @@ const ReportForm = ({ /> { setFieldTouched("engagementDate", true, false) // onBlur doesn't work when selecting a date setFieldValue("engagementDate", value, true) + setEngagementDate(value) }} onBlur={() => setFieldTouched("engagementDate")} widget={ } > @@ -587,7 +609,37 @@ const ReportForm = ({ )} { + value = Event.filterClientSideFields(value) + // 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={ + + } + /> + + + {visitBan ? ( + + ) : undefined} utils.getReference(t)) report.location = utils.getReference(report.location) + report.event = utils.getReference(report.event) report.customFields = customFieldsJSONString(values) const edit = isEditMode(values) const operation = edit ? "updateReport" : "createReport" diff --git a/client/src/pages/reports/New.js b/client/src/pages/reports/New.js index 845c64f063..8fabb1945c 100644 --- a/client/src/pages/reports/New.js +++ b/client/src/pages/reports/New.js @@ -1,4 +1,5 @@ import { DEFAULT_SEARCH_PROPS, PAGE_PROPS_NO_NAV } from "actions" +import API from "api" import AppContext from "components/AppContext" import { initInvisibleFields } from "components/CustomFields" import GuidedTour from "components/GuidedTour" @@ -8,15 +9,18 @@ import { useBoilerplate, usePageTitle } from "components/Page" -import { Person, Report } from "models" +import { Event, Person, Report, Task } from "models" import { reportTour } from "pages/HopscotchTour" +import PropTypes from "prop-types" import React, { useContext } from "react" import { connect } from "react-redux" +import { useLocation } from "react-router-dom" import Settings from "settings" +import utils from "utils" import ReportForm from "./Form" const ReportNew = ({ pageDispatchers }) => { - const { currentUser } = useContext(AppContext) + const routerLocation = useLocation() useBoilerplate({ pageProps: PAGE_PROPS_NO_NAV, searchProps: DEFAULT_SEARCH_PROPS, @@ -24,8 +28,66 @@ 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))) + report.event = Event.filterClientSideFields(event) + report.location = event.location + report.tasks = tasks + } // mutates the object initInvisibleFields(report, Settings.fields.report.customFields) @@ -42,7 +104,6 @@ const ReportNew = ({ pageDispatchers }) => { report.getTasksEngagementAssessments(), report.getAttendeesEngagementAssessments() ) - return (
    @@ -65,6 +126,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 676ff0cc3f..520a15da38 100644 --- a/client/src/pages/reports/Show.js +++ b/client/src/pages/reports/Show.js @@ -241,6 +241,11 @@ const GQL_GET_REPORT = gql` attachments { ${Attachment.basicFieldsQuery} } + event { + uuid + name + description + } customFields ${GRAPHQL_NOTES_FIELDS} } @@ -659,6 +664,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)) } @@ -1119,6 +1193,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( @@ -1132,7 +1207,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 @@ -1159,6 +1235,15 @@ const Search = ({ }), [searchQueryParams, pageSize] ) + const eventSearchQueryParams = useMemo( + () => ({ + ...searchQueryParams, + pageSize, + sortBy: "NAME", + sortOrder: "ASC" + }), + [searchQueryParams, pageSize] + ) const reportsSearchQueryParams = useMemo( () => ({ ...searchQueryParams, @@ -1191,6 +1276,7 @@ const Search = ({ latestQuery.current = { queryTypes, searchQueryParams } setNumAttachments(0) setNumAuthorizationGroups(0) + setNumEvents(0) setNumLocations(0) setNumOrganizations(0) setNumPeople(0) @@ -1206,6 +1292,7 @@ const Search = ({ setRecipients, setNumAttachments, setNumAuthorizationGroups, + setNumEvents, setNumLocations, setNumOrganizations, setNumPeople, @@ -1231,6 +1318,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, @@ -1340,6 +1429,16 @@ const Search = ({ )} + + + {" "} + {SEARCH_OBJECT_LABELS[SEARCH_OBJECT_TYPES.EVENTS]}{" "} + {hasEventsResults && ( + + {numEvents} + + )} + @@ -1660,6 +1759,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 0000000000..fa5add24c5 Binary files /dev/null and b/client/src/resources/events.png differ diff --git a/client/tests/webdriver/baseSpecs/advancedSearch.spec.js b/client/tests/webdriver/baseSpecs/advancedSearch.spec.js index 1b11205bf2..a7f614c142 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/AnetObjectEngine.java b/src/main/java/mil/dds/anet/AnetObjectEngine.java index 7f0e0f3255..7f9de0611d 100644 --- a/src/main/java/mil/dds/anet/AnetObjectEngine.java +++ b/src/main/java/mil/dds/anet/AnetObjectEngine.java @@ -29,6 +29,8 @@ import mil.dds.anet.database.EmailAddressDao; import mil.dds.anet.database.EmailDao; import mil.dds.anet.database.EntityAvatarDao; +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; @@ -148,6 +150,14 @@ public EntityAvatarDao getEntityAvatarDao() { return ApplicationContextProvider.getBean(EntityAvatarDao.class); } + public EventSeriesDao getEventSeriesDao() { + return ApplicationContextProvider.getBean(EventSeriesDao.class); + } + + public EventDao getEventDao() { + return ApplicationContextProvider.getBean(EventDao.class); + } + public CompletableFuture canUserApproveStep(GraphQLContext context, String userUuid, String approvalStepUuid, String advisorOrgUuid) { return new UuidFetcher() 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..d60cef8ae2 --- /dev/null +++ b/src/main/java/mil/dds/anet/beans/Event.java @@ -0,0 +1,252 @@ +package mil.dds.anet.beans; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import graphql.GraphQLContext; +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.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import mil.dds.anet.utils.IdDataLoaderKey; +import mil.dds.anet.views.UuidFetcher; + +public class Event extends EventSeries { + public enum EventType { + CONFERENCE("CONFERENCE"), // - + EXERCISE("EXERCISE"), // - + VISIT_BAN("VISIT_BAN"), // - + OTHER("OTHER"); // - + + private static final Map BY_CODE = new HashMap<>(); + static { + for (final Event.EventType e : values()) { + BY_CODE.put(e.code, e); + } + } + + public static Event.EventType valueOfCode(String code) { + return BY_CODE.get(code); + } + + private final String code; + + EventType(String code) { + this.code = code; + } + + @Override + public String toString() { + return code; + } + } + + @GraphQLQuery + @GraphQLInputField + EventType type; + @GraphQLQuery + @GraphQLInputField + Instant startDate; + @GraphQLQuery + @GraphQLInputField + Instant endDate; + @GraphQLQuery + @GraphQLInputField + String outcomes; + + // Lazy Loaded + // annotated below + List tasks; + + // Lazy Loaded + // annotated below + List organizations; + + // Lazy Loaded + // annotated below + List people; + + private ForeignObjectHolder eventSeries = new ForeignObjectHolder<>(); + private ForeignObjectHolder location = new ForeignObjectHolder<>(); + + @GraphQLQuery(name = "eventSeries") + public CompletableFuture loadEventSeries( + @GraphQLRootContext GraphQLContext 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 GraphQLContext 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 GraphQLContext context) { + if (tasks != null) { + return CompletableFuture.completedFuture(tasks); + } + return engine().getEventDao().getTasksForEvent(context, uuid).thenApply(o -> { + tasks = o; + return o; + }); + } + + @GraphQLQuery(name = "organizations") + public CompletableFuture> loadOrganizations( + @GraphQLRootContext GraphQLContext context) { + if (organizations != null) { + return CompletableFuture.completedFuture(organizations); + } + return engine().getEventDao().getOrganizationsForEvent(context, uuid).thenApply(o -> { + organizations = o; + return o; + }); + } + + @GraphQLQuery(name = "people") + public CompletableFuture> loadPeople(@GraphQLRootContext GraphQLContext context) { + if (people != null) { + return CompletableFuture.completedFuture(people); + } + return engine().getEventDao().getPeopleForEvent(context, uuid).thenApply(o -> { + people = o; + return o; + }); + } + + @GraphQLInputField(name = "tasks") + public void setTasks(List tasks) { + this.tasks = tasks; + } + + public List getTasks() { + return tasks; + } + + @GraphQLInputField(name = "organizations") + public void setOrganizations(List organizations) { + this.organizations = organizations; + } + + public List getOrganizations() { + return organizations; + } + + @GraphQLInputField(name = "people") + public void setPeople(List people) { + this.people = people; + } + + public List getPeople() { + return people; + } + + public EventType getType() { + return type; + } + + public void setType(EventType 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(organizations, event.organizations) + && Objects.equals(people, event.people) && Objects.equals(eventSeries, event.eventSeries) + && Objects.equals(location, event.location); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), type, startDate, endDate, outcomes, tasks, organizations, + people, 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..05794a2e59 --- /dev/null +++ b/src/main/java/mil/dds/anet/beans/EventSeries.java @@ -0,0 +1,138 @@ +package mil.dds.anet.beans; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import graphql.GraphQLContext; +import io.leangen.graphql.annotations.GraphQLInputField; +import io.leangen.graphql.annotations.GraphQLQuery; +import io.leangen.graphql.annotations.GraphQLRootContext; +import java.util.Objects; +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 GraphQLContext 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 GraphQLContext 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 36ed503c96..c32fba8f69 100644 --- a/src/main/java/mil/dds/anet/beans/Report.java +++ b/src/main/java/mil/dds/anet/beans/Report.java @@ -99,11 +99,9 @@ public enum ReportCancelledReason { @GraphQLQuery @GraphQLInputField String reportText; - @GraphQLQuery @GraphQLInputField private String classification; - // annotated below private List reportPeople; // annotated below @@ -126,6 +124,8 @@ public enum ReportCancelledReason { private List authorizationGroups; // annotated below private List workflow; + // annotated below + private ForeignObjectHolder event = new ForeignObjectHolder<>(); @GraphQLQuery(name = "approvalStep") public CompletableFuture loadApprovalStep( @@ -803,6 +803,37 @@ public boolean isAuthor(Person user) { .anyMatch(p -> Objects.equals(p.getUuid(), user.getUuid())); } + @GraphQLQuery(name = "event") + public CompletableFuture loadEvent(@GraphQLRootContext GraphQLContext 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)) { @@ -824,7 +855,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 @@ -832,7 +864,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..d1b730dadb --- /dev/null +++ b/src/main/java/mil/dds/anet/beans/search/EventSearchQuery.java @@ -0,0 +1,130 @@ +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; + @GraphQLQuery + @GraphQLInputField + Boolean onlyWithTasks; + + 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; + } + + public boolean isOnlyWithTasks() { + return Boolean.TRUE.equals(onlyWithTasks); + } + + public void setOnlyWithTasks(Boolean onlyWithTasks) { + this.onlyWithTasks = onlyWithTasks; + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), getHostOrgUuid(), getAdminOrgUuid(), eventSeriesUuid, + locationUuid, taskUuid, includeDate, startDate, endDate, type, onlyWithTasks); + } + + @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()) + && Objects.equals(isOnlyWithTasks(), other.isOnlyWithTasks()); + } + + @Override + public EventSearchQuery clone() throws CloneNotSupportedException { + return (EventSearchQuery) super.clone(); + } +} 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..c11f073889 --- /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 { + CREATED_AT, 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 getGraphQLResources() { // Create all GraphQL Resources return List.of(adminResource, anetEmailResource, approvalStepResource, attachmentResource, - authorizationGroupResource, entityAvatarResource, locationResource, noteResource, - organizationResource, personResource, positionResource, reportResource, savedSearchResource, - subscriptionResource, subscriptionUpdateResource, taskResource); + authorizationGroupResource, entityAvatarResource, eventResource, eventSeriesResource, + locationResource, noteResource, organizationResource, personResource, positionResource, + reportResource, savedSearchResource, subscriptionResource, subscriptionUpdateResource, + taskResource); } public static class AuthorizationInterceptor implements ResolverInterceptor { diff --git a/src/main/java/mil/dds/anet/database/EventDao.java b/src/main/java/mil/dds/anet/database/EventDao.java new file mode 100644 index 0000000000..13786df8af --- /dev/null +++ b/src/main/java/mil/dds/anet/database/EventDao.java @@ -0,0 +1,287 @@ +package mil.dds.anet.database; + +import graphql.GraphQLContext; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import mil.dds.anet.beans.Event; +import mil.dds.anet.beans.Organization; +import mil.dds.anet.beans.Person; +import mil.dds.anet.beans.Task; +import mil.dds.anet.beans.lists.AnetBeanList; +import mil.dds.anet.beans.search.EventSearchQuery; +import mil.dds.anet.database.mappers.EventMapper; +import mil.dds.anet.database.mappers.OrganizationMapper; +import mil.dds.anet.database.mappers.PersonMapper; +import mil.dds.anet.database.mappers.TaskMapper; +import mil.dds.anet.search.pg.PostgresqlEventSearcher; +import mil.dds.anet.utils.DaoUtils; +import mil.dds.anet.utils.FkDataLoaderKey; +import mil.dds.anet.views.ForeignKeyFetcher; +import org.jdbi.v3.core.Handle; +import org.jdbi.v3.sqlobject.customizer.Bind; +import org.jdbi.v3.sqlobject.customizer.BindBean; +import org.jdbi.v3.sqlobject.statement.SqlBatch; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class EventDao extends AnetSubscribableObjectDao { + + 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); + + public EventDao(DatabaseHandler databaseHandler) { + super(databaseHandler); + } + + @Override + public Event getByUuid(String uuid) { + return getByIds(Collections.singletonList(uuid)).get(0); + } + + class SelfIdBatcher extends IdBatcher { + private static final String sql = "/* batch.getEventsByUuids */ SELECT " + EVENT_FIELDS + + " from events where uuid IN ( )"; + + public SelfIdBatcher() { + super(databaseHandler, sql, "uuids", new EventMapper()); + } + } + + 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(databaseHandler, sql, "foreignKeys", new TaskMapper(), "eventUuid"); + } + } + + class OrganizationsBatcher extends ForeignKeyBatcher { + private static final String sql = + "/* batch.getOrganizationsForEvent */ SELECT " + OrganizationDao.ORGANIZATION_FIELDS + + ", \"eventOrganizations\".\"eventUuid\" FROM organizations, \"eventOrganizations\" " + + "WHERE \"eventOrganizations\".\"eventUuid\" IN ( ) " + + "AND \"eventOrganizations\".\"organizationUuid\" = organizations.uuid ORDER BY uuid"; + + public OrganizationsBatcher() { + super(databaseHandler, sql, "foreignKeys", new OrganizationMapper(), "eventUuid"); + } + } + + class PeopleBatcher extends ForeignKeyBatcher { + private static final String sql = "/* batch.getPeopleForEvent */ SELECT " + + PersonDao.PERSON_FIELDS + ", \"eventPeople\".\"eventUuid\" FROM people, \"eventPeople\" " + + "WHERE \"eventPeople\".\"eventUuid\" IN ( ) " + + "AND \"eventPeople\".\"personUuid\" = people.uuid ORDER BY uuid"; + + public PeopleBatcher() { + super(databaseHandler, sql, "foreignKeys", new PersonMapper(), "eventUuid"); + } + } + + public List> getTasks(List foreignKeys) { + return new TasksBatcher().getByForeignKeys(foreignKeys); + } + + public List> getOrganizations(List foreignKeys) { + return new OrganizationsBatcher().getByForeignKeys(foreignKeys); + } + + public List> getPeople(List foreignKeys) { + return new PeopleBatcher().getByForeignKeys(foreignKeys); + } + + @Override + public List getByIds(List uuids) { + return new SelfIdBatcher().getByIds(uuids); + } + + @Override + public Event insertInternal(Event event) { + final Handle handle = getDbHandle(); + try { + handle.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 EventBatch rb = handle.attach(EventBatch.class); + + if (event.getTasks() != null) { + rb.insertEventTasks(event.getUuid(), event.getTasks()); + } + + if (event.getOrganizations() != null) { + rb.insertEventOrganizations(event.getUuid(), event.getOrganizations()); + } + + if (event.getPeople() != null) { + rb.insertEventPeople(event.getUuid(), event.getPeople()); + } + + return event; + } finally { + closeDbHandle(handle); + } + } + + public interface EventBatch { + @SqlBatch("INSERT INTO \"eventTasks\" (\"eventUuid\", \"taskUuid\") VALUES (:eventUuid, :uuid)") + void insertEventTasks(@Bind("eventUuid") String eventUuid, @BindBean List tasks); + + @SqlBatch("INSERT INTO \"eventOrganizations\" (\"eventUuid\", \"organizationUuid\") VALUES (:eventUuid, :uuid)") + void insertEventOrganizations(@Bind("eventUuid") String eventUuid, + @BindBean List organizations); + + @SqlBatch("INSERT INTO \"eventPeople\" (\"eventUuid\", \"personUuid\") VALUES (:eventUuid, :uuid)") + void insertEventPeople(@Bind("eventUuid") String eventUuid, @BindBean List people); + } + + @Override + public int updateInternal(Event event) { + final Handle handle = getDbHandle(); + try { + return handle.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(); + } finally { + closeDbHandle(handle); + } + } + + @Transactional + public int addTaskToEvent(Task t, Event e) { + final Handle handle = getDbHandle(); + try { + return handle + .createUpdate( + "/* addTaskToEvent */ INSERT INTO \"eventTasks\" (\"taskUuid\", \"eventUuid\") " + + "VALUES (:taskUuid, :eventUuid)") + .bind("eventUuid", e.getUuid()).bind("taskUuid", t.getUuid()).execute(); + } finally { + closeDbHandle(handle); + } + } + + @Transactional + public int addOrganizationToEvent(Organization o, Event e) { + final Handle handle = getDbHandle(); + try { + return handle.createUpdate( + "/* addOrganizationToEvent */ INSERT INTO \"eventOrganizations\" (\"organizationUuid\", \"eventUuid\") " + + "VALUES (:organizationUuid, :eventUuid)") + .bind("eventUuid", e.getUuid()).bind("organizationUuid", o.getUuid()).execute(); + } finally { + closeDbHandle(handle); + } + } + + @Transactional + public int addPersonToEvent(Person p, Event e) { + final Handle handle = getDbHandle(); + try { + return handle + .createUpdate( + "/* addPersonToEvent */ INSERT INTO \"eventPeople\" (\"personUuid\", \"eventUuid\") " + + "VALUES (:personUuid, :eventUuid)") + .bind("eventUuid", e.getUuid()).bind("personUuid", p.getUuid()).execute(); + } finally { + closeDbHandle(handle); + } + } + + @Transactional + public int removeTaskFromEvent(String taskUuid, Event e) { + final Handle handle = getDbHandle(); + try { + return handle + .createUpdate("/* removeTaskFromEvent*/ DELETE FROM \"eventTasks\" " + + "WHERE \"eventUuid\" = :eventUuid AND \"taskUuid\" = :taskUuid") + .bind("eventUuid", e.getUuid()).bind("taskUuid", taskUuid).execute(); + } finally { + closeDbHandle(handle); + } + } + + @Transactional + public int removeOrganizationFromEvent(String organizationUuid, Event e) { + final Handle handle = getDbHandle(); + try { + return handle + .createUpdate("/* removeOrganizationFromEvent*/ DELETE FROM \"eventOrganizations\" " + + "WHERE \"eventUuid\" = :eventUuid AND \"organizationUuid\" = :organizationUuid") + .bind("eventUuid", e.getUuid()).bind("organizationUuid", organizationUuid).execute(); + } finally { + closeDbHandle(handle); + } + } + + @Transactional + public int removePersonFromEvent(String personUuid, Event e) { + final Handle handle = getDbHandle(); + try { + return handle + .createUpdate("/* removePersonFromEvent*/ DELETE FROM \"eventPeople\" " + + "WHERE \"eventUuid\" = :eventUuid AND \"personUuid\" = :personUuid") + .bind("eventUuid", e.getUuid()).bind("personUuid", personUuid).execute(); + } finally { + closeDbHandle(handle); + } + } + + public CompletableFuture> getTasksForEvent(GraphQLContext context, String eventUuid) { + return new ForeignKeyFetcher().load(context, FkDataLoaderKey.EVENT_TASKS, eventUuid); + } + + public CompletableFuture> getOrganizationsForEvent(GraphQLContext context, + String eventUuid) { + return new ForeignKeyFetcher().load(context, FkDataLoaderKey.EVENT_ORGANIZATIONS, + eventUuid); + } + + public CompletableFuture> getPeopleForEvent(GraphQLContext context, + String eventUuid) { + return new ForeignKeyFetcher().load(context, FkDataLoaderKey.EVENT_PEOPLE, eventUuid); + } + + + @Override + public AnetBeanList search(EventSearchQuery query) { + return new PostgresqlEventSearcher(databaseHandler).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..b1939a7786 --- /dev/null +++ b/src/main/java/mil/dds/anet/database/EventSeriesDao.java @@ -0,0 +1,94 @@ +package mil.dds.anet.database; + +import java.util.Collections; +import java.util.List; +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.search.pg.PostgresqlEventSeriesSearcher; +import mil.dds.anet.utils.DaoUtils; +import org.jdbi.v3.core.Handle; +import org.springframework.stereotype.Component; + +@Component +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); + + public EventSeriesDao(DatabaseHandler databaseHandler) { + super(databaseHandler); + } + + @Override + public EventSeries getByUuid(String uuid) { + return getByIds(Collections.singletonList(uuid)).get(0); + } + + class SelfIdBatcher extends IdBatcher { + private static final String sql = "/* batch.getEventSeriesByUuids */ SELECT " + + EVENT_SERIES_FIELDS + " from \"eventSeries\" where uuid IN ( )"; + + public SelfIdBatcher() { + super(databaseHandler, sql, "uuids", new EventSeriesMapper()); + } + } + + @Override + public List getByIds(List uuids) { + return new SelfIdBatcher().getByIds(uuids); + } + + @Override + public EventSeries insertInternal(EventSeries eventSeries) { + final Handle handle = getDbHandle(); + try { + handle.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; + } finally { + closeDbHandle(handle); + } + } + + @Override + public int updateInternal(EventSeries eventSeries) { + final Handle handle = getDbHandle(); + try { + return handle.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(); + } finally { + closeDbHandle(handle); + } + } + + + @Override + public AnetBeanList search(EventSeriesSearchQuery query) { + return new PostgresqlEventSeriesSearcher(databaseHandler).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 13d08e0293..f2fee87177 100644 --- a/src/main/java/mil/dds/anet/database/ReportDao.java +++ b/src/main/java/mil/dds/anet/database/ReportDao.java @@ -85,7 +85,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(Event.EventType.valueOfCode(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 b31e903c06..1d39c9551a 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.setInterlocutorOrgUuid( MapperUtils.getOptionalString(rs, "reports_interlocutorOrganizationUuid")); + r.setEventUuid(MapperUtils.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..f5c6084d18 --- /dev/null +++ b/src/main/java/mil/dds/anet/resources/EventResource.java @@ -0,0 +1,173 @@ +package mil.dds.anet.resources; + +import graphql.GraphQLContext; +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 io.leangen.graphql.spqr.spring.annotations.GraphQLApi; +import java.util.List; +import mil.dds.anet.AnetObjectEngine; +import mil.dds.anet.beans.Event; +import mil.dds.anet.beans.Organization; +import mil.dds.anet.beans.Person; +import mil.dds.anet.beans.Task; +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.AnetAuditLogger; +import mil.dds.anet.utils.AuthUtils; +import mil.dds.anet.utils.DaoUtils; +import mil.dds.anet.utils.Utils; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +@Component +@GraphQLApi +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 ResponseStatusException(HttpStatus.FORBIDDEN, AuthUtils.UNAUTH_MESSAGE); + } + } + + @GraphQLQuery(name = "event") + public Event getByUuid(@GraphQLArgument(name = "uuid") String uuid) { + final Event es = dao.getByUuid(uuid); + if (es == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Event not found"); + } + return es; + } + + @GraphQLQuery(name = "eventList") + @AllowUnverifiedUsers + public AnetBeanList search(@GraphQLRootContext GraphQLContext context, + @GraphQLArgument(name = "query") EventSearchQuery query) { + query.setUser(DaoUtils.getUserFromContext(context)); + return dao.search(query); + } + + @GraphQLMutation(name = "createEvent") + public Event createEvent(@GraphQLRootContext GraphQLContext 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 GraphQLContext context, + @GraphQLArgument(name = "event") Event event) { + final Person user = DaoUtils.getUserFromContext(context); + validateEvent(user, event); + + // Validate user has permission also for the original adminOrg + final Event existing = dao.getByUuid(event.getUuid()); + assertPermission(user, existing.getAdminOrgUuid()); + + // 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 ResponseStatusException(HttpStatus.NOT_FOUND, "Event 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 ResponseStatusException(HttpStatus.NOT_FOUND, "Couldn't process event update"); + } + + // 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)); + } + + // Update Organizations: + if (event.getOrganizations() != null) { + final List existingOrganizations = + dao.getOrganizationsForEvent(engine.getContext(), event.getUuid()).join(); + Utils.addRemoveElementsByUuid(existingOrganizations, event.getOrganizations(), + newOrganization -> dao.addOrganizationToEvent(newOrganization, event), + oldOrganization -> dao.removeOrganizationFromEvent(DaoUtils.getUuid(oldOrganization), + event)); + } + + // Update People: + if (event.getPeople() != null) { + final List existingPeople = + dao.getPeopleForEvent(engine.getContext(), event.getUuid()).join(); + Utils.addRemoveElementsByUuid(existingPeople, event.getPeople(), + newPerson -> dao.addPersonToEvent(newPerson, event), + oldPerson -> dao.removePersonFromEvent(DaoUtils.getUuid(oldPerson), event)); + } + + return numRows; + } + + private void validateEvent(final Person user, final Event event) { + if (event.getType() == null || event.getType().name().trim().isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Event type must not be empty"); + } + if (event.getName() == null || event.getName().trim().isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Event name must not be empty"); + } + if (event.getDescription() == null || event.getDescription().trim().isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Event description must not be empty"); + } + if (event.getStartDate() == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Event start date must not be empty"); + } + if (event.getEndDate() == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Event end date must not be empty"); + } + if (event.getHostOrgUuid() == null || event.getHostOrgUuid().trim().isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Event Host Organization must not be empty"); + } + if (event.getAdminOrgUuid() == null || event.getAdminOrgUuid().trim().isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Event Admin Organization must not be empty"); + } + 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..ee62c84add --- /dev/null +++ b/src/main/java/mil/dds/anet/resources/EventSeriesResource.java @@ -0,0 +1,116 @@ +package mil.dds.anet.resources; + +import graphql.GraphQLContext; +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 io.leangen.graphql.spqr.spring.annotations.GraphQLApi; +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; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +@Component +@GraphQLApi +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 ResponseStatusException(HttpStatus.FORBIDDEN, AuthUtils.UNAUTH_MESSAGE); + } + } + + @GraphQLQuery(name = "eventSeries") + public EventSeries getByUuid(@GraphQLArgument(name = "uuid") String uuid) { + final EventSeries es = dao.getByUuid(uuid); + if (es == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Event series not found"); + } + return es; + } + + @GraphQLQuery(name = "eventSeriesList") + @AllowUnverifiedUsers + public AnetBeanList search(@GraphQLRootContext GraphQLContext context, + @GraphQLArgument(name = "query") EventSeriesSearchQuery query) { + query.setUser(DaoUtils.getUserFromContext(context)); + return dao.search(query); + } + + @GraphQLMutation(name = "createEventSeries") + public EventSeries createEventSeries(@GraphQLRootContext GraphQLContext 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 GraphQLContext context, + @GraphQLArgument(name = "eventSeries") EventSeries eventSeries) { + final Person user = DaoUtils.getUserFromContext(context); + validateEventSeries(user, eventSeries); + + // Validate user has permission also for the original adminOrg + final EventSeries existing = dao.getByUuid(eventSeries.getUuid()); + assertPermission(user, existing.getAdminOrgUuid()); + + eventSeries.setDescription(Utils.isEmptyHtml(eventSeries.getDescription()) ? null + : Utils.sanitizeHtml(eventSeries.getDescription())); + + final int numRows = dao.update(eventSeries); + if (numRows == 0) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, + "Couldn't process event series update"); + } + + 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 ResponseStatusException(HttpStatus.BAD_REQUEST, + "Event Series name must not be empty"); + } + if (eventSeries.getDescription() == null || eventSeries.getDescription().trim().isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Event Series description must not be empty"); + } + if (eventSeries.getHostOrgUuid() == null || eventSeries.getHostOrgUuid().trim().isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Event Series Host Organization must not be empty"); + } + if (eventSeries.getAdminOrgUuid() == null || eventSeries.getAdminOrgUuid().trim().isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Event Series Admin Organization must not be empty"); + } + 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..9c4546f585 --- /dev/null +++ b/src/main/java/mil/dds/anet/search/AbstractEventSearcher.java @@ -0,0 +1,126 @@ +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.beans.search.ISearchQuery; +import mil.dds.anet.database.DatabaseHandler; +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 org.springframework.transaction.annotation.Transactional; + +public abstract class AbstractEventSearcher extends AbstractSearcher + implements IEventSearcher { + + public AbstractEventSearcher(DatabaseHandler databaseHandler, + AbstractSearchQueryBuilder qb) { + super(databaseHandler, qb); + } + + @Transactional + @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.addEnumEqualsClause("status", "events.status", query.getStatus()); + + if (query.getEmailNetwork() != null) { + // Should never match + qb.addWhereClause("FALSE"); + } + + if (hasTextQuery(query)) { + addTextQuery(query); + } + + if (query.isOnlyWithTasks()) { + qb.addFromClause("INNER JOIN \"eventTasks\" et ON et.\"eventUuid\" = events.uuid"); + } + + 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.getStartDate() != null) { + qb.addWhereClause("events.\"endDate\" >= :startDate"); + DaoUtils.addInstantAsLocalDateTime(qb.sqlArgs, "startDate", query.getStartDate()); + } + if (query.getEndDate() != null) { + qb.addWhereClause("events.\"startDate\" <= :endDate"); + DaoUtils.addInstantAsLocalDateTime(qb.sqlArgs, "endDate", query.getEndDate()); + } + addOrderByClauses(qb, query); + } + + protected void addLocationQuery(AbstractSearchQueryBuilder outerQb, + EventSearchQuery query) { + if (query.getLocationUuid().size() == 1 + && Location.DUMMY_LOCATION_UUID.equals(query.getLocationUuid().get(0))) { + qb.addWhereClause("events.\"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("events.\"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("events.\"adminOrgUuid\" IS NULL"); + } else { + qb.addRecursiveClause(outerQb, "events", new String[] {"\"adminOrgUuid\""}, "parent_orgs", + "organizations", "uuid", "\"parentOrgUuid\"", "orgUuid", query.getAdminOrgUuid(), true, + true); + } + } + + protected void addOrderByClauses(AbstractSearchQueryBuilder qb, EventSearchQuery query) { + switch (query.getSortBy()) { + case CREATED_AT: + qb.addAllOrderByClauses(getOrderBy(query.getSortOrder(), "events_createdAt")); + break; + case NAME: + default: + qb.addAllOrderByClauses(getOrderBy(query.getSortOrder(), "events_name")); + break; + } + qb.addAllOrderByClauses(getOrderBy(ISearchQuery.SortOrder.ASC, "events_uuid")); + } +} 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..5ca58f46fe --- /dev/null +++ b/src/main/java/mil/dds/anet/search/AbstractEventSeriesSearcher.java @@ -0,0 +1,75 @@ +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.beans.search.ISearchQuery; +import mil.dds.anet.database.DatabaseHandler; +import mil.dds.anet.database.EventSeriesDao; +import mil.dds.anet.database.mappers.EventSeriesMapper; +import mil.dds.anet.utils.Utils; +import org.springframework.transaction.annotation.Transactional; + +public abstract class AbstractEventSeriesSearcher + extends AbstractSearcher implements IEventSeriesSearcher { + + public AbstractEventSeriesSearcher(DatabaseHandler databaseHandler, + AbstractSearchQueryBuilder qb) { + super(databaseHandler, qb); + } + + @Transactional + @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.addEnumEqualsClause("status", "\"eventSeries\".status", query.getStatus()); + + if (query.getEmailNetwork() != null) { + // Should never match + qb.addWhereClause("FALSE"); + } + + if (hasTextQuery(query)) { + addTextQuery(query); + } + if (!Utils.isEmptyOrNull(query.getAdminOrgUuid())) { + addAdminOrgQuery(qb, query); + } + addOrderByClauses(qb, query); + } + + protected void addAdminOrgQuery( + AbstractSearchQueryBuilder outerQb, + EventSeriesSearchQuery query) { + if (query.getAdminOrgUuid().size() == 1 + && Organization.DUMMY_ORG_UUID.equals(query.getAdminOrgUuid().get(0))) { + qb.addWhereClause("\"eventSeries\".\"adminOrgUuid\" IS NULL"); + } else { + qb.addRecursiveClause(outerQb, "\"eventSeries\"", new String[] {"\"adminOrgUuid\""}, + "parent_orgs", "organizations", "uuid", "\"parentOrgUuid\"", "orgUuid", + query.getAdminOrgUuid(), true, true); + } + } + + protected void addOrderByClauses(AbstractSearchQueryBuilder qb, + EventSeriesSearchQuery query) { + switch (query.getSortBy()) { + case CREATED_AT: + qb.addAllOrderByClauses(getOrderBy(query.getSortOrder(), "eventSeries_createdAt")); + break; + case NAME: + default: + qb.addAllOrderByClauses(getOrderBy(query.getSortOrder(), "eventSeries_name")); + break; + } + qb.addAllOrderByClauses(getOrderBy(ISearchQuery.SortOrder.ASC, "eventSeries_uuid")); + } +} diff --git a/src/main/java/mil/dds/anet/search/AbstractReportSearcher.java b/src/main/java/mil/dds/anet/search/AbstractReportSearcher.java index 56a7c8b5dc..90473bc8c0 100644 --- a/src/main/java/mil/dds/anet/search/AbstractReportSearcher.java +++ b/src/main/java/mil/dds/anet/search/AbstractReportSearcher.java @@ -42,7 +42,8 @@ public abstract class AbstractReportSearcher extends AbstractSearcher qb) { @@ -192,6 +193,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/pg/PostgresqlEventSearcher.java b/src/main/java/mil/dds/anet/search/pg/PostgresqlEventSearcher.java new file mode 100644 index 0000000000..adbf2a2c4e --- /dev/null +++ b/src/main/java/mil/dds/anet/search/pg/PostgresqlEventSearcher.java @@ -0,0 +1,31 @@ +package mil.dds.anet.search.pg; + +import mil.dds.anet.beans.search.EventSearchQuery; +import mil.dds.anet.beans.search.ISearchQuery; +import mil.dds.anet.database.DatabaseHandler; +import mil.dds.anet.search.AbstractEventSearcher; +import mil.dds.anet.search.AbstractSearchQueryBuilder; +import org.springframework.stereotype.Component; + +@Component +public class PostgresqlEventSearcher extends AbstractEventSearcher { + + public PostgresqlEventSearcher(DatabaseHandler databaseHandler) { + super(databaseHandler, new PostgresqlSearchQueryBuilder<>("PostgresqlEventSearch")); + } + + @Override + protected void addTextQuery(EventSearchQuery query) { + addFullTextSearch("events", query.getText(), query.isSortByPresent()); + } + + @Override + protected void addOrderByClauses(AbstractSearchQueryBuilder qb, EventSearchQuery query) { + if (hasTextQuery(query) && !query.isSortByPresent()) { + // We're doing a full-text search without an explicit sort order, + // so sort first on the search pseudo-rank. + qb.addAllOrderByClauses(getOrderBy(ISearchQuery.SortOrder.DESC, "search_rank")); + } + super.addOrderByClauses(qb, query); + } +} 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..9425238a43 --- /dev/null +++ b/src/main/java/mil/dds/anet/search/pg/PostgresqlEventSeriesSearcher.java @@ -0,0 +1,32 @@ +package mil.dds.anet.search.pg; + +import mil.dds.anet.beans.search.EventSeriesSearchQuery; +import mil.dds.anet.beans.search.ISearchQuery; +import mil.dds.anet.database.DatabaseHandler; +import mil.dds.anet.search.AbstractEventSeriesSearcher; +import mil.dds.anet.search.AbstractSearchQueryBuilder; +import org.springframework.stereotype.Component; + +@Component +public class PostgresqlEventSeriesSearcher extends AbstractEventSeriesSearcher { + + public PostgresqlEventSeriesSearcher(DatabaseHandler databaseHandler) { + super(databaseHandler, new PostgresqlSearchQueryBuilder<>("PostgresqlEventSeriesSearch")); + } + + @Override + protected void addTextQuery(EventSeriesSearchQuery query) { + addFullTextSearch("eventSeries", query.getText(), query.isSortByPresent()); + } + + @Override + protected void addOrderByClauses(AbstractSearchQueryBuilder qb, + EventSeriesSearchQuery query) { + if (hasTextQuery(query) && !query.isSortByPresent()) { + // We're doing a full-text search without an explicit sort order, + // so sort first on the search pseudo-rank. + qb.addAllOrderByClauses(getOrderBy(ISearchQuery.SortOrder.DESC, "search_rank")); + } + super.addOrderByClauses(qb, query); + } +} diff --git a/src/main/java/mil/dds/anet/threads/MaterializedViewForLinksRefreshWorker.java b/src/main/java/mil/dds/anet/threads/MaterializedViewForLinksRefreshWorker.java index 54d2474ea6..a863b90956 100644 --- a/src/main/java/mil/dds/anet/threads/MaterializedViewForLinksRefreshWorker.java +++ b/src/main/java/mil/dds/anet/threads/MaterializedViewForLinksRefreshWorker.java @@ -15,9 +15,9 @@ @ConditionalOnExpression("not ${anet.no-workers:false}") public class MaterializedViewForLinksRefreshWorker extends AbstractWorker { - public static final String[] materializedViews = - {"mv_lts_attachments", "mv_lts_locations", "mv_lts_organizations", "mv_lts_people", - "mv_lts_positions", "mv_lts_reports", "mv_lts_tasks"}; + public static final String[] materializedViews = {"mv_lts_attachments", "mv_lts_events", + "mv_lts_eventSeries", "mv_lts_locations", "mv_lts_organizations", "mv_lts_people", + "mv_lts_positions", "mv_lts_reports", "mv_lts_tasks"}; private final AdminDao dao; diff --git a/src/main/java/mil/dds/anet/threads/MaterializedViewRefreshWorker.java b/src/main/java/mil/dds/anet/threads/MaterializedViewRefreshWorker.java index b265aac1cf..427a8a35f6 100644 --- a/src/main/java/mil/dds/anet/threads/MaterializedViewRefreshWorker.java +++ b/src/main/java/mil/dds/anet/threads/MaterializedViewRefreshWorker.java @@ -15,9 +15,10 @@ @ConditionalOnExpression("not ${anet.no-workers:false}") public class MaterializedViewRefreshWorker extends AbstractWorker { - public static final String[] materializedViews = {"mv_fts_attachments", - "mv_fts_authorizationGroups", "mv_fts_locations", "mv_fts_organizations", "mv_fts_people", - "mv_fts_positions", "mv_fts_reports", "mv_fts_tasks"}; + public static final String[] materializedViews = + {"mv_fts_attachments", "mv_fts_authorizationGroups", "mv_fts_events", "mv_fts_eventSeries", + "mv_fts_locations", "mv_fts_organizations", "mv_fts_people", "mv_fts_positions", + "mv_fts_reports", "mv_fts_tasks"}; private final AdminDao dao; diff --git a/src/main/java/mil/dds/anet/threads/MergedEntityWorker.java b/src/main/java/mil/dds/anet/threads/MergedEntityWorker.java index 2f878af376..63ff8a781b 100644 --- a/src/main/java/mil/dds/anet/threads/MergedEntityWorker.java +++ b/src/main/java/mil/dds/anet/threads/MergedEntityWorker.java @@ -20,6 +20,8 @@ private record FieldWithEntityReference(String tableName, String columnName) {} private static final List fieldsWithEntityReference = List.of(// - new FieldWithEntityReference("attachments", "description"), // - + new FieldWithEntityReference("events", "description"), // - + new FieldWithEntityReference("eventSeries", "description"), // - new FieldWithEntityReference("customSensitiveInformation", "customFieldValue"), // - new FieldWithEntityReference("locations", "description"), new FieldWithEntityReference("locations", "customFields"), // - diff --git a/src/main/java/mil/dds/anet/utils/BatchingUtils.java b/src/main/java/mil/dds/anet/utils/BatchingUtils.java index 6d2a1759d6..e6e2957467 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.CustomSensitiveInformation; import mil.dds.anet.beans.EmailAddress; import mil.dds.anet.beans.EntityAvatar; +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; @@ -132,6 +134,31 @@ private void registerDataLoaders(AnetObjectEngine engine) { (BatchLoader) keys -> CompletableFuture .supplyAsync(() -> engine.getEntityAvatarDao().getByIds(keys), 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(FkDataLoaderKey.EVENT_ORGANIZATIONS.toString(), + DataLoaderFactory.newDataLoader( + (BatchLoader>) foreignKeys -> CompletableFuture.supplyAsync( + () -> engine.getEventDao().getOrganizations(foreignKeys), dispatcherService), + dataLoaderOptions)); + dataLoaderRegistry.register(FkDataLoaderKey.EVENT_PEOPLE.toString(), + DataLoaderFactory.newDataLoader( + (BatchLoader>) foreignKeys -> CompletableFuture + .supplyAsync(() -> engine.getEventDao().getPeople(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..207e79de0f 100644 --- a/src/main/java/mil/dds/anet/utils/FkDataLoaderKey.java +++ b/src/main/java/mil/dds/anet/utils/FkDataLoaderKey.java @@ -8,6 +8,9 @@ public enum FkDataLoaderKey { AUTHORIZATION_GROUP_ADMINISTRATIVE_POSITIONS, // authorizationGroup.administrativePositions AUTHORIZATION_GROUP_AUTHORIZATION_GROUP_RELATED_OBJECTS, // authorizationGroup.authorizationGroupRelatedObjects EMAIL_ADDRESSES_FOR_RELATED_OBJECT, // .emailAddresses + EVENT_TASKS, // event.tasks + EVENT_ORGANIZATIONS, // event.organizations + EVENT_PEOPLE, // event.people LOCATION_CHILDREN_LOCATIONS, // location.childrenLocations LOCATION_PARENT_LOCATIONS, // location.parentLocations NOTE_NOTE_RELATED_OBJECTS, // note.noteRelatedObjects diff --git a/src/main/java/mil/dds/anet/utils/IdDataLoaderKey.java b/src/main/java/mil/dds/anet/utils/IdDataLoaderKey.java index 257d1cbcff..5ba85cf74c 100644 --- a/src/main/java/mil/dds/anet/utils/IdDataLoaderKey.java +++ b/src/main/java/mil/dds/anet/utils/IdDataLoaderKey.java @@ -6,6 +6,8 @@ import mil.dds.anet.database.AuthorizationGroupDao; import mil.dds.anet.database.CommentDao; import mil.dds.anet.database.EntityAvatarDao; +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; @@ -20,6 +22,8 @@ public enum IdDataLoaderKey { AUTHORIZATION_GROUPS(AuthorizationGroupDao.TABLE_NAME), // - COMMENTS(CommentDao.TABLE_NAME), // - ENTITY_AVATAR(EntityAvatarDao.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 ea4b46922c..4123a74390 100644 --- a/src/main/resources/anet-schema.yml +++ b/src/main/resources/anet-schema.yml @@ -453,6 +453,12 @@ properties: title: Whether engagements also include a time and a duration description: Used for report engagements; if set to `true`, you might also want to supply dateFormats.forms.inputWithTime and dateFormats.forms.longWithTime + eventsIncludeStartAndEndTime: + type: boolean + default: false + title: Whether events also include a time and a duration + description: Used for events; if set to `true`, you might also want to supply dateFormats.forms.inputWithTime and dateFormats.forms.longWithTime + dateFormats: type: object additionalProperties: false @@ -708,6 +714,51 @@ 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" + organizations: + "$ref": "#/$defs/inputField" + people: + "$ref": "#/$defs/inputField" + tasks: + "$ref": "#/$defs/inputField" + report: type: object additionalProperties: false @@ -785,6 +836,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 a24d860334..754a102c3b 100644 --- a/src/main/resources/migrations.xml +++ b/src/main/resources/migrations.xml @@ -5927,6 +5927,230 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -5939,6 +6163,8 @@ DROP MATERIALIZED VIEW IF EXISTS mv_fts_attachments; DROP MATERIALIZED VIEW IF EXISTS "mv_fts_authorizationGroups"; + DROP MATERIALIZED VIEW IF EXISTS mv_fts_events; + DROP MATERIALIZED VIEW IF EXISTS "mv_fts_eventSeries"; DROP MATERIALIZED VIEW IF EXISTS mv_fts_locations; DROP MATERIALIZED VIEW IF EXISTS mv_fts_organizations; DROP MATERIALIZED VIEW IF EXISTS mv_fts_people; @@ -5949,6 +6175,8 @@ DROP MATERIALIZED VIEW IF EXISTS mv_lts_attachments; + DROP MATERIALIZED VIEW IF EXISTS mv_lts_events; + DROP MATERIALIZED VIEW IF EXISTS "mv_lts_eventSeries"; DROP MATERIALIZED VIEW IF EXISTS mv_lts_locations; DROP MATERIALIZED VIEW IF EXISTS mv_lts_organizations; DROP MATERIALIZED VIEW IF EXISTS mv_lts_people; @@ -5958,6 +6186,8 @@ ALTER TABLE attachments DROP COLUMN IF EXISTS full_text; + ALTER TABLE events DROP COLUMN IF EXISTS full_text; + ALTER TABLE "eventSeries" DROP COLUMN IF EXISTS full_text; ALTER TABLE "authorizationGroups" DROP COLUMN IF EXISTS full_text; ALTER TABLE "emailAddresses" DROP COLUMN IF EXISTS full_text; ALTER TABLE locations DROP COLUMN IF EXISTS full_text; @@ -5972,6 +6202,8 @@ ALTER TABLE attachments DROP COLUMN IF EXISTS core_text; ALTER TABLE "authorizationGroups" DROP COLUMN IF EXISTS core_text; ALTER TABLE "emailAddresses" DROP COLUMN IF EXISTS core_text; + ALTER TABLE events DROP COLUMN IF EXISTS core_text; + ALTER TABLE "eventSeries" DROP COLUMN IF EXISTS core_text; ALTER TABLE locations DROP COLUMN IF EXISTS core_text; ALTER TABLE notes DROP COLUMN IF EXISTS core_text; ALTER TABLE organizations DROP COLUMN IF EXISTS core_text; @@ -5983,6 +6215,8 @@ ALTER TABLE attachments DROP COLUMN IF EXISTS more_text; ALTER TABLE "authorizationGroups" DROP COLUMN IF EXISTS more_text; + ALTER TABLE events DROP COLUMN IF EXISTS more_text; + ALTER TABLE "eventSeries" DROP COLUMN IF EXISTS more_text; ALTER TABLE locations DROP COLUMN IF EXISTS more_text; ALTER TABLE organizations DROP COLUMN IF EXISTS more_text; ALTER TABLE people DROP COLUMN IF EXISTS more_text; @@ -6083,7 +6317,7 @@ BEGIN RETURN QUERY EXECUTE format( ''WITH links AS ('' || - '' SELECT uuid, regexp_matches(%I, ''''"urn:anet:(attachments|authorizationGroups|locations|organizations|people|positions|reports|tasks):([^"]*)"'''', ''''g'''') AS arr FROM public.%I'' || + '' SELECT uuid, regexp_matches(%I, ''''"urn:anet:(attachments|authorizationGroups|events|eventSeries|locations|organizations|people|positions|reports|tasks):([^"]*)"'''', ''''g'''') AS arr FROM public.%I'' || '')'' || '' SELECT uuid, arr[1], arr[2] FROM links'', column_name, table_name @@ -6100,13 +6334,15 @@ BEGIN RETURN QUERY EXECUTE format( ''WITH links AS ('' || - '' SELECT uuid, regexp_matches(%I, ''''\\"urn:anet:(attachments|authorizationGroups|locations|organizations|people|positions|reports|tasks):([^\\]*)\\"|{"type":"(Attachment|AuthorizationGroup|Location|Organization|Person|Position|Report|Task)","uuid":"([^"]*)"}'''', ''''g'''') AS arr FROM public.%I'' || + '' SELECT uuid, regexp_matches(%I, ''''\\"urn:anet:(attachments|authorizationGroups|events|eventSeries|locations|organizations|people|positions|reports|tasks):([^\\]*)\\"|{"type":"(Attachment|AuthorizationGroup|Event|EventSeries|Location|Organization|Person|Position|Report|Task)","uuid":"([^"]*)"}'''', ''''g'''') AS arr FROM public.%I'' || '')'' || '' SELECT uuid,'' || '' CASE WHEN arr[1] IS NOT NULL THEN arr[1] ELSE'' || '' CASE'' || '' WHEN arr[3] = ''''Attachment'''' THEN ''''attachments'''' '' || '' WHEN arr[3] = ''''AuthorizationGroup'''' THEN ''''authorizationGroups'''' '' || + '' WHEN arr[3] = ''''Event'''' THEN ''''events'''' '' || + '' WHEN arr[3] = ''''EventSeries'''' THEN ''''eventSeries'''' '' || '' WHEN arr[3] = ''''Location'''' THEN ''''locations'''' '' || '' WHEN arr[3] = ''''Organization'''' THEN ''''organizations'''' '' || '' WHEN arr[3] = ''''Person'''' THEN ''''people'''' '' || @@ -6145,6 +6381,18 @@ setweight(to_tsvector('simple', translate(coalesce("emailAddresses".address, ''), '@.', ' ')), ${fts_lower}) ) STORED; + ALTER TABLE events + ADD COLUMN core_text tsvector GENERATED ALWAYS AS ( + setweight(to_tsvector(${fts_config}, coalesce(events.name, '')) + || to_tsvector('simple', coalesce(events.name, '')), ${fts_high}) + ) STORED; + + ALTER TABLE "eventSeries" + ADD COLUMN core_text tsvector GENERATED ALWAYS AS ( + setweight(to_tsvector(${fts_config}, coalesce("eventSeries".name, '')) + || to_tsvector('simple', coalesce("eventSeries".name, '')), ${fts_high}) + ) STORED; + ALTER TABLE locations ADD COLUMN core_text tsvector GENERATED ALWAYS AS ( setweight(to_tsvector(${fts_config}, coalesce(locations.name, '')) @@ -6208,6 +6456,18 @@ || to_tsvector('simple', coalesce("authorizationGroups".description, '')), ${fts_low}) ) STORED; + ALTER TABLE events + ADD COLUMN more_text tsvector GENERATED ALWAYS AS ( + setweight(to_tsvector(${fts_config}, coalesce(events.description, '')) + || to_tsvector('simple', coalesce(events.description, '')), ${fts_low}) + ) STORED; + + ALTER TABLE "eventSeries" + ADD COLUMN more_text tsvector GENERATED ALWAYS AS ( + setweight(to_tsvector(${fts_config}, coalesce("eventSeries".description, '')) + || to_tsvector('simple', coalesce("eventSeries".description, '')), ${fts_low}) + ) STORED; + ALTER TABLE locations ADD COLUMN more_text tsvector GENERATED ALWAYS AS ( setweight(to_tsvector(${fts_config}, coalesce(locations.description, '')) @@ -6274,6 +6534,32 @@ WITH DATA; CREATE UNIQUE INDEX "UQ_mv_lts_attachments_uuid" ON mv_lts_attachments(uuid); + CREATE MATERIALIZED VIEW IF NOT EXISTS mv_lts_events(uuid, link_text) AS + WITH rt_links AS ( + SELECT * FROM get_rt_referenced_objects('events', 'description') + ) + SELECT + events.uuid, + setweight(coalesce(tsvector_agg(get_core_text(rt_links.object_table, rt_links.object_uuid)), ''::tsvector), ${fts_low}) + FROM events + LEFT JOIN rt_links ON rt_links.uuid = events.uuid + GROUP BY events.uuid + WITH DATA; + CREATE UNIQUE INDEX "UQ_mv_lts_events_uuid" ON mv_lts_events(uuid); + + CREATE MATERIALIZED VIEW IF NOT EXISTS "mv_lts_eventSeries"(uuid, link_text) AS + WITH rt_links AS ( + SELECT * FROM get_rt_referenced_objects('eventSeries', 'description') + ) + SELECT + "eventSeries".uuid, + setweight(coalesce(tsvector_agg(get_core_text(rt_links.object_table, rt_links.object_uuid)), ''::tsvector), ${fts_low}) + FROM "eventSeries" + LEFT JOIN rt_links ON rt_links.uuid = "eventSeries".uuid + GROUP BY "eventSeries".uuid + WITH DATA; + CREATE UNIQUE INDEX "UQ_mv_lts_eventSeries_uuid" ON "mv_lts_eventSeries"(uuid); + CREATE MATERIALIZED VIEW IF NOT EXISTS mv_lts_locations(uuid, link_text) AS WITH rt_links AS ( SELECT * FROM get_rt_referenced_objects('locations', 'description') @@ -6461,6 +6747,32 @@ CREATE INDEX "FT_mv_fts_authorizationGroups" ON "mv_fts_authorizationGroups" USING gin(full_text); CREATE INDEX "TR_authorizationGroups_uuid" ON "mv_fts_authorizationGroups" USING gin(uuid gin_trgm_ops); + CREATE MATERIALIZED VIEW IF NOT EXISTS mv_fts_events(uuid, full_text) AS + SELECT + events.uuid, + events.core_text || events.more_text + || coalesce(tsvector_agg(mv_lts_events.link_text), ''::tsvector) + FROM events + LEFT JOIN mv_lts_events ON events.uuid = mv_lts_events.uuid + GROUP BY events.uuid + WITH DATA; + CREATE UNIQUE INDEX "UQ_mv_fts_events_uuid" ON mv_fts_events(uuid); + CREATE INDEX "FT_mv_fts_events" ON mv_fts_events USING gin(full_text); + CREATE INDEX "TR_events_uuid" ON mv_fts_events USING gin(uuid gin_trgm_ops); + + CREATE MATERIALIZED VIEW IF NOT EXISTS "mv_fts_eventSeries"(uuid, full_text) AS + SELECT + "eventSeries".uuid, + "eventSeries".core_text || "eventSeries".more_text + || coalesce(tsvector_agg("mv_lts_eventSeries".link_text), ''::tsvector) + FROM "eventSeries" + LEFT JOIN "mv_lts_eventSeries" ON "eventSeries".uuid = "mv_lts_eventSeries".uuid + GROUP BY "eventSeries".uuid + WITH DATA; + CREATE UNIQUE INDEX "UQ_mv_fts_eventSeries_uuid" ON "mv_fts_eventSeries"(uuid); + CREATE INDEX "FT_mv_fts_eventSeries" ON "mv_fts_eventSeries" USING gin(full_text); + CREATE INDEX "TR_eventSeries_uuid" ON "mv_fts_eventSeries" USING gin(uuid gin_trgm_ops); + CREATE MATERIALIZED VIEW IF NOT EXISTS mv_fts_locations(uuid, full_text) AS SELECT locations.uuid, diff --git a/src/test/java/mil/dds/anet/test/TestData.java b/src/test/java/mil/dds/anet/test/TestData.java index 4a5d9e9257..ed4bbb1f08 100644 --- a/src/test/java/mil/dds/anet/test/TestData.java +++ b/src/test/java/mil/dds/anet/test/TestData.java @@ -8,6 +8,8 @@ import mil.dds.anet.beans.RollupGraph; import mil.dds.anet.test.client.AnetEmailInput; import mil.dds.anet.test.client.CommentInput; +import mil.dds.anet.test.client.EventInput; +import mil.dds.anet.test.client.EventType; import mil.dds.anet.test.client.LocationInput; import mil.dds.anet.test.client.LocationType; import mil.dds.anet.test.client.OrganizationInput; @@ -91,4 +93,11 @@ public static TaskInput createTaskInput(String shortName, String longName, Strin .withTaskedOrganizations(taskedOrganizations).withStatus(status).build(); } + public static EventInput createEventInput(String name, String description, + OrganizationInput hostOrg, OrganizationInput adminOrg) { + return EventInput.builder().withName(name).withStatus(Status.ACTIVE) + .withDescription(description).withAdminOrg(adminOrg).withHostOrg(hostOrg) + .withStartDate(Instant.now()).withEndDate(Instant.now()).withType(EventType.CONFERENCE) + .build(); + } } diff --git a/src/test/java/mil/dds/anet/test/resources/AbstractResourceTest.java b/src/test/java/mil/dds/anet/test/resources/AbstractResourceTest.java index 19d528a0d6..1443849e48 100644 --- a/src/test/java/mil/dds/anet/test/resources/AbstractResourceTest.java +++ b/src/test/java/mil/dds/anet/test/resources/AbstractResourceTest.java @@ -22,6 +22,10 @@ import mil.dds.anet.test.client.ApprovalStepInput; import mil.dds.anet.test.client.AuthorizationGroup; import mil.dds.anet.test.client.AuthorizationGroupInput; +import mil.dds.anet.test.client.Event; +import mil.dds.anet.test.client.EventInput; +import mil.dds.anet.test.client.EventSeries; +import mil.dds.anet.test.client.EventSeriesInput; import mil.dds.anet.test.client.Location; import mil.dds.anet.test.client.LocationInput; import mil.dds.anet.test.client.Note; @@ -345,6 +349,14 @@ protected static PositionInput getPositionInput(final Position position) { return getInput(position, PositionInput.class); } + protected static EventInput getEventInput(final Event event) { + return getInput(event, EventInput.class); + } + + protected static EventSeriesInput getEventSeriesInput(final EventSeries eventSeries) { + return getInput(eventSeries, EventSeriesInput.class); + } + protected static List getPositionsInput(final List positions) { return positions.stream().map(AbstractResourceTest::getPositionInput).toList(); } diff --git a/src/test/java/mil/dds/anet/test/resources/EventResourceTest.java b/src/test/java/mil/dds/anet/test/resources/EventResourceTest.java new file mode 100644 index 0000000000..e006a7d072 --- /dev/null +++ b/src/test/java/mil/dds/anet/test/resources/EventResourceTest.java @@ -0,0 +1,65 @@ +package mil.dds.anet.test.resources; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collections; +import mil.dds.anet.test.TestData; +import mil.dds.anet.test.client.AnetBeanList_Event; +import mil.dds.anet.test.client.Event; +import mil.dds.anet.test.client.EventInput; +import mil.dds.anet.test.client.EventSearchQueryInput; +import mil.dds.anet.test.utils.UtilsTest; +import org.junit.jupiter.api.Test; + +public class EventResourceTest extends AbstractResourceTest { + + private static final String _ORGANIZATION_FIELDS = "uuid shortName"; + protected static final String _TASK_FIELDS = + "{ uuid shortName longName description category parentTask { uuid } taskedOrganizations { uuid } status customFields }"; + public static final String _EVENT_FIELDS = "uuid name adminOrg hostOrg startDate endDate type"; + + @Test + void eventTestGraphQL() { + // Create + final EventInput eInput = TestData.createEventInput("NMI PDT", "Training", + TestData.createAdvisorOrganizationInput(true), + TestData.createAdvisorOrganizationInput(true)); + final Event created = + withCredentials(adminUser, t -> mutationExecutor.createEvent(_EVENT_FIELDS, eInput)); + assertThat(created).isNotNull(); + assertThat(created.getUuid()).isNotNull(); + assertThat(created.getName()).isEqualTo(eInput.getName()); + + // Search + final EventSearchQueryInput query = EventSearchQueryInput.builder().withText("NMI PDT").build(); + final AnetBeanList_Event searchObjects = withCredentials(adminUser, + t -> queryExecutor.eventList(getListFields(_EVENT_FIELDS), query)); + assertThat(searchObjects).isNotNull(); + assertThat(searchObjects.getList()).isNotEmpty(); + + // Update an event field + created.setName("NMI PDT v2"); + Integer nrUpdated = + withCredentials(adminUser, t -> mutationExecutor.updateEvent("", getEventInput(created))); + assertThat(nrUpdated).isEqualTo(1); + Event updated = + withCredentials(adminUser, t -> queryExecutor.event(_EVENT_FIELDS, created.getUuid())); + assertThat(updated.getName()).isEqualTo(created.getName()); + + // Add entities to event + created.setOrganizations(Collections.singletonList( + withCredentials(adminUser, t -> mutationExecutor.createOrganization(_ORGANIZATION_FIELDS, + TestData.createAdvisorOrganizationInput(true))))); + created.setPeople(Collections.singletonList(getJackJackson())); + created.setTasks(Collections.singletonList(withCredentials(adminUser, + t -> mutationExecutor.createTask(_TASK_FIELDS, + TestData.createTaskInput("TestF1", "Do a thing with a person", "Test-EF", + UtilsTest.getCombinedJsonTestCase().getInput()))))); + updated = + withCredentials(adminUser, t -> queryExecutor.event(_EVENT_FIELDS, created.getUuid())); + assertThat(updated.getOrganizations().size()).isEqualTo(1); + assertThat(updated.getPeople().size()).isEqualTo(1); + assertThat(updated.getTasks().size()).isEqualTo(1); + + } +} diff --git a/src/test/resources/anet.graphql b/src/test/resources/anet.graphql index 88d1d9cf5c..318600ec76 100644 --- a/src/test/resources/anet.graphql +++ b/src/test/resources/anet.graphql @@ -81,6 +81,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] @@ -397,6 +413,138 @@ input EntityAvatarInput { relatedObjectUuid: String } +"""""" +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] + organizations: [Organization] + outcomes: String + people: [Person] + startDate: Instant + status: Status + tasks: [Task] + type: EventType + 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 + organizations: [OrganizationInput] + outcomes: String + people: [PersonInput] + startDate: Instant + status: Status + tasks: [TaskInput] + type: EventType + updatedAt: Instant + uuid: String +} + +"""""" +input EventSearchQueryInput { + adminOrgUuid: [String] + emailNetwork: String + endDate: Instant + eventSeriesUuid: String + hostOrgUuid: [String] + inMyReports: Boolean + includeDate: Instant + locationUuid: [String] + onlyWithTasks: Boolean + 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 { + CREATED_AT + NAME +} + +"""""" +enum EventType { + CONFERENCE + EXERCISE + OTHER + VISIT_BAN +} + """""" type GenericRelatedObject { objectUuid: String @@ -506,6 +654,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 createOrUpdateEntityAvatar(entityAvatar: EntityAvatarInput): Int @@ -542,6 +692,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 @@ -893,6 +1045,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 @@ -938,7 +1094,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 { @@ -960,6 +1116,7 @@ type Report { engagementDate: Instant engagementDayOfWeek: Int engagementStatus: [EngagementStatus] + event: Event exsum: String intent: String interlocutorOrg: Organization @@ -1021,6 +1178,7 @@ input ReportInput { duration: Int engagementDate: Instant engagementDayOfWeek: Int + event: EventInput exsum: String intent: String interlocutorOrg: OrganizationInput @@ -1122,6 +1280,7 @@ input ReportSearchQueryInput { engagementDateStart: Instant engagementDayOfWeek: Int engagementStatus: [EngagementStatus] + eventUuid: String inMyReports: Boolean includeAllDrafts: Boolean includeEngagementDayOfWeek: Boolean @@ -1218,6 +1377,9 @@ input SavedSearchInput { """""" enum SearchObjectType { + ATTACHMENTS + AUTHORIZATION_GROUPS + EVENTS LOCATIONS ORGANIZATIONS PEOPLE @@ -1246,7 +1408,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 2c2e4936bd..18639d75c2 100644 --- a/testDictionaries/no-custom-fields.yml +++ b/testDictionaries/no-custom-fields.yml @@ -4,6 +4,7 @@ SUPPORT_EMAIL_ADDR: support@example.com regularUsersCanCreateLocations: true engagementsIncludeTimeAndDuration: true +eventsIncludeStartAndEndTime: true calendarOptions: attendeesType: advisor @@ -343,6 +344,61 @@ 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 + organizations: + label: Organizations attending + placeholder: Organizations attending the event + people: + label: People attending + placeholder: People attending the event + tasks: + label: Objectives + placeholder: Objectives of the event + report: canUnpublishReports: true intent: @@ -390,6 +446,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: