Compare commits

...

5 Commits

Author SHA1 Message Date
Muhammad Faraz Maqsood
06497bf85c fix: publish btn doesn't show after component edit
When we edit & save the component, publish button doesn't show up until we refresh the page manualy or open this unit by opening previous unit and coming back to this unit again.
In this commit, we are dispatching a storage event whenever we edit the component, it'll refresh the page & show the publish button as expected.
2025-08-20 15:08:24 +05:00
Jacobo Dominguez
7e0b7f94e8 docs: (backport) adding comprehensive readme documentation for plugin slots (#2340) 2025-07-29 15:27:24 -07:00
Jansen Kantor
4bc34c268b fix: pages and resources plugins not rendered (#1885) 2025-07-22 13:26:38 +05:30
Muhammad Anas
2973614e3b fix: loading unit page directly from link after logging in in Teak (#2246)
This is a simple version of the fix for Teak; on master it was fixed with https://github.com/openedx/frontend-app-authoring/pull/1867
2025-07-09 09:35:58 -07:00
Brayan Cerón
bdc99fddc3 fix: clear selection on files & uploads page after deleting (backport) (#2228)
* refactor: remove selected rows when deleting or adding elements

* refactor: ensure unique asset IDs when adding new ones

* refactor: remove unnecessary loading checks in mockStore function

* test: add unit tests for TableActions component
2025-07-07 16:47:45 -07:00
25 changed files with 392 additions and 42 deletions

View File

@@ -41,6 +41,7 @@ const CourseUnit = ({ courseId }) => {
courseUnit,
isLoading,
sequenceId,
courseUnitLoadingStatus,
unitTitle,
unitCategory,
errorMessage,
@@ -210,6 +211,7 @@ const CourseUnit = ({ courseId }) => {
courseId={courseId}
blockId={blockId}
isUnitVerticalType={isUnitVerticalType}
courseUnitLoadingStatus={courseUnitLoadingStatus}
unitXBlockActions={unitXBlockActions}
courseVerticalChildren={courseVerticalChildren.children}
handleConfigureSubmit={handleConfigureSubmit}

View File

@@ -12,16 +12,19 @@ export function useSequenceNavigationMetadata(courseId, currentSequenceId, curre
const isLastUnit = !nextUrl;
const sequenceIds = useSelector(getSequenceIds);
const sequenceIndex = sequenceIds.indexOf(currentSequenceId);
const unitIndex = sequence.unitIds.indexOf(currentUnitId);
let unitIndex = sequence?.unitIds.indexOf(currentUnitId);
const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null;
const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null;
if (!unitIndex) {
// Handle case where unitIndex is not found
unitIndex = 0;
}
let nextLink;
const nextIndex = unitIndex + 1;
if (nextIndex < sequence.unitIds.length) {
const nextUnitId = sequence.unitIds[nextIndex];
if (nextIndex < sequence?.unitIds.length) {
const nextUnitId = sequence?.unitIds[nextIndex];
nextLink = `/course/${courseId}/container/${nextUnitId}/${currentSequenceId}`;
} else if (nextSequenceId) {
const pathToNextUnit = decodeURIComponent(nextUrl);
@@ -32,7 +35,7 @@ export function useSequenceNavigationMetadata(courseId, currentSequenceId, curre
const previousIndex = unitIndex - 1;
if (previousIndex >= 0) {
const previousUnitId = sequence.unitIds[previousIndex];
const previousUnitId = sequence?.unitIds[previousIndex];
previousLink = `/course/${courseId}/container/${previousUnitId}/${currentSequenceId}`;
} else if (previousSequenceId) {
const pathToPreviousUnit = decodeURIComponent(prevUrl);

View File

@@ -35,7 +35,7 @@ const SequenceNavigation = ({
const shouldDisplayNotificationTriggerInSequence = useWindowSize().width < breakpoints.small.minWidth;
const renderUnitButtons = () => {
if (sequence.unitIds?.length === 0 || unitId === null) {
if (sequence?.unitIds?.length === 0 || unitId === null) {
return (
<div style={{ flexBasis: '100%', minWidth: 0, borderBottom: 'solid 1px #EAEAEA' }} />
);
@@ -43,7 +43,7 @@ const SequenceNavigation = ({
return (
<SequenceNavigationTabs
unitIds={sequence.unitIds || []}
unitIds={sequence?.unitIds || []}
unitId={unitId}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
showPasteUnit={showPasteUnit}

View File

@@ -16,7 +16,7 @@ export const getCourseVerticalChildren = (state) => state.courseUnit.courseVerti
export const getCourseOutlineInfo = (state) => state.courseUnit.courseOutlineInfo;
export const getCourseOutlineInfoLoadingStatus = (state) => state.courseUnit.courseOutlineInfoLoadingStatus;
export const getMovedXBlockParams = (state) => state.courseUnit.movedXBlockParams;
const getLoadingStatuses = (state) => state.courseUnit.loadingStatus;
export const getLoadingStatuses = (state) => state.courseUnit.loadingStatus;
export const getIsLoading = createSelector(
[getLoadingStatuses],
loadingStatus => Object.values(loadingStatus)

View File

@@ -35,6 +35,7 @@ import {
getSavingStatus,
getSequenceStatus,
getStaticFileNotices,
getLoadingStatuses,
} from './data/selectors';
import {
changeEditTitleFormOpen,
@@ -51,6 +52,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
const [isMoveModalOpen, openMoveModal, closeMoveModal] = useToggle(false);
const courseUnit = useSelector(getCourseUnitData);
const courseUnitLoadingStatus = useSelector(getLoadingStatuses);
const savingStatus = useSelector(getSavingStatus);
const isLoading = useSelector(getIsLoading);
const errorMessage = useSelector(getErrorMessage);
@@ -215,9 +217,28 @@ export const useCourseUnit = ({ courseId, blockId }) => {
}
}, [isMoveModalOpen]);
useEffect(() => {
const handlePageRefreshUsingStorage = (event) => {
// ignoring tests for if block, because it triggers when someone
// edits the component using editor which has a separate store
/* istanbul ignore next */
if (event.key === 'courseRefreshTriggerOnComponentEditSave') {
dispatch(fetchCourseSectionVerticalData(blockId, sequenceId));
dispatch(fetchCourseVerticalChildrenData(blockId, isSplitTestType));
localStorage.removeItem(event.key);
}
};
window.addEventListener('storage', handlePageRefreshUsingStorage);
return () => {
window.removeEventListener('storage', handlePageRefreshUsingStorage);
};
}, [blockId, sequenceId, isSplitTestType]);
return {
sequenceId,
courseUnit,
courseUnitLoadingStatus,
unitTitle,
unitCategory,
errorMessage,

View File

@@ -99,4 +99,4 @@ export const getIconVariant = (visibilityState, published, hasChanges) => {
* @param {string} id - The course unit ID.
* @returns {string} The clear course unit ID extracted from the provided data.
*/
export const extractCourseUnitId = (id) => id.match(/block@(.+)$/)[1];
export const extractCourseUnitId = (id) => id?.match(/block@(.+)$/)[1];

View File

@@ -37,9 +37,16 @@ import { useIframeContent } from '../../generic/hooks/useIframeContent';
import { useIframeMessages } from '../../generic/hooks/useIframeMessages';
import VideoSelectorPage from '../../editors/VideoSelectorPage';
import EditorPage from '../../editors/EditorPage';
import { RequestStatus } from '../../data/constants';
const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
courseId, blockId, unitXBlockActions, courseVerticalChildren, handleConfigureSubmit, isUnitVerticalType,
courseId,
blockId,
unitXBlockActions,
courseVerticalChildren,
handleConfigureSubmit,
isUnitVerticalType,
courseUnitLoadingStatus,
}) => {
const intl = useIntl();
const dispatch = useDispatch();
@@ -70,6 +77,23 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
setIframeRef(iframeRef);
}, [setIframeRef]);
useEffect(() => {
const iframe = iframeRef?.current;
if (!iframe) { return undefined; }
const handleIframeLoad = () => {
if (courseUnitLoadingStatus.fetchUnitLoadingStatus === RequestStatus.FAILED) {
window.location.reload();
}
};
iframe.addEventListener('load', handleIframeLoad);
return () => {
iframe.removeEventListener('load', handleIframeLoad);
};
}, [iframeRef]);
const onXBlockSave = useCallback(/* istanbul ignore next */ () => {
closeXBlockEditorModal();
closeVideoSelectorModal();

View File

@@ -42,6 +42,11 @@ export interface XBlockContainerIframeProps {
courseId: string;
blockId: string;
isUnitVerticalType: boolean,
courseUnitLoadingStatus: {
fetchUnitLoadingStatus: string;
fetchVerticalChildrenLoadingStatus: string;
fetchXBlockDataLoadingStatus: string;
};
unitXBlockActions: {
handleDelete: (XBlockId: string | null) => void;
handleDuplicate: (XBlockId: string | null) => void;

View File

@@ -125,6 +125,16 @@ export const saveBlock = (content, returnToUnit) => (dispatch) => {
content,
onSuccess: (response) => {
dispatch(actions.app.setSaveResponse(response));
const parsedData = JSON.parse(response.config.data);
if (parsedData?.has_changes) {
const storageKey = 'courseRefreshTriggerOnComponentEditSave';
localStorage.setItem(storageKey, Date.now());
window.dispatchEvent(new StorageEvent('storage', {
key: storageKey,
newValue: Date.now().toString(),
}));
}
returnToUnit(response.data);
},
}));

View File

@@ -352,7 +352,11 @@ describe('app thunkActions', () => {
});
it('dispatches actions.app.setSaveResponse with response and then calls returnToUnit', () => {
dispatch.mockClear();
const response = 'testRESPONSE';
const mockParsedData = { has_changes: true };
const response = {
config: { data: JSON.stringify(mockParsedData) },
data: {},
};
calls[1][0].saveBlock.onSuccess(response);
expect(dispatch).toHaveBeenCalledWith(actions.app.setSaveResponse(response));
expect(returnToUnit).toHaveBeenCalled();

View File

@@ -70,15 +70,6 @@ const mockStore = async (
}
renderComponent();
await executeThunk(fetchAssets(courseId), store.dispatch);
// Finish loading the expected files into the data table before returning,
// because loading new files can disrupt things like accessing file menus.
if (status === RequestStatus.SUCCESSFUL) {
const numFiles = skipNextPageFetch ? 13 : 15;
await waitFor(() => {
expect(screen.getByText(`Showing ${numFiles} of ${numFiles}`)).toBeInTheDocument();
});
}
};
const emptyMockStore = async (status) => {

View File

@@ -28,7 +28,7 @@ const slice = createSlice({
if (isEmpty(state.assetIds)) {
state.assetIds = payload.assetIds;
} else {
state.assetIds = [...state.assetIds, ...payload.assetIds];
state.assetIds = [...new Set([...state.assetIds, ...payload.assetIds])];
}
},
setSortedAssetIds: (state, { payload }) => {

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
@@ -7,6 +7,7 @@ import {
AlertModal,
Button,
Collapsible,
DataTableContext,
Hyperlink,
Truncate,
} from '@openedx/paragon';
@@ -22,6 +23,13 @@ const DeleteConfirmationModal = ({
// injected
intl,
}) => {
const { clearSelection } = useContext(DataTableContext);
const handleConfirmDeletion = () => {
handleBulkDelete();
clearSelection();
};
const firstSelectedRow = selectedRows[0]?.original;
let activeContentRows = [];
if (Array.isArray(selectedRows)) {
@@ -73,7 +81,7 @@ const DeleteConfirmationModal = ({
<Button variant="tertiary" onClick={closeDeleteConfirmation}>
{intl.formatMessage(messages.cancelButtonLabel)}
</Button>
<Button onClick={handleBulkDelete}>
<Button onClick={handleConfirmDeletion}>
{intl.formatMessage(messages.deleteFileButtonLabel)}
</Button>
</ActionRow>

View File

@@ -273,6 +273,16 @@ const FileTable = ({
setSelectedRows={setSelectedRows}
fileType={fileType}
/>
<DeleteConfirmationModal
{...{
isDeleteConfirmationOpen,
closeDeleteConfirmation,
handleBulkDelete,
selectedRows,
fileType,
}}
/>
</DataTable>
<FileInput key="generic-file-upload" fileInput={fileInputControl} supportedFileFormats={supportedFileFormats} />
{!isEmpty(selectedRows) && (
@@ -286,15 +296,7 @@ const FileTable = ({
sidebar={infoModalSidebar}
/>
)}
<DeleteConfirmationModal
{...{
isDeleteConfirmationOpen,
closeDeleteConfirmation,
handleBulkDelete,
selectedRows,
fileType,
}}
/>
</div>
);
};

View File

@@ -26,13 +26,18 @@ const TableActions = ({
intl,
}) => {
const [isSortOpen, openSort, closeSort] = useToggle(false);
const { state } = useContext(DataTableContext);
const { state, clearSelection } = useContext(DataTableContext);
// This useEffect saves DataTable state so it can persist after table re-renders due to data reload.
useEffect(() => {
setInitialState(state);
}, [state]);
const handleOpenFileSelector = () => {
fileInputControl.click();
clearSelection();
};
return (
<>
<Button variant="outline-primary" onClick={openSort} iconBefore={Tune}>
@@ -71,7 +76,7 @@ const TableActions = ({
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
<Button iconBefore={Add} onClick={fileInputControl.click}>
<Button iconBefore={Add} onClick={handleOpenFileSelector}>
{intl.formatMessage(messages.addFilesButtonLabel, { fileType })}
</Button>
<SortAndFilterModal {...{ isSortOpen, closeSort, handleSort }} />

View File

@@ -0,0 +1,138 @@
import React from 'react';
import { screen, fireEvent } from '@testing-library/react';
import { DataTableContext } from '@openedx/paragon';
import { initializeMocks, render } from '../../../testUtils';
import TableActions from './TableActions';
import messages from '../messages';
const defaultProps = {
selectedFlatRows: [],
fileInputControl: { click: jest.fn() },
handleOpenDeleteConfirmation: jest.fn(),
handleBulkDownload: jest.fn(),
encodingsDownloadUrl: null,
handleSort: jest.fn(),
fileType: 'video',
setInitialState: jest.fn(),
intl: {
formatMessage: (msg, values) => msg.defaultMessage.replace('{fileType}', values?.fileType ?? ''),
},
};
const mockColumns = [
{
id: 'wrapperType',
Header: 'Type',
accessor: 'wrapperType',
filter: 'includes',
},
];
const renderWithContext = (props = {}, contextOverrides = {}) => {
const contextValue = {
state: {
selectedRowIds: {},
filters: [],
...contextOverrides.state,
},
clearSelection: jest.fn(),
gotoPage: jest.fn(),
setAllFilters: jest.fn(),
columns: mockColumns,
...contextOverrides,
};
return render(
<DataTableContext.Provider value={contextValue}>
<TableActions {...defaultProps} {...props} />
</DataTableContext.Provider>,
);
};
describe('TableActions', () => {
beforeEach(() => {
initializeMocks();
jest.clearAllMocks();
});
test('renders buttons and dropdown', () => {
renderWithContext();
expect(screen.getByRole('button', { name: messages.sortButtonLabel.defaultMessage })).toBeInTheDocument();
expect(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage })).toBeInTheDocument();
expect(screen.getByRole('button', { name: messages.addFilesButtonLabel.defaultMessage.replace('{fileType}', 'video') })).toBeInTheDocument();
});
test('disables bulk and delete actions if no rows selected', () => {
renderWithContext();
fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage }));
const downloadOption = screen.getByText(messages.downloadTitle.defaultMessage);
const deleteButton = screen.getByTestId('open-delete-confirmation-button');
expect(downloadOption).toHaveAttribute('aria-disabled', 'true');
expect(downloadOption).toHaveClass('disabled');
expect(deleteButton).toHaveAttribute('aria-disabled', 'true');
expect(deleteButton).toHaveClass('disabled');
});
test('enables bulk and delete actions when rows are selected', () => {
renderWithContext({
selectedFlatRows: [{ original: { id: '1', displayName: 'Video 1', wrapperType: 'video' } }],
});
fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage }));
expect(screen.getByText(messages.downloadTitle.defaultMessage)).not.toBeDisabled();
expect(screen.getByTestId('open-delete-confirmation-button')).not.toBeDisabled();
});
test('calls file input click and clears selection when add button clicked', () => {
const mockClick = jest.fn();
const mockClear = jest.fn();
renderWithContext({ fileInputControl: { click: mockClick } }, {}, mockClear);
fireEvent.click(screen.getByRole('button', { name: messages.addFilesButtonLabel.defaultMessage.replace('{fileType}', 'video') }));
expect(mockClick).toHaveBeenCalled();
});
test('opens sort modal when sort button clicked', () => {
renderWithContext();
fireEvent.click(screen.getByRole('button', { name: messages.sortButtonLabel.defaultMessage }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
test('calls handleBulkDownload when selected and clicked', () => {
const handleBulkDownload = jest.fn();
renderWithContext({
selectedFlatRows: [{ original: { id: '1', displayName: 'Video 1', wrapperType: 'video' } }],
handleBulkDownload,
});
fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage }));
fireEvent.click(screen.getByText(messages.downloadTitle.defaultMessage));
expect(handleBulkDownload).toHaveBeenCalled();
});
test('calls handleOpenDeleteConfirmation when clicked', () => {
const handleOpenDeleteConfirmation = jest.fn();
const selectedFlatRows = [{ original: { id: '1', displayName: 'Video 1', wrapperType: 'video' } }];
renderWithContext({
selectedFlatRows,
handleOpenDeleteConfirmation,
});
fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage }));
fireEvent.click(screen.getByTestId('open-delete-confirmation-button'));
expect(handleOpenDeleteConfirmation).toHaveBeenCalledWith(selectedFlatRows);
});
test('shows encoding download link when provided', () => {
const encodingsDownloadUrl = '/some/path/to/encoding.zip';
renderWithContext({ encodingsDownloadUrl });
fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage }));
expect(screen.getByRole('link', { name: messages.downloadEncodingsTitle.defaultMessage })).toHaveAttribute('href', expect.stringContaining(encodingsDownloadUrl));
});
});

View File

@@ -92,7 +92,7 @@ const PagesAndResources = ({ courseId }) => {
<Route path=":appId/settings" element={<PageWrap><SettingsComponent url={redirectUrl} /></PageWrap>} />
</Routes>
<PageGrid pages={pages} pluginSlotComponent={AdditionalCoursePluginSlot} courseId={courseId} />
<PageGrid pages={pages} pluginSlotComponent={<AdditionalCoursePluginSlot />} courseId={courseId} />
{
(contentPermissionsPages.length > 0 || hasAdditionalCoursePlugin)
&& (
@@ -100,7 +100,7 @@ const PagesAndResources = ({ courseId }) => {
<div className="d-flex justify-content-between my-4 my-md-5 align-items-center">
<h3 className="m-0">{intl.formatMessage(messages.contentPermissions)}</h3>
</div>
<PageGrid pages={contentPermissionsPages} pluginSlotComponent={AdditionalCourseContentPluginSlot} />
<PageGrid pages={contentPermissionsPages} pluginSlotComponent={<AdditionalCourseContentPluginSlot />} />
</>
)
}

View File

@@ -1,16 +1,39 @@
import { screen, waitFor } from '@testing-library/react';
import { getConfig, setConfig } from '@edx/frontend-platform';
import { PLUGIN_OPERATIONS, DIRECT_PLUGIN } from '@openedx/frontend-plugin-framework';
import { PagesAndResources } from '.';
import { render } from './utils.test';
const mockPlugin = (identifier) => ({
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'mock-plugin-1',
type: DIRECT_PLUGIN,
priority: 1,
RenderWidget: () => <div data-testid={identifier}>HELLO</div>,
},
},
],
});
const courseId = 'course-v1:edX+TestX+Test_Course';
describe('PagesAndResources', () => {
beforeEach(() => {
jest.clearAllMocks();
setConfig({
...getConfig(),
pluginSlots: {
'org.openedx.frontend.authoring.additional_course_plugin.v1': mockPlugin('additional_course_plugin'),
'org.openedx.frontend.authoring.additional_course_content_plugin.v1': mockPlugin('additional_course_content_plugin'),
},
});
});
it('doesn\'t show content permissions section if relevant apps are not enabled', () => {
it('doesn\'t show content permissions section if relevant apps are not enabled', async () => {
const initialState = {
models: {
courseApps: {},
@@ -25,8 +48,11 @@ describe('PagesAndResources', () => {
{ preloadedState: initialState },
);
expect(screen.queryByRole('heading', { name: 'Content permissions' })).not.toBeInTheDocument();
await waitFor(() => expect(screen.queryByRole('heading', { name: 'Content permissions' })).not.toBeInTheDocument());
await waitFor(() => expect(screen.queryByTestId('additional_course_plugin')).toBeInTheDocument());
await waitFor(() => expect(screen.queryByTestId('additional_course_content_plugin')).not.toBeInTheDocument());
});
it('show content permissions section if Learning Assistant app is enabled', async () => {
const initialState = {
models: {
@@ -56,6 +82,8 @@ describe('PagesAndResources', () => {
await waitFor(() => expect(screen.getByRole('heading', { name: 'Content permissions' })).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Learning Assistant')).toBeInTheDocument());
await waitFor(() => expect(screen.queryByTestId('additional_course_plugin')).toBeInTheDocument());
await waitFor(() => expect(screen.queryByTestId('additional_course_content_plugin')).toBeInTheDocument());
});
it('show content permissions section if Xpert learning summaries app is enabled', async () => {
@@ -89,5 +117,7 @@ describe('PagesAndResources', () => {
await waitFor(() => expect(screen.getByRole('heading', { name: 'Content permissions' })).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Xpert unit summaries')).toBeInTheDocument());
await waitFor(() => expect(screen.queryByTestId('additional_course_plugin')).toBeInTheDocument());
await waitFor(() => expect(screen.queryByTestId('additional_course_content_plugin')).toBeInTheDocument());
});
});

View File

@@ -3,4 +3,4 @@
### Slot ID: `org.openedx.frontend.authoring.additional_course_content_plugin.v1`
### Slot ID Aliases
* `additional_course_content_plugin`
* `additional_course_content_plugin`

View File

@@ -1,5 +1,4 @@
import { PluginSlot } from '@openedx/frontend-plugin-framework/dist';
import React from 'react';
export const AdditionalCourseContentPluginSlot = () => (
<PluginSlot

View File

@@ -1,6 +1,64 @@
# AdditionalCoursePluginSlot
# Additional Course Plugin Slot
### Slot ID: `org.openedx.frontend.authoring.additional_course_plugin.v1`
### Slot ID Aliases
* `additional_course_plugin`
## Description
This slot is used to add a custom card on the the page & resources page.
## Example
The following `env.config.jsx` will add a custom card at the end of the page & resources section.
![Screenshot of the unit sidebar surrounded by border](./images/additional-course-plugin-slot-example.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
import { Badge, Card } from '@openedx/paragon';
import { Settings } from '@openedx/paragon/icons';
const config = {
pluginSlots: {
'org.openedx.frontend.authoring.additional_course_plugin.v1': {
plugins: [
{
op: PLUGIN_OPERATIONS.Hide,
widgetId: 'default_contents',
},
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_additional_course',
type: DIRECT_PLUGIN,
RenderWidget: () => (
<Card className={'shadow justify-content-between'} >
<Card.Header
title={'Additional Course'}
subtitle={(
<Badge variant="success" className="mt-1">
slot props course
</Badge>
)}
actions={<Settings />}
size="sm"
/>
<Card.Body>
<Card.Section>
Additional course from slot props description.
Or anything else.
</Card.Section>
</Card.Body>
</Card>
),
},
},
]
}
},
}
export default config;
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

View File

@@ -1,5 +1,4 @@
import { PluginSlot } from '@openedx/frontend-plugin-framework/dist';
import React from 'react';
export const AdditionalCoursePluginSlot = () => (
<PluginSlot

View File

@@ -13,3 +13,54 @@
* `additionalProps` - Object
* `transcriptType` - String
* `isAiTranslationsEnabled` - Boolean
## Description
This slot is used to add a custom block in the **Video Transcription Settings** drawer.
## Example
The following `env.config.jsx` will add a custom transcript option in the Transcript Settings drawer.
![Screenshot of the unit sidebar surrounded by border](./images/additional-translation-example.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
import { Collapsible, Icon } from '@openedx/paragon';
import { ChevronRight } from '@openedx/paragon/icons';
const TranslationsBlock = ({ setIsAiTranslations, courseId }) => (
<div key="transcript-type-selection" className="mt-3">
<Collapsible.Advanced
onOpen={() => setIsAiTranslations(courseId === 'anyId')}
>
<Collapsible.Trigger
className="row m-0 justify-content-between align-items-center"
>
Custom transcript 💬
<Icon src={ChevronRight} />
</Collapsible.Trigger>
</Collapsible.Advanced>
</div>
);
const config = {
pluginSlots: {
'org.openedx.frontend.authoring.video_transcript_additional_translations_component.v1': {
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_additional_translation_id',
type: DIRECT_PLUGIN,
RenderWidget: TranslationsBlock,
},
},
],
},
},
}
export default config;
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB