// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 // Import React, Amplify, and AWS SDK packages import React from 'react'; import { LinkContainer } from 'react-router-bootstrap'; import { API, graphqlOperation, I18n, Storage } from 'aws-amplify'; import { GraphQLResult } from '@aws-amplify/api-graphql'; import { Logger } from '@aws-amplify/core'; import { AmplifyS3Image } from "@aws-amplify/ui-react"; import { GoX } from 'react-icons/go'; // Import React Bootstrap components import Container from 'react-bootstrap/Container'; import Row from 'react-bootstrap/Row'; import Col from 'react-bootstrap/Col'; import Breadcrumb from 'react-bootstrap/Breadcrumb'; import Button from 'react-bootstrap/Button'; import Form from 'react-bootstrap/Form'; import Card from 'react-bootstrap/Card'; import Table from 'react-bootstrap/Table'; import ProgressBar from 'react-bootstrap/ProgressBar'; import Alert from 'react-bootstrap/Alert'; import Modal from 'react-bootstrap/Modal'; import ListGroup from 'react-bootstrap/ListGroup' // Import graphql import { getProcess } from '../graphql/queries'; import { createEvent, updateEvent } from '../graphql/mutations'; import { onCreateRootCause, onDeleteRootCause } from '../graphql/subscriptions'; // Import UUID import * as uuid from 'uuid'; // Import custom setting import { LOGGING_LEVEL, sendMetrics, validateGeneralInput, validatePhoneNumber, validateEmailAddress, sortByName, getInputFormValidationClassName, makeAllVisible, handleSubscriptionError } from '../util/CustomUtil'; import GraphQLCommon from '../util/GraphQLCommon'; import { IEvent, IEventUpdate, IRootCause } from '../components/Interfaces'; import { EventPriority, SortBy } from '../components/Enums'; import EmptyRow from '../components/EmptyRow'; /** * Properties Interface * @interface IProps */ interface IProps { history?: any; match?: any; handleNotification: Function; } /** * State Interface * @interface IState */ interface IState { event?: IEvent; rootCauses: IRootCause[]; isLoading: boolean; rootCauseSearchKeyword: string; error: string; siteId: string; siteName: string; areaId: string; areaName: string; processId: string; processName: string; eventName: string; eventDescription: string; eventSms: string; eventEmail: string; eventPriority: EventPriority; eventType: string; eventTopicArn: string; isEventNameValid: boolean; isEventDescriptionValid: boolean; isEventSmsValid: boolean; isEventEmailValid: boolean; isEventTypeValid: boolean; selectAllRootCauses: boolean; eventImgKeys: string[]; eventImgKey: string; eventModalError: string; showEventImageLibrary: boolean; eventAlias: string; isEventAliasValid: boolean; isPageLoading: boolean; isFatalError: boolean; showSubEventModal: boolean; isModalProcessing: boolean; existingSubEvents: IEvent[]; toBeAddedSubEvents: IEvent[]; toBeDeletedSubEvents: IEvent[]; imageSelectSubEventId: string; modifiedExistingSubEventIds: string[]; } /** * Types of subscriptions that will be maintained by the main Event class */ export enum EventSubscriptionTypes { CREATE_ROOT_CAUSE, DELETE_ROOT_CAUSE } // Logging const LOGGER = new Logger('AddEditEvent', LOGGING_LEVEL); /** * Page for adding a new or editing an existing Event * @class AddEditEvent */ class AddEditEvent extends React.Component { // GraphQL common class private graphQlCommon: GraphQLCommon; // Create root cause subscription private createRootCauseSubscription: any; // Delete root cause subscription private deleteRootCauseSubscription: any; // The saved root causes would save the original state of queried root causes. private savedRootCauses: IRootCause[]; // Root causes for events private rootCauses: string[]; constructor(props: Readonly) { super(props); this.state = { rootCauses: [], isLoading: false, rootCauseSearchKeyword: '', error: '', siteId: '', siteName: '', areaId: '', areaName: '', processId: '', processName: '', eventName: '', eventDescription: '', eventSms: '', eventEmail: '', eventPriority: EventPriority.Low, eventType: '', eventTopicArn: '', isEventNameValid: false, isEventDescriptionValid: false, isEventSmsValid: true, isEventEmailValid: true, isEventTypeValid: true, selectAllRootCauses: false, eventImgKeys: [], eventImgKey: '', eventModalError: '', showEventImageLibrary: false, eventAlias: '', isEventAliasValid: true, isPageLoading: true, isFatalError: false, showSubEventModal: false, isModalProcessing: false, existingSubEvents: [], toBeAddedSubEvents: [], toBeDeletedSubEvents: [], imageSelectSubEventId: '', modifiedExistingSubEventIds: [] }; this.graphQlCommon = new GraphQLCommon(); this.savedRootCauses = []; this.rootCauses = []; this.eventNameExists = this.eventNameExists.bind(this); this.addEvent = this.addEvent.bind(this); this.editEvent = this.editEvent.bind(this); this.loadEventImages = this.loadEventImages.bind(this); this.toggleEventImageLibrary = this.toggleEventImageLibrary.bind(this); this.onPickImageToUpload = this.onPickImageToUpload.bind(this); this.onSelectEventImage = this.onSelectEventImage.bind(this); this.configureSubscription = this.configureSubscription.bind(this); this.isEditMode = this.isEditMode.bind(this); this.handleFormInputChange = this.handleFormInputChange.bind(this); this.shouldAllowFormSubmit = this.shouldAllowFormSubmit.bind(this); this.doMainFormSubmit = this.doMainFormSubmit.bind(this); this.doSubEventFormSubmit = this.doSubEventFormSubmit.bind(this); this.openSubEventModal = this.openSubEventModal.bind(this); this.loadSubEvents = this.loadSubEvents.bind(this); this.displaySubEvents = this.displaySubEvents.bind(this); this.addSubEvent = this.addSubEvent.bind(this); this.markSubEventForDeletion = this.markSubEventForDeletion.bind(this); this.addNewSubEventRow = this.addNewSubEventRow.bind(this); this.createNewToBeAddedSubEvent = this.createNewToBeAddedSubEvent.bind(this); this.handleAllRootCauseCheckboxFormInputChange = this.handleAllRootCauseCheckboxFormInputChange.bind(this); this.handleIndividualRootCauseCheckboxFormInputChange = this.handleIndividualRootCauseCheckboxFormInputChange.bind(this); this.handleRootCauseFilterTextBoxFormInputChange = this.handleRootCauseFilterTextBoxFormInputChange.bind(this); this.handleSubEventFormInputChange = this.handleSubEventFormInputChange.bind(this); this.showSubEventImageSelect = this.showSubEventImageSelect.bind(this); this.onSelectSubEventImage = this.onSelectSubEventImage.bind(this); this.editSubEvent = this.editSubEvent.bind(this); } /** * React componentDidMount function */ async componentDidMount() { // Get process and root causes await this.getProcess(); await this.getRootCauses(); // Load any event images in the image library await this.loadEventImages(); // Configure subscriptions for (const evtSubscriptionType of Object.values(EventSubscriptionTypes).filter(t => typeof t === 'number')) { await this.configureSubscription(evtSubscriptionType as EventSubscriptionTypes); } if (this.isEditMode()) { // When editing, make sure previously-selected root causes are checked this.rootCauses = (this.state.event && this.state.event.rootCauses) ? [...this.state.event.rootCauses] : []; } this.setState({ isPageLoading: false }); } /** * Configures the subscription for the supplied `subscriptionType` * @param subscriptionType The type of subscription to configure * @param delayMS (Optional) This value will be used to set a delay for reestablishing the subscription if the socket connection is lost */ async configureSubscription(subscriptionType: EventSubscriptionTypes, delayMS: number = 10): Promise { try { switch (subscriptionType) { case EventSubscriptionTypes.CREATE_ROOT_CAUSE: if (this.createRootCauseSubscription) { this.createRootCauseSubscription.unsubscribe(); } // @ts-ignore this.createRootCauseSubscription = API.graphql(graphqlOperation(onCreateRootCause)).subscribe({ next: (response: any) => { const { rootCauses } = this.state; const newRootCause = response.value.data.onCreateRootCause; newRootCause.visible = true; const newRootCauses = sortByName([...rootCauses, newRootCause], SortBy.Asc, 'rootCause'); this.setState({ rootCauses: newRootCauses }); // To prevent unwanted root cause while editing, saved root causes consist of themselves and new one. this.savedRootCauses = sortByName([...this.savedRootCauses, newRootCause], SortBy.Asc, 'rootCause'); }, error: async (e: any) => { await handleSubscriptionError(e, subscriptionType, this.configureSubscription, delayMS); } }); break; case EventSubscriptionTypes.DELETE_ROOT_CAUSE: if (this.deleteRootCauseSubscription) { this.deleteRootCauseSubscription.unsubscribe(); } // @ts-ignore this.deleteRootCauseSubscription = API.graphql(graphqlOperation(onDeleteRootCause)).subscribe({ next: (response: any) => { const { rootCauses } = this.state; const deletedRootCause = response.value.data.onDeleteRootCause; const index = this.savedRootCauses.findIndex((rootCause: IRootCause) => rootCause.id === deletedRootCause.id); deletedRootCause.visible = true; deletedRootCause.deleted = true; this.setState({ rootCauses: [...rootCauses.slice(0, index), deletedRootCause, ...rootCauses.slice(index + 1)] }); this.savedRootCauses = [...this.savedRootCauses.slice(0, index), ...this.savedRootCauses.slice(index + 1)]; }, error: async (e: any) => { await handleSubscriptionError(e, subscriptionType, this.configureSubscription, delayMS); } }); break; } } catch (err) { LOGGER.error('Unable to configure subscription', err); } } /** * React componentWillUnmount function */ componentWillUnmount() { if (this.createRootCauseSubscription) this.createRootCauseSubscription.unsubscribe(); if (this.deleteRootCauseSubscription) this.deleteRootCauseSubscription.unsubscribe(); } /** * Retrieves a list of event image keys from S3 so the images can be displayed * upon opening the event image library */ async loadEventImages() { this.setState({ isLoading: true }); try { const eventImgs = await Storage.list('event-images/', { level: 'public' }); this.setState({ eventImgKeys: eventImgs.map((img: any) => img.key) }); } catch (err) { LOGGER.error(err); } this.setState({ isLoading: false }); } /** * Get the process detail. */ async getProcess() { this.setState({ isLoading: true, error: '' }); try { // Graphql operation to get a site const { processId, eventId } = this.props.match.params; const response = await API.graphql(graphqlOperation(getProcess, { id: processId })) as GraphQLResult; const data: any = response.data; const resultData = data.getProcess; const siteId = resultData.area.site.id; const siteName = resultData.area.site.name; const areaId = resultData.area.id; const areaName = resultData.area.name; const processName = resultData.name; if (eventId) { // Find the event that matches the `eventId` in the URL const event = (resultData.event.items as IEvent[]).find(e => e.id === eventId); if (!event) { // If the event is not found, display an error this.setState({ isFatalError: true, error: `${I18n.get('error.event.id.not.found')} (${eventId})` }); } else { // Populate the state with the values from the event matched to the `eventId` in the URL this.setState({ event, eventName: event.name, eventDescription: event.description, eventEmail: event.email || '', eventSms: event.sms || '', eventAlias: event.alias || '', eventImgKey: event.eventImgKey || '' }); } } this.setState({ siteId, siteName, areaId, areaName, processId, processName }); } catch (error) { LOGGER.error('Error while getting process', error); this.setState({ error: I18n.get('error.get.process') }); } this.setState({ isLoading: false }); } /** * Get root causes. */ async getRootCauses() { try { const rootCauses: IRootCause[] = await this.graphQlCommon.listRootCauses(); // Make all root causes visible. makeAllVisible(rootCauses); const sortedRootCauses = sortByName(rootCauses, SortBy.Asc, 'rootCause'); this.savedRootCauses = [...sortedRootCauses]; this.setState({ rootCauses: [...sortedRootCauses] }); } catch (error) { LOGGER.error('Error occurred while getting users.'); this.setState((prevState) => ({ error: `${prevState.error}\n${I18n.get('error.get.rootcauses')}` })); } } /** * Checks if the supplied event name is used for any other events in this Process * @param eventName The event name to check */ async eventNameExists(eventName: string): Promise { const { processId } = this.state; const eventsInProcess: IEvent[] = await this.graphQlCommon.listEvents(processId); return eventsInProcess.some(e => e.name.trim() === eventName.trim()); } /** * Register an event. */ async addEvent() { this.setState({ isLoading: true }); try { // Graphql operation to list events const { processId, eventName, eventDescription, eventPriority } = this.state; if (await this.eventNameExists(eventName)) { this.props.handleNotification(I18n.get('error.duplicate.event.name'), 'error', 5); this.setState({ isLoading: false }); } else { // Graphql operation to register a event let input: any = { name: eventName, type: 'EVENT', eventProcessId: processId, description: eventDescription, priority: eventPriority, rootCauses: this.rootCauses }; const propertyMaps: { stateProp: keyof IState, inputProp: keyof IEvent }[] = [ { stateProp: 'eventSms', inputProp: 'sms' }, { stateProp: 'eventEmail', inputProp: 'email' }, { stateProp: 'eventType', inputProp: 'eventType' }, { stateProp: 'eventImgKey', inputProp: 'eventImgKey' }, { stateProp: 'eventAlias', inputProp: 'alias' } ]; for (const map of propertyMaps) { if (this.state[map.stateProp] !== '') { input[map.inputProp] = this.state[map.stateProp]; } } await API.graphql(graphqlOperation(createEvent, input)) as GraphQLResult; this.props.handleNotification(I18n.get('info.add.event'), 'info', 5); await sendMetrics({ 'event': 1 }); this.setState({ isLoading: false }); } } catch (error: any) { let message = I18n.get('error.create.event'); if (error.errors) { const { errorType } = error.errors[0]; if (errorType === 'Unauthorized') { message = I18n.get('error.not.authorized'); } else if (errorType === 'DataDuplicatedError') { message = I18n.get('error.duplicate.event.name'); } } LOGGER.error('Error while creating event', error); this.props.handleNotification(message, 'error', 5); this.setState({ isLoading: false }); } } /** * Register a sub event */ async addSubEvent(subEvent: IEvent) { this.setState({ isLoading: true }); if (this.state.existingSubEvents.some(e => e.name === subEvent.name)) { this.props.handleNotification(I18n.get('error.duplicate.event.name'), 'error', 5); this.setState({ isLoading: false }); } else { // Set the sub event's properties based on user input for `name` and `eventImgKey`. Other required event properties // (such as `priority` & `eventType`) will be inherited from the parent event let input: IEvent = { id: subEvent.id!, type: 'EVENT', eventImgKey: subEvent.eventImgKey, name: subEvent.name, description: subEvent.name, // For sub events, use the name as the description priority: this.state.event!.priority, email: (this.state.event!.email && this.state.event!.email.trim() !== '') ? this.state.event!.email.trim() : undefined, sms: (this.state.event!.sms && this.state.event!.sms.trim() !== '') ? this.state.event!.sms.trim() : undefined, eventType: this.state.event!.eventType, parentId: subEvent.parentId!, eventProcessId: this.state.processId, rootCauses: this.state.event!.rootCauses }; await API.graphql(graphqlOperation(createEvent, input)); await sendMetrics({ 'sub-event': 1 }); this.setState({ isLoading: false }); } } /** * Edit an event. */ async editEvent() { this.setState({ isLoading: true }); try { const { event, eventSms, eventEmail, eventImgKey, eventAlias } = this.state; // Graphql operation to edit an event const input: IEventUpdate = { id: event!.id!, rootCauses: this.rootCauses }; input.sms = eventSms; input.email = eventEmail; input.alias = eventAlias; if (eventImgKey !== '') { input.eventImgKey = eventImgKey; } const previousEventData = this.state.event; if (previousEventData) { input.previousSms = previousEventData.sms; input.previousEmail = previousEventData.email; } await API.graphql(graphqlOperation(updateEvent, input)) as GraphQLResult; this.props.handleNotification(I18n.get('info.edit.event'), 'info', 5); this.setState({ isLoading: false }); } catch (error: any) { let message = I18n.get('error.update.event'); if (error.errors) { const { errorType } = error.errors[0]; if (errorType === 'Unauthorized') { message = I18n.get('error.not.authorized'); } } LOGGER.error('Error while updating event', error); this.props.handleNotification(message, 'error', 5); this.setState({ isLoading: false }); } } /** * Edit a sub event. */ async editSubEvent(subEvent: IEvent): Promise { this.setState({ isLoading: true }); // Graphql operation to edit an event const input: IEventUpdate = { id: subEvent.id!, rootCauses: subEvent.rootCauses || [], alias: subEvent.alias, email: subEvent.email, eventImgKey: subEvent.eventImgKey, sms: subEvent.sms }; await API.graphql(graphqlOperation(updateEvent, input)) as GraphQLResult; this.setState({ isLoading: false }); } /** * Get unique root causes from the provided array. * @param {string[]} rootCauses - Array to get unique root causes * @return {string[]} Unique root causes array */ getUniqueRootCauses(rootCauses: string[]): string[] { return Array.from(new Set(rootCauses)); } /** * Opens or closes the event image library */ async toggleEventImageLibrary() { this.setState({ showEventImageLibrary: !this.state.showEventImageLibrary }); } /** * Use magic number to validate the file is an image * https://en.wikipedia.org/wiki/Magic_number_(programming)#In_files * @param file The file to inspect * @returns Promise */ async getFileType(file: File): Promise { return new Promise((resolve, reject) => { try { const reader = new FileReader(); reader.onloadend = (evt) => { try { if (evt && evt.target && evt.target.readyState === FileReader.DONE) { const bytes: string[] = []; new Uint8Array(evt.target.result as ArrayBuffer).forEach((byte) => bytes.push(byte.toString(16))); switch (bytes.join('').toUpperCase()) { case 'FFD8FFDB': case 'FFD8FFE0': resolve('image/jpeg'); break; case '47494638': resolve('image/gif'); break; case '89504E47': resolve('image/png'); break; default: reject('Unsupported file type'); break; } } } catch (err) { reject(err); } }; reader.readAsArrayBuffer(file.slice(0, 4)); } catch (err) { reject(err); } }); } /** * Uploads the event image that was selected from the local computer to S3 * @param e Event handler for the select file browser dialog box * @returns */ async onPickImageToUpload(e: any) { this.setState({ eventModalError: '' }); if (!e.target || !e.target.files || !e.target.files[0]) { return; } const file = e.target.files[0]; const { size } = file; const IMAGE_FILE_SIZE_LIMIT = 5000000; // 5MB if (size > IMAGE_FILE_SIZE_LIMIT) { this.setState({ eventModalError: I18n.get('error.limit.image.size') }); return; } let fileType: string | undefined; try { fileType = await this.getFileType(file); } catch (err) { LOGGER.error(err); } if (!fileType) { this.setState({ eventModalError: I18n.get('error.image.type') }); return; } this.setState({ isLoading: true }); try { const imgKey = uuid.v4(); const resp: any = await Storage.put(`event-images/${imgKey}`, file, { level: 'public', contentType: fileType }); if (resp && resp.key) { if (this.state.showSubEventModal) { this.onSelectSubEventImage(resp.key); } else { this.setState({ eventImgKey: resp.key }); } } this.setState((state, props) => ({ isEventNameValid: validateGeneralInput(state.eventName, 1, 40, '- _/#'), isEventDescriptionValid: validateGeneralInput(state.eventDescription, 1, 40, '- _/#'), isEventSmsValid: validatePhoneNumber(state.eventSms, true), isEventEmailValid: validateEmailAddress(state.eventEmail, true), isEventTypeValid: validateGeneralInput(state.eventType, 0, 40, '- _/'), isEventAliasValid: validateGeneralInput(state.eventAlias, 0, 40, '- _/') })); } catch (err) { LOGGER.error(err); } await this.loadEventImages(); } /** * Associates the selected S3 object key for the selected image with this event * @param imgKey S3 object key for the image that was selected */ async onSelectEventImage(imgKey: string) { this.setState((state, props) => ({ eventImgKey: state.eventImgKey === imgKey ? '' : imgKey, isEventNameValid: validateGeneralInput(state.eventName, 1, 40, '- _/#'), isEventDescriptionValid: validateGeneralInput(state.eventDescription, 1, 40, '- _/#'), isEventSmsValid: validatePhoneNumber(state.eventSms, true), isEventEmailValid: validateEmailAddress(state.eventEmail, true), isEventTypeValid: validateGeneralInput(state.eventType, 0, 40, '- _/'), isEventAliasValid: validateGeneralInput(state.eventAlias, 0, 40, '- _/') })); } /** * Returns a boolean indicating whether this component is editing an existing * event or adding a new one */ isEditMode(): boolean { // Event ID that is in the URL const { eventId } = this.props.match.params; // Data for the event that matches the above Event ID const { event } = this.state; if (event && event.id === eventId) { return true; } return false; } /** * Handles all form input * @param evt Event handler for the form element's onChange function */ handleFormInputChange(evt: any) { try { switch (evt.target.id) { case 'eventName': this.setState({ eventName: evt.target.value }); break; case 'eventDescription': this.setState({ eventDescription: evt.target.value }); break; case 'eventSms': this.setState({ eventSms: evt.target.value }); break; case 'eventEmail': this.setState({ eventEmail: evt.target.value }); break; case 'eventPriority': this.setState({ eventPriority: evt.target.value }); break; case 'eventType': this.setState({ eventType: evt.target.value }); break; case 'eventAlias': this.setState({ eventAlias: evt.target.value }); break; case 'allRootCauseCheckbox': this.handleAllRootCauseCheckboxFormInputChange(evt); break; case 'rootCauseFilterTextBox': this.handleRootCauseFilterTextBoxFormInputChange(evt); break; default: if ((evt.target.id as string).startsWith('sub-event-')) { // Name of a "to be added" sub event this.handleSubEventFormInputChange(evt); } else { // Default behavior for individual root cause checkboxes this.handleIndividualRootCauseCheckboxFormInputChange(evt); } break; } this.setState((state, props) => ({ isEventNameValid: validateGeneralInput(state.eventName, 1, 40, '- _/#'), isEventDescriptionValid: validateGeneralInput(state.eventDescription, 1, 40, '- _/#'), isEventSmsValid: validatePhoneNumber(state.eventSms, true), isEventEmailValid: validateEmailAddress(state.eventEmail, true), isEventTypeValid: validateGeneralInput(state.eventType, 0, 40, '- _/'), isEventAliasValid: validateGeneralInput(state.eventAlias, 0, 40, '- _/') })); } catch (err) { LOGGER.error('Error while handling form input change', err); } } /** * Handles form input change for the "select all root cause" checkbox * @param evt Event handler for the form element's onChange function */ handleAllRootCauseCheckboxFormInputChange(evt: any) { for (const rootCause of this.state.rootCauses.filter(rc => rc.visible)) { if (evt.target.checked) { this.rootCauses = this.getUniqueRootCauses([...this.rootCauses, rootCause.name]); } else { const index = this.rootCauses.findIndex((removedRootCause: string) => removedRootCause === rootCause.name); this.rootCauses = [...this.rootCauses.slice(0, index), ...this.rootCauses.slice(index + 1)]; } } this.setState({ selectAllRootCauses: evt.target.checked }); } /** * Handles form input change for a root cause checkbox * @param evt Event handler for the form element's onChange function */ handleIndividualRootCauseCheckboxFormInputChange(evt: any) { const checkedRootCause = JSON.parse(evt.target.id); if (evt.target.checked) { this.rootCauses.push(checkedRootCause.rootCause); } else { const index = this.rootCauses.findIndex((removedRootCause: string) => removedRootCause === checkedRootCause.rootCause); this.rootCauses = [...this.rootCauses.slice(0, index), ...this.rootCauses.slice(index + 1)]; } // Compare if visible root causes are all checked. const visibleRootCauses = this.state.rootCauses.filter((rootCause: IRootCause) => rootCause.visible); const filteredCheckedRootCauses = this.rootCauses.filter((rootCause: string) => { const index = visibleRootCauses.findIndex((visibleRootCause: IRootCause) => visibleRootCause.name === rootCause); return index > -1; }); this.setState({ selectAllRootCauses: visibleRootCauses.length > 0 && visibleRootCauses.length === filteredCheckedRootCauses.length }); } /** * Handles form input change the text box that filters the displayed root causes * @param evt Event handler for the form element's onChange function */ handleRootCauseFilterTextBoxFormInputChange(evt: any) { let { rootCauses } = this.state; for (let rootCause of rootCauses) { rootCause.visible = (evt.target.value === '' || rootCause.name.toLowerCase().includes(evt.target.value.toLowerCase())); } this.setState({ rootCauses, rootCauseSearchKeyword: evt.target.value }); } /** * Handles form input change for the text box for a sub event's name * @param evt Event handler for the form element's onChange function */ handleSubEventFormInputChange(evt: any) { const toBeAddedSubEvents = this.state.toBeAddedSubEvents; const idx = toBeAddedSubEvents.findIndex(e => e.id === (evt.target.id as string).replace('sub-event-', '')); if (idx > -1) { const subEvent = toBeAddedSubEvents[idx]; subEvent.name = evt.target.value; toBeAddedSubEvents[idx] = subEvent; this.setState({ toBeAddedSubEvents: [...toBeAddedSubEvents] }); } } /** * Returns a boolean for whether the form is valid and can be submitted */ shouldAllowFormSubmit(): boolean { if (this.state.showSubEventModal) { if (this.state.isFatalError || this.state.isLoading) { return false; } // Make sure all the "to be added" events have valid names return this.state.toBeAddedSubEvents.every(e => validateGeneralInput(e.name, 0, 40, '- _/#')); } return ( !this.state.isFatalError && !this.state.isLoading && this.state.isEventNameValid && this.state.isEventDescriptionValid && this.state.isEventSmsValid && this.state.isEventEmailValid && this.state.isEventTypeValid && this.state.isEventAliasValid ); } /** * Submits the main form and either edits or adds a new event */ async doMainFormSubmit() { if (!this.shouldAllowFormSubmit) { return; } if (this.isEditMode()) { await this.editEvent(); } else { await this.addEvent(); } // Redirect back to the process page this.props.history.push(`/processes/${this.state.processId}`); } /** * Submits the sub event form. "To be deleted" sub events are deleted and "to be added" * sub events are created */ async doSubEventFormSubmit() { if (!this.shouldAllowFormSubmit) { return; } this.setState({ isModalProcessing: true }); try { // Delete any sub events that are marked for deletion for (const subEvent of this.state.toBeDeletedSubEvents) { await this.graphQlCommon.deleteEvent(subEvent.id!); } for (const subEvent of this.state.toBeAddedSubEvents.filter(e => e.name.trim() !== '')) { await this.addSubEvent(subEvent); } for (const subEvent of this.state.existingSubEvents.filter(e => this.state.modifiedExistingSubEventIds.includes(e.id!))) { await this.editSubEvent(subEvent); } this.props.handleNotification(I18n.get('info.sub.events.updated'), 'info', 5); this.setState({ isModalProcessing: false, showSubEventModal: false }); } catch (error: any) { let message = I18n.get('error.sub.event.manage'); if (error.errors) { const { errorType } = error.errors[0]; if (errorType === 'Unauthorized') { message = I18n.get('error.not.authorized'); } else if (errorType === 'DataDuplicatedError') { message = I18n.get('error.duplicate.event.name'); } } LOGGER.error('Error while managing sub events', error); this.props.handleNotification(message, 'error', 5); this.setState({ isModalProcessing: false }); } } /** * Opens the sub event modal after loading the sub events for the current root event */ async openSubEventModal(): Promise { if (!this.isEditMode()) { return; } this.setState({ showSubEventModal: true, isModalProcessing: true, existingSubEvents: [], toBeDeletedSubEvents: [], toBeAddedSubEvents: [], imageSelectSubEventId: '' }); const existingSubEvents: IEvent[] = await this.loadSubEvents(this.state.processId); existingSubEvents.sort((a, b) => a.name.localeCompare(b.name)); // Create a new 'to be added' event, which will show the empty // text box in the modal const defaultToBeAddedEventPlaceholder = this.createNewToBeAddedSubEvent(this.state.event!.id!); this.setState({ existingSubEvents: [...existingSubEvents], toBeAddedSubEvents: [defaultToBeAddedEventPlaceholder], isModalProcessing: false }); } /** * Loads all events for the current process and returns a filtered list of events that are * nested under the currently displayed root event * @param {string} processId The ID of the process to load events for * @returns {IEvent[]} A list of events */ async loadSubEvents(processId: string): Promise { const response: IEvent[] = await this.graphQlCommon.listEventsInProcess(processId); return this.getAllSubEventsWithCommonAncestor(this.state.event!.id!, response); } /** * Filters a list of events and returns all sub events nested under the supplied * parent event * @param {string} parentId The ID of the parent event to filter by * @param {IEvent[]} subEvents A list of events that will be filtered * @returns {IEvent[]} A list of events */ getAllSubEventsWithCommonAncestor(parentId: string, subEvents: IEvent[]): IEvent[] { const output: IEvent[] = []; const childSubEvents = subEvents.filter(e => e.parentId === parentId); for (const subEvent of childSubEvents) { output.push(subEvent); output.push(...this.getAllSubEventsWithCommonAncestor(subEvent.id!, subEvents)) } return output; } /** * Displays the sub events in the Modal differently depending on whether they are to be added, * to be deleted, or existing * @param {string} parentId The ID of the parent event to filter by * @param {number} indent A number representing how deep in the nested event tree we are currently displaying * @returns {JSX.Element[]} A list of JSX elements to be displayed in the component */ displaySubEvents(parentId: string, indent: number = 0): JSX.Element[] { if (!this.state.showSubEventModal) { return []; } const MAX_INDENT = 1; const output: JSX.Element[] = []; const children = [ ...this.state.existingSubEvents.filter(subEvent => subEvent.parentId === parentId), ...this.state.toBeDeletedSubEvents.filter(subEvent => subEvent.parentId === parentId), ...this.state.toBeAddedSubEvents.filter(subEvent => subEvent.parentId === parentId) ]; for (const child of children) { let listItemContent: JSX.Element; if (this.state.existingSubEvents.find(e => e.id === child.id)) { // If this is an existing sub event, only option is to mark it for deletion or add a sub event under it listItemContent = (<> { child.eventImgKey &&
}
{child.name}
{indent < MAX_INDENT && ()}
); } else if (this.state.toBeDeletedSubEvents.find(e => e.id === child.id)) { // If this sub event is marked for deletion, only option is to un-mark it for deletion listItemContent = (
{child.name} (To be deleted)
); } else { // If this is the final "to be added" sub event at this level and no other "to be added" sub events (including this one) // have invalid input, show a button to add a new sub event at this level let addButton; const toBeAddedSubEvents = this.state.toBeAddedSubEvents.filter(subEvent => subEvent.parentId === parentId); const allToBeAddedSubEventsValidName = toBeAddedSubEvents.every(e => validateGeneralInput(e.name, 1, 40, '- _/#')); const isFinalInList = toBeAddedSubEvents[toBeAddedSubEvents.length - 1]!.id === child.id; if (allToBeAddedSubEventsValidName && isFinalInList) { addButton = ( ); } // If this is a to be added sub event, show a text box so the name can be input listItemContent = ( <>
{I18n.get('text.sub.event.new')} * {`(${I18n.get('text.required')}) ${I18n.get('info.valid.general.input')}`}
{ indent < MAX_INDENT && ( ) } {addButton}
); } let subEventImgContent; if (this.state.imageSelectSubEventId === child.id) { subEventImgContent = (
{this.state.eventImgKeys.map(imgKey => { return ( this.onSelectSubEventImage(imgKey)}> ) })}
); } output.push(
{listItemContent} {subEventImgContent}
); // Append any children of this child event output.push(...this.displaySubEvents(child.id!, indent + 1)); } return output; } /** * Shows the event image selection option for the supplied sub event * @param subEventId ID of the sub event for which an image will be selected */ showSubEventImageSelect(subEventId: string): void { this.setState((state, props) => ({ imageSelectSubEventId: subEventId === state.imageSelectSubEventId ? '' : subEventId })); } /** * Assigns the supplied S3 image key with the sub event * @param imgKey The S3 image key of the selected image */ onSelectSubEventImage(imgKey: string): void { const existingSubEvents = this.state.existingSubEvents; const existingIdx = existingSubEvents.findIndex(e => e.id === this.state.imageSelectSubEventId); if (existingIdx > -1) { const subEvent = existingSubEvents[existingIdx]; subEvent.eventImgKey = subEvent.eventImgKey === imgKey ? '' : imgKey; existingSubEvents[existingIdx] = subEvent; const modifiedExistingSubEventIds = this.state.modifiedExistingSubEventIds; this.setState({ existingSubEvents: [...existingSubEvents], modifiedExistingSubEventIds: Array.from(new Set([...modifiedExistingSubEventIds, this.state.imageSelectSubEventId])) }); // No need to check the to be added sub events if we matched the ID to an existing sub event return; } const toBeAddedSubEvents = this.state.toBeAddedSubEvents; const toBeAddedIdx = toBeAddedSubEvents.findIndex(e => e.id === this.state.imageSelectSubEventId); if (toBeAddedIdx > -1) { const subEvent = toBeAddedSubEvents[toBeAddedIdx]; subEvent.eventImgKey = subEvent.eventImgKey === imgKey ? '' : imgKey; toBeAddedSubEvents[toBeAddedIdx] = subEvent; this.setState({ toBeAddedSubEvents: [...toBeAddedSubEvents] }); } } /** * Marks the sub event with the supplied ID for deletion as well as any sub events that * are nested underneath it * @param {string} subEventId The ID for sub event to be deleted */ markSubEventForDeletion(subEventId: string) { // Mark any children of this sub event for deletion const childSubEventIds: string[] = []; for (const e of this.state.existingSubEvents) { if (e.parentId === subEventId) { childSubEventIds.push(e.id!); } } for (const e of this.state.toBeAddedSubEvents) { if (e.parentId === subEventId) { childSubEventIds.push(e.id!); } } for (const childSubEventId of childSubEventIds) { this.markSubEventForDeletion(childSubEventId); } // Mark this sub event for deletion const idx = this.state.existingSubEvents.findIndex(e => e.id === subEventId); if (idx > -1) { const subEvent = this.state.existingSubEvents[idx]; this.setState((state, props) => ({ toBeDeletedSubEvents: [...state.toBeDeletedSubEvents, subEvent] })); } this.setState((state, props) => ({ existingSubEvents: [...state.existingSubEvents.filter(e => e.id !== subEventId)], toBeAddedSubEvents: [...state.toBeAddedSubEvents.filter(e => e.id !== subEventId)] })); } /** * Adds a row for a new sub event under the event with the supplied parent ID * @param parentId The ID of the event that will serve as the new sub event's parent */ addNewSubEventRow(parentId: string) { this.setState((state, props) => ({ toBeAddedSubEvents: [...state.toBeAddedSubEvents, this.createNewToBeAddedSubEvent(parentId)] })); } /** * Creates a new sub event that will be added * @param parentId ID of the event the new sub event will be nested under * @returns {IEvent} new Event object */ createNewToBeAddedSubEvent(parentId: string): IEvent { return { id: uuid.v4(), parentId, name: '', description: '', priority: this.state.event!.priority, type: this.state.event!.type }; } /** * Render this page. */ render() { return (
{I18n.get('text.sites')} {I18n.get('text.areas')}: {this.state.siteName} {I18n.get('info.processes')}: {this.state.areaName} {I18n.get('text.events')}: {this.state.processName} {I18n.get(`${!this.isEditMode() ? 'button.add.event' : 'text.edit.event'}`)} { this.state.error && {I18n.get('error')}:
{this.state.error}
} {!this.state.isPageLoading && !this.state.isFatalError &&
{this.isEditMode() ? I18n.get('text.edit.event') : I18n.get('button.add.event')}
{this.isEditMode() && }
{I18n.get('text.event.name')} * { !this.isEditMode() && {`(${I18n.get('text.required')}) ${I18n.get('info.valid.general.input')}`} } {I18n.get('text.event.description')} * { !this.isEditMode() && {`(${I18n.get('text.required')}) ${I18n.get('info.valid.general.input')}`} } {I18n.get('text.sms.no')} {`(${I18n.get('text.optional')}) ${I18n.get('info.valid.phone.number')}`} {I18n.get('text.email')} {`(${I18n.get('text.optional')}) ${I18n.get('info.valid.email')}`} { !this.isEditMode() &&
{I18n.get('text.event.priority')} * {I18n.get('text.event.type')} {`(${I18n.get('text.optional')}) ${I18n.get('info.valid.event.type')}`}
} {I18n.get('text.event.alias')} {`(${I18n.get('text.optional')}) ${I18n.get('info.valid.event.alias')}`} {I18n.get('text.rootcauses')} { this.state.rootCauses.filter((rootCause: IRootCause) => rootCause.visible) .map((rootCause: IRootCause) => { const rootCauseId = JSON.stringify({ id: rootCause.id, rootCause: rootCause.name }); const isRootCauseChecked: boolean = this.rootCauses.findIndex((checkedRootCause: string) => checkedRootCause === rootCause.name) > -1; return ( ); }) }
{rootCause.name}{rootCause.deleted ? ` (${I18n.get('text.deleted')})` : ''}
{I18n.get('text.event.image')}
{ this.state.eventImgKey ? :
}
{ this.state.showEventImageLibrary ? this.state.eventImgKeys.map(imgKey => { return ( this.onSelectEventImage(imgKey)}> ) }) :
}
} { (this.state.isLoading || this.state.isPageLoading) && }
this.setState({ showSubEventModal: false })}> {I18n.get('button.manage.sub.events')} { !this.state.isModalProcessing && this.state.existingSubEvents.length === 0 &&
{I18n.get('text.sub.event.empty')}
} { this.isEditMode() && this.displaySubEvents(this.state.event!.id!) } { this.state.isModalProcessing && }
); } } export default AddEditEvent;