mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Panel options UI: Allow collapsible categories (#30301)
This commit is contained in:
parent
6a2b0dde63
commit
bedd662c07
@ -24,7 +24,6 @@ export function createFieldConfigRegistry<TFieldConfigOptions>(
|
|||||||
|
|
||||||
for (const customProp of builder.getRegistry().list()) {
|
for (const customProp of builder.getRegistry().list()) {
|
||||||
customProp.isCustom = true;
|
customProp.isCustom = true;
|
||||||
customProp.category = [`${pluginName} options`].concat(customProp.category || []);
|
|
||||||
// need to do something to make the custom items not conflict with standard ones
|
// need to do something to make the custom items not conflict with standard ones
|
||||||
// problem is id (registry index) is used as property path
|
// problem is id (registry index) is used as property path
|
||||||
// so sort of need a property path on the FieldPropertyEditorItem
|
// so sort of need a property path on the FieldPropertyEditorItem
|
||||||
|
@ -39,7 +39,7 @@ export interface OptionEditorConfig<TOptions, TSettings = any, TValue = any> {
|
|||||||
/**
|
/**
|
||||||
* Array of strings representing category of the option. First element in the array will make option render as collapsible section.
|
* Array of strings representing category of the option. First element in the array will make option render as collapsible section.
|
||||||
*/
|
*/
|
||||||
category?: string[];
|
category?: Array<string | undefined>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set this value if undefined
|
* Set this value if undefined
|
||||||
|
@ -127,7 +127,7 @@ export const Components = {
|
|||||||
backArrow: 'Go Back button',
|
backArrow: 'Go Back button',
|
||||||
},
|
},
|
||||||
OptionsGroup: {
|
OptionsGroup: {
|
||||||
toggle: (title: string) => `Options group ${title}`,
|
toggle: (title?: string) => (title ? `Options group ${title}` : 'Options group'),
|
||||||
},
|
},
|
||||||
PluginVisualization: {
|
PluginVisualization: {
|
||||||
item: (title: string) => `Plugin visualization item ${title}`,
|
item: (title: string) => `Plugin visualization item ${title}`,
|
||||||
|
@ -86,4 +86,178 @@ describe('DefaultFieldConfigEditor', () => {
|
|||||||
const editors = queryAllByLabelText(selectors.components.PanelEditor.FieldOptions.propertyEditor('Custom'));
|
const editors = queryAllByLabelText(selectors.components.PanelEditor.FieldOptions.propertyEditor('Custom'));
|
||||||
expect(editors).toHaveLength(2);
|
expect(editors).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('categories', () => {
|
||||||
|
it('should render uncategorized options under panel category', () => {
|
||||||
|
const plugin = new PanelPlugin(() => null).useFieldConfig({
|
||||||
|
standardOptions: {},
|
||||||
|
useCustomConfig: b => {
|
||||||
|
b.addBooleanSwitch({
|
||||||
|
name: 'a',
|
||||||
|
path: 'a',
|
||||||
|
} as FieldConfigEditorConfig<FakeFieldOptions>)
|
||||||
|
.addBooleanSwitch({
|
||||||
|
name: 'c',
|
||||||
|
path: 'c',
|
||||||
|
} as FieldConfigEditorConfig<FakeFieldOptions>)
|
||||||
|
.addTextInput({
|
||||||
|
name: 'b',
|
||||||
|
path: 'b',
|
||||||
|
} as FieldConfigEditorConfig<FakeFieldOptions>);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
plugin.meta.name = 'Test plugin';
|
||||||
|
|
||||||
|
const { queryAllByLabelText } = render(
|
||||||
|
<DefaultFieldConfigEditor data={[]} onChange={jest.fn()} plugin={plugin} config={fieldConfigMock} />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
queryAllByLabelText(selectors.components.OptionsGroup.toggle(`${plugin.meta.name} options/0`))
|
||||||
|
).toHaveLength(1);
|
||||||
|
|
||||||
|
expect(queryAllByLabelText(selectors.components.OptionsGroup.toggle(), { exact: false })).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render categorized options under custom category', () => {
|
||||||
|
const CATEGORY_NAME = 'Cat1';
|
||||||
|
const plugin = new PanelPlugin(() => null).useFieldConfig({
|
||||||
|
standardOptions: {},
|
||||||
|
useCustomConfig: b => {
|
||||||
|
b.addTextInput({
|
||||||
|
name: 'b',
|
||||||
|
path: 'b',
|
||||||
|
} as FieldConfigEditorConfig<FakeFieldOptions>)
|
||||||
|
.addBooleanSwitch({
|
||||||
|
name: 'a',
|
||||||
|
path: 'a',
|
||||||
|
category: [CATEGORY_NAME],
|
||||||
|
} as FieldConfigEditorConfig<FakeFieldOptions>)
|
||||||
|
.addBooleanSwitch({
|
||||||
|
name: 'c',
|
||||||
|
path: 'c',
|
||||||
|
category: [CATEGORY_NAME],
|
||||||
|
} as FieldConfigEditorConfig<FakeFieldOptions>);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
plugin.meta.name = 'Test plugin';
|
||||||
|
|
||||||
|
const { queryAllByLabelText } = render(
|
||||||
|
<DefaultFieldConfigEditor data={[]} onChange={jest.fn()} plugin={plugin} config={fieldConfigMock} />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
queryAllByLabelText(selectors.components.OptionsGroup.toggle(`${plugin.meta.name} options/0`))
|
||||||
|
).toHaveLength(1);
|
||||||
|
|
||||||
|
expect(queryAllByLabelText(selectors.components.OptionsGroup.toggle(`${CATEGORY_NAME}/1`))).toHaveLength(1);
|
||||||
|
|
||||||
|
expect(queryAllByLabelText(selectors.components.OptionsGroup.toggle(), { exact: false })).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow subcategories in panel category', () => {
|
||||||
|
const SUBCATEGORY_NAME = 'Sub1';
|
||||||
|
const plugin = new PanelPlugin(() => null).useFieldConfig({
|
||||||
|
standardOptions: {},
|
||||||
|
useCustomConfig: b => {
|
||||||
|
b.addTextInput({
|
||||||
|
name: 'b',
|
||||||
|
path: 'b',
|
||||||
|
category: [undefined, SUBCATEGORY_NAME],
|
||||||
|
} as FieldConfigEditorConfig<FakeFieldOptions>)
|
||||||
|
.addBooleanSwitch({
|
||||||
|
name: 'a',
|
||||||
|
path: 'a',
|
||||||
|
} as FieldConfigEditorConfig<FakeFieldOptions>)
|
||||||
|
.addBooleanSwitch({
|
||||||
|
name: 'c',
|
||||||
|
path: 'c',
|
||||||
|
} as FieldConfigEditorConfig<FakeFieldOptions>);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
plugin.meta.name = 'Test plugin';
|
||||||
|
|
||||||
|
const { queryAllByLabelText, queryAllByText } = render(
|
||||||
|
<DefaultFieldConfigEditor data={[]} onChange={jest.fn()} plugin={plugin} config={fieldConfigMock} />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
queryAllByLabelText(selectors.components.OptionsGroup.toggle(`${plugin.meta.name} options/0`))
|
||||||
|
).toHaveLength(1);
|
||||||
|
|
||||||
|
expect(queryAllByText(SUBCATEGORY_NAME, { exact: false })).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow subcategories in custom category', () => {
|
||||||
|
const CATEGORY_NAME = 'Cat1';
|
||||||
|
const SUBCATEGORY_NAME = 'Sub1';
|
||||||
|
const plugin = new PanelPlugin(() => null).useFieldConfig({
|
||||||
|
standardOptions: {},
|
||||||
|
useCustomConfig: b => {
|
||||||
|
b.addBooleanSwitch({
|
||||||
|
name: 'a',
|
||||||
|
path: 'a',
|
||||||
|
} as FieldConfigEditorConfig<FakeFieldOptions>)
|
||||||
|
.addBooleanSwitch({
|
||||||
|
name: 'c',
|
||||||
|
path: 'c',
|
||||||
|
} as FieldConfigEditorConfig<FakeFieldOptions>)
|
||||||
|
.addTextInput({
|
||||||
|
name: 'b',
|
||||||
|
path: 'b',
|
||||||
|
category: [CATEGORY_NAME, SUBCATEGORY_NAME],
|
||||||
|
} as FieldConfigEditorConfig<FakeFieldOptions>);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
plugin.meta.name = 'Test plugin';
|
||||||
|
|
||||||
|
const { queryAllByLabelText, queryAllByText } = render(
|
||||||
|
<DefaultFieldConfigEditor data={[]} onChange={jest.fn()} plugin={plugin} config={fieldConfigMock} />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
queryAllByLabelText(selectors.components.OptionsGroup.toggle(`${plugin.meta.name} options/0`))
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(queryAllByLabelText(selectors.components.OptionsGroup.toggle(`${CATEGORY_NAME}/1`))).toHaveLength(1);
|
||||||
|
|
||||||
|
expect(queryAllByText(SUBCATEGORY_NAME, { exact: false })).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render categories with hidden fields only', () => {
|
||||||
|
const CATEGORY_NAME = 'Cat1';
|
||||||
|
const SUBCATEGORY_NAME = 'Sub1';
|
||||||
|
const plugin = new PanelPlugin(() => null).useFieldConfig({
|
||||||
|
standardOptions: {},
|
||||||
|
useCustomConfig: b => {
|
||||||
|
b.addBooleanSwitch({
|
||||||
|
name: 'a',
|
||||||
|
path: 'a',
|
||||||
|
} as FieldConfigEditorConfig<FakeFieldOptions>)
|
||||||
|
.addBooleanSwitch({
|
||||||
|
name: 'c',
|
||||||
|
path: 'c',
|
||||||
|
} as FieldConfigEditorConfig<FakeFieldOptions>)
|
||||||
|
.addTextInput({
|
||||||
|
name: 'b',
|
||||||
|
path: 'b',
|
||||||
|
hideFromDefaults: true,
|
||||||
|
category: [CATEGORY_NAME, SUBCATEGORY_NAME],
|
||||||
|
} as FieldConfigEditorConfig<FakeFieldOptions>);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
plugin.meta.name = 'Test plugin';
|
||||||
|
|
||||||
|
const { queryAllByLabelText, queryAllByText } = render(
|
||||||
|
<DefaultFieldConfigEditor data={[]} onChange={jest.fn()} plugin={plugin} config={fieldConfigMock} />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
queryAllByLabelText(selectors.components.OptionsGroup.toggle(`${plugin.meta.name} options/0`))
|
||||||
|
).toHaveLength(1);
|
||||||
|
|
||||||
|
expect(queryAllByLabelText(selectors.components.OptionsGroup.toggle(`${CATEGORY_NAME}/1`))).toHaveLength(0);
|
||||||
|
|
||||||
|
expect(queryAllByText(SUBCATEGORY_NAME, { exact: false })).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -34,7 +34,7 @@ export const DefaultFieldConfigEditor: React.FC<Props> = ({ data, onChange, conf
|
|||||||
: get(defaults, item.path);
|
: get(defaults, item.path);
|
||||||
|
|
||||||
let label: ReactNode | undefined = (
|
let label: ReactNode | undefined = (
|
||||||
<Label description={item.description} category={item.category?.slice(1)}>
|
<Label description={item.description} category={item.category?.slice(1) as string[]}>
|
||||||
{item.name}
|
{item.name}
|
||||||
</Label>
|
</Label>
|
||||||
);
|
);
|
||||||
@ -67,27 +67,39 @@ export const DefaultFieldConfigEditor: React.FC<Props> = ({ data, onChange, conf
|
|||||||
[config]
|
[config]
|
||||||
);
|
);
|
||||||
|
|
||||||
const groupedConfigs = groupBy(plugin.fieldConfigRegistry.list(), i => i.category && i.category[0]);
|
const GENERAL_OPTIONS_CATEGORY = `${plugin.meta.name} options`;
|
||||||
|
|
||||||
|
const groupedConfigs = groupBy(plugin.fieldConfigRegistry.list(), i => {
|
||||||
|
if (!i.category) {
|
||||||
|
return GENERAL_OPTIONS_CATEGORY;
|
||||||
|
}
|
||||||
|
return i.category[0] ? i.category[0] : GENERAL_OPTIONS_CATEGORY;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div aria-label={selectors.components.FieldConfigEditor.content}>
|
<div aria-label={selectors.components.FieldConfigEditor.content}>
|
||||||
{Object.keys(groupedConfigs).map((k, i) => {
|
{Object.keys(groupedConfigs).map((groupName, i) => {
|
||||||
const groupItemsCounter = countGroupItems(groupedConfigs[k], config);
|
const group = groupedConfigs[groupName];
|
||||||
|
const groupItemsCounter = countGroupItems(group, config);
|
||||||
|
|
||||||
|
if (!shouldRenderGroup(group)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OptionsGroup
|
<OptionsGroup
|
||||||
renderTitle={isExpanded => {
|
renderTitle={isExpanded => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{k} {!isExpanded && groupItemsCounter && <Counter value={groupItemsCounter} />}
|
{groupName} {!isExpanded && groupItemsCounter && <Counter value={groupItemsCounter} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
id={`${k}/${i}`}
|
id={`${groupName}/${i}`}
|
||||||
key={`${k}/${i}`}
|
key={`${groupName}/${i}`}
|
||||||
>
|
>
|
||||||
{groupedConfigs[k].map(c => {
|
{group.map(c => {
|
||||||
return renderEditor(c, groupedConfigs[k].length);
|
return renderEditor(c, group.length);
|
||||||
})}
|
})}
|
||||||
</OptionsGroup>
|
</OptionsGroup>
|
||||||
);
|
);
|
||||||
@ -96,7 +108,7 @@ export const DefaultFieldConfigEditor: React.FC<Props> = ({ data, onChange, conf
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const countGroupItems = (group: FieldConfigPropertyItem[], config: FieldConfigSource) => {
|
function countGroupItems(group: FieldConfigPropertyItem[], config: FieldConfigSource) {
|
||||||
let counter = 0;
|
let counter = 0;
|
||||||
|
|
||||||
for (const item of group) {
|
for (const item of group) {
|
||||||
@ -111,4 +123,9 @@ const countGroupItems = (group: FieldConfigPropertyItem[], config: FieldConfigSo
|
|||||||
}
|
}
|
||||||
|
|
||||||
return counter === 0 ? undefined : counter;
|
return counter === 0 ? undefined : counter;
|
||||||
};
|
}
|
||||||
|
|
||||||
|
function shouldRenderGroup(group: FieldConfigPropertyItem[]) {
|
||||||
|
const hiddenPropertiesCount = group.filter(i => i.hideFromDefaults).length;
|
||||||
|
return group.length - hiddenPropertiesCount > 0;
|
||||||
|
}
|
||||||
|
@ -35,7 +35,10 @@ export const DynamicConfigValueEditor: React.FC<DynamicConfigValueEditorProps> =
|
|||||||
// eslint-disable-next-line react/display-name
|
// eslint-disable-next-line react/display-name
|
||||||
const renderLabel = (includeDescription = true, includeCounter = false) => (isExpanded = false) => (
|
const renderLabel = (includeDescription = true, includeCounter = false) => (isExpanded = false) => (
|
||||||
<HorizontalGroup justify="space-between">
|
<HorizontalGroup justify="space-between">
|
||||||
<Label category={item.category?.splice(1)} description={includeDescription ? item.description : undefined}>
|
<Label
|
||||||
|
category={item.category?.filter(c => c !== undefined) as string[]}
|
||||||
|
description={includeDescription ? item.description : undefined}
|
||||||
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
{!isExpanded && includeCounter && item.getItemsCount && <Counter value={item.getItemsCount(property.value)} />}
|
{!isExpanded && includeCounter && item.getItemsCount && <Counter value={item.getItemsCount(property.value)} />}
|
||||||
</Label>
|
</Label>
|
||||||
|
@ -22,7 +22,7 @@ interface PanelOptionsEditorProps<TOptions> {
|
|||||||
options: TOptions;
|
options: TOptions;
|
||||||
onChange: (options: TOptions) => void;
|
onChange: (options: TOptions) => void;
|
||||||
}
|
}
|
||||||
|
const DISPLAY_OPTIONS_CATEGORY = 'Display';
|
||||||
export const PanelOptionsEditor: React.FC<PanelOptionsEditorProps<any>> = ({
|
export const PanelOptionsEditor: React.FC<PanelOptionsEditorProps<any>> = ({
|
||||||
plugin,
|
plugin,
|
||||||
options,
|
options,
|
||||||
@ -33,7 +33,10 @@ export const PanelOptionsEditor: React.FC<PanelOptionsEditorProps<any>> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const optionEditors = useMemo<Record<string, PanelOptionsEditorItem[]>>(() => {
|
const optionEditors = useMemo<Record<string, PanelOptionsEditorItem[]>>(() => {
|
||||||
return groupBy(plugin.optionEditors.list(), i => {
|
return groupBy(plugin.optionEditors.list(), i => {
|
||||||
return i.category ? i.category[0] : 'Display';
|
if (!i.category) {
|
||||||
|
return DISPLAY_OPTIONS_CATEGORY;
|
||||||
|
}
|
||||||
|
return i.category[0] ? i.category[0] : DISPLAY_OPTIONS_CATEGORY;
|
||||||
});
|
});
|
||||||
}, [plugin]);
|
}, [plugin]);
|
||||||
|
|
||||||
@ -62,7 +65,7 @@ export const PanelOptionsEditor: React.FC<PanelOptionsEditorProps<any>> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const label = (
|
const label = (
|
||||||
<Label description={e.description} category={e.category?.slice(1)}>
|
<Label description={e.description} category={e.category?.slice(1) as string[]}>
|
||||||
{e.name}
|
{e.name}
|
||||||
</Label>
|
</Label>
|
||||||
);
|
);
|
||||||
|
Loading…
Reference in New Issue
Block a user