Skip to content

Commit

Permalink
Add popover for grades in auto-grade assignments
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed Sep 12, 2024
1 parent cf198ae commit b3f9569
Show file tree
Hide file tree
Showing 5 changed files with 318 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { replaceURLParams } from '../../utils/url';
import DashboardActivityFilters from './DashboardActivityFilters';
import DashboardBreadcrumbs from './DashboardBreadcrumbs';
import FormattedDate from './FormattedDate';
import GradeStatusChip from './GradeStatusChip';
import GradeIndicator from './GradeIndicator';
import type { OrderableActivityTableColumn } from './OrderableActivityTable';
import OrderableActivityTable from './OrderableActivityTable';

Expand Down Expand Up @@ -208,7 +208,12 @@ export default function AssignmentActivity() {
'-my-0.5',
)}
>
<GradeStatusChip grade={stats.auto_grading_grade ?? 0} />
<GradeIndicator
grade={stats.auto_grading_grade ?? 0}
annotations={stats.annotations}
replies={stats.replies}
config={assignment.data?.auto_grading_config}
/>
</div>
);
default:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { CheckIcon, CancelIcon } from '@hypothesis/frontend-shared';
import classnames from 'classnames';
import type { ComponentChildren } from 'preact';
import { useCallback, useState } from 'preact/hooks';

import type { AutoGradingConfig } from '../../api-types';
import GradeStatusChip from './GradeStatusChip';

type AnnotationCountProps = {
children: ComponentChildren;
actualAmount: number;
requiredAmount: number;
};

function AnnotationCount({
children,
actualAmount,
requiredAmount,
}: AnnotationCountProps) {
const requirementWasMet = actualAmount >= requiredAmount;

return (
<div
className={classnames(
'flex justify-between items-center gap-x-3',
'border-b last:border-0 px-3 py-2.5',
)}
>
<div className="flex items-center gap-x-2">
{children}
<div className="px-2 py-1 rounded bg-grey-3 text-grey-7 font-bold">
{actualAmount}/{requiredAmount}
</div>
</div>
<div
className={classnames('rounded-full p-1', {
'bg-grade-success-light text-grade-success': requirementWasMet,
'bg-grade-error-light text-grade-error': !requirementWasMet,
})}
>
{requirementWasMet ? <CheckIcon /> : <CancelIcon />}
</div>
</div>
);
}

export type GradeIndicatorProps = {
grade: number;
annotations: number;
replies: number;
config?: AutoGradingConfig;
};

/**
* Includes a GradeStatusChip, together with a popover indicating why that is
* the grade
*/
export default function GradeIndicator({
grade,
annotations,
replies,
config,
}: GradeIndicatorProps) {
const [popoverVisible, setPopoverVisible] = useState(false);
const showPopover = useCallback(() => setPopoverVisible(true), []);
const hidePopover = useCallback(() => setPopoverVisible(false), []);

const isCalculationSeparate = config?.activity_calculation === 'separate';
const combined = annotations + replies;
const requiredCombined = config
? config.required_annotations + (config.required_replies ?? 0)
: 0;

return (
<div
className="inline-block relative"
onMouseOver={showPopover}
onMouseOut={hidePopover}
// Make element focusable so that the popover can be shown even when
// interacting with the keyboard
onFocus={showPopover}
onBlur={hidePopover}
role="button"
tabIndex={0}
data-testid="container"
>
<GradeStatusChip grade={grade} />
<div aria-live="polite" aria-relevant="additions">
{popoverVisible && (
<div
role="tooltip"
className={classnames(
'rounded shadow-lg bg-white border',
'w-64 absolute -left-6 top-full mt-0.5',
)}
data-testid="popover"
>
<div className="border-b px-3 py-2 font-bold text-grey-7">
Grade calculation
</div>
{isCalculationSeparate && (
<AnnotationCount
actualAmount={annotations}
requiredAmount={config.required_annotations}
>
Annotations
</AnnotationCount>
)}
{isCalculationSeparate ? (
<AnnotationCount
actualAmount={replies}
requiredAmount={config.required_replies ?? 0}
>
Replies
</AnnotationCount>
) : (
<AnnotationCount
actualAmount={combined}
requiredAmount={requiredCombined}
>
Annotations and replies
</AnnotationCount>
)}
</div>
)}
</div>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,19 @@ export default function GradeStatusChip({ grade }: GradeStatusChipProps) {

return (
<div
className={classnames('rounded inline-block font-bold px-2 py-0.5', {
'bg-grade-success text-white': grade === 100,
'bg-grade-success-light text-grade-success': grade >= 80 && grade < 100,
'bg-grade-warning-light text-grade-warning': grade >= 50 && grade < 80,
'bg-grade-error-light text-grade-error': grade >= 1 && grade < 50,
'bg-grade-error text-white': grade === 0,
'bg-grey-3 text-grey-7': gradeIsInvalid,
})}
className={classnames(
'rounded inline-block font-bold px-2 py-0.5 cursor-default',
{
'bg-grade-success text-white': grade === 100,
'bg-grade-success-light text-grade-success':
grade >= 80 && grade < 100,
'bg-grade-warning-light text-grade-warning':
grade >= 50 && grade < 80,
'bg-grade-error-light text-grade-error': grade >= 1 && grade < 50,
'bg-grade-error text-white': grade === 0,
'bg-grey-3 text-grey-7': gradeIsInvalid,
},
)}
>
{grade}
{!gradeIsInvalid && '%'}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -384,9 +384,9 @@ describe('AssignmentActivity', () => {
.find('OrderableActivityTable')
.props()
.renderItem({ auto_grading_grade }, 'auto_grading_grade');
const gradeChip = mount(item).find('GradeStatusChip');
const gradeIndicator = mount(item).find('GradeIndicator');

assert.equal(gradeChip.prop('grade'), auto_grading_grade ?? 0);
assert.equal(gradeIndicator.prop('grade'), auto_grading_grade ?? 0);
});
},
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { checkAccessibility } from '@hypothesis/frontend-testing';
import { mount } from 'enzyme';
import { act } from 'preact/test-utils';

import GradeIndicator from '../GradeIndicator';

describe('GradeIndicator', () => {
const defaultConfig = {
grading_type: 'all_or_nothing',
activity_calculation: 'separate',
required_annotations: 1,
required_replies: 1,
};

function createComponent(config = defaultConfig) {
return mount(
<GradeIndicator
grade={100}
annotations={5}
replies={2}
config={config}
/>,
);
}

/**
* @param {'onMouseOver' | 'onMouseOut' | 'onFocus' | 'onBlur'} callback
*/
function invokeCallback(wrapper, callback) {
act(() => wrapper.find('[data-testid="container"]').prop(callback)());
wrapper.update();
}

function openPopover(wrapper) {
invokeCallback(wrapper, 'onMouseOver');
}

function isPopoverVisible(wrapper) {
return wrapper.exists('[data-testid="popover"]');
}

['onMouseOver', 'onFocus'].forEach(callback => {
it(`shows popover ${callback}`, () => {
const wrapper = createComponent();

assert.isFalse(isPopoverVisible(wrapper));
invokeCallback(wrapper, callback);
assert.isTrue(isPopoverVisible(wrapper));
});
});

['onMouseOut', 'onBlur'].forEach(callback => {
it(`hides popover ${callback}`, () => {
const wrapper = createComponent();

// Start with the popover open
openPopover(wrapper);
assert.isTrue(isPopoverVisible(wrapper));

invokeCallback(wrapper, callback);
assert.isFalse(isPopoverVisible(wrapper));
});
});

[
{
config: {
activity_calculation: 'cumulative',
},
expectedAnnotationCounts: [
{
text: 'Annotations and replies7/2',
icon: 'CheckIcon',
},
],
},
{
config: {
activity_calculation: 'cumulative',
required_annotations: 30,
required_replies: 5,
},
expectedAnnotationCounts: [
{
text: 'Annotations and replies7/35',
icon: 'CancelIcon',
},
],
},
{
config: {
activity_calculation: 'separate',
required_annotations: 8,
required_replies: 15,
},
expectedAnnotationCounts: [
{
text: 'Annotations5/8',
icon: 'CancelIcon',
},
{
text: 'Replies2/15',
icon: 'CancelIcon',
},
],
},
{
config: {
activity_calculation: 'separate',
},
expectedAnnotationCounts: [
{
text: 'Annotations5/1',
icon: 'CheckIcon',
},
{
text: 'Replies2/1',
icon: 'CheckIcon',
},
],
},
].forEach(({ config, expectedAnnotationCounts }) => {
it('shows expected annotation counts for config', () => {
const wrapper = createComponent({
...defaultConfig,
...config,
});
openPopover(wrapper);

const annotationCountElements = wrapper.find('AnnotationCount');
assert.equal(
annotationCountElements.length,
expectedAnnotationCounts.length,
);

expectedAnnotationCounts.forEach((expectedAnnotationCount, index) => {
const annotationCountElement = annotationCountElements.at(index);

assert.equal(
annotationCountElement.text(),
expectedAnnotationCount.text,
);
assert.isTrue(
annotationCountElement.exists(expectedAnnotationCount.icon),
);
});
});
});

it(
'should pass a11y checks',
checkAccessibility([
{
name: 'popover closed',
content: () => createComponent(),
},
{
name: 'popover open',
content: () => {
const wrapper = createComponent();
openPopover(wrapper);
return wrapper;
},
},
]),
);
});

0 comments on commit b3f9569

Please sign in to comment.