diff --git a/exercises/resources/styles/app.scss b/exercises/resources/styles/app.scss index 5b7a65a738..3a3c85b044 100644 --- a/exercises/resources/styles/app.scss +++ b/exercises/resources/styles/app.scss @@ -16,6 +16,7 @@ $input-height: 1.5rem; @import "./vocabulary"; @import './tags'; @import './preview'; +@import './print'; .exercises-body { margin-top: 60px; diff --git a/exercises/resources/styles/print.scss b/exercises/resources/styles/print.scss new file mode 100644 index 0000000000..88b1179d60 --- /dev/null +++ b/exercises/resources/styles/print.scss @@ -0,0 +1,53 @@ +@media print { + .panel.search { + + .card-footer, + .pagination, + .search-filter + { + display: none; + } + + .search-title { + position: fixed; + top: 0; + display: none; + } + + .openstax-exercise-preview { + page-break-inside: avoid; + border: 1px solid black; + + .card-body { + .answers-table { + margin-bottom: 0.5rem; + line-height: inherit; + label { + margin-bottom: 0; + } + .question-feedback-content { + line-height: 1rem; + margin: 0; + } + } + .question-stem { + margin-bottom: 0; + } + } + + + .openstax-question { + .detailed-solution { + margin-bottom: 0; + .solution, + .header { + margin-bottom: 0; + } + + } + } + } + } + +} + diff --git a/exercises/specs/components/__snapshots__/exercise.spec.js.snap b/exercises/specs/components/__snapshots__/exercise.spec.js.snap index b731598574..96504746bc 100644 --- a/exercises/specs/components/__snapshots__/exercise.spec.js.snap +++ b/exercises/specs/components/__snapshots__/exercise.spec.js.snap @@ -1316,6 +1316,28 @@ exports[`Exercises component renders and matches snapshot 1`] = ` +
+
+ + Solution is public + +
+
+ +
@@ -1507,28 +1529,13 @@ exports[`Exercises component renders and matches snapshot 1`] = ` -
-
- Formats: -
- - free-response - - - multiple-choice - -
- Detailed solution + Detailed solution:
+
+
+ Formats: +
+ + free-response + + + multiple-choice + +
@@ -4263,6 +4285,28 @@ exports[`Exercises component renders with intro and a multiple questions when ex +
+
+ + Solution is public + +
+
+ +
@@ -4444,28 +4488,13 @@ exports[`Exercises component renders with intro and a multiple questions when ex -
-
- Formats: -
- - free-response - - - multiple-choice - -
- Detailed solution + Detailed solution:
+
+
+ Formats: +
+ + free-response + + + multiple-choice + +
@@ -4742,28 +4786,13 @@ exports[`Exercises component renders with intro and a multiple questions when ex
-
-
- Formats: -
- - free-response - - - multiple-choice - -
- Detailed solution + Detailed solution:
+
+
+ Formats: +
+ + free-response + + + multiple-choice + +
@@ -4900,28 +4944,13 @@ exports[`Exercises component renders with intro and a multiple questions when ex
-
-
- Formats: -
- - free-response - - - multiple-choice - -
- Detailed solution + Detailed solution:
+
+
+ Formats: +
+ + free-response + + + multiple-choice + +
@@ -6000,6 +6044,19 @@ exports[`Exercises component resets fields when model is new 1`] = ` + + +
+
+ + Solution is public + +
+
+ +
+ +
@@ -6205,6 +6262,14 @@ exports[`Exercises component resets fields when model is new 1`] = ` +
+
+ Detailed solution: +
+ +
+ +
@@ -6218,14 +6283,6 @@ exports[`Exercises component resets fields when model is new 1`] = `
-
-
- Detailed solution -
- -
- -
diff --git a/exercises/src/components/exercise/tags.jsx b/exercises/src/components/exercise/tags.jsx index c9ca670415..e2101ad340 100644 --- a/exercises/src/components/exercise/tags.jsx +++ b/exercises/src/components/exercise/tags.jsx @@ -14,6 +14,7 @@ import HistoricalThinking from '../tags/historical-thinking'; import ReasoningProcess from '../tags/reasoning-process'; import ApLo from '../tags/aplo'; import SciencePractice from '../tags/science-practice'; +import PublicSolutions from '../tags/public-solutions'; import Exercise from '../../models/exercises/exercise'; function ExerciseTags({ exercise }) { @@ -36,6 +37,7 @@ function ExerciseTags({ exercise }) {
); diff --git a/exercises/src/components/search.jsx b/exercises/src/components/search.tsx similarity index 70% rename from exercises/src/components/search.jsx rename to exercises/src/components/search.tsx index d51d70c7d5..d288675383 100644 --- a/exercises/src/components/search.jsx +++ b/exercises/src/components/search.tsx @@ -5,20 +5,26 @@ import Preview from './exercise/preview'; import Clause from './search/clause'; import Controls from './search/controls'; import { observer, inject } from 'mobx-react'; +import type { IReactionDisposer } from 'mobx'; import BSPagination from 'shared/components/pagination'; import Loading from 'shared/components/loading-animation'; -import { modelize, action } from 'shared/model'; +import { modelize, action, autorun } from 'shared/model'; import UX from '../ux'; + const Pagination = styled(BSPagination)` justify-content: center; margin-top: 2rem; - `; +interface SearchProps { + ux: UX + history: any +} + @inject('ux') @observer -class Search extends React.Component { +class Search extends React.Component { static Controls = Controls; static propTypes = { @@ -28,16 +34,26 @@ class Search extends React.Component { }).isRequired, }; - constructor(props) { + titleChangeDisposer: IReactionDisposer + + constructor(props: SearchProps) { super(props); modelize(this); + this.titleChangeDisposer = autorun(() => { + document.title = this.search.title + }) + } + + componentWillUnmount() { + this.titleChangeDisposer() + document.title = 'OpenStax Exercises' } get search() { return this.props.ux.search; } - @action.bound onEdit(ev) { + @action.bound onEdit(ev: React.MouseEvent) { 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:
); 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} > - +