From fabf98790c3816db7e86c17355116cf66645e2a0 Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Wed, 27 May 2026 11:07:32 -0600 Subject: [PATCH] Add collapsible sections to Ivanti Queue page Group queue items into a hybrid layout: Inventory section (CARD/GRANITE/DECOM) at top, then vendor-grouped sections for FP/Archer items sorted alphabetically. Each section header is clickable to collapse/expand with chevron indicators. - Extract grouping logic into reusable utility (queueGrouping.js) - Add collapse state management (all sections expanded by default) - Preserve cross-section multi-select, floating action bar, ticket badges - Add 5 property-based tests covering grouping correctness, ordering, empty section omission, count accuracy, and selection independence --- ...e-grouping-count-accuracy.property.test.js | 88 ++++ ...e-grouping-empty-sections.property.test.js | 111 +++++ .../queue-grouping-ordering.property.test.js | 92 +++++ .../__tests__/queue-grouping.property.test.js | 135 ++++++ ...ue-selection-independence.property.test.js | 213 ++++++++++ .../components/pages/IvantiTodoQueuePage.js | 388 +++++++++++------- frontend/src/utils/queueGrouping.js | 63 +++ 7 files changed, 941 insertions(+), 149 deletions(-) create mode 100644 frontend/src/__tests__/queue-grouping-count-accuracy.property.test.js create mode 100644 frontend/src/__tests__/queue-grouping-empty-sections.property.test.js create mode 100644 frontend/src/__tests__/queue-grouping-ordering.property.test.js create mode 100644 frontend/src/__tests__/queue-grouping.property.test.js create mode 100644 frontend/src/__tests__/queue-selection-independence.property.test.js create mode 100644 frontend/src/utils/queueGrouping.js diff --git a/frontend/src/__tests__/queue-grouping-count-accuracy.property.test.js b/frontend/src/__tests__/queue-grouping-count-accuracy.property.test.js new file mode 100644 index 0000000..f11b74a --- /dev/null +++ b/frontend/src/__tests__/queue-grouping-count-accuracy.property.test.js @@ -0,0 +1,88 @@ +/** + * Property-Based Test: Section Header Count Accuracy + * + * Feature: queue-collapsible-sections, Property 4: Section Header Count Accuracy + * **Validates: Requirements 3.1, 3.2** + * + * For every section in the grouped output, the items array length equals the count + * that would be displayed in the header. The sum of all section item counts equals + * the total input array length. + */ +import fc from 'fast-check'; + +// Replicate the grouping logic inline (utility file not yet extracted) +function groupQueueItems(visibleItems) { + const INVENTORY_TYPES = new Set(['CARD', 'GRANITE', 'DECOM']); + const inventoryItems = []; + const vendorMap = new Map(); + for (const item of visibleItems) { + if (INVENTORY_TYPES.has(item.workflow_type)) { + inventoryItems.push(item); + } else { + const vendor = item.vendor?.trim() || 'Unknown'; + if (!vendorMap.has(vendor)) vendorMap.set(vendor, []); + vendorMap.get(vendor).push(item); + } + } + const sections = []; + if (inventoryItems.length > 0) { + sections.push({ key: 'inventory', label: 'Inventory', type: 'inventory', items: inventoryItems }); + } + const sortedVendors = [...vendorMap.entries()].sort((a, b) => a[0].localeCompare(b[0])); + for (const [vendor, items] of sortedVendors) { + sections.push({ key: `vendor:${vendor}`, label: vendor, type: 'vendor', items }); + } + return sections; +} + +// Generator for queue items with realistic workflow types and vendor names +const workflowTypeArb = fc.constantFrom('CARD', 'GRANITE', 'DECOM', 'FP', 'Archer'); +const vendorArb = fc.oneof( + fc.constantFrom('Microsoft', 'Adobe', 'Cisco', 'Oracle', 'VMware', 'Apple'), + fc.constant(''), + fc.constant(null), + fc.constant(undefined) +); + +const queueItemArb = fc.record({ + id: fc.uuid(), + workflow_type: workflowTypeArb, + vendor: vendorArb, + hostname: fc.string({ minLength: 1, maxLength: 20 }), + status: fc.constant('pending'), +}); + +const queueItemsArb = fc.array(queueItemArb, { minLength: 1, maxLength: 50 }); + +describe('Queue Grouping — Property 4: Section Header Count Accuracy', () => { + it('each section items.length is a positive integer (the displayed count)', () => { + fc.assert( + fc.property(queueItemsArb, (items) => { + const sections = groupQueueItems(items); + + for (const section of sections) { + // items.length is the count that would be displayed in the header + expect(Number.isInteger(section.items.length)).toBe(true); + expect(section.items.length).toBeGreaterThan(0); + } + }), + { numRuns: 200 } + ); + }); + + it('sum of all section item counts equals total input array length', () => { + fc.assert( + fc.property(queueItemsArb, (items) => { + const sections = groupQueueItems(items); + + const totalItemsInSections = sections.reduce( + (sum, section) => sum + section.items.length, + 0 + ); + + expect(totalItemsInSections).toBe(items.length); + }), + { numRuns: 200 } + ); + }); +}); diff --git a/frontend/src/__tests__/queue-grouping-empty-sections.property.test.js b/frontend/src/__tests__/queue-grouping-empty-sections.property.test.js new file mode 100644 index 0000000..d61e8a2 --- /dev/null +++ b/frontend/src/__tests__/queue-grouping-empty-sections.property.test.js @@ -0,0 +1,111 @@ +/** + * Property-Based Test: Empty Section Omission + * + * Feature: queue-collapsible-sections, Property 3: Empty Section Omission + * **Validates: Requirements 1.6, 1.7** + * + * For any array of queue items (including empty arrays), the grouped output + * contains no sections with zero items. If no CARD/GRANITE/DECOM items exist + * in the input, no section with type 'inventory' appears in the output. + */ +import fc from 'fast-check'; + +// Inline the grouping logic since the utility file may not exist yet +function groupQueueItems(visibleItems) { + const INVENTORY_TYPES = new Set(['CARD', 'GRANITE', 'DECOM']); + const inventoryItems = []; + const vendorMap = new Map(); + for (const item of visibleItems) { + if (INVENTORY_TYPES.has(item.workflow_type)) { + inventoryItems.push(item); + } else { + const vendor = item.vendor?.trim() || 'Unknown'; + if (!vendorMap.has(vendor)) vendorMap.set(vendor, []); + vendorMap.get(vendor).push(item); + } + } + const sections = []; + if (inventoryItems.length > 0) { + sections.push({ key: 'inventory', label: 'Inventory', type: 'inventory', items: inventoryItems }); + } + const sortedVendors = [...vendorMap.entries()].sort((a, b) => a[0].localeCompare(b[0])); + for (const [vendor, items] of sortedVendors) { + sections.push({ key: `vendor:${vendor}`, label: vendor, type: 'vendor', items }); + } + return sections; +} + +// Generator for queue items with various workflow types and vendors +const workflowTypeArb = fc.constantFrom('CARD', 'GRANITE', 'DECOM', 'FP', 'Archer'); +const vendorArb = fc.oneof( + fc.constant(null), + fc.constant(undefined), + fc.constant(''), + fc.constant(' '), + fc.stringMatching(/^[A-Za-z][A-Za-z0-9 ]{0,14}$/) +); + +const queueItemArb = fc.record({ + id: fc.integer({ min: 1, max: 100000 }), + workflow_type: workflowTypeArb, + vendor: vendorArb, + hostname: fc.stringMatching(/^[a-z]{3,10}$/), + status: fc.constant('pending'), +}); + +const queueItemsArb = fc.array(queueItemArb, { minLength: 0, maxLength: 50 }); + +describe('Queue Grouping — Property 3: Empty Section Omission', () => { + it('no section in the output has zero items', () => { + fc.assert( + fc.property(queueItemsArb, (items) => { + const sections = groupQueueItems(items); + for (const section of sections) { + expect(section.items.length).toBeGreaterThan(0); + } + }), + { numRuns: 200 } + ); + }); + + it('if no inventory-type items exist, no inventory section appears', () => { + // Generate arrays that explicitly exclude inventory types + const nonInventoryTypeArb = fc.constantFrom('FP', 'Archer'); + const nonInventoryItemArb = fc.record({ + id: fc.integer({ min: 1, max: 100000 }), + workflow_type: nonInventoryTypeArb, + vendor: vendorArb, + hostname: fc.stringMatching(/^[a-z]{3,10}$/), + status: fc.constant('pending'), + }); + const nonInventoryItemsArb = fc.array(nonInventoryItemArb, { minLength: 0, maxLength: 50 }); + + fc.assert( + fc.property(nonInventoryItemsArb, (items) => { + const sections = groupQueueItems(items); + const inventorySection = sections.find(s => s.type === 'inventory'); + expect(inventorySection).toBeUndefined(); + }), + { numRuns: 200 } + ); + }); + + it('if no CARD/GRANITE/DECOM items exist in a mixed array, no inventory section appears', () => { + fc.assert( + fc.property(queueItemsArb, (items) => { + const INVENTORY_TYPES = new Set(['CARD', 'GRANITE', 'DECOM']); + const hasInventoryItems = items.some(item => INVENTORY_TYPES.has(item.workflow_type)); + const sections = groupQueueItems(items); + const inventorySection = sections.find(s => s.type === 'inventory'); + + if (!hasInventoryItems) { + expect(inventorySection).toBeUndefined(); + } else { + expect(inventorySection).toBeDefined(); + expect(inventorySection.items.length).toBeGreaterThan(0); + } + }), + { numRuns: 200 } + ); + }); +}); diff --git a/frontend/src/__tests__/queue-grouping-ordering.property.test.js b/frontend/src/__tests__/queue-grouping-ordering.property.test.js new file mode 100644 index 0000000..c2743b9 --- /dev/null +++ b/frontend/src/__tests__/queue-grouping-ordering.property.test.js @@ -0,0 +1,92 @@ +/** + * Property-Based Test: Section Ordering + * + * Feature: queue-collapsible-sections, Property 2: Section Ordering + * **Validates: Requirements 1.3, 1.4** + * + * For any array of queue items, the Inventory section (if present) is always + * the first element, and all vendor sections are sorted alphabetically by label. + */ +import fc from 'fast-check'; + +// Inline grouping logic (mirrors the implementation in IvantiTodoQueuePage) +function groupQueueItems(visibleItems) { + const INVENTORY_TYPES = new Set(['CARD', 'GRANITE', 'DECOM']); + const inventoryItems = []; + const vendorMap = new Map(); + for (const item of visibleItems) { + if (INVENTORY_TYPES.has(item.workflow_type)) { + inventoryItems.push(item); + } else { + const vendor = item.vendor?.trim() || 'Unknown'; + if (!vendorMap.has(vendor)) vendorMap.set(vendor, []); + vendorMap.get(vendor).push(item); + } + } + const sections = []; + if (inventoryItems.length > 0) { + sections.push({ key: 'inventory', label: 'Inventory', type: 'inventory', items: inventoryItems }); + } + const sortedVendors = [...vendorMap.entries()].sort((a, b) => a[0].localeCompare(b[0])); + for (const [vendor, items] of sortedVendors) { + sections.push({ key: `vendor:${vendor}`, label: vendor, type: 'vendor', items }); + } + return sections; +} + +// Generator for queue items with random workflow types and vendors +const workflowTypeArb = fc.constantFrom('CARD', 'GRANITE', 'DECOM', 'FP', 'Archer'); + +const vendorArb = fc.oneof( + fc.constant(null), + fc.constant(undefined), + fc.constant(''), + fc.constant(' '), + fc.stringMatching(/^[A-Za-z][A-Za-z0-9 ]{0,14}$/) +); + +const queueItemArb = fc.record({ + id: fc.integer({ min: 1, max: 100000 }), + workflow_type: workflowTypeArb, + vendor: vendorArb, + hostname: fc.string({ minLength: 1, maxLength: 20 }), + status: fc.constant('pending'), +}); + +const queueItemsArb = fc.array(queueItemArb, { minLength: 0, maxLength: 50 }); + +describe('Queue Grouping — Property 2: Section Ordering', () => { + it('Inventory section (if present) is always the first element', () => { + fc.assert( + fc.property(queueItemsArb, (items) => { + const sections = groupQueueItems(items); + + const inventoryIndex = sections.findIndex(s => s.type === 'inventory'); + + // If inventory section exists, it must be at index 0 + if (inventoryIndex !== -1) { + expect(inventoryIndex).toBe(0); + } + }), + { numRuns: 500 } + ); + }); + + it('Vendor sections are sorted alphabetically by label', () => { + fc.assert( + fc.property(queueItemsArb, (items) => { + const sections = groupQueueItems(items); + + const vendorSections = sections.filter(s => s.type === 'vendor'); + + // Each consecutive pair of vendor sections must be in alphabetical order + for (let i = 1; i < vendorSections.length; i++) { + const prev = vendorSections[i - 1].label; + const curr = vendorSections[i].label; + expect(prev.localeCompare(curr)).toBeLessThanOrEqual(0); + } + }), + { numRuns: 500 } + ); + }); +}); diff --git a/frontend/src/__tests__/queue-grouping.property.test.js b/frontend/src/__tests__/queue-grouping.property.test.js new file mode 100644 index 0000000..95d706b --- /dev/null +++ b/frontend/src/__tests__/queue-grouping.property.test.js @@ -0,0 +1,135 @@ +/** + * Property-Based Test: Grouping Correctness + * + * Feature: queue-collapsible-sections, Property 1: Grouping Correctness + * **Validates: Requirements 1.1, 1.2, 1.5** + * + * For any array of visible queue items, every item with workflow_type CARD, + * GRANITE, or DECOM appears in the Inventory section; every FP/Archer item + * appears in the vendor section matching its vendor field (or "Unknown" if + * null/empty); no item appears in more than one section; and total items + * across all sections equals input length. + */ +import fc from 'fast-check'; +import { groupQueueItems } from '../utils/queueGrouping'; + +// --------------------------------------------------------------------------- +// Arbitraries +// --------------------------------------------------------------------------- + +const INVENTORY_TYPES = ['CARD', 'GRANITE', 'DECOM']; +const VENDOR_TYPES = ['FP', 'Archer']; +const ALL_WORKFLOW_TYPES = [...INVENTORY_TYPES, ...VENDOR_TYPES]; + +// Generate vendor strings including edge cases: null, undefined, empty, whitespace-only, normal strings +const vendorArbitrary = fc.oneof( + fc.constant(null), + fc.constant(undefined), + fc.constant(''), + fc.constant(' '), + fc.stringMatching(/^[A-Za-z][A-Za-z0-9 ]{0,19}$/) +); + +// Generate a single queue item with a random workflow_type and vendor +const queueItemArbitrary = fc.record({ + id: fc.integer({ min: 1, max: 100000 }), + workflow_type: fc.constantFrom(...ALL_WORKFLOW_TYPES), + vendor: vendorArbitrary, + status: fc.constant('pending'), + hostname: fc.string({ minLength: 1, maxLength: 20 }), +}); + +// Generate arrays of queue items (0 to 50 items) +const queueItemsArbitrary = fc.array(queueItemArbitrary, { minLength: 0, maxLength: 50 }); + +// --------------------------------------------------------------------------- +// Property Test +// --------------------------------------------------------------------------- + +describe('Queue Grouping — Property 1: Grouping Correctness', () => { + it('every CARD/GRANITE/DECOM item appears in Inventory section', () => { + fc.assert( + fc.property(queueItemsArbitrary, (items) => { + const sections = groupQueueItems(items); + const inventorySection = sections.find(s => s.key === 'inventory'); + const inventoryItems = inventorySection ? inventorySection.items : []; + + const expectedInventoryItems = items.filter(item => + INVENTORY_TYPES.includes(item.workflow_type) + ); + + // Every inventory-type item must be in the inventory section + for (const item of expectedInventoryItems) { + expect(inventoryItems).toContain(item); + } + + // Inventory section should contain exactly the inventory-type items + expect(inventoryItems.length).toBe(expectedInventoryItems.length); + }), + { numRuns: 200 } + ); + }); + + it('every FP/Archer item appears in a vendor section matching its vendor field (or "Unknown")', () => { + fc.assert( + fc.property(queueItemsArbitrary, (items) => { + const sections = groupQueueItems(items); + + const vendorSections = sections.filter(s => s.type === 'vendor'); + const allVendorItems = vendorSections.flatMap(s => s.items); + + const expectedVendorItems = items.filter(item => + VENDOR_TYPES.includes(item.workflow_type) + ); + + // Every vendor-type item must appear in a vendor section + for (const item of expectedVendorItems) { + expect(allVendorItems).toContain(item); + + // Determine expected vendor key + const expectedVendor = item.vendor?.trim() || 'Unknown'; + const expectedKey = `vendor:${expectedVendor}`; + const matchingSection = sections.find(s => s.key === expectedKey); + + expect(matchingSection).toBeDefined(); + expect(matchingSection.items).toContain(item); + } + + // Vendor sections should contain exactly the vendor-type items + expect(allVendorItems.length).toBe(expectedVendorItems.length); + }), + { numRuns: 200 } + ); + }); + + it('no item appears in more than one section', () => { + fc.assert( + fc.property(queueItemsArbitrary, (items) => { + const sections = groupQueueItems(items); + + // Collect all items across all sections + const allSectionItems = sections.flatMap(s => s.items); + + // Use a Set to check for duplicates (by reference) + const seen = new Set(); + for (const item of allSectionItems) { + expect(seen.has(item)).toBe(false); + seen.add(item); + } + }), + { numRuns: 200 } + ); + }); + + it('total items across all sections equals input length', () => { + fc.assert( + fc.property(queueItemsArbitrary, (items) => { + const sections = groupQueueItems(items); + const totalInSections = sections.reduce((sum, s) => sum + s.items.length, 0); + + expect(totalInSections).toBe(items.length); + }), + { numRuns: 200 } + ); + }); +}); diff --git a/frontend/src/__tests__/queue-selection-independence.property.test.js b/frontend/src/__tests__/queue-selection-independence.property.test.js new file mode 100644 index 0000000..d6d265d --- /dev/null +++ b/frontend/src/__tests__/queue-selection-independence.property.test.js @@ -0,0 +1,213 @@ +/** + * Property-Based Test: Selection Independence from Collapse State + * + * Feature: queue-collapsible-sections, Property 5: Selection Independence from Collapse State + * **Validates: Requirements 4.2, 4.4, 4.5** + * + * For any combination of selected items and collapse state, the set of selected + * item IDs remains unchanged when sections are collapsed or expanded. Select All + * always covers all visible items regardless of which sections are collapsed. + */ +import fc from 'fast-check'; + +// --- Pure logic under test (mirrors IvantiTodoQueuePage behavior) --- + +/** + * Toggle a section's collapse state. Only modifies collapsedSections, + * never touches selectedIds. + */ +function toggleSection(collapsedSections, sectionKey) { + return { ...collapsedSections, [sectionKey]: !collapsedSections[sectionKey] }; +} + +/** + * Select All — always selects all visible items regardless of collapse state. + * The visibleItems array contains ALL pending items, not just those in expanded sections. + */ +function selectAll(visibleItems) { + return new Set(visibleItems.map(item => item.id)); +} + +// --- Generators --- + +const WORKFLOW_TYPES = ['CARD', 'GRANITE', 'DECOM', 'FP', 'Archer']; +const VENDORS = ['Microsoft', 'Adobe', 'Cisco', 'Unknown', 'Oracle', 'VMware']; + +const queueItemArbitrary = fc.record({ + id: fc.integer({ min: 1, max: 100000 }), + workflow_type: fc.constantFrom(...WORKFLOW_TYPES), + vendor: fc.constantFrom(...VENDORS), + status: fc.constant('pending'), +}); + +// Generate arrays of queue items with unique IDs +const queueItemsArbitrary = fc + .array(queueItemArbitrary, { minLength: 1, maxLength: 50 }) + .map(items => { + // Ensure unique IDs + const seen = new Set(); + return items.filter(item => { + if (seen.has(item.id)) return false; + seen.add(item.id); + return true; + }); + }) + .filter(items => items.length > 0); + +// Generate a subset of item IDs as the selected set +const selectedIdsArbitrary = (items) => + fc.subarray(items.map(i => i.id), { minLength: 0 }).map(ids => new Set(ids)); + +// Generate section keys that would exist for a given set of items +const sectionKeysForItems = (items) => { + const keys = new Set(); + const INVENTORY_TYPES = new Set(['CARD', 'GRANITE', 'DECOM']); + let hasInventory = false; + for (const item of items) { + if (INVENTORY_TYPES.has(item.workflow_type)) { + hasInventory = true; + } else { + keys.add(`vendor:${item.vendor || 'Unknown'}`); + } + } + if (hasInventory) keys.add('inventory'); + return [...keys]; +}; + +// Generate arbitrary collapse state for given section keys +const collapsedSectionsArbitrary = (sectionKeys) => + fc.record( + Object.fromEntries(sectionKeys.map(key => [key, fc.boolean()])), + { withDeletedKeys: true } + ); + +// --- Tests --- + +describe('Property 5: Selection Independence from Collapse State', () => { + it('toggling collapse state does not alter the set of selected item IDs', () => { + fc.assert( + fc.property( + queueItemsArbitrary.chain(items => + fc.tuple( + fc.constant(items), + selectedIdsArbitrary(items), + fc.constant(sectionKeysForItems(items)) + ) + ).chain(([items, selectedIds, sectionKeys]) => + fc.tuple( + fc.constant(items), + fc.constant(selectedIds), + sectionKeys.length > 0 + ? collapsedSectionsArbitrary(sectionKeys) + : fc.constant({}), + sectionKeys.length > 0 + ? fc.array(fc.constantFrom(...sectionKeys), { minLength: 1, maxLength: 10 }) + : fc.constant([]) + ) + ), + ([items, selectedIds, initialCollapsed, toggleSequence]) => { + // Record the original selected IDs + const originalSelectedIds = new Set(selectedIds); + + // Apply a sequence of toggleSection calls + let currentCollapsed = { ...initialCollapsed }; + for (const sectionKey of toggleSequence) { + currentCollapsed = toggleSection(currentCollapsed, sectionKey); + } + + // The selectedIds set must remain completely unchanged + // (toggleSection only modifies collapsedSections, never selectedIds) + expect(selectedIds).toEqual(originalSelectedIds); + expect(selectedIds.size).toBe(originalSelectedIds.size); + for (const id of originalSelectedIds) { + expect(selectedIds.has(id)).toBe(true); + } + } + ), + { numRuns: 200 } + ); + }); + + it('Select All always covers all visible items regardless of collapse state', () => { + fc.assert( + fc.property( + queueItemsArbitrary.chain(items => + fc.tuple( + fc.constant(items), + fc.constant(sectionKeysForItems(items)) + ) + ).chain(([items, sectionKeys]) => + fc.tuple( + fc.constant(items), + sectionKeys.length > 0 + ? collapsedSectionsArbitrary(sectionKeys) + : fc.constant({}) + ) + ), + ([visibleItems, collapsedSections]) => { + // Regardless of which sections are collapsed, selectAll operates + // on the full visibleItems array (all pending items) + const allSelectedIds = selectAll(visibleItems); + + // Every visible item must be in the selected set + for (const item of visibleItems) { + expect(allSelectedIds.has(item.id)).toBe(true); + } + + // The selected set size must equal the number of visible items + expect(allSelectedIds.size).toBe(visibleItems.length); + + // Verify this holds even after toggling all sections to collapsed + let fullyCollapsed = { ...collapsedSections }; + const sectionKeys = Object.keys(collapsedSections); + for (const key of sectionKeys) { + fullyCollapsed = toggleSection(fullyCollapsed, key); + } + + // selectAll still returns all visible items — collapse state is irrelevant + const afterCollapseSelectAll = selectAll(visibleItems); + expect(afterCollapseSelectAll).toEqual(allSelectedIds); + expect(afterCollapseSelectAll.size).toBe(visibleItems.length); + } + ), + { numRuns: 200 } + ); + }); + + it('selection count always equals total selected items across all sections regardless of collapse', () => { + fc.assert( + fc.property( + queueItemsArbitrary.chain(items => + fc.tuple( + fc.constant(items), + selectedIdsArbitrary(items), + fc.constant(sectionKeysForItems(items)) + ) + ).chain(([items, selectedIds, sectionKeys]) => + fc.tuple( + fc.constant(items), + fc.constant(selectedIds), + sectionKeys.length > 0 + ? collapsedSectionsArbitrary(sectionKeys) + : fc.constant({}) + ) + ), + ([_items, selectedIds, collapsedSections]) => { + // The selection count (selectedIds.size) is independent of collapse state. + // Changing collapse state should never change the count. + const countBefore = selectedIds.size; + + // Toggle every section + let currentCollapsed = { ...collapsedSections }; + const sectionKeys = Object.keys(collapsedSections); + for (const key of sectionKeys) { + currentCollapsed = toggleSection(currentCollapsed, key); + // After each toggle, selection count must remain the same + expect(selectedIds.size).toBe(countBefore); + } + } + ), + { numRuns: 200 } + ); + }); +}); diff --git a/frontend/src/components/pages/IvantiTodoQueuePage.js b/frontend/src/components/pages/IvantiTodoQueuePage.js index 9fa8d7c..9ca58ff 100644 --- a/frontend/src/components/pages/IvantiTodoQueuePage.js +++ b/frontend/src/components/pages/IvantiTodoQueuePage.js @@ -1,8 +1,9 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle } from 'lucide-react'; +import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle, ChevronDown, ChevronRight } from 'lucide-react'; import { useAuth } from '../../contexts/AuthContext'; import ConsolidationModal from '../ConsolidationModal'; import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor } from '../../utils/jiraConsolidation'; +import { groupQueueItems } from '../../utils/queueGrouping'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; @@ -234,6 +235,45 @@ const STYLES = { width: '100%', outline: 'none', }, + sectionHeaderInventory: { + display: 'flex', + alignItems: 'center', + gap: '0.5rem', + padding: '0.5rem 0.75rem', + marginTop: '0.5rem', + borderBottom: '1px solid rgba(16, 185, 129, 0.2)', + cursor: 'pointer', + userSelect: 'none', + fontFamily: 'monospace', + fontSize: '0.7rem', + fontWeight: 700, + color: '#10B981', + textTransform: 'uppercase', + letterSpacing: '0.1em', + }, + sectionHeaderVendor: { + display: 'flex', + alignItems: 'center', + gap: '0.5rem', + padding: '0.5rem 0.75rem', + marginTop: '0.5rem', + borderBottom: '1px solid rgba(148, 163, 184, 0.15)', + cursor: 'pointer', + userSelect: 'none', + fontFamily: 'monospace', + fontSize: '0.7rem', + fontWeight: 700, + color: '#94A3B8', + textTransform: 'uppercase', + letterSpacing: '0.1em', + }, + sectionCount: { + fontFamily: 'monospace', + fontSize: '0.65rem', + fontWeight: 600, + color: '#64748B', + marginLeft: '0.25rem', + }, }; // --------------------------------------------------------------------------- @@ -265,6 +305,9 @@ export default function IvantiTodoQueuePage() { const [singleJiraSaving, setSingleJiraSaving] = useState(false); const [singleJiraSummaryError, setSingleJiraSummaryError] = useState(null); + // Collapse state for grouped sections (Requirement 2.2, 2.7) + const [collapsedSections, setCollapsedSections] = useState({}); + // --------------------------------------------------------------------------- // Data fetching // --------------------------------------------------------------------------- @@ -323,6 +366,21 @@ export default function IvantiTodoQueuePage() { return queueItems.filter((item) => item.status === 'pending'); }, [queueItems]); + // --------------------------------------------------------------------------- + // Grouped sections — hybrid Inventory + vendor grouping (Requirements 1.1–1.7) + // --------------------------------------------------------------------------- + const groupedSections = useMemo(() => groupQueueItems(visibleItems), [visibleItems]); + + // --------------------------------------------------------------------------- + // Toggle section collapse (Requirement 2.2, 2.7) + // --------------------------------------------------------------------------- + const toggleSection = useCallback((sectionKey) => { + setCollapsedSections((prev) => ({ + ...prev, + [sectionKey]: !prev[sectionKey], + })); + }, []); + // --------------------------------------------------------------------------- // Selection mode toggle (Requirement 1.1, 1.5) // When deactivated, clear all selections @@ -627,158 +685,190 @@ export default function IvantiTodoQueuePage() { Host - {/* Queue item rows */} - {visibleItems.map((item) => { - const isSelected = selectedIds.has(item.id); - const wfColor = getWorkflowColor(item.workflow_type); - const cves = item.cves || []; - const cveDisplay = cves.length > 0 - ? cves.slice(0, 2).join(', ') + (cves.length > 2 ? ` +${cves.length - 2}` : '') - : ''; + {/* Grouped sections with collapsible headers (Requirements 2.1, 2.3–2.6, 3.1, 3.2) */} + {groupedSections.map((section) => { + const isCollapsed = !!collapsedSections[section.key]; return ( -
toggleItemSelection(item.id) : undefined} - role={selectionMode ? 'button' : undefined} - tabIndex={selectionMode ? 0 : undefined} - onKeyDown={selectionMode ? (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggleItemSelection(item.id); } } : undefined} - > - {/* Selection checkbox (Requirement 1.2) */} - {selectionMode && ( - { e.stopPropagation(); toggleItemSelection(item.id); }} - onClick={(e) => e.stopPropagation()} - style={STYLES.checkbox} - aria-label={`Select ${item.finding_title || item.finding_id}`} - /> - )} +
+ {/* Section Header */} +
toggleSection(section.key)} + style={section.type === 'inventory' ? STYLES.sectionHeaderInventory : STYLES.sectionHeaderVendor} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleSection(section.key); + } + }} + aria-expanded={!isCollapsed} + aria-label={`${section.label} section, ${section.items.length} items`} + > + {isCollapsed + ? + : + } + {section.label} + ({section.items.length}) +
- {/* Finding info */} -
-
- {item.finding_title || item.finding_id} -
- {cveDisplay && ( -
- {cveDisplay} + {/* Section Body — only rendered when expanded */} + {!isCollapsed && section.items.map((item) => { + const isSelected = selectedIds.has(item.id); + const wfColor = getWorkflowColor(item.workflow_type); + const cves = item.cves || []; + const cveDisplay = cves.length > 0 + ? cves.slice(0, 2).join(', ') + (cves.length > 2 ? ` +${cves.length - 2}` : '') + : ''; + + return ( +
toggleItemSelection(item.id) : undefined} + role={selectionMode ? 'button' : undefined} + tabIndex={selectionMode ? 0 : undefined} + onKeyDown={selectionMode ? (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggleItemSelection(item.id); } } : undefined} + > + {/* Selection checkbox (Requirement 1.2) */} + {selectionMode && ( + { e.stopPropagation(); toggleItemSelection(item.id); }} + onClick={(e) => e.stopPropagation()} + style={STYLES.checkbox} + aria-label={`Select ${item.finding_title || item.finding_id}`} + /> + )} + + {/* Finding info */} +
+
+ {item.finding_title || item.finding_id} +
+ {cveDisplay && ( +
+ {cveDisplay} +
+ )} +
+ + {/* Ticket link badge (Requirements 6.3, 6.4) */} + {ticketLinks[item.id] && ( + e.stopPropagation()} + style={{ + fontFamily: 'monospace', + fontSize: '0.6rem', + fontWeight: 700, + color: '#6EE7B7', + background: 'rgba(16, 185, 129, 0.1)', + border: '1px solid rgba(16, 185, 129, 0.3)', + borderRadius: '999px', + padding: '0.15rem 0.5rem', + textDecoration: 'none', + whiteSpace: 'nowrap', + flexShrink: 0, + display: 'inline-flex', + alignItems: 'center', + gap: '0.25rem', + transition: 'all 0.2s', + }} + title={`Open ${ticketLinks[item.id].ticket_key} in Jira`} + > + {ticketLinks[item.id].ticket_key} ↗ + + )} + + {/* Workflow type badge */} +
+ + {item.workflow_type} + +
+ + {/* Vendor */} +
+ {item.vendor || '—'} +
+ + {/* Hostname / IP */} +
+ {item.hostname && ( +
+ {item.hostname} +
+ )} + {item.ip_address && ( +
+ {item.ip_address} +
+ )} +
- )} -
- - {/* Ticket link badge (Requirements 6.3, 6.4) */} - {ticketLinks[item.id] && ( - e.stopPropagation()} - style={{ - fontFamily: 'monospace', - fontSize: '0.6rem', - fontWeight: 700, - color: '#6EE7B7', - background: 'rgba(16, 185, 129, 0.1)', - border: '1px solid rgba(16, 185, 129, 0.3)', - borderRadius: '999px', - padding: '0.15rem 0.5rem', - textDecoration: 'none', - whiteSpace: 'nowrap', - flexShrink: 0, - display: 'inline-flex', - alignItems: 'center', - gap: '0.25rem', - transition: 'all 0.2s', - }} - title={`Open ${ticketLinks[item.id].ticket_key} in Jira`} - > - {ticketLinks[item.id].ticket_key} ↗ - - )} - - {/* Workflow type badge */} -
- - {item.workflow_type} - -
- - {/* Vendor */} -
- {item.vendor || '—'} -
- - {/* Hostname / IP */} -
- {item.hostname && ( -
- {item.hostname} -
- )} - {item.ip_address && ( -
- {item.ip_address} -
- )} -
+ ); + })}
); })} diff --git a/frontend/src/utils/queueGrouping.js b/frontend/src/utils/queueGrouping.js new file mode 100644 index 0000000..b440e11 --- /dev/null +++ b/frontend/src/utils/queueGrouping.js @@ -0,0 +1,63 @@ +/** + * Queue grouping utility — extracts the hybrid Inventory + vendor grouping logic + * from IvantiTodoQueuePage into a testable pure function. + * + * Spec: .kiro/specs/queue-collapsible-sections + * Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7 + */ + +const INVENTORY_TYPES = new Set(['CARD', 'GRANITE', 'DECOM']); + +/** + * Groups visible queue items into the hybrid section layout. + * + * @param {Array} visibleItems - Queue items with status 'pending' + * @returns {Array<{key: string, label: string, type: string, items: Array}>} + * + * Rules: + * - Items with workflow_type CARD, GRANITE, or DECOM → Inventory section + * - Items with workflow_type FP or Archer → grouped by vendor field + * - Items with null/undefined/empty vendor → placed in "Unknown" vendor section + * - Inventory section appears first (if non-empty) + * - Vendor sections sorted alphabetically by label + * - Sections with zero items are omitted from output + */ +export function groupQueueItems(visibleItems) { + const inventoryItems = []; + const vendorMap = new Map(); + + for (const item of visibleItems) { + if (INVENTORY_TYPES.has(item.workflow_type)) { + inventoryItems.push(item); + } else { + const vendor = item.vendor?.trim() || 'Unknown'; + if (!vendorMap.has(vendor)) vendorMap.set(vendor, []); + vendorMap.get(vendor).push(item); + } + } + + const sections = []; + + if (inventoryItems.length > 0) { + sections.push({ + key: 'inventory', + label: 'Inventory', + type: 'inventory', + items: inventoryItems, + }); + } + + const sortedVendors = [...vendorMap.entries()] + .sort((a, b) => a[0].localeCompare(b[0])); + + for (const [vendor, items] of sortedVendors) { + sections.push({ + key: `vendor:${vendor}`, + label: vendor, + type: 'vendor', + items, + }); + } + + return sections; +}