@@ -40,6 +40,10 @@ const PORT_FIELDS = [
4040 [ 'tlsPort' , 'TLS Port' ]
4141] ;
4242
43+ // A JIRA issue key like CONTECH-1553 — used to tell "user typed a key to validate"
44+ // from "user is searching an epic by name" in the epic field.
45+ const EPIC_KEY_RE = / ^ [ A - Z a - z ] [ A - Z a - z 0 - 9 ] * - \d + $ / ;
46+
4347export default function EditAppDrawer ( { app, onClose, onSave } ) {
4448 const [ activeTab , setActiveTab ] = useDrawerTab ( 'appTab' , 'general' , TAB_IDS ) ;
4549 const [ formData , setFormData ] = useState ( {
@@ -112,6 +116,12 @@ export default function EditAppDrawer({ app, onClose, onSave }) {
112116 const [ loadingProjects , setLoadingProjects ] = useState ( false ) ;
113117 const [ projectSearch , setProjectSearch ] = useState ( '' ) ;
114118 const [ projectDropdownOpen , setProjectDropdownOpen ] = useState ( false ) ;
119+ const [ jiraBoards , setJiraBoards ] = useState ( [ ] ) ;
120+ const [ loadingBoards , setLoadingBoards ] = useState ( false ) ;
121+ const [ boardSprints , setBoardSprints ] = useState ( [ ] ) ;
122+ const [ epicResults , setEpicResults ] = useState ( [ ] ) ;
123+ const [ epicDropdownOpen , setEpicDropdownOpen ] = useState ( false ) ;
124+ const [ epicValidation , setEpicValidation ] = useState ( { state : 'idle' } ) ;
115125
116126 useEffect ( ( ) => {
117127 const toInstances = ( data ) => data ?. instances ? Object . values ( data . instances ) : [ ] ;
@@ -149,6 +159,72 @@ export default function EditAppDrawer({ app, onClose, onSave }) {
149159 }
150160 } , [ formData . jiraInstanceId , jiraInstances , formData . jiraAssignee ] ) ;
151161
162+ // Detect the project's agile boards so the boardId is picked from live data
163+ // instead of hand-typed (which is how a boardId goes stale across a migration).
164+ useEffect ( ( ) => {
165+ if ( ! formData . jiraInstanceId || ! formData . jiraProjectKey ) {
166+ setJiraBoards ( [ ] ) ;
167+ return ;
168+ }
169+ let cancelled = false ;
170+ setLoadingBoards ( true ) ;
171+ api . getJiraBoards ( formData . jiraInstanceId , formData . jiraProjectKey , { silent : true } )
172+ . then ( boards => { if ( ! cancelled ) setJiraBoards ( boards || [ ] ) ; } )
173+ . catch ( ( ) => { if ( ! cancelled ) setJiraBoards ( [ ] ) ; } )
174+ . finally ( ( ) => { if ( ! cancelled ) setLoadingBoards ( false ) ; } ) ;
175+ return ( ) => { cancelled = true ; } ;
176+ } , [ formData . jiraInstanceId , formData . jiraProjectKey ] ) ;
177+
178+ // Show the selected board's active sprint as confirmation it's the right board.
179+ useEffect ( ( ) => {
180+ if ( ! formData . jiraInstanceId || ! formData . jiraBoardId ) {
181+ setBoardSprints ( [ ] ) ;
182+ return ;
183+ }
184+ let cancelled = false ;
185+ api . getJiraBoardSprints ( formData . jiraInstanceId , formData . jiraBoardId , { silent : true } )
186+ . then ( sprints => { if ( ! cancelled ) setBoardSprints ( sprints || [ ] ) ; } )
187+ . catch ( ( ) => { if ( ! cancelled ) setBoardSprints ( [ ] ) ; } ) ;
188+ return ( ) => { cancelled = true ; } ;
189+ } , [ formData . jiraInstanceId , formData . jiraBoardId ] ) ;
190+
191+ // Validate the configured epic key still resolves as an Epic on this instance.
192+ useEffect ( ( ) => {
193+ const key = formData . jiraEpicKey . trim ( ) ;
194+ if ( ! formData . jiraInstanceId || ! EPIC_KEY_RE . test ( key ) ) {
195+ setEpicValidation ( { state : 'idle' } ) ;
196+ return ;
197+ }
198+ let cancelled = false ;
199+ setEpicValidation ( { state : 'checking' } ) ;
200+ const t = setTimeout ( ( ) => {
201+ api . getJiraIssue ( formData . jiraInstanceId , key , { silent : true } )
202+ . then ( issue => {
203+ if ( cancelled ) return ;
204+ const isEpic = ( issue . issueType || '' ) . toLowerCase ( ) === 'epic' ;
205+ setEpicValidation ( { state : isEpic ? 'ok' : 'wrongtype' , issue } ) ;
206+ } )
207+ . catch ( ( ) => { if ( ! cancelled ) setEpicValidation ( { state : 'stale' } ) ; } ) ;
208+ } , 400 ) ;
209+ return ( ) => { cancelled = true ; clearTimeout ( t ) ; } ;
210+ } , [ formData . jiraInstanceId , formData . jiraEpicKey ] ) ;
211+
212+ // When the epic field holds free text (not a key), search epics by name to pick one.
213+ useEffect ( ( ) => {
214+ const q = formData . jiraEpicKey . trim ( ) ;
215+ if ( ! formData . jiraInstanceId || ! formData . jiraProjectKey || q . length < 2 || EPIC_KEY_RE . test ( q ) ) {
216+ setEpicResults ( [ ] ) ;
217+ return ;
218+ }
219+ let cancelled = false ;
220+ const t = setTimeout ( ( ) => {
221+ api . searchJiraEpics ( formData . jiraInstanceId , formData . jiraProjectKey , q , { silent : true } )
222+ . then ( results => { if ( ! cancelled ) setEpicResults ( results || [ ] ) ; } )
223+ . catch ( ( ) => { if ( ! cancelled ) setEpicResults ( [ ] ) ; } ) ;
224+ } , 300 ) ;
225+ return ( ) => { cancelled = true ; clearTimeout ( t ) ; } ;
226+ } , [ formData . jiraInstanceId , formData . jiraProjectKey , formData . jiraEpicKey ] ) ;
227+
152228 // Project-key combobox options: filter by the search box, sort by key, cap at
153229 // 100. Derived once per render so the predicate isn't duplicated between the
154230 // option list and the "no matching projects" empty-state check below.
@@ -161,6 +237,22 @@ export default function EditAppDrawer({ app, onClose, onSave }) {
161237 . sort ( ( a , b ) => a . key . localeCompare ( b . key ) )
162238 . slice ( 0 , 100 ) ;
163239
240+ // A saved boardId that isn't among the project's detected boards is stale
241+ // (e.g. carried over from a Server instance after a Cloud migration).
242+ const boardIsStale = ! ! formData . jiraBoardId && jiraBoards . length > 0
243+ && ! jiraBoards . some ( b => String ( b . id ) === String ( formData . jiraBoardId ) ) ;
244+ const activeSprint = boardSprints [ 0 ] || null ;
245+
246+ // Changing the instance or project invalidates the selected board, so clear it
247+ // in one place — every discrete instance/project change goes through these so no
248+ // call site forgets the reset. Deliberately interaction-driven, NOT a projectKey
249+ // effect: an effect would fire on mount with the saved projectKey and wipe the
250+ // saved boardId before the boards fetch resolves, defeating stale-board detection.
251+ const changeInstance = ( jiraInstanceId ) =>
252+ setFormData ( prev => ( { ...prev , jiraInstanceId, jiraProjectKey : '' , jiraBoardId : '' } ) ) ;
253+ const selectProject = ( jiraProjectKey ) =>
254+ setFormData ( prev => ( { ...prev , jiraProjectKey, jiraBoardId : '' } ) ) ;
255+
164256 const handleSubmit = async ( e ) => {
165257 e . preventDefault ( ) ;
166258 setError ( null ) ;
@@ -217,7 +309,7 @@ export default function EditAppDrawer({ app, onClose, onSave }) {
217309 issueType : formData . jiraIssueType || 'Task' ,
218310 labels : formData . jiraLabels ? formData . jiraLabels . split ( ',' ) . map ( s => s . trim ( ) ) . filter ( Boolean ) : [ ] ,
219311 assignee : formData . jiraAssignee || undefined ,
220- epicKey : formData . jiraEpicKey || undefined ,
312+ epicKey : formData . jiraEpicKey . trim ( ) || undefined ,
221313 createPR : formData . jiraCreatePR
222314 } : { enabled : false } ,
223315 datadog : formData . datadogEnabled ? {
@@ -536,7 +628,7 @@ export default function EditAppDrawer({ app, onClose, onSave }) {
536628 < select
537629 id = "edit-app-jira-instance"
538630 value = { formData . jiraInstanceId }
539- onChange = { e => setFormData ( { ... formData , jiraInstanceId : e . target . value , jiraProjectKey : '' } ) }
631+ onChange = { e => changeInstance ( e . target . value ) }
540632 className = "w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white focus:border-port-accent focus:outline-hidden"
541633 >
542634 < option value = "" > Select instance...</ option >
@@ -575,7 +667,7 @@ export default function EditAppDrawer({ app, onClose, onSave }) {
575667 { formData . jiraProjectKey && ! projectDropdownOpen && (
576668 < button
577669 type = "button"
578- onClick = { ( ) => setFormData ( { ... formData , jiraProjectKey : '' } ) }
670+ onClick = { ( ) => selectProject ( '' ) }
579671 className = "absolute right-2 top-8 text-gray-500 hover:text-white text-sm"
580672 >
581673 x
@@ -590,7 +682,7 @@ export default function EditAppDrawer({ app, onClose, onSave }) {
590682 type = "button"
591683 onMouseDown = { e => {
592684 e . preventDefault ( ) ;
593- setFormData ( { ... formData , jiraProjectKey : proj . key } ) ;
685+ selectProject ( proj . key ) ;
594686 setProjectDropdownOpen ( false ) ;
595687 setProjectSearch ( '' ) ;
596688 } }
@@ -622,15 +714,48 @@ export default function EditAppDrawer({ app, onClose, onSave }) {
622714 </ div >
623715
624716 < div >
625- < label htmlFor = "edit-app-jira-board" className = "block text-sm text-gray-400 mb-1" > Board ID</ label >
626- < input
627- id = "edit-app-jira-board"
628- type = "text"
629- value = { formData . jiraBoardId }
630- onChange = { e => setFormData ( { ...formData , jiraBoardId : e . target . value } ) }
631- className = "w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white focus:border-port-accent focus:outline-hidden"
632- placeholder = "e.g. 11810 (from JIRA board URL rapidView param)"
633- />
717+ < label htmlFor = "edit-app-jira-board" className = "block text-sm text-gray-400 mb-1" > Board</ label >
718+ { ! formData . jiraProjectKey ? (
719+ < div className = "text-xs text-gray-500" > Select a project to detect its boards.</ div >
720+ ) : loadingBoards ? (
721+ < div className = "text-xs text-gray-500" > Detecting boards…</ div >
722+ ) : jiraBoards . length > 0 ? (
723+ < >
724+ < select
725+ id = "edit-app-jira-board"
726+ value = { formData . jiraBoardId }
727+ onChange = { e => setFormData ( { ...formData , jiraBoardId : e . target . value } ) }
728+ className = "w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white focus:border-port-accent focus:outline-hidden"
729+ >
730+ < option value = "" > Select board...</ option >
731+ { jiraBoards . map ( b => (
732+ < option key = { b . id } value = { String ( b . id ) } > { b . id } — { b . name } ({ b . type } )</ option >
733+ ) ) }
734+ { boardIsStale && (
735+ < option value = { formData . jiraBoardId } > { formData . jiraBoardId } — (not in this project)</ option >
736+ ) }
737+ </ select >
738+ { boardIsStale ? (
739+ < p className = "text-xs text-port-warning mt-1" > ⚠ Saved board { formData . jiraBoardId } isn't among this project's boards — pick a current one.</ p >
740+ ) : formData . jiraBoardId && (
741+ < p className = "text-xs text-gray-500 mt-1" >
742+ { activeSprint ? `Active sprint: ${ activeSprint . name } ` : 'No active sprint on this board.' }
743+ </ p >
744+ ) }
745+ </ >
746+ ) : (
747+ < >
748+ < input
749+ id = "edit-app-jira-board"
750+ type = "text"
751+ value = { formData . jiraBoardId }
752+ onChange = { e => setFormData ( { ...formData , jiraBoardId : e . target . value } ) }
753+ className = "w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white focus:border-port-accent focus:outline-hidden"
754+ placeholder = "e.g. 1294 (from JIRA board URL rapidView param)"
755+ />
756+ < p className = "text-xs text-gray-500 mt-1" > Couldn't auto-detect boards — enter the id manually (board URL < span className = "font-mono" > rapidView</ span > param).</ p >
757+ </ >
758+ ) }
634759 </ div >
635760
636761 < div className = "grid grid-cols-2 gap-3" >
@@ -670,16 +795,49 @@ export default function EditAppDrawer({ app, onClose, onSave }) {
670795 />
671796 </ div >
672797
673- < div >
798+ < div className = "relative" >
674799 < label htmlFor = "edit-app-jira-epic" className = "block text-sm text-gray-400 mb-1" > Epic Key</ label >
675800 < input
676801 id = "edit-app-jira-epic"
677802 type = "text"
678803 value = { formData . jiraEpicKey }
679804 onChange = { e => setFormData ( { ...formData , jiraEpicKey : e . target . value } ) }
805+ onFocus = { ( ) => setEpicDropdownOpen ( true ) }
806+ onBlur = { ( ) => setTimeout ( ( ) => setEpicDropdownOpen ( false ) , 150 ) }
680807 className = "w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white focus:border-port-accent focus:outline-hidden"
681- placeholder = "e.g. CONTECH-100"
808+ placeholder = "e.g. CONTECH-100, or type a name to search "
682809 />
810+ { epicDropdownOpen && epicResults . length > 0 && (
811+ < div className = "absolute z-50 w-full mt-1 bg-port-bg border border-port-border rounded-lg max-h-48 overflow-auto shadow-lg" >
812+ { epicResults . map ( ep => (
813+ < button
814+ key = { ep . key }
815+ type = "button"
816+ onMouseDown = { e => {
817+ e . preventDefault ( ) ;
818+ setFormData ( { ...formData , jiraEpicKey : ep . key } ) ;
819+ setEpicDropdownOpen ( false ) ;
820+ } }
821+ className = "w-full text-left px-3 py-2 text-sm hover:bg-port-accent/20 text-white"
822+ >
823+ < span className = "font-mono" > { ep . key } </ span >
824+ < span className = "text-gray-400 ml-2" > { ep . summary } </ span >
825+ </ button >
826+ ) ) }
827+ </ div >
828+ ) }
829+ { epicValidation . state !== 'idle' && (
830+ < p className = { `text-xs mt-1 ${
831+ epicValidation . state === 'ok' ? 'text-port-success'
832+ : epicValidation . state === 'checking' ? 'text-gray-500'
833+ : 'text-port-warning'
834+ } `} >
835+ { epicValidation . state === 'checking' && 'Checking epic…' }
836+ { epicValidation . state === 'ok' && `✓ ${ epicValidation . issue . key } · ${ epicValidation . issue . summary } ` }
837+ { epicValidation . state === 'wrongtype' && `⚠ ${ epicValidation . issue . key } is a ${ epicValidation . issue . issueType } , not an Epic` }
838+ { epicValidation . state === 'stale' && `⚠ ${ formData . jiraEpicKey } doesn't resolve on this instance` }
839+ </ p >
840+ ) }
683841 </ div >
684842
685843 < label className = "flex items-center gap-2 cursor-pointer" >
0 commit comments