Merge branch 'develop' into release/1.13.0

This commit is contained in:
Sherfin Shamsudeen 2022-05-11 20:31:55 +05:30
commit f20cb9fba8
82 changed files with 3787 additions and 1311 deletions

View file

@ -34,11 +34,11 @@ SMTP_PASSWORD=
SMTP_DOMAIN=
SMTP_PORT=
# DISABLE USER SIGNUPS (true or false). only applicable if MULTI_ORGANIZATION=true
# DISABLE USER SIGNUPS (true or false). only applicable if Multi-Workspace feature is enabled
DISABLE_SIGNUPS=
# Enables all multi organization features
MULTI_ORGANIZATION=
# Disable Multi-Workspace features (true or false)
DISABLE_MULTI_WORKSPACE=
# OBSERVABILITY
APM_VENDOR=

View file

@ -34,11 +34,11 @@
"value": "--max-old-space-size=4096"
},
"DISABLE_SIGNUPS": {
"description": "Disable sign up in login page only applicable if MULTI_ORGANIZATION=true",
"description": "Disable sign up in login page only applicable if Multi-Workspace feature is turned on",
"value": "false"
},
"MULTI_ORGANIZATION": {
"description": "Enables multi organization feature",
"DISABLE_MULTI_WORKSPACE": {
"description": "Disables Multi-Workspace feature",
"value": "false"
}
},

View file

@ -7,6 +7,15 @@ title: MinIO
ToolJet can connect to minio and perform various operation on them.
## Supported operations
- **Read object**
- **Put object**
- **List buckets**
- **List objects in a bucket**
- **Presigned url for download**
- **Presigned url for upload**
## Connection
To add a new minio source, click on the **Add or edit datasource** icon on the left sidebar of the app editor and click on `Add datasource` button. Select Minio from the modal that pops up.

View file

@ -1,6 +1,6 @@
---
sidebar_position: 1
sidebar_label: Bulk update multiple rows in table
id: bulk-update-multiple-rows
title: Bulk update multiple rows in table
---
# Bulk update multiple rows in table

View file

@ -0,0 +1,137 @@
---
id: upload-files-aws
title: Upload files on AWS S3 bucket
---
# Upload and download files on AWS S3 bucket
This guide will help you in quickly building a basic UI for uploading or downloading files from AWS S3 buckets.
Before building the UI, check out the **[docs for AWS S3 data source](/docs/data-sources/s3)** to learn about setting up AWS S3 and adding the data source.
Once you have successfully added the AWS data source, build a basic UI using the following widgets:
- **Dropdown**: For selecting a bucket in S3 storage.
- **Table**: For listing all the objects inside the selected bucket in dropdown.
- **Text Input**: For getting a path for the file that is to be uploaded.
- **File picker**: For uploading the file.
- **Button**: This will be used to fire the upload query.
<div style={{textAlign: 'center'}}>
![ToolJet - How To - Upload files on AWS S3 bucket](/img/how-to/upload-files-aws/ui.png)
</div>
## Queries
We'll create the following queries:
1. **getBuckets**
2. **listObjects**
3. **uploadToS3**
4. **download**
### getBuckets
This query will fetch the list of all the buckets in your S3. Just create a new query, select AWS S3 data souce, and choose **List buckets** operation. Name the query **getBuckets** and click **Save**.
<div style={{textAlign: 'center'}}>
![ToolJet - How To - Upload files on AWS S3 bucket](/img/how-to/upload-files-aws/getBuckets.png)
</div>
Now, let's edit the properties of **dropdown** widget.
- **Label**: Set the label as Bucket.
- **Option values**: Set option values as `{{queries.getBuckets.data.Buckets.map(bucket => bucket['Name'])}}`. We're mapping the data returned by the query as the returned data is array of abjects.
- **Option label**: Set option values as `{{queries.getBuckets.data.Buckets.map(bucket => bucket['Name'])}}`. This will display the same option label as option values.
You can later add an event handler for running the **listObject** query whenever an option is selected from the dropdown.
<div style={{textAlign: 'center'}}>
![ToolJet - How To - Upload files on AWS S3 bucket](/img/how-to/upload-files-aws/dropdown.png)
</div>
### listObjects
This query will list all the objects inside the selected Bucket in dropdown. Select **List objects in a bucket** operation, enter `{{components.dropdown1.value}}` in the Bucket field - this will dynamically get the field value from the selected option in dropdown.
<div style={{textAlign: 'center'}}>
![ToolJet - How To - Upload files on AWS S3 bucket](/img/how-to/upload-files-aws/listObjects.png)
</div>
Edit the properties of **table** widget:
- **Table data**: `{{queries.listObjects.data['Contents']}}`
- **Add Columns**:
- **Key**: Set the **Column Name** to `Key` and **Key** to `Key`
- **Last Modified**: Set the **Column Name** to `Last Modified` and **Key** to `LastModified`
- **Size**: Set the **Column Name** to `Size` and **Key** to `Size`
- Add a **Action button**: Set button text to **Copy signed URL**, Add a handler to this button for On Click event and Action to Copy to clipboard, in the text field enter `{{queries.download.data.url}}` - this will get the download url from the **download** query that we will create next.
<div style={{textAlign: 'center'}}>
![ToolJet - How To - Upload files on AWS S3 bucket](/img/how-to/upload-files-aws/table.png)
</div>
### download
Create a new query and select **Signed URL for download** operation. In the Bucket field, enter `{{components.dropdown1.value}}` and in Key enter `{{components.table1.selectedRow.Key}}`.
<div style={{textAlign: 'center'}}>
![ToolJet - How To - Upload files on AWS S3 bucket](/img/how-to/upload-files-aws/download.png)
</div>
Edit the **properites** of the table, add a Event handler for running the `download` query for `Row clicked` event. This will generate a signed url for download every time a row is clicked on the table.
### uploadToS3
Create a new query, select the **Upload object** operation. Enter the following values in their respective fields:
- **Bucket**: `{{components.dropdown1.value}}`
- **Key**: {{ components.textinput1.value + '/' +components.filepicker1.file[0].name}}`
- **Content type**: `{{components.filepicker1.file[0].type}}`
- **Upload data**: `{{components.filepicker1.file[0].base64Data}}`
- **Encoding**: `base64`
<div style={{textAlign: 'center'}}>
![ToolJet - How To - Upload files on AWS S3 bucket](/img/how-to/upload-files-aws/uploadToS3.png)
</div>
#### Configure the file picker:
Click on the widget handle to edit the file picker properties:
- Change the **Accept file types** to `{{"application/pdf"}}` for the picker to accept only pdf files or `{{"image/*"}}` for the picker to accept only image files . In the screenshot below, we have set the accepted file type property to `{{"application/pdf"}}` so it will allow to select only pdf files:
<div style={{textAlign: 'center'}}>
![ToolJet - How To - Upload files using GCS](/img/how-to/upload-files-gcs/result-filepicker.png)
</div>
- Change the **Max file count** to `{{1}}` as we are only going to upload 1 file at a time.
- Select a pdf file and hold it in the file picker.
:::info
File types must be valid **[MIME](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types)** type according to input element specification or a valid file extension.
To accept any/all file type(s), set `Accept file types` to an empty value.
:::
<div style={{textAlign: 'center'}}>
![ToolJet - How To - Upload files using GCS](/img/how-to/upload-files-gcs/config-filepicker.png)
</div>
Final steps, go to the **Advanced** tab of the **uploadToS3** query and add a query to run **listObjects** query so that whenever a file is uploaded the tabled is refreshed.

View file

@ -49,6 +49,7 @@ The references for data sources and widgets:
- **[Build a WhatsApp CRM](https://blog.tooljet.com/build-a-whatsapp-crm-using-tooljet-within-10-mins/)**
- **[Build a cryptocurrency dashboard](https://blog.tooljet.com/how-to-build-a-cryptocurrency-dashboard-in-10-minutes/)**
- **[Build a Redis GUI](https://blog.tooljet.com/building-a-redis-gui-using-tooljet-in-5-minutes/)**
- **[Build a coupon code manager app](https://blog.tooljet.com/build-a-coupon-code-manager-app-in-10-minutes/)**
## Help and Support
- We have extensively documented the features of ToolJet, but in case you are stuck, please feel to e-mail us at **hello@tooljet.com**

View file

@ -5,9 +5,9 @@ sidebar_label: Password Login
# Password Login
Password login is enabled by default for all organizations. User with admin privilege can enable/disable it.
Password login is enabled by default for all workspaces. User with admin privilege can enable/disable it.
Select `Manage SSO` from organization options
Select `Manage SSO` from workspace options
<div style={{textAlign: 'center'}}>

View file

@ -73,14 +73,14 @@ You can specify a different server for backend if it is hosted on another server
| -------- | ---------------------- |
| SERVER_HOST | Configure a hostname for the server as a proxy pass. If no value is set, it defaults to `server`. |
#### Enable multiple organizations ( optional )
#### Disable Multi-Workspace ( optional )
If you want to enable multiple environments, set the environment variable `MULTI_ORGANIZATION` to `true`.
If you want to disable Multi-Workspace feature, set the environment variable `DISABLE_MULTI_WORKSPACE` to `true`.
#### Disabling signups ( optional )
Sign up is enabled only for multiple organization environment. If you want to restrict the signups and allow new users only by invitations, set the environment variable `DISABLE_SIGNUPS` to `true`.
Sign up is enabled only if Multi-Workspace is enabled. If you want to restrict the signups and allow new users only by invitations, set the environment variable `DISABLE_SIGNUPS` to `true`.
:::tip
You will still be able to see the signup page but won't be able to successfully submit the form.
@ -93,7 +93,7 @@ You can set `SERVE_CLIENT` to `true` and the server will attempt to serve the cl
#### SMTP configuration ( optional )
ToolJet uses SMTP services to send emails ( Eg: invitation email when you add new users to your organization ).
ToolJet uses SMTP services to send emails ( Eg: invitation email when you add new users to your workspace ).
| variable | description |
| ------------------ | ----------------------------------------- |

View file

@ -5,7 +5,7 @@ sidebar_label: General Settings
# Single Sign-On General Settings
Select `Manage SSO` from organization options
Select `Manage SSO` from workspace options
<div style={{textAlign: 'center'}}>

View file

@ -5,7 +5,7 @@ title: GitHub
# GitHub Single Sign-on
Select `Manage SSO` from organization options
Select `Manage SSO` from workspace options
<div style={{textAlign: 'center'}}>
@ -55,4 +55,4 @@ Go to [GitHub Developer settings](https://github.com/settings/developers) and na
Lastly, enter `Client Id` and `Client Secret` in Git manage SSO page and save.
The GitHub sign-in button will now be available in your ToolJet login screen if you have not enabled multiple organization.
The GitHub sign-in button will now be available in your ToolJet login screen if you have not enabled Multi-Workspace.

View file

@ -5,7 +5,7 @@ title: Google
# Google Single Sign-on
Select `Manage SSO` from organization options
Select `Manage SSO` from workspace options
<div style={{textAlign: 'center'}}>
@ -45,7 +45,7 @@ Go to [Google cloud console](https://console.cloud.google.com/) and create a pro
</div>
- You'll be asked to select user type in consent screen. To allow only users within your organization, select 'Internal', otherwise,
- You'll be asked to select user type in consent screen. To allow only users within your workspace, select 'Internal', otherwise,
select 'External'.
<div style={{textAlign: 'center'}}>
@ -82,4 +82,4 @@ Set the `Redirect URL` generated at manage SSO `Google` page under Authorised re
Lastly, set the `client id` in google manage SSO page. This value will be available from your [Google cloud console credentials page](https://console.cloud.google.com/apis/credentials)
The Google sign-in button will now be available in your ToolJet login screen, if you are not enabled multiple organization.
The Google sign-in button will now be available in your ToolJet login screen, if you are not enabled Multi-Workspace.

View file

@ -6,7 +6,7 @@ title: Adding a data source
# Adding a data source
:::tip
The data sources are created on app level and not on organization level.
The data sources are created on app level and not on workspace level.
:::
**Datasource manager** is on the left-sidebar of the app builder. To add a new data source, click on the `Add datasource` button.

View file

@ -7,7 +7,7 @@ title: Managing Users and Groups
## Managing Users
Admin of an organization can add users to the organization. To manage the users in your organization, just go to the **Account menu** on top right corner and click on the **Manage Users**.
Admin of a workspace can add users to the workspace. To manage the users in your workspace, just go to the **Workspace menu** on top right corner and click on the **Manage Users**.
<div style={{textAlign: 'center'}}>
@ -17,7 +17,7 @@ Admin of an organization can add users to the organization. To manage the users
### Inviting users
Admins can invite anyone to a ToolJet organization using the email address. To invite a user:
Admins can invite anyone to a workspace using the email address. To invite a user:
- On the **Manage Users** page click on the `Invite new user` button.
@ -35,7 +35,7 @@ Admins can invite anyone to a ToolJet organization using the email address. To i
</div>
- An email including the **Invite Link** to join your organization will be send to the created user. The status will turn from **invited** to **active** after the user successfully joins your organization using the invite link.
- An email including the **Invite Link** to join your workspace will be send to the created user. The status will turn from **invited** to **active** after the user successfully joins your workspace using the invite link.
:::tip
@ -51,7 +51,7 @@ You can also copy the invitation url by clicking on the copy icon next to `invit
### Disabling a user's access
You can disable any active user's access to your organization by clicking on the **Archive** and then the status of the user will change from **active** to **archived**.
You can disable any active user's access to your workspace by clicking on the **Archive** and then the status of the user will change from **active** to **archived**.
<div style={{textAlign: 'center'}}>
@ -71,7 +71,7 @@ Similar to archiving a user's access, you can enable it again by clicking on **U
## Managing Groups
On ToolJet, Admins can create groups for users added in an organization and grant them access to particular app(s) with specific permissions. To manage groups, just go to the **Account menu** on top right corner and click on the **Manage Groups**.
On ToolJet, Admins can create groups for users added in a workspace and grant them access to particular app(s) with specific permissions. To manage groups, just go to the **Account menu** on top right corner and click on the **Manage Groups**.
<div style={{textAlign: 'center'}}>
@ -115,13 +115,13 @@ Admins can set granular permission like creating/deleting apps or creating folde
:::tip
All the activities performed by any Admin or any user in a ToolJet organization is logged in `Audit logs` - including any activity related with managing users and groups.
All the activities performed by any Admin or any user in a workspace is logged in `Audit logs` - including any activity related with managing users and groups.
:::
### Predefined Groups
By default, every organization will have two User Groups:
By default, every workspace will have two User Groups:
**1. All Users**
@ -129,7 +129,7 @@ This group contains all the users and admins.
| Apps | Users | Permissions |
| ----------- | ----------- | ----------- |
| You can add or remove apps. | Modification is disabled. This group will have all the users and admins added in an organization. | You can edit permissions for all the users globally. |
| You can add or remove apps. | Modification is disabled. This group will have all the users and admins added in a workspace. | You can edit permissions for all the users globally. |
<div style={{textAlign: 'center'}}>
@ -143,7 +143,7 @@ This group contains admins by default. Admins can add more admins or remove the
| Apps | Users | Permissions |
| ----------- | ----------- | ----------- |
| Modification is disabled. By default, this group has `Edit` permission for all the apps in an organization | Admins can add or remove users in this group. | Modification is disabled. By default, all the admins can create and delete apps or create folders. |
| Modification is disabled. By default, this group has `Edit` permission for all the apps in a workspace | Admins can add or remove users in this group. | Modification is disabled. By default, all the admins can create and delete apps or create folders. |
<div style={{textAlign: 'center'}}>

View file

@ -0,0 +1,92 @@
---
id: custom-component
title: Custom Component
---
# Custom Component
Custom Component can be used to do create your own React component when the needed functionality isn't available in other components.
<div style={{textAlign: 'center'}}>
![ToolJet - Widget Reference - Timeline](/img/widgets/custom-component/custom-component.png)
</div>
## Properties
### Data
The data needs to be an objects which needs to be passed as `data` props to the custom component
**Example:**
```json
{{{
title: "Hi! There",
buttonText: "Updated Text",
queryName: "runjs1"
}}}
```
### Code
This field is used to add a React code for your custom component. The packages for the custom component can be imported from [Skypack](https://www.skypack.dev/). For example, to import `React` package into the custom component it can be imported as `import React from 'https://cdn.skypack.dev/react'`.
Tooljet provides 3 props to interact with the app: `data`, `updateData` and `runQuery`.
- `data` is a shared object between custom component and Tooljet app.
- `updateData` is a function which accepts a single object used to update the data passed to the custom component.
- `runQuery` is a function which accepts a query name as a string used to run the query from the custom component.
**Example:**
```json
import React from "https://cdn.skypack.dev/react";
import ReactDOM from "https://cdn.skypack.dev/react-dom";
import { Button, Container, Link } from "https://cdn.skypack.dev/@material-ui/core";
const MyCustomComponent = ({data, updateData, runQuery}) => (
<Container>
<h1>{data.title}</h1>
<Button
color="primary"
variant="outlined"
onClick={() => {updateData({title: 'Hello World!!'})}}>
{data.buttonText}
</Button>
<Button
color="primary"
variant="outlined"
onClick={() => {runQuery(data.queryName)}}
>
Run Query
</Button>
</Container>
);
const ConnectedComponent = Tooljet.connectComponent(MyCustomComponent);
ReactDOM.render(<ConnectedComponent />, document.body);
```
:::info
`Tooljet.connectComponent` acts as a HOC and it is required to get access to the data passed into the custom component and run the query
:::
## Layout
| Layout | description | Expected value |
| --------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Show on desktop | Toggle on or off to display desktop view. | You can programmatically determining the value by clicking on `Fx` to set the value `{{true}}` or `{{false}}` |
| Show on mobile | Toggle on or off to display mobile view. | You can programmatically determining the value by clicking on `Fx` to set the value `{{true}}` or `{{false}}` |
## Styles
| Style | Description |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Visibility | Toggle on or off to control the visibility of the widget. You can programmatically change its value by clicking on the `Fx` button next to it. If `{{false}}` the widget will not visible after the app is deployed. By default, it's set to `{{true}}`. |
:::info
Any property having `Fx` button next to its field can be **programmatically configured**.
:::

39
docs/docs/widgets/pdf.md Normal file
View file

@ -0,0 +1,39 @@
---
id: pdf
title: PDF
---
# PDF
PDF widget can be used to embed the PDF file either by URL or as a Base64 encoded.
## Properties
### File URL
The URL of the PDF file on the web. `data:application/pdf;base64,` format is supported and the input needs to be prefixed with `data:application/pdf;base64,`
### Scale page to width
It can be toggled to adjust the PDF content to fit the width or height of the component
### Show page controls
By default, page number, previous & next button is displayed while hovering the PDF file. It can be toggled on or off.
## Layout
| Layout | description | Expected value |
| --------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Show on desktop | Toggle on or off to display desktop view. | You can programmatically determining the value by clicking on `Fx` to set the value `{{true}}` or `{{false}}` |
| Show on mobile | Toggle on or off to display mobile view. | You can programmatically determining the value by clicking on `Fx` to set the value `{{true}}` or `{{false}}` |
## Styles
| Style | Description |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Visibility | Toggle on or off to control the visibility of the widget. You can programmatically change its value by clicking on the `Fx` button next to it. If `{{false}}` the widget will not visible after the app is deployed. By default, it's set to `{{true}}`. |
:::info
Any property having `Fx` button next to its field can be **programmatically configured**.
:::

View file

@ -103,6 +103,7 @@ const sidebars = {
'widgets/circular-progress-bar',
'widgets/code-editor',
'widgets/container',
'widgets/custom-component',
'widgets/date-range-picker',
'widgets/datepicker',
'widgets/divider',
@ -116,6 +117,7 @@ const sidebars = {
'widgets/multiselect',
'widgets/number-input',
'widgets/password-input',
'widgets/pdf',
'widgets/qr-scanner',
'widgets/radio-button',
'widgets/range-slider',
@ -160,7 +162,9 @@ const sidebars = {
keywords: ['how to'],
},
items: [
'how-to/bulk-update-multiple-rows',
'how-to/oauth2-authorization',
'how-to/upload-files-aws',
'how-to/upload-files-gcs',
],
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

View file

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-tools" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="#2c3e50" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M3 21h4l13 -13a1.5 1.5 0 0 0 -4 -4l-13 13v4" />
<line x1="14.5" y1="5.5" x2="18.5" y2="9.5" />
<polyline points="12 8 7 3 3 7 8 12" />
<line x1="7" y1="8" x2="5.5" y2="9.5" />
<polyline points="16 12 21 17 17 21 12 16" />
<line x1="16" y1="17" x2="14.5" y2="18.5" />
</svg>

After

Width:  |  Height:  |  Size: 570 B

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 115.28 122.88" style="enable-background:new 0 0 115.28 122.88" xml:space="preserve"><style type="text/css">.st0{fill-rule:evenodd;clip-rule:evenodd;}</style><g><path class="st0" d="M25.38,57h64.88V37.34H69.59c-2.17,0-5.19-1.17-6.62-2.6c-1.43-1.43-2.3-4.01-2.3-6.17V7.64l0,0H8.15 c-0.18,0-0.32,0.09-0.41,0.18C7.59,7.92,7.55,8.05,7.55,8.24v106.45c0,0.14,0.09,0.32,0.18,0.41c0.09,0.14,0.28,0.18,0.41,0.18 c22.78,0,58.09,0,81.51,0c0.18,0,0.17-0.09,0.27-0.18c0.14-0.09,0.33-0.28,0.33-0.41v-11.16H25.38c-4.14,0-7.56-3.4-7.56-7.56 V64.55C17.82,60.4,21.22,57,25.38,57L25.38,57z M29.5,67.4h13.19c2.87,0,5.02,0.68,6.46,2.05c1.43,1.37,2.14,3.31,2.14,5.84 c0,2.59-0.78,4.62-2.34,6.08c-1.56,1.46-3.94,2.19-7.14,2.19h-4.35v9.49H29.5V67.4L29.5,67.4z M37.45,78.37h1.95 c1.54,0,2.62-0.27,3.24-0.8c0.62-0.53,0.93-1.21,0.93-2.04c0-0.81-0.27-1.49-0.81-2.05c-0.54-0.56-1.55-0.84-3.05-0.84h-2.27V78.37 L37.45,78.37z M54.99,67.4h11.78c2.32,0,4.2,0.32,5.63,0.94c1.43,0.63,2.61,1.53,3.55,2.71c0.93,1.18,1.61,2.55,2.02,4.11 c0.42,1.56,0.63,3.22,0.63,4.97c0,2.74-0.31,4.87-0.94,6.38c-0.62,1.51-1.49,2.78-2.6,3.8c-1.11,1.02-2.3,1.7-3.57,2.04 c-1.74,0.47-3.31,0.7-4.72,0.7H54.99V67.4L54.99,67.4z M62.9,73.21v14.01h1.95c1.66,0,2.84-0.19,3.55-0.55 c0.7-0.37,1.25-1.01,1.65-1.92c0.4-0.92,0.6-2.4,0.6-4.45c0-2.72-0.44-4.57-1.33-5.58c-0.89-1-2.36-1.5-4.42-1.5H62.9L62.9,73.21z M82.25,67.4h19.6v5.52H90.21v4.48h9.96v5.2h-9.96v10.46h-7.95V67.4L82.25,67.4z M97.79,57h9.93c4.16,0,7.56,3.41,7.56,7.56v31.42 c0,4.15-3.41,7.56-7.56,7.56h-9.93v13.55c0,1.61-0.65,3.04-1.7,4.1c-1.06,1.06-2.49,1.7-4.1,1.7c-29.44,0-56.59,0-86.18,0 c-1.61,0-3.04-0.64-4.1-1.7c-1.06-1.06-1.7-2.49-1.7-4.1V5.85c0-1.61,0.65-3.04,1.7-4.1c1.06-1.06,2.53-1.7,4.1-1.7h58.72 C64.66,0,64.8,0,64.94,0c0.64,0,1.29,0.28,1.75,0.69h0.09c0.09,0.05,0.14,0.09,0.23,0.18l29.99,30.36c0.51,0.51,0.88,1.2,0.88,1.98 c0,0.23-0.05,0.41-0.09,0.65V57L97.79,57z M67.52,27.97V8.94l21.43,21.7H70.19c-0.74,0-1.38-0.32-1.89-0.78 C67.84,29.4,67.52,28.71,67.52,27.97L67.52,27.97z" fill='#61656F'/></g></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -38,6 +38,7 @@
"emoji-mart": "^3.0.1",
"fuse.js": "^6.4.6",
"history": "^4.9.0",
"html-loader": "^3.1.0",
"html-webpack-plugin": "^5.3.2",
"immer": "^9.0.6",
"immutability-helper": "^3.1.1",
@ -72,6 +73,7 @@
"react-loading-skeleton": "^2.2.0",
"react-mentions": "^4.3.0",
"react-multi-select-component": "^4.2.3",
"react-pdf": "^5.7.2",
"react-plotly.js": "^2.5.1",
"react-qr-reader": "^2.2.1",
"react-rnd": "^10.3.0",
@ -133,6 +135,7 @@
"@tooljet-plugins/mssql": "file:packages/mssql",
"@tooljet-plugins/mysql": "file:packages/mysql",
"@tooljet-plugins/n8n": "file:packages/n8n",
"@tooljet-plugins/notion": "file:packages/notion",
"@tooljet-plugins/openapi": "file:packages/openapi",
"@tooljet-plugins/oracledb": "file:packages/oracledb",
"@tooljet-plugins/postgresql": "file:packages/postgresql",
@ -21578,6 +21581,55 @@
"node": "^10.12.0 || >=12.0.0"
}
},
"node_modules/file-loader": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz",
"integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==",
"dependencies": {
"loader-utils": "^2.0.0",
"schema-utils": "^3.0.0"
},
"engines": {
"node": ">= 10.13.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
},
"peerDependencies": {
"webpack": "^4.0.0 || ^5.0.0"
}
},
"node_modules/file-loader/node_modules/loader-utils": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz",
"integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==",
"dependencies": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^2.1.2"
},
"engines": {
"node": ">=8.9.0"
}
},
"node_modules/file-loader/node_modules/schema-utils": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
"integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
"dependencies": {
"@types/json-schema": "^7.0.8",
"ajv": "^6.12.5",
"ajv-keywords": "^3.5.2"
},
"engines": {
"node": ">= 10.13.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/file-selector": {
"version": "0.2.4",
"integrity": "sha512-ZDsQNbrv6qRi1YTDOEWzf5J2KjZ9KMI1Q2SGeTkCJmNNW25Jg4TW4UMcmoqcg4WrAyKRcpBXdbWRxkfrOzVRbA==",
@ -22190,6 +22242,131 @@
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true
},
"node_modules/html-loader": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/html-loader/-/html-loader-3.1.0.tgz",
"integrity": "sha512-ycMYFRiCF7YANcLDNP72kh3Po5pTcH+bROzdDwh00iVOAY/BwvpuZ1BKPziQ35Dk9D+UD84VGX1Lu/H4HpO4fw==",
"dependencies": {
"html-minifier-terser": "^6.0.2",
"parse5": "^6.0.1"
},
"engines": {
"node": ">= 12.13.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
},
"peerDependencies": {
"webpack": "^5.0.0"
}
},
"node_modules/html-loader/node_modules/acorn": {
"version": "8.7.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.1.tgz",
"integrity": "sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/html-loader/node_modules/clean-css": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.0.tgz",
"integrity": "sha512-YYuuxv4H/iNb1Z/5IbMRoxgrzjWGhOEFfd+groZ5dMCVkpENiMZmwspdrzBo9286JjM1gZJPAyL7ZIdzuvu2AQ==",
"dependencies": {
"source-map": "~0.6.0"
},
"engines": {
"node": ">= 10.0"
}
},
"node_modules/html-loader/node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"engines": {
"node": ">= 12"
}
},
"node_modules/html-loader/node_modules/html-minifier-terser": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
"integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==",
"dependencies": {
"camel-case": "^4.1.2",
"clean-css": "^5.2.2",
"commander": "^8.3.0",
"he": "^1.2.0",
"param-case": "^3.0.4",
"relateurl": "^0.2.7",
"terser": "^5.10.0"
},
"bin": {
"html-minifier-terser": "cli.js"
},
"engines": {
"node": ">=12"
}
},
"node_modules/html-loader/node_modules/terser": {
"version": "5.13.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.13.1.tgz",
"integrity": "sha512-hn4WKOfwnwbYfe48NgrQjqNOH9jzLqRcIfbYytOXCOv46LBfWr9bDS17MQqOi+BWGD0sJK3Sj5NC/gJjiojaoA==",
"dependencies": {
"acorn": "^8.5.0",
"commander": "^2.20.0",
"source-map": "~0.8.0-beta.0",
"source-map-support": "~0.5.20"
},
"bin": {
"terser": "bin/terser"
},
"engines": {
"node": ">=10"
}
},
"node_modules/html-loader/node_modules/terser/node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
},
"node_modules/html-loader/node_modules/terser/node_modules/source-map": {
"version": "0.8.0-beta.0",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz",
"integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==",
"dependencies": {
"whatwg-url": "^7.0.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/html-loader/node_modules/tr46": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
"integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=",
"dependencies": {
"punycode": "^2.1.0"
}
},
"node_modules/html-loader/node_modules/webidl-conversions": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
"integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="
},
"node_modules/html-loader/node_modules/whatwg-url": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
"integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==",
"dependencies": {
"lodash.sortby": "^4.7.0",
"tr46": "^1.0.1",
"webidl-conversions": "^4.0.2"
}
},
"node_modules/html-minifier-terser": {
"version": "5.1.1",
"integrity": "sha512-ZPr5MNObqnV/T9akshPKbVgyOqLmy+Bxo7juKCfTfnjNniTAMdy4hz21YQqoofMBJD2kdREaqPPdThoR78Tgxg==",
@ -26446,6 +26623,14 @@
"node": ">=4.0"
}
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/kleur": {
"version": "3.0.3",
"integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
@ -26770,6 +26955,11 @@
"version": "4.6.2",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
},
"node_modules/lodash.sortby": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
"integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg="
},
"node_modules/lodash.throttle": {
"version": "4.1.1",
"integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ="
@ -26823,6 +27013,14 @@
"integrity": "sha1-81ypHEk/e3PaDgdJUwTxezH4fuU=",
"optional": true
},
"node_modules/make-cancellable-promise": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-1.1.0.tgz",
"integrity": "sha512-X5Opjm2xcZsOLuJ+Bnhb4t5yfu4ehlA3OKEYLtqUchgVzL/QaqW373ZUVxVHKwvJ38cmYuR4rAHD2yUvAIkTPA==",
"funding": {
"url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1"
}
},
"node_modules/make-dir": {
"version": "2.1.0",
"integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
@ -26841,6 +27039,14 @@
"node": ">=6"
}
},
"node_modules/make-event-props": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-1.3.0.tgz",
"integrity": "sha512-oWiDZMcVB1/A487251hEWza1xzgCzl6MXxe9aF24l5Bt9N9UEbqTqKumEfuuLhmlhRZYnc+suVvW4vUs8bwO7Q==",
"funding": {
"url": "https://github.com/wojtekmaj/make-event-props?sponsor=1"
}
},
"node_modules/makeerror": {
"version": "1.0.12",
"integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==",
@ -26940,11 +27146,24 @@
"node": ">=0.10.0"
}
},
"node_modules/merge-class-names": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/merge-class-names/-/merge-class-names-1.4.2.tgz",
"integrity": "sha512-bOl98VzwCGi25Gcn3xKxnR5p/WrhWFQB59MS/aGENcmUc6iSm96yrFDF0XSNurX9qN4LbJm0R9kfvsQ17i8zCw==",
"funding": {
"url": "https://github.com/wojtekmaj/merge-class-names?sponsor=1"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.1",
"integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=",
"dev": true
},
"node_modules/merge-refs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-1.0.0.tgz",
"integrity": "sha512-WZ4S5wqD9FCR9hxkLgvcHJCBxzXzy3VVE6p8W2OzxRzB+hLRlcadGE2bW9xp2KSzk10rvp4y+pwwKO6JQVguMg=="
},
"node_modules/merge-stream": {
"version": "2.0.0",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="
@ -27634,8 +27853,7 @@
},
"node_modules/parse5": {
"version": "6.0.1",
"integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==",
"dev": true
"integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="
},
"node_modules/parseurl": {
"version": "1.3.3",
@ -27717,6 +27935,19 @@
"node": ">=0.10.0"
}
},
"node_modules/pdfjs-dist": {
"version": "2.12.313",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-2.12.313.tgz",
"integrity": "sha512-1x6iXO4Qnv6Eb+YFdN5JdUzt4pAkxSp3aLAYPX93eQCyg/m7QFzXVWJHJVtoW48CI8HCXju4dSkhQZwoheL5mA==",
"peerDependencies": {
"worker-loader": "^3.0.8"
},
"peerDependenciesMeta": {
"worker-loader": {
"optional": true
}
}
},
"node_modules/performance-now": {
"version": "2.1.0",
"integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns="
@ -28757,6 +28988,30 @@
"react-dom": ">=16.3.0"
}
},
"node_modules/react-pdf": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-5.7.2.tgz",
"integrity": "sha512-hdDwvf007V0i2rPCqQVS1fa70CXut17SN3laJYlRHzuqcu8sLLjEoeXihty6c0Ev5g1mw31b8OT8EwRw1s8C4g==",
"dependencies": {
"@babel/runtime": "^7.0.0",
"file-loader": "^6.0.0",
"make-cancellable-promise": "^1.0.0",
"make-event-props": "^1.1.0",
"merge-class-names": "^1.1.1",
"merge-refs": "^1.0.0",
"pdfjs-dist": "2.12.313",
"prop-types": "^15.6.2",
"tiny-invariant": "^1.0.0",
"tiny-warning": "^1.0.0"
},
"funding": {
"url": "https://github.com/wojtekmaj/react-pdf?sponsor=1"
},
"peerDependencies": {
"react": "^16.3.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.3.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-plotly.js": {
"version": "2.5.1",
"integrity": "sha512-Oya14whSHvPsYXdI0nHOGs1pZhMzV2edV7HAW1xFHD58Y73m/LbG2Encvyz1tztL0vfjph0JNhiwO8cGBJnlhg==",
@ -29766,8 +30021,9 @@
}
},
"node_modules/source-map-support": {
"version": "0.5.19",
"integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==",
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
@ -31324,13 +31580,6 @@
"node": ">=6"
}
},
"node_modules/webpack-merge/node_modules/kind-of": {
"version": "6.0.3",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/webpack-merge/node_modules/shallow-clone": {
"version": "3.0.1",
"integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==",
@ -31402,14 +31651,6 @@
"randombytes": "^2.1.0"
}
},
"node_modules/webpack/node_modules/source-map-support": {
"version": "0.5.20",
"integrity": "sha512-n1lZZ8Ve4ksRqizaBQgxXDgKwttHDhyfQjA6YZZn8+AroHbsIz+JjwxQDxbp+7y5OYCI8t1Yk7etjD9CRd2hIw==",
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
}
},
"node_modules/webpack/node_modules/tapable": {
"version": "2.2.1",
"integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
@ -34499,6 +34740,7 @@
"@tooljet-plugins/mssql": "file:packages/mssql",
"@tooljet-plugins/mysql": "file:packages/mysql",
"@tooljet-plugins/n8n": "file:packages/n8n",
"@tooljet-plugins/notion": "file:packages/notion",
"@tooljet-plugins/openapi": "file:packages/openapi",
"@tooljet-plugins/oracledb": "file:packages/oracledb",
"@tooljet-plugins/postgresql": "file:packages/postgresql",
@ -48083,6 +48325,37 @@
"flat-cache": "^3.0.4"
}
},
"file-loader": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz",
"integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==",
"requires": {
"loader-utils": "^2.0.0",
"schema-utils": "^3.0.0"
},
"dependencies": {
"loader-utils": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz",
"integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==",
"requires": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^2.1.2"
}
},
"schema-utils": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
"integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
"requires": {
"@types/json-schema": "^7.0.8",
"ajv": "^6.12.5",
"ajv-keywords": "^3.5.2"
}
}
}
},
"file-selector": {
"version": "0.2.4",
"integrity": "sha512-ZDsQNbrv6qRi1YTDOEWzf5J2KjZ9KMI1Q2SGeTkCJmNNW25Jg4TW4UMcmoqcg4WrAyKRcpBXdbWRxkfrOzVRbA==",
@ -48524,6 +48797,98 @@
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true
},
"html-loader": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/html-loader/-/html-loader-3.1.0.tgz",
"integrity": "sha512-ycMYFRiCF7YANcLDNP72kh3Po5pTcH+bROzdDwh00iVOAY/BwvpuZ1BKPziQ35Dk9D+UD84VGX1Lu/H4HpO4fw==",
"requires": {
"html-minifier-terser": "^6.0.2",
"parse5": "^6.0.1"
},
"dependencies": {
"acorn": {
"version": "8.7.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.1.tgz",
"integrity": "sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A=="
},
"clean-css": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.0.tgz",
"integrity": "sha512-YYuuxv4H/iNb1Z/5IbMRoxgrzjWGhOEFfd+groZ5dMCVkpENiMZmwspdrzBo9286JjM1gZJPAyL7ZIdzuvu2AQ==",
"requires": {
"source-map": "~0.6.0"
}
},
"commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="
},
"html-minifier-terser": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
"integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==",
"requires": {
"camel-case": "^4.1.2",
"clean-css": "^5.2.2",
"commander": "^8.3.0",
"he": "^1.2.0",
"param-case": "^3.0.4",
"relateurl": "^0.2.7",
"terser": "^5.10.0"
}
},
"terser": {
"version": "5.13.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.13.1.tgz",
"integrity": "sha512-hn4WKOfwnwbYfe48NgrQjqNOH9jzLqRcIfbYytOXCOv46LBfWr9bDS17MQqOi+BWGD0sJK3Sj5NC/gJjiojaoA==",
"requires": {
"acorn": "^8.5.0",
"commander": "^2.20.0",
"source-map": "~0.8.0-beta.0",
"source-map-support": "~0.5.20"
},
"dependencies": {
"commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
},
"source-map": {
"version": "0.8.0-beta.0",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz",
"integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==",
"requires": {
"whatwg-url": "^7.0.0"
}
}
}
},
"tr46": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
"integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=",
"requires": {
"punycode": "^2.1.0"
}
},
"webidl-conversions": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
"integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="
},
"whatwg-url": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
"integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==",
"requires": {
"lodash.sortby": "^4.7.0",
"tr46": "^1.0.1",
"webidl-conversions": "^4.0.2"
}
}
}
},
"html-minifier-terser": {
"version": "5.1.1",
"integrity": "sha512-ZPr5MNObqnV/T9akshPKbVgyOqLmy+Bxo7juKCfTfnjNniTAMdy4hz21YQqoofMBJD2kdREaqPPdThoR78Tgxg==",
@ -51649,6 +52014,11 @@
"object.assign": "^4.1.2"
}
},
"kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="
},
"kleur": {
"version": "3.0.3",
"integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
@ -51883,6 +52253,11 @@
"version": "4.6.2",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
},
"lodash.sortby": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
"integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg="
},
"lodash.throttle": {
"version": "4.1.1",
"integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ="
@ -51932,6 +52307,11 @@
"integrity": "sha1-81ypHEk/e3PaDgdJUwTxezH4fuU=",
"optional": true
},
"make-cancellable-promise": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-1.1.0.tgz",
"integrity": "sha512-X5Opjm2xcZsOLuJ+Bnhb4t5yfu4ehlA3OKEYLtqUchgVzL/QaqW373ZUVxVHKwvJ38cmYuR4rAHD2yUvAIkTPA=="
},
"make-dir": {
"version": "2.1.0",
"integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
@ -51946,6 +52326,11 @@
}
}
},
"make-event-props": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-1.3.0.tgz",
"integrity": "sha512-oWiDZMcVB1/A487251hEWza1xzgCzl6MXxe9aF24l5Bt9N9UEbqTqKumEfuuLhmlhRZYnc+suVvW4vUs8bwO7Q=="
},
"makeerror": {
"version": "1.0.12",
"integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==",
@ -52023,11 +52408,21 @@
}
}
},
"merge-class-names": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/merge-class-names/-/merge-class-names-1.4.2.tgz",
"integrity": "sha512-bOl98VzwCGi25Gcn3xKxnR5p/WrhWFQB59MS/aGENcmUc6iSm96yrFDF0XSNurX9qN4LbJm0R9kfvsQ17i8zCw=="
},
"merge-descriptors": {
"version": "1.0.1",
"integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=",
"dev": true
},
"merge-refs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-1.0.0.tgz",
"integrity": "sha512-WZ4S5wqD9FCR9hxkLgvcHJCBxzXzy3VVE6p8W2OzxRzB+hLRlcadGE2bW9xp2KSzk10rvp4y+pwwKO6JQVguMg=="
},
"merge-stream": {
"version": "2.0.0",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="
@ -52524,8 +52919,7 @@
},
"parse5": {
"version": "6.0.1",
"integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==",
"dev": true
"integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="
},
"parseurl": {
"version": "1.3.3",
@ -52596,6 +52990,12 @@
"pinkie-promise": "^2.0.0"
}
},
"pdfjs-dist": {
"version": "2.12.313",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-2.12.313.tgz",
"integrity": "sha512-1x6iXO4Qnv6Eb+YFdN5JdUzt4pAkxSp3aLAYPX93eQCyg/m7QFzXVWJHJVtoW48CI8HCXju4dSkhQZwoheL5mA==",
"requires": {}
},
"performance-now": {
"version": "2.1.0",
"integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns="
@ -53352,6 +53752,23 @@
"warning": "^4.0.3"
}
},
"react-pdf": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-5.7.2.tgz",
"integrity": "sha512-hdDwvf007V0i2rPCqQVS1fa70CXut17SN3laJYlRHzuqcu8sLLjEoeXihty6c0Ev5g1mw31b8OT8EwRw1s8C4g==",
"requires": {
"@babel/runtime": "^7.0.0",
"file-loader": "^6.0.0",
"make-cancellable-promise": "^1.0.0",
"make-event-props": "^1.1.0",
"merge-class-names": "^1.1.1",
"merge-refs": "^1.0.0",
"pdfjs-dist": "2.12.313",
"prop-types": "^15.6.2",
"tiny-invariant": "^1.0.0",
"tiny-warning": "^1.0.0"
}
},
"react-plotly.js": {
"version": "2.5.1",
"integrity": "sha512-Oya14whSHvPsYXdI0nHOGs1pZhMzV2edV7HAW1xFHD58Y73m/LbG2Encvyz1tztL0vfjph0JNhiwO8cGBJnlhg==",
@ -54126,8 +54543,9 @@
}
},
"source-map-support": {
"version": "0.5.19",
"integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==",
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"requires": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
@ -55042,14 +55460,6 @@
"randombytes": "^2.1.0"
}
},
"source-map-support": {
"version": "0.5.20",
"integrity": "sha512-n1lZZ8Ve4ksRqizaBQgxXDgKwttHDhyfQjA6YZZn8+AroHbsIz+JjwxQDxbp+7y5OYCI8t1Yk7etjD9CRd2hIw==",
"requires": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
}
},
"tapable": {
"version": "2.2.1",
"integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ=="
@ -55342,10 +55752,6 @@
"shallow-clone": "^3.0.0"
}
},
"kind-of": {
"version": "6.0.3",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="
},
"shallow-clone": {
"version": "3.0.1",
"integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==",

View file

@ -34,6 +34,7 @@
"emoji-mart": "^3.0.1",
"fuse.js": "^6.4.6",
"history": "^4.9.0",
"html-loader": "^3.1.0",
"html-webpack-plugin": "^5.3.2",
"immer": "^9.0.6",
"immutability-helper": "^3.1.1",
@ -68,6 +69,7 @@
"react-loading-skeleton": "^2.2.0",
"react-mentions": "^4.3.0",
"react-multi-select-component": "^4.2.3",
"react-pdf": "^5.7.2",
"react-plotly.js": "^2.5.1",
"react-qr-reader": "^2.2.1",
"react-rnd": "^10.3.0",

View file

@ -126,7 +126,7 @@ class ConfirmationPage extends React.Component {
</div>
</div>
<div className="mb-3">
<label className="form-label">Organization</label>
<label className="form-label">Workspace</label>
<div className="input-group input-group-flat">
<input
onChange={this.handleChange}

View file

@ -10,7 +10,7 @@ class OrganizationInvitationPage extends React.Component {
isLoading: false,
};
this.formRef = React.createRef(null);
this.single_organization = window.public_config?.MULTI_ORGANIZATION !== 'true';
this.single_organization = window.public_config?.DISABLE_MULTI_WORKSPACE === 'true';
}
handleChange = (event) => {
@ -49,7 +49,7 @@ class OrganizationInvitationPage extends React.Component {
})
.then(() => {
this.setState({ isLoading: false });
toast.success(`Added to the organization${isSetPassword ? ' and password has been set ' : ' '}successfully.`, {
toast.success(`Added to the workspace${isSetPassword ? ' and password has been set ' : ' '}successfully.`, {
position: 'top-center',
});
this.props.history.push('/login');

View file

@ -132,7 +132,7 @@ export const AppVersionsManager = function AppVersionsManager({
return (
<div ref={wrapperRef} className="input-group app-version-menu">
<span className="input-group-text app-version-menu-sm">App Version</span>
<span className="input-group-text app-version-menu-sm">Version</span>
<span
className={`app-version-name form-select app-version-menu-sm ${appVersions ? '' : 'disabled'}`}
onClick={() => {
@ -149,49 +149,58 @@ export const AppVersionsManager = function AppVersionsManager({
<div className="app-version-content">
{appVersions.map((version) =>
releasedVersionId == version.id ? (
<div className="row dropdown-item released" key={version.id} onClick={() => selectVersion(version)}>
<div className="col-md-4">{version.name}</div>
<div className="released-subtext">
<img src={'/assets/images/icons/editor/deploy-rocket.svg'} />
<span className="px-1">Currently Released</span>
<>
<div
className="row dropdown-item released"
key={version.id}
onClick={() => selectVersion(version)}
>
<div className="col-md-4">{version.name}</div>
<div className="released-subtext">
<img src={'/assets/images/icons/editor/deploy-rocket.svg'} />
<span className="px-1">Currently Released</span>
</div>
</div>
</div>
<div className="dropdown-divider m-0"></div>
</>
) : (
<div
className="dropdown-item row"
key={version.id}
onClick={() => selectVersion(version)}
onMouseEnter={() => setMouseHoveredOnVersion(version.id)}
onMouseLeave={() => setMouseHoveredOnVersion(null)}
>
<div className="col-md-4">{version.name}</div>
<>
<div
className="dropdown-item row"
key={version.id}
onClick={() => selectVersion(version)}
onMouseEnter={() => setMouseHoveredOnVersion(version.id)}
onMouseLeave={() => setMouseHoveredOnVersion(null)}
>
<div className="col-md-4">{version.name}</div>
<div className="col-md-2 offset-md-6">
<button
className="btn badge bg-azure-lt"
onClick={(e) => {
e.stopPropagation();
setDeletingVersionId(version.id);
setShowVersionDeletionConfirmation(true);
}}
disabled={isDeletingVersion}
style={{
display: mouseHoveredOnVersion === version.id ? 'flex' : 'none',
}}
>
<img
src="/assets/images/icons/query-trash-icon.svg"
width="12"
height="12"
className="mx-1"
style={{ paddingLeft: '0.6px' }}
/>
</button>
<div className="col-md-2 offset-md-6">
<button
className="btn badge bg-azure-lt"
onClick={(e) => {
e.stopPropagation();
setDeletingVersionId(version.id);
setShowVersionDeletionConfirmation(true);
}}
disabled={isDeletingVersion}
style={{
display: mouseHoveredOnVersion === version.id ? 'flex' : 'none',
}}
>
<img
src="/assets/images/icons/query-trash-icon.svg"
width="12"
height="12"
className="mx-1"
style={{ paddingLeft: '0.6px' }}
/>
</button>
</div>
</div>
</div>
<div className="dropdown-divider m-0"></div>
</>
)
)}
<div className="dropdown-divider"></div>
</div>
<div className="dropdown-item" onClick={() => setShowModal(true)}>
<span className="color-primary create-link">Create Version</span>

View file

@ -38,7 +38,9 @@ import { renderTooltip } from '@/_helpers/appUtils';
import { RangeSlider } from './Components/RangeSlider';
import { Timeline } from './Components/Timeline';
import { SvgImage } from './Components/SvgImage';
import { CustomComponent } from './Components/CustomComponent/CustomComponent';
import { VerticalDivider } from './Components/verticalDivider';
import { PDF } from './Components/PDF';
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
import '@/_styles/custom.scss';
import { resolveProperties, resolveStyles } from './component-properties-resolution';
@ -83,7 +85,9 @@ const AllComponents = {
RangeSlider,
Timeline,
SvgImage,
CustomComponent,
VerticalDivider,
PDF,
};
export const Box = function Box({
@ -110,6 +114,7 @@ export const Box = function Box({
parentId,
allComponents,
extraProps,
dataQueries,
}) {
const backgroundColor = yellow ? 'yellow' : '';
@ -214,6 +219,7 @@ export const Box = function Box({
validate={validate}
parentId={parentId}
customResolvables={customResolvables}
dataQueries={dataQueries}
></ComponentToRender>
) : (
<div className="m-1" style={{ height: '76px', width: '76px', marginLeft: '18px' }}>

View file

@ -112,6 +112,7 @@ export function CodeHinter({
};
const getPreview = () => {
if (!enablePreview) return;
const [preview, error] = resolveReferences(currentValue, realState, null, {}, true);
const themeCls = darkMode ? 'bg-dark py-1' : 'bg-light py-1';
@ -181,7 +182,8 @@ export function CodeHinter({
};
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
const defaultClassName = className === 'query-hinter' || undefined ? '' : 'code-hinter';
const defaultClassName =
className === 'query-hinter' || className === 'custom-component' || undefined ? '' : 'code-hinter';
const ElementToRender = AllElements[TypeMapping[type]];

View file

@ -2,18 +2,43 @@ import _ from 'lodash';
import Fuse from 'fuse.js';
export function getSuggestionKeys(currentState) {
let suggestions = [];
_.keys(currentState).forEach((key) => {
_.keys(currentState[key]).forEach((key2) => {
if (key === 'variables') {
return suggestions.push(`${key}.${key2}`);
const suggestionList = [];
const map = new Map();
const buildMap = (data, path = '') => {
const keys = Object.keys(data);
keys.forEach((key, index) => {
const value = data[key];
const _type = Object.prototype.toString.call(value).slice(8, -1);
const prevType = map.get(path)?.type;
let newPath = '';
if (path === '') {
newPath = key;
} else if (prevType === 'Array') {
newPath = `${path}[${index}]`;
} else {
newPath = `${path}.${key}`;
}
if (_type === 'Object') {
map.set(newPath, { type: _type });
buildMap(value, newPath);
}
if (_type === 'Array') {
map.set(newPath, { type: _type });
buildMap(value, newPath);
} else {
map.set(newPath, { type: _type });
}
_.keys(currentState[key][key2]).forEach((key3) => {
suggestions.push(`${key}.${key2}.${key3}`);
});
});
});
return suggestions;
};
buildMap(currentState, '');
map.forEach((__, key) => suggestionList.push(key));
return suggestionList;
}
export function generateHints(word, suggestions) {

View file

@ -0,0 +1,105 @@
import React, { useEffect, useState, useRef } from 'react';
import { isEqual } from 'lodash';
import iframeContent from './iframe.html';
export const CustomComponent = (props) => {
const { height, properties, styles, id, setExposedVariable, exposedVariables, fireEvent, dataQueries } = props;
const { visibility } = styles;
const { code, data } = properties;
const [customProps, setCustomProps] = useState(data);
const iFrameRef = useRef(null);
const dataQueryRef = useRef(dataQueries);
const customPropRef = useRef(data);
useEffect(() => {
setCustomProps(data);
customPropRef.current = data;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(data)]);
useEffect(() => {
if (!isEqual(exposedVariables.data, customProps)) {
setExposedVariable('data', customProps);
sendMessageToIframe({ message: 'DATA_UPDATED' });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setExposedVariable, customProps, exposedVariables.data]);
useEffect(() => {
sendMessageToIframe({ message: 'CODE_UPDATED' });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [code]);
useEffect(() => {
dataQueryRef.current = dataQueries;
}, [dataQueries]);
useEffect(() => {
window.addEventListener('message', (e) => {
try {
if (e.data.from === 'customComponent' && e.data.componentId === id) {
if (e.data.message === 'UPDATE_DATA') {
setCustomProps({ ...customPropRef.current, ...e.data.updatedObj });
} else if (e.data.message === 'RUN_QUERY') {
const filteredQuery = dataQueryRef.current.filter((query) => query.name === e.data.queryName);
filteredQuery.length === 1 &&
fireEvent('onTrigger', { queryId: filteredQuery[0].id, queryName: filteredQuery[0].name });
} else {
sendMessageToIframe(e.data);
}
}
} catch (err) {
console.log(err);
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const sendMessageToIframe = ({ message }) => {
if (!iFrameRef.current) return;
switch (message) {
case 'INIT':
return iFrameRef.current.contentWindow.postMessage(
{
message: 'INIT_RESPONSE',
componentId: id,
data: customProps,
code: code,
},
'*'
);
case 'CODE_UPDATED':
return iFrameRef.current.contentWindow.postMessage(
{
message: 'CODE_UPDATED',
componentId: id,
data: customProps,
code: code,
},
'*'
);
case 'DATA_UPDATED':
return iFrameRef.current.contentWindow.postMessage(
{
message: 'DATA_UPDATED',
componentId: id,
data: customProps,
},
'*'
);
default:
return;
}
};
return (
<div className="card" style={{ display: visibility ? '' : 'none', height }}>
<iframe
srcDoc={iframeContent}
style={{ width: '100%', height: '100%', border: 'none' }}
ref={iFrameRef}
data-id={id}
></iframe>
</div>
);
};

View file

@ -0,0 +1,86 @@
<html>
<head>
<script src="https://unpkg.com/@babel/standalone@7.17.9/babel.min.js" integrity="sha384-q/mWU54AdnQn35rIhX7g2MtszBgXHwH9exPcvCVnncKy5WoKc457RNDNmm23Fag7" crossorigin="anonymous" referrerpolicy="no-referrer" data-required="true"></script>
<script src="https://unpkg.com/react@16.7.0/umd/react.production.min.js" integrity="sha384-bDWFfmoLfqL0ZuPgUiUz3ekiv8NyiuJrrk1wGblri8Nut8UVD6mj7vXhjnenE9vy" crossorigin="anonymous" referrerpolicy="no-referrer" data-required="true"></script>
<script src="https://unpkg.com/react-dom@16.7.0/umd/react-dom.production.min.js" integrity="sha384-mcyjbblFFAXUUcVbGLbJZR86Xd7La0uD1S7/Snd1tW0N+zhy97geTqVYDQ92c8tI" crossorigin="anonymous" referrerpolicy="no-referrer" data-required="true"></script>
</head>
<body style='margin: 0'>
<script data-required="true">
let callbackFn = () => {};
let props = {};
window.Tooljet = {
componentId: window.frameElement.getAttribute('data-id'),
subscribe: fn => {
fn(props);
callbackFn = fn;
},
runQuery: name => {
window.parent.postMessage({
from: 'customComponent',
message: "RUN_QUERY",
queryName: name,
componentId: window.Tooljet.componentId
}, "*")},
updateProps: obj => window.parent.postMessage({
from: 'customComponent',
message: "UPDATE_DATA",
updatedObj: obj,
componentId: window.Tooljet.componentId
}, "*"),
init: () => {
window.parent.postMessage({
from: 'customComponent',
message: "INIT",
componentId: window.Tooljet.componentId,
}, "*");
window.addEventListener('message', (e) => {
if(e.data.message === 'CODE_UPDATED' || e.data.message === 'INIT_RESPONSE'){
const tags = document.getElementsByTagName("script");
for(let i = 0; i < tags.length; i++){
if(tags[i].getAttribute("data-required") !== 'true'){
tags[i].parentNode.removeChild(tags[i]);
i = i - 1;
}
}
var head = document.getElementsByTagName('head')[0];
script = document.createElement('script');
script.text = e.data.code
script.type = "text/babel";
script.setAttribute("data-type", "module");
head.appendChild(script)
window.dispatchEvent(new Event('DOMContentLoaded'));
props = e.data.data;
callbackFn(e.data.data)
} else if(e.data.message === 'DATA_UPDATED'){
props = e.data.data;
callbackFn(e.data.data)
}
});
}
}
window.addEventListener('load', function() {
window.Tooljet.init();
})
</script>
<script type="text/babel" data-required="true"> window.Tooljet.connectComponent = WrappedComponent => {
class ConnectedComponent extends React.Component {
constructor() {
super(), this.state = {}
}
componentDidMount() {
window.Tooljet.subscribe((e => this.setState({
data: e
})))
}
render() {
return <WrappedComponent data={this.state?.data ?? {}}
updateData={(e) => Tooljet.updateProps(e)}
runQuery={(e) => Tooljet.runQuery(e)}/>
}
}
return ConnectedComponent;
}
</script>
</body>
</html>

View file

@ -0,0 +1,93 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { Document, Page } from 'react-pdf/dist/esm/entry.webpack';
export const PDF = React.memo(({ styles, properties, width, height }) => {
const { visibility } = styles;
const { url, scale, pageControls } = properties;
const [numPages, setNumPages] = useState(null);
const [pageNumber, setPageNumber] = useState(null);
const pageRef = useRef([]);
const onDocumentLoadSuccess = (document) => {
const { numPages: nextNumPages } = document;
setNumPages(nextNumPages);
setPageNumber(1);
};
const options = {
root: document.querySelector('#pdf-wrapper'),
rootMargin: '0px',
threshold: 0.5,
};
const trackIntersection = (entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const currentPage = parseInt(entry.target.getAttribute('data-page-number'));
if (pageNumber !== currentPage) setPageNumber(currentPage);
}
});
};
useEffect(() => {
if (numPages === 0 || numPages === null) return;
const observer = new IntersectionObserver(trackIntersection, options);
document.querySelectorAll('.react-pdf__Page').forEach((elem) => {
if (elem) observer.observe(elem);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [numPages, options]);
const updatePage = useCallback(
(offset) => {
pageRef.current[pageNumber + offset - 1].scrollIntoView({ block: 'nearest' });
setPageNumber((prevPageNumber) => (prevPageNumber || 1) + offset);
},
[pageNumber]
);
return (
<div style={{ display: visibility ? 'flex' : 'none', width: width - 3, height }}>
<div className="d-flex position-relative h-100" style={{ margin: '0 auto', overflow: 'hidden' }}>
<div className="scrollable h-100 col position-relative" id="pdf-wrapper">
<Document file={url} onLoadSuccess={onDocumentLoadSuccess} className="pdf-document">
{Array.from(new Array(numPages), (el, index) => (
<Page
pageNumber={index + 1}
width={scale ? width - 12 : undefined}
height={scale ? undefined : height}
key={`page_${index + 1}`}
inputRef={(el) => (pageRef.current[index] = el)}
/>
))}
{pageControls && (
<>
<div className="pdf-page-controls">
<button
disabled={pageNumber <= 1}
onClick={() => updatePage(-1)}
type="button"
aria-label="Previous page"
>
</button>
<span>
{pageNumber} of {numPages}
</span>
<button
disabled={pageNumber >= numPages}
onClick={() => updatePage(1)}
type="button"
aria-label="Next page"
>
</button>
</div>
</>
)}
</Document>
</div>
</div>
</div>
);
});

View file

@ -1,7 +1,14 @@
import React from 'react';
export const Statistics = function Statistics({ height, properties, styles, darkMode }) {
const { primaryValueLabel, primaryValue, secondaryValueLabel, secondaryValue, secondarySignDisplay, hideSecondary } =
properties;
export const Statistics = function Statistics({ width, height, properties, styles, darkMode }) {
const {
primaryValueLabel,
primaryValue,
secondaryValueLabel,
secondaryValue,
secondarySignDisplay,
hideSecondary,
loadingState,
} = properties;
const { primaryLabelColour, primaryTextColour, secondaryLabelColour, secondaryTextColour, visibility } = styles;
const baseStyle = {
@ -64,45 +71,55 @@ export const Statistics = function Statistics({ height, properties, styles, dark
return (
<div style={baseStyle}>
<p
style={{
...letterStyle,
...marginStyle,
color: primaryLabelColour !== '#8092AB' ? primaryLabelColour : darkMode && '#FFFFFC',
}}
>
{primaryValueLabel}
</p>
<h2 style={primaryStyle}>{primaryValue}</h2>
{hideSecondary ? (
''
{loadingState === true ? (
<div style={{ width }} className="p-2">
<center>
<div className="spinner-border" role="status"></div>
</center>
</div>
) : (
<div>
<div className="d-flex flex-row justify-content-center align-items-baseline">
{secondarySignDisplay !== 'negative' ? (
<img
src="/assets/images/icons/widgets/upstatistics.svg"
style={{ ...marginStyle, marginRight: '6.5px' }}
/>
) : (
<img
src="/assets/images/icons/widgets/downstatistics.svg"
style={{ ...marginStyle, marginRight: '6.5px' }}
/>
)}
<p style={{ ...secondaryContainerStyle }}>{secondaryValue}</p>
</div>
<>
<p
style={{
...letterStyle,
color: secondaryLabelColour !== '#8092AB' ? secondaryLabelColour : darkMode && '#FFFFFC',
padding: '6px 20px 12px 20px ',
marginBottom: '0px',
...marginStyle,
color: primaryLabelColour !== '#8092AB' ? primaryLabelColour : darkMode && '#FFFFFC',
}}
>
{secondaryValueLabel}
{primaryValueLabel}
</p>
</div>
<h2 style={primaryStyle}>{primaryValue}</h2>
{hideSecondary ? (
''
) : (
<div>
<div className="d-flex flex-row justify-content-center align-items-baseline">
{secondarySignDisplay !== 'negative' ? (
<img
src="/assets/images/icons/widgets/upstatistics.svg"
style={{ ...marginStyle, marginRight: '6.5px' }}
/>
) : (
<img
src="/assets/images/icons/widgets/downstatistics.svg"
style={{ ...marginStyle, marginRight: '6.5px' }}
/>
)}
<p style={{ ...secondaryContainerStyle }}>{secondaryValue}</p>
</div>
<p
style={{
...letterStyle,
color: secondaryLabelColour !== '#8092AB' ? secondaryLabelColour : darkMode && '#FFFFFC',
padding: '6px 20px 12px 20px ',
marginBottom: '0px',
}}
>
{secondaryValueLabel}
</p>
</div>
)}
</>
)}
</div>
);

View file

@ -4,7 +4,7 @@ import DOMPurify from 'dompurify';
export const Text = function Text({ height, properties, styles, darkMode }) {
const [loadingState, setLoadingState] = useState(false);
const { textColor, textAlign, visibility, disabledState } = styles;
const { textSize, textColor, textAlign, visibility, disabledState } = styles;
const text = properties.text === 0 || properties.text === false ? properties.text?.toString() : properties.text;
@ -27,7 +27,10 @@ export const Text = function Text({ height, properties, styles, darkMode }) {
return (
<div data-disabled={disabledState} className="text-widget" style={computedStyles}>
{!loadingState && (
<div style={{ width: '100%' }} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(text) }} />
<div
style={{ width: '100%', fontSize: textSize }}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(text) }}
/>
)}
{loadingState === true && (
<div style={{ width: '100%' }}>

View file

@ -8,7 +8,10 @@ export const Timeline = function Timeline({ height, darkMode, properties, styles
const darkModeStyle = darkMode && 'text-white-50';
return (
<div className="card" style={{ display: visibility ? '' : 'none', height }}>
<div
className="card"
style={{ display: visibility ? '' : 'none', height, overflow: 'auto', overflowWrap: 'normal' }}
>
<div className="card-body">
<ul className={`list list-timeline ${hideDate && 'list-timeline-simple'}`}>
{(isArray(data) ? data : []).map((item, index) => (

View file

@ -762,6 +762,7 @@ export const componentTypes = [
},
events: [],
styles: {
textSize: { type: 'number', displayName: 'Text Size' },
textColor: { type: 'color', displayName: 'Text Color' },
textAlign: { type: 'alignButtons', displayName: 'Align Text' },
visibility: { type: 'toggle', displayName: 'Visibility' },
@ -781,6 +782,7 @@ export const componentTypes = [
events: [],
styles: {
groupActions: { value: 'left' },
textSize: { value: 14 },
textColor: { value: '#000' },
textAlign: { value: 'left' },
visibility: { value: '{{true}}' },
@ -1854,6 +1856,7 @@ export const componentTypes = [
secondaryValueLabel: { type: 'code', displayName: 'Secondary value label' },
secondaryValue: { type: 'code', displayName: 'Secondary value' },
secondarySignDisplay: { type: 'code', displayName: 'Secondary sign display' },
loadingState: { type: 'toggle', displayName: 'Loading State' },
},
events: {},
styles: {
@ -1874,6 +1877,7 @@ export const componentTypes = [
secondaryValueLabel: { value: 'Last month' },
secondaryValue: { value: '2.85' },
secondarySignDisplay: { value: 'positive' },
loadingState: { value: `{{false}}` },
},
events: [],
styles: {
@ -2058,4 +2062,110 @@ export const componentTypes = [
},
},
},
{
name: 'CustomComponent',
displayName: 'Custom Component',
description: 'Visual representation of a sequence of events',
component: 'CustomComponent',
properties: {
data: { type: 'code', displayName: 'Data' },
code: { type: 'code', displayName: 'Code' },
},
defaultSize: {
width: 20,
height: 140,
},
others: {
showOnDesktop: { type: 'toggle', displayName: 'Show on desktop' },
showOnMobile: { type: 'toggle', displayName: 'Show on mobile' },
},
events: {},
styles: {
visibility: { type: 'toggle', displayName: 'Visibility' },
},
exposedVariables: {
data: { value: `{{{ title: 'Hi! There', buttonText: 'Update Title'}}}` },
},
definition: {
others: {
showOnDesktop: { value: '{{true}}' },
showOnMobile: { value: '{{false}}' },
},
properties: {
visible: { value: '{{true}}' },
data: {
value: `{{{ title: 'Hi! There', buttonText: 'Update Title'}}}`,
},
code: {
value: `import React from 'https://cdn.skypack.dev/react';
import ReactDOM from 'https://cdn.skypack.dev/react-dom';
import { Button, Container } from 'https://cdn.skypack.dev/@material-ui/core';
const MyCustomComponent = ({data, updateData, runQuery}) => (
<Container>
<h1>{data.title}</h1>
<Button
color="primary"
variant="outlined"
onClick={() => {updateData({title: 'Hello World!!'})}}
>
{data.buttonText}
</Button>
</Container>
);
const ConnectedComponent = Tooljet.connectComponent(MyCustomComponent);
ReactDOM.render(<ConnectedComponent />, document.body);`,
},
},
events: [],
styles: {
visibility: { value: '{{true}}' },
},
},
},
{
name: 'PDF',
displayName: 'PDF',
description: 'Embed PDF file',
component: 'PDF',
properties: {
url: { type: 'code', displayName: 'File URL' },
scale: { type: 'toggle', displayName: 'Scale page to width' },
pageControls: { type: 'toggle', displayName: 'Show page controls' },
},
defaultSize: {
width: 20,
height: 640,
},
others: {
showOnDesktop: { type: 'toggle', displayName: 'Show on desktop' },
showOnMobile: { type: 'toggle', displayName: 'Show on mobile' },
},
events: {},
styles: {
visibility: { type: 'toggle', displayName: 'Visibility' },
},
exposedVariables: {},
definition: {
others: {
showOnDesktop: { value: '{{true}}' },
showOnMobile: { value: '{{false}}' },
},
properties: {
url: {
value:
'https://upload.wikimedia.org/wikipedia/commons/e/ee/Guideline_No._GD-Ed-2214_Marman_Clamp_Systems_Design_Guidelines.pdf',
},
scale: {
value: '{{true}}',
},
pageControls: {
value: `{{true}}`,
},
},
events: [],
styles: {
visibility: { value: '{{true}}' },
},
},
},
];

View file

@ -9,6 +9,7 @@ export const ConfigHandle = function ConfigHandle({
position,
widgetTop,
widgetHeight,
isMultipleComponentsSelected = false,
}) {
return (
<div
@ -24,7 +25,7 @@ export const ConfigHandle = function ConfigHandle({
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setSelectedComponent(id, component);
setSelectedComponent(id, component, e.shiftKey);
}}
role="button"
>
@ -37,17 +38,19 @@ export const ConfigHandle = function ConfigHandle({
/>
<span>{component.name}</span>
</div>
<div className="delete-part">
<img
style={{ cursor: 'pointer', marginLeft: '5px' }}
src="/assets/images/icons/trash-light.svg"
width="12"
role="button"
height="12"
draggable="false"
onClick={() => removeComponent({ id })}
/>
</div>
{!isMultipleComponentsSelected && (
<div className="delete-part">
<img
style={{ cursor: 'pointer', marginLeft: '5px' }}
src="/assets/images/icons/trash-light.svg"
width="12"
role="button"
height="12"
draggable="false"
onClick={() => removeComponent({ id })}
/>
</div>
)}
</span>
</div>
);

View file

@ -1,3 +1,4 @@
/* eslint-disable import/no-named-as-default */
import React, { useCallback, useState, useEffect, useRef } from 'react';
import cx from 'classnames';
import { v4 as uuidv4 } from 'uuid';
@ -14,9 +15,11 @@ import { commentsService } from '@/_services';
import config from 'config';
import Spinner from '@/_ui/Spinner';
import { useHotkeys } from 'react-hotkeys-hook';
import produce from 'immer';
export const Container = ({
canvasWidth,
canvasHeight,
mode,
snapToGrid,
onComponentClick,
@ -32,7 +35,7 @@ export const Container = ({
currentLayout,
removeComponent,
deviceWindowWidth,
selectedComponent,
selectedComponents,
darkMode,
showComments,
appVersionsId,
@ -41,11 +44,13 @@ export const Container = ({
handleRedo,
onComponentHover,
hoveredComponent,
dataQueries,
}) => {
const styles = {
width: currentLayout === 'mobile' ? deviceWindowWidth : '100%',
height: 2400,
height: '100%',
maxWidth: `${canvasWidth}px`,
maxHeight: `${canvasHeight}px`,
position: 'absolute',
backgroundSize: `${canvasWidth / 43}px 10px`,
};
@ -208,7 +213,7 @@ export const Container = ({
);
function onDragStop(e, componentId, direction, currentLayout) {
const id = componentId ? componentId : uuidv4();
// const id = componentId ? componentId : uuidv4();
// Get the width of the canvas
const canvasBounds = document.getElementsByClassName('real-canvas')[0].getBoundingClientRect();
@ -217,25 +222,24 @@ export const Container = ({
// Computing the left offset
const leftOffset = nodeBounds.x - canvasBounds.x;
const left = convertXToPercentage(leftOffset, canvasWidth);
const currentLeftOffset = boxes[componentId].layouts[currentLayout].left;
const leftDiff = currentLeftOffset - convertXToPercentage(leftOffset, canvasWidth);
// Computing the top offset
const top = nodeBounds.y - canvasBounds.y;
// const currentTopOffset = boxes[componentId].layouts[currentLayout].top;
const topDiff = boxes[componentId].layouts[currentLayout].top - (nodeBounds.y - canvasBounds.y);
let newBoxes = {
...boxes,
[id]: {
...boxes[id],
layouts: {
...boxes[id]['layouts'],
[currentLayout]: {
...boxes[id]['layouts'][currentLayout],
top: top,
left: left,
},
},
},
};
let newBoxes = { ...boxes };
for (const selectedComponent of selectedComponents) {
newBoxes = produce(newBoxes, (draft) => {
const topOffset = draft[selectedComponent.id].layouts[currentLayout].top;
const leftOffset = draft[selectedComponent.id].layouts[currentLayout].left;
draft[selectedComponent.id].layouts[currentLayout].top = topOffset - topDiff;
draft[selectedComponent.id].layouts[currentLayout].left = leftOffset - leftDiff;
});
}
setBoxes(newBoxes);
}
@ -307,7 +311,7 @@ export const Container = ({
}
}
React.useEffect(() => {}, [selectedComponent]);
React.useEffect(() => {}, [selectedComponents]);
const handleAddThread = async (e) => {
e.stopPropogation && e.stopPropogation();
@ -463,10 +467,14 @@ export const Container = ({
removeComponent={removeComponent}
currentLayout={currentLayout}
deviceWindowWidth={deviceWindowWidth}
isSelectedComponent={selectedComponent ? selectedComponent.id === key : false}
isSelectedComponent={
mode === 'edit' ? selectedComponents.find((component) => component.id === key) : false
}
darkMode={darkMode}
onComponentHover={onComponentHover}
hoveredComponent={hoveredComponent}
isMultipleComponentsSelected={selectedComponents?.length > 1 ? true : false}
dataQueries={dataQueries}
containerProps={{
mode,
snapToGrid,
@ -483,10 +491,11 @@ export const Container = ({
removeComponent,
currentLayout,
deviceWindowWidth,
selectedComponent,
selectedComponents,
darkMode,
onComponentHover,
hoveredComponent,
dataQueries,
}}
/>
);

View file

@ -95,6 +95,8 @@ export const DraggableBox = function DraggableBox({
parentId,
hoveredComponent,
onComponentHover,
isMultipleComponentsSelected,
dataQueries,
}) {
const [isResizing, setResizing] = useState(false);
const [isDragging2, setDragging] = useState(false);
@ -219,7 +221,7 @@ export const DraggableBox = function DraggableBox({
mouseOver || isResizing || isDragging2 || isSelectedComponent ? 'resizer-active' : ''
} `}
onResize={() => setResizing(true)}
onDrag={(e) => {
onDrag={(e, direction) => {
e.preventDefault();
e.stopImmediatePropagation();
if (!isDragging2) {
@ -251,7 +253,10 @@ export const DraggableBox = function DraggableBox({
position={currentLayoutOptions.top < 15 ? 'bottom' : 'top'}
widgetTop={currentLayoutOptions.top}
widgetHeight={currentLayoutOptions.height}
setSelectedComponent={(id, component) => setSelectedComponent(id, component)}
setSelectedComponent={(id, component, multiSelect) =>
setSelectedComponent(id, component, multiSelect)
}
isMultipleComponentsSelected={isMultipleComponentsSelected}
/>
)}
<ErrorBoundary showFallback={mode === 'edit'}>
@ -278,6 +283,7 @@ export const DraggableBox = function DraggableBox({
parentId={parentId}
allComponents={allComponents}
extraProps={extraProps}
dataQueries={dataQueries}
/>
</ErrorBoundary>
</div>

View file

@ -7,6 +7,7 @@ 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 { EditorKeyHooks } from './EditorKeyHooks';
import { CustomDragLayer } from './CustomDragLayer';
import { LeftSidebar } from './LeftSidebar';
import { componentTypes } from './Components/components';
@ -41,6 +42,7 @@ 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 Spinner from '@/_ui/Spinner';
import { AppVersionsManager } from './AppVersionsManager';
import { SearchBoxComponent } from '@/_ui/Search';
import { createWebsocketConnection } from '@/_helpers/websocketConnection';
@ -82,6 +84,7 @@ class Editor extends React.Component {
hideHeader: false,
appInMaintenance: false,
canvasMaxWidth: 1292,
canvasMaxHeight: 2400,
canvasBackgroundColor: props.darkMode ? '#2f3c4c' : '#edeff5',
},
};
@ -126,6 +129,8 @@ class Editor extends React.Component {
showInitVersionCreateModal: false,
showCreateVersionModalPrompt: false,
isSourceSelected: false,
isSaving: false,
saveError: false,
};
this.autoSave = debounce(this.saveEditingVersion, 3000);
@ -144,7 +149,7 @@ class Editor extends React.Component {
this.initEventListeners();
this.setState({
currentSidebarTab: 2,
selectedComponent: null,
selectedComponents: [],
});
}
@ -169,6 +174,12 @@ class Editor extends React.Component {
if (!isEqual(prevState.appDefinition, this.state.appDefinition)) {
computeComponentState(this, this.state.appDefinition.components);
}
if (config.ENABLE_MULTIPLAYER_EDITING) {
if (this.props.othersOnSameVersion.length !== prevProps.othersOnSameVersion.length) {
ReactTooltip.rebuild();
}
}
}
isVersionReleased = (version = this.state.editingVersion) => {
@ -179,7 +190,7 @@ class Editor extends React.Component {
};
closeCreateVersionModalPrompt = () => {
this.setState({ showCreateVersionModalPrompt: false });
this.setState({ isSaving: false, showCreateVersionModalPrompt: false });
};
onMouseMove = (e) => {
@ -389,6 +400,7 @@ class Editor extends React.Component {
});
this.setState({
editingVersion: version,
isSaving: false,
});
this.fetchDataSources();
@ -425,9 +437,6 @@ class Editor extends React.Component {
};
switchSidebarTab = (tabIndex) => {
if (tabIndex === 2) {
this.setState({ selectedComponent: null });
}
this.setState({
currentSidebarTab: tabIndex,
});
@ -525,26 +534,55 @@ class Editor extends React.Component {
},
this.handleAddPatch
);
this.setState({ appDefinition: newDefinition }, () => {
this.setState({ isSaving: true, 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);
}
}
handleInspectorView = () => {
this.switchSidebarTab(2);
};
handleSlugChange = (newSlug) => {
this.setState({ slug: newSlug });
};
removeComponents = () => {
if (!this.isVersionReleased() && this.state?.selectedComponents?.length > 1) {
let newDefinition = cloneDeep(this.state.appDefinition);
const selectedComponents = this.state?.selectedComponents;
selectedComponents.forEach((component) => {
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('Selected components deleted! (⌘Z to undo)', {
icon: '🗑️',
});
this.appDefinitionChanged(newDefinition, {
skipAutoSave: this.isVersionReleased(),
});
this.handleInspectorView();
}
};
removeComponent = (component) => {
if (!this.isVersionReleased()) {
let newDefinition = cloneDeep(this.state.appDefinition);
@ -573,7 +611,7 @@ class Editor extends React.Component {
this.appDefinitionChanged(newDefinition, {
skipAutoSave: this.isVersionReleased(),
});
this.handleInspectorView(component);
this.handleInspectorView();
}
};
@ -595,6 +633,7 @@ class Editor extends React.Component {
);
setStateAsync(_self, newDefinition).then(() => {
computeComponentState(_self, _self.state.appDefinition.components);
this.setState({ isSaving: true });
this.autoSave();
this.props.ymap?.set('appDef', {
newDefinition: newDefinition.appDefinition,
@ -603,6 +642,47 @@ class Editor extends React.Component {
});
};
handleEditorEscapeKeyPress = () => {
if (this.state?.selectedComponents?.length > 0) {
this.setState({ selectedComponents: [] });
this.handleInspectorView();
}
};
moveComponents = (direction) => {
let appDefinition = JSON.parse(JSON.stringify(this.state.appDefinition));
let newComponents = appDefinition.components;
for (const selectedComponent of this.state.selectedComponents) {
newComponents = produce(newComponents, (draft) => {
let top = draft[selectedComponent.id].layouts[this.state.currentLayout].top;
let left = draft[selectedComponent.id].layouts[this.state.currentLayout].left;
const gridWidth = (1 * 100) / 43; // width of the canvas grid in percentage
switch (direction) {
case 'ArrowLeft':
left = left - gridWidth;
break;
case 'ArrowRight':
left = left + gridWidth;
break;
case 'ArrowDown':
top = top + 10;
break;
case 'ArrowUp':
top = top - 10;
break;
}
draft[selectedComponent.id].layouts[this.state.currentLayout].top = top;
draft[selectedComponent.id].layouts[this.state.currentLayout].left = left;
});
}
appDefinition.components = newComponents;
this.appDefinitionChanged(appDefinition);
};
cloneComponent = (newComponent) => {
const appDefinition = JSON.parse(JSON.stringify(this.state.appDefinition));
@ -617,6 +697,7 @@ class Editor extends React.Component {
appDefinition.globalSettings[key] = value;
this.setState(
{
isSaving: true,
appDefinition,
},
() => {
@ -811,9 +892,22 @@ class Editor extends React.Component {
this.setState({ showComments: !this.state.showComments });
};
setSelectedComponent = (id, component) => {
this.switchSidebarTab(1);
this.setState({ selectedComponent: { id, component } });
setSelectedComponent = (id, component, multiSelect = false) => {
if (this.state.selectedComponents.length === 0 || !multiSelect) {
this.switchSidebarTab(1);
} else {
this.switchSidebarTab(2);
}
const isAlreadySelected = this.state.selectedComponents.find((component) => component.id === id);
if (!isAlreadySelected) {
this.setState((prevState) => {
return {
selectedComponents: [...(multiSelect ? prevState.selectedComponents : []), { id, component }],
};
});
}
};
filterQueries = (value) => {
@ -867,6 +961,11 @@ class Editor extends React.Component {
return canvasBoundingRect?.width;
};
getCanvasHeight = () => {
const canvasBoundingRect = document.getElementsByClassName('canvas-area')[0].getBoundingClientRect();
return canvasBoundingRect?.height;
};
renderLayoutIcon = (isDesktopSelected) => {
if (isDesktopSelected)
return (
@ -896,21 +995,31 @@ class Editor extends React.Component {
saveEditingVersion = () => {
if (this.isVersionReleased()) {
this.setState({ showCreateVersionModalPrompt: true });
this.setState({ isSaving: false, 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 },
appVersionService
.save(this.state.appId, this.state.editingVersion.id, this.state.appDefinition)
.then(() => {
this.setState(
{
saveError: false,
editingVersion: {
...this.state.editingVersion,
...{ definition: this.state.appDefinition },
},
},
() => {
this.setState({
isSaving: false,
});
}
);
})
.catch(() => {
this.setState({ saveError: true, isSaving: false }, () => {
toast.error('App could not save.');
});
return 'Saved!';
},
error: 'App could not save.',
});
});
}
};
@ -954,7 +1063,7 @@ class Editor extends React.Component {
render() {
const {
currentSidebarTab,
selectedComponent = {},
selectedComponents = [],
appDefinition,
appId,
slug,
@ -1034,6 +1143,15 @@ class Editor extends React.Component {
</span>
</div>
)}
<span
className={cx('autosave-indicator', {
'autosave-indicator-saving': this.state.isSaving,
'text-danger': this.state.saveError,
'd-none': this.isVersionReleased(),
})}
>
{this.state.isSaving ? <Spinner size="small" /> : 'All changes are saved'}
</span>
{config.ENABLE_MULTIPLAYER_EDITING && (
<RealtimeAvatars
updatePresence={this.props.updatePresence}
@ -1056,7 +1174,9 @@ class Editor extends React.Component {
<a
href={appVersionPreviewLink}
target="_blank"
className={`btn btn-sm font-500 color-primary ${app?.current_version_id ? '' : 'disabled'}`}
className={`btn btn-sm font-500 color-primary border-0 ${
app?.current_version_id ? '' : 'disabled'
}`}
rel="noreferrer"
>
Preview
@ -1109,7 +1229,7 @@ class Editor extends React.Component {
appDefinition={{
components: appDefinition.components,
queries: dataQueries,
selectedComponent: this.state?.selectedComponent,
selectedComponent: selectedComponents ? selectedComponents[selectedComponents.length - 1] : {},
}}
setSelectedComponent={this.setSelectedComponent}
removeComponent={this.removeComponent}
@ -1123,7 +1243,7 @@ class Editor extends React.Component {
style={{ transform: `scale(${zoomLevel})` }}
onClick={(e) => {
if (['real-canvas', 'modal'].includes(e.target.className)) {
this.switchSidebarTab(2);
this.setState({ selectedComponents: [], currentSidebarTab: 2 });
}
}}
>
@ -1131,7 +1251,9 @@ class Editor extends React.Component {
className="canvas-area"
style={{
width: currentLayout === 'desktop' ? '100%' : '450px',
minHeight: +this.state.appDefinition.globalSettings.canvasMaxHeight,
maxWidth: +this.state.appDefinition.globalSettings.canvasMaxWidth,
maxHeight: +this.state.appDefinition.globalSettings.canvasMaxHeight,
backgroundColor: this.state.appDefinition.globalSettings.canvasBackgroundColor,
}}
>
@ -1145,6 +1267,7 @@ class Editor extends React.Component {
<>
<Container
canvasWidth={this.getCanvasWidth()}
canvasHeight={this.getCanvasHeight()}
socket={this.socket}
showComments={showComments}
appVersionsId={this.state?.editingVersion?.id}
@ -1156,7 +1279,7 @@ class Editor extends React.Component {
zoomLevel={zoomLevel}
currentLayout={currentLayout}
deviceWindowWidth={deviceWindowWidth}
selectedComponent={selectedComponent}
selectedComponents={selectedComponents}
appLoading={isLoading}
onEvent={this.handleEvent}
onComponentOptionChanged={this.handleOnComponentOptionChanged}
@ -1169,6 +1292,7 @@ class Editor extends React.Component {
onComponentClick={this.handleComponentClick}
onComponentHover={this.handleComponentHover}
hoveredComponent={hoveredComponent}
dataQueries={dataQueries}
/>
<CustomDragLayer
snapToGrid={true}
@ -1354,6 +1478,7 @@ class Editor extends React.Component {
disabled: !this.canUndo,
})}
width="44"
data-tip="undo"
height="44"
viewBox="0 0 24 24"
strokeWidth="1.5"
@ -1371,6 +1496,7 @@ class Editor extends React.Component {
</svg>
<svg
title="redo"
data-tip="redo"
onClick={this.handleRedo}
xmlns="http://www.w3.org/2000/svg"
className={cx('cursor-pointer icon icon-tabler icon-tabler-arrow-forward-up', {
@ -1395,26 +1521,34 @@ class Editor extends React.Component {
{this.renderLayoutIcon(currentLayout === 'desktop')}
</div>
</div>
<EditorKeyHooks
moveComponents={this.moveComponents}
handleEditorEscapeKeyPress={this.handleEditorEscapeKeyPress}
removeMultipleComponents={this.removeComponents}
/>
{currentSidebarTab === 1 && (
<div className="pages-container">
{selectedComponent &&
{selectedComponents.length === 1 &&
!isEmpty(appDefinition.components) &&
!isEmpty(appDefinition.components[selectedComponent.id]) ? (
!isEmpty(appDefinition.components[selectedComponents[0].id]) ? (
<Inspector
cloneComponent={this.cloneComponent}
moveComponents={this.moveComponents}
componentDefinitionChanged={this.componentDefinitionChanged}
dataQueries={dataQueries}
removeComponent={this.removeComponent}
selectedComponentId={selectedComponent.id}
selectedComponentId={selectedComponents[0].id}
currentState={currentState}
allComponents={appDefinition.components}
key={selectedComponent.id}
key={selectedComponents[0].id}
switchSidebarTab={this.switchSidebarTab}
apps={apps}
darkMode={this.props.darkMode}
></Inspector>
) : (
<div className="mt-5 p-2">Please select a component to inspect</div>
<center className="mt-5 p-2">Please select a component to inspect</center>
)}
</div>
)}

View file

@ -0,0 +1,21 @@
import React from 'react';
import useKeyHooks from '@/_hooks/useKeyHooks';
export const EditorKeyHooks = ({ moveComponents, handleEditorEscapeKeyPress, removeMultipleComponents }) => {
const handleHotKeysCallback = (key) => {
switch (key) {
case 'Escape':
handleEditorEscapeKeyPress();
break;
case 'Backspace':
removeMultipleComponents();
break;
default:
moveComponents(key);
}
};
useKeyHooks(['up, down, left, right', 'esc', 'backspace'], handleHotKeysCallback);
return <></>;
};

View file

@ -0,0 +1,81 @@
import React from 'react';
import { renderElement } from '../Utils';
import { CodeHinter } from '../../CodeBuilder/CodeHinter';
import Accordion from '@/_ui/Accordion';
export const CustomComponent = function CustomComponent({
dataQueries,
component,
paramUpdated,
componentMeta,
components,
darkMode,
currentState,
layoutPropertyChanged,
}) {
const code = component.component.definition.properties.code;
const args = component.component.definition.properties.data;
let items = [];
items.push({
title: 'Data',
children: (
<CodeHinter
currentState={currentState}
initialValue={args.value ?? {}}
theme={darkMode ? 'monokai' : 'base16-light'}
onChange={(value) => paramUpdated({ name: 'data' }, 'value', value, 'properties')}
componentName={`widget/${component.component.name}/data`}
/>
),
});
items.push({
title: 'Code',
children: (
<CodeHinter
currentState={currentState}
initialValue={code.value ?? {}}
theme={darkMode ? 'monokai' : 'base16-light'}
mode="jsx"
lineNumbers
className="custom-component"
onChange={(value) => paramUpdated({ name: 'code' }, 'value', value, 'properties')}
componentName={`widget/${component.component.name}/code`}
enablePreview={false}
height={400}
/>
),
});
items.push({
title: 'Layout',
isOpen: false,
children: (
<>
{renderElement(
component,
componentMeta,
layoutPropertyChanged,
dataQueries,
'showOnDesktop',
'others',
currentState,
components
)}
{renderElement(
component,
componentMeta,
layoutPropertyChanged,
dataQueries,
'showOnMobile',
'others',
currentState,
components
)}
</>
),
});
return <Accordion items={items} />;
};

View file

@ -12,6 +12,7 @@ import { ConfirmDialog } from '@/_components';
import { useHotkeys } from 'react-hotkeys-hook';
import { DefaultComponent } from './Components/DefaultComponent';
import { FilePicker } from './Components/FilePicker';
import { CustomComponent } from './Components/CustomComponent';
import useFocus from '@/_hooks/use-Focus';
export const Inspector = ({
@ -288,6 +289,22 @@ export const Inspector = ({
/>
);
case 'CustomComponent':
return (
<CustomComponent
layoutPropertyChanged={layoutPropertyChanged}
component={component}
paramUpdated={paramUpdated}
dataQueries={dataQueries}
componentMeta={componentMeta}
currentState={currentState}
darkMode={darkMode}
eventsChanged={eventsChanged}
apps={apps}
allComponents={allComponents}
/>
);
default: {
return (
<DefaultComponent

View file

@ -13,7 +13,7 @@ export const LeftSidebarGlobalSettings = ({
is_maintenance_on,
}) => {
const [open, trigger, content] = usePopover(false);
const { hideHeader, canvasMaxWidth, canvasBackgroundColor } = globalSettings;
const { hideHeader, canvasMaxWidth, canvasMaxHeight, canvasBackgroundColor } = globalSettings;
const [showPicker, setShowPicker] = React.useState(false);
const [showConfirmation, setConfirmationShow] = React.useState(false);
const coverStyles = {
@ -75,7 +75,7 @@ export const LeftSidebarGlobalSettings = ({
<input
type="text"
className={`form-control form-control-sm`}
placeholder={'Enter canvas max-width'}
placeholder={'0'}
onChange={(e) => {
globalSettingsChanged('canvasMaxWidth', e.target.value);
}}
@ -85,6 +85,24 @@ export const LeftSidebarGlobalSettings = ({
</div>
</div>
</div>
<div className="d-flex mb-3">
<span className="w-full m-auto">Max height of canvas</span>
<div className="position-relative">
<div className="input-with-icon">
<input
type="text"
className={`form-control form-control-sm`}
placeholder={'0'}
onChange={(e) => {
const height = e.target.value;
if (!Number.isNaN(height) && height <= 2400) globalSettingsChanged('canvasMaxHeight', height);
}}
value={canvasMaxHeight}
/>
<span className="input-group-text">px</span>
</div>
</div>
</div>
<div className="d-flex">
<span className="w-full m-auto">Background color of canvas</span>
<div>

View file

@ -306,7 +306,7 @@ class ManageAppUsers extends React.Component {
<Modal.Footer>
<a href="/users" target="_blank" className="btn color-primary mt-3">
Manage Organization Users
Manage Users
</a>
</Modal.Footer>
</Modal>

View file

@ -1,6 +1,5 @@
/* eslint-disable import/no-unresolved */
import React from 'react';
import config from 'config';
import Avatar from '@/_ui/Avatar';
import { useOthers } from 'y-presence';

View file

@ -7,6 +7,7 @@ import { snapToGrid as doSnapToGrid } from './snapToGrid';
import update from 'immutability-helper';
import { componentTypes } from './Components/components';
import { computeComponentName } from '@/_helpers/utils';
import produce from 'immer';
export const SubContainer = ({
mode,
@ -35,6 +36,7 @@ export const SubContainer = ({
listViewItemOptions,
onComponentHover,
hoveredComponent,
selectedComponents,
}) => {
const [_containerCanvasWidth, setContainerCanvasWidth] = useState(0);
@ -251,9 +253,6 @@ export const SubContainer = ({
}
function onDragStop(e, componentId, direction, currentLayout) {
const id = componentId ? componentId : uuidv4();
// Get the width of the canvas
const canvasWidth = getContainerCanvasWidth();
const nodeBounds = direction.node.getBoundingClientRect();
@ -261,25 +260,24 @@ export const SubContainer = ({
// Computing the left offset
const leftOffset = nodeBounds.x - canvasBounds.x;
const left = convertXToPercentage(leftOffset, canvasWidth);
const currentLeftOffset = boxes[componentId].layouts[currentLayout].left;
const leftDiff = currentLeftOffset - convertXToPercentage(leftOffset, canvasWidth);
// Computing the top offset
const top = nodeBounds.y - canvasBounds.y;
const topDiff = boxes[componentId].layouts[currentLayout].top - (nodeBounds.y - canvasBounds.y);
let newBoxes = {
...boxes,
[id]: {
...boxes[id],
layouts: {
...boxes[id]['layouts'],
[currentLayout]: {
...boxes[id]['layouts'][currentLayout],
top: top,
left: left,
},
},
},
};
let newBoxes = { ...boxes };
if (selectedComponents) {
for (const selectedComponent of selectedComponents) {
newBoxes = produce(newBoxes, (draft) => {
const topOffset = draft[selectedComponent.id].layouts[currentLayout].top;
const leftOffset = draft[selectedComponent.id].layouts[currentLayout].left;
draft[selectedComponent.id].layouts[currentLayout].top = topOffset - topDiff;
draft[selectedComponent.id].layouts[currentLayout].left = leftOffset - leftDiff;
});
}
}
setBoxes(newBoxes);
}
@ -418,7 +416,7 @@ export const SubContainer = ({
currentLayout={currentLayout}
selectedComponent={selectedComponent}
deviceWindowWidth={deviceWindowWidth}
isSelectedComponent={selectedComponent ? selectedComponent.id === key : false}
isSelectedComponent={mode === 'edit' ? selectedComponents.find((component) => component.id === key) : false}
removeComponent={customRemoveComponent}
canvasWidth={_containerCanvasWidth}
readOnly={readOnly}
@ -427,6 +425,7 @@ export const SubContainer = ({
onComponentHover={onComponentHover}
hoveredComponent={hoveredComponent}
parentId={parentComponent?.name}
isMultipleComponentsSelected={selectedComponents?.length > 1 ? true : false}
containerProps={{
mode,
snapToGrid,

View file

@ -103,6 +103,7 @@ class Viewer extends React.Component {
urlparams: JSON.parse(JSON.stringify(queryString.parse(this.props.location.search))),
},
},
dataQueries: data.data_queries,
},
() => {
computeComponentState(this, data?.definition?.components).then(() => {
@ -185,6 +186,7 @@ class Viewer extends React.Component {
deviceWindowWidth,
defaultComponentStateComputed,
canvasWidth,
dataQueries,
} = this.state;
if (this.state.app?.is_maintenance_on) {
@ -232,7 +234,9 @@ class Viewer extends React.Component {
className="canvas-area"
style={{
width: canvasWidth,
minHeight: +appDefinition.globalSettings?.canvasMaxHeight || 2400,
maxWidth: +appDefinition.globalSettings?.canvasMaxWidth || 1292,
maxHeight: +appDefinition.globalSettings?.canvasMaxHeight || 2400,
backgroundColor: appDefinition.globalSettings?.canvasBackgroundColor || '#edeff5',
}}
>
@ -270,6 +274,7 @@ class Viewer extends React.Component {
onComponentOptionsChanged(this, component, options)
}
canvasWidth={this.getCanvasWidth()}
dataQueries={dataQueries}
/>
)}
</>

View file

@ -16,7 +16,7 @@ class LoginPage extends React.Component {
isGettingConfigs: true,
configs: undefined,
};
this.single_organization = window.public_config?.MULTI_ORGANIZATION !== 'true';
this.single_organization = window.public_config?.DISABLE_MULTI_WORKSPACE === 'true';
}
componentDidMount() {
@ -132,7 +132,7 @@ class LoginPage extends React.Component {
this.showLoading()
) : (
<div className="card-body">
{!configs && <div className="text-center">No login methods enabled for this organization</div>}
{!configs && <div className="text-center">No login methods enabled for this workspace</div>}
{configs?.form?.enabled && (
<div>
<h2 className="card-title text-center mb-4" data-cy="login-page-header">

View file

@ -77,7 +77,7 @@ export function Google({ settings, updateData }) {
<input
type="text"
className="form-control"
placeholder="Enter Client Secret"
placeholder="Enter Client Id"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
/>

View file

@ -6,7 +6,7 @@ import { toast } from 'react-hot-toast';
import { SearchBox } from './SearchBox';
export const Organization = function Organization() {
const isSingleOrganization = window.public_config?.MULTI_ORGANIZATION !== 'true';
const isSingleOrganization = window.public_config?.DISABLE_MULTI_WORKSPACE === 'true';
const { admin, organization_id } = authenticationService.currentUserValue;
const [organization, setOrganization] = useState(authenticationService.currentUserValue?.organization);
const [showCreateOrg, setShowCreateOrg] = useState(false);
@ -21,11 +21,13 @@ export const Organization = function Organization() {
const getAvatar = (organization) => {
if (!organization) return;
const orgName = organization.split(' ');
const orgName = organization.split(' ').filter((e) => e && !!e.trim());
if (orgName.length > 1) {
return `${orgName[0]?.[0]}${orgName[1]?.[0]}`;
} else {
} else if (organization.length >= 2) {
return `${organization[0]}${organization[1]}`;
} else {
return `${organization[0]}${organization[0]}`;
}
};
@ -58,7 +60,7 @@ export const Organization = function Organization() {
const createOrganization = () => {
if (!(newOrgName && newOrgName.trim())) {
toast.error("organization name can't be empty.", {
toast.error('Workspace name can not be empty.', {
position: 'top-center',
});
return;
@ -66,21 +68,22 @@ export const Organization = function Organization() {
setIsCreating(true);
organizationService.createOrganization(newOrgName).then(
(data) => {
setIsCreating(false);
authenticationService.updateCurrentUserDetails(data);
window.location.href = '/';
},
() => {
toast.error('Error while creating organization', {
setIsCreating(false);
toast.error('Error while creating workspace', {
position: 'top-center',
});
}
);
setIsCreating(false);
};
const editOrganization = () => {
if (!(newOrgName && newOrgName.trim())) {
toast.error("organization name can't be empty.", {
toast.error('Workspace name can not be empty.', {
position: 'top-center',
});
return;
@ -89,13 +92,13 @@ export const Organization = function Organization() {
organizationService.editOrganization({ name: newOrgName }).then(
() => {
authenticationService.updateCurrentUserDetails({ organization: newOrgName });
toast.success('Organization updated', {
toast.success('Workspace updated', {
position: 'top-center',
});
setOrganization(newOrgName);
},
() => {
toast.error('Error while editing organization', {
toast.error('Error while editing workspace', {
position: 'top-center',
});
}
@ -258,7 +261,7 @@ export const Organization = function Organization() {
</div>
{!isSingleOrganization && (
<div className="dropdown-item org-actions">
<div onClick={showCreateModal}>Add Organizations</div>
<div onClick={showCreateModal}>Add workspace</div>
</div>
)}
{admin && (
@ -281,8 +284,12 @@ export const Organization = function Organization() {
return (
<div>
<div className="dropdown organization-list" onMouseEnter={() => setIsListOrganizations(false)}>
<a href="#" className={`btn ${!isSingleOrganization || admin ? 'dropdown-toggle' : ''}`}>
<div className="dropdown organization-list">
<a
href="#"
className={`btn ${!isSingleOrganization || admin ? 'dropdown-toggle' : ''}`}
onMouseOver={() => setIsListOrganizations(false)}
>
<div>{organization}</div>
</a>
{(!isSingleOrganization || admin) && (
@ -291,14 +298,14 @@ export const Organization = function Organization() {
</div>
)}
</div>
<Modal show={showCreateOrg} closeModal={() => setShowCreateOrg(false)} title="Create organization">
<Modal show={showCreateOrg} closeModal={() => setShowCreateOrg(false)} title="Create workspace">
<div className="row">
<div className="col modal-main">
<input
type="text"
onChange={(e) => setNewOrgName(e.target.value)}
className="form-control"
placeholder="organization name"
placeholder="workspace name"
disabled={isCreating}
maxLength={25}
/>
@ -314,19 +321,19 @@ export const Organization = function Organization() {
className={`btn btn-primary ${isCreating ? 'btn-loading' : ''}`}
onClick={createOrganization}
>
Create organization
Create workspace
</button>
</div>
</div>
</Modal>
<Modal show={showEditOrg} closeModal={() => setShowEditOrg(false)} title="Edit organization">
<Modal show={showEditOrg} closeModal={() => setShowEditOrg(false)} title="Edit workspace">
<div className="row">
<div className="col modal-main">
<input
type="text"
onChange={(e) => setNewOrgName(e.target.value)}
className="form-control"
placeholder="organization name"
placeholder="workspace name"
disabled={isCreating}
value={newOrgName}
maxLength={25}

View file

@ -323,6 +323,26 @@ export async function onEvent(_ref, eventName, options, mode = 'edit') {
const { customVariables } = options;
if (eventName === 'onTrigger') {
const { component, queryId, queryName } = options;
_self.setState(
{
currentState: {
..._self.state.currentState,
components: {
..._self.state.currentState.components,
[component.name]: {
..._self.state.currentState.components[component.name],
},
},
},
},
() => {
runQuery(_ref, queryId, queryName, true, mode);
}
);
}
if (eventName === 'onRowClicked') {
const { component, data, rowId } = options;
_self.setState(

View file

@ -0,0 +1,9 @@
import { useHotkeys } from 'react-hotkeys-hook';
const useKeyHooks = (hotkeys = [], callback) =>
useHotkeys(hotkeys.toString(), (e) => {
e.preventDefault();
callback(e.code);
});
export default useKeyHooks;

View file

@ -114,7 +114,7 @@ button {
.editor {
.header-container {
max-width: 100%;
padding: 0 15px;
padding: 0 10px;
}
.resizer-active {
@ -262,7 +262,7 @@ button {
height: 100%;
width: 76px;
position: fixed;
z-index: 1;
z-index: 2;
left: 0;
overflow-x: hidden;
flex: 1 1 auto;
@ -3971,7 +3971,7 @@ input[type="text"] {
}
.app-name {
width: 325px;
width: 250px;
left: 150px;
position: absolute;
}
@ -4010,7 +4010,7 @@ input[type="text"] {
}
.app-version-menu .dropdown-menu {
left: -90px;
left: -65px;
width: 283px;
}
@ -4021,7 +4021,7 @@ input[type="text"] {
.app-version-menu .released-subtext {
font-size: 12px;
color: #36af8b;
padding: 0;
padding: 0 8px;
}
.app-version-menu .create-link {
@ -5080,8 +5080,9 @@ div#driver-page-overlay {
.realtime-avatars {
position: absolute;
left: 35%;
left: 50%;
}
.widget-style-field-header{
font-family: 'Inter';
font-style: normal;
@ -5114,4 +5115,69 @@ div#driver-page-overlay {
padding: 5px;
display: flex;
justify-content: end;
}
}
.autosave-indicator {
position: absolute;
left: 30%;
color: #868aa5;
white-space: nowrap;
font-weight: 400;
font-size: 12px;
letter-spacing: 0.5px;
}
.autosave-indicator-saving {
left: 34.5%;
}
.pdf-page-controls {
position: fixed;
bottom: 20px;
left: 50%;
background: white;
opacity: 0;
transform: translateX(-50%);
transition: opacity ease-in-out 0.2s;
border-radius: 4px;
box-shadow: 0 30px 40px 0 rgba(16, 36, 94, 0.2);
button {
width: 36px;
height: 36px;
background: white;
border: 0;
font-size: 1.2em;
border-radius: 4px;
&:first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
&:last-child {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
&:hover {
background-color: #e6e6e6;
}
}
span {
font-family: inherit;
font-size: 1em;
padding: 0 0.5em;
color: #000;
}
}
.pdf-document{
canvas{
margin: 0px auto;
}
&:hover {
.pdf-page-controls {
opacity: 1;
}
}
}

View file

@ -4,7 +4,7 @@ const Avatar = ({ text, title = '', borderColor = '' }) => {
return (
<span
data-tip={title}
style={{ border: `1px solid ${borderColor}` }}
style={{ border: `1.5px solid ${borderColor}` }}
className="avatar avatar-sm avatar-rounded animation-fade"
>
{text}

View file

@ -327,10 +327,10 @@ export const JSONNode = ({ data, ...restProps }) => {
<div
style={{ width: 'inherit' }}
className={`${shouldDisplayIntendedBlock && 'group-border'} ${applySelectedNodeStyles && 'selected-node'}`}
onMouseEnter={() => updateHoveredNode(currentNode, currentNodePath)}
onMouseLeave={() => updateHoveredNode(null)}
>
<div
onMouseEnter={() => updateHoveredNode(currentNode, currentNodePath)}
onMouseLeave={() => updateHoveredNode(null)}
className={cx('d-flex', {
'group-object-container': shouldDisplayIntendedBlock,
'mx-2': typeofCurrentNode !== 'Object' && typeofCurrentNode !== 'Array',

View file

@ -15,7 +15,11 @@ const JSONTreeValueNode = ({ data, type }) => {
);
}
const value = type === 'String' ? `"${data}"` : String(data);
let value = type === 'String' ? `"${data}"` : String(data);
if (value.length > 65) {
value = `${value.substring(0, 65)} ... "`;
}
const clsForUndefinedOrNull = (type === 'Undefined' || type === 'Null') && 'badge badge-secondary';
return (
<span

View file

@ -157,10 +157,9 @@ export class JSONTreeViewer extends React.Component {
newPath = `${path}.${key}`;
}
if (_.isObject(value) || _.isArray(value)) {
buildMap(value, newPath);
} else if (_.isFunction(value)) {
if (_.isObject(value)) {
map.set(newPath, { type: _type });
buildMap(value, newPath);
} else {
map.set(newPath, { type: _type });
}

View file

@ -1,7 +1,15 @@
import React from 'react';
import cx from 'classnames';
const Spinner = ({ ...props }) => (
<div {...props} className="spinner-border spinner-border-lg text-muted" role="status" />
const Spinner = ({ size = 'large', ...props }) => (
<div
{...props}
className={cx('spinner-border text-muted', {
'spinner-border-lg': size === 'large',
'spinner-border-sm': size === 'small',
})}
role="status"
/>
);
export default Spinner;

View file

@ -82,6 +82,10 @@ module.exports = {
},
},
},
{
test: /\.html$/,
loader: 'html-loader',
},
],
},
plugins: [

View file

@ -1,9 +1,12 @@
[build]
base = "frontend/"
publish = "build/"
base = "/"
publish = "frontend/build/"
command = "npm run build:plugins && npm run build:frontend"
[template.environment]
NODE_ENV = "production"
NODE_VERSION = "14.17.3"
NPM_VERSION = "7.20.0"
[[redirects]]
from = "/*"

View file

@ -16818,6 +16818,7 @@
}
},
"packages/notion": {
"name": "@tooljet-plugins/notion",
"version": "1.0.0",
"dependencies": {
"@notionhq/client": "^1.0.4",

View file

@ -130,7 +130,7 @@ export default class GooglesheetsQueryService implements QueryService {
} catch (error) {
console.log(error.response);
if (error.response.statusCode === 401) {
if (error?.response?.statusCode === 401) {
throw new OAuthUnauthorizedClientError('Query could not be completed', error.message, { ...error });
}
throw new QueryError('Query could not be completed', error.message, {});

View file

@ -63,12 +63,12 @@ export class OauthService {
async #findAndActivateUser(email: string, organizationId: string): Promise<User> {
const user = await this.usersService.findByEmail(email, organizationId);
if (!user) {
throw new UnauthorizedException('User not exist in the organization');
throw new UnauthorizedException('User not exist in the workspace');
}
const organizationUser: OrganizationUser = user.organizationUsers?.[0];
if (!organizationUser) {
throw new UnauthorizedException('User not exist in the organization');
throw new UnauthorizedException('User not exist in the workspace');
}
if (organizationUser.status != 'active') await this.organizationUsersService.activate(organizationUser);
return user;

View file

@ -9,7 +9,7 @@ export class PopulateSSOConfigs1650485473528 implements MigrationInterface {
const encryptionService = new EncryptionService();
const OrganizationRepository = entityManager.getRepository(Organization);
const isSingleOrganization = process.env.MULTI_ORGANIZATION !== 'true';
const isSingleOrganization = process.env.DISABLE_MULTI_WORKSPACE === 'true';
const enableSignUp = process.env.SSO_DISABLE_SIGNUP !== 'true';
const domain = process.env.SSO_RESTRICTED_DOMAIN;

View file

@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { AppVersion } from '../src/entities/app_version.entity';
export class PopulateTextSize1651820577708 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const entityManager = queryRunner.manager;
const appVersions = await entityManager.find(AppVersion);
for (const version of appVersions) {
const definition = version['definition'];
if (definition) {
const components = definition['components'];
for (const componentId of Object.keys(components)) {
const component = components[componentId];
if (component.component.component === 'Text') {
component.component.definition.styles.textSize = { value: 14 };
components[componentId] = {
...component,
component: {
...component.component,
definition: {
...component.component.definition,
},
},
};
}
}
definition['components'] = components;
version.definition = definition;
await entityManager.update(AppVersion, { id: version.id }, { definition });
}
}
}
public async down(queryRunner: QueryRunner): Promise<void> {}
}

View file

@ -49,7 +49,7 @@ export class OrganizationsController {
@Get(['/:organizationId/public-configs', '/public-configs'])
async getOrganizationDetails(@Param('organizationId') organizationId: string) {
if (!organizationId && this.configService.get<string>('MULTI_ORGANIZATION') !== 'true') {
if (!organizationId && this.configService.get<string>('DISABLE_MULTI_WORKSPACE') === 'true') {
// Request from single organization login page - find one from organization and setting
organizationId = (await this.organizationsService.getSingleOrganization()).id;
}

View file

@ -40,6 +40,10 @@ async function bootstrap() {
"'unsafe-inline'",
"'unsafe-eval'",
'blob:',
'https://unpkg.com/@babel/standalone@7.17.9/babel.min.js',
'https://unpkg.com/react@16.7.0/umd/react.production.min.js',
'https://unpkg.com/react-dom@16.7.0/umd/react-dom.production.min.js',
'cdn.skypack.dev',
],
'default-src': [
'maps.googleapis.com',

View file

@ -7,6 +7,6 @@ export class MultiOrganizationGuard implements CanActivate {
constructor(private configService: ConfigService) {}
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
return this.configService.get<string>('MULTI_ORGANIZATION') === 'true';
return this.configService.get<string>('DISABLE_MULTI_WORKSPACE') !== 'true';
}
}

View file

@ -23,7 +23,7 @@ export class AppConfigService {
'SENTRY_DNS',
'SENTRY_DEBUG',
'DISABLE_SIGNUPS',
'MULTI_ORGANIZATION',
'DISABLE_MULTI_WORKSPACE',
];
}

View file

@ -51,7 +51,7 @@ export class AuthService {
if (!organizationId) {
// Global login
// Determine the organization to be loaded
if (this.configService.get<string>('MULTI_ORGANIZATION') !== 'true') {
if (this.configService.get<string>('DISABLE_MULTI_WORKSPACE') === 'true') {
// Single organization
organization = await this.organizationsService.getSingleOrganization();
if (!organization?.ssoConfigs?.find((oc) => oc.sso == 'form' && oc.enabled)) {
@ -72,7 +72,7 @@ export class AuthService {
organization = organizationList[0];
} else {
// no form login enabled organization available for user - creating new one
organization = await this.organizationsService.create('Untitled organization', user);
organization = await this.organizationsService.create('Untitled workspace', user);
}
}
user.organizationId = organization.id;
@ -122,7 +122,7 @@ export class AuthService {
if (!(isNewOrganization || user.isPasswordLogin)) {
throw new UnauthorizedException();
}
if (this.configService.get<string>('MULTI_ORGANIZATION') !== 'true') {
if (this.configService.get<string>('DISABLE_MULTI_WORKSPACE') === 'true') {
throw new UnauthorizedException();
}
const newUser = await this.usersService.findByEmail(user.email, newOrganizationId);
@ -174,7 +174,7 @@ export class AuthService {
let organization: Organization;
// Check if the configs allows user signups
if (this.configService.get<string>('MULTI_ORGANIZATION') !== 'true') {
if (this.configService.get<string>('DISABLE_MULTI_WORKSPACE') === 'true') {
// Single organization checking if organization exist
organization = await this.organizationsService.getSingleOrganization();
@ -188,7 +188,7 @@ export class AuthService {
}
}
// Create default organization
organization = await this.organizationsService.create('Untitled organization');
organization = await this.organizationsService.create('Untitled workspace');
const user = await this.usersService.create({ email }, organization.id, ['all_users', 'admin'], existingUser, true);
await this.organizationUsersService.create(user, organization, true);
await this.emailService.sendWelcomeEmail(user.email, user.firstName, user.invitationToken);

View file

@ -100,7 +100,7 @@ export class EmailService {
<p>Hi ${name || ''},</p>
<br>
<span>
${sender} has invited you to use ToolJet organisation ${organisationName}. Use the link below to set up your account and get started.
${sender} has invited you to use ToolJet workspace ${organisationName}. Use the link below to set up your account and get started.
</span>
<br>
<a href="${inviteUrl}">${inviteUrl}</a>

View file

@ -30,7 +30,7 @@ export class SeedsService {
sso: 'form',
},
],
name: 'My organization',
name: 'My workspace',
});
await manager.save(organization);

View file

@ -38,6 +38,18 @@ describe('Authentication', () => {
});
describe('Single organization', () => {
beforeEach(async () => {
jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => {
switch (key) {
case 'DISABLE_SIGNUPS':
return 'false';
case 'DISABLE_MULTI_WORKSPACE':
return 'true';
default:
return process.env[key];
}
});
});
it('should create new users and organization', async () => {
const response = await request(app.getHttpServer()).post('/api/signup').send({ email: 'test@tooljet.io' });
expect(response.statusCode).toBe(201);
@ -52,7 +64,7 @@ describe('Authentication', () => {
});
expect(user.defaultOrganizationId).toBe(user?.organizationUsers?.[0]?.organizationId);
expect(organization.name).toBe('Untitled organization');
expect(organization.name).toBe('Untitled workspace');
const groupPermissions = await user.groupPermissions;
const groupNames = groupPermissions.map((x) => x.group);
@ -142,8 +154,6 @@ describe('Authentication', () => {
switch (key) {
case 'DISABLE_SIGNUPS':
return 'false';
case 'MULTI_ORGANIZATION':
return 'true';
default:
return process.env[key];
}
@ -155,8 +165,6 @@ describe('Authentication', () => {
switch (key) {
case 'DISABLE_SIGNUPS':
return 'true';
case 'MULTI_ORGANIZATION':
return 'true';
default:
return process.env[key];
}
@ -182,7 +190,7 @@ describe('Authentication', () => {
});
expect(user.defaultOrganizationId).toBe(user?.organizationUsers?.[0]?.organizationId);
expect(organization?.name).toBe('Untitled organization');
expect(organization?.name).toBe('Untitled workspace');
const groupPermissions = await user.groupPermissions;
const groupNames = groupPermissions.map((x) => x.group);
@ -255,7 +263,7 @@ describe('Authentication', () => {
.send({ email: 'admin@tooljet.io', password: 'password' });
expect(response.statusCode).toBe(201);
expect(response.body.organization_id).not.toBe(current_organization.id);
expect(response.body.organization).toBe('Untitled organization');
expect(response.body.organization).toBe('Untitled workspace');
});
it('should be able to switch between organizations with admin privilage', async () => {
const { organization: invited_organization } = await createUser(

File diff suppressed because it is too large Load diff

View file

@ -13,14 +13,6 @@ describe('organizations controller', () => {
beforeEach(async () => {
await clearDB();
jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => {
switch (key) {
case 'MULTI_ORGANIZATION':
return 'false';
default:
return process.env[key];
}
});
});
beforeAll(async () => {
@ -65,28 +57,20 @@ describe('organizations controller', () => {
describe('create organization', () => {
it('should allow only authenticated users to create organization', async () => {
await request(app.getHttpServer()).post('/api/organizations').send({ name: 'My organization' }).expect(401);
await request(app.getHttpServer()).post('/api/organizations').send({ name: 'My workspace' }).expect(401);
});
it('should create new organization if multi organization supported', async () => {
jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => {
switch (key) {
case 'MULTI_ORGANIZATION':
return 'true';
default:
return process.env[key];
}
});
it('should create new organization if Multi-Workspace supported', async () => {
const { user, organization } = await createUser(app, {
email: 'admin@tooljet.io',
});
const response = await request(app.getHttpServer())
.post('/api/organizations')
.send({ name: 'My organization' })
.send({ name: 'My workspace' })
.set('Authorization', authHeaderForUser(user));
expect(response.statusCode).toBe(201);
expect(response.body.organization_id).not.toBe(organization.id);
expect(response.body.organization).toBe('My organization');
expect(response.body.organization).toBe('My workspace');
expect(response.body.admin).toBeTruthy();
const newUser = await userRepository.findOneOrFail({ where: { id: user.id } });
@ -94,14 +78,6 @@ describe('organizations controller', () => {
});
it('should throw error if name is empty', async () => {
jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => {
switch (key) {
case 'MULTI_ORGANIZATION':
return 'true';
default:
return process.env[key];
}
});
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
const response = await request(app.getHttpServer())
.post('/api/organizations')
@ -111,35 +87,35 @@ describe('organizations controller', () => {
expect(response.statusCode).toBe(400);
});
it('should not create new organization if multi organization not supported', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
await request(app.getHttpServer())
.post('/api/organizations')
.send({ name: 'My organization' })
.set('Authorization', authHeaderForUser(user))
.expect(403);
});
it('should create new organization if multi organization supported and user logged in via SSO', async () => {
it('should not create new organization if Multi-Workspace not supported', async () => {
jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => {
switch (key) {
case 'MULTI_ORGANIZATION':
case 'DISABLE_MULTI_WORKSPACE':
return 'true';
default:
return process.env[key];
}
});
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
await request(app.getHttpServer())
.post('/api/organizations')
.send({ name: 'My workspace' })
.set('Authorization', authHeaderForUser(user))
.expect(403);
});
it('should create new organization if Multi-Workspace supported and user logged in via SSO', async () => {
const { user, organization } = await createUser(app, {
email: 'admin@tooljet.io',
});
const response = await request(app.getHttpServer())
.post('/api/organizations')
.send({ name: 'My organization' })
.send({ name: 'My workspace' })
.set('Authorization', authHeaderForUser(user, null, false));
expect(response.statusCode).toBe(201);
expect(response.body.organization_id).not.toBe(organization.id);
expect(response.body.organization).toBe('My organization');
expect(response.body.organization).toBe('My workspace');
expect(response.body.admin).toBeTruthy();
});
});
@ -252,6 +228,14 @@ describe('organizations controller', () => {
describe('get public organization configs', () => {
it('should get organization details for all users for single organization', async () => {
jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => {
switch (key) {
case 'DISABLE_MULTI_WORKSPACE':
return 'true';
default:
return process.env[key];
}
});
const { user } = await createUser(app, {
email: 'admin@tooljet.io',
});
@ -291,14 +275,6 @@ describe('organizations controller', () => {
});
it('should get organization specific details for all users for multiple organization deployment', async () => {
jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => {
switch (key) {
case 'MULTI_ORGANIZATION':
return 'true';
default:
return process.env[key];
}
});
const { user, organization } = await createUser(app, {
email: 'admin@tooljet.io',
});

View file

@ -12,22 +12,17 @@ describe('users controller', () => {
beforeEach(async () => {
await clearDB();
jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => {
switch (key) {
case 'DISABLE_SIGNUPS':
return 'false';
case 'MULTI_ORGANIZATION':
return 'false';
default:
return process.env[key];
}
});
});
beforeAll(async () => {
({ app, mockConfig } = await createNestAppInstanceWithEnvMock());
});
afterEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
describe('PATCH /api/users/change_password', () => {
it('should allow users to update their password', async () => {
const userData = await createUser(app, { email: 'admin@tooljet.io' });
@ -87,17 +82,7 @@ describe('users controller', () => {
});
describe('POST /api/users/set_password_from_token', () => {
it('should allow users to setup account after sign up using multi organization', async () => {
jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => {
switch (key) {
case 'DISABLE_SIGNUPS':
return 'false';
case 'MULTI_ORGANIZATION':
return 'true';
default:
return process.env[key];
}
});
it('should allow users to setup account after sign up using Multi-Workspace', async () => {
const invitationToken = uuidv4();
const userData = await createUser(app, {
email: 'signup@tooljet.io',
@ -125,17 +110,7 @@ describe('users controller', () => {
expect(organizationUser.status).toEqual('active');
});
it('should return error if required params are not present - multi organization', async () => {
jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => {
switch (key) {
case 'DISABLE_SIGNUPS':
return 'false';
case 'MULTI_ORGANIZATION':
return 'true';
default:
return process.env[key];
}
});
it('should return error if required params are not present - Multi-Workspace', async () => {
const invitationToken = uuidv4();
await createUser(app, {
email: 'signup@tooljet.io',
@ -155,6 +130,14 @@ describe('users controller', () => {
});
it('should not allow users to setup account for single organization', async () => {
jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => {
switch (key) {
case 'DISABLE_MULTI_WORKSPACE':
return 'true';
default:
return process.env[key];
}
});
const invitationToken = uuidv4();
await createUser(app, {
email: 'signup@tooljet.io',
@ -174,13 +157,11 @@ describe('users controller', () => {
expect(response.statusCode).toBe(403);
});
it('should not allow users to setup account for multi organization and sign up disabled', async () => {
it('should not allow users to setup account for Multi-Workspace and sign up disabled', async () => {
jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => {
switch (key) {
case 'DISABLE_SIGNUPS':
return 'true';
case 'MULTI_ORGANIZATION':
return 'true';
default:
return process.env[key];
}
@ -216,15 +197,6 @@ describe('users controller', () => {
organization: org,
});
jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => {
switch (key) {
case 'MULTI_ORGANIZATION':
return 'true';
default:
return process.env[key];
}
});
const signUpResponse = await request(app.getHttpServer())
.post('/api/signup')
.send({ email: 'invited@tooljet.io' });
@ -269,15 +241,6 @@ describe('users controller', () => {
organization: org,
});
jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => {
switch (key) {
case 'MULTI_ORGANIZATION':
return 'true';
default:
return process.env[key];
}
});
const signUpResponse = await request(app.getHttpServer())
.post('/api/signup')
.send({ email: 'invited@tooljet.io' });
@ -321,15 +284,7 @@ describe('users controller', () => {
});
describe('POST /api/users/accept-invite', () => {
it('should allow users to accept invitation when multi organization is enabled', async () => {
jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => {
switch (key) {
case 'MULTI_ORGANIZATION':
return 'true';
default:
return process.env[key];
}
});
it('should allow users to accept invitation when Multi-Workspace is enabled', async () => {
const userData = await createUser(app, {
email: 'organizationUser@tooljet.io',
status: 'invited',
@ -347,11 +302,11 @@ describe('users controller', () => {
expect(organizationUser.status).toEqual('active');
});
it('should allow users to accept invitation when multi organization is disabled', async () => {
it('should allow users to accept invitation when Multi-Workspace is disabled', async () => {
jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => {
switch (key) {
case 'MULTI_ORGANIZATION':
return 'false';
case 'DISABLE_MULTI_WORKSPACE':
return 'true';
default:
return process.env[key];
}