diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index 10a9ad25b0..0054a77e24 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -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} diff --git a/src/course-unit/course-sequence/hooks.js b/src/course-unit/course-sequence/hooks.js index 28035e1afd..cb541f1ab8 100644 --- a/src/course-unit/course-sequence/hooks.js +++ b/src/course-unit/course-sequence/hooks.js @@ -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); diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx index 0fa15fa29e..0af7ef63bf 100644 --- a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx +++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx @@ -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 (
); @@ -43,7 +43,7 @@ const SequenceNavigation = ({ return ( 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) diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index fc8fe092eb..f707bae452 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -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, diff --git a/src/course-unit/sidebar/utils.js b/src/course-unit/sidebar/utils.js index af3263861f..390d9a3160 100644 --- a/src/course-unit/sidebar/utils.js +++ b/src/course-unit/sidebar/utils.js @@ -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]; diff --git a/src/course-unit/xblock-container-iframe/index.tsx b/src/course-unit/xblock-container-iframe/index.tsx index 48be568b27..ac6fc92933 100644 --- a/src/course-unit/xblock-container-iframe/index.tsx +++ b/src/course-unit/xblock-container-iframe/index.tsx @@ -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 = ({ - 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 = ({ 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(); diff --git a/src/course-unit/xblock-container-iframe/types.ts b/src/course-unit/xblock-container-iframe/types.ts index 084577d163..e83d5f759b 100644 --- a/src/course-unit/xblock-container-iframe/types.ts +++ b/src/course-unit/xblock-container-iframe/types.ts @@ -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; diff --git a/src/editors/data/redux/thunkActions/app.js b/src/editors/data/redux/thunkActions/app.js index 8da9f24b71..4c5079a5a9 100644 --- a/src/editors/data/redux/thunkActions/app.js +++ b/src/editors/data/redux/thunkActions/app.js @@ -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); }, })); diff --git a/src/editors/data/redux/thunkActions/app.test.js b/src/editors/data/redux/thunkActions/app.test.js index 35debc7f3c..3f8dc10c9e 100644 --- a/src/editors/data/redux/thunkActions/app.test.js +++ b/src/editors/data/redux/thunkActions/app.test.js @@ -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(); diff --git a/src/files-and-videos/files-page/FilesPage.test.jsx b/src/files-and-videos/files-page/FilesPage.test.jsx index c1aad27252..c554646567 100644 --- a/src/files-and-videos/files-page/FilesPage.test.jsx +++ b/src/files-and-videos/files-page/FilesPage.test.jsx @@ -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) => { diff --git a/src/files-and-videos/files-page/data/slice.js b/src/files-and-videos/files-page/data/slice.js index 3a96779185..4fbe4915c9 100644 --- a/src/files-and-videos/files-page/data/slice.js +++ b/src/files-and-videos/files-page/data/slice.js @@ -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 }) => { diff --git a/src/files-and-videos/generic/DeleteConfirmationModal.jsx b/src/files-and-videos/generic/DeleteConfirmationModal.jsx index 0955c83a5c..b6f4627855 100644 --- a/src/files-and-videos/generic/DeleteConfirmationModal.jsx +++ b/src/files-and-videos/generic/DeleteConfirmationModal.jsx @@ -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 = ({ - diff --git a/src/files-and-videos/generic/FileTable.jsx b/src/files-and-videos/generic/FileTable.jsx index ded6884a83..219148fd7e 100644 --- a/src/files-and-videos/generic/FileTable.jsx +++ b/src/files-and-videos/generic/FileTable.jsx @@ -273,6 +273,16 @@ const FileTable = ({ setSelectedRows={setSelectedRows} fileType={fileType} /> + + {!isEmpty(selectedRows) && ( @@ -286,15 +296,7 @@ const FileTable = ({ sidebar={infoModalSidebar} /> )} - +
); }; diff --git a/src/files-and-videos/generic/table-components/TableActions.jsx b/src/files-and-videos/generic/table-components/TableActions.jsx index 3e813e6c4c..0663ea8498 100644 --- a/src/files-and-videos/generic/table-components/TableActions.jsx +++ b/src/files-and-videos/generic/table-components/TableActions.jsx @@ -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 ( <> diff --git a/src/files-and-videos/generic/table-components/TableActions.test.jsx b/src/files-and-videos/generic/table-components/TableActions.test.jsx new file mode 100644 index 0000000000..d97f3b2bcb --- /dev/null +++ b/src/files-and-videos/generic/table-components/TableActions.test.jsx @@ -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( + + + , + ); +}; + +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)); + }); +}); diff --git a/src/plugin-slots/AdditionalCourseContentPluginSlot/README.md b/src/plugin-slots/AdditionalCourseContentPluginSlot/README.md index e467dc5061..e132e646ca 100644 --- a/src/plugin-slots/AdditionalCourseContentPluginSlot/README.md +++ b/src/plugin-slots/AdditionalCourseContentPluginSlot/README.md @@ -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` \ No newline at end of file diff --git a/src/plugin-slots/AdditionalCoursePluginSlot/README.md b/src/plugin-slots/AdditionalCoursePluginSlot/README.md index 39bf9e8fbf..5e6c286a33 100644 --- a/src/plugin-slots/AdditionalCoursePluginSlot/README.md +++ b/src/plugin-slots/AdditionalCoursePluginSlot/README.md @@ -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: () => ( + + + slot props course + + )} + actions={} + size="sm" + /> + + + Additional course from slot props description. + Or anything else. + + + + ), + }, + }, + ] + } + }, +} + +export default config; +``` \ No newline at end of file diff --git a/src/plugin-slots/AdditionalCoursePluginSlot/images/additional-course-plugin-slot-example.png b/src/plugin-slots/AdditionalCoursePluginSlot/images/additional-course-plugin-slot-example.png new file mode 100644 index 0000000000..6dab83f755 Binary files /dev/null and b/src/plugin-slots/AdditionalCoursePluginSlot/images/additional-course-plugin-slot-example.png differ diff --git a/src/plugin-slots/AdditionalTranslationsComponentSlot/README.md b/src/plugin-slots/AdditionalTranslationsComponentSlot/README.md index 68d4f27e7b..7d1677de5d 100644 --- a/src/plugin-slots/AdditionalTranslationsComponentSlot/README.md +++ b/src/plugin-slots/AdditionalTranslationsComponentSlot/README.md @@ -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 }) => ( +
+ setIsAiTranslations(courseId === 'anyId')} + > + + Custom transcript 💬 + + + +
+); + +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; +``` diff --git a/src/plugin-slots/AdditionalTranslationsComponentSlot/images/additional-translation-example.png b/src/plugin-slots/AdditionalTranslationsComponentSlot/images/additional-translation-example.png new file mode 100644 index 0000000000..fa79a1610c Binary files /dev/null and b/src/plugin-slots/AdditionalTranslationsComponentSlot/images/additional-translation-example.png differ