From ae5994c6c86da69dcf651df5734361047810d5c9 Mon Sep 17 00:00:00 2001
From: Kavin Venkatachalam
<50441969+kavinvenkatachalam@users.noreply.github.com>
Date: Tue, 12 Jul 2022 16:39:02 +0530
Subject: [PATCH] [Feature]: Copy/Paste widgets (#3252)
* Copy and paste widgets
* Paste widgets into container, support multi widget copy paste
* Replaced localstorage with clipboard while copying widget
* Fixed component name bug
---
frontend/src/Editor/Container.jsx | 44 +++++-
frontend/src/Editor/Editor.jsx | 12 +-
frontend/src/Editor/EditorKeyHooks.jsx | 16 ++-
frontend/src/Editor/Inspector/Inspector.jsx | 32 -----
frontend/src/_helpers/appUtils.js | 145 ++++++++++++++++++++
5 files changed, 207 insertions(+), 42 deletions(-)
diff --git a/frontend/src/Editor/Container.jsx b/frontend/src/Editor/Container.jsx
index a4355ec178..df16892ef0 100644
--- a/frontend/src/Editor/Container.jsx
+++ b/frontend/src/Editor/Container.jsx
@@ -16,6 +16,7 @@ import config from 'config';
import Spinner from '@/_ui/Spinner';
import { useHotkeys } from 'react-hotkeys-hook';
import produce from 'immer';
+import { addComponents } from '@/_helpers/appUtils';
export const Container = ({
canvasWidth,
@@ -61,11 +62,49 @@ export const Container = ({
const [isResizing, setIsResizing] = useState(false);
const [commentsPreviewList, setCommentsPreviewList] = useState([]);
const [newThread, addNewThread] = useState({});
+ const [isContainerFocused, setContainerFocus] = useState(false);
const router = useRouter();
+ const canvasRef = useRef(null);
+ const focusedParentIdRef = useRef(undefined);
useHotkeys('⌘+z, control+z', () => handleUndo());
useHotkeys('⌘+shift+z, control+shift+z', () => handleRedo());
+ useHotkeys(
+ '⌘+v, control+v',
+ () => {
+ if (isContainerFocused) {
+ navigator.clipboard
+ .readText()
+ .then((cliptext) =>
+ addComponents(appDefinition, appDefinitionChanged, focusedParentIdRef.current, JSON.parse(cliptext))
+ );
+ }
+ },
+ [isContainerFocused, appDefinition, focusedParentIdRef]
+ );
+
+ useEffect(() => {
+ const handleClick = (e) => {
+ if (canvasRef.current.contains(e.target)) {
+ const elem = e.target.closest('.real-canvas').getAttribute('id');
+ if (elem === 'real-canvas') {
+ focusedParentIdRef.current = undefined;
+ } else {
+ const parentId = elem.split('canvas-')[1];
+ focusedParentIdRef.current = parentId;
+ }
+ if (!isContainerFocused) {
+ setContainerFocus(true);
+ }
+ } else if (isContainerFocused) {
+ setContainerFocus(false);
+ }
+ };
+ document.addEventListener('click', handleClick);
+ return () => document.removeEventListener('click', handleClick);
+ }, [isContainerFocused, canvasRef]);
+
useEffect(() => {
setBoxes(components);
}, [components]);
@@ -403,7 +442,10 @@ export const Container = ({
return (
{
+ canvasRef.current = el;
+ drop(el);
+ }}
style={styles}
className={cx('real-canvas', {
'show-grid': isDragging || isResizing,
diff --git a/frontend/src/Editor/Editor.jsx b/frontend/src/Editor/Editor.jsx
index 358cd6ca4c..e4ef606fc1 100644
--- a/frontend/src/Editor/Editor.jsx
+++ b/frontend/src/Editor/Editor.jsx
@@ -34,6 +34,7 @@ import {
setStateAsync,
computeComponentState,
getSvgIcon,
+ cloneComponents,
} from '@/_helpers/appUtils';
import { Confirm } from './Viewer/Confirm';
import ReactTooltip from 'react-tooltip';
@@ -726,14 +727,10 @@ class Editor extends React.Component {
this.appDefinitionChanged(appDefinition);
};
- cloneComponent = (newComponent) => {
- const appDefinition = JSON.parse(JSON.stringify(this.state.appDefinition));
+ copyComponents = () => cloneComponents(this, this.appDefinitionChanged, false);
- newComponent.component.name = computeComponentName(newComponent.component.component, appDefinition.components);
+ cloneComponents = () => cloneComponents(this, this.appDefinitionChanged, true);
- appDefinition.components[newComponent.id] = newComponent;
- this.appDefinitionChanged(appDefinition);
- };
decimalToHex = (alpha) => (alpha === 0 ? '00' : Math.round(255 * alpha).toString(16));
globalSettingsChanged = (key, value) => {
@@ -1590,6 +1587,8 @@ class Editor extends React.Component {
@@ -1600,7 +1599,6 @@ class Editor extends React.Component {
!isEmpty(appDefinition.components) &&
!isEmpty(appDefinition.components[selectedComponents[0].id]) ? (
{
+export const EditorKeyHooks = ({
+ moveComponents,
+ copyComponents,
+ cloneComponents,
+ handleEditorEscapeKeyPress,
+ removeMultipleComponents,
+}) => {
const handleHotKeysCallback = (key) => {
switch (key) {
case 'Escape':
@@ -10,12 +16,18 @@ export const EditorKeyHooks = ({ moveComponents, handleEditorEscapeKeyPress, rem
case 'Backspace':
removeMultipleComponents();
break;
+ case 'KeyD':
+ cloneComponents();
+ break;
+ case 'KeyC':
+ copyComponents();
+ break;
default:
moveComponents(key);
}
};
- useKeyHooks(['up, down, left, right', 'esc', 'backspace'], handleHotKeysCallback);
+ useKeyHooks(['up, down, left, right', 'esc', 'backspace', 'cmd+d, ctrl+d, cmd+c, ctrl+c'], handleHotKeysCallback);
return <>>;
};
diff --git a/frontend/src/Editor/Inspector/Inspector.jsx b/frontend/src/Editor/Inspector/Inspector.jsx
index 53dc2d8aeb..a2de722b00 100644
--- a/frontend/src/Editor/Inspector/Inspector.jsx
+++ b/frontend/src/Editor/Inspector/Inspector.jsx
@@ -1,7 +1,6 @@
import React, { useState, useRef, useLayoutEffect } from 'react';
import Tabs from 'react-bootstrap/Tabs';
import Tab from 'react-bootstrap/Tab';
-import { v4 as uuidv4 } from 'uuid';
import { componentTypes } from '../WidgetManager/components';
import { Table } from './Components/Table';
import { Chart } from './Components/Chart';
@@ -17,7 +16,6 @@ import useFocus from '@/_hooks/use-focus';
import Accordion from '@/_ui/Accordion';
export const Inspector = ({
- cloneComponent,
selectedComponentId,
componentDefinitionChanged,
dataQueries,
@@ -45,36 +43,6 @@ export const Inspector = ({
useHotkeys('backspace', () => setWidgetDeleteConfirmation(true));
useHotkeys('escape', () => switchSidebarTab(2));
- useHotkeys('cmd+d, ctrl+d', (e) => {
- e.preventDefault();
- let clonedComponent = JSON.parse(JSON.stringify(component));
- clonedComponent.id = uuidv4();
- cloneComponent(clonedComponent);
-
- let childComponents = [];
-
- if ((component.component.component === 'Tabs') | (component.component.component === 'Calendar')) {
- childComponents = Object.keys(allComponents).filter((key) => allComponents[key].parent?.startsWith(component.id));
- } else {
- childComponents = Object.keys(allComponents).filter((key) => allComponents[key].parent === component.id);
- }
-
- childComponents.forEach((componentId) => {
- let childComponent = JSON.parse(JSON.stringify(allComponents[componentId]));
- childComponent.id = uuidv4();
-
- if ((component.component.component === 'Tabs') | (component.component.component === 'Calendar')) {
- const childTabId = childComponent.parent.split('-').at(-1);
- childComponent.parent = `${clonedComponent.id}-${childTabId}`;
- } else {
- childComponent.parent = clonedComponent.id;
- }
- cloneComponent(childComponent);
- });
- toast.success(`${component.component.name} cloned succesfully`);
- switchSidebarTab(2);
- });
-
const componentMeta = componentTypes.find((comp) => component.component.component === comp.component);
useLayoutEffect(() => {
diff --git a/frontend/src/_helpers/appUtils.js b/frontend/src/_helpers/appUtils.js
index efdba79928..0c46e6e877 100644
--- a/frontend/src/_helpers/appUtils.js
+++ b/frontend/src/_helpers/appUtils.js
@@ -5,6 +5,7 @@ import {
resolveReferences,
executeMultilineJS,
serializeNestedObjectToQueryParams,
+ computeComponentName,
} from '@/_helpers/utils';
import { dataqueryService } from '@/_services';
import _ from 'lodash';
@@ -14,6 +15,7 @@ import { componentTypes } from '@/Editor/WidgetManager/components';
import generateCSV from '@/_lib/generate-csv';
import generateFile from '@/_lib/generate-file';
import { allSvgs } from '@tooljet/plugins/client';
+import { v4 as uuidv4 } from 'uuid';
export function setStateAsync(_ref, state) {
return new Promise((resolve) => {
@@ -872,3 +874,146 @@ export const getSvgIcon = (key, height = 50, width = 50) => {
return ;
};
+
+const updateNewComponents = (appDefinition, newComponents, updateAppDefinition) => {
+ const newAppDefinition = JSON.parse(JSON.stringify(appDefinition));
+ newComponents.forEach((newComponent) => {
+ newComponent.component.name = computeComponentName(newComponent.component.component, newAppDefinition.components);
+ newAppDefinition.components[newComponent.id] = newComponent;
+ });
+ updateAppDefinition(newAppDefinition);
+};
+
+export const cloneComponents = (_ref, updateAppDefinition, isCloning = true) => {
+ const { selectedComponents, appDefinition } = _ref.state;
+ const { components: allComponents } = appDefinition;
+ let newComponents = [];
+ for (let selectedComponent of selectedComponents) {
+ const component = {
+ id: selectedComponent.id,
+ component: allComponents[selectedComponent.id]?.component,
+ layouts: allComponents[selectedComponent.id]?.layouts,
+ parent: allComponents[selectedComponent.id]?.parent,
+ };
+ let clonedComponent = JSON.parse(JSON.stringify(component));
+ clonedComponent.parent = undefined;
+ clonedComponent.children = [];
+ clonedComponent.children = [...getChildComponents(allComponents, component, clonedComponent)];
+ newComponents = [...newComponents, clonedComponent];
+ }
+ if (isCloning) {
+ addComponents(appDefinition, updateAppDefinition, undefined, newComponents, true);
+ toast.success('Component cloned succesfully');
+ } else {
+ navigator.clipboard.writeText(JSON.stringify(newComponents));
+ toast.success('Component copied succesfully');
+ }
+ _ref.setState({ currentSidebarTab: 2 });
+};
+
+const getChildComponents = (allComponents, component, parentComponent) => {
+ let childComponents = [],
+ selectedChildComponents = [];
+
+ if (component.component.component === 'Tabs' || component.component.component === 'Calendar') {
+ childComponents = Object.keys(allComponents).filter((key) => allComponents[key].parent?.startsWith(component.id));
+ } else {
+ childComponents = Object.keys(allComponents).filter((key) => allComponents[key].parent === component.id);
+ }
+
+ childComponents.forEach((componentId) => {
+ let childComponent = JSON.parse(JSON.stringify(allComponents[componentId]));
+ childComponent.id = componentId;
+ const newComponent = JSON.parse(
+ JSON.stringify({
+ id: componentId,
+ component: allComponents[componentId]?.component,
+ layouts: allComponents[componentId]?.layouts,
+ parent: allComponents[componentId]?.parent,
+ })
+ );
+
+ if ((component.component.component === 'Tabs') | (component.component.component === 'Calendar')) {
+ const childTabId = childComponent.parent.split('-').at(-1);
+ childComponent.parent = `${parentComponent.id}-${childTabId}`;
+ } else {
+ childComponent.parent = parentComponent.id;
+ }
+ parentComponent.children = [...(parentComponent.children || []), childComponent];
+ childComponent.children = [...getChildComponents(allComponents, newComponent, childComponent)];
+ selectedChildComponents.push(childComponent);
+ });
+
+ return selectedChildComponents;
+};
+
+const updateComponentLayout = (components, parentId) => {
+ let prevComponent;
+ components.forEach((component, index) => {
+ Object.keys(component.layouts).map((layout) => {
+ if (parentId !== undefined) {
+ if (index > 0) {
+ component.layouts[layout].top = prevComponent.layouts[layout].top + prevComponent.layouts[layout].height;
+ component.layouts[layout].left = 0;
+ } else {
+ component.layouts[layout].top = 0;
+ component.layouts[layout].left = 0;
+ }
+ prevComponent = component;
+ } else {
+ component.layouts[layout].top = component.layouts[layout].top + component.layouts[layout].height;
+ }
+ });
+ });
+};
+
+export const addComponents = (
+ appDefinition,
+ appDefinitionChanged,
+ parentId = undefined,
+ pastedComponent = [],
+ isCloning = false
+) => {
+ const finalComponents = [];
+ let parentComponent = undefined;
+
+ if (parentId) {
+ const id = Object.keys(appDefinition.components).filter((key) => parentId.startsWith(key));
+ parentComponent = JSON.parse(JSON.stringify(appDefinition.components[id[0]]));
+ parentComponent.id = parentId;
+ }
+
+ !isCloning && updateComponentLayout(pastedComponent, parentId);
+
+ const buildComponents = (components, parentComponent = undefined, skipTabCalendarCheck = false) => {
+ if (Array.isArray(components) && components.length > 0) {
+ components.forEach((component) => {
+ const newComponent = {
+ id: uuidv4(),
+ component: component?.component,
+ layouts: component?.layouts,
+ };
+ if (parentComponent) {
+ if (
+ !skipTabCalendarCheck &&
+ (parentComponent.component.component === 'Tabs' || parentComponent.component.component === 'Calendar')
+ ) {
+ const childTabId = component.parent.split('-').at(-1);
+ newComponent.parent = `${parentComponent.id}-${childTabId}`;
+ } else {
+ newComponent.parent = parentComponent.id;
+ }
+ }
+ finalComponents.push(newComponent);
+ if (component.children.length > 0) {
+ buildComponents(component.children, newComponent);
+ }
+ });
+ }
+ };
+
+ buildComponents(pastedComponent, parentComponent, true);
+
+ updateNewComponents(appDefinition, finalComponents, appDefinitionChanged);
+ !isCloning && toast.success('Component pasted succesfully');
+};