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"
+ />
(selectedItems = val)"
/>
@@ -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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.raw.title }}
+
+
+
+
+
+
+
+
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);
+ });
+ });
+});