diff --git a/src/authz/constants.ts b/src/authz/constants.ts index 88d4b4da58..aa148ea08b 100644 --- a/src/authz/constants.ts +++ b/src/authz/constants.ts @@ -17,4 +17,7 @@ export const CONTENT_LIBRARY_PERMISSIONS = { export const COURSE_PERMISSIONS = { MANAGE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings', + + VIEW_GRADING_SETTINGS: 'courses.view_grading_settings', + EDIT_GRADING_SETTINGS: 'courses.edit_grading_settings', }; diff --git a/src/authz/hooks.test.ts b/src/authz/hooks.test.ts new file mode 100644 index 0000000000..e7e108193a --- /dev/null +++ b/src/authz/hooks.test.ts @@ -0,0 +1,79 @@ +import { renderHook } from '@testing-library/react'; +import { useUserPermissions } from '@src/authz/data/apiHooks'; +import { mockWaffleFlags } from '@src/data/apiHooks.mock'; +import { useCourseUserPermissions } from './hooks'; +import { COURSE_PERMISSIONS } from './constants'; + +jest.mock('@src/authz/data/apiHooks', () => ({ + useUserPermissions: jest.fn(), +})); + +const courseId = 'course-v1:org+course+run'; +const permissions = { + canView: { action: COURSE_PERMISSIONS.VIEW_GRADING_SETTINGS, scope: courseId }, + canEdit: { action: COURSE_PERMISSIONS.EDIT_GRADING_SETTINGS, scope: courseId }, +}; + +describe('useCourseUserPermissions', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useUserPermissions).mockReturnValue({ + isLoading: false, + data: undefined, + } as unknown as ReturnType); + }); + + it('defaults all permissions to true when authz is disabled', () => { + mockWaffleFlags({ enableAuthzCourseAuthoring: false }); + + const { result } = renderHook(() => useCourseUserPermissions(courseId, permissions)); + + expect(result.current.isLoading).toBe(false); + expect(result.current.isAuthzEnabled).toBe(false); + expect(result.current.canView).toBe(true); + expect(result.current.canEdit).toBe(true); + }); + + it('returns actual permission values when authz is enabled and permissions are loaded', () => { + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + jest.mocked(useUserPermissions).mockReturnValue({ + isLoading: false, + data: { canView: true, canEdit: false }, + } as unknown as ReturnType); + + const { result } = renderHook(() => useCourseUserPermissions(courseId, permissions)); + + expect(result.current.isLoading).toBe(false); + expect(result.current.isAuthzEnabled).toBe(true); + expect(result.current.canView).toBe(true); + expect(result.current.canEdit).toBe(false); + }); + + it('returns isLoading=true and no permission keys while authz permissions are loading', () => { + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + jest.mocked(useUserPermissions).mockReturnValue({ + isLoading: true, + data: undefined, + } as unknown as ReturnType); + + const { result } = renderHook(() => useCourseUserPermissions(courseId, permissions)); + + expect(result.current.isLoading).toBe(true); + expect(result.current.isAuthzEnabled).toBe(true); + expect(result.current.canView).toBeUndefined(); + expect(result.current.canEdit).toBeUndefined(); + }); + + it('falls back to false for permissions absent from server response when authz is enabled', () => { + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + jest.mocked(useUserPermissions).mockReturnValue({ + isLoading: false, + data: {}, + } as unknown as ReturnType); + + const { result } = renderHook(() => useCourseUserPermissions(courseId, permissions)); + + expect(result.current.canView).toBe(false); + expect(result.current.canEdit).toBe(false); + }); +}); diff --git a/src/authz/hooks.ts b/src/authz/hooks.ts new file mode 100644 index 0000000000..a88da83102 --- /dev/null +++ b/src/authz/hooks.ts @@ -0,0 +1,71 @@ +import { useWaffleFlags } from '@src/data/apiHooks'; +import { useUserPermissions } from '@src/authz/data/apiHooks'; +import { PermissionValidationAnswer, PermissionValidationQuery } from '@src/authz/types'; + +type UseCourseUserPermissionsReturn = { + isLoading: boolean; + isAuthzEnabled: boolean; +} & PermissionValidationAnswer; + +/** + * Custom hook to retrieve and evaluate user permissions for the current course using the openedx-authz service. + * + * The hook: + * 1. Validate if authz is enabled via waffle flag + * 2. Fetch user permissions when authz is enabled + * 3. Fallback all permissions to 'true' when authz is disabled + * 4. Provide fallback values for undefined permissions + * + * @param courseId - The course ID to check permissions for + * @param permissions - Object mapping permission names to their action/scope definitions + * @returns Object containing loading state, permissions results, and authz status + * + * @example + * ```tsx + * const { isLoading, canViewGradingSettings, canEditGradingSettings, isAuthzEnabled } = useCourseUserPermissions( + * courseId, + * { + * canViewGradingSettings: { + * action: COURSE_PERMISSIONS.VIEW_GRADING_SETTINGS, + * scope: courseId, + * }, + * canEditGradingSettings: { + * action: COURSE_PERMISSIONS.EDIT_GRADING_SETTINGS, + * scope: courseId, + * }, + * } + * ); + * ``` + */ +export const useCourseUserPermissions = ( + courseId: string, + permissions: Query, +): UseCourseUserPermissionsReturn => { + const waffleFlags = useWaffleFlags(courseId); + const isAuthzEnabled: boolean = waffleFlags?.enableAuthzCourseAuthoring ?? false; + + const { + isLoading: isLoadingUserPermissions, + data: userPermissions, + } = useUserPermissions(permissions, isAuthzEnabled); + + const resolvePermission = (key: string): boolean => { + if (!isAuthzEnabled) { + return true; + } + return userPermissions?.[key] ?? false; + }; + + const permissionResults: Record = isLoadingUserPermissions + ? {} + : Object.keys(permissions).reduce>((acc, key) => { + acc[key] = resolvePermission(key); + return acc; + }, {}); + + return { + isLoading: isAuthzEnabled ? isLoadingUserPermissions : false, + isAuthzEnabled, + ...permissionResults as PermissionValidationAnswer, + }; +}; diff --git a/src/authz/permissionHelpers.ts b/src/authz/permissionHelpers.ts new file mode 100644 index 0000000000..76585ea0bf --- /dev/null +++ b/src/authz/permissionHelpers.ts @@ -0,0 +1,12 @@ +import { COURSE_PERMISSIONS } from './constants'; + +export const getGradingPermissions = (courseId: string) => ({ + canViewGradingSettings: { + action: COURSE_PERMISSIONS.VIEW_GRADING_SETTINGS, + scope: courseId, + }, + canEditGradingSettings: { + action: COURSE_PERMISSIONS.EDIT_GRADING_SETTINGS, + scope: courseId, + }, +}); diff --git a/src/authz/types.ts b/src/authz/types.ts index 1d641e2af2..6c7fdb3437 100644 --- a/src/authz/types.ts +++ b/src/authz/types.ts @@ -11,6 +11,6 @@ export interface PermissionValidationQuery { [permissionKey: string]: PermissionValidationRequestItem; } -export interface PermissionValidationAnswer { - [permissionKey: string]: boolean; -} +export type PermissionValidationAnswer = { + [K in keyof Query]: boolean; +}; diff --git a/src/grading-settings/GradingSettings.jsx b/src/grading-settings/GradingSettings.jsx index 5fc02a8588..20cf200d51 100644 --- a/src/grading-settings/GradingSettings.jsx +++ b/src/grading-settings/GradingSettings.jsx @@ -12,7 +12,10 @@ import { Helmet } from 'react-helmet'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { STATEFUL_BUTTON_STATES } from '@src/constants'; import { useCourseSettings } from '@src/data/apiHooks'; +import { useCourseUserPermissions } from '@src/authz/hooks'; +import { getGradingPermissions } from '@src/authz/permissionHelpers'; import ConnectionErrorAlert from '@src/generic/ConnectionErrorAlert'; +import PermissionDeniedAlert from '@src/generic/PermissionDeniedAlert'; import SectionSubHeader from '@src/generic/section-sub-header'; import SubHeader from '@src/generic/sub-header/SubHeader'; import AlertMessage from '@src/generic/alert-message'; @@ -34,6 +37,13 @@ import messages from './messages'; const GradingSettings = () => { const intl = useIntl(); const { courseId, courseDetails } = useCourseAuthoringContext(); + + const { + isLoading: isLoadingUserPermissions, + canViewGradingSettings, + canEditGradingSettings, + } = useCourseUserPermissions(courseId, getGradingPermissions(courseId)); + const { data: gradingSettings, isLoading: isGradingSettingsLoading, @@ -55,7 +65,7 @@ const GradingSettings = () => { const courseGradingDetails = gradingSettings?.courseDetails; const isLoadingDenied = isGradingSettingsError || isCourseSettingsError; const [showSuccessAlert, setShowSuccessAlert] = useState(false); - const isLoading = isCourseSettingsLoading || isGradingSettingsLoading; + const isLoading = isCourseSettingsLoading || isGradingSettingsLoading || isLoadingUserPermissions; const [isQueryPending, setIsQueryPending] = useState(false); const [showOverrideInternetConnectionAlert, setOverrideInternetConnectionAlert] = useState(false); const [eligibleGrade, setEligibleGrade] = useState(null); @@ -93,6 +103,10 @@ const GradingSettings = () => { } }, [savePending]); + if (!isLoadingUserPermissions && !canViewGradingSettings) { + return ; + } + if (isLoadingDenied) { return ( @@ -105,6 +119,8 @@ const GradingSettings = () => { return null; } + const isEditable = !isLoadingUserPermissions && canEditGradingSettings; + const handleQueryProcessing = () => { setShowSuccessAlert(false); updateGradingSettings(gradingData); @@ -177,6 +193,7 @@ const GradingSettings = () => { setOverrideInternetConnectionAlert={setOverrideInternetConnectionAlert} setEligibleGrade={setEligibleGrade} defaultGradeDesignations={gradingSettings?.defaultGradeDesignations} + isEditable={isEditable} /> {courseSettingsData.creditEligibilityEnabled && courseSettingsData.isCreditCourse && ( @@ -191,6 +208,7 @@ const GradingSettings = () => { minimumGradeCredit={minimumGradeCredit} setGradingData={setGradingData} setShowSuccessAlert={setShowSuccessAlert} + isEditable={isEditable} /> )} @@ -204,6 +222,7 @@ const GradingSettings = () => { gracePeriod={gracePeriod} setGradingData={setGradingData} setShowSuccessAlert={setShowSuccessAlert} + isEditable={isEditable} />
@@ -222,11 +241,13 @@ const GradingSettings = () => { setGradingData={setGradingData} courseAssignmentLists={courseAssignmentLists} setShowSuccessAlert={setShowSuccessAlert} + isEditable={isEditable} /> @@ -270,6 +291,7 @@ const GradingSettings = () => { key="statefulBtn" onClick={handleSendGradingSettingsData} state={isQueryPending ? STATEFUL_BUTTON_STATES.pending : STATEFUL_BUTTON_STATES.default} + disabled={!isEditable} {...updateValuesButtonState} />, ].filter(Boolean)} diff --git a/src/grading-settings/GradingSettings.test.jsx b/src/grading-settings/GradingSettings.test.jsx index c9ebc64ad0..8b9325f9ef 100644 --- a/src/grading-settings/GradingSettings.test.jsx +++ b/src/grading-settings/GradingSettings.test.jsx @@ -7,6 +7,8 @@ import { } from '@src/testUtils'; import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; import { getCourseSettingsApiUrl } from '@src/data/api'; +import { mockWaffleFlags } from '@src/data/apiHooks.mock'; +import { useCourseUserPermissions } from '@src/authz/hooks'; import gradingSettings from './__mocks__/gradingSettings'; import { getGradingSettingsApiUrl } from './data/api'; @@ -14,6 +16,14 @@ import * as apiHooks from './data/apiHooks'; import GradingSettings from './GradingSettings'; import messages from './messages'; +jest.mock('@src/authz/hooks', () => ({ + useCourseUserPermissions: jest.fn().mockReturnValue({ + isLoading: false, + canViewGradingSettings: true, + canEditGradingSettings: true, + }), +})); + const courseId = '123'; let axiosMock; @@ -129,3 +139,70 @@ describe('', () => { expect(screen.getByTestId('connectionErrorAlert')).toBeInTheDocument(); }); }); + +describe(' permissions', () => { + beforeEach(() => { + jest.restoreAllMocks(); + const mocks = initializeMocks(); + Object.defineProperty(window, 'scrollTo', { value: jest.fn(), writable: true }); + const { axiosMock: mock } = mocks; + mock.onGet(getGradingSettingsApiUrl(courseId)).reply(200, gradingSettings); + mock.onPost(getGradingSettingsApiUrl(courseId)).reply(200, {}); + mock.onGet(getCourseSettingsApiUrl(courseId)).reply(200, {}); + jest.mocked(useCourseUserPermissions).mockReturnValue({ + isLoading: false, + canViewGradingSettings: true, + canEditGradingSettings: true, + }); + }); + + it('should render normally when authz flag is disabled (no regression)', async () => { + mockWaffleFlags({ enableAuthzCourseAuthoring: false }); + render(); + expect(await screen.findAllByText(messages.headingTitle.defaultMessage)).not.toHaveLength(0); + }); + + it('should render normally when user has view and edit permissions', async () => { + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + render(); + expect(await screen.findAllByText(messages.headingTitle.defaultMessage)).not.toHaveLength(0); + }); + + it('should show permission denied alert when user lacks view permission', async () => { + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + jest.mocked(useCourseUserPermissions).mockReturnValue({ + isLoading: false, + canViewGradingSettings: false, + canEditGradingSettings: false, + }); + render(); + expect(await screen.findByTestId('permissionDeniedAlert')).toBeInTheDocument(); + }); + + it('should disable inputs when user has view but not edit permission', async () => { + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + jest.mocked(useCourseUserPermissions).mockReturnValue({ + isLoading: false, + canViewGradingSettings: true, + canEditGradingSettings: false, + }); + render(); + const segmentInputs = await screen.findAllByTestId('grading-scale-segment-input'); + segmentInputs.forEach((input) => expect(input).toBeDisabled()); + }); + + it('should disable save button when user lacks edit permission', async () => { + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + jest.mocked(useCourseUserPermissions).mockReturnValue({ + isLoading: false, + canViewGradingSettings: true, + canEditGradingSettings: false, + }); + render(); + const segmentInputs = await screen.findAllByTestId('grading-scale-segment-input'); + // Trigger a change to show the save alert + fireEvent.change(segmentInputs[1], { target: { value: 'Test' } }); + const saveBtn = screen.getByTestId('grading-settings-save-alert').querySelector('button[type="button"]:last-child'); + expect(saveBtn).toBeDisabled(); + }); +}); diff --git a/src/grading-settings/assignment-section/AssignmentSection.test.jsx b/src/grading-settings/assignment-section/AssignmentSection.test.jsx index 1761470a67..1cc22778b9 100644 --- a/src/grading-settings/assignment-section/AssignmentSection.test.jsx +++ b/src/grading-settings/assignment-section/AssignmentSection.test.jsx @@ -3,6 +3,8 @@ import { render, waitFor, fireEvent } from '@testing-library/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import AssignmentSection from '.'; +import AssignmentItem from './assignments/AssignmentItem'; +import AssignmentTypeName from './assignments/AssignmentTypeName'; import messages from './messages'; const testObj = {}; @@ -108,6 +110,45 @@ describe('', () => { expect(getByText(messages.totalNumberErrorMessage.defaultMessage)).toBeInTheDocument(); }); }); + it('should disable all inputs and delete button when isEditable is false', async () => { + const { getAllByRole, getByText } = render(); + await waitFor(() => { + const inputs = getAllByRole('textbox').concat(getAllByRole('spinbutton')); + inputs.forEach((input) => expect(input).toBeDisabled()); + const deleteBtn = getByText(messages.assignmentDeleteButton.defaultMessage).closest('button'); + expect(deleteBtn).toBeDisabled(); + }); + }); + + it('renders AssignmentItem with default disabled=false when prop is omitted', () => { + const { getByTestId } = render( + +
    + +
+
, + ); + expect(getByTestId('assignment-shortLabel-input')).not.toBeDisabled(); + }); + + it('renders AssignmentTypeName with default disabled=false when prop is omitted', () => { + const { getByTestId } = render( + +
    + +
+
, + ); + expect(getByTestId('assignment-type-name-input')).not.toBeDisabled(); + }); + it('checking correct error msg if total weight have negative number', async () => { const { getByText, getByTestId } = render(); await waitFor(() => { diff --git a/src/grading-settings/assignment-section/assignments/AssignmentItem.jsx b/src/grading-settings/assignment-section/assignments/AssignmentItem.jsx index 209877f62d..22906e159c 100644 --- a/src/grading-settings/assignment-section/assignments/AssignmentItem.jsx +++ b/src/grading-settings/assignment-section/assignments/AssignmentItem.jsx @@ -20,6 +20,7 @@ const AssignmentItem = ({ secondErrorMsg, gradeField, trailingElement, + disabled = false, }) => (
  • {descriptions} @@ -82,6 +84,7 @@ AssignmentItem.propTypes = { value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), gradeField: PropTypes.shape(defaultAssignmentsPropTypes), trailingElement: PropTypes.string, + disabled: PropTypes.bool, }; export default AssignmentItem; diff --git a/src/grading-settings/assignment-section/assignments/AssignmentTypeName.jsx b/src/grading-settings/assignment-section/assignments/AssignmentTypeName.jsx index 056fc489bc..9de0a1a020 100644 --- a/src/grading-settings/assignment-section/assignments/AssignmentTypeName.jsx +++ b/src/grading-settings/assignment-section/assignments/AssignmentTypeName.jsx @@ -11,6 +11,7 @@ const AssignmentTypeName = ({ value, errorEffort, onChange, + disabled = false, }) => { const intl = useIntl(); const initialAssignmentName = useRef(value); @@ -32,6 +33,7 @@ const AssignmentTypeName = ({ onChange={onChange} value={value} isInvalid={Boolean(errorEffort)} + disabled={disabled} /> {intl.formatMessage(messages.assignmentTypeNameDescription)} @@ -67,6 +69,7 @@ AssignmentTypeName.propTypes = { onChange: PropTypes.func.isRequired, errorEffort: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]).isRequired, + disabled: PropTypes.bool, }; export default AssignmentTypeName; diff --git a/src/grading-settings/assignment-section/index.jsx b/src/grading-settings/assignment-section/index.jsx index 487e8fc37a..84ebb15e20 100644 --- a/src/grading-settings/assignment-section/index.jsx +++ b/src/grading-settings/assignment-section/index.jsx @@ -22,6 +22,7 @@ const AssignmentSection = ({ setGradingData, courseAssignmentLists, setShowSuccessAlert, + isEditable = true, }) => { const intl = useIntl(); const [errorList, setErrorList] = useState({}); @@ -87,6 +88,7 @@ const AssignmentSection = ({ value={gradeField.type} errorEffort={errorList[`${type}-${gradeField.id}`]} onChange={(e) => handleAssignmentChange(e, gradeField.id)} + disabled={!isEditable} /> handleAssignmentChange(e, gradeField.id)} + disabled={!isEditable} /> handleAssignmentChange(e, gradeField.id)} errorEffort={errorList[`${weight}-${gradeField.id}`]} trailingElement="%" + disabled={!isEditable} /> handleAssignmentChange(e, gradeField.id)} errorEffort={errorList[`${minCount}-${gradeField.id}`]} + disabled={!isEditable} /> {showDefinedCaseAlert && ( @@ -187,6 +193,7 @@ const AssignmentSection = ({ variant="outline-primary" size="sm" onClick={() => handleRemoveAssignment(gradeField.id)} + disabled={!isEditable} > {intl.formatMessage(messages.assignmentDeleteButton)} @@ -211,6 +218,7 @@ AssignmentSection.propTypes = { graders: PropTypes.arrayOf( PropTypes.shape(defaultAssignmentsPropTypes), ), + isEditable: PropTypes.bool, }; export default AssignmentSection; diff --git a/src/grading-settings/credit-section/index.jsx b/src/grading-settings/credit-section/index.jsx index 3b706849d2..68de0c3ac0 100644 --- a/src/grading-settings/credit-section/index.jsx +++ b/src/grading-settings/credit-section/index.jsx @@ -12,6 +12,7 @@ const CreditSection = ({ minimumGradeCredit, setGradingData, setShowSuccessAlert, + isEditable = true, }) => { const intl = useIntl(); const [errorEffort, setErrorEffort] = useState(false); @@ -51,6 +52,7 @@ const CreditSection = ({ value={Math.round(parseFloat(minimumGradeCredit) * 100) || ''} name="minimum_grade_credit" onChange={handleCreditChange} + disabled={!isEditable} /> {intl.formatMessage(messages.creditEligibilityDescription)} @@ -70,6 +72,7 @@ CreditSection.propTypes = { setGradingData: PropTypes.func.isRequired, setShowSuccessAlert: PropTypes.func.isRequired, minimumGradeCredit: PropTypes.number.isRequired, + isEditable: PropTypes.bool, }; export default CreditSection; diff --git a/src/grading-settings/deadline-section/index.jsx b/src/grading-settings/deadline-section/index.jsx index 60d95842ac..450fe33396 100644 --- a/src/grading-settings/deadline-section/index.jsx +++ b/src/grading-settings/deadline-section/index.jsx @@ -13,6 +13,7 @@ const DeadlineSection = ({ gracePeriod, setGradingData, setShowSuccessAlert, + isEditable = true, }) => { const intl = useIntl(); const timeStampValue = gracePeriod @@ -57,6 +58,7 @@ const DeadlineSection = ({ value={newDeadlineValue} onChange={handleDeadlineChange} placeholder={TIME_FORMAT.toUpperCase()} + disabled={!isEditable} /> {intl.formatMessage(messages.gracePeriodOnDeadlineDescription)} @@ -82,6 +84,7 @@ DeadlineSection.propTypes = { hours: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), minutes: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), }), + isEditable: PropTypes.bool, }; export default DeadlineSection; diff --git a/src/grading-settings/grading-scale/GradingScale.jsx b/src/grading-settings/grading-scale/GradingScale.jsx index 0ae0f0aafa..3d45e04ffa 100644 --- a/src/grading-settings/grading-scale/GradingScale.jsx +++ b/src/grading-settings/grading-scale/GradingScale.jsx @@ -23,6 +23,7 @@ const GradingScale = ({ setOverrideInternetConnectionAlert, setEligibleGrade, defaultGradeDesignations, + isEditable = true, }) => { const intl = useIntl(); const [gradingSegments, setGradingSegments] = useState(sortedGrades); @@ -207,7 +208,7 @@ const GradingScale = ({ = (defaultGradeDesignations.length + 1)} + disabled={!isEditable || gradingSegments.length >= (defaultGradeDesignations.length + 1)} data-testid="grading-scale-btn-add-segment" className="mr-3" src={IconAdd} @@ -229,6 +230,7 @@ const GradingScale = ({ idx={idx} handleLetterChange={handleLetterChange} letters={letters} + isEditable={isEditable} /> ))} {handles.map(({ value, getHandleProps }, idx) => ( @@ -238,6 +240,7 @@ const GradingScale = ({ gradingSegments={gradingSegments} value={value} idx={idx} + isEditable={isEditable} /> ))} @@ -261,6 +264,7 @@ GradingScale.propTypes = { ).isRequired, setEligibleGrade: PropTypes.func.isRequired, defaultGradeDesignations: PropTypes.arrayOf(PropTypes.string), + isEditable: PropTypes.bool, }; GradingScale.defaultProps = { diff --git a/src/grading-settings/grading-scale/GradingScale.test.jsx b/src/grading-settings/grading-scale/GradingScale.test.jsx index d4b1db1861..ae0bbaa4e9 100644 --- a/src/grading-settings/grading-scale/GradingScale.test.jsx +++ b/src/grading-settings/grading-scale/GradingScale.test.jsx @@ -4,6 +4,7 @@ import { initializeMockApp } from '@edx/frontend-platform'; import { render, waitFor, fireEvent } from '@testing-library/react'; import GradingScale from './GradingScale'; +import GradingScaleHandle from './components/GradingScaleHandle'; const gradeCutoffs = { A: 0.9, B: 0.8, C: 0.7 }; @@ -122,6 +123,47 @@ describe('', () => { }); }); + it('renders GradingScaleHandle with default isEditable=true when prop is omitted', () => { + const gradingSegments = [ + { current: 90, previous: 0 }, + { current: 100, previous: 90 }, + ]; + const { container } = render( + ({})} + />, + ); + expect(container.querySelector('.grading-scale-segment-btn-resize')).not.toBeDisabled(); + }); + + it('should disable inputs and buttons when isEditable is false', async () => { + const { getAllByTestId, queryAllByTestId } = render( + + + , + ); + await waitFor(() => { + const segmentInputs = getAllByTestId('grading-scale-segment-input'); + segmentInputs.forEach((input) => expect(input).toBeDisabled()); + const removeButtons = queryAllByTestId('grading-scale-btn-remove'); + removeButtons.forEach((btn) => expect(btn).toBeDisabled()); + }); + }); + it('should render GradingScale component with more than 5 grades', async () => { const { getAllByTestId } = render( diff --git a/src/grading-settings/grading-scale/components/GradingScaleHandle.jsx b/src/grading-settings/grading-scale/components/GradingScaleHandle.jsx index c24879d9a1..fa012a8c7b 100644 --- a/src/grading-settings/grading-scale/components/GradingScaleHandle.jsx +++ b/src/grading-settings/grading-scale/components/GradingScaleHandle.jsx @@ -8,14 +8,15 @@ const GradingScaleHandle = ({ value, gradingSegments, getHandleProps, + isEditable = true, }) => (