Skip to content

Commit 85830d3

Browse files
authored
feat: handle 429 error when sending record and creating record link (#532)
1 parent c7b4cee commit 85830d3

15 files changed

+925
-398
lines changed

package-lock.json

Lines changed: 14 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/ProgramRecord/ProgramRecord.jsx

Lines changed: 38 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,20 @@ import ProgramRecordAlert from '../ProgramRecordAlert';
2323
import SendLearnerRecordModal from '../ProgramRecordSendModal';
2424
import createCorrectInternalRoute from '../../utils';
2525

26-
import getProgramDetails from './data/service';
26+
import { getProgramDetails } from './data/service';
2727

2828
function ProgramRecord({ isPublic }) {
2929
const [recordDetails, setRecordDetails] = useState({});
3030
const [isLoaded, setIsLoaded] = useState(false);
3131
const [hasNoData, setHasNoData] = useState(false);
3232
const [showSendRecordButton, setShowSendRecordButton] = useState(false);
3333

34+
const [showProgramRecord429Error, setShowProgramRecord429Error] = useState(false);
35+
3436
const [sendRecord, setSendRecord] = useState({
3537
sendRecordModalOpen: false,
36-
sendRecordSuccessOrgs: [],
37-
sendRecordFailureOrgs: [],
38+
sendRecordSuccessPathways: [],
39+
sendRecordFailurePathways: [],
3840
});
3941

4042
const { programUUID } = useParams();
@@ -67,11 +69,11 @@ function ProgramRecord({ isPublic }) {
6769
}));
6870
};
6971

70-
const updateSuccessAndFailureOrgs = (id) => {
72+
const onCloseSuccessAndFailureAlert = (id) => {
7173
setSendRecord(prev => ({
7274
...prev,
73-
sendRecordSuccessOrgs: prev.sendRecordSuccessOrgs.filter(org => org.id !== id),
74-
sendRecordFailureOrgs: prev.sendRecordFailureOrgs.filter(org => org.id !== id),
75+
sendRecordSuccessPathways: prev.sendRecordSuccessPathways.filter(pathway => pathway.id !== id),
76+
sendRecordFailurePathways: prev.sendRecordFailurePathways.filter(pathway => pathway.id !== id),
7577
}));
7678
};
7779

@@ -99,29 +101,37 @@ function ProgramRecord({ isPublic }) {
99101
username={recordDetails.record.learner.username}
100102
programUUID={programUUID}
101103
sharedRecordUUID={recordDetails.record.shared_program_record_uuid}
104+
setShowProgramRecord429Error={setShowProgramRecord429Error}
102105
/>
103-
{sendRecord.sendRecordSuccessOrgs && sendRecord.sendRecordSuccessOrgs.map(org => (
106+
{sendRecord.sendRecordSuccessPathways && sendRecord.sendRecordSuccessPathways.map(pathway => (
104107
<ProgramRecordAlert
105-
key={org.id}
108+
key={pathway.id}
106109
alertType="success"
107-
onClose={updateSuccessAndFailureOrgs}
108-
creditPathway={org}
110+
onClose={onCloseSuccessAndFailureAlert}
111+
creditPathway={pathway}
109112
setSendRecord={setSendRecord}
110113
programUUID={programUUID}
111114
username={recordDetails.record.learner.username}
112115
platform={recordDetails.record.platform_name}
113116
/>
114117
))}
115-
{sendRecord.sendRecordFailureOrgs && sendRecord.sendRecordFailureOrgs.map(org => (
118+
{showProgramRecord429Error && (
119+
<ProgramRecordAlert
120+
alertType="429"
121+
setShowProgramRecord429Error={setShowProgramRecord429Error}
122+
/>
123+
)}
124+
{sendRecord.sendRecordFailurePathways && sendRecord.sendRecordFailurePathways.map(pathway => (
116125
<ProgramRecordAlert
117-
key={org.id}
126+
key={pathway.id}
118127
alertType="failure"
119-
onClose={updateSuccessAndFailureOrgs}
120-
creditPathway={org}
128+
onClose={onCloseSuccessAndFailureAlert}
129+
creditPathway={pathway}
121130
setSendRecord={setSendRecord}
122131
programUUID={programUUID}
123132
username={recordDetails.record.learner.username}
124133
platform={recordDetails.record.platform_name}
134+
setShowProgramRecord429Error={setShowProgramRecord429Error}
125135
/>
126136
))}
127137
<article className="program-record my-4.5">
@@ -138,21 +148,22 @@ function ProgramRecord({ isPublic }) {
138148
</article>
139149

140150
{recordDetails.records_help_url && (
141-
<RecordsHelp
142-
helpUrl={recordDetails.records_help_url}
143-
/>
151+
<RecordsHelp
152+
helpUrl={recordDetails.records_help_url}
153+
/>
144154
)}
145155
{sendRecord.sendRecordModalOpen && (
146-
<SendLearnerRecordModal
147-
isOpen={sendRecord.sendRecordModalOpen}
148-
toggleSendRecordModal={toggleSendRecordModal}
149-
creditPathways={recordDetails.record.pathways}
150-
programUUID={programUUID}
151-
username={recordDetails.record.learner.username}
152-
setSendRecord={setSendRecord}
153-
platform={recordDetails.record.platform_name}
154-
programType={recordDetails.record.program.type_name}
155-
/>
156+
<SendLearnerRecordModal
157+
isOpen={sendRecord.sendRecordModalOpen}
158+
toggleSendRecordModal={toggleSendRecordModal}
159+
creditPathways={recordDetails.record.pathways}
160+
programUUID={programUUID}
161+
username={recordDetails.record.learner.username}
162+
setSendRecord={setSendRecord}
163+
platform={recordDetails.record.platform_name}
164+
programType={recordDetails.record.program.type_name}
165+
setShowProgramRecord429Error={setShowProgramRecord429Error}
166+
/>
156167
)}
157168
</>
158169
);

src/components/ProgramRecord/ProgramRecordActions.jsx

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,14 @@ import { getConfig } from '@edx/frontend-platform';
1616
import { getProgramRecordUrl, getProgramRecordCsv } from './data/service';
1717

1818
function ProgramRecordActions({
19-
showSendRecordButton, isPublic, toggleSendRecordModal, renderBackButton, username, programUUID, sharedRecordUUID,
19+
showSendRecordButton,
20+
isPublic,
21+
toggleSendRecordModal,
22+
renderBackButton,
23+
username,
24+
programUUID,
25+
sharedRecordUUID,
26+
setShowProgramRecord429Error,
2027
}) {
2128
const [programRecordUrl, setProgramRecordUrl] = useState(sharedRecordUUID && `${getConfig().CREDENTIALS_BASE_URL}/records/programs/shared/${sharedRecordUUID}`);
2229
const [showCopyTooltip, setShowCopyTooltip] = useState(false);
@@ -105,6 +112,9 @@ function ProgramRecordActions({
105112
})
106113
.catch((error) => {
107114
logError(error);
115+
if (error.status === 429) {
116+
setShowProgramRecord429Error(true);
117+
}
108118
setShowCreateLinkAlert(true);
109119
});
110120
handleCopyEvent();
@@ -174,17 +184,19 @@ function ProgramRecordActions({
174184
/>
175185
</Button>
176186
) : (
177-
<Button
178-
variant="outline-primary"
179-
iconBefore={ContentCopy}
180-
className="copy-record-button"
181-
onClick={handleProgramUrlCreate}
182-
>
183-
<FormattedMessage
184-
id="create.program.record.link"
185-
defaultMessage="Create program record link"
186-
description="Button text for creating a link to the program record"
187-
/>
187+
<>
188+
<Button
189+
variant="outline-primary"
190+
iconBefore={ContentCopy}
191+
className="copy-record-button"
192+
onClick={handleProgramUrlCreate}
193+
>
194+
<FormattedMessage
195+
id="create.program.record.link"
196+
defaultMessage="Create program record link"
197+
description="Button text for creating a link to the program record"
198+
/>
199+
</Button>
188200
<Toast
189201
onClose={() => setShowCreateLinkAlert(false)}
190202
show={showCreateLinkAlert}
@@ -195,7 +207,7 @@ function ProgramRecordActions({
195207
description="A message to briefly display when the creation of a shared program record link fails"
196208
/>
197209
</Toast>
198-
</Button>
210+
</>
199211
)}
200212
</OverlayTrigger>
201213
<OverlayTrigger
@@ -287,6 +299,7 @@ ProgramRecordActions.propTypes = {
287299
username: PropTypes.string.isRequired,
288300
programUUID: PropTypes.string.isRequired,
289301
sharedRecordUUID: PropTypes.string,
302+
setShowProgramRecord429Error: PropTypes.func.isRequired,
290303
};
291304

292305
ProgramRecordActions.defaultProps = {
Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
22
import { getConfig } from '@edx/frontend-platform/config';
33

4-
async function getProgramDetails(programUUID, isPublic) {
4+
export async function getProgramDetails(programUUID, isPublic) {
55
const url = `${getConfig().CREDENTIALS_BASE_URL}/records/api/v1/program_records/${programUUID}/?is_public=${isPublic}`;
66
let data = {};
77

@@ -18,22 +18,14 @@ async function getProgramDetails(programUUID, isPublic) {
1818

1919
export async function getProgramRecordUrl(programUUID, username) {
2020
const url = `${getConfig().CREDENTIALS_BASE_URL}/records/programs/${programUUID}/share`;
21-
try {
22-
const response = await getAuthenticatedHttpClient().post(url, { username }, { withCredentials: true });
23-
return response;
24-
} catch (error) {
25-
throw new Error(error);
26-
}
21+
const response = await getAuthenticatedHttpClient().post(url, { username }, { withCredentials: true });
22+
return response;
2723
}
2824

2925
export async function getProgramRecordCsv(programUUID) {
3026
const url = `${getConfig().CREDENTIALS_BASE_URL}/records/programs/shared/${programUUID}/csv`;
31-
try {
32-
const response = await getAuthenticatedHttpClient().get(url, { withCredentials: true });
33-
return response;
34-
} catch (error) {
35-
throw new Error(error);
36-
}
27+
const response = await getAuthenticatedHttpClient().get(url, { withCredentials: true });
28+
return response;
3729
}
3830

3931
export default getProgramDetails;
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
2+
import { getProgramDetails, getProgramRecordUrl, getProgramRecordCsv } from './service';
3+
4+
const mockGet = jest.fn();
5+
const mockPost = jest.fn();
6+
7+
jest.mock('@edx/frontend-platform/auth', () => ({
8+
getAuthenticatedHttpClient: jest.fn(() => ({
9+
get: mockGet,
10+
post: mockPost,
11+
})),
12+
}));
13+
14+
jest.mock('@edx/frontend-platform/config', () => ({
15+
getConfig: jest.fn(() => ({
16+
CREDENTIALS_BASE_URL: 'https://credentials.example.com',
17+
})),
18+
}));
19+
20+
describe('ProgramRecordService', () => {
21+
beforeEach(() => {
22+
jest.clearAllMocks();
23+
});
24+
25+
describe('getProgramDetails', () => {
26+
it('calls the correct API endpoint with programUUID and isPublic', async () => {
27+
const programUUID = 'test-uuid';
28+
const isPublic = true;
29+
const expectedUrl = 'https://credentials.example.com/records/api/v1/program_records/test-uuid/?is_public=true';
30+
mockGet.mockResolvedValue({ data: { key: 'value' } });
31+
32+
const result = await getProgramDetails(programUUID, isPublic);
33+
34+
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
35+
expect(mockGet).toHaveBeenCalledWith(expectedUrl, { withCredentials: true });
36+
expect(result).toEqual({ key: 'value' });
37+
});
38+
39+
it('returns an empty object if the API call fails', async () => {
40+
const programUUID = 'test-uuid';
41+
const isPublic = false;
42+
mockGet.mockRejectedValue(new Error('API error'));
43+
44+
const result = await getProgramDetails(programUUID, isPublic);
45+
46+
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
47+
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining(programUUID), { withCredentials: true });
48+
expect(result).toEqual({});
49+
});
50+
});
51+
52+
describe('getProgramRecordUrl', () => {
53+
it('calls the correct API endpoint with programUUID and username', async () => {
54+
const programUUID = 'test-uuid';
55+
const username = 'testuser';
56+
const expectedUrl = 'https://credentials.example.com/records/programs/test-uuid/share';
57+
const mockResponse = { data: { url: 'shared-url' } };
58+
mockPost.mockResolvedValue(mockResponse);
59+
60+
const result = await getProgramRecordUrl(programUUID, username);
61+
62+
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
63+
expect(mockPost).toHaveBeenCalledWith(expectedUrl, { username }, { withCredentials: true });
64+
expect(result).toEqual(mockResponse);
65+
});
66+
67+
it('correctly handles API errors', async () => {
68+
const mockError = new Error('API request failed');
69+
mockPost.mockRejectedValue(mockError);
70+
71+
await expect(getProgramRecordUrl('test-uuid', 'testuser')).rejects.toThrow(mockError);
72+
});
73+
});
74+
75+
describe('getProgramRecordCsv', () => {
76+
it('calls the correct API endpoint with programUUID', async () => {
77+
const programUUID = 'test-uuid';
78+
const expectedUrl = 'https://credentials.example.com/records/programs/shared/test-uuid/csv';
79+
const mockResponse = { status: 200, data: 'csv data' };
80+
mockGet.mockResolvedValue(mockResponse);
81+
82+
const result = await getProgramRecordCsv(programUUID);
83+
84+
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
85+
expect(mockGet).toHaveBeenCalledWith(expectedUrl, { withCredentials: true });
86+
expect(result).toEqual(mockResponse);
87+
});
88+
89+
it('correctly handles API errors', async () => {
90+
const mockError = new Error('API request failed');
91+
mockGet.mockRejectedValue(mockError);
92+
93+
await expect(getProgramRecordCsv('test-uuid')).rejects.toThrow(mockError);
94+
});
95+
});
96+
});

0 commit comments

Comments
 (0)