Files
grafana/public/app/features/scopes/ScopesService.ts
Andrej Ocenas 1aaf8adee4 Scopes: Global scopes search in command palette (#105597)
* Add pills in search bar for context

* Add scope actions

* Add selection functionality

* Show selected scope on secondary row

* Fix selected scope titles

* Add some basic tests

* Test for toggle by name

* Remove unnecessary mocking

* Small cleanups

* Lint fixes

* Fix test

* Update public/app/features/scopes/selector/ScopesSelectorService.ts

Co-authored-by: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com>

* Bump input and breadcrumbs test

* Change breadcrumbs color

* Makes the breacrumb spacing consistent

* Add basic global search

* Change scope selector data structures

* Fix scope selector functionality

* Fix errors in selector and cmdk actions

* Fix cmdk actions

* Fix global search in cmdk

* Fix some merge edits

* merge diffs

* Small merge fixes

* Fix ScopesSelectorService.test.ts

* Fix tests

* Remove unrelated lint fixes

* Move ScopesTreeItemList.tsx into separate file

* Simplify if condition

* Use node.title in the scopesRow

* Use better dependency array for actions

* Make recentScopes more robust

* Fix beterrer

* Update betterer file

* Add test for changeScopes early return

* Fix input tooltip title access

---------

Co-authored-by: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com>
2025-06-10 10:21:43 +02:00

193 lines
6.8 KiB
TypeScript

import { isEqual } from 'lodash';
import { BehaviorSubject, Observable, combineLatest, Subscription } from 'rxjs';
import { map, distinctUntilChanged } from 'rxjs/operators';
import { LocationService, ScopesContextValue, ScopesContextValueState } from '@grafana/runtime';
import { ScopesDashboardsService } from './dashboards/ScopesDashboardsService';
import { ScopesSelectorService } from './selector/ScopesSelectorService';
export interface State {
enabled: boolean;
readOnly: boolean;
}
/**
* The ScopesService is mainly an aggregation of the ScopesSelectorService and ScopesDashboardsService which handle
* the scope selection mechanics and then loading and showing related dashboards. We aggregate the state of these
* here in single service to serve as a public facade we can later publish through the grafana/runtime to plugins.
*/
export class ScopesService implements ScopesContextValue {
// Only internal part of the state.
private readonly _state: BehaviorSubject<State>;
// This will contain the combined state that will be public.
private readonly _stateObservable: BehaviorSubject<ScopesContextValueState>;
private subscriptions: Subscription[] = [];
constructor(
private selectorService: ScopesSelectorService,
private dashboardsService: ScopesDashboardsService,
private locationService: LocationService
) {
this._state = new BehaviorSubject<State>({
enabled: false,
readOnly: false,
});
this._stateObservable = new BehaviorSubject({
...this._state.getValue(),
value: this.selectorService.state.appliedScopes
.map((s) => this.selectorService.state.scopes[s.scopeId])
// Filter out scopes if we don't have actual scope data loaded yet
.filter((s) => s),
loading: this.selectorService.state.loading,
drawerOpened: this.dashboardsService.state.drawerOpened,
});
// We combine the latest emissions from this state + selectorService + dashboardsService.
this.subscriptions.push(
combineLatest([
this._state.asObservable(),
this.getSelectorServiceStateObservable(),
this.getDashboardsServiceStateObservable(),
])
.pipe(
// Map the 3 states into single ScopesContextValueState object
map(
([thisState, selectorState, dashboardsState]): ScopesContextValueState => ({
...thisState,
value: selectorState.selectedScopes,
loading: selectorState.loading,
drawerOpened: dashboardsState.drawerOpened,
})
)
)
// We pass this into behaviourSubject so we get the 1 event buffer and we can access latest value.
.subscribe(this._stateObservable)
);
// Init from the URL when we first load
const queryParams = new URLSearchParams(locationService.getLocation().search);
this.changeScopes(queryParams.getAll('scopes'));
// Update scopes state based on URL.
this.subscriptions.push(
locationService.getLocationObservable().subscribe((location) => {
if (!this.state.enabled) {
// We don't need to react on pages that don't interact with scopes.
return;
}
const queryParams = new URLSearchParams(location.search);
const scopes = queryParams.getAll('scopes');
//const scopesFromState = this.state.value.map((scope) => scope.metadata.name);
if (scopes.length) {
// We only update scopes but never delete them. This is to keep the scopes in memory if user navigates to
// page that does not use scopes (like from dashboard to dashboard list back to dashboard). If user
// changes the URL directly, it would trigger a reload so scopes would still be reset.
this.changeScopes(scopes);
}
})
);
// Update the URL based on change in the scopes state
this.subscriptions.push(
selectorService.subscribeToState((state, prev) => {
const oldScopeNames = prev.appliedScopes.map((scope) => scope.scopeId);
const newScopeNames = state.appliedScopes.map((scope) => scope.scopeId);
if (!isEqual(oldScopeNames, newScopeNames)) {
this.locationService.partial({ scopes: newScopeNames }, true);
}
})
);
}
/**
* This updates only the internal state of this service.
* @param newState
*/
private updateState = (newState: Partial<State>) => {
this._state.next({ ...this._state.getValue(), ...newState });
};
/**
* The state of this service is a combination of the downstream services state plus the state of this service.
*/
public get state(): ScopesContextValueState {
// As a side effect this also gives us memoizeOne on this so it should be safe to use in react without unnecessary
// rerenders.
return this._stateObservable.value;
}
public get stateObservable(): Observable<ScopesContextValueState> {
return this._stateObservable;
}
public changeScopes = (scopeNames: string[]) => this.selectorService.changeScopes(scopeNames);
public setReadOnly = (readOnly: boolean) => {
if (this.state.readOnly !== readOnly) {
this.updateState({ readOnly });
}
if (readOnly && this.selectorService.state.opened) {
this.selectorService.closeAndReset();
}
};
public setEnabled = (enabled: boolean) => {
if (this.state.enabled !== enabled) {
this.updateState({ enabled });
if (enabled) {
this.locationService.partial(
{
scopes: this.selectorService.state.appliedScopes.map((s) => s.scopeId),
},
true
);
}
}
};
/**
* Returns observable that emits when relevant parts of the selectorService state change.
* @private
*/
private getSelectorServiceStateObservable() {
return this.selectorService.stateObservable.pipe(
map((state) => ({
// We only need these 2 properties from the selectorService state.
// We do mapping here but mainly to make the distinctUntilChanged simpler
selectedScopes: state.appliedScopes
.map((s) => state.scopes[s.scopeId])
// Filter out scopes if we don't have actual scope data loaded yet
.filter((s) => s),
loading: state.loading,
})),
distinctUntilChanged(
(prev, curr) => prev.loading === curr.loading && isEqual(prev.selectedScopes, curr.selectedScopes)
)
);
}
/**
* Returns observable that emits when relevant parts of the dashboardService state change.
* @private
*/
private getDashboardsServiceStateObservable() {
return this.dashboardsService.stateObservable.pipe(
distinctUntilChanged((prev, curr) => prev.drawerOpened === curr.drawerOpened)
);
}
/**
* Cleanup subscriptions so this can be garbage collected.
*/
public cleanUp() {
for (const sub of this.subscriptions) {
sub.unsubscribe();
}
}
}