Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[8.x] [controls] fix controls plugin loading async chunks on home page (#194182) #194235

Merged
merged 1 commit into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions src/plugins/controls/public/control_factory_registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
import { i18n } from '@kbn/i18n';
import { ControlFactory, DefaultControlApi } from './controls/types';

const registry: { [key: string]: ControlFactory<any, any> } = {};
const registry: { [key: string]: () => Promise<ControlFactory<any, any>> } = {};

export const registerControlFactory = async <
export const registerControlFactory = <
State extends object = object,
ApiType extends DefaultControlApi = DefaultControlApi
>(
Expand All @@ -26,23 +26,23 @@ export const registerControlFactory = async <
values: { key: type },
})
);
registry[type] = (await getFactory()) as ControlFactory<any, any>;
registry[type] = getFactory as () => Promise<ControlFactory<any, any>>;
};

export const getControlFactory = <
export const getControlFactory = async <
State extends object = object,
ApiType extends DefaultControlApi = DefaultControlApi
>(
key: string
): ControlFactory<State, ApiType> => {
): Promise<ControlFactory<State, ApiType>> => {
if (registry[key] === undefined)
throw new Error(
i18n.translate('controls.controlFactoryRegistry.factoryNotFoundError', {
defaultMessage: 'No control factory found for type: {key}',
values: { key },
})
);
return registry[key] as ControlFactory<State, ApiType>;
return (await registry[key]()) as ControlFactory<State, ApiType>;
};

export const getAllControlTypes = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export const ControlRenderer = <

async function buildControl() {
const parentApi = getParentApi();
const factory = getControlFactory<StateType, ApiType>(type);
const factory = await getControlFactory<StateType, ApiType>(type);
const buildApi = (
apiRegistration: ControlApiRegistration<ApiType>,
comparators: StateComparators<StateType>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,15 +117,17 @@ 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 }),
const mockRegistry: {
[key: string]: () => Promise<ControlFactory<DefaultDataControlState, DataControlApi>>;
} = {
search: async () => getMockedSearchControlFactory({ parentApi: controlGroupApi }),
optionsList: async () => getMockedOptionsListControlFactory({ parentApi: controlGroupApi }),
rangeSlider: async () => getMockedRangeSliderControlFactory({ parentApi: controlGroupApi }),
};

beforeAll(() => {
(getAllControlTypes as jest.Mock).mockReturnValue(Object.keys(mockRegistry));
(getControlFactory as jest.Mock).mockImplementation((key) => mockRegistry[key]);
(getControlFactory as jest.Mock).mockImplementation((key) => mockRegistry[key]());
});

describe('creating a new control', () => {
Expand All @@ -146,33 +148,35 @@ describe('Data control editor', () => {

test('CompatibleControlTypesComponent respects ordering', async () => {
const tempRegistry: {
[key: string]: ControlFactory<DefaultDataControlState, DataControlApi>;
[key: string]: () => Promise<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,
alphabeticalFirstControl: async () =>
({
type: 'alphabeticalFirst',
getIconType: () => 'lettering',
getDisplayName: () => 'Alphabetically first',
isFieldCompatible: () => true,
buildControl: jest.fn().mockReturnValue({
api: controlGroupApi,
Component: <>Should be first alphabetically</>,
}),
} as DataControlFactory),
supremeControl: async () =>
({
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]);
(getControlFactory as jest.Mock).mockImplementation((key) => tempRegistry[key]());

const controlEditor = await mountComponent({});
const menu = controlEditor.getByTestId('controlTypeMenu');
Expand All @@ -185,7 +189,7 @@ describe('Data control editor', () => {
expect(menu.children[4].textContent).toEqual('Search');

(getAllControlTypes as jest.Mock).mockReturnValue(Object.keys(mockRegistry));
(getControlFactory as jest.Mock).mockImplementation((key) => mockRegistry[key]);
(getControlFactory as jest.Mock).mockImplementation((key) => mockRegistry[key]());
});

test('selecting a keyword field - can only create an options list control', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import React, { useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import useAsync from 'react-use/lib/useAsync';

import {
Expand All @@ -27,6 +27,7 @@ import {
EuiIcon,
EuiKeyPadMenu,
EuiKeyPadMenuItem,
EuiSkeletonRectangle,
EuiSpacer,
EuiSwitch,
EuiTitle,
Expand All @@ -39,6 +40,7 @@ import {
withSuspense,
} from '@kbn/presentation-util-plugin/public';

import { asyncMap } from '@kbn/std';
import {
DEFAULT_CONTROL_GROW,
DEFAULT_CONTROL_WIDTH,
Expand All @@ -56,6 +58,7 @@ import {
type DataControlFactory,
type DataControlFieldRegistry,
} from './types';
import { ControlFactory } from '../types';

export interface ControlEditorProps<
State extends DefaultDataControlState = DefaultDataControlState
Expand Down Expand Up @@ -83,60 +86,89 @@ const CompatibleControlTypesComponent = ({
selectedControlType?: string;
setSelectedControlType: (type: string) => void;
}) => {
const dataControlFactories = useMemo(() => {
return getAllControlTypes()
.map((type) => getControlFactory(type))
.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;
const [dataControlFactories, setDataControlFactories] = useState<
DataControlFactory[] | undefined
>(undefined);

useEffect(() => {
let cancelled = false;

asyncMap<string, ControlFactory>(getAllControlTypes(), async (controlType) =>
getControlFactory(controlType)
)
.then((controlFactories) => {
if (!cancelled) {
setDataControlFactories(
controlFactories
.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;
}
) as unknown as DataControlFactory[]
);
}
);
})
.catch(() => {
if (!cancelled) setDataControlFactories([]);
});

return () => {
cancelled = true;
};
}, []);

return (
<EuiKeyPadMenu data-test-subj={`controlTypeMenu`} aria-label={'type'}>
{dataControlFactories.map((factory) => {
const disabled =
fieldRegistry && selectedFieldName
? !fieldRegistry[selectedFieldName]?.compatibleControlTypes.includes(factory.type)
: true;
const keyPadMenuItem = (
<EuiKeyPadMenuItem
key={factory.type}
id={`create__${factory.type}`}
aria-label={factory.getDisplayName()}
data-test-subj={`create__${factory.type}`}
isSelected={factory.type === selectedControlType}
disabled={disabled}
onClick={() => setSelectedControlType(factory.type)}
label={factory.getDisplayName()}
>
<EuiIcon type={factory.getIconType()} size="l" />
</EuiKeyPadMenuItem>
);
<EuiSkeletonRectangle
isLoading={dataControlFactories === undefined}
width="100px"
height="100px"
>
<EuiKeyPadMenu data-test-subj={`controlTypeMenu`} aria-label={'type'}>
{(dataControlFactories ?? []).map((factory) => {
const disabled =
fieldRegistry && selectedFieldName
? !fieldRegistry[selectedFieldName]?.compatibleControlTypes.includes(factory.type)
: true;
const keyPadMenuItem = (
<EuiKeyPadMenuItem
key={factory.type}
id={`create__${factory.type}`}
aria-label={factory.getDisplayName()}
data-test-subj={`create__${factory.type}`}
isSelected={factory.type === selectedControlType}
disabled={disabled}
onClick={() => setSelectedControlType(factory.type)}
label={factory.getDisplayName()}
>
<EuiIcon type={factory.getIconType()} size="l" />
</EuiKeyPadMenuItem>
);

return disabled ? (
<EuiToolTip
key={`disabled__${factory.type}`}
content={DataControlEditorStrings.manageControl.dataSource.getControlTypeErrorMessage({
fieldSelected: Boolean(selectedFieldName),
controlType: factory.getDisplayName(),
})}
>
{keyPadMenuItem}
</EuiToolTip>
) : (
keyPadMenuItem
);
})}
</EuiKeyPadMenu>
return disabled ? (
<EuiToolTip
key={`disabled__${factory.type}`}
content={DataControlEditorStrings.manageControl.dataSource.getControlTypeErrorMessage(
{
fieldSelected: Boolean(selectedFieldName),
controlType: factory.getDisplayName(),
}
)}
>
{keyPadMenuItem}
</EuiToolTip>
) : (
keyPadMenuItem
);
})}
</EuiKeyPadMenu>
</EuiSkeletonRectangle>
);
};

Expand Down Expand Up @@ -186,9 +218,33 @@ export const DataControlEditor = <State extends DefaultDataControlState = Defaul
};
}, [editorState.dataViewId]);

const [controlFactory, setControlFactory] = useState<DataControlFactory | undefined>(undefined);
useEffect(() => {
if (!selectedControlType) {
setControlFactory(undefined);
return;
}

let cancelled = false;
getControlFactory(selectedControlType)
.then((nextControlFactory) => {
if (!cancelled) {
setControlFactory(nextControlFactory as unknown as DataControlFactory);
}
})
.catch(() => {
if (!cancelled) {
setControlFactory(undefined);
}
});

return () => {
cancelled = true;
};
}, [selectedControlType]);

const CustomSettingsComponent = useMemo(() => {
if (!selectedControlType || !editorState.fieldName || !fieldRegistry) return;
const controlFactory = getControlFactory(selectedControlType) as DataControlFactory;
if (!controlFactory || !editorState.fieldName || !fieldRegistry) return;
const CustomSettings = controlFactory.CustomOptionsComponent;

if (!CustomSettings) return;
Expand Down Expand Up @@ -217,7 +273,7 @@ export const DataControlEditor = <State extends DefaultDataControlState = Defaul
/>
</EuiDescribedFormGroup>
);
}, [fieldRegistry, selectedControlType, initialState, editorState, controlGroupApi]);
}, [fieldRegistry, controlFactory, initialState, editorState, controlGroupApi]);

return (
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { memoize } from 'lodash';

import type { DataView } from '@kbn/data-views-plugin/common';
import { asyncMap } from '@kbn/std';
import { getAllControlTypes, getControlFactory } from '../../control_factory_registry';
import { isDataControlFactory, type DataControlFieldRegistry } from './types';

Expand All @@ -23,7 +24,7 @@ export const getDataControlFieldRegistry = memoize(
const loadFieldRegistryFromDataView = async (
dataView: DataView
): Promise<DataControlFieldRegistry> => {
const controlFactories = getAllControlTypes().map((controlType) =>
const controlFactories = await asyncMap(getAllControlTypes(), async (controlType) =>
getControlFactory(controlType)
);
const fieldRegistry: DataControlFieldRegistry = {};
Expand Down
3 changes: 2 additions & 1 deletion src/plugins/controls/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
"@kbn/content-management-utils",
"@kbn/field-formats-plugin",
"@kbn/presentation-panel-plugin",
"@kbn/shared-ux-utility"
"@kbn/shared-ux-utility",
"@kbn/std"
],
"exclude": ["target/**/*"]
}