diff --git a/src/files-and-videos/files-page/FilesPage.jsx b/src/files-and-videos/files-page/FilesPage.jsx index 3439b8cd3..318c5e333 100644 --- a/src/files-and-videos/files-page/FilesPage.jsx +++ b/src/files-and-videos/files-page/FilesPage.jsx @@ -1,5 +1,6 @@ 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 } from '@edx/paragon'; @@ -85,12 +86,24 @@ const FilesPage = ({ const activeColumn = { id: 'usageLocations', Header: 'Active', + accessor: (({ usageLocations }) => !isEmpty(usageLocations)), Cell: ({ row }) => ActiveColumn({ row }), + Filter: CheckboxFilter, + filterChoices: [ + { name: intl.formatMessage(messages.activeCheckboxLabel), value: true }, + { name: intl.formatMessage(messages.inactiveCheckboxLabel), value: false }, + ], }; const accessColumn = { id: 'locked', Header: 'Access', + accessor: 'locked', Cell: ({ row }) => AccessColumn({ row }), + Filter: CheckboxFilter, + filterChoices: [ + { name: intl.formatMessage(messages.lockedCheckboxLabel), value: true }, + { name: intl.formatMessage(messages.publicCheckboxLabel), value: false }, + ], }; const thumbnailColumn = { id: 'thumbnail', @@ -100,6 +113,7 @@ const FilesPage = ({ const fileSizeColumn = { id: 'fileSize', Header: 'File size', + accessor: 'fileSize', Cell: ({ row }) => { const { fileSize } = row.original; return getFileSizeToClosestByte(fileSize); @@ -120,21 +134,25 @@ const FilesPage = ({ filter: 'includesValue', filterChoices: [ { - name: 'Code', + name: intl.formatMessage(messages.codeCheckboxLabel), value: 'code', }, { - name: 'Images', + name: intl.formatMessage(messages.imageCheckboxLabel), value: 'image', }, { - name: 'Documents', + name: intl.formatMessage(messages.documentCheckboxLabel), value: 'document', }, { - name: 'Audio', + name: intl.formatMessage(messages.audioCheckboxLabel), value: 'audio', }, + { + name: intl.formatMessage(messages.otherCheckboxLabel), + value: 'other', + }, ], }, { ...activeColumn }, diff --git a/src/files-and-videos/files-page/messages.js b/src/files-and-videos/files-page/messages.js index ccc1273dc..b06b48d45 100644 --- a/src/files-and-videos/files-page/messages.js +++ b/src/files-and-videos/files-page/messages.js @@ -45,6 +45,42 @@ const messages = defineMessages({ you lock a file, the web URL only allows learners who are enrolled in your course and signed in to access the file.`, }, + activeCheckboxLabel: { + id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.activeCheckbox.label', + defaultMessage: 'Active', + }, + inactiveCheckboxLabel: { + id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.inactiveCheckbox.label', + defaultMessage: 'Inactive', + }, + lockedCheckboxLabel: { + id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.lockedCheckbox.label', + defaultMessage: 'Locked', + }, + publicCheckboxLabel: { + id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.publicCheckbox.label', + defaultMessage: 'Public', + }, + codeCheckboxLabel: { + id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.codeCheckbox.label', + defaultMessage: 'Code', + }, + imageCheckboxLabel: { + id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.imageCheckbox.label', + defaultMessage: 'Images', + }, + documentCheckboxLabel: { + id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.documentCheckbox.label', + defaultMessage: 'Documents', + }, + audioCheckboxLabel: { + id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.audioCheckbox.label', + defaultMessage: 'Audio', + }, + otherCheckboxLabel: { + id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.otherCheckbox.label', + defaultMessage: 'Other', + }, }); export default messages; diff --git a/src/files-and-videos/generic/FileTable.jsx b/src/files-and-videos/generic/FileTable.jsx index 065df94f6..9ad2a0087 100644 --- a/src/files-and-videos/generic/FileTable.jsx +++ b/src/files-and-videos/generic/FileTable.jsx @@ -22,10 +22,11 @@ import FileInput, { useFileInput } from './FileInput'; import { GalleryCard, TableActions, + RowStatus, + MoreInfoColumn, + FilterStatus, } from './table-components'; import ApiStatusToast from './ApiStatusToast'; -import FilterStatus from './table-components/FilterStatus'; -import MoreInfoColumn from './table-components/table-custom-columns/MoreInfoColumn'; const FileTable = ({ files, @@ -171,7 +172,7 @@ const FileTable = ({ } return ( - <> +
{isEmpty(files) && loadingStatus !== RequestStatus.IN_PROGRESS ? ( {intl.formatMessage(messages.deleteConfirmationMessage, { fileNumber: selectedRows.length })} - +
); }; diff --git a/src/files-and-videos/generic/messages.js b/src/files-and-videos/generic/messages.js index 883bcaa7b..c7340d3bc 100644 --- a/src/files-and-videos/generic/messages.js +++ b/src/files-and-videos/generic/messages.js @@ -1,6 +1,10 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ + rowStatusMessage: { + id: 'course-authoring.files-and-upload.rowStatus.message', + defaultMessage: 'Showing {fileCount} of {rowCount}', + }, apiStatusToastMessage: { id: 'course-authoring.files-and-upload.apiStatus.message', defaultMessage: '{actionType} {selectedRowCount} file(s)', @@ -95,7 +99,7 @@ const messages = defineMessages({ }, sortButtonLabel: { id: 'course-authoring.files-and-uploads.sortButton.label', - defaultMessage: 'Sort', + defaultMessage: 'Sort and Filter', }, sortModalTitleLabel: { id: 'course-authoring.files-and-uploads.sortModal.title', diff --git a/src/files-and-videos/generic/table-components/FilterStatus.jsx b/src/files-and-videos/generic/table-components/FilterStatus.jsx index 88fe561db..9c8f5e7c5 100644 --- a/src/files-and-videos/generic/table-components/FilterStatus.jsx +++ b/src/files-and-videos/generic/table-components/FilterStatus.jsx @@ -1,46 +1,56 @@ import React, { useContext } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; -import { DataTableContext, Button } from '@edx/paragon'; +import { + DataTableContext, Button, Row, Chip, +} from '@edx/paragon'; +import { Close } from '@edx/paragon/icons'; +import { getFilters, removeFilter } from './utils'; const FilterStatus = ({ className, variant, size, clearFiltersText, buttonClassName, }) => { const { - setAllFilters, RowStatusComponent, page, rows, + state, setAllFilters, setFilter, RowStatusComponent, columns, } = useContext(DataTableContext); + if (!setAllFilters) { return null; } - const RowStatus = RowStatusComponent; - - const pageSize = page?.length || rows?.length; + const filters = getFilters(state, columns); return (
-
- Filters applied - {!!pageSize && ' ('} - - {!!pageSize && ')'} -
- + + + Filters applied + {filters.map(({ name, value }) => ( + removeFilter(value, setFilter, setAllFilters, state)} + > + {name} + + ))} + +
); }; diff --git a/src/files-and-videos/generic/table-components/RowStatus.jsx b/src/files-and-videos/generic/table-components/RowStatus.jsx new file mode 100644 index 000000000..0733e8757 --- /dev/null +++ b/src/files-and-videos/generic/table-components/RowStatus.jsx @@ -0,0 +1,30 @@ +import React, { useContext } from 'react'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { DataTableContext } from '@edx/paragon'; +import { getCurrentViewRange } from './utils'; + +const RowStatus = ({ + // injected + intl, +}) => { + const { filteredRows, page, initialRows } = useContext(DataTableContext); + + return ( +
+ + {getCurrentViewRange({ + filterRowCount: filteredRows.length, + initialRowCount: initialRows.length, + fileCount: page.length, + intl, + })} + +
+ ); +}; + +RowStatus.propTypes = { + intl: intlShape.isRequired, +}; + +export default injectIntl(RowStatus); diff --git a/src/files-and-videos/generic/table-components/TableActions.jsx b/src/files-and-videos/generic/table-components/TableActions.jsx index af8cb49df..d36f0606c 100644 --- a/src/files-and-videos/generic/table-components/TableActions.jsx +++ b/src/files-and-videos/generic/table-components/TableActions.jsx @@ -1,18 +1,16 @@ -import React, { useState } from 'react'; +import React from 'react'; import _ from 'lodash'; import { PropTypes } from 'prop-types'; -import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n'; +import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; import { getConfig } from '@edx/frontend-platform'; import { - ActionRow, Button, Dropdown, - ModalDialog, - SelectableBox, useToggle, } from '@edx/paragon'; -import { Add } from '@edx/paragon/icons'; +import { Add, Tune } from '@edx/paragon/icons'; import messages from '../messages'; +import SortAndFilterModal from './sort-and-filter-modal'; const TableActions = ({ selectedFlatRows, @@ -21,17 +19,11 @@ const TableActions = ({ handleBulkDownload, handleOpenDeleteConfirmation, encodingsDownloadUrl, - // injected - intl, }) => { const [isSortOpen, openSort, closeSort] = useToggle(false); - const [sortBy, setSortBy] = useState('dateAdded,desc'); - const handleChange = (e) => { - setSortBy(e.target.value); - }; return ( <> - @@ -70,94 +62,7 @@ const TableActions = ({ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + ); }; @@ -186,8 +91,6 @@ TableActions.propTypes = { handleBulkDownload: PropTypes.func.isRequired, encodingsDownloadUrl: PropTypes.string, handleSort: PropTypes.func.isRequired, - // injected - intl: intlShape.isRequired, }; TableActions.defaultProps = { diff --git a/src/files-and-videos/generic/table-components/index.js b/src/files-and-videos/generic/table-components/index.js index 960dc072e..f81bce1cc 100644 --- a/src/files-and-videos/generic/table-components/index.js +++ b/src/files-and-videos/generic/table-components/index.js @@ -1,5 +1,7 @@ import GalleryCard from './GalleryCard'; import TableActions from './TableActions'; +import FilterStatus from './FilterStatus'; +import RowStatus from './RowStatus'; import { AccessColumn, ActiveColumn, @@ -11,6 +13,8 @@ import { export { TableActions, GalleryCard, + FilterStatus, + RowStatus, AccessColumn, ActiveColumn, MoreInfoColumn, diff --git a/src/files-and-videos/generic/table-components/sort-and-filter-modal/SortAndFilterModal.jsx b/src/files-and-videos/generic/table-components/sort-and-filter-modal/SortAndFilterModal.jsx new file mode 100644 index 000000000..1249a8373 --- /dev/null +++ b/src/files-and-videos/generic/table-components/sort-and-filter-modal/SortAndFilterModal.jsx @@ -0,0 +1,180 @@ +import React, { useContext, useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n'; +import { + ActionRow, + Button, + DataTableContext, + Form, + ModalDialog, + SelectableBox, + useCheckboxSetValues, +} from '@edx/paragon'; +import messages from './messages'; +import { getCheckedFilters, getFilterOptions, processFilters } from './utils'; + +const SortAndFilterModal = ({ + isSortOpen, + closeSort, + handleSort, + // injected + intl, +}) => { + const { state, setAllFilters, columns } = useContext(DataTableContext); + const filterOptions = getFilterOptions(columns); + const currentFilters = getCheckedFilters(state); + const [sortBy, setSortBy] = useState('dateAdded,desc'); + const [filterBy, { + add, remove, set, clear, + }] = useCheckboxSetValues(currentFilters); + + useEffect(() => { + const updatedFilters = getCheckedFilters(state); + set(updatedFilters); + }, [state]); + + const handleChange = (e) => { + setSortBy(e.target.value); + }; + + const handleFilterUpdate = (e) => { + if (e.target.checked) { + add(e.target.value); + } else { + remove(e.target.value); + } + }; + + const handleApply = () => { + closeSort(); + handleSort(sortBy); + processFilters(filterBy, columns, setAllFilters); + }; + + const handleClearAll = () => { + setSortBy('dateAdded,desc'); + clear(); + }; + + return ( + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + + {filterOptions.map(({ name, value }) => ( + {name} + ))} + + + +
+
+ + + + + + + + +
+ ); +}; + +SortAndFilterModal.propTypes = { + handleSort: PropTypes.func.isRequired, + isSortOpen: PropTypes.bool.isRequired, + closeSort: PropTypes.func.isRequired, + // injected + intl: intlShape.isRequired, +}; + +export default injectIntl(SortAndFilterModal); diff --git a/src/files-and-videos/generic/table-components/sort-and-filter-modal/index.js b/src/files-and-videos/generic/table-components/sort-and-filter-modal/index.js new file mode 100644 index 000000000..08166f3e0 --- /dev/null +++ b/src/files-and-videos/generic/table-components/sort-and-filter-modal/index.js @@ -0,0 +1,3 @@ +import SortAndFilterModal from './SortAndFilterModal'; + +export default SortAndFilterModal; diff --git a/src/files-and-videos/generic/table-components/sort-and-filter-modal/messages.js b/src/files-and-videos/generic/table-components/sort-and-filter-modal/messages.js new file mode 100644 index 000000000..13165bde5 --- /dev/null +++ b/src/files-and-videos/generic/table-components/sort-and-filter-modal/messages.js @@ -0,0 +1,54 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + modalTitle: { + id: 'course-authoring.files-and-videos.sort-and-filter.modal.title', + defaultMessage: 'Sort and Filter', + }, + sortByHeader: { + id: 'course-authoring.files-and-videos.sort-and-filter.modal.sortBySection.header', + defaultMessage: 'Sort by', + }, + filterByHeader: { + id: 'course-authoring.files-and-videos.sort-and-filter.modal.filterBySection.header', + defaultMessage: 'Filter by', + }, + clearAllButtonLabel: { + id: 'course-authoring.files-and-videos.sort-and-filter.modal.clearAllButton.label', + defaultMessage: 'Clear all', + }, + cancelButtonLabel: { + id: 'course-authoring.files-and-videos.sort-and-filter.modal.cancelButton.label', + defaultMessage: 'Cancel', + }, + sortByNameAscending: { + id: 'course-authoring..files-and-videos.sort-and-filter.modal.sortByNameAscendingButton.label', + defaultMessage: 'Name (A-Z)', + }, + sortByNewest: { + id: 'course-authoring..files-and-videos.sort-and-filter.modal.sortByNewestButton.label', + defaultMessage: 'Newest', + }, + sortBySizeDescending: { + id: 'course-authoring..files-and-videos.sort-and-filter.modal.sortBySizeDescendingButton.label', + defaultMessage: 'File size (High to low)', + }, + sortByNameDescending: { + id: 'course-authoring..files-and-videos.sort-and-filter.modal.sortByNameDescendingButton.label', + defaultMessage: 'Name (Z-A)', + }, + sortByOldest: { + id: 'course-authoring..files-and-videos.sort-and-filter.modal.sortByOldestButton.label', + defaultMessage: 'Oldest', + }, + sortBySizeAscending: { + id: 'course-authoring..files-and-videos.sort-and-filter.modal.sortBySizeAscendingButton.label', + defaultMessage: 'File size (Low to high)', + }, + applySortButton: { + id: 'course-authoring..files-and-videos.sort-and-filter.modal.applyySortButton.label', + defaultMessage: 'Apply', + }, +}); + +export default messages; 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 new file mode 100644 index 000000000..9f3f738f3 --- /dev/null +++ b/src/files-and-videos/generic/table-components/sort-and-filter-modal/utils.js @@ -0,0 +1,119 @@ +import { isEmpty } from 'lodash'; + +export const getFilterOptions = (columns) => { + const allOptions = []; + 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); + }); + + return allOptions; +}; + +export const getCheckedFilters = (state) => { + const { filters } = 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; + } + + allFilters.push(...updatedValues); + }); + + return allFilters; +}; + +export const processFilters = (filters, columns, setAllFilters) => { + const filterableColumns = columns.filter(column => column?.filterChoices); + const allFilters = []; + + 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)); + + if (!isEmpty(matchingFilters)) { + allFilters.push({ id, value: matchingFilters }); + } + }); + + setAllFilters(allFilters); +}; 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 new file mode 100644 index 000000000..39d37b2f2 --- /dev/null +++ b/src/files-and-videos/generic/table-components/sort-and-filter-modal/utils.test.js @@ -0,0 +1,304 @@ +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 = { + filters: [ + { id: 'testId', value: ['testValue'] }, + ], + }; + const expected = ['testValue']; + const actual = getCheckedFilters(state); + + expect(actual).toEqual(expected); + }); + }); +}); + +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 = [ + { id: 'other', filterChoices: [{ name: 'Test', value: 'test' }] }, + ]; + const expected = [ + { name: 'Test', value: 'test' }, + ]; + const actual = getFilterOptions(columns); + + expect(actual).toEqual(expected); + }); + }); +}); + +describe('processFilters', () => { + const setAllFilters = jest.fn(); + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should call setAllFilters with an empty array', () => { + const filters = []; + const columns = [ + { id: 'locked', filterChoices: [{ name: 'Locked', value: true }] }, + ]; + const expectedParameter = []; + processFilters(filters, columns, setAllFilters); + + 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']; + const columns = [ + { id: 'test', filterChoices: [{ name: 'Filter', value: 'filter' }] }, + ]; + const expectedParameter = [{ id: 'test', value: ['filter'] }]; + processFilters(filters, columns, setAllFilters); + + expect(setAllFilters).toHaveBeenCalledWith(expectedParameter); + }); + }); +}); diff --git a/src/files-and-videos/generic/table-components/table-custom-columns/StatusColumn.jsx b/src/files-and-videos/generic/table-components/table-custom-columns/StatusColumn.jsx index 8773042e1..d28f4ad90 100644 --- a/src/files-and-videos/generic/table-components/table-custom-columns/StatusColumn.jsx +++ b/src/files-and-videos/generic/table-components/table-custom-columns/StatusColumn.jsx @@ -4,6 +4,12 @@ import { Badge } from '@edx/paragon'; const StatusColumn = ({ row }) => { const { status } = row.original; + const isUploaded = status === 'Success'; + + if (isUploaded) { + return null; + } + return ( {status} diff --git a/src/files-and-videos/generic/table-components/utils.js b/src/files-and-videos/generic/table-components/utils.js new file mode 100644 index 000000000..d4bb6fd9d --- /dev/null +++ b/src/files-and-videos/generic/table-components/utils.js @@ -0,0 +1,55 @@ +import { isEmpty } from 'lodash'; +import messages from '../messages'; + +const getFilterDisplayName = (column, values) => { + const displayNames = []; + const { filterChoices } = column; + values.forEach(value => { + const [displayName] = filterChoices.filter(choice => choice.value === value); + displayNames.push(displayName); + }); + return displayNames; +}; + +export const getFilters = (state, columns) => { + const { filters } = state; + const filterableColumns = columns.filter(column => column?.filterChoices); + const allFilters = []; + filters.forEach(filter => { + const { id, value } = filter; + const [filterColumn] = filterableColumns.filter(column => column.id === id); + const currentFilters = getFilterDisplayName(filterColumn, value); + allFilters.push(...currentFilters); + }); + return allFilters; +}; + +export const removeFilter = (filter, setFilter, setAllFilters, state) => { + const { filters } = state; + const [editedFilter] = filters.filter(currentFilter => currentFilter.value.includes(filter)); + const updatedFilterValue = editedFilter.value.filter(value => value !== filter); + if (isEmpty(updatedFilterValue)) { + const updatedFilters = filters.filter(currentFilter => currentFilter.id !== editedFilter.id); + setAllFilters(updatedFilters); + } else { + setFilter(editedFilter.id, updatedFilterValue); + } +}; + +export const getCurrentViewRange = ({ + filterRowCount, + initialRowCount, + fileCount, + intl, +}) => { + if (filterRowCount === initialRowCount) { + return intl.formatMessage( + messages.rowStatusMessage, + { fileCount, rowCount: initialRowCount }, + ); + } + return intl.formatMessage( + messages.rowStatusMessage, + { fileCount, rowCount: filterRowCount }, + ); +}; diff --git a/src/files-and-videos/index.scss b/src/files-and-videos/index.scss index 98679e50d..32c5d08f9 100644 --- a/src/files-and-videos/index.scss +++ b/src/files-and-videos/index.scss @@ -1,3 +1,21 @@ @import "files-and-videos/videos-page/transcript-settings/TranscriptSettings"; @import "files-and-videos/videos-page/VideoThumbnail"; -@import "files-and-videos/generic/table-components/GalleryCard" +@import "files-and-videos/generic/table-components/GalleryCard"; + +.files-table { + #table-filters-dropdown { + visibility: hidden; + } +} + +.pgn__form-control-set-inline { + .pgn__form-checkbox { + margin: 0; + } + + width: 90%; + display: inline-grid; + grid-auto-flow: row; + gap: 24px 16px; + grid-template-columns: repeat(3, 33%); +} diff --git a/src/files-and-videos/videos-page/VideoThumbnail.jsx b/src/files-and-videos/videos-page/VideoThumbnail.jsx index d9378fa18..f86b3e00d 100644 --- a/src/files-and-videos/videos-page/VideoThumbnail.jsx +++ b/src/files-and-videos/videos-page/VideoThumbnail.jsx @@ -38,18 +38,7 @@ const VideoThumbnail = ({ } const supportedFiles = videoImageSettings?.supportedFileFormats ? Object.values(videoImageSettings.supportedFileFormats) : null; - let isUploaded = false; - - switch (status) { - case 'Ready': - isUploaded = true; - break; - case 'Imported': - isUploaded = true; - break; - default: - break; - } + const isUploaded = status === 'Success'; const showThumbnail = allowThumbnailUpload && thumbnail && isUploaded; diff --git a/src/files-and-videos/videos-page/VideosPage.jsx b/src/files-and-videos/videos-page/VideosPage.jsx index a1774f9c6..448f3ccec 100644 --- a/src/files-and-videos/videos-page/VideosPage.jsx +++ b/src/files-and-videos/videos-page/VideosPage.jsx @@ -1,5 +1,6 @@ import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; +import { isEmpty } from 'lodash'; import { useDispatch, useSelector } from 'react-redux'; import { injectIntl, @@ -10,6 +11,7 @@ import { useToggle, ActionRow, Button, + CheckboxFilter, } from '@edx/paragon'; import Placeholder from '@edx/frontend-lib-content-components'; @@ -109,20 +111,33 @@ const VideosPage = ({ const transcriptColumn = { id: 'transcripts', Header: 'Transcript', + accessor: (({ transcripts }) => !isEmpty(transcripts)), Cell: ({ row }) => { const { transcripts } = row.original; const numOfTranscripts = transcripts?.length; return numOfTranscripts > 0 ? `(${numOfTranscripts}) available` : null; }, + Filter: CheckboxFilter, + filterChoices: [ + { name: intl.formatMessage(messages.transcribedCheckboxLabel), value: true }, + { name: intl.formatMessage(messages.notTranscribedCheckboxLabel), value: false }, + ], }; const activeColumn = { id: 'usageLocations', Header: 'Active', + accessor: (({ usageLocations }) => !isEmpty(usageLocations)), Cell: ({ row }) => ActiveColumn({ row }), + Filter: CheckboxFilter, + filterChoices: [ + { name: intl.formatMessage(messages.activeCheckboxLabel), value: true }, + { name: intl.formatMessage(messages.inactiveCheckboxLabel), value: false }, + ], }; const durationColumn = { id: 'duration', Header: 'Video length', + accessor: 'duration', Cell: ({ row }) => { const { duration } = row.original; return getFormattedDuration(duration); @@ -132,6 +147,8 @@ const VideosPage = ({ id: 'status', Header: '', Cell: ({ row }) => StatusColumn({ row }), + Filter: CheckboxFilter, + filterChoices: [{ name: intl.formatMessage(messages.processingCheckboxLabel), value: 'Processing' }], }; const videoThumbnailColumn = { id: 'courseVideoImageUrl', diff --git a/src/files-and-videos/videos-page/VideosPage.test.jsx b/src/files-and-videos/videos-page/VideosPage.test.jsx index 41ca144e4..ad16c30cf 100644 --- a/src/files-and-videos/videos-page/VideosPage.test.jsx +++ b/src/files-and-videos/videos-page/VideosPage.test.jsx @@ -336,38 +336,84 @@ describe('FilesAndUploads', () => { expect(updateStatus).toEqual(RequestStatus.SUCCESSFUL); }); - it('sort button should be enabled and sort files by name', async () => { - renderComponent(); - await mockStore(RequestStatus.SUCCESSFUL); - const sortsButton = screen.getByText(messages.sortButtonLabel.defaultMessage); - expect(sortsButton).toBeVisible(); + describe('Sort and filter button', () => { + beforeEach(async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + const sortAndFilterButton = screen.getByText(messages.sortButtonLabel.defaultMessage); - await waitFor(() => { - fireEvent.click(sortsButton); - expect(screen.getByText(messages.sortModalTitleLabel.defaultMessage)).toBeVisible(); + await waitFor(() => { + fireEvent.click(sortAndFilterButton); + }); }); - const sortNameAscendingButton = screen.getByText(messages.sortByNameAscending.defaultMessage); - fireEvent.click(sortNameAscendingButton); - fireEvent.click(screen.getByText(messages.applySortButton.defaultMessage)); - expect(screen.queryByText(messages.sortModalTitleLabel.defaultMessage)).toBeNull(); - }); + describe('sort function', () => { + it('should be enabled and sort files by name', async () => { + const sortNameAscendingButton = screen.getByText(messages.sortByNameAscending.defaultMessage); + fireEvent.click(sortNameAscendingButton); + fireEvent.click(screen.getByText(messages.applySortButton.defaultMessage)); - it('sort button should be enabled and sort files by file size', async () => { - renderComponent(); - await mockStore(RequestStatus.SUCCESSFUL); - const sortsButton = screen.getByText(messages.sortButtonLabel.defaultMessage); - expect(sortsButton).toBeVisible(); + expect(screen.queryByText(messages.sortModalTitleLabel.defaultMessage)).toBeNull(); + }); - await waitFor(() => { - fireEvent.click(sortsButton); - expect(screen.getByText(messages.sortModalTitleLabel.defaultMessage)).toBeVisible(); + it('sort button should be enabled and sort files by file size', async () => { + const sortBySizeDescendingButton = screen.getByText(messages.sortBySizeDescending.defaultMessage); + fireEvent.click(sortBySizeDescendingButton); + fireEvent.click(screen.getByText(messages.applySortButton.defaultMessage)); + + expect(screen.queryByText(messages.sortModalTitleLabel.defaultMessage)).toBeNull(); + }); }); - const sortBySizeDescendingButton = screen.getByText(messages.sortBySizeDescending.defaultMessage); - fireEvent.click(sortBySizeDescendingButton); - fireEvent.click(screen.getByText(messages.applySortButton.defaultMessage)); - expect(screen.queryByText(messages.sortModalTitleLabel.defaultMessage)).toBeNull(); + describe('filter function', () => { + it('should filter videos with transcripts', async () => { + const notTranscribedCheckboxFilter = screen.getByText( + videoMessages.notTranscribedCheckboxLabel.defaultMessage, + ); + const transcribedCheckboxFilter = screen.getByText(videoMessages.transcribedCheckboxLabel.defaultMessage); + fireEvent.click(transcribedCheckboxFilter); + fireEvent.click(notTranscribedCheckboxFilter); + fireEvent.click(transcribedCheckboxFilter); + fireEvent.click(screen.getByText(messages.applySortButton.defaultMessage)); + + const galleryCards = screen.getAllByTestId('grid-card', { exact: false }); + + expect(galleryCards).toHaveLength(1); + }); + + it('should clearAll selections', async () => { + const sortByNewest = screen.getByText(messages.sortByNewest.defaultMessage); + const sortBySizeDescendingButton = screen.getByText(messages.sortBySizeDescending.defaultMessage); + const transcribedCheckboxFilter = screen.getByLabelText( + videoMessages.transcribedCheckboxLabel.defaultMessage, + ); + + fireEvent.click(sortBySizeDescendingButton); + fireEvent.click(transcribedCheckboxFilter); + + const clearAllButton = screen.getByText('Clear all'); + await waitFor(() => fireEvent.click(clearAllButton)); + + expect(transcribedCheckboxFilter).toHaveProperty('checked', false); + + expect(within(sortBySizeDescendingButton).getByLabelText('file size descending radio')) + .toHaveProperty('checked', false); + + expect(within(sortByNewest).getByLabelText('date added descending radio')) + .toHaveProperty('checked', true); + }); + + it('should remove Transcribed filter chip', async () => { + const transcribedCheckboxFilter = screen.getByText(videoMessages.transcribedCheckboxLabel.defaultMessage); + fireEvent.click(transcribedCheckboxFilter); + fireEvent.click(screen.getByText(messages.applySortButton.defaultMessage)); + + const imageFilterChip = screen.getByTestId('icon-after'); + fireEvent.click(imageFilterChip); + + expect(screen.queryByText(videoMessages.transcribedCheckboxLabel.defaultMessage)).toBeNull(); + }); + }); }); }); diff --git a/src/files-and-videos/videos-page/data/utils.js b/src/files-and-videos/videos-page/data/utils.js index 9676e0649..f75fc93e0 100644 --- a/src/files-and-videos/videos-page/data/utils.js +++ b/src/files-and-videos/videos-page/data/utils.js @@ -21,6 +21,7 @@ export const updateFileValues = (files) => { clientVideoId, created, courseVideoImageUrl, + status, } = file; const wrapperType = 'video'; @@ -29,6 +30,13 @@ export const updateFileValues = (files) => { thumbnail = `${getConfig().STUDIO_BASE_URL}${thumbnail}`; } + let uploadStatus = status; + if (status === 'Ready' || status === 'Imported') { + uploadStatus = 'Success'; + } else if (status === 'In Progress' || status === 'Uploaded') { + uploadStatus = 'Processing'; + } + updatedFiles.push({ ...file, displayName: clientVideoId, @@ -36,6 +44,7 @@ export const updateFileValues = (files) => { wrapperType, dateAdded: created.toString(), usageLocations: [], + status: uploadStatus, thumbnail, }); }); diff --git a/src/files-and-videos/videos-page/factories/mockApiResponses.jsx b/src/files-and-videos/videos-page/factories/mockApiResponses.jsx index 9bda11b0f..fc0610319 100644 --- a/src/files-and-videos/videos-page/factories/mockApiResponses.jsx +++ b/src/files-and-videos/videos-page/factories/mockApiResponses.jsx @@ -98,7 +98,7 @@ export const initialState = { created: '', courseVideoImageUrl: '/video', transcripts: [], - status: 'Imported', + status: 'In Progress', downloadLink: 'http://mOckID0.mp4', }, }, diff --git a/src/files-and-videos/videos-page/messages.js b/src/files-and-videos/videos-page/messages.js index b0dea00c2..27c5a5ef6 100644 --- a/src/files-and-videos/videos-page/messages.js +++ b/src/files-and-videos/videos-page/messages.js @@ -13,6 +13,26 @@ const messages = defineMessages({ id: 'course-authoring.video-uploads.thumbnail.alt', defaultMessage: '{displayName} video thumbnail', }, + activeCheckboxLabel: { + id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.activeCheckbox.label', + defaultMessage: 'Active', + }, + inactiveCheckboxLabel: { + id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.inactiveCheckbox.label', + defaultMessage: 'Inactive', + }, + transcribedCheckboxLabel: { + id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.transcribedCheckbox.label', + defaultMessage: 'Transcribed', + }, + notTranscribedCheckboxLabel: { + id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.notTranscribedCheckbox.label', + defaultMessage: 'Not transcribed', + }, + processingCheckboxLabel: { + id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.processingCheckbox.label', + defaultMessage: 'Processing', + }, }); export default messages;