From a085bb12d47c9340b63af201fc349798c4c2cb33 Mon Sep 17 00:00:00 2001 From: devinleighsmith Date: Wed, 22 Apr 2026 21:35:49 -0700 Subject: [PATCH 1/4] jasper-625 allow multi-select of document categories with chips. --- .../case-details/civil/CivilDocumentsView.vue | 125 +++++++++++++----- .../civil/documents/AllDocuments.vue | 11 +- .../criminal/CriminalDocumentsView.vue | 109 ++++++++++----- .../civil/CivilDocumentsView.test.ts | 13 +- .../civil/documents/AllDocuments.test.ts | 26 +++- .../criminal/CriminalDocumentsView.test.ts | 10 +- 6 files changed, 210 insertions(+), 84 deletions(-) diff --git a/web/src/components/case-details/civil/CivilDocumentsView.vue b/web/src/components/case-details/civil/CivilDocumentsView.vue index 200b895df..a162d1eab 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="props.documents.length" + /> ([]); const selectedBinderItems = ref([]); @@ -171,14 +164,44 @@ 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(() => { + return ( + documentCategories.value.length > 0 && + selectedCategories.value.length === documentCategories.value.length + ); + }); + const activeCategories = computed(() => + isAllSelected.value ? [] : selectedCategories.value + ); + const isScheduledOnlySelected = computed( + () => + activeCategories.value.length === 1 && + activeCategories.value[0] === SCHEDULED_CATEGORY + ); + const allDocumentsSectionTitle = computed(() => { + if (activeCategories.value.length === 0) { + return 'All Documents'; + } + + if (activeCategories.value.length === 1) { + return getCategoryDisplayTitle(activeCategories.value[0]); + } + + return 'Selected Categories'; + }); const sortBy = computed<[{ key: string; order: 'desc' | 'asc' }]>(() => { return selectedCategory?.value === CSR_CATEGORY ? [{ key: 'filedDt', order: 'desc' as const }] @@ -187,7 +210,7 @@ const headers = computed(() => { const baseHeaders = shared.getBaseCivilDocumentTableHeaders( - selectedCategory.value === SCHEDULED_CATEGORY + isScheduledOnlySelected.value ); return [ @@ -216,9 +239,20 @@ return categoryMap[category] || category; }; - const documentCategories = computed<{ title: string; value: string }[]>(() => + const getUncategorizedDocumentCount = (): number => + uniqueDocuments.value.filter((doc) => !doc.category?.trim()).length; + + const documentCategories = computed< + { title: string; value: string; count: number }[] + >(() => (scheduledDocuments.value.length > 0 - ? [{ title: SCHEDULED_CATEGORY, value: SCHEDULED_CATEGORY }] + ? [ + { + title: SCHEDULED_CATEGORY, + value: SCHEDULED_CATEGORY, + count: categoryCount(SCHEDULED_CATEGORY), + }, + ] : [] ).concat( [ @@ -227,28 +261,41 @@ .filter((d) => d.category) .map((doc) => doc.category) ), - ].map((category) => ({ - title: getCategoryDisplayTitle(category), - value: category, - })) + ] + .map((category) => ({ + title: getCategoryDisplayTitle(category), + value: category, + count: categoryCount(category), + })) + .concat([ + { + title: OTHER_CATEGORY, + value: OTHER_CATEGORY, + count: getUncategorizedDocumentCount(), + }, + ]) ) ); const filterByCategory = (item: civilDocumentType) => { - const category = selectedCategory.value?.toLowerCase(); - if (!category) { + if (activeCategories.value.length === 0) { return true; } - if (category === SCHEDULED_CATEGORY.toLowerCase()) { - return !!item.nextAppearanceDt; - } + const normalizedItemCategory = item.category?.trim().toLowerCase(); + return activeCategories.value.some((category) => { + const normalizedCategory = category.toLowerCase(); - if (category === CSR_CATEGORY_DESC.toLowerCase()) { - return item.category === CSR_CATEGORY; - } + if (normalizedCategory === SCHEDULED_CATEGORY.toLowerCase()) { + return !!item.nextAppearanceDt; + } + + if (normalizedCategory === OTHER_CATEGORY.toLowerCase()) { + return !normalizedItemCategory; + } - return item.category?.toLowerCase() === category; + return normalizedItemCategory === normalizedCategory; + }); }; const filteredDocuments = computed(() => @@ -278,14 +325,22 @@ return uniqueDocuments.value.filter((doc) => doc.nextAppearanceDt).length; } - if (category.toLowerCase() === CSR_CATEGORY_DESC.toLowerCase()) { + if ( + category.toLowerCase() === CSR_CATEGORY_DESC.toLowerCase() || + category.toLowerCase() === CSR_CATEGORY.toLowerCase() + ) { return uniqueDocuments.value.filter( - (doc) => doc.category === CSR_CATEGORY + (doc) => + doc.category?.trim().toLowerCase() === CSR_CATEGORY.toLowerCase() ).length; } + if (category.toLowerCase() === OTHER_CATEGORY.toLowerCase()) { + return getUncategorizedDocumentCount(); + } + return uniqueDocuments.value.filter( - (doc) => doc.category?.toLowerCase() === category.toLowerCase() + (doc) => doc.category?.trim().toLowerCase() === category.toLowerCase() ).length; }; diff --git a/web/src/components/case-details/civil/documents/AllDocuments.vue b/web/src/components/case-details/civil/documents/AllDocuments.vue index 7a18afe48..05ab8d31b 100644 --- a/web/src/components/case-details/civil/documents/AllDocuments.vue +++ b/web/src/components/case-details/civil/documents/AllDocuments.vue @@ -4,15 +4,17 @@ color="var(--bg-gray-500)" elevation="0" data-testid="all-documents-container" - v-if="documents?.length > 0" + v-if="documents?.length > 0 || props.selectedCategory" > {{ - props.selectedCategory && props.getCategoryDisplayTitle - ? props.getCategoryDisplayTitle(props.selectedCategory) - : 'All Documents' + props.sectionTitle + ? props.sectionTitle + : props.selectedCategory && props.getCategoryDisplayTitle + ? props.getCategoryDisplayTitle(props.selectedCategory) + : 'All Documents' }} ({{ documents.length }}) @@ -105,6 +107,7 @@ binderDocumentIds: string[]; addDocumentToBinder: (document: civilDocumentType) => void; selectedCategory?: string; + sectionTitle?: string; sortBy?: [{ key: string; order: 'asc' | 'desc' }]; getCategoryDisplayTitle?: (category: string) => string; openIndividualDocument: (data: civilDocumentType) => void; diff --git a/web/src/components/case-details/criminal/CriminalDocumentsView.vue b/web/src/components/case-details/criminal/CriminalDocumentsView.vue index 811b97fd9..d1c0c14d4 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 }}) @@ -154,6 +144,7 @@ import { formatFromFullname } from '@/utils/utils'; import { mdiFileDocumentMultipleOutline } from '@mdi/js'; import { computed, ref } from 'vue'; + import ChipMultiSelect from '../common/ChipMultiSelect.vue'; const props = defineProps<{ participants: criminalParticipantType[] }>(); @@ -163,12 +154,36 @@ ); 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 = 'Other'; + + const isAllSelected = computed(() => { + return ( + documentCategories.value.length > 0 && + selectedCategories.value.length === documentCategories.value.length + ); + }); + + const activeCategories = computed(() => + isAllSelected.value ? [] : selectedCategories.value + ); + + const filterByCategory = (item: any) => { + if (activeCategories.value.length === 0) { + return true; + } + + const normalizedItemCategory = item.category?.trim().toLowerCase(); + return activeCategories.value.some((category) => { + if (category.toLowerCase() === OTHER_CATEGORY.toLowerCase()) { + return !normalizedItemCategory; + } + + return normalizedItemCategory === category.toLowerCase(); + }); + }; - const filterByCategory = (item: any) => - !selectedCategory.value || - item.category?.toLowerCase() === selectedCategory.value?.toLowerCase(); const filterByAccused = (item: any) => !selectedAccused.value || (item.fullName && @@ -218,22 +233,54 @@ unfilteredKeyDocuments.value.filter(filterByAccused) ); + const getUncategorizedDocumentCount = (): number => + unfilteredDocuments.value.filter((doc) => !doc.category?.trim()).length; + 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(() => { + if (activeCategories.value.length === 0) { + return 'All Documents'; + } + + if (activeCategories.value.length === 1) { + return activeCategories.value[0]; + } + + return 'Selected Categories'; + }); + + const documentCategories = computed< + { title: string; value: string; count: number }[] + >(() => + [ + ...new Set( + unfilteredDocuments.value + ?.filter((doc) => doc.category) + .map((doc) => doc.category) || [] + ), + ] + .map((category) => ({ + title: category, + value: category, + count: categoryCount(category), + })) + .concat([ + { + title: OTHER_CATEGORY, + value: OTHER_CATEGORY, + count: getUncategorizedDocumentCount(), + }, + ]) + ); const groupBy = ref([ { diff --git a/web/tests/components/case-details/civil/CivilDocumentsView.test.ts b/web/tests/components/case-details/civil/CivilDocumentsView.test.ts index 39825aa0d..f6cd3f100 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); 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..c0585cfe6 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: [], + selectedCategory: 'Scheduled', + }, + }); + + 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; @@ -116,7 +132,7 @@ describe('AllDocuments.vue', () => { imageId: 'img-123', nextAppearanceDt: '2026-01-15', } as civilDocumentType; - + mockProps.documents = [mockDocument]; const wrapper = mount(AllDocuments, { props: { @@ -125,7 +141,9 @@ describe('AllDocuments.vue', () => { }, }); - 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'); @@ -139,7 +157,7 @@ describe('AllDocuments.vue', () => { imageId: 'img-123', nextAppearanceDt: '2026-01-15', } as civilDocumentType; - + mockProps.documents = [mockDocument]; const wrapper = mount(AllDocuments, { props: { @@ -158,7 +176,7 @@ describe('AllDocuments.vue', () => { documentTypeDescription: 'Test Document', imageId: 'img-123', } as civilDocumentType; - + mockProps.documents = [mockDocument]; const wrapper = mount(AllDocuments, { props: { diff --git a/web/tests/components/case-details/criminal/CriminalDocumentsView.test.ts b/web/tests/components/case-details/criminal/CriminalDocumentsView.test.ts index 447944735..935242e40 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; From 5e1e560e8dacaa9a0ccd4721564669cb98555b95 Mon Sep 17 00:00:00 2001 From: devinleighsmith Date: Wed, 22 Apr 2026 22:08:41 -0700 Subject: [PATCH 2/4] PR cleanup. --- .../case-details/civil/CivilDocumentsView.vue | 177 ++++++++++-------- .../civil/documents/AllDocuments.vue | 19 +- .../case-details/common/ChipMultiSelect.vue | 152 +++++++++++++++ .../common/categoryFilterUtils.ts | 73 ++++++++ .../criminal/CriminalDocumentsView.vue | 96 +++++----- .../civil/CivilDocumentsView.test.ts | 25 +++ .../civil/documents/AllDocuments.test.ts | 37 +--- .../common/ChipMultiSelect.test.ts | 138 ++++++++++++++ 8 files changed, 551 insertions(+), 166 deletions(-) create mode 100644 web/src/components/case-details/common/ChipMultiSelect.vue create mode 100644 web/src/components/case-details/common/categoryFilterUtils.ts create mode 100644 web/tests/components/case-details/common/ChipMultiSelect.test.ts diff --git a/web/src/components/case-details/civil/CivilDocumentsView.vue b/web/src/components/case-details/civil/CivilDocumentsView.vue index a162d1eab..3ac35eb5e 100644 --- a/web/src/components/case-details/civil/CivilDocumentsView.vue +++ b/web/src/components/case-details/civil/CivilDocumentsView.vue @@ -33,10 +33,10 @@ :openIndividualDocument :selectedItems :binderDocumentIds="currentBinder?.documents.map((d) => d.documentId) ?? []" - :selectedCategory="selectedCategory" + :hasActiveFilters="activeCategories.length > 0" + :isScheduledView="isScheduledOnlySelected" :sectionTitle="allDocumentsSectionTitle" :sortBy - :getCategoryDisplayTitle="getCategoryDisplayTitle" @update:selectedItems="(val) => (selectedItems = val)" /> @@ -112,12 +112,20 @@ DocumentRequestType, } from '@/types/shared'; import { getCourtClassStyle, getRoles } from '@/utils/utils'; + import { + DEFAULT_OTHER_LABEL, + getActiveSelections, + getSectionTitle, + getUncategorizedCount, + isAllOptionsSelected, + matchesCategorySelection, + } from '../common/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'; @@ -140,7 +148,7 @@ const CSR_CATEGORY_DESC = 'Court Summary'; const AFF_FIN_STMT = 'Affidavits/Financial Stmts'; const LITIGANT = 'Litigant'; - const OTHER_CATEGORY = 'Other'; + const OTHER_CATEGORY = DEFAULT_OTHER_LABEL; const selectedItems = ref([]); const selectedBinderItems = ref([]); @@ -177,31 +185,23 @@ ? selectedCategories.value[0] : undefined; }); - const isAllSelected = computed(() => { - return ( - documentCategories.value.length > 0 && - selectedCategories.value.length === documentCategories.value.length - ); - }); + const isAllSelected = computed(() => + isAllOptionsSelected( + selectedCategories.value, + documentCategories.value.length + ) + ); const activeCategories = computed(() => - isAllSelected.value ? [] : selectedCategories.value + getActiveSelections(selectedCategories.value, isAllSelected.value) ); const isScheduledOnlySelected = computed( () => activeCategories.value.length === 1 && activeCategories.value[0] === SCHEDULED_CATEGORY ); - const allDocumentsSectionTitle = computed(() => { - if (activeCategories.value.length === 0) { - return 'All Documents'; - } - - if (activeCategories.value.length === 1) { - return getCategoryDisplayTitle(activeCategories.value[0]); - } - - return 'Selected Categories'; - }); + 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 }] @@ -240,20 +240,47 @@ }; const getUncategorizedDocumentCount = (): number => - uniqueDocuments.value.filter((doc) => !doc.category?.trim()).length; + 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 }[] - >(() => - (scheduledDocuments.value.length > 0 - ? [ - { - title: SCHEDULED_CATEGORY, - value: SCHEDULED_CATEGORY, - count: categoryCount(SCHEDULED_CATEGORY), - }, - ] - : [] + >(() => { + const uncategorizedDocumentCount = getUncategorizedDocumentCount(); + + return ( + scheduledDocuments.value.length > 0 + ? [ + { + title: SCHEDULED_CATEGORY, + value: SCHEDULED_CATEGORY, + count: categoryCount(SCHEDULED_CATEGORY), + }, + ] + : [] ).concat( [ ...new Set( @@ -267,35 +294,47 @@ value: category, count: categoryCount(category), })) - .concat([ - { - title: OTHER_CATEGORY, - value: OTHER_CATEGORY, - count: getUncategorizedDocumentCount(), - }, - ]) - ) - ); - - const filterByCategory = (item: civilDocumentType) => { - if (activeCategories.value.length === 0) { - return true; - } + .concat( + uncategorizedDocumentCount > 0 + ? [ + { + title: OTHER_CATEGORY, + value: OTHER_CATEGORY, + count: uncategorizedDocumentCount, + }, + ] + : [] + ) + ); + }); - const normalizedItemCategory = item.category?.trim().toLowerCase(); - return activeCategories.value.some((category) => { - const normalizedCategory = category.toLowerCase(); + watch( + documentCategories, + (categories) => { + const validValues = new Set(categories.map((category) => category.value)); + const filteredSelections = selectedCategories.value.filter((value) => + validValues.has(value) + ); - if (normalizedCategory === SCHEDULED_CATEGORY.toLowerCase()) { - return !!item.nextAppearanceDt; + if (filteredSelections.length !== selectedCategories.value.length) { + selectedCategories.value = filteredSelections; } + }, + { immediate: true } + ); - if (normalizedCategory === OTHER_CATEGORY.toLowerCase()) { - return !normalizedItemCategory; + const filterByCategory = (item: civilDocumentType) => { + return matchesCategorySelection( + item, + activeCategories.value, + (doc) => doc.category, + { + otherLabel: OTHER_CATEGORY, + specialPredicates: { + [SCHEDULED_CATEGORY.toLowerCase()]: (doc) => !!doc.nextAppearanceDt, + }, } - - return normalizedItemCategory === normalizedCategory; - }); + ); }; const filteredDocuments = computed(() => @@ -320,30 +359,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() || - 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; - }; - 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 05ab8d31b..6626c01fc 100644 --- a/web/src/components/case-details/civil/documents/AllDocuments.vue +++ b/web/src/components/case-details/civil/documents/AllDocuments.vue @@ -4,18 +4,12 @@ color="var(--bg-gray-500)" elevation="0" data-testid="all-documents-container" - v-if="documents?.length > 0 || props.selectedCategory" + v-if="documents?.length > 0 || props.hasActiveFilters" > - {{ - props.sectionTitle - ? props.sectionTitle - : props.selectedCategory && props.getCategoryDisplayTitle - ? props.getCategoryDisplayTitle(props.selectedCategory) - : 'All Documents' - }} + {{ props.sectionTitle || 'All Documents' }} ({{ documents.length }}) @@ -55,10 +49,7 @@ {{ item.documentTypeDescription }} - +
Date Filed: {{ formatDateToDDMMMYYYY(item.filedDt) }}
@@ -106,10 +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/common/categoryFilterUtils.ts b/web/src/components/case-details/common/categoryFilterUtils.ts new file mode 100644 index 000000000..72423279e --- /dev/null +++ b/web/src/components/case-details/common/categoryFilterUtils.ts @@ -0,0 +1,73 @@ +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 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/src/components/case-details/criminal/CriminalDocumentsView.vue b/web/src/components/case-details/criminal/CriminalDocumentsView.vue index d1c0c14d4..224a90179 100644 --- a/web/src/components/case-details/criminal/CriminalDocumentsView.vue +++ b/web/src/components/case-details/criminal/CriminalDocumentsView.vue @@ -144,6 +144,14 @@ import { formatFromFullname } from '@/utils/utils'; import { mdiFileDocumentMultipleOutline } from '@mdi/js'; import { computed, ref } from 'vue'; + import { + DEFAULT_OTHER_LABEL, + getActiveSelections, + getSectionTitle, + getUncategorizedCount, + isAllOptionsSelected, + matchesCategorySelection, + } from '../common/categoryFilterUtils'; import ChipMultiSelect from '../common/ChipMultiSelect.vue'; const props = defineProps<{ participants: criminalParticipantType[] }>(); @@ -156,37 +164,39 @@ const keyDocumentsSortBy = ref([{ key: 'category', order: 'asc' }] as const); const selectedCategories = ref([]); const selectedAccused = ref(); - const OTHER_CATEGORY = 'Other'; + const OTHER_CATEGORY = DEFAULT_OTHER_LABEL; + type CriminalViewDocument = documentType & { + fullName?: string; + fullNameLastFirst?: string; + profSeqNo?: string | number; + id: string; + }; - const isAllSelected = computed(() => { - return ( - documentCategories.value.length > 0 && - selectedCategories.value.length === documentCategories.value.length - ); - }); + const isAllSelected = computed(() => + isAllOptionsSelected( + selectedCategories.value, + documentCategories.value.length + ) + ); const activeCategories = computed(() => - isAllSelected.value ? [] : selectedCategories.value + getActiveSelections(selectedCategories.value, isAllSelected.value) ); - const filterByCategory = (item: any) => { - if (activeCategories.value.length === 0) { - return true; - } - - const normalizedItemCategory = item.category?.trim().toLowerCase(); - return activeCategories.value.some((category) => { - if (category.toLowerCase() === OTHER_CATEGORY.toLowerCase()) { - return !normalizedItemCategory; + const filterByCategory = (item: CriminalViewDocument) => { + return matchesCategorySelection( + item, + activeCategories.value, + (doc) => doc.category, + { + otherLabel: OTHER_CATEGORY, } - - return normalizedItemCategory === category.toLowerCase(); - }); + ); }; - const filterByAccused = (item: any) => + const filterByAccused = (item: CriminalViewDocument) => !selectedAccused.value || - (item.fullName && + (!!item.fullName && formatFromFullname(item.fullName) === selectedAccused.value); const unfilteredDocuments = computed( @@ -234,7 +244,7 @@ ); const getUncategorizedDocumentCount = (): number => - unfilteredDocuments.value.filter((doc) => !doc.category?.trim()).length; + getUncategorizedCount(unfilteredDocuments.value, (doc) => doc.category); const categoryCount = (category: string): number => { if (category.toLowerCase() === OTHER_CATEGORY.toLowerCase()) { @@ -246,22 +256,16 @@ ).length; }; - const documentsSectionTitle = computed(() => { - if (activeCategories.value.length === 0) { - return 'All Documents'; - } - - if (activeCategories.value.length === 1) { - return activeCategories.value[0]; - } - - return 'Selected Categories'; - }); + 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) @@ -273,14 +277,18 @@ value: category, count: categoryCount(category), })) - .concat([ - { - title: OTHER_CATEGORY, - value: OTHER_CATEGORY, - count: getUncategorizedDocumentCount(), - }, - ]) - ); + .concat( + uncategorizedDocumentCount > 0 + ? [ + { + title: OTHER_CATEGORY, + value: OTHER_CATEGORY, + count: uncategorizedDocumentCount, + }, + ] + : [] + ); + }); const groupBy = ref([ { diff --git a/web/tests/components/case-details/civil/CivilDocumentsView.test.ts b/web/tests/components/case-details/civil/CivilDocumentsView.test.ts index f6cd3f100..92deba094 100644 --- a/web/tests/components/case-details/civil/CivilDocumentsView.test.ts +++ b/web/tests/components/case-details/civil/CivilDocumentsView.test.ts @@ -463,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 c0585cfe6..a711e4c45 100644 --- a/web/tests/components/case-details/civil/documents/AllDocuments.test.ts +++ b/web/tests/components/case-details/civil/documents/AllDocuments.test.ts @@ -36,7 +36,7 @@ describe('AllDocuments.vue', () => { props: { ...mockProps, documents: [], - selectedCategory: 'Scheduled', + hasActiveFilters: true, }, }); @@ -81,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, + sectionTitle: 'Orders', }, }); 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, - }, - }); - - 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, }, }); @@ -125,7 +108,7 @@ 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', @@ -137,7 +120,7 @@ describe('AllDocuments.vue', () => { const wrapper = mount(AllDocuments, { props: { ...mockProps, - selectedCategory: 'Scheduled', + isScheduledView: true, }, }); @@ -150,7 +133,7 @@ describe('AllDocuments.vue', () => { 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', @@ -162,7 +145,7 @@ describe('AllDocuments.vue', () => { const wrapper = mount(AllDocuments, { props: { ...mockProps, - selectedCategory: 'ORDER', + isScheduledView: false, }, }); @@ -181,7 +164,7 @@ describe('AllDocuments.vue', () => { 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); + }); +}); From 1e84940638c4e25e4f75a5ae8425fb1862db14e1 Mon Sep 17 00:00:00 2001 From: devinleighsmith Date: Wed, 22 Apr 2026 22:23:40 -0700 Subject: [PATCH 3/4] clean up criminal logic such that changed documents/categories are cleaned properly. --- .../case-details/civil/CivilDocumentsView.vue | 9 +- .../criminal/CriminalDocumentsView.vue | 20 ++- .../common => utils}/categoryFilterUtils.ts | 8 ++ .../criminal/CriminalDocumentsView.test.ts | 31 +++++ web/tests/utils/categoryFilterUtils.test.ts | 131 ++++++++++++++++++ 5 files changed, 193 insertions(+), 6 deletions(-) rename web/src/{components/case-details/common => utils}/categoryFilterUtils.ts (90%) create mode 100644 web/tests/utils/categoryFilterUtils.test.ts diff --git a/web/src/components/case-details/civil/CivilDocumentsView.vue b/web/src/components/case-details/civil/CivilDocumentsView.vue index 3ac35eb5e..d227fd48d 100644 --- a/web/src/components/case-details/civil/CivilDocumentsView.vue +++ b/web/src/components/case-details/civil/CivilDocumentsView.vue @@ -115,11 +115,12 @@ import { DEFAULT_OTHER_LABEL, getActiveSelections, + pruneInvalidSelections, getSectionTitle, getUncategorizedCount, isAllOptionsSelected, matchesCategorySelection, - } from '../common/categoryFilterUtils'; + } from '@/utils/categoryFilterUtils'; import { mdiFileDocumentMultipleOutline, mdiNotebookOutline, @@ -311,9 +312,9 @@ watch( documentCategories, (categories) => { - const validValues = new Set(categories.map((category) => category.value)); - const filteredSelections = selectedCategories.value.filter((value) => - validValues.has(value) + const filteredSelections = pruneInvalidSelections( + selectedCategories.value, + categories.map((category) => category.value) ); if (filteredSelections.length !== selectedCategories.value.length) { diff --git a/web/src/components/case-details/criminal/CriminalDocumentsView.vue b/web/src/components/case-details/criminal/CriminalDocumentsView.vue index 224a90179..3ca0ce53e 100644 --- a/web/src/components/case-details/criminal/CriminalDocumentsView.vue +++ b/web/src/components/case-details/criminal/CriminalDocumentsView.vue @@ -143,15 +143,16 @@ 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 '../common/categoryFilterUtils'; + } from '@/utils/categoryFilterUtils'; import ChipMultiSelect from '../common/ChipMultiSelect.vue'; const props = defineProps<{ participants: criminalParticipantType[] }>(); @@ -290,6 +291,21 @@ ); }); + 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([ { key: 'fullName', diff --git a/web/src/components/case-details/common/categoryFilterUtils.ts b/web/src/utils/categoryFilterUtils.ts similarity index 90% rename from web/src/components/case-details/common/categoryFilterUtils.ts rename to web/src/utils/categoryFilterUtils.ts index 72423279e..e97de5676 100644 --- a/web/src/components/case-details/common/categoryFilterUtils.ts +++ b/web/src/utils/categoryFilterUtils.ts @@ -17,6 +17,14 @@ export const getActiveSelections = ( 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 diff --git a/web/tests/components/case-details/criminal/CriminalDocumentsView.test.ts b/web/tests/components/case-details/criminal/CriminalDocumentsView.test.ts index 935242e40..817beced5 100644 --- a/web/tests/components/case-details/criminal/CriminalDocumentsView.test.ts +++ b/web/tests/components/case-details/criminal/CriminalDocumentsView.test.ts @@ -703,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); + }); + }); +}); From 2690688cb7a6dc4a4c985445052c3f7ac9c926fb Mon Sep 17 00:00:00 2001 From: devinleighsmith Date: Wed, 22 Apr 2026 22:27:03 -0700 Subject: [PATCH 4/4] ensure document count applied consistently. --- web/src/components/case-details/civil/CivilDocumentsView.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/case-details/civil/CivilDocumentsView.vue b/web/src/components/case-details/civil/CivilDocumentsView.vue index d227fd48d..ec639410d 100644 --- a/web/src/components/case-details/civil/CivilDocumentsView.vue +++ b/web/src/components/case-details/civil/CivilDocumentsView.vue @@ -19,7 +19,7 @@