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
This commit is contained in:
Jordan Ramos
2026-05-27 11:07:32 -06:00
parent d081961341
commit fabf98790c
7 changed files with 941 additions and 149 deletions

View File

@@ -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 }
);
});
});

View File

@@ -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 }
);
});
});

View File

@@ -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 }
);
});
});

View File

@@ -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 }
);
});
});

View File

@@ -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 }
);
});
});

View File

@@ -1,8 +1,9 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'; 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 { useAuth } from '../../contexts/AuthContext';
import ConsolidationModal from '../ConsolidationModal'; import ConsolidationModal from '../ConsolidationModal';
import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor } from '../../utils/jiraConsolidation'; 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'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
@@ -234,6 +235,45 @@ const STYLES = {
width: '100%', width: '100%',
outline: 'none', 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 [singleJiraSaving, setSingleJiraSaving] = useState(false);
const [singleJiraSummaryError, setSingleJiraSummaryError] = useState(null); const [singleJiraSummaryError, setSingleJiraSummaryError] = useState(null);
// Collapse state for grouped sections (Requirement 2.2, 2.7)
const [collapsedSections, setCollapsedSections] = useState({});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Data fetching // Data fetching
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -323,6 +366,21 @@ export default function IvantiTodoQueuePage() {
return queueItems.filter((item) => item.status === 'pending'); return queueItems.filter((item) => item.status === 'pending');
}, [queueItems]); }, [queueItems]);
// ---------------------------------------------------------------------------
// Grouped sections — hybrid Inventory + vendor grouping (Requirements 1.11.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) // Selection mode toggle (Requirement 1.1, 1.5)
// When deactivated, clear all selections // When deactivated, clear all selections
@@ -627,158 +685,190 @@ export default function IvantiTodoQueuePage() {
<span style={{ ...STYLES.tableHeaderLabel, width: '120px' }}>Host</span> <span style={{ ...STYLES.tableHeaderLabel, width: '120px' }}>Host</span>
</div> </div>
{/* Queue item rows */} {/* Grouped sections with collapsible headers (Requirements 2.1, 2.32.6, 3.1, 3.2) */}
{visibleItems.map((item) => { {groupedSections.map((section) => {
const isSelected = selectedIds.has(item.id); const isCollapsed = !!collapsedSections[section.key];
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 ( return (
<div <div key={section.key}>
key={item.id} {/* Section Header */}
style={isSelected ? STYLES.queueItemSelected : STYLES.queueItem} <div
onClick={selectionMode ? () => toggleItemSelection(item.id) : undefined} onClick={() => toggleSection(section.key)}
role={selectionMode ? 'button' : undefined} style={section.type === 'inventory' ? STYLES.sectionHeaderInventory : STYLES.sectionHeaderVendor}
tabIndex={selectionMode ? 0 : undefined} role="button"
onKeyDown={selectionMode ? (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggleItemSelection(item.id); } } : undefined} tabIndex={0}
> onKeyDown={(e) => {
{/* Selection checkbox (Requirement 1.2) */} if (e.key === 'Enter' || e.key === ' ') {
{selectionMode && ( e.preventDefault();
<input toggleSection(section.key);
type="checkbox" }
checked={isSelected} }}
onChange={(e) => { e.stopPropagation(); toggleItemSelection(item.id); }} aria-expanded={!isCollapsed}
onClick={(e) => e.stopPropagation()} aria-label={`${section.label} section, ${section.items.length} items`}
style={STYLES.checkbox} >
aria-label={`Select ${item.finding_title || item.finding_id}`} {isCollapsed
/> ? <ChevronRight style={{ width: '14px', height: '14px' }} />
)} : <ChevronDown style={{ width: '14px', height: '14px' }} />
}
<span>{section.label}</span>
<span style={STYLES.sectionCount}>({section.items.length})</span>
</div>
{/* Finding info */} {/* Section Body — only rendered when expanded */}
<div style={{ flex: 1, minWidth: 0 }}> {!isCollapsed && section.items.map((item) => {
<div style={{ const isSelected = selectedIds.has(item.id);
fontFamily: 'monospace', const wfColor = getWorkflowColor(item.workflow_type);
fontSize: '0.75rem', const cves = item.cves || [];
fontWeight: 600, const cveDisplay = cves.length > 0
color: '#CBD5E1', ? cves.slice(0, 2).join(', ') + (cves.length > 2 ? ` +${cves.length - 2}` : '')
overflow: 'hidden', : '';
textOverflow: 'ellipsis',
whiteSpace: 'nowrap', return (
}} title={item.finding_title || item.finding_id}> <div
{item.finding_title || item.finding_id} key={item.id}
</div> style={isSelected ? STYLES.queueItemSelected : STYLES.queueItem}
{cveDisplay && ( onClick={selectionMode ? () => toggleItemSelection(item.id) : undefined}
<div style={{ role={selectionMode ? 'button' : undefined}
fontFamily: 'monospace', tabIndex={selectionMode ? 0 : undefined}
fontSize: '0.65rem', onKeyDown={selectionMode ? (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggleItemSelection(item.id); } } : undefined}
color: '#64748B', >
marginTop: '2px', {/* Selection checkbox (Requirement 1.2) */}
overflow: 'hidden', {selectionMode && (
textOverflow: 'ellipsis', <input
whiteSpace: 'nowrap', type="checkbox"
}} title={cves.join(', ')}> checked={isSelected}
{cveDisplay} onChange={(e) => { e.stopPropagation(); toggleItemSelection(item.id); }}
onClick={(e) => e.stopPropagation()}
style={STYLES.checkbox}
aria-label={`Select ${item.finding_title || item.finding_id}`}
/>
)}
{/* Finding info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontFamily: 'monospace',
fontSize: '0.75rem',
fontWeight: 600,
color: '#CBD5E1',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}} title={item.finding_title || item.finding_id}>
{item.finding_title || item.finding_id}
</div>
{cveDisplay && (
<div style={{
fontFamily: 'monospace',
fontSize: '0.65rem',
color: '#64748B',
marginTop: '2px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}} title={cves.join(', ')}>
{cveDisplay}
</div>
)}
</div>
{/* Ticket link badge (Requirements 6.3, 6.4) */}
{ticketLinks[item.id] && (
<a
href={ticketLinks[item.id].jira_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => 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}
</a>
)}
{/* Workflow type badge */}
<div style={{
width: '80px',
textAlign: 'center',
flexShrink: 0,
}}>
<span style={{
fontFamily: 'monospace',
fontSize: '0.6rem',
fontWeight: 700,
color: wfColor.col,
background: `rgba(${wfColor.rgb}, 0.1)`,
border: `1px solid rgba(${wfColor.rgb}, 0.3)`,
borderRadius: '4px',
padding: '0.15rem 0.4rem',
textTransform: 'uppercase',
}}>
{item.workflow_type}
</span>
</div>
{/* Vendor */}
<div style={{
width: '120px',
flexShrink: 0,
fontFamily: 'monospace',
fontSize: '0.68rem',
color: '#94A3B8',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}} title={item.vendor}>
{item.vendor || '—'}
</div>
{/* Hostname / IP */}
<div style={{
width: '120px',
flexShrink: 0,
minWidth: 0,
}}>
{item.hostname && (
<div style={{
fontFamily: 'monospace',
fontSize: '0.65rem',
color: '#94A3B8',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}} title={item.hostname}>
{item.hostname}
</div>
)}
{item.ip_address && (
<div style={{
fontFamily: 'monospace',
fontSize: '0.62rem',
color: '#10B981',
marginTop: item.hostname ? '1px' : 0,
}}>
{item.ip_address}
</div>
)}
</div>
</div> </div>
)} );
</div> })}
{/* Ticket link badge (Requirements 6.3, 6.4) */}
{ticketLinks[item.id] && (
<a
href={ticketLinks[item.id].jira_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => 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}
</a>
)}
{/* Workflow type badge */}
<div style={{
width: '80px',
textAlign: 'center',
flexShrink: 0,
}}>
<span style={{
fontFamily: 'monospace',
fontSize: '0.6rem',
fontWeight: 700,
color: wfColor.col,
background: `rgba(${wfColor.rgb}, 0.1)`,
border: `1px solid rgba(${wfColor.rgb}, 0.3)`,
borderRadius: '4px',
padding: '0.15rem 0.4rem',
textTransform: 'uppercase',
}}>
{item.workflow_type}
</span>
</div>
{/* Vendor */}
<div style={{
width: '120px',
flexShrink: 0,
fontFamily: 'monospace',
fontSize: '0.68rem',
color: '#94A3B8',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}} title={item.vendor}>
{item.vendor || '—'}
</div>
{/* Hostname / IP */}
<div style={{
width: '120px',
flexShrink: 0,
minWidth: 0,
}}>
{item.hostname && (
<div style={{
fontFamily: 'monospace',
fontSize: '0.65rem',
color: '#94A3B8',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}} title={item.hostname}>
{item.hostname}
</div>
)}
{item.ip_address && (
<div style={{
fontFamily: 'monospace',
fontSize: '0.62rem',
color: '#10B981',
marginTop: item.hostname ? '1px' : 0,
}}>
{item.ip_address}
</div>
)}
</div>
</div> </div>
); );
})} })}

View File

@@ -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;
}