diff --git a/src/components/GradesView/SearchControls/__snapshots__/index.test.jsx.snap b/src/components/GradesView/SearchControls/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000..d9f5c94
--- /dev/null
+++ b/src/components/GradesView/SearchControls/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,18 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SearchControls component render snapshot 1`] = `
+
+
+
+ test-hint-text
+
+
+`;
diff --git a/src/components/GradesView/SearchControls/hooks.js b/src/components/GradesView/SearchControls/hooks.js
new file mode 100644
index 0000000..8ce4973
--- /dev/null
+++ b/src/components/GradesView/SearchControls/hooks.js
@@ -0,0 +1,41 @@
+import { useIntl } from '@edx/frontend-platform/i18n';
+
+import { actions, selectors, thunkActions } from 'data/redux/hooks';
+
+import messages from './messages';
+
+/**
+ * Controls for filtering the GradebookTable. Contains the "Edit Filters" button for opening the filter drawer
+ * as well as the search box for searching by username/email.
+ */
+export const useSearchControlsData = () => {
+ const { formatMessage } = useIntl();
+ const searchValue = selectors.app.useSearchValue();
+ const fetchGrades = thunkActions.grades.useFetchGrades();
+ const setSearchValue = actions.app.useSetSearchValue();
+
+ const onBlur = (e) => {
+ setSearchValue(e.target.value);
+ };
+
+ const onClear = () => {
+ setSearchValue('');
+ fetchGrades();
+ };
+
+ const onSubmit = (newValue) => {
+ setSearchValue(newValue);
+ fetchGrades();
+ };
+
+ return {
+ onSubmit,
+ onBlur,
+ onClear,
+ searchValue,
+ inputLabel: formatMessage(messages.label),
+ hintText: formatMessage(messages.hint),
+ };
+};
+
+export default useSearchControlsData;
diff --git a/src/components/GradesView/SearchControls/hooks.test.js b/src/components/GradesView/SearchControls/hooks.test.js
new file mode 100644
index 0000000..db0c291
--- /dev/null
+++ b/src/components/GradesView/SearchControls/hooks.test.js
@@ -0,0 +1,71 @@
+import { useIntl } from '@edx/frontend-platform/i18n';
+
+import { formatMessage } from 'testUtils';
+import { actions, selectors, thunkActions } from 'data/redux/hooks';
+
+import useSearchControlsData from './hooks';
+import messages from './messages';
+
+jest.mock('data/redux/hooks', () => ({
+ actions: {
+ app: { useSetSearchValue: jest.fn() },
+ },
+ selectors: {
+ app: { useSearchValue: jest.fn() },
+ },
+ thunkActions: {
+ grades: { useFetchGrades: jest.fn() },
+ },
+}));
+
+const searchValue = 'test-search-value';
+selectors.app.useSearchValue.mockReturnValue(searchValue);
+const setSearchValue = jest.fn();
+actions.app.useSetSearchValue.mockReturnValue(setSearchValue);
+const fetchGrades = jest.fn();
+thunkActions.grades.useFetchGrades.mockReturnValue(fetchGrades);
+
+const testValue = 'test-value';
+let out;
+describe('useSearchControlsData', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ out = useSearchControlsData();
+ });
+ describe('behavior', () => {
+ it('initializes intl hook', () => {
+ expect(useIntl).toHaveBeenCalled();
+ });
+ it('initializes redux hooks', () => {
+ expect(actions.app.useSetSearchValue).toHaveBeenCalled();
+ expect(selectors.app.useSearchValue).toHaveBeenCalled();
+ expect(thunkActions.grades.useFetchGrades).toHaveBeenCalled();
+ });
+ });
+ describe('output', () => {
+ test('onSubmit sets search value and fetches grades', () => {
+ out.onSubmit(testValue);
+ expect(setSearchValue).toHaveBeenCalledWith(testValue);
+ expect(fetchGrades).toHaveBeenCalled();
+ });
+ test('onBlur sets search value to event target', () => {
+ out.onBlur({ target: { value: testValue } });
+ expect(setSearchValue).toHaveBeenCalledWith(testValue);
+ expect(fetchGrades).not.toHaveBeenCalled();
+ });
+ test('onClear clears search value and fetches grades', () => {
+ out.onClear();
+ expect(setSearchValue).toHaveBeenCalledWith('');
+ expect(fetchGrades).toHaveBeenCalled();
+ });
+ it('forwards searchValue from redux', () => {
+ expect(out.searchValue).toEqual(searchValue);
+ });
+ test('input label message', () => {
+ expect(out.inputLabel).toEqual(formatMessage(messages.label));
+ });
+ test('hint text message', () => {
+ expect(out.hintText).toEqual(formatMessage(messages.hint));
+ });
+ });
+});
diff --git a/src/components/GradesView/SearchControls/index.jsx b/src/components/GradesView/SearchControls/index.jsx
new file mode 100644
index 0000000..17f418b
--- /dev/null
+++ b/src/components/GradesView/SearchControls/index.jsx
@@ -0,0 +1,38 @@
+import React from 'react';
+
+import { SearchField } from '@edx/paragon';
+import useSearchControlsData from './hooks';
+
+/**
+ * Controls for filtering the GradebookTable. Contains the "Edit Filters" button for opening the filter drawer
+ * as well as the search box for searching by username/email.
+ */
+export const SearchControls = () => {
+ const {
+ onSubmit,
+ onBlur,
+ onClear,
+ searchValue,
+ inputLabel,
+ hintText,
+ } = useSearchControlsData();
+
+ return (
+
+
+
+ {hintText}
+
+
+ );
+};
+
+SearchControls.propTypes = {};
+
+export default SearchControls;
diff --git a/src/components/GradesView/SearchControls/index.test.jsx b/src/components/GradesView/SearchControls/index.test.jsx
new file mode 100644
index 0000000..ae9a102
--- /dev/null
+++ b/src/components/GradesView/SearchControls/index.test.jsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { SearchField } from '@edx/paragon';
+
+import useSearchControlsData from './hooks';
+import SearchControls from '.';
+
+jest.mock('./hooks', () => jest.fn());
+
+const hookProps = {
+ onSubmit: jest.fn().mockName('hooks.onSubmit'),
+ onBlur: jest.fn().mockName('hooks.onBlur'),
+ onClear: jest.fn().mockName('hooks.onClear'),
+ searchValue: 'test-search-value',
+ inputLabel: 'test-input-label',
+ hintText: 'test-hint-text',
+};
+useSearchControlsData.mockReturnValue(hookProps);
+
+let el;
+describe('SearchControls component', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ el = shallow();
+ });
+ describe('behavior', () => {
+ it('initializes component hooks', () => {
+ expect(useSearchControlsData).toHaveBeenCalled();
+ });
+ });
+ describe('render', () => {
+ test('snapshot', () => {
+ expect(el).toMatchSnapshot();
+ });
+ test('search field', () => {
+ const props = el.find(SearchField).props();
+ expect(props.onSubmit).toEqual(hookProps.onSubmit);
+ expect(props.onBlur).toEqual(hookProps.onBlur);
+ expect(props.onClear).toEqual(hookProps.onClear);
+ expect(props.inputLabel).toEqual(hookProps.inputLabel);
+ expect(props.value).toEqual(hookProps.searchValue);
+ });
+ test('hint text', () => {
+ expect(el.find('small').text()).toEqual(hookProps.hintText);
+ });
+ });
+});
diff --git a/src/components/GradesView/SearchControls/messages.js b/src/components/GradesView/SearchControls/messages.js
new file mode 100644
index 0000000..79b117f
--- /dev/null
+++ b/src/components/GradesView/SearchControls/messages.js
@@ -0,0 +1,16 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ label: {
+ id: 'gradebook.GradesView.search.label',
+ defaultMessage: 'Search for a learner',
+ description: 'Text prompting a user to use this functionality to search for a learner',
+ },
+ hint: {
+ id: 'gradebook.GradesView.search.hint',
+ defaultMessage: 'Search by username, email, or student key',
+ description: 'A hint explaining the ways a user can search',
+ },
+});
+
+export default messages;