diff --git a/web/src/components/case-details/civil/CivilDocumentsView.vue b/web/src/components/case-details/civil/CivilDocumentsView.vue index 200b895df..ec639410d 100644 --- a/web/src/components/case-details/civil/CivilDocumentsView.vue +++ b/web/src/components/case-details/civil/CivilDocumentsView.vue @@ -16,21 +16,11 @@ - - - + :select-all-count="uniqueDocuments.length" + /> @@ -121,12 +112,22 @@ DocumentRequestType, } from '@/types/shared'; import { getCourtClassStyle, getRoles } from '@/utils/utils'; + import { + DEFAULT_OTHER_LABEL, + getActiveSelections, + pruneInvalidSelections, + getSectionTitle, + getUncategorizedCount, + isAllOptionsSelected, + matchesCategorySelection, + } from '@/utils/categoryFilterUtils'; import { mdiFileDocumentMultipleOutline, mdiNotebookOutline, mdiNotebookRemoveOutline, } from '@mdi/js'; - import { computed, inject, onMounted, ref } from 'vue'; + import { computed, inject, onMounted, ref, watch } from 'vue'; + import ChipMultiSelect from '../common/ChipMultiSelect.vue'; import AllDocuments from './documents/AllDocuments.vue'; import JudicialBinder from './documents/JudicialBinder.vue'; @@ -148,6 +149,7 @@ const CSR_CATEGORY_DESC = 'Court Summary'; const AFF_FIN_STMT = 'Affidavits/Financial Stmts'; const LITIGANT = 'Litigant'; + const OTHER_CATEGORY = DEFAULT_OTHER_LABEL; const selectedItems = ref([]); const selectedBinderItems = ref([]); @@ -171,14 +173,36 @@ const scheduledDocuments = computed(() => uniqueDocuments.value.filter((doc) => doc.nextAppearanceDt) ); - const selectedCategory = ref( - scheduledDocuments.value.length > 0 ? SCHEDULED_CATEGORY : undefined + const selectedCategories = ref( + scheduledDocuments.value.length > 0 ? [SCHEDULED_CATEGORY] : [] ); const isBinderLoading = ref(true); const rolesLoading = ref(false); const roles = ref(); const currentBinder = ref(); const courtClassCdStyle = getCourtClassStyle(props.courtClassCd); + const selectedCategory = computed(() => { + return selectedCategories.value.length === 1 + ? selectedCategories.value[0] + : undefined; + }); + const isAllSelected = computed(() => + isAllOptionsSelected( + selectedCategories.value, + documentCategories.value.length + ) + ); + const activeCategories = computed(() => + getActiveSelections(selectedCategories.value, isAllSelected.value) + ); + const isScheduledOnlySelected = computed( + () => + activeCategories.value.length === 1 && + activeCategories.value[0] === SCHEDULED_CATEGORY + ); + const allDocumentsSectionTitle = computed(() => + getSectionTitle(activeCategories.value, getCategoryDisplayTitle) + ); const sortBy = computed<[{ key: string; order: 'desc' | 'asc' }]>(() => { return selectedCategory?.value === CSR_CATEGORY ? [{ key: 'filedDt', order: 'desc' as const }] @@ -187,7 +211,7 @@ const headers = computed(() => { const baseHeaders = shared.getBaseCivilDocumentTableHeaders( - selectedCategory.value === SCHEDULED_CATEGORY + isScheduledOnlySelected.value ); return [ @@ -216,10 +240,48 @@ return categoryMap[category] || category; }; - const documentCategories = computed<{ title: string; value: string }[]>(() => - (scheduledDocuments.value.length > 0 - ? [{ title: SCHEDULED_CATEGORY, value: SCHEDULED_CATEGORY }] - : [] + const getUncategorizedDocumentCount = (): number => + getUncategorizedCount(uniqueDocuments.value, (doc) => doc.category); + + const categoryCount = (category: string): number => { + if (category.toLowerCase() === SCHEDULED_CATEGORY.toLowerCase()) { + return uniqueDocuments.value.filter((doc) => doc.nextAppearanceDt).length; + } + + if ( + category.toLowerCase() === CSR_CATEGORY_DESC.toLowerCase() || + category.toLowerCase() === CSR_CATEGORY.toLowerCase() + ) { + return uniqueDocuments.value.filter( + (doc) => + doc.category?.trim().toLowerCase() === CSR_CATEGORY.toLowerCase() + ).length; + } + + if (category.toLowerCase() === OTHER_CATEGORY.toLowerCase()) { + return getUncategorizedDocumentCount(); + } + + return uniqueDocuments.value.filter( + (doc) => doc.category?.trim().toLowerCase() === category.toLowerCase() + ).length; + }; + + const documentCategories = computed< + { title: string; value: string; count: number }[] + >(() => { + const uncategorizedDocumentCount = getUncategorizedDocumentCount(); + + return ( + scheduledDocuments.value.length > 0 + ? [ + { + title: SCHEDULED_CATEGORY, + value: SCHEDULED_CATEGORY, + count: categoryCount(SCHEDULED_CATEGORY), + }, + ] + : [] ).concat( [ ...new Set( @@ -227,28 +289,53 @@ .filter((d) => d.category) .map((doc) => doc.category) ), - ].map((category) => ({ - title: getCategoryDisplayTitle(category), - value: category, - })) - ) - ); - - const filterByCategory = (item: civilDocumentType) => { - const category = selectedCategory.value?.toLowerCase(); - if (!category) { - return true; - } + ] + .map((category) => ({ + title: getCategoryDisplayTitle(category), + value: category, + count: categoryCount(category), + })) + .concat( + uncategorizedDocumentCount > 0 + ? [ + { + title: OTHER_CATEGORY, + value: OTHER_CATEGORY, + count: uncategorizedDocumentCount, + }, + ] + : [] + ) + ); + }); - if (category === SCHEDULED_CATEGORY.toLowerCase()) { - return !!item.nextAppearanceDt; - } + watch( + documentCategories, + (categories) => { + const filteredSelections = pruneInvalidSelections( + selectedCategories.value, + categories.map((category) => category.value) + ); - if (category === CSR_CATEGORY_DESC.toLowerCase()) { - return item.category === CSR_CATEGORY; - } + if (filteredSelections.length !== selectedCategories.value.length) { + selectedCategories.value = filteredSelections; + } + }, + { immediate: true } + ); - return item.category?.toLowerCase() === category; + const filterByCategory = (item: civilDocumentType) => { + return matchesCategorySelection( + item, + activeCategories.value, + (doc) => doc.category, + { + otherLabel: OTHER_CATEGORY, + specialPredicates: { + [SCHEDULED_CATEGORY.toLowerCase()]: (doc) => !!doc.nextAppearanceDt, + }, + } + ); }; const filteredDocuments = computed(() => @@ -273,22 +360,6 @@ .filter((item): item is civilDocumentType => item !== undefined); }); - const categoryCount = (category: string): number => { - if (category.toLowerCase() === SCHEDULED_CATEGORY.toLowerCase()) { - return uniqueDocuments.value.filter((doc) => doc.nextAppearanceDt).length; - } - - if (category.toLowerCase() === CSR_CATEGORY_DESC.toLowerCase()) { - return uniqueDocuments.value.filter( - (doc) => doc.category === CSR_CATEGORY - ).length; - } - - return uniqueDocuments.value.filter( - (doc) => doc.category?.toLowerCase() === category.toLowerCase() - ).length; - }; - onMounted(async () => { try { rolesLoading.value = true; diff --git a/web/src/components/case-details/civil/documents/AllDocuments.vue b/web/src/components/case-details/civil/documents/AllDocuments.vue index 7a18afe48..6626c01fc 100644 --- a/web/src/components/case-details/civil/documents/AllDocuments.vue +++ b/web/src/components/case-details/civil/documents/AllDocuments.vue @@ -4,16 +4,12 @@ color="var(--bg-gray-500)" elevation="0" data-testid="all-documents-container" - v-if="documents?.length > 0" + v-if="documents?.length > 0 || props.hasActiveFilters" > - {{ - props.selectedCategory && props.getCategoryDisplayTitle - ? props.getCategoryDisplayTitle(props.selectedCategory) - : 'All Documents' - }} + {{ props.sectionTitle || 'All Documents' }} ({{ documents.length }}) @@ -53,10 +49,7 @@ {{ item.documentTypeDescription }} - +
Date Filed: {{ formatDateToDDMMMYYYY(item.filedDt) }}
@@ -104,9 +97,10 @@ baseHeaders: DataTableHeader[]; binderDocumentIds: string[]; addDocumentToBinder: (document: civilDocumentType) => void; - selectedCategory?: string; + hasActiveFilters?: boolean; + isScheduledView?: boolean; + sectionTitle?: string; sortBy?: [{ key: string; order: 'asc' | 'desc' }]; - getCategoryDisplayTitle?: (category: string) => string; openIndividualDocument: (data: civilDocumentType) => void; }>(); const emit = diff --git a/web/src/components/case-details/common/ChipMultiSelect.vue b/web/src/components/case-details/common/ChipMultiSelect.vue new file mode 100644 index 000000000..278f0bb93 --- /dev/null +++ b/web/src/components/case-details/common/ChipMultiSelect.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/web/src/components/case-details/criminal/CriminalDocumentsView.vue b/web/src/components/case-details/criminal/CriminalDocumentsView.vue index 811b97fd9..3ca0ce53e 100644 --- a/web/src/components/case-details/criminal/CriminalDocumentsView.vue +++ b/web/src/components/case-details/criminal/CriminalDocumentsView.vue @@ -15,19 +15,11 @@ - - - + :select-all-count="unfilteredDocuments.length" + /> {{ - type === 'keyDocuments' - ? 'Key Documents' - : getCategoryDisplayTitle() + type === 'keyDocuments' ? 'Key Documents' : documentsSectionTitle }} ({{ documentList.length }}) @@ -153,7 +143,17 @@ import { formatDateToDDMMMYYYY } from '@/utils/dateUtils'; import { formatFromFullname } from '@/utils/utils'; import { mdiFileDocumentMultipleOutline } from '@mdi/js'; - import { computed, ref } from 'vue'; + import { computed, ref, watch } from 'vue'; + import { + DEFAULT_OTHER_LABEL, + getActiveSelections, + pruneInvalidSelections, + getSectionTitle, + getUncategorizedCount, + isAllOptionsSelected, + matchesCategorySelection, + } from '@/utils/categoryFilterUtils'; + import ChipMultiSelect from '../common/ChipMultiSelect.vue'; const props = defineProps<{ participants: criminalParticipantType[] }>(); @@ -163,15 +163,41 @@ ); const sortBy = ref([{ key: 'issueDate', order: 'desc' }] as const); const keyDocumentsSortBy = ref([{ key: 'category', order: 'asc' }] as const); - const selectedCategory = ref(); + const selectedCategories = ref([]); const selectedAccused = ref(); + const OTHER_CATEGORY = DEFAULT_OTHER_LABEL; + type CriminalViewDocument = documentType & { + fullName?: string; + fullNameLastFirst?: string; + profSeqNo?: string | number; + id: string; + }; + + const isAllSelected = computed(() => + isAllOptionsSelected( + selectedCategories.value, + documentCategories.value.length + ) + ); + + const activeCategories = computed(() => + getActiveSelections(selectedCategories.value, isAllSelected.value) + ); + + const filterByCategory = (item: CriminalViewDocument) => { + return matchesCategorySelection( + item, + activeCategories.value, + (doc) => doc.category, + { + otherLabel: OTHER_CATEGORY, + } + ); + }; - const filterByCategory = (item: any) => - !selectedCategory.value || - item.category?.toLowerCase() === selectedCategory.value?.toLowerCase(); - const filterByAccused = (item: any) => + const filterByAccused = (item: CriminalViewDocument) => !selectedAccused.value || - (item.fullName && + (!!item.fullName && formatFromFullname(item.fullName) === selectedAccused.value); const unfilteredDocuments = computed( @@ -218,22 +244,67 @@ unfilteredKeyDocuments.value.filter(filterByAccused) ); + const getUncategorizedDocumentCount = (): number => + getUncategorizedCount(unfilteredDocuments.value, (doc) => doc.category); + const categoryCount = (category: string): number => { - return unfilteredDocuments.value.filter((doc) => doc.category === category) - .length; - }; + if (category.toLowerCase() === OTHER_CATEGORY.toLowerCase()) { + return getUncategorizedDocumentCount(); + } - const getCategoryDisplayTitle = (): string => { - return selectedCategory.value ? selectedCategory.value : 'All Documents'; + return unfilteredDocuments.value.filter( + (doc) => doc.category?.trim().toLowerCase() === category.toLowerCase() + ).length; }; - const documentCategories = computed(() => [ - ...new Set( - (unfilteredDocuments.value ?? []) - .filter((doc) => doc.category) - .map((doc) => doc.category) - ), - ]); + const documentsSectionTitle = computed(() => + getSectionTitle(activeCategories.value) + ); + + const documentCategories = computed< + { title: string; value: string; count: number }[] + >(() => { + const uncategorizedDocumentCount = getUncategorizedDocumentCount(); + + return [ + ...new Set( + unfilteredDocuments.value + ?.filter((doc) => doc.category) + .map((doc) => doc.category) || [] + ), + ] + .map((category) => ({ + title: category, + value: category, + count: categoryCount(category), + })) + .concat( + uncategorizedDocumentCount > 0 + ? [ + { + title: OTHER_CATEGORY, + value: OTHER_CATEGORY, + count: uncategorizedDocumentCount, + }, + ] + : [] + ); + }); + + watch( + documentCategories, + (categories) => { + const filteredSelections = pruneInvalidSelections( + selectedCategories.value, + categories.map((category) => category.value) + ); + + if (filteredSelections.length !== selectedCategories.value.length) { + selectedCategories.value = filteredSelections; + } + }, + { immediate: true } + ); const groupBy = ref([ { diff --git a/web/src/utils/categoryFilterUtils.ts b/web/src/utils/categoryFilterUtils.ts new file mode 100644 index 000000000..e97de5676 --- /dev/null +++ b/web/src/utils/categoryFilterUtils.ts @@ -0,0 +1,81 @@ +export const DEFAULT_OTHER_LABEL = 'Other'; +export const DEFAULT_SECTION_TITLE = 'All Documents'; +export const MULTI_SECTION_TITLE = 'Selected Categories'; + +type CategoryValue = string | null | undefined; + +const normalizeCategory = (value: CategoryValue): string => + (value ?? '').trim().toLowerCase(); + +export const isAllOptionsSelected = ( + selectedValues: string[], + totalOptions: number +): boolean => totalOptions > 0 && selectedValues.length === totalOptions; + +export const getActiveSelections = ( + selectedValues: string[], + allSelected: boolean +): string[] => (allSelected ? [] : selectedValues); + +export const pruneInvalidSelections = ( + selectedValues: string[], + validValues: string[] +): string[] => { + const validSet = new Set(validValues); + return selectedValues.filter((value) => validSet.has(value)); +}; + +export const getSectionTitle = ( + activeValues: string[], + mapDisplayTitle?: (value: string) => string +): string => { + if (activeValues.length === 0) { + return DEFAULT_SECTION_TITLE; + } + + if (activeValues.length === 1) { + return mapDisplayTitle ? mapDisplayTitle(activeValues[0]) : activeValues[0]; + } + + return MULTI_SECTION_TITLE; +}; + +export const getUncategorizedCount = ( + items: T[], + getCategory: (item: T) => CategoryValue +): number => + items.filter((item) => !normalizeCategory(getCategory(item))).length; + +export const matchesCategorySelection = ( + item: T, + activeValues: string[], + getCategory: (entry: T) => CategoryValue, + options?: { + otherLabel?: string; + specialPredicates?: Record boolean>; + } +): boolean => { + if (activeValues.length === 0) { + return true; + } + + const normalizedCategory = normalizeCategory(getCategory(item)); + const normalizedOtherLabel = normalizeCategory( + options?.otherLabel ?? DEFAULT_OTHER_LABEL + ); + + return activeValues.some((value) => { + const normalizedValue = normalizeCategory(value); + const specialPredicate = options?.specialPredicates?.[normalizedValue]; + + if (specialPredicate) { + return specialPredicate(item); + } + + if (normalizedValue === normalizedOtherLabel) { + return !normalizedCategory; + } + + return normalizedCategory === normalizedValue; + }); +}; diff --git a/web/tests/components/case-details/civil/CivilDocumentsView.test.ts b/web/tests/components/case-details/civil/CivilDocumentsView.test.ts index 39825aa0d..92deba094 100644 --- a/web/tests/components/case-details/civil/CivilDocumentsView.test.ts +++ b/web/tests/components/case-details/civil/CivilDocumentsView.test.ts @@ -107,7 +107,9 @@ describe('CivilDocumentsView.vue', () => { (mockBinderService.getBinders as Mock).mockResolvedValue([]); expect(wrapper.exists()).toBe(true); - expect(wrapper.find('v-select').exists()).toBe(true); + expect(wrapper.findComponent({ name: 'ChipMultiSelect' }).exists()).toBe( + true + ); expect(wrapper.findComponent({ name: 'JudicialBinder' }).exists()).toBe( true ); @@ -115,7 +117,7 @@ describe('CivilDocumentsView.vue', () => { }); it('filters documents by selected type', async () => { - wrapper.vm.selectedCategory = 'CSR'; + wrapper.vm.selectedCategories = ['CSR']; expect(wrapper.vm.filteredDocuments).toEqual([mockDocuments[0]]); }); @@ -167,6 +169,7 @@ describe('CivilDocumentsView.vue', () => { expect(wrapper.vm.documentCategories[0]).toEqual({ title: 'Scheduled', value: 'Scheduled', + count: 2, }); }); @@ -174,6 +177,7 @@ describe('CivilDocumentsView.vue', () => { expect(wrapper.vm.documentCategories[1]).toEqual({ title: 'Court Summary', value: 'CSR', + count: 1, }); }); @@ -181,6 +185,7 @@ describe('CivilDocumentsView.vue', () => { expect(wrapper.vm.documentCategories[4]).toEqual({ title: 'Affidavits/Financial Stmts', value: 'Affidavits', + count: 1, }); }); @@ -367,7 +372,7 @@ describe('CivilDocumentsView.vue', () => { }); it('does not filter judicial binder documents by category', async () => { - wrapper.vm.selectedCategory = 'CSR'; + wrapper.vm.selectedCategories = ['CSR']; await nextTick(); expect(wrapper.vm.filteredDocuments).toHaveLength(1); @@ -380,7 +385,7 @@ describe('CivilDocumentsView.vue', () => { }); it('filters all documents table by category', async () => { - wrapper.vm.selectedCategory = 'ROP'; + wrapper.vm.selectedCategories = ['ROP']; await nextTick(); expect(wrapper.vm.filteredDocuments).toHaveLength(1); @@ -458,5 +463,30 @@ describe('CivilDocumentsView.vue', () => { it('uses deduplicated documents for category counts', () => { expect(wrapper.vm.categoryCount('CSR')).toBe(1); }); + + it('removes stale selected categories that are no longer valid', async () => { + wrapper.vm.selectedCategories = ['Scheduled']; + + await wrapper.setProps({ + documents: [ + { + civilDocumentId: '10', + category: 'CSR', + documentTypeDescription: 'Civil Document 10', + filedDt: '2024-04-01', + filedBy: [{ roleTypeCode: 'Role10' }], + fileSeqNo: '10', + issue: [{ issueTypeDesc: 'Issue10' }], + documentSupport: [{ actCd: 'Act10' }], + nextAppearanceDt: '', + }, + ], + }); + + await nextTick(); + + expect(wrapper.vm.selectedCategories).toEqual([]); + expect(wrapper.vm.allDocumentsSectionTitle).toBe('All Documents'); + }); }); }); diff --git a/web/tests/components/case-details/civil/documents/AllDocuments.test.ts b/web/tests/components/case-details/civil/documents/AllDocuments.test.ts index 6e32694d2..a711e4c45 100644 --- a/web/tests/components/case-details/civil/documents/AllDocuments.test.ts +++ b/web/tests/components/case-details/civil/documents/AllDocuments.test.ts @@ -31,6 +31,22 @@ describe('AllDocuments.vue', () => { expect(mainEl.exists()).toBe(false); }); + it('renders header when selectedCategory is set even if there are no documents', () => { + const wrapper = mount(AllDocuments, { + props: { + ...mockProps, + documents: [], + hasActiveFilters: true, + }, + }); + + const mainEl = wrapper.find('[data-testid="all-documents-container"]'); + const header = wrapper.find('.text-h5'); + + expect(mainEl.exists()).toBe(true); + expect(header.text()).toContain('All Documents (0)'); + }); + it('renders All Documents', () => { const mockDocuments = [{} as civilDocumentType, {} as civilDocumentType]; mockProps.documents = mockDocuments; @@ -65,43 +81,26 @@ describe('AllDocuments.vue', () => { expect(tableEl.exists()).toBe(true); }); - it('displays "All Documents" when no category is selected', () => { + it('displays sectionTitle when provided', () => { mockProps.documents = [{} as civilDocumentType]; mockProps.binderDocumentIds = []; const wrapper = mount(AllDocuments, { props: { ...mockProps, - selectedCategory: undefined, - }, - }); - - const header = wrapper.find('.text-h5'); - expect(header.text()).toContain('All Documents (1)'); - }); - - it('displays category display title when category is selected', () => { - mockProps.documents = [{} as civilDocumentType]; - const getCategoryDisplayTitle = vi.fn().mockReturnValue('Orders'); - const wrapper = mount(AllDocuments, { - props: { - ...mockProps, - selectedCategory: 'ORDER', - getCategoryDisplayTitle, + sectionTitle: 'Orders', }, }); const header = wrapper.find('.text-h5'); - expect(getCategoryDisplayTitle).toHaveBeenCalledWith('ORDER'); expect(header.text()).toContain('Orders (1)'); }); - it('displays "All Documents" when category is selected but getCategoryDisplayTitle is not provided', () => { + it('displays "All Documents" when sectionTitle is not provided', () => { mockProps.documents = [{} as civilDocumentType]; const wrapper = mount(AllDocuments, { props: { ...mockProps, - selectedCategory: 'ORDER', - getCategoryDisplayTitle: undefined, + sectionTitle: undefined, }, }); @@ -109,42 +108,44 @@ describe('AllDocuments.vue', () => { expect(header.text()).toContain('All Documents (1)'); }); - it('displays scheduled date when selectedCategory is "Scheduled" and nextAppearanceDt exists', () => { + it('displays scheduled date when isScheduledView is true and nextAppearanceDt exists', () => { const mockDocument = { civilDocumentId: '1', documentTypeDescription: 'Test Document', imageId: 'img-123', nextAppearanceDt: '2026-01-15', } as civilDocumentType; - + mockProps.documents = [mockDocument]; const wrapper = mount(AllDocuments, { props: { ...mockProps, - selectedCategory: 'Scheduled', + isScheduledView: true, }, }); - const documentCell = wrapper.find('[data-testid="all-documents-container"]'); + const documentCell = wrapper.find( + '[data-testid="all-documents-container"]' + ); expect(documentCell.exists()).toBe(true); // The scheduled date should be formatted and displayed const tableEl = wrapper.find('v-data-table-virtual'); expect(tableEl.exists()).toBe(true); }); - it('does not display scheduled date when selectedCategory is not "Scheduled"', () => { + it('does not display scheduled date when isScheduledView is false', () => { const mockDocument = { civilDocumentId: '1', documentTypeDescription: 'Test Document', imageId: 'img-123', nextAppearanceDt: '2026-01-15', } as civilDocumentType; - + mockProps.documents = [mockDocument]; const wrapper = mount(AllDocuments, { props: { ...mockProps, - selectedCategory: 'ORDER', + isScheduledView: false, }, }); @@ -158,12 +159,12 @@ describe('AllDocuments.vue', () => { documentTypeDescription: 'Test Document', imageId: 'img-123', } as civilDocumentType; - + mockProps.documents = [mockDocument]; const wrapper = mount(AllDocuments, { props: { ...mockProps, - selectedCategory: 'Scheduled', + isScheduledView: true, }, }); diff --git a/web/tests/components/case-details/common/ChipMultiSelect.test.ts b/web/tests/components/case-details/common/ChipMultiSelect.test.ts new file mode 100644 index 000000000..dc6a1359f --- /dev/null +++ b/web/tests/components/case-details/common/ChipMultiSelect.test.ts @@ -0,0 +1,138 @@ +import ChipMultiSelect from '@/components/case-details/common/ChipMultiSelect.vue'; +import { shallowMount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; + +describe('ChipMultiSelect.vue', () => { + const baseProps = { + modelValue: [] as string[], + items: [ + { title: 'Option A', value: 'a', count: 2 }, + { title: 'Option B', value: 'b' }, + { title: 'Option C', value: 'c', count: 0 }, + ], + }; + + it('uses generic defaults for select text', () => { + const wrapper = shallowMount(ChipMultiSelect, { + props: baseProps, + }); + + expect(wrapper.vm.getSelectAllTitle()).toBe('Select All'); + }); + + it('includes select all count when provided', () => { + const wrapper = shallowMount(ChipMultiSelect, { + props: { + ...baseProps, + selectAllCount: 12, + }, + }); + + expect(wrapper.vm.getSelectAllTitle()).toBe('Select All (12)'); + }); + + it('supports overriding select-all label', () => { + const wrapper = shallowMount(ChipMultiSelect, { + props: { + ...baseProps, + selectAllLabel: 'Select everything', + }, + }); + + expect(wrapper.vm.getSelectAllTitle()).toBe('Select everything'); + }); + + it('formats item title with count only when count is present', () => { + const wrapper = shallowMount(ChipMultiSelect, { + props: baseProps, + }); + + expect(wrapper.vm.getItemTitle(baseProps.items[0])).toBe('Option A (2)'); + expect(wrapper.vm.getItemTitle(baseProps.items[1])).toBe('Option B'); + expect(wrapper.vm.getItemTitle(baseProps.items[2])).toBe('Option C (0)'); + }); + + it('emits an empty selection when update value is undefined', () => { + const wrapper = shallowMount(ChipMultiSelect, { + props: baseProps, + }); + + wrapper.vm.onUpdate(undefined as unknown as string[]); + + expect(wrapper.emitted('update:modelValue')).toEqual([[[]]]); + }); + + it('selects all values when toggleSelectAll is called and not all selected', () => { + const wrapper = shallowMount(ChipMultiSelect, { + props: baseProps, + }); + + wrapper.vm.toggleSelectAll(); + + expect(wrapper.emitted('update:modelValue')).toEqual([[['a', 'b', 'c']]]); + }); + + it('clears values when toggleSelectAll is called and all selected', () => { + const wrapper = shallowMount(ChipMultiSelect, { + props: { + ...baseProps, + modelValue: ['a', 'b', 'c'], + }, + }); + + wrapper.vm.toggleSelectAll(); + + expect(wrapper.emitted('update:modelValue')).toEqual([[[]]]); + }); + + it('removeValue emits model without removed option', () => { + const wrapper = shallowMount(ChipMultiSelect, { + props: { + ...baseProps, + modelValue: ['a', 'b'], + }, + }); + + wrapper.vm.removeValue('a'); + + expect(wrapper.emitted('update:modelValue')).toEqual([[['b']]]); + }); + + it('toggleValue removes value when selected', () => { + const wrapper = shallowMount(ChipMultiSelect, { + props: { + ...baseProps, + modelValue: ['b'], + }, + }); + + wrapper.vm.toggleValue('b'); + + expect(wrapper.emitted('update:modelValue')).toEqual([[[]]]); + }); + + it('toggleValue adds value when not selected', () => { + const wrapper = shallowMount(ChipMultiSelect, { + props: { + ...baseProps, + modelValue: ['a'], + }, + }); + + wrapper.vm.toggleValue('c'); + + expect(wrapper.emitted('update:modelValue')).toEqual([[['a', 'c']]]); + }); + + it('detects selected values through isSelected', () => { + const wrapper = shallowMount(ChipMultiSelect, { + props: { + ...baseProps, + modelValue: ['b'], + }, + }); + + expect(wrapper.vm.isSelected('b')).toBe(true); + expect(wrapper.vm.isSelected('a')).toBe(false); + }); +}); diff --git a/web/tests/components/case-details/criminal/CriminalDocumentsView.test.ts b/web/tests/components/case-details/criminal/CriminalDocumentsView.test.ts index 447944735..817beced5 100644 --- a/web/tests/components/case-details/criminal/CriminalDocumentsView.test.ts +++ b/web/tests/components/case-details/criminal/CriminalDocumentsView.test.ts @@ -142,14 +142,12 @@ describe('CriminalDocumentsView.vue', () => { }); it('computes unfilteredDocuments correctly', async () => { - wrapper.vm.selectedCategory = 'ROP'; - const unfilteredDocuments = wrapper.vm.unfilteredDocuments; expect(unfilteredDocuments).toHaveLength(2); }); it('filters documents by category', async () => { - wrapper.vm.selectedCategory = 'bail'; + wrapper.vm.selectedCategories = ['bail']; const documents = wrapper.vm.documents; expect(documents).toHaveLength(1); @@ -173,7 +171,7 @@ describe('CriminalDocumentsView.vue', () => { }, }); - (wrapper.vm as any).selectedCategory = 'bail'; + (wrapper.vm as any).selectedCategories = ['bail']; await nextTick(); const documents = (wrapper.vm as any).documents; @@ -554,7 +552,7 @@ describe('CriminalDocumentsView.vue', () => { }); it('displays original category name when no special formatting applies', async () => { - wrapper.vm.selectedCategory = 'bail'; + wrapper.vm.selectedCategories = ['bail']; await nextTick(); const sections = wrapper.findAll('v-card-text .text-h5'); @@ -648,7 +646,7 @@ describe('CriminalDocumentsView.vue', () => { expect(documentIds[2]).toContain('145'); // Filter to only show transcripts - wrapper.vm.selectedCategory = 'Transcript'; + wrapper.vm.selectedCategories = ['Transcript']; await nextTick(); const filteredDocuments = wrapper.vm.documents; @@ -705,4 +703,35 @@ describe('CriminalDocumentsView.vue', () => { expect(documentIds[1]).toContain('IMG-002'); }); }); + + it('removes stale selected categories that are no longer valid', async () => { + wrapper.vm.selectedCategories = ['bail']; + + await wrapper.setProps({ + participants: [ + { + fullName: 'New Person', + lastNm: 'Person', + givenNm: 'New', + profSeqNo: 99, + keyDocuments: [], + document: [ + { + issueDate: '2023-09-01', + documentTypeDescription: 'Only Transcript', + category: 'Transcript', + documentPageCount: 1, + imageId: '999', + docmDispositionDsc: 'Disposition', + }, + ], + }, + ], + }); + + await nextTick(); + + expect(wrapper.vm.selectedCategories).toEqual([]); + expect(wrapper.vm.documentsSectionTitle).toBe('All Documents'); + }); }); diff --git a/web/tests/utils/categoryFilterUtils.test.ts b/web/tests/utils/categoryFilterUtils.test.ts new file mode 100644 index 000000000..554dc0c7a --- /dev/null +++ b/web/tests/utils/categoryFilterUtils.test.ts @@ -0,0 +1,131 @@ +import { + DEFAULT_SECTION_TITLE, + MULTI_SECTION_TITLE, + getSectionTitle, + getUncategorizedCount, + matchesCategorySelection, + pruneInvalidSelections, +} from '@/utils/categoryFilterUtils'; +import { describe, expect, it } from 'vitest'; + +describe('categoryFilterUtils', () => { + describe('pruneInvalidSelections', () => { + it('keeps only selections present in valid values', () => { + const result = pruneInvalidSelections( + ['Scheduled', 'Other', 'Missing'], + ['Scheduled', 'CSR', 'Other'] + ); + + expect(result).toEqual(['Scheduled', 'Other']); + }); + + it('returns empty when no selections are valid', () => { + const result = pruneInvalidSelections(['A', 'B'], ['X', 'Y']); + expect(result).toEqual([]); + }); + }); + + describe('getSectionTitle', () => { + it('returns default title when there are no active values', () => { + expect(getSectionTitle([])).toBe(DEFAULT_SECTION_TITLE); + }); + + it('returns mapped display title for a single selection when mapper is provided', () => { + const mapDisplayTitle = (value: string) => + value === 'CSR' ? 'Court Summary' : value; + + expect(getSectionTitle(['CSR'], mapDisplayTitle)).toBe('Court Summary'); + }); + + it('returns single raw value when mapper is not provided', () => { + expect(getSectionTitle(['Transcript'])).toBe('Transcript'); + }); + + it('returns multi-selection title when multiple values are active', () => { + expect(getSectionTitle(['CSR', 'Transcript'])).toBe(MULTI_SECTION_TITLE); + }); + }); + + describe('getUncategorizedCount', () => { + it('counts null, undefined, empty and whitespace-only categories as uncategorized', () => { + const items = [ + { category: null }, + { category: undefined }, + { category: '' }, + { category: ' ' }, + { category: 'CSR' }, + ]; + + const count = getUncategorizedCount(items, (item) => item.category); + expect(count).toBe(4); + }); + }); + + describe('matchesCategorySelection', () => { + const getCategory = (item: { category?: string | null }) => item.category; + + it('returns true when no active filters are selected', () => { + expect( + matchesCategorySelection({ category: 'CSR' }, [], getCategory) + ).toBe(true); + }); + + it('matches selected category case-insensitively and ignores whitespace', () => { + expect( + matchesCategorySelection({ category: ' csr ' }, ['CSR'], getCategory) + ).toBe(true); + }); + + it('matches Other against uncategorized items', () => { + expect( + matchesCategorySelection({ category: ' ' }, ['Other'], getCategory) + ).toBe(true); + }); + + it('does not match Other when item has a real category', () => { + expect( + matchesCategorySelection({ category: 'CSR' }, ['Other'], getCategory) + ).toBe(false); + }); + + it('supports custom other label', () => { + expect( + matchesCategorySelection({ category: '' }, ['Misc'], getCategory, { + otherLabel: 'Misc', + }) + ).toBe(true); + }); + + it('uses special predicates when provided', () => { + const item = { category: 'CSR', nextAppearanceDt: '2025-01-01' }; + const result = matchesCategorySelection( + item, + ['Scheduled'], + (doc) => doc.category, + { + specialPredicates: { + scheduled: (doc) => !!doc.nextAppearanceDt, + }, + } + ); + + expect(result).toBe(true); + }); + + it('falls back to category matching when special predicate is not available', () => { + const item = { category: 'Transcript', nextAppearanceDt: '' }; + const result = matchesCategorySelection( + item, + ['Transcript', 'Scheduled'], + (doc) => doc.category, + { + specialPredicates: { + scheduled: (doc) => !!doc.nextAppearanceDt, + }, + } + ); + + expect(result).toBe(true); + }); + }); +});