1414 * limitations under the License.
1515 */
1616
17- import React , { KeyboardEvent , useCallback , useEffect , useMemo , useState } from 'react' ;
17+ import React , { KeyboardEvent , useCallback , useEffect , useMemo , useRef , useState } from 'react' ;
1818import { QueryEditorProps , SelectableValue } from '@grafana/data' ;
19- import { Alert , InlineField , InlineFieldRow , AsyncSelect , LinkButton , Select , TextArea , Tooltip } from '@grafana/ui' ;
19+ import { Alert , InlineField , InlineFieldRow , LinkButton , Select , TextArea , Tooltip } from '@grafana/ui' ;
2020import { DataSource } from './datasource' ;
2121import { CloudLoggingOptions , defaultQuery , Query } from './types' ;
2222
@@ -34,33 +34,107 @@ export function LoggingQueryEditor({ datasource, query, range, onChange, onRunQu
3434 }
3535 } ;
3636
37- // Apply defaults if needed, and validate against project list filter
38- if ( ! query . projectId ) {
39- datasource . getDefaultProject ( ) . then ( r => {
40- if ( datasource . filterProjects ( [ r ] ) . length > 0 ) {
41- query . projectId = r ;
37+ // Keep a ref to the latest query so async callbacks avoid stale closures
38+ const queryRef = useRef ( query ) ;
39+ queryRef . current = query ;
40+
41+ // Compute normalized queryText as a derived value (never mutate the prop directly)
42+ const effectiveQueryText = query . query ?? query . queryText ?? defaultQuery . queryText ;
43+
44+ // Initialization effect: validates the carried-over project against the
45+ // datasource's actual filtered project list. The list is the source of truth;
46+ // the regex filter alone is insufficient because "no filter" passes everything,
47+ // so a stale projectId from a different datasource would slip through.
48+ useEffect ( ( ) => {
49+ let cancelled = false ;
50+
51+ const computeTextUpdates = ( q : Query ) : Partial < Query > => {
52+ const norm : Partial < Query > = { } ;
53+ if ( q . query ) {
54+ norm . queryText = q . query ;
55+ norm . query = undefined ;
56+ }
57+ if ( ( norm . queryText ?? q . queryText ) == null ) {
58+ norm . queryText = defaultQuery . queryText ;
59+ }
60+ return norm ;
61+ } ;
62+
63+ ( async ( ) => {
64+ const latestQuery = queryRef . current ;
65+ const textUpdates = computeTextUpdates ( latestQuery ) ;
66+ const currentProjectId = latestQuery . projectId ;
67+
68+ if ( currentProjectId && currentProjectId . startsWith ( '$' ) ) {
69+ if ( ! cancelled && Object . keys ( textUpdates ) . length > 0 ) {
70+ onChange ( { ...latestQuery , ...textUpdates } ) ;
71+ }
72+ return ;
4273 }
43- } ) ;
44- } else if ( ! query . projectId . startsWith ( '$' ) && datasource . filterProjects ( [ query . projectId ] ) . length === 0 ) {
45- // Previously selected project no longer passes the filter — clear it
46- query . projectId = '' ;
47- }
4874
49- if ( query . bucketId && ! query . bucketId . startsWith ( '$' ) && datasource . filterBuckets ( [ query . bucketId ] ) . length === 0 ) {
50- // Previously selected bucket no longer passes the filter — clear it
51- query . bucketId = '' ;
52- }
75+ const defaultProject = await datasource . getDefaultProject ( ) ;
76+ if ( cancelled ) { return ; }
5377
54- // Check query field from query params to support default way of propagating query from other parts of grafana.
55- if ( query . query ) {
56- query . queryText = query . query ;
57- query . query = undefined ;
58- }
78+ let cachedList : string [ ] | null = null ;
79+ let isValid = false ;
80+
81+ if ( ! currentProjectId ) {
82+ isValid = false ;
83+ } else if ( datasource . filterProjects ( [ currentProjectId ] ) . length === 0 ) {
84+ isValid = false ;
85+ } else {
86+ try {
87+ cachedList = await datasource . getFilteredProjects ( ) ;
88+ if ( cancelled ) { return ; }
89+ isValid = cachedList . includes ( currentProjectId ) ;
90+ } catch {
91+ // Transient API error — assume valid so we don't reset a saved
92+ // project on a flaky network. The eventual query will surface
93+ // any real access error.
94+ isValid = true ;
95+ }
96+ }
97+
98+ if ( isValid ) {
99+ const updates : Partial < Query > = { ...textUpdates } ;
100+ if ( latestQuery . bucketId && ! latestQuery . bucketId . startsWith ( '$' ) &&
101+ datasource . filterBuckets ( [ latestQuery . bucketId ] ) . length === 0 ) {
102+ updates . bucketId = '' ;
103+ }
104+ if ( Object . keys ( updates ) . length > 0 ) {
105+ onChange ( { ...latestQuery , ...updates } ) ;
106+ }
107+ return ;
108+ }
109+
110+ // Pick a replacement project.
111+ let newProjectId = '' ;
112+ if ( defaultProject && datasource . filterProjects ( [ defaultProject ] ) . length > 0 ) {
113+ newProjectId = defaultProject ;
114+ } else {
115+ let list = cachedList ;
116+ if ( list === null ) {
117+ try {
118+ list = await datasource . getFilteredProjects ( ) ;
119+ if ( cancelled ) { return ; }
120+ } catch {
121+ list = null ;
122+ }
123+ }
124+ if ( list && list . length > 0 ) {
125+ newProjectId = list [ 0 ] ;
126+ }
127+ }
59128
60- if ( query . queryText == null ) {
61- query . queryText = defaultQuery . queryText ;
62- }
129+ if ( cancelled ) { return ; }
130+ onChange ( { ...latestQuery , ...textUpdates , projectId : newProjectId , bucketId : '' , viewId : '' } ) ;
131+ if ( newProjectId ) {
132+ onRunQuery ( ) ;
133+ }
134+ } ) ( ) ;
63135
136+ return ( ) => { cancelled = true ; } ;
137+ } , [ datasource ] ) ;
64138
65139 const [ fetchError , setFetchError ] = useState < string | undefined > ( ) ;
66140
@@ -82,41 +156,89 @@ export function LoggingQueryEditor({ datasource, query, range, onChange, onRunQu
82156 return text ;
83157 } ;
84158
85- const loadProjects = useCallback ( ( inputValue : string ) : Promise < Array < SelectableValue < string > > > => {
86- return datasource . getFilteredProjects ( inputValue || undefined ) . then ( res => {
87- setFetchError ( undefined ) ;
88- return res . map ( project => ( {
89- label : project ,
90- value : project ,
91- } ) ) ;
92- } ) . catch ( err => {
93- setFetchError ( sanitizeFetchError ( err ) ) ;
94- return [ ] ;
95- } ) ;
159+ // Project list state, tagged with the DS uid it was loaded for. The uid tag
160+ // lets the picker's `options` derivation (`projectsForCurrentDs` below)
161+ // return [] whenever the loaded data doesn't belong to the current DS —
162+ // preventing react-select from auto-selecting/displaying a stale option
163+ // (e.g. the only entry in test1's list when we've switched to test2 but
164+ // test2's load hasn't resolved yet).
165+ const [ projectsState , setProjectsState ] = useState < {
166+ uid : string | null ;
167+ list : Array < SelectableValue < string > > ;
168+ } > ( { uid : null , list : [ ] } ) ;
169+ const [ projectsLoading , setProjectsLoading ] = useState ( false ) ;
170+ const searchTimer = useRef < ReturnType < typeof setTimeout > > ( ) ;
171+ const projectsForCurrentDs = projectsState . uid === datasource . uid
172+ ? projectsState . list
173+ : [ ] ;
174+
175+ useEffect ( ( ) => {
176+ let cancelled = false ;
177+ const loadingForUid = datasource . uid ;
178+ setProjectsLoading ( true ) ;
179+ datasource . getFilteredProjects ( )
180+ . then ( res => {
181+ if ( cancelled ) { return ; }
182+ setProjectsState ( {
183+ uid : loadingForUid ,
184+ list : res . map ( project => ( { label : project , value : project } ) ) ,
185+ } ) ;
186+ setFetchError ( undefined ) ;
187+ } )
188+ . catch ( err => {
189+ if ( cancelled ) { return ; }
190+ setProjectsState ( { uid : loadingForUid , list : [ ] } ) ;
191+ setFetchError ( sanitizeFetchError ( err ) ) ;
192+ } )
193+ . finally ( ( ) => {
194+ if ( ! cancelled ) { setProjectsLoading ( false ) ; }
195+ } ) ;
196+ return ( ) => {
197+ cancelled = true ;
198+ if ( searchTimer . current ) { clearTimeout ( searchTimer . current ) ; }
199+ } ;
200+ } , [ datasource ] ) ;
201+
202+ const onProjectSearchChange = useCallback ( ( value : string ) => {
203+ if ( searchTimer . current ) { clearTimeout ( searchTimer . current ) ; }
204+ const searchDsUid = datasource . uid ;
205+ setProjectsLoading ( true ) ;
206+ searchTimer . current = setTimeout ( ( ) => {
207+ datasource . getFilteredProjects ( value || undefined )
208+ . then ( res => {
209+ // Discard the response if the datasource changed during the debounce
210+ // or in-flight fetch. Without this, a late search response from the
211+ // OLD datasource could clobber a freshly-loaded list from the new one.
212+ if ( searchDsUid !== datasource . uid ) { return ; }
213+ setProjectsState ( {
214+ uid : searchDsUid ,
215+ list : res . map ( project => ( { label : project , value : project } ) ) ,
216+ } ) ;
217+ setFetchError ( undefined ) ;
218+ } )
219+ . catch ( err => {
220+ if ( searchDsUid !== datasource . uid ) { return ; }
221+ setFetchError ( sanitizeFetchError ( err ) ) ;
222+ } )
223+ . finally ( ( ) => {
224+ if ( searchDsUid === datasource . uid ) { setProjectsLoading ( false ) ; }
225+ } ) ;
226+ } , 300 ) ;
96227 } , [ datasource ] ) ;
97228
98229 const [ buckets , setBuckets ] = useState < Array < SelectableValue < string > > > ( ) ;
99230 useEffect ( ( ) => {
100- if ( ! query . projectId ) {
101- datasource . getDefaultProject ( ) . then ( r => {
102- query . projectId = r ;
103- datasource . getFilteredBuckets ( query . projectId ) . then ( res => {
104- setBuckets ( res . map ( bucket => ( {
105- label : bucket ,
106- value : bucket ,
107- } ) ) ) ;
108- } ) . catch ( err => setFetchError ( sanitizeFetchError ( err ) ) ) ;
109- } ) ;
110- } else if ( ! query . projectId . startsWith ( '$' ) ) {
111- datasource . getFilteredBuckets ( query . projectId ) . then ( res => {
112- setBuckets ( res . map ( bucket => ( {
113- label : bucket ,
114- value : bucket ,
115- } ) ) ) ;
116- setFetchError ( undefined ) ;
117- } ) . catch ( err => setFetchError ( sanitizeFetchError ( err ) ) ) ;
118- }
119- } , [ datasource , query . projectId ] ) ;
231+ if ( ! query . projectId || query . projectId . startsWith ( '$' ) ) { return ; }
232+ if ( projectsLoading ) { return ; }
233+ if ( projectsForCurrentDs . length > 0 && ! projectsForCurrentDs . some ( p => p . value === query . projectId ) ) { return ; }
234+ datasource . getFilteredBuckets ( query . projectId ) . then ( res => {
235+ setBuckets ( res . map ( bucket => ( {
236+ label : bucket ,
237+ value : bucket ,
238+ } ) ) ) ;
239+ setFetchError ( undefined ) ;
240+ } ) . catch ( err => setFetchError ( sanitizeFetchError ( err ) ) ) ;
241+ } , [ datasource , query . projectId , projectsLoading , projectsForCurrentDs ] ) ;
120242
121243 const [ views , setViews ] = useState < Array < SelectableValue < string > > > ( ) ;
122244 useEffect ( ( ) => {
@@ -141,7 +263,7 @@ export function LoggingQueryEditor({ datasource, query, range, onChange, onRunQu
141263 * Keep an up-to-date URI that links to the equivalent query in the GCP console
142264 */
143265 const gcpConsoleURI = useMemo < string | undefined > ( ( ) => {
144- if ( ! query . queryText ) {
266+ if ( ! effectiveQueryText ) {
145267 return undefined ;
146268 }
147269
@@ -169,7 +291,7 @@ export function LoggingQueryEditor({ datasource, query, range, onChange, onRunQu
169291 storageScope = `;storageScope=${ scopePath . replace ( / \/ / g, '%2F' ) } ` ;
170292 }
171293
172- const encodedText = encodeURIComponent ( `${ query . queryText } ` ) . replace ( / [ ! ' ( ) * ] / g, function ( c ) {
294+ const encodedText = encodeURIComponent ( `${ effectiveQueryText } ` ) . replace ( / [ ! ' ( ) * ] / g, function ( c ) {
173295 if ( c === '(' || c === ')' ) {
174296 return '%25' + c . charCodeAt ( 0 ) . toString ( 16 ) ;
175297 }
@@ -197,7 +319,8 @@ export function LoggingQueryEditor({ datasource, query, range, onChange, onRunQu
197319 < >
198320 < InlineFieldRow >
199321 < InlineField label = 'Project ID' >
200- < AsyncSelect
322+ < Select
323+ key = { datasource . uid }
201324 width = { 30 }
202325 allowCustomValue
203326 formatCreateLabel = { ( v ) => `Use project: ${ v } ` }
@@ -207,8 +330,18 @@ export function LoggingQueryEditor({ datasource, query, range, onChange, onRunQu
207330 bucketId : query . bucketId && query . bucketId . startsWith ( '$' ) ? query . bucketId : "" ,
208331 viewId : query . viewId && query . viewId . startsWith ( '$' ) ? query . viewId : "" ,
209332 } ) }
210- loadOptions = { loadProjects }
211- defaultOptions
333+ options = { projectsForCurrentDs }
334+ isLoading = { projectsLoading }
335+ onInputChange = { onProjectSearchChange }
336+ filterOption = { ( ) => true }
337+ // Pass value as a self-contained literal — do NOT derive it by
338+ // looking up query.projectId in the options list. react-select's
339+ // internal `cleanValue(value, options)` reconciliation lags under
340+ // React 18 concurrent rendering, which would make the picker
341+ // visually stuck on stale data on DS switch (only "fixed" by
342+ // opening DevTools, which forces a paint flush).
343+ // The init effect keeps query.projectId valid; the picker just
344+ // displays whatever it says. See react-select#4936.
212345 value = { query . projectId ? { label : query . projectId , value : query . projectId } : undefined }
213346 placeholder = "Select Project"
214347 inputId = { `${ query . refId } -project` }
@@ -252,7 +385,7 @@ export function LoggingQueryEditor({ datasource, query, range, onChange, onRunQu
252385 < TextArea
253386 name = "Query"
254387 className = "slate-query-field"
255- value = { query . queryText }
388+ value = { effectiveQueryText }
256389 rows = { 10 }
257390 placeholder = "Enter a Cloud Logging query (Run with Shift+Enter)"
258391 onBlur = { onRunQuery }
0 commit comments