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:
parent
fa007ddb26
commit
e34ecb191e
|
@ -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')
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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')],
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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'))
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in New Issue