) {
ev.preventDefault();
this.props.history.push(ev.currentTarget.pathname);
}
@@ -49,7 +65,7 @@ class Search extends React.Component {
exercises.map((e) => );
return (
-
+
{clauses.map((c, i) =>
)}
{pagination &&
}
{body}
diff --git a/exercises/src/components/search/clause.jsx b/exercises/src/components/search/clause.jsx
index 24f2624eef..dec77ff39d 100644
--- a/exercises/src/components/search/clause.jsx
+++ b/exercises/src/components/search/clause.jsx
@@ -18,7 +18,7 @@ class Clause extends React.Component {
const { clause } = this.props;
return (
-
+
- Go
+ Go
diff --git a/exercises/src/components/tags/public-solutions.jsx b/exercises/src/components/tags/public-solutions.jsx
new file mode 100644
index 0000000000..13e95155db
--- /dev/null
+++ b/exercises/src/components/tags/public-solutions.jsx
@@ -0,0 +1,36 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { observer } from 'mobx-react';
+import { action, modelize } from 'shared/model';
+import Exercise from '../../models/exercises/exercise';
+import Wrapper from './wrapper';
+
+@observer
+class PublicSolutions extends React.Component {
+ static propTypes = {
+ exercise: PropTypes.instanceOf(Exercise).isRequired,
+ };
+
+ constructor(props) {
+ super(props);
+ modelize(this);
+ }
+
+ @action.bound updateValue(ev) {
+ this.props.exercise.solutions_are_public = ev.target.checked;
+ }
+
+ render() {
+ return (
+
+
+
+ );
+ }
+}
+
+export default PublicSolutions;
diff --git a/exercises/src/models/exercises.ts b/exercises/src/models/exercises.ts
index 132c939c7d..6787e936cf 100644
--- a/exercises/src/models/exercises.ts
+++ b/exercises/src/models/exercises.ts
@@ -78,9 +78,11 @@ export class ExercisesMap extends Map {
}
}
- publish(exercise: Exercise) {
- const data = exercise.toJSON()
- return { uid: exercise.uid, data } ;
+ async publish(exercise: Exercise) {
+ this.onSaved(
+ await this.api.request(urlFor('publish', { uid: exercise.uid }), { data: exercise.toJSON() }),
+ exercise
+ )
}
async saveDraft(exercise: Exercise) {
@@ -91,7 +93,7 @@ export class ExercisesMap extends Map {
url = urlFor('saveExistingDraft', { number: exercise.number })
}
this.onSaved(
- await this.api.request(url, exercise.toJSON()),
+ await this.api.request(url, { data: exercise.toJSON() }),
exercise
)
}
diff --git a/exercises/src/models/exercises/exercise.ts b/exercises/src/models/exercises/exercise.ts
index dac430bef2..7c51d646c4 100644
--- a/exercises/src/models/exercises/exercise.ts
+++ b/exercises/src/models/exercises/exercise.ts
@@ -1,6 +1,6 @@
import { action } from 'mobx';
import { merge, find, isEmpty, isObject, map } from 'lodash';
-import { modelize, model, hydrateModel, observable, array } from 'shared/model';
+import { field, modelize, model, hydrateModel, observable, array } from 'shared/model';
import Image from './image';
import Delegation from './delegation';
import SharedExercise from 'shared/model/exercise';
@@ -9,6 +9,7 @@ import CurrentUser from '../user';
export default
class Exercise extends SharedExercise {
+ @field solutions_are_public = false
static build(attrs: any) {
return hydrateModel(Exercise, merge(attrs, {
diff --git a/exercises/src/models/search.ts b/exercises/src/models/search.ts
index fdff09179e..6a7015def3 100644
--- a/exercises/src/models/search.ts
+++ b/exercises/src/models/search.ts
@@ -4,6 +4,8 @@ import {
} from 'shared/model';
import Exercise from './exercises/exercise';
import urlFor from '../api'
+import pluralize from 'pluralize';
+import { toSentence } from 'shared/helpers/string'
class Clause extends BaseModel {
@@ -21,6 +23,10 @@ class Clause extends BaseModel {
return `Search by ${this.filter}`;
}
+ @computed get asString() {
+ return `${this.filter}="${this.value}"`;
+ }
+
@action.bound setFilter(filter: string) {
this.filter = filter;
this.search.currentPage = 1;
@@ -72,6 +78,12 @@ class Search extends BaseModel {
this.perform();
}
+ @computed get title() {
+ if (!this.exercises.length) {
+ return 'Exercise Search';
+ }
+ return `${pluralize('exercise', this.exercises.length, true)} found for ${toSentence(this.clauses.map(c => c.asString))}`
+ }
@action.bound onPageChange(pg: number) {
this.currentPage = pg;
diff --git a/exercises/src/ux.js b/exercises/src/ux.ts
similarity index 100%
rename from exercises/src/ux.js
rename to exercises/src/ux.ts
diff --git a/shared/resources/styles/components/question.scss b/shared/resources/styles/components/question.scss
index 41aba9d0f7..566c8c133c 100644
--- a/shared/resources/styles/components/question.scss
+++ b/shared/resources/styles/components/question.scss
@@ -3,18 +3,18 @@
//@include clearfix;
.detailed-solution {
- margin-bottom: 1.5rem;
-
- .header {
- color: #5e6062;
- font-weight: bold;
- margin-bottom: 0.5rem;
- }
-
- .solution {
- color: #6f6f6f;
margin-bottom: 1rem;
- }
+ .header {
+ display: inline;
+ float: left;
+ margin-right: 0.5rem;
+ color: #5e6062;
+ font-weight: bold;
+ flex-basis: 0;
+ }
+ .solution {
+ color: #6f6f6f;
+ }
}
img {
diff --git a/shared/specs/components/exercise-preview/__snapshots__/index.spec.js.snap b/shared/specs/components/exercise-preview/__snapshots__/index.spec.js.snap
index 707c0ed91b..c8ca36fc08 100644
--- a/shared/specs/components/exercise-preview/__snapshots__/index.spec.js.snap
+++ b/shared/specs/components/exercise-preview/__snapshots__/index.spec.js.snap
@@ -159,7 +159,7 @@ exports[`Exercise Preview Component callbacks are called when overlay and action
- Detailed solution
+ Detailed solution:
- Detailed solution
+ Detailed solution:
-
-
- Formats:
-
-
- free-response
-
-
- multiple-choice
-
-
- Detailed solution
+ Detailed solution:
+
+
+ Formats:
+
+
+ free-response
+
+
+ multiple-choice
+
+
@@ -703,7 +703,7 @@ exports[`Exercise Preview Component hides context if missing 1`] = `
- Detailed solution
+ Detailed solution:
- Detailed solution
+ Detailed solution:
- Detailed solution
+ Detailed solution:
- Detailed solution
+ Detailed solution:
- Detailed solution
+ Detailed solution:
- Detailed solution
+ Detailed solution:
{this.props.children}
- {this.props.displayFormats ?
: undefined}
{solution}
+ {this.props.displayFormats ?
: undefined}
{exerciseUid}
);
diff --git a/shared/src/helpers/string.ts b/shared/src/helpers/string.ts
new file mode 100644
index 0000000000..6bb4b8ef3f
--- /dev/null
+++ b/shared/src/helpers/string.ts
@@ -0,0 +1,128 @@
+import { isNaN, isString, isNumber, isEmpty as _isEmpty, trimStart, trimEnd } from 'lodash';
+
+const SMALL_WORDS = /^(a|an|and|as|at|but|by|en|for|if|in|nor|of|on|or|per|the|to|vs?\.?|via)$/i;
+const UUID_REGEX = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/;
+
+
+export function toNumber(value: number | string) {
+ return isNumber(value) ? value : parseFloat(value)
+}
+export function toInt(string: string) {
+ const int = parseInt(string);
+ if (isNaN(int)) {
+ return 0;
+ }
+ return int;
+}
+export function asPercent(num: number) {
+ return Math.round(num * 100)
+}
+
+export function numberWithOneDecimalPlace(value: number | string) {
+ const num = toNumber(value)
+ return (Math.round(num * 10) / 10).toFixed(1)
+}
+export function numberWithTwoDecimalPlaces(val: number | string) {
+ return (Math.round(toNumber(val) * 100) / 100).toFixed(2)
+}
+
+export function capitalize(string: string, lowerOthers = true) {
+ const other = lowerOthers ? string.substring(1).toLowerCase() : string.substring(1);
+ return string.charAt(0).toUpperCase() + other;
+}
+
+export function replaceAt(string: string, index: number, character: string) {
+ return string.substr(0, index) + character + string.substr(index + character.length);
+}
+
+export function insertAt(string: string, index: number, character: string) {
+ return string.substr(0, index) + character + string.substr(index);
+}
+
+export function removeAt(string: string, index: number, length = 1) {
+ return string.substr(0, index) + string.substr(index + length);
+}
+
+export function getNumberAndStringOrder(string: string) {
+ const parsedInt = parseFloat(string);
+ if (isNaN(parsedInt)) { return string.toLowerCase(); } else { return parsedInt; }
+}
+export function dasherize(string: string) {
+ return String(string)
+ .replace(/[A-Z]/g, (char, index) => (index !== 0 ? '-' : '') + char.toLowerCase())
+ .replace(/[-_\s]+/g, '-');
+}
+
+// originated from http://individed.com/code/to-title-case/
+export function titleize(string = '') {
+ return String(string)
+ .replace(/_/g, ' ')
+ .replace(/[A-Za-z0-9\u00C0-\u00FF]+[^\s-]*/g, function(match, index, title) {
+ if ((index > 0) && ((index + match.length) !== title.length) &&
+ (match.search(SMALL_WORDS) > -1) &&
+ (title.charAt(index - 2) !== ':') &&
+ ( (title.charAt(index + match.length) !== '-') || (title.charAt(index - 1) === '-') ) &&
+ (title.charAt(index - 1).search(/[^\s-]/) < 0)) {
+
+ return match.toLowerCase();
+ }
+
+ if (match.substr(1).search(/[A-Z]|\../) > -1) {
+ return match;
+ }
+
+ return match.charAt(0).toUpperCase() + match.substr(1);
+ });
+}
+
+export function toSentence(arry: string | string[], join = 'and') {
+ if (isString(arry)) { arry = arry.split(' '); }
+ if (arry.length > 1) {
+ return `${arry.slice(0, arry.length - 1).join(', ')} ${join} ${arry.slice(-1)}`;
+ } else {
+ return arry[0];
+ }
+}
+
+export function isEmpty(s: string | null | undefined): boolean {
+ return Boolean(
+ _isEmpty(s) || (isString(s) && !s.match(/\S/))
+ );
+}
+
+export function isUUID(uuid = '') { return UUID_REGEX.test(uuid); }
+
+export function countWords(text: string) {
+ if(!isString(text)) return 0;
+
+ let trimmedText = trimStart(text);
+ trimmedText = trimEnd(trimmedText);
+ //https://css-tricks.com/build-word-counter-app/
+ const words = trimmedText.match(/\b[-?(\w+)?]+\b/gi);
+ if(!words) return 0;
+ return words.length;
+}
+
+export function stripHTMLTags(text: string) {
+ return isString(text) ? text.replace(/(<([^>]+)>)/ig, '') : text;
+}
+
+export default {
+ toNumber,
+ toInt,
+ asPercent,
+ numberWithOneDecimalPlace,
+ numberWithTwoDecimalPlaces,
+ capitalize,
+ replaceAt,
+ insertAt,
+ removeAt,
+ getNumberAndStringOrder,
+ dasherize,
+ titleize,
+ toSentence,
+ isEmpty,
+ isUUID,
+ countWords,
+ stripHTMLTags,
+};
diff --git a/tutor/specs/components/exercises/__snapshots__/preview.spec.js.snap b/tutor/specs/components/exercises/__snapshots__/preview.spec.js.snap
index 8ba0bb541e..8eea8b943a 100644
--- a/tutor/specs/components/exercises/__snapshots__/preview.spec.js.snap
+++ b/tutor/specs/components/exercises/__snapshots__/preview.spec.js.snap
@@ -226,7 +226,7 @@ exports[`Exercise Preview Wrapper Component renders and matches snapshot 1`] = `
- Detailed solution
+ Detailed solution:
(index !== 0 ? '-' : '') + char.toLowerCase())
- .replace(/[-_\s]+/g, '-');
- },
-
- // originated from http://individed.com/code/to-title-case/
- titleize(string = '') {
- return String(string)
- .replace(/_/g, ' ')
- .replace(/[A-Za-z0-9\u00C0-\u00FF]+[^\s-]*/g, function(match, index, title) {
- if ((index > 0) && ((index + match.length) !== title.length) &&
- (match.search(SMALL_WORDS) > -1) &&
- (title.charAt(index - 2) !== ':') &&
- ( (title.charAt(index + match.length) !== '-') || (title.charAt(index - 1) === '-') ) &&
- (title.charAt(index - 1).search(/[^\s-]/) < 0)) {
-
- return match.toLowerCase();
- }
-
- if (match.substr(1).search(/[A-Z]|\../) > -1) {
- return match;
- }
-
- return match.charAt(0).toUpperCase() + match.substr(1);
- });
- },
-
- toSentence(arry, join = 'and') {
- if (isString(arry)) { arry = arry.split(' '); }
- if (arry.length > 1) {
- return `${arry.slice(0, arry.length - 1).join(', ')} ${join} ${arry.slice(-1)}`;
- } else {
- return arry[0];
- }
- },
-
- isEmpty(s) {
- return Boolean(
- isEmpty(s) || (isString(s) && !s.match(/\S/))
- );
- },
-
- isUUID(uuid = '') { return UUID_REGEX.test(uuid); },
-
- stringToInt(string) {
- const int = parseInt(string);
- if (isNaN(int)) {
- return 0;
- }
- return int;
- },
-
- countWords(text) {
- if(!isString(text)) return 0;
-
- let trimmedText = trimStart(text);
- trimmedText = trimEnd(trimmedText);
- //https://css-tricks.com/build-word-counter-app/
- const words = trimmedText.match(/\b[-?(\w+)?]+\b/gi);
- if(!words) return 0;
- return words.length;
- },
-
- stripHTMLTags(text) {
- return isString(text) ? text.replace(/(<([^>]+)>)/ig, '') : text;
- },
-
- assignmentHeaderText(type) {
- if(type === 'external') return 'external assignment';
- return type;
- },
-
-};
diff --git a/tutor/src/helpers/string.ts b/tutor/src/helpers/string.ts
new file mode 100644
index 0000000000..12ff37082c
--- /dev/null
+++ b/tutor/src/helpers/string.ts
@@ -0,0 +1,8 @@
+export function assignmentHeaderText(type: string) {
+ if(type === 'external') return 'external assignment';
+ return type;
+}
+import S from 'shared/helpers/string'
+
+export * from 'shared/helpers/string'
+export default S
diff --git a/tutor/src/models/student-tasks/task.ts b/tutor/src/models/student-tasks/task.ts
index 95ccc1ef91..f58cc4e48a 100644
--- a/tutor/src/models/student-tasks/task.ts
+++ b/tutor/src/models/student-tasks/task.ts
@@ -75,7 +75,7 @@ export class StudentTask extends BaseModel {
}
@computed get humanLateWorkPenalty() {
- const amount = this.late_work_penalty_applied !== 'not_accepted' ? this.late_work_penalty_per_period : 1;
+ const amount = this.late_work_penalty_applied !== 'not_accepted' ? this.late_work_penalty_per_period || 0 : 1;
return `${S.asPercent(amount)}%`;
}
diff --git a/tutor/src/screens/assignment-edit/index.js b/tutor/src/screens/assignment-edit/index.js
index 0e44d5c778..5ee01fd648 100644
--- a/tutor/src/screens/assignment-edit/index.js
+++ b/tutor/src/screens/assignment-edit/index.js
@@ -9,7 +9,7 @@ import { withRouter } from 'react-router';
import UX from './ux';
import { BackgroundWrapper, ContentWrapper } from '../../helpers/background-wrapper';
import CourseBreadcrumb from '../../components/course-breadcrumb';
-import S from '../../helpers/string';
+import { assignmentHeaderText } from '../../helpers/string';
import './styles.scss';
@@ -80,7 +80,7 @@ class AssignmentBuilder extends React.Component {
courseId={ux.course.id}
>
-
+