diff --git a/src/components/Assignments/ShadowAssignmentPointsTable/ShadowAssignmentPointsTable.js b/src/components/Assignments/ShadowAssignmentPointsTable/ShadowAssignmentPointsTable.js index 616280c47..a6458b4e7 100644 --- a/src/components/Assignments/ShadowAssignmentPointsTable/ShadowAssignmentPointsTable.js +++ b/src/components/Assignments/ShadowAssignmentPointsTable/ShadowAssignmentPointsTable.js @@ -157,10 +157,7 @@ class ShadowAssignmentPointsTable extends Component { }> )} diff --git a/src/components/forms/AddExerciseTagForm/AddExerciseTagForm.js b/src/components/forms/AddExerciseTagForm/AddExerciseTagForm.js new file mode 100644 index 000000000..d3d031052 --- /dev/null +++ b/src/components/forms/AddExerciseTagForm/AddExerciseTagForm.js @@ -0,0 +1,159 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { Form } from 'react-bootstrap'; +import { reduxForm, Field } from 'redux-form'; + +import { TextField } from '../Fields'; +import SubmitButton from '../SubmitButton'; +import { AddIcon, LoadingIcon } from '../../../components/icons'; + +const AddExerciseTagForm = ({ + submitting, + handleSubmit, + onSubmit, + reset, + submitFailed, + submitSucceeded, + invalid, + tags, + updatePending = false, +}) => ( +
+ + + + + + + +
+ + {tags.map(tag => ( + + ))} + + + + onSubmit(data).then(reset))} + defaultIcon={updatePending ? : } + messages={{ + submit: , + submitting: , + success: , + }} + /> +
+
+); + +AddExerciseTagForm.propTypes = { + exercise: PropTypes.object.isRequired, + tags: PropTypes.array, + updatePending: PropTypes.bool, + submitting: PropTypes.bool, + submitFailed: PropTypes.bool, + submitSucceeded: PropTypes.bool, + invalid: PropTypes.bool, + handleSubmit: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + reset: PropTypes.func, +}; + +const validate = ({ tag }, { exercise }) => { + const errors = {}; + if (!tag) { + errors._error = true; + return errors; + } + + if (tag.length < 2) { + errors.tag = ( + + ); + return errors; + } + + if (tag.length > 16) { + errors.tag = ( + + ); + return errors; + } + + if (!tag.match(/^[-a-zA-Z0-9_]+$/)) { + errors.tag = ( + + ); + return errors; + } + + if (exercise && exercise.tags && exercise.tags.includes(tag)) { + errors.tag = ( + + ); + return errors; + } + + return errors; +}; + +const warn = ({ tag }, { tags }) => { + const warnings = {}; + if (!tag) { + return warnings; + } + + if (tag.length < 3) { + warnings.tag = ( + + ); + return warnings; + } + + if (tag.length > 12) { + warnings.tag = ( + + ); + return warnings; + } + + if (tags && tags.length > 0) { + if (!tags.includes(tag)) { + warnings.tag = ( + + ); + } + return warnings; + } + + return warnings; +}; + +export default reduxForm({ + form: 'addExerciseTag', + validate, + warn, +})(AddExerciseTagForm); diff --git a/src/components/forms/AddExerciseTagForm/index.js b/src/components/forms/AddExerciseTagForm/index.js new file mode 100644 index 000000000..0ffa099c3 --- /dev/null +++ b/src/components/forms/AddExerciseTagForm/index.js @@ -0,0 +1,2 @@ +import AddExerciseTagForm from './AddExerciseTagForm'; +export default AddExerciseTagForm; diff --git a/src/components/forms/EditTestsForm/EditTestsTestRow.js b/src/components/forms/EditTestsForm/EditTestsTestRow.js index 603e61fe4..6c712bda6 100644 --- a/src/components/forms/EditTestsForm/EditTestsTestRow.js +++ b/src/components/forms/EditTestsForm/EditTestsTestRow.js @@ -38,7 +38,7 @@ const EditTestsTestRow = ({ test, onRemove, isUniform, percent, readOnly = false )} diff --git a/src/components/icons/index.js b/src/components/icons/index.js index 9a4542618..ad8b98c14 100644 --- a/src/components/icons/index.js +++ b/src/components/icons/index.js @@ -8,7 +8,7 @@ import Icon from './Icon'; const defaultMessageIcon = ['far', 'envelope']; -export const AddIcon = props => ; +export const AddIcon = props => ; export const AdressIcon = props => ; export const ArchiveGroupIcon = ({ archived = false, ...props }) => ( @@ -39,7 +39,7 @@ export const MailIcon = props => ; export const NeedFixingIcon = props => ; export const PipelineIcon = props => ; export const RefreshIcon = props => ; -export const RemoveIcon = props => ; +export const RemoveIcon = props => ; export const ResultsIcon = props => ; export const SearchIcon = props => ; export const SendIcon = props => ; diff --git a/src/containers/ExercisesNameContainer/ExercisesNameContainer.js b/src/containers/ExercisesNameContainer/ExercisesNameContainer.js index eea5fb58a..0248d58f7 100644 --- a/src/containers/ExercisesNameContainer/ExercisesNameContainer.js +++ b/src/containers/ExercisesNameContainer/ExercisesNameContainer.js @@ -2,7 +2,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; -import { injectIntl } from 'react-intl'; +import { injectIntl, intlShape } from 'react-intl'; import { fetchExerciseIfNeeded } from '../../redux/modules/exercises'; import { exerciseSelector } from '../../redux/selectors/exercises'; @@ -42,7 +42,7 @@ ExercisesNameContainer.propTypes = { exerciseId: PropTypes.string.isRequired, exercise: ImmutablePropTypes.map, noLink: PropTypes.bool, - intl: PropTypes.shape({ locale: PropTypes.string.isRequired }).isRequired, + intl: intlShape.isRequired, }; export default injectIntl( diff --git a/src/containers/ExercisesTagsEditContainer/ExercisesTagsEditContainer.js b/src/containers/ExercisesTagsEditContainer/ExercisesTagsEditContainer.js new file mode 100644 index 000000000..110e6b64c --- /dev/null +++ b/src/containers/ExercisesTagsEditContainer/ExercisesTagsEditContainer.js @@ -0,0 +1,91 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; +import { FormattedMessage } from 'react-intl'; +import { Table } from 'react-bootstrap'; + +import AddExerciseTagForm from '../../components/forms/AddExerciseTagForm'; +import { addTag, removeTag } from '../../redux/modules/exercises'; +import { + exerciseSelector, + getExerciseTags, + getExerciseTagsLoading, + getExerciseTagsUpdatePending, +} from '../../redux/selectors/exercises'; +import ResourceRenderer from '../../components/helpers/ResourceRenderer'; +import { getTagCSSColor } from '../../helpers/exercise/tags'; +import Icon, { LoadingIcon, RemoveIcon } from '../../components/icons'; +import Button from '../../components/widgets/FlatButton'; + +const ADD_TAG_INITIAL_VALUES = { tag: '' }; + +const ExercisesTagsEditContainer = ({ exercise, tags, tagsLoading, updatePending, addTag, removeTag }) => ( + + {exercise => ( +
+ {exercise.tags && exercise.tags.length > 0 ? ( + + + {exercise.tags.sort().map(tag => ( + + + + + + ))} + +
+ + {tag} + +
+ ) : ( +

+ +

+ )} +
+ {tagsLoading ? ( +
?
+ ) : ( + + addTag(data.tag)} + initialValues={ADD_TAG_INITIAL_VALUES} + updatePending={updatePending} + /> + + )} +
+ )} +
+); + +ExercisesTagsEditContainer.propTypes = { + exerciseId: PropTypes.string.isRequired, + exercise: ImmutablePropTypes.map, + tags: PropTypes.array, + tagsLoading: PropTypes.bool.isRequired, + updatePending: PropTypes.bool.isRequired, + addTag: PropTypes.func.isRequired, + removeTag: PropTypes.func.isRequired, +}; + +export default connect( + (state, { exerciseId }) => ({ + exercise: exerciseSelector(exerciseId)(state), + tags: getExerciseTags(state), + tagsLoading: getExerciseTagsLoading(state), + updatePending: getExerciseTagsUpdatePending(state) !== null, + }), + (dispatch, { exerciseId }) => ({ + addTag: tagName => dispatch(addTag(exerciseId, tagName)), + removeTag: tagName => dispatch(removeTag(exerciseId, tagName)), + }) +)(ExercisesTagsEditContainer); diff --git a/src/containers/ExercisesTagsEditContainer/index.js b/src/containers/ExercisesTagsEditContainer/index.js new file mode 100644 index 000000000..e3128fcf6 --- /dev/null +++ b/src/containers/ExercisesTagsEditContainer/index.js @@ -0,0 +1,2 @@ +import ExercisesTagsEditContainer from './ExercisesTagsEditContainer'; +export default ExercisesTagsEditContainer; diff --git a/src/helpers/exercise/tags.js b/src/helpers/exercise/tags.js new file mode 100644 index 000000000..47bfeda75 --- /dev/null +++ b/src/helpers/exercise/tags.js @@ -0,0 +1,13 @@ +const goldenRatio = (1 + Math.sqrt(5)) / 2; + +/** + * A deterministic way to produce a random tag color from its name. + */ +export const getTagCSSColor = tag => { + const hash = tag.split('').reduce((res, char) => { + res = res + (char.charCodeAt(0) * goldenRatio) / 256; + return Math.abs(res - Math.trunc(res)); + }, 0); + const hue = Math.round(hash * 360); + return `hsl(${hue}, 66%, 42%)`; +}; diff --git a/src/locales/cs.json b/src/locales/cs.json index f894be9c2..9e5a2188a 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -8,6 +8,16 @@ "app.acceptSolution.acceptedShort": "Neakceptovat", "app.acceptSolution.notAccepted": "Akceptovat jako finální", "app.acceptSolution.notAcceptedShort": "Akceptovat", + "app.addExerciseTagForm.submit": "Přidat nálepku", + "app.addExerciseTagForm.submitting": "Přidávám...", + "app.addExerciseTagForm.success": "Nálepka přidána", + "app.addExerciseTagForm.validation.alreadyAssigned": "Úloha již obsahuje zvolenou nálepku.", + "app.addExerciseTagForm.validation.invalidCharacters": "Nálepka obsahuje nepovolené znaky. Povoleny jsou pouze alfanumerické znaky bez diakritiky, pomlčka a podtržítko.", + "app.addExerciseTagForm.validation.tooLong": "Nálepka je příliš dlouhá.", + "app.addExerciseTagForm.validation.tooShort": "Nálepka je příliš krátká.", + "app.addExerciseTagForm.warnings.newTag": "Definujete novou nálepku, která ještě nebyla použita u žádné úlohy. Ujistěte se, že v textu není překlep a že neexistuje jiná nálepka podobného významu, ale s jiným klíčovým slovem.", + "app.addExerciseTagForm.warnings.tooLong": "Nálepka je poněkud dlouhá.", + "app.addExerciseTagForm.warnings.tooShort": "Nálepka je poněkud krátká.", "app.addLicence.addLicenceTitle": "Přidat novou licenci", "app.addLicence.failed": "Přidávání licence selhalo.", "app.addLicence.note": "Popis:", @@ -253,6 +263,7 @@ "app.editExercise.deleteExerciseWarning": "Smazání úlohy odstraní všechna studentská řešení a všechna zadání této úlohy.", "app.editExercise.description": "Změna nastavení úlohy", "app.editExercise.editConfig": "Nastavení konfigurace úlohy", + "app.editExercise.editTags": "Editace nálepek", "app.editExercise.title": "Změna nastavení úlohy", "app.editExerciseAdvancedConfigForm.validation.emptyFileName": "Prosíme, vložte platné jméno.", "app.editExerciseConfig.cannotDisplayConfigForm": "Formulář s nastavením úlohy nemůže být zobrazen dokud nebude definován alespoň jeden test.", @@ -349,6 +360,7 @@ "app.editExerciseSimpleConfigTests.useCustomJudge": "Použít vlastní soubor sudího", "app.editExerciseSimpleConfigTests.useOutfile": "Použít výstupní soubor místo std. výstupu", "app.editExerciseSimpleConfigTests.validation.sentryPointString": "Vstupní bod musí být identifikátor.", + "app.editExerciseTags.noTags": "nejsou přiřazeny žádné nálepky", "app.editGroup.archivedExplain": "Archivní skupiny jsou ohrádky pro studenty, zadané úlohy a jejich řešení u skončených kurzů. Tyto skupiny není možné upravovat a je možné je najít na separátní stránce Archiv.", "app.editGroup.cannotDeleteGroupWithSubgroups": "Skupinu s podskupinami není možné smazat přímo.", "app.editGroup.cannotDeleteRootGroup": "Toto je primární skupina a jako taková nemůže být smazána.", @@ -456,7 +468,6 @@ "app.editTestsTest.name": "Název testu:", "app.editTestsTest.noTests": "Dosud nebyly přidány žádné testy. Je velmi vhodné definovat seznam testů jako první krok, neboť většina konfigurace na nich závisí.", "app.editTestsTest.pointsPercentage": "Body v procentech:", - "app.editTestsTest.remove": "Odebrat", "app.editTestsTest.weight": "Váha testu:", "app.editUser.description": "Upravit nastavení uživatele", "app.editUser.emailStillNotVerified": "Vaše emailová adresa dosud nebyla ověřena. ReCodEx se potřebuje spolehnout na platnost adres, protože řada notifikací je zasílána emailem. Pomocí tlačítka níže si můžete nechat opětovně zaslat ověřovací email. Ten obsahuje odkaz, který potvrzuje platnost adresy. Prosíme, ověřte vaši adresu co nejdříve.", @@ -1100,7 +1111,6 @@ "app.shadowAssignmentPointsTable.formModalTitle": "Přiřadit body za stínovou úlohu", "app.shadowAssignmentPointsTable.note": "Poznámka", "app.shadowAssignmentPointsTable.receivedPoints": "Body", - "app.shadowAssignmentPointsTable.removePointsButton": "Odebrat", "app.shadowAssignmentPointsTable.removePointsButtonConfirmation": "Opravdu si přejete odebrat přidělené body?", "app.shadowAssignmentPointsTable.title": "Body za stínovou úlohu", "app.shadowAssignmentPointsTable.updatePointsButton": "Upravit", @@ -1328,6 +1338,7 @@ "generic.operationFailed": "Operace se nezdařila. Prosíme, opakujte akci později.", "generic.reevaluatedBy": "Nechal(a) znovu vyhodnotit", "generic.refresh": "Občerstvit", + "generic.remove": "Odebrat", "generic.reset": "Resetovat", "generic.results": "Výsledky", "generic.role": "Role", diff --git a/src/locales/en.json b/src/locales/en.json index f24c19597..71784dac0 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -8,6 +8,16 @@ "app.acceptSolution.acceptedShort": "Revoke", "app.acceptSolution.notAccepted": "Accept as Final", "app.acceptSolution.notAcceptedShort": "Accept", + "app.addExerciseTagForm.submit": "Add Tag", + "app.addExerciseTagForm.submitting": "Adding...", + "app.addExerciseTagForm.success": "Tag Added", + "app.addExerciseTagForm.validation.alreadyAssigned": "Given tag is already assigned to the exercise.", + "app.addExerciseTagForm.validation.invalidCharacters": "The tag name contains invalid characters. Only alphanumeric letters, dash and underscore are allowed.", + "app.addExerciseTagForm.validation.tooLong": "The tag name is too long.", + "app.addExerciseTagForm.validation.tooShort": "The tag name is too short.", + "app.addExerciseTagForm.warnings.newTag": "You have specified a new tag, which has not been used in any exercise yet. Make sure that there is not a typo in your tag and there is no other tag with the same meaning but different key word.", + "app.addExerciseTagForm.warnings.tooLong": "The tag name is rather long.", + "app.addExerciseTagForm.warnings.tooShort": "The tag name is rather short.", "app.addLicence.addLicenceTitle": "Add New Licence", "app.addLicence.failed": "Cannot add the licence.", "app.addLicence.note": "Note:", @@ -253,6 +263,7 @@ "app.editExercise.deleteExerciseWarning": "Deleting an exercise will remove all the students submissions and all assignments.", "app.editExercise.description": "Change exercise settings", "app.editExercise.editConfig": "Edit Exercise Configuration", + "app.editExercise.editTags": "Edit Tags", "app.editExercise.title": "Edit exercise", "app.editExerciseAdvancedConfigForm.validation.emptyFileName": "Please, fill in a vaild file name.", "app.editExerciseConfig.cannotDisplayConfigForm": "The exercise configuration form cannot be displayed until at least one test is defined.", @@ -349,6 +360,7 @@ "app.editExerciseSimpleConfigTests.useCustomJudge": "Use custom judge binary", "app.editExerciseSimpleConfigTests.useOutfile": "Use output file instead of stdout", "app.editExerciseSimpleConfigTests.validation.sentryPointString": "The entry point value must be an identifier.", + "app.editExerciseTags.noTags": "no tags assigned", "app.editGroup.archivedExplain": "Archived groups are containers for students, assignments and results after the course is finished. They are immutable and can be accessed through separate Archive page.", "app.editGroup.cannotDeleteGroupWithSubgroups": "Group with nested sub-groups cannot be deleted.", "app.editGroup.cannotDeleteRootGroup": "This is a so-called root group and it cannot be deleted.", @@ -456,7 +468,6 @@ "app.editTestsTest.name": "Test name:", "app.editTestsTest.noTests": "There are no tests yet. It is highly recommended to set up the tests first since most of the remaining configurations depends on them.", "app.editTestsTest.pointsPercentage": "Points Percentage:", - "app.editTestsTest.remove": "Remove", "app.editTestsTest.weight": "Test weight:", "app.editUser.description": "Edit user's profile", "app.editUser.emailStillNotVerified": "Your email addres has not been verified yet. ReCodEx needs to rely on vaild addresses since many notifications are sent via email. You may send yourself a validation email using the button below and then use a link from that email to verify its acceptance. Please validate your address as soon as possible.", @@ -1100,7 +1111,6 @@ "app.shadowAssignmentPointsTable.formModalTitle": "Set Shadow Assignment Points", "app.shadowAssignmentPointsTable.note": "Note", "app.shadowAssignmentPointsTable.receivedPoints": "Points", - "app.shadowAssignmentPointsTable.removePointsButton": "Remove", "app.shadowAssignmentPointsTable.removePointsButtonConfirmation": "Do you really wish to remove awarded points?", "app.shadowAssignmentPointsTable.title": "Shadow Assignment Points", "app.shadowAssignmentPointsTable.updatePointsButton": "Edit", @@ -1328,6 +1338,7 @@ "generic.operationFailed": "Operation failed. Please try again later.", "generic.reevaluatedBy": "Re-evaluated by", "generic.refresh": "Refresh", + "generic.remove": "Remove", "generic.reset": "Reset", "generic.results": "Results", "generic.role": "Role", @@ -1356,4 +1367,4 @@ "recodex-judge-shuffle-all": "Unordered-tokens-and-rows judge", "recodex-judge-shuffle-newline": "Unordered-tokens judge (ignoring ends of lines)", "recodex-judge-shuffle-rows": "Unordered-rows judge" -} +} \ No newline at end of file diff --git a/src/locales/whitelist_en.json b/src/locales/whitelist_en.json index 4c850b2c8..1e668fb64 100644 --- a/src/locales/whitelist_en.json +++ b/src/locales/whitelist_en.json @@ -8,6 +8,16 @@ "app.acceptSolution.acceptedShort", "app.acceptSolution.notAccepted", "app.acceptSolution.notAcceptedShort", + "app.addExerciseTagForm.submit", + "app.addExerciseTagForm.submitting", + "app.addExerciseTagForm.success", + "app.addExerciseTagForm.validation.alreadyAssigned", + "app.addExerciseTagForm.validation.invalidCharacters", + "app.addExerciseTagForm.validation.tooLong", + "app.addExerciseTagForm.validation.tooShort", + "app.addExerciseTagForm.warnings.newTag", + "app.addExerciseTagForm.warnings.tooLong", + "app.addExerciseTagForm.warnings.tooShort", "app.addLicence.addLicenceTitle", "app.addLicence.failed", "app.addLicence.note", @@ -253,6 +263,7 @@ "app.editExercise.deleteExerciseWarning", "app.editExercise.description", "app.editExercise.editConfig", + "app.editExercise.editTags", "app.editExercise.title", "app.editExerciseAdvancedConfigForm.validation.emptyFileName", "app.editExerciseConfig.cannotDisplayConfigForm", @@ -349,6 +360,7 @@ "app.editExerciseSimpleConfigTests.useCustomJudge", "app.editExerciseSimpleConfigTests.useOutfile", "app.editExerciseSimpleConfigTests.validation.sentryPointString", + "app.editExerciseTags.noTags", "app.editGroup.archivedExplain", "app.editGroup.cannotDeleteGroupWithSubgroups", "app.editGroup.cannotDeleteRootGroup", @@ -456,7 +468,6 @@ "app.editTestsTest.name", "app.editTestsTest.noTests", "app.editTestsTest.pointsPercentage", - "app.editTestsTest.remove", "app.editTestsTest.weight", "app.editUser.description", "app.editUser.emailStillNotVerified", @@ -1100,7 +1111,6 @@ "app.shadowAssignmentPointsTable.formModalTitle", "app.shadowAssignmentPointsTable.note", "app.shadowAssignmentPointsTable.receivedPoints", - "app.shadowAssignmentPointsTable.removePointsButton", "app.shadowAssignmentPointsTable.removePointsButtonConfirmation", "app.shadowAssignmentPointsTable.title", "app.shadowAssignmentPointsTable.updatePointsButton", @@ -1328,6 +1338,7 @@ "generic.operationFailed", "generic.reevaluatedBy", "generic.refresh", + "generic.remove", "generic.reset", "generic.results", "generic.role", diff --git a/src/pages/EditExercise/EditExercise.js b/src/pages/EditExercise/EditExercise.js index c77f0e7a3..9982dce0f 100644 --- a/src/pages/EditExercise/EditExercise.js +++ b/src/pages/EditExercise/EditExercise.js @@ -11,11 +11,12 @@ import Page from '../../components/layout/Page'; import Box from '../../components/widgets/Box'; import EditExerciseForm from '../../components/forms/EditExerciseForm'; import AttachmentFilesTableContainer from '../../containers/AttachmentFilesTableContainer'; +import ExercisesTagsEditContainer from '../../containers/ExercisesTagsEditContainer'; import DeleteExerciseButtonContainer from '../../containers/DeleteExerciseButtonContainer'; import ExerciseButtons from '../../components/Exercises/ExerciseButtons'; import { NeedFixingIcon } from '../../components/icons'; -import { fetchExerciseIfNeeded, editExercise } from '../../redux/modules/exercises'; +import { fetchExerciseIfNeeded, editExercise, fetchTags } from '../../redux/modules/exercises'; import { getExercise } from '../../redux/selectors/exercises'; import { isSubmitting } from '../../redux/selectors/submission'; import { loggedInUserIdSelector } from '../../redux/selectors/auth'; @@ -53,7 +54,8 @@ class EditExercise extends Component { } } - static loadAsync = ({ exerciseId }, dispatch) => Promise.all([dispatch(fetchExerciseIfNeeded(exerciseId))]); + static loadAsync = ({ exerciseId }, dispatch) => + Promise.all([dispatch(fetchExerciseIfNeeded(exerciseId)), dispatch(fetchTags())]); editExerciseSubmitHandler = formData => { const { exercise, editExercise } = this.props; @@ -142,6 +144,9 @@ class EditExercise extends Component { + }> + +