diff --git a/assets/js/Components/ReviewFilesPage.css b/assets/js/Components/ReviewFilesPage.css index c9c91ff76..4daea88b0 100644 --- a/assets/js/Components/ReviewFilesPage.css +++ b/assets/js/Components/ReviewFilesPage.css @@ -1,3 +1,61 @@ +.unused-files-list-container { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1rem 1.5rem; + + .rounded-table-wrapper { + max-height: 60vh; + } + + td.text-center input[type='checkbox'] { + margin: 0; + } +} + +.select-all-unused-toggle-row { + display: flex; + flex-direction: row; + gap: 0.5rem; + padding: 0.25rem 0.25rem 0.5rem; +} + +.unused-file-list-item { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + border: 1px solid var(--border-color); + border-radius: var(--element-radius); + background-color: var(--white); + cursor: pointer; +} + +.unused-file-list-details { + display: flex; + flex-direction: column; +} + +.unused-file-list-title { + font-weight: 500; +} + +.review-files-delete-button { + --review-files-delete-color: var(--issue-color, var(--error-color)); + background-color: var(--review-files-delete-color); + border-color: var(--review-files-delete-color); + color: var(--white); + fill: var(--white); + + &:hover, + &:focus { + filter: brightness(var(--hover-brightness)); + color: var(--white); + fill: var(--white); + } +} + .udoit-sortable-table { tr { diff --git a/assets/js/Components/ReviewFilesPage.js b/assets/js/Components/ReviewFilesPage.js index d59a57cba..a82e01182 100644 --- a/assets/js/Components/ReviewFilesPage.js +++ b/assets/js/Components/ReviewFilesPage.js @@ -1,9 +1,11 @@ import React, { useState, useEffect } from 'react' import ReviewFilesFilters from './Widgets/ReviewFilesFilters' +import ToggleSwitch from './Widgets/ToggleSwitch' import SortableTable from './Widgets/SortableTable' import FileFixitWidget from './Widgets/FileFixitWidget' import FileReviewPreview from './Widgets/FileReviewPreview' import FileTypeIcon from './Icons/FileTypeIcon' +import DeleteIcon from './Icons/DeleteIcon' import LeftArrowIcon from './Icons/LeftArrowIcon' import RightArrowIcon from './Icons/RightArrowIcon' import StatusPill from './Widgets/StatusPill' @@ -33,6 +35,7 @@ import './ReviewFilesPage.css' import * as Html from '../Services/Html.js' import CloseIcon from './Icons/CloseIcon.js' import LearnMore from './Widgets/LearnMore.js' +import ProgressIcon from './Icons/ProgressIcon.js' export default function ReviewFilesPage({ t, @@ -61,6 +64,7 @@ export default function ReviewFilesPage({ const WIDGET_STATE = settings.WIDGET_STATE const dialogId = "udoit-file-dialog" + const unusedFileDialogId = "unused-files-dialog" const headers = [ { id: "name", text: t('fix.label.file_name') }, @@ -70,17 +74,36 @@ export default function ReviewFilesPage({ { id: "status", text: t('fix.label.status')}, ] + const unusedFilesHeaders = [ + { id: "action", text: '', alignText: 'center' }, + { id: "name", text: t('fix.label.file_name') }, + { id: "type", text: t('fix.label.file_type') }, + { id: "size", text: t('fix.label.file_size'), alignText: 'start' }, + { id: "date", text: t('fix.label.file_updated') }, + ] + const [tableSettings, setTableSettings] = useState({ sortBy: 'name', ascending: false, pageNum: 0, }) + const [unusedTableSettings, setUnusedTableSettings] = useState({ + sortBy: 'date', + ascending: false, + pageNum: 0, + }) + const handleTableSettings = (setting) => { setTableSettings(Object.assign({}, tableSettings, setting)) } + const handleUnusedTableSettings = (setting) => { + setUnusedTableSettings(Object.assign({}, unusedTableSettings, setting)) + } + const [rows, setRows] = useState([]) + const [unusedRows, setUnusedRows] = useState([]) const [activeIssue, setActiveIssue] = useState(null) const [tempActiveIssue, setTempActiveIssue] = useState(null) const [searchTerm, setSearchTerm] = useState('') @@ -105,6 +128,10 @@ export default function ReviewFilesPage({ const [isDisabled, setIsDisabled] = useState(false) const [showLearnMore, setShowLearnMore] = useState(false) + const [unusedFiles, setUnusedFiles] = useState([]) + const [deleteFileQueue, setDeleteFileQueue] = useState([]) + const [unusedDialogModal, setUnusedDialogModal] = useState(false) // Keeps track of if we need to use unused files modal or the default file modal + const formatFileData = (fileData) => { @@ -222,10 +249,77 @@ export default function ReviewFilesPage({ return tempRows } + const getUnusedFilesTableContent = () => { + if(unusedFiles.length === 0) { + return [] + } + + let tempRows = [] + unusedFiles.forEach((unusedFile) => { + const fileName = unusedFile.fileName || unusedFile.display_name || t('label.unknown') + const fileType = unusedFile.fileType || 'unknown' + const fileSize = parseInt(unusedFile.fileSize, 10) + const isSelected = deleteFileQueue.includes(`files/${unusedFile.lmsFileId}`) + + tempRows.push({ + id: unusedFile.id, + name: { value: fileName.toLowerCase(), display: fileName }, + type: { value: fileType.toLowerCase(), display: getFileTypeDisplay(fileType) }, + size: !isNaN(fileSize) + ? { value: fileSize, display: Text.getReadableFileSize(fileSize) } + : { value: -1, display: t('label.unknown') }, + date: unusedFile.updated + ? { value: unusedFile.updated, display: Text.getReadableDateTime(unusedFile.updated) } + : { value: '', display: t('label.unknown') }, + action: ( + toggleDeleteFileQueue(unusedFile.lmsFileId)} + aria-label={t('files.button.delete_selected') + ': ' + fileName} + /> + ), + }) + }) + + const { sortBy, ascending } = unusedTableSettings + + tempRows.sort((a, b) => { + let aSort = a[sortBy] + if(typeof aSort === 'object' && aSort?.value !== undefined) { + aSort = aSort.value + } + + let bSort = b[sortBy] + if(typeof bSort === 'object' && bSort?.value !== undefined) { + bSort = bSort.value + } + + if(typeof aSort === 'string' || typeof bSort === 'string') { + const aText = String(aSort || '').toLowerCase() + const bText = String(bSort || '').toLowerCase() + return (aText > bText) ? -1 : 1 + } + + return (Number(aSort) < Number(bSort)) ? -1 : 1 + }) + + if(!ascending) { + tempRows.reverse() + } + + return tempRows + } + useEffect(() => { setRows(getContent()) }, [tableSettings, filteredFiles]) + useEffect(() => { + setUnusedRows(getUnusedFilesTableContent()) + }, [unusedTableSettings, unusedFiles, deleteFileQueue]) + // The report object is updated whenever a scan or rescan is completed. At this point, the list of issues // needs to be rebuilt and the activeIssue may need to be updated. For instance, if an issue is marked as // unreviewed then it will be deleted during the rescan and a new issue with a new id will take its place. @@ -233,11 +327,19 @@ export default function ReviewFilesPage({ let tempUnfilteredIssues = [] let tempFiles = Object.assign({}, report.files) + let tempUnusedFiles = [] for (const [key, value] of Object.entries(tempFiles)) { let tempFile = formatFileData(value) tempUnfilteredIssues.push(tempFile) + if((!tempFile?.fileData?.references || tempFile?.fileData?.references?.length === 0) + && (!tempFile?.fileData?.sectionRefs || tempFile?.fileData?.sectionRefs?.length === 0)) { + tempUnusedFiles.push(tempFile.fileData) + } } + setUnusedFiles(tempUnusedFiles) + setDeleteFileQueue((oldQueue) => oldQueue.filter((fileId) => tempUnusedFiles.some((file) => file.id === fileId))) + tempUnfilteredIssues.sort((a, b) => { return (a.formLabel.toLowerCase() < b.formLabel.toLowerCase()) ? -1 : 1 }) @@ -354,7 +456,10 @@ export default function ReviewFilesPage({ // Pull focus into the dialog when it opens, and return focus to the most recently clicked issue when it closes useEffect(() => { if(widgetState === WIDGET_STATE.FIXIT) { - const dialog = document.getElementById(dialogId) + let dialog = document.getElementById(dialogId) + if(unusedDialogModal){ + dialog = document.getElementById(unusedFileDialogId) + } if (dialog) { dialog.addEventListener('keydown', handleEscapeKey) const title = dialog.querySelector('#ufixit-dialog-title') @@ -377,22 +482,51 @@ export default function ReviewFilesPage({ } }, [widgetState]) - - const isDialogOpen = () => { - const dialog = document.getElementById(dialogId) - return dialog && dialog.open - } - - - const openDialog = () => { + const openDialog = (currDialogId) => { setWidgetState(WIDGET_STATE.FIXIT) setModalActive(true) + if(currDialogId == dialogId){ + setUnusedDialogModal(false) + } + else{ + setUnusedDialogModal(true) + } } const closeDialog = () => { setWidgetState(WIDGET_STATE.LIST) setModalActive(false) setActiveIssue(null) + setUnusedDialogModal(false) + } + + const toggleDeleteFileQueue = (fileId) => { + if(!fileId) { + return + } + + const url = `files/${fileId}` + + let tempQueue = JSON.parse(JSON.stringify(deleteFileQueue)) + console.log(tempQueue) + if(tempQueue.includes(url)){ + tempQueue = tempQueue.filter((q_url) => q_url != url) + } + else{ + tempQueue.push(url) + } + + console.log(tempQueue) + setDeleteFileQueue(tempQueue) + } + + const updateSelectAllUnusedFilesToggle = (newValue) => { + if(newValue === false) { + setDeleteFileQueue([]) + return + } + + setDeleteFileQueue(unusedFiles.map((file) => `files/${file.lmsFileId}`)) } const getFileTypeDisplay = (fileType) => { @@ -847,6 +981,65 @@ const getSectionPostOptions = (newFile, sectionReferences) => { } } + const removeFileFromReport = (fileIds) => { + const tempReport = Object.assign({}, report) + + if(!Array.isArray(tempReport.files)){ + tempReport.files = Object.values(tempReport.files) + } + + if(!tempReport || !tempReport.files || tempReport.files.length == 0){ + return tempReport + } + + for(const id of fileIds){ + tempReport.files = tempReport.files.filter((file) => parseInt(file.lmsFileId) != id) + } + + return tempReport + } + + const deleteSelectedFiles = async (payload) => { + if(!deleteFileQueue || deleteFileQueue?.length == 0){ + return + } + + setIsDisabled(true) + const reomovedFileId = [] + const tempQueue = JSON.parse(JSON.stringify(deleteFileQueue)) + console.log(tempQueue) + try{ + const api = new Api(settings) + while(tempQueue.length > 0){ + let payloadTracker = 0 + let paths = [] + while(tempQueue.length > 0 && payloadTracker < payload){ + paths.push(tempQueue.pop()) + payloadTracker++ + } + const respone_str = await api.batchDelete(paths) + const response = await respone_str.json() + for(const r of response){ + reomovedFileId.push(r?.content?.id) + } + } + } + catch(e){ + console.error(e) + } + + const newReport = removeFileFromReport(reomovedFileId) + setDeleteFileQueue(tempQueue) + processNewReport(newReport) + setIsDisabled(false) + + } + + const deleteSelectedFilesWrapper = () => { + const payload = 10 + deleteSelectedFiles(payload) + } + // Wrapper to pass to file form for unreviewing const handleFileResolveWrapper = () => { handleFileResolve(activeIssue.fileData) @@ -913,7 +1106,7 @@ const getSectionPostOptions = (newFile, sectionReferences) => { setActiveIssue(filteredFiles[filteredFileIndex]) setMostRecentFileId(fileId) - openDialog() + openDialog(dialogId) } const nextFile = (previous = false) => { @@ -958,13 +1151,28 @@ const getSectionPostOptions = (newFile, sectionReferences) => { { widgetState === WIDGET_STATE.LOADING ? ( <>> ) : ( +
{t('files.subtitle')}
+Some users cannot use a mouse or trackpad due to physical limitations. When this is the case, they may have a custom device that helps them navigate. These devices translate interactions, from rough muscle control to voice commands, into equivalent keystrokes.
Normally, all interactive elements should be reachable without a mouse by using the arrow keys, tab, and shift + tab on the keyboard. When using normal HTML elements, like <a> for links and <button> for buttons, this should happen automatically. However, if custom elements are used (like by adding the role="button" attribute to a non-<button> element), you must also manually add the tabindex="0" attribute to add a "tab stop" and ensure that they can be reached using tab, and shift + tab on the keyboard.
Additionally, if you have a complex widget that has more than one interactive element, it should only have one "tab stop", and users should be able to navigate between the elements using the arrow keys on the keyboard.
W3.org: Keyboard Accessible, Focus Order, Keyboard Navigation Between Components
WebAIM.org: Keyboard Accessibility
", "rule.desc.widget_tabbable_single": "Ensure that interactive components include only one tab stop, and that all inner content is accessible using only the arrow keys." -} \ No newline at end of file +}