diff --git a/example-publish-button-slot.config.jsx b/example-publish-button-slot.config.jsx new file mode 100644 index 0000000000..ebfcef9a3f --- /dev/null +++ b/example-publish-button-slot.config.jsx @@ -0,0 +1,25 @@ +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/CourseAuthoringContext.tsx b/src/CourseAuthoringContext.tsx index d497749500..2bd5af4cc6 100644 --- a/src/CourseAuthoringContext.tsx +++ b/src/CourseAuthoringContext.tsx @@ -26,6 +26,7 @@ type CourseAuthoringProviderProps = { courseId: string; }; + export const CourseAuthoringProvider = ({ children, courseId, diff --git a/src/course-team/data/api.js b/src/course-team/data/api.js index 321671600d..fd21cda689 100644 --- a/src/course-team/data/api.js +++ b/src/course-team/data/api.js @@ -1,10 +1,11 @@ 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'; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; export const getCourseTeamApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_team/${courseId}`; +export const getCourseRunApiUrl = (courseId) => `${getApiBaseUrl()}/api/v1/course_runs/${courseId}/`; export const updateCourseTeamUserApiUrl = (courseId, email) => `${getApiBaseUrl()}/course_team/${courseId}/${email}`; /** @@ -19,6 +20,39 @@ export async function getCourseTeam(courseId) { return camelCaseObject(data); } +/** + * Get the current user's role for a course using the course_runs API. + * This uses the existing /api/v1/course_runs/{course_id}/ endpoint. + * @param {string} courseId + * @returns {Promise<{ role: ('instructor'|'staff'|null) }>} + */ +export async function getCourseUserRole(courseId) { + try { + const { data } = await getAuthenticatedHttpClient() + .get(getCourseRunApiUrl(courseId)); + + const camelCaseData = camelCaseObject(data); + const currentUser = getAuthenticatedUser(); + const currentUsername = currentUser?.username; + + // The course_runs API returns a 'team' array with user roles + const currentUserInTeam = camelCaseData?.team?.find( + (member) => member.user === currentUsername + ); + + // Return the role in the same format as before + return { + role: currentUserInTeam?.role || null, + }; + } catch (error) { + // If there's an error (e.g., 404, 403), return null role + console.error('Error fetching user role from course_runs API:', error); + return { + role: null, + }; + } +} + /** * Create course team user. * @param {string} courseId 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..dd130491cf 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 { getCourseRunApiUrl } 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,11 @@ describe('', () => { enable_copy_paste_units: true, }, }); + axiosMock + .onGet(getCourseRunApiUrl('course-v1:edX+DemoX+Demo_Course')) + .reply(200, { + team: [{ user: 'abc123', role: 'instructor' }], + }); 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..36b9115b38 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,58 @@ 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; + } + + if (!courseKey) { + return; + } + + let cancelled = false; + + (async () => { + try { + const { role } = await getCourseUserRole(courseKey) as any; + setCourseRole(role); + if (cancelled) return; + } 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/)