Merge remote-tracking branch 'origin/main' into query-history-app
This commit is contained in:
@@ -146,7 +146,12 @@ type ScopeNodeSpec struct {
|
|||||||
|
|
||||||
NodeType NodeType `json:"nodeType"` // container | leaf
|
NodeType NodeType `json:"nodeType"` // container | leaf
|
||||||
|
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
//+optional
|
||||||
|
// Displays next to the title to provide more context.
|
||||||
|
SubTitle string `json:"subTitle,omitempty"`
|
||||||
|
|
||||||
|
//+optional
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
DisableMultiSelect bool `json:"disableMultiSelect"`
|
DisableMultiSelect bool `json:"disableMultiSelect"`
|
||||||
|
|
||||||
|
|||||||
@@ -838,6 +838,13 @@ func schema_pkg_apis_scope_v0alpha1_ScopeNodeSpec(ref common.ReferenceCallback)
|
|||||||
Format: "",
|
Format: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"subTitle": {
|
||||||
|
SchemaProps: spec.SchemaProps{
|
||||||
|
Description: "Displays next to the title to provide more context.",
|
||||||
|
Type: []string{"string"},
|
||||||
|
Format: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
"description": {
|
"description": {
|
||||||
SchemaProps: spec.SchemaProps{
|
SchemaProps: spec.SchemaProps{
|
||||||
Type: []string{"string"},
|
Type: []string{"string"},
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
openScopesSelector,
|
openScopesSelector,
|
||||||
searchScopes,
|
searchScopes,
|
||||||
selectScope,
|
selectScope,
|
||||||
TestScope,
|
|
||||||
} from '../utils/scope-helpers';
|
} from '../utils/scope-helpers';
|
||||||
import { testScopes } from '../utils/scopes';
|
import { testScopes } from '../utils/scopes';
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export async function scopeSelectRequest(page: Page, selectedScope: TestScope):
|
|||||||
export async function selectScope(page: Page, scopeName: string, selectedScope?: TestScope) {
|
export async function selectScope(page: Page, scopeName: string, selectedScope?: TestScope) {
|
||||||
const click = async () => {
|
const click = async () => {
|
||||||
const element = page.locator(
|
const element = page.locator(
|
||||||
`[data-testid="scopes-tree-${scopeName}-checkbox"], [data-testid="scopes-tree-${scopeName}-radio"]`
|
`[data-testid="scopes-tree-${scopeName}-checkbox"], [data-testid="scopes-tree-${scopeName}-radio"], [data-testid="scopes-tree-${scopeName}-link"]`
|
||||||
);
|
);
|
||||||
await element.scrollIntoViewIfNeeded();
|
await element.scrollIntoViewIfNeeded();
|
||||||
await element.click({ force: true });
|
await element.click({ force: true });
|
||||||
@@ -254,9 +254,9 @@ export async function getScopeTreeName(page: Page, nth: number): Promise<string>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getScopeLeafName(page: Page, nth: number): Promise<string> {
|
export async function getScopeLeafName(page: Page, nth: number): Promise<string> {
|
||||||
const locator = page.getByTestId(/^scopes-tree-.*-(checkbox|radio)/).nth(nth);
|
const locator = page.getByTestId(/^scopes-tree-.*-(checkbox|radio|link)/).nth(nth);
|
||||||
const fullTestId = await locator.getAttribute('data-testid');
|
const fullTestId = await locator.getAttribute('data-testid');
|
||||||
const scopeName = fullTestId?.replace(/^scopes-tree-/, '').replace(/-(checkbox|radio)/, '');
|
const scopeName = fullTestId?.replace(/^scopes-tree-/, '').replace(/-(checkbox|radio|link)/, '');
|
||||||
|
|
||||||
if (!scopeName) {
|
if (!scopeName) {
|
||||||
throw new Error('There are no scopes in the selector');
|
throw new Error('There are no scopes in the selector');
|
||||||
@@ -266,10 +266,11 @@ export async function getScopeLeafName(page: Page, nth: number): Promise<string>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getScopeLeafTitle(page: Page, nth: number): Promise<string> {
|
export async function getScopeLeafTitle(page: Page, nth: number): Promise<string> {
|
||||||
const leafLocator = page.getByTestId(/^scopes-tree-.*-(checkbox|radio)/).nth(nth);
|
// Get the nth selectable tree item (checkbox, radio, or link)
|
||||||
|
const leafLocator = page.getByTestId(/^scopes-tree-.*-(checkbox|radio|link)/).nth(nth);
|
||||||
// Find the closest ancestor element that has the main tree item test id
|
// Find the closest ancestor element that has the main tree item test id
|
||||||
const titleLocator = leafLocator.locator(
|
const titleLocator = leafLocator.locator(
|
||||||
'xpath=ancestor::*[@data-testid][starts-with(@data-testid, "scopes-tree-") and not(contains(@data-testid, "-checkbox")) and not(contains(@data-testid, "-radio")) and not(contains(@data-testid, "-expand"))]'
|
'xpath=ancestor::*[@data-testid][starts-with(@data-testid, "scopes-tree-") and not(contains(@data-testid, "-checkbox")) and not(contains(@data-testid, "-radio")) and not(contains(@data-testid, "-link")) and not(contains(@data-testid, "-expand"))]'
|
||||||
);
|
);
|
||||||
const scopeTitle = await titleLocator.textContent();
|
const scopeTitle = await titleLocator.textContent();
|
||||||
if (!scopeTitle) {
|
if (!scopeTitle) {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export type ScopeNodeLinkType = 'scope';
|
|||||||
export interface ScopeNodeSpec {
|
export interface ScopeNodeSpec {
|
||||||
nodeType: ScopeNodeNodeType;
|
nodeType: ScopeNodeNodeType;
|
||||||
title: string;
|
title: string;
|
||||||
|
subTitle?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
// If true for a scope category/type, it means only single child can be selected at a time.
|
// If true for a scope category/type, it means only single child can be selected at a time.
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ func (p *IndexProvider) HandleRequest(writer http.ResponseWriter, request *http.
|
|||||||
p.runIndexDataHooks(reqCtx, &data)
|
p.runIndexDataHooks(reqCtx, &data)
|
||||||
|
|
||||||
writer.Header().Set("Content-Type", "text/html; charset=UTF-8")
|
writer.Header().Set("Content-Type", "text/html; charset=UTF-8")
|
||||||
|
writer.Header().Set("Cache-Control", "no-store")
|
||||||
writer.WriteHeader(200)
|
writer.WriteHeader(200)
|
||||||
if err := p.index.Execute(writer, &data); err != nil {
|
if err := p.index.Execute(writer, &data); err != nil {
|
||||||
if errors.Is(err, syscall.EPIPE) { // Client has stopped listening.
|
if errors.Is(err, syscall.EPIPE) { // Client has stopped listening.
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ func TestFrontendService_WebAssets(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, 200, recorder.Code)
|
assert.Equal(t, 200, recorder.Code)
|
||||||
assert.Contains(t, recorder.Header().Get("Content-Type"), "text/html")
|
assert.Contains(t, recorder.Header().Get("Content-Type"), "text/html")
|
||||||
|
assert.Contains(t, recorder.Header().Get("Cache-Control"), "no-store")
|
||||||
|
|
||||||
// The response should contain references to the assets
|
// The response should contain references to the assets
|
||||||
body := recorder.Body.String()
|
body := recorder.Body.String()
|
||||||
|
|||||||
@@ -2,13 +2,177 @@ import { css, cx } from '@emotion/css';
|
|||||||
import Highlighter from 'react-highlight-words';
|
import Highlighter from 'react-highlight-words';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { t } from '@grafana/i18n';
|
import { Checkbox, Icon, RadioButtonDot, useStyles2, Text } from '@grafana/ui';
|
||||||
import { Checkbox, Icon, RadioButtonDot, useStyles2 } from '@grafana/ui';
|
|
||||||
|
import { useScopesServices } from '../ScopesContextProvider';
|
||||||
|
|
||||||
import { ScopesTree } from './ScopesTree';
|
import { ScopesTree } from './ScopesTree';
|
||||||
import { isNodeExpandable, isNodeSelectable } from './scopesTreeUtils';
|
import { isNodeExpandable, isNodeSelectable } from './scopesTreeUtils';
|
||||||
import { NodesMap, SelectedScope, TreeNode } from './types';
|
import { NodesMap, SelectedScope, TreeNode } from './types';
|
||||||
|
|
||||||
|
// Helper components for rendering different selectable content types
|
||||||
|
interface RadioButtonDotProps {
|
||||||
|
scopeNodeId: string;
|
||||||
|
selected: boolean;
|
||||||
|
onChange: () => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
'aria-labelledby'?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScopeRadioButtonDot({
|
||||||
|
scopeNodeId,
|
||||||
|
selected,
|
||||||
|
onChange,
|
||||||
|
children,
|
||||||
|
'aria-labelledby': ariaLabelledby,
|
||||||
|
}: RadioButtonDotProps) {
|
||||||
|
return (
|
||||||
|
<RadioButtonDot
|
||||||
|
id={scopeNodeId}
|
||||||
|
name={scopeNodeId}
|
||||||
|
checked={selected}
|
||||||
|
label={children ?? undefined}
|
||||||
|
data-testid={`scopes-tree-${scopeNodeId}-radio`}
|
||||||
|
onChange={onChange}
|
||||||
|
aria-labelledby={ariaLabelledby ?? undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkLikeButtonProps {
|
||||||
|
scopeNodeId: string;
|
||||||
|
onClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScopeLinkLikeButton({ scopeNodeId, onClick, children }: LinkLikeButtonProps) {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
return (
|
||||||
|
<button className={styles.linkLikeItem} data-testid={`scopes-tree-${scopeNodeId}-link`} onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CheckboxWithLabelProps {
|
||||||
|
scopeNodeId: string;
|
||||||
|
selected: boolean;
|
||||||
|
showLabel: boolean;
|
||||||
|
onChange: () => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
'aria-labelledby'?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScopeCheckboxWithLabel({
|
||||||
|
scopeNodeId,
|
||||||
|
selected,
|
||||||
|
showLabel,
|
||||||
|
onChange,
|
||||||
|
children,
|
||||||
|
'aria-labelledby': ariaLabelledby,
|
||||||
|
}: CheckboxWithLabelProps) {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
return (
|
||||||
|
<div className={styles.checkboxWithLabel}>
|
||||||
|
<Checkbox
|
||||||
|
id={scopeNodeId}
|
||||||
|
checked={selected}
|
||||||
|
data-testid={`scopes-tree-${scopeNodeId}-checkbox`}
|
||||||
|
label=""
|
||||||
|
onChange={onChange}
|
||||||
|
aria-labelledby={ariaLabelledby ?? undefined}
|
||||||
|
/>
|
||||||
|
{showLabel && (
|
||||||
|
<label htmlFor={scopeNodeId} className={styles.checkboxLabel}>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TitleContentProps {
|
||||||
|
shouldHighlight: boolean;
|
||||||
|
titleText: string;
|
||||||
|
searchWords: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function TitleContent({ shouldHighlight, titleText, searchWords }: TitleContentProps) {
|
||||||
|
if (shouldHighlight) {
|
||||||
|
return <Highlighter textToHighlight={titleText} searchWords={searchWords} autoEscape />;
|
||||||
|
}
|
||||||
|
return <>{titleText}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExpandButtonProps {
|
||||||
|
scopeNodeId: string;
|
||||||
|
expanded: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
controlsId: string;
|
||||||
|
isSelectable: boolean;
|
||||||
|
disableMultiSelect: boolean;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScopeExpandButton({
|
||||||
|
scopeNodeId,
|
||||||
|
expanded,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
controlsId,
|
||||||
|
isSelectable,
|
||||||
|
disableMultiSelect,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: ExpandButtonProps) {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
const buttonId = getTreeItemElementId(scopeNodeId) + '-button';
|
||||||
|
|
||||||
|
const SelectComponent = () => {
|
||||||
|
if (!isSelectable || expanded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (disableMultiSelect) {
|
||||||
|
return (
|
||||||
|
<ScopeRadioButtonDot
|
||||||
|
scopeNodeId={scopeNodeId}
|
||||||
|
selected={selected}
|
||||||
|
onChange={onSelect}
|
||||||
|
aria-labelledby={buttonId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ScopeCheckboxWithLabel
|
||||||
|
scopeNodeId={scopeNodeId}
|
||||||
|
selected={selected}
|
||||||
|
showLabel={false}
|
||||||
|
onChange={onSelect}
|
||||||
|
aria-labelledby={buttonId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SelectComponent />
|
||||||
|
<button
|
||||||
|
id={buttonId}
|
||||||
|
className={styles.expand}
|
||||||
|
data-testid={`scopes-tree-${scopeNodeId}-expand`}
|
||||||
|
onClick={onClick}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-controls={controlsId}
|
||||||
|
>
|
||||||
|
<Icon name={!expanded ? 'angle-right' : 'angle-down'} />
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface ScopesTreeItemProps {
|
export interface ScopesTreeItemProps {
|
||||||
anyChildExpanded: boolean;
|
anyChildExpanded: boolean;
|
||||||
loadingNodeName: string | undefined;
|
loadingNodeName: string | undefined;
|
||||||
@@ -38,7 +202,8 @@ export function ScopesTreeItem({
|
|||||||
toggleExpandedNode,
|
toggleExpandedNode,
|
||||||
}: ScopesTreeItemProps) {
|
}: ScopesTreeItemProps) {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
const services = useScopesServices();
|
||||||
|
const { closeAndApply } = services?.scopesSelectorService || {};
|
||||||
if (anyChildExpanded && !treeNode.expanded) {
|
if (anyChildExpanded && !treeNode.expanded) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -48,6 +213,7 @@ export function ScopesTreeItem({
|
|||||||
// Should not happen as only way we show a tree is if we also load the nodes.
|
// Should not happen as only way we show a tree is if we also load the nodes.
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parentNode = scopeNode.spec.parentName ? scopeNodes[scopeNode.spec.parentName] : undefined;
|
const parentNode = scopeNode.spec.parentName ? scopeNodes[scopeNode.spec.parentName] : undefined;
|
||||||
const disableMultiSelect = parentNode?.spec.disableMultiSelect ?? false;
|
const disableMultiSelect = parentNode?.spec.disableMultiSelect ?? false;
|
||||||
|
|
||||||
@@ -57,9 +223,11 @@ export function ScopesTreeItem({
|
|||||||
// Create search words for highlighting if there's a query
|
// Create search words for highlighting if there's a query
|
||||||
// Only highlight if we have a query AND this node is not expanded (not a parent showing children)
|
// Only highlight if we have a query AND this node is not expanded (not a parent showing children)
|
||||||
const titleText = scopeNode.spec.title;
|
const titleText = scopeNode.spec.title;
|
||||||
const shouldHighlight = treeNode.query && !treeNode.expanded;
|
const shouldHighlight = Boolean(treeNode.query && !treeNode.expanded);
|
||||||
const searchWords = shouldHighlight ? getSearchWordsFromQuery(treeNode.query) : [];
|
const searchWords = shouldHighlight ? getSearchWordsFromQuery(treeNode.query) : [];
|
||||||
|
|
||||||
|
const childrenId = getTreeItemElementId(treeNode.scopeNodeId) + '-children';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={treeNode.scopeNodeId}
|
key={treeNode.scopeNodeId}
|
||||||
@@ -78,75 +246,57 @@ export function ScopesTreeItem({
|
|||||||
)}
|
)}
|
||||||
data-testid={`scopes-tree-${treeNode.scopeNodeId}`}
|
data-testid={`scopes-tree-${treeNode.scopeNodeId}`}
|
||||||
>
|
>
|
||||||
{isSelectable && !treeNode.expanded ? (
|
{isSelectable && !isExpandable && !treeNode.expanded && (
|
||||||
disableMultiSelect ? (
|
<>
|
||||||
<RadioButtonDot
|
{disableMultiSelect && (
|
||||||
id={treeNode.scopeNodeId}
|
<ScopeLinkLikeButton
|
||||||
name={treeNode.scopeNodeId}
|
scopeNodeId={treeNode.scopeNodeId}
|
||||||
checked={selected}
|
onClick={() => {
|
||||||
label={
|
selectScope(treeNode.scopeNodeId);
|
||||||
isExpandable ? (
|
closeAndApply?.();
|
||||||
''
|
}}
|
||||||
) : shouldHighlight ? (
|
>
|
||||||
<Highlighter textToHighlight={titleText} searchWords={searchWords} autoEscape />
|
<TitleContent shouldHighlight={shouldHighlight} titleText={titleText} searchWords={searchWords} />
|
||||||
) : (
|
</ScopeLinkLikeButton>
|
||||||
titleText
|
)}
|
||||||
)
|
{!disableMultiSelect && (
|
||||||
}
|
<ScopeCheckboxWithLabel
|
||||||
data-testid={`scopes-tree-${treeNode.scopeNodeId}-radio`}
|
scopeNodeId={treeNode.scopeNodeId}
|
||||||
onClick={() => {
|
selected={selected}
|
||||||
selected ? deselectScope(treeNode.scopeNodeId) : selectScope(treeNode.scopeNodeId);
|
showLabel={!isExpandable}
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className={styles.checkboxWithLabel}>
|
|
||||||
<Checkbox
|
|
||||||
id={treeNode.scopeNodeId}
|
|
||||||
checked={selected}
|
|
||||||
data-testid={`scopes-tree-${treeNode.scopeNodeId}-checkbox`}
|
|
||||||
label=""
|
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
selected ? deselectScope(treeNode.scopeNodeId) : selectScope(treeNode.scopeNodeId);
|
selected ? deselectScope(treeNode.scopeNodeId) : selectScope(treeNode.scopeNodeId);
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
{!isExpandable && (
|
<TitleContent shouldHighlight={shouldHighlight} titleText={titleText} searchWords={searchWords} />
|
||||||
<label htmlFor={treeNode.scopeNodeId} className={styles.checkboxLabel}>
|
</ScopeCheckboxWithLabel>
|
||||||
{shouldHighlight ? (
|
)}
|
||||||
<Highlighter textToHighlight={titleText} searchWords={searchWords} autoEscape />
|
</>
|
||||||
) : (
|
)}
|
||||||
titleText
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{isExpandable && (
|
{isExpandable && (
|
||||||
<button
|
<ScopeExpandButton
|
||||||
className={styles.expand}
|
scopeNodeId={treeNode.scopeNodeId}
|
||||||
data-testid={`scopes-tree-${treeNode.scopeNodeId}-expand`}
|
expanded={treeNode.expanded}
|
||||||
aria-label={
|
controlsId={childrenId}
|
||||||
treeNode.expanded
|
onClick={() => toggleExpandedNode(treeNode.scopeNodeId)}
|
||||||
? t('scopes.tree.collapse', 'Collapse {{title}}', { title: titleText })
|
onSelect={() => (selected ? deselectScope(treeNode.scopeNodeId) : selectScope(treeNode.scopeNodeId))}
|
||||||
: t('scopes.tree.expand', 'Expand {{title}}', { title: titleText })
|
isSelectable={isSelectable}
|
||||||
}
|
disableMultiSelect={disableMultiSelect}
|
||||||
onClick={() => {
|
selected={selected}
|
||||||
toggleExpandedNode(treeNode.scopeNodeId);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Icon name={!treeNode.expanded ? 'angle-right' : 'angle-down'} />
|
<TitleContent shouldHighlight={shouldHighlight} titleText={titleText} searchWords={searchWords} />
|
||||||
|
</ScopeExpandButton>
|
||||||
|
)}
|
||||||
|
|
||||||
{shouldHighlight ? (
|
{scopeNode.spec.subTitle && (
|
||||||
<Highlighter textToHighlight={titleText} searchWords={searchWords} autoEscape />
|
<Text truncate variant="body" color="secondary">
|
||||||
) : (
|
{scopeNode.spec.subTitle}
|
||||||
titleText
|
</Text>
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.children}>
|
<div id={childrenId} className={styles.children}>
|
||||||
{treeNode.expanded && (
|
{treeNode.expanded && (
|
||||||
<ScopesTree
|
<ScopesTree
|
||||||
tree={treeNode}
|
tree={treeNode}
|
||||||
@@ -227,6 +377,20 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
margin: 0,
|
margin: 0,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
}),
|
}),
|
||||||
|
linkLikeItem: css({
|
||||||
|
alignItems: 'center',
|
||||||
|
background: 'none',
|
||||||
|
border: 0,
|
||||||
|
display: 'flex',
|
||||||
|
gap: theme.spacing(1),
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
textDecoration: 'none',
|
||||||
|
|
||||||
|
'&:hover': {
|
||||||
|
textDecoration: 'underline',
|
||||||
|
},
|
||||||
|
}),
|
||||||
children: css({
|
children: css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
|||||||
@@ -79,7 +79,12 @@ export function ScopesTreeSearch({
|
|||||||
setInputState({ value, dirty: true });
|
setInputState({ value, dirty: true });
|
||||||
}}
|
}}
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
onBlur={onBlur}
|
onBlur={() => {
|
||||||
|
// TODO:Handle weird race condition where the blur event interupts selection of a radio button. This is because disableHighlighting is called, which forces a re-render of the tree. This re-render causes the radio button to lose focus, and the selection to be interrupted.
|
||||||
|
setTimeout(() => {
|
||||||
|
onBlur();
|
||||||
|
}, 0);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useScopesServices } from '../ScopesContextProvider';
|
||||||
|
|
||||||
import { getTreeItemElementId } from './ScopesTreeItem';
|
import { getTreeItemElementId } from './ScopesTreeItem';
|
||||||
import { isNodeExpandable, isNodeSelectable } from './scopesTreeUtils';
|
import { isNodeExpandable, isNodeSelectable } from './scopesTreeUtils';
|
||||||
import { NodesMap, SelectedScope, TreeNode } from './types';
|
import { NodesMap, SelectedScope, TreeNode } from './types';
|
||||||
@@ -28,6 +30,8 @@ export function useScopesHighlighting({
|
|||||||
}: UseScopesHighlightingParams) {
|
}: UseScopesHighlightingParams) {
|
||||||
// Enable keyboard highlighting when the search field is focused
|
// Enable keyboard highlighting when the search field is focused
|
||||||
const [highlightEnabled, setHighlightEnabled] = useState(false);
|
const [highlightEnabled, setHighlightEnabled] = useState(false);
|
||||||
|
const services = useScopesServices();
|
||||||
|
const { changeScopes } = services?.scopesSelectorService || {};
|
||||||
|
|
||||||
const items = [...selectedNodes, ...resultNodes];
|
const items = [...selectedNodes, ...resultNodes];
|
||||||
|
|
||||||
@@ -52,6 +56,16 @@ export function useScopesHighlighting({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If parent has disableMultiSelect, apply scope directly
|
||||||
|
const parentNode = scopeNodes[nodeId]?.spec.parentName
|
||||||
|
? scopeNodes[scopeNodes[nodeId]?.spec.parentName]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (parentNode?.spec.disableMultiSelect && changeScopes && scopeNodes[nodeId]?.spec.linkId) {
|
||||||
|
changeScopes([scopeNodes[nodeId].spec.linkId], parentNode.metadata.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Toggle selection
|
// Toggle selection
|
||||||
if (selectedScopes.some((s) => s.scopeNodeId === nodeId)) {
|
if (selectedScopes.some((s) => s.scopeNodeId === nodeId)) {
|
||||||
deselectScope(nodeId);
|
deselectScope(nodeId);
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ import {
|
|||||||
selectResultCloud,
|
selectResultCloud,
|
||||||
selectResultCloudDev,
|
selectResultCloudDev,
|
||||||
selectResultCloudOps,
|
selectResultCloudOps,
|
||||||
|
expandResultEnvironments,
|
||||||
|
selectResultEnvironmentsDev,
|
||||||
|
selectResultEnvironmentsProd,
|
||||||
updateScopes,
|
updateScopes,
|
||||||
} from './utils/actions';
|
} from './utils/actions';
|
||||||
import {
|
import {
|
||||||
@@ -33,10 +36,10 @@ import {
|
|||||||
expectResultApplicationsMimirNotPresent,
|
expectResultApplicationsMimirNotPresent,
|
||||||
expectResultApplicationsMimirPresent,
|
expectResultApplicationsMimirPresent,
|
||||||
expectResultApplicationsMimirSelected,
|
expectResultApplicationsMimirSelected,
|
||||||
expectResultCloudDevNotSelected,
|
expectResultEnvironmentsDevNotSelected,
|
||||||
expectResultCloudDevSelected,
|
expectResultEnvironmentsDevSelected,
|
||||||
expectResultCloudOpsNotSelected,
|
expectResultEnvironmentsProdNotSelected,
|
||||||
expectResultCloudOpsSelected,
|
expectResultEnvironmentsProdSelected,
|
||||||
expectScopesHeadline,
|
expectScopesHeadline,
|
||||||
expectScopesSelectorValue,
|
expectScopesSelectorValue,
|
||||||
} from './utils/assertions';
|
} from './utils/assertions';
|
||||||
@@ -133,12 +136,33 @@ describe('Tree', () => {
|
|||||||
await openSelector();
|
await openSelector();
|
||||||
await expandResultCloud();
|
await expandResultCloud();
|
||||||
await selectResultCloudDev();
|
await selectResultCloudDev();
|
||||||
expectResultCloudDevSelected();
|
|
||||||
expectResultCloudOpsNotSelected();
|
|
||||||
|
|
||||||
|
// Verify the content of the scopes selector input
|
||||||
|
expectScopesSelectorValue('Dev');
|
||||||
|
|
||||||
|
// Single leaf node links always apply the scope, hence we need to open the selector again
|
||||||
|
await openSelector();
|
||||||
await selectResultCloudOps();
|
await selectResultCloudOps();
|
||||||
expectResultCloudDevNotSelected();
|
expectScopesSelectorValue('Ops');
|
||||||
expectResultCloudOpsSelected();
|
});
|
||||||
|
|
||||||
|
it('Can only select one selectable container at a time', async () => {
|
||||||
|
await openSelector();
|
||||||
|
await expandResultEnvironments();
|
||||||
|
|
||||||
|
// Select the Development environment container
|
||||||
|
await selectResultEnvironmentsDev();
|
||||||
|
expectResultEnvironmentsDevSelected(); // Check selection state before applying
|
||||||
|
expectResultEnvironmentsProdNotSelected(); // Production should not be selected
|
||||||
|
|
||||||
|
// Select the Production environment container - should replace Development
|
||||||
|
await selectResultEnvironmentsProd();
|
||||||
|
expectResultEnvironmentsProdSelected(); // Check selection state before applying
|
||||||
|
expectResultEnvironmentsDevNotSelected(); // Development should no longer be selected
|
||||||
|
|
||||||
|
// Apply scopes and verify final state
|
||||||
|
await applyScopes();
|
||||||
|
expectScopesSelectorValue('Production');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Search works', async () => {
|
it('Search works', async () => {
|
||||||
@@ -276,7 +300,7 @@ describe('Tree', () => {
|
|||||||
await openSelector();
|
await openSelector();
|
||||||
|
|
||||||
// Verify that Cloud is expanded
|
// Verify that Cloud is expanded
|
||||||
expect(screen.getByRole('button', { name: 'Collapse Cloud' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Cloud' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Keyboard Navigation', () => {
|
describe('Keyboard Navigation', () => {
|
||||||
|
|||||||
@@ -22,16 +22,19 @@ import {
|
|||||||
getResultApplicationsCloudSelect,
|
getResultApplicationsCloudSelect,
|
||||||
getResultApplicationsGrafanaSelect,
|
getResultApplicationsGrafanaSelect,
|
||||||
getResultApplicationsMimirSelect,
|
getResultApplicationsMimirSelect,
|
||||||
getResultCloudDevRadio,
|
|
||||||
getResultCloudExpand,
|
getResultCloudExpand,
|
||||||
getResultCloudOpsRadio,
|
|
||||||
getResultCloudSelect,
|
getResultCloudSelect,
|
||||||
|
getResultEnvironmentsExpand,
|
||||||
|
getResultEnvironmentsDevSelect,
|
||||||
|
getResultEnvironmentsProdSelect,
|
||||||
getSelectorApply,
|
getSelectorApply,
|
||||||
getSelectorCancel,
|
getSelectorCancel,
|
||||||
getSelectorClear,
|
getSelectorClear,
|
||||||
getSelectorInput,
|
getSelectorInput,
|
||||||
getTreeSearch,
|
getTreeSearch,
|
||||||
findResultApplicationsExpand,
|
findResultApplicationsExpand,
|
||||||
|
getResultCloudDevLink,
|
||||||
|
getResultCloudOpsLink,
|
||||||
} from './selectors';
|
} from './selectors';
|
||||||
|
|
||||||
const click = async (selector: () => HTMLElement) => act(() => userEvent.click(selector()));
|
const click = async (selector: () => HTMLElement) => act(() => userEvent.click(selector()));
|
||||||
@@ -68,8 +71,12 @@ export const selectResultApplicationsMimir = async () => click(getResultApplicat
|
|||||||
export const selectResultApplicationsCloud = async () => click(getResultApplicationsCloudSelect);
|
export const selectResultApplicationsCloud = async () => click(getResultApplicationsCloudSelect);
|
||||||
export const selectResultApplicationsCloudDev = async () => click(getResultApplicationsCloudDevSelect);
|
export const selectResultApplicationsCloudDev = async () => click(getResultApplicationsCloudDevSelect);
|
||||||
export const selectResultCloud = async () => click(getResultCloudSelect);
|
export const selectResultCloud = async () => click(getResultCloudSelect);
|
||||||
export const selectResultCloudDev = async () => click(getResultCloudDevRadio);
|
export const selectResultCloudDev = async () => click(getResultCloudDevLink);
|
||||||
export const selectResultCloudOps = async () => click(getResultCloudOpsRadio);
|
export const selectResultCloudOps = async () => click(getResultCloudOpsLink);
|
||||||
|
|
||||||
|
export const expandResultEnvironments = async () => click(getResultEnvironmentsExpand);
|
||||||
|
export const selectResultEnvironmentsDev = async () => click(getResultEnvironmentsDevSelect);
|
||||||
|
export const selectResultEnvironmentsProd = async () => click(getResultEnvironmentsProdSelect);
|
||||||
|
|
||||||
export const toggleDashboards = async () => click(getDashboardsExpand);
|
export const toggleDashboards = async () => click(getDashboardsExpand);
|
||||||
export const searchDashboards = async (value: string) => type(getDashboardsSearch, value);
|
export const searchDashboards = async (value: string) => type(getDashboardsSearch, value);
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import {
|
|||||||
getResultApplicationsCloudSelect,
|
getResultApplicationsCloudSelect,
|
||||||
getResultApplicationsGrafanaSelect,
|
getResultApplicationsGrafanaSelect,
|
||||||
getResultApplicationsMimirSelect,
|
getResultApplicationsMimirSelect,
|
||||||
getResultCloudDevRadio,
|
|
||||||
getResultCloudOpsRadio,
|
|
||||||
getSelectorInput,
|
getSelectorInput,
|
||||||
getTreeHeadline,
|
getTreeHeadline,
|
||||||
queryAllDashboard,
|
queryAllDashboard,
|
||||||
@@ -28,14 +26,16 @@ import {
|
|||||||
queryResultApplicationsCloudSelect,
|
queryResultApplicationsCloudSelect,
|
||||||
queryResultApplicationsGrafanaSelect,
|
queryResultApplicationsGrafanaSelect,
|
||||||
queryResultApplicationsMimirSelect,
|
queryResultApplicationsMimirSelect,
|
||||||
|
getResultEnvironmentsDevSelect,
|
||||||
|
getResultEnvironmentsProdSelect,
|
||||||
querySelectorApply,
|
querySelectorApply,
|
||||||
} from './selectors';
|
} from './selectors';
|
||||||
|
|
||||||
const expectInDocument = (selector: () => HTMLElement) => expect(selector()).toBeInTheDocument();
|
const expectInDocument = (selector: () => HTMLElement) => expect(selector()).toBeInTheDocument();
|
||||||
const expectNotInDocument = (selector: () => HTMLElement | null) => expect(selector()).not.toBeInTheDocument();
|
const expectNotInDocument = (selector: () => HTMLElement | null) => expect(selector()).not.toBeInTheDocument();
|
||||||
const expectChecked = (selector: () => HTMLInputElement) => expect(selector()).toBeChecked();
|
const expectChecked = (selector: () => HTMLInputElement) => expect(selector()).toBeChecked();
|
||||||
const expectRadioChecked = (selector: () => HTMLInputElement) => expect(selector().checked).toBe(true);
|
const expectNotChecked = (selector: () => HTMLInputElement) => expect(selector()).not.toBeChecked();
|
||||||
const expectRadioNotChecked = (selector: () => HTMLInputElement) => expect(selector().checked).toBe(false);
|
|
||||||
const expectValue = (selector: () => HTMLInputElement, value: string) => expect(selector().value).toBe(value);
|
const expectValue = (selector: () => HTMLInputElement, value: string) => expect(selector().value).toBe(value);
|
||||||
const expectTextContent = (selector: () => HTMLElement, text: string) => expect(selector()).toHaveTextContent(text);
|
const expectTextContent = (selector: () => HTMLElement, text: string) => expect(selector()).toHaveTextContent(text);
|
||||||
const expectDisabled = (selector: () => HTMLElement) => expect(selector()).toBeDisabled();
|
const expectDisabled = (selector: () => HTMLElement) => expect(selector()).toBeDisabled();
|
||||||
@@ -62,10 +62,11 @@ export const expectResultApplicationsMimirPresent = () => expectInDocument(getRe
|
|||||||
export const expectResultApplicationsMimirNotPresent = () => expectNotInDocument(queryResultApplicationsMimirSelect);
|
export const expectResultApplicationsMimirNotPresent = () => expectNotInDocument(queryResultApplicationsMimirSelect);
|
||||||
export const expectResultApplicationsCloudPresent = () => expectInDocument(getResultApplicationsCloudSelect);
|
export const expectResultApplicationsCloudPresent = () => expectInDocument(getResultApplicationsCloudSelect);
|
||||||
export const expectResultApplicationsCloudNotPresent = () => expectNotInDocument(queryResultApplicationsCloudSelect);
|
export const expectResultApplicationsCloudNotPresent = () => expectNotInDocument(queryResultApplicationsCloudSelect);
|
||||||
export const expectResultCloudDevSelected = () => expectRadioChecked(getResultCloudDevRadio);
|
|
||||||
export const expectResultCloudDevNotSelected = () => expectRadioNotChecked(getResultCloudDevRadio);
|
export const expectResultEnvironmentsDevSelected = () => expectChecked(getResultEnvironmentsDevSelect);
|
||||||
export const expectResultCloudOpsSelected = () => expectRadioChecked(getResultCloudOpsRadio);
|
export const expectResultEnvironmentsDevNotSelected = () => expectNotChecked(getResultEnvironmentsDevSelect);
|
||||||
export const expectResultCloudOpsNotSelected = () => expectRadioNotChecked(getResultCloudOpsRadio);
|
export const expectResultEnvironmentsProdSelected = () => expectChecked(getResultEnvironmentsProdSelect);
|
||||||
|
export const expectResultEnvironmentsProdNotSelected = () => expectNotChecked(getResultEnvironmentsProdSelect);
|
||||||
|
|
||||||
export const expectDashboardsDisabled = () => expectDisabled(getDashboardsExpand);
|
export const expectDashboardsDisabled = () => expectDisabled(getDashboardsExpand);
|
||||||
export const expectDashboardsClosed = () => expectNotInDocument(queryDashboardsContainer);
|
export const expectDashboardsClosed = () => expectNotInDocument(queryDashboardsContainer);
|
||||||
|
|||||||
@@ -59,6 +59,20 @@ export const mocksScopes: Scope[] = [
|
|||||||
filters: [{ key: 'app', value: 'tempo', operator: 'equals' }],
|
filters: [{ key: 'app', value: 'tempo', operator: 'equals' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
metadata: { name: 'dev-env' },
|
||||||
|
spec: {
|
||||||
|
title: 'Development',
|
||||||
|
filters: [{ key: 'environment', value: 'dev', operator: 'equals' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metadata: { name: 'prod-env' },
|
||||||
|
spec: {
|
||||||
|
title: 'Production',
|
||||||
|
filters: [{ key: 'environment', value: 'prod', operator: 'equals' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const dashboardBindingsGenerator = (
|
const dashboardBindingsGenerator = (
|
||||||
@@ -341,6 +355,38 @@ export const mocksNodes: ScopeNode[] = [
|
|||||||
parentName: 'cloud-applications',
|
parentName: 'cloud-applications',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
metadata: { name: 'environments' },
|
||||||
|
spec: {
|
||||||
|
nodeType: 'container',
|
||||||
|
title: 'Environments',
|
||||||
|
description: 'Environment Scopes',
|
||||||
|
disableMultiSelect: true,
|
||||||
|
parentName: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metadata: { name: 'environments-dev' },
|
||||||
|
spec: {
|
||||||
|
nodeType: 'container',
|
||||||
|
title: 'Development',
|
||||||
|
description: 'Development Environment',
|
||||||
|
linkType: 'scope',
|
||||||
|
linkId: 'dev-env',
|
||||||
|
parentName: 'environments',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metadata: { name: 'environments-prod' },
|
||||||
|
spec: {
|
||||||
|
nodeType: 'container',
|
||||||
|
title: 'Production',
|
||||||
|
description: 'Production Environment',
|
||||||
|
linkType: 'scope',
|
||||||
|
linkId: 'prod-env',
|
||||||
|
parentName: 'environments',
|
||||||
|
},
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const dashboardReloadSpy = jest.spyOn(getDashboardScenePageStateManager(), 'reloadDashboard');
|
export const dashboardReloadSpy = jest.spyOn(getDashboardScenePageStateManager(), 'reloadDashboard');
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const selectors = {
|
|||||||
headline: 'scopes-tree-headline',
|
headline: 'scopes-tree-headline',
|
||||||
select: (nodeId: string) => `scopes-tree-${nodeId}-checkbox`,
|
select: (nodeId: string) => `scopes-tree-${nodeId}-checkbox`,
|
||||||
radio: (nodeId: string) => `scopes-tree-${nodeId}-radio`,
|
radio: (nodeId: string) => `scopes-tree-${nodeId}-radio`,
|
||||||
|
link: (nodeId: string) => `scopes-tree-${nodeId}-link`,
|
||||||
expand: (nodeId: string) => `scopes-tree-${nodeId}-expand`,
|
expand: (nodeId: string) => `scopes-tree-${nodeId}-expand`,
|
||||||
title: (nodeId: string) => `scopes-tree-${nodeId}-title`,
|
title: (nodeId: string) => `scopes-tree-${nodeId}-title`,
|
||||||
},
|
},
|
||||||
@@ -93,7 +94,15 @@ export const getResultApplicationsCloudDevSelect = () =>
|
|||||||
|
|
||||||
export const getResultCloudSelect = () => screen.getByTestId(selectors.tree.select('cloud'));
|
export const getResultCloudSelect = () => screen.getByTestId(selectors.tree.select('cloud'));
|
||||||
export const getResultCloudExpand = () => screen.getByTestId(selectors.tree.expand('cloud'));
|
export const getResultCloudExpand = () => screen.getByTestId(selectors.tree.expand('cloud'));
|
||||||
export const getResultCloudDevRadio = () => screen.getByTestId<HTMLInputElement>(selectors.tree.radio('cloud-dev'));
|
export const getResultCloudDevLink = () => screen.getByTestId<HTMLButtonElement>(selectors.tree.link('cloud-dev'));
|
||||||
export const getResultCloudOpsRadio = () => screen.getByTestId<HTMLInputElement>(selectors.tree.radio('cloud-ops'));
|
export const getResultCloudOpsLink = () => screen.getByTestId<HTMLButtonElement>(selectors.tree.link('cloud-ops'));
|
||||||
|
|
||||||
|
export const getResultEnvironmentsExpand = () => screen.getByTestId(selectors.tree.expand('environments'));
|
||||||
|
export const getResultEnvironmentsDevSelect = () =>
|
||||||
|
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('environments-dev'));
|
||||||
|
export const getResultEnvironmentsProdSelect = () =>
|
||||||
|
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('environments-prod'));
|
||||||
|
export const queryResultEnvironmentsDevSelect = () => screen.queryByTestId(selectors.tree.radio('environments-dev'));
|
||||||
|
export const queryResultEnvironmentsProdSelect = () => screen.queryByTestId(selectors.tree.radio('environments-prod'));
|
||||||
|
|
||||||
export const getListOfScopes = (service: ScopesService) => service.state.value;
|
export const getListOfScopes = (service: ScopesService) => service.state.value;
|
||||||
|
|||||||
@@ -12305,8 +12305,6 @@
|
|||||||
"title": "Select scopes"
|
"title": "Select scopes"
|
||||||
},
|
},
|
||||||
"tree": {
|
"tree": {
|
||||||
"collapse": "Collapse {{title}}",
|
|
||||||
"expand": "Expand {{title}}",
|
|
||||||
"headline": {
|
"headline": {
|
||||||
"noResults": "No results found for your query",
|
"noResults": "No results found for your query",
|
||||||
"recommended": "Recommended",
|
"recommended": "Recommended",
|
||||||
|
|||||||
Reference in New Issue
Block a user