Files
grafana/public/app/features/scopes/internal/ScopesSelectorScene.tsx
Ashley Harrison e43e430e6a SingleTopNav: Move scopes selector to second bar (#94186)
move scopes selector to second bar
2024-10-04 15:31:28 +01:00

408 lines
11 KiB
TypeScript

import { css } from '@emotion/css';
import { isEqual } from 'lodash';
import { finalize, from, Subscription } from 'rxjs';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import {
SceneComponentProps,
SceneObjectBase,
SceneObjectRef,
SceneObjectState,
SceneObjectUrlSyncConfig,
SceneObjectUrlValues,
SceneObjectWithUrlSync,
} from '@grafana/scenes';
import { Button, Drawer, IconButton, Spinner, useStyles2 } from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { t, Trans } from 'app/core/internationalization';
import { ScopesDashboardsScene } from './ScopesDashboardsScene';
import { ScopesInput } from './ScopesInput';
import { ScopesTree } from './ScopesTree';
import { fetchNodes, fetchScope, fetchSelectedScopes } from './api';
import { NodeReason, NodesMap, SelectedScope, TreeScope } from './types';
import { getBasicScope, getScopeNamesFromSelectedScopes, getTreeScopesFromSelectedScopes } from './utils';
export interface ScopesSelectorSceneState extends SceneObjectState {
dashboards: SceneObjectRef<ScopesDashboardsScene> | null;
nodes: NodesMap;
loadingNodeName: string | undefined;
scopes: SelectedScope[];
treeScopes: TreeScope[];
isReadOnly: boolean;
isLoadingScopes: boolean;
isPickerOpened: boolean;
isEnabled: boolean;
}
export const initialSelectorState: Omit<ScopesSelectorSceneState, 'dashboards'> = {
nodes: {
'': {
name: '',
reason: NodeReason.Result,
nodeType: 'container',
title: '',
isExpandable: true,
isSelectable: false,
isExpanded: true,
query: '',
nodes: {},
},
},
loadingNodeName: undefined,
scopes: [],
treeScopes: [],
isReadOnly: false,
isLoadingScopes: false,
isPickerOpened: false,
isEnabled: false,
};
export class ScopesSelectorScene extends SceneObjectBase<ScopesSelectorSceneState> implements SceneObjectWithUrlSync {
static Component = ScopesSelectorSceneRenderer;
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['scopes'] });
private nodesFetchingSub: Subscription | undefined;
constructor() {
super({
dashboards: null,
...initialSelectorState,
});
this.addActivationHandler(() => {
this.fetchBaseNodes();
return () => {
this.nodesFetchingSub?.unsubscribe();
};
});
}
public getUrlState() {
return {
scopes: this.state.isEnabled ? getScopeNamesFromSelectedScopes(this.state.scopes) : [],
};
}
public updateFromUrl(values: SceneObjectUrlValues) {
let scopeNames = values.scopes ?? [];
scopeNames = Array.isArray(scopeNames) ? scopeNames : [scopeNames];
this.updateScopes(scopeNames.map((scopeName) => ({ scopeName, path: [] })));
}
public fetchBaseNodes() {
return this.updateNode([''], true, '');
}
public async updateNode(path: string[], isExpanded: boolean, query: string) {
this.nodesFetchingSub?.unsubscribe();
let nodes = { ...this.state.nodes };
let currentLevel: NodesMap = nodes;
for (let idx = 0; idx < path.length - 1; idx++) {
currentLevel = currentLevel[path[idx]].nodes;
}
const name = path[path.length - 1];
const currentNode = currentLevel[name];
const isDifferentQuery = currentNode.query !== query;
currentNode.isExpanded = isExpanded;
currentNode.query = query;
if (isExpanded || isDifferentQuery) {
this.setState({ nodes, loadingNodeName: name });
this.nodesFetchingSub = from(fetchNodes(name, query))
.pipe(
finalize(() => {
this.setState({ loadingNodeName: undefined });
})
)
.subscribe((childNodes) => {
const persistedNodes = this.state.treeScopes
.map(({ path }) => path[path.length - 1])
.filter((nodeName) => nodeName in currentNode.nodes && !(nodeName in childNodes))
.reduce<NodesMap>((acc, nodeName) => {
acc[nodeName] = {
...currentNode.nodes[nodeName],
reason: NodeReason.Persisted,
};
return acc;
}, {});
currentNode.nodes = { ...persistedNodes, ...childNodes };
this.setState({ nodes });
this.nodesFetchingSub?.unsubscribe();
});
} else {
this.setState({ nodes, loadingNodeName: undefined });
}
}
public toggleNodeSelect(path: string[]) {
let treeScopes = [...this.state.treeScopes];
let parentNode = this.state.nodes[''];
for (let idx = 1; idx < path.length - 1; idx++) {
parentNode = parentNode.nodes[path[idx]];
}
const nodeName = path[path.length - 1];
const { linkId } = parentNode.nodes[nodeName];
const selectedIdx = treeScopes.findIndex(({ scopeName }) => scopeName === linkId);
if (selectedIdx === -1) {
fetchScope(linkId!);
const selectedFromSameNode =
treeScopes.length === 0 ||
Object.values(parentNode.nodes).some(({ linkId }) => linkId === treeScopes[0].scopeName);
const treeScope = {
scopeName: linkId!,
path,
};
this.setState({
treeScopes: parentNode?.disableMultiSelect || !selectedFromSameNode ? [treeScope] : [...treeScopes, treeScope],
});
} else {
treeScopes.splice(selectedIdx, 1);
this.setState({ treeScopes });
}
}
public openPicker() {
if (!this.state.isReadOnly) {
let nodes = { ...this.state.nodes };
// First close all nodes
nodes = this.closeNodes(nodes);
// Extract the path of a scope
let path = [...(this.state.scopes[0]?.path ?? ['', ''])];
path.splice(path.length - 1, 1);
// Expand the nodes to the selected scope
nodes = this.expandNodes(nodes, path);
this.setState({ isPickerOpened: true, nodes });
}
}
public closePicker() {
this.setState({ isPickerOpened: false });
}
public async updateScopes(treeScopes = this.state.treeScopes) {
if (isEqual(treeScopes, getTreeScopesFromSelectedScopes(this.state.scopes))) {
return;
}
this.setState({
// Update the scopes with the basic scopes otherwise they'd be lost between URL syncs
scopes: treeScopes.map(({ scopeName, path }) => ({ scope: getBasicScope(scopeName), path })),
treeScopes,
isLoadingScopes: true,
});
const scopes = await fetchSelectedScopes(treeScopes);
this.setState({ scopes, isLoadingScopes: false });
}
public resetDirtyScopeNames() {
this.setState({ treeScopes: getTreeScopesFromSelectedScopes(this.state.scopes) });
}
public removeAllScopes() {
this.setState({ scopes: [], treeScopes: [], isLoadingScopes: false });
}
public enterReadOnly() {
this.setState({ isReadOnly: true, isPickerOpened: false });
}
public exitReadOnly() {
this.setState({ isReadOnly: false });
}
public enable() {
this.setState({ isEnabled: true });
}
public disable() {
this.setState({ isEnabled: false });
}
private closeNodes(nodes: NodesMap): NodesMap {
return Object.entries(nodes).reduce<NodesMap>((acc, [id, node]) => {
acc[id] = {
...node,
isExpanded: false,
nodes: this.closeNodes(node.nodes),
};
return acc;
}, {});
}
private expandNodes(nodes: NodesMap, path: string[]): NodesMap {
nodes = { ...nodes };
let currentNodes = nodes;
for (let i = 0; i < path.length; i++) {
const nodeId = path[i];
currentNodes[nodeId] = {
...currentNodes[nodeId],
isExpanded: true,
};
currentNodes = currentNodes[nodeId].nodes;
}
return nodes;
}
}
export function ScopesSelectorSceneRenderer({ model }: SceneComponentProps<ScopesSelectorScene>) {
const { chrome } = useGrafana();
const state = chrome.useState();
const menuDockedAndOpen = !state.chromeless && state.megaMenuDocked && state.megaMenuOpen;
const styles = useStyles2(getStyles, menuDockedAndOpen);
const {
dashboards: dashboardsRef,
nodes,
loadingNodeName,
scopes,
treeScopes,
isReadOnly,
isLoadingScopes,
isPickerOpened,
isEnabled,
} = model.useState();
const dashboards = dashboardsRef?.resolve();
const { isPanelOpened: isDashboardsPanelOpened } = dashboards?.useState() ?? {};
if (!isEnabled) {
return null;
}
const dashboardsIconLabel = isReadOnly
? t('scopes.dashboards.toggle.disabled', 'Suggested dashboards list is disabled due to read only mode')
: isDashboardsPanelOpened
? t('scopes.dashboards.toggle.collapse', 'Collapse suggested dashboards list')
: t('scopes.dashboards.toggle..expand', 'Expand suggested dashboards list');
return (
<div className={styles.container}>
<IconButton
name="dashboard"
className={styles.dashboards}
aria-label={dashboardsIconLabel}
tooltip={dashboardsIconLabel}
data-testid="scopes-dashboards-expand"
disabled={isReadOnly}
onClick={() => dashboards?.togglePanel()}
/>
<ScopesInput
nodes={nodes}
scopes={scopes}
isDisabled={isReadOnly}
isLoading={isLoadingScopes}
onInputClick={() => model.openPicker()}
onRemoveAllClick={() => model.removeAllScopes()}
/>
{isPickerOpened && (
<Drawer
title={t('scopes.selector.title', 'Select scopes')}
size="sm"
onClose={() => {
model.closePicker();
model.resetDirtyScopeNames();
}}
>
{isLoadingScopes ? (
<Spinner data-testid="scopes-selector-loading" />
) : (
<ScopesTree
nodes={nodes}
nodePath={['']}
loadingNodeName={loadingNodeName}
scopes={treeScopes}
onNodeUpdate={(path, isExpanded, query) => model.updateNode(path, isExpanded, query)}
onNodeSelectToggle={(path) => model.toggleNodeSelect(path)}
/>
)}
<div className={styles.buttonGroup}>
<Button
variant="primary"
data-testid="scopes-selector-apply"
onClick={() => {
model.closePicker();
model.updateScopes();
}}
>
<Trans i18nKey="scopes.selector.apply">Apply</Trans>
</Button>
<Button
variant="secondary"
data-testid="scopes-selector-cancel"
onClick={() => {
model.closePicker();
model.resetDirtyScopeNames();
}}
>
<Trans i18nKey="scopes.selector.cancel">Cancel</Trans>
</Button>
</div>
</Drawer>
)}
</div>
);
}
const getStyles = (theme: GrafanaTheme2, menuDockedAndOpen: boolean) => {
return {
container: css({
display: 'flex',
flexDirection: 'row',
paddingLeft: menuDockedAndOpen ? theme.spacing(2) : 'unset',
...(!config.featureToggles.singleTopNav && {
paddingLeft: theme.spacing(2),
borderLeft: `1px solid ${theme.colors.border.weak}`,
}),
}),
dashboards: css({
color: theme.colors.text.secondary,
marginRight: theme.spacing(2),
'&:hover': css({
color: theme.colors.text.primary,
}),
}),
buttonGroup: css({
display: 'flex',
gap: theme.spacing(1),
marginTop: theme.spacing(8),
}),
};
};