## Description ### Fixes - [x] https://github.com/appsmithorg/appsmith/issues/19383 - [x] https://github.com/appsmithorg/appsmith/issues/19384 - [x] https://github.com/appsmithorg/appsmith/issues/19385 - [x] https://github.com/appsmithorg/appsmith/issues/19386 - [x] https://github.com/appsmithorg/appsmith/issues/19387 - [x] https://github.com/appsmithorg/appsmith/issues/19388 - [x] https://github.com/appsmithorg/appsmith/issues/19389 - [x] https://github.com/appsmithorg/appsmith/issues/19390 - [x] https://github.com/appsmithorg/appsmith/issues/19391 - [x] https://github.com/appsmithorg/appsmith/issues/19392 - [x] https://github.com/appsmithorg/appsmith/issues/19393 - [x] https://github.com/appsmithorg/appsmith/issues/19394 - [x] https://github.com/appsmithorg/appsmith/issues/19395 - [x] https://github.com/appsmithorg/appsmith/issues/19396 - [x] https://github.com/appsmithorg/appsmith/issues/19397 - [x] https://github.com/appsmithorg/appsmith/issues/19398 - [x] https://github.com/appsmithorg/appsmith/issues/19399 - [x] https://github.com/appsmithorg/appsmith/issues/19400 - [x] https://github.com/appsmithorg/appsmith/issues/19401 - [x] https://github.com/appsmithorg/appsmith/issues/19402 - [x] https://github.com/appsmithorg/appsmith/issues/19403 - [x] https://github.com/appsmithorg/appsmith/issues/19404 - [x] https://github.com/appsmithorg/appsmith/issues/19405 - [x] https://github.com/appsmithorg/appsmith/issues/19406 - [x] https://github.com/appsmithorg/appsmith/issues/19407 - [x] https://github.com/appsmithorg/appsmith/issues/19408 - [x] https://github.com/appsmithorg/appsmith/issues/19409 Fixes # (issue) > if no issue exists, please create an issue and ask the maintainers about this first Media > A video or a GIF is preferred. when using Loom, don’t embed because it looks like it’s a GIF. instead, just link to the video ## Type of change > Please delete options that are not relevant. - Bug fix (non-breaking change which fixes an issue) - New feature (non-breaking change which adds functionality) - Breaking change (fix or feature that would cause existing functionality to not work as expected) - Chore (housekeeping or task changes that don't impact user perception) - This change requires a documentation update ## How Has This Been Tested? > Please describe the tests that you ran to verify your changes. Provide instructions, so we can reproduce. > Please also list any relevant details for your test configuration. > Delete anything that is not important - Manual - Jest - Cypress ### Test Plan > Add Testsmith test cases links that relate to this PR ### Issues raised during DP testing > Link issues raised during DP testing for better visiblity and tracking (copy link from comments dropped on this PR) ## Checklist: ### Dev activity - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] PR is being merged under a feature flag ### QA activity: - [ ] Test plan has been approved by relevant developers - [ ] Test plan has been peer reviewed by QA - [ ] Cypress test cases have been added and approved by either SDET or manual QA - [ ] Organized project review call with relevant stakeholders after Round 1/2 of QA - [ ] Added Test Plan Approved label after reveiwing all Cypress test --------- Co-authored-by: Ankita Kinger <ankita@appsmith.com> Co-authored-by: akash-codemonk <67054171+akash-codemonk@users.noreply.github.com> Co-authored-by: Tanvi Bhakta <tanvi@appsmith.com> Co-authored-by: Arsalan <arsalanyaldram0211@outlook.com> Co-authored-by: Aman Agarwal <aman@appsmith.com> Co-authored-by: Rohit Agarwal <rohit_agarwal@live.in> Co-authored-by: Nilesh Sarupriya <nilesh@appsmith.com> Co-authored-by: Nilesh Sarupriya <20905988+nsarupr@users.noreply.github.com> Co-authored-by: Tanvi Bhakta <tanvibhakta@gmail.com> Co-authored-by: Aishwarya UR <aishwarya@appsmith.com> Co-authored-by: Parthvi Goswami <parthvigoswami@Parthvis-MacBook-Pro.local> Co-authored-by: Vijetha-Kaja <vijetha@appsmith.com> Co-authored-by: Parthvi <80334441+Parthvi12@users.noreply.github.com> Co-authored-by: Apple <nandan@thinkify.io> Co-authored-by: Saroj <43822041+sarojsarab@users.noreply.github.com> Co-authored-by: Sangeeth Sivan <74818788+berzerkeer@users.noreply.github.com> Co-authored-by: Ashok Kumar M <35134347+marks0351@users.noreply.github.com> Co-authored-by: Aishwarya-U-R <91450662+Aishwarya-U-R@users.noreply.github.com> Co-authored-by: rahulramesha <rahul@appsmith.com> Co-authored-by: Aswath K <aswath.sana@gmail.com> Co-authored-by: Preet Sidhu <preetsidhu.bits@gmail.com> Co-authored-by: Vijetha-Kaja <119562824+Vijetha-Kaja@users.noreply.github.com> Co-authored-by: Shrikant Sharat Kandula <shrikant@appsmith.com>
402 lines
10 KiB
TypeScript
402 lines
10 KiB
TypeScript
import React, { useMemo, useRef } from "react";
|
|
import styled from "styled-components";
|
|
import { get, minBy } from "lodash";
|
|
import { useSelector, useDispatch } from "react-redux";
|
|
|
|
import {
|
|
copyWidget,
|
|
cutWidget,
|
|
groupWidgets,
|
|
deleteSelectedWidget,
|
|
} from "actions/widgetActions";
|
|
import { modText } from "utils/helpers";
|
|
import { Layers } from "constants/Layers";
|
|
import { TooltipComponent as Tooltip } from "design-system-old";
|
|
import { getSelectedWidgets } from "selectors/ui";
|
|
|
|
import { stopEventPropagation } from "utils/AppsmithUtils";
|
|
import { getCanvasWidgets } from "selectors/entitiesSelector";
|
|
import type { IPopoverSharedProps } from "@blueprintjs/core";
|
|
import { useWidgetSelection } from "utils/hooks/useWidgetSelection";
|
|
import WidgetFactory from "utils/WidgetFactory";
|
|
import type { AppState } from "@appsmith/reducers";
|
|
import { useWidgetDragResize } from "utils/hooks/dragResizeHooks";
|
|
import { getBoundariesFromSelectedWidgets } from "sagas/WidgetOperationUtils";
|
|
import { CONTAINER_GRID_PADDING } from "constants/WidgetConstants";
|
|
import { Icon } from "design-system";
|
|
|
|
const WidgetTypes = WidgetFactory.widgetTypes;
|
|
const StyledSelectionBox = styled.div`
|
|
position: absolute;
|
|
cursor: grab;
|
|
`;
|
|
|
|
const StyledActionsContainer = styled.div`
|
|
position: relative;
|
|
height: 100%;
|
|
width: 100%;
|
|
`;
|
|
|
|
const StyledActions = styled.div`
|
|
left: calc(100% - 38px);
|
|
padding: 5px 0;
|
|
width: max-content;
|
|
z-index: ${Layers.contextMenu};
|
|
position: absolute;
|
|
background-color: ${(props) => props.theme.colors.appBackground};
|
|
`;
|
|
|
|
const StyledAction = styled.button`
|
|
cursor: pointer;
|
|
height: 28px;
|
|
width: 28px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin: 0 5px;
|
|
outline: none;
|
|
border: none;
|
|
background: transparent;
|
|
z-index: ${Layers.contextMenu};
|
|
position: relative;
|
|
|
|
&:hover,
|
|
&:active,
|
|
&.active {
|
|
background: ${(props) =>
|
|
props.disabled
|
|
? "initial"
|
|
: props.theme.colors.widgetGroupingContextMenu.actionActiveBg};
|
|
}
|
|
&:focus {
|
|
outline: none;
|
|
}
|
|
`;
|
|
|
|
const StyledSelectBoxHandleTop = styled.div`
|
|
width: 100%;
|
|
height: 1px;
|
|
position: absolute;
|
|
z-index: ${Layers.contextMenu};
|
|
border-top: 1px dashed
|
|
${(props) => props.theme.colors.widgetGroupingContextMenu.border};
|
|
top: 0px;
|
|
left: 0px;
|
|
`;
|
|
|
|
const StyledSelectBoxHandleLeft = styled.div`
|
|
width: 0px;
|
|
height: 100%;
|
|
position: absolute;
|
|
z-index: ${Layers.contextMenu};
|
|
border-left: 1px dashed
|
|
${(props) => props.theme.colors.widgetGroupingContextMenu.border};
|
|
top: 0px;
|
|
left: 0px;
|
|
`;
|
|
|
|
const StyledSelectBoxHandleRight = styled.div`
|
|
width: 0px;
|
|
height: 100%;
|
|
position: absolute;
|
|
z-index: ${Layers.contextMenu};
|
|
border-left: 1px dashed
|
|
${(props) => props.theme.colors.widgetGroupingContextMenu.border};
|
|
top: 0px;
|
|
left: calc(100% - 1px);
|
|
`;
|
|
|
|
const StyledSelectBoxHandleBottom = styled.div`
|
|
width: 100%;
|
|
height: 1px;
|
|
position: absolute;
|
|
z-index: ${Layers.contextMenu};
|
|
border-bottom: 1px dashed
|
|
${(props) => props.theme.colors.widgetGroupingContextMenu.border};
|
|
top: 100%;
|
|
left: 0px;
|
|
`;
|
|
|
|
export const PopoverModifiers: IPopoverSharedProps["modifiers"] = {
|
|
offset: {
|
|
enabled: true,
|
|
},
|
|
arrow: {
|
|
enabled: false,
|
|
},
|
|
};
|
|
|
|
/**
|
|
* helper text that comes in popover on hover of actions in context menu
|
|
* @returns
|
|
*/
|
|
const copyHelpText = (
|
|
<>
|
|
Click or <b>{modText()} C</b> & {modText()} V
|
|
</>
|
|
);
|
|
const cutHelpText = (
|
|
<>
|
|
Click or <b>{modText()} X</b> & {modText()} V
|
|
</>
|
|
);
|
|
const deleteHelpText = (
|
|
<>
|
|
Click or <b> Del </b>
|
|
</>
|
|
);
|
|
const groupHelpText = (
|
|
<>
|
|
Click or <b>{modText()} G to group</b>
|
|
</>
|
|
);
|
|
|
|
function WidgetsMultiSelectBox(props: {
|
|
widgetId: string;
|
|
widgetType: string;
|
|
noContainerOffset: boolean;
|
|
snapColumnSpace: number;
|
|
snapRowSpace: number;
|
|
}): any {
|
|
const dispatch = useDispatch();
|
|
const canvasWidgets = useSelector(getCanvasWidgets);
|
|
const selectedWidgetIDs = useSelector(getSelectedWidgets);
|
|
const selectedWidgets = selectedWidgetIDs.map(
|
|
(widgetID) => canvasWidgets[widgetID],
|
|
);
|
|
const { focusWidget } = useWidgetSelection();
|
|
const isDragging = useSelector(
|
|
(state: AppState) => state.ui.widgetDragResize.isDragging,
|
|
);
|
|
/**
|
|
* the multi-selection bounding box should only render when:
|
|
*
|
|
* 1. the widgetID is equal to the parent id of selected widget
|
|
* 2. has common parent
|
|
* 3. multiple widgets are selected
|
|
*/
|
|
const shouldRender = useMemo(() => {
|
|
if (isDragging) {
|
|
return false;
|
|
}
|
|
const parentIDs = selectedWidgets
|
|
.filter(Boolean)
|
|
.map((widget) => widget.parentId);
|
|
const hasCommonParent = parentIDs.every((v) => v === parentIDs[0]);
|
|
const isMultipleWidgetsSelected = selectedWidgetIDs.length > 1;
|
|
|
|
return (
|
|
props.widgetType === WidgetTypes.CANVAS_WIDGET &&
|
|
isMultipleWidgetsSelected &&
|
|
hasCommonParent &&
|
|
get(selectedWidgets, "0.parentId") === props.widgetId
|
|
);
|
|
}, [selectedWidgets, isDragging]);
|
|
const draggableRef = useRef<HTMLDivElement>(null);
|
|
const { setDraggingState } = useWidgetDragResize();
|
|
|
|
const onDragStart = (e: any) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (draggableRef.current) {
|
|
const bounds = draggableRef.current.getBoundingClientRect();
|
|
const parentId = get(selectedWidgets, "0.parentId");
|
|
const startPoints = {
|
|
top: (e.clientY - bounds.top) / props.snapRowSpace,
|
|
left: (e.clientX - bounds.left) / props.snapColumnSpace,
|
|
};
|
|
const top = minBy(selectedWidgets, (rect) => rect.topRow)?.topRow;
|
|
const left = minBy(
|
|
selectedWidgets,
|
|
(rect) => rect.leftColumn,
|
|
)?.leftColumn;
|
|
setDraggingState({
|
|
isDragging: true,
|
|
dragGroupActualParent: parentId || "",
|
|
draggingGroupCenter: {
|
|
top,
|
|
left,
|
|
},
|
|
startPoints,
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* calculate bounding box
|
|
*/
|
|
const { height, left, top, width } = useMemo(() => {
|
|
if (shouldRender) {
|
|
const { leftMostColumn, topMostRow, totalHeight, totalWidth } =
|
|
getBoundariesFromSelectedWidgets(selectedWidgets);
|
|
|
|
return {
|
|
top:
|
|
topMostRow * props.snapRowSpace +
|
|
(props.noContainerOffset ? 0 : CONTAINER_GRID_PADDING),
|
|
left:
|
|
leftMostColumn * props.snapColumnSpace +
|
|
(props.noContainerOffset ? 0 : CONTAINER_GRID_PADDING),
|
|
height: totalHeight * props.snapRowSpace,
|
|
width: totalWidth * props.snapColumnSpace,
|
|
};
|
|
}
|
|
|
|
return {};
|
|
}, [
|
|
selectedWidgets,
|
|
props.snapColumnSpace,
|
|
props.snapRowSpace,
|
|
props.noContainerOffset,
|
|
]);
|
|
|
|
/**
|
|
* copies the selected widgets
|
|
*
|
|
* @param e
|
|
*/
|
|
const onCopySelectedWidgets = (
|
|
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
|
|
) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
dispatch(copyWidget(true));
|
|
|
|
return false;
|
|
};
|
|
|
|
/**
|
|
* cut the selected widgets
|
|
*
|
|
* @param e
|
|
*/
|
|
const onCutSelectedWidgets = (
|
|
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
|
|
) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
dispatch(cutWidget());
|
|
};
|
|
|
|
/**
|
|
* cut the selected widgets
|
|
*
|
|
* @param e
|
|
*/
|
|
const onDeleteSelectedWidgets = (
|
|
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
|
|
) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
dispatch(deleteSelectedWidget(true));
|
|
};
|
|
|
|
/**
|
|
* group widgets into container
|
|
*
|
|
* @param e
|
|
*/
|
|
const onGroupWidgets = (
|
|
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
|
|
) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
dispatch(groupWidgets());
|
|
};
|
|
|
|
if (!shouldRender) return false;
|
|
|
|
return (
|
|
<StyledSelectionBox
|
|
className="t--multi-selection-box"
|
|
data-testid="t--selection-box"
|
|
draggable
|
|
key={`selection-box-${props.widgetId}`}
|
|
onDragStart={onDragStart}
|
|
onMouseMove={() => focusWidget()}
|
|
onMouseOver={() => focusWidget()}
|
|
ref={draggableRef}
|
|
style={{
|
|
left,
|
|
top,
|
|
height,
|
|
width,
|
|
}}
|
|
>
|
|
<StyledSelectBoxHandleTop />
|
|
<StyledSelectBoxHandleLeft />
|
|
<StyledSelectBoxHandleRight />
|
|
<StyledSelectBoxHandleBottom />
|
|
<StyledActionsContainer>
|
|
<StyledActions>
|
|
{/* copy widgets */}
|
|
<Tooltip
|
|
boundary="viewport"
|
|
content={copyHelpText}
|
|
maxWidth="400px"
|
|
modifiers={PopoverModifiers}
|
|
position="right"
|
|
>
|
|
<StyledAction
|
|
onClick={stopEventPropagation}
|
|
onClickCapture={onCopySelectedWidgets}
|
|
>
|
|
<Icon name="duplicate" size="md" />
|
|
</StyledAction>
|
|
</Tooltip>
|
|
{/* cut widgets */}
|
|
<Tooltip
|
|
boundary="viewport"
|
|
content={cutHelpText}
|
|
maxWidth="400px"
|
|
modifiers={PopoverModifiers}
|
|
position="right"
|
|
>
|
|
<StyledAction
|
|
onClick={stopEventPropagation}
|
|
onClickCapture={onCutSelectedWidgets}
|
|
>
|
|
<Icon name="cut-control" size="md" />
|
|
</StyledAction>
|
|
</Tooltip>
|
|
{/* delete widgets */}
|
|
<Tooltip
|
|
boundary="viewport"
|
|
content={deleteHelpText}
|
|
maxWidth="400px"
|
|
modifiers={PopoverModifiers}
|
|
position="right"
|
|
>
|
|
<StyledAction
|
|
onClick={stopEventPropagation}
|
|
onClickCapture={onDeleteSelectedWidgets}
|
|
>
|
|
<Icon name="delete-bin-line" size="md" />
|
|
</StyledAction>
|
|
</Tooltip>
|
|
{/* group widgets */}
|
|
<Tooltip
|
|
boundary="viewport"
|
|
content={groupHelpText}
|
|
maxWidth="400px"
|
|
modifiers={PopoverModifiers}
|
|
position="right"
|
|
>
|
|
<StyledAction
|
|
onClick={stopEventPropagation}
|
|
onClickCapture={onGroupWidgets}
|
|
>
|
|
<Icon name="group-control" size="sm" />
|
|
</StyledAction>
|
|
</Tooltip>
|
|
</StyledActions>
|
|
</StyledActionsContainer>
|
|
</StyledSelectionBox>
|
|
);
|
|
}
|
|
|
|
export default WidgetsMultiSelectBox;
|