Skip to content

Commit

Permalink
Add UI components to configure assignments auto-grading
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed Sep 4, 2024
1 parent 09a4847 commit bf7bb91
Show file tree
Hide file tree
Showing 7 changed files with 475 additions and 26 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
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 enabled for the assignment or not */
enabled?: 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
* - separate: students will have different annotation and reply goals.
*/
activityCalculation: 'cumulative' | 'separate';

/**
* Required number of annotations if activityCalculation is 'separate' or
* combined number of annotations and replies otherwise.
*/
requiredAnnotations: number;

/**
* Required number of replies if activityCalculation is 'separate'
*/
requiredReplies?: number;
};

type AnnotationsGoalInputProps = {
children?: ComponentChildren;
gradingType: GradingType;
value: number;
onChange: (newValue: number) => void;

/** Minimum required value for the input. Defaults to 1 */
min?: number;
};

/**
* Controls containing a number input to set the amount of required annotations
* or replies
*/
function AnnotationsGoalInput({
children,
gradingType,
value,
onChange,
min = 1,
}: AnnotationsGoalInputProps) {
const inputId = useId();

return (
<div className="flex gap-2 items-center">
<label
className="grow flex justify-between items-center"
htmlFor={inputId}
>
{children}
<span className="uppercase font-semibold">
{gradingType === 'all_or_nothing' ? 'Minimum' : 'Goal'}
</span>
</label>
<Input
id={inputId}
classes="max-w-14"
type="number"
required
min={min}
step={1}
value={value}
onChange={e => onChange(Number((e.target as HTMLInputElement).value))}
/>
</div>
);
}

export type AutoGradingConfiguratorProps = {
config: AutoGradingConfig;
onChange: (newConfig: AutoGradingConfig) => void;
};

/**
* Allows instructors to enable auto grading for an assignment, and provide the
* configuration to determine how to calculate each student's grade.
*/
export default function AutoGradingConfigurator({
config,
onChange,
}: AutoGradingConfiguratorProps) {
const {
enabled = false,
gradingType,
activityCalculation,
requiredAnnotations,
requiredReplies = 0,
} = config;
const updateConfig = useCallback(
(newConfig: Partial<AutoGradingConfig>) =>
onChange({ ...config, ...newConfig }),
[config, onChange],
);

const gradingTypeId = useId();
const activityCalculationId = useId();

return (
<div className="flex flex-col gap-y-3">
<Checkbox
checked={enabled}
checkedIcon={CheckboxCheckedFilledIcon}
onChange={e =>
updateConfig({
enabled: (e.target as HTMLInputElement).checked,
})
}
>
Enable automatic participation grading
</Checkbox>
{enabled && (
<>
<div>
<h3 id={gradingTypeId} className="font-semibold mb-1">
Grading type
</h3>
<RadioGroup
data-testid="grading-type-radio-group"
aria-labelledby={gradingTypeId}
selected={gradingType}
onChange={gradingType => updateConfig({ gradingType })}
>
<RadioGroup.Radio
value="all_or_nothing"
subtitle={<small>Must meet minimum requirements.</small>}
>
All or nothing
</RadioGroup.Radio>
<RadioGroup.Radio
value="scaled"
subtitle={<small>Proportional to percent completed.</small>}
>
Scaled
</RadioGroup.Radio>
</RadioGroup>
</div>
<div>
<h3 id={activityCalculationId} className="font-semibold mb-1">
Activity calculation
</h3>
<RadioGroup
data-testid="activity-calculation-radio-group"
aria-labelledby={activityCalculationId}
selected={activityCalculation}
onChange={activityCalculation =>
updateConfig({ activityCalculation })
}
>
<RadioGroup.Radio
value="cumulative"
subtitle={
<small>Annotations and replies tallied together.</small>
}
>
Calculate cumulative
</RadioGroup.Radio>
<RadioGroup.Radio
value="separate"
subtitle={
<small>Annotations and replies tallied separately.</small>
}
>
Calculate separately
</RadioGroup.Radio>
</RadioGroup>
</div>
<AnnotationsGoalInput
gradingType={gradingType}
value={requiredAnnotations}
onChange={requiredAnnotations =>
updateConfig({ requiredAnnotations })
}
>
{activityCalculation === 'cumulative'
? 'Annotations and replies'
: 'Annotations'}
</AnnotationsGoalInput>
{activityCalculation === 'separate' && (
<AnnotationsGoalInput
gradingType={gradingType}
value={requiredReplies}
onChange={requiredReplies => updateConfig({ requiredReplies })}
min={0}
>
Replies
</AnnotationsGoalInput>
)}
</>
)}
</div>
);
}
46 changes: 41 additions & 5 deletions lms/static/scripts/frontend_apps/components/FilePickerApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -170,22 +172,37 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) {
settings: { groupsEnabled: enableGroupConfig },
},
assignment,
filePicker: { deepLinkingAPI, formAction, formFields, promptForTitle },
filePicker: {
autoGradingEnabled,
deepLinkingAPI,
formAction,
formFields,
promptForTitle,
},
} = useConfig(['api', 'filePicker']);

// Currently selected content for assignment.
const [content, setContent] = useState<Content | null>(
assignment ? contentFromURL(assignment.document.url) : null,
);

const [autoGradingConfig, setAutoGradingConfig] = useState<AutoGradingConfig>(
{
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.
const isEditing = !!assignment;

// 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) {
Expand Down Expand Up @@ -241,6 +258,15 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) {
try {
const data = {
...deepLinkingAPI.data,
auto_grading_config:
autoGradingEnabled && autoGradingConfig.enabled
? {
grading_type: autoGradingConfig.gradingType,
activity_calculation: autoGradingConfig.activityCalculation,
required_annotations: autoGradingConfig.requiredAnnotations,
required_replies: autoGradingConfig.requiredReplies,
}
: null,
content,
group_set: groupConfig.useGroupSet ? groupConfig.groupSet : null,
title,
Expand Down Expand Up @@ -269,6 +295,8 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) {
groupConfig.groupSet,
groupConfig.useGroupSet,
title,
autoGradingEnabled,
autoGradingConfig,
],
);

Expand Down Expand Up @@ -415,12 +443,20 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) {
/>
</>
)}
{autoGradingEnabled && (
<>
<div className="sm:col-span-2 border-b" />
<PanelLabel isCurrentStep>Auto grading</PanelLabel>
<AutoGradingConfigurator
config={autoGradingConfig}
onChange={setAutoGradingConfig}
/>
</>
)}
{enableGroupConfig && (
<>
<div className="sm:col-span-2 border-b" />
<PanelLabel isCurrentStep={true}>
Group assignment
</PanelLabel>
<PanelLabel isCurrentStep>Group assignment</PanelLabel>
<div
className={classnames(
// Set a height on this container to give the group
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { Checkbox, Link, Select } from '@hypothesis/frontend-shared';
import {
Checkbox,
CheckboxCheckedFilledIcon,
Link,
Select,
} from '@hypothesis/frontend-shared';
import classnames from 'classnames';
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';

Expand Down Expand Up @@ -245,6 +250,7 @@ export default function GroupConfigSelector({
<div className="space-y-3">
<Checkbox
checked={useGroupSet}
checkedIcon={CheckboxCheckedFilledIcon}
onInput={(e: Event) =>
onChangeGroupConfig({
useGroupSet: (e.target as HTMLInputElement).checked,
Expand Down
Loading

0 comments on commit bf7bb91

Please sign in to comment.