From 2402769d9d8316557d64cbb3f96030e657863923 Mon Sep 17 00:00:00 2001 From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com> Date: Tue, 28 Nov 2023 13:13:54 -0500 Subject: [PATCH 01/13] fix: sort of boolean columns (#705) --- src/files-and-videos/files-page/FilesPage.jsx | 18 +- .../files-page/data/thunks.js | 3 + src/files-and-videos/files-page/data/utils.js | 6 +- .../sort-and-filter-modal/utils.js | 89 +------ .../sort-and-filter-modal/utils.test.js | 240 ------------------ .../videos-page/VideosPage.jsx | 19 +- .../videos-page/data/thunks.js | 9 + .../videos-page/data/utils.js | 6 + 8 files changed, 48 insertions(+), 342 deletions(-) diff --git a/src/files-and-videos/files-page/FilesPage.jsx b/src/files-and-videos/files-page/FilesPage.jsx index 1111dbc93a..8bbf0802f3 100644 --- a/src/files-and-videos/files-page/FilesPage.jsx +++ b/src/files-and-videos/files-page/FilesPage.jsx @@ -1,6 +1,5 @@ import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; -import { isEmpty } from 'lodash'; import { useDispatch, useSelector } from 'react-redux'; import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n'; import { CheckboxFilter, Container } from '@edx/paragon'; @@ -85,25 +84,26 @@ const FilesPage = ({ const maxFileSize = 20 * 1048576; const activeColumn = { - id: 'usageLocations', + id: 'activeStatus', Header: 'Active', - accessor: (({ usageLocations }) => !isEmpty(usageLocations)), + accessor: 'activeStatus', Cell: ({ row }) => ActiveColumn({ row }), Filter: CheckboxFilter, + filter: 'exactTextCase', filterChoices: [ - { name: intl.formatMessage(messages.activeCheckboxLabel), value: true }, - { name: intl.formatMessage(messages.inactiveCheckboxLabel), value: false }, + { name: intl.formatMessage(messages.activeCheckboxLabel), value: 'active' }, + { name: intl.formatMessage(messages.inactiveCheckboxLabel), value: 'inactive' }, ], }; const accessColumn = { - id: 'locked', + id: 'lockStatus', Header: 'Access', - accessor: 'locked', + accessor: 'lockStatus', Cell: ({ row }) => AccessColumn({ row }), Filter: CheckboxFilter, filterChoices: [ - { name: intl.formatMessage(messages.lockedCheckboxLabel), value: true }, - { name: intl.formatMessage(messages.publicCheckboxLabel), value: false }, + { name: intl.formatMessage(messages.lockedCheckboxLabel), value: 'locked' }, + { name: intl.formatMessage(messages.publicCheckboxLabel), value: 'public' }, ], }; const thumbnailColumn = { diff --git a/src/files-and-videos/files-page/data/thunks.js b/src/files-and-videos/files-page/data/thunks.js index 27922f095e..5dd502a0dc 100644 --- a/src/files-and-videos/files-page/data/thunks.js +++ b/src/files-and-videos/files-page/data/thunks.js @@ -111,6 +111,7 @@ export function updateAssetLock({ assetId, courseId, locked }) { model: { id: assetId, locked, + lockStatus: locked, }, })); dispatch(updateEditStatus({ editType: 'lock', status: RequestStatus.SUCCESSFUL })); @@ -133,11 +134,13 @@ export function getUsagePaths({ asset, courseId }) { try { const { usageLocations } = await getAssetUsagePaths({ assetId: asset.id, courseId }); const assetLocations = usageLocations[asset.id]; + const activeStatus = assetLocations?.length > 0 ? 'active' : 'inactive'; dispatch(updateModel({ modelType: 'assets', model: { id: asset.id, usageLocations: assetLocations, + activeStatus, }, })); dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.SUCCESSFUL })); diff --git a/src/files-and-videos/files-page/data/utils.js b/src/files-and-videos/files-page/data/utils.js index 2526b4ad42..676c140ecd 100644 --- a/src/files-and-videos/files-page/data/utils.js +++ b/src/files-and-videos/files-page/data/utils.js @@ -24,13 +24,17 @@ export const updateFileValues = (files) => { wrapperType = 'audio'; } - const { dateAdded } = file; + const { dateAdded, locked, usageLocations } = file; const utcDateString = dateAdded.replace(/\bat\b/g, ''); const utcDateTime = new Date(utcDateString).toString(); + const lockStatus = locked ? 'locked' : 'public'; + const activeStatus = usageLocations?.length > 0 ? 'active' : 'inactive'; updatedFiles.push({ ...file, wrapperType, + lockStatus, + activeStatus, dateAdded: utcDateTime, }); }); diff --git a/src/files-and-videos/generic/table-components/sort-and-filter-modal/utils.js b/src/files-and-videos/generic/table-components/sort-and-filter-modal/utils.js index 149aed54ce..7c07deeeea 100644 --- a/src/files-and-videos/generic/table-components/sort-and-filter-modal/utils.js +++ b/src/files-and-videos/generic/table-components/sort-and-filter-modal/utils.js @@ -5,30 +5,8 @@ export const getFilterOptions = (columns) => { const filterableColumns = columns.filter(column => column?.filterChoices); filterableColumns.forEach(column => { - const { id, filterChoices } = column; - let updatedChoices = filterChoices; - - switch (id) { - case 'locked': - updatedChoices = filterChoices.map(choice => ( - { ...choice, value: choice.value ? 'locked' : 'public' } - )); - break; - case 'usageLocations': - updatedChoices = filterChoices.map(choice => ( - { ...choice, value: choice.value ? 'active' : 'inactive' } - )); - break; - case 'transcripts': - updatedChoices = filterChoices.map(choice => ( - { ...choice, value: choice.value ? 'transcribed' : 'notTranscribed' } - )); - break; - default: - break; - } - - allOptions.push(...updatedChoices); + const { filterChoices } = column; + allOptions.push(...filterChoices); }); return allOptions; @@ -39,26 +17,11 @@ export const getCheckedFilters = (state) => { const allFilters = []; filters.forEach(filter => { const { id, value } = filter; - let updatedValues = value; - - switch (id) { - case 'locked': - updatedValues = value.map(val => (val ? 'locked' : 'public')); - break; - case 'usageLocations': - updatedValues = value.map(val => (val ? 'active' : 'inactive')); - break; - case 'transcripts': - updatedValues = value.map(val => (val ? 'transcribed' : 'notTranscribed')); - break; - default: - break; - } - if (isArray(updatedValues)) { - allFilters.push(...updatedValues); + if (isArray(value)) { + allFilters.push(...value); } else { - allFilters.push([id, updatedValues]); + allFilters.push([id, value]); } }); @@ -77,47 +40,7 @@ export const processFilters = (filters, columns, setAllFilters) => { filterableColumns.forEach(({ id, filterChoices }) => { const filterValues = filterChoices.map(choice => choice.value); - let processedFilters = filters; - - switch (id) { - case 'locked': - processedFilters = filters.map(match => { - if (match === 'locked') { - return true; - } - if (match === 'public') { - return false; - } - return match; - }); - break; - case 'usageLocations': - processedFilters = filters.map(match => { - if (match === 'active') { - return true; - } - if (match === 'inactive') { - return false; - } - return match; - }); - break; - case 'transcripts': - processedFilters = filters.map(match => { - if (match === 'transcribed') { - return true; - } - if (match === 'notTranscribed') { - return false; - } - return match; - }); - break; - default: - break; - } - - const matchingFilters = filterValues.filter(value => processedFilters.includes(value)); + const matchingFilters = filterValues.filter(value => filters.includes(value)); if (!isEmpty(matchingFilters)) { allFilters.push({ id, value: matchingFilters }); diff --git a/src/files-and-videos/generic/table-components/sort-and-filter-modal/utils.test.js b/src/files-and-videos/generic/table-components/sort-and-filter-modal/utils.test.js index 55fdb3a245..824f2b66d6 100644 --- a/src/files-and-videos/generic/table-components/sort-and-filter-modal/utils.test.js +++ b/src/files-and-videos/generic/table-components/sort-and-filter-modal/utils.test.js @@ -1,84 +1,6 @@ import { getCheckedFilters, getFilterOptions, processFilters } from './utils'; describe('getCheckboxFilters', () => { - describe('switch case locked', () => { - it('should equal array with string locked', () => { - const state = { - filters: [ - { id: 'locked', value: [true] }, - ], - }; - const expected = ['locked']; - const actual = getCheckedFilters(state); - - expect(actual).toEqual(expected); - }); - - it('value attribute should equal public', () => { - const state = { - filters: [ - { id: 'locked', value: [false] }, - ], - }; - const expected = ['public']; - const actual = getCheckedFilters(state); - - expect(actual).toEqual(expected); - }); - }); - - describe('switch case usageLocations', () => { - it('value attribute should equal active', () => { - const state = { - filters: [ - { id: 'usageLocations', value: [true] }, - ], - }; - const expected = ['active']; - const actual = getCheckedFilters(state); - - expect(actual).toEqual(expected); - }); - - it('value attribute should equal inactive', () => { - const state = { - filters: [ - { id: 'usageLocations', value: [false] }, - ], - }; - const expected = ['inactive']; - const actual = getCheckedFilters(state); - - expect(actual).toEqual(expected); - }); - }); - - describe('switch case transcripts', () => { - it('should equal array with string transcribed', () => { - const state = { - filters: [ - { id: 'transcripts', value: [true] }, - ], - }; - const expected = ['transcribed']; - const actual = getCheckedFilters(state); - - expect(actual).toEqual(expected); - }); - - it('should equal array with string notTranscribed', () => { - const state = { - filters: [ - { id: 'transcripts', value: [false] }, - ], - }; - const expected = ['notTranscribed']; - const actual = getCheckedFilters(state); - - expect(actual).toEqual(expected); - }); - }); - describe('switch case default', () => { it('should equal array with string test', () => { const state = { @@ -107,84 +29,6 @@ describe('getCheckboxFilters', () => { }); describe('getFilterOptions', () => { - describe('switch case locked', () => { - it('value attribute should equal locked', () => { - const columns = [ - { id: 'locked', filterChoices: [{ name: 'Locked', value: true }] }, - ]; - const expected = [ - { name: 'Locked', value: 'locked' }, - ]; - const actual = getFilterOptions(columns); - - expect(actual).toEqual(expected); - }); - - it('value attribute should equal public', () => { - const columns = [ - { id: 'locked', filterChoices: [{ name: 'Public', value: false }] }, - ]; - const expected = [ - { name: 'Public', value: 'public' }, - ]; - const actual = getFilterOptions(columns); - - expect(actual).toEqual(expected); - }); - }); - - describe('switch case usageLocation', () => { - it('value attribute should equal active', () => { - const columns = [ - { id: 'usageLocations', filterChoices: [{ name: 'Active', value: true }] }, - ]; - const expected = [ - { name: 'Active', value: 'active' }, - ]; - const actual = getFilterOptions(columns); - - expect(actual).toEqual(expected); - }); - - it('value attribute should equal inactive', () => { - const columns = [ - { id: 'usageLocations', filterChoices: [{ name: 'Inactive', value: false }] }, - ]; - const expected = [ - { name: 'Inactive', value: 'inactive' }, - ]; - const actual = getFilterOptions(columns); - - expect(actual).toEqual(expected); - }); - }); - - describe('switch case transcripts', () => { - it('value attribute should equal transcribed', () => { - const columns = [ - { id: 'transcripts', filterChoices: [{ name: 'Transcribed', value: true }] }, - ]; - const expected = [ - { name: 'Transcribed', value: 'transcribed' }, - ]; - const actual = getFilterOptions(columns); - - expect(actual).toEqual(expected); - }); - - it('value attribute should equal notTranscribed', () => { - const columns = [ - { id: 'transcripts', filterChoices: [{ name: 'Not transcribed', value: false }] }, - ]; - const expected = [ - { name: 'Not transcribed', value: 'notTranscribed' }, - ]; - const actual = getFilterOptions(columns); - - expect(actual).toEqual(expected); - }); - }); - describe('switch case default', () => { it('value attribute should equal test', () => { const columns = [ @@ -217,90 +61,6 @@ describe('processFilters', () => { expect(setAllFilters).toHaveBeenCalledWith(expectedParameter); }); - describe('switch case locked', () => { - it('should call setAllFilters with locked filter', () => { - const filters = ['locked']; - const columns = [ - { id: 'locked', filterChoices: [{ name: 'Locked', value: true }, { name: 'Public', value: false }] }, - ]; - const expectedParameter = [{ id: 'locked', value: [true] }]; - processFilters(filters, columns, setAllFilters); - - expect(setAllFilters).toHaveBeenCalledWith(expectedParameter); - }); - - it('should call setAllFilters with public filter', () => { - const filters = ['public', 'filter']; - const columns = [ - { id: 'locked', filterChoices: [{ name: 'Public', value: false }] }, - { id: 'test', filterChoices: [{ name: 'Filter', value: 'filter' }] }, - ]; - const expectedParameter = [ - { id: 'locked', value: [false] }, - { id: 'test', value: ['filter'] }, - ]; - processFilters(filters, columns, setAllFilters); - - expect(setAllFilters).toHaveBeenCalledWith(expectedParameter); - }); - }); - - describe('switch case usageLocations', () => { - it('should call setAllFilters with active filter', () => { - const filters = ['active']; - const columns = [ - { id: 'usageLocations', filterChoices: [{ name: 'Active', value: true }] }, - ]; - const expectedParameter = [{ id: 'usageLocations', value: [true] }]; - processFilters(filters, columns, setAllFilters); - - expect(setAllFilters).toHaveBeenCalledWith(expectedParameter); - }); - - it('should call setAllFilters with inactive filter', () => { - const filters = ['inactive', 'filter']; - const columns = [ - { id: 'usageLocations', filterChoices: [{ name: 'Inactive', value: false }] }, - { id: 'test', filterChoices: [{ name: 'Filter', value: 'filter' }] }, - ]; - const expectedParameter = [ - { id: 'usageLocations', value: [false] }, - { id: 'test', value: ['filter'] }, - ]; - processFilters(filters, columns, setAllFilters); - - expect(setAllFilters).toHaveBeenCalledWith(expectedParameter); - }); - }); - - describe('switch case transcripts', () => { - it('should call setAllFilters with transcribed filter', () => { - const filters = ['transcribed', 'filter']; - const columns = [ - { id: 'transcripts', filterChoices: [{ name: 'Transcribed', value: true }] }, - { id: 'test', filterChoices: [{ name: 'Filter', value: 'filter' }] }, - ]; - const expectedParameter = [ - { id: 'transcripts', value: [true] }, - { id: 'test', value: ['filter'] }, - ]; - processFilters(filters, columns, setAllFilters); - - expect(setAllFilters).toHaveBeenCalledWith(expectedParameter); - }); - - it('should call setAllFilters with notTranscribed filter', () => { - const filters = ['notTranscribed']; - const columns = [ - { id: 'transcripts', filterChoices: [{ name: 'Not transcribed', value: false }] }, - ]; - const expectedParameter = [{ id: 'transcripts', value: [false] }]; - processFilters(filters, columns, setAllFilters); - - expect(setAllFilters).toHaveBeenCalledWith(expectedParameter); - }); - }); - describe('switch case default', () => { it('should call setAllFilters with test filter', () => { const filters = ['filter']; diff --git a/src/files-and-videos/videos-page/VideosPage.jsx b/src/files-and-videos/videos-page/VideosPage.jsx index 984bf9818a..79ebe6ce96 100644 --- a/src/files-and-videos/videos-page/VideosPage.jsx +++ b/src/files-and-videos/videos-page/VideosPage.jsx @@ -1,6 +1,5 @@ import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; -import { isEmpty } from 'lodash'; import { useDispatch, useSelector } from 'react-redux'; import { injectIntl, @@ -111,29 +110,31 @@ const VideosPage = ({ const infoModalSidebar = (video) => VideoInfoModalSidebar({ video }); const maxFileSize = videoUploadMaxFileSize * 1073741824; const transcriptColumn = { - id: 'transcripts', + id: 'transcriptStatus', Header: 'Transcript', - accessor: (({ transcripts }) => !isEmpty(transcripts)), + accessor: 'transcriptStatus', Cell: ({ row }) => { const { transcripts } = row.original; const numOfTranscripts = transcripts?.length; return numOfTranscripts > 0 ? `(${numOfTranscripts}) available` : null; }, Filter: CheckboxFilter, + filter: 'exactTextCase', filterChoices: [ - { name: intl.formatMessage(messages.transcribedCheckboxLabel), value: true }, - { name: intl.formatMessage(messages.notTranscribedCheckboxLabel), value: false }, + { name: intl.formatMessage(messages.transcribedCheckboxLabel), value: 'transcribed' }, + { name: intl.formatMessage(messages.notTranscribedCheckboxLabel), value: 'notTranscribed' }, ], }; const activeColumn = { - id: 'usageLocations', + id: 'activeStatus', Header: 'Active', - accessor: (({ usageLocations }) => !isEmpty(usageLocations)), + accessor: 'activeStatus', Cell: ({ row }) => ActiveColumn({ row }), Filter: CheckboxFilter, + filter: 'exactTextCase', filterChoices: [ - { name: intl.formatMessage(messages.activeCheckboxLabel), value: true }, - { name: intl.formatMessage(messages.inactiveCheckboxLabel), value: false }, + { name: intl.formatMessage(messages.activeCheckboxLabel), value: 'active' }, + { name: intl.formatMessage(messages.inactiveCheckboxLabel), value: 'inactive' }, ], }; const durationColumn = { diff --git a/src/files-and-videos/videos-page/data/thunks.js b/src/files-and-videos/videos-page/data/thunks.js index 42547a4719..72719bc1ec 100644 --- a/src/files-and-videos/videos-page/data/thunks.js +++ b/src/files-and-videos/videos-page/data/thunks.js @@ -176,11 +176,14 @@ export function deleteVideoTranscript({ apiUrl, }); const updatedTranscripts = transcripts.filter(transcript => transcript !== language); + const transcriptStatus = updatedTranscripts?.length > 0 ? 'transcribed' : 'notTranscribed'; + dispatch(updateModel({ modelType: 'videos', model: { id: videoId, transcripts: updatedTranscripts, + transcriptStatus, }, })); @@ -244,11 +247,14 @@ export function uploadVideoTranscript({ updatedTranscripts = [...transcripts, newLanguage]; } + const transcriptStatus = updatedTranscripts?.length > 0 ? 'transcribed' : 'notTranscribed'; + dispatch(updateModel({ modelType: 'videos', model: { id: videoId, transcripts: updatedTranscripts, + transcriptStatus, }, })); @@ -272,11 +278,14 @@ export function getUsagePaths({ video, courseId }) { try { const { usageLocations } = await getVideoUsagePaths({ videoId: video.id, courseId }); + const activeStatus = usageLocations?.length > 0 ? 'active' : 'inactive'; + dispatch(updateModel({ modelType: 'videos', model: { id: video.id, usageLocations, + activeStatus, }, })); dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.SUCCESSFUL })); diff --git a/src/files-and-videos/videos-page/data/utils.js b/src/files-and-videos/videos-page/data/utils.js index a5c4963e40..1843c46e93 100644 --- a/src/files-and-videos/videos-page/data/utils.js +++ b/src/files-and-videos/videos-page/data/utils.js @@ -22,6 +22,8 @@ export const updateFileValues = (files) => { created, courseVideoImageUrl, status, + transcripts, + usageLocations, } = file; const wrapperType = 'video'; @@ -29,6 +31,8 @@ export const updateFileValues = (files) => { if (thumbnail && thumbnail.startsWith('/')) { thumbnail = `${getConfig().STUDIO_BASE_URL}${thumbnail}`; } + const transcriptStatus = transcripts?.length > 0 ? 'transcribed' : 'notTranscribed'; + const activeStatus = usageLocations?.length > 0 ? 'active' : 'inactive'; let uploadStatus = status; if (status === 'Ready' || status === 'Imported') { @@ -45,6 +49,8 @@ export const updateFileValues = (files) => { dateAdded: created.toString(), status: uploadStatus, thumbnail, + transcriptStatus, + activeStatus, }); }); 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 02/13] 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 8f81149c4c..6b9c5ee5c8 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 })}
- -
-
-
-
- {sidebar(file)} + )} + variant="danger" + /> + )} +
+
+ + +
+
+ +
+ +
+
+
+
+ {sidebar(file, activeTab, setActiveTab)} +
-
- - -); + + + ); +}; 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 0449cc8705..a9dc9d93ec 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 757e871f21..ba62f2b2d7 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 4d8a1eacee..d82173b263 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 0000000000..0f445c5c3e --- /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 78284945fc..5c33cab8e6 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 79ebe6ce96..d2c3b6241c 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 1a4e889ce6..b645c0275a 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(); + expect(screen.getByText(messages.infoTitle.defaultMessage)).toBeVisible(); - const { usageStatus } = store.getState().videos; + const { usageStatus } = store.getState().videos; - expect(usageStatus).toEqual(RequestStatus.SUCCESSFUL); + expect(usageStatus).toEqual(RequestStatus.SUCCESSFUL); - expect(screen.getByText('subsection - unit / block')).toBeVisible(); - }); + expect(screen.getByText('subsection - unit / block')).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(); + 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')); + 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(screen.getByText(messages.usageNotInUseMessage.defaultMessage)).toBeVisible(); + + const infoTab = screen.getAllByRole('tab')[0]; + expect(infoTab).toBeVisible(); + + 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); - }); - expect(transcriptTab).toBeVisible(); + axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID3/usage`).reply(201, { usageLocations: [] }); + await waitFor(() => { + fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); + fireEvent.click(screen.getByText('Info')); + }); - expect(transcriptTab).toHaveClass('active'); + const transcriptTab = screen.getAllByRole('tab')[1]; + await act(async () => { + fireEvent.click(transcriptTab); + }); + + expect(screen.getByText('Transcript (1)')).toBeVisible(); + }); }); 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 b534e7eb73..ab666b37aa 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 1843c46e93..4d9408607b 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 fc0610319d..3aa621e9d9 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 2c240fb006..dc3f9fa551 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 3beb245c61..a1823e4667 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', From ac1fc43250b70453b5f8f33ca79b03fdaf3c660b Mon Sep 17 00:00:00 2001 From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com> Date: Thu, 30 Nov 2023 13:21:34 -0500 Subject: [PATCH 03/13] fix: visibility of transcript dropdowns (#719) --- .../info-sidebar/transcript-item/LanguageSelect.jsx | 2 +- .../videos-page/info-sidebar/transcript-item/Transcript.jsx | 5 +++-- .../info-sidebar/transcript-item/TranscriptMenu.jsx | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/files-and-videos/videos-page/info-sidebar/transcript-item/LanguageSelect.jsx b/src/files-and-videos/videos-page/info-sidebar/transcript-item/LanguageSelect.jsx index 024c14eff1..f513927a33 100644 --- a/src/files-and-videos/videos-page/info-sidebar/transcript-item/LanguageSelect.jsx +++ b/src/files-and-videos/videos-page/info-sidebar/transcript-item/LanguageSelect.jsx @@ -24,7 +24,7 @@ const LanguageSelect = ({ > {currentSelection} - +
{Object.entries(options).map(([valueKey, text]) => { if (valueKey === value) { diff --git a/src/files-and-videos/videos-page/info-sidebar/transcript-item/Transcript.jsx b/src/files-and-videos/videos-page/info-sidebar/transcript-item/Transcript.jsx index 993aea8c25..af8351a008 100644 --- a/src/files-and-videos/videos-page/info-sidebar/transcript-item/Transcript.jsx +++ b/src/files-and-videos/videos-page/info-sidebar/transcript-item/Transcript.jsx @@ -54,18 +54,19 @@ const Transcript = ({ <> {isConfirmationOpen ? ( - )} /> + )} /> -