Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ const courseOther = 'course-v1:OrgX+CS999+2024';
const groupsUrl = apiUrls.competencyCriteriaGroups(tagId);
const profileUrl = apiUrls.defaultCompetencyRuleProfile();
const createUrl = apiUrls.createCompetencyCriterion(tagId);
const updateGroupOperatorUrl = apiUrls.updateCompetencyCriteriaGroup(tagId, 10);
const updateRuleScoreUrl = apiUrls.updateCompetencyCriteriaRule(10);

/** `#773`'s real response envelope: a paginated list, not a single object -
* see `CompetencyRuleProfileListResponse` in `./data/types`.
Expand Down Expand Up @@ -217,6 +219,16 @@ const TestConsumer = () => {
>
associate-duplicate
</button>
<button type="button" onClick={() => ctx.updateGroupOperator(10, 'OR')}>update-group-operator</button>
<button
type="button"
onClick={() => {
// Caught only to silence an unhandled-rejection warning; irrelevant to this consumer.
ctx.updateRuleScore(10, [900], { op: 'gte', value: 0.8, scale: 'percent' }).catch(() => {});
}}
>
update-rule-score
</button>
</div>
);
};
Expand Down Expand Up @@ -650,4 +662,111 @@ describe('CompetencyAssociationsProvider', () => {
});
});
});

describe('updateGroupOperator', () => {
it('sends the new operator, with no toast, on success', async () => {
axiosMock.onGet(groupsUrl).reply(200, singleGroupResponse);
axiosMock.onGet(profileUrl).reply(200, profileResponse);
axiosMock.onGet(getCourseOutlineIndexApiUrl(courseA)).reply(200, outlineFixture);
axiosMock.onPatch(updateGroupOperatorUrl).reply(200, {
id: 10,
parent_id: 1,
tag_id: tagId,
course_key: null,
name: 'leaf',
ordering: 0,
logic_operator: 'OR',
archived: false,
});
renderProvider();
await waitFor(() => expect(screen.getByTestId('groups-status')).toHaveTextContent('groups-success'));

fireEvent.click(screen.getByText('update-group-operator'));

await waitFor(() => expect(axiosMock.history.patch).toHaveLength(1));
expect(axiosMock.history.patch[0].url).toEqual(updateGroupOperatorUrl);
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({ logic_operator: 'OR' });
expect(mockShowToast).not.toHaveBeenCalled();
});

it('shows a failure toast on rejection', async () => {
axiosMock.onGet(groupsUrl).reply(200, singleGroupResponse);
axiosMock.onGet(profileUrl).reply(200, profileResponse);
axiosMock.onGet(getCourseOutlineIndexApiUrl(courseA)).reply(200, outlineFixture);
axiosMock.onPatch(updateGroupOperatorUrl).reply(500);
renderProvider();
await waitFor(() => expect(screen.getByTestId('groups-status')).toHaveTextContent('groups-success'));

fireEvent.click(screen.getByText('update-group-operator'));

await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith(
'There was a problem updating this group\'s logic. Please try again.',
);
});
});
});

describe('updateRuleScore', () => {
it(
'sends the batch payload and derives the new focus key from the response\'s own values, not the '
+ 'request\'s, on success',
async () => {
axiosMock.onGet(groupsUrl).reply(200, singleGroupResponse);
axiosMock.onGet(profileUrl).reply(200, profileResponse);
axiosMock.onGet(getCourseOutlineIndexApiUrl(courseA)).reply(200, outlineFixture);
// The request (below) sends an explicit override of 0.8; the mocked response simulates
// the backend's "reset to default" behavior instead, reassigning the criterion to the
// shared default profile (0.7) with both override fields cleared. An implementation that
// (incorrectly) derived the new focus key from the request would compute 0.8's key here.
axiosMock.onPatch(updateRuleScoreUrl).reply(200, [
{
id: 900,
object_id: 'existing-sub',
group_id: 10,
rule_profile_id: 1,
rule_type_override: null,
rule_payload_override: null,
},
]);
renderProvider();
await waitFor(() => expect(screen.getByTestId('groups-status')).toHaveTextContent('groups-success'));

fireEvent.click(screen.getByText('update-rule-score'));

await waitFor(() => expect(axiosMock.history.patch).toHaveLength(1));
expect(axiosMock.history.patch[0].url).toEqual(updateRuleScoreUrl);
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
criterion_ids: [900],
rule_type_override: 'grade',
rule_payload_override: { op: 'gte', value: 0.8, scale: 'percent' },
});

await waitFor(() => {
expect(screen.getByTestId('focus')).toHaveTextContent('"groupId":10');
});
expect(screen.getByTestId('focus')).toHaveTextContent('"ruleKey":"grade:gte:0.7:percent"');
},
);

it('shows a failure toast on rejection, without changing focus', async () => {
axiosMock.onGet(groupsUrl).reply(200, singleGroupResponse);
axiosMock.onGet(profileUrl).reply(200, profileResponse);
axiosMock.onGet(getCourseOutlineIndexApiUrl(courseA)).reply(200, outlineFixture);
axiosMock.onPatch(updateRuleScoreUrl).reply(500);
renderProvider();
await waitFor(() => {
expect(screen.getByTestId('focus')).toHaveTextContent('"ruleKey":"grade:gte:0.7:percent"');
});

fireEvent.click(screen.getByText('update-rule-score'));

await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith(
'There was a problem updating this rule\'s score. Please try again.',
);
});
expect(screen.getByTestId('focus')).toHaveTextContent('"ruleKey":"grade:gte:0.7:percent"');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,12 @@ import {
useCourseTaggingPermissions,
useCreateCompetencyCriterion,
useDefaultCompetencyRuleProfile,
useUpdateCompetencyCriteriaGroupOperator,
useUpdateCompetencyCriteriaRule,
} from './data/apiHooks';
import type {
CompetencyCriteriaGroupsResponse,
CompetencyGroupLogicOperator,
CompetencyRuleProfile,
CourseCompetencyCriteriaGroup,
CreateCompetencyCriterionPayload,
Expand All @@ -39,6 +42,7 @@ import {
associatedObjectIds,
bottomTierGroupsForCourse,
buildCompetencyCriteriaGroupsIndex,
effectiveRuleOf,
lastBottomTierGroupForCourse,
lastRealRuleKeyIn,
ruleBoxesForGroup,
Expand Down Expand Up @@ -75,8 +79,18 @@ export interface CompetencyAssociationsContextValue {
* active competency.
*/
associateSubsection: (objectId: string, courseId: string) => void;
/** Whether the signed-in author can create/manage associations for the
* given course (`useCourseTaggingPermissions`'s `courses.manage_tags`).
/** Updates a bottom-tier group's any/all combining logic (`#760`); a
* rejected save is a no-op besides a failure toast.
*/
updateGroupOperator: (groupId: number, logicOperator: CompetencyGroupLogicOperator) => void;
/** Updates a rule box's score threshold across every criterion listed in
* `criterionIds` (`#759`), deriving the new focus key from the response.
*/
updateRuleScore: (groupId: number, criterionIds: number[], rulePayload: GradeRulePayload) => Promise<void>;
/**
* Whether the signed-in author can create/manage associations for the
* given course, via `useCourseTaggingPermissions`'s `courses.manage_tags`
* check.
*/
canEditCourse: (courseId: string) => boolean;
groupsQuery: UseQueryResult<CompetencyCriteriaGroupsResponse>;
Expand Down Expand Up @@ -163,6 +177,8 @@ export const CompetencyAssociationsProvider = ({
const groupsQuery = useCompetencyCriteriaGroups(tagId);
const profileQuery = useDefaultCompetencyRuleProfile();
const createCriterion = useCreateCompetencyCriterion();
const updateGroupOperatorMutation = useUpdateCompetencyCriteriaGroupOperator();
const updateRuleScoreMutation = useUpdateCompetencyCriteriaRule();

const index = useMemo(
() => (groupsQuery.data ? buildCompetencyCriteriaGroupsIndex(groupsQuery.data) : undefined),
Expand Down Expand Up @@ -363,12 +379,63 @@ export const CompetencyAssociationsProvider = ({
});
}, [associatedIds, index, systemDefaultProfile, focus, tagId, createCriterion, showToast, intl]);

const updateGroupOperator = useCallback((groupId: number, logicOperator: CompetencyGroupLogicOperator) => {
updateGroupOperatorMutation.mutate({ tagId, groupId, logicOperator }, {
onError: () => {
// No local rollback needed: `LogicOperatorSelect` always renders
// from `group.logicOperator`, which a rejected mutation never touches.
showToast(intl.formatMessage(messages.updateGroupOperatorFailedToastMessage));
},
});
}, [tagId, updateGroupOperatorMutation, showToast, intl]);

const updateRuleScore = useCallback((
groupId: number,
criterionIds: number[],
rulePayload: GradeRulePayload,
): Promise<void> => {
if (!index || !systemDefaultProfile) {
// Defensive TypeScript-narrowing guard, not an expected runtime path: a rule box is only
// ever rendered - let alone made editable - once both queries have resolved.
throw new Error('updateRuleScore called before the groups index and system default profile were loaded.');
}
// Rule type is pinned to the box's current effective rule, resolved
// from any one criterion already in it (they all share it, by definition).
const groupCriteria = index.criteriaByGroupId.get(groupId) ?? [];
const anchorCriterion = groupCriteria.find((criterion) => criterionIds.includes(criterion.id));
if (!anchorCriterion) {
// Defensive guard, not an expected runtime path: `criterionIds` always comes from this
// same group's own rendered rule box.
throw new Error(`updateRuleScore: no criterion in group ${groupId} matched the given criterionIds.`);
}
const { ruleType } = effectiveRuleOf(anchorCriterion, systemDefaultProfile);

return updateRuleScoreMutation.mutateAsync({ tagId, groupId, criterionIds, ruleType, rulePayload })
.then((updatedCriteria) => {
// From the response, not the request: a "reset to default" edit can
// echo back a shared profile reference instead of the sent values,
// so the new focus key has to come from what was actually persisted.
const [updatedCriterion] = updatedCriteria;
if (updatedCriterion) {
setFocus({ groupId, ruleKey: ruleKeyOf(updatedCriterion, systemDefaultProfile) });
}
})
.catch((error) => {
showToast(intl.formatMessage(messages.updateRuleScoreFailedToastMessage));
// Re-thrown so ScoreThresholdField's own commit handler also sees
// the rejection and reverts its local input.
throw error;
});
}, [index, systemDefaultProfile, tagId, updateRuleScoreMutation, showToast, intl]);

const contextValue = useMemo<CompetencyAssociationsContextValue>(() => ({
focus,
focusGroup,
focusRuleBox,
notifyCourseExpanded,
associateSubsection,
updateGroupOperator,
updateRuleScore,
canEditCourse,
groupsQuery,
profileQuery,
Expand All @@ -384,6 +451,8 @@ export const CompetencyAssociationsProvider = ({
focusRuleBox,
notifyCourseExpanded,
associateSubsection,
updateGroupOperator,
updateRuleScore,
canEditCourse,
groupsQuery,
profileQuery,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Col, Row, Stack } from '@openedx/paragon';

import { ResizableBox } from '@src/generic/resizable/Resizable';
import { useIsDesktop } from '@src/utils';
import { CompetencyAssociationsProvider } from '../CompetencyAssociationsContext';
import CompetencyTree, { type CompetencyTreeNode } from '../CompetencyTree';
import { CourseSearchBrowse } from '../course-search';

Expand All @@ -24,10 +25,10 @@ const CompetencyAssociationsPanel = ({ taxonomyId, taxonomyName }: CompetencyAss
const [selectedCompetency, setSelectedCompetency] = useState<CompetencyTreeNode | null>(null);
const isDesktop = useIsDesktop();

// Rendered once and reused, bare, in both branches below: `CompetencyTree`
// keeps its own expand/collapse state locally, so moving it to a different
// position in the element tree between states would remount it and reset
// that state.
// Rendered once and reused in both branches below at the same element-tree
// position: `CompetencyTree` keeps its own expand/collapse state locally,
// so moving it between states would make React remount it and silently
// reset what the user had expanded.
const tree = (
<CompetencyTree
taxonomyId={taxonomyId}
Expand All @@ -39,19 +40,22 @@ const CompetencyAssociationsPanel = ({ taxonomyId, taxonomyName }: CompetencyAss

if (isDesktop) {
return (
// `gap={3.5}` is Paragon's spacing-scale value for the original 20px
// gap (each step is `N * 16px`), collapsed to `0` before a competency
// is selected since there's no second column to put a gap against yet.
// `align-items-stretch` overrides `.pgn__hstack`'s default `center`, so
// the two columns start flush at the top instead of the shorter one
// being vertically centered against the taller.
// `gap={3.5}` is Paragon's exact spacing-scale value for the 20px gap
// (0 before selection, with no second column to gap against).
// `align-items-stretch` overrides `.pgn__hstack`'s default `center` so
// columns start flush at the top instead of the shorter one centering.
<Stack direction="horizontal" gap={selectedCompetency ? 3.5 : 0} className="align-items-stretch">
<ResizableBox handleSide="right" fullWidth={!selectedCompetency} stretchContent>
{tree}
</ResizableBox>
{selectedCompetency && (
<div className="flex-grow-1">
<CourseSearchBrowse activeCompetency={selectedCompetency} />
<CompetencyAssociationsProvider
tagId={Number(selectedCompetency.id)}
competencyExternalId={selectedCompetency.externalId ?? null}
>
<CourseSearchBrowse activeCompetency={selectedCompetency} />
</CompetencyAssociationsProvider>
</div>
)}
</Stack>
Expand All @@ -60,12 +64,17 @@ const CompetencyAssociationsPanel = ({ taxonomyId, taxonomyName }: CompetencyAss

return (
<Row>
<Col xs={12}>
<Col xs={12} lg={selectedCompetency ? 4 : 12}>
{tree}
</Col>
{selectedCompetency && (
<Col xs={12}>
<CourseSearchBrowse activeCompetency={selectedCompetency} />
<Col xs={12} lg={8}>
<CompetencyAssociationsProvider
tagId={Number(selectedCompetency.id)}
competencyExternalId={selectedCompetency.externalId ?? null}
>
<CourseSearchBrowse activeCompetency={selectedCompetency} />
</CompetencyAssociationsProvider>
</Col>
)}
</Row>
Expand Down
Loading