Skip to content

Commit f7e6173

Browse files
aespinoza-quoinjtoliver-quoin
authored andcommitted
Merged in r2-2807-user-cannot-delete-100th-attachment (pull request #6726)
R2-2807 Users cannot delete 100th attachment Approved-by: Pavel Nabutovsky Approved-by: Joshua Toliver
2 parents 5ea0059 + 3b1bfa1 commit f7e6173

File tree

17 files changed

+256
-31
lines changed

17 files changed

+256
-31
lines changed

app/javascript/components/application/selectors.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,9 @@ export const getMaximumUsers = state => state.getIn([NAMESPACE, "systemOptions",
170170

171171
export const getMaximumUsersWarning = state => state.getIn([NAMESPACE, "systemOptions", "maximum_users_warning"]);
172172

173+
export const getMaximumAttachmentsPerRecord = state =>
174+
state.getIn([NAMESPACE, "systemOptions", "maximum_attachments_per_record"]);
175+
173176
export const getTheme = state => state.getIn([NAMESPACE, "theme"], fromJS({}));
174177

175178
export const getShowPoweredByPrimero = state => state.getIn([NAMESPACE, "theme", "showPoweredByPrimero"], false);
@@ -193,6 +196,7 @@ export const getAppData = memoize(state => {
193196
const useContainedNavStyle = getUseContainedNavStyle(state);
194197
const showPoweredByPrimero = getShowPoweredByPrimero(state);
195198
const hasLoginLogo = getLoginBackground(state);
199+
const maximumttachmentsPerRecord = getMaximumAttachmentsPerRecord(state);
196200

197201
return {
198202
modules,
@@ -206,7 +210,8 @@ export const getAppData = memoize(state => {
206210
maximumUsersWarning,
207211
useContainedNavStyle,
208212
showPoweredByPrimero,
209-
hasLoginLogo
213+
hasLoginLogo,
214+
maximumttachmentsPerRecord
210215
};
211216
});
212217

app/javascript/components/application/selectors.unit.test.js

+10-1
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,8 @@ const stateWithRecords = fromJS({
129129
disabledApplication: true,
130130
systemOptions: {
131131
maximum_users: 50,
132-
maximum_users_warning: 45
132+
maximum_users_warning: 45,
133+
maximum_attachments_per_record: 55
133134
}
134135
}
135136
});
@@ -611,4 +612,12 @@ describe("Application - Selectors", () => {
611612
expect(result).to.be.true;
612613
});
613614
});
615+
616+
describe("getMaximumAttachmentsPerRecord", () => {
617+
it("should return maximum users warning", () => {
618+
const result = selectors.getMaximumAttachmentsPerRecord(stateWithRecords);
619+
620+
expect(result).to.be.equal(55);
621+
});
622+
});
614623
});

app/javascript/components/record-form/form/components/validation-errors.jsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ const ValidationErrors = ({ formErrors, forms, submitCount }) => {
4545
.get("fields")
4646
.filter(field => fieldNames.includes(field.get("name")))
4747
.map(field => ({
48-
[field.get("name")]: formErrors[field.get("name")]
48+
[field.get("name")]: Array.isArray(formErrors[field.get("name")])
49+
? formErrors[field.get("name")].join("")
50+
: formErrors[field.get("name")]
4951
}))
5052
.reduce((acc, subCurrent) => ({ ...acc, ...subCurrent }), {})
5153
}

app/javascript/components/record-form/form/field-types/attachments/attachment-label.jsx

+7-3
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,25 @@ import css from "../../styles.css";
88
import ActionButton from "../../../../action-button";
99
import { ACTION_BUTTON_TYPES } from "../../../../action-button/constants";
1010

11-
const AttachmentLabel = ({ label, helpText, disabled, mode, arrayHelpers, handleAttachmentAddition }) => {
11+
const AttachmentLabel = ({ label, helpText, disabled, mode, arrayHelpers, handleAttachmentAddition, error }) => {
1212
const isDisabled = !disabled && !mode.isShow;
1313
const onClick = () => handleAttachmentAddition(arrayHelpers);
1414

1515
return (
1616
<div className={css.attachmentHeading}>
1717
<div className={css.attachmentLabel}>
18-
<h4>{label}</h4>
19-
<FormHelperText>{helpText}</FormHelperText>
18+
<h4 data-testid="attachment-label">{label}</h4>
19+
<FormHelperText data-testid="attachment-label-helptext" error={Boolean(error)}>
20+
{error || helpText}
21+
</FormHelperText>
2022
</div>
2123
{isDisabled && (
2224
<div>
2325
<ActionButton
2426
icon={<AddIcon />}
2527
text="Add"
2628
type={ACTION_BUTTON_TYPES.icon}
29+
data-testid="attachment-label-action-button"
2730
rest={{
2831
onClick
2932
}}
@@ -39,6 +42,7 @@ AttachmentLabel.displayName = "AttachmentLabel";
3942
AttachmentLabel.propTypes = {
4043
arrayHelpers: PropTypes.object.isRequired,
4144
disabled: PropTypes.bool,
45+
error: PropTypes.string,
4246
handleAttachmentAddition: PropTypes.func.isRequired,
4347
helpText: PropTypes.string,
4448
label: PropTypes.string.isRequired,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) 2014 - 2024 UNICEF. All rights reserved.
2+
3+
import { mountedComponent, screen } from "test-utils";
4+
5+
import AttachmentLabel from "./attachment-label";
6+
7+
describe("<AttachmentLabel />", () => {
8+
const props = {
9+
label: "Some label",
10+
helpText: "Some Help Text",
11+
mode: { isShow: false },
12+
handleAttachmentAddition: () => {},
13+
arrayHelpers: {},
14+
disabled: false
15+
};
16+
17+
beforeEach(() => {
18+
mountedComponent(<AttachmentLabel {...props} />);
19+
});
20+
21+
it("renders the AttachmentLabel", () => {
22+
expect(screen.getAllByTestId("attachment-label")).toHaveLength(1);
23+
});
24+
25+
it("renders the FormHelperText", () => {
26+
expect(screen.getAllByTestId("attachment-label-helptext")).toHaveLength(1);
27+
});
28+
29+
it("renders a <ActionButton /> component", () => {
30+
expect(screen.getAllByTestId("attachment-label-action-button")).toHaveLength(1);
31+
});
32+
});

app/javascript/components/record-form/form/field-types/attachments/component.jsx

+7-4
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,15 @@ import PhotoArray from "./photo-array";
2323
import { buildBase64URL } from "./utils";
2424

2525
// TODO: No link to display / download upload
26-
const Component = ({ name, field, label, disabled, formik, mode, recordType }) => {
26+
const Component = ({ name, field, label, disabled, formik, mode, recordType, helperText }) => {
2727
const i18n = useI18n();
2828

2929
const loading = useMemoizedSelector(state => getLoadingRecordState(state, recordType));
3030
const processing = useMemoizedSelector(state => getIsProcessingAttachments(state, recordType, name));
3131
const recordAttachments = useMemoizedSelector(state => getRecordAttachments(state, recordType));
3232

3333
const values = get(formik.values, name, []);
34+
const error = get(formik.errors, name, "");
3435
const attachment = FIELD_ATTACHMENT_TYPES[field.type];
3536

3637
const [openLastDialog, setOpenLastDialog] = useState(false);
@@ -109,7 +110,7 @@ const Component = ({ name, field, label, disabled, formik, mode, recordType }) =
109110
if (field.type === PHOTO_FIELD && mode.isShow) {
110111
const images = values?.map(value => value.attachment_url || buildBase64URL(value.content_type, value.attachment));
111112

112-
return <PhotoArray images={images} />;
113+
return <PhotoArray images={images} data-testid="photo-array" />;
113114
}
114115

115116
if (field.type === AUDIO_FIELD && mode.isShow) {
@@ -122,11 +123,12 @@ const Component = ({ name, field, label, disabled, formik, mode, recordType }) =
122123
return (
123124
<FieldArray name={name} validateOnChange={false}>
124125
{arrayHelpers => (
125-
<div>
126+
<div data-testid="field-array">
126127
<AttachmentLabel
127128
label={label}
128129
mode={mode}
129-
helpText={field.help_text[i18n.locale]}
130+
helpText={helperText}
131+
error={error}
130132
handleAttachmentAddition={handleAttachmentAddition}
131133
arrayHelpers={arrayHelpers}
132134
disabled={disabled}
@@ -149,6 +151,7 @@ Component.propTypes = {
149151
disabled: PropTypes.bool,
150152
field: PropTypes.object,
151153
formik: PropTypes.object,
154+
helperText: PropTypes.string,
152155
label: PropTypes.string,
153156
mode: PropTypes.object,
154157
name: PropTypes.string,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright (c) 2014 - 2024 UNICEF. All rights reserved.
2+
3+
import { mountedComponent, screen } from "test-utils";
4+
5+
import AttachmentField from "./component";
6+
import { FIELD_ATTACHMENT_TYPES } from "./constants";
7+
8+
describe("<AttachmentField />", () => {
9+
const props = {
10+
label: "Some label",
11+
helpText: "Some Help Text",
12+
mode: { isShow: true },
13+
disabled: false,
14+
recordType: "cases",
15+
name: "photos",
16+
field: {
17+
type: FIELD_ATTACHMENT_TYPES.photos
18+
}
19+
};
20+
21+
const initialState = {
22+
records: {
23+
cases: {
24+
loading: false,
25+
recordAttachments: {
26+
photos: {
27+
processing: false
28+
}
29+
}
30+
}
31+
}
32+
};
33+
34+
beforeEach(() => {
35+
mountedComponent(<AttachmentField {...props} />, initialState, {}, [], {
36+
values: { photos: [{ attachment_url: "random-string" }] }
37+
});
38+
});
39+
40+
it("render the <AttachmentField />", () => {
41+
expect(screen.getAllByTestId("attachment-label")).toHaveLength(1);
42+
expect(screen.getAllByTestId("field-array")).toHaveLength(1);
43+
});
44+
45+
it("render a AttachmentLabel", () => {
46+
expect(screen.getAllByTestId("attachment-label-helptext")).toHaveLength(1);
47+
});
48+
});

app/javascript/components/record-form/form/record-form.jsx

+43-3
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { memo, useEffect, useRef, useState } from "react";
44
import PropTypes from "prop-types";
5-
import { object } from "yup";
5+
import { object, ValidationError } from "yup";
66
import { Formik } from "formik";
77
import isEmpty from "lodash/isEmpty";
88
import { batch, useDispatch } from "react-redux";
@@ -14,6 +14,7 @@ import { constructInitialValues, sortSubformValues } from "../utils";
1414
import { useMemoizedSelector } from "../../../libs";
1515
import { INCIDENT_FROM_CASE, RECORD_TYPES } from "../../../config";
1616
import { getDataProtectionInitialValues } from "../selectors";
17+
import { AUDIO_FIELD, DOCUMENT_FIELD, PHOTO_FIELD } from "../constants";
1718
import { LEGITIMATE_BASIS } from "../../record-creation-flow/components/consent-prompt/constants";
1819
import renderFormSections from "../components/render-form-sections";
1920
import { useApp } from "../../application";
@@ -43,7 +44,7 @@ const RecordForm = ({
4344
}) => {
4445
const i18n = useI18n();
4546
const dispatch = useDispatch();
46-
const { online } = useApp();
47+
const { online, maximumttachmentsPerRecord } = useApp();
4748

4849
const [initialValues, setInitialValues] = useState(mode.isNew ? constructInitialValues(forms.values()) : {});
4950
const [formTouched, setFormTouched] = useState({});
@@ -71,7 +72,46 @@ const RecordForm = ({
7172
);
7273
}, {});
7374

74-
return object().shape(schema);
75+
const attachmentsFieldNames = [
76+
...formSections
77+
.flatMap(obj =>
78+
obj.fields.filter(field => [AUDIO_FIELD, DOCUMENT_FIELD, PHOTO_FIELD].includes(field.type) && !field.disabled)
79+
)
80+
.map(field => field.name)
81+
];
82+
83+
return object()
84+
.shape(schema)
85+
.test({
86+
name: "maxAttach",
87+
// eslint-disable-next-line object-shorthand, func-names
88+
test: function (values) {
89+
const attachmentsKeys = Object.keys(values).filter(key => attachmentsFieldNames.includes(key));
90+
const totalAttachments = attachmentsKeys.reduce(
91+
(acc, arr) =>
92+
acc +
93+
values[arr].filter(
94+
value => !(Object.prototype.hasOwnProperty.call(value, "_destroy") || value.field_name === undefined)
95+
).length,
96+
0
97+
);
98+
99+
if (totalAttachments <= maximumttachmentsPerRecord) return true;
100+
101+
const errors = attachmentsKeys.map(key => {
102+
return new ValidationError(
103+
i18n.t("fields.attachments.maximum_attached", { maximumttachmentsPerRecord }),
104+
true,
105+
key
106+
);
107+
});
108+
109+
// eslint-disable-next-line react/no-this-in-sfc
110+
return this.createError({
111+
message: () => errors
112+
});
113+
}
114+
});
75115
};
76116

77117
useEffect(() => {

app/javascript/libs/queue/index.js

+8-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ import transformOfflineRequest from "../transform-offline-request";
1212
import EventManager from "../messenger";
1313
import { queueIndexedDB } from "../../db";
1414

15-
import { deleteFromQueue, messageQueueFailed, messageQueueSkip, messageQueueSuccess } from "./utils";
15+
import {
16+
deleteFromQueue,
17+
messageQueueFailed,
18+
messageQueueSkip,
19+
messageQueueSuccess,
20+
sortQueueByDeleteFirst
21+
} from "./utils";
1622
import {
1723
QUEUE_PENDING,
1824
QUEUE_READY,
@@ -141,7 +147,7 @@ class Queue {
141147
if (this.ready) {
142148
this.working = true;
143149

144-
const item = head(this.queue);
150+
const item = head(sortQueueByDeleteFirst(this.queue));
145151

146152
if (item && !item?.processed && ((item.tries || 0) < QUEUE_ALLOWED_RETRIES || this.force)) {
147153
this.onAttachmentProcess(item);

app/javascript/libs/queue/utils.js

+16
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { queueIndexedDB } from "../../db";
44
import EventManager from "../messenger";
5+
import { METHODS } from "../../config/constants";
56

67
import { QUEUE_FAILED, QUEUE_SKIP, QUEUE_SUCCESS } from "./constants";
78

@@ -28,3 +29,18 @@ export const messageQueueSuccess = action => {
2829
EventManager.publish(QUEUE_SUCCESS, action);
2930
}
3031
};
32+
33+
export const sortQueueByDeleteFirst = queue =>
34+
queue.sort((elem1, elem2) => {
35+
if (elem1.api.method === elem2.api.method) {
36+
return 0;
37+
}
38+
if (elem1.api.method === METHODS.DELETE) {
39+
return -1;
40+
}
41+
if (elem2.api.method === METHODS.DELETE) {
42+
return 1;
43+
}
44+
45+
return 0;
46+
});

app/javascript/middleware/utils/fetch-single-payload.js

+17-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { DEFAULT_FETCH_OPTIONS } from "../constants";
77
import { disableNavigation } from "../../components/application/action-creators";
88
import { applyingConfigMessage } from "../../components/pages/admin/configurations-form/action-creators";
99
import userActions from "../../components/user/actions";
10+
import { SET_ATTACHMENT_STATUS } from "../../components/records/actions";
1011

1112
import fetchStatus from "./fetch-status";
1213
import getToken from "./get-token";
@@ -111,12 +112,16 @@ const fetchSinglePayload = async (action, store, options) => {
111112
} else if (failureCallback) {
112113
messageQueueFailed(fromQueue);
113114
handleRestCallback(store, failureCallback, response, json, fromQueue);
115+
} else if (action.type.includes("SAVE_ATTACHMENT") && status === 422) {
116+
throw new FetchError(response, json);
114117
} else {
115118
messageQueueFailed(fromQueue);
116119
throw new FetchError(response, json);
117120
}
118121

119-
throw new Error(window.I18n.t("error_message.error_something_went_wrong"));
122+
if (!action.type.includes("SAVE_ATTACHMENT") && status !== 422) {
123+
throw new Error(window.I18n.t("error_message.error_something_went_wrong"));
124+
}
120125
}
121126
await handleSuccess(store, {
122127
type,
@@ -151,7 +156,17 @@ const fetchSinglePayload = async (action, store, options) => {
151156
} catch (error) {
152157
const errorDataObject = { json: error?.json, recordType, fromQueue, id, error };
153158

154-
messageQueueFailed(fromQueue);
159+
if (fromAttachment && error?.response?.status === 422) {
160+
deleteFromQueue(fromQueue);
161+
messageQueueSkip();
162+
store.dispatch({
163+
type: `${fromAttachment.record_type}/${SET_ATTACHMENT_STATUS}`,
164+
payload: { processing: false, error: false, pending: false, fieldName: fromAttachment.field_name }
165+
});
166+
errorDataObject.fromQueue = false;
167+
} else {
168+
messageQueueFailed(fromQueue);
169+
}
155170

156171
fetchStatus({ store, type }, "FAILURE", false);
157172

0 commit comments

Comments
 (0)