From 04eb21a7d3c9f2e5e5ff1b77551ddfea531f921b Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Wed, 27 May 2026 15:08:08 -0600 Subject: [PATCH] Add vendor-specific issue type dropdown for Jira ticket creation When the Project Key field contains a vendor project key (e.g. AA_VECIMA), the Issue Type dropdown switches from STEAM types (Story, Epic, Program, Project, Reservation, Automation Maintenance) to vendor types (Epic, Story, Task, Defect, Production Defect/Incident Fix, New Feature, Spike, Release Candidate, Documentation). - Add VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES constants - Add isVendorProject() and getIssueTypesForProject() pure functions - Update JiraPage modal with context-aware dropdown and reset on switch - Update Ivanti queue modal with project_key and issue_type fields - Add property-based tests for determination logic and state transitions --- ...endor-issue-type-dropdown.property.test.js | 211 ++++++++++++++++++ .../components/pages/IvantiTodoQueuePage.js | 29 ++- frontend/src/components/pages/JiraPage.js | 70 +++++- 3 files changed, 301 insertions(+), 9 deletions(-) create mode 100644 backend/__tests__/vendor-issue-type-dropdown.property.test.js diff --git a/backend/__tests__/vendor-issue-type-dropdown.property.test.js b/backend/__tests__/vendor-issue-type-dropdown.property.test.js new file mode 100644 index 0000000..b60e5a9 --- /dev/null +++ b/backend/__tests__/vendor-issue-type-dropdown.property.test.js @@ -0,0 +1,211 @@ +/** + * Property-Based Tests: Vendor Issue Type Dropdown + * + * Feature: vendor-issue-type-dropdown + * + * Tests the pure determination logic that decides which issue type list + * to display based on the project key input. + * + * Validates: Requirements 1.2, 1.3, 1.4, 1.5, 2.1, 2.3, 3.4, 3.5, 4.1, 4.2, 6.3 + */ + +const fc = require('fast-check'); + +// --------------------------------------------------------------------------- +// Replicate the pure functions from JiraPage.js for testing +// --------------------------------------------------------------------------- + +const VENDOR_PROJECT_KEYS = ['AA_VECIMA']; + +const VENDOR_ISSUE_TYPES = [ + 'Epic', + 'Story', + 'Task', + 'Defect', + 'Production Defect/Incident Fix', + 'New Feature', + 'Spike', + 'Release Candidate', + 'Documentation', +]; + +const STEAM_ISSUE_TYPES = [ + 'Story', + 'Epic', + 'Program', + 'Project', + 'Reservation', + 'Automation Maintenance', +]; + +function isVendorProject(projectKey, vendorKeys) { + if (!projectKey || typeof projectKey !== 'string') return false; + const normalized = projectKey.trim().toUpperCase(); + if (normalized.length === 0) return false; + return vendorKeys.includes(normalized); +} + +function getIssueTypesForProject(projectKey, vendorKeys, vendorTypes, steamTypes) { + return isVendorProject(projectKey, vendorKeys) ? vendorTypes : steamTypes; +} + +/** + * Simulates the project_key onChange logic: resets issue_type only on context switch. + */ +function simulateProjectKeyChange(oldKey, newKey, currentIssueType, vendorKeys) { + const wasVendor = isVendorProject(oldKey, vendorKeys); + const isNowVendor = isVendorProject(newKey, vendorKeys); + return (wasVendor !== isNowVendor) ? '' : currentIssueType; +} + +// --------------------------------------------------------------------------- +// Property 1: Issue type list determination +// --------------------------------------------------------------------------- +describe('Feature: vendor-issue-type-dropdown, Property 1: Issue type list determination', () => { + it('returns VENDOR_ISSUE_TYPES when project key matches a vendor key (case-insensitive, trimmed)', () => { + // Generate variations of the vendor key with different casing and whitespace + const vendorKeyVariants = fc.oneof( + fc.constant('AA_VECIMA'), + fc.constant('aa_vecima'), + fc.constant('Aa_Vecima'), + fc.constant(' AA_VECIMA '), + fc.constant('aa_VECIMA'), + ); + + fc.assert( + fc.property(vendorKeyVariants, (key) => { + const result = getIssueTypesForProject(key, VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES); + expect(result).toBe(VENDOR_ISSUE_TYPES); + }), + { numRuns: 100 } + ); + }); + + it('returns STEAM_ISSUE_TYPES for any string that does not match a vendor key after normalization', () => { + // Generate arbitrary strings that are NOT 'AA_VECIMA' after trim+uppercase + const nonVendorKey = fc.string({ minLength: 0, maxLength: 50 }).filter(s => { + const normalized = s.trim().toUpperCase(); + return normalized !== 'AA_VECIMA'; + }); + + fc.assert( + fc.property(nonVendorKey, (key) => { + const result = getIssueTypesForProject(key, VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES); + expect(result).toBe(STEAM_ISSUE_TYPES); + }), + { numRuns: 100 } + ); + }); + + it('returns STEAM_ISSUE_TYPES for null, undefined, and empty string', () => { + const emptyInputs = fc.oneof( + fc.constant(null), + fc.constant(undefined), + fc.constant(''), + fc.constant(' '), + ); + + fc.assert( + fc.property(emptyInputs, (key) => { + const result = getIssueTypesForProject(key, VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES); + expect(result).toBe(STEAM_ISSUE_TYPES); + }), + { numRuns: 100 } + ); + }); +}); + +// --------------------------------------------------------------------------- +// Property 2: Context switch resets issue type selection +// --------------------------------------------------------------------------- +describe('Feature: vendor-issue-type-dropdown, Property 2: Context switch resets issue type', () => { + it('resets issue_type to empty when switching from vendor to non-vendor context', () => { + // Generate a vendor key variant and a non-vendor key + const vendorKey = fc.oneof( + fc.constant('AA_VECIMA'), + fc.constant('aa_vecima'), + fc.constant(' AA_VECIMA '), + ); + const nonVendorKey = fc.string({ minLength: 1, maxLength: 30 }).filter(s => { + const normalized = s.trim().toUpperCase(); + return normalized !== 'AA_VECIMA' && normalized.length > 0; + }); + const anyIssueType = fc.string({ minLength: 1, maxLength: 50 }); + + fc.assert( + fc.property(vendorKey, nonVendorKey, anyIssueType, (oldKey, newKey, issueType) => { + const result = simulateProjectKeyChange(oldKey, newKey, issueType, VENDOR_PROJECT_KEYS); + expect(result).toBe(''); + }), + { numRuns: 100 } + ); + }); + + it('resets issue_type to empty when switching from non-vendor to vendor context', () => { + const nonVendorKey = fc.string({ minLength: 1, maxLength: 30 }).filter(s => { + const normalized = s.trim().toUpperCase(); + return normalized !== 'AA_VECIMA' && normalized.length > 0; + }); + const vendorKey = fc.oneof( + fc.constant('AA_VECIMA'), + fc.constant('aa_vecima'), + ); + const anyIssueType = fc.string({ minLength: 1, maxLength: 50 }); + + fc.assert( + fc.property(nonVendorKey, vendorKey, anyIssueType, (oldKey, newKey, issueType) => { + const result = simulateProjectKeyChange(oldKey, newKey, issueType, VENDOR_PROJECT_KEYS); + expect(result).toBe(''); + }), + { numRuns: 100 } + ); + }); +}); + +// --------------------------------------------------------------------------- +// Property 3: Same context preserves issue type selection +// --------------------------------------------------------------------------- +describe('Feature: vendor-issue-type-dropdown, Property 3: Same context preserves issue type', () => { + it('preserves issue_type when both old and new keys resolve to STEAM context', () => { + const nonVendorKey1 = fc.string({ minLength: 0, maxLength: 30 }).filter(s => { + const normalized = s.trim().toUpperCase(); + return normalized !== 'AA_VECIMA'; + }); + const nonVendorKey2 = fc.string({ minLength: 0, maxLength: 30 }).filter(s => { + const normalized = s.trim().toUpperCase(); + return normalized !== 'AA_VECIMA'; + }); + const anyIssueType = fc.string({ minLength: 0, maxLength: 50 }); + + fc.assert( + fc.property(nonVendorKey1, nonVendorKey2, anyIssueType, (oldKey, newKey, issueType) => { + const result = simulateProjectKeyChange(oldKey, newKey, issueType, VENDOR_PROJECT_KEYS); + expect(result).toBe(issueType); + }), + { numRuns: 100 } + ); + }); + + it('preserves issue_type when both old and new keys resolve to vendor context', () => { + // With only one vendor key, both must be variants of AA_VECIMA + const vendorKey1 = fc.oneof( + fc.constant('AA_VECIMA'), + fc.constant('aa_vecima'), + fc.constant(' AA_VECIMA '), + ); + const vendorKey2 = fc.oneof( + fc.constant('AA_VECIMA'), + fc.constant('Aa_Vecima'), + fc.constant('aa_VECIMA'), + ); + const anyIssueType = fc.string({ minLength: 0, maxLength: 50 }); + + fc.assert( + fc.property(vendorKey1, vendorKey2, anyIssueType, (oldKey, newKey, issueType) => { + const result = simulateProjectKeyChange(oldKey, newKey, issueType, VENDOR_PROJECT_KEYS); + expect(result).toBe(issueType); + }), + { numRuns: 100 } + ); + }); +}); diff --git a/frontend/src/components/pages/IvantiTodoQueuePage.js b/frontend/src/components/pages/IvantiTodoQueuePage.js index 9ca58ff..fc20c03 100644 --- a/frontend/src/components/pages/IvantiTodoQueuePage.js +++ b/frontend/src/components/pages/IvantiTodoQueuePage.js @@ -4,6 +4,7 @@ import { useAuth } from '../../contexts/AuthContext'; import ConsolidationModal from '../ConsolidationModal'; import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor } from '../../utils/jiraConsolidation'; import { groupQueueItems } from '../../utils/queueGrouping'; +import { VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES, isVendorProject, getIssueTypesForProject } from './JiraPage'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; @@ -300,7 +301,7 @@ export default function IvantiTodoQueuePage() { // Single-item Jira creation modal state (Requirement 2.4) const [showSingleJiraModal, setShowSingleJiraModal] = useState(false); const [singleJiraItem, setSingleJiraItem] = useState(null); - const [singleJiraForm, setSingleJiraForm] = useState({ cve_id: '', vendor: '', summary: '', description: '', source_context: 'ivanti_queue' }); + const [singleJiraForm, setSingleJiraForm] = useState({ cve_id: '', vendor: '', summary: '', description: '', source_context: 'ivanti_queue', project_key: '', issue_type: '' }); const [singleJiraError, setSingleJiraError] = useState(null); const [singleJiraSaving, setSingleJiraSaving] = useState(false); const [singleJiraSummaryError, setSingleJiraSummaryError] = useState(null); @@ -480,6 +481,8 @@ export default function IvantiTodoQueuePage() { summary: generateConsolidatedSummary(items), description: generateConsolidatedDescription(items), source_context: 'ivanti_queue', + project_key: '', + issue_type: '', }); setSingleJiraError(null); setSingleJiraSummaryError(null); @@ -966,6 +969,30 @@ export default function IvantiTodoQueuePage() { onChange={e => setSingleJiraForm(f => ({ ...f, description: e.target.value }))} /> +
+
+ + { + const newKey = e.target.value.toUpperCase(); + const wasVendor = isVendorProject(singleJiraForm.project_key, VENDOR_PROJECT_KEYS); + const isNowVendor = isVendorProject(newKey, VENDOR_PROJECT_KEYS); + setSingleJiraForm(f => ({ + ...f, + project_key: newKey, + issue_type: (wasVendor !== isNowVendor) ? '' : f.issue_type, + })); + }} /> +
+
+ + +
+