[Embeddable Rebuild] [Controls] Add order to control factory (#189670)

Closes https://github.com/elastic/kibana/issues/189407

## Summary


This PR adds an `order` attribute to the control factory so that the
ordering in the UI remains consistent - previously, the order was
determined by the order the factories were registered in (which is no
longer predictable now that the registration happens `async` - it's hard
to repro, but there were times where something delayed the options list
registration and it would appear at the end of my list). Adding and
sorting the UI based on the `order` of the factory removes this
uncertainty.

### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Hannah Mudge 2024-08-02 10:30:41 -06:00 committed by GitHub
parent 73d85c38c4
commit e5cb696f47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 81 additions and 12 deletions

View file

@ -32,7 +32,7 @@ import {
getMockedSearchControlFactory,
} from './mocks/factory_mocks';
import { ControlFactory } from '../types';
import { DataControlApi, DefaultDataControlState } from './types';
import { DataControlApi, DataControlFactory, DefaultDataControlState } from './types';
const mockDataViews = dataViewPluginMocks.createStartContract();
const mockDataView = createStubDataView({
@ -106,13 +106,13 @@ describe('Data control editor', () => {
return controlEditor.getByTestId(testId).getAttribute('aria-pressed');
};
const mockRegistry: { [key: string]: ControlFactory<DefaultDataControlState, DataControlApi> } = {
search: getMockedSearchControlFactory({ parentApi: controlGroupApi }),
optionsList: getMockedOptionsListControlFactory({ parentApi: controlGroupApi }),
rangeSlider: getMockedRangeSliderControlFactory({ parentApi: controlGroupApi }),
};
beforeAll(() => {
const mockRegistry: { [key: string]: ControlFactory<DefaultDataControlState, DataControlApi> } =
{
search: getMockedSearchControlFactory({ parentApi: controlGroupApi }),
optionsList: getMockedOptionsListControlFactory({ parentApi: controlGroupApi }),
rangeSlider: getMockedRangeSliderControlFactory({ parentApi: controlGroupApi }),
};
(getAllControlTypes as jest.Mock).mockReturnValue(Object.keys(mockRegistry));
(getControlFactory as jest.Mock).mockImplementation((key) => mockRegistry[key]);
});
@ -133,6 +133,50 @@ describe('Data control editor', () => {
expect(saveButton).toBeEnabled();
});
test('CompatibleControlTypesComponent respects ordering', async () => {
const tempRegistry: {
[key: string]: ControlFactory<DefaultDataControlState, DataControlApi>;
} = {
...mockRegistry,
alphabeticalFirstControl: {
type: 'alphabeticalFirst',
getIconType: () => 'lettering',
getDisplayName: () => 'Alphabetically first',
isFieldCompatible: () => true,
buildControl: jest.fn().mockReturnValue({
api: controlGroupApi,
Component: <>Should be first alphabetically</>,
}),
} as DataControlFactory,
supremeControl: {
type: 'supremeControl',
order: 100, // force it first despite alphabetical ordering
getIconType: () => 'starFilled',
getDisplayName: () => 'Supreme leader',
isFieldCompatible: () => true,
buildControl: jest.fn().mockReturnValue({
api: controlGroupApi,
Component: <>This control is forced first via the factory order</>,
}),
} as DataControlFactory,
};
(getAllControlTypes as jest.Mock).mockReturnValue(Object.keys(tempRegistry));
(getControlFactory as jest.Mock).mockImplementation((key) => tempRegistry[key]);
const controlEditor = await mountComponent({});
const menu = controlEditor.getByTestId('controlTypeMenu');
expect(menu.children.length).toEqual(5);
expect(menu.children[0].textContent).toEqual('Supreme leader'); // forced first - ignore alphabetical sorting
// the rest should be alphabetically sorted
expect(menu.children[1].textContent).toEqual('Alphabetically first');
expect(menu.children[2].textContent).toEqual('Options list');
expect(menu.children[3].textContent).toEqual('Range slider');
expect(menu.children[4].textContent).toEqual('Search');
(getAllControlTypes as jest.Mock).mockReturnValue(Object.keys(mockRegistry));
(getControlFactory as jest.Mock).mockImplementation((key) => mockRegistry[key]);
});
test('selecting a keyword field - can only create an options list control', async () => {
const controlEditor = await mountComponent({});
await selectField(controlEditor, 'machine.os.raw');

View file

@ -80,9 +80,18 @@ const CompatibleControlTypesComponent = ({
const dataControlFactories = useMemo(() => {
return getAllControlTypes()
.map((type) => getControlFactory(type))
.filter((factory) => {
return isDataControlFactory(factory);
});
.filter((factory) => isDataControlFactory(factory))
.sort(
(
{ order: orderA = 0, getDisplayName: getDisplayNameA },
{ order: orderB = 0, getDisplayName: getDisplayNameB }
) => {
const orderComparison = orderB - orderA; // sort descending by order
return orderComparison === 0
? getDisplayNameA().localeCompare(getDisplayNameB()) // if equal order, compare display names
: orderComparison;
}
);
}, []);
return (
@ -283,8 +292,23 @@ export const DataControlEditor = <State extends DataControlEditorState = DataCon
dataView={selectedDataView}
onSelectField={(field) => {
setEditorState({ ...editorState, fieldName: field.name });
setSelectedControlType(fieldRegistry?.[field.name]?.compatibleControlTypes[0]);
/**
* make sure that the new field is compatible with the selected control type and, if it's not,
* reset the selected control type to the **first** compatible control type
*/
const newCompatibleControlTypes =
fieldRegistry?.[field.name]?.compatibleControlTypes ?? [];
if (
!selectedControlType ||
!newCompatibleControlTypes.includes(selectedControlType!)
) {
setSelectedControlType(newCompatibleControlTypes[0]);
}
/**
* set the control title (i.e. the one set by the user) + default title (i.e. the field display name)
*/
const newDefaultTitle = field.displayName ?? field.name;
setDefaultPanelTitle(newDefaultTitle);
const currentTitle = editorState.title;
@ -365,7 +389,6 @@ export const DataControlEditor = <State extends DataControlEditorState = DataCon
{/* )} */}
</EuiDescribedFormGroup>
{CustomSettingsComponent}
{/* {!editorConfig?.hideAdditionalSettings ? CustomSettingsComponent : null} */}
{initialState.controlId && (
<>
<EuiSpacer size="l" />

View file

@ -44,6 +44,7 @@ export const getOptionsListControlFactory = (
): DataControlFactory<OptionsListControlState, OptionsListControlApi> => {
return {
type: OPTIONS_LIST_CONTROL_TYPE,
order: 3, // should always be first, since this is the most popular control
getIconType: () => 'editorChecklist',
getDisplayName: OptionsListStrings.control.getDisplayName,
isFieldCompatible: (field) => {

View file

@ -75,6 +75,7 @@ export interface ControlFactory<
ControlApi extends DefaultControlApi = DefaultControlApi
> {
type: string;
order?: number;
getIconType: () => string;
getDisplayName: () => string;
buildControl: (