Skip to content

Commit 473c4fb

Browse files
[MBL-19179][Parent] - Implement search in Grades page (#3363)
* [MBL-19179][Parent] - Implement search in Grades page Add a real-time search feature to the Parent app Grades page that allows filtering assignments by name. Changes: - Added search icon next to filter icon in Grades card - Implemented collapsible search field below "Based on graded assignments" section - Search filters assignments automatically when query reaches 3 characters - Added real-time filtering as user types (no need to press enter) - Enhanced SearchBar component with onQueryChange callback for instant text updates - Added search state management in GradesUiState and GradesViewModel - Filter logic removes empty assignment groups from results Technical details: - SearchBar now supports onQueryChange callback that fires on every keystroke - ViewModel caches unfiltered items and applies filtering based on search query - Case-insensitive search across assignment names - Toggling search restores full unfiltered list refs: MBL-19179 affects: Parent release note: Added search functionality to Grades page 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * PR changes (animation, debounce, focusrequest) * Minor PR fixes. * PR changes. (Cursor keeps it's state). --------- Co-authored-by: Claude <[email protected]>
1 parent 70f2fd1 commit 473c4fb

File tree

5 files changed

+146
-10
lines changed

5 files changed

+146
-10
lines changed

libs/pandares/src/main/res/values/strings.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1929,6 +1929,8 @@
19291929
<string name="gradePreferencesHeaderSortBy">Sort By</string>
19301930
<string name="gradesEmptyTitle">No Assignments</string>
19311931
<string name="gradesEmptyMessage">It looks like assignments haven\'t been created in this space yet.</string>
1932+
<string name="noMatchingAssignments">No Matching Assignments</string>
1933+
<string name="noMatchingAssignmentsDescription">No assignments match your search. Try a different search term.</string>
19321934
<string name="errorLoadingGrades">We\'re having trouble loading your student\'s grades. Please try reloading the page or check back later.</string>
19331935
<string name="errorLoadingCourse">We\'re having trouble loading your student\'s course details. Please try reloading the page or check back later.</string>
19341936
<string name="gradesFilterContentDescription">Filter</string>

libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/SearchBar.kt

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import androidx.compose.runtime.LaunchedEffect
3434
import androidx.compose.runtime.getValue
3535
import androidx.compose.runtime.mutableStateOf
3636
import androidx.compose.runtime.remember
37+
import androidx.compose.runtime.saveable.rememberSaveable
3738
import androidx.compose.runtime.setValue
3839
import androidx.compose.ui.Alignment
3940
import androidx.compose.ui.Modifier
@@ -45,6 +46,7 @@ import androidx.compose.ui.platform.testTag
4546
import androidx.compose.ui.res.painterResource
4647
import androidx.compose.ui.res.stringResource
4748
import androidx.compose.ui.text.input.ImeAction
49+
import androidx.compose.ui.text.input.TextFieldValue
4850
import androidx.compose.ui.tooling.preview.Preview
4951
import androidx.compose.ui.unit.dp
5052
import com.instructure.pandautils.R
@@ -61,15 +63,18 @@ fun SearchBar(
6163
searchQuery: String = "",
6264
collapsable: Boolean = true,
6365
@DrawableRes hintIcon: Int? = null,
64-
collapseOnSearch: Boolean = false
66+
collapseOnSearch: Boolean = false,
67+
onQueryChange: ((String) -> Unit)? = null
6568
) {
6669
Row(
6770
modifier = modifier
6871
.height(56.dp),
6972
verticalAlignment = Alignment.CenterVertically
7073
) {
7174
var expanded by remember { mutableStateOf(!collapsable) }
72-
var query by remember { mutableStateOf(searchQuery) }
75+
var query by rememberSaveable(stateSaver = TextFieldValue.Saver) {
76+
mutableStateOf(TextFieldValue(searchQuery))
77+
}
7378
val keyboardController = LocalSoftwareKeyboardController.current
7479
val focusRequester = remember { FocusRequester() }
7580

@@ -103,15 +108,18 @@ fun SearchBar(
103108
.focusRequester(focusRequester),
104109
placeholder = { Text(placeholder) },
105110
value = query,
106-
onValueChange = { query = it },
111+
onValueChange = {
112+
query = it
113+
onQueryChange?.invoke(it.text)
114+
},
107115
singleLine = true,
108116
keyboardOptions = KeyboardOptions.Default.copy(
109117
imeAction = ImeAction.Search
110118
),
111119
keyboardActions = KeyboardActions(
112120
onSearch = {
113121
keyboardController?.hide()
114-
onSearch(query)
122+
onSearch(query.text)
115123
if (collapseOnSearch) {
116124
expanded = false
117125
onExpand?.invoke(false)
@@ -142,11 +150,11 @@ fun SearchBar(
142150
)
143151
},
144152
trailingIcon = {
145-
if (query.isNotEmpty()) {
153+
if (query.text.isNotEmpty()) {
146154
IconButton(
147155
modifier = Modifier.testTag("clearButton"),
148156
onClick = {
149-
query = ""
157+
query = TextFieldValue("")
150158
onClear?.invoke()
151159
}) {
152160
Icon(

libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesScreen.kt

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import android.content.res.Configuration
2121
import androidx.compose.animation.AnimatedContent
2222
import androidx.compose.animation.AnimatedVisibility
2323
import androidx.compose.animation.core.animateFloatAsState
24+
import androidx.compose.animation.fadeIn
25+
import androidx.compose.animation.fadeOut
2426
import androidx.compose.animation.slideInVertically
2527
import androidx.compose.animation.slideOutVertically
2628
import androidx.compose.animation.togetherWith
@@ -73,6 +75,8 @@ import androidx.compose.ui.ExperimentalComposeUiApi
7375
import androidx.compose.ui.Modifier
7476
import androidx.compose.ui.draw.clip
7577
import androidx.compose.ui.draw.rotate
78+
import androidx.compose.ui.focus.FocusRequester
79+
import androidx.compose.ui.focus.focusRequester
7680
import androidx.compose.ui.graphics.Color
7781
import androidx.compose.ui.platform.LocalConfiguration
7882
import androidx.compose.ui.platform.LocalContext
@@ -100,6 +104,7 @@ import com.instructure.pandautils.compose.composables.ErrorContent
100104
import com.instructure.pandautils.compose.composables.FullScreenDialog
101105
import com.instructure.pandautils.compose.composables.GroupHeader
102106
import com.instructure.pandautils.compose.composables.Loading
107+
import com.instructure.pandautils.compose.composables.SearchBar
103108
import com.instructure.pandautils.compose.composables.SubmissionState
104109
import com.instructure.pandautils.features.grades.gradepreferences.GradePreferencesScreen
105110
import com.instructure.pandautils.utils.DisplayGrade
@@ -296,8 +301,46 @@ private fun GradesScreenContent(
296301
)
297302
}
298303

304+
AnimatedVisibility(
305+
visible = uiState.isSearchExpanded,
306+
enter = slideInVertically() + fadeIn(),
307+
exit = slideOutVertically() + fadeOut()
308+
) {
309+
val focusRequester = remember { FocusRequester() }
310+
311+
LaunchedEffect(uiState.isSearchExpanded) {
312+
if (uiState.isSearchExpanded) {
313+
focusRequester.requestFocus()
314+
}
315+
}
316+
317+
SearchBar(
318+
icon = R.drawable.ic_search_white_24dp,
319+
searchQuery = uiState.searchQuery,
320+
tintColor = colorResource(R.color.textDarkest),
321+
placeholder = stringResource(R.string.search),
322+
collapsable = false,
323+
onSearch = {
324+
actionHandler(GradesAction.SearchQueryChanged(it))
325+
},
326+
onClear = {
327+
actionHandler(GradesAction.SearchQueryChanged(""))
328+
},
329+
onQueryChange = {
330+
actionHandler(GradesAction.SearchQueryChanged(it))
331+
},
332+
modifier = Modifier
333+
.testTag("searchField")
334+
.focusRequester(focusRequester)
335+
)
336+
}
337+
299338
if (uiState.items.isEmpty()) {
300-
EmptyContent()
339+
if (uiState.searchQuery.length >= 3) {
340+
EmptySearchContent()
341+
} else {
342+
EmptyContent()
343+
}
301344
}
302345
}
303346

@@ -422,6 +465,27 @@ private fun GradesCard(
422465
modifier = Modifier.size(24.dp)
423466
)
424467
}
468+
Box(
469+
modifier = Modifier
470+
.size(48.dp)
471+
.clip(CircleShape)
472+
.clickable {
473+
actionHandler(GradesAction.ToggleSearch)
474+
}
475+
.semantics {
476+
role = Role.Button
477+
},
478+
contentAlignment = Alignment.Center
479+
) {
480+
Icon(
481+
painter = painterResource(id = R.drawable.ic_search_white_24dp),
482+
contentDescription = stringResource(id = R.string.search),
483+
tint = Color(userColor),
484+
modifier = Modifier
485+
.size(24.dp)
486+
.testTag("searchIcon")
487+
)
488+
}
425489
}
426490
}
427491

@@ -437,6 +501,18 @@ private fun EmptyContent() {
437501
)
438502
}
439503

504+
@Composable
505+
private fun EmptySearchContent() {
506+
EmptyContent(
507+
emptyTitle = stringResource(id = R.string.noMatchingAssignments),
508+
emptyMessage = stringResource(id = R.string.noMatchingAssignmentsDescription),
509+
imageRes = R.drawable.ic_panda_space,
510+
modifier = Modifier
511+
.fillMaxWidth()
512+
.padding(vertical = 32.dp, horizontal = 16.dp)
513+
)
514+
}
515+
440516
@OptIn(ExperimentalLayoutApi::class)
441517
@Composable
442518
fun AssignmentItem(

libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesUiState.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ data class GradesUiState(
3939
val onlyGradedAssignmentsSwitchEnabled: Boolean = true,
4040
val gradeText: String = "",
4141
val isGradeLocked: Boolean = false,
42-
val snackbarMessage: String? = null
42+
val snackbarMessage: String? = null,
43+
val searchQuery: String = "",
44+
val isSearchExpanded: Boolean = false
4345
)
4446

4547
data class AssignmentGroupUiState(
@@ -97,6 +99,8 @@ sealed class GradesAction {
9799
data class AssignmentClick(val id: Long) : GradesAction()
98100
data object SnackbarDismissed : GradesAction()
99101
data class ToggleCheckpointsExpanded(val assignmentId: Long) : GradesAction()
102+
data object ToggleSearch : GradesAction()
103+
data class SearchQueryChanged(val query: String) : GradesAction()
100104
}
101105

102106
sealed class GradesViewModelAction {

libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesViewModel.kt

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import com.instructure.pandautils.R
3535
import com.instructure.pandautils.compose.composables.DiscussionCheckpointUiState
3636
import com.instructure.pandautils.features.grades.gradepreferences.SortBy
3737
import com.instructure.pandautils.utils.Const
38+
import com.instructure.pandautils.utils.debounce
3839
import com.instructure.pandautils.utils.filterHiddenAssignments
3940
import com.instructure.pandautils.utils.getAssignmentIcon
4041
import com.instructure.pandautils.utils.getGrade
@@ -78,6 +79,16 @@ class GradesViewModel @Inject constructor(
7879
private var courseGrade: CourseGrade? = null
7980

8081
private var customStatuses = listOf<CustomGradeStatusesQuery.Node>()
82+
private var allItems = emptyList<AssignmentGroupUiState>()
83+
84+
private val debouncedSearch = debounce<String>(
85+
coroutineScope = viewModelScope
86+
) { query ->
87+
val filteredItems = filterItems(allItems, query)
88+
_uiState.update {
89+
it.copy(items = filteredItems)
90+
}
91+
}
8192

8293
init {
8394
loadGrades(
@@ -121,16 +132,18 @@ class GradesViewModel @Inject constructor(
121132

122133
courseGrade = repository.getCourseGrade(course, repository.studentId, enrollments, selectedGradingPeriod?.id)
123134

124-
val items = when (sortBy) {
135+
allItems = when (sortBy) {
125136
SortBy.GROUP -> groupByAssignmentGroup(assignmentGroups)
126137
SortBy.DUE_DATE -> groupByDueDate(assignmentGroups)
127138
}.filter {
128139
it.assignments.isNotEmpty()
129140
}
130141

142+
val filteredItems = filterItems(allItems, _uiState.value.searchQuery)
143+
131144
_uiState.update {
132145
it.copy(
133-
items = items,
146+
items = filteredItems,
134147
isLoading = false,
135148
isRefreshing = false,
136149
gradePreferencesUiState = it.gradePreferencesUiState.copy(
@@ -280,6 +293,21 @@ class GradesViewModel @Inject constructor(
280293
context.getString(R.string.due, "$dateText $timeText")
281294
} ?: context.getString(R.string.gradesNoDueDate)
282295

296+
private fun filterItems(items: List<AssignmentGroupUiState>, query: String): List<AssignmentGroupUiState> {
297+
if (query.length < 3) return items
298+
299+
return items.mapNotNull { group ->
300+
val filteredAssignments = group.assignments.filter { assignment ->
301+
assignment.name.contains(query, ignoreCase = true)
302+
}
303+
if (filteredAssignments.isEmpty()) {
304+
null
305+
} else {
306+
group.copy(assignments = filteredAssignments)
307+
}
308+
}
309+
}
310+
283311
fun handleAction(action: GradesAction) {
284312
when (action) {
285313
is GradesAction.Refresh -> {
@@ -351,6 +379,24 @@ class GradesViewModel @Inject constructor(
351379
}
352380
_uiState.update { it.copy(items = items) }
353381
}
382+
383+
is GradesAction.ToggleSearch -> {
384+
val isExpanding = !uiState.value.isSearchExpanded
385+
_uiState.update {
386+
it.copy(
387+
isSearchExpanded = isExpanding,
388+
searchQuery = if (!isExpanding) "" else it.searchQuery,
389+
items = if (!isExpanding) allItems else it.items
390+
)
391+
}
392+
}
393+
394+
is GradesAction.SearchQueryChanged -> {
395+
_uiState.update {
396+
it.copy(searchQuery = action.query)
397+
}
398+
debouncedSearch(action.query)
399+
}
354400
}
355401
}
356402
}

0 commit comments

Comments
 (0)