Filter tag improvements

1. Show separate tags for each condition (not in named filter).

2. Shows tooltip with list of conditions when hovering over tag of
   named filter.

Test plan:
  - Create filter with some conditions
    - Apply the filter
    - Check that tags are shown for each condition
  - Save the filter with a name
    - Only one tag should show now for the filter
    - Hover over the filter tag
    - Each condition should show in the tooltip
  - Dismiss a filter tag
    - The filter should be disabled and not deleted

flag=enhanced_gradebook_filters

Closes EVAL-2265
Closes EVAL-2266

Change-Id: I68ae6d70187c40f03cdbf66c5688d7e842d5895a
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/285361
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Product-Review: Syed Hussain <shussain@instructure.com>
Reviewed-by: Dustin Cowles <dustin.cowles@instructure.com>
Reviewed-by: Spencer Olson <solson@instructure.com>
QA-Review: Eduardo Escobar <eduardo.escobar@instructure.com>
This commit is contained in:
Aaron Shafovaloff 2022-02-16 20:18:54 -06:00
parent fa007ddb26
commit e34ecb191e
6 changed files with 173 additions and 30 deletions

View File

@ -26,13 +26,20 @@ import _ from 'lodash'
import htmlEscape from 'html-escape'
import type {
Assignment,
AssignmentGroup,
Filter,
FilterCondition,
FilterConditionType,
GradebookFilterApiResponse,
GradebookFilterApiRequest,
GradebookFilterApiResponse,
GradingPeriod,
Module,
PartialFilter,
Section,
SectionMap,
StudentGroup,
StudentGroupCategory,
StudentGroupCategoryMap,
Submission
} from './gradebook.d'
@ -195,20 +202,30 @@ export function getAllAppliedFilterValues(filters: Filter[]) {
.map(c => c.value)
}
export const conditionTypes: FilterConditionType[] = [
'section',
'module',
'assignment-group',
'grading-period',
'student-group',
'start-date',
'end-date',
'submissions'
]
// Extra normalization; comes from jsonb payload
export const deserializeFilter = (json: GradebookFilterApiResponse): Filter => {
const filter = json.gradebook_filter
if (!filter.id || typeof filter.id !== 'string') throw new Error('invalid filter id')
if (!Array.isArray(filter.payload.conditions)) throw new Error('invalid filter conditions')
const conditions = filter.payload.conditions.map(c => {
if (!c || typeof c.id !== 'string') throw new Error('invalid condition id')
return {
const conditions = filter.payload.conditions
.filter(c => c && (typeof c.type === 'undefined' || conditionTypes.includes(c.type)))
.map(c => ({
id: c.id,
type: c.type,
value: c.value,
created_at: String(c.created_at)
}
})
}))
return {
id: filter.id,
name: String(filter.name),
@ -230,3 +247,65 @@ export const serializeFilter = (filter: PartialFilter): GradebookFilterApiReques
export const compareFilterByDate = (a: Filter, b: Filter) =>
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
export const getLabelForFilterCondition = (
condition: FilterCondition,
assignmentGroups: Pick<AssignmentGroup, 'id' | 'name'>[],
gradingPeriods: Pick<GradingPeriod, 'id' | 'title'>[],
modules: Pick<Module, 'id' | 'name'>[],
sections: Pick<Section, 'id' | 'name'>[],
studentGroupCategories: StudentGroupCategoryMap
) => {
if (!condition.type) throw new Error('missing condition type')
if (condition.type === 'section') {
return sections.find(s => s.id === condition.value)?.name || I18n.t('Section')
} else if (condition.type === 'module') {
return modules.find(m => m.id === condition.value)?.name || I18n.t('Module')
} else if (condition.type === 'assignment-group') {
return assignmentGroups.find(a => a.id === condition.value)?.name || I18n.t('Assignment Group')
} else if (condition.type === 'grading-period') {
return gradingPeriods.find(g => g.id === condition.value)?.title || I18n.t('Grading Period')
} else if (condition.type === 'student-group') {
const studentGroups: StudentGroup[] = Object.values(studentGroupCategories)
.map((c: StudentGroupCategory) => c.groups)
.flat()
return (
studentGroups.find((g: StudentGroup) => g.id === condition.value)?.name ||
I18n.t('Student Group')
)
} else if (condition.type === 'submissions') {
if (condition.value === 'has-ungraded-submissions') {
return I18n.t('Has ungraded submissions')
} else if (condition.value === 'has-submissions') {
return I18n.t('Has submissions')
} else {
throw new Error('invalid submissions condition value')
}
} else if (condition.type === 'start-date') {
const options: any = {
year: 'numeric',
month: 'numeric',
day: 'numeric'
}
if (typeof condition.value !== 'string') throw new Error('invalid start-date value')
const value = Intl.DateTimeFormat(I18n.currentLocale(), options).format(
new Date(condition.value)
)
return I18n.t('Start Date %{value}', {value})
} else if (condition.type === 'end-date') {
const options: any = {
year: 'numeric',
month: 'numeric',
day: 'numeric'
}
if (typeof condition.value !== 'string') throw new Error('invalid end-date value')
const value = Intl.DateTimeFormat(I18n.currentLocale(), options).format(
new Date(condition.value)
)
return I18n.t('End Date %{value}', {value})
}
// unrecognized types should have been filtered out by deserializeFilter
throw new Error('invalid condition type')
}

View File

@ -22,6 +22,7 @@ import {AccessibleContent} from '@instructure/ui-a11y-content'
import uuid from 'uuid'
// @ts-ignore
import I18n from 'i18n!gradebook'
import {Tooltip} from '@instructure/ui-tooltip'
import {IconFilterSolid, IconFilterLine} from '@instructure/ui-icons'
import {TextInput} from '@instructure/ui-text-input'
import {View, ContextView} from '@instructure/ui-view'
@ -40,6 +41,7 @@ import type {
Section,
StudentGroupCategoryMap
} from '../gradebook.d'
import {getLabelForFilterCondition} from '../Gradebook.utils'
import useStore from '../stores/index'
const {Item: FlexItem} = Flex as any
@ -79,34 +81,69 @@ export default function FilterNav({
const saveStagedFilter = useStore(state => state.saveStagedFilter)
const updateFilter = useStore(state => state.updateFilter)
const deleteFilter = useStore(state => state.deleteFilter)
const filterComponents = filters
.filter(f => f.is_applied)
.map(filter => {
const tooltip = filter.conditions
.filter(c => c.value)
.map(condition => {
const label = getLabelForFilterCondition(
condition,
assignmentGroups,
gradingPeriods,
modules,
sections,
studentGroupCategories
)
return <div key={`filter-${filter.id}-condition-${condition.id}}`}>{label}</div>
})
return (
<Tag
key={filter.id}
data-testid={`filter-tag-${filter.id}`}
text={<AccessibleContent alt={I18n.t('Remove filter')}>{filter.name}</AccessibleContent>}
dismissible
onClick={() => updateFilter({...filter, is_applied: false})}
margin="0 xx-small 0 0"
/>
<Tooltip key={filter.id} renderTip={tooltip} placement="top" on={['hover', 'focus']}>
<Tag
data-testid={`filter-tag-${filter.id}`}
text={
<AccessibleContent alt={I18n.t('Remove filter')}>{filter.name}</AccessibleContent>
}
dismissible
onClick={() => updateFilter({...filter, is_applied: false})}
margin="0 xx-small 0 0"
/>
</Tooltip>
)
})
if (stagedFilter) {
filterComponents.push(
<Tag
key="staged-filter"
text={
<AccessibleContent alt={I18n.t('Remove filter')}>
{stagedFilter.name || I18n.t('Unnamed Filter')}
</AccessibleContent>
}
dismissible
onClick={() => useStore.setState({stagedFilter: null})}
margin="0 xx-small 0 0"
/>
)
if (stagedFilter && stagedFilter.is_applied) {
stagedFilter.conditions
.filter(c => c.value)
.forEach(condition => {
const label = getLabelForFilterCondition(
condition,
assignmentGroups,
gradingPeriods,
modules,
sections,
studentGroupCategories
)
filterComponents.push(
<Tag
data-testid="staged-filter-condition-tag"
key={`staged-condition-${condition.id}`}
text={<AccessibleContent alt={I18n.t('Remove condition')}>{label}</AccessibleContent>}
dismissible
onClick={() =>
useStore.setState({
stagedFilter: {
...stagedFilter,
conditions: stagedFilter.conditions.filter(c => c.id !== condition.id)
}
})
}
margin="0 xx-small 0 0"
/>
)
})
}
return (

View File

@ -42,7 +42,7 @@ const {Option, Group: OptionGroup} = SimpleSelect as any
const formatDate = date => tz.format(date, 'date.formats.medium')
const dateLabels = {'start-date': I18n.t('Start Date'), 'end-date': I18n.t('End Date')}
type SubmissionTypeOption = [string, string]
type SubmissionTypeOption = ['has-ungraded-submissions' | 'has-submissions', string]
const submissionTypeOptions: SubmissionTypeOption[] = [
['has-ungraded-submissions', I18n.t('Has ungraded submissions')],

View File

@ -221,7 +221,7 @@ export default function FilterNavFilter({
<Item>
<Checkbox
checked={filter.is_applied}
label={I18n.t('Apply filter')}
label={filter.id ? I18n.t('Apply filter') : I18n.t('Apply conditions')}
labelPlacement="start"
onChange={toggleApply}
size="small"

View File

@ -150,6 +150,32 @@ describe('FilterNav', () => {
expect(getByTestId('filter-name-1')).toHaveTextContent('Filter 1')
})
it('render condition tag for applied staged filter', async () => {
store.setState({
stagedFilter: {
name: '',
conditions: [
{
id: '4',
type: 'module',
value: '1',
created_at: new Date().toISOString()
},
{
id: '5',
type: undefined,
value: undefined,
created_at: new Date().toISOString()
}
],
is_applied: true,
created_at: new Date().toISOString()
}
})
const {getAllByTestId} = render(<FilterNav {...defaultProps} />)
expect(await getAllByTestId('staged-filter-condition-tag')[0]).toHaveTextContent('Module 1')
})
it('opens tray', () => {
const {getByText, getByRole} = render(<FilterNav {...defaultProps} />)
userEvent.click(getByText('Filters'))

1
ui/global.d.ts vendored
View File

@ -62,6 +62,7 @@ declare global {
declare interface Array<T> {
flatMap: <Y>(callback: (value: T, index: number, array: T[]) => Y[]) => Y[]
flat: <Y>(depth?: number) => Y[]
}
declare interface Object {