diff --git a/.env.example b/.env.example index d31e89dd21..bd33d9cdb0 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/app.json b/app.json index 17d25a93c9..703237fcfb 100644 --- a/app.json +++ b/app.json @@ -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" } }, diff --git a/docs/docs/data-sources/minio.md b/docs/docs/data-sources/minio.md index 44c8a4e40c..052b332eb6 100644 --- a/docs/docs/data-sources/minio.md +++ b/docs/docs/data-sources/minio.md @@ -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. diff --git a/docs/docs/how-to/bulk-update-multiple-rows-in-table.md b/docs/docs/how-to/bulk-update-multiple-rows-in-table.md index d4a570432d..e610c3149f 100644 --- a/docs/docs/how-to/bulk-update-multiple-rows-in-table.md +++ b/docs/docs/how-to/bulk-update-multiple-rows-in-table.md @@ -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 diff --git a/docs/docs/how-to/upload-files-aws.md b/docs/docs/how-to/upload-files-aws.md new file mode 100644 index 0000000000..c6101f4ece --- /dev/null +++ b/docs/docs/how-to/upload-files-aws.md @@ -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. + +
+ +![ToolJet - How To - Upload files on AWS S3 bucket](/img/how-to/upload-files-aws/ui.png) + +
+ +## 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**. + +
+ +![ToolJet - How To - Upload files on AWS S3 bucket](/img/how-to/upload-files-aws/getBuckets.png) + +
+ +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. + +
+ +![ToolJet - How To - Upload files on AWS S3 bucket](/img/how-to/upload-files-aws/dropdown.png) + +
+ +### 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. + +
+ +![ToolJet - How To - Upload files on AWS S3 bucket](/img/how-to/upload-files-aws/listObjects.png) + +
+ +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. + +
+ +![ToolJet - How To - Upload files on AWS S3 bucket](/img/how-to/upload-files-aws/table.png) + +
+ +### 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}}`. + +
+ +![ToolJet - How To - Upload files on AWS S3 bucket](/img/how-to/upload-files-aws/download.png) + +
+ +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` + +
+ +![ToolJet - How To - Upload files on AWS S3 bucket](/img/how-to/upload-files-aws/uploadToS3.png) + +
+ +#### 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: + +
+ +![ToolJet - How To - Upload files using GCS](/img/how-to/upload-files-gcs/result-filepicker.png) + +
+ +- 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. +::: + +
+ +![ToolJet - How To - Upload files using GCS](/img/how-to/upload-files-gcs/config-filepicker.png) + +
+ +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. \ No newline at end of file diff --git a/docs/docs/intro.md b/docs/docs/intro.md index 901b7cdbda..6e3fd631ea 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -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** diff --git a/docs/docs/password-login/password-login.md b/docs/docs/password-login/password-login.md index 620ed24ae7..9297925435 100644 --- a/docs/docs/password-login/password-login.md +++ b/docs/docs/password-login/password-login.md @@ -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
diff --git a/docs/docs/setup/env-vars.md b/docs/docs/setup/env-vars.md index 75fc391d5f..5f8e58df4d 100644 --- a/docs/docs/setup/env-vars.md +++ b/docs/docs/setup/env-vars.md @@ -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 | | ------------------ | ----------------------------------------- | diff --git a/docs/docs/sso/general-settings.md b/docs/docs/sso/general-settings.md index 87c8bc8d87..82436bbad2 100644 --- a/docs/docs/sso/general-settings.md +++ b/docs/docs/sso/general-settings.md @@ -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
diff --git a/docs/docs/sso/github.md b/docs/docs/sso/github.md index 1d5aa22e2f..a7faf8bb4c 100644 --- a/docs/docs/sso/github.md +++ b/docs/docs/sso/github.md @@ -5,7 +5,7 @@ title: GitHub # GitHub Single Sign-on -Select `Manage SSO` from organization options +Select `Manage SSO` from workspace options
@@ -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. diff --git a/docs/docs/sso/google.md b/docs/docs/sso/google.md index b7a1988265..43c25cbe3d 100644 --- a/docs/docs/sso/google.md +++ b/docs/docs/sso/google.md @@ -5,7 +5,7 @@ title: Google # Google Single Sign-on -Select `Manage SSO` from organization options +Select `Manage SSO` from workspace options
@@ -45,7 +45,7 @@ Go to [Google cloud console](https://console.cloud.google.com/) and create a pro
-- 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'.
@@ -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. diff --git a/docs/docs/tutorial/adding-a-datasource.md b/docs/docs/tutorial/adding-a-datasource.md index 2d46d1e914..75a1d2be88 100644 --- a/docs/docs/tutorial/adding-a-datasource.md +++ b/docs/docs/tutorial/adding-a-datasource.md @@ -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. diff --git a/docs/docs/tutorial/manage-users-groups.md b/docs/docs/tutorial/manage-users-groups.md index 9c3bf778ee..f1d62f21e1 100644 --- a/docs/docs/tutorial/manage-users-groups.md +++ b/docs/docs/tutorial/manage-users-groups.md @@ -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**.
@@ -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
-- 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**.
@@ -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**.
@@ -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. |
@@ -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. |
diff --git a/docs/docs/widgets/custom-component.md b/docs/docs/widgets/custom-component.md new file mode 100644 index 0000000000..fa9affbee6 --- /dev/null +++ b/docs/docs/widgets/custom-component.md @@ -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. + +
+ +![ToolJet - Widget Reference - Timeline](/img/widgets/custom-component/custom-component.png) + +
+ +## 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}) => ( + +

{data.title}

+ + +
+); + +const ConnectedComponent = Tooljet.connectComponent(MyCustomComponent); + +ReactDOM.render(, 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**. +::: diff --git a/docs/docs/widgets/pdf.md b/docs/docs/widgets/pdf.md new file mode 100644 index 0000000000..b3b9e51d70 --- /dev/null +++ b/docs/docs/widgets/pdf.md @@ -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**. +::: diff --git a/docs/sidebars.js b/docs/sidebars.js index 1f8ef310cf..62ed3d1c00 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -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', ], }, diff --git a/docs/static/img/how-to/upload-files-aws/download.png b/docs/static/img/how-to/upload-files-aws/download.png new file mode 100644 index 0000000000..0f0dae1c57 Binary files /dev/null and b/docs/static/img/how-to/upload-files-aws/download.png differ diff --git a/docs/static/img/how-to/upload-files-aws/dropdown.png b/docs/static/img/how-to/upload-files-aws/dropdown.png new file mode 100644 index 0000000000..936ca6da56 Binary files /dev/null and b/docs/static/img/how-to/upload-files-aws/dropdown.png differ diff --git a/docs/static/img/how-to/upload-files-aws/getBuckets.png b/docs/static/img/how-to/upload-files-aws/getBuckets.png new file mode 100644 index 0000000000..3e3a615929 Binary files /dev/null and b/docs/static/img/how-to/upload-files-aws/getBuckets.png differ diff --git a/docs/static/img/how-to/upload-files-aws/listObjects.png b/docs/static/img/how-to/upload-files-aws/listObjects.png new file mode 100644 index 0000000000..c3af4c562c Binary files /dev/null and b/docs/static/img/how-to/upload-files-aws/listObjects.png differ diff --git a/docs/static/img/how-to/upload-files-aws/table.png b/docs/static/img/how-to/upload-files-aws/table.png new file mode 100644 index 0000000000..0a5a4bb61d Binary files /dev/null and b/docs/static/img/how-to/upload-files-aws/table.png differ diff --git a/docs/static/img/how-to/upload-files-aws/ui.png b/docs/static/img/how-to/upload-files-aws/ui.png new file mode 100644 index 0000000000..0224fe6638 Binary files /dev/null and b/docs/static/img/how-to/upload-files-aws/ui.png differ diff --git a/docs/static/img/how-to/upload-files-aws/uploadToS3.png b/docs/static/img/how-to/upload-files-aws/uploadToS3.png new file mode 100644 index 0000000000..d66721df40 Binary files /dev/null and b/docs/static/img/how-to/upload-files-aws/uploadToS3.png differ diff --git a/docs/static/img/widgets/custom-component/custom-component.png b/docs/static/img/widgets/custom-component/custom-component.png new file mode 100644 index 0000000000..e2ff0f18ec Binary files /dev/null and b/docs/static/img/widgets/custom-component/custom-component.png differ diff --git a/frontend/assets/images/icons/widgets/customcomponent.svg b/frontend/assets/images/icons/widgets/customcomponent.svg new file mode 100644 index 0000000000..ebf2b793af --- /dev/null +++ b/frontend/assets/images/icons/widgets/customcomponent.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/assets/images/icons/widgets/pdf.svg b/frontend/assets/images/icons/widgets/pdf.svg new file mode 100644 index 0000000000..e50c0d128a --- /dev/null +++ b/frontend/assets/images/icons/widgets/pdf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 093a712139..4d35683e20 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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==", diff --git a/frontend/package.json b/frontend/package.json index dda44cd216..60f3bcc3d2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/ConfirmationPage/ConfirmationPage.jsx b/frontend/src/ConfirmationPage/ConfirmationPage.jsx index f28bb089ef..7ba00652cd 100644 --- a/frontend/src/ConfirmationPage/ConfirmationPage.jsx +++ b/frontend/src/ConfirmationPage/ConfirmationPage.jsx @@ -126,7 +126,7 @@ class ConfirmationPage extends React.Component {
- +
{ @@ -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'); diff --git a/frontend/src/Editor/AppVersionsManager.jsx b/frontend/src/Editor/AppVersionsManager.jsx index 514aa5581f..b338b0f6c0 100644 --- a/frontend/src/Editor/AppVersionsManager.jsx +++ b/frontend/src/Editor/AppVersionsManager.jsx @@ -132,7 +132,7 @@ export const AppVersionsManager = function AppVersionsManager({ return (
- App Version + Version { @@ -149,49 +149,58 @@ export const AppVersionsManager = function AppVersionsManager({
{appVersions.map((version) => releasedVersionId == version.id ? ( -
selectVersion(version)}> -
{version.name}
-
- - Currently Released + <> +
selectVersion(version)} + > +
{version.name}
+
+ + Currently Released +
-
+
+ ) : ( -
selectVersion(version)} - onMouseEnter={() => setMouseHoveredOnVersion(version.id)} - onMouseLeave={() => setMouseHoveredOnVersion(null)} - > -
{version.name}
+ <> +
selectVersion(version)} + onMouseEnter={() => setMouseHoveredOnVersion(version.id)} + onMouseLeave={() => setMouseHoveredOnVersion(null)} + > +
{version.name}
-
- +
+ +
-
+
+ ) )} -
setShowModal(true)}> Create Version diff --git a/frontend/src/Editor/Box.jsx b/frontend/src/Editor/Box.jsx index b2b7a2ab72..977b1a9810 100644 --- a/frontend/src/Editor/Box.jsx +++ b/frontend/src/Editor/Box.jsx @@ -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} > ) : (
diff --git a/frontend/src/Editor/CodeBuilder/CodeHinter.jsx b/frontend/src/Editor/CodeBuilder/CodeHinter.jsx index 6c0967fcfb..c792037ce2 100644 --- a/frontend/src/Editor/CodeBuilder/CodeHinter.jsx +++ b/frontend/src/Editor/CodeBuilder/CodeHinter.jsx @@ -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]]; diff --git a/frontend/src/Editor/CodeBuilder/utils.js b/frontend/src/Editor/CodeBuilder/utils.js index f439f6a51f..54b602439b 100644 --- a/frontend/src/Editor/CodeBuilder/utils.js +++ b/frontend/src/Editor/CodeBuilder/utils.js @@ -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) { diff --git a/frontend/src/Editor/Components/CustomComponent/CustomComponent.jsx b/frontend/src/Editor/Components/CustomComponent/CustomComponent.jsx new file mode 100644 index 0000000000..9d8ff33c6e --- /dev/null +++ b/frontend/src/Editor/Components/CustomComponent/CustomComponent.jsx @@ -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 ( +
+ +
+ ); +}; diff --git a/frontend/src/Editor/Components/CustomComponent/iframe.html b/frontend/src/Editor/Components/CustomComponent/iframe.html new file mode 100644 index 0000000000..19aa58b78f --- /dev/null +++ b/frontend/src/Editor/Components/CustomComponent/iframe.html @@ -0,0 +1,86 @@ + + + + + + + + + + + diff --git a/frontend/src/Editor/Components/PDF.jsx b/frontend/src/Editor/Components/PDF.jsx new file mode 100644 index 0000000000..442c4e7b75 --- /dev/null +++ b/frontend/src/Editor/Components/PDF.jsx @@ -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 ( +
+
+
+ + {Array.from(new Array(numPages), (el, index) => ( + (pageRef.current[index] = el)} + /> + ))} + {pageControls && ( + <> +
+ + + {pageNumber} of {numPages} + + +
+ + )} +
+
+
+
+ ); +}); diff --git a/frontend/src/Editor/Components/Statistics.jsx b/frontend/src/Editor/Components/Statistics.jsx index b3686db46d..21c8c97237 100644 --- a/frontend/src/Editor/Components/Statistics.jsx +++ b/frontend/src/Editor/Components/Statistics.jsx @@ -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 (
-

- {primaryValueLabel} -

-

{primaryValue}

- {hideSecondary ? ( - '' + {loadingState === true ? ( +
+
+
+
+
) : ( -
-
- {secondarySignDisplay !== 'negative' ? ( - - ) : ( - - )} -

{secondaryValue}

-
+ <>

- {secondaryValueLabel} + {primaryValueLabel}

-
+

{primaryValue}

+ {hideSecondary ? ( + '' + ) : ( +
+
+ {secondarySignDisplay !== 'negative' ? ( + + ) : ( + + )} +

{secondaryValue}

+
+

+ {secondaryValueLabel} +

+
+ )} + )}
); diff --git a/frontend/src/Editor/Components/Text.jsx b/frontend/src/Editor/Components/Text.jsx index 63cd592c21..48089dfd8e 100644 --- a/frontend/src/Editor/Components/Text.jsx +++ b/frontend/src/Editor/Components/Text.jsx @@ -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 (
{!loadingState && ( -
+
)} {loadingState === true && (
diff --git a/frontend/src/Editor/Components/Timeline.jsx b/frontend/src/Editor/Components/Timeline.jsx index dd14ceebd6..e1f6ad4f91 100644 --- a/frontend/src/Editor/Components/Timeline.jsx +++ b/frontend/src/Editor/Components/Timeline.jsx @@ -8,7 +8,10 @@ export const Timeline = function Timeline({ height, darkMode, properties, styles const darkModeStyle = darkMode && 'text-white-50'; return ( -
+
    {(isArray(data) ? data : []).map((item, index) => ( diff --git a/frontend/src/Editor/Components/components.js b/frontend/src/Editor/Components/components.js index 2a30fb0560..af5fe1342a 100644 --- a/frontend/src/Editor/Components/components.js +++ b/frontend/src/Editor/Components/components.js @@ -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}) => ( + +

    {data.title}

    + +
    +); +const ConnectedComponent = Tooljet.connectComponent(MyCustomComponent); +ReactDOM.render(, 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}}' }, + }, + }, + }, ]; diff --git a/frontend/src/Editor/ConfigHandle.jsx b/frontend/src/Editor/ConfigHandle.jsx index b857ff0fd8..e59b6017b9 100644 --- a/frontend/src/Editor/ConfigHandle.jsx +++ b/frontend/src/Editor/ConfigHandle.jsx @@ -9,6 +9,7 @@ export const ConfigHandle = function ConfigHandle({ position, widgetTop, widgetHeight, + isMultipleComponentsSelected = false, }) { return (
    { e.preventDefault(); e.stopPropagation(); - setSelectedComponent(id, component); + setSelectedComponent(id, component, e.shiftKey); }} role="button" > @@ -37,17 +38,19 @@ export const ConfigHandle = function ConfigHandle({ /> {component.name}
    -
    - removeComponent({ id })} - /> -
    + {!isMultipleComponentsSelected && ( +
    + removeComponent({ id })} + /> +
    + )}
); diff --git a/frontend/src/Editor/Container.jsx b/frontend/src/Editor/Container.jsx index 0bac6ff30e..6250787fab 100644 --- a/frontend/src/Editor/Container.jsx +++ b/frontend/src/Editor/Container.jsx @@ -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, }} /> ); diff --git a/frontend/src/Editor/DraggableBox.jsx b/frontend/src/Editor/DraggableBox.jsx index 1d53b0f88c..6a634fe6ef 100644 --- a/frontend/src/Editor/DraggableBox.jsx +++ b/frontend/src/Editor/DraggableBox.jsx @@ -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} /> )} @@ -278,6 +283,7 @@ export const DraggableBox = function DraggableBox({ parentId={parentId} allComponents={allComponents} extraProps={extraProps} + dataQueries={dataQueries} />
diff --git a/frontend/src/Editor/Editor.jsx b/frontend/src/Editor/Editor.jsx index c2b89cf93b..a01121e183 100644 --- a/frontend/src/Editor/Editor.jsx +++ b/frontend/src/Editor/Editor.jsx @@ -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 {
)} + + {this.state.isSaving ? : 'All changes are saved'} + {config.ENABLE_MULTIPLAYER_EDITING && ( 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 { <>
+ + + {currentSidebarTab === 1 && (
- {selectedComponent && + {selectedComponents.length === 1 && !isEmpty(appDefinition.components) && - !isEmpty(appDefinition.components[selectedComponent.id]) ? ( + !isEmpty(appDefinition.components[selectedComponents[0].id]) ? ( ) : ( -
Please select a component to inspect
+
Please select a component to inspect
)}
)} diff --git a/frontend/src/Editor/EditorKeyHooks.jsx b/frontend/src/Editor/EditorKeyHooks.jsx new file mode 100644 index 0000000000..09ce9f3742 --- /dev/null +++ b/frontend/src/Editor/EditorKeyHooks.jsx @@ -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 <>; +}; diff --git a/frontend/src/Editor/Inspector/Components/CustomComponent.jsx b/frontend/src/Editor/Inspector/Components/CustomComponent.jsx new file mode 100644 index 0000000000..260d3b00d7 --- /dev/null +++ b/frontend/src/Editor/Inspector/Components/CustomComponent.jsx @@ -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: ( + paramUpdated({ name: 'data' }, 'value', value, 'properties')} + componentName={`widget/${component.component.name}/data`} + /> + ), + }); + + items.push({ + title: 'Code', + children: ( + 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 ; +}; diff --git a/frontend/src/Editor/Inspector/Inspector.jsx b/frontend/src/Editor/Inspector/Inspector.jsx index d5edfed6a8..dd9c918bbc 100644 --- a/frontend/src/Editor/Inspector/Inspector.jsx +++ b/frontend/src/Editor/Inspector/Inspector.jsx @@ -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 ( + + ); + default: { return ( { 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 = ({ { globalSettingsChanged('canvasMaxWidth', e.target.value); }} @@ -85,6 +85,24 @@ export const LeftSidebarGlobalSettings = ({
+
+ Max height of canvas +
+
+ { + const height = e.target.value; + if (!Number.isNaN(height) && height <= 2400) globalSettingsChanged('canvasMaxHeight', height); + }} + value={canvasMaxHeight} + /> + px +
+
+
Background color of canvas
diff --git a/frontend/src/Editor/ManageAppUsers.jsx b/frontend/src/Editor/ManageAppUsers.jsx index afe9055894..0296b97bc1 100644 --- a/frontend/src/Editor/ManageAppUsers.jsx +++ b/frontend/src/Editor/ManageAppUsers.jsx @@ -306,7 +306,7 @@ class ManageAppUsers extends React.Component { - Manage Organization Users + Manage Users diff --git a/frontend/src/Editor/RealtimeAvatars.jsx b/frontend/src/Editor/RealtimeAvatars.jsx index b23da766af..7f9687a01b 100644 --- a/frontend/src/Editor/RealtimeAvatars.jsx +++ b/frontend/src/Editor/RealtimeAvatars.jsx @@ -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'; diff --git a/frontend/src/Editor/SubContainer.jsx b/frontend/src/Editor/SubContainer.jsx index d98ea21f5f..78e4724ffc 100644 --- a/frontend/src/Editor/SubContainer.jsx +++ b/frontend/src/Editor/SubContainer.jsx @@ -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, diff --git a/frontend/src/Editor/Viewer.jsx b/frontend/src/Editor/Viewer.jsx index 99ba2323fd..404b06c605 100644 --- a/frontend/src/Editor/Viewer.jsx +++ b/frontend/src/Editor/Viewer.jsx @@ -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} /> )} diff --git a/frontend/src/LoginPage/LoginPage.jsx b/frontend/src/LoginPage/LoginPage.jsx index f422826887..89d581085d 100644 --- a/frontend/src/LoginPage/LoginPage.jsx +++ b/frontend/src/LoginPage/LoginPage.jsx @@ -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() ) : (
- {!configs &&
No login methods enabled for this organization
} + {!configs &&
No login methods enabled for this workspace
} {configs?.form?.enabled && (

diff --git a/frontend/src/ManageSSO/Google.jsx b/frontend/src/ManageSSO/Google.jsx index 5c49075383..025b3b3725 100644 --- a/frontend/src/ManageSSO/Google.jsx +++ b/frontend/src/ManageSSO/Google.jsx @@ -77,7 +77,7 @@ export function Google({ settings, updateData }) { setClientId(e.target.value)} /> diff --git a/frontend/src/_components/Organization.jsx b/frontend/src/_components/Organization.jsx index 9b65d3f4a9..8f2d1aa138 100644 --- a/frontend/src/_components/Organization.jsx +++ b/frontend/src/_components/Organization.jsx @@ -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() {

{!isSingleOrganization && (
-
Add Organizations
+
Add workspace
)} {admin && ( @@ -281,8 +284,12 @@ export const Organization = function Organization() { return (
-
setIsListOrganizations(false)}> - +
+ setIsListOrganizations(false)} + >
{organization}
{(!isSingleOrganization || admin) && ( @@ -291,14 +298,14 @@ export const Organization = function Organization() {
)}
- setShowCreateOrg(false)} title="Create organization"> + setShowCreateOrg(false)} title="Create workspace">
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
- setShowEditOrg(false)} title="Edit organization"> + setShowEditOrg(false)} title="Edit workspace">
setNewOrgName(e.target.value)} className="form-control" - placeholder="organization name" + placeholder="workspace name" disabled={isCreating} value={newOrgName} maxLength={25} diff --git a/frontend/src/_helpers/appUtils.js b/frontend/src/_helpers/appUtils.js index 372fce26fa..a6dca62333 100644 --- a/frontend/src/_helpers/appUtils.js +++ b/frontend/src/_helpers/appUtils.js @@ -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( diff --git a/frontend/src/_hooks/useKeyHooks.js b/frontend/src/_hooks/useKeyHooks.js new file mode 100644 index 0000000000..4344adf59b --- /dev/null +++ b/frontend/src/_hooks/useKeyHooks.js @@ -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; diff --git a/frontend/src/_styles/theme.scss b/frontend/src/_styles/theme.scss index c0e2b1e233..238bc438f4 100644 --- a/frontend/src/_styles/theme.scss +++ b/frontend/src/_styles/theme.scss @@ -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; -} \ No newline at end of file +} + +.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; + } + } +} diff --git a/frontend/src/_ui/Avatar/index.jsx b/frontend/src/_ui/Avatar/index.jsx index a2fdbe0205..a66b22c608 100644 --- a/frontend/src/_ui/Avatar/index.jsx +++ b/frontend/src/_ui/Avatar/index.jsx @@ -4,7 +4,7 @@ const Avatar = ({ text, title = '', borderColor = '' }) => { return ( {text} diff --git a/frontend/src/_ui/JSONTreeViewer/JSONNode.jsx b/frontend/src/_ui/JSONTreeViewer/JSONNode.jsx index 97f348e6cd..99223424ab 100644 --- a/frontend/src/_ui/JSONTreeViewer/JSONNode.jsx +++ b/frontend/src/_ui/JSONTreeViewer/JSONNode.jsx @@ -327,10 +327,10 @@ export const JSONNode = ({ data, ...restProps }) => {
updateHoveredNode(currentNode, currentNodePath)} + onMouseLeave={() => updateHoveredNode(null)} >
updateHoveredNode(currentNode, currentNodePath)} - onMouseLeave={() => updateHoveredNode(null)} className={cx('d-flex', { 'group-object-container': shouldDisplayIntendedBlock, 'mx-2': typeofCurrentNode !== 'Object' && typeofCurrentNode !== 'Array', diff --git a/frontend/src/_ui/JSONTreeViewer/JSONNodeValue.jsx b/frontend/src/_ui/JSONTreeViewer/JSONNodeValue.jsx index 1596a50d83..f58ba2b2db 100644 --- a/frontend/src/_ui/JSONTreeViewer/JSONNodeValue.jsx +++ b/frontend/src/_ui/JSONTreeViewer/JSONNodeValue.jsx @@ -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 ( ( -
+const Spinner = ({ size = 'large', ...props }) => ( +
); export default Spinner; diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index b5b9c8f36f..af147056c5 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -82,6 +82,10 @@ module.exports = { }, }, }, + { + test: /\.html$/, + loader: 'html-loader', + }, ], }, plugins: [ diff --git a/netlify.toml b/netlify.toml index 1a7526debd..af5a71133d 100644 --- a/netlify.toml +++ b/netlify.toml @@ -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 = "/*" diff --git a/plugins/package-lock.json b/plugins/package-lock.json index 1571570ce3..c55a7d310a 100644 --- a/plugins/package-lock.json +++ b/plugins/package-lock.json @@ -16818,6 +16818,7 @@ } }, "packages/notion": { + "name": "@tooljet-plugins/notion", "version": "1.0.0", "dependencies": { "@notionhq/client": "^1.0.4", diff --git a/plugins/packages/googlesheets/lib/index.ts b/plugins/packages/googlesheets/lib/index.ts index 195739acd1..1076c7ab45 100644 --- a/plugins/packages/googlesheets/lib/index.ts +++ b/plugins/packages/googlesheets/lib/index.ts @@ -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, {}); diff --git a/server/ee/services/oauth/oauth.service.ts b/server/ee/services/oauth/oauth.service.ts index 969da0625b..e39d262629 100644 --- a/server/ee/services/oauth/oauth.service.ts +++ b/server/ee/services/oauth/oauth.service.ts @@ -63,12 +63,12 @@ export class OauthService { async #findAndActivateUser(email: string, organizationId: string): Promise { 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; diff --git a/server/migrations/1650485473528-PopulateSSOConfigs.ts b/server/migrations/1650485473528-PopulateSSOConfigs.ts index d22725d66d..fa35d95b6c 100644 --- a/server/migrations/1650485473528-PopulateSSOConfigs.ts +++ b/server/migrations/1650485473528-PopulateSSOConfigs.ts @@ -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; diff --git a/server/migrations/1651820577708-PopulateTextSize.ts b/server/migrations/1651820577708-PopulateTextSize.ts new file mode 100644 index 0000000000..224897b72c --- /dev/null +++ b/server/migrations/1651820577708-PopulateTextSize.ts @@ -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 { + 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 {} +} diff --git a/server/src/controllers/organizations.controller.ts b/server/src/controllers/organizations.controller.ts index ef560507ae..2996810297 100644 --- a/server/src/controllers/organizations.controller.ts +++ b/server/src/controllers/organizations.controller.ts @@ -49,7 +49,7 @@ export class OrganizationsController { @Get(['/:organizationId/public-configs', '/public-configs']) async getOrganizationDetails(@Param('organizationId') organizationId: string) { - if (!organizationId && this.configService.get('MULTI_ORGANIZATION') !== 'true') { + if (!organizationId && this.configService.get('DISABLE_MULTI_WORKSPACE') === 'true') { // Request from single organization login page - find one from organization and setting organizationId = (await this.organizationsService.getSingleOrganization()).id; } diff --git a/server/src/main.ts b/server/src/main.ts index e902fa503c..60878170ec 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -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', diff --git a/server/src/modules/auth/multi-organization.guard.ts b/server/src/modules/auth/multi-organization.guard.ts index ef84dbefd4..f2797b7e25 100644 --- a/server/src/modules/auth/multi-organization.guard.ts +++ b/server/src/modules/auth/multi-organization.guard.ts @@ -7,6 +7,6 @@ export class MultiOrganizationGuard implements CanActivate { constructor(private configService: ConfigService) {} canActivate(context: ExecutionContext): boolean | Promise | Observable { - return this.configService.get('MULTI_ORGANIZATION') === 'true'; + return this.configService.get('DISABLE_MULTI_WORKSPACE') !== 'true'; } } diff --git a/server/src/services/app_config.service.ts b/server/src/services/app_config.service.ts index 066c7b43e2..52fa315d4f 100644 --- a/server/src/services/app_config.service.ts +++ b/server/src/services/app_config.service.ts @@ -23,7 +23,7 @@ export class AppConfigService { 'SENTRY_DNS', 'SENTRY_DEBUG', 'DISABLE_SIGNUPS', - 'MULTI_ORGANIZATION', + 'DISABLE_MULTI_WORKSPACE', ]; } diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 61653c45b3..92a7a6ed22 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -51,7 +51,7 @@ export class AuthService { if (!organizationId) { // Global login // Determine the organization to be loaded - if (this.configService.get('MULTI_ORGANIZATION') !== 'true') { + if (this.configService.get('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('MULTI_ORGANIZATION') !== 'true') { + if (this.configService.get('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('MULTI_ORGANIZATION') !== 'true') { + if (this.configService.get('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); diff --git a/server/src/services/email.service.ts b/server/src/services/email.service.ts index 5a0a6dfd05..e6f90229d0 100644 --- a/server/src/services/email.service.ts +++ b/server/src/services/email.service.ts @@ -100,7 +100,7 @@ export class EmailService {

Hi ${name || ''},


- ${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.
${inviteUrl} diff --git a/server/src/services/seeds.service.ts b/server/src/services/seeds.service.ts index fe8599e721..e4938c596f 100644 --- a/server/src/services/seeds.service.ts +++ b/server/src/services/seeds.service.ts @@ -30,7 +30,7 @@ export class SeedsService { sso: 'form', }, ], - name: 'My organization', + name: 'My workspace', }); await manager.save(organization); diff --git a/server/test/controllers/app.e2e-spec.ts b/server/test/controllers/app.e2e-spec.ts index 74f9fa4f75..a712fcb4eb 100644 --- a/server/test/controllers/app.e2e-spec.ts +++ b/server/test/controllers/app.e2e-spec.ts @@ -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( diff --git a/server/test/controllers/oauth.e2e-spec.ts b/server/test/controllers/oauth.e2e-spec.ts index 1d043ee4d0..5ea15f6438 100644 --- a/server/test/controllers/oauth.e2e-spec.ts +++ b/server/test/controllers/oauth.e2e-spec.ts @@ -19,13 +19,6 @@ describe('oauth controller', () => { beforeEach(async () => { await clearDB(); - jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => { - if (key === 'MULTI_ORGANIZATION') { - return 'false'; - } else { - return process.env[key]; - } - }); }); beforeAll(async () => { @@ -57,917 +50,1844 @@ describe('oauth controller', () => { current_organization = organization; }); - describe('sign in via Google OAuth', () => { - let sso_configs; - const token = 'some-Token'; - beforeEach(() => { - sso_configs = current_organization.ssoConfigs.find((conf) => conf.sso === 'google'); - }); - it('should return 401 if google sign in is disabled', async () => { - await ssoConfigsRepository.update(sso_configs.id, { enabled: false }); - await request(app.getHttpServer()) - .post('/api/oauth/sign-in/' + sso_configs.id) - .send({ token }) - .expect(401); - }); - - it('should return 401 when the user does not exist and sign up is disabled', async () => { - await orgRepository.update(current_organization.id, { enableSignUp: false }); - const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); - googleVerifyMock.mockImplementation(() => ({ - getPayload: () => ({ - sub: 'someSSOId', - email: 'ssoUser@tooljet.io', - name: 'SSO User', - hd: 'tooljet.io', - }), - })); - await request(app.getHttpServer()) - .post('/api/oauth/sign-in/' + sso_configs.id) - .send({ token }) - .expect(401); - }); - - it('should return 401 when the user does not exist domain mismatch', async () => { - await orgRepository.update(current_organization.id, { domain: 'tooljet.io,tooljet.com' }); - const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); - googleVerifyMock.mockImplementation(() => ({ - getPayload: () => ({ - sub: 'someSSOId', - email: 'ssoUser@tooljett.io', - name: 'SSO User', - hd: 'tooljet.io', - }), - })); - await request(app.getHttpServer()) - .post('/api/oauth/sign-in/' + sso_configs.id) - .send({ token }) - .expect(401); - }); - - it('should return login info when the user does not exist and domain matches and sign up is enabled', async () => { - await orgRepository.update(current_organization.id, { domain: 'tooljet.io,tooljet.com' }); - const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); - googleVerifyMock.mockImplementation(() => ({ - getPayload: () => ({ - sub: 'someSSOId', - email: 'ssoUser@tooljet.io', - name: 'SSO User', - hd: 'tooljet.io', - }), - })); - - const response = await request(app.getHttpServer()) - .post('/api/oauth/sign-in/' + sso_configs.id) - .send({ token }); - - expect(googleVerifyMock).toHaveBeenCalledWith({ - idToken: token, - audience: sso_configs.configs.clientId, + describe('Multi-Workspace', () => { + describe('sign in via Google OAuth', () => { + let sso_configs; + const token = 'some-Token'; + beforeEach(() => { + sso_configs = current_organization.ssoConfigs.find((conf) => conf.sso === 'google'); + }); + it('should return 401 if google sign in is disabled', async () => { + await ssoConfigsRepository.update(sso_configs.id, { enabled: false }); + await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }) + .expect(401); }); - expect(response.statusCode).toBe(201); - expect(Object.keys(response.body).sort()).toEqual( - [ - 'id', - 'email', - 'first_name', - 'last_name', - 'auth_token', - 'admin', - 'organization_id', - 'organization', - 'group_permissions', - 'app_group_permissions', - ].sort() - ); - - const { - email, - first_name, - last_name, - admin, - group_permissions, - app_group_permissions, - organization_id, - organization, - } = response.body; - - expect(email).toEqual('ssoUser@tooljet.io'); - expect(first_name).toEqual('SSO'); - expect(last_name).toEqual('User'); - expect(admin).toBeFalsy(); - expect(organization_id).toBe(current_organization.id); - expect(organization).toBe(current_organization.name); - expect(group_permissions).toHaveLength(1); - expect(group_permissions[0].group).toEqual('all_users'); - expect(Object.keys(group_permissions[0]).sort()).toEqual( - [ - 'id', - 'organization_id', - 'group', - 'app_create', - 'app_delete', - 'updated_at', - 'created_at', - 'folder_create', - ].sort() - ); - expect(app_group_permissions).toHaveLength(0); - }); - - it('should return login info when the user does not exist and sign up is enabled', async () => { - const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); - googleVerifyMock.mockImplementation(() => ({ - getPayload: () => ({ - sub: 'someSSOId', - email: 'ssoUser@tooljet.io', - name: 'SSO User', - hd: 'tooljet.io', - }), - })); - - const response = await request(app.getHttpServer()) - .post('/api/oauth/sign-in/' + sso_configs.id) - .send({ token }); - - expect(googleVerifyMock).toHaveBeenCalledWith({ - idToken: token, - audience: sso_configs.configs.clientId, + it('should return 401 when the user does not exist and sign up is disabled', async () => { + await orgRepository.update(current_organization.id, { enableSignUp: false }); + const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); + googleVerifyMock.mockImplementation(() => ({ + getPayload: () => ({ + sub: 'someSSOId', + email: 'ssoUser@tooljet.io', + name: 'SSO User', + hd: 'tooljet.io', + }), + })); + await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }) + .expect(401); }); - expect(response.statusCode).toBe(201); - expect(Object.keys(response.body).sort()).toEqual( - [ - 'id', - 'email', - 'first_name', - 'last_name', - 'auth_token', - 'admin', - 'organization_id', - 'organization', - 'group_permissions', - 'app_group_permissions', - ].sort() - ); - - const { - email, - first_name, - last_name, - admin, - group_permissions, - app_group_permissions, - organization_id, - organization, - } = response.body; - - expect(email).toEqual('ssoUser@tooljet.io'); - expect(first_name).toEqual('SSO'); - expect(last_name).toEqual('User'); - expect(admin).toBeFalsy(); - expect(organization_id).toBe(current_organization.id); - expect(organization).toBe(current_organization.name); - expect(group_permissions).toHaveLength(1); - expect(group_permissions[0].group).toEqual('all_users'); - expect(Object.keys(group_permissions[0]).sort()).toEqual( - [ - 'id', - 'organization_id', - 'group', - 'app_create', - 'app_delete', - 'updated_at', - 'created_at', - 'folder_create', - ].sort() - ); - expect(app_group_permissions).toHaveLength(0); - }); - it('should return login info when the user does not exist and name not available and sign up is enabled', async () => { - const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); - googleVerifyMock.mockImplementation(() => ({ - getPayload: () => ({ - sub: 'someSSOId', - email: 'ssoUser@tooljet.io', - name: '', - hd: 'tooljet.io', - }), - })); - - const response = await request(app.getHttpServer()) - .post('/api/oauth/sign-in/' + sso_configs.id) - .send({ token }); - - expect(googleVerifyMock).toHaveBeenCalledWith({ - idToken: token, - audience: sso_configs.configs.clientId, + it('should return 401 when the user does not exist domain mismatch', async () => { + await orgRepository.update(current_organization.id, { domain: 'tooljet.io,tooljet.com' }); + const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); + googleVerifyMock.mockImplementation(() => ({ + getPayload: () => ({ + sub: 'someSSOId', + email: 'ssoUser@tooljett.io', + name: 'SSO User', + hd: 'tooljet.io', + }), + })); + await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }) + .expect(401); }); - expect(response.statusCode).toBe(201); - expect(Object.keys(response.body).sort()).toEqual( - [ - 'id', - 'email', - 'first_name', - 'last_name', - 'auth_token', - 'admin', - 'organization_id', - 'organization', - 'group_permissions', - 'app_group_permissions', - ].sort() - ); + it('should return login info when the user does not exist and domain matches and sign up is enabled', async () => { + await orgRepository.update(current_organization.id, { domain: 'tooljet.io,tooljet.com' }); + const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); + googleVerifyMock.mockImplementation(() => ({ + getPayload: () => ({ + sub: 'someSSOId', + email: 'ssoUser@tooljet.io', + name: 'SSO User', + hd: 'tooljet.io', + }), + })); - const { email, first_name, admin, group_permissions, app_group_permissions, organization_id, organization } = - response.body; + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); - expect(email).toEqual('ssoUser@tooljet.io'); - expect(first_name).toEqual('ssoUser'); - expect(admin).toBeFalsy(); - expect(organization_id).toBe(current_organization.id); - expect(organization).toBe(current_organization.name); - expect(group_permissions).toHaveLength(1); - expect(group_permissions[0].group).toEqual('all_users'); - expect(Object.keys(group_permissions[0]).sort()).toEqual( - [ - 'id', - 'organization_id', - 'group', - 'app_create', - 'app_delete', - 'updated_at', - 'created_at', - 'folder_create', - ].sort() - ); - expect(app_group_permissions).toHaveLength(0); - }); - it('should return login info when the user exist', async () => { - await createUser(app, { - firstName: 'SSO', - lastName: 'userExist', - email: 'anotherUser1@tooljet.io', - groups: ['all_users'], - organization: current_organization, - status: 'active', + expect(googleVerifyMock).toHaveBeenCalledWith({ + idToken: token, + audience: sso_configs.configs.clientId, + }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { + email, + first_name, + last_name, + admin, + group_permissions, + app_group_permissions, + organization_id, + organization, + } = response.body; + + expect(email).toEqual('ssoUser@tooljet.io'); + expect(first_name).toEqual('SSO'); + expect(last_name).toEqual('User'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); }); - const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); - googleVerifyMock.mockImplementation(() => ({ - getPayload: () => ({ - sub: 'someSSOId', + + it('should return login info when the user does not exist and sign up is enabled', async () => { + const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); + googleVerifyMock.mockImplementation(() => ({ + getPayload: () => ({ + sub: 'someSSOId', + email: 'ssoUser@tooljet.io', + name: 'SSO User', + hd: 'tooljet.io', + }), + })); + + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); + + expect(googleVerifyMock).toHaveBeenCalledWith({ + idToken: token, + audience: sso_configs.configs.clientId, + }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { + email, + first_name, + last_name, + admin, + group_permissions, + app_group_permissions, + organization_id, + organization, + } = response.body; + + expect(email).toEqual('ssoUser@tooljet.io'); + expect(first_name).toEqual('SSO'); + expect(last_name).toEqual('User'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); + it('should return login info when the user does not exist and name not available and sign up is enabled', async () => { + const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); + googleVerifyMock.mockImplementation(() => ({ + getPayload: () => ({ + sub: 'someSSOId', + email: 'ssoUser@tooljet.io', + name: '', + hd: 'tooljet.io', + }), + })); + + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); + + expect(googleVerifyMock).toHaveBeenCalledWith({ + idToken: token, + audience: sso_configs.configs.clientId, + }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { email, first_name, admin, group_permissions, app_group_permissions, organization_id, organization } = + response.body; + + expect(email).toEqual('ssoUser@tooljet.io'); + expect(first_name).toEqual('ssoUser'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); + it('should return login info when the user exist', async () => { + await createUser(app, { + firstName: 'SSO', + lastName: 'userExist', email: 'anotherUser1@tooljet.io', - name: 'SSO User', - hd: 'tooljet.io', - }), - })); + groups: ['all_users'], + organization: current_organization, + status: 'active', + }); + const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); + googleVerifyMock.mockImplementation(() => ({ + getPayload: () => ({ + sub: 'someSSOId', + email: 'anotherUser1@tooljet.io', + name: 'SSO User', + hd: 'tooljet.io', + }), + })); - const response = await request(app.getHttpServer()) - .post('/api/oauth/sign-in/' + sso_configs.id) - .send({ token }); + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); - expect(googleVerifyMock).toHaveBeenCalledWith({ - idToken: token, - audience: sso_configs.configs.clientId, + expect(googleVerifyMock).toHaveBeenCalledWith({ + idToken: token, + audience: sso_configs.configs.clientId, + }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { + email, + first_name, + last_name, + admin, + group_permissions, + app_group_permissions, + organization_id, + organization, + } = response.body; + + expect(email).toEqual('anotherUser1@tooljet.io'); + expect(first_name).toEqual('SSO'); + expect(last_name).toEqual('userExist'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); + }); + describe('sign in via Git OAuth', () => { + let sso_configs; + const token = 'some-Token'; + beforeEach(() => { + sso_configs = current_organization.ssoConfigs.find((conf) => conf.sso === 'git'); + }); + it('should return 401 if git sign in is disabled', async () => { + await ssoConfigsRepository.update(sso_configs.id, { enabled: false }); + await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }) + .expect(401); }); - expect(response.statusCode).toBe(201); - expect(Object.keys(response.body).sort()).toEqual( - [ - 'id', - 'email', - 'first_name', - 'last_name', - 'auth_token', - 'admin', - 'organization_id', - 'organization', - 'group_permissions', - 'app_group_permissions', - ].sort() - ); + it('should return 401 when the user does not exist and sign up is disabled', async () => { + await orgRepository.update(current_organization.id, { enableSignUp: false }); + const gitAuthResponse = jest.fn(); + gitAuthResponse.mockImplementation(() => { + return { + json: () => { + return { + access_token: 'some-access-token', + scope: 'scope', + token_type: 'bearer', + }; + }, + }; + }); + const gitGetUserResponse = jest.fn(); + gitGetUserResponse.mockImplementation(() => { + return { + json: () => { + return { + name: 'SSO UserGit', + email: 'ssoUserGit@tooljet.io', + }; + }, + }; + }); - const { - email, - first_name, - last_name, - admin, - group_permissions, - app_group_permissions, - organization_id, - organization, - } = response.body; + mockedGot.mockImplementationOnce(gitAuthResponse); + mockedGot.mockImplementationOnce(gitGetUserResponse); + await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }) + .expect(401); + }); - expect(email).toEqual('anotherUser1@tooljet.io'); - expect(first_name).toEqual('SSO'); - expect(last_name).toEqual('userExist'); - expect(admin).toBeFalsy(); - expect(organization_id).toBe(current_organization.id); - expect(organization).toBe(current_organization.name); - expect(group_permissions).toHaveLength(1); - expect(group_permissions[0].group).toEqual('all_users'); - expect(Object.keys(group_permissions[0]).sort()).toEqual( - [ - 'id', - 'organization_id', - 'group', - 'app_create', - 'app_delete', - 'updated_at', - 'created_at', - 'folder_create', - ].sort() - ); - expect(app_group_permissions).toHaveLength(0); + it('should return 401 when the user does not exist domain mismatch', async () => { + await orgRepository.update(current_organization.id, { domain: 'tooljet.io,tooljet.com' }); + const gitAuthResponse = jest.fn(); + gitAuthResponse.mockImplementation(() => { + return { + json: () => { + return { + access_token: 'some-access-token', + scope: 'scope', + token_type: 'bearer', + }; + }, + }; + }); + const gitGetUserResponse = jest.fn(); + gitGetUserResponse.mockImplementation(() => { + return { + json: () => { + return { + name: 'SSO UserGit', + email: 'ssoUserGit@tooljett.io', + }; + }, + }; + }); + + mockedGot.mockImplementationOnce(gitAuthResponse); + mockedGot.mockImplementationOnce(gitGetUserResponse); + + await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }) + .expect(401); + }); + + it('should return login info when the user does not exist and domain matches and sign up is enabled', async () => { + await orgRepository.update(current_organization.id, { domain: 'tooljet.io,tooljet.com' }); + const gitAuthResponse = jest.fn(); + gitAuthResponse.mockImplementation(() => { + return { + json: () => { + return { + access_token: 'some-access-token', + scope: 'scope', + token_type: 'bearer', + }; + }, + }; + }); + const gitGetUserResponse = jest.fn(); + gitGetUserResponse.mockImplementation(() => { + return { + json: () => { + return { + name: 'SSO UserGit', + email: 'ssoUserGit@tooljet.io', + }; + }, + }; + }); + + mockedGot.mockImplementationOnce(gitAuthResponse); + mockedGot.mockImplementationOnce(gitGetUserResponse); + + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { + email, + first_name, + last_name, + admin, + group_permissions, + app_group_permissions, + organization_id, + organization, + } = response.body; + + expect(email).toEqual('ssoUserGit@tooljet.io'); + expect(first_name).toEqual('SSO'); + expect(last_name).toEqual('UserGit'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); + + it('should return login info when the user does not exist and domain includes spance matches and sign up is enabled', async () => { + await orgRepository.update(current_organization.id, { + domain: ' tooljet.io , tooljet.com, , , gmail.com', + }); + const gitAuthResponse = jest.fn(); + gitAuthResponse.mockImplementation(() => { + return { + json: () => { + return { + access_token: 'some-access-token', + scope: 'scope', + token_type: 'bearer', + }; + }, + }; + }); + const gitGetUserResponse = jest.fn(); + gitGetUserResponse.mockImplementation(() => { + return { + json: () => { + return { + name: 'SSO UserGit', + email: 'ssoUserGit@tooljet.io', + }; + }, + }; + }); + + mockedGot.mockImplementationOnce(gitAuthResponse); + mockedGot.mockImplementationOnce(gitGetUserResponse); + + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { + email, + first_name, + last_name, + admin, + group_permissions, + app_group_permissions, + organization_id, + organization, + } = response.body; + + expect(email).toEqual('ssoUserGit@tooljet.io'); + expect(first_name).toEqual('SSO'); + expect(last_name).toEqual('UserGit'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); + + it('should return login info when the user does not exist and sign up is enabled', async () => { + const gitAuthResponse = jest.fn(); + gitAuthResponse.mockImplementation(() => { + return { + json: () => { + return { + access_token: 'some-access-token', + scope: 'scope', + token_type: 'bearer', + }; + }, + }; + }); + const gitGetUserResponse = jest.fn(); + gitGetUserResponse.mockImplementation(() => { + return { + json: () => { + return { + name: 'SSO UserGit', + email: 'ssoUserGit@tooljet.io', + }; + }, + }; + }); + + mockedGot.mockImplementationOnce(gitAuthResponse); + mockedGot.mockImplementationOnce(gitGetUserResponse); + + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { + email, + first_name, + last_name, + admin, + group_permissions, + app_group_permissions, + organization_id, + organization, + } = response.body; + + expect(email).toEqual('ssoUserGit@tooljet.io'); + expect(first_name).toEqual('SSO'); + expect(last_name).toEqual('UserGit'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); + it('should return login info when the user does not exist and name not available and sign up is enabled', async () => { + const gitAuthResponse = jest.fn(); + gitAuthResponse.mockImplementation(() => { + return { + json: () => { + return { + access_token: 'some-access-token', + scope: 'scope', + token_type: 'bearer', + }; + }, + }; + }); + const gitGetUserResponse = jest.fn(); + gitGetUserResponse.mockImplementation(() => { + return { + json: () => { + return { + name: '', + email: 'ssoUserGit@tooljet.io', + }; + }, + }; + }); + + mockedGot.mockImplementationOnce(gitAuthResponse); + mockedGot.mockImplementationOnce(gitGetUserResponse); + + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { email, first_name, admin, group_permissions, app_group_permissions, organization_id, organization } = + response.body; + + expect(email).toEqual('ssoUserGit@tooljet.io'); + expect(first_name).toEqual('ssoUserGit'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); + it('should return login info when the user does not exist and email id not available and sign up is enabled', async () => { + const gitAuthResponse = jest.fn(); + gitAuthResponse.mockImplementation(() => { + return { + json: () => { + return { + access_token: 'some-access-token', + scope: 'scope', + token_type: 'bearer', + }; + }, + }; + }); + const gitGetUserResponse = jest.fn(); + gitGetUserResponse.mockImplementation(() => { + return { + json: () => { + return { + name: '', + email: '', + }; + }, + }; + }); + const gitGetUserEmailResponse = jest.fn(); + gitGetUserEmailResponse.mockImplementation(() => { + return { + json: () => { + return [ + { + email: 'ssoUserGit@tooljet.io', + primary: true, + verified: true, + }, + { + email: 'ssoUserGit2@tooljet.io', + primary: false, + verified: true, + }, + ]; + }, + }; + }); + + mockedGot.mockImplementationOnce(gitAuthResponse); + mockedGot.mockImplementationOnce(gitGetUserResponse); + mockedGot.mockImplementationOnce(gitGetUserEmailResponse); + + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { email, first_name, admin, group_permissions, app_group_permissions, organization_id, organization } = + response.body; + + expect(email).toEqual('ssoUserGit@tooljet.io'); + expect(first_name).toEqual('ssoUserGit'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); + it('should return login info when the user exist', async () => { + await createUser(app, { + firstName: 'SSO', + lastName: 'userExist', + email: 'anotherUser1@tooljet.io', + groups: ['all_users'], + organization: current_organization, + status: 'active', + }); + + const gitAuthResponse = jest.fn(); + gitAuthResponse.mockImplementation(() => { + return { + json: () => { + return { + access_token: 'some-access-token', + scope: 'scope', + token_type: 'bearer', + }; + }, + }; + }); + const gitGetUserResponse = jest.fn(); + gitGetUserResponse.mockImplementation(() => { + return { + json: () => { + return { + name: 'SSO userExist', + email: 'anotherUser1@tooljet.io', + }; + }, + }; + }); + + mockedGot.mockImplementationOnce(gitAuthResponse); + mockedGot.mockImplementationOnce(gitGetUserResponse); + + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { + email, + first_name, + last_name, + admin, + group_permissions, + app_group_permissions, + organization_id, + organization, + } = response.body; + + expect(email).toEqual('anotherUser1@tooljet.io'); + expect(first_name).toEqual('SSO'); + expect(last_name).toEqual('userExist'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); }); }); - describe('sign in via Git OAuth', () => { - let sso_configs; - const token = 'some-Token'; - beforeEach(() => { - sso_configs = current_organization.ssoConfigs.find((conf) => conf.sso === 'git'); + + describe('Multi-Workspace Disabled', () => { + beforeEach(async () => { + jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => { + if (key === 'DISABLE_MULTI_WORKSPACE') { + return 'true'; + } else { + return process.env[key]; + } + }); }); - it('should return 401 if git sign in is disabled', async () => { - await ssoConfigsRepository.update(sso_configs.id, { enabled: false }); - await request(app.getHttpServer()) - .post('/api/oauth/sign-in/' + sso_configs.id) - .send({ token }) - .expect(401); + describe('sign in via Google OAuth', () => { + let sso_configs; + const token = 'some-Token'; + beforeEach(() => { + sso_configs = current_organization.ssoConfigs.find((conf) => conf.sso === 'google'); + }); + it('should return 401 if google sign in is disabled', async () => { + await ssoConfigsRepository.update(sso_configs.id, { enabled: false }); + await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }) + .expect(401); + }); + + it('should return 401 when the user does not exist and sign up is disabled', async () => { + await orgRepository.update(current_organization.id, { enableSignUp: false }); + const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); + googleVerifyMock.mockImplementation(() => ({ + getPayload: () => ({ + sub: 'someSSOId', + email: 'ssoUser@tooljet.io', + name: 'SSO User', + hd: 'tooljet.io', + }), + })); + await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }) + .expect(401); + }); + + it('should return 401 when the user does not exist domain mismatch', async () => { + await orgRepository.update(current_organization.id, { domain: 'tooljet.io,tooljet.com' }); + const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); + googleVerifyMock.mockImplementation(() => ({ + getPayload: () => ({ + sub: 'someSSOId', + email: 'ssoUser@tooljett.io', + name: 'SSO User', + hd: 'tooljet.io', + }), + })); + await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }) + .expect(401); + }); + + it('should return login info when the user does not exist and domain matches and sign up is enabled', async () => { + await orgRepository.update(current_organization.id, { domain: 'tooljet.io,tooljet.com' }); + const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); + googleVerifyMock.mockImplementation(() => ({ + getPayload: () => ({ + sub: 'someSSOId', + email: 'ssoUser@tooljet.io', + name: 'SSO User', + hd: 'tooljet.io', + }), + })); + + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); + + expect(googleVerifyMock).toHaveBeenCalledWith({ + idToken: token, + audience: sso_configs.configs.clientId, + }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { + email, + first_name, + last_name, + admin, + group_permissions, + app_group_permissions, + organization_id, + organization, + } = response.body; + + expect(email).toEqual('ssoUser@tooljet.io'); + expect(first_name).toEqual('SSO'); + expect(last_name).toEqual('User'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); + + it('should return login info when the user does not exist and sign up is enabled', async () => { + const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); + googleVerifyMock.mockImplementation(() => ({ + getPayload: () => ({ + sub: 'someSSOId', + email: 'ssoUser@tooljet.io', + name: 'SSO User', + hd: 'tooljet.io', + }), + })); + + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); + + expect(googleVerifyMock).toHaveBeenCalledWith({ + idToken: token, + audience: sso_configs.configs.clientId, + }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { + email, + first_name, + last_name, + admin, + group_permissions, + app_group_permissions, + organization_id, + organization, + } = response.body; + + expect(email).toEqual('ssoUser@tooljet.io'); + expect(first_name).toEqual('SSO'); + expect(last_name).toEqual('User'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); + it('should return login info when the user does not exist and name not available and sign up is enabled', async () => { + const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); + googleVerifyMock.mockImplementation(() => ({ + getPayload: () => ({ + sub: 'someSSOId', + email: 'ssoUser@tooljet.io', + name: '', + hd: 'tooljet.io', + }), + })); + + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); + + expect(googleVerifyMock).toHaveBeenCalledWith({ + idToken: token, + audience: sso_configs.configs.clientId, + }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { email, first_name, admin, group_permissions, app_group_permissions, organization_id, organization } = + response.body; + + expect(email).toEqual('ssoUser@tooljet.io'); + expect(first_name).toEqual('ssoUser'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); + it('should return login info when the user exist', async () => { + await createUser(app, { + firstName: 'SSO', + lastName: 'userExist', + email: 'anotherUser1@tooljet.io', + groups: ['all_users'], + organization: current_organization, + status: 'active', + }); + const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); + googleVerifyMock.mockImplementation(() => ({ + getPayload: () => ({ + sub: 'someSSOId', + email: 'anotherUser1@tooljet.io', + name: 'SSO User', + hd: 'tooljet.io', + }), + })); + + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); + + expect(googleVerifyMock).toHaveBeenCalledWith({ + idToken: token, + audience: sso_configs.configs.clientId, + }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { + email, + first_name, + last_name, + admin, + group_permissions, + app_group_permissions, + organization_id, + organization, + } = response.body; + + expect(email).toEqual('anotherUser1@tooljet.io'); + expect(first_name).toEqual('SSO'); + expect(last_name).toEqual('userExist'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); }); - - it('should return 401 when the user does not exist and sign up is disabled', async () => { - await orgRepository.update(current_organization.id, { enableSignUp: false }); - const gitAuthResponse = jest.fn(); - gitAuthResponse.mockImplementation(() => { - return { - json: () => { - return { - access_token: 'some-access-token', - scope: 'scope', - token_type: 'bearer', - }; - }, - }; + describe('sign in via Git OAuth', () => { + let sso_configs; + const token = 'some-Token'; + beforeEach(() => { + sso_configs = current_organization.ssoConfigs.find((conf) => conf.sso === 'git'); }); - const gitGetUserResponse = jest.fn(); - gitGetUserResponse.mockImplementation(() => { - return { - json: () => { - return { - name: 'SSO UserGit', - email: 'ssoUserGit@tooljet.io', - }; - }, - }; + it('should return 401 if git sign in is disabled', async () => { + await ssoConfigsRepository.update(sso_configs.id, { enabled: false }); + await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }) + .expect(401); }); - mockedGot.mockImplementationOnce(gitAuthResponse); - mockedGot.mockImplementationOnce(gitGetUserResponse); - await request(app.getHttpServer()) - .post('/api/oauth/sign-in/' + sso_configs.id) - .send({ token }) - .expect(401); - }); - - it('should return 401 when the user does not exist domain mismatch', async () => { - await orgRepository.update(current_organization.id, { domain: 'tooljet.io,tooljet.com' }); - const gitAuthResponse = jest.fn(); - gitAuthResponse.mockImplementation(() => { - return { - json: () => { - return { - access_token: 'some-access-token', - scope: 'scope', - token_type: 'bearer', - }; - }, - }; - }); - const gitGetUserResponse = jest.fn(); - gitGetUserResponse.mockImplementation(() => { - return { - json: () => { - return { - name: 'SSO UserGit', - email: 'ssoUserGit@tooljett.io', - }; - }, - }; - }); - - mockedGot.mockImplementationOnce(gitAuthResponse); - mockedGot.mockImplementationOnce(gitGetUserResponse); - - await request(app.getHttpServer()) - .post('/api/oauth/sign-in/' + sso_configs.id) - .send({ token }) - .expect(401); - }); - - it('should return login info when the user does not exist and domain matches and sign up is enabled', async () => { - await orgRepository.update(current_organization.id, { domain: 'tooljet.io,tooljet.com' }); - const gitAuthResponse = jest.fn(); - gitAuthResponse.mockImplementation(() => { - return { - json: () => { - return { - access_token: 'some-access-token', - scope: 'scope', - token_type: 'bearer', - }; - }, - }; - }); - const gitGetUserResponse = jest.fn(); - gitGetUserResponse.mockImplementation(() => { - return { - json: () => { - return { - name: 'SSO UserGit', - email: 'ssoUserGit@tooljet.io', - }; - }, - }; - }); - - mockedGot.mockImplementationOnce(gitAuthResponse); - mockedGot.mockImplementationOnce(gitGetUserResponse); - - const response = await request(app.getHttpServer()) - .post('/api/oauth/sign-in/' + sso_configs.id) - .send({ token }); - - expect(response.statusCode).toBe(201); - expect(Object.keys(response.body).sort()).toEqual( - [ - 'id', - 'email', - 'first_name', - 'last_name', - 'auth_token', - 'admin', - 'organization_id', - 'organization', - 'group_permissions', - 'app_group_permissions', - ].sort() - ); - - const { - email, - first_name, - last_name, - admin, - group_permissions, - app_group_permissions, - organization_id, - organization, - } = response.body; - - expect(email).toEqual('ssoUserGit@tooljet.io'); - expect(first_name).toEqual('SSO'); - expect(last_name).toEqual('UserGit'); - expect(admin).toBeFalsy(); - expect(organization_id).toBe(current_organization.id); - expect(organization).toBe(current_organization.name); - expect(group_permissions).toHaveLength(1); - expect(group_permissions[0].group).toEqual('all_users'); - expect(Object.keys(group_permissions[0]).sort()).toEqual( - [ - 'id', - 'organization_id', - 'group', - 'app_create', - 'app_delete', - 'updated_at', - 'created_at', - 'folder_create', - ].sort() - ); - expect(app_group_permissions).toHaveLength(0); - }); - - it('should return login info when the user does not exist and domain includes spance matches and sign up is enabled', async () => { - await orgRepository.update(current_organization.id, { - domain: ' tooljet.io , tooljet.com, , , gmail.com', - }); - const gitAuthResponse = jest.fn(); - gitAuthResponse.mockImplementation(() => { - return { - json: () => { - return { - access_token: 'some-access-token', - scope: 'scope', - token_type: 'bearer', - }; - }, - }; - }); - const gitGetUserResponse = jest.fn(); - gitGetUserResponse.mockImplementation(() => { - return { - json: () => { - return { - name: 'SSO UserGit', - email: 'ssoUserGit@tooljet.io', - }; - }, - }; - }); - - mockedGot.mockImplementationOnce(gitAuthResponse); - mockedGot.mockImplementationOnce(gitGetUserResponse); - - const response = await request(app.getHttpServer()) - .post('/api/oauth/sign-in/' + sso_configs.id) - .send({ token }); - - expect(response.statusCode).toBe(201); - expect(Object.keys(response.body).sort()).toEqual( - [ - 'id', - 'email', - 'first_name', - 'last_name', - 'auth_token', - 'admin', - 'organization_id', - 'organization', - 'group_permissions', - 'app_group_permissions', - ].sort() - ); - - const { - email, - first_name, - last_name, - admin, - group_permissions, - app_group_permissions, - organization_id, - organization, - } = response.body; - - expect(email).toEqual('ssoUserGit@tooljet.io'); - expect(first_name).toEqual('SSO'); - expect(last_name).toEqual('UserGit'); - expect(admin).toBeFalsy(); - expect(organization_id).toBe(current_organization.id); - expect(organization).toBe(current_organization.name); - expect(group_permissions).toHaveLength(1); - expect(group_permissions[0].group).toEqual('all_users'); - expect(Object.keys(group_permissions[0]).sort()).toEqual( - [ - 'id', - 'organization_id', - 'group', - 'app_create', - 'app_delete', - 'updated_at', - 'created_at', - 'folder_create', - ].sort() - ); - expect(app_group_permissions).toHaveLength(0); - }); - - it('should return login info when the user does not exist and sign up is enabled', async () => { - const gitAuthResponse = jest.fn(); - gitAuthResponse.mockImplementation(() => { - return { - json: () => { - return { - access_token: 'some-access-token', - scope: 'scope', - token_type: 'bearer', - }; - }, - }; - }); - const gitGetUserResponse = jest.fn(); - gitGetUserResponse.mockImplementation(() => { - return { - json: () => { - return { - name: 'SSO UserGit', - email: 'ssoUserGit@tooljet.io', - }; - }, - }; - }); - - mockedGot.mockImplementationOnce(gitAuthResponse); - mockedGot.mockImplementationOnce(gitGetUserResponse); - - const response = await request(app.getHttpServer()) - .post('/api/oauth/sign-in/' + sso_configs.id) - .send({ token }); - - expect(response.statusCode).toBe(201); - expect(Object.keys(response.body).sort()).toEqual( - [ - 'id', - 'email', - 'first_name', - 'last_name', - 'auth_token', - 'admin', - 'organization_id', - 'organization', - 'group_permissions', - 'app_group_permissions', - ].sort() - ); - - const { - email, - first_name, - last_name, - admin, - group_permissions, - app_group_permissions, - organization_id, - organization, - } = response.body; - - expect(email).toEqual('ssoUserGit@tooljet.io'); - expect(first_name).toEqual('SSO'); - expect(last_name).toEqual('UserGit'); - expect(admin).toBeFalsy(); - expect(organization_id).toBe(current_organization.id); - expect(organization).toBe(current_organization.name); - expect(group_permissions).toHaveLength(1); - expect(group_permissions[0].group).toEqual('all_users'); - expect(Object.keys(group_permissions[0]).sort()).toEqual( - [ - 'id', - 'organization_id', - 'group', - 'app_create', - 'app_delete', - 'updated_at', - 'created_at', - 'folder_create', - ].sort() - ); - expect(app_group_permissions).toHaveLength(0); - }); - it('should return login info when the user does not exist and name not available and sign up is enabled', async () => { - const gitAuthResponse = jest.fn(); - gitAuthResponse.mockImplementation(() => { - return { - json: () => { - return { - access_token: 'some-access-token', - scope: 'scope', - token_type: 'bearer', - }; - }, - }; - }); - const gitGetUserResponse = jest.fn(); - gitGetUserResponse.mockImplementation(() => { - return { - json: () => { - return { - name: '', - email: 'ssoUserGit@tooljet.io', - }; - }, - }; - }); - - mockedGot.mockImplementationOnce(gitAuthResponse); - mockedGot.mockImplementationOnce(gitGetUserResponse); - - const response = await request(app.getHttpServer()) - .post('/api/oauth/sign-in/' + sso_configs.id) - .send({ token }); - - expect(response.statusCode).toBe(201); - expect(Object.keys(response.body).sort()).toEqual( - [ - 'id', - 'email', - 'first_name', - 'last_name', - 'auth_token', - 'admin', - 'organization_id', - 'organization', - 'group_permissions', - 'app_group_permissions', - ].sort() - ); - - const { email, first_name, admin, group_permissions, app_group_permissions, organization_id, organization } = - response.body; - - expect(email).toEqual('ssoUserGit@tooljet.io'); - expect(first_name).toEqual('ssoUserGit'); - expect(admin).toBeFalsy(); - expect(organization_id).toBe(current_organization.id); - expect(organization).toBe(current_organization.name); - expect(group_permissions).toHaveLength(1); - expect(group_permissions[0].group).toEqual('all_users'); - expect(Object.keys(group_permissions[0]).sort()).toEqual( - [ - 'id', - 'organization_id', - 'group', - 'app_create', - 'app_delete', - 'updated_at', - 'created_at', - 'folder_create', - ].sort() - ); - expect(app_group_permissions).toHaveLength(0); - }); - it('should return login info when the user does not exist and email id not available and sign up is enabled', async () => { - const gitAuthResponse = jest.fn(); - gitAuthResponse.mockImplementation(() => { - return { - json: () => { - return { - access_token: 'some-access-token', - scope: 'scope', - token_type: 'bearer', - }; - }, - }; - }); - const gitGetUserResponse = jest.fn(); - gitGetUserResponse.mockImplementation(() => { - return { - json: () => { - return { - name: '', - email: '', - }; - }, - }; - }); - const gitGetUserEmailResponse = jest.fn(); - gitGetUserEmailResponse.mockImplementation(() => { - return { - json: () => { - return [ - { + it('should return 401 when the user does not exist and sign up is disabled', async () => { + await orgRepository.update(current_organization.id, { enableSignUp: false }); + const gitAuthResponse = jest.fn(); + gitAuthResponse.mockImplementation(() => { + return { + json: () => { + return { + access_token: 'some-access-token', + scope: 'scope', + token_type: 'bearer', + }; + }, + }; + }); + const gitGetUserResponse = jest.fn(); + gitGetUserResponse.mockImplementation(() => { + return { + json: () => { + return { + name: 'SSO UserGit', email: 'ssoUserGit@tooljet.io', - primary: true, - verified: true, - }, - { - email: 'ssoUserGit2@tooljet.io', - primary: false, - verified: true, - }, - ]; - }, - }; + }; + }, + }; + }); + + mockedGot.mockImplementationOnce(gitAuthResponse); + mockedGot.mockImplementationOnce(gitGetUserResponse); + await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }) + .expect(401); }); - mockedGot.mockImplementationOnce(gitAuthResponse); - mockedGot.mockImplementationOnce(gitGetUserResponse); - mockedGot.mockImplementationOnce(gitGetUserEmailResponse); + it('should return 401 when the user does not exist domain mismatch', async () => { + await orgRepository.update(current_organization.id, { domain: 'tooljet.io,tooljet.com' }); + const gitAuthResponse = jest.fn(); + gitAuthResponse.mockImplementation(() => { + return { + json: () => { + return { + access_token: 'some-access-token', + scope: 'scope', + token_type: 'bearer', + }; + }, + }; + }); + const gitGetUserResponse = jest.fn(); + gitGetUserResponse.mockImplementation(() => { + return { + json: () => { + return { + name: 'SSO UserGit', + email: 'ssoUserGit@tooljett.io', + }; + }, + }; + }); - const response = await request(app.getHttpServer()) - .post('/api/oauth/sign-in/' + sso_configs.id) - .send({ token }); + mockedGot.mockImplementationOnce(gitAuthResponse); + mockedGot.mockImplementationOnce(gitGetUserResponse); - expect(response.statusCode).toBe(201); - expect(Object.keys(response.body).sort()).toEqual( - [ - 'id', - 'email', - 'first_name', - 'last_name', - 'auth_token', - 'admin', - 'organization_id', - 'organization', - 'group_permissions', - 'app_group_permissions', - ].sort() - ); - - const { email, first_name, admin, group_permissions, app_group_permissions, organization_id, organization } = - response.body; - - expect(email).toEqual('ssoUserGit@tooljet.io'); - expect(first_name).toEqual('ssoUserGit'); - expect(admin).toBeFalsy(); - expect(organization_id).toBe(current_organization.id); - expect(organization).toBe(current_organization.name); - expect(group_permissions).toHaveLength(1); - expect(group_permissions[0].group).toEqual('all_users'); - expect(Object.keys(group_permissions[0]).sort()).toEqual( - [ - 'id', - 'organization_id', - 'group', - 'app_create', - 'app_delete', - 'updated_at', - 'created_at', - 'folder_create', - ].sort() - ); - expect(app_group_permissions).toHaveLength(0); - }); - it('should return login info when the user exist', async () => { - await createUser(app, { - firstName: 'SSO', - lastName: 'userExist', - email: 'anotherUser1@tooljet.io', - groups: ['all_users'], - organization: current_organization, - status: 'active', + await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }) + .expect(401); }); - const gitAuthResponse = jest.fn(); - gitAuthResponse.mockImplementation(() => { - return { - json: () => { - return { - access_token: 'some-access-token', - scope: 'scope', - token_type: 'bearer', - }; - }, - }; - }); - const gitGetUserResponse = jest.fn(); - gitGetUserResponse.mockImplementation(() => { - return { - json: () => { - return { - name: 'SSO userExist', - email: 'anotherUser1@tooljet.io', - }; - }, - }; + it('should return login info when the user does not exist and domain matches and sign up is enabled', async () => { + await orgRepository.update(current_organization.id, { domain: 'tooljet.io,tooljet.com' }); + const gitAuthResponse = jest.fn(); + gitAuthResponse.mockImplementation(() => { + return { + json: () => { + return { + access_token: 'some-access-token', + scope: 'scope', + token_type: 'bearer', + }; + }, + }; + }); + const gitGetUserResponse = jest.fn(); + gitGetUserResponse.mockImplementation(() => { + return { + json: () => { + return { + name: 'SSO UserGit', + email: 'ssoUserGit@tooljet.io', + }; + }, + }; + }); + + mockedGot.mockImplementationOnce(gitAuthResponse); + mockedGot.mockImplementationOnce(gitGetUserResponse); + + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { + email, + first_name, + last_name, + admin, + group_permissions, + app_group_permissions, + organization_id, + organization, + } = response.body; + + expect(email).toEqual('ssoUserGit@tooljet.io'); + expect(first_name).toEqual('SSO'); + expect(last_name).toEqual('UserGit'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); }); - mockedGot.mockImplementationOnce(gitAuthResponse); - mockedGot.mockImplementationOnce(gitGetUserResponse); + it('should return login info when the user does not exist and domain includes spance matches and sign up is enabled', async () => { + await orgRepository.update(current_organization.id, { + domain: ' tooljet.io , tooljet.com, , , gmail.com', + }); + const gitAuthResponse = jest.fn(); + gitAuthResponse.mockImplementation(() => { + return { + json: () => { + return { + access_token: 'some-access-token', + scope: 'scope', + token_type: 'bearer', + }; + }, + }; + }); + const gitGetUserResponse = jest.fn(); + gitGetUserResponse.mockImplementation(() => { + return { + json: () => { + return { + name: 'SSO UserGit', + email: 'ssoUserGit@tooljet.io', + }; + }, + }; + }); - const response = await request(app.getHttpServer()) - .post('/api/oauth/sign-in/' + sso_configs.id) - .send({ token }); + mockedGot.mockImplementationOnce(gitAuthResponse); + mockedGot.mockImplementationOnce(gitGetUserResponse); - expect(response.statusCode).toBe(201); - expect(Object.keys(response.body).sort()).toEqual( - [ - 'id', - 'email', - 'first_name', - 'last_name', - 'auth_token', - 'admin', - 'organization_id', - 'organization', - 'group_permissions', - 'app_group_permissions', - ].sort() - ); + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); - const { - email, - first_name, - last_name, - admin, - group_permissions, - app_group_permissions, - organization_id, - organization, - } = response.body; + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); - expect(email).toEqual('anotherUser1@tooljet.io'); - expect(first_name).toEqual('SSO'); - expect(last_name).toEqual('userExist'); - expect(admin).toBeFalsy(); - expect(organization_id).toBe(current_organization.id); - expect(organization).toBe(current_organization.name); - expect(group_permissions).toHaveLength(1); - expect(group_permissions[0].group).toEqual('all_users'); - expect(Object.keys(group_permissions[0]).sort()).toEqual( - [ - 'id', - 'organization_id', - 'group', - 'app_create', - 'app_delete', - 'updated_at', - 'created_at', - 'folder_create', - ].sort() - ); - expect(app_group_permissions).toHaveLength(0); + const { + email, + first_name, + last_name, + admin, + group_permissions, + app_group_permissions, + organization_id, + organization, + } = response.body; + + expect(email).toEqual('ssoUserGit@tooljet.io'); + expect(first_name).toEqual('SSO'); + expect(last_name).toEqual('UserGit'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); + + it('should return login info when the user does not exist and sign up is enabled', async () => { + const gitAuthResponse = jest.fn(); + gitAuthResponse.mockImplementation(() => { + return { + json: () => { + return { + access_token: 'some-access-token', + scope: 'scope', + token_type: 'bearer', + }; + }, + }; + }); + const gitGetUserResponse = jest.fn(); + gitGetUserResponse.mockImplementation(() => { + return { + json: () => { + return { + name: 'SSO UserGit', + email: 'ssoUserGit@tooljet.io', + }; + }, + }; + }); + + mockedGot.mockImplementationOnce(gitAuthResponse); + mockedGot.mockImplementationOnce(gitGetUserResponse); + + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { + email, + first_name, + last_name, + admin, + group_permissions, + app_group_permissions, + organization_id, + organization, + } = response.body; + + expect(email).toEqual('ssoUserGit@tooljet.io'); + expect(first_name).toEqual('SSO'); + expect(last_name).toEqual('UserGit'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); + it('should return login info when the user does not exist and name not available and sign up is enabled', async () => { + const gitAuthResponse = jest.fn(); + gitAuthResponse.mockImplementation(() => { + return { + json: () => { + return { + access_token: 'some-access-token', + scope: 'scope', + token_type: 'bearer', + }; + }, + }; + }); + const gitGetUserResponse = jest.fn(); + gitGetUserResponse.mockImplementation(() => { + return { + json: () => { + return { + name: '', + email: 'ssoUserGit@tooljet.io', + }; + }, + }; + }); + + mockedGot.mockImplementationOnce(gitAuthResponse); + mockedGot.mockImplementationOnce(gitGetUserResponse); + + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { email, first_name, admin, group_permissions, app_group_permissions, organization_id, organization } = + response.body; + + expect(email).toEqual('ssoUserGit@tooljet.io'); + expect(first_name).toEqual('ssoUserGit'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); + it('should return login info when the user does not exist and email id not available and sign up is enabled', async () => { + const gitAuthResponse = jest.fn(); + gitAuthResponse.mockImplementation(() => { + return { + json: () => { + return { + access_token: 'some-access-token', + scope: 'scope', + token_type: 'bearer', + }; + }, + }; + }); + const gitGetUserResponse = jest.fn(); + gitGetUserResponse.mockImplementation(() => { + return { + json: () => { + return { + name: '', + email: '', + }; + }, + }; + }); + const gitGetUserEmailResponse = jest.fn(); + gitGetUserEmailResponse.mockImplementation(() => { + return { + json: () => { + return [ + { + email: 'ssoUserGit@tooljet.io', + primary: true, + verified: true, + }, + { + email: 'ssoUserGit2@tooljet.io', + primary: false, + verified: true, + }, + ]; + }, + }; + }); + + mockedGot.mockImplementationOnce(gitAuthResponse); + mockedGot.mockImplementationOnce(gitGetUserResponse); + mockedGot.mockImplementationOnce(gitGetUserEmailResponse); + + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { email, first_name, admin, group_permissions, app_group_permissions, organization_id, organization } = + response.body; + + expect(email).toEqual('ssoUserGit@tooljet.io'); + expect(first_name).toEqual('ssoUserGit'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); + it('should return login info when the user exist', async () => { + await createUser(app, { + firstName: 'SSO', + lastName: 'userExist', + email: 'anotherUser1@tooljet.io', + groups: ['all_users'], + organization: current_organization, + status: 'active', + }); + + const gitAuthResponse = jest.fn(); + gitAuthResponse.mockImplementation(() => { + return { + json: () => { + return { + access_token: 'some-access-token', + scope: 'scope', + token_type: 'bearer', + }; + }, + }; + }); + const gitGetUserResponse = jest.fn(); + gitGetUserResponse.mockImplementation(() => { + return { + json: () => { + return { + name: 'SSO userExist', + email: 'anotherUser1@tooljet.io', + }; + }, + }; + }); + + mockedGot.mockImplementationOnce(gitAuthResponse); + mockedGot.mockImplementationOnce(gitGetUserResponse); + + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { + email, + first_name, + last_name, + admin, + group_permissions, + app_group_permissions, + organization_id, + organization, + } = response.body; + + expect(email).toEqual('anotherUser1@tooljet.io'); + expect(first_name).toEqual('SSO'); + expect(last_name).toEqual('userExist'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); }); }); }); diff --git a/server/test/controllers/organizations.e2e-spec.ts b/server/test/controllers/organizations.e2e-spec.ts index 98e9f842cd..972e182c6a 100644 --- a/server/test/controllers/organizations.e2e-spec.ts +++ b/server/test/controllers/organizations.e2e-spec.ts @@ -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', }); diff --git a/server/test/controllers/users.e2e-spec.ts b/server/test/controllers/users.e2e-spec.ts index 975ad27312..26d4b23774 100644 --- a/server/test/controllers/users.e2e-spec.ts +++ b/server/test/controllers/users.e2e-spec.ts @@ -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]; }