Skip to content

Commit

Permalink
[Controls] [Dashboard] Allow existing controls to change type (#129385)
Browse files Browse the repository at this point in the history
* Replace is half working

* Child embeddable listens for change in type

* Fix control order on replace

* Fix factory & remove duplicated onPanelAdded/Removed

* Comments + clean up code

* Set invalid editor state on type change

* Add functional tests and clean test code

* Comment out time slider tests

* Remove getReplacementPanelState

* Fix promise syntax

* Fix mutation

* Fix flaky test

* Fix conflicts
  • Loading branch information
Heenawter authored May 12, 2022
1 parent 1f91501 commit 595522b
Show file tree
Hide file tree
Showing 25 changed files with 477 additions and 215 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,15 @@ export interface ControlFrameProps {
customPrepend?: JSX.Element;
enableActions?: boolean;
embeddableId: string;
embeddableType: string;
}

export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: ControlFrameProps) => {
export const ControlFrame = ({
customPrepend,
enableActions,
embeddableId,
embeddableType,
}: ControlFrameProps) => {
const embeddableRoot: React.RefObject<HTMLDivElement> = useMemo(() => React.createRef(), []);
const {
useEmbeddableSelector,
Expand All @@ -42,7 +48,7 @@ export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: Con
const { overlays } = pluginServices.getHooks();
const { openConfirm } = overlays.useService();

const embeddable = useChildEmbeddable({ untilEmbeddableLoaded, embeddableId });
const embeddable = useChildEmbeddable({ untilEmbeddableLoaded, embeddableId, embeddableType });

const [title, setTitle] = useState<string>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export const ControlGroup = () => {
isEditable={isEditable}
dragInfo={{ index, draggingIndex }}
embeddableId={controlId}
embeddableType={panels[controlId].type}
key={controlId}
/>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,44 +60,50 @@ export const SortableControl = (frameProps: SortableControlProps) => {
const SortableControlInner = forwardRef<
HTMLButtonElement,
SortableControlProps & { style: HTMLAttributes<HTMLButtonElement>['style'] }
>(({ embeddableId, dragInfo, style, isEditable, ...dragHandleProps }, dragHandleRef) => {
const { isOver, isDragging, draggingIndex, index } = dragInfo;
const { useEmbeddableSelector } = useReduxContainerContext<ControlGroupInput>();
const { panels } = useEmbeddableSelector((state) => state);
>(
(
{ embeddableId, embeddableType, dragInfo, style, isEditable, ...dragHandleProps },
dragHandleRef
) => {
const { isOver, isDragging, draggingIndex, index } = dragInfo;
const { useEmbeddableSelector } = useReduxContainerContext<ControlGroupInput>();
const { panels } = useEmbeddableSelector((state) => state);

const width = panels[embeddableId].width;
const width = panels[embeddableId].width;

const dragHandle = (
<button ref={dragHandleRef} {...dragHandleProps} className="controlFrame__dragHandle">
<EuiIcon type="grabHorizontal" />
</button>
);
const dragHandle = (
<button ref={dragHandleRef} {...dragHandleProps} className="controlFrame__dragHandle">
<EuiIcon type="grabHorizontal" />
</button>
);

return (
<EuiFlexItem
grow={width === 'auto'}
data-control-id={embeddableId}
data-test-subj={`control-frame`}
data-render-complete="true"
className={classNames('controlFrameWrapper', {
'controlFrameWrapper-isDragging': isDragging,
'controlFrameWrapper-isEditable': isEditable,
'controlFrameWrapper--small': width === 'small',
'controlFrameWrapper--medium': width === 'medium',
'controlFrameWrapper--large': width === 'large',
'controlFrameWrapper--insertBefore': isOver && (index ?? -1) < (draggingIndex ?? -1),
'controlFrameWrapper--insertAfter': isOver && (index ?? -1) > (draggingIndex ?? -1),
})}
style={style}
>
<ControlFrame
enableActions={isEditable && draggingIndex === -1}
embeddableId={embeddableId}
customPrepend={isEditable ? dragHandle : undefined}
/>
</EuiFlexItem>
);
});
return (
<EuiFlexItem
grow={width === 'auto'}
data-control-id={embeddableId}
data-test-subj={`control-frame`}
data-render-complete="true"
className={classNames('controlFrameWrapper', {
'controlFrameWrapper-isDragging': isDragging,
'controlFrameWrapper-isEditable': isEditable,
'controlFrameWrapper--small': width === 'small',
'controlFrameWrapper--medium': width === 'medium',
'controlFrameWrapper--large': width === 'large',
'controlFrameWrapper--insertBefore': isOver && (index ?? -1) < (draggingIndex ?? -1),
'controlFrameWrapper--insertAfter': isOver && (index ?? -1) > (draggingIndex ?? -1),
})}
style={style}
>
<ControlFrame
enableActions={isEditable && draggingIndex === -1}
embeddableId={embeddableId}
embeddableType={embeddableType}
customPrepend={isEditable ? dragHandle : undefined}
/>
</EuiFlexItem>
);
}
);

/**
* A simplified clone version of the control which is dragged. This version only shows
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
ControlEmbeddable,
ControlInput,
ControlWidth,
DataControlInput,
IEditableControlFactory,
} from '../../types';
import { CONTROL_WIDTH_OPTIONS } from './editor_constants';
Expand Down Expand Up @@ -85,6 +86,11 @@ export const ControlEditor = ({
const [currentTitle, setCurrentTitle] = useState(title);
const [currentWidth, setCurrentWidth] = useState(width);
const [controlEditorValid, setControlEditorValid] = useState(false);
const [selectedField, setSelectedField] = useState<string | undefined>(
embeddable
? (embeddable.getInput() as DataControlInput).fieldName // CLEAN THIS ONCE OTHER PR GETS IN
: undefined
);

const getControlTypeEditor = (type: string) => {
const factory = getControlFactory(type);
Expand All @@ -96,6 +102,8 @@ export const ControlEditor = ({
onChange={onTypeEditorChange}
setValidState={setControlEditorValid}
initialInput={embeddable?.getInput()}
selectedField={selectedField}
setSelectedField={setSelectedField}
setDefaultTitle={(newDefaultTitle) => {
if (!currentTitle || currentTitle === defaultTitle) {
setCurrentTitle(newDefaultTitle);
Expand All @@ -107,8 +115,8 @@ export const ControlEditor = ({
) : null;
};

const getTypeButtons = (controlTypes: string[]) => {
return controlTypes.map((type) => {
const getTypeButtons = () => {
return getControlTypes().map((type) => {
const factory = getControlFactory(type);
const icon = (factory as EmbeddableFactoryDefinition).getIconType?.();
const tooltip = (factory as EmbeddableFactoryDefinition).getDescription?.();
Expand All @@ -120,6 +128,12 @@ export const ControlEditor = ({
isSelected={selectedType === type}
onClick={() => {
setSelectedType(type);
if (!isCreate)
setSelectedField(
embeddable && type === embeddable.type
? (embeddable.getInput() as DataControlInput).fieldName
: undefined
);
}}
>
<EuiIcon type={!icon || icon === 'empty' ? 'controlsHorizontal' : icon} size="l" />
Expand Down Expand Up @@ -150,9 +164,7 @@ export const ControlEditor = ({
<EuiFlyoutBody data-test-subj="control-editor-flyout">
<EuiForm>
<EuiFormRow label={ControlGroupStrings.manageControl.getControlTypeTitle()}>
<EuiKeyPadMenu>
{isCreate ? getTypeButtons(getControlTypes()) : getTypeButtons([selectedType])}
</EuiKeyPadMenu>
<EuiKeyPadMenu>{getTypeButtons()}</EuiKeyPadMenu>
</EuiFormRow>
{selectedType && (
<>
Expand Down
172 changes: 98 additions & 74 deletions src/plugins/controls/public/control_group/editor/edit_control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ import { IEditableControlFactory, ControlInput } from '../../types';
import { controlGroupReducers } from '../state/control_group_reducers';
import { ControlGroupContainer, setFlyoutRef } from '../embeddable/control_group_container';

interface EditControlResult {
type: string;
controlInput: Omit<ControlInput, 'id'>;
}

export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => {
// Controls Services Context
const { overlays, controls } = pluginServices.getHooks();
Expand All @@ -34,7 +39,7 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) =>
typeof controlGroupReducers
>();
const {
containerActions: { untilEmbeddableLoaded, removeEmbeddable, updateInputForChild },
containerActions: { untilEmbeddableLoaded, removeEmbeddable, replaceEmbeddable },
actions: { setControlWidth },
useEmbeddableSelector,
useEmbeddableDispatch,
Expand All @@ -52,88 +57,107 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) =>

const editControl = async () => {
const panel = panels[embeddableId];
const factory = getControlFactory(panel.type);
let factory = getControlFactory(panel.type);
if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type);

const embeddable = await untilEmbeddableLoaded(embeddableId);
const controlGroup = embeddable.getRoot() as ControlGroupContainer;

let inputToReturn: Partial<ControlInput> = {};
const initialInputPromise = new Promise<EditControlResult>((resolve, reject) => {
let inputToReturn: Partial<ControlInput> = {};

if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type);
let removed = false;
const onCancel = (ref: OverlayRef) => {
if (
removed ||
(isEqual(latestPanelState.current.explicitInput, {
...panel.explicitInput,
...inputToReturn,
}) &&
isEqual(latestPanelState.current.width, panel.width))
) {
reject();
ref.close();
return;
}
openConfirm(ControlGroupStrings.management.discardChanges.getSubtitle(), {
confirmButtonText: ControlGroupStrings.management.discardChanges.getConfirm(),
cancelButtonText: ControlGroupStrings.management.discardChanges.getCancel(),
title: ControlGroupStrings.management.discardChanges.getTitle(),
buttonColor: 'danger',
}).then((confirmed) => {
if (confirmed) {
dispatch(setControlWidth({ width: panel.width, embeddableId }));
reject();
ref.close();
}
});
};

let removed = false;
const onCancel = (ref: OverlayRef) => {
if (
removed ||
(isEqual(latestPanelState.current.explicitInput, {
...panel.explicitInput,
...inputToReturn,
}) &&
isEqual(latestPanelState.current.width, panel.width))
) {
const onSave = (type: string, ref: OverlayRef) => {
// if the control now has a new type, need to replace the old factory with
// one of the correct new type
if (latestPanelState.current.type !== type) {
factory = getControlFactory(type);
if (!factory) throw new EmbeddableFactoryNotFoundError(type);
}
const editableFactory = factory as IEditableControlFactory;
if (editableFactory.presaveTransformFunction) {
inputToReturn = editableFactory.presaveTransformFunction(inputToReturn, embeddable);
}
resolve({ type, controlInput: inputToReturn });
ref.close();
return;
}
openConfirm(ControlGroupStrings.management.discardChanges.getSubtitle(), {
confirmButtonText: ControlGroupStrings.management.discardChanges.getConfirm(),
cancelButtonText: ControlGroupStrings.management.discardChanges.getCancel(),
title: ControlGroupStrings.management.discardChanges.getTitle(),
buttonColor: 'danger',
}).then((confirmed) => {
if (confirmed) {
dispatch(setControlWidth({ width: panel.width, embeddableId }));
ref.close();
};

const flyoutInstance = openFlyout(
forwardAllContext(
<ControlEditor
isCreate={false}
width={panel.width}
embeddable={embeddable}
title={embeddable.getTitle()}
onCancel={() => onCancel(flyoutInstance)}
updateTitle={(newTitle) => (inputToReturn.title = newTitle)}
setLastUsedDataViewId={(lastUsed) => controlGroup.setLastUsedDataViewId(lastUsed)}
updateWidth={(newWidth) => dispatch(setControlWidth({ width: newWidth, embeddableId }))}
onTypeEditorChange={(partialInput) => {
inputToReturn = { ...inputToReturn, ...partialInput };
}}
onSave={(type) => onSave(type, flyoutInstance)}
removeControl={() => {
openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), {
confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(),
cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(),
title: ControlGroupStrings.management.deleteControls.getDeleteTitle(),
buttonColor: 'danger',
}).then((confirmed) => {
if (confirmed) {
removeEmbeddable(embeddableId);
removed = true;
flyoutInstance.close();
}
});
}}
/>,
reduxContainerContext
),
{
outsideClickCloses: false,
onClose: (flyout) => {
setFlyoutRef(undefined);
onCancel(flyout);
},
}
});
};
);
setFlyoutRef(flyoutInstance);
});

const flyoutInstance = openFlyout(
forwardAllContext(
<ControlEditor
isCreate={false}
width={panel.width}
embeddable={embeddable}
title={embeddable.getTitle()}
onCancel={() => onCancel(flyoutInstance)}
updateTitle={(newTitle) => (inputToReturn.title = newTitle)}
setLastUsedDataViewId={(lastUsed) => controlGroup.setLastUsedDataViewId(lastUsed)}
updateWidth={(newWidth) => dispatch(setControlWidth({ width: newWidth, embeddableId }))}
onTypeEditorChange={(partialInput) =>
(inputToReturn = { ...inputToReturn, ...partialInput })
}
onSave={() => {
const editableFactory = factory as IEditableControlFactory;
if (editableFactory.presaveTransformFunction) {
inputToReturn = editableFactory.presaveTransformFunction(inputToReturn, embeddable);
}
updateInputForChild(embeddableId, inputToReturn);
flyoutInstance.close();
}}
removeControl={() => {
openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), {
confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(),
cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(),
title: ControlGroupStrings.management.deleteControls.getDeleteTitle(),
buttonColor: 'danger',
}).then((confirmed) => {
if (confirmed) {
removeEmbeddable(embeddableId);
removed = true;
flyoutInstance.close();
}
});
}}
/>,
reduxContainerContext
),
{
outsideClickCloses: false,
onClose: (flyout) => {
setFlyoutRef(undefined);
onCancel(flyout);
},
}
initialInputPromise.then(
async (promise) => {
await replaceEmbeddable(embeddable.id, promise.controlInput, promise.type);
},
() => {} // swallow promise rejection because it can be part of normal flow
);
setFlyoutRef(flyoutInstance);
};

return (
Expand Down
Loading

0 comments on commit 595522b

Please sign in to comment.