## Description
This PR upgrades Prettier to v2 + enforces TypeScript’s [`import
type`](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html#type-only-imports-and-export)
syntax where applicable. It’s submitted as a separate PR so we can merge
it easily.
As a part of this PR, we reformat the codebase heavily:
- add `import type` everywhere where it’s required, and
- re-format the code to account for Prettier 2’s breaking changes:
https://prettier.io/blog/2020/03/21/2.0.0.html#breaking-changes
This PR is submitted against `release` to make sure all new code by team
members will adhere to new formatting standards, and we’ll have fewer
conflicts when merging `bundle-optimizations` into `release`. (I’ll
merge `release` back into `bundle-optimizations` once this PR is
merged.)
### Why is this needed?
This PR is needed because, for the Lodash optimization from
7cbb12af88,
we need to use `import type`. Otherwise, `babel-plugin-lodash` complains
that `LoDashStatic` is not a lodash function.
However, just using `import type` in the current codebase will give you
this:
<img width="962" alt="Screenshot 2023-03-08 at 17 45 59"
src="https://user-images.githubusercontent.com/2953267/223775744-407afa0c-e8b9-44a1-90f9-b879348da57f.png">
That’s because Prettier 1 can’t parse `import type` at all. To parse it,
we need to upgrade to Prettier 2.
### Why enforce `import type`?
Apart from just enabling `import type` support, this PR enforces
specifying `import type` everywhere it’s needed. (Developers will get
immediate TypeScript and ESLint errors when they forget to do so.)
I’m doing this because I believe `import type` improves DX and makes
refactorings easier.
Let’s say you had a few imports like below. Can you tell which of these
imports will increase the bundle size? (Tip: it’s not all of them!)
```ts
// app/client/src/workers/Linting/utils.ts
import { Position } from "codemirror";
import { LintError as JSHintError, LintOptions } from "jshint";
import { get, isEmpty, isNumber, keys, last, set } from "lodash";
```
It’s pretty hard, right?
What about now?
```ts
// app/client/src/workers/Linting/utils.ts
import type { Position } from "codemirror";
import type { LintError as JSHintError, LintOptions } from "jshint";
import { get, isEmpty, isNumber, keys, last, set } from "lodash";
```
Now, it’s clear that only `lodash` will be bundled.
This helps developers to see which imports are problematic, but it
_also_ helps with refactorings. Now, if you want to see where
`codemirror` is bundled, you can just grep for `import \{.*\} from
"codemirror"` – and you won’t get any type-only imports.
This also helps (some) bundlers. Upon transpiling, TypeScript erases
type-only imports completely. In some environment (not ours), this makes
the bundle smaller, as the bundler doesn’t need to bundle type-only
imports anymore.
## Type of change
- Chore (housekeeping or task changes that don't impact user perception)
## How Has This Been Tested?
This was tested to not break the build.
### 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
- [x] 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
- [x] 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: Satish Gandham <hello@satishgandham.com>
Co-authored-by: Satish Gandham <satish.iitg@gmail.com>
408 lines
10 KiB
TypeScript
408 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 { FormIcons } from "icons/FormIcons";
|
|
import { TooltipComponent as Tooltip } from "design-system-old";
|
|
import { ControlIcons } from "icons/ControlIcons";
|
|
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";
|
|
|
|
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,
|
|
},
|
|
};
|
|
|
|
const CopyIcon = ControlIcons.COPY2_CONTROL;
|
|
const DeleteIcon = FormIcons.DELETE_ICON;
|
|
const CutIcon = ControlIcons.CUT_CONTROL;
|
|
const GroupIcon = ControlIcons.GROUP_CONTROL;
|
|
|
|
/**
|
|
* 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}
|
|
>
|
|
<CopyIcon color="black" height={16} width={16} />
|
|
</StyledAction>
|
|
</Tooltip>
|
|
{/* cut widgets */}
|
|
<Tooltip
|
|
boundary="viewport"
|
|
content={cutHelpText}
|
|
maxWidth="400px"
|
|
modifiers={PopoverModifiers}
|
|
position="right"
|
|
>
|
|
<StyledAction
|
|
onClick={stopEventPropagation}
|
|
onClickCapture={onCutSelectedWidgets}
|
|
>
|
|
<CutIcon color="black" height={16} width={16} />
|
|
</StyledAction>
|
|
</Tooltip>
|
|
{/* delete widgets */}
|
|
<Tooltip
|
|
boundary="viewport"
|
|
content={deleteHelpText}
|
|
maxWidth="400px"
|
|
modifiers={PopoverModifiers}
|
|
position="right"
|
|
>
|
|
<StyledAction
|
|
onClick={stopEventPropagation}
|
|
onClickCapture={onDeleteSelectedWidgets}
|
|
>
|
|
<DeleteIcon color="black" height={16} width={16} />
|
|
</StyledAction>
|
|
</Tooltip>
|
|
{/* group widgets */}
|
|
<Tooltip
|
|
boundary="viewport"
|
|
content={groupHelpText}
|
|
maxWidth="400px"
|
|
modifiers={PopoverModifiers}
|
|
position="right"
|
|
>
|
|
<StyledAction
|
|
onClick={stopEventPropagation}
|
|
onClickCapture={onGroupWidgets}
|
|
>
|
|
<GroupIcon color="black" height={16} width={16} />
|
|
</StyledAction>
|
|
</Tooltip>
|
|
</StyledActions>
|
|
</StyledActionsContainer>
|
|
</StyledSelectionBox>
|
|
);
|
|
}
|
|
|
|
export default WidgetsMultiSelectBox;
|