@@ -455,6 +540,104 @@ const ConfigureWeightsPrompt: FC
= ({
{err}
)}
+ {mode === 'equal' && !noAssessments && (
+ <>
+
+ {
+ if (keepEnabled) {
+ // Turn off: just toggle the on flag
+ setKeepOn((prev) => ({
+ ...prev,
+ [tb.id]: false,
+ }));
+ } else {
+ // Turn on: seed default value if not already set
+ if (keepValue === 0) {
+ setKeepHighest((prev) => ({
+ ...prev,
+ [tb.id]: Math.max(1, includedCount - 1),
+ }));
+ }
+ setKeepOn((prev) => ({
+ ...prev,
+ [tb.id]: true,
+ }));
+ }
+ }}
+ size="small"
+ />
+
+ {t(translations.keepHighestLabel)}
+
+ {keepEnabled && (
+
+ handleKeepChange(tb.id, e.target.value)
+ }
+ onKeyDown={(e) => {
+ if (
+ ['.', ',', 'e', 'E', '-', '+'].includes(
+ e.key,
+ )
+ )
+ e.preventDefault();
+ }}
+ size="small"
+ sx={{ width: 80, mx: 0.5 }}
+ type="number"
+ value={keepValue}
+ />
+ )}
+ {keepEnabled && keepErr === null && (
+
+ {t(translations.keepSubtitle, {
+ keep: kept,
+ included: includedCount,
+ pct: keepPct.toFixed(2),
+ })}
+
+ )}
+
+ {keepEnabled && keepErr && (
+
+ {keepErr}
+
+ )}
+ {isKeepOverflow && (
+
+ {t(translations.keepOverflowWarning, {
+ tab: tb.title,
+ })}
+
+ )}
+ >
+ )}
{unbalanced && (
{t(translations.unbalanced, { tab: tb.title })}
@@ -537,6 +720,14 @@ const ConfigureWeightsPrompt: FC = ({
);
}
+ let caption = t(translations.ofGrade, {
+ pct: pct.toFixed(2),
+ });
+ if (isExcluded) {
+ caption = t(translations.excluded);
+ } else if (keepEnabled) {
+ caption = '—';
+ }
return (
= ({
color="text.disabled"
variant="caption"
>
- {isExcluded
- ? t(translations.excluded)
- : t(translations.ofGrade, {
- pct: pct.toFixed(2),
- })}
+ {caption}
);
diff --git a/client/app/bundles/course/gradebook/components/GradebookWeightedTable.tsx b/client/app/bundles/course/gradebook/components/GradebookWeightedTable.tsx
index 671a6c7fdb..07d73b5f8d 100644
--- a/client/app/bundles/course/gradebook/components/GradebookWeightedTable.tsx
+++ b/client/app/bundles/course/gradebook/components/GradebookWeightedTable.tsx
@@ -21,6 +21,8 @@ import {
Tooltip,
Typography,
} from '@mui/material';
+import type { Theme } from '@mui/material/styles';
+import { lighten } from '@mui/material/styles';
import type {
AssessmentData,
CategoryData,
@@ -40,7 +42,11 @@ import { DEFAULT_TABLE_ROWS_PER_PAGE } from 'lib/constants/sharedConstants';
import useTranslation from 'lib/hooks/useTranslation';
import tableTranslations from 'lib/translations/table';
-import type { AssessmentContribution, WeightedRow } from '../computeWeighted';
+import type {
+ AssessmentContribution,
+ TabBreakdown,
+ WeightedRow,
+} from '../computeWeighted';
import {
computeStudentBreakdown,
computeWeightedRows,
@@ -160,6 +166,10 @@ const translations = defineMessages({
id: 'course.gradebook.GradebookWeightedTable.excluded',
defaultMessage: 'Excluded',
},
+ dropped: {
+ id: 'course.gradebook.GradebookWeightedTable.dropped',
+ defaultMessage: 'Dropped (lowest)',
+ },
total: {
id: 'course.gradebook.GradebookWeightedTable.total',
defaultMessage: 'Total',
@@ -290,35 +300,59 @@ const GradebookWeightedTable = ({
return a.points;
};
- const [expandedIds, setExpandedIds] = useState
>(new Set());
+ // Single-open accordion: auditing one student at a time. Replaces the former
+ // multi-expand Set so only the focused student's breakdown is on screen (and
+ // only one summary row ever pins under the header).
+ const [expandedId, setExpandedId] = useState(null);
const toggleExpanded = (studentId: number): void =>
- setExpandedIds((prev) => {
- const next = new Set(prev);
- if (next.has(studentId)) next.delete(studentId);
- else next.add(studentId);
- return next;
- });
+ setExpandedId((prev) => (prev === studentId ? null : studentId));
- const breakdownsByStudent = useMemo(
+ const expandedBreakdown = useMemo(
() =>
- new Map(
- [...expandedIds].map((studentId) => [
- studentId,
- computeStudentBreakdown({
- studentId,
+ expandedId === null
+ ? null
+ : computeStudentBreakdown({
+ studentId: expandedId,
tabs: resolvedTabs,
assessments,
submissions,
}),
- ]),
- ),
- [expandedIds, resolvedTabs, assessments, submissions],
+ [expandedId, resolvedTabs, assessments, submissions],
);
+ const containerRef = useRef(null);
+ const activeRowRef = useRef(null);
const row1Ref = useRef(null);
const row2Ref = useRef(null);
+ const row3Ref = useRef(null);
const [row2Top, setRow2Top] = useState(0);
const [row3Top, setRow3Top] = useState(0);
+ // Full header height = where the pinned summary row sticks, and the scroll
+ // offset that lands a focused row just beneath the header.
+ const [headerHeight, setHeaderHeight] = useState(0);
+
+ // sx for a cell of the focused (expanded) student's summary row. `variant`
+ // selects the layering: 'checkbox' and 'name' are corner-sticky (top + the
+ // left freeze they already carry) and sit above the body's frozen column;
+ // 'data' cells pin on top only. The checkbox cell also carries the left
+ // accent bar that marks "this is the student you're auditing".
+ const pinnedCellSx =
+ (variant: 'checkbox' | 'name' | 'data') =>
+ (theme: Theme): Record => {
+ const isLead = variant !== 'data';
+ return {
+ position: 'sticky',
+ top: headerHeight,
+ zIndex: isLead ? 6 : 3,
+ // Opaque tint (not alpha) so scrolled body content can't bleed through
+ // the sticky cell.
+ backgroundColor: lighten(theme.palette.primary.light, 0.96),
+ // The checkbox cell carries the left accent bar marking the audited row.
+ ...(variant === 'checkbox' && {
+ boxShadow: `inset 3px 0 0 ${theme.palette.primary.main}`,
+ }),
+ };
+ };
// Row-3 subheader for a tab: "Excluded" when the tab contributes nothing,
// else the weight in the active lens ("/{w}" points / "{w}% of grade").
@@ -346,25 +380,54 @@ const GradebookWeightedTable = ({
useLayoutEffect(() => {
const row1 = row1Ref.current;
const row2 = row2Ref.current;
- if (!row1 || !row2) return undefined;
+ const row3 = row3Ref.current;
+ if (!row1 || !row2 || !row3) return undefined;
// Re-measure on every header-row resize, not just on mount. Expanding or
// collapsing a row, switching display mode and showing/hiding columns all
// reflow the header after mount; with a one-shot measurement rows 2–3 keep
// a stale `top` and stay permanently dislodged from the rows above them.
const measure = (): void => {
- const h1 = row1.offsetHeight;
+ // getBoundingClientRect (subpixel) not offsetHeight (integer-rounded):
+ // rows are fractional (32px min + lineHeight content), and a rounded
+ // `top` lands the stuck rows 2-3 a fraction off — opening thin gaps
+ // between header rows (body bleeds through) and overshooting the single
+ // rowSpan=3 frozen-left cell so the right header reads a touch taller.
+ const h1 = row1.getBoundingClientRect().height;
+ const h2 = row2.getBoundingClientRect().height;
+ const h3 = row3.getBoundingClientRect().height;
setRow2Top(h1);
- setRow3Top(h1 + row2.offsetHeight);
+ setRow3Top(h1 + h2);
+ setHeaderHeight(h1 + h2 + h3);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(row1);
observer.observe(row2);
+ observer.observe(row3);
return () => observer.disconnect();
}, [visibleCategories, resolvedTabs]);
+ // On expand, glide the focused row to just beneath the header so its
+ // breakdown is guaranteed in view (even when the clicked row was near the
+ // bottom). getBoundingClientRect keeps this correct regardless of the row's
+ // offsetParent; scrollTo is optional-chained because jsdom lacks it.
+ useLayoutEffect(() => {
+ if (expandedId === null) return;
+ const container = containerRef.current;
+ const rowEl = activeRowRef.current;
+ if (!container || !rowEl) return;
+ const prefersReducedMotion =
+ window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
+ const delta =
+ rowEl.getBoundingClientRect().top - container.getBoundingClientRect().top;
+ container.scrollTo?.({
+ top: container.scrollTop + delta - headerHeight,
+ behavior: prefersReducedMotion ? 'auto' : 'smooth',
+ });
+ }, [expandedId, headerHeight]);
+
const rows = useMemo(
() =>
computeWeightedRows({
@@ -622,6 +685,7 @@ const GradebookWeightedTable = ({
toolbar — plus the pagination below, so the table fills the
remaining viewport; shorter classes shrink to fit (no whitespace). */}
too — dropping the table's bottom edge and
+ // that row's right edge (the open bottom-right corner). The
+ // separator ABOVE it is owned by the previous row's borderBottom
+ // (that row isn't last-child, so it keeps it), so only the bottom
+ // and right edges need restoring here. Same (0,2,3) > (0,2,1).
+ '& tbody tr:last-of-type td': {
+ borderBottom: gridLine,
+ borderRight: gridLine,
+ },
+ // The two frozen columns (checkbox + Name) are each their own
+ // sticky compositing layer; when scrolled, the next row's
+ // opaque sticky bg paints over the cell's borderBottom and the
+ // horizontal line vanishes in those columns only. Mirror the
+ // header fix (CLAUDE-tables.md): drop the coverable borderBottom
+ // and draw each separator as the lower cell's borderTop, which
+ // its own layer owns and always paints. Row 1's top line stays
+ // owned by the sticky header above it, so only rows 2+ get the
+ // borderTop — else both borders show at rest and read 2px thick.
+ '& tbody td:first-of-type, & tbody td:nth-of-type(2)': {
+ borderBottom: 'none',
+ },
+ '& tbody tr:not(:first-of-type) td:first-of-type, & tbody tr:not(:first-of-type) td:nth-of-type(2)':
+ {
+ borderTop: gridLine,
+ },
};
}}
>
@@ -803,6 +893,7 @@ const GradebookWeightedTable = ({
{/* Row 3: Weight subheaders */}
{resolvedTabs.map((tab) => (
@@ -842,10 +933,10 @@ const GradebookWeightedTable = ({
{body.rows.map((row, idx) => {
const rowProps = body.forEachRow(row, idx);
const studentId = row.original.studentId;
- const isExpanded = expandedIds.has(studentId);
+ const isExpanded = expandedId === studentId;
return (
-
+
{/* Body sticky-left cells sit at zIndex 1 — strictly
below the header's sticky cells (MUI gives every
stickyHeader cell zIndex 2). On a z-index tie the cell
@@ -854,16 +945,19 @@ const GradebookWeightedTable = ({
column the frozen Name cell (z4) doesn't cover — i.e.
the identity columns once they're toggled on. */}
{showEmail && (
- {row.original.email}
+
+ {row.original.email}
+
)}
{showExternalId && (
- {row.original.externalId ?? ''}
+
+ {row.original.externalId ?? ''}
+
)}
{row.original.subtotals.map((subtotal, i) => {
const weight = resolvedTabs[i].gradebookWeight ?? 0;
return (
-
+
{fmtDisplay(
tabDisplayValue(subtotal, weight),
columnPrecisions.tabs[i],
@@ -918,7 +1027,10 @@ const GradebookWeightedTable = ({
);
})}
-
+
{fmtDisplay(
totalDisplayValue(row.original.total),
columnPrecisions.total,
@@ -926,47 +1038,50 @@ const GradebookWeightedTable = ({
{isExpanded &&
- (breakdownsByStudent.get(studentId) ?? []).flatMap(
- (tb, tabIdx) =>
- tb.assessments.map((a) => {
- const isExcluded = a.excluded;
- // Weightage is always "% of grade" — it never
- // follows the points/percent lens.
- const weightText = t(
- translations.percentOfGrade,
- {
- weight:
- Math.round(a.effectiveWeight * 100) / 100,
- },
- );
- const gradeText =
- a.grade === null
- ? `—/${a.maxGrade}`
- : `${a.grade}/${a.maxGrade}`;
- return (
-
+ tb.assessments.map((a) => {
+ const isExcluded = a.excluded;
+ const isDropped = a.dropped;
+ const isInactive = isExcluded || isDropped;
+ // Weightage is always "% of grade" — it never
+ // follows the points/percent lens.
+ const weightText = t(translations.percentOfGrade, {
+ weight: Math.round(a.effectiveWeight * 100) / 100,
+ });
+ const gradeText =
+ a.grade === null
+ ? `—/${a.maxGrade}`
+ : `${a.grade}/${a.maxGrade}`;
+ let statusText = weightText;
+ if (isExcluded) {
+ statusText = t(translations.excluded);
+ } else if (isDropped) {
+ statusText = t(translations.dropped);
+ }
+ return (
+
+ {/* Empty checkbox cell so the breakdown row
+ carries the same checkbox | name divider (the
+ universal cell borderRight) as the rows above. */}
+
- {/* Empty checkbox cell so the breakdown row
- carries the same checkbox | name divider (the
- universal cell borderRight) as the rows above. */}
-
- {/* Title over a muted "raw mark · weightage"
+ />
+ {/* Title over a muted "raw mark · weightage"
subtitle, stacked and confined to the (sticky)
Name column. The breakdown row freezes the same
checkbox | Name region as the student rows above —
@@ -976,74 +1091,74 @@ const GradebookWeightedTable = ({
indent sits the title under the student name (past
the expand chevron), signalling these are that
student's assessments. */}
-
- {/* nowrap keeps the title on one line: its
+
+ {/* nowrap keeps the title on one line: its
max-content width then drives the table's auto
layout, expanding the (frozen) Name column to fit
the longest title. With the metadata line also
nowrap, every breakdown row is exactly 2 lines —
no fixed widths, no JS measurement. */}
-
- {a.title}
-
- {/* Muted metadata on its own line below the
+
+ {a.title}
+
+ {/* Muted metadata on its own line below the
title: raw mark · effective weightage, kept on
one line (nowrap). Weightage is always "% of
grade" — never routed through the points/percent
lens. */}
-
- {`${gradeText} · ${isExcluded ? t(translations.excluded) : weightText}`}
-
-
- {/* One empty cell per visible identity column so
+
+ {`${gradeText} · ${statusText}`}
+
+
+ {/* One empty cell per visible identity column so
the grid lines stay aligned with the rows above.
These scroll with the table (only checkbox + Name
are frozen), matching the student rows. */}
- {showEmail && }
- {showExternalId && }
- {resolvedTabs.map((tab, i) => {
- const tabCellValue = isExcluded
- ? '—'
- : fmtDisplay(
- breakdownDisplayValue(a),
- columnPrecisions.tabs[i],
- );
- return (
-
- {i === tabIdx ? tabCellValue : ''}
-
- );
- })}
-
-
- );
- }),
+ {showEmail && }
+ {showExternalId && }
+ {resolvedTabs.map((tab, i) => {
+ const tabCellValue = isExcluded
+ ? '—'
+ : fmtDisplay(
+ breakdownDisplayValue(a),
+ columnPrecisions.tabs[i],
+ );
+ return (
+
+ {i === tabIdx ? tabCellValue : ''}
+
+ );
+ })}
+
+
+ );
+ }),
)}
);
diff --git a/client/app/bundles/course/gradebook/computeWeighted.ts b/client/app/bundles/course/gradebook/computeWeighted.ts
index d4bb0424f7..f490594d93 100644
--- a/client/app/bundles/course/gradebook/computeWeighted.ts
+++ b/client/app/bundles/course/gradebook/computeWeighted.ts
@@ -28,6 +28,7 @@ export interface AssessmentContribution {
// Custom mode: the assessment's own configured weight.
effectiveWeight: number;
excluded: boolean;
+ dropped: boolean; // equal-mode keep-highest: ranked out for this student
}
export interface TabBreakdown {
@@ -65,19 +66,25 @@ const buildAssessmentsByTab = (
// Equal-weight formula: average of (grade/maxGrade) ratios over INCLUDED assessments.
// Excluded assessments are dropped from both numerator and count; ungraded included
-// contribute 0. Returns null when no assessment is included.
+// contribute 0. When keepN > 0, only the top keepN ratios are averaged.
+// Returns null when no assessment is included.
const equalSubtotal = (
studentId: number,
+ tab: TabData,
tabAssessments: AssessmentData[],
gradeLookup: GradeLookup,
): number | null => {
const included = tabAssessments.filter((a) => !a.gradebookExcluded);
if (included.length === 0) return null;
+ const keepN = tab.keepHighest ?? 0;
const ratios = included.map((a) => {
const grade = gradeLookup.get(gradeKey(studentId, a.id));
- return grade != null ? grade / a.maxGrade : 0;
+ return grade != null && a.maxGrade > 0 ? grade / a.maxGrade : 0;
});
- return ratios.reduce((acc, r) => acc + r, 0) / ratios.length;
+ ratios.sort((x, y) => x - y); // ascending
+ const keep = keepN > 0 ? Math.min(keepN, included.length) : included.length;
+ const kept = ratios.slice(included.length - keep); // the `keep` highest
+ return kept.reduce((acc, r) => acc + r, 0) / kept.length;
};
// Custom-weight formula: Σ(grade_i/maxGrade_i × assessmentWeight_i) / tabWeight over
@@ -97,7 +104,8 @@ const customSubtotal = (
if (a.gradebookExcluded) return;
const grade = gradeLookup.get(gradeKey(studentId, a.id));
const assessmentWeight = a.gradebookWeight ?? 0;
- if (grade != null) numerator += (grade / a.maxGrade) * assessmentWeight;
+ if (grade != null && a.maxGrade > 0)
+ numerator += (grade / a.maxGrade) * assessmentWeight;
hasContributing = true;
});
return hasContributing ? numerator / tabWeight : null;
@@ -114,7 +122,7 @@ const subtotalFromLookup = (
if (tab.weightMode === 'custom') {
return customSubtotal(studentId, tab, tabAssessments, gradeLookup);
}
- return equalSubtotal(studentId, tabAssessments, gradeLookup);
+ return equalSubtotal(studentId, tab, tabAssessments, gradeLookup);
};
// Weighted, additive total from already-computed subtotals.
@@ -189,23 +197,47 @@ export const computeStudentBreakdown = ({
return tabs.map((tab) => {
const list = assessmentsByTab.get(tab.id) ?? [];
const weight = tab.gradebookWeight ?? 0;
- const includedCount = list.filter((a) => !a.gradebookExcluded).length;
+ const included = list.filter((a) => !a.gradebookExcluded);
+ const includedCount = included.length;
+
+ let droppedIds = new Set();
+ let keptCount = includedCount;
+ if (tab.weightMode !== 'custom' && includedCount > 0) {
+ const keepN = tab.keepHighest ?? 0;
+ keptCount = keepN > 0 ? Math.min(keepN, includedCount) : includedCount;
+ if (keptCount < includedCount) {
+ const ranked = included
+ .map((a) => {
+ const grade = gradeLookup.get(gradeKey(studentId, a.id));
+ return {
+ id: a.id,
+ ratio: grade != null && a.maxGrade > 0 ? grade / a.maxGrade : 0,
+ };
+ })
+ .sort((x, y) => x.ratio - y.ratio || x.id - y.id); // ascending: lowest first, tie-break by id
+ // Drop the lowest (includedCount − keptCount).
+ droppedIds = new Set(
+ ranked.slice(0, includedCount - keptCount).map((r) => r.id),
+ );
+ }
+ }
const contributions = list.map((a) => {
const excluded = !!a.gradebookExcluded;
+ const dropped = droppedIds.has(a.id);
const grade = gradeLookup.get(gradeKey(studentId, a.id)) ?? null;
- const ratio = grade != null ? grade / a.maxGrade : 0;
+ const ratio = grade != null && a.maxGrade > 0 ? grade / a.maxGrade : 0;
let points: number;
let effectiveWeight: number;
- if (excluded) {
+ if (excluded || dropped) {
points = 0;
effectiveWeight = 0;
} else if (tab.weightMode === 'custom') {
points = ratio * (a.gradebookWeight ?? 0);
effectiveWeight = a.gradebookWeight ?? 0;
} else {
- points = includedCount > 0 ? (ratio / includedCount) * weight : 0;
- effectiveWeight = includedCount > 0 ? weight / includedCount : 0;
+ points = keptCount > 0 ? (ratio / keptCount) * weight : 0;
+ effectiveWeight = keptCount > 0 ? weight / keptCount : 0;
}
return {
assessmentId: a.id,
@@ -215,6 +247,7 @@ export const computeStudentBreakdown = ({
points,
effectiveWeight,
excluded,
+ dropped,
};
});
return { tabId: tab.id, assessments: contributions };
diff --git a/client/app/bundles/course/gradebook/store.ts b/client/app/bundles/course/gradebook/store.ts
index 4777e16b3b..c8267ed793 100644
--- a/client/app/bundles/course/gradebook/store.ts
+++ b/client/app/bundles/course/gradebook/store.ts
@@ -70,6 +70,7 @@ const reducer = produce(
tabId,
weight,
weightMode,
+ keepHighest,
assessmentWeights,
excludedAssessmentIds,
}) => {
@@ -77,6 +78,7 @@ const reducer = produce(
if (tab) {
tab.gradebookWeight = weight;
tab.weightMode = weightMode;
+ tab.keepHighest = keepHighest ?? 0;
}
const excludedSet = new Set(excludedAssessmentIds ?? []);
const tabAssessments = draft.assessments.filter(
diff --git a/client/app/types/course/gradebook.ts b/client/app/types/course/gradebook.ts
index c9009afbfe..2d72bcb1c0 100644
--- a/client/app/types/course/gradebook.ts
+++ b/client/app/types/course/gradebook.ts
@@ -9,6 +9,7 @@ export interface TabData {
categoryId: number;
gradebookWeight?: number;
weightMode?: 'equal' | 'custom';
+ keepHighest?: number;
}
export interface AssessmentData {
@@ -52,6 +53,7 @@ export interface UpdateWeightsPayload {
tabId: number;
weight: number;
weightMode?: 'equal' | 'custom';
+ keepHighest?: number;
excludedAssessmentIds?: number[];
assessmentWeights?: { assessmentId: number; weight: number }[];
}[];
diff --git a/client/locales/en.json b/client/locales/en.json
index 1d9bf1cce6..bca35e87f7 100644
--- a/client/locales/en.json
+++ b/client/locales/en.json
@@ -9383,6 +9383,9 @@
"course.gradebook.GradebookWeightedTable.email": {
"defaultMessage": "Email"
},
+ "course.gradebook.GradebookWeightedTable.dropped": {
+ "defaultMessage": "Dropped (lowest)"
+ },
"course.gradebook.GradebookWeightedTable.excluded": {
"defaultMessage": "Excluded"
},
diff --git a/db/schema.rb b/db/schema.rb
index d1a22b40e8..75ec3ce3e2 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2026_06_11_000000) do
+ActiveRecord::Schema[7.2].define(version: 2026_06_11_130000) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
enable_extension "uuid-ossp"
@@ -564,6 +564,7 @@
t.integer "updater_id", null: false
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
+ t.boolean "gradebook_excluded", default: false, null: false
t.index ["category_id"], name: "fk__course_assessment_tabs_category_id"
t.index ["creator_id"], name: "fk__course_assessment_tabs_creator_id"
t.index ["updater_id"], name: "fk__course_assessment_tabs_updater_id"
diff --git a/spec/controllers/course/gradebook_controller_spec.rb b/spec/controllers/course/gradebook_controller_spec.rb
index 59397f4120..aa94670ed1 100644
--- a/spec/controllers/course/gradebook_controller_spec.rb
+++ b/spec/controllers/course/gradebook_controller_spec.rb
@@ -297,6 +297,26 @@ def weight_for(tab)
end
end
+ describe '#update_weights keepHighest' do
+ render_views
+
+ let(:category2) { create(:course_assessment_category, course: course) }
+ let(:tab) { create(:course_assessment_tab, category: category2) }
+
+ before { controller_sign_in(controller, manager.user) }
+
+ it 'persists keepHighest and echoes it back' do
+ post :update_weights, as: :json, params: {
+ course_id: course.id,
+ weights: [{ tabId: tab.id, weight: '50', weightMode: 'equal', keepHighest: 2 }]
+ }
+ expect(response).to have_http_status(:ok)
+ body = JSON.parse(response.body)
+ expect(body['weights'].first['keepHighest']).to eq(2)
+ expect(Course::Gradebook::Contribution.find_by(tab_id: tab.id).keep_highest).to eq(2)
+ end
+ end
+
describe '#update_weights with modes' do
render_views
@@ -421,6 +441,16 @@ def weight_for(tab)
expect(body['assessments'].first).to have_key('gradebookExcluded')
expect(body['assessments'].first['gradebookExcluded']).to eq(false)
end
+
+ it 'includes keepHighest in the weighted tabs response' do
+ contribution.update!(keep_highest: 3)
+ controller_sign_in(controller, manager.user)
+ get :index, params: { course_id: course.id }, format: :json
+ body = JSON.parse(response.body)
+ tab_json = body['tabs'].find { |t| t['id'] == tab.id }
+ expect(tab_json).to have_key('keepHighest')
+ expect(tab_json['keepHighest']).to eq(3)
+ end
end
end
end
diff --git a/spec/models/course/gradebook/contribution_spec.rb b/spec/models/course/gradebook/contribution_spec.rb
index d4d433e028..06e215a507 100644
--- a/spec/models/course/gradebook/contribution_spec.rb
+++ b/spec/models/course/gradebook/contribution_spec.rb
@@ -208,6 +208,29 @@ def excluded?(assessment)
expect(excluded?(a1)).to eq(false)
end
end
+
+ context 'with keep_highest' do
+ it 'persists keep_highest in equal mode' do
+ described_class.bulk_update(
+ course: course,
+ updates: [{ tab_id: tab1.id, weight: 50, weight_mode: 'equal', keep_highest: 3 }]
+ )
+ expect(described_class.find_by(tab_id: tab1.id).keep_highest).to eq(3)
+ end
+
+ it 'accepts 0 as a valid keep_highest value' do
+ described_class.bulk_update(
+ course: course,
+ updates: [{ tab_id: tab1.id, weight: 50, weight_mode: 'equal', keep_highest: 5 }]
+ )
+ expect(described_class.find_by(tab_id: tab1.id).keep_highest).to eq(5)
+ described_class.bulk_update(
+ course: course,
+ updates: [{ tab_id: tab1.id, weight: 50, weight_mode: 'equal', keep_highest: 0 }]
+ )
+ expect(described_class.find_by(tab_id: tab1.id).keep_highest).to eq(0)
+ end
+ end
end
end
end