Skip to content

Commit

Permalink
Form for editing exercise tags created and placed on edit exercise page.
Browse files Browse the repository at this point in the history
  • Loading branch information
Martin Krulis committed Nov 25, 2019
1 parent 24f3068 commit f84167f
Show file tree
Hide file tree
Showing 13 changed files with 320 additions and 18 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,7 @@ class ShadowAssignmentPointsTable extends Component {
}>
<Button bsStyle="danger" bsSize="xs">
<DeleteIcon gapRight />
<FormattedMessage
id="app.shadowAssignmentPointsTable.removePointsButton"
defaultMessage="Remove"
/>
<FormattedMessage id="generic.remove" defaultMessage="Remove" />
</Button>
</Confirm>
)}
Expand Down
159 changes: 159 additions & 0 deletions src/components/forms/AddExerciseTagForm/AddExerciseTagForm.js
Original file line number Diff line number Diff line change
@@ -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,
}) => (
<Form>
<table>
<tbody>
<tr>
<td className="full-width valign-top">
<datalist id="knownExerciseTags">
{tags.map(tag => (
<option key={tag}>{tag}</option>
))}
</datalist>
<Field
name="tag"
component={TextField}
ignoreDirty
groupClassName="full-width"
list="knownExerciseTags"
maxLength={16}
/>
</td>
<td className="valign-top">
<SubmitButton
id="addExerciseTag"
disabled={invalid || updatePending}
submitting={submitting}
hasSucceeded={submitSucceeded}
hasFailed={submitFailed}
handleSubmit={handleSubmit(data => onSubmit(data).then(reset))}
defaultIcon={updatePending ? <LoadingIcon gapRight /> : <AddIcon gapRight />}
messages={{
submit: <FormattedMessage id="app.addExerciseTagForm.submit" defaultMessage="Add Tag" />,
submitting: <FormattedMessage id="app.addExerciseTagForm.submitting" defaultMessage="Adding..." />,
success: <FormattedMessage id="app.addExerciseTagForm.success" defaultMessage="Tag Added" />,
}}
/>
</td>
</tr>
</tbody>
</table>
</Form>
);

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 = (
<FormattedMessage id="app.addExerciseTagForm.validation.tooShort" defaultMessage="The tag name is too short." />
);
return errors;
}

if (tag.length > 16) {
errors.tag = (
<FormattedMessage id="app.addExerciseTagForm.validation.tooLong" defaultMessage="The tag name is too long." />
);
return errors;
}

if (!tag.match(/^[-a-zA-Z0-9_]+$/)) {
errors.tag = (
<FormattedMessage
id="app.addExerciseTagForm.validation.invalidCharacters"
defaultMessage="The tag name contains invalid characters. Only alphanumeric letters, dash and underscore are allowed."
/>
);
return errors;
}

if (exercise && exercise.tags && exercise.tags.includes(tag)) {
errors.tag = (
<FormattedMessage
id="app.addExerciseTagForm.validation.alreadyAssigned"
defaultMessage="Given tag is already assigned to the exercise."
/>
);
return errors;
}

return errors;
};

const warn = ({ tag }, { tags }) => {
const warnings = {};
if (!tag) {
return warnings;
}

if (tag.length < 3) {
warnings.tag = (
<FormattedMessage id="app.addExerciseTagForm.warnings.tooShort" defaultMessage="The tag name is rather short." />
);
return warnings;
}

if (tag.length > 12) {
warnings.tag = (
<FormattedMessage id="app.addExerciseTagForm.warnings.tooLong" defaultMessage="The tag name is rather long." />
);
return warnings;
}

if (tags && tags.length > 0) {
if (!tags.includes(tag)) {
warnings.tag = (
<FormattedMessage
id="app.addExerciseTagForm.warnings.newTag"
defaultMessage="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."
/>
);
}
return warnings;
}

return warnings;
};

export default reduxForm({
form: 'addExerciseTag',
validate,
warn,
})(AddExerciseTagForm);
2 changes: 2 additions & 0 deletions src/components/forms/AddExerciseTagForm/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import AddExerciseTagForm from './AddExerciseTagForm';
export default AddExerciseTagForm;
2 changes: 1 addition & 1 deletion src/components/forms/EditTestsForm/EditTestsTestRow.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const EditTestsTestRow = ({ test, onRemove, isUniform, percent, readOnly = false
<td style={{ verticalAlign: 'middle' }}>
<Button onClick={onRemove} bsStyle={'danger'} bsSize="xs" className="btn-flat pull-right">
<RemoveIcon gapRight />
<FormattedMessage id="app.editTestsTest.remove" defaultMessage="Remove" />
<FormattedMessage id="generic.remove" defaultMessage="Remove" />
</Button>
</td>
)}
Expand Down
4 changes: 2 additions & 2 deletions src/components/icons/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import Icon from './Icon';

const defaultMessageIcon = ['far', 'envelope'];

export const AddIcon = props => <Icon {...props} icon="plus" />;
export const AddIcon = props => <Icon {...props} icon="plus-circle" />;
export const AdressIcon = props => <Icon {...props} icon="at" />;
export const ArchiveGroupIcon = ({ archived = false, ...props }) => (
<Icon {...props} icon={archived ? 'dolly' : 'archive'} />
Expand Down Expand Up @@ -39,7 +39,7 @@ export const MailIcon = props => <Icon {...props} icon={defaultMessageIcon} />;
export const NeedFixingIcon = props => <Icon {...props} icon="medkit" />;
export const PipelineIcon = props => <Icon {...props} icon="random" />;
export const RefreshIcon = props => <Icon {...props} icon="sync" />;
export const RemoveIcon = props => <Icon {...props} icon="minus" />;
export const RemoveIcon = props => <Icon {...props} icon="minus-circle" />;
export const ResultsIcon = props => <Icon {...props} icon="chart-line" />;
export const SearchIcon = props => <Icon {...props} icon="search" />;
export const SendIcon = props => <Icon {...props} icon={['far', 'paper-plane']} />;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }) => (
<ResourceRenderer resource={exercise}>
{exercise => (
<div>
{exercise.tags && exercise.tags.length > 0 ? (
<Table hover condensed>
<tbody>
{exercise.tags.sort().map(tag => (
<tr key={`${exercise.id}:${tag}`}>
<td>
<Icon icon="tag" style={{ color: getTagCSSColor(tag) }}></Icon>
</td>
<td className="full-width">{tag}</td>
<td>
<Button bsStyle="danger" bsSize="xs" onClick={() => removeTag(tag)} disabled={updatePending}>
{updatePending ? <LoadingIcon gapRight /> : <RemoveIcon gapRight />}
<FormattedMessage id="generic.remove" defaultMessage="Remove" />
</Button>
</td>
</tr>
))}
</tbody>
</Table>
) : (
<p className="small text-muted text-center">
<FormattedMessage id="app.editExerciseTags.noTags" defaultMessage="no tags assigned" />
</p>
)}
<hr />
{tagsLoading ? (
<div>?</div>
) : (
<React.Fragment>
<AddExerciseTagForm
exercise={exercise}
tags={tags}
onSubmit={data => addTag(data.tag)}
initialValues={ADD_TAG_INITIAL_VALUES}
updatePending={updatePending}
/>
</React.Fragment>
)}
</div>
)}
</ResourceRenderer>
);

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);
2 changes: 2 additions & 0 deletions src/containers/ExercisesTagsEditContainer/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import ExercisesTagsEditContainer from './ExercisesTagsEditContainer';
export default ExercisesTagsEditContainer;
13 changes: 13 additions & 0 deletions src/helpers/exercise/tags.js
Original file line number Diff line number Diff line change
@@ -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%)`;
};
15 changes: 13 additions & 2 deletions src/locales/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading

0 comments on commit f84167f

Please sign in to comment.