diff --git a/example-publish-button-slot.config.jsx b/example-publish-button-slot.config.jsx new file mode 100644 index 0000000000..65accd4283 --- /dev/null +++ b/example-publish-button-slot.config.jsx @@ -0,0 +1,24 @@ +import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; +import HidePublishButtonForStaff from './src/plugin-slots/CourseUnitPublishButtonSlot/example'; + +// Load environment variables from .env file +const config = { + ...process.env, + pluginSlots: { + // Example: Hide the Publish button for staff users + 'org.openedx.frontend.authoring.course_unit_publish_button.v1': { + plugins: [ + { + op: PLUGIN_OPERATIONS.Replace, + widget: { + id: 'hide_publish_for_staff', + type: DIRECT_PLUGIN, + RenderWidget: HidePublishButtonForStaff, + }, + }, + ], + }, + }, +}; + +export default config; diff --git a/src/course-team/data/api.js b/src/course-team/data/api.js index 321671600d..9238ced9f1 100644 --- a/src/course-team/data/api.js +++ b/src/course-team/data/api.js @@ -1,5 +1,5 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { USER_ROLES } from '../../constants'; @@ -19,6 +19,31 @@ export async function getCourseTeam(courseId) { return camelCaseObject(data); } +/** + * Get the current user's role for a course. + * @param {string} courseId + * @returns {Promise<{ role: ('instructor'|'staff'|null) }>} + */ +export async function getCourseUserRole(courseId) { + const { data } = await getAuthenticatedHttpClient() + .get(getCourseTeamApiUrl(courseId)); + + const camelCaseData = camelCaseObject(data); + const currentUser = getAuthenticatedUser(); + const currentUserEmail = currentUser?.email; + const currentUsername = currentUser?.username; + + // Find the current user in the users array + const currentUserInTeam = camelCaseData?.users?.find( + (user) => user.email === currentUserEmail || user.username === currentUsername, + ); + + // Return the role in the same format as before + return { + role: currentUserInTeam?.role || null, + }; +} + /** * Create course team user. * @param {string} courseId diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index f957f7472b..d3891ff224 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -272,7 +272,7 @@ describe('', () => { ).toBeInTheDocument(); expect( within(courseUnitSidebar).queryByRole('button', { - name: sidebarMessages.actionButtonPublishTitle.defaultMessage, + name: 'Publish', }), ).toBeInTheDocument(); }); @@ -311,7 +311,7 @@ describe('', () => { ).toBeInTheDocument(); expect( within(courseUnitSidebar).queryByRole('button', { - name: sidebarMessages.actionButtonPublishTitle.defaultMessage, + name: 'Publish', }), ).toBeInTheDocument(); }); @@ -379,7 +379,7 @@ describe('', () => { .replace('{publishedOn}', courseSectionVerticalMock.xblock_info.published_on) .replace('{publishedBy}', userName), )).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); + expect(screen.queryByTestId('course-unit-publish-button')).not.toBeInTheDocument(); expect(screen.getByText(unitDisplayName)).toBeInTheDocument(); }); @@ -427,7 +427,7 @@ describe('', () => { expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByTestId('course-unit-publish-button')).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument(); expect(screen.getByText( @@ -508,7 +508,7 @@ describe('', () => { }); await user.click( - await screen.findByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage }), + await screen.findByRole('button', { name: 'Publish' }), ); const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); @@ -548,7 +548,7 @@ describe('', () => { .replace('{publishedOn}', courseSectionVerticalMock.xblock_info.published_on) .replace('{publishedBy}', userName), )).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); + expect(screen.queryByTestId('course-unit-publish-button')).not.toBeInTheDocument(); expect(screen.getByText(unitDisplayName)).toBeInTheDocument(); }); @@ -571,7 +571,7 @@ describe('', () => { expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByTestId('course-unit-publish-button')).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument(); expect(screen.getByText( @@ -699,7 +699,7 @@ describe('', () => { render(); await waitFor(async () => { - await user.click(screen.getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); + await user.click(screen.getByTestId('course-unit-publish-button')); }); axiosMock @@ -743,7 +743,7 @@ describe('', () => { expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByTestId('course-unit-publish-button')).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument(); expect(screen.getByText( @@ -884,7 +884,7 @@ describe('', () => { render(); await waitFor(async () => { - await user.click(screen.getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); + await user.click(screen.getByTestId('course-unit-publish-button')); }); axiosMock @@ -916,7 +916,7 @@ describe('', () => { .replace('{publishedOn}', courseSectionVerticalMock.xblock_info.published_on) .replace('{publishedBy}', userName), )).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); + expect(screen.queryByTestId('course-unit-publish-button')).not.toBeInTheDocument(); const videoButton = screen.getByRole('button', { name: new RegExp(`${addComponentMessages.buttonText.defaultMessage} Video`, 'i'), @@ -925,6 +925,15 @@ describe('', () => { await user.click(videoButton); + // Wait for the video modal to appear + await waitFor(() => { + expect(screen.getByRole('heading', { name: /add video to your course/i, hidden: true })).toBeInTheDocument(); + }); + + // Close the modal by clicking the close button + const closeButtons = screen.getAllByRole('button', { name: 'Close' }); + await user.click(closeButtons[closeButtons.length - 1]); + axiosMock .onGet(getCourseSectionVerticalApiUrl(blockId)) .reply(200, courseSectionVerticalMock); @@ -932,13 +941,15 @@ describe('', () => { await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); // after creating video xblock, the sidebar status changes to Draft (unpublished changes) - expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); + }); expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByTestId('course-unit-publish-button')).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument(); expect(screen.getByText( @@ -950,7 +961,6 @@ describe('', () => { sidebarMessages.releaseInfoWithSection.defaultMessage .replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from), )).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: /add video to your course/i, hidden: true })).toBeInTheDocument(); waffleSpy.mockRestore(); }); @@ -962,7 +972,7 @@ describe('', () => { render(); await waitFor(async () => { - await user.click(screen.getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); + await user.click(screen.getByTestId('course-unit-publish-button')); }); axiosMock @@ -992,7 +1002,7 @@ describe('', () => { .replace('{publishedOn}', courseSectionVerticalMock.xblock_info.published_on) .replace('{publishedBy}', userName), )).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); + expect(screen.queryByTestId('course-unit-publish-button')).not.toBeInTheDocument(); const videoButton = screen.getByRole('button', { name: new RegExp(`${addComponentMessages.buttonText.defaultMessage} Video`, 'i'), @@ -1003,10 +1013,19 @@ describe('', () => { }); /** TODO -- fix this test. + The video editor modal opens but is stuck in loading state. + This needs proper API mocking for the video editor. + For now, we verify the modal opens and close it. + */ + + // Wait for the editor modal to appear (it will show "Loading..." or "Editor Dialog") await waitFor(() => { - expect(getByRole('textbox', { name: /paste your video id or url/i })).toBeInTheDocument(); + expect(screen.getByRole('dialog', { name: /editor dialog/i })).toBeInTheDocument(); }); - */ + + // Close the modal by clicking the close button + const closeButtons = screen.getAllByRole('button', { name: 'Close' }); + await user.click(closeButtons[closeButtons.length - 1]); axiosMock .onGet(getCourseSectionVerticalApiUrl(blockId)) @@ -1015,13 +1034,15 @@ describe('', () => { await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); // after creating video xblock, the sidebar status changes to Draft (unpublished changes) - expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); + }); expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByTestId('course-unit-publish-button')).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument(); expect(screen.getByText( @@ -1045,7 +1066,7 @@ describe('', () => { expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByTestId('course-unit-publish-button')).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument(); expect(screen.getByText( @@ -1195,7 +1216,7 @@ describe('', () => { await waitFor(async () => { courseUnitSidebar = screen.getByTestId('course-unit-sidebar'); - publishBtn = within(courseUnitSidebar).queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage }); + publishBtn = within(courseUnitSidebar).queryByTestId('course-unit-publish-button'); expect(publishBtn).toBeInTheDocument(); await user.click(publishBtn); @@ -2338,7 +2359,7 @@ describe('', () => { const courseUnitSidebar = screen.getByTestId('course-unit-sidebar'); const publishButton = within(courseUnitSidebar).getByRole( 'button', - { name: sidebarMessages.actionButtonPublishTitle.defaultMessage }, + { name: 'Publish' }, ); expect(publishButton).toBeInTheDocument(); expect(publishButton).toBeEnabled(); diff --git a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx index 652b0234d1..4702eca9d3 100644 --- a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx +++ b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx @@ -16,6 +16,7 @@ import { fetchCourseSectionVerticalData } from '../../../data/thunk'; import { courseSectionVerticalMock } from '../../../__mocks__'; import messages from '../../messages'; import ActionButtons from './ActionButtons'; +import { getCourseTeamApiUrl } from '../../../../course-team/data/api'; let store; let axiosMock; @@ -38,6 +39,7 @@ describe('', () => { authenticatedUser: { userId: 3, username: 'abc123', + email: 'abc123@example.com', administrator: true, roles: [], }, @@ -54,6 +56,13 @@ describe('', () => { enable_copy_paste_units: true, }, }); + axiosMock + .onGet(getCourseTeamApiUrl('course-v1:edX+DemoX+Demo_Course')) + .reply(200, { + users: [{ email: 'abc123@example.com', role: 'instructor', username: 'abc123' }], + allow_actions: true, + show_transfer_ownership_hint: false, + }); axiosMock .onPost(getClipboardUrl()) .reply(200, clipboardUnit); diff --git a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.tsx b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.tsx index 8bedc41c8b..e2f911977b 100644 --- a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.tsx +++ b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.tsx @@ -1,17 +1,30 @@ +import { useEffect, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; import { Button } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Divider } from '../../../../generic/divider'; -import { getCanEdit, getCourseUnitData } from '../../../data/selectors'; +import { getCanEdit, getCourseSectionVertical, getCourseUnitData } from '../../../data/selectors'; import { useClipboard } from '../../../../generic/clipboard'; +import { getCourseUserRole } from '../../../../course-team/data/api'; import messages from '../../messages'; +import CourseUnitPublishButtonSlot from '../../../../plugin-slots/CourseUnitPublishButtonSlot'; interface ActionButtonsProps { openDiscardModal: () => void, handlePublishing: () => void, } +type CourseRole = 'staff' | 'instructor' | null; + +const extractCourseKeyFromOutlineUrl = (outlineUrl?: string): string | null => { + if (!outlineUrl) { + return null; + } + const match = outlineUrl.match(/\/course\/([^?]+)/); + return match?.[1] ?? null; +}; + const ActionButtons = ({ openDiscardModal, handlePublishing, @@ -23,21 +36,62 @@ const ActionButtons = ({ hasChanges, enableCopyPasteUnits, } = useSelector(getCourseUnitData); + const courseSectionVertical = useSelector(getCourseSectionVertical); const canEdit = useSelector(getCanEdit); const { copyToClipboard } = useClipboard(); + const [courseRole, setCourseRole] = useState(null); + + const courseKey = useMemo( + () => extractCourseKeyFromOutlineUrl(courseSectionVertical?.outlineUrl), + [courseSectionVertical?.outlineUrl], + ); + + useEffect(() => { + // Only needed when the Publish button will render. + if (!(!published || hasChanges)) { + return undefined; + } + + if (!courseKey) { + return undefined; + } + + let cancelled = false; + + (async () => { + try { + const { role } = await getCourseUserRole(courseKey) as any; + if (cancelled) { + return; + } + setCourseRole(role); + } catch (error: any) { + if (cancelled) { + return; + } + setCourseRole(null); + } + })(); + + return () => { + cancelled = true; + }; + }, [courseKey, hasChanges, published]); + + const publishButtonText = courseRole + ? intl.formatMessage(messages.actionButtonPublishTitle, { role: courseRole }) + : 'Publish'; + return ( <> - {(!published || hasChanges) && ( - - )} + {(published && hasChanges) && ( + ); + + return ( + {}), + }} + > + {defaultComponent} + + ); +}; + +CourseUnitPublishButtonSlot.propTypes = { + courseRole: PropTypes.oneOf(['staff', 'instructor', null]), + published: PropTypes.bool.isRequired, + hasChanges: PropTypes.bool.isRequired, + publishButtonText: PropTypes.string.isRequired, + onPublish: PropTypes.func.isRequired, +}; + +export default CourseUnitPublishButtonSlot; diff --git a/src/plugin-slots/README.md b/src/plugin-slots/README.md index 6d6603dafd..9c9ff6be97 100644 --- a/src/plugin-slots/README.md +++ b/src/plugin-slots/README.md @@ -9,6 +9,7 @@ ## Course Unit page * [`org.openedx.frontend.authoring.course_unit_header_actions.v1`](./CourseUnitHeaderActionsSlot/) * [`org.openedx.frontend.authoring.course_unit_sidebar.v1`](./CourseAuthoringUnitSidebarSlot/) +* [`org.openedx.frontend.authoring.course_unit_publish_button.v1`](./CourseUnitPublishButtonSlot/) - Publish button slot with role-based control ## Other Slots * [`org.openedx.frontend.authoring.additional_course_content_plugin.v1`](./AdditionalCourseContentPluginSlot/) diff --git a/tsconfig.json b/tsconfig.json index 456fd4d8f3..5eea2aa472 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ }, "include": [ "*.js", + "*.jsx", ".eslintrc.js", "src/**/*", "plugins/**/*"