From a2dceac62fc13623434ff5324392608d470bc13b Mon Sep 17 00:00:00 2001 From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com> Date: Tue, 28 Nov 2023 16:55:00 -0500 Subject: [PATCH] feat: add notification of transcription error (#715) --- src/files-and-videos/generic/InfoModal.jsx | 124 +++++++++++------- src/files-and-videos/generic/index.js | 2 + src/files-and-videos/generic/messages.js | 4 + .../generic/table-components/index.js | 2 + .../table-custom-columns/TranscriptColumn.jsx | 37 ++++++ .../table-custom-columns/index.js | 2 + .../videos-page/VideosPage.jsx | 11 +- .../videos-page/VideosPage.test.jsx | 119 ++++++++++------- .../videos-page/data/constants.js | 3 + .../videos-page/data/utils.js | 6 +- .../factories/mockApiResponses.jsx | 3 + .../info-sidebar/VideoInfoModalSidebar.jsx | 17 ++- .../videos-page/info-sidebar/messages.js | 5 + 13 files changed, 229 insertions(+), 106 deletions(-) create mode 100644 src/files-and-videos/generic/table-components/table-custom-columns/TranscriptColumn.jsx diff --git a/src/files-and-videos/generic/InfoModal.jsx b/src/files-and-videos/generic/InfoModal.jsx index 8f81149c4..6b9c5ee5c 100644 --- a/src/files-and-videos/generic/InfoModal.jsx +++ b/src/files-and-videos/generic/InfoModal.jsx @@ -1,19 +1,24 @@ -import React from 'react'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { injectIntl, + intlShape, FormattedMessage, } from '@edx/frontend-platform/i18n'; import { + Icon, ModalDialog, Stack, Truncate, } from '@edx/paragon'; +import { Error } from '@edx/paragon/icons'; import messages from './messages'; import UsageMetricsMessages from './UsageMetricsMessage'; import FileThumbnail from './ThumbnailPreview'; +import { TRANSCRIPT_FAILURE_STATUSES } from '../videos-page/data/constants'; +import AlertMessage from '../../generic/alert-message'; const InfoModal = ({ file, @@ -23,55 +28,74 @@ const InfoModal = ({ usagePathStatus, error, sidebar, -}) => ( - - - -
- - {file?.displayName} - -
-
-
- -
-
-
- - -
-
- + // injected + intl, +}) => { + const [activeTab, setActiveTab] = useState('fileInfo'); + const showTranscriptionError = TRANSCRIPT_FAILURE_STATUSES.includes(file?.transcriptionStatus) + && activeTab !== 'fileInfo'; + + return ( + + + +
+ + {file?.displayName} + +
+
+
+ +
+ {showTranscriptionError && ( + + + {intl.formatMessage(messages.transcriptionErrorMessage, { error: file.errorDescription })}
- -
-
+ )} + variant="danger" + /> + )} +
+
+ + +
+
+ +
+ +
+
+
+
+ {sidebar(file, activeTab, setActiveTab)} +
-
- {sidebar(file)} -
-
- - -); + + + ); +}; InfoModal.propTypes = { file: PropTypes.shape({ @@ -86,6 +110,8 @@ InfoModal.propTypes = { fileSize: PropTypes.number.isRequired, usageLocations: PropTypes.arrayOf(PropTypes.string), status: PropTypes.string, + transcriptionStatus: PropTypes.string, + errorDescription: PropTypes.string, }), onClose: PropTypes.func.isRequired, isOpen: PropTypes.bool.isRequired, @@ -93,6 +119,8 @@ InfoModal.propTypes = { error: PropTypes.arrayOf(PropTypes.string).isRequired, thumbnailPreview: PropTypes.func.isRequired, sidebar: PropTypes.func.isRequired, + // injected + intl: intlShape.isRequired, }; InfoModal.defaultProps = { diff --git a/src/files-and-videos/generic/index.js b/src/files-and-videos/generic/index.js index 0449cc870..a9dc9d93e 100644 --- a/src/files-and-videos/generic/index.js +++ b/src/files-and-videos/generic/index.js @@ -6,6 +6,7 @@ import { MoreInfoColumn, StatusColumn, ThumbnailColumn, + TranscriptColumn, } from './table-components'; import FileInput, { useFileInput } from './FileInput'; @@ -19,6 +20,7 @@ export { ThumbnailColumn, FileInput, useFileInput, + TranscriptColumn, }; export { default as FileTable } from './FileTable'; export { default as EditFileErrors } from './EditFileErrors'; diff --git a/src/files-and-videos/generic/messages.js b/src/files-and-videos/generic/messages.js index 757e871f2..ba62f2b2d 100644 --- a/src/files-and-videos/generic/messages.js +++ b/src/files-and-videos/generic/messages.js @@ -37,6 +37,10 @@ const messages = defineMessages({ id: 'course-authoring.files-and-upload.errorAlert.message', defaultMessage: '{message}', }, + transcriptionErrorMessage: { + id: 'course-authoring.files-and-uploads.file-info.transcripts.error.alert', + defaultMessage: 'Transcript failed: "{error}"', + }, usageTitle: { id: 'course-authoring.files-and-uploads.file-info.usage.title', defaultMessage: 'Usage', diff --git a/src/files-and-videos/generic/table-components/index.js b/src/files-and-videos/generic/table-components/index.js index 4d8a1eace..d82173b26 100644 --- a/src/files-and-videos/generic/table-components/index.js +++ b/src/files-and-videos/generic/table-components/index.js @@ -9,6 +9,7 @@ import { MoreInfoColumn, StatusColumn, ThumbnailColumn, + TranscriptColumn, } from './table-custom-columns'; export { @@ -22,4 +23,5 @@ export { MoreInfoColumn, StatusColumn, ThumbnailColumn, + TranscriptColumn, }; diff --git a/src/files-and-videos/generic/table-components/table-custom-columns/TranscriptColumn.jsx b/src/files-and-videos/generic/table-components/table-custom-columns/TranscriptColumn.jsx new file mode 100644 index 000000000..0f445c5c3 --- /dev/null +++ b/src/files-and-videos/generic/table-components/table-custom-columns/TranscriptColumn.jsx @@ -0,0 +1,37 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Icon } from '@edx/paragon'; +import { Info } from '@edx/paragon/icons'; +import { TRANSCRIPT_FAILURE_STATUSES } from '../../../videos-page/data/constants'; + +const TranscriptColumn = ({ row }) => { + const { transcripts, transcriptionStatus } = row.original; + const numOfTranscripts = transcripts?.length; + const transcriptMessage = numOfTranscripts > 0 ? `(${numOfTranscripts}) available` : null; + + return ( +
+ {TRANSCRIPT_FAILURE_STATUSES.includes(transcriptionStatus) && ( + + )} + +
+ ); +}; + +TranscriptColumn.propTypes = { + row: { + original: { + transcript: PropTypes.arrayOf([PropTypes.string]).isRequired, + transcriptionStatus: PropTypes.string.isRequired, + }.isRequired, + }.isRequired, +}; + +export default injectIntl(TranscriptColumn); diff --git a/src/files-and-videos/generic/table-components/table-custom-columns/index.js b/src/files-and-videos/generic/table-components/table-custom-columns/index.js index 78284945f..5c33cab8e 100644 --- a/src/files-and-videos/generic/table-components/table-custom-columns/index.js +++ b/src/files-and-videos/generic/table-components/table-custom-columns/index.js @@ -3,6 +3,7 @@ import ActiveColumn from './ActiveColumn'; import MoreInfoColumn from './MoreInfoColumn'; import StatusColumn from './StatusColumn'; import ThumbnailColumn from './ThumbnailColumn'; +import TranscriptColumn from './TranscriptColumn'; export { AccessColumn, @@ -10,4 +11,5 @@ export { MoreInfoColumn, StatusColumn, ThumbnailColumn, + TranscriptColumn, }; diff --git a/src/files-and-videos/videos-page/VideosPage.jsx b/src/files-and-videos/videos-page/VideosPage.jsx index 79ebe6ce9..d2c3b6241 100644 --- a/src/files-and-videos/videos-page/VideosPage.jsx +++ b/src/files-and-videos/videos-page/VideosPage.jsx @@ -36,6 +36,7 @@ import { FileTable, StatusColumn, ThumbnailColumn, + TranscriptColumn, } from '../generic'; import TranscriptSettings from './transcript-settings'; import VideoThumbnail from './VideoThumbnail'; @@ -107,17 +108,15 @@ const VideosPage = ({ fileType: 'video', }; const thumbnailPreview = (props) => VideoThumbnail({ ...props, handleAddThumbnail, videoImageSettings }); - const infoModalSidebar = (video) => VideoInfoModalSidebar({ video }); + const infoModalSidebar = (video, activeTab, setActiveTab) => ( + VideoInfoModalSidebar({ video, activeTab, setActiveTab }) + ); const maxFileSize = videoUploadMaxFileSize * 1073741824; const transcriptColumn = { id: 'transcriptStatus', Header: 'Transcript', accessor: 'transcriptStatus', - Cell: ({ row }) => { - const { transcripts } = row.original; - const numOfTranscripts = transcripts?.length; - return numOfTranscripts > 0 ? `(${numOfTranscripts}) available` : null; - }, + Cell: ({ row }) => TranscriptColumn({ row }), Filter: CheckboxFilter, filter: 'exactTextCase', filterChoices: [ diff --git a/src/files-and-videos/videos-page/VideosPage.test.jsx b/src/files-and-videos/videos-page/VideosPage.test.jsx index 1a4e889ce..b645c0275 100644 --- a/src/files-and-videos/videos-page/VideosPage.test.jsx +++ b/src/files-and-videos/videos-page/VideosPage.test.jsx @@ -430,73 +430,94 @@ describe('FilesAndUploads', () => { }); describe('card menu actions', () => { - it('should open video info', async () => { - renderComponent(); - await mockStore(RequestStatus.SUCCESSFUL); - expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); + describe('Info', () => { + it('should open video info', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); - const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); - expect(videoMenuButton).toBeVisible(); + const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); + expect(videoMenuButton).toBeVisible(); - axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1/usage`) - .reply(201, { usageLocations: ['subsection - unit / block'] }); - await waitFor(() => { - fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); - fireEvent.click(screen.getByText('Info')); + axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1/usage`) + .reply(201, { usageLocations: ['subsection - unit / block'] }); + await waitFor(() => { + fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); + fireEvent.click(screen.getByText('Info')); + }); + + expect(screen.getByText(messages.infoTitle.defaultMessage)).toBeVisible(); + + const { usageStatus } = store.getState().videos; + + expect(usageStatus).toEqual(RequestStatus.SUCCESSFUL); + + expect(screen.getByText('subsection - unit / block')).toBeVisible(); }); - expect(screen.getByText(messages.infoTitle.defaultMessage)).toBeVisible(); + it('should open video info modal and show info tab', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); + const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); + expect(videoMenuButton).toBeVisible(); - const { usageStatus } = store.getState().videos; + axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1/usage`).reply(201, { usageLocations: [] }); + await waitFor(() => { + fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); + fireEvent.click(screen.getByText('Info')); + }); - expect(usageStatus).toEqual(RequestStatus.SUCCESSFUL); + expect(screen.getByText(messages.usageNotInUseMessage.defaultMessage)).toBeVisible(); - expect(screen.getByText('subsection - unit / block')).toBeVisible(); - }); + const infoTab = screen.getAllByRole('tab')[0]; + expect(infoTab).toBeVisible(); - it('should open video info modal and show info tab', async () => { - renderComponent(); - await mockStore(RequestStatus.SUCCESSFUL); - expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); - const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); - expect(videoMenuButton).toBeVisible(); - - axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1/usage`).reply(201, { usageLocations: [] }); - await waitFor(() => { - fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); - fireEvent.click(screen.getByText('Info')); + expect(infoTab).toHaveClass('active'); }); - expect(screen.getByText(messages.usageNotInUseMessage.defaultMessage)).toBeVisible(); + it('should open video info modal and show transcript tab', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); + const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); + expect(videoMenuButton).toBeVisible(); - const infoTab = screen.getAllByRole('tab')[0]; - expect(infoTab).toBeVisible(); + axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1/usage`).reply(201, { usageLocations: [] }); + await waitFor(() => { + fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); + fireEvent.click(screen.getByText('Info')); + }); - expect(infoTab).toHaveClass('active'); - }); + expect(screen.getByText(messages.usageNotInUseMessage.defaultMessage)).toBeVisible(); - it('should open video info modal and show transcript tab', async () => { - renderComponent(); - await mockStore(RequestStatus.SUCCESSFUL); - expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); - const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); - expect(videoMenuButton).toBeVisible(); + const transcriptTab = screen.getAllByRole('tab')[1]; + await act(async () => { + fireEvent.click(transcriptTab); + }); + expect(transcriptTab).toBeVisible(); - axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1/usage`).reply(201, { usageLocations: [] }); - await waitFor(() => { - fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); - fireEvent.click(screen.getByText('Info')); + expect(transcriptTab).toHaveClass('active'); }); - expect(screen.getByText(messages.usageNotInUseMessage.defaultMessage)).toBeVisible(); + it('should show transcript error', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID3'); - const transcriptTab = screen.getAllByRole('tab')[1]; - await act(async () => { - fireEvent.click(transcriptTab); + axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID3/usage`).reply(201, { usageLocations: [] }); + await waitFor(() => { + fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); + fireEvent.click(screen.getByText('Info')); + }); + + const transcriptTab = screen.getAllByRole('tab')[1]; + await act(async () => { + fireEvent.click(transcriptTab); + }); + + expect(screen.getByText('Transcript (1)')).toBeVisible(); }); - expect(transcriptTab).toBeVisible(); - - expect(transcriptTab).toHaveClass('active'); }); it('download button should download file', async () => { diff --git a/src/files-and-videos/videos-page/data/constants.js b/src/files-and-videos/videos-page/data/constants.js index b534e7eb7..ab666b37a 100644 --- a/src/files-and-videos/videos-page/data/constants.js +++ b/src/files-and-videos/videos-page/data/constants.js @@ -6,3 +6,6 @@ export const MIN_WIDTH = 640; export const MIN_HEIGHT = 360; export const ASPECT_RATIO = 16 / 9; export const ASPECT_RATIO_ERROR_MARGIN = 0.1; +export const TRANSCRIPT_FAILURE_STATUSES = ['Transcript Failed', 'Partial Failure']; +export const VIDEO_PROCESSING_STATUSES = ['Uploading', 'In Progress', 'Uploaded']; +export const VIDEO_SUCCESS_STATUSES = ['Ready', 'Imported']; diff --git a/src/files-and-videos/videos-page/data/utils.js b/src/files-and-videos/videos-page/data/utils.js index 1843c46e9..4d9408607 100644 --- a/src/files-and-videos/videos-page/data/utils.js +++ b/src/files-and-videos/videos-page/data/utils.js @@ -7,6 +7,8 @@ import { MAX_WIDTH, MIN_HEIGHT, MIN_WIDTH, + VIDEO_PROCESSING_STATUSES, + VIDEO_SUCCESS_STATUSES, } from './constants'; ensureConfig([ @@ -35,9 +37,9 @@ export const updateFileValues = (files) => { const activeStatus = usageLocations?.length > 0 ? 'active' : 'inactive'; let uploadStatus = status; - if (status === 'Ready' || status === 'Imported') { + if (VIDEO_SUCCESS_STATUSES.includes(status)) { uploadStatus = 'Success'; - } else if (status === 'In Progress' || status === 'Uploaded') { + } else if (VIDEO_PROCESSING_STATUSES.includes(status)) { uploadStatus = 'Processing'; } diff --git a/src/files-and-videos/videos-page/factories/mockApiResponses.jsx b/src/files-and-videos/videos-page/factories/mockApiResponses.jsx index fc0610319..3aa621e9d 100644 --- a/src/files-and-videos/videos-page/factories/mockApiResponses.jsx +++ b/src/files-and-videos/videos-page/factories/mockApiResponses.jsx @@ -120,6 +120,7 @@ export const generateFetchVideosApiResponse = () => ({ status: 'Imported', duration: 12333, downloadLink: 'http://mOckID1.mp4', + fileSize: 213456354, }, { edx_video_id: 'mOckID5', @@ -140,6 +141,8 @@ export const generateFetchVideosApiResponse = () => ({ status: 'Ready', duration: null, downloadLink: '', + transcription_status: 'Transcript Failed', + error_description: 'Unable to process transcript request', }, ], concurrent_upload_limit: 4, diff --git a/src/files-and-videos/videos-page/info-sidebar/VideoInfoModalSidebar.jsx b/src/files-and-videos/videos-page/info-sidebar/VideoInfoModalSidebar.jsx index 2c240fb00..dc3f9fa55 100644 --- a/src/files-and-videos/videos-page/info-sidebar/VideoInfoModalSidebar.jsx +++ b/src/files-and-videos/videos-page/info-sidebar/VideoInfoModalSidebar.jsx @@ -8,13 +8,20 @@ import { import InfoTab from './InfoTab'; import TranscriptTab from './TranscriptTab'; import messages from './messages'; +import { TRANSCRIPT_FAILURE_STATUSES } from '../data/constants'; const VideoInfoModalSidebar = ({ video, + activeTab, + setActiveTab, // injected intl, }) => ( - + setActiveTab(tab)} + > @@ -24,6 +31,11 @@ const VideoInfoModalSidebar = ({ messages.transcriptTabTitle, { transcriptCount: video.transcripts.length }, )} + notification={TRANSCRIPT_FAILURE_STATUSES.includes(video.transcriptionStatus) && ( + + {intl.formatMessage(messages.notificationScreenReaderText)} + + )} > @@ -38,7 +50,10 @@ VideoInfoModalSidebar.propTypes = { dateAdded: PropTypes.string.isRequired, fileSize: PropTypes.number.isRequired, transcripts: PropTypes.arrayOf(PropTypes.string), + transcriptionStatus: PropTypes.string.isRequired, }), + activeTab: PropTypes.string.isRequired, + setActiveTab: PropTypes.func.isRequired, // injected intl: intlShape.isRequired, }; diff --git a/src/files-and-videos/videos-page/info-sidebar/messages.js b/src/files-and-videos/videos-page/info-sidebar/messages.js index 3beb245c6..a1823e466 100644 --- a/src/files-and-videos/videos-page/info-sidebar/messages.js +++ b/src/files-and-videos/videos-page/info-sidebar/messages.js @@ -11,6 +11,11 @@ const messages = defineMessages({ defaultMessage: 'Transcript ({transcriptCount})', description: 'Title for info tab', }, + notificationScreenReaderText: { + id: 'course-authoring.video-uploads.file-info.transcriptTab.notification.screenReader.text', + defaultMessage: 'Transcription error', + description: 'Scrren reader text for transcript tab notification', + }, dateAddedTitle: { id: 'course-authoring.video-uploads.file-info.infoTab.dateAdded.title', defaultMessage: 'Date added',