diff --git a/src/components/Assignments/SolutionsTable/SolutionsTable.js b/src/components/Assignments/SolutionsTable/SolutionsTable.js index 2150dd93f..f991ae3a5 100644 --- a/src/components/Assignments/SolutionsTable/SolutionsTable.js +++ b/src/components/Assignments/SolutionsTable/SolutionsTable.js @@ -3,13 +3,17 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { FormattedMessage } from 'react-intl'; import { Table } from 'react-bootstrap'; +import { defaultMemoize } from 'reselect'; import NoSolutionYetTableRow from './NoSolutionYetTableRow'; import SolutionsTableRow from './SolutionsTableRow'; import { LoadingIcon } from '../../icons'; +import { EMPTY_ARRAY } from '../../../helpers/common'; import styles from './SolutionsTable.less'; +const createHighlightsIndex = defaultMemoize(highlights => new Set(highlights)); + const SolutionsTable = ({ assignmentId, groupId, @@ -18,122 +22,128 @@ const SolutionsTable = ({ noteMaxlen = null, compact = false, selected = null, + highlights = EMPTY_ARRAY, assignmentSolver = null, assignmentSolversLoading = false, showActionButtons = true, onSelect = null, -}) => ( - - - - - - - +}) => { + const highlightsIndex = createHighlightsIndex(highlights); - {!compact && ( + return ( +
- - - - - - - - - -
+ + + + + + - )} - {(!compact || showActionButtons) && ( - + )} - {assignmentSolver.get('lastAttemptIndex') > solutions.size && ( - - {!compact && <>  }( + {(!compact || showActionButtons) && ( + + )} + + + {solutions.size === 0 ? ( + + ) : ( + solutions.map((data, idx) => { + if (!data) { + return ( + + + + + + ); + } + + const id = data.id; + const runtimeEnvironment = + data.runtimeEnvironmentId && + runtimeEnvironments && + runtimeEnvironments.find(({ id }) => id === data.runtimeEnvironmentId); - {!compact && !assignmentSolver && solutions.size > 5 && ( - - )} - - )} - - )} - - - {solutions.size === 0 ? ( - - ) : ( - solutions.map((data, idx) => { - if (!data) { return ( - - - - - + ); - } - - const id = data.id; - const runtimeEnvironment = - data.runtimeEnvironmentId && - runtimeEnvironments && - runtimeEnvironments.find(({ id }) => id === data.runtimeEnvironmentId); - - return ( - - ); - }) - )} -
+ - + + + + + + + - {assignmentSolversLoading ? ( - - ) : ( - <> - {assignmentSolver && - (assignmentSolver.get('lastAttemptIndex') > 5 || - assignmentSolver.get('lastAttemptIndex') > solutions.size) && ( - <> - {!compact && ( - - )} + {!compact && ( + + + + {assignmentSolversLoading ? ( + + ) : ( + <> + {assignmentSolver && + (assignmentSolver.get('lastAttemptIndex') > 5 || + assignmentSolver.get('lastAttemptIndex') > solutions.size) && ( + <> + {!compact && ( - ) - - )} - + )} + + {assignmentSolver.get('lastAttemptIndex') > solutions.size && ( + + {!compact && <>  }( + + ) + + )} + + )} + + {!compact && !assignmentSolver && solutions.size > 5 && ( + )} + + )} +
+ +
- -
-); + }) + )} + + ); +}; SolutionsTable.propTypes = { assignmentId: PropTypes.string.isRequired, @@ -143,6 +153,7 @@ SolutionsTable.propTypes = { noteMaxlen: PropTypes.number, compact: PropTypes.bool, selected: PropTypes.string, + highlights: PropTypes.array, assignmentSolver: ImmutablePropTypes.map, assignmentSolversLoading: PropTypes.bool, showActionButtons: PropTypes.bool, diff --git a/src/components/Assignments/SolutionsTable/SolutionsTableRow.js b/src/components/Assignments/SolutionsTable/SolutionsTableRow.js index e6ec3eeea..c22e7b01b 100644 --- a/src/components/Assignments/SolutionsTable/SolutionsTableRow.js +++ b/src/components/Assignments/SolutionsTable/SolutionsTableRow.js @@ -11,7 +11,7 @@ import DeleteSolutionButtonContainer from '../../../containers/DeleteSolutionBut import AcceptSolutionContainer from '../../../containers/AcceptSolutionContainer'; import ReviewSolutionContainer from '../../../containers/ReviewSolutionContainer'; -import { DetailIcon } from '../../icons'; +import { DetailIcon, CodeFileIcon } from '../../icons'; import DateTime from '../../widgets/DateTime'; import OptionalTooltipWrapper from '../../widgets/OptionalTooltipWrapper'; import Button, { TheButtonGroup } from '../../widgets/TheButton'; @@ -43,9 +43,10 @@ const SolutionsTableRow = ({ noteMaxlen = null, compact = false, selected = false, + highlighted = false, showActionButtons = true, onSelect = null, - links: { SOLUTION_DETAIL_URI_FACTORY }, + links: { SOLUTION_DETAIL_URI_FACTORY, SOLUTION_SOURCE_CODES_URI_FACTORY }, intl: { locale }, }) => { const trimmedNote = note && note.trim(); @@ -63,7 +64,11 @@ const SolutionsTableRow = ({ return ( onSelect(id) : null}> {attemptIndex}. @@ -132,17 +137,31 @@ const SolutionsTableRow = ({ rowSpan={splitOnTwoLines ? 2 : 1}> {permissionHints && permissionHints.viewDetail && ( - } - hide={!compact} - tooltipId={`detail-${id}`}> - - - - + <> + } + hide={!compact} + tooltipId={`detail-${id}`}> + + + + + + } + hide={!compact} + tooltipId={`codes-${id}`}> + + + + + )} {permissionHints && permissionHints.setFlag && ( @@ -207,6 +226,7 @@ SolutionsTableRow.propTypes = { noteMaxlen: PropTypes.number, compact: PropTypes.bool.isRequired, selected: PropTypes.bool, + highlighted: PropTypes.bool, showActionButtons: PropTypes.bool, onSelect: PropTypes.func, links: PropTypes.object, diff --git a/src/components/Solutions/RecentlyVisited/RecentlyVisited.js b/src/components/Solutions/RecentlyVisited/RecentlyVisited.js new file mode 100644 index 000000000..3e3efdc08 --- /dev/null +++ b/src/components/Solutions/RecentlyVisited/RecentlyVisited.js @@ -0,0 +1,97 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { Table } from 'react-bootstrap'; +import { FormattedMessage } from 'react-intl'; + +import UsersNameContainer from '../../../containers/UsersNameContainer'; +import AssignmentNameContainer from '../../../containers/AssignmentNameContainer'; +import DateTime from '../../widgets/DateTime'; +import Button from '../../widgets/TheButton'; +import { DeleteIcon } from '../../icons'; +import { getRecentlyVisitedSolutions, clearRecentlyVisitedSolutions } from './functions'; + +const RecentlyVisited = ({ selectedId, secondSelectedId = null, onSelect = null }) => { + const [renderTrigger, setRenderTrigger] = useState(0); + const visited = getRecentlyVisitedSolutions(); + + return visited.length > 1 ? ( + <> + + + + + + + + + + + {visited.map(({ id, authorId, assignmentId, createdAt, attemptIndex }) => ( + { + ev.preventDefault(); + onSelect(id); + } + : null + } + className={ + id === selectedId + ? 'table-primary text-muted' + : id === secondSelectedId + ? 'table-warning text-muted' + : 'clickable' + }> + + + + + + ))} + +
+ + + + + + + +
+ + + + #{attemptIndex} + +
+
+ +
+ + ) : ( +
+ +
+ ); +}; + +RecentlyVisited.propTypes = { + selectedId: PropTypes.string.isRequired, + secondSelectedId: PropTypes.string, + onSelect: PropTypes.func, +}; + +export default RecentlyVisited; diff --git a/src/components/Solutions/RecentlyVisited/functions.js b/src/components/Solutions/RecentlyVisited/functions.js new file mode 100644 index 000000000..1c7b947e3 --- /dev/null +++ b/src/components/Solutions/RecentlyVisited/functions.js @@ -0,0 +1,16 @@ +import { storageGetItem, storageSetItem, storageRemoveItem } from '../../../helpers/localStorage'; + +const SOLUTIONS_LOCAL_STORAGE_KEY = 'Soluttions.SourceCodeBox.recent'; +const MAX_RECENT_SOLUTIONS = 25; + +export const registerSolutionVisit = ({ id, authorId, assignmentId, createdAt, attemptIndex }) => { + const recent = storageGetItem(SOLUTIONS_LOCAL_STORAGE_KEY, []).filter(solution => solution.id !== id); + while (recent.length >= MAX_RECENT_SOLUTIONS) { + recent.pop(); + } + storageSetItem(SOLUTIONS_LOCAL_STORAGE_KEY, [{ id, authorId, assignmentId, createdAt, attemptIndex }, ...recent]); +}; + +export const getRecentlyVisitedSolutions = () => storageGetItem(SOLUTIONS_LOCAL_STORAGE_KEY, []); + +export const clearRecentlyVisitedSolutions = () => storageRemoveItem(SOLUTIONS_LOCAL_STORAGE_KEY); diff --git a/src/components/Solutions/RecentlyVisited/index.js b/src/components/Solutions/RecentlyVisited/index.js new file mode 100644 index 000000000..2ebab2ced --- /dev/null +++ b/src/components/Solutions/RecentlyVisited/index.js @@ -0,0 +1,2 @@ +import RecentlyVisited from './RecentlyVisited'; +export default RecentlyVisited; diff --git a/src/components/Solutions/SourceCodeBox/SourceCodeBox.js b/src/components/Solutions/SourceCodeBox/SourceCodeBox.js new file mode 100644 index 000000000..90a848698 --- /dev/null +++ b/src/components/Solutions/SourceCodeBox/SourceCodeBox.js @@ -0,0 +1,226 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import { OverlayTrigger, Tooltip } from 'react-bootstrap'; +import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer'; +import Prism from 'prismjs'; + +import Box from '../../widgets/Box'; +import SourceCodeViewer from '../../helpers/SourceCodeViewer'; +import ResourceRenderer from '../../helpers/ResourceRenderer'; +import Icon, { CodeCompareIcon, DownloadIcon, LoadingIcon, WarningIcon } from '../../icons'; + +import { getPrismModeFromExtension } from '../../helpers/syntaxHighlighting'; +import { getFileExtensionLC } from '../../../helpers/common'; + +const diffViewHighlightSyntax = lang => str => + str && ( +
+  );
+
+const SourceCodeBox = ({
+  id,
+  parentId = id,
+  name,
+  entryName = null,
+  download = null,
+  diffWith = null,
+  diffMode = false,
+  fileContentsSelector,
+  adjustDiffMapping = null,
+  intl: { formatMessage },
+}) => {
+  const res = fileContentsSelector(parentId, entryName);
+  return (
+    
+              
+              {name}
+              {diffWith && (
+                <>
+                  
+                  {diffWith.name}
+                
+              )}
+            
+          }
+          noPadding
+        />
+      }>
+      {(content, secondContent = null) => (
+        
+              {content.malformedCharacters && (
+                
+                      
+                    
+                  }>
+                  
+                
+              )}
+
+              {content.tooLarge && (
+                
+                      
+                    
+                  }>
+                  
+                
+              )}
+
+              {name}
+
+              {download && (
+                 {
+                    ev.stopPropagation();
+                    download(parentId, entryName);
+                  }}
+                />
+              )}
+
+              {diffMode && (
+                <>
+                  
+                        
+                      
+                    }>
+                     adjustDiffMapping({ id, parentId, name, entryName }, diffWith) : null
+                      }
+                    />
+                  
+
+                  {secondContent && secondContent.malformedCharacters && (
+                    
+                          
+                        
+                      }>
+                      
+                    
+                  )}
+
+                  {secondContent && secondContent.tooLarge && (
+                    
+                          
+                        
+                      }>
+                      
+                    
+                  )}
+
+                  {diffWith ? (
+                    {diffWith.name}
+                  ) : (
+                    
+                      
+                    
+                  )}
+
+                  {diffWith && download && (
+                     {
+                        ev.stopPropagation();
+                        download(diffWith.parentId || diffWith.id, diffWith.entryName || null);
+                      }}
+                    />
+                  )}
+                
+              )}
+            
+          }
+          noPadding
+          unlimitedHeight
+          collapsable
+          isOpen={!content.malformedCharacters}>
+          {content.malformedCharacters ? (
+            
{content.content}
+ ) : secondContent && !secondContent.malformedCharacters ? ( +
+ +
+ ) : ( + + )} +
+ )} +
+ ); +}; + +SourceCodeBox.propTypes = { + id: PropTypes.string.isRequired, + parentId: PropTypes.string, + name: PropTypes.string.isRequired, + entryName: PropTypes.string, + download: PropTypes.func, + fileContentsSelector: PropTypes.func, + diffWith: PropTypes.object, + diffMode: PropTypes.bool, + adjustDiffMapping: PropTypes.func, + intl: PropTypes.shape({ formatMessage: PropTypes.func.isRequired }).isRequired, +}; + +export default injectIntl(SourceCodeBox); diff --git a/src/components/Solutions/SourceCodeBox/index.js b/src/components/Solutions/SourceCodeBox/index.js new file mode 100644 index 000000000..44aac1a56 --- /dev/null +++ b/src/components/Solutions/SourceCodeBox/index.js @@ -0,0 +1,2 @@ +import SourceCodeBox from './SourceCodeBox'; +export default SourceCodeBox; diff --git a/src/components/helpers/SourceCodeViewer/SourceCodeViewer.js b/src/components/helpers/SourceCodeViewer/SourceCodeViewer.js index fb021d6b6..a6572d551 100644 --- a/src/components/helpers/SourceCodeViewer/SourceCodeViewer.js +++ b/src/components/helpers/SourceCodeViewer/SourceCodeViewer.js @@ -6,6 +6,7 @@ import { vs } from 'react-syntax-highlighter/dist/esm/styles/prism'; import 'prismjs/themes/prism.css'; import { getPrismModeFromExtension } from '../../helpers/syntaxHighlighting'; +import { getFileExtensionLC } from '../../../helpers/common'; import './SourceCodeViewer.css'; @@ -29,7 +30,7 @@ const linePropsGenerator = lineNumber => ({ const SourceCodeViewer = ({ name, content = '' }) => canUseDOM ? ( ; export const BonusIcon = props => ; export const BugIcon = props => ; export { CheckRequiredIcon }; +export const CircleIcon = ({ selected = false, ...props }) => ( + +); export const CloseIcon = props => ; export const CodeFileIcon = props => ; export const CodeIcon = props => ; +export const CodeCompareIcon = props => ; export const CopyIcon = props => ; export const ChatIcon = props => ; export const DashboardIcon = props => ; @@ -99,6 +103,7 @@ export const SortedIcon = ({ active = true, descending = false, ...props }) => ( {...props} /> ); +export const StopIcon = props => ; export const SuccessIcon = props => ; export const SuccessOrFailureIcon = ({ success = false, colors = true, ...props }) => success ? ( @@ -140,6 +145,10 @@ export const WarningIcon = props => ; export const ZipIcon = props => ; +CircleIcon.propTypes = { + selected: PropTypes.bool, +}; + ExpandCollapseIcon.propTypes = { isOpen: PropTypes.bool, }; diff --git a/src/helpers/common.js b/src/helpers/common.js index df6d4e65e..cdcff8acf 100644 --- a/src/helpers/common.js +++ b/src/helpers/common.js @@ -68,6 +68,10 @@ export const encodeNumId = id => { return 'ID' + id; }; +export const getFileExtension = fileName => fileName.split('.').pop(); + +export const getFileExtensionLC = fileName => getFileExtension(fileName).toLowerCase(); + /* * Array/Object Helpers */ diff --git a/src/locales/cs.json b/src/locales/cs.json index 0ded1027f..1557479e3 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -1597,18 +1597,28 @@ "app.solutionFiles.sizeLimitExceeded": "Celková velikost všech odevzdaných souborů překročila výchozí limit ({limit} KiB).", "app.solutionFiles.title": "Odevzdané soubory", "app.solutionFiles.total": "Celkem:", + "app.solutionSourceCodes.adjustMappingTooltip": "Změnit, který soubor z druhého řešení bude porovnán s tímto souborem.", "app.solutionSourceCodes.cancelDiffButton": "Vypnout srovnávací režim", "app.solutionSourceCodes.diffButton": "Porovnat s...", "app.solutionSourceCodes.diffButtonChange": "Porovnat s jiným...", "app.solutionSourceCodes.diffModal.explain": "Jakmile vyberete druhé řešení z tabulky níže, zobrazí se rozdíly odpovídajících souborů v dvousloupcovém pohledu. Aktuální řešení bude zobrazeno nalevo, druhé vybrané řešení napravo.", + "app.solutionSourceCodes.diffModal.tabRecentSolutions": "Nedávno navštívené", + "app.solutionSourceCodes.diffModal.tabUserSolutions": "Uživatelská řešení", "app.solutionSourceCodes.diffModal.title": "Porovnat dvě řešení a zobrazit rozdíly", "app.solutionSourceCodes.isBeingComparedWith": "... je porovnáváno s ...", "app.solutionSourceCodes.left": "Nalevo", "app.solutionSourceCodes.malformedTooltip": "Tento soubor neobsahuje běžný text v kódování UTF-8, takže není možne jej zobrazit jako zdrojový kód.", + "app.solutionSourceCodes.mappingModal.explain": "Vyberte soubor z druhého řešení, který bude porovnán se souborem ''{name}'' z prvního řešení. Tato změna může ovlivnit celkové mapování mezi soubory obou řešení.", + "app.solutionSourceCodes.mappingModal.fileIsAssociatedWith": "soubor (na levo) je porovnáván s...", + "app.solutionSourceCodes.mappingModal.resetButton": "Výchozí mapování", + "app.solutionSourceCodes.mappingModal.title": "Upravit mapování porovnávaných souborů", + "app.solutionSourceCodes.noDiffWithFile": "žádný odpovídající soubor pro porovnání nebyl nalezen", "app.solutionSourceCodes.right": "Napravo", "app.solutionSourceCodes.title": "Přehled odevzdaných zdrojových souborů řešení", "app.solutionSourceCodes.titleDiff": "Srovnání zdrojových souborů dvou řešení", "app.solutionSourceCodes.tooLargeTooltip": "Soubor je příliš velký pro zobrazení náhledu, a proto byl oříznut.", + "app.solutions.recentlyVisited.clearCache": "Smazat záznamy", + "app.solutions.recentlyVisited.noRecentlyVisited": "Nejsou zaznamenány žádné nedávné návštěvy řešení úloh. Pokud chcete porovnat tuto úlohu s jinými, zkuste je nejprve otevřít a následně obnovit tuto stránku.", "app.solutionsTable.assignment": "Úloha", "app.solutionsTable.attemptsCount": "Odevzdaných řešení: {count}", "app.solutionsTable.attemptsDeleted": "{deleted} {deleted, plural, =2 {smazána} =3 {smazána} =4 {smazána} other {smazáno}}", @@ -1773,6 +1783,7 @@ "generic.accessDenied": "Nemáte oprávnění vidět tuto stránku. Pokud došlo k přesměrování na tuto stránku po kliknutí na zdánlivě legitimní odkaz nebo tlačítko, prosíme nahlaste chybu.", "generic.acknowledge": "Beru na vědomí", "generic.assignedAt": "Zadáno", + "generic.attempt": "Pokus", "generic.author": "Autor", "generic.cancel": "Zrušit", "generic.clearAll": "Zrušit vše", @@ -1799,6 +1810,7 @@ "generic.errorMessage": "Chybová hláška", "generic.explain": "vysvětlit", "generic.export": "Exportovat", + "generic.files": "Soubory", "generic.filtersSet": "Filtry nastaveny", "generic.finishedAt": "Ukončeno", "generic.hideAll": "Skrýt vše", diff --git a/src/locales/en.json b/src/locales/en.json index cc1189fd6..b8be23f35 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1597,18 +1597,28 @@ "app.solutionFiles.sizeLimitExceeded": "The total size of all submitted files exceeds the default solution size limit ({limit} KiB).", "app.solutionFiles.title": "Submitted Files", "app.solutionFiles.total": "Total:", + "app.solutionSourceCodes.adjustMappingTooltip": "Adjust file mappings by selecting which file from the second solution will be compared to this file.", "app.solutionSourceCodes.cancelDiffButton": "Compare mode off", "app.solutionSourceCodes.diffButton": "Compare with...", "app.solutionSourceCodes.diffButtonChange": "Compare with another...", "app.solutionSourceCodes.diffModal.explain": "When second solution is selected for comparison from the table below, the differences of the corresponding files will be displayed in a two-column views. The current solution will be displayed on the left, the second solution on the right.", + "app.solutionSourceCodes.diffModal.tabRecentSolutions": "Recently visited", + "app.solutionSourceCodes.diffModal.tabUserSolutions": "User solutions", "app.solutionSourceCodes.diffModal.title": "Compare two solutions and display differences", "app.solutionSourceCodes.isBeingComparedWith": "... is being compared with ...", "app.solutionSourceCodes.left": "Left side", "app.solutionSourceCodes.malformedTooltip": "The file is not a valid UTF-8 text file so it cannot be properly displayed as a source code.", + "app.solutionSourceCodes.mappingModal.explain": "Select a file from second solution that will be compared with ''{name}'' file from the first solution. Note that changing a mapping between two files may affect how other files are mapped.", + "app.solutionSourceCodes.mappingModal.fileIsAssociatedWith": "file (on the left) is associated with...", + "app.solutionSourceCodes.mappingModal.resetButton": "Reset mapping", + "app.solutionSourceCodes.mappingModal.title": "Adjust mapping of compared files", + "app.solutionSourceCodes.noDiffWithFile": "no corresponding file for the comparison found", "app.solutionSourceCodes.right": "Right side", "app.solutionSourceCodes.title": "Solution Source Code Files Overview", "app.solutionSourceCodes.titleDiff": "Comparing Source Codes of Two Solutions", "app.solutionSourceCodes.tooLargeTooltip": "The file is too large for code preview and it was cropped.", + "app.solutions.recentlyVisited.clearCache": "Clear the cache", + "app.solutions.recentlyVisited.noRecentlyVisited": "There are no other solutions recorded as recently visited. Try finding and opening the solution that you wish to compare with this one and then refreshing this page.", "app.solutionsTable.assignment": "Assignment", "app.solutionsTable.attemptsCount": "Solutions submitted: {count}", "app.solutionsTable.attemptsDeleted": "{deleted} deleted", @@ -1773,6 +1783,7 @@ "generic.accessDenied": "You do not have permissions to see this page. If you got to this page via a seemingly legitimate link or button, please report a bug.", "generic.acknowledge": "Acknowledge", "generic.assignedAt": "Assigned at", + "generic.attempt": "Attempt", "generic.author": "Author", "generic.cancel": "Cancel", "generic.clearAll": "Clear All", @@ -1799,6 +1810,7 @@ "generic.errorMessage": "Error message", "generic.explain": "explain", "generic.export": "Export", + "generic.files": "Files", "generic.filtersSet": "Filters Set", "generic.finishedAt": "Finished at", "generic.hideAll": "Hide All", diff --git a/src/pages/Solution/Solution.js b/src/pages/Solution/Solution.js index 695eec3bb..bf8ab9ec6 100644 --- a/src/pages/Solution/Solution.js +++ b/src/pages/Solution/Solution.js @@ -41,6 +41,7 @@ import { import { evaluationsForSubmissionSelector, fetchManyStatus } from '../../redux/selectors/submissionEvaluations'; import { assignmentSubmissionScoreConfigSelector } from '../../redux/selectors/exerciseScoreConfig'; +import { registerSolutionVisit } from '../../components/Solutions/RecentlyVisited/functions'; import { hasPermissions } from '../../helpers/common'; import { SolutionResultsIcon, WarningIcon } from '../../components/icons'; @@ -56,12 +57,13 @@ class Solution extends Component { dispatch(fetchRuntimeEnvironments()), dispatch(fetchSolutionIfNeeded(solutionId)) .then(res => res.value) - .then(solution => - Promise.all([ + .then(solution => { + registerSolutionVisit(solution); + return Promise.all([ dispatch(fetchUsersSolutions(solution.authorId, assignmentId)), dispatch(fetchAssignmentSolversIfNeeded({ assignmentId, userId: solution.authorId })), - ]) - ), + ]); + }), dispatch(fetchSubmissionEvaluationsForSolution(solutionId)), dispatch(fetchAssignmentIfNeeded(assignmentId)), dispatch(fetchAssignmentSolutionFilesIfNeeded(solutionId)), diff --git a/src/pages/SolutionSourceCodes/SolutionSourceCodes.js b/src/pages/SolutionSourceCodes/SolutionSourceCodes.js index c89f025ef..71ed3a7b5 100644 --- a/src/pages/SolutionSourceCodes/SolutionSourceCodes.js +++ b/src/pages/SolutionSourceCodes/SolutionSourceCodes.js @@ -3,23 +3,29 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; import { FormattedMessage, injectIntl } from 'react-intl'; -import { Row, Col, OverlayTrigger, Tooltip, Modal } from 'react-bootstrap'; +import { Row, Col, Modal, Tabs, Tab, Table } from 'react-bootstrap'; import { defaultMemoize } from 'reselect'; import { withRouter } from 'react-router'; -import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer'; -import Prism from 'prismjs'; import Page from '../../components/layout/Page'; -import Box from '../../components/widgets/Box'; import InsetPanel from '../../components/widgets/InsetPanel'; -import SourceCodeViewer from '../../components/helpers/SourceCodeViewer'; import { AssignmentSolutionNavigation } from '../../components/layout/Navigation'; import ResourceRenderer from '../../components/helpers/ResourceRenderer'; import SolutionsTable from '../../components/Assignments/SolutionsTable'; import Button, { TheButtonGroup } from '../../components/widgets/TheButton'; -import Icon, { DownloadIcon, LoadingIcon, SolutionResultsIcon, SwapIcon, WarningIcon } from '../../components/icons'; +import { + CircleIcon, + CodeCompareIcon, + RefreshIcon, + SolutionResultsIcon, + StopIcon, + SwapIcon, +} from '../../components/icons'; import AcceptSolutionContainer from '../../containers/AcceptSolutionContainer'; import ReviewSolutionContainer from '../../containers/ReviewSolutionContainer'; +import SourceCodeBox from '../../components/Solutions/SourceCodeBox'; +import RecentlyVisited from '../../components/Solutions/RecentlyVisited'; +import { registerSolutionVisit } from '../../components/Solutions/RecentlyVisited/functions'; import { fetchRuntimeEnvironments } from '../../redux/modules/runtimeEnvironments'; import { fetchAssignmentIfNeeded } from '../../redux/modules/assignments'; @@ -37,13 +43,23 @@ import { import { getFilesContentSelector } from '../../redux/selectors/files'; import { getLoggedInUserEffectiveRole } from '../../redux/selectors/users'; +import { storageGetItem, storageSetItem, storageRemoveItem } from '../../helpers/localStorage'; import withLinks from '../../helpers/withLinks'; import { isSupervisorRole } from '../../components/helpers/usersRoles'; -import { hasPermissions } from '../../helpers/common'; -import { getPrismModeFromExtension } from '../../components/helpers/syntaxHighlighting'; +import { + arrayToObject, + hasPermissions, + getFileExtensionLC, + isEmptyObject, + EMPTY_ARRAY, + EMPTY_OBJ, +} from '../../helpers/common'; const nameComparator = (a, b) => a.name.localeCompare(b.name, 'en'); +/** + * Expand zip entries as regular files with adjusted parameters (name and ID are composed of the zip container and the entry itself). + */ const preprocessZipEntries = ({ zipEntries, ...file }) => { if (zipEntries) { file.zipEntries = zipEntries @@ -60,6 +76,9 @@ const preprocessZipEntries = ({ zipEntries, ...file }) => { return file; }; +/** + * Preprocess zip entries, consolidate, and sort by names. + */ const preprocessFiles = defaultMemoize(files => files .sort(nameComparator) @@ -67,55 +86,121 @@ const preprocessFiles = defaultMemoize(files => .reduce((acc, file) => [...acc, ...(file.zipEntries || [file])], []) ); -const associateFilesForDiff = defaultMemoize((files, secondFiles) => { +/** + * @param {Array} files of the main solution + * @param {Array|null} secondFiles of the second solution to diffWith + * @param {Object} mapping explicit mappings as { firstId: secondId } + * @return {Array} copy of files array where file objects are augmented -- if a file is matched with a second file + * a `diffWith` entry is added into the file object + */ +const associateFilesForDiff = defaultMemoize((files, secondFiles, mapping = EMPTY_OBJ) => { if (!secondFiles) { return files; } + // create an index {name: file} and extensions index {ext: [ fileNames ]} const index = {}; + const indexLC = {}; + const extensionsIndex = {}; + const usedSecondFiles = new Set(Object.values(mapping)); secondFiles - .map(preprocessZipEntries) - .reduce((acc, file) => [...acc, ...(file.zipEntries || [file])], []) + .filter(file => !usedSecondFiles.has(file.id)) .forEach(file => { index[file.name] = file; + const nameLC = file.name.toLowerCase(); + indexLC[nameLC] = indexLC[nameLC] || []; + indexLC[nameLC].push(file.name); + const ext = getFileExtensionLC(file.name); + extensionsIndex[ext] = extensionsIndex[ext] || []; + extensionsIndex[ext].push(file.name); }); - return files.map(file => (index[file.name] ? { ...file, diffWith: index[file.name] } : file)); + // prepare a helper function that gets and removes file of given name from index + const getFile = name => { + const file = index[name] || null; + if (file) { + const nameLC = file.name.toLowerCase(); + indexLC[nameLC] = indexLC[nameLC].filter(n => n !== name); + const ext = getFileExtensionLC(name); + extensionsIndex[ext] = extensionsIndex[ext].filter(n => n !== name); + delete index[name]; + } + return file; + }; + + // four stage association -- 1. explicit mapping by IDs, 2. exact file name match, 3. lower-cased name match, 4. extensions match + // any ambiguity is treated as non-passable obstacle + return files + .map(file => { + // explicit mapping + const diffWith = mapping[file.id] && secondFiles.find(f => f.id === mapping[file.id]); + return diffWith ? { ...file, diffWith } : file; + }) + .map(file => { + // exact file name match + const diffWith = !file.diffWith ? getFile(file.name) : null; + return diffWith ? { ...file, diffWith } : file; + }) + .map(file => { + // lowercased file name match + if (!file.diffWith) { + const nameLC = file.name.toLowerCase(); + if (indexLC[nameLC] && indexLC[nameLC].length === 1) { + const diffWith = getFile(indexLC[nameLC].pop()); + return { ...file, diffWith }; + } + } + return file; + }) + .map(file => { + // file extension match + if (!file.diffWith) { + const ext = getFileExtensionLC(file.name); + if (extensionsIndex[ext] && extensionsIndex[ext].length === 1) { + const diffWith = getFile(extensionsIndex[ext].pop()); + return { ...file, diffWith }; + } + } + return file; + }); }); +/** + * Helper that computes reverted mapping { secondId: firstId } from the result of associateFilesForDiff. + */ +const getRevertedMapping = defaultMemoize(files => + arrayToObject( + files.filter(({ diffWith }) => Boolean(diffWith)), + ({ diffWith }) => diffWith.id + ) +); + const fileNameAndEntry = file => [file.parentId || file.id, file.entryName || null]; -const fileContentResource = (fileContentsSelector, file) => { - const res = fileContentsSelector(...fileNameAndEntry(file)); - return file.diffWith ? [res, fileContentsSelector(...fileNameAndEntry(file.diffWith))] : res; -}; +const wrapInArray = defaultMemoize(entry => [entry]); -const diffViewHighlightSyntax = lang => str => - str && ( -
-  );
+const localStorageDiffMappingsKey = 'SolutionSourceCodes.diffMappings.';
 
 class SolutionSourceCodes extends Component {
-  state = { dialogOpen: false };
+  state = { diffDialogOpen: false, mappingDialogOpenFile: null, mappingDialogDiffWith: null, diffMappings: {} };
 
   static loadAsync = ({ solutionId, assignmentId, secondSolutionId }, dispatch) =>
     Promise.all([
       dispatch(fetchRuntimeEnvironments()),
       secondSolutionId
-        ? dispatch(fetchSolutionIfNeeded(secondSolutionId)).then(res =>
-            res.value.assignmentId !== assignmentId
+        ? dispatch(fetchSolutionIfNeeded(secondSolutionId)).then(res => {
+            registerSolutionVisit(res.value);
+            return res.value.assignmentId !== assignmentId
               ? dispatch(fetchAssignmentIfNeeded(res.value.assignmentId))
-              : Promise.resolve()
-          )
+              : Promise.resolve();
+          })
         : Promise.resolve(),
       dispatch(fetchSolutionIfNeeded(solutionId))
         .then(res => res.value)
-        .then(solution => Promise.all([dispatch(fetchUsersSolutions(solution.authorId, assignmentId))])),
+        .then(solution => {
+          registerSolutionVisit(solution);
+          return Promise.all([dispatch(fetchUsersSolutions(solution.authorId, assignmentId))]);
+        }),
       dispatch(fetchAssignmentIfNeeded(assignmentId)),
       dispatch(fetchAssignmentSolutionFilesIfNeeded(solutionId))
         .then(res => preprocessFiles(res.value))
@@ -127,7 +212,26 @@ class SolutionSourceCodes extends Component {
         : Promise.resolve(),
     ]);
 
-  componentDidMount = () => this.props.loadAsync();
+  getDiffMappingsLocalStorageKey = () => {
+    const {
+      match: {
+        params: { solutionId, secondSolutionId },
+      },
+    } = this.props;
+
+    return secondSolutionId ? `${localStorageDiffMappingsKey}${solutionId}/${secondSolutionId}` : null;
+  };
+
+  componentDidMount = () => {
+    this.props.loadAsync();
+
+    const lsKey = this.getDiffMappingsLocalStorageKey();
+    if (lsKey) {
+      this.setState({
+        diffMappings: storageGetItem(lsKey, {}),
+      });
+    }
+  };
 
   componentDidUpdate(prevProps) {
     if (
@@ -135,15 +239,26 @@ class SolutionSourceCodes extends Component {
       this.props.match.params.secondSolutionId !== prevProps.match.params.secondSolutionId
     ) {
       this.props.loadAsync();
+
+      const lsKey = this.getDiffMappingsLocalStorageKey();
+      if (lsKey) {
+        this.setState({
+          diffMappings: storageGetItem(lsKey, {}),
+        });
+      }
     }
   }
 
-  openDialog = () => {
-    this.setState({ dialogOpen: true });
+  openDiffDialog = () => {
+    this.setState({ diffDialogOpen: true });
+  };
+
+  openMappingDialog = (mappingDialogOpenFile, mappingDialogDiffWith) => {
+    this.setState({ mappingDialogOpenFile, mappingDialogDiffWith });
   };
 
-  closeDialog = () => {
-    this.setState({ dialogOpen: false });
+  closeDialogs = () => {
+    this.setState({ diffDialogOpen: false, mappingDialogOpenFile: null, mappingDialogDiffWith: null });
   };
 
   selectDiffSolution = id => {
@@ -154,7 +269,7 @@ class SolutionSourceCodes extends Component {
       history: { replace },
       links: { SOLUTION_SOURCE_CODES_URI_FACTORY, SOLUTION_SOURCE_CODES_DIFF_URI_FACTORY },
     } = this.props;
-    this.closeDialog();
+    this.closeDialogs();
     if (id !== secondSolutionId && (id || secondSolutionId))
       replace(
         id
@@ -179,6 +294,30 @@ class SolutionSourceCodes extends Component {
     }
   };
 
+  adjustDiffMapping = (firstId, secondId = null) => {
+    this.closeDialogs();
+    const diffMappings = Object.fromEntries(
+      Object.entries(this.state.diffMappings).filter(([key, value]) => key !== firstId && value !== secondId)
+    );
+    if (secondId) {
+      diffMappings[firstId] = secondId;
+    }
+    this.setState({ diffMappings });
+    const lsKey = this.getDiffMappingsLocalStorageKey();
+    if (lsKey) {
+      storageSetItem(lsKey, diffMappings);
+    }
+  };
+
+  resetDiffMappings = () => {
+    this.closeDialogs();
+    this.setState({ diffMappings: {} });
+    const lsKey = this.getDiffMappingsLocalStorageKey();
+    if (lsKey) {
+      storageRemoveItem(lsKey);
+    }
+  };
+
   render() {
     const {
       assignment,
@@ -286,135 +425,151 @@ class SolutionSourceCodes extends Component {
                   )}
                 
 
-                {userSolutionsSelector(solution.authorId, assignmentId).size > 1 && (
-                  
-                    
-                      
-
-                      {diffMode && (
-                        
+                
+                  
+                    
+
+                    {diffMode && (
+                      
+                    )}
+                  
+                
               
             )}
 
             
-              {files => (
+              {filesRaw => (
                 
-                  {(secondFiles = null) =>
-                    associateFilesForDiff(preprocessFiles(files), secondFiles).map(file => (
-                      
-                                
-                                {file.name}
-                              
-                            }
-                            noPadding
-                          />
-                        }>
-                        {(content, secondContent = null) => (
-                           {
+                    const secondFiles = secondFilesRaw && preprocessFiles(secondFilesRaw);
+                    const files = associateFilesForDiff(
+                      preprocessFiles(filesRaw),
+                      secondFiles,
+                      this.state.diffMappings
+                    );
+                    const revertedIndex = files && secondFiles && getRevertedMapping(files);
+                    return (
+                      <>
+                        {files.map(file => (
+                          
-                                {content.malformedCharacters && (
-                                  
-                                        
-                                      
-                                    }>
-                                    
-                                  
-                                )}
-
-                                {content.tooLarge && (
-                                  
-                                        
-                                      
-                                    }>
-                                    
-                                  
-                                )}
-
-                                {file.name}
-
-                                 {
-                                    ev.stopPropagation();
-                                    download(...fileNameAndEntry(file));
-                                  }}
+                            {...file}
+                            download={download}
+                            fileContentsSelector={fileContentsSelector}
+                            diffMode={diffMode}
+                            adjustDiffMapping={this.openMappingDialog}
+                          />
+                        ))}
+
+                        {diffMode && secondFiles && (
+                          
+                            
+                              
+                                
-                              
-                            }
-                            noPadding
-                            unlimitedHeight
-                            collapsable
-                            isOpen={!content.malformedCharacters}>
-                            {content.malformedCharacters ? (
-                              
{content.content}
- ) : secondContent && !secondContent.malformedCharacters ? ( -
- + + +
+ + {this.state.mappingDialogOpenFile && this.state.mappingDialogOpenFile.name} + {' '} + -
- ) : ( - - )} -
+ + + + {this.state.mappingDialogOpenFile && ( + {content}, + }} + /> + )} + + + + + {secondFiles.map(file => { + const selected = + this.state.mappingDialogDiffWith && + file.id === this.state.mappingDialogDiffWith.id; + return ( + this.adjustDiffMapping(this.state.mappingDialogOpenFile.id, file.id) + }> + + + + + ); + })} + +
+ + {file.name} + {revertedIndex && revertedIndex[file.id] && ( + <> + + {revertedIndex[file.id].name} + + )} +
+ + {this.state.diffMappings && !isEmptyObject(this.state.diffMappings) && ( +
+ +
+ )} + + )} -
- )) - } + + ); + }}
)}
- + - - {runtimes => ( - + + }> + + {runtimes => ( + + )} + + + + }> + - )} - + +