diff --git a/src/course-outline/CourseOutline.test.tsx b/src/course-outline/CourseOutline.test.tsx index bc97222ff7..80d161ae81 100644 --- a/src/course-outline/CourseOutline.test.tsx +++ b/src/course-outline/CourseOutline.test.tsx @@ -708,7 +708,7 @@ describe('', () => { const { findByLabelText } = renderComponent(); axiosMock - .onPost(getCourseBlockApiUrl(courseId), { + .onPatch(getCourseBlockApiUrl(courseId), { metadata: { video_sharing_options: VIDEO_SHARING_OPTIONS.allOff, }, @@ -721,7 +721,7 @@ describe('', () => { // The video sharing POST is the one with the expected data. // Find it by data content rather than assuming a fixed index. - const videoSharingPost = axiosMock.history.post.find( + const videoSharingPost = axiosMock.history.patch.find( (entry: any) => entry.data && entry.data.includes('video_sharing_options'), ); expect(videoSharingPost).toBeDefined(); @@ -738,7 +738,7 @@ describe('', () => { renderComponent(); axiosMock - .onPost(getCourseBlockApiUrl(courseId), { + .onPatch(getCourseBlockApiUrl(courseId), { metadata: { video_sharing_options: VIDEO_SHARING_OPTIONS.allOff, }, @@ -749,7 +749,7 @@ describe('', () => { async () => fireEvent.change(optionDropdown, { target: { value: VIDEO_SHARING_OPTIONS.allOff } }), ); - const videoSharingPost = axiosMock.history.post.find( + const videoSharingPost = axiosMock.history.patch.find( (entry: any) => entry.data && entry.data.includes('video_sharing_options'), ); expect(videoSharingPost).toBeDefined(); @@ -955,7 +955,6 @@ describe('', () => { ); expect(newUnitPost).toBeDefined(); expect(JSON.parse(newUnitPost!.data)).toEqual({ - type: COURSE_BLOCK_NAMES.vertical.id, category: COURSE_BLOCK_NAMES.vertical.id, parent_locator: subsection.id, display_name: COURSE_BLOCK_NAMES.vertical.name, @@ -1065,7 +1064,7 @@ describe('', () => { axiosMock.reset(); axiosMock - .onPost(getCourseBlockApiUrl(courseId), { + .onPatch(getCourseBlockApiUrl(courseId), { publish: 'republish', metadata: { highlights_enabled_for_messaging: true, @@ -1178,7 +1177,7 @@ describe('', () => { .reply(200, courseLaunchMock); // Rename-specific handlers axiosMock - .onPost(getCourseItemApiUrl(item.id)) + .onPatch(getCourseItemApiUrl(item.id)) .reply(200, { dummy: 'value' }); // mock section, subsection and unit name and check within the elements. @@ -1213,7 +1212,7 @@ describe('', () => { fireEvent.change(editField, { target: { value: newName } }); await user.keyboard('{enter}'); expect( - axiosMock.history.post[axiosMock.history.post.length - 1].data, + axiosMock.history.patch[axiosMock.history.patch.length - 1].data, ).toBe(JSON.stringify({ metadata: { display_name: newName, @@ -1432,7 +1431,7 @@ describe('', () => { ).toHaveTextContent(cardHeaderMessages.statusBadgeDraft.defaultMessage); axiosMock - .onPost(getCourseItemApiUrl(item.id), { + .onPatch(getCourseItemApiUrl(item.id), { publish: 'make_public', }) .reply(200, { dummy: 'value' }); @@ -1500,7 +1499,7 @@ describe('', () => { expect(releaseDatePicker).toHaveValue('08/10/2023'); axiosMock - .onPost(getCourseItemApiUrl(section.id), { + .onPatch(getCourseItemApiUrl(section.id), { publish: 'republish', metadata: { visible_to_staff_only: true, @@ -1521,7 +1520,7 @@ describe('', () => { const saveButton = await findByTestId('configure-save-button'); await act(async () => fireEvent.click(saveButton)); - const sectionCfgPost = axiosMock.history.post.find( + const sectionCfgPost = axiosMock.history.patch.find( (entry: any) => entry.data && entry.data.includes('visible_to_staff_only'), ); expect(sectionCfgPost).toBeDefined(); @@ -1571,7 +1570,7 @@ describe('', () => { }; axiosMock - .onPost(getCourseItemApiUrl(subsection.id), expectedRequestData) + .onPatch(getCourseItemApiUrl(subsection.id), expectedRequestData) .reply(200, { dummy: 'value' }); const [currentSection] = await findAllByTestId('section-card'); @@ -1630,7 +1629,7 @@ describe('', () => { await user.click(saveButton); // verify request - const subCfgPost = axiosMock.history.post.find( + const subCfgPost = axiosMock.history.patch.find( (entry: any) => entry.data && entry.data.includes(expectedRequestData.graderType), ); expect(subCfgPost).toBeDefined(); @@ -1698,7 +1697,7 @@ describe('', () => { }; axiosMock - .onPost(getCourseItemApiUrl(subsection.id), expectedRequestData) + .onPatch(getCourseItemApiUrl(subsection.id), expectedRequestData) .reply(200, { dummy: 'value' }); const [currentSection] = await findAllByTestId('section-card'); @@ -1775,7 +1774,7 @@ describe('', () => { await user.click(saveButton); // verify request - const cfgPosta = axiosMock.history.post.find( + const cfgPosta = axiosMock.history.patch.find( (entry: any) => entry.data && entry.data.includes(expectedRequestData.graderType), ); expect(cfgPosta).toBeDefined(); @@ -1849,7 +1848,7 @@ describe('', () => { }; axiosMock - .onPost(getCourseItemApiUrl(subsection.id), expectedRequestData) + .onPatch(getCourseItemApiUrl(subsection.id), expectedRequestData) .reply(200, { dummy: 'value' }); const [currentSection] = await findAllByTestId('section-card'); @@ -1904,7 +1903,7 @@ describe('', () => { await user.click(saveButton); // verify request - const cfgPostb = axiosMock.history.post.find( + const cfgPostb = axiosMock.history.patch.find( (entry: any) => entry.data && entry.data.includes(expectedRequestData.graderType), ); expect(cfgPostb).toBeDefined(); @@ -1960,7 +1959,7 @@ describe('', () => { }; axiosMock - .onPost(getCourseItemApiUrl(subsection.id), expectedRequestData) + .onPatch(getCourseItemApiUrl(subsection.id), expectedRequestData) .reply(200, { dummy: 'value' }); const [currentSection] = await findAllByTestId('section-card'); @@ -2016,7 +2015,7 @@ describe('', () => { await user.click(saveButton); // verify request - const cfgPostc = axiosMock.history.post.find( + const cfgPostc = axiosMock.history.patch.find( (entry: any) => entry.data && entry.data.includes(expectedRequestData.graderType), ); expect(cfgPostc).toBeDefined(); @@ -2071,7 +2070,7 @@ describe('', () => { }; axiosMock - .onPost(getCourseItemApiUrl(subsection.id), expectedRequestData) + .onPatch(getCourseItemApiUrl(subsection.id), expectedRequestData) .reply(200, { dummy: 'value' }); const [, currentSection] = await findAllByTestId('section-card'); @@ -2125,7 +2124,7 @@ describe('', () => { await user.click(saveButton); // verify request - const noSpecialPost = axiosMock.history.post.find( + const noSpecialPost = axiosMock.history.patch.find( (entry: any) => entry.data && entry.data.includes(expectedRequestData.graderType), ); expect(noSpecialPost).toBeDefined(); @@ -2169,7 +2168,7 @@ describe('', () => { const isVisibleToStaffOnly = true; axiosMock - .onPost(getCourseItemApiUrl(unit.id), { + .onPatch(getCourseItemApiUrl(unit.id), { publish: 'republish', metadata: { visible_to_staff_only: isVisibleToStaffOnly, @@ -2297,7 +2296,7 @@ describe('', () => { ]; axiosMock - .onPost(getCourseItemApiUrl(section.id), { + .onPatch(getCourseItemApiUrl(section.id), { publish: 'republish', metadata: { highlights, diff --git a/src/course-outline/data/api.ts b/src/course-outline/data/api.ts index 4a95b4d19b..40ecacd0c5 100644 --- a/src/course-outline/data/api.ts +++ b/src/course-outline/data/api.ts @@ -46,13 +46,19 @@ export const getCourseLaunchApiUrl = ({ export const getCourseBlockApiUrl = (courseId: string) => { const formattedCourseId = courseIDtoBlockID(courseId); - return `${getApiBaseUrl()}/xblock/${formattedCourseId}`; + // FC-0118: v1 xblock REST detail URL for the course's root block. + return `${getApiBaseUrl()}/api/contentstore/v1/xblock/${formattedCourseId}/`; }; export const getCourseReindexApiUrl = (reindexLink: string) => `${getApiBaseUrl()}${reindexLink}`; -export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`; -export const getCourseItemApiUrl = (itemId: string) => `${getXBlockBaseApiUrl()}${itemId}`; -export const getXBlockApiUrl = (blockId: string) => `${getXBlockBaseApiUrl()}outline/${blockId}`; +// FC-0118 (ADR 0028/0037): standardized v1 xblock REST endpoint. Collection URL +// (POST here creates a block); detail URLs carry the trailing slash the DRF +// DefaultRouter requires. +export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v1/xblock/`; +export const getCourseItemApiUrl = (itemId: string) => `${getXBlockBaseApiUrl()}${itemId}/`; +// The outline handler (`/xblock/outline/{id}`) is a separate legacy Studio route, +// NOT part of the v1 REST viewset, so it keeps the legacy `/xblock/` base. +export const getXBlockApiUrl = (blockId: string) => `${getApiBaseUrl()}/xblock/outline/${blockId}`; export const exportTags = (courseId: string) => `${getApiBaseUrl()}/api/content_tagging/v1/object_tags/${courseId}/export/`; export const createDiscussionsTopicsUrl = (courseId: string) => @@ -163,7 +169,7 @@ export async function getCourseLaunch({ */ export async function enableCourseHighlightsEmails(courseId: string): Promise { const { data } = await getAuthenticatedHttpClient() - .post(getCourseBlockApiUrl(courseId), { + .patch(getCourseBlockApiUrl(courseId), { publish: 'republish', metadata: { highlights_enabled_for_messaging: true, @@ -205,7 +211,7 @@ export async function updateCourseSectionHighlights( highlights: Array, ): Promise { const { data } = await getAuthenticatedHttpClient() - .post(getCourseItemApiUrl(sectionId), { + .patch(getCourseItemApiUrl(sectionId), { publish: 'republish', metadata: { highlights, @@ -220,7 +226,7 @@ export async function updateCourseSectionHighlights( */ export async function publishCourseItem(itemId: string): Promise { const { data } = await getAuthenticatedHttpClient() - .post(getCourseItemApiUrl(itemId), { + .patch(getCourseItemApiUrl(itemId), { publish: 'make_public', }); @@ -232,7 +238,7 @@ export async function publishCourseItem(itemId: string): Promise { const { data } = await getAuthenticatedHttpClient() - .post(getCourseItemApiUrl(variables.sectionId), { + .patch(getCourseItemApiUrl(variables.sectionId), { publish: 'republish', metadata: { // The backend expects metadata.visible_to_staff_only to either true or null @@ -304,7 +310,7 @@ export async function configureCourseSubsection( }); const { data } = await getAuthenticatedHttpClient() - .post(getCourseItemApiUrl(itemId), body); + .patch(getCourseItemApiUrl(itemId), body); return data; } @@ -329,7 +335,8 @@ export async function configureCourseUnit(variables: ConfigureUnitData): Promise }; const url = getCourseItemApiUrl(variables.unitId); const { data } = await getAuthenticatedHttpClient() - .post(url, body); + // FC-0118: legacy POST-as-update becomes PATCH (partial_update) on v1. + .patch(url, body); return camelCaseObject(data); } @@ -342,7 +349,7 @@ export async function editItemDisplayName({ itemId, displayName }: { displayName: string; }): Promise { const { data } = await getAuthenticatedHttpClient() - .post(getCourseItemApiUrl(itemId), { + .patch(getCourseItemApiUrl(itemId), { metadata: { display_name: displayName, }, @@ -405,7 +412,9 @@ export async function createCourseXblock({ libraryContentKey, }: CreateCourseXBlockType) { const body = { - type, + // FC-0118: `type` is intentionally not sent — the v1 strict serializer does + // not accept it and the backend only reads `category` (which falls back to + // `type` here). `library_content_key` is accepted by the v1 serializer. boilerplate, category: category || type, parent_locator: parentLocator, @@ -461,7 +470,7 @@ export async function setVideoSharingOption( videoSharingOption: string, ): Promise { const { data } = await getAuthenticatedHttpClient() - .post(getCourseBlockApiUrl(courseId), { + .patch(getCourseBlockApiUrl(courseId), { metadata: { video_sharing_options: videoSharingOption, }, diff --git a/src/course-outline/outline-sidebar/AddSidebar.test.tsx b/src/course-outline/outline-sidebar/AddSidebar.test.tsx index 7d988cedc1..c2635a9240 100644 --- a/src/course-outline/outline-sidebar/AddSidebar.test.tsx +++ b/src/course-outline/outline-sidebar/AddSidebar.test.tsx @@ -298,21 +298,18 @@ describe('AddSidebar', () => { const unit = await screen.findByRole('button', { name: 'Unit' }); await user.click(section); expect(axiosMock.history.post[0].data).toEqual(JSON.stringify(snakeCaseKeys({ - type: 'chapter', category: 'chapter', parentLocator: 'block-v1:UNIX+UX1+2025_T3+type@course+block@course', displayName: 'Section', }))); await user.click(subsection); expect(axiosMock.history.post[1].data).toEqual(JSON.stringify(snakeCaseKeys({ - type: 'sequential', category: 'sequential', parentLocator: lastSection.id, displayName: 'Subsection', }))); await user.click(unit); expect(axiosMock.history.post[2].data).toEqual(JSON.stringify(snakeCaseKeys({ - type: 'vertical', category: 'vertical', parentLocator: lastSubsection.id, displayName: 'Unit', @@ -337,14 +334,12 @@ describe('AddSidebar', () => { await waitFor(() => expect(axiosMock.history.post.length).toBeGreaterThan(1)); // should add a section first expect(axiosMock.history.post[0].data).toEqual(JSON.stringify(snakeCaseKeys({ - type: 'chapter', category: 'chapter', parentLocator: 'block-v1:UNIX+UX1+2025_T3+type@course+block@course', displayName: 'Section', }))); // then subsection expect(axiosMock.history.post[1].data).toEqual(JSON.stringify(snakeCaseKeys({ - type: 'sequential', category: 'sequential', parentLocator: sectionId, displayName: 'Subsection', @@ -365,19 +360,16 @@ describe('AddSidebar', () => { const subsectionId = 'block-v1:UNIX+UX1+2025_T3+type@sequential+block@sequential234'; const unitId = 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@vertical2133'; const sectionBody = snakeCaseKeys({ - type: 'chapter', category: 'chapter', parentLocator: 'block-v1:UNIX+UX1+2025_T3+type@course+block@course', displayName: 'Section', }); const subsectionBody = snakeCaseKeys({ - type: 'sequential', category: 'sequential', parentLocator: sectionId, displayName: 'Subsection', }); const unitBody = snakeCaseKeys({ - type: 'vertical', category: 'vertical', parentLocator: subsectionId, displayName: 'Unit', @@ -421,7 +413,6 @@ describe('AddSidebar', () => { // first one is unit as per mock await user.click(addBtns[0]); expect(axiosMock.history.post[0].data).toEqual(JSON.stringify(snakeCaseKeys({ - type: 'library_v2', category: 'vertical', parentLocator: lastSubsection.id, libraryContentKey: searchResult.results[0].hits[0].usage_key, @@ -429,7 +420,6 @@ describe('AddSidebar', () => { // second one is subsection as per mock await user.click(addBtns[1]); expect(axiosMock.history.post[1].data).toEqual(JSON.stringify(snakeCaseKeys({ - type: 'library_v2', category: 'sequential', parentLocator: lastSection.id, libraryContentKey: searchResult.results[0].hits[1].usage_key, @@ -437,7 +427,6 @@ describe('AddSidebar', () => { // third one is section as per mock await user.click(addBtns[2]); expect(axiosMock.history.post[2].data).toEqual(JSON.stringify(snakeCaseKeys({ - type: 'library_v2', category: 'chapter', parentLocator: 'block-v1:UNIX+UX1+2025_T3+type@course+block@course', libraryContentKey: searchResult.results[0].hits[2].usage_key, diff --git a/src/course-unit/CourseUnit.test.tsx b/src/course-unit/CourseUnit.test.tsx index c3073affe7..1453b5610d 100644 --- a/src/course-unit/CourseUnit.test.tsx +++ b/src/course-unit/CourseUnit.test.tsx @@ -391,7 +391,7 @@ describe('', () => { await user.click(deleteButton); axiosMock - .onPost(getXBlockBaseApiUrl(blockId), { + .onPatch(getXBlockBaseApiUrl(blockId), { publish: PUBLISH_TYPES.makePublic, }) .reply(200, { dummy: 'value' }); @@ -553,7 +553,7 @@ describe('', () => { .replyOnce(200, { locator: '1234567890' }); axiosMock - .onPost(getCourseItemApiUrl(blockId), { + .onPatch(getCourseItemApiUrl(blockId), { publish: PUBLISH_TYPES.makePublic, }) .reply(200, { dummy: 'value' }); @@ -677,7 +677,7 @@ describe('', () => { const newDisplayName = `${unitDisplayName} new`; axiosMock - .onPost(getXBlockBaseApiUrl(blockId), { + .onPatch(getXBlockBaseApiUrl(blockId), { metadata: { display_name: newDisplayName, }, @@ -734,7 +734,7 @@ describe('', () => { const user = userEvent.setup(); const { courseKey, locator } = courseCreateXblockMock; axiosMock - .onPost(postXBlockBaseApiUrl(), { type: 'video', category: 'video', parent_locator: blockId }) + .onPost(postXBlockBaseApiUrl(), { category: 'video', parent_locator: blockId }) .reply(500, {}); render(); @@ -749,12 +749,12 @@ describe('', () => { it('handle creating Problem xblock and showing editor modal', async () => { const user = userEvent.setup(); axiosMock - .onPost(postXBlockBaseApiUrl(), { type: 'problem', category: 'problem', parent_locator: blockId }) + .onPost(postXBlockBaseApiUrl(), { category: 'problem', parent_locator: blockId }) .reply(200, courseCreateXblockMock); render(); axiosMock - .onPost(getXBlockBaseApiUrl(blockId), { + .onPatch(getXBlockBaseApiUrl(blockId), { publish: PUBLISH_TYPES.makePublic, }) .reply(200, { dummy: 'value' }); @@ -914,7 +914,7 @@ describe('', () => { const newDisplayName = `${unitDisplayName} new`; axiosMock - .onPost(getXBlockBaseApiUrl(blockId), { + .onPatch(getXBlockBaseApiUrl(blockId), { metadata: { display_name: newDisplayName, }, @@ -963,7 +963,7 @@ describe('', () => { const waffleSpy = mockWaffleFlags({ useVideoGalleryFlow: true }); axiosMock - .onPost(postXBlockBaseApiUrl(), { type: 'video', category: 'video', parent_locator: blockId }) + .onPost(postXBlockBaseApiUrl(), { category: 'video', parent_locator: blockId }) .reply(200, courseCreateXblockMock); render(); @@ -973,7 +973,7 @@ describe('', () => { await user.click(publishButton); axiosMock - .onPost(getXBlockBaseApiUrl(blockId), { + .onPatch(getXBlockBaseApiUrl(blockId), { publish: PUBLISH_TYPES.makePublic, }) .reply(200, { dummy: 'value' }); @@ -1054,7 +1054,7 @@ describe('', () => { it('handles creating Video xblock and showing editor modal', async () => { axiosMock - .onPost(postXBlockBaseApiUrl(), { type: 'video', category: 'video', parent_locator: blockId }) + .onPost(postXBlockBaseApiUrl(), { category: 'video', parent_locator: blockId }) .reply(200, courseCreateXblockMock); const user = userEvent.setup(); render(); @@ -1064,7 +1064,7 @@ describe('', () => { ); axiosMock - .onPost(getXBlockBaseApiUrl(blockId), { + .onPatch(getXBlockBaseApiUrl(blockId), { publish: PUBLISH_TYPES.makePublic, }) .reply(200, { dummy: 'value' }); @@ -1247,7 +1247,7 @@ describe('', () => { expect(visibilityCheckbox).not.toBeChecked(); axiosMock - .onPost(getXBlockBaseApiUrl(blockId), { + .onPatch(getXBlockBaseApiUrl(blockId), { publish: PUBLISH_TYPES.republish, metadata: { visible_to_staff_only: true }, }) @@ -1302,7 +1302,7 @@ describe('', () => { await user.click(makeVisibilityBtn); axiosMock - .onPost(getXBlockBaseApiUrl(blockId), { + .onPatch(getXBlockBaseApiUrl(blockId), { publish: PUBLISH_TYPES.republish, metadata: { visible_to_staff_only: null }, }) @@ -1346,7 +1346,7 @@ describe('', () => { }); axiosMock - .onPost(getXBlockBaseApiUrl(blockId), { + .onPatch(getXBlockBaseApiUrl(blockId), { publish: PUBLISH_TYPES.makePublic, }) .reply(200, { dummy: 'value' }); @@ -1428,7 +1428,7 @@ describe('', () => { }); axiosMock - .onPost(getXBlockBaseApiUrl(blockId), { + .onPatch(getXBlockBaseApiUrl(blockId), { publish: PUBLISH_TYPES.discardChanges, }) .reply(200, { dummy: 'value' }); @@ -1505,7 +1505,7 @@ describe('', () => { expect(group1Checkbox).toBeChecked(); axiosMock - .onPost(getXBlockBaseApiUrl(courseSectionVerticalMock.xblock_info.id), { + .onPatch(getXBlockBaseApiUrl(courseSectionVerticalMock.xblock_info.id), { publish: 'republish', metadata: { visible_to_staff_only: true, group_access: { 50: [2] }, discussion_enabled: true }, }) @@ -1996,7 +1996,7 @@ describe('', () => { render(); axiosMock - .onPatch(postXBlockBaseApiUrl()) + .onPatch(getXBlockBaseApiUrl(blockId)) .reply(200, {}); axiosMock @@ -2060,7 +2060,7 @@ describe('', () => { render(); axiosMock - .onPatch(postXBlockBaseApiUrl()) + .onPatch(getXBlockBaseApiUrl(blockId)) .reply(200, {}); await executeThunk( @@ -2114,7 +2114,7 @@ describe('', () => { render(); axiosMock - .onPatch(postXBlockBaseApiUrl()) + .onPatch(getXBlockBaseApiUrl(blockId)) .reply(200, {}); await executeThunk( @@ -2197,7 +2197,7 @@ describe('', () => { it('handles submit xblock restrict access data when save button is clicked', async () => { const user = userEvent.setup(); axiosMock - .onPost(getXBlockBaseApiUrl(id), { + .onPatch(getXBlockBaseApiUrl(id), { publish: PUBLISH_TYPES.republish, metadata: { visible_to_staff_only: false, group_access: { 970807507: [1959537066] } }, }) @@ -2251,8 +2251,8 @@ describe('', () => { await user.click(saveModalBtnText); await waitFor(() => { - expect(axiosMock.history.post.length).toBeGreaterThan(0); - expect(axiosMock.history.post[0].url).toBe(getXBlockBaseApiUrl(id)); + expect(axiosMock.history.patch.length).toBeGreaterThan(0); + expect(axiosMock.history.patch[0].url).toBe(getXBlockBaseApiUrl(id)); }); expect(screen.queryByTestId('configure-modal')).not.toBeInTheDocument(); @@ -2698,7 +2698,7 @@ describe('', () => { await user.click(settingsTab); axiosMock - .onPost(getXBlockBaseApiUrl(blockId), { + .onPatch(getXBlockBaseApiUrl(blockId), { publish: PUBLISH_TYPES.republish, metadata: { visible_to_staff_only: true, discussion_enabled: true }, }) @@ -2730,7 +2730,7 @@ describe('', () => { expect(await screen.findByRole('heading', { name: /visible to staff only/i })).toBeInTheDocument(); axiosMock - .onPost(getXBlockBaseApiUrl(blockId), { + .onPatch(getXBlockBaseApiUrl(blockId), { publish: PUBLISH_TYPES.republish, metadata: { visible_to_staff_only: null, discussion_enabled: true }, }) @@ -2798,7 +2798,7 @@ describe('', () => { await user.click(settingsTab); axiosMock - .onPost(getXBlockBaseApiUrl(blockId), { + .onPatch(getXBlockBaseApiUrl(blockId), { publish: PUBLISH_TYPES.republish, metadata: { visible_to_staff_only: null, @@ -2839,7 +2839,7 @@ describe('', () => { ...courseSectionVerticalMock, }); axiosMock - .onPost(getXBlockBaseApiUrl(courseSectionVerticalMock.xblock_info.id)) + .onPatch(getXBlockBaseApiUrl(courseSectionVerticalMock.xblock_info.id)) .reply(200, { ...courseSectionVerticalMock, }); @@ -2898,11 +2898,11 @@ describe('', () => { // Check that the group access is being updated await waitFor(() => { - expect(axiosMock.history.post.length).toBeGreaterThan(0); + expect(axiosMock.history.patch.length).toBeGreaterThan(0); }); - expect(axiosMock.history.post[0].url).toBe(getXBlockBaseApiUrl(courseSectionVerticalMock.xblock_info.id)); - expect(axiosMock.history.post[0].data).toMatch(/"group_access":\{"10":\[1,2\]\}/); + expect(axiosMock.history.patch[0].url).toBe(getXBlockBaseApiUrl(courseSectionVerticalMock.xblock_info.id)); + expect(axiosMock.history.patch[0].data).toMatch(/"group_access":\{"10":\[1,2\]\}/); }); it('should one group in the visibility field in the unit sidebar', async () => { @@ -3360,7 +3360,6 @@ describe('', () => { expect(JSON.parse(axiosMock.history.post[0].data)).toMatchObject({ category: blockType, parent_locator: blockId, - type: blockType, }); }); }); @@ -3432,7 +3431,6 @@ describe('', () => { category: blockType, parent_locator: blockId, boilerplate: template.boilerplate, - ...(blockType !== 'openassessment' ? { type: blockType } : {}), }); }); }); @@ -3464,7 +3462,6 @@ describe('', () => { expect(JSON.parse(axiosMock.history.post[0].data)).toMatchObject({ category: blockType, parent_locator: blockId, - type: blockType, }); }); }); @@ -3489,7 +3486,6 @@ describe('', () => { category: 'html', parent_locator: blockId, library_content_key: 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd', - type: 'library_v2', }); }); }); diff --git a/src/course-unit/data/api.ts b/src/course-unit/data/api.ts index 609c4715a1..aa50547035 100644 --- a/src/course-unit/data/api.ts +++ b/src/course-unit/data/api.ts @@ -6,13 +6,18 @@ import { isUnitImportedFromLib, normalizeCourseSectionVerticalData, updateXBlock const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL; -export const getXBlockBaseApiUrl = (itemId: string) => `${getStudioBaseUrl()}/xblock/${itemId}`; +// FC-0118 (ADR 0028/0037): standardized v1 xblock REST endpoint. The trailing +// slash is required by the DRF DefaultRouter that serves the v1 XblockViewSet +// (the legacy `/xblock/{id}` route had none). Detail actions: GET (retrieve), +// PUT/PATCH (update), DELETE (destroy). +export const getXBlockBaseApiUrl = (itemId: string) => `${getStudioBaseUrl()}/api/contentstore/v1/xblock/${itemId}/`; export const getCourseSectionVerticalApiUrl = (itemId: string) => `${getStudioBaseUrl()}/api/contentstore/v1/container_handler/${itemId}`; export const getCourseVerticalChildrenApiUrl = (itemId: string, getUpstreamInfo: boolean = false) => `${getStudioBaseUrl()}/api/contentstore/v1/container/${itemId}/children?get_upstream_info=${getUpstreamInfo}`; export const getCourseOutlineInfoUrl = (courseId: string) => `${getStudioBaseUrl()}/course/${courseId}?format=concise`; -export const postXBlockBaseApiUrl = () => `${getStudioBaseUrl()}/xblock/`; +// FC-0118: v1 xblock collection endpoint — POST here creates a block. +export const postXBlockBaseApiUrl = () => `${getStudioBaseUrl()}/api/contentstore/v1/xblock/`; export const libraryBlockChangesUrl = (blockId: string) => `${getStudioBaseUrl()}/api/contentstore/v2/downstreams/${blockId}/sync`; @@ -21,7 +26,8 @@ export const libraryBlockChangesUrl = (blockId: string) => */ export async function editUnitDisplayName(unitId: string, displayName: string): Promise { const { data } = await getAuthenticatedHttpClient() - .post(getXBlockBaseApiUrl(unitId), { + // FC-0118: legacy POST-as-update becomes PATCH (partial_update) on v1. + .patch(getXBlockBaseApiUrl(unitId), { metadata: { display_name: displayName, }, @@ -94,8 +100,13 @@ export async function getCourseOutlineInfo(courseId: string): Promise { + // FC-0118: the move used to PATCH the legacy collection URL (`/xblock/`); the + // v1 REST viewset only exposes PATCH on the detail route, so we PATCH the + // source block's detail URL. `update_xblock_response` still routes to the + // move-item path off `move_source_locator` in the body (the URL key is used + // only to derive the course for the permission check). const { data } = await getAuthenticatedHttpClient() - .patch(postXBlockBaseApiUrl(), { + .patch(getXBlockBaseApiUrl(sourceLocator), { parent_locator: targetParentLocator, move_source_locator: sourceLocator, }); diff --git a/src/course-updates/data/api.js b/src/course-updates/data/api.js index 4a528a506d..20ae08811e 100644 --- a/src/course-updates/data/api.js +++ b/src/course-updates/data/api.js @@ -7,7 +7,10 @@ export const updateCourseUpdatesApiUrl = (courseId, updateId) => `${getApiBaseUrl()}/course_info_update/${courseId}/${updateId}`; export const getCourseHandoutApiUrl = (courseId) => { const formattedCourseId = courseId.split('course-v1:')[1]; - return `${getApiBaseUrl()}/xblock/block-v1:${formattedCourseId}+type@course_info+block@handouts`; + // FC-0118 (ADR 0028/0037): standardized v1 xblock REST detail endpoint for the + // course handouts block. Trailing slash required by the DRF DefaultRouter; + // used by GET (retrieve) and PUT (full update). + return `${getApiBaseUrl()}/api/contentstore/v1/xblock/block-v1:${formattedCourseId}+type@course_info+block@handouts/`; }; /** diff --git a/src/custom-pages/CustomPages.test.tsx b/src/custom-pages/CustomPages.test.tsx index 3fcd850422..57d8e2e488 100644 --- a/src/custom-pages/CustomPages.test.tsx +++ b/src/custom-pages/CustomPages.test.tsx @@ -110,9 +110,9 @@ describe('CustomPages', () => { }); it('should add new page when "add a new page button" is clicked', async () => { - const xblockAddUrl = `${getApiBaseUrl()}/xblock/`; + const xblockAddUrl = `${getApiBaseUrl()}/api/contentstore/v1/xblock/`; axiosMock.onGet(getTabHandlerUrl(courseId)).reply(200, generateFetchPageApiResponse()); - axiosMock.onPut(xblockAddUrl).reply(200, generateNewPageApiResponse()); + axiosMock.onPost(xblockAddUrl).reply(200, generateNewPageApiResponse()); renderComponent(); const addButton = await screen.findByTestId('body-add-button'); @@ -121,7 +121,7 @@ describe('CustomPages', () => { fireEvent.click(addButton); await waitFor(() => { - expect(axiosMock.history.put.length).toBeGreaterThanOrEqual(1); + expect(axiosMock.history.post.length).toBeGreaterThanOrEqual(1); }); }); @@ -166,7 +166,7 @@ describe('CustomPages', () => { it('should delete page on successful delete', async () => { const pageId = 'mOckID1'; - const xblockEditUrl = `${getApiBaseUrl()}/xblock/${pageId}`; + const xblockEditUrl = `${getApiBaseUrl()}/api/contentstore/v1/xblock/${pageId}/`; axiosMock.onGet(getTabHandlerUrl(courseId)).reply(200, generateFetchPageApiResponse()); axiosMock.onDelete(xblockEditUrl).reply(204); @@ -182,7 +182,7 @@ describe('CustomPages', () => { it('should show error alert on delete failure', async () => { const pageId = 'mOckID1'; - const xblockEditUrl = `${getApiBaseUrl()}/xblock/${pageId}`; + const xblockEditUrl = `${getApiBaseUrl()}/api/contentstore/v1/xblock/${pageId}/`; axiosMock.onGet(getTabHandlerUrl(courseId)).reply(200, generateFetchPageApiResponse()); axiosMock.onDelete(xblockEditUrl).reply(500); @@ -250,7 +250,7 @@ describe('CustomPages', () => { it('should save visibility on successful toggle', async () => { const pageId = 'mOckID1'; - const xblockEditUrl = `${getApiBaseUrl()}/xblock/${pageId}`; + const xblockEditUrl = `${getApiBaseUrl()}/api/contentstore/v1/xblock/${pageId}/`; axiosMock.onGet(getTabHandlerUrl(courseId)).reply(200, generateFetchPageApiResponse()); axiosMock.onPut(xblockEditUrl).reply(200, {}); @@ -265,7 +265,7 @@ describe('CustomPages', () => { it('should show error alert on visibility save failure', async () => { const pageId = 'mOckID1'; - const xblockEditUrl = `${getApiBaseUrl()}/xblock/${pageId}`; + const xblockEditUrl = `${getApiBaseUrl()}/api/contentstore/v1/xblock/${pageId}/`; axiosMock.onGet(getTabHandlerUrl(courseId)).reply(200, generateFetchPageApiResponse()); axiosMock.onPut(xblockEditUrl).reply(500); diff --git a/src/custom-pages/data/api.ts b/src/custom-pages/data/api.ts index 2381e90912..431ad65109 100644 --- a/src/custom-pages/data/api.ts +++ b/src/custom-pages/data/api.ts @@ -29,8 +29,10 @@ export async function getCustomPages(courseId: string): Promise { * Delete custom page for provided block. */ export async function deleteCustomPage(blockId: string): Promise { + // FC-0118 (ADR 0028/0037): standardized v1 xblock REST endpoint (trailing + // slash required by the DRF DefaultRouter). await getAuthenticatedHttpClient() - .delete(`${getApiBaseUrl()}/xblock/${blockId}`); + .delete(`${getApiBaseUrl()}/api/contentstore/v1/xblock/${blockId}/`); } /** @@ -40,7 +42,9 @@ export async function addCustomPage(courseId: string): Promise; }): Promise> { const { data } = await getAuthenticatedHttpClient() - .put(`${getApiBaseUrl()}/xblock/${blockId}`, { + // FC-0118: PUT (full update) on the v1 detail endpoint (trailing slash). + .put(`${getApiBaseUrl()}/api/contentstore/v1/xblock/${blockId}/`, { id: blockId, data: htmlString, metadata, diff --git a/src/editors/containers/PdfEditor/index.test.tsx b/src/editors/containers/PdfEditor/index.test.tsx index 5e26a8dc70..bb1fdf064f 100644 --- a/src/editors/containers/PdfEditor/index.test.tsx +++ b/src/editors/containers/PdfEditor/index.test.tsx @@ -115,7 +115,7 @@ describe('PdfEditor', () => { expect(screen.queryAllByLabelText(downloadMessages.allowDownloadLabel.defaultMessage)).toEqual([]); }); it('submits changes to the cms', async () => { - axiosMock.onPost('https://studio.local/xblock/pdf-block-id').reply(200, { + axiosMock.onPatch('https://studio.local/api/contentstore/v1/xblock/pdf-block-id/').reply(200, { data: { id: 'pdf-block-id', data: null, @@ -136,8 +136,10 @@ describe('PdfEditor', () => { fireEvent.change(field, { target: { value: 'https://somewhere.com/stuff.pdf' } }); const saveButton = screen.getByLabelText(editorMessages.saveButtonAriaLabel.defaultMessage); await user.click(saveButton); - await waitFor(() => expect(axiosMock.history.post[0].url).toEqual('https://studio.local/xblock/pdf-block-id')); - const request = axiosMock.history.post[0]; + await waitFor(() => + expect(axiosMock.history.patch[0].url).toEqual('https://studio.local/api/contentstore/v1/xblock/pdf-block-id/') + ); + const request = axiosMock.history.patch[0]; expect(JSON.parse(request.data).metadata.url).toEqual('https://somewhere.com/stuff.pdf'); }); }); diff --git a/src/editors/data/services/cms/api.test.ts b/src/editors/data/services/cms/api.test.ts index 45a4bb4806..4f9c1bdbb8 100644 --- a/src/editors/data/services/cms/api.test.ts +++ b/src/editors/data/services/cms/api.test.ts @@ -4,11 +4,13 @@ import { get, post, put, + patch, deleteObject, } from './utils'; jest.mock('./urls', () => ({ block: jest.fn().mockReturnValue('urls.block'), + blockV1: jest.fn().mockReturnValue('urls.blockV1'), blockAncestor: jest.fn().mockReturnValue('urls.blockAncestor'), blockStudioView: jest.fn().mockReturnValue('urls.StudioView'), courseAssets: jest.fn().mockReturnValue('urls.courseAssets'), @@ -34,6 +36,7 @@ jest.mock('./utils', () => ({ get: jest.fn().mockName('get'), post: jest.fn().mockName('post'), put: jest.fn().mockName('put'), + patch: jest.fn().mockName('patch'), deleteObject: jest.fn().mockName('deleteObject'), })); @@ -53,7 +56,7 @@ describe('cms api', () => { describe('fetchBlockId', () => { it('should call get with url.blocks', async () => { await apiMethods.fetchBlockById({ blockId, studioEndpointUrl }); - expect(get).toHaveBeenCalledWith(urls.block({ blockId, studioEndpointUrl })); + expect(get).toHaveBeenCalledWith(urls.blockV1({ blockId, studioEndpointUrl })); }); }); @@ -175,11 +178,9 @@ describe('cms api', () => { blockId, blockType: 'html', content, - learningContextId, title, })).toEqual({ category: 'html', - courseKey: learningContextId, data: content, has_changes: true, id: blockId, @@ -221,11 +222,9 @@ describe('cms api', () => { blockId, blockType: 'video', content, - learningContextId, title, })).toEqual({ category: 'video', - courseKey: learningContextId, display_name: title, id: blockId, metadata: { @@ -259,22 +258,20 @@ describe('cms api', () => { describe('saveBlock', () => { const content = 'Im baby palo santo ugh celiac fashion axe. La croix lo-fi venmo whatever. Beard man braid migas single-origin coffee forage ramps.'; - it('should call post with urls.block and normalizeContent', async () => { + it('should call patch with urls.blockV1 and normalizeContent', async () => { await apiMethods.saveBlock({ blockId, blockType: 'html', content, - learningContextId, studioEndpointUrl, title, }); - expect(post).toHaveBeenCalledWith( - urls.block({ studioEndpointUrl }), + expect(patch).toHaveBeenCalledWith( + urls.blockV1({ studioEndpointUrl, blockId }), apiMethods.normalizeContent({ blockType: 'html', content, blockId, - learningContextId, title, }), ); diff --git a/src/editors/data/services/cms/api.ts b/src/editors/data/services/cms/api.ts index dc33a8b9ec..dd9485f11b 100644 --- a/src/editors/data/services/cms/api.ts +++ b/src/editors/data/services/cms/api.ts @@ -6,6 +6,7 @@ import { get, post, put, + patch, deleteObject, } from './utils'; import { durationStringFromValue } from '../../../containers/VideoEditor/components/VideoSettingsModal/components/DurationWidget/hooks'; @@ -113,7 +114,7 @@ export const processLicense = (licenseType, licenseDetails) => { export const apiMethods = { fetchBlockById: ({ blockId, studioEndpointUrl }): Promise<{ data: FieldsResponse; }> => get( - urls.block({ blockId, studioEndpointUrl }), + urls.blockV1({ blockId, studioEndpointUrl }), ), /** A better name for this would be 'get ancestors of block' */ fetchByUnitId: ({ blockId, studioEndpointUrl }): Promise<{ data: AncestorsResponse; }> => @@ -301,20 +302,17 @@ export const apiMethods = { blockId, blockType, content, - learningContextId, title, }: { blockId: string; blockType: string; content: any; // string for 'html' blocks, otherwise Record - learningContextId: string; title: string; }) => { let response = {}; if (blockType === 'html') { response = { category: blockType, - courseKey: learningContextId, data: content, has_changes: true, id: blockId, @@ -324,7 +322,6 @@ export const apiMethods = { response = { data: content.olx, category: blockType, - courseKey: learningContextId, has_changes: true, id: blockId, metadata: { display_name: title, ...content.settings }, @@ -341,7 +338,6 @@ export const apiMethods = { }); response = { category: blockType, - courseKey: learningContextId, display_name: title, id: blockId, metadata: { @@ -364,7 +360,6 @@ export const apiMethods = { } else if (blockType === 'pdf') { response = { category: blockType, - courseKey: learningContextId, has_changes: true, id: blockId, metadata: { ...snakeCaseKeys(content), display_name: title }, @@ -378,17 +373,17 @@ export const apiMethods = { blockId, blockType, content, - learningContextId, studioEndpointUrl, title, }) => - post( - urls.block({ studioEndpointUrl, blockId }), + // FC-0118: legacy POST-as-update becomes PATCH (partial_update) on the v1 + // xblock REST detail endpoint. + patch( + urls.blockV1({ studioEndpointUrl, blockId }), apiMethods.normalizeContent({ blockType, content, blockId, - learningContextId, title, }), ), diff --git a/src/editors/data/services/cms/urls.test.ts b/src/editors/data/services/cms/urls.test.ts index 3409e83c5b..caf5754f38 100644 --- a/src/editors/data/services/cms/urls.test.ts +++ b/src/editors/data/services/cms/urls.test.ts @@ -3,6 +3,7 @@ import { unit, libraryV1, block, + blockV1, blockAncestor, blockStudioView, courseAssets, @@ -115,10 +116,20 @@ describe('cms url methods', () => { .toEqual(`${studioEndpointUrl}/api/xblock/v2/xblocks/${v2BlockId}/fields/`); }); }); + describe('blockV1', () => { + it('returns v1 REST url with studioEndpointUrl and blockId', () => { + expect(blockV1({ studioEndpointUrl, blockId })) + .toEqual(`${studioEndpointUrl}/api/contentstore/v1/xblock/${blockId}/`); + }); + it('returns v2 url with studioEndpointUrl and v2BlockId', () => { + expect(blockV1({ studioEndpointUrl, blockId: v2BlockId })) + .toEqual(`${studioEndpointUrl}/api/xblock/v2/xblocks/${v2BlockId}/fields/`); + }); + }); describe('blockAncestor', () => { it('returns url with studioEndpointUrl, blockId and ancestor query', () => { expect(blockAncestor({ studioEndpointUrl, blockId })) - .toEqual(`${block({ studioEndpointUrl, blockId })}?fields=ancestorInfo`); + .toEqual(`${blockV1({ studioEndpointUrl, blockId })}?fields=ancestorInfo`); }); it('throws error with studioEndpointUrl, v2 blockId and ancestor query', () => { expect(() => { diff --git a/src/editors/data/services/cms/urls.ts b/src/editors/data/services/cms/urls.ts index d24bd6bcc2..9ef361adf9 100644 --- a/src/editors/data/services/cms/urls.ts +++ b/src/editors/data/services/cms/urls.ts @@ -42,15 +42,28 @@ export const returnUrl = ({ return ''; }; +// Legacy Studio xblock detail base. Kept for the handler/view sub-routes +// (`/studio_view`, `/handler/...`) that are NOT served by the v1 REST viewset +// and are appended onto this URL by `blockStudioView` / `videoTranscripts`. export const block = (({ studioEndpointUrl, blockId }) => ( blockId.startsWith('lb:') ? `${studioEndpointUrl}/api/xblock/v2/xblocks/${blockId}/fields/` : `${studioEndpointUrl}/xblock/${blockId}` )) satisfies UrlFunction; +// FC-0118 (ADR 0028/0037): standardized v1 xblock REST detail URL, for CRUD +// (retrieve/update/delete). The trailing slash is required by the DRF +// DefaultRouter. `lb:` library blocks keep the v2 library xblock API. +export const blockV1 = (({ studioEndpointUrl, blockId }) => ( + blockId.startsWith('lb:') + ? `${studioEndpointUrl}/api/xblock/v2/xblocks/${blockId}/fields/` + : `${studioEndpointUrl}/api/contentstore/v1/xblock/${blockId}/` +)) satisfies UrlFunction; + export const blockAncestor = (({ studioEndpointUrl, blockId }) => { if (blockId.includes('block-v1')) { - return `${block({ studioEndpointUrl, blockId })}?fields=ancestorInfo`; + // v1 retrieve honours the legacy `?fields=ancestorInfo` pass-through. + return `${blockV1({ studioEndpointUrl, blockId })}?fields=ancestorInfo`; } throw new Error('Block ancestor not available (and not needed) for V2 blocks'); }) satisfies UrlFunction; diff --git a/src/editors/data/services/cms/utils.ts b/src/editors/data/services/cms/utils.ts index 2e77435cd5..e06d37105a 100644 --- a/src/editors/data/services/cms/utils.ts +++ b/src/editors/data/services/cms/utils.ts @@ -23,6 +23,14 @@ export const post: Axios['post'] = (...args) => client().post(...args); */ export const put: Axios['put'] = (...args) => client().put(...args); +/** + * patch(url, data) + * simple wrapper providing an authenticated Http client patch action + * @param {string} url - target url + * @param {object|string} data - patch payload + */ +export const patch: Axios['patch'] = (...args) => client().patch(...args); + /** * delete(url, data) * simple wrapper providing an authenticated Http client delete action