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.title')}

+
+

{t('files.title')}

+ +

{t('files.subtitle')}

+ { id={dialogId} role="dialog" aria-modal="true" - className={`dialog-full-screen ${widgetState === WIDGET_STATE.FIXIT ? 'open' : 'hidden'}`} + className={`dialog-full-screen ${widgetState === WIDGET_STATE.FIXIT && !unusedDialogModal ? 'open' : 'hidden'}`} onClose={closeDialog} aria-labelledby="ufixit-dialog-title" > @@ -1089,6 +1297,78 @@ const getSectionPostOptions = (newFile, sectionReferences) => {
+ + ) -} \ No newline at end of file +} diff --git a/assets/js/Services/Api.js b/assets/js/Services/Api.js index 097c521c6..8d514bcf3 100644 --- a/assets/js/Services/Api.js +++ b/assets/js/Services/Api.js @@ -12,7 +12,9 @@ export default class Api { reviewFile: '/api/files/{file}/review', postFile: '/api/files/{file}/post', deleteFile: '/api/files/{file}/delete', + batchDelete: '/api/{course}/files/delete', updateContent: '/api/{file}/content', + reportPdf: '/download/courses/{course}/reports/pdf', adminCourses: '/api/admin/courses/account/{account}/term/{term}', scanContent: '/api/sync/content/{contentItem}?report={getReport}', scanCourse: '/api/sync/{course}', @@ -159,6 +161,22 @@ export default class Api { }) } + batchDelete(urlList) { + const authToken = this.getAuthToken() + let url = `${this.apiUrl}${this.endpoints.batchDelete}` + url = url.replace('{course}', this.getCourseId()) + + return fetch(url, { + method: 'DELETE', + headers: { + 'X-AUTH-TOKEN': authToken, + }, + body: JSON.stringify({ + paths: urlList + }) + }) + } + updateContent(contentOptions, sectionOptions, fileId){ let url = `${this.apiUrl}${this.endpoints.updateContent}` url = url.replace('{file}', fileId) diff --git a/assets/js/Services/Report.js b/assets/js/Services/Report.js index 5e39b37bb..a795daedc 100644 --- a/assets/js/Services/Report.js +++ b/assets/js/Services/Report.js @@ -449,5 +449,7 @@ export function analyzeReport(report, ISSUE_STATE) { tempReport.sessionFiles = sessionFiles tempReport.filesReviewed = tempFilesReviewed + console.log(report) + return tempReport } diff --git a/src/Controller/FileItemsController.php b/src/Controller/FileItemsController.php index 7f243ed10..93020bab6 100644 --- a/src/Controller/FileItemsController.php +++ b/src/Controller/FileItemsController.php @@ -4,6 +4,7 @@ use App\Entity\FileItem; use App\Entity\ContentItem; +use App\Entity\Course; use App\Response\ApiResponse; use App\Services\LmsPostService; use App\Services\LmsFetchService; @@ -199,4 +200,24 @@ public function deleteFile(SessionService $sessionService, FileItem $file, Utili return new JsonResponse($apiResponse); } + + #[Route('/api/{course}/files/delete', methods: ['DELETE'], name: 'delete_files')] + public function batchDeleteFiles(SessionService $sessionService, Course $course, Request $request, UtilityService $util, LmsPostService $lmsPost, LmsFetchService $lmsFetch){ + $apiResponse = new ApiResponse(); + $user = $this->getUser(); + try{ + if (!$this->userHasCourseAccess($course, $sessionService)) { + throw new \Exception("You do not have permission to access this issue."); + } + + $content= \json_decode($request->getContent(), true); + $paths = $content['paths']; + $apiResponse = $lmsPost->batchDeleteFromLms($paths, $user); + } + catch (\Exception $e) { + $apiResponse->addError($e->getMessage()); + } + + return new JsonResponse($apiResponse); + } } \ No newline at end of file diff --git a/src/Controller/ReportsController.php b/src/Controller/ReportsController.php index dd105a656..79743aee0 100644 --- a/src/Controller/ReportsController.php +++ b/src/Controller/ReportsController.php @@ -139,4 +139,75 @@ public function setReportData( // Construct Response return new JsonResponse($apiResponse); } + + #[Route('/download/courses/{course}/reports/pdf', methods: ['GET'], name: 'get_report_pdf')] + public function getPdfReport( + SessionService $sessionService, + Request $request, + UtilityService $util, + Course $course + ): Response { + $this->request = $request; + $this->util = $util; + + try { + /** @var User $user */ + $user = $this->getUser(); + /** @var \App\Entity\Institution $institution */ + $institution = $user->getInstitution(); + + // Check if user has course access + if (!$this->userHasCourseAccess($course, $sessionService)) { + throw new \Exception("msg.no_permissions"); + } + + $metadata = $institution->getMetadata(); + $lang = (!empty($metadata['lang'])) ? $metadata['lang'] : $_ENV['DEFAULT_LANG']; + + $content = []; + foreach ($course->getContentItems() as $item) { + $issues = $item->getIssues(); + + if (count($issues)) { + $issueCount = ['error' => [], 'suggestion' => []]; + foreach ($issues as $issue) { + if (!isset($issueCount[$issue->getType()][$issue->getScanRuleId()])) { + $issueCount[$issue->getType()][$issue->getScanRuleId()] = 0; + } + $issueCount[$issue->getType()][$issue->getScanRuleId()]++; + } + + $content[] = [ + 'title' => $item->getTitle(), + 'type' => $item->getContentType(), + 'issues' => $issueCount, + ]; + } + } + + + // Generate Twig Template + $html = $this->renderView( + 'report.html.twig', + [ + 'course' => $course, + 'report' => $course->getLatestReport(), + 'content' => $content, + 'labels' => (array) $this->util->getTranslation($lang), + ] + ); + + // Generate PDF + $mPdf = new Mpdf(['tempDir' => '/tmp']); + $mPdf->WriteHTML($html); + + return $mPdf->Output('udoit_report.pdf', \Mpdf\Output\Destination::DOWNLOAD); + } + catch(\Exception $e) { + $apiResponse = new ApiResponse(); + $apiResponse->addMessage($e->getMessage()); + + return new JsonResponse($apiResponse); + } + } } diff --git a/src/Lms/Canvas/CanvasLms.php b/src/Lms/Canvas/CanvasLms.php index 65beeee34..5fd37f827 100644 --- a/src/Lms/Canvas/CanvasLms.php +++ b/src/Lms/Canvas/CanvasLms.php @@ -583,6 +583,17 @@ public function updateFileItem(Course $course, $file) return $fileItem; } + public function batchDeleteContent($paths) { + $user = $this->security->getUser(); + $apiDomain = $this->getApiDomain($user); + $apiToken = $this->getApiToken($user); + + $canvasApi = new CanvasApi($apiDomain, $apiToken); + + return $canvasApi->apiDeleteBatch($paths); + + } + public function updateContentItem(ContentItem $contentItem) { $user = $this->security->getUser(); diff --git a/src/Services/LmsPostService.php b/src/Services/LmsPostService.php index 46c8845f1..ab7f2d3d5 100644 --- a/src/Services/LmsPostService.php +++ b/src/Services/LmsPostService.php @@ -100,6 +100,21 @@ public function deleteFileFromLms(FileItem $file, User $user){ } } + public function batchDeleteFromLms($paths, $user){ + $lms = $this->lmsApi->getLms(); + $this->lmsUser->validateApiKey($user); + + try{ + return $lms->batchDeleteContent($paths); + } + catch(\Exception){ + $this->util->createMessage('Failed to download unused files.', 'error'); + return; + } + + + } + public function saveFileToLms(FileItem $file, UploadedFile $uploadedFile, User $user) { $lms = $this->lmsApi->getLms(); diff --git a/translations/en.json b/translations/en.json index 069cf8a5a..53c1722b2 100644 --- a/translations/en.json +++ b/translations/en.json @@ -58,6 +58,11 @@ "files.title": "Review Files", "files.subtitle": "Files cannot be automatically scanned by UDOIT. Review course files for common accessibility issues and update them if necessary.", + "files.button.delete_unused_files": "Delete Unused Files", + "files.button.delete_selected": "Delete Selected", + "files.label.select_all_unused_files": "Select All Unused Files", + "files.label.selected_count": "{count} file(s) selected", + "files.msg.no_unused_files": "No unused files were found.", "report.title": "Reports", "report.subtitle": "View and print detailed accessibility reports for your course currently or over time.", @@ -245,6 +250,7 @@ "fix.label.found_in": "Found in:", "fix.label.no_references": "Not found in any module", "fix.label.loading_content": "Loading Content", + "fix.label.deleting_files": "Deleting Unused Files", "fix.label.file_name": "Name", "fix.label.file_type": "Type", "fix.label.file_size": "Size", @@ -261,6 +267,7 @@ "fix.label.barrier_information": "Barrier Information", "fix.label.barrier_repair": "Repair this Barrier", "fix.label.or": "Or", + "fix.label.cancel": "Cancel", "fix.label.and_or": "And/Or", "fix.label.no_changes": "No Changes Needed", "fix.label.no_changes_desc": "Only select this option if you have verified that this does not present an accessibility barrier.", @@ -911,4 +918,4 @@ "rule.desc.widget_tabbable_exists": "

Keyboard Accessibility

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.

Keyboard Navigation

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.

Best Practices

For More Information

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 +}