Skip to content

Commit 0c86a39

Browse files
committed
refactor: address feedback
1 parent 91daea4 commit 0c86a39

4 files changed

Lines changed: 109 additions & 28 deletions

File tree

src/editors/sharedComponents/TinyMceWidget/hooks.test.js

Lines changed: 91 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import 'CourseAuthoring/editors/setupEditorTest';
22
import { getConfig } from '@edx/frontend-platform';
3+
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
4+
import * as keyUtils from '../../../generic/key-utils';
35
import { MockUseState } from '../../testUtils';
46

57
import * as tinyMCE from '../../data/constants/tinyMCE';
@@ -19,6 +21,10 @@ jest.mock('react', () => ({
1921
useCallback: (cb, prereqs) => ({ cb, prereqs }),
2022
}));
2123

24+
jest.mock('@edx/frontend-platform/auth', () => ({
25+
getAuthenticatedHttpClient: jest.fn(),
26+
}));
27+
2228
const state = new MockUseState(module);
2329
const moduleKeys = keyStore(module);
2430

@@ -192,30 +198,34 @@ describe('TinyMceEditor hooks', () => {
192198
const initialContent = `<img src="/static/soMEImagEURl1.jpeg"/><a href="/assets/v1/${baseAssetUrl}/test.pdf">test</a><img src="/${baseAssetUrl}@correct.png" /><img src="/${baseAssetUrl}/correct.png" />`;
193199
const learningContextId = 'course-v1:org+test+run';
194200
const lmsEndpointUrl = getConfig().LMS_BASE_URL;
195-
it('returns updated src for text editor to update content', () => {
201+
202+
beforeEach(() => {
203+
jest.clearAllMocks();
204+
});
205+
206+
it('returns updated src for text editor to update content', async () => {
196207
const expected = `<img src="/${baseAssetUrl}@soMEImagEURl1.jpeg"/><a href="/${baseAssetUrl}@test.pdf">test</a><img src="/${baseAssetUrl}@correct.png" /><img src="/${baseAssetUrl}@correct.png" />`;
197-
const actual = module.replaceStaticWithAsset({
208+
const actual = await module.replaceStaticWithAsset({
198209
initialContent,
199210
learningContextId,
200211
validateAssetUrl: false,
201212
});
202213
expect(actual).toEqual(expected);
203214
});
204-
it('returns updated src with absolute url for expandable editor to update content', () => {
205-
const editorType = 'expandable';
215+
it('returns updated src with absolute url for expandable editor to update content', async () => {
206216
const expected = `<img src="${lmsEndpointUrl}/${baseAssetUrl}@soMEImagEURl1.jpeg"/><a href="${lmsEndpointUrl}/${baseAssetUrl}@test.pdf">test</a><img src="${lmsEndpointUrl}/${baseAssetUrl}@correct.png" /><img src="${lmsEndpointUrl}/${baseAssetUrl}@correct.png" />`;
207-
const actual = module.replaceStaticWithAsset({
217+
const actual = await module.replaceStaticWithAsset({
208218
initialContent,
209-
editorType,
219+
editorType: 'expandable',
210220
lmsEndpointUrl,
211221
learningContextId,
212222
validateAssetUrl: false,
213223
});
214224
expect(actual).toEqual(expected);
215225
});
216-
it('returns false when there are no srcs to update', () => {
226+
it('returns false when there are no srcs to update', async () => {
217227
const content = '<div>Hello world!</div>';
218-
const actual = module.replaceStaticWithAsset({ initialContent: content, learningContextId });
228+
const actual = await module.replaceStaticWithAsset({ initialContent: content, learningContextId });
219229
expect(actual).toBeFalsy();
220230
});
221231
it('does not convert static URLs with subdirectories but converts direct static files', () => {
@@ -227,6 +237,79 @@ describe('TinyMceEditor hooks', () => {
227237
});
228238
expect(actual).toEqual(expected);
229239
});
240+
241+
it('replaces multiple static assets in one content string', async () => {
242+
const content = `
243+
<img src="/static/a.png"/>
244+
<img src="/static/b.png"/>
245+
`;
246+
247+
const result = await module.replaceStaticWithAsset({
248+
initialContent: content,
249+
learningContextId,
250+
validateAssetUrl: false,
251+
});
252+
253+
expect(result).toBeTruthy();
254+
});
255+
256+
it('validateAssetUrl success path replaces url', async () => {
257+
getAuthenticatedHttpClient.mockReturnValue({
258+
get: jest.fn(() => Promise.resolve({})),
259+
});
260+
261+
const content = '<img src="/static/test.png"/>';
262+
263+
const result = await module.replaceStaticWithAsset({
264+
initialContent: content,
265+
learningContextId,
266+
validateAssetUrl: true,
267+
});
268+
269+
expect(result).toBeTruthy();
270+
});
271+
272+
it('validateAssetUrl failure path keeps original content', async () => {
273+
getAuthenticatedHttpClient.mockReturnValue({
274+
get: jest.fn(() => Promise.reject(new Error('404'))),
275+
});
276+
277+
const content = '<img src="/static/test.png"/>';
278+
279+
const result = await module.replaceStaticWithAsset({
280+
initialContent: content,
281+
learningContextId,
282+
validateAssetUrl: true,
283+
});
284+
285+
expect(result).toBeFalsy();
286+
});
287+
288+
it('handles library keys correctly', async () => {
289+
jest.spyOn(keyUtils, 'isLibraryKey').mockReturnValue(true);
290+
291+
const content = '<img src="/static/test.png"/>';
292+
293+
const result = await module.replaceStaticWithAsset({
294+
initialContent: content,
295+
learningContextId: 'lib:test',
296+
validateAssetUrl: false,
297+
});
298+
299+
expect(result).toContain('static/test.png');
300+
});
301+
302+
it('returns false when asset already valid and no replacement needed', async () => {
303+
const content = '<img src="/asset-v1:test+org+run+type@[email protected]"/>';
304+
305+
const result = await module.replaceStaticWithAsset({
306+
initialContent: content,
307+
learningContextId,
308+
validateAssetUrl: false,
309+
});
310+
311+
expect(result).toBe(false);
312+
});
230313
});
231314
describe('setAssetToStaticUrl', () => {
232315
it('returns content with updated img links', () => {

src/editors/sharedComponents/TinyMceWidget/hooks.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ export const parseContentForLabels = ({ editor, updateContent }) => {
105105
}
106106
};
107107

108-
export const replaceStaticWithAsset = ({
108+
export const replaceStaticWithAsset = async ({
109109
initialContent,
110110
learningContextId,
111111
editorType,
@@ -118,7 +118,7 @@ export const replaceStaticWithAsset = ({
118118
src => src.startsWith('/static') || src.startsWith('/asset'),
119119
);
120120
if (!isEmpty(srcs)) {
121-
srcs.forEach(async src => {
121+
for (const src of srcs) {
122122
const currentContent = content;
123123
let staticFullUrl;
124124
const isStatic = src.startsWith('/static/');
@@ -160,6 +160,11 @@ export const replaceStaticWithAsset = ({
160160
// check if the asset exists on the server before replacing
161161
try {
162162
if (validateAssetUrl) {
163+
// We intentionally await inside this loop because each replacement
164+
// depends on the progressively updated `content` value.
165+
// Executing the requests in parallel could introduce race conditions
166+
// and produce inconsistent string replacements.
167+
// eslint-disable-next-line no-await-in-loop
163168
await getAuthenticatedHttpClient()
164169
.get(staticFullUrl);
165170
}
@@ -169,7 +174,7 @@ export const replaceStaticWithAsset = ({
169174
content = currentContent;
170175
}
171176
}
172-
});
177+
}
173178
if (hasChanges) { return content; }
174179
}
175180
return false;
@@ -353,9 +358,9 @@ export const setupCustomBehavior = ({
353358
onAction: toggleLabelFormatting,
354359
});
355360
if (editorType === 'expandable') {
356-
editor.on('init', () => {
361+
editor.on('init', async () => {
357362
const initialContent = editor.getContent();
358-
const newContent = replaceStaticWithAsset({
363+
const newContent = await replaceStaticWithAsset({
359364
initialContent,
360365
editorType,
361366
lmsEndpointUrl,
@@ -381,10 +386,11 @@ export const setupCustomBehavior = ({
381386
}
382387
});
383388

384-
editor.on('ExecCommand', /* istanbul ignore next */ (e) => {
389+
editor.on('ExecCommand', /* istanbul ignore next */ async (e) => {
385390
if (editorType === 'text' && e.command === 'mceFocus') {
386391
const initialContent = editor.getContent();
387-
const newContent = replaceStaticWithAsset({
392+
// @ts-ignore Some parameters like 'lmsEndpointUrl' were missing here. Fix me?
393+
const newContent = await replaceStaticWithAsset({
388394
initialContent,
389395
editorType,
390396
lmsEndpointUrl,
Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,24 @@
1-
import { useQuery, useQueryClient } from '@tanstack/react-query';
2-
1+
import { useQuery } from '@tanstack/react-query';
32
import { getCourseDetails } from './api';
43

54
/**
65
* Get the details of a course.
76
*/
87
export const useGetCourseDetails = (courseId?: string) => {
9-
const queryClient = useQueryClient();
10-
118
const {
12-
data, isLoading, isError, refetch,
9+
data, isLoading, isError, refetch, dataUpdatedAt
1310
} = useQuery({
1411
queryKey: ['courseDetails', courseId],
1512
queryFn: () => getCourseDetails(courseId),
13+
enabled: !!courseId,
1614
refetchOnWindowFocus: false,
1715
});
18-
let globalDefaults: { [key: string]: any } | undefined;
19-
if (data === undefined && courseId) {
20-
// If course-specific waffle flags were requested, first default to the
21-
// global (studio-wide) flags until we've loaded the course-specific ones.
22-
globalDefaults = queryClient.getQueryData(['courseDetails', undefined]);
23-
}
2416
return {
25-
...globalDefaults,
2617
...data,
2718
id: courseId,
2819
isLoading,
2920
isError,
3021
refetch,
22+
dataUpdatedAt,
3123
};
3224
};

src/schedule-and-details/hooks.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ const useSaveValuesPrompt = (
5252
if (!isQueryPending && !isEditableState) {
5353
setEditedValues(initialEditedData);
5454
}
55-
}, [initialEditedData.isLoading]);
55+
}, [initialEditedData.dataUpdatedAt]);
5656

5757
useEffect(() => {
5858
const errors = validateScheduleAndDetails(editedValues, canShowCertificateAvailableDateField, intl);

0 commit comments

Comments
 (0)