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

[Embeddable Rebuild] [Controls] Refactor control editing + creation #187606

Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
bf391c3
Remove use of state manager for editor + add create button
Heenawter Jul 4, 2024
609a2f2
Small style fix + add tooltip
Heenawter Jul 5, 2024
d00bc16
Fix some small bugs with editor
Heenawter Jul 5, 2024
2337d23
Clean up + add data editor tests
Heenawter Jul 5, 2024
0d5a661
Add range slider custom options test
Heenawter Jul 5, 2024
d699bd5
Fix tests
Heenawter Jul 5, 2024
8c63479
Fix linting
Heenawter Jul 8, 2024
6ab5cb7
[CI] Auto-commit changed files from 'node scripts/eslint --no-cache -…
kibanamachine Jul 8, 2024
2600815
Small cleanup
Heenawter Jul 8, 2024
e153a68
Merge branch 'main' of github.com:elastic/kibana into embeddableRebui…
Heenawter Jul 8, 2024
416a80d
Remove unnecessary default
Heenawter Jul 8, 2024
1bc3de1
Merge branch 'embeddableRebuild_controls_add-creation_2024-07-03' of …
Heenawter Jul 8, 2024
0eb12c1
Cleanup
Heenawter Jul 8, 2024
0faa00f
Fix missed rename
Heenawter Jul 8, 2024
dbd3578
Fix typo in comment
Heenawter Jul 8, 2024
2ab0845
Make `openDataControlEditor` synchronous
Heenawter Jul 8, 2024
1ef8e39
Mock registry functions
Heenawter Jul 8, 2024
be2636d
Clean up name
Heenawter Jul 8, 2024
3bf7641
Only apply updated changes + fix types
Heenawter Jul 10, 2024
bd01531
Merge branch 'main' into embeddableRebuild_controls_add-creation_2024…
Heenawter Jul 10, 2024
6401c66
Merge branch 'main' into embeddableRebuild_controls_add-creation_2024…
Heenawter Jul 12, 2024
eb54f16
Merge branch 'main' into embeddableRebuild_controls_add-creation_2024…
Heenawter Jul 15, 2024
b5147af
Switch to `isEqual`
Heenawter Jul 15, 2024
d81e30c
Small range slider style fix
Heenawter Jul 16, 2024
57223a7
Merge branch 'main' of github.com:elastic/kibana into embeddableRebui…
Heenawter Jul 16, 2024
4fcd85a
More quick style fixes
Heenawter Jul 16, 2024
f33c1ca
[CI] Auto-commit changed files from 'node scripts/lint_ts_projects --…
kibanamachine Jul 16, 2024
7b8c87b
Fix types
Heenawter Jul 16, 2024
1d5c7e8
Merge branch 'main' of github.com:elastic/kibana into embeddableRebui…
Heenawter Jul 17, 2024
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
35 changes: 34 additions & 1 deletion examples/controls_example/public/app/react_control_example.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ import {
EuiSuperDatePicker,
OnTimeChangeProps,
} from '@elastic/eui';
import { CONTROL_GROUP_TYPE } from '@kbn/controls-plugin/common';
import {
CONTROL_GROUP_TYPE,
DEFAULT_CONTROL_GROW,
DEFAULT_CONTROL_WIDTH,
} from '@kbn/controls-plugin/common';
import { CoreStart } from '@kbn/core/public';
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { ReactEmbeddableRenderer, ViewMode } from '@kbn/embeddable-plugin/public';
Expand All @@ -39,6 +43,7 @@ import { ControlGroupApi } from '../react_controls/control_group/types';
import { RANGE_SLIDER_CONTROL_TYPE } from '../react_controls/data_controls/range_slider/types';
import { SEARCH_CONTROL_TYPE } from '../react_controls/data_controls/search_control/types';
import { TIMESLIDER_CONTROL_TYPE } from '../react_controls/timeslider_control/types';
import { openDataControlEditor } from '../react_controls/data_controls/open_data_control_editor';

const toggleViewButtons = [
{
Expand Down Expand Up @@ -167,6 +172,7 @@ export const ReactControlExample = ({
addNewPanel: () => {
return Promise.resolve(undefined);
},
lastUsedDataViewId: new BehaviorSubject<string>(WEB_LOGS_DATA_VIEW_ID),
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
Expand Down Expand Up @@ -282,6 +288,33 @@ export const ReactControlExample = ({
Serialize control group
</EuiButton>
</EuiFlexItem>
{controlGroupApi && (
<EuiFlexItem grow={false}>
<EuiButton
onClick={async () => {
const { type: controlType, state: initialState } = await openDataControlEditor({
Heenawter marked this conversation as resolved.
Show resolved Hide resolved
initialState: {
grow: DEFAULT_CONTROL_GROW,
width: DEFAULT_CONTROL_WIDTH,
dataViewId: dashboardApi.lastUsedDataViewId.getValue(),
},
controlGroupApi,
services: {
core,
dataViews: dataViewsService,
},
});
controlGroupApi.addNewPanel({
panelType: controlType,
initialState,
});
}}
size="s"
>
Add new data control
</EuiButton>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiButtonGroup
legend="Change the view mode"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import React, { useEffect } from 'react';
import { BehaviorSubject } from 'rxjs';

import { EuiFlexGroup } from '@elastic/eui';
import {
ControlGroupChainingSystem,
ControlWidth,
Expand Down Expand Up @@ -36,9 +37,9 @@ import {
useStateFromPublishingSubject,
} from '@kbn/presentation-publishing';

import { EuiFlexGroup } from '@elastic/eui';
import { ControlRenderer } from '../control_renderer';
import { DefaultControlApi } from '../types';
import { dataControlFetch$ } from './data_control_fetch';
import { openEditControlGroupFlyout } from './open_edit_control_group_flyout';
import { deserializeControlGroup, serializeControlGroup } from './serialization_utils';
import {
Expand All @@ -47,7 +48,6 @@ import {
ControlGroupSerializedState,
ControlGroupUnsavedChanges,
} from './types';
import { dataControlFetch$ } from './data_control_fetch';

export const getControlGroupEmbeddableFactory = (services: {
core: CoreStart;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@
border-radius: $euiBorderRadius !important;
}

&--label {
@include euiTextTruncate;
&--labelWrapper {
max-width: 40%;
background-color: transparent;
border-radius: $euiBorderRadius;

margin-left: 0 !important;
padding-left: 0 !important;
.controlPanel--label {
@include euiTextTruncate;
background-color: transparent;
border-radius: $euiBorderRadius;
margin-left: 0 !important;
padding-left: 0 !important;
}
Comment on lines +16 to +22
Copy link
Contributor Author

@Heenawter Heenawter Jul 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixes a style error where the truncation was happening before the max-width applied, so labels were getting cut off too early on the "small" control size. I also verified that the truncation now happens at the same point that it does for the old controls, so there should be no visual changes from the user's perspective once the React controls are officially swapped in Dashboard.

Before After Legacy
image image image
image image image

}

&--hideComponent {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@
import classNames from 'classnames';
import React, { useState } from 'react';

import { EuiFlexItem, EuiFormControlLayout, EuiFormLabel, EuiFormRow, EuiIcon } from '@elastic/eui';
import {
EuiFlexItem,
EuiFormControlLayout,
EuiFormLabel,
EuiFormRow,
EuiIcon,
EuiToolTip,
} from '@elastic/eui';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { i18n } from '@kbn/i18n';
import {
Expand Down Expand Up @@ -135,12 +142,18 @@ export const ControlPanel = <ApiType extends DefaultControlApi = DefaultControlA
controlTitle={panelTitle || defaultPanelTitle}
hideEmptyDragHandle={usingTwoLineLayout || Boolean(api?.CustomPrependComponent)}
/>

{api?.CustomPrependComponent ? (
<api.CustomPrependComponent />
) : usingTwoLineLayout ? null : (
<EuiFormLabel className="controlPanel--label">
{panelTitle || defaultPanelTitle}
</EuiFormLabel>
<EuiToolTip
anchorClassName="controlPanel--labelWrapper"
content={panelTitle || defaultPanelTitle}
>
<EuiFormLabel className="controlPanel--label">
{panelTitle || defaultPanelTitle}
</EuiFormLabel>
</EuiToolTip>
Comment on lines +149 to +156
Copy link
Contributor Author

@Heenawter Heenawter Jul 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I accidentally forgot to include the tooltip when initially building this, but we need it for when things get truncated.

)}
</>
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import React from 'react';
import { BehaviorSubject } from 'rxjs';

import { createStubDataView } from '@kbn/data-views-plugin/common/data_view.stub';
import { stubFieldSpecMap } from '@kbn/data-views-plugin/common/field.stub';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { TimeRange } from '@kbn/es-query';
import { I18nProvider } from '@kbn/i18n-react';
import { act, fireEvent, render, RenderResult, waitFor } from '@testing-library/react';

import { registerControlFactory } from '../control_factory_registry';
import { ControlGroupApi } from '../control_group/types';
import { DataControlEditor } from './data_control_editor';
import {
getMockedOptionsListControlFactory,
getMockedRangeSliderControlFactory,
getMockedSearchControlFactory,
} from './mocks/data_control_mocks';
import { DEFAULT_CONTROL_GROW, DEFAULT_CONTROL_WIDTH } from '@kbn/controls-plugin/common';
import { DataControlEditorState } from './open_data_control_editor';

const mockDataViews = dataViewPluginMocks.createStartContract();
const mockDataView = createStubDataView({
spec: {
id: 'logstash-*',
fields: {
...stubFieldSpecMap,
'machine.os.raw': {
name: 'machine.os.raw',
customLabel: 'OS',
type: 'string',
esTypes: ['keyword'],
aggregatable: true,
searchable: true,
},
},
title: 'logstash-*',
timeFieldName: '@timestamp',
},
});
mockDataViews.get = jest.fn().mockResolvedValue(mockDataView);

const dashboardApi = {
timeRange$: new BehaviorSubject<TimeRange | undefined>(undefined),
lastUsedDataViewId$: new BehaviorSubject<string>(mockDataView.id!),
};
const controlGroupApi = {
parentApi: dashboardApi,
grow: new BehaviorSubject(DEFAULT_CONTROL_GROW),
width: new BehaviorSubject(DEFAULT_CONTROL_WIDTH),
} as unknown as ControlGroupApi;

describe('Data control editor', () => {
const mountComponent = async ({
initialState,
}: {
initialState?: Partial<DataControlEditorState>;
}) => {
mockDataViews.get = jest.fn().mockResolvedValue(mockDataView);

const controlEditor = render(
<I18nProvider>
<DataControlEditor
onCancel={() => {}}
onSave={() => {}}
parentApi={controlGroupApi}
initialState={{
dataViewId: dashboardApi.lastUsedDataViewId$.getValue(),
...initialState,
}}
services={{ dataViews: mockDataViews }}
/>
</I18nProvider>
);

await waitFor(() => {
expect(mockDataViews.get).toHaveBeenCalledTimes(1);
});

return controlEditor;
};

const selectField = async (controlEditor: RenderResult, fieldName: string) => {
expect(controlEditor.queryByTestId(`field-picker-select-${fieldName}`)).toBeInTheDocument();
await act(async () => {
fireEvent.click(controlEditor.getByTestId(`field-picker-select-${fieldName}`));
});
};

const getPressedAttribute = (controlEditor: RenderResult, testId: string) => {
return controlEditor.getByTestId(testId).getAttribute('aria-pressed');
};

beforeAll(() => {
/** Register all of my mocked data controls */
registerControlFactory('search', async () => {
Heenawter marked this conversation as resolved.
Show resolved Hide resolved
return getMockedSearchControlFactory({ parentApi: controlGroupApi });
});
registerControlFactory('optionsList', async () => {
return getMockedOptionsListControlFactory({ parentApi: controlGroupApi });
});
registerControlFactory('rangeSlider', async () => {
return getMockedRangeSliderControlFactory({ parentApi: controlGroupApi });
});
});

describe('creating a new control', () => {
test('field list does not include fields that are not compatible with any control types', async () => {
const controlEditor = await mountComponent({});
const nonAggOption = controlEditor.queryByTestId('field-picker-select-machine.os');
expect(nonAggOption).not.toBeInTheDocument();
});

test('cannot save before selecting a field', async () => {
const controlEditor = await mountComponent({});

const saveButton = controlEditor.getByTestId('control-editor-save');
expect(saveButton).toBeDisabled();
await selectField(controlEditor, 'machine.os.raw');
expect(saveButton).toBeEnabled();
});

test('selecting a keyword field - can only create an options list control', async () => {
const controlEditor = await mountComponent({});
await selectField(controlEditor, 'machine.os.raw');

expect(controlEditor.getByTestId('create__optionsList')).toBeEnabled();
expect(controlEditor.getByTestId('create__rangeSlider')).not.toBeEnabled();
expect(controlEditor.getByTestId('create__search')).not.toBeEnabled();
});

test('selecting an IP field - can only create an options list control', async () => {
const controlEditor = await mountComponent({});
await selectField(controlEditor, 'clientip');

expect(controlEditor.getByTestId('create__optionsList')).toBeEnabled();
expect(controlEditor.getByTestId('create__rangeSlider')).not.toBeEnabled();
expect(controlEditor.getByTestId('create__search')).not.toBeEnabled();
});

describe('selecting a number field', () => {
let controlEditor: RenderResult;

beforeEach(async () => {
controlEditor = await mountComponent({});
await selectField(controlEditor, 'bytes');
});

test('can create an options list or range slider control', () => {
expect(controlEditor.getByTestId('create__optionsList')).toBeEnabled();
expect(controlEditor.getByTestId('create__rangeSlider')).toBeEnabled();
expect(controlEditor.getByTestId('create__search')).not.toBeEnabled();
});

test('defaults to options list creation', () => {
expect(getPressedAttribute(controlEditor, 'create__optionsList')).toBe('true');
expect(getPressedAttribute(controlEditor, 'create__rangeSlider')).toBe('false');
});
});

test('renders custom settings when provided', async () => {
const controlEditor = await mountComponent({});
await selectField(controlEditor, 'machine.os.raw');
expect(controlEditor.queryByTestId('optionsListCustomSettings')).toBeInTheDocument();
});
});

test('selects the default width and grow', async () => {
const controlEditor = await mountComponent({});

expect(getPressedAttribute(controlEditor, 'control-editor-width-small')).toBe('false');
expect(getPressedAttribute(controlEditor, 'control-editor-width-medium')).toBe('true');
expect(getPressedAttribute(controlEditor, 'control-editor-width-large')).toBe('false');
expect(
controlEditor.getByTestId('control-editor-grow-switch').getAttribute('aria-checked')
).toBe(`${DEFAULT_CONTROL_GROW}`);
});

describe('editing existing control', () => {
describe('control title', () => {
test('auto-fills input with the default title', async () => {
const controlEditor = await mountComponent({
initialState: {
controlType: 'optionsList',
controlId: 'testId',
fieldName: 'machine.os.raw',
defaultPanelTitle: 'OS',
},
});
const titleInput = await controlEditor.findByTestId('control-editor-title-input');
expect(titleInput.getAttribute('value')).toBe('OS');
expect(titleInput.getAttribute('placeholder')).toBe('OS');
});

test('auto-fills input with the custom title', async () => {
const controlEditor = await mountComponent({
initialState: {
controlType: 'optionsList',
controlId: 'testId',
fieldName: 'machine.os.raw',
title: 'Custom title',
},
});
const titleInput = await controlEditor.findByTestId('control-editor-title-input');
expect(titleInput.getAttribute('value')).toBe('Custom title');
expect(titleInput.getAttribute('placeholder')).toBe('machine.os.raw');
});
});

test('selects the provided control type', async () => {
const controlEditor = await mountComponent({
initialState: {
controlType: 'rangeSlider',
controlId: 'testId',
fieldName: 'bytes',
},
});

expect(controlEditor.getByTestId('create__optionsList')).toBeEnabled();
expect(controlEditor.getByTestId('create__rangeSlider')).toBeEnabled();
expect(controlEditor.getByTestId('create__search')).not.toBeEnabled();

expect(getPressedAttribute(controlEditor, 'create__optionsList')).toBe('false');
expect(getPressedAttribute(controlEditor, 'create__rangeSlider')).toBe('true');
expect(getPressedAttribute(controlEditor, 'create__search')).toBe('false');
});
});
});
Loading