Skip to content

Commit

Permalink
Merge pull request #296 from Jozian/develop_send_email_on_field_change
Browse files Browse the repository at this point in the history
Send email alerts on certain field changes.
  • Loading branch information
jtoliver-quoin authored Oct 25, 2023
2 parents 99335f7 + b6378d1 commit cc5a428
Show file tree
Hide file tree
Showing 40 changed files with 726 additions and 69 deletions.
11 changes: 11 additions & 0 deletions app/controllers/api/v2/alerts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ def index
@alerts = @record.alerts
end

def destroy
authorize! :remove_alert, @record
alert_id = params[:id]
alert = @record.alerts.find { |a| a.unique_id == alert_id }
if alert.present?
alert.destroy!
return
end
raise ActiveRecord::RecordNotFound
end

def index_action_message
'show_alerts'
end
Expand Down
13 changes: 10 additions & 3 deletions app/javascript/components/internal-alert/component.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import SignalWifiOffIcon from "@material-ui/icons/SignalWifiOff";
import { generate } from "../notifier/utils";
import { useI18n } from "../i18n";

import InternalAlertItem from "./components/item";
import { NAME, SEVERITY } from "./constants";
import { expansionPanelSummaryClasses } from "./theme";
import css from "./styles.css";
Expand All @@ -38,7 +39,9 @@ const Component = ({ title, items, severity, customIcon }) => {
<AccordionDetails>
<ul className={accordionDetailsClasses}>
{items.map(item => (
<li key={generate.messageKey()}>{item.get("message")}</li>
<li key={generate.messageKey()}>
<InternalAlertItem item={item} />
</li>
))}
</ul>
</AccordionDetails>
Expand All @@ -61,12 +64,16 @@ const Component = ({ title, items, severity, customIcon }) => {

const renderTitle = () => {
const titleMessage =
items?.size > 1 ? title || i18n.t("messages.alert_items", { items: items.size }) : items?.first()?.get("message");
items?.size > 1 ? (
title || <div className={css.accordionTitle}>{i18n.t("messages.alert_items", { items: items.size })}</div>
) : (
<InternalAlertItem item={items.first()} />
);

return (
<>
<div className={css.icon}>{customIcon || renderIcon()}</div>
<span className={css.message}>{titleMessage}</span>
{titleMessage}
</>
);
};
Expand Down
28 changes: 27 additions & 1 deletion app/javascript/components/internal-alert/component.unit.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2014 - 2023 UNICEF. All rights reserved.

import { fromJS } from "immutable";
import { AccordionDetails, AccordionSummary } from "@material-ui/core";
import { AccordionDetails, AccordionSummary, IconButton } from "@material-ui/core";

import { setupMountedComponent } from "../../test";

Expand Down Expand Up @@ -62,4 +62,30 @@ describe("<InternalAlert />", () => {

expect(component.find(AccordionSummary).text()).to.be.equal(title);
});

it("renders a dismiss button if onDismiss is on an item", () => {
const { component } = setupMountedComponent(
InternalAlert,
{
items: fromJS([{ message: "Alert Message 1", onDismiss: () => {} }]),
severity: "warning"
},
{}
);

expect(component.find(IconButton)).to.have.lengthOf(1);
});

it("does not render a dismiss button if onDismiss is not on an item", () => {
const { component } = setupMountedComponent(
InternalAlert,
{
items: fromJS([{ message: "Alert Message 1" }]),
severity: "warning"
},
{}
);

expect(component.find(IconButton)).to.have.lengthOf(0);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { IconButton } from "@material-ui/core";
import CloseIcon from "@material-ui/icons/Close";
import PropTypes from "prop-types";

import css from "./styles.css";

const Component = ({ handler }) => {
const handlerWrapper = event => {
event.stopPropagation();
handler();
};

return (
<IconButton className={css.dismissButton} onClick={handlerWrapper}>
<CloseIcon />
</IconButton>
);
};

Component.displayName = "InternalAlertDismissButton";
Component.propTypes = {
handler: PropTypes.func.isRequired
};
export default Component;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./component";
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.dismissButton {
pointer-events: auto;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import PropTypes from "prop-types";

import InternalAlertDismissButton from "../dismiss-button";

import css from "./styles.css";

const Component = ({ item }) => {
return (
<div className={css.alertItemElement}>
<span>{item.get("message")}</span>
{item.get("onDismiss") && InternalAlertDismissButton({ handler: item.get("onDismiss") })}
</div>
);
};

Component.displayName = "InternalAlertItem";
Component.propTypes = {
item: PropTypes.object.isRequired
};

export default Component;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./component";
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.alertItemElement {
display: flex;
flex-wrap: nowrap;
align-items: center;
justify-content: space-between;
flex-grow: 1;
}
7 changes: 6 additions & 1 deletion app/javascript/components/internal-alert/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
& svg {
font-size: var(--fs-16);
}
align-self: center;
}

.alertTitle {
display: inline-flex;
display: flex;
align-items: center;
width: 100%;

Expand Down Expand Up @@ -73,3 +74,7 @@
}
}
}

.accordionTitle {
align-self: center;
}
5 changes: 4 additions & 1 deletion app/javascript/components/permissions/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ export const ACTIONS = {
WORKFLOW_REPORT: "workflow_report",
WRITE: "write",
VIEW_FAMILY_RECORD: "view_family_record",
LINK_FAMILY_RECORD: "link_family_record"
LINK_FAMILY_RECORD: "link_family_record",
REMOVE_ALERT: "remove_alert"
};

export const MANAGE = [ACTIONS.MANAGE];
Expand Down Expand Up @@ -343,3 +344,5 @@ export const CREATE_CASE_FROM_FAMILY = [...MANAGE, ACTIONS.CASE_FROM_FAMILY];
export const LINK_FAMILY_RECORD_FROM_CASE = [...MANAGE, ACTIONS.LINK_FAMILY_RECORD];

export const VIEW_FAMILY_RECORD_FROM_CASE = [...MANAGE, ACTIONS.VIEW_FAMILY_RECORD];

export const REMOVE_ALERT = [...MANAGE, ACTIONS.REMOVE_ALERT];
3 changes: 2 additions & 1 deletion app/javascript/components/permissions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ export {
VIEW_INCIDENTS_FROM_CASE,
VIEW_KPIS,
WRITE_RECORDS,
WRITE_REGISTRY_RECORD
WRITE_REGISTRY_RECORD,
REMOVE_ALERT
} from "./constants";

export { checkPermissions } from "./utils";
Expand Down
22 changes: 19 additions & 3 deletions app/javascript/components/record-form-alerts/component.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,34 @@

import PropTypes from "prop-types";
import { fromJS, List } from "immutable";
import { useDispatch } from "react-redux";

import { useI18n } from "../i18n";
import InternalAlert from "../internal-alert";
import useMemoizedSelector from "../../libs/use-memoized-selector";
import { getRecordFormAlerts } from "../records";
import { getRecordFormAlerts, getSelectedRecord, deleteAlertFromRecord } from "../records";
import { getSubformsDisplayName, getValidationErrors } from "../record-form";
import { getDuplicatedFields } from "../record-form/selectors";
import { usePermissions, REMOVE_ALERT } from "../permissions";

import { getMessageData } from "./utils";
import { NAME } from "./constants";

const Component = ({ form, recordType, attachmentForms }) => {
const Component = ({ form, recordType, attachmentForms, formMode }) => {
const i18n = useI18n();

const dispatch = useDispatch();

const recordAlerts = useMemoizedSelector(state => getRecordFormAlerts(state, recordType, form.unique_id));
const validationErrors = useMemoizedSelector(state => getValidationErrors(state, form.unique_id));
const subformDisplayNames = useMemoizedSelector(state => getSubformsDisplayName(state, i18n.locale));
const duplicatedFields = useMemoizedSelector(state => getDuplicatedFields(state, recordType, form.unique_id));
const selectedRecord = useMemoizedSelector(state => getSelectedRecord(state, recordType));
const hasDismissPermission = usePermissions(recordType, REMOVE_ALERT);

const showDismissButton = () => {
return hasDismissPermission && formMode.isShow;
};

const errors =
validationErrors?.size &&
Expand All @@ -44,7 +54,12 @@ const Component = ({ form, recordType, attachmentForms }) => {
message: i18n.t(
`messages.alerts_for.${alert.get("alert_for")}`,
getMessageData({ alert, form, duplicatedFields, i18n })
)
),
onDismiss: showDismissButton()
? () => {
dispatch(deleteAlertFromRecord(recordType, selectedRecord, alert.get("unique_id")));
}
: null
})
);

Expand Down Expand Up @@ -73,6 +88,7 @@ Component.defaultProps = {
Component.propTypes = {
attachmentForms: PropTypes.object,
form: PropTypes.object.isRequired,
formMode: PropTypes.object,
recordType: PropTypes.string.isRequired
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ const renderFormSections =
{...titleProps}
/>

<RecordFormAlerts recordType={recordType} form={form} attachmentForms={attachmentForms} />
<RecordFormAlerts recordType={recordType} form={form} attachmentForms={attachmentForms} formMode={mode} />
{renderFormFields(
fs,
form,
Expand Down
18 changes: 17 additions & 1 deletion app/javascript/components/records/action-creators.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ import {
EXTERNAL_SYNC,
OFFLINE_INCIDENT_FROM_CASE,
CREATE_CASE_FROM_FAMILY_MEMBER,
CREATE_CASE_FROM_FAMILY_DETAIL
CREATE_CASE_FROM_FAMILY_DETAIL,
DELETE_ALERT_FROM_RECORD,
DELETE_ALERT_FROM_RECORD_SUCCESS
} from "./actions";

const getSuccessCallback = ({
Expand Down Expand Up @@ -170,6 +172,20 @@ export const fetchRecordsAlerts = (recordType, recordId, asCallback = false) =>
}
});

export const deleteAlertFromRecord = (recordType, recordId, alertId) => ({
type: `${recordType}/${DELETE_ALERT_FROM_RECORD}`,
api: {
path: `${recordType}/${recordId}/alerts/${alertId}`,
method: METHODS.DELETE,
skipDB: true,
performFromQueue: true,
successCallback: {
action: `${recordType}/${DELETE_ALERT_FROM_RECORD_SUCCESS}`,
payload: { alertId }
}
}
});

export const saveRecord = (
recordType,
saveMethod,
Expand Down
21 changes: 20 additions & 1 deletion app/javascript/components/records/action-creators.unit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ describe("records - Action Creators", () => {
"setSelectedCasePotentialMatch",
"setSelectedPotentialMatch",
"setSelectedRecord",
"unMatchCaseForTrace"
"unMatchCaseForTrace",
"deleteAlertFromRecord"
].forEach(property => {
expect(creators).to.have.property(property);
expect(creators[property]).to.be.a("function");
Expand Down Expand Up @@ -537,4 +538,22 @@ describe("records - Action Creators", () => {

expect(actionCreators.clearPotentialMatches()).be.deep.equals(expected);
});

it("checks that 'deleteAlertFromRecord' action creator to return the correct object", () => {
const expected = {
type: `${RECORD_PATH.cases}/DELETE_ALERT_FROM_RECORD`,
api: {
path: `${RECORD_PATH.cases}/12345/alerts/12345-alert`,
method: METHODS.DELETE,
skipDB: true,
performFromQueue: true,
successCallback: {
action: `${RECORD_PATH.cases}/DELETE_ALERT_FROM_RECORD_SUCCESS`,
payload: { alertId: "12345-alert" }
}
}
};

expect(actionCreators.deleteAlertFromRecord(RECORD_PATH.cases, "12345", "12345-alert")).be.deep.equals(expected);
});
});
5 changes: 5 additions & 0 deletions app/javascript/components/records/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ export const FETCH_RECORD_ALERTS_FAILURE = "FETCH_RECORD_ALERTS_FAILURE";
export const FETCH_RECORD_ALERTS_FINISHED = "FETCH_RECORD_ALERTS_FINISHED";
export const FETCH_RECORD_ALERTS_STARTED = "FETCH_RECORD_ALERTS_STARTED";
export const FETCH_RECORD_ALERTS_SUCCESS = "FETCH_RECORD_ALERTS_SUCCESS";
export const DELETE_ALERT_FROM_RECORD = "DELETE_ALERT_FROM_RECORD";
export const DELETE_ALERT_FROM_RECORD_FAILURE = "DELETE_ALERT_FROM_RECORD_FAILURE";
export const DELETE_ALERT_FROM_RECORD_FINISHED = "DELETE_ALERT_FROM_RECORD_FINISHED";
export const DELETE_ALERT_FROM_RECORD_STARTED = "DELETE_ALERT_FROM_RECORD_STARTED";
export const DELETE_ALERT_FROM_RECORD_SUCCESS = "DELETE_ALERT_FROM_RECORD_SUCCESS";
export const FETCH_INCIDENT_FROM_CASE = "FETCH_INCIDENT_FROM_CASE";
export const FETCH_INCIDENT_FROM_CASE_FAILURE = "FETCH_INCIDENT_FROM_CASE_FAILURE";
export const FETCH_INCIDENT_FROM_CASE_FINISHED = "FETCH_INCIDENT_FROM_CASE_FINISHED";
Expand Down
7 changes: 6 additions & 1 deletion app/javascript/components/records/actions.unit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,12 @@ describe("records - Actions", () => {
"CREATE_CASE_FROM_FAMILY_DETAIL_SUCCESS",
"CREATE_CASE_FROM_FAMILY_DETAIL_STARTED",
"CREATE_CASE_FROM_FAMILY_DETAIL_FAILURE",
"CREATE_CASE_FROM_FAMILY_DETAIL_FINISHED"
"CREATE_CASE_FROM_FAMILY_DETAIL_FINISHED",
"DELETE_ALERT_FROM_RECORD",
"DELETE_ALERT_FROM_RECORD_FAILURE",
"DELETE_ALERT_FROM_RECORD_FINISHED",
"DELETE_ALERT_FROM_RECORD_STARTED",
"DELETE_ALERT_FROM_RECORD_SUCCESS"
].forEach(property => {
expect(cloneActions).to.have.property(property);
expect(cloneActions[property]).to.be.a("string");
Expand Down
8 changes: 8 additions & 0 deletions app/javascript/components/records/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
RECORD_FINISHED,
SERVICE_REFERRED_SAVE,
FETCH_RECORD_ALERTS_SUCCESS,
DELETE_ALERT_FROM_RECORD_SUCCESS,
FETCH_INCIDENT_FROM_CASE_SUCCESS,
CLEAR_METADATA,
CLEAR_CASE_FROM_INCIDENT,
Expand Down Expand Up @@ -178,6 +179,13 @@ export default namespace =>
return state.set("errors", true);
case `${namespace}/${RECORD_FINISHED}`:
return state.set("loading", false);
case `${namespace}/${DELETE_ALERT_FROM_RECORD_SUCCESS}`:
state.set("alert_count", state.get("alert_count") - 1);

return state.set(
"recordAlerts",
state.get("recordAlerts").filter(alert => alert.get("unique_id") !== payload.alertId)
);
case `${namespace}/${FETCH_RECORD_ALERTS_SUCCESS}`:
return state.set("recordAlerts", fromJS(payload.data));
case "user/LOGOUT_SUCCESS":
Expand Down
Loading

0 comments on commit cc5a428

Please sign in to comment.