diff --git a/lms/static/scripts/frontend_apps/components/AutoGradingConfigurator.tsx b/lms/static/scripts/frontend_apps/components/AutoGradingConfigurator.tsx new file mode 100644 index 0000000000..e39b598aac --- /dev/null +++ b/lms/static/scripts/frontend_apps/components/AutoGradingConfigurator.tsx @@ -0,0 +1,210 @@ +import { + Checkbox, + CheckboxCheckedFilledIcon, + Input, + RadioGroup, +} from '@hypothesis/frontend-shared'; +import type { ComponentChildren } from 'preact'; +import { useCallback, useId } from 'preact/hooks'; + +export type GradingType = 'all_or_nothing' | 'scaled'; + +export type AutoGradingConfig = { + /** Whether auto grading is active for the assignment or not */ + active?: boolean; + + /** + * - all_or_nothing: students need to meet a minimum value, making them get + * either 0% or 100% + * - scaled: students may get a proportional grade based on the amount of + * annotations. If requirement is 4, and they created 3, they'll + * get a 75% + */ + gradingType: GradingType; + + /** + * - cumulative: both annotations and replies will be counted together for + * the grade calculation + * - separately: students will have different annotation and reply goals. + */ + activityCalculation: 'cumulative' | 'separately'; + + /** + * Required number of annotations if activityCalculation is 'separately' or + * annotations+replies otherwise + */ + requiredAnnotations: number; + + /** + * Required number of replies if activityCalculation is 'separately' + */ + requiredReplies?: number; +}; + +type AnnotationsGoalInputGroupProps = { + children?: ComponentChildren; + gradingType: GradingType; + value: number; + onChange: (newValue: number) => void; +}; + +/** + * Controls containing a number input to set the amount of required annotations + * or replies + */ +function AnnotationsGoalInputGroup({ + children, + gradingType, + value, + onChange, +}: AnnotationsGoalInputGroupProps) { + const inputId = useId(); + + return ( +
+ + onChange(Number((e.target as HTMLInputElement).value))} + /> +
+ ); +} + +export type AutoGradingConfiguratorProps = { + config: AutoGradingConfig; + updateAutoGradingConfig: (newConfig: AutoGradingConfig) => void; +}; + +/** + * Allows instructors to enable auto grading for an assignment, and provide the + * configuration to determine how to calculate every student's grade. + */ +export default function AutoGradingConfigurator({ + config, + updateAutoGradingConfig, +}: AutoGradingConfiguratorProps) { + const { + active = false, + gradingType, + activityCalculation, + requiredAnnotations, + requiredReplies = 1, + } = config; + const updatePartialConfig = useCallback( + (partialConfig: Partial) => + updateAutoGradingConfig({ ...config, ...partialConfig }), + [config, updateAutoGradingConfig], + ); + + const gradingTypeId = useId(); + const activityCalculationId = useId(); + + return ( +
+ + updatePartialConfig({ + active: (e.target as HTMLInputElement).checked, + }) + } + > + Enable automatic participation grading + + {active && ( + <> +
+

+ Grading type +

+ updatePartialConfig({ gradingType })} + > + Must meet minimum requirements.} + > + All or nothing + + Proportional to percent completed.} + > + Scaled + + +
+
+

+ Activity calculation +

+ + updatePartialConfig({ activityCalculation }) + } + > + Annotations and replies tallied together. + } + > + Calculate cumulative + + Annotations and replies tallied separately. + } + > + Calculate separately + + +
+ + updatePartialConfig({ requiredAnnotations }) + } + > + Annotations{activityCalculation === 'cumulative' && ' and replies'} + + {activityCalculation === 'separately' && ( + + updatePartialConfig({ requiredReplies }) + } + > + Replies + + )} + + )} +
+ ); +} diff --git a/lms/static/scripts/frontend_apps/components/FilePickerApp.tsx b/lms/static/scripts/frontend_apps/components/FilePickerApp.tsx index 36ccc100b4..f8ffb49be3 100644 --- a/lms/static/scripts/frontend_apps/components/FilePickerApp.tsx +++ b/lms/static/scripts/frontend_apps/components/FilePickerApp.tsx @@ -22,6 +22,8 @@ import { apiCall } from '../utils/api'; import type { Content, URLContent } from '../utils/content-item'; import { truncateURL } from '../utils/format'; import { useUniqueId } from '../utils/hooks'; +import type { AutoGradingConfig } from './AutoGradingConfigurator'; +import AutoGradingConfigurator from './AutoGradingConfigurator'; import ContentSelector from './ContentSelector'; import ErrorModal from './ErrorModal'; import FilePickerFormFields from './FilePickerFormFields'; @@ -170,7 +172,13 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) { settings: { groupsEnabled: enableGroupConfig }, }, assignment, - filePicker: { deepLinkingAPI, formAction, formFields, promptForTitle }, + filePicker: { + deepLinkingAPI, + formAction, + formFields, + promptForTitle, + autoGradingEnabled, + }, } = useConfig(['api', 'filePicker']); // Currently selected content for assignment. @@ -178,6 +186,14 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) { assignment ? contentFromURL(assignment.document.url) : null, ); + const [autoGradingConfig, setAutoGradingConfig] = useState( + { + gradingType: 'all_or_nothing', + activityCalculation: 'cumulative', + requiredAnnotations: 1, + }, + ); + // Flag indicating if we are editing content that was previously selected. const [editingContent, setEditingContent] = useState(false); // True if we are editing an existing assignment configuration. @@ -185,7 +201,8 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) { // Whether there are additional configuration options to present after the // user has selected the content for the assignment. - const showDetailsScreen = enableGroupConfig || promptForTitle; + const showDetailsScreen = + enableGroupConfig || promptForTitle || autoGradingEnabled; let currentStep: PickerStep; if (editingContent) { @@ -243,6 +260,15 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) { ...deepLinkingAPI.data, content, group_set: groupConfig.useGroupSet ? groupConfig.groupSet : null, + auto_grading_config: + autoGradingEnabled && autoGradingConfig.active + ? { + grading_type: autoGradingConfig.gradingType, + activity_calculation: autoGradingConfig.activityCalculation, + required_annotations: autoGradingConfig.requiredAnnotations, + required_replies: autoGradingConfig.requiredReplies, + } + : null, title, }; setDeepLinkingFields( @@ -269,6 +295,8 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) { groupConfig.groupSet, groupConfig.useGroupSet, title, + autoGradingEnabled, + autoGradingConfig, ], ); @@ -415,12 +443,20 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) { /> )} + {autoGradingEnabled && ( + <> +
+ Auto grading + + + )} {enableGroupConfig && ( <>
- - Group assignment - + Group assignment
onChangeGroupConfig({ useGroupSet: (e.target as HTMLInputElement).checked, diff --git a/lms/static/scripts/frontend_apps/components/test/AutoGradingConfigurator-test.js b/lms/static/scripts/frontend_apps/components/test/AutoGradingConfigurator-test.js new file mode 100644 index 0000000000..b5d6957181 --- /dev/null +++ b/lms/static/scripts/frontend_apps/components/test/AutoGradingConfigurator-test.js @@ -0,0 +1,172 @@ +import { checkAccessibility } from '@hypothesis/frontend-testing'; +import { mount } from 'enzyme'; +import { act } from 'preact/test-utils'; + +import AutoGradingConfigurator from '../AutoGradingConfigurator'; + +describe('AutoGradingConfigurator', () => { + let fakeAutoGradingConfig; + let fakeUpdateAutoGradingConfig; + + beforeEach(() => { + fakeAutoGradingConfig = { + gradingType: 'all_or_nothing', + activityCalculation: 'cumulative', + requiredAnnotations: 1, + }; + fakeUpdateAutoGradingConfig = sinon.stub(); + }); + + function createComponent() { + return mount( + , + ); + } + + function dispatchOnChange(wrapper, selector, event) { + act(() => wrapper.find(selector).props().onChange(event)); + } + + [true, false].forEach(active => { + it('renders components if auto grading is active', () => { + fakeAutoGradingConfig.active = active; + const wrapper = createComponent(); + + assert.equal(wrapper.exists('RadioGroup'), active); + }); + + it('updates config when checkbox is changed', () => { + const wrapper = createComponent(); + + dispatchOnChange(wrapper, 'Checkbox', { + target: { checked: active }, + }); + + assert.calledWith(fakeUpdateAutoGradingConfig, sinon.match({ active })); + }); + }); + + context('when auto grading is active', () => { + beforeEach(() => { + fakeAutoGradingConfig.active = true; + }); + + ['cumulative', 'separately'].forEach(activityCalculation => { + it('updates config when changing activity calculation', () => { + const wrapper = createComponent(); + + dispatchOnChange( + wrapper, + '[data-testid="activity-calculation-radio-group"]', + activityCalculation, + ); + + assert.calledWith( + fakeUpdateAutoGradingConfig, + sinon.match({ activityCalculation }), + ); + }); + + it('renders inputs based on activity calculation value', () => { + fakeAutoGradingConfig.activityCalculation = activityCalculation; + + const wrapper = createComponent(); + const inputs = wrapper.find('AnnotationsGoalInputGroup'); + const firstInput = inputs.first(); + + assert.equal( + inputs.length, + activityCalculation === 'separately' ? 2 : 1, + ); + assert.equal( + firstInput.text(), + `Annotations${activityCalculation === 'cumulative' ? ' and replies' : ''}Minimum`, + ); + }); + }); + + ['all_or_nothing', 'scaled'].forEach(gradingType => { + it('updates config when changing grading type', () => { + const wrapper = createComponent(); + + dispatchOnChange( + wrapper, + '[data-testid="grading-type-radio-group"]', + gradingType, + ); + + assert.calledWith( + fakeUpdateAutoGradingConfig, + sinon.match({ gradingType }), + ); + }); + + it('renders different input label depending on grading type value', () => { + fakeAutoGradingConfig.gradingType = gradingType; + + const wrapper = createComponent(); + const input = wrapper.find('AnnotationsGoalInputGroup').first(); + + assert.isTrue( + input + .text() + .endsWith(gradingType === 'all_or_nothing' ? 'Minimum' : 'Goal'), + ); + }); + }); + + [ + { + inputIndex: 0, + value: '15', + expectedConfig: { requiredAnnotations: 15 }, + }, + { + inputIndex: 1, + value: '3', + expectedConfig: { requiredReplies: 3 }, + }, + ].forEach(({ inputIndex, value, expectedConfig }) => { + it('updates config when inputs change', () => { + fakeAutoGradingConfig.activityCalculation = 'separately'; + + const wrapper = createComponent(); + const inputs = wrapper.find('AnnotationsGoalInputGroup'); + + act(() => + inputs.at(inputIndex).find('Input').props().onChange({ + target: { value }, + }), + ); + + assert.calledWith( + fakeUpdateAutoGradingConfig, + sinon.match(expectedConfig), + ); + }); + }); + }); + + it( + 'should pass a11y checks', + checkAccessibility([ + { + name: 'inactive', + content: () => createComponent(), + }, + // FIXME We have an unrelated accessibility issue on RadioGroup, where + // selected Radio's subtitles do not have enough contrast with the + // background color. + // { + // name: 'active', + // content: () => { + // fakeAutoGradingConfig.active = true; + // return createComponent(); + // }, + // }, + ]), + ); +}); diff --git a/lms/static/scripts/frontend_apps/components/test/FilePickerApp-test.js b/lms/static/scripts/frontend_apps/components/test/FilePickerApp-test.js index 1fa2af9616..c9f7fe6ab1 100644 --- a/lms/static/scripts/frontend_apps/components/test/FilePickerApp-test.js +++ b/lms/static/scripts/frontend_apps/components/test/FilePickerApp-test.js @@ -194,8 +194,9 @@ describe('FilePickerApp', () => { data: { ...deepLinkingAPIData, content: { type: 'url', url: 'https://example.com' }, - group_set: null, title: null, + group_set: null, + auto_grading_config: null, }, }); @@ -553,6 +554,18 @@ describe('FilePickerApp', () => { clickButton(wrapper, 'cancel-edit-content'); assert.isFalse(wrapper.exists('ContentSelector')); }); + + [true, false].forEach(autoGradingEnabled => { + it('displays auto grading configurator when it is enabled', () => { + fakeConfig.filePicker.autoGradingEnabled = autoGradingEnabled; + const wrapper = renderFilePicker(); + + assert.equal( + wrapper.exists('AutoGradingConfigurator'), + autoGradingEnabled, + ); + }); + }); }); it( diff --git a/package.json b/package.json index a819999831..2225b79b1d 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "@babel/preset-react": "^7.24.7", "@babel/preset-typescript": "^7.24.7", "@hypothesis/frontend-build": "^3.0.0", - "@hypothesis/frontend-shared": "^8.4.0", + "@hypothesis/frontend-shared": "^8.4.1", "@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-commonjs": "^26.0.1", "@rollup/plugin-node-resolve": "^15.2.3", @@ -47,7 +47,7 @@ "preact": "10.23.2", "rollup": "^4.19.1", "sass": "^1.76.0", - "tailwindcss": "^3.3.3", + "tailwindcss": "^3.4.10", "tiny-emitter": "^2.1.0", "wouter-preact": "^3.3.0" }, diff --git a/yarn.lock b/yarn.lock index 204a9691fb..639f66b083 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2169,15 +2169,15 @@ __metadata: languageName: node linkType: hard -"@hypothesis/frontend-shared@npm:^8.4.0": - version: 8.4.0 - resolution: "@hypothesis/frontend-shared@npm:8.4.0" +"@hypothesis/frontend-shared@npm:^8.4.1": + version: 8.4.1 + resolution: "@hypothesis/frontend-shared@npm:8.4.1" dependencies: highlight.js: ^11.6.0 wouter-preact: ^3.0.0 peerDependencies: preact: ^10.4.0 - checksum: 34d7ae1cab013825be55504c5a9af582b0f20962a358192e66dad19097dfaa6ee052129399dcec9e317c41ba5a2e4b595f33b40b639404ab5f9e997b27cc93e1 + checksum: da2df9d847939c867176b8a90428c02a8dd4c9b1d357bc26746ff38a7792fb4d110e27ab48ff3d8a8f408ff69e58a4e7f92682604ecf4c0ec840fee9b6a01e4f languageName: node linkType: hard @@ -5371,7 +5371,7 @@ __metadata: languageName: node linkType: hard -"fast-glob@npm:^3.2.12, fast-glob@npm:^3.2.9": +"fast-glob@npm:^3.2.9": version: 3.2.12 resolution: "fast-glob@npm:3.2.12" dependencies: @@ -5384,6 +5384,19 @@ __metadata: languageName: node linkType: hard +"fast-glob@npm:^3.3.0": + version: 3.3.2 + resolution: "fast-glob@npm:3.3.2" + dependencies: + "@nodelib/fs.stat": ^2.0.2 + "@nodelib/fs.walk": ^1.2.3 + glob-parent: ^5.1.2 + merge2: ^1.3.0 + micromatch: ^4.0.4 + checksum: 900e4979f4dbc3313840078419245621259f349950411ca2fa445a2f9a1a6d98c3b5e7e0660c5ccd563aa61abe133a21765c6c0dec8e57da1ba71d8000b05ec1 + languageName: node + linkType: hard + "fast-json-stable-stringify@npm:^2.0.0": version: 2.1.0 resolution: "fast-json-stable-stringify@npm:2.1.0" @@ -7130,12 +7143,12 @@ __metadata: languageName: node linkType: hard -"jiti@npm:^1.18.2": - version: 1.18.2 - resolution: "jiti@npm:1.18.2" +"jiti@npm:^1.21.0": + version: 1.21.6 + resolution: "jiti@npm:1.21.6" bin: jiti: bin/jiti.js - checksum: 46c41cd82d01c6efdee3fc0ae9b3e86ed37457192d6366f19157d863d64961b07982ab04e9d5879576a1af99cc4d132b0b73b336094f86a5ce9fb1029ec2d29f + checksum: 9ea4a70a7bb950794824683ed1c632e2ede26949fbd348e2ba5ec8dc5efa54dc42022d85ae229cadaa60d4b95012e80ea07d625797199b688cc22ab0e8891d32 languageName: node linkType: hard @@ -7437,7 +7450,7 @@ __metadata: "@babel/preset-react": ^7.24.7 "@babel/preset-typescript": ^7.24.7 "@hypothesis/frontend-build": ^3.0.0 - "@hypothesis/frontend-shared": ^8.4.0 + "@hypothesis/frontend-shared": ^8.4.1 "@hypothesis/frontend-testing": ^1.2.2 "@rollup/plugin-babel": ^6.0.4 "@rollup/plugin-commonjs": ^26.0.1 @@ -7485,7 +7498,7 @@ __metadata: rollup: ^4.19.1 sass: ^1.76.0 sinon: ^17.0.1 - tailwindcss: ^3.3.3 + tailwindcss: ^3.4.10 tiny-emitter: ^2.1.0 typescript: ^5.2.2 wouter-preact: ^3.3.0 @@ -10199,19 +10212,19 @@ __metadata: languageName: node linkType: hard -"tailwindcss@npm:^3.3.3": - version: 3.3.3 - resolution: "tailwindcss@npm:3.3.3" +"tailwindcss@npm:^3.4.10": + version: 3.4.10 + resolution: "tailwindcss@npm:3.4.10" dependencies: "@alloc/quick-lru": ^5.2.0 arg: ^5.0.2 chokidar: ^3.5.3 didyoumean: ^1.2.2 dlv: ^1.1.3 - fast-glob: ^3.2.12 + fast-glob: ^3.3.0 glob-parent: ^6.0.2 is-glob: ^4.0.3 - jiti: ^1.18.2 + jiti: ^1.21.0 lilconfig: ^2.1.0 micromatch: ^4.0.5 normalize-path: ^3.0.0 @@ -10228,7 +10241,7 @@ __metadata: bin: tailwind: lib/cli.js tailwindcss: lib/cli.js - checksum: 0195c7a3ebb0de5e391d2a883d777c78a4749f0c532d204ee8aea9129f2ed8e701d8c0c276aa5f7338d07176a3c2a7682c1d0ab9c8a6c2abe6d9325c2954eb50 + checksum: aa8db3514ec5110b2dee0bf5b35b84ebedf0c23a0dcafc870a5176bc2bad7d581956e0692ed6d888d602c114d2c54d7aa8fdb7028456880bd28b326078c8ba6e languageName: node linkType: hard