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 (
+
+
+ {children}
+
+ {gradingType === 'all_or_nothing' ? 'Minimum' : 'Goal'}
+
+
+ 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