ToolJet/frontend/src/Editor/Editor.jsx
Gandharv 9dbe6368e6
fix: able to edit app post version released (#2915)
* fix: able to edit app post version released

* feat: make datasources & queries realtime
2022-04-28 16:46:27 +05:30

1369 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* eslint-disable import/no-named-as-default */
import React, { createRef } from 'react';
import { datasourceService, dataqueryService, appService, authenticationService, appVersionService } from '@/_services';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { computeComponentName } from '@/_helpers/utils';
import { defaults, cloneDeep, isEqual, isEmpty, debounce } from 'lodash';
import { Container } from './Container';
import { CustomDragLayer } from './CustomDragLayer';
import { LeftSidebar } from './LeftSidebar';
import { componentTypes } from './Components/components';
import { Inspector } from './Inspector/Inspector';
import { DataSourceTypes } from './DataSourceManager/SourceComponents';
import { QueryManager } from './QueryManager';
import { Link } from 'react-router-dom';
import { ManageAppUsers } from './ManageAppUsers';
import { ReleaseVersionButton } from './ReleaseVersionButton';
import {
onComponentOptionChanged,
onComponentOptionsChanged,
onEvent,
onQueryConfirm,
onQueryCancel,
runQuery,
setStateAsync,
computeComponentState,
getSvgIcon,
} from '@/_helpers/appUtils';
import { Confirm } from './Viewer/Confirm';
import ReactTooltip from 'react-tooltip';
import CommentNotifications from './CommentNotifications';
import { WidgetManager } from './WidgetManager';
import Fuse from 'fuse.js';
import config from 'config';
import queryString from 'query-string';
import toast from 'react-hot-toast';
import produce, { enablePatches, setAutoFreeze, applyPatches } from 'immer';
import Logo from './Icons/logo.svg';
import RunjsIcon from './Icons/runjs.svg';
import EditIcon from './Icons/edit.svg';
import MobileSelectedIcon from './Icons/mobile-selected.svg';
import DesktopSelectedIcon from './Icons/desktop-selected.svg';
import { AppVersionsManager } from './AppVersionsManager';
import { SearchBoxComponent } from '@/_ui/Search';
import { createWebsocketConnection } from '@/_helpers/websocketConnection';
import { Cursor } from './Cursor';
import Tooltip from 'react-bootstrap/Tooltip';
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
import RealtimeAvatars from './RealtimeAvatars';
import InitVersionCreateModal from './InitVersionCreateModal';
setAutoFreeze(false);
enablePatches();
class Editor extends React.Component {
constructor(props) {
super(props);
const appId = this.props.match.params.id;
const currentUser = authenticationService.currentUserValue;
const { socket } = createWebsocketConnection(appId);
this.socket = socket;
let userVars = {};
if (currentUser) {
userVars = {
email: currentUser.email,
firstName: currentUser.first_name,
lastName: currentUser.last_name,
groups: currentUser?.group_permissions.map((group) => group.group),
};
}
this.defaultDefinition = {
components: {},
globalSettings: {
hideHeader: false,
appInMaintenance: false,
canvasMaxWidth: 1292,
canvasBackgroundColor: props.darkMode ? '#2f3c4c' : '#edeff5',
},
};
this.state = {
currentUser: authenticationService.currentUserValue,
app: {},
allComponentTypes: componentTypes,
isQueryPaneDragging: false,
queryPaneHeight: 70,
isTopOfQueryPane: false,
isLoading: true,
users: null,
appId,
editingVersion: null,
loadingDataSources: true,
loadingDataQueries: true,
showQueryEditor: true,
showLeftSidebar: true,
showComments: false,
zoomLevel: 1.0,
currentLayout: 'desktop',
deviceWindowWidth: 450,
appDefinition: this.defaultDefinition,
currentState: {
queries: {},
components: {},
globals: {
currentUser: userVars,
theme: { name: props.darkMode ? 'dark' : 'light' },
urlparams: JSON.parse(JSON.stringify(queryString.parse(props.location.search))),
},
errors: {},
variables: {},
},
apps: [],
dataQueriesDefaultText: "You haven't created queries yet.",
showQuerySearchField: false,
isDeletingDataQuery: false,
showHiddenOptionsForDataQueryId: null,
showQueryConfirmation: false,
showInitVersionCreateModal: false,
showCreateVersionModalPrompt: false,
isSourceSelected: false,
};
this.autoSave = debounce(this.saveEditingVersion, 3000);
this.realtimeSave = debounce(this.appDefinitionChanged, 500);
}
setWindowTitle(name) {
document.title = name ? `${name} - Tooljet` : `Untitled App - Tooljet`;
}
componentDidMount() {
this.fetchApps(0);
this.fetchApp();
this.initComponentVersioning();
this.initRealtimeSave();
this.initEventListeners();
this.setState({
currentSidebarTab: 2,
selectedComponent: null,
});
}
/**
* When a new update is received over-the-websocket connection
* the useEffect in Container.jsx is trigged, but already appDef had been updated
* to avoid ymap observe going into a infinite loop a check is added where if the
* current appDef is equal to the newAppDef then we do not trigger a realtimeSave
*/
initRealtimeSave = () => {
this.props.ymap.observe(() => {
if (!isEqual(this.state.editingVersion?.id, this.props.ymap.get('appDef').editingVersionId)) return;
if (isEqual(this.state.appDefinition, this.props.ymap.get('appDef').newDefinition)) return;
this.realtimeSave(this.props.ymap.get('appDef').newDefinition, { skipAutoSave: true, skipYmapUpdate: true });
});
};
componentDidUpdate(prevProps, prevState) {
if (!isEqual(prevState.appDefinition, this.state.appDefinition)) {
computeComponentState(this, this.state.appDefinition.components);
}
}
isVersionReleased = (version = this.state.editingVersion) => {
if (isEmpty(version)) {
return false;
}
return this.state.app.current_version_id === version.id;
};
closeCreateVersionModalPrompt = () => {
this.setState({ showCreateVersionModalPrompt: false });
};
onMouseMove = (e) => {
const componentTop = Math.round(this.queryPaneRef.current.getBoundingClientRect().top);
const clientY = e.clientY;
if ((clientY >= componentTop) & (clientY <= componentTop + 5)) {
this.setState({
isTopOfQueryPane: true,
});
} else if (this.state.isTopOfQueryPane) {
this.setState({
isTopOfQueryPane: false,
});
}
if (this.state.isQueryPaneDragging) {
let queryPaneHeight = (clientY / window.innerHeight) * 100;
if (queryPaneHeight > 95) queryPaneHeight = 100;
if (queryPaneHeight < 4.5) queryPaneHeight = 4.5;
this.setState({
queryPaneHeight,
});
}
};
onMouseDown = () => {
this.state.isTopOfQueryPane &&
this.setState({
isQueryPaneDragging: true,
});
};
onMouseUp = () => {
this.setState({
isQueryPaneDragging: false,
});
};
initEventListeners() {
document.addEventListener('mousemove', this.onMouseMove);
document.addEventListener('mouseup', this.onMouseUp);
this.socket?.addEventListener('message', (event) => {
if (event.data === 'versionReleased') this.fetchApp();
else if (event.data === 'dataQueriesChanged') this.fetchDataQueries();
else if (event.data === 'dataSourcesChanged') this.fetchDataSources();
});
}
componentWillUnmount() {
document.removeEventListener('mousemove', this.onMouseMove);
document.removeEventListener('mouseup', this.onMouseUp);
document.title = 'Tooljet - Dashboard';
this.socket && this.socket?.close();
}
// 1. When we receive an undoable action we can always undo but cannot redo anymore.
// 2. Whenever you perform an undo you can always redo and keep doing undo as long as we have a patch for it.
// 3. Whenever you redo you can always undo and keep doing redo as long as we have a patch for it.
initComponentVersioning = () => {
this.currentVersion = -1;
this.currentVersionChanges = {};
this.noOfVersionsSupported = 100;
this.canUndo = false;
this.canRedo = false;
};
fetchDataSources = () => {
this.setState(
{
loadingDataSources: true,
},
() => {
datasourceService.getAll(this.state.appId, this.state.editingVersion?.id).then((data) =>
this.setState({
dataSources: data.data_sources,
loadingDataSources: false,
})
);
}
);
};
fetchDataQueries = () => {
this.setState(
{
loadingDataQueries: true,
},
() => {
dataqueryService.getAll(this.state.appId, this.state.editingVersion?.id).then((data) => {
this.setState(
{
dataQueries: data.data_queries,
loadingDataQueries: false,
app: {
...this.state.app,
data_queries: data.data_queries,
},
},
() => {
let queryState = {};
data.data_queries.forEach((query) => {
queryState[query.name] = {
...DataSourceTypes.find((source) => source.kind === query.kind).exposedVariables,
...this.state.currentState.queries[query.name],
};
});
// Select first query by default
let selectedQuery =
data.data_queries.find((dq) => dq.id === this.state.selectedQuery?.id) || data.data_queries[0];
let editingQuery = selectedQuery ? true : false;
this.setState({
selectedQuery,
editingQuery,
currentState: {
...this.state.currentState,
queries: {
...queryState,
},
},
showQuerySearchField: false,
});
}
);
});
}
);
};
runQueries = (queries) => {
queries.forEach((query) => {
if (query.options.runOnPageLoad) {
runQuery(this, query.id, query.name);
}
});
};
toggleAppMaintenance = () => {
const newState = !this.state.app.is_maintenance_on;
// eslint-disable-next-line no-unused-vars
appService.setMaintenance(this.state.app.id, newState).then((data) => {
this.setState({
app: {
...this.state.app,
is_maintenance_on: newState,
},
});
if (newState) {
toast.success('Application is on maintenance.');
} else {
toast.success('Application maintenance is completed');
}
});
};
fetchApps = (page) => {
appService.getAll(page).then((data) =>
this.setState({
apps: data.apps,
isLoading: false,
})
);
};
fetchApp = () => {
const appId = this.props.match.params.id;
appService.getApp(appId).then((data) => {
const dataDefinition = defaults(data.definition, this.defaultDefinition);
this.setState(
{
app: data,
isLoading: false,
editingVersion: data.editing_version,
appDefinition: dataDefinition,
slug: data.slug,
},
() => {
this.setState({
showInitVersionCreateModal: isEmpty(this.state.editingVersion),
});
computeComponentState(this, this.state.appDefinition.components).then(() => {
console.log('Default component state computed and set');
this.runQueries(data.data_queries);
});
this.setWindowTitle(data.name);
}
);
this.fetchDataSources();
this.fetchDataQueries();
});
};
setAppDefinitionFromVersion = (version) => {
this.appDefinitionChanged(defaults(version.definition, this.defaultDefinition), {
skipAutoSave: true,
skipYmapUpdate: true,
});
this.setState({
editingVersion: version,
});
this.fetchDataSources();
this.fetchDataQueries();
this.initComponentVersioning();
};
dataSourcesChanged = () => {
this.socket.send(
JSON.stringify({
event: 'events',
data: { message: 'dataSourcesChanged', appId: this.state.appId },
})
);
};
dataQueriesChanged = () => {
this.setState({ addingQuery: false }, () => {
this.socket.send(
JSON.stringify({
event: 'events',
data: { message: 'dataQueriesChanged', appId: this.state.appId },
})
);
});
};
switchSidebarTab = (tabIndex) => {
if (tabIndex === 2) {
this.setState({ selectedComponent: null });
}
this.setState({
currentSidebarTab: tabIndex,
});
};
filterComponents = (event) => {
const searchText = event.currentTarget.value;
let filteredComponents = this.state.allComponentTypes;
if (searchText !== '') {
filteredComponents = this.state.allComponentTypes.filter(
(e) => e.name.toLowerCase() === searchText.toLowerCase()
);
}
this.setState({ componentTypes: filteredComponents });
};
handleAddPatch = (patches, inversePatches) => {
if (isEmpty(patches) && isEmpty(inversePatches)) return;
if (isEqual(patches, inversePatches)) return;
this.currentVersion++;
this.currentVersionChanges[this.currentVersion] = {
redo: patches,
undo: inversePatches,
};
this.canUndo = this.currentVersionChanges.hasOwnProperty(this.currentVersion);
this.canRedo = this.currentVersionChanges.hasOwnProperty(this.currentVersion + 1);
delete this.currentVersionChanges[this.currentVersion + 1];
delete this.currentVersionChanges[this.currentVersion - this.noOfVersionsSupported];
};
handleUndo = () => {
if (this.canUndo) {
const appDefinition = applyPatches(
this.state.appDefinition,
this.currentVersionChanges[this.currentVersion--].undo
);
this.canUndo = this.currentVersionChanges.hasOwnProperty(this.currentVersion);
this.canRedo = true;
if (!appDefinition) return;
this.setState({
appDefinition,
});
}
};
handleRedo = () => {
if (this.canRedo) {
const appDefinition = applyPatches(
this.state.appDefinition,
this.currentVersionChanges[++this.currentVersion].redo
);
this.canUndo = true;
this.canRedo = this.currentVersionChanges.hasOwnProperty(this.currentVersion + 1);
if (!appDefinition) return;
this.setState({
appDefinition,
});
}
};
appDefinitionChanged = (newDefinition, opts = {}) => {
if (isEqual(this.state.appDefinition, newDefinition)) return;
if (!opts.skipYmapUpdate) {
this.props.ymap.set('appDef', { newDefinition, editingVersionId: this.state.editingVersion?.id });
}
produce(
this.state.appDefinition,
(draft) => {
draft.components = newDefinition.components;
},
this.handleAddPatch
);
this.setState({ appDefinition: newDefinition }, () => {
if (!opts.skipAutoSave) this.autoSave();
});
computeComponentState(this, newDefinition.components);
};
handleInspectorView = (component) => {
if (this.state.selectedComponent?.hasOwnProperty('component')) {
const { id: selectedComponentId } = this.state.selectedComponent;
if (selectedComponentId === component.id) {
this.setState({ selectedComponent: null });
this.switchSidebarTab(2);
}
}
};
handleSlugChange = (newSlug) => {
this.setState({ slug: newSlug });
};
removeComponent = (component) => {
if (!this.isVersionReleased()) {
let newDefinition = cloneDeep(this.state.appDefinition);
// Delete child components when parent is deleted
let childComponents = [];
if (newDefinition.components[component.id].component.component === 'Tabs') {
childComponents = Object.keys(newDefinition.components).filter((key) =>
newDefinition.components[key].parent?.startsWith(component.id)
);
} else {
childComponents = Object.keys(newDefinition.components).filter(
(key) => newDefinition.components[key].parent === component.id
);
}
childComponents.forEach((componentId) => {
delete newDefinition.components[componentId];
});
delete newDefinition.components[component.id];
toast('Component deleted! (⌘Z to undo)', {
icon: '🗑️',
});
this.appDefinitionChanged(newDefinition, {
skipAutoSave: this.isVersionReleased(),
});
this.handleInspectorView(component);
}
};
componentDefinitionChanged = (componentDefinition) => {
let _self = this;
const newDefinition = {
appDefinition: produce(this.state.appDefinition, (draft) => {
draft.components[componentDefinition.id].component = componentDefinition.component;
}),
};
produce(
this.state.appDefinition,
(draft) => {
draft.components[componentDefinition.id].component = componentDefinition.component;
},
this.handleAddPatch
);
setStateAsync(_self, newDefinition).then(() => {
computeComponentState(_self, _self.state.appDefinition.components);
this.autoSave();
this.props.ymap.set('appDef', {
newDefinition: newDefinition.appDefinition,
editingVersionId: this.state.editingVersion?.id,
});
});
};
cloneComponent = (newComponent) => {
const appDefinition = JSON.parse(JSON.stringify(this.state.appDefinition));
newComponent.component.name = computeComponentName(newComponent.component.component, appDefinition.components);
appDefinition.components[newComponent.id] = newComponent;
this.appDefinitionChanged(appDefinition);
};
globalSettingsChanged = (key, value) => {
const appDefinition = { ...this.state.appDefinition };
appDefinition.globalSettings[key] = value;
this.setState(
{
appDefinition,
},
() => {
this.props.ymap.set('appDef', {
newDefinition: appDefinition,
editingVersionId: this.state.editingVersion?.id,
});
this.autoSave();
}
);
};
saveApp = (id, attributes, notify = false) => {
appService.saveApp(id, attributes).then(() => {
if (notify) {
toast.success('App saved sucessfully');
}
});
};
saveAppName = (id, name, notify = false) => {
if (!name.trim()) {
toast("App name can't be empty or whitespace", {
icon: '🚨',
});
this.setState({
app: { ...this.state.app, name: this.state.oldName },
});
return;
}
this.saveApp(id, { name }, notify);
};
renderDataSource = (dataSource) => {
const sourceMeta = DataSourceTypes.find((source) => source.kind === dataSource.kind);
return (
<tr
role="button"
key={dataSource.name}
onClick={() => {
this.setState({
selectedDataSource: dataSource,
showDataSourceManagerModal: true,
});
}}
>
<td>
{getSvgIcon(sourceMeta.kind.toLowerCase(), 25, 25)} {dataSource.name}
</td>
</tr>
);
};
deleteDataQuery = () => {
this.setState({ showDataQueryDeletionConfirmation: true });
};
cancelDeleteDataQuery = () => {
this.setState({ showDataQueryDeletionConfirmation: false });
};
executeDataQueryDeletion = () => {
this.setState({
showDataQueryDeletionConfirmation: false,
isDeletingDataQuery: true,
});
dataqueryService
.del(this.state.selectedQuery.id)
.then(() => {
toast.success('Query Deleted');
this.setState({ isDeletingDataQuery: false });
this.dataQueriesChanged();
})
.catch(({ error }) => {
this.setState({ isDeletingDataQuery: false });
toast.error(error);
});
};
setShowHiddenOptionsForDataQuery = (dataQueryId) => {
this.setState({ showHiddenOptionsForDataQueryId: dataQueryId });
};
renderDataQuery = (dataQuery) => {
const sourceMeta = DataSourceTypes.find((source) => source.kind === dataQuery.kind);
let isSeletedQuery = false;
if (this.state.selectedQuery) {
isSeletedQuery = dataQuery.id === this.state.selectedQuery.id;
}
const isQueryBeingDeleted = this.state.isDeletingDataQuery && isSeletedQuery;
const { currentState } = this.state;
const isLoading = currentState.queries[dataQuery.name] ? currentState.queries[dataQuery.name].isLoading : false;
return (
<div
className={
'row query-row mb-1 py-2 px-3' +
(isSeletedQuery ? ' query-row-selected' : '') +
(this.props.darkMode ? ' dark' : '')
}
key={dataQuery.id}
onClick={() => this.setState({ editingQuery: true, selectedQuery: dataQuery })}
role="button"
onMouseEnter={() => this.setShowHiddenOptionsForDataQuery(dataQuery.id)}
onMouseLeave={() => this.setShowHiddenOptionsForDataQuery(null)}
>
<div className="col-auto" style={{ width: '28px' }}>
{sourceMeta.kind === 'runjs' ? (
<RunjsIcon style={{ height: 25, width: 25 }} />
) : (
getSvgIcon(sourceMeta.kind.toLowerCase(), 25, 25)
)}
</div>
<div className="col">
<OverlayTrigger
trigger={['hover', 'focus']}
placement="top"
delay={{ show: 800, hide: 100 }}
overlay={<Tooltip id="button-tooltip">{dataQuery.name}</Tooltip>}
>
<div className="px-3 query-name">{dataQuery.name}</div>
</OverlayTrigger>
</div>
<div className="col-auto mx-1">
{isQueryBeingDeleted ? (
<div className="px-2">
<div className="text-center spinner-border spinner-border-sm" role="status"></div>
</div>
) : (
<button
className="btn badge bg-azure-lt"
onClick={this.deleteDataQuery}
style={{
display: this.state.showHiddenOptionsForDataQueryId === dataQuery.id ? 'block' : 'none',
marginTop: '3px',
}}
>
<div>
<img src="/assets/images/icons/query-trash-icon.svg" width="12" height="12" className="mx-1" />
</div>
</button>
)}
</div>
<div className="col-auto" style={{ width: '28px' }}>
{isLoading === true ? (
<center>
<div className="pt-1">
<div className="text-center spinner-border spinner-border-sm" role="status"></div>
</div>
</center>
) : (
<button
style={{ marginTop: '3px' }}
className="btn badge bg-light-1"
onClick={() => {
runQuery(this, dataQuery.id, dataQuery.name).then(() => {
toast(`Query (${dataQuery.name}) completed.`, {
icon: '🚀',
});
});
}}
>
<div className={`query-icon ${this.props.darkMode && 'dark'}`}>
<img src="/assets/images/icons/editor/play.svg" width="8" height="8" className="mx-1" />
</div>
</button>
)}
</div>
</div>
);
};
onNameChanged = (newName) => {
this.setState({
app: { ...this.state.app, name: newName },
});
this.setWindowTitle(newName);
};
toggleQueryEditor = () => {
this.setState((prev) => ({
showQueryEditor: !prev.showQueryEditor,
queryPaneHeight: this.state.queryPaneHeight === 100 ? 30 : 100,
}));
};
toggleComments = () => {
this.setState({ showComments: !this.state.showComments });
};
setSelectedComponent = (id, component) => {
this.switchSidebarTab(1);
this.setState({ selectedComponent: { id, component } });
};
filterQueries = (value) => {
if (value) {
const fuse = new Fuse(this.state.dataQueries, { keys: ['name'] });
const results = fuse.search(value);
this.setState({
dataQueries: results.map((result) => result.item),
dataQueriesDefaultText: 'No Queries found.',
});
} else {
this.fetchDataQueries();
}
};
toggleQuerySearch = () => {
this.setState((prev) => ({
showQuerySearchField: !prev.showQuerySearchField,
}));
};
onVersionRelease = (versionId) => {
this.setState(
{
app: {
...this.state.app,
current_version_id: versionId,
},
},
() => {
this.socket.send(
JSON.stringify({
event: 'events',
data: { message: 'versionReleased', appId: this.state.appId },
})
);
}
);
};
onZoomChanged = (zoom) => {
this.setState({
zoomLevel: zoom,
});
};
queryPaneRef = createRef();
getCanvasWidth = () => {
const canvasBoundingRect = document.getElementsByClassName('canvas-area')[0].getBoundingClientRect();
return canvasBoundingRect?.width;
};
renderLayoutIcon = (isDesktopSelected) => {
if (isDesktopSelected)
return (
<span
onClick={() =>
this.setState({
currentLayout: isDesktopSelected ? 'mobile' : 'desktop',
})
}
>
<DesktopSelectedIcon />
</span>
);
return (
<span
onClick={() =>
this.setState({
currentLayout: isDesktopSelected ? 'mobile' : 'desktop',
})
}
>
<MobileSelectedIcon />
</span>
);
};
saveEditingVersion = () => {
if (this.isVersionReleased()) {
this.setState({ showCreateVersionModalPrompt: true });
} else if (!isEmpty(this.state.editingVersion)) {
toast.promise(appVersionService.save(this.state.appId, this.state.editingVersion.id, this.state.appDefinition), {
loading: 'Saving...',
success: () => {
this.setState({
editingVersion: {
...this.state.editingVersion,
...{ definition: this.state.appDefinition },
},
});
return 'Saved!';
},
error: 'App could not save.',
});
}
};
handleOnComponentOptionChanged = (component, optionName, value) => {
return onComponentOptionChanged(this, component, optionName, value);
};
handleOnComponentOptionsChanged = (component, options) => {
return onComponentOptionsChanged(this, component, options);
};
handleComponentClick = (id, component) => {
this.setState({
selectedComponent: { id, component },
});
this.switchSidebarTab(1);
};
handleComponentHover = (id) => {
this.setState({
hoveredComponent: id,
});
};
changeDarkMode = (newMode) => {
this.setState({
currentState: {
...this.state.currentState,
globals: {
...this.state.currentState.globals,
theme: { name: newMode ? 'dark' : 'light' },
},
},
showQuerySearchField: false,
});
this.props.switchDarkMode(newMode);
};
handleEvent = (eventName, options) => onEvent(this, eventName, options, 'edit');
render() {
const {
currentSidebarTab,
selectedComponent = {},
appDefinition,
appId,
slug,
dataSources,
loadingDataQueries,
dataQueries,
loadingDataSources,
addingQuery,
selectedQuery,
editingQuery,
app,
showQueryConfirmation,
queryPaneHeight,
showLeftSidebar,
currentState,
isLoading,
zoomLevel,
currentLayout,
deviceWindowWidth,
dataQueriesDefaultText,
showQuerySearchField,
showDataQueryDeletionConfirmation,
isDeletingDataQuery,
apps,
defaultComponentStateComputed,
showComments,
editingVersion,
showCreateVersionModalPrompt,
hoveredComponent,
} = this.state;
const appVersionPreviewLink = editingVersion ? `/applications/${app.id}/versions/${editingVersion.id}` : '';
return (
<div className="editor wrapper">
<ReactTooltip type="dark" effect="solid" eventOff="click" delayShow={250} />
{/* This is for viewer to show query confirmations */}
<Confirm
show={showQueryConfirmation}
message={'Do you want to run this query?'}
onConfirm={(queryConfirmationData) => onQueryConfirm(this, queryConfirmationData)}
onCancel={() => onQueryCancel(this)}
queryConfirmationData={this.state.queryConfirmationData}
darkMode={this.props.darkMode}
/>
<Confirm
show={showDataQueryDeletionConfirmation}
message={'Do you really want to delete this query?'}
confirmButtonLoading={isDeletingDataQuery}
onConfirm={() => this.executeDataQueryDeletion()}
onCancel={() => this.cancelDeleteDataQuery()}
darkMode={this.props.darkMode}
/>
<div className="header">
<header className="navbar navbar-expand-md navbar-light d-print-none">
<div className="container-xl header-container">
<button className="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu">
<span className="navbar-toggler-icon"></span>
</button>
<h1 className="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0">
<Link to={'/'}>
<Logo />
</Link>
</h1>
{this.state.app && (
<div className={`app-name input-icon ${this.props.darkMode ? 'dark' : ''}`}>
<input
type="text"
onFocus={(e) => this.setState({ oldName: e.target.value })}
onChange={(e) => this.onNameChanged(e.target.value)}
onBlur={(e) => this.saveAppName(this.state.app.id, e.target.value)}
className="form-control-plaintext form-control-plaintext-sm"
value={this.state.app.name}
/>
<span className="input-icon-addon">
<EditIcon />
</span>
</div>
)}
<RealtimeAvatars
updatePresence={this.props.updatePresence}
editingVersionId={this.state?.editingVersion?.id}
self={this.props.self}
/>
{editingVersion && (
<AppVersionsManager
appId={appId}
editingVersion={editingVersion}
releasedVersionId={app.current_version_id}
setAppDefinitionFromVersion={this.setAppDefinitionFromVersion}
showCreateVersionModalPrompt={showCreateVersionModalPrompt}
closeCreateVersionModalPrompt={this.closeCreateVersionModalPrompt}
/>
)}
<div className="layout-buttons cursor-pointer">{this.renderLayoutIcon(currentLayout === 'desktop')}</div>
<div className="navbar-nav flex-row order-md-last release-buttons">
<div className="nav-item dropdown d-none d-md-flex me-2">
<a
href={appVersionPreviewLink}
target="_blank"
className={`btn btn-sm font-500 color-primary ${app?.current_version_id ? '' : 'disabled'}`}
rel="noreferrer"
>
Preview
</a>
</div>
<div className="nav-item dropdown d-none d-md-flex me-2">
{app.id && (
<ManageAppUsers
app={app}
slug={slug}
darkMode={this.props.darkMode}
handleSlugChange={this.handleSlugChange}
/>
)}
</div>
<div className="nav-item dropdown me-2">
{app.id && (
<ReleaseVersionButton
isVersionReleased={this.isVersionReleased()}
appId={app.id}
appName={app.name}
onVersionRelease={this.onVersionRelease}
editingVersion={editingVersion}
fetchApp={this.fetchApp}
saveEditingVersion={this.saveEditingVersion}
/>
)}
</div>
</div>
</div>
</header>
</div>
<DndProvider backend={HTML5Backend}>
<div className="sub-section">
<LeftSidebar
appVersionsId={this.state?.editingVersion?.id}
errorLogs={currentState.errors}
components={currentState.components}
appId={appId}
darkMode={this.props.darkMode}
dataSources={this.state.dataSources}
dataSourcesChanged={this.dataSourcesChanged}
onZoomChanged={this.onZoomChanged}
toggleComments={this.toggleComments}
switchDarkMode={this.changeDarkMode}
globalSettingsChanged={this.globalSettingsChanged}
globalSettings={appDefinition.globalSettings}
currentState={currentState}
toggleAppMaintenance={this.toggleAppMaintenance}
is_maintenance_on={this.state.app.is_maintenance_on}
/>
<div className="main main-editor-canvas" id="main-editor-canvas">
<div
className={`canvas-container align-items-center ${!showLeftSidebar && 'hide-sidebar'}`}
style={{ transform: `scale(${zoomLevel})` }}
onClick={(e) => {
if (['real-canvas', 'modal'].includes(e.target.className)) {
this.switchSidebarTab(2);
}
}}
>
<div
className="canvas-area"
style={{
width: currentLayout === 'desktop' ? '100%' : '450px',
maxWidth: +this.state.appDefinition.globalSettings.canvasMaxWidth,
backgroundColor: this.state.appDefinition.globalSettings.canvasBackgroundColor,
}}
>
{this.props.othersOnSameVersion.map(({ id, presence }) => {
if (!presence) return null;
return (
<Cursor key={id} name={presence.firstName} color={presence.color} x={presence.x} y={presence.y} />
);
})}
{defaultComponentStateComputed && (
<>
<Container
canvasWidth={this.getCanvasWidth()}
socket={this.socket}
showComments={showComments}
appVersionsId={this.state?.editingVersion?.id}
appDefinition={appDefinition}
appDefinitionChanged={this.appDefinitionChanged}
snapToGrid={true}
darkMode={this.props.darkMode}
mode={'edit'}
zoomLevel={zoomLevel}
currentLayout={currentLayout}
deviceWindowWidth={deviceWindowWidth}
selectedComponent={selectedComponent}
appLoading={isLoading}
onEvent={this.handleEvent}
onComponentOptionChanged={this.handleOnComponentOptionChanged}
onComponentOptionsChanged={this.handleOnComponentOptionsChanged}
currentState={this.state.currentState}
setSelectedComponent={this.setSelectedComponent}
handleUndo={this.handleUndo}
handleRedo={this.handleRedo}
removeComponent={this.removeComponent}
onComponentClick={this.handleComponentClick}
onComponentHover={this.handleComponentHover}
hoveredComponent={hoveredComponent}
/>
<CustomDragLayer
snapToGrid={true}
currentLayout={currentLayout}
canvasWidth={this.getCanvasWidth()}
/>
</>
)}
</div>
</div>
<div
className="query-pane"
style={{
height: 40,
background: '#fff',
padding: '8px 16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<h5 className="mb-0">QUERIES</h5>
<span onClick={this.toggleQueryEditor} className="cursor-pointer m-1" data-tip="Show query editor">
<svg
style={{ transform: 'rotate(180deg)' }}
width="18"
height="10"
viewBox="0 0 18 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1 1L9 9L17 1"
stroke="#61656F"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</div>
<div
ref={this.queryPaneRef}
onTouchEnd={this.onMouseUp}
onMouseDown={this.onMouseDown}
className="query-pane"
style={{
height: `calc(100% - ${this.state.queryPaneHeight}%)`,
width: !showLeftSidebar ? '85%' : '',
left: !showLeftSidebar ? '0' : '',
cursor: this.state.isQueryPaneDragging || this.state.isTopOfQueryPane ? 'row-resize' : 'default',
}}
>
<div className="row main-row">
<div className="data-pane">
<div className="queries-container">
<div className="queries-header row" style={{ marginLeft: '1.5px' }}>
{showQuerySearchField && (
<div className="col-12 p-1">
<div className="queries-search px-1">
<SearchBoxComponent
onChange={this.filterQueries}
callback={this.toggleQuerySearch}
placeholder={'Search queries'}
/>
</div>
</div>
)}
{!showQuerySearchField && (
<>
<div className="col">
<h5
style={{ fontSize: '14px', marginLeft: ' 6px' }}
className="py-1 px-3 mt-2 text-muted"
>
Queries
</h5>
</div>
<div className="col-auto mx-1">
<span
className={`query-btn mx-1 ${this.props.darkMode ? 'dark' : ''}`}
data-class="py-1 px-0"
onClick={this.toggleQuerySearch}
>
<img className="py-1 mt-2" src="/assets/images/icons/lens.svg" width="24" height="24" />
</span>
<span
className={`query-btn mx-3 ${this.props.darkMode ? 'dark' : ''}`}
data-tip="Add new query"
data-class="py-1 px-2"
onClick={() =>
this.setState({
options: {},
selectedDataSource: null,
selectedQuery: {},
editingQuery: false,
addingQuery: true,
isSourceSelected: false,
})
}
>
<img className="mt-2" src="/assets/images/icons/plus.svg" width="24" height="24" />
</span>
</div>
</>
)}
</div>
{loadingDataQueries ? (
<div className="p-5">
<center>
<div className="spinner-border" role="status"></div>
</center>
</div>
) : (
<div className="query-list p-1 mt-1">
<div>{dataQueries.map((query) => this.renderDataQuery(query))}</div>
{dataQueries.length === 0 && (
<div className="mt-5">
<center>
<span className="mute-text">{dataQueriesDefaultText}</span> <br />
<button
className={`button-family-secondary mt-3 ${this.props.darkMode && 'dark'}`}
onClick={() =>
this.setState({
options: {},
selectedDataSource: null,
selectedQuery: {},
editingQuery: false,
addingQuery: true,
})
}
>
{'Create query'}
</button>
</center>
</div>
)}
</div>
)}
</div>
</div>
<div className="query-definition-pane-wrapper">
{!loadingDataSources && (
<div className="query-definition-pane">
<div>
<QueryManager
toggleQueryEditor={this.toggleQueryEditor}
dataSources={dataSources}
dataQueries={dataQueries}
mode={editingQuery ? 'edit' : 'create'}
selectedQuery={selectedQuery}
selectedDataSource={this.state.selectedDataSource}
dataQueriesChanged={this.dataQueriesChanged}
appId={appId}
editingVersionId={editingVersion?.id}
addingQuery={addingQuery}
editingQuery={editingQuery}
queryPaneHeight={queryPaneHeight}
currentState={currentState}
darkMode={this.props.darkMode}
apps={apps}
allComponents={appDefinition.components}
isSourceSelected={this.state.isSourceSelected}
/>
</div>
</div>
)}
</div>
</div>
</div>
</div>
<div className="editor-sidebar">
<div className="col-md-12">
<div></div>
</div>
{currentSidebarTab === 1 && (
<div className="pages-container">
{selectedComponent &&
!isEmpty(appDefinition.components) &&
!isEmpty(appDefinition.components[selectedComponent.id]) ? (
<Inspector
cloneComponent={this.cloneComponent}
componentDefinitionChanged={this.componentDefinitionChanged}
dataQueries={dataQueries}
removeComponent={this.removeComponent}
selectedComponentId={selectedComponent.id}
currentState={currentState}
allComponents={appDefinition.components}
key={selectedComponent.id}
switchSidebarTab={this.switchSidebarTab}
apps={apps}
darkMode={this.props.darkMode}
></Inspector>
) : (
<div className="mt-5 p-2">Please select a component to inspect</div>
)}
</div>
)}
{currentSidebarTab === 2 && (
<WidgetManager
componentTypes={componentTypes}
zoomLevel={zoomLevel}
currentLayout={currentLayout}
darkMode={this.props.darkMode}
></WidgetManager>
)}
</div>
{config.COMMENT_FEATURE_ENABLE && showComments && (
<CommentNotifications
socket={this.socket}
appVersionsId={this.state?.editingVersion?.id}
toggleComments={this.toggleComments}
/>
)}
</div>
<InitVersionCreateModal
showModal={this.state.showInitVersionCreateModal}
hideModal={() => this.setState({ showInitVersionCreateModal: false })}
fetchApp={this.fetchApp}
darkMode={this.props.darkMode}
appId={this.state.appId}
/>
</DndProvider>
</div>
);
}
}
export { Editor };