ToolJet/frontend/src/Editor/Viewer.jsx
Midhun Kumar E d36cc44920
test: Add automation for text-input revamp. (#8671)
* init textinput revamp

* updated styles panel

* bugfix

* updates

* fix :: accordion

* fix :: styling

* add box shadow , additional property,tooltip

* fix conditional render for styles

* feat :: fixed order of each property and styles

* feat :: styling input

* bugfix

* feat :: add option to add icon

* add option to add icon

* adding option to toggle visibility

* updated password input with new design

* chnaging component location

* bugfix

* style fixes

* fix :: added loader

* updated :: few detailing

* few bugfixes

* fix :: for form widget label

* fixes

* added option to add icon color

* including label field for password input

* fix for label

* fix

* test fix backward compatibility for height

* updates

* revert

* adding key for distinguishing older and newer widgets

* testing

* test

* test

* update

* update

* migration testing

* limit vertical resizing in textinput

* testing

* throw test

* test

* adding check for label length

* fixing edge cases

* removing resize

* backward compatibility height

* backward compatibility

* number input review fixes

* added exposed items

* fixing csa

* ui fixes

* fix height compatibility

* feat :: csa for all inputs and exposed variables

* backward compatibility fixes and validation fixes

* fixes :: textinput positioning of loader and icon

* fix :: password input

* cleanup and fixes

* fixes

* cleanup

* fixes

* review fixes

* review fixes

* typo fix

* fix padding

* review fixes styles component panel

* fix naming

* fix padding

* fix :: icons position

* updates

* cleanup

* updates events , csa

* backward compatibility

* clean

* feat :: change validation from properties

* ui fixes

* icon name

* removed 'px' text from tooltip

* fixes placeholder

* few updates :: removing label in form

* ui in form

* update :: number input validation behaviour

* testing fixes

* added side handlers

* removing unwanted fx

* disabling fx for padding field

* ordering change

* fix

* label issue + restricted side handler

* fix :: box shadow bug

* on change event doesnt propagate exposed vars correctly

* adding debounce for slider value change

* fix :: for modal ooen bug during onfocus event

* test slider

* Add common utils

* Modify helpers

* Add text input spec

* Add utils for field validation

* Minor spec updates

* Fix for password  basic automation cases

---------

Co-authored-by: stepinfwd <stepinfwd@gmail.com>
2024-02-06 10:27:31 +05:30

704 lines
25 KiB
JavaScript

import React from 'react';
import {
authenticationService,
orgEnvironmentVariableService,
orgEnvironmentConstantService,
dataqueryService,
appService,
} from '@/_services';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { Container } from './Container';
import { Confirm } from './Viewer/Confirm';
import { ViewerNavigation } from './Viewer/ViewerNavigation';
import {
onComponentOptionChanged,
onComponentOptionsChanged,
onComponentClick,
onQueryConfirmOrCancel,
onEvent,
runQuery,
computeComponentState,
buildAppDefinition,
} from '@/_helpers/appUtils';
import queryString from 'query-string';
import ViewerLogoIcon from './Icons/viewer-logo.svg';
import { DataSourceTypes } from './DataSourceManager/SourceComponents';
import { resolveReferences, isQueryRunnable } from '@/_helpers/utils';
import { withTranslation } from 'react-i18next';
import _ from 'lodash';
import { Navigate } from 'react-router-dom';
import Spinner from '@/_ui/Spinner';
import { withRouter } from '@/_hoc/withRouter';
import { useEditorStore } from '@/_stores/editorStore';
import { setCookie } from '@/_helpers/cookie';
import { useDataQueriesStore } from '@/_stores/dataQueriesStore';
import { useCurrentStateStore } from '@/_stores/currentStateStore';
import { shallow } from 'zustand/shallow';
import { useAppDataActions, useAppDataStore } from '@/_stores/appDataStore';
import { getPreviewQueryParams, redirectToErrorPage } from '@/_helpers/routes';
import { ERROR_TYPES } from '@/_helpers/constants';
import { camelizeKeys } from 'humps';
class ViewerComponent extends React.Component {
constructor(props) {
super(props);
const deviceWindowWidth = window.screen.width - 5;
const slug = this.props.params.slug;
this.subscription = null;
this.state = {
slug,
deviceWindowWidth,
currentUser: null,
isLoading: true,
users: null,
appDefinition: { pages: {} },
isAppLoaded: false,
pages: {},
homepage: null,
};
}
getViewerRef() {
return {
appDefinition: this.state.appDefinition,
queryConfirmationList: this.props.queryConfirmationList,
updateQueryConfirmationList: this.updateQueryConfirmationList,
navigate: this.props.navigate,
switchPage: this.switchPage,
currentPageId: this.state.currentPageId,
};
}
setStateForApp = (data, byAppSlug = false) => {
const appDefData = buildAppDefinition(data);
if (byAppSlug) {
appDefData.globalSettings = data.globalSettings;
appDefData.homePageId = data.homePageId;
appDefData.showViewerNavigation = data.showViewerNavigation;
}
this.setState({
app: data,
isLoading: false,
isAppLoaded: true,
appDefinition: { ...appDefData },
pages: appDefData.pages,
});
};
setStateForContainer = async (data, appVersionId) => {
const appDefData = buildAppDefinition(data);
const currentUser = this.state.currentUser;
let userVars = {};
if (currentUser) {
userVars = {
email: currentUser.email,
firstName: currentUser.first_name,
lastName: currentUser.last_name,
groups: authenticationService.currentSessionValue?.group_permissions.map((group) => group.group),
};
}
let mobileLayoutHasWidgets = false;
if (this.props.currentLayout === 'mobile') {
const currentComponents = appDefData.pages[appDefData.homePageId].components;
mobileLayoutHasWidgets =
Object.keys(currentComponents).filter((componentId) => currentComponents[componentId]['layouts']['mobile'])
.length > 0;
}
let queryState = {};
let dataQueries = [];
if (appVersionId) {
const { data_queries } = await dataqueryService.getAll(appVersionId);
dataQueries = data_queries;
} else {
dataQueries = data.data_queries;
}
const queryConfirmationList = [];
if (dataQueries.length > 0) {
dataQueries.forEach((query) => {
if (query?.options && query?.options?.requestConfirmation && query?.options?.runOnPageLoad) {
queryConfirmationList.push({ queryId: query.id, queryName: query.name });
}
if (query.pluginId || query?.plugin?.id) {
const exposedVariables =
query.plugin?.manifestFile?.data?.source?.exposedVariables ||
query.plugin?.manifest_file?.data?.source?.exposed_variables;
queryState[query.name] = {
...exposedVariables,
...this.props.currentState.queries[query.name],
};
} else {
const dataSourceTypeDetail = DataSourceTypes.find((source) => source.kind === query.kind);
queryState[query.name] = {
...dataSourceTypeDetail.exposedVariables,
...this.props.currentState.queries[query.name],
};
}
});
}
if (queryConfirmationList.length !== 0) {
this.updateQueryConfirmationList(queryConfirmationList);
}
const variables = await this.fetchOrgEnvironmentVariables(data.slug, data.is_public);
const constants = await this.fetchOrgEnvironmentConstants(data.slug, data.is_public);
const pages = data.pages;
const homePageId = appVersionId ? data.editing_version.homePageId : data?.homePageId;
const startingPageHandle = this.props?.params?.pageHandle;
const currentPageId = pages.filter((page) => page.handle === startingPageHandle)[0]?.id ?? homePageId;
const currentPage = pages.find((page) => page.id === currentPageId);
useDataQueriesStore.getState().actions.setDataQueries(dataQueries);
this.props.setCurrentState({
queries: queryState,
components: {},
globals: {
currentUser: userVars, // currentUser is updated in setupViewer function as well
theme: { name: this.props.darkMode ? 'dark' : 'light' },
urlparams: JSON.parse(JSON.stringify(queryString.parse(this.props.location.search))),
mode: {
value: this.state.slug ? 'view' : 'preview',
},
},
variables: {},
page: {
id: currentPage.id,
handle: currentPage.handle,
name: currentPage.name,
variables: {},
},
...variables,
...constants,
});
useEditorStore.getState().actions.toggleCurrentLayout(mobileLayoutHasWidgets ? 'mobile' : 'desktop');
this.props.updateState({ events: data.events ?? [] });
this.setState(
{
currentUser,
currentSidebarTab: 2,
canvasWidth:
this.props.currentLayout === 'desktop'
? '100%'
: mobileLayoutHasWidgets
? `${this.state.deviceWindowWidth}px`
: '1292px',
selectedComponent: null,
dataQueries: dataQueries,
currentPageId: currentPage.id,
homepage: appDefData?.pages?.[this.state.appDefinition?.homePageId]?.handle,
events: data.events ?? [],
},
() => {
const components = appDefData?.pages[currentPageId]?.components || {};
computeComponentState(components).then(async () => {
this.setState({ initialComputationOfStateDone: true, defaultComponentStateComputed: true });
console.log('Default component state computed and set');
this.runQueries(dataQueries);
const currentPageEvents = this.state.events.filter(
(event) => event.target === 'page' && event.sourceId === this.state.currentPageId
);
await this.handleEvent('onPageLoad', currentPageEvents);
});
}
);
};
runQueries = (data_queries) => {
data_queries.forEach((query) => {
if (query.options.runOnPageLoad && isQueryRunnable(query)) {
runQuery(this.getViewerRef(), query.id, query.name, undefined, 'view');
}
});
};
fetchOrgEnvironmentConstants = async (slug, isPublic) => {
const orgConstants = {};
let variablesResult;
if (!isPublic) {
const { constants } = await orgEnvironmentConstantService.getAll();
variablesResult = constants;
} else {
const { constants } = await orgEnvironmentConstantService.getConstantsFromPublicApp(slug);
variablesResult = constants;
}
if (variablesResult && Array.isArray(variablesResult)) {
variablesResult.map((constant) => {
const constantValue = constant.values.find((value) => value.environmentName === 'production')['value'];
orgConstants[constant.name] = constantValue;
});
return {
constants: orgConstants,
};
}
return { constants: {} };
};
fetchOrgEnvironmentVariables = async (slug, isPublic) => {
const variables = {
client: {},
server: {},
};
let variablesResult;
if (!isPublic) {
variablesResult = await orgEnvironmentVariableService.getVariables();
} else {
variablesResult = await orgEnvironmentVariableService.getVariablesFromPublicApp(slug);
}
variablesResult.variables.map((variable) => {
variables[variable.variable_type][variable.variable_name] =
variable.variable_type === 'server' ? 'HiddenEnvironmentVariable' : variable.value;
});
return variables;
};
loadApplicationBySlug = (slug, authentication_failed = false) => {
appService
.fetchAppBySlug(slug)
.then((data) => {
const isAppPublic = data?.is_public;
if (authentication_failed && !isAppPublic) {
return redirectToErrorPage(ERROR_TYPES.URL_UNAVAILABLE, {});
}
this.setStateForApp(data, true);
this.setStateForContainer(data);
this.setWindowTitle(data.name);
})
.catch((error) => {
this.setState({
isLoading: false,
});
if (error?.statusCode === 404) {
/* User is not authenticated. but the app url is wrong */
redirectToErrorPage(ERROR_TYPES.INVALID);
} else if (error?.statusCode === 403) {
redirectToErrorPage(ERROR_TYPES.RESTRICTED);
} else if (error?.statusCode !== 401) {
redirectToErrorPage(ERROR_TYPES.UNKNOWN);
}
});
};
loadApplicationByVersion = (appId, versionId) => {
appService
.fetchAppByVersion(appId, versionId)
.then((data) => {
this.setStateForApp(data);
this.setStateForContainer(data, versionId);
})
.catch(() => {
this.setState({
isLoading: false,
});
});
};
updateQueryConfirmationList = (queryConfirmationList) =>
useEditorStore.getState().actions.updateQueryConfirmationList(queryConfirmationList);
setupViewer() {
this.subscription = authenticationService.currentSession.subscribe((currentSession) => {
const slug = this.props.params.slug;
const appId = this.props.id;
const versionId = this.props.versionId;
if (currentSession?.load_app && slug) {
if (currentSession?.group_permissions) {
useAppDataStore.getState().actions.setAppId(appId);
const currentUser = currentSession.current_user;
const userVars = {
email: currentUser.email,
firstName: currentUser.first_name,
lastName: currentUser.last_name,
groups: currentSession?.group_permissions?.map((group) => group.group),
};
this.props.setCurrentState({
globals: {
...this.props.currentState.globals,
currentUser: userVars, // currentUser is updated in setStateForContainer function as well
},
});
this.setState({
currentUser,
userVars,
versionId,
});
versionId ? this.loadApplicationByVersion(appId, versionId) : this.loadApplicationBySlug(slug);
} else if (currentSession?.authentication_failed) {
this.loadApplicationBySlug(slug, true);
}
}
this.setState({ isLoading: false });
});
}
/**
*
* ThandleMessage event listener in the login component fir iframe communication.
* It now checks if the received message has a type of 'redirectTo' and extracts the redirectPath value from the payload.
* If the value is present, it sets a cookie named 'redirectPath' with the received value and a one-day expiration.
* This allows for redirection to a specific path after the login process is completed.
*/
handleMessage = (event) => {
const { data } = event;
if (data?.type === 'redirectTo') {
const redirectCookie = data?.payload['redirectPath'];
setCookie('redirectPath', redirectCookie, 1);
}
};
componentDidMount() {
this.setupViewer();
const isMobileDevice = this.state.deviceWindowWidth < 600;
useEditorStore.getState().actions.toggleCurrentLayout(isMobileDevice ? 'mobile' : 'desktop');
window.addEventListener('message', this.handleMessage);
}
componentDidUpdate(prevProps, prevState) {
if (this.props.params.slug && this.props.params.slug !== prevProps.params.slug) {
this.setState({ isLoading: true });
this.loadApplicationBySlug(this.props.params.slug);
}
if (this.state.initialComputationOfStateDone) this.handlePageSwitchingBasedOnURLparam();
if (this.state.homepage !== prevState.homepage && !this.state.isLoading) {
<Navigate to={`${this.state.homepage}${this.props.params.pageHandle ? '' : window.location.search}`} replace />;
}
}
handlePageSwitchingBasedOnURLparam() {
const handleOnURL = this.props.params.pageHandle;
const shouldShowPage = handleOnURL ? this.validatePageHandle(handleOnURL) : true;
if (!shouldShowPage) return this.switchPage(this.state.appDefinition.homePageId);
const pageIdCorrespondingToHandleOnURL =
handleOnURL && shouldShowPage ? this.findPageIdFromHandle(handleOnURL) : this.state.appDefinition.homePageId;
const currentPageId = this.state.currentPageId;
if (pageIdCorrespondingToHandleOnURL != this.state.currentPageId) {
const targetPage = this.state.appDefinition.pages[pageIdCorrespondingToHandleOnURL];
this.props.setCurrentState({
globals: {
...this.props.currentState.globals,
urlparams: JSON.parse(JSON.stringify(queryString.parse(this.props.location.search))),
},
page: {
...this.props.currentState.page,
name: targetPage.name,
handle: targetPage.handle,
variables: this.state.pages?.[pageIdCorrespondingToHandleOnURL]?.variables ?? {},
id: pageIdCorrespondingToHandleOnURL,
},
});
this.setState(
{
pages: {
...this.state.pages,
[currentPageId]: {
...this.state.pages?.[currentPageId],
variables: {
...this.props.currentState?.page?.variables,
},
},
},
currentPageId: pageIdCorrespondingToHandleOnURL,
handle: targetPage.handle,
name: targetPage.name,
},
async () => {
computeComponentState(this.state.appDefinition?.pages[this.state.currentPageId].components).then(async () => {
const currentPageEvents = this.state.events.filter(
(event) => event.target === 'page' && event.sourceId === this.state.currentPageId
);
await this.handleEvent('onPageLoad', currentPageEvents);
});
}
);
}
}
validatePageHandle(handle) {
const allPages = this.state.appDefinition.pages;
return Object.values(allPages).some((page) => page.handle === handle && !page.disabled);
}
findPageIdFromHandle(handle) {
return (
Object.entries(this.state.appDefinition.pages).filter(([_id, page]) => page.handle === handle)?.[0]?.[0] ??
this.state.appDefinition.homePageId
);
}
getCanvasWidth = () => {
const canvasBoundingRect = document.getElementsByClassName('canvas-area')[0]?.getBoundingClientRect();
return canvasBoundingRect?.width;
};
setWindowTitle(name) {
document.title = name ?? 'My App';
}
computeCanvasBackgroundColor = () => {
const bgColor =
(this.state.appDefinition.globalSettings?.backgroundFxQuery ||
this.state.appDefinition.globalSettings?.canvasBackgroundColor) ??
'#2f3c4c';
const resolvedBackgroundColor = resolveReferences(bgColor, this.props.currentState);
if (['#2f3c4c', '#F2F2F5', '#edeff5'].includes(resolvedBackgroundColor)) {
return this.props.darkMode ? '#2f3c4c' : '#F2F2F5';
}
return resolvedBackgroundColor;
};
changeDarkMode = (newMode) => {
this.props.setCurrentState({
globals: {
...this.props.currentState.globals,
theme: { name: newMode ? 'dark' : 'light' },
},
});
this.setState({
showQuerySearchField: false,
});
this.props.switchDarkMode(newMode);
};
switchPage = (id, queryParams = []) => {
document.getElementById('real-canvas').scrollIntoView();
/* Keep default query params for preview */
const defaultParams = getPreviewQueryParams();
if (this.state.currentPageId === id) return;
const { handle } = this.state.appDefinition.pages[id];
const queryParamsString = queryParams.map(([key, value]) => `${key}=${value}`).join('&');
this.props.navigate(
`/applications/${this.state.slug}/${handle}?${
!_.isEmpty(defaultParams) ? `version=${defaultParams.version}` : ''
}${queryParamsString ? `${!_.isEmpty(defaultParams) ? '&' : ''}${queryParamsString}` : ''}`,
{
state: {
isSwitchingPage: true,
},
}
);
};
handleEvent = (eventName, events, options) => {
return onEvent(this.getViewerRef(), eventName, events, options, 'view');
};
computeCanvasMaxWidth = () => {
const { appDefinition } = this.state;
let computedCanvasMaxWidth = 1292;
if (appDefinition.globalSettings?.canvasMaxWidthType === 'px')
computedCanvasMaxWidth =
(+appDefinition.globalSettings?.canvasMaxWidth || 1292) - (appDefinition?.showViewerNavigation ? 200 : 0);
else if (appDefinition.globalSettings?.canvasMaxWidthType === '%')
computedCanvasMaxWidth = +appDefinition.globalSettings?.canvasMaxWidth + '%';
return computedCanvasMaxWidth;
};
componentWillUnmount() {
this.subscription && this.subscription.unsubscribe();
}
render() {
const {
appDefinition,
isLoading,
isAppLoaded,
deviceWindowWidth,
defaultComponentStateComputed,
dataQueries,
canvasWidth,
} = this.state;
const currentCanvasWidth = canvasWidth;
const queryConfirmationList = this.props?.queryConfirmationList ?? [];
const canvasMaxWidth = this.computeCanvasMaxWidth();
if (this.state.app?.isLoading) {
return (
<div className="tooljet-logo-loader">
<div>
<div className="loader-logo">
<ViewerLogoIcon />
</div>
<div className="loader-spinner">
<Spinner />
</div>
</div>
</div>
);
} else {
if (this.state.app?.is_maintenance_on) {
return (
<div className="maintenance_container">
<div className="card">
<div className="card-body" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<h3>{this.props.t('viewer', 'Sorry!. This app is under maintenance')}</h3>
</div>
</div>
</div>
);
} else {
return (
<div className="viewer wrapper">
<Confirm
show={queryConfirmationList.length > 0}
message={'Do you want to run this query?'}
onConfirm={(queryConfirmationData) =>
onQueryConfirmOrCancel(this.getViewerRef(), queryConfirmationData, true, 'view')
}
onCancel={() => onQueryConfirmOrCancel(this.getViewerRef(), queryConfirmationList[0], false, 'view')}
queryConfirmationData={queryConfirmationList[0]}
key={queryConfirmationList[0]?.queryName}
/>
<DndProvider backend={HTML5Backend}>
<ViewerNavigation.Header
showHeader={!appDefinition.globalSettings?.hideHeader && isAppLoaded}
appName={this.state.app?.name ?? null}
changeDarkMode={this.changeDarkMode}
darkMode={this.props.darkMode}
pages={Object.entries(this.state.appDefinition?.pages) ?? []}
currentPageId={this.state?.currentPageId ?? this.state.appDefinition?.homePageId}
switchPage={this.switchPage}
/>
<div className="sub-section">
<div className="main">
<div
className="canvas-container align-items-center"
style={{
background: this.computeCanvasBackgroundColor() || (!this.props.darkMode ? '#EBEBEF' : '#2E3035'),
}}
>
<div className="areas d-flex flex-rows">
{appDefinition?.showViewerNavigation && (
<ViewerNavigation
isMobileDevice={this.props.currentLayout === 'mobile'}
canvasBackgroundColor={this.computeCanvasBackgroundColor()}
pages={Object.entries(this.state.appDefinition?.pages) ?? []}
currentPageId={this.state?.currentPageId ?? this.state.appDefinition?.homePageId}
switchPage={this.switchPage}
darkMode={this.props.darkMode}
/>
)}
<div className="flex-grow-1 d-flex justify-content-center">
<div
className="canvas-area"
style={{
width: currentCanvasWidth,
maxWidth: canvasMaxWidth,
backgroundColor: this.computeCanvasBackgroundColor(),
margin: 0,
padding: 0,
}}
>
{defaultComponentStateComputed && (
<>
{isLoading ? (
<div className="mx-auto mt-5 w-50 p-5">
<center>
<div className="spinner-border text-azure" role="status"></div>
</center>
</div>
) : (
<Container
appDefinition={appDefinition}
appDefinitionChanged={() => false} // function not relevant in viewer
snapToGrid={true}
appLoading={isLoading}
darkMode={this.props.darkMode}
onEvent={this.handleEvent}
mode="view"
deviceWindowWidth={deviceWindowWidth}
selectedComponent={this.state.selectedComponent}
onComponentClick={(id, component) => {
this.setState({
selectedComponent: { id, component },
});
onComponentClick(this, id, component, 'view');
}}
onComponentOptionChanged={(component, optionName, value) => {
return onComponentOptionChanged(component, optionName, value);
}}
onComponentOptionsChanged={onComponentOptionsChanged}
canvasWidth={this.getCanvasWidth()}
dataQueries={dataQueries}
currentPageId={this.state.currentPageId}
/>
)}
</>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</DndProvider>
</div>
);
}
}
}
}
const withStore = (Component) => (props) => {
const currentState = useCurrentStateStore();
const { currentLayout, queryConfirmationList } = useEditorStore(
(state) => ({
currentLayout: state?.currentLayout,
queryConfirmationList: state?.queryConfirmationList,
}),
shallow
);
const { updateState } = useAppDataActions();
return (
<Component
{...props}
currentState={currentState}
setCurrentState={currentState?.actions?.setCurrentState}
currentLayout={currentLayout}
updateState={updateState}
queryConfirmationList={queryConfirmationList}
/>
);
};
export const Viewer = withTranslation()(withStore(withRouter(ViewerComponent)));