Commit bedd662c by Dominik Prokop Committed by GitHub

Panel options UI: Allow collapsible categories (#30301)

parent 6a2b0dde
...@@ -24,7 +24,6 @@ export function createFieldConfigRegistry<TFieldConfigOptions>( ...@@ -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> { ...@@ -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 = { ...@@ -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', () => { ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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> = ...@@ -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> { ...@@ -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>> = ({ ...@@ -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>> = ({ ...@@ -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>
); );
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment