Skip to content
Draft
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
4 changes: 4 additions & 0 deletions src/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@
.text-prewrap {
white-space: pre-wrap;
}

.info-tooltip .tooltip-inner {
max-width: none;
}
8 changes: 1 addition & 7 deletions src/components/ActionCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ describe('ActionCard', () => {
title: 'Test Card Title',
description: 'This is a test card description',
buttonLabel: 'Click Me',
onButtonClick: jest.fn(),
};

beforeEach(() => {
Expand Down Expand Up @@ -63,11 +64,4 @@ describe('ActionCard', () => {
expect(screen.getByText('Custom Button 2')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Click Me' })).not.toBeInTheDocument();
});

it('should handle missing onButtonClick prop gracefully', async () => {
render(<ActionCard {...defaultProps} />);
const button = screen.getByRole('button', { name: 'Click Me' });
await user.click(button);
expect(button).toBeInTheDocument();
});
});
14 changes: 7 additions & 7 deletions src/components/ActionCard.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Button, Card } from '@openedx/paragon';

interface ActionCardProps {
buttonLabel: string,
export interface ActionCardProps {
buttonLabel?: string,
customAction?: React.ReactNode,
description: string,
hasBorderBottom?: boolean,
Expand All @@ -22,21 +22,21 @@ const ActionCard = ({
return (
<Card className={`bg-light-200 py-2 border-gray-500 rounded-0 shadow-none ${hasBorderBottom ? 'border-bottom' : ''}`} orientation="horizontal">
<Card.Body className="flex-grow-1">
<Card.Section>
<h4 className="mb-2">{title}</h4>
<p className="text-muted mb-0">{description}</p>
<Card.Section className="pl-0">
<h4 className="text-primary-700 mb-2">{title}</h4>
<p className="text-primary-500 mb-0">{description}</p>
</Card.Section>
</Card.Body>
<Card.Footer className="d-flex align-items-center justify-content-end">
{customAction ?? (
{customAction ?? (buttonLabel && onButtonClick && (
<Button
onClick={onButtonClick}
disabled={isLoading}
variant="primary"
>
{buttonLabel}
</Button>
)}
))}
</Card.Footer>
</Card>
);
Expand Down
4 changes: 2 additions & 2 deletions src/components/SpecifyLearnerField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ const SpecifyLearnerField = ({ onChange }: SpecifyLearnerFieldProps) => {
const intl = useIntl();

return (
<FormGroup size="sm">
<FormLabel>{intl.formatMessage(messages.specifyLearner)}</FormLabel>
<FormGroup size="sm" className="mb-0">
<FormLabel className="text-primary-500 d-flex">{intl.formatMessage(messages.specifyLearner)}</FormLabel>
<div className="d-flex">
<FormControl className="mr-2" name="emailOrUsername" placeholder={intl.formatMessage(messages.specifyLearnerPlaceholder)} size="md" autoResize onChange={onChange} />
<Button>{intl.formatMessage(messages.select)}</Button>
Expand Down
77 changes: 77 additions & 0 deletions src/components/SpecifyProblemField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { useState } from 'react';
import { Button, Form, Icon, OverlayTrigger, Tooltip } from '@openedx/paragon';
import { InfoOutline } from '@openedx/paragon/icons';
import { useIntl } from '@openedx/frontend-base';
import messages from './messages';
import { useDebouncedFilter } from '@src/hooks/useDebouncedFilter';

interface SpecifyProblemFieldProps {
onClick: (problemLocation: string, event: React.MouseEvent<HTMLButtonElement>) => void,
problemResponsesError?: string,
disabled?: boolean,
fieldLabel: string,
buttonLabel: string,
}

const SpecifyProblemField = ({
onClick,
problemResponsesError,
disabled,
fieldLabel,
buttonLabel,
}: SpecifyProblemFieldProps) => {
const intl = useIntl();
const [problemLocation, setProblemLocation] = useState('');

const { inputValue, handleChange } = useDebouncedFilter({
filterValue: problemLocation,
setFilter: setProblemLocation,
});

const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onClick(inputValue, event);
};

return (
<Form.Group className="mb-0" isInvalid={!!problemResponsesError} size="sm">
<Form.Label className="d-flex align-content-end align-items-center gap-2 text-primary-500">
{fieldLabel}
<OverlayTrigger
placement="top"
overlay={(
<Tooltip id="problem-location-tooltip" className="info-tooltip">
{intl.formatMessage(messages.problemLocationTooltip)}
</Tooltip>
)}
>
<Icon src={InfoOutline} size="sm" aria-label={intl.formatMessage(messages.problemLocationInfoIconLabel)} />
</OverlayTrigger>
</Form.Label>
<div className="d-flex align-items-center">
<Form.Control
type="text"
placeholder={intl.formatMessage(messages.problemLocationPlaceholder)}
value={problemLocation}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleChange(e.target.value)}
className="flex-grow-1"
size="md"
/>
{problemResponsesError && (
<Form.Control.Feedback type="invalid">
{problemResponsesError}
</Form.Control.Feedback>
)}
<Button
variant="primary"
onClick={handleClick}
disabled={disabled}
className="text-nowrap"
>
{buttonLabel}
</Button>
</div>
</Form.Group>
);
};

export default SpecifyProblemField;
17 changes: 16 additions & 1 deletion src/components/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,22 @@ const messages = defineMessages({
id: 'instruct.csvComponent.uploadingFileMessage',
defaultMessage: 'File chosen: {fileName}',
description: 'Message displayed when a file is being uploaded, with the file name included'
}
},
problemLocationPlaceholder: {
id: 'instruct.specifyProblemField.locationPlaceholder',
defaultMessage: 'Problem location',
description: 'Placeholder text for problem location input',
},
problemLocationInfoIconLabel: {
id: 'instruct.specifyProblemField.infoIconLabel',
defaultMessage: 'Example format for problem location',
description: 'Aria label for the info icon next to the problem location input',
},
problemLocationTooltip: {
id: 'instruct.specifyProblemField.locationTooltip',
defaultMessage: 'Example: block-v1:edX+DemoX+2015+type@problem+block@618c5933b8b544e4a4cc103d3e508378',
description: 'Tooltip text showing an example problem location format',
},
});

export default messages;
2 changes: 2 additions & 0 deletions src/courseInfo/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export interface CourseInfoResponse {
gradeCutoffs: string | null,
staffCount: number,
learnerCount: number,
gradebookUrl: string,
studioGradingUrl?: string,
}

interface EnrollmentCounts extends Record<string, number> {
Expand Down
1 change: 1 addition & 0 deletions src/data/apiHook.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const mockCourseData = {
gradeCutoffs: null,
staffCount: 5,
learnerCount: 145,
gradebookUrl: 'http://example.com/gradebook',
};

const createWrapper = () => {
Expand Down
135 changes: 135 additions & 0 deletions src/grading/GradingPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithIntl } from '@src/testUtils';
import GradingPage from './GradingPage';
import messages from './messages';

// Mock child components, each component should have its own test suite
jest.mock('./components/GradingLearnerContent', () => {
return function MockGradingLearnerContent({ toolType }: { toolType: string }) {
return <div>Grading Content for: {toolType}</div>;
};
});

jest.mock('./components/GradingActionRow', () => {
return function MockGradingActionRow() {
return <div>Grading Action Row</div>;
};
});

jest.mock('@src/components/PendingTasks', () => {
return {
PendingTasks: function MockPendingTasks() {
return <div>Pending Tasks</div>;
}
};
});

describe('GradingPage', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('renders the page title correctly', () => {
renderWithIntl(<GradingPage />);
expect(screen.getByText(messages.pageTitle.defaultMessage)).toBeInTheDocument();
});

it('renders all child components', () => {
renderWithIntl(<GradingPage />);
expect(screen.getByText('Grading Action Row')).toBeInTheDocument();
expect(screen.getByText('Grading Content for: single')).toBeInTheDocument();
expect(screen.getByText('Pending Tasks')).toBeInTheDocument();
});

it('renders both button options with correct labels', () => {
renderWithIntl(<GradingPage />);
expect(screen.getByRole('button', { name: messages.singleLearner.defaultMessage })).toBeInTheDocument();
expect(screen.getByRole('button', { name: messages.allLearners.defaultMessage })).toBeInTheDocument();
});

it('defaults to single learner tool being selected', () => {
renderWithIntl(<GradingPage />);
const singleLearnerButton = screen.getByRole('button', { name: messages.singleLearner.defaultMessage });
const allLearnersButton = screen.getByRole('button', { name: messages.allLearners.defaultMessage });

// Single learner should have primary variant (selected state)
expect(singleLearnerButton).toHaveClass('btn-primary');
expect(allLearnersButton).toHaveClass('btn-outline-primary');

// GradingLearnerContent should receive 'single' as initial toolType
expect(screen.getByText('Grading Content for: single')).toBeInTheDocument();
});

it('switches to All Learners when All Learners button is clicked', async () => {
renderWithIntl(<GradingPage />);
const user = userEvent.setup();

const allLearnersButton = screen.getByRole('button', { name: messages.allLearners.defaultMessage });

await user.click(allLearnersButton);

// All learners should now be selected
expect(allLearnersButton).toHaveClass('btn-primary');
expect(screen.getByRole('button', { name: messages.singleLearner.defaultMessage })).toHaveClass('btn-outline-primary');

// GradingLearnerContent should receive 'all' as toolType
expect(screen.getByText('Grading Content for: all')).toBeInTheDocument();
});

it('switches back to Single Learner when Single Learner button is clicked', async () => {
renderWithIntl(<GradingPage />);
const user = userEvent.setup();

const singleLearnerButton = screen.getByRole('button', { name: messages.singleLearner.defaultMessage });
const allLearnersButton = screen.getByRole('button', { name: messages.allLearners.defaultMessage });

// First switch to all learners
await user.click(allLearnersButton);
expect(screen.getByText('Grading Content for: all')).toBeInTheDocument();

// Then switch back to single learner
await user.click(singleLearnerButton);

// Single learner should be selected again
expect(singleLearnerButton).toHaveClass('btn-primary');
expect(allLearnersButton).toHaveClass('btn-outline-primary');
expect(screen.getByText('Grading Content for: single')).toBeInTheDocument();
});

it('maintains correct button states during multiple interactions', async () => {
renderWithIntl(<GradingPage />);
const user = userEvent.setup();

const singleLearnerButton = screen.getByRole('button', { name: messages.singleLearner.defaultMessage });
const allLearnersButton = screen.getByRole('button', { name: messages.allLearners.defaultMessage });

// Initial state - single learner selected
expect(singleLearnerButton).toHaveClass('btn-primary');
expect(allLearnersButton).toHaveClass('btn-outline-primary');

// Click all learners multiple times - should remain selected
await user.click(allLearnersButton);
await user.click(allLearnersButton);

expect(allLearnersButton).toHaveClass('btn-primary');
expect(singleLearnerButton).toHaveClass('btn-outline-primary');
expect(screen.getByText('Grading Content for: all')).toBeInTheDocument();

// Click single learner multiple times - should remain selected
await user.click(singleLearnerButton);
await user.click(singleLearnerButton);

expect(singleLearnerButton).toHaveClass('btn-primary');
expect(allLearnersButton).toHaveClass('btn-outline-primary');
expect(screen.getByText('Grading Content for: single')).toBeInTheDocument();
});

it('passes correct toolType prop to GradingLearnerContent component', () => {
renderWithIntl(<GradingPage />);

// Initially should pass 'single'
expect(screen.getByText('Grading Content for: single')).toBeInTheDocument();
expect(screen.queryByText('Grading Content for: all')).not.toBeInTheDocument();
});
});
39 changes: 36 additions & 3 deletions src/grading/GradingPage.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,41 @@
import { useState } from 'react';
import { useIntl } from '@openedx/frontend-base';
import { Button, ButtonGroup, Card } from '@openedx/paragon';
import GradingLearnerContent from './components/GradingLearnerContent';
import messages from './messages';
import GradingActionRow from './components/GradingActionRow';
import { GradingToolsType } from './types';
import { PendingTasks } from '@src/components/PendingTasks';

const GradingPage = () => {
const intl = useIntl();
const [selectedTools, setSelectedTools] = useState<GradingToolsType>('single');

return (
<div>
<h3>Grading</h3>
</div>
<>
<div className="d-flex justify-content-between align-items-center">
<h3 className="text-primary-700 mb-0">{intl.formatMessage(messages.pageTitle)}</h3>
<GradingActionRow />
</div>
<Card className="bg-light-200 p-4 mt-4.5">
<ButtonGroup className="d-block">
<Button
onClick={() => setSelectedTools('single')}
variant={selectedTools === 'single' ? 'primary' : 'outline-primary'}
>
{intl.formatMessage(messages.singleLearner)}
</Button>
<Button
onClick={() => setSelectedTools('all')}
variant={selectedTools === 'all' ? 'primary' : 'outline-primary'}
>
{intl.formatMessage(messages.allLearners)}
</Button>
</ButtonGroup>
<GradingLearnerContent toolType={selectedTools} />
</Card>
<PendingTasks />
</>
);
};

Expand Down
Loading