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) && (