diff --git a/.version b/.version index 09a3acfa13..7ceb04048e 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -0.6.0 \ No newline at end of file +0.6.1 \ No newline at end of file diff --git a/cypress.json b/cypress.json index 8de8cc095c..fe0fdaa584 100644 --- a/cypress.json +++ b/cypress.json @@ -8,7 +8,8 @@ "empty-state-dashboard.spec.js", "dashboard.spec.js", "apps-page-operations.spec.js", - "editor-navigation-bar.spec.js" + "editor-navigation-bar.spec.js", + "editor-datasource-postgres.spec.js" ] } \ No newline at end of file diff --git a/cypress/integration/editor-datasource-postgres.spec.js b/cypress/integration/editor-datasource-postgres.spec.js new file mode 100644 index 0000000000..e8b4d27f08 --- /dev/null +++ b/cypress/integration/editor-datasource-postgres.spec.js @@ -0,0 +1,55 @@ +describe('Editor- Add "PostgreSQL" datasource', () => { + + beforeEach(() => { + + //read login data from fixtures + cy.fixture('login-data').then(function (testdata) { + cy.login(testdata.email, testdata.password) + }) + cy.wait(1000) + cy.get('body').then(($title => { + //check you are not running tests on empty dashboard state + if ($title.text().includes('You haven\'t created any apps yet.')) { + cy.get('a.btn').eq(0).should('have.text', 'Create your first app') + .click() + cy.go('back') + } + cy.wait(2000) + cy.get('.badge').contains('Edit').click() + cy.get('title').should('have.text', 'ToolJet - Dashboard') + })) + }) + + it('should add First data source successfully', () => { + + cy.get('.left-sidebar') + .find('.datasources-container.w-100.mt-3') + .find('.p-1.text-muted') + .should('have.text', 'DATASOURCES') + .and('be.visible') + + cy.get('.table-responsive') + .find('.p-2') + .should('include.text', "You haven't added data sources yet. ") + + cy.get('center[class="p-2 text-muted"]') + .find('button[class="btn btn-sm btn-outline-azure mt-3"]') + .should('have.text', 'add datasource') + .click() + + cy.addPostgresDataSource() + }); + + it('should add data source from "Add new datasource button', () => { + + cy.get('.left-sidebar') + .find('.datasources-container.w-100.mt-3') + .find('.p-1.text-muted') + .should('have.text', 'DATASOURCES') + .and('be.visible') + + cy.get('[data-tip="Add new datasource"]').click() + + cy.addPostgresDataSource() + }); +}) \ No newline at end of file diff --git a/cypress/integration/editor-navigation-bar.spec.js b/cypress/integration/editor-navigation-bar.spec.js index dff8bde43b..0ba0a5254d 100644 --- a/cypress/integration/editor-navigation-bar.spec.js +++ b/cypress/integration/editor-navigation-bar.spec.js @@ -164,7 +164,7 @@ describe('Editor - Navigation Bar', () => { cy.get('.modal-content') .find('.modal-header') .find('.modal-title') - .should('have.text', 'Users and permissions') + .should('have.text', 'Share') cy.get('.form-label') .should('have.text', 'Get shareable link for this application') @@ -172,13 +172,7 @@ describe('Editor - Navigation Bar', () => { cy.get('.input-group') .find('.btn.btn-secondary.btn-sm') .should('have.text', 'Copy') - .click() // check how to validate cliboard content - - cy.document().then(doc => { - doc.execCommand('copy'); - }); - cy.task('getClipboard').should('contain', '/applications/') - + .click() //check how to validate clipboard content }); it('should deploy app', () => { @@ -214,6 +208,7 @@ describe('Editor - Navigation Bar', () => { }); it('should launch app', () => { + cy.get('.navbar-nav.flex-row.order-md-last') .find('a[target="_blank"]') .should('have.text', 'Launch') diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js index aa67a28104..4e43ca55c8 100644 --- a/cypress/plugins/index.js +++ b/cypress/plugins/index.js @@ -19,11 +19,10 @@ module.exports = (on, config) => { // `on` is used to hook into various events Cypress emits // `config` is the resolved Cypress config } -const clipboardy = require('clipboardy'); module.exports = (on, config) => { - on('task', { - getClipboard() { - return clipboardy.readSync(); - } - }); -}; \ No newline at end of file + // modify env value + config.env = process.env + + // return config + return config +} \ No newline at end of file diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 490de79b28..f3d35f3f61 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -8,3 +8,62 @@ Cypress.Commands.add('login', (email, password) => { Cypress.Commands.add('checkToastMessage', (toastId, message) => { cy.get(`[id=${toastId}]`).should('contain', message); }); + +Cypress.Commands.add('addPostgresDataSource', fn => { + + cy.get('div[class="modal-title h4"] span[class="text-muted"]') + .should('have.text', 'Add new datasource') + .and('be.visible') + + cy.get('.modal-body') + .find('div[class="row row-deck"]') + .find('h4[class="text-muted mb-2"]') + .should('have.text', 'DATABASES') + + cy.get('.modal-body') + .find('.col-md-2') + .contains('PostgreSQL') + .and('be.visible') + .click() + + cy.get('.row.mt-3') + .find('.col-md-4') + .find('.form-label') + .contains('Database Name') + + cy.get('div[class="row mt-3"] div:nth-child(1)') + .find('.form-control') + .should('have.attr', 'type', 'text') + .type(Cypress.env('TEST_PG_DB')) + + cy.get('.row.mt-3') + .find('.col-md-4') + .find('.form-label') + .contains('Username') + + cy.get('div[class="row mt-3"] div:nth-child(2)') + .find('.form-control') + .should('have.attr', 'type', 'text') + .type(Cypress.env('TEST_PG_USERNAME')) + + cy.get('.row.mt-3') + .find('.col-md-4') + .find('.form-label') + .contains('Password') + + cy.get('div[class="row mt-3"] div:nth-child(3)') + .find('.form-control') + .should('have.attr', 'type', 'password') + .type(Cypress.env('TEST_PG_PASSWORD')) + + cy.get('button[class="m-2 btn btn-success"]') + .should('have.text', 'Test Connection') + .click() + + cy.get('.badge') + .should('have.text', 'connection verified') + + cy.get('div[class="col-auto"] button[type="button"]') + .should('have.text', 'Save') + .click() +}); diff --git a/deploy/docker/docker-compose.yaml b/deploy/docker/docker-compose.yaml index a0a06235d9..ad435f747b 100644 --- a/deploy/docker/docker-compose.yaml +++ b/deploy/docker/docker-compose.yaml @@ -27,6 +27,7 @@ services: env_file: .env environment: RAILS_LOG_TO_STDOUT: "true" + SERVE_CLIENT: "false" command: ['npm', 'run', '--prefix', 'server', 'start:prod'] volumes: diff --git a/deploy/ec2/tooljet_ubuntu_bionic.pkr.hcl b/deploy/ec2/tooljet_ubuntu_bionic.pkr.hcl index ebaca85aaa..f33ded1aec 100644 --- a/deploy/ec2/tooljet_ubuntu_bionic.pkr.hcl +++ b/deploy/ec2/tooljet_ubuntu_bionic.pkr.hcl @@ -11,6 +11,7 @@ source "amazon-ebs" "ubuntu" { ami_name = "${var.ami_name}" instance_type = "${var.instance_type}" region = "${var.ami_region}" + ami_regions = "${var.ami_regions}" source_ami_filter { filters = { name = "ubuntu/images/hvm-ssd/ubuntu-bionic-18.04-amd64-server-*" diff --git a/deploy/ec2/variables.pkr.hcl b/deploy/ec2/variables.pkr.hcl index 8ca7bb8c1e..a79b8aa77c 100644 --- a/deploy/ec2/variables.pkr.hcl +++ b/deploy/ec2/variables.pkr.hcl @@ -11,3 +11,8 @@ variable "ami_region" { type = string default = "us-west-2" } + +variable "ami_regions" { + type = list(string) + default = ["us-west-1", "us-east-1", "us-east-2", "eu-west-2", "eu-central-1", "ap-northeast-1", "ap-southeast-1", "ap-northeast-3", "ap-south-1", "ap-northeast-2", "ap-southeast-2", "ca-central-1", "eu-west-1", "eu-north-1", "sa-east-1"] +} diff --git a/docker-compose.yaml b/docker-compose.yaml index ab26b1a61c..109273c3ce 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -17,7 +17,6 @@ services: command: ['npm', 'start'] server: - # env_file: .env build: context: . dockerfile: ./docker/server.Dockerfile.dev @@ -30,12 +29,7 @@ services: ports: - 3000:3000 environment: - - NODE_ENV=development - - PG_DB=tooljet_development - - PG_USER=postgres - - PG_PASS=postgres - - PG_HOST=postgres - entrypoint: ./entrypoint.sh + - SERVE_CLIENT=false command: npm run start:dev postgres: @@ -46,8 +40,6 @@ services: volumes: - postgres:/data/postgres environment: - - POSTGRES_DB=tooljet_development - - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres volumes: diff --git a/docs/docs/contributing-guide/setup/Mac OS.md b/docs/docs/contributing-guide/setup/Mac OS.md index 24f54bef15..40d478aa85 100644 --- a/docs/docs/contributing-guide/setup/Mac OS.md +++ b/docs/docs/contributing-guide/setup/Mac OS.md @@ -56,7 +56,6 @@ Follow these steps to setup and run ToolJet on Mac OS for development purposes. 5. ## Setup database ```bash npm run --prefix server db:reset - npm run --prefix server start:dev ``` 6. ## Install webpack & nest-cli ```bash diff --git a/docs/docs/contributing-guide/setup/docker.md b/docs/docs/contributing-guide/setup/docker.md index d6d414b3e8..b7c317b56c 100644 --- a/docs/docs/contributing-guide/setup/docker.md +++ b/docs/docs/contributing-guide/setup/docker.md @@ -30,9 +30,10 @@ We recommend: 2. Create a `.env` file by copying `.env.example`. More information on the variables that can be set is given here: env variable reference ```bash cp .env.example .env + cp .env.example .env.test ``` -3. Populate the keys in the `.env` file. +3. Populate the keys in the `.env` and `.env.test` file. :::info `SECRET_KEY_BASE` requires a 64 byte key. (If you have `openssl` installed, run `openssl rand -hex 64` to create a 64 byte secure random key) @@ -42,9 +43,32 @@ We recommend: Example: ```bash cat .env - TOOLJET_HOST=http://localhost:8082 - LOCKBOX_MASTER_KEY=1d291a926ddfd221205a23adb4cc1db66cb9fcaf28d97c8c1950e3538e3b9281 - SECRET_KEY_BASE=4229d5774cfe7f60e75d6b3bf3a1dbb054a696b6d21b6d5de7b73291899797a222265e12c0a8e8d844f83ebacdf9a67ec42584edf1c2b23e1e7813f8a3339041 + TOOLJET_HOST=http://localhost:8082 + LOCKBOX_MASTER_KEY=13c9b8364ae71f714774c82498ba328813069e48d80029bb29f49d0ada5a8e40 + SECRET_KEY_BASE=ea85064ed42ad02cfc022e66d8bccf452e3fa1142421cbd7a13592d91a2cbb866d6001060b73a98a65be57e65524357d445efae00a218461088a706decd62dcb + NODE_ENV=development + # DATABASE CONFIG + PG_HOST=postgres + PG_PORT=5432 + PG_USER=postgres + PG_PASS=postgres + PG_DB=tooljet_development + ORM_LOGGING=all + ``` + + ```bash + cat .env.test + TOOLJET_HOST=http://localhost:8082 + LOCKBOX_MASTER_KEY=13c9b8364ae71f714774c82498ba328813069e48d80029bb29f49d0ada5a8e40 + SECRET_KEY_BASE=ea85064ed42ad02cfc022e66d8bccf452e3fa1142421cbd7a13592d91a2cbb866d6001060b73a98a65be57e65524357d445efae00a218461088a706decd62dcb + NODE_ENV=test + # DATABASE CONFIG + PG_HOST=postgres + PG_PORT=5432 + PG_USER=postgres + PG_PASS=postgres + PG_DB=tooljet_test + ORM_LOGGING=error ``` 4. Build docker images @@ -52,9 +76,10 @@ We recommend: docker-compose build ``` -5. ToolJet server is built using NestJS and the data such as application definitions are persisted on a postgres database. You have to reset the database if building for the first time. +5. ToolJet server is built using NestJS and the data such as application definitions are persisted on a postgres database. You have to create and migrate the database if building for the first time. ```bash - docker-compose run server npm run db:reset + docker-compose run server npm run db:create + docker-compose run server npm run db:migrate docker-compose run server npm run db:seed ``` @@ -118,7 +143,13 @@ Once you've updated the Dockerfile, rebuild the image by running `docker-compose ## Running tests -Test config requires the presence of `.env.test` file at the root of the project. +Test config picks up config from `.env.test` file at the root of the project. + +Run the following command to create and migrate data for test db +```bash +docker-compose run -e NODE_ENV=test server npm run db:create +docker-compose run -e NODE_ENV=test server npm run db:migrate +``` To run the unit tests diff --git a/docs/docs/contributing-guide/setup/ubuntu.md b/docs/docs/contributing-guide/setup/ubuntu.md index 7f6ca7fed7..d51daf0cf8 100644 --- a/docs/docs/contributing-guide/setup/ubuntu.md +++ b/docs/docs/contributing-guide/setup/ubuntu.md @@ -48,7 +48,6 @@ Follow these steps to setup and run ToolJet on Ubuntu. Open terminal and run the 5. ## Setup database ```bash npm run --prefix server db:reset - npm run --prefix server start:dev ``` 6. ## Running the server ```bash @@ -62,11 +61,11 @@ Follow these steps to setup and run ToolJet on Ubuntu. Open terminal and run the The client will start running on the port 8082, you can access the client by visiting: [https://localhost:8082](https://localhost:8082) -9. ## Creating login credentials +8. ## Creating login credentials Visiting https://localhost:8082 should redirect you to the login page, click on the signup link and enter your email. The emails sent by the server in development environment are captured and are opened in your default browser. Click the invitation link in the email preview to setup the account. -10. ## Running tests +9. ## Running tests Test config requires the presence of `.env.test` file at the root of the project. diff --git a/docs/docs/data-sources/airtable.md b/docs/docs/data-sources/airtable.md index 154a8d5395..3c849274f9 100644 --- a/docs/docs/data-sources/airtable.md +++ b/docs/docs/data-sources/airtable.md @@ -7,6 +7,8 @@ sidebar_position: 3 ToolJet can connect to Airtable using Airtable API ( https://airtable.com/api ). Airtable API key is required to create Airtable datasource on ToolJet. You can generate API key by visiting [Airtable account page](https://airtable.com/account). +ToolJet - Airtable + :::tip Airtable API has a rate limit and at the time of writing this documentation, the limit is 5 requests per second per base. You can read more about rate limits here ( https://airtable.com/api ). ::: @@ -20,6 +22,8 @@ Supported queries: - Listing records - Retrieving a record +- Updating a record +- Deleting a record ## Listing records @@ -88,4 +92,61 @@ Example response from Airtable: }, "createdTime": "2021-05-12T14:30:33.000Z" } +``` + +## Updating a record + +Required parameters: +- Base ID +- Table name +- Record ID + +ToolJet - Airtable Update Operarion + +#### Example body: + +ToolJet - Airtable Update Operarion Body + + +Click on the `run` button to run the query. + +:::info +NOTE: Query must be saved before running. +::: + +Example response from Airtable: +```json +{ + "id": "recu9xMnUdr2n2cw8", + "fields": { + "Notes": "Example Notes", + "Name": "change" + }, + "createdTime": "2021-08-08T17:27:17.000Z" +} +``` + +## Deleting a record + +Required parameters: +- Base ID +- Table name +- Record ID + +ToolJet - Airtable Delete Operarion + + +Click on the `run` button to run the query. + +:::info +NOTE: Query must be saved before running. +::: + +Example response from Airtable: + +```json +{ + deleted: true + id: "recIKsyZgqI4zoqS7" +} ``` \ No newline at end of file diff --git a/docs/docs/deployment/env-vars.md b/docs/docs/deployment/env-vars.md index b6c04fb80f..05c94af43b 100644 --- a/docs/docs/deployment/env-vars.md +++ b/docs/docs/deployment/env-vars.md @@ -51,6 +51,10 @@ If want to restrict the signups and allow new users only by invitations, set the You will still be able to see the signup page but won't be able to successfully submit the form. ::: +#### Serve client as a server end-point ( optional ) + +By default, the `SERVE_CLIENT` variable will be set to `false` and the server won't serve the client at its `/` end-point. +You can set `SERVE_CLIENT` to `true` and the server will attempt to serve the client at its root end-point (`/`). #### SMTP configuration ( optional ) diff --git a/docs/docs/deployment/kubernetes.md b/docs/docs/deployment/kubernetes.md index 42edf933aa..8007f8c586 100644 --- a/docs/docs/deployment/kubernetes.md +++ b/docs/docs/deployment/kubernetes.md @@ -21,12 +21,12 @@ Follow the steps below to deploy ToolJet on a Kubernetes cluster. 3. Create a Kubernetes deployment ```bash - kubectl apply -f https://raw.githubusercontent.com/ToolJet/ToolJet/main/deploy/kubernetes/server-deployment.yaml + kubectl apply -f https://raw.githubusercontent.com/ToolJet/ToolJet/main/deploy/kubernetes/deployment.yaml ``` - :::info - The file given above is just a template and might not suit production environments. You should download the file and configure parameters such as the replica count and environment variables according to your needs. - ::: +:::info +The file given above is just a template and might not suit production environments. You should download the file and configure parameters such as the replica count and environment variables according to your needs. +::: 4. Verify if ToolJet is running diff --git a/docs/docs/widgets/qr-scanner.md b/docs/docs/widgets/qr-scanner.md new file mode 100644 index 0000000000..aa604b48c3 --- /dev/null +++ b/docs/docs/widgets/qr-scanner.md @@ -0,0 +1,24 @@ +--- +sidebar_position: 1 +--- + +# QR Scanner +Scan QR codes using device camera and hold the data they carry. +ToolJet - QR Scanner + +#### Known issue: +In IOS, you might have to stick to the Safari browser as camera access had been restricted for third party browsers. + +## Exposed variables +#### lastDetectedValue +This variable holds the data contained in the last QR code scanned by the widget. + +## Events +#### onDetect +This event is fired whenever the widget successfully scans a QR code. + +## Debugging tip +Browser camera APIs restrict this widget to only work in either `localhost` or `https`. + +So if you're testing it out, be sure to either use `localhost` or `https`. + diff --git a/docs/docs/widgets/radio-button.md b/docs/docs/widgets/radio-button.md new file mode 100644 index 0000000000..895d8aafb2 --- /dev/null +++ b/docs/docs/widgets/radio-button.md @@ -0,0 +1,35 @@ +--- +sidebar_position: 2 +--- + +# Radio Button + +Radio Buttons can be used to select one option from a group of options. + +ToolJet - Radio Button Widget + +:::tip +Radio buttons are preferred when the list of options is less than six and all the options can be displayed at once. +::: + +:::info +For more than six options, consider using [Dropdown](/docs/widgets/dropdown) widget. +::: + + +## Event: onSelectionChange + +This event is triggered when an option is clicked. + + +## Property : Values (Array) + +This property can be used for setting the value of the input it was called on. + +## Property : Display Values (Array of strings) + +This property specifies the values to be displayed as options + + +## Example +ToolJet - Radio Button Widget Properties \ No newline at end of file diff --git a/docs/docs/widgets/table.md b/docs/docs/widgets/table.md index 568647f7b7..b1c277f78c 100644 --- a/docs/docs/widgets/table.md +++ b/docs/docs/widgets/table.md @@ -39,6 +39,7 @@ To display email column, the key for the column should be `user.email`. - Tags - similar to badges but the values are not predefined. - Dropdown - Multiselect dropdown +- Toggle switch ## Client-side pagination @@ -51,6 +52,8 @@ Server-side pagination can be used to run a query whenever the page is changed. ## Search Client-side search is enabled by default and server-side search can be enabled from the events section of the inspector. Whenever the search text is changed, the `searchText` property of the table component is updated. If server-side search is enabled, `on search` event is fired after the content of `searchText` property is changed. `searchText` can be used to run a specific query to search for the records in your datasource. +If you don't wish to use the search feature altogether, you can disable it from the inspector. + ## Event: On row clicked This event is triggered when a table row is clicked. `selectedRow` property of the table object will have the table data of the selected row. diff --git a/docs/docs/widgets/toggle-switch.md b/docs/docs/widgets/toggle-switch.md new file mode 100644 index 0000000000..6e197be4e8 --- /dev/null +++ b/docs/docs/widgets/toggle-switch.md @@ -0,0 +1,20 @@ +--- +sidebar_position: 2 +--- + +# Toggle Switch + +Toggle switch widget allows the user to change a setting between two states. + +The Toggle switch widget should be used if we want to make a binary choice, +such as turning something **on or off** or **enable or disable**. + +ToolJet - Toggle switch widget + + +## Event: onChange +This event is triggered whenever toggle switch is clicked. + +## Property : Label +This property can be used to set a label for the switch. +Default Label: **Toggle label** \ No newline at end of file diff --git a/docs/static/img/datasource-reference/airtable-delete.png b/docs/static/img/datasource-reference/airtable-delete.png new file mode 100644 index 0000000000..0cfc7e7d52 Binary files /dev/null and b/docs/static/img/datasource-reference/airtable-delete.png differ diff --git a/docs/static/img/datasource-reference/airtable-intro.gif b/docs/static/img/datasource-reference/airtable-intro.gif new file mode 100644 index 0000000000..e97c799525 Binary files /dev/null and b/docs/static/img/datasource-reference/airtable-intro.gif differ diff --git a/docs/static/img/datasource-reference/airtable-update-example-body.png b/docs/static/img/datasource-reference/airtable-update-example-body.png new file mode 100644 index 0000000000..b166effcac Binary files /dev/null and b/docs/static/img/datasource-reference/airtable-update-example-body.png differ diff --git a/docs/static/img/datasource-reference/airtable-update.png b/docs/static/img/datasource-reference/airtable-update.png new file mode 100644 index 0000000000..f3b2a9a721 Binary files /dev/null and b/docs/static/img/datasource-reference/airtable-update.png differ diff --git a/docs/static/img/widgets/qr-scanner/qr-scanner.jpeg b/docs/static/img/widgets/qr-scanner/qr-scanner.jpeg new file mode 100644 index 0000000000..0dc368a4ba Binary files /dev/null and b/docs/static/img/widgets/qr-scanner/qr-scanner.jpeg differ diff --git a/docs/static/img/widgets/radio-button/property.gif b/docs/static/img/widgets/radio-button/property.gif new file mode 100644 index 0000000000..d51dd91c0b Binary files /dev/null and b/docs/static/img/widgets/radio-button/property.gif differ diff --git a/docs/static/img/widgets/radio-button/widget.gif b/docs/static/img/widgets/radio-button/widget.gif new file mode 100644 index 0000000000..8f23d457b3 Binary files /dev/null and b/docs/static/img/widgets/radio-button/widget.gif differ diff --git a/docs/static/img/widgets/toggle-switch/toggle-switch.gif b/docs/static/img/widgets/toggle-switch/toggle-switch.gif new file mode 100644 index 0000000000..9f092844a6 Binary files /dev/null and b/docs/static/img/widgets/toggle-switch/toggle-switch.gif differ diff --git a/frontend/assets/images/icons/editor/left-sidebar/back.svg b/frontend/assets/images/icons/editor/left-sidebar/back.svg new file mode 100644 index 0000000000..0cf2d04965 --- /dev/null +++ b/frontend/assets/images/icons/editor/left-sidebar/back.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/assets/images/icons/editor/left-sidebar/database.svg b/frontend/assets/images/icons/editor/left-sidebar/database.svg new file mode 100644 index 0000000000..e08aede99c --- /dev/null +++ b/frontend/assets/images/icons/editor/left-sidebar/database.svg @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/assets/images/icons/editor/left-sidebar/debugger.svg b/frontend/assets/images/icons/editor/left-sidebar/debugger.svg new file mode 100644 index 0000000000..10c0a14e49 --- /dev/null +++ b/frontend/assets/images/icons/editor/left-sidebar/debugger.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/assets/images/icons/editor/left-sidebar/inspector.svg b/frontend/assets/images/icons/editor/left-sidebar/inspector.svg new file mode 100644 index 0000000000..f80b5e3431 --- /dev/null +++ b/frontend/assets/images/icons/editor/left-sidebar/inspector.svg @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/assets/images/icons/editor/left-sidebar/support.svg b/frontend/assets/images/icons/editor/left-sidebar/support.svg new file mode 100644 index 0000000000..1295c1f2e6 --- /dev/null +++ b/frontend/assets/images/icons/editor/left-sidebar/support.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/assets/images/icons/widgets/qrscanner.svg b/frontend/assets/images/icons/widgets/qrscanner.svg new file mode 100644 index 0000000000..076e9d6889 --- /dev/null +++ b/frontend/assets/images/icons/widgets/qrscanner.svg @@ -0,0 +1 @@ + diff --git a/frontend/assets/images/icons/widgets/radio-button.svg b/frontend/assets/images/icons/widgets/radio-button.svg new file mode 100644 index 0000000000..e7ac452bb5 --- /dev/null +++ b/frontend/assets/images/icons/widgets/radio-button.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/assets/images/icons/widgets/textarea.svg b/frontend/assets/images/icons/widgets/textarea.svg index 85a1ac1549..a950ea3e8d 100644 --- a/frontend/assets/images/icons/widgets/textarea.svg +++ b/frontend/assets/images/icons/widgets/textarea.svg @@ -11,4 +11,4 @@ - \ No newline at end of file + diff --git a/frontend/assets/images/icons/widgets/toggle.svg b/frontend/assets/images/icons/widgets/toggle.svg new file mode 100644 index 0000000000..7e39fbc4ab --- /dev/null +++ b/frontend/assets/images/icons/widgets/toggle.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/assets/images/icons/widgets/toggleswitch.svg b/frontend/assets/images/icons/widgets/toggleswitch.svg new file mode 100644 index 0000000000..7e39fbc4ab --- /dev/null +++ b/frontend/assets/images/icons/widgets/toggleswitch.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6720a0dc9d..7d2a26a6ba 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -5,6 +5,7 @@ "requires": true, "packages": { "": { + "name": "frontend", "version": "0.1.0", "dependencies": { "@babel/core": "^7.4.3", @@ -47,11 +48,13 @@ "react-lazyload": "^3.2.0", "react-loading-skeleton": "^2.2.0", "react-plotly.js": "^2.5.1", + "react-qr-reader": "^2.2.1", "react-resizable": "^1.11.1", "react-rnd": "^10.3.0", "react-router-dom": "^5.0.0", "react-scripts": "3.4.3", "react-select-search": "^3.0.5", + "react-spring": "^9.2.4", "react-table": "^7.6.3", "react-table-plugins": "^1.3.1", "react-toastify": "^7.0.3", @@ -2227,6 +2230,133 @@ "resolved": "https://registry.npmjs.org/@react-google-maps/marker-clusterer/-/marker-clusterer-2.1.1.tgz", "integrity": "sha512-LIqf1T2dVXRaf/gqk/7kEDpRvr1aQGlqOv/p5zwZus3qI6/3qK4jxNpc89gQ2WkCMrGwcI/fTQrnI55BZCxsLA==" }, + "node_modules/@react-spring/animated": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.2.4.tgz", + "integrity": "sha512-AfV6ZM8pCCAT29GY5C8/1bOPjZrv/7kD0vedjiE/tEYvNDwg9GlscrvsTViWR2XykJoYrDfdkYArrldWpsCJ5g==", + "dependencies": { + "@react-spring/shared": "~9.2.0", + "@react-spring/types": "~9.2.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.2.4.tgz", + "integrity": "sha512-R+PwyfsjiuYCWqaTTfCpYpRmsP0h87RNm7uxC1Uxy7QAHUfHEm2sAHn+AdHPwq/MbVwDssVT8C5yf2WGcqiXGg==", + "hasInstallScript": true, + "dependencies": { + "@react-spring/animated": "~9.2.0", + "@react-spring/shared": "~9.2.0", + "@react-spring/types": "~9.2.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0" + } + }, + "node_modules/@react-spring/konva": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@react-spring/konva/-/konva-9.2.4.tgz", + "integrity": "sha512-19anDOIkfjcydDTfGgVIuZ3lruZxKubYGs9oHCswaP8SRLj7c1kkopJHUr/S4LXGxiIdqdF0XucWm0iTEPEq4w==", + "dependencies": { + "@react-spring/animated": "~9.2.0", + "@react-spring/core": "~9.2.0", + "@react-spring/shared": "~9.2.0", + "@react-spring/types": "~9.2.0" + }, + "peerDependencies": { + "konva": ">=2.6", + "react": "^16.8.0 || ^17.0.0", + "react-konva": "^16.8.0 || ^17.0.0" + } + }, + "node_modules/@react-spring/native": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@react-spring/native/-/native-9.2.4.tgz", + "integrity": "sha512-xKJWKh5qOhSclpL3iuGwJRLoZzTNvlBEnIrMs8yh8xvX6z9Lmnu4uGu5DpfrnM1GzBvRoktoCoLEx/VcEYFSng==", + "dependencies": { + "@react-spring/animated": "~9.2.0", + "@react-spring/core": "~9.2.0", + "@react-spring/shared": "~9.2.0", + "@react-spring/types": "~9.2.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0", + "react-native": ">=0.58" + } + }, + "node_modules/@react-spring/rafz": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.2.4.tgz", + "integrity": "sha512-SOKf9eue+vAX+DGo7kWYNl9i9J3gPUlQjifIcV9Bzw9h3i30wPOOP0TjS7iMG/kLp2cdHQYDNFte6nt23VAZkQ==" + }, + "node_modules/@react-spring/shared": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.2.4.tgz", + "integrity": "sha512-ZEr4l2BxmyFRUvRA2VCkPfCJii4E7cGkwbjmTBx1EmcGrOnde/V2eF5dxqCTY3k35QuCegkrWe0coRJVkh8q2Q==", + "dependencies": { + "@react-spring/rafz": "~9.2.0", + "@react-spring/types": "~9.2.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0" + } + }, + "node_modules/@react-spring/three": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@react-spring/three/-/three-9.2.4.tgz", + "integrity": "sha512-ljFig7XW099VWwRPKPUf+4yYLivp/sSWXN3oO5SJOF/9BSoV1quS/9chZ5Myl5J14od3CsHf89Tv4FdlX5kHlA==", + "dependencies": { + "@react-spring/animated": "~9.2.0", + "@react-spring/core": "~9.2.0", + "@react-spring/shared": "~9.2.0", + "@react-spring/types": "~9.2.0" + }, + "peerDependencies": { + "@react-three/fiber": ">=6.0", + "react": ">=16.11", + "three": ">=0.126" + } + }, + "node_modules/@react-spring/types": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.2.4.tgz", + "integrity": "sha512-zHUXrWO8nweUN/ISjrjqU7GgXXvoEbFca1CgiE0TY0H/dqJb3l+Rhx8ecPVNYimzFg3ZZ1/T0egpLop8SOv4aA==" + }, + "node_modules/@react-spring/web": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-9.2.4.tgz", + "integrity": "sha512-vtPvOalLFvuju/MDBtoSnCyt0xXSL6Amyv82fljOuWPl1yGd4M1WteijnYL9Zlriljl0a3oXcPunAVYTD9dbDQ==", + "dependencies": { + "@react-spring/animated": "~9.2.0", + "@react-spring/core": "~9.2.0", + "@react-spring/shared": "~9.2.0", + "@react-spring/types": "~9.2.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0", + "react-dom": "^16.8.0 || ^17.0.0" + } + }, + "node_modules/@react-spring/zdog": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@react-spring/zdog/-/zdog-9.2.4.tgz", + "integrity": "sha512-rv7ptedS37SHr6yuCbRkUErAzAhebdgt8f4KUtZWzseC+7qLNkaZWf+uujgsb881qAuX9b9yz8rre9UKeYepgw==", + "dependencies": { + "@react-spring/animated": "~9.2.0", + "@react-spring/core": "~9.2.0", + "@react-spring/shared": "~9.2.0", + "@react-spring/types": "~9.2.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0", + "react-dom": "^16.8.0 || ^17.0.0", + "react-zdog": ">=1.0", + "zdog": ">=1.0" + } + }, "node_modules/@restart/context": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@restart/context/-/context-2.1.4.tgz", @@ -8690,7 +8820,8 @@ "node_modules/flatten": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/flatten/-/flatten-1.0.3.tgz", - "integrity": "sha512-dVsPA/UwQ8+2uoFe5GHtiBMu48dWLTdsuEd7CKGlZlD78r1TTWBvDuFaFGKCo/ZfEr95Uk56vZoX86OsHkUeIg==" + "integrity": "sha512-dVsPA/UwQ8+2uoFe5GHtiBMu48dWLTdsuEd7CKGlZlD78r1TTWBvDuFaFGKCo/ZfEr95Uk56vZoX86OsHkUeIg==", + "deprecated": "flatten is deprecated in favor of utility frameworks such as lodash." }, "node_modules/flush-write-stream": { "version": "1.1.1", @@ -11633,6 +11764,11 @@ "verror": "1.10.0" } }, + "node_modules/jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==" + }, "node_modules/killable": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz", @@ -16191,6 +16327,20 @@ "react": "^15.0.0-0 || ^16.0.0-0 || ^17.0.0-0" } }, + "node_modules/react-qr-reader": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/react-qr-reader/-/react-qr-reader-2.2.1.tgz", + "integrity": "sha512-EL5JEj53u2yAOgtpAKAVBzD/SiKWn0Bl7AZy6ZrSf1lub7xHwtaXe6XSx36Wbhl1VMGmvmrwYMRwO1aSCT2fwA==", + "dependencies": { + "jsqr": "^1.2.0", + "prop-types": "^15.7.2", + "webrtc-adapter": "^7.2.1" + }, + "peerDependencies": { + "react": "~16", + "react-dom": "~16" + } + }, "node_modules/react-resizable": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-1.11.1.tgz", @@ -17207,6 +17357,19 @@ "node": ">=6" } }, + "node_modules/react-spring": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/react-spring/-/react-spring-9.2.4.tgz", + "integrity": "sha512-bMjbyTW0ZGd+/h9cjtohLqCwOGqX2OuaTvalOVfLCGmhzEg/u3GgopI3LAm4UD2Br3MNdVdGgNVoESg4MGqKFQ==", + "dependencies": { + "@react-spring/core": "~9.2.0", + "@react-spring/konva": "~9.2.0", + "@react-spring/native": "~9.2.0", + "@react-spring/three": "~9.2.0", + "@react-spring/web": "~9.2.0", + "@react-spring/zdog": "~9.2.0" + } + }, "node_modules/react-table": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/react-table/-/react-table-7.6.3.tgz", @@ -17954,6 +18117,18 @@ "node": "6.* || >= 7.*" } }, + "node_modules/rtcpeerconnection-shim": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rtcpeerconnection-shim/-/rtcpeerconnection-shim-1.2.15.tgz", + "integrity": "sha512-C6DxhXt7bssQ1nHb154lqeL0SXz5Dx4RczXZu2Aa/L1NJFnEVDxFwCBo3fqtuljhHIGceg5JKBV4XJ0gW5JKyw==", + "dependencies": { + "sdp": "^2.6.0" + }, + "engines": { + "node": ">=6.0.0", + "npm": ">=3.10.0" + } + }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -18179,6 +18354,11 @@ "node": ">=0.8.0" } }, + "node_modules/sdp": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-2.12.0.tgz", + "integrity": "sha512-jhXqQAQVM+8Xj5EjJGVweuEzgtGWb3tmEEpl3CLP3cStInSbVHSg0QWOGQzNq8pSID4JkpeV2mPqlMDLrm0/Vw==" + }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -19279,6 +19459,7 @@ "version": "2.2.2", "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.2.tgz", "integrity": "sha512-FCEhQ/4rE1zYv9rYXJw/msRqsnmlje5jHP6huWeBZ704jUTy02c5AZyWujpMR1ax6mVw9NyJMfuK2CMDWVIfgA==", + "deprecated": "This version of tar is no longer supported, and will not receive security updates. Please upgrade asap.", "dependencies": { "block-stream": "*", "fstream": "^1.0.12", @@ -20993,6 +21174,19 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, + "node_modules/webrtc-adapter": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-7.7.1.tgz", + "integrity": "sha512-TbrbBmiQBL9n0/5bvDdORc6ZfRY/Z7JnEj+EYOD1ghseZdpJ+nF2yx14k3LgQKc7JZnG7HAcL+zHnY25So9d7A==", + "dependencies": { + "rtcpeerconnection-shim": "^1.2.15", + "sdp": "^2.12.0" + }, + "engines": { + "node": ">=6.0.0", + "npm": ">=3.10.0" + } + }, "node_modules/websocket-driver": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", @@ -23233,6 +23427,99 @@ "resolved": "https://registry.npmjs.org/@react-google-maps/marker-clusterer/-/marker-clusterer-2.1.1.tgz", "integrity": "sha512-LIqf1T2dVXRaf/gqk/7kEDpRvr1aQGlqOv/p5zwZus3qI6/3qK4jxNpc89gQ2WkCMrGwcI/fTQrnI55BZCxsLA==" }, + "@react-spring/animated": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.2.4.tgz", + "integrity": "sha512-AfV6ZM8pCCAT29GY5C8/1bOPjZrv/7kD0vedjiE/tEYvNDwg9GlscrvsTViWR2XykJoYrDfdkYArrldWpsCJ5g==", + "requires": { + "@react-spring/shared": "~9.2.0", + "@react-spring/types": "~9.2.0" + } + }, + "@react-spring/core": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.2.4.tgz", + "integrity": "sha512-R+PwyfsjiuYCWqaTTfCpYpRmsP0h87RNm7uxC1Uxy7QAHUfHEm2sAHn+AdHPwq/MbVwDssVT8C5yf2WGcqiXGg==", + "requires": { + "@react-spring/animated": "~9.2.0", + "@react-spring/shared": "~9.2.0", + "@react-spring/types": "~9.2.0" + } + }, + "@react-spring/konva": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@react-spring/konva/-/konva-9.2.4.tgz", + "integrity": "sha512-19anDOIkfjcydDTfGgVIuZ3lruZxKubYGs9oHCswaP8SRLj7c1kkopJHUr/S4LXGxiIdqdF0XucWm0iTEPEq4w==", + "requires": { + "@react-spring/animated": "~9.2.0", + "@react-spring/core": "~9.2.0", + "@react-spring/shared": "~9.2.0", + "@react-spring/types": "~9.2.0" + } + }, + "@react-spring/native": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@react-spring/native/-/native-9.2.4.tgz", + "integrity": "sha512-xKJWKh5qOhSclpL3iuGwJRLoZzTNvlBEnIrMs8yh8xvX6z9Lmnu4uGu5DpfrnM1GzBvRoktoCoLEx/VcEYFSng==", + "requires": { + "@react-spring/animated": "~9.2.0", + "@react-spring/core": "~9.2.0", + "@react-spring/shared": "~9.2.0", + "@react-spring/types": "~9.2.0" + } + }, + "@react-spring/rafz": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.2.4.tgz", + "integrity": "sha512-SOKf9eue+vAX+DGo7kWYNl9i9J3gPUlQjifIcV9Bzw9h3i30wPOOP0TjS7iMG/kLp2cdHQYDNFte6nt23VAZkQ==" + }, + "@react-spring/shared": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.2.4.tgz", + "integrity": "sha512-ZEr4l2BxmyFRUvRA2VCkPfCJii4E7cGkwbjmTBx1EmcGrOnde/V2eF5dxqCTY3k35QuCegkrWe0coRJVkh8q2Q==", + "requires": { + "@react-spring/rafz": "~9.2.0", + "@react-spring/types": "~9.2.0" + } + }, + "@react-spring/three": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@react-spring/three/-/three-9.2.4.tgz", + "integrity": "sha512-ljFig7XW099VWwRPKPUf+4yYLivp/sSWXN3oO5SJOF/9BSoV1quS/9chZ5Myl5J14od3CsHf89Tv4FdlX5kHlA==", + "requires": { + "@react-spring/animated": "~9.2.0", + "@react-spring/core": "~9.2.0", + "@react-spring/shared": "~9.2.0", + "@react-spring/types": "~9.2.0" + } + }, + "@react-spring/types": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.2.4.tgz", + "integrity": "sha512-zHUXrWO8nweUN/ISjrjqU7GgXXvoEbFca1CgiE0TY0H/dqJb3l+Rhx8ecPVNYimzFg3ZZ1/T0egpLop8SOv4aA==" + }, + "@react-spring/web": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-9.2.4.tgz", + "integrity": "sha512-vtPvOalLFvuju/MDBtoSnCyt0xXSL6Amyv82fljOuWPl1yGd4M1WteijnYL9Zlriljl0a3oXcPunAVYTD9dbDQ==", + "requires": { + "@react-spring/animated": "~9.2.0", + "@react-spring/core": "~9.2.0", + "@react-spring/shared": "~9.2.0", + "@react-spring/types": "~9.2.0" + } + }, + "@react-spring/zdog": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@react-spring/zdog/-/zdog-9.2.4.tgz", + "integrity": "sha512-rv7ptedS37SHr6yuCbRkUErAzAhebdgt8f4KUtZWzseC+7qLNkaZWf+uujgsb881qAuX9b9yz8rre9UKeYepgw==", + "requires": { + "@react-spring/animated": "~9.2.0", + "@react-spring/core": "~9.2.0", + "@react-spring/shared": "~9.2.0", + "@react-spring/types": "~9.2.0" + } + }, "@restart/context": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@restart/context/-/context-2.1.4.tgz", @@ -30684,6 +30971,11 @@ "verror": "1.10.0" } }, + "jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==" + }, "killable": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz", @@ -34357,6 +34649,16 @@ "prop-types": "^15.5.8" } }, + "react-qr-reader": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/react-qr-reader/-/react-qr-reader-2.2.1.tgz", + "integrity": "sha512-EL5JEj53u2yAOgtpAKAVBzD/SiKWn0Bl7AZy6ZrSf1lub7xHwtaXe6XSx36Wbhl1VMGmvmrwYMRwO1aSCT2fwA==", + "requires": { + "jsqr": "^1.2.0", + "prop-types": "^15.7.2", + "webrtc-adapter": "^7.2.1" + } + }, "react-resizable": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-1.11.1.tgz", @@ -35148,6 +35450,19 @@ } } }, + "react-spring": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/react-spring/-/react-spring-9.2.4.tgz", + "integrity": "sha512-bMjbyTW0ZGd+/h9cjtohLqCwOGqX2OuaTvalOVfLCGmhzEg/u3GgopI3LAm4UD2Br3MNdVdGgNVoESg4MGqKFQ==", + "requires": { + "@react-spring/core": "~9.2.0", + "@react-spring/konva": "~9.2.0", + "@react-spring/native": "~9.2.0", + "@react-spring/three": "~9.2.0", + "@react-spring/web": "~9.2.0", + "@react-spring/zdog": "~9.2.0" + } + }, "react-table": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/react-table/-/react-table-7.6.3.tgz", @@ -35727,6 +36042,14 @@ "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", "integrity": "sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA==" }, + "rtcpeerconnection-shim": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rtcpeerconnection-shim/-/rtcpeerconnection-shim-1.2.15.tgz", + "integrity": "sha512-C6DxhXt7bssQ1nHb154lqeL0SXz5Dx4RczXZu2Aa/L1NJFnEVDxFwCBo3fqtuljhHIGceg5JKBV4XJ0gW5JKyw==", + "requires": { + "sdp": "^2.6.0" + } + }, "run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -35891,6 +36214,11 @@ } } }, + "sdp": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-2.12.0.tgz", + "integrity": "sha512-jhXqQAQVM+8Xj5EjJGVweuEzgtGWb3tmEEpl3CLP3cStInSbVHSg0QWOGQzNq8pSID4JkpeV2mPqlMDLrm0/Vw==" + }, "select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -38141,6 +38469,15 @@ "source-map": "~0.6.1" } }, + "webrtc-adapter": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-7.7.1.tgz", + "integrity": "sha512-TbrbBmiQBL9n0/5bvDdORc6ZfRY/Z7JnEj+EYOD1ghseZdpJ+nF2yx14k3LgQKc7JZnG7HAcL+zHnY25So9d7A==", + "requires": { + "rtcpeerconnection-shim": "^1.2.15", + "sdp": "^2.12.0" + } + }, "websocket-driver": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index c5f235a516..3f2b5b24c1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -43,11 +43,13 @@ "react-lazyload": "^3.2.0", "react-loading-skeleton": "^2.2.0", "react-plotly.js": "^2.5.1", + "react-qr-reader": "^2.2.1", "react-resizable": "^1.11.1", "react-rnd": "^10.3.0", "react-router-dom": "^5.0.0", "react-scripts": "3.4.3", "react-select-search": "^3.0.5", + "react-spring": "^9.2.4", "react-table": "^7.6.3", "react-table-plugins": "^1.3.1", "react-toastify": "^7.0.3", @@ -55,6 +57,7 @@ "rxjs": "^6.3.3", "semver": "^5.7.1", "tinycolor2": "^1.4.2", + "uuid": "8.3.2", "webpack-cli": "^3.3.0", "yup": "^0.27.0" }, diff --git a/frontend/src/App/App.jsx b/frontend/src/App/App.jsx index 4ffb486917..9eb2edd322 100644 --- a/frontend/src/App/App.jsx +++ b/frontend/src/App/App.jsx @@ -13,6 +13,7 @@ import '@/_styles/theme.scss'; import { ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; import { ManageOrgUsers } from '@/ManageOrgUsers'; +import { SettingsPage } from '../SettingsPage/SettingsPage'; import { OnboardingModal } from '@/Onboarding/OnboardingModal'; import {ForgotPassword} from '@/ForgotPassword' import { ResetPassword } from '@/ResetPassword'; @@ -87,6 +88,7 @@ class App extends React.Component { + ); diff --git a/frontend/src/Editor/Box.jsx b/frontend/src/Editor/Box.jsx index 8d2eaee7cd..803ceedf78 100644 --- a/frontend/src/Editor/Box.jsx +++ b/frontend/src/Editor/Box.jsx @@ -15,6 +15,9 @@ import { Multiselect } from './Components/Multiselect'; import { Modal } from './Components/Modal'; import { Chart } from './Components/Chart'; import { Map } from './Components/Map/Map'; +import { QrScanner } from './Components/QrScanner/QrScanner'; +import { ToggleSwitch } from './Components/Toggle' +import { RadioButton } from './Components/RadioButton' import { renderTooltip } from '../_helpers/appUtils'; import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; @@ -35,6 +38,9 @@ const AllComponents = { Modal, Chart, Map, + QrScanner, + ToggleSwitch, + RadioButton }; export const Box = function Box({ diff --git a/frontend/src/Editor/CodeBuilder/CodeHinter.jsx b/frontend/src/Editor/CodeBuilder/CodeHinter.jsx index 762fee1d7d..3b71a776a1 100644 --- a/frontend/src/Editor/CodeBuilder/CodeHinter.jsx +++ b/frontend/src/Editor/CodeBuilder/CodeHinter.jsx @@ -24,11 +24,14 @@ export function CodeHinter({ placeholder, ignoreBraces, enablePreview, - height + height, + minHeight, + lineWrapping }) { console.log('theme', theme) const options = { lineNumbers: lineNumbers, + lineWrapping: lineWrapping, singleLine: true, mode: mode || 'handlebars', tabSize: 2, @@ -55,12 +58,16 @@ export function CodeHinter({ } return ( -
+
{ const value = editor.getValue(); onChange(value); @@ -70,7 +77,7 @@ export function CodeHinter({ options={options} /> {enablePreview && -
+
{resolveReferences(currentValue, realState)}
} diff --git a/frontend/src/Editor/Components/DropDown.jsx b/frontend/src/Editor/Components/DropDown.jsx index 6378ab579f..fccef68473 100644 --- a/frontend/src/Editor/Components/DropDown.jsx +++ b/frontend/src/Editor/Components/DropDown.jsx @@ -57,11 +57,11 @@ export const DropDown = function DropDown({ }, [currentValue]); return ( -
onComponentClick(id, component)}> +
onComponentClick(id, component)}>
- +
-
+
diff --git a/frontend/src/Editor/Components/Multiselect.jsx b/frontend/src/Editor/Components/Multiselect.jsx index 8b1587ecac..06fa69e1fa 100644 --- a/frontend/src/Editor/Components/Multiselect.jsx +++ b/frontend/src/Editor/Components/Multiselect.jsx @@ -40,11 +40,11 @@ export const Multiselect = function Multiselect({ }, [newValue]); return ( -
onComponentClick(id, component)}> +
onComponentClick(id, component)}>
- +
-
+
{ + setShow(false) + } + + return( +
+ { + show ? + + : + '' + } +
+ ); +}; diff --git a/frontend/src/Editor/Components/QrScanner/QrScanner.jsx b/frontend/src/Editor/Components/QrScanner/QrScanner.jsx new file mode 100644 index 0000000000..fc51535e8c --- /dev/null +++ b/frontend/src/Editor/Components/QrScanner/QrScanner.jsx @@ -0,0 +1,36 @@ +import React, { useState } from 'react'; +import QrReader from 'react-qr-reader'; +import ErrorModal from './ErrorModal'; + +export const QrScanner = function QrScanner({ + component, onEvent, onComponentOptionChanged +}) { + + const handleError = async (errorMessage) => { + console.log(errorMessage); + setErrorOccured(true); + }; + + const handleScan = async (data) => { + if (data != null) { + onEvent('onDetect', { component, data: data }); + onComponentOptionChanged(component, 'lastDetectedValue', data); + }; + }; + + let [errorOccured, setErrorOccured] = useState(false); + + return ( +
+ { + errorOccured ? + + : + + } +
+ ); +}; diff --git a/frontend/src/Editor/Components/RadioButton.jsx b/frontend/src/Editor/Components/RadioButton.jsx new file mode 100644 index 0000000000..01fe6b487c --- /dev/null +++ b/frontend/src/Editor/Components/RadioButton.jsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { resolveReferences } from '@/_helpers/utils'; + + +export const RadioButton = function RadioButton({ + id, + width, + height, + component, + onComponentClick, + currentState, + onComponentOptionChanged, + onEvent +}) { + + const label = component.definition.properties.label.value; + const textColorProperty = component.definition.styles.textColor; + const textColor = textColorProperty ? textColorProperty.value : '#000'; + + const values = component.definition.properties.values.value; + const displayValues = component.definition.properties.display_values.value; + + let parsedValues = values; + + try { + parsedValues = resolveReferences(values, currentState, []); + } catch (err) { console.log(err); } + + let parsedDisplayValues = displayValues; + + try { + parsedDisplayValues = resolveReferences(displayValues, currentState, []); + } catch (err) { console.log(err); } + + let selectOptions = []; + + try { + selectOptions = [ + ...parsedValues.map((value, index) => { + return { name: parsedDisplayValues[index], value: value }; + }) + ]; + } catch (err) { console.log(err); } + + + function onSelect(event) { + const selection = event.target.value + onComponentOptionChanged(component, 'value', selection); + if (selection) { + onEvent('onSelectionChange', { component }); + } + } + + + + return ( +
onComponentClick(id, component)}> + {label} +
onSelect(e)}> + {selectOptions.map((option, index) => ( + + ))} +
+
+ ); +}; diff --git a/frontend/src/Editor/Components/Table/Radio.jsx b/frontend/src/Editor/Components/Table/Radio.jsx index b6b704d83b..5e4b5c5a93 100644 --- a/frontend/src/Editor/Components/Table/Radio.jsx +++ b/frontend/src/Editor/Components/Table/Radio.jsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; export const Radio = ({ options, value, onChange, readOnly }) => { - value = value || []; + value = value === undefined ? [] : value; return (
diff --git a/frontend/src/Editor/Components/Table/Table.jsx b/frontend/src/Editor/Components/Table/Table.jsx index a97f509881..8e023233c0 100644 --- a/frontend/src/Editor/Components/Table/Table.jsx +++ b/frontend/src/Editor/Components/Table/Table.jsx @@ -18,6 +18,7 @@ import { Pagination } from './Pagination'; import { CustomSelect } from './CustomSelect'; import { Tags } from './Tags'; import { Radio } from './Radio'; +import { Toggle } from './Toggle' var _ = require('lodash'); @@ -43,6 +44,9 @@ export function Table({ const serverSideSearchProperty = component.definition.properties.serverSideSearch; const serverSideSearch = serverSideSearchProperty ? serverSideSearchProperty.value : false; + const displaySearchBoxProperty = component.definition.properties.displaySearchBox; + const displaySearchBox = displaySearchBoxProperty ? displaySearchBoxProperty.value : true; + const [loadingState, setLoadingState] = useState(false); useEffect(() => { @@ -226,7 +230,7 @@ export function Table({ const changeSet = componentState ? componentState.changeSet : {}; const columnData = component.definition.properties.columns.value.map((column) => { - const columnSize = columnSizes[column.key] || columnSizes[column.name]; + const columnSize = columnSizes[column.id] || columnSizes[column.name]; const columnType = column.columnType; const columnOptions = {}; @@ -244,6 +248,7 @@ export function Table({ const width = columnSize || defaultColumn.width; return { + id: column.id, Header: column.name, accessor: column.key || column.name, filter: customFilter, @@ -363,6 +368,18 @@ export function Table({ onChange={(value) => { handleCellValueChange(cell.row.index, column.key || column.name, value, cell.row.original); }} + /> +
+ ); + } if (columnType === 'toggle') { + return ( +
+ { + handleCellValueChange(cell.row.index, column.key || column.name, value, cell.row.original); + }} />
); @@ -517,34 +534,37 @@ export function Table({ style={{ width: `${width}px`, height: `${height}px` }} onClick={() => onComponentClick(id, component)} > -
-
- {!serverSidePagination && -
- Show -
- + {/* Show top bar unless search box is disabled and server pagination is enabled */} + {(!(!displaySearchBox && serverSidePagination) && +
+
+ {!serverSidePagination && +
+ Show +
+ +
+ entries
- entries -
- } -
- + } + {displaySearchBox &&
+ +
}
-
+ )}
@@ -590,7 +610,8 @@ export function Table({ if (componentState.changeSet) { if (componentState.changeSet[cell.row.index]) { - if (_.get(componentState.changeSet[cell.row.index], cell.column.id, undefined)) { + + if (_.get(componentState.changeSet[cell.row.index], cell.column.Header, undefined) !== undefined) { console.log('componentState.changeSet', componentState.changeSet); cellProps.style.backgroundColor = '#ffffde'; } @@ -680,7 +701,7 @@ export function Table({
{ - return { name: column.Header, value: column.accessor }; + return { name: column.Header, value: column.id }; })} value={filter.id} search={true} diff --git a/frontend/src/Editor/Components/Table/Toggle.jsx b/frontend/src/Editor/Components/Table/Toggle.jsx new file mode 100644 index 0000000000..f80d098f19 --- /dev/null +++ b/frontend/src/Editor/Components/Table/Toggle.jsx @@ -0,0 +1,25 @@ +import React, { useState } from 'react'; + +export const Toggle = ({readOnly, value, onChange, options }) => { + const [on, setOn] = useState(() => value) + + const toggle = () => { + setOn((prev) => !prev) + onChange(!on) + } + + return ( +
+
+ +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/Editor/Components/Toggle.jsx b/frontend/src/Editor/Components/Toggle.jsx new file mode 100644 index 0000000000..412527f416 --- /dev/null +++ b/frontend/src/Editor/Components/Toggle.jsx @@ -0,0 +1,57 @@ +import React from 'react'; + +class Switch extends React.Component { + render() { + const { + on, + onClick, + onChange + } = this.props + return ( + + ) + } + } + +export const ToggleSwitch = ({ + id, + width, + height, + component, + onComponentClick, + currentState, + onComponentOptionChanged, + onEvent +}) => { + + const [on, setOn] = React.useState(false) + const label = component.definition.properties.label.value; + const textColorProperty = component.definition.styles.textColor; + const textColor = textColorProperty ? textColorProperty.value : '#000'; + + function toggleValue(e) { + const toggled = e.target.checked; + onComponentOptionChanged(component, 'value', toggled); + onEvent('onChange', { component }); + + } + + const toggle = () => setOn(!on) + + return ( +
onComponentClick(id, component)}> + {label} +
+ +
+
+ ); +}; diff --git a/frontend/src/Editor/Components/components.js b/frontend/src/Editor/Components/components.js index fa431828a5..98483998de 100644 --- a/frontend/src/Editor/Components/components.js +++ b/frontend/src/Editor/Components/components.js @@ -12,7 +12,8 @@ export const componentTypes = [ serverSidePagination: { type: 'toggle', displayName: 'Server-side pagination'}, serverSideSearch: { type: 'toggle', displayName: 'Server-side search'}, actionButtonBackgroundColor: { type: 'color', displayName: 'Background color'}, - actionButtonTextColor: { type: 'color', displayName: 'Text color'} + actionButtonTextColor: { type: 'color', displayName: 'Text color'}, + displaySearchBox: { type: 'toggle', displayName: 'Display search box' } }, others: { showOnDesktop: { type: 'toggle', displayName: 'Show on desktop? '}, @@ -47,13 +48,14 @@ export const componentTypes = [ title: { value: 'Table' }, visible: { value: true }, loadingState: { value: false }, - data: { value: '{{[]}}' }, + data: { value: "{{ [ \n\t\t{ id: 1, name: 'Sarah', email: 'sarah@example.com'}, \n\t\t{ id: 2, name: 'Lisa', email: 'lisa@example.com'}, \n\t\t{ id: 3, name: 'Sam', email: 'sam@example.com'}, \n\t\t{ id: 4, name: 'Jon', email: 'jon@example.com'} \n] }}" }, serverSidePagination: { value: false }, + displaySearchBox: { value: true }, columns: { value: [ - { name: 'id' }, - { name: 'name' }, - { name: 'email' } + { name: 'id', id: "e3ecbf7fa52c4d7210a93edb8f43776267a489bad52bd108be9588f790126737"}, + { name: 'name', id: "5d2a3744a006388aadd012fcc15cc0dbcb5f9130e0fbb64c558561c97118754a"}, + { name: 'email', id: "afc9a5091750a1bd4760e38760de3b4be11a43452ae8ae07ce2eebc569fe9a7f"} ] } }, @@ -255,7 +257,7 @@ export const componentTypes = [ }, exposedVariables: { - value: {} + value: '' }, definition: { others: { @@ -365,6 +367,104 @@ export const componentTypes = [ } } }, + { + name: 'Radio-button', + displayName: 'Radio Button', + description: 'Radio buttons', + component: 'RadioButton', + defaultSize: { + width: 200, + height: 30 + }, + others: { + showOnDesktop: { type: 'toggle', displayName: 'Show on desktop? '}, + showOnMobile: { type: 'toggle', displayName: 'Show on mobile?'}, + }, + properties: { + label: { type: 'code', displayName: 'Label' }, + value: { type: 'code', displayName: 'Default value' }, + values: { type: 'code', displayName: 'Option values' }, + display_values: { type: 'code', displayName: 'Option labels' } + }, + events: { + onSelectionChange: { displayName: 'On select'}, + }, + styles: { + textColor: { type: 'color', displayName: 'Text Color' } + }, + exposedVariables: {}, + definition: { + others: { + showOnDesktop: { value: true }, + showOnMobile: { value : false } + }, + properties: { + label: { value: 'Select' }, + value: { value: '' }, + values: { value: '{{[true,false]}}' }, + display_values: { value: '{{["yes", "no"]}}' }, + visible: { value: true } + }, + events: { + onSelectionChange: { + options: { + + } + }, + }, + styles: { + textColor: { value: '#000' } + } + } + }, + { + name: 'ToggleSwitch', + displayName: 'Toggle Switch', + description: 'Toggle Switch', + component: 'ToggleSwitch', + defaultSize: { + width: 130, + height: 30 + }, + others: { + showOnDesktop: { type: 'toggle', displayName: 'Show on desktop? '}, + showOnMobile: { type: 'toggle', displayName: 'Show on mobile?'}, + }, + properties: { + label: { type: 'code', displayName: 'Label' } + }, + events: { + onChange: { displayName: 'On change'}, + }, + styles: { + textColor: { type: 'color', displayName: 'Text Color' } + }, + exposedVariables: {}, + definition: { + others: { + showOnDesktop: { value: true }, + showOnMobile: { value : false } + }, + properties: { + label: { value: 'Toggle label' } + }, + events: { + onToggle: { + options: { + + } + }, + onUnToggle: { + options: { + + } + } + }, + styles: { + textColor: { value: '#000' } + } + } + }, { name: 'Textarea', displayName: 'Textarea', @@ -616,7 +716,7 @@ export const componentTypes = [ description: 'Select multiple values from options', defaultSize: { width: 200, - height: 60 + height: 37 }, component: 'Multiselect', others: { @@ -759,4 +859,41 @@ defaultMarkers: { value: `[{ } } }, + { + name: 'QrScanner', + displayName: 'QR Scanner', + description: 'Scan QR codes and hold its data', + component: 'QrScanner', + defaultSize: { + width: 300, + height: 300 + }, + others: { + showOnDesktop: { type: 'toggle', displayName: 'Show on desktop? '}, + showOnMobile: { type: 'toggle', displayName: 'Show on mobile?'}, + }, + properties: {}, + events: { + onDetect: { displayName: 'On detect'}, + }, + styles: { + + }, + exposedVariables: { + lastDetectedValue: '' + }, + definition: { + others: { + showOnDesktop: { value: true }, + showOnMobile: { value : true }, + }, + properties: {}, + events: { + onDetect: { + } + }, + styles: { + } + } + } ]; diff --git a/frontend/src/Editor/Container.jsx b/frontend/src/Editor/Container.jsx index 83feddf796..892122d3d0 100644 --- a/frontend/src/Editor/Container.jsx +++ b/frontend/src/Editor/Container.jsx @@ -304,9 +304,8 @@ export const Container = ({ } } )} - {Object.keys(boxes).length === 0 && !appLoading && !isDragging && ( -
+
You haven't added any components yet. Drag components from the right sidebar and drop here.
)} diff --git a/frontend/src/Editor/DataSourceManager/DataSourceManager.jsx b/frontend/src/Editor/DataSourceManager/DataSourceManager.jsx index 8720070798..a25482f19d 100644 --- a/frontend/src/Editor/DataSourceManager/DataSourceManager.jsx +++ b/frontend/src/Editor/DataSourceManager/DataSourceManager.jsx @@ -41,6 +41,16 @@ class DataSourceManager extends React.Component { }); } + componentDidUpdate(prevProps) { + if (prevProps.selectedDataSource != this.props.selectedDataSource) { + this.setState({ + selectedDataSource: this.props.selectedDataSource, + options: this.props.selectedDataSource?.options, + dataSourceMeta: DataSourceTypes.find((source) => source.kind === this.props.selectedDataSource?.kind), + }); + } + } + selectDataSource = (source) => { this.setState({ dataSourceMeta: source, @@ -59,6 +69,14 @@ class DataSourceManager extends React.Component { }); }; + onExit = () => { + this.setState({ + dataSourceMeta: {}, + selectedDataSource: null, + options: {}, + }); + }; + setStateAsync = (state) => { return new Promise((resolve) => { this.setState(state, resolve); @@ -76,6 +94,7 @@ class DataSourceManager extends React.Component { }; hideModal = () => { + this.onExit(); this.props.hideModal(); }; @@ -131,12 +150,10 @@ class DataSourceManager extends React.Component { onConnectionTestFailed = (data) => { this.setState({ connectionTestError: data }); - } + }; render() { - const { - dataSourceMeta, selectedDataSource, options, isSaving, connectionTestError - } = this.state; + const { dataSourceMeta, selectedDataSource, options, isSaving, connectionTestError } = this.state; return (
@@ -147,6 +164,7 @@ class DataSourceManager extends React.Component { className="mt-5" contentClassName={this.props.darkMode ? 'theme-dark' : ''} animation={false} + onExit={this.onExit} > @@ -207,7 +225,7 @@ class DataSourceManager extends React.Component {
))}
-
+

APIS

{apiSources.map((dataSource) => (
@@ -240,33 +258,38 @@ class DataSourceManager extends React.Component {
-
- Please white-list our IP address if your datasource is not publicly accessible. - IP: {config.SERVER_IP} - toast.success('IP copied to clipboard', { - hideProgressBar: true, - position: 'top-center' - }) - } - > - - -
-
-
- - {connectionTestError && -
-
- {connectionTestError.message} + Please white-list our IP address if your datasource is not publicly accessible. IP:{' '} + {config.SERVER_IP} + + toast.success('IP copied to clipboard', { + hideProgressBar: true, + position: 'top-center', + }) + } + > + +
+
+ + {connectionTestError && ( +
+
+
{connectionTestError.message}
+
- } - + )} +
-
@@ -289,7 +312,7 @@ class DataSourceManager extends React.Component { onClick={this.createDataSource} > {'Save'} - +
)} diff --git a/frontend/src/Editor/Editor.jsx b/frontend/src/Editor/Editor.jsx index 9073df589c..4ba42d4ee0 100644 --- a/frontend/src/Editor/Editor.jsx +++ b/frontend/src/Editor/Editor.jsx @@ -1,16 +1,15 @@ import React from 'react'; -import { - datasourceService, dataqueryService, appService, authenticationService -} from '@/_services'; -import { DarkModeToggle } from '@/_components/DarkModeToggle'; +import { datasourceService, dataqueryService, appService, authenticationService } from '@/_services'; +// import { DarkModeToggle } from '@/_components/DarkModeToggle'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { Container } from './Container'; import { CustomDragLayer } from './CustomDragLayer'; +import { LeftSidebar } from './LeftSidebar'; import { componentTypes } from './Components/components'; import { Inspector } from './Inspector/Inspector'; -import ReactJson from 'react-json-view'; -import { DataSourceManager } from './DataSourceManager'; +// import ReactJson from 'react-json-view'; +// import { DataSourceManager } from './DataSourceManager'; import { DataSourceTypes } from './DataSourceManager/DataSourceTypes'; import { QueryManager } from './QueryManager'; import { toast } from 'react-toastify'; @@ -25,11 +24,11 @@ import { onQueryConfirm, onQueryCancel, runQuery, - setStateAsync + setStateAsync, } from '@/_helpers/appUtils'; import { Confirm } from './Viewer/Confirm'; import ReactTooltip from 'react-tooltip'; -import { Resizable } from 're-resizable'; +// import { Resizable } from 're-resizable'; import { WidgetManager } from './WidgetManager'; import Fuse from 'fuse.js'; import queryString from 'query-string'; @@ -47,7 +46,7 @@ class Editor extends React.Component { userVars = { email: currentUser.email, firstName: currentUser.first_name, - lastName: currentUser.last_name + lastName: currentUser.last_name, }; } @@ -68,19 +67,19 @@ class Editor extends React.Component { scaleValue: 1, deviceWindowWidth: 450, appDefinition: { - components: null + components: null, }, currentState: { queries: {}, components: {}, globals: { currentUser: userVars, - urlparams: JSON.parse(JSON.stringify(queryString.parse(props.location.search))) - } + urlparams: JSON.parse(JSON.stringify(queryString.parse(props.location.search))), + }, }, apps: [], - dataQueriesDefaultText: 'You haven\'t created queries yet.', - showQuerySearchField: false + dataQueriesDefaultText: "You haven't created queries yet.", + showQuerySearchField: false, }; } @@ -88,41 +87,45 @@ class Editor extends React.Component { const appId = this.props.match.params.id; this.fetchApps(0); - appService.getApp(appId).then((data) => this.setState( - { - app: data, - isLoading: false, - appDefinition: { ...this.state.appDefinition, ...data.definition }, - slug: data.slug - }, - () => { - data.data_queries.forEach((query) => { - if (query.options.runOnPageLoad) { - runQuery(this, query.id, query.name); - } - }); - } - )); + appService.getApp(appId).then((data) => + this.setState( + { + app: data, + isLoading: false, + appDefinition: { ...this.state.appDefinition, ...data.definition }, + slug: data.slug, + }, + () => { + data.data_queries.forEach((query) => { + if (query.options.runOnPageLoad) { + runQuery(this, query.id, query.name); + } + }); + } + ) + ); this.fetchDataSources(); this.fetchDataQueries(); this.setState({ currentSidebarTab: 2, - selectedComponent: null + selectedComponent: null, }); } fetchDataSources = () => { this.setState( { - loadingDataSources: true + loadingDataSources: true, }, () => { - datasourceService.getAll(this.state.appId).then((data) => this.setState({ - dataSources: data.data_sources, - loadingDataSources: false - })); + datasourceService.getAll(this.state.appId).then((data) => + this.setState({ + dataSources: data.data_sources, + loadingDataSources: false, + }) + ); } ); }; @@ -130,7 +133,7 @@ class Editor extends React.Component { fetchDataQueries = () => { this.setState( { - loadingDataQueries: true + loadingDataQueries: true, }, () => { dataqueryService.getAll(this.state.appId).then((data) => { @@ -140,15 +143,15 @@ class Editor extends React.Component { loadingDataQueries: false, app: { ...this.state.app, - data_queries: data.data_queries - } + data_queries: data.data_queries, + }, }, () => { let queryState = {}; data.data_queries.forEach((query) => { queryState[query.name] = { ...DataSourceTypes.find((source) => source.kind === query.kind).exposedVariables, - ...this.state.currentState.queries[query.name] + ...this.state.currentState.queries[query.name], }; }); @@ -170,9 +173,9 @@ class Editor extends React.Component { currentState: { ...this.state.currentState, queries: { - ...queryState - } - } + ...queryState, + }, + }, }); } ); @@ -182,11 +185,13 @@ class Editor extends React.Component { }; fetchApps = (page) => { - appService.getAll(page).then((data) => this.setState({ - apps: data.apps, - isLoading: false - })); - } + appService.getAll(page).then((data) => + this.setState({ + apps: data.apps, + isLoading: false, + }) + ); + }; computeComponentState = (components) => { let componentState = {}; @@ -205,9 +210,9 @@ class Editor extends React.Component { currentState: { ...this.state.currentState, components: { - ...componentState - } - } + ...componentState, + }, + }, }); }; @@ -221,12 +226,11 @@ class Editor extends React.Component { }; switchSidebarTab = (tabIndex) => { - if (tabIndex == 2) - { + if (tabIndex == 2) { this.setState({ selectedComponent: null }); } this.setState({ - currentSidebarTab: tabIndex + currentSidebarTab: tabIndex, }); }; @@ -280,6 +284,7 @@ class Editor extends React.Component { componentDefinitionChanged = (newDefinition) => { let _self = this; + return setStateAsync(_self, { appDefinition: { ...this.state.appDefinition, @@ -287,11 +292,10 @@ class Editor extends React.Component { ...this.state.appDefinition.components, [newDefinition.id]: { ...this.state.appDefinition.components[newDefinition.id], - component: newDefinition.component, - layouts: newDefinition.layouts - } - } - } + component: newDefinition.component + }, + }, + }, }); }; @@ -303,10 +307,10 @@ class Editor extends React.Component { ...this.state.appDefinition.components, [newComponent.id]: { ...this.state.appDefinition.components[newComponent.id], - ...newComponent - } - } - } + ...newComponent, + }, + }, + }, }); }; @@ -375,7 +379,7 @@ class Editor extends React.Component { runQuery(this, dataQuery.id, dataQuery.name).then(() => { toast.info(`Query (${dataQuery.name}) completed.`, { hideProgressBar: true, - position: 'bottom-center' + position: 'bottom-center', }); }); }} @@ -397,13 +401,13 @@ class Editor extends React.Component { onNameChanged = (newName) => { this.setState({ - app: { ...this.state.app, name: newName } + app: { ...this.state.app, name: newName }, }); }; toggleQueryPaneHeight = () => { this.setState({ - queryPaneHeight: this.state.queryPaneHeight === '30%' ? '80%' : '30%' + queryPaneHeight: this.state.queryPaneHeight === '30%' ? '80%' : '30%', }); }; @@ -426,23 +430,31 @@ class Editor extends React.Component { const results = fuse.search(value); this.setState({ dataQueries: results.map((result) => result.item), - dataQueriesDefaultText: results.length || 'No Queries found.' + dataQueriesDefaultText: results.length || 'No Queries found.', }); } else { this.fetchDataQueries(); } - } + }; toggleQuerySearch = () => { this.setState({ showQuerySearchField: !this.state.showQuerySearchField }); - } + }; onVersionDeploy = (versionId) => { - this.setState({ app: { - ...this.state.app, - current_version_id: versionId - }}) - } + this.setState({ + app: { + ...this.state.app, + current_version_id: versionId, + }, + }); + }; + + onZoomChanged = (zoom) => { + this.setState({ + zoomLevel: zoom, + }); + }; render() { const { @@ -471,7 +483,7 @@ class Editor extends React.Component { scaleValue, dataQueriesDefaultText, showQuerySearchField, - apps + apps, } = this.state; const appLink = slug ? `/applications/${slug}` : ''; @@ -516,13 +528,6 @@ class Editor extends React.Component { /> )}
- - -
-
+ {/*
-
+
*/}
- @@ -612,7 +624,18 @@ class Editor extends React.Component {
- + {/*
- + */}
-
+
onEvent(this, eventName, options)} - onComponentOptionChanged={(component, optionName, value) => onComponentOptionChanged(this, component, optionName, value) + onComponentOptionChanged={(component, optionName, value) => + onComponentOptionChanged(this, component, optionName, value) } - onComponentOptionsChanged={(component, options) => onComponentOptionsChanged(this, component, options) + onComponentOptionsChanged={(component, options) => + onComponentOptionsChanged(this, component, options) } currentState={this.state.currentState} configHandleClicked={this.configHandleClicked} removeComponent={this.removeComponent} onComponentClick={(id, component) => { - // this.setState({ selectedComponent: { id, component } }); - // this.switchSidebarTab(1); + this.setState({ selectedComponent: { id, component } }); + this.switchSidebarTab(1); onComponentClick(this, id, component); }} /> @@ -771,7 +799,7 @@ class Editor extends React.Component { style={{ height: showQueryEditor ? this.state.queryPaneHeight : '0px', width: !showLeftSidebar ? '85%' : '', - left: !showLeftSidebar ? '0' : '' + left: !showLeftSidebar ? '0' : '', }} >
@@ -783,7 +811,7 @@ class Editor extends React.Component {
- {showQuerySearchField - &&
+ {showQuerySearchField && ( +
- } + )} {loadingDataQueries ? (
@@ -827,7 +855,8 @@ class Editor extends React.Component { {dataQueriesDefaultText}
-
- -
+
{currentSidebarTab === 1 && ( diff --git a/frontend/src/Editor/Inspector/Components/Table.jsx b/frontend/src/Editor/Inspector/Components/Table.jsx index b5efc8c747..e45b99bbb5 100644 --- a/frontend/src/Editor/Inspector/Components/Table.jsx +++ b/frontend/src/Editor/Inspector/Components/Table.jsx @@ -8,6 +8,7 @@ import Popover from 'react-bootstrap/Popover'; import { EventSelector } from '../EventSelector'; import { Color } from '../Elements/Color'; import SelectSearch, { fuzzySearch } from 'react-select-search'; +import { v4 as uuidv4 } from 'uuid'; class Table extends React.Component { constructor(props) { @@ -102,7 +103,8 @@ class Table extends React.Component { { name: 'Tags', value: 'tags' }, { name: 'Dropdown', value: 'dropdown' }, { name: 'Radio', value: 'radio' }, - { name: 'Multiselect', value: 'multiselect' } + { name: 'Multiselect', value: 'multiselect' }, + { name: 'Toggle switch', value: 'toggle' } ]} value={column.columnType} search={true} @@ -276,7 +278,7 @@ class Table extends React.Component { addNewColumn = () => { const columns = this.props.component.component.definition.properties.columns; const newValue = columns.value; - newValue.push({ name: this.generateNewColumnName(columns.value) }); + newValue.push({ name: this.generateNewColumnName(columns.value), id: uuidv4() }); this.props.paramUpdated({ name: 'columns' }, 'value', newValue, 'properties'); }; @@ -338,6 +340,10 @@ class Table extends React.Component { const columns = component.component.definition.properties.columns; const actions = component.component.definition.properties.actions || { value: [] }; + if (!component.component.definition.properties.displaySearchBox) + paramUpdated({ name: 'displaySearchBox' }, 'value', true, 'properties'); + const displaySearchBox = component.component.definition.properties.displaySearchBox.value; + return (
{renderElement(component, componentMeta, paramUpdated, dataQueries, 'data', 'properties', currentState, components)} @@ -403,7 +409,8 @@ class Table extends React.Component {
{renderElement(component, componentMeta, paramUpdated, dataQueries, 'serverSidePagination', 'properties', currentState)} - {renderElement(component, componentMeta, paramUpdated, dataQueries, 'serverSideSearch', 'properties', currentState)} + {renderElement(component, componentMeta, paramUpdated, dataQueries, 'displaySearchBox', 'properties', currentState)} + {displaySearchBox && renderElement(component, componentMeta, paramUpdated, dataQueries, 'serverSideSearch', 'properties', currentState)}
Events
diff --git a/frontend/src/Editor/Inspector/Elements/Code.jsx b/frontend/src/Editor/Inspector/Elements/Code.jsx index ecbaa479fb..c2fe0f9a8e 100644 --- a/frontend/src/Editor/Inspector/Elements/Code.jsx +++ b/frontend/src/Editor/Inspector/Elements/Code.jsx @@ -23,6 +23,7 @@ export const Code = ({ initialValue={initialValue} mode={options.mode} theme={darkMode? 'monokai' : options.theme} + lineWrapping={true} className={options.className} onChange={(value) => handleCodeChanged(value)} /> diff --git a/frontend/src/Editor/Inspector/EventSelector.jsx b/frontend/src/Editor/Inspector/EventSelector.jsx index 13d9b24c71..54be16fc2f 100644 --- a/frontend/src/Editor/Inspector/EventSelector.jsx +++ b/frontend/src/Editor/Inspector/EventSelector.jsx @@ -54,7 +54,7 @@ export const EventSelector = ({ apps.map((item) => { appsOptionsList.push({ name: item.name, - value: item.id + value: item.slug }) }) return appsOptionsList; diff --git a/frontend/src/Editor/LeftSidebar/index.js b/frontend/src/Editor/LeftSidebar/index.js new file mode 100644 index 0000000000..11335a443d --- /dev/null +++ b/frontend/src/Editor/LeftSidebar/index.js @@ -0,0 +1,29 @@ +import '@/_styles/left-sidebar.scss'; + +import React from 'react'; + +import { LeftSidebarItem } from './sidebar-item'; +import { LeftSidebarInspector } from './sidebar-inspector'; +import { LeftSidebarDataSources } from './sidebar-datasources'; +import { LeftSidebarZoom } from './sidebar-zoom'; +import { DarkModeToggle } from '../../_components/DarkModeToggle'; +import useRouter from '../../_hooks/use-router'; + +export const LeftSidebar = ({ appId, switchDarkMode, darkMode = false, globals, components, queries, onZoomChanged, dataSources = [], dataSourcesChanged }) => { + const router = useRouter() + return ( +
+ + + {/* */} + router.push('/')} tip='Back to home' icon='back' className='left-sidebar-item no-border' /> +
+ +
+ +
+ {/* */} +
+
+ ) +} diff --git a/frontend/src/Editor/LeftSidebar/sidebar-datasources.js b/frontend/src/Editor/LeftSidebar/sidebar-datasources.js new file mode 100644 index 0000000000..6eedbc9718 --- /dev/null +++ b/frontend/src/Editor/LeftSidebar/sidebar-datasources.js @@ -0,0 +1,72 @@ +import React from 'react'; +import usePopover from '../../_hooks/use-popover'; +import { LeftSidebarItem } from './sidebar-item'; +import { DataSourceManager } from '../DataSourceManager'; +import { DataSourceTypes } from '../DataSourceManager/DataSourceTypes'; + +export const LeftSidebarDataSources = ({ appId, darkMode, dataSources= [], dataSourcesChanged }) => { + const [open, trigger, content] = usePopover(false) + const [showDataSourceManagerModal, toggleDataSourceManagerModal] = React.useState(false); + const [selectedDataSource, setSelectedDataSource] = React.useState(null); + + const renderDataSource = dataSource => { + const sourceMeta = DataSourceTypes.find((source) => source.kind === dataSource.kind); + return ( +
{ + setSelectedDataSource(dataSource) + toggleDataSourceManagerModal(true) + }} + > + + + ); + } + + return ( + <> + +
+
+
+
+ {' '} + {dataSource.name} +
+ {dataSources?.map((source) => renderDataSource(source))} +
+ {dataSources?.length === 0 && ( +
+ You haven't added any datasources yet.
+
+ )} +
+ +
+
+
+
+ { + setSelectedDataSource(null) + toggleDataSourceManagerModal(false) + } + } + dataSourcesChanged={dataSourcesChanged} + showDataSourceManagerModal={showDataSourceManagerModal} + selectedDataSource={selectedDataSource} + /> + + ) +} diff --git a/frontend/src/Editor/LeftSidebar/sidebar-inspector.js b/frontend/src/Editor/LeftSidebar/sidebar-inspector.js new file mode 100644 index 0000000000..5254a06b1b --- /dev/null +++ b/frontend/src/Editor/LeftSidebar/sidebar-inspector.js @@ -0,0 +1,56 @@ +import React from 'react'; +import usePopover from '../../_hooks/use-popover'; +import { LeftSidebarItem } from './sidebar-item'; +import ReactJson from 'react-json-view'; + +export const LeftSidebarInspector = ({ darkMode, globals, components, queries }) => { + const [open, trigger, content] = usePopover(false) + + return ( + <> + +
+
+ + + +
+
+ + ) +} diff --git a/frontend/src/Editor/LeftSidebar/sidebar-item.js b/frontend/src/Editor/LeftSidebar/sidebar-item.js new file mode 100644 index 0000000000..bb170278c1 --- /dev/null +++ b/frontend/src/Editor/LeftSidebar/sidebar-item.js @@ -0,0 +1,22 @@ +import React from 'react'; +import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; +import Tooltip from 'react-bootstrap/Tooltip'; + +export const LeftSidebarItem = ({ tip = '', className, icon, text, onClick, ...rest }) => { + + return ( + + {tip} + } + > +
+ {icon && } + {text && text} +
+
+ ) +} diff --git a/frontend/src/Editor/LeftSidebar/sidebar-zoom.js b/frontend/src/Editor/LeftSidebar/sidebar-zoom.js new file mode 100644 index 0000000000..4e2d80286f --- /dev/null +++ b/frontend/src/Editor/LeftSidebar/sidebar-zoom.js @@ -0,0 +1,83 @@ +import React from 'react'; +import usePopover from '../../_hooks/use-popover'; +import { LeftSidebarItem } from './sidebar-item'; + +export const LeftSidebarZoom = ({ onZoomChanged }) => { + const [open, trigger, content, setOpen] = usePopover(false) + const [text, setText] = React.useState(100) + return ( + <> + +
+
+
+ + + { + setText(100) + onZoomChanged(1) + setOpen(false) + }} + > + + + { + setText(90) + onZoomChanged(0.9) + setOpen(false) + }} + > + + + { + setText(80) + onZoomChanged(0.8) + setOpen(false) + }} + > + + + { + setText(70) + onZoomChanged(0.7) + setOpen(false) + }} + > + + + { + setText(60) + onZoomChanged(0.6) + setOpen(false) + }} + > + + + +
+ 100% +
+ 90% +
+ 80% +
+ 70% +
+ 60% +
+
+
+
+ + ) +} diff --git a/frontend/src/Editor/QueryManager/QueryEditors/Airtable.jsx b/frontend/src/Editor/QueryManager/QueryEditors/Airtable.jsx index 956b1e10ba..343714bc7d 100644 --- a/frontend/src/Editor/QueryManager/QueryEditors/Airtable.jsx +++ b/frontend/src/Editor/QueryManager/QueryEditors/Airtable.jsx @@ -46,6 +46,7 @@ class Airtable extends React.Component { options={[ { value: 'list_records', name: 'List records' }, { value: 'retrieve_record', name: 'Retrieve record' }, + { value: 'update_record', name: 'Update record' }, { value: 'delete_record', name: 'Delete record' }, ]} value={this.state.options.operation} @@ -138,6 +139,54 @@ class Airtable extends React.Component {
)} + {['update_record'].includes(this.state.options.operation) && ( +
+
+ + changeOption(this, 'base_id', value)} + /> +
+
+ + changeOption(this, 'table_name', value)} + /> +
+
+ + changeOption(this, 'record_id', value)} + /> +
+
+
+ + changeOption(this, 'body', value)} + /> +
+
+
+ )} + {['delete_record'].includes(this.state.options.operation) && (
@@ -179,4 +228,4 @@ class Airtable extends React.Component { } } -export { Airtable }; +export { Airtable }; \ No newline at end of file diff --git a/frontend/src/Editor/QueryManager/QueryEditors/Mysql.jsx b/frontend/src/Editor/QueryManager/QueryEditors/Mysql.jsx index 06495dc230..051d56ff61 100644 --- a/frontend/src/Editor/QueryManager/QueryEditors/Mysql.jsx +++ b/frontend/src/Editor/QueryManager/QueryEditors/Mysql.jsx @@ -1,6 +1,7 @@ import React from 'react'; import CodeMirror from '@uiw/react-codemirror'; import 'codemirror/theme/duotone-light.css'; +import SelectSearch, { fuzzySearch } from 'react-select-search'; import { CodeHinter } from '../../CodeBuilder/CodeHinter'; import { changeOption } from './utils'; @@ -8,7 +9,9 @@ class Mysql extends React.Component { constructor(props) { super(props); - this.state = {}; + this.state = { + options: this.props.options + }; } componentDidMount() { @@ -23,16 +26,98 @@ class Mysql extends React.Component { return (
{options && ( -
- changeOption(this, 'query', value)} - /> +
+
+ { + changeOption(this, 'mode', value); + }} + filterOptions={fuzzySearch} + placeholder="Select.." + /> +
+ + {options.mode === 'sql' && ( +
+ changeOption(this, 'query', value)} + /> +
+ )} + {options.mode === 'gui' && ( +
+
+
+ + changeOption(this, 'table', value)} + /> +
+
+ +
+ { + changeOption(this, 'operation', value); + }} + filterOptions={fuzzySearch} + placeholder="Select.." + /> +
+
+
+ + {options.operation === 'bulk_update_pkey' && ( +
+
+ + changeOption(this, 'primary_key_column', value)} + /> +
+
+ + changeOption(this, 'records', instance.getValue())} + placeholder="{{ [ ] }}" + options={{ + theme: 'duotone-light', + mode: 'javascript', + lineWrapping: true, + scrollbarStyle: null + }} + /> +
+
+ )} +
+ )}
)}
diff --git a/frontend/src/Editor/QueryManager/QueryEditors/Restapi.jsx b/frontend/src/Editor/QueryManager/QueryEditors/Restapi.jsx index 3888b5afa4..02dca4fac0 100644 --- a/frontend/src/Editor/QueryManager/QueryEditors/Restapi.jsx +++ b/frontend/src/Editor/QueryManager/QueryEditors/Restapi.jsx @@ -94,6 +94,7 @@ class Restapi extends React.Component { { diff --git a/frontend/src/Editor/Viewer.jsx b/frontend/src/Editor/Viewer.jsx index 36c0e4287f..dfaccc6825 100644 --- a/frontend/src/Editor/Viewer.jsx +++ b/frontend/src/Editor/Viewer.jsx @@ -138,7 +138,7 @@ class Viewer extends React.Component { {this.state.app && {this.state.app.name}} -
+
this.setState({ apps: data.apps, - meta: data.meta, + meta: {...this.state.meta, ...data.meta}, isLoading: false })); } @@ -65,7 +66,7 @@ class HomePage extends React.Component { folderChanged = (folder) => { this.setState({'currentFolder': folder}); - this.fetchApps(0, folder.id); + this.fetchApps(1, folder.id); } foldersChanged = () => { @@ -82,7 +83,7 @@ class HomePage extends React.Component { }; deleteApp = (app) => { - this.setState({ showAppDeletionConfirmation: true, appToBeDeleted: app }) + this.setState({ showAppDeletionConfirmation: true, appToBeDeleted: app }); } executeAppDeletion = () => { @@ -92,16 +93,17 @@ class HomePage extends React.Component { hideProgressBar: true, position: 'top-center' }); - this.setState({ - isDeletingApp: false, + this.setState({ + isDeletingApp: false, appToBeDeleted: null, showAppDeletionConfirmation: false }); - this.fetchApps(this.state.currentPage || 0, this.state.currentFolder.id) + this.fetchApps(this.state.currentPage || 1, this.state.currentFolder.id); + this.fetchFolders(); }).catch(({ error }) => { toast.error('Could not delete the app.', { hideProgressBar: true, position: 'top-center' }); - this.setState({ - isDeletingApp: false, + this.setState({ + isDeletingApp: false, appToBeDeleted: null, showAppDeletionConfirmation: false }); @@ -109,6 +111,10 @@ class HomePage extends React.Component { ; } + pageCount = () => { + return this.state.currentFolder.id ? this.state.meta.folder_count : this.state.meta.total_count; + } + render() { const { apps, isLoading, creatingApp, meta, currentFolder, showAppDeletionConfirmation, isDeletingApp @@ -128,7 +134,7 @@ class HomePage extends React.Component { switchDarkMode={this.props.switchDarkMode} darkMode={this.props.darkMode} /> - {!isLoading && meta.total_count === 0 && + {!isLoading && meta.total_count === 0 && !currentFolder.id && @@ -214,15 +220,16 @@ class HomePage extends React.Component { renderTooltip({props, text: 'Open in app viewer'})} + overlay={(props) => renderTooltip({props, text: app?.current_version_id == null ? 'App does not have a deployed version' : 'Open in app viewer'})} > - launch - + launch @@ -246,10 +253,10 @@ class HomePage extends React.Component {
- {meta.total_count > 0 && ( + {this.pageCount() > 10 && ( diff --git a/frontend/src/SettingsPage/SettingsPage.jsx b/frontend/src/SettingsPage/SettingsPage.jsx new file mode 100644 index 0000000000..716c6bb265 --- /dev/null +++ b/frontend/src/SettingsPage/SettingsPage.jsx @@ -0,0 +1,160 @@ +import React from 'react'; +import { authenticationService, userService } from '@/_services'; +import 'react-toastify/dist/ReactToastify.css'; +import { Header } from '@/_components'; +import { toast } from 'react-toastify'; + +function SettingsPage(props) { + const [firstName, setFirstName] = React.useState(authenticationService.currentUserValue.first_name) + const [lastName, setLastName] = React.useState(authenticationService.currentUserValue.last_name) + const [currentpassword, setCurrentPassword] = React.useState('') + const [newPassword, setNewPassword] = React.useState('') + const [updateInProgress, setUpdateInProgress] = React.useState(false) + const [passwordChangeInProgress, setPasswordChangeInProgress] = React.useState(false) + + const updateDetails = async () => { + setUpdateInProgress(true); + const updatedDetails = await userService.updateCurrentUser(firstName, lastName); + authenticationService.updateCurrentUserDetails(updatedDetails) + toast.success('Details updated!', { hideProgressBar: true, autoClose: 3000 }); + setUpdateInProgress(false); + } + + const changePassword = async () => { + setPasswordChangeInProgress(true) + const response = userService.changePassword(currentpassword, newPassword) + response.then(() => { + toast.success('Password updated successfully', { hideProgressBar: true, autoClose: 3000 }); + setCurrentPassword('') + setNewPassword('') + }).catch(() => { + toast.error('Please verify that you have entered the correct password', { hideProgressBar: true, autoClose: 3000 }); + }) + setPasswordChangeInProgress(false); + } + + const newPasswordKeyPressHandler = async event => { + if (event.key === 'Enter') { + changePassword() + } + } + + return ( +
+
+ +
+
+
+
+
+
+

Settings

+
+
+
+
+ +
+
+
+
+

Profile

+
+
+
+
+
+ + setFirstName(event.target.value)} + /> +
+
+
+
+ + setLastName(event.target.value)} + /> +
+
+
+ + + Update + + {/* An !important style on theme.scss is making the last child of every .card-body color to #c3c3c3!. */} + {/* The div below is a placeholder to prevent it from affecting the button above. */} +
+
+
+
+
+
+

Change password

+
+
+
+
+
+ + setCurrentPassword(event.target.value)} + /> +
+
+
+
+ + setNewPassword(event.target.value)} + onKeyPress={newPasswordKeyPressHandler} + /> +
+
+
+ + Change password + + {/* An !important style on theme.scss is making the last child of every .card-body color to #c3c3c3!. */} + {/* The div below is a placeholder to prevent it from affecting the button above. */} +
+
+
+
+
+
+
+ ); +} + +export { SettingsPage }; diff --git a/frontend/src/SettingsPage/index.js b/frontend/src/SettingsPage/index.js new file mode 100644 index 0000000000..f533f5abe0 --- /dev/null +++ b/frontend/src/SettingsPage/index.js @@ -0,0 +1 @@ +export * from './SettingsPage'; diff --git a/frontend/src/_components/DarkModeToggle.jsx b/frontend/src/_components/DarkModeToggle.jsx index 7ad47f28d8..d0afa589d1 100644 --- a/frontend/src/_components/DarkModeToggle.jsx +++ b/frontend/src/_components/DarkModeToggle.jsx @@ -1,22 +1,86 @@ -import React, { useState, useEffect } from 'react'; +import React from 'react'; +import { useSpring, animated } from 'react-spring'; +import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; +import Tooltip from 'react-bootstrap/Tooltip'; -export const DarkModeToggle = function DarkModeToggle({ - darkMode, switchDarkMode -}) { +export const DarkModeToggle = function DarkModeToggle({ darkMode = false, switchDarkMode }) { + const toggleDarkMode = () => { + switchDarkMode(!darkMode); + }; - const [darkModeEnabled, setMode] = useState(darkMode); + const properties = { + sun: { + r: 9, + transform: 'rotate(40deg)', + cx: 12, + cy: 4, + opacity: 0, + }, + moon: { + r: 5, + transform: 'rotate(90deg)', + cx: 30, + cy: 0, + opacity: 1, + }, + springConfig: { mass: 4, tension: 250, friction: 35 }, + }; - const icon = darkModeEnabled ? 'night.svg' : 'day.svg'; - - return
- -
-} + const { r, transform, cx, cy, opacity } = properties[darkMode ? 'moon' : 'sun']; + + const svgContainerProps = useSpring({ + transform, + config: properties.springConfig, + }); + const centerCircleProps = useSpring({ r, config: properties.springConfig }); + const maskedCircleProps = useSpring({ + cx, + cy, + color: 'white', + config: properties.springConfig, + }); + const linesProps = useSpring({ opacity, config: properties.springConfig }); + + return ( + {darkMode ? 'Activate light mode' : 'Activate dark mode'}} + > + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/frontend/src/_components/Header.jsx b/frontend/src/_components/Header.jsx index 8a49bf7420..ea06cbb6dc 100644 --- a/frontend/src/_components/Header.jsx +++ b/frontend/src/_components/Header.jsx @@ -19,6 +19,10 @@ export const Header = function Header({ history.push('/login'); } + function openSettings() { + history.push('/settings') + } + const { first_name, last_name } = authenticationService.currentUserValue; return
@@ -56,7 +60,7 @@ export const Header = function Header({
-
+
+ + Settings + Logout diff --git a/frontend/src/_components/Pagination.jsx b/frontend/src/_components/Pagination.jsx index b5e3239e0a..6534c1a600 100644 --- a/frontend/src/_components/Pagination.jsx +++ b/frontend/src/_components/Pagination.jsx @@ -19,20 +19,32 @@ export const Pagination = function Pagination({ gotoPage(currentPage - 1); } - return (
-

Showing { (currentPage - 1) * 10 + 1} to {currentPage * 10} of {count}

-
    -
  • - - - -
  • - {Array.from(Array(totalPages).keys()).map((i) => (renderPageItem(i)))} -
  • - - - -
  • -
-
); + function startingAppCount(currentPage) { + return ((currentPage - 1) * 10) + 1; + } + + function endingAppCount(currentPage, totalCount) { + const num = currentPage * 10; + + return num > totalCount ? totalCount : num; + } + + return ( +
+

Showing {startingAppCount(currentPage)} to {endingAppCount(currentPage, count)} of {count}

+
    +
  • + + + +
  • + {Array.from(Array(totalPages).keys()).map((i) => (renderPageItem(i)))} +
  • + + + +
  • +
+
+ ); }; diff --git a/frontend/src/_helpers/appUtils.js b/frontend/src/_helpers/appUtils.js index 21e6513e15..08e1f8edcc 100644 --- a/frontend/src/_helpers/appUtils.js +++ b/frontend/src/_helpers/appUtils.js @@ -204,7 +204,7 @@ export function onEvent(_ref, eventName, options, mode = 'edit') { } } - if (['onPageChanged', 'onSearch'].includes(eventName)) { + if (['onPageChanged', 'onSearch', 'onSelectionChange'].includes(eventName)) { const { component } = options; const event = component.definition.events[eventName]; @@ -222,6 +222,16 @@ export function onEvent(_ref, eventName, options, mode = 'edit') { } } + /* Events for QrScanner */ + if (['onDetect'].includes(eventName)) { + const { component } = options; + const event = component.definition.events[eventName]; + + if (event.actionId) { + executeAction(_self, event, mode); + } + } + if (eventName === 'onBulkUpdate') { return new Promise(function (resolve, reject) { onComponentOptionChanged(_self, options.component, 'isSavingChanges', true); @@ -319,7 +329,6 @@ export function runQuery(_ref, queryId, queryName, confirmed = undefined) { return; } } - const newState = { ..._ref.state.currentState, queries: { @@ -346,7 +355,7 @@ export function runQuery(_ref, queryId, queryName, confirmed = undefined) { } if (data.status === 'failed') { - toast.error(data.error.message, { hideProgressBar: true, autoClose: 3000 }); + toast.error(data.message, { hideProgressBar: true, autoClose: 3000 }); } let rawData = data.data; diff --git a/frontend/src/_hooks/use-popover.jsx b/frontend/src/_hooks/use-popover.jsx new file mode 100644 index 0000000000..1e9f8389c1 --- /dev/null +++ b/frontend/src/_hooks/use-popover.jsx @@ -0,0 +1,54 @@ +import { useRef, useState, useEffect, useCallback } from 'react'; +const noop = () => {}; +const useEscapeHandler = (handler = noop, dependencies = []) => { + const escapeHandler = (e) => { + if (e.code === 'Escape') { + handler(); + } + }; + useEffect(() => { + document === null || document === void 0 ? void 0 : document.addEventListener('keyup', escapeHandler); + return () => + document === null || document === void 0 ? void 0 : document.removeEventListener('keyup', escapeHandler); + }, dependencies); +}; +const useClickOutside = (handler = noop, dependencies) => { + const callbackRef = useRef(handler); + const ref = useRef(null); + const outsideClickHandler = (e) => { + if (callbackRef.current && ref.current && !ref.current.contains(e.target)) { + callbackRef.current(e); + } + }; + // useEffect wrapper to be safe for concurrent mode + useEffect(() => { + callbackRef.current = handler; + }); + useEffect(() => { + document === null || document === void 0 ? void 0 : document.addEventListener('click', outsideClickHandler, { capture: true }); + return () => + document === null || document === void 0 ? void 0 : document.removeEventListener('click', outsideClickHandler, { capture: true }); + }, dependencies); + return ref; +}; +const role = 'dialog'; +const usePopover = (defaultOpen = false) => { + const triggerRef = useRef(null); + const [open, setOpen] = useState(defaultOpen); + const toggle = useCallback(() => setOpen(!open), []); + const close = useCallback(() => setOpen(false), []); + useEscapeHandler(close, []); + const contentRef = useClickOutside(open ? close : undefined, []); + const trigger = { + ref: triggerRef, + onClick: toggle, + 'aria-haspopup': role, + 'aria-expanded': open, + }; + const content = { + ref: contentRef, + role, + }; + return [open, trigger, content, setOpen]; +}; +export default usePopover; diff --git a/frontend/src/_hooks/use-router.jsx b/frontend/src/_hooks/use-router.jsx new file mode 100644 index 0000000000..fdc159d82c --- /dev/null +++ b/frontend/src/_hooks/use-router.jsx @@ -0,0 +1,37 @@ +import { useMemo } from 'react'; +import { + useParams, + useLocation, + useHistory, + useRouteMatch, +} from 'react-router-dom'; +import queryString from 'query-string'; + +export default function useRouter() { + const params = useParams(); + const location = useLocation(); + const history = useHistory(); + const match = useRouteMatch(); + // Return our custom router object + // Memoize so that a new object is only returned if something changes + return useMemo(() => { + return { + // For convenience add push(), replace(), pathname at top level + push: history.push, + replace: history.replace, + pathname: location.pathname, + // Merge params and parsed query string into single 'query' object + // so that they can be used interchangeably. + // Example: /:topic?sort=popular -> { topic: 'react', sort: 'popular' } + query: { + ...queryString.parse(location.search), // Convert string to object + ...params, + }, + // Include match, location, history objects so we have + // access to extra React Router functionality if needed. + match, + location, + history, + }; + }, [params, match, location, history]); +} diff --git a/frontend/src/_services/authentication.service.js b/frontend/src/_services/authentication.service.js index a44a7728dd..5d2b5dcaab 100644 --- a/frontend/src/_services/authentication.service.js +++ b/frontend/src/_services/authentication.service.js @@ -9,6 +9,7 @@ export const authenticationService = { login, logout, signup, + updateCurrentUserDetails, currentUser: currentUserSubject.asObservable(), get currentUserValue() { return currentUserSubject.value; } }; @@ -31,6 +32,13 @@ function login(email, password) { }); } +function updateCurrentUserDetails(details) { + const currentUserDetails = JSON.parse(localStorage.getItem('currentUser')); + const updatedUserDetails = Object.assign({}, currentUserDetails, details) + localStorage.setItem('currentUser', JSON.stringify(updatedUserDetails)); + currentUserSubject.next(updatedUserDetails); +} + function signup(email) { const requestOptions = { method: 'POST', diff --git a/frontend/src/_services/user.service.js b/frontend/src/_services/user.service.js index 373df98a3d..11ac655c8a 100644 --- a/frontend/src/_services/user.service.js +++ b/frontend/src/_services/user.service.js @@ -5,7 +5,9 @@ export const userService = { getAll, createUser, deleteUser, - setPasswordFromToken + setPasswordFromToken, + updateCurrentUser, + changePassword, }; function getAll() { @@ -43,3 +45,15 @@ function setPasswordFromToken({ token, password, organization, newSignup, firstN const requestOptions = { method: 'POST', headers: authHeader(), body: JSON.stringify(body) }; return fetch(`${config.apiUrl}/users/set_password_from_token`, requestOptions).then(handleResponse); } + +function updateCurrentUser(firstName , lastName) { + const body = { firstName, lastName }; + const requestOptions = { method: 'PATCH', headers: authHeader(), body: JSON.stringify(body) }; + return fetch(`${config.apiUrl}/users/update`, requestOptions).then(handleResponse); +} + +function changePassword(currentPassword, newPassword) { + const body = { currentPassword, newPassword }; + const requestOptions = { method: 'PATCH', headers: authHeader(), body: JSON.stringify(body) }; + return fetch(`${config.apiUrl}/users/change_password`, requestOptions).then(handleResponse); +} diff --git a/frontend/src/_styles/colors.scss b/frontend/src/_styles/colors.scss new file mode 100644 index 0000000000..e495abd023 --- /dev/null +++ b/frontend/src/_styles/colors.scss @@ -0,0 +1,2 @@ +$white: #fff !default; +$grey: #eee !default; \ No newline at end of file diff --git a/frontend/src/_styles/left-sidebar.scss b/frontend/src/_styles/left-sidebar.scss new file mode 100644 index 0000000000..bb7930868e --- /dev/null +++ b/frontend/src/_styles/left-sidebar.scss @@ -0,0 +1,55 @@ +@import './colors.scss'; + +.left-sidebar { + background: $white; + + .left-sidebar-item { + margin: 5px; + text-align: center; + padding-top: 1vw; + padding-bottom: 1vw; + border-bottom: 1px solid $grey; + transition: all .2s ease-in-out; + cursor: pointer; + + &:hover { + transform: scale(1.2); + } + } + .left-sidebar-stack-bottom { + width: 3%; + position: fixed; + bottom: 12vw; + height: 50px; + text-align: center; + } + .popover { + position: fixed; + left: 3%; + top: 55px; + overflow: auto; + max-height: 60%; + } + .datasources-popover { + top: 100px; + } + .zoom-popover { + top: 500px; + } + .sidebar-zoom { + white-space: nowrap; + font-size: 12px; + } + .show { + display: block; + } + .hide { + display: none; + } + .dark-mode { + transform: scale(.7); + } + .no-border { + border: 0; + } +} \ No newline at end of file diff --git a/frontend/src/_styles/theme.scss b/frontend/src/_styles/theme.scss index deb4e033ca..e7d5bb67bd 100644 --- a/frontend/src/_styles/theme.scss +++ b/frontend/src/_styles/theme.scss @@ -7,6 +7,8 @@ body { .editor { .header-container { max-width: 100%; + padding-left: 0.5rem; + padding-right: 0.5rem; } .resizer-active { @@ -64,12 +66,11 @@ body { .left-sidebar { height: 100%; - width: 12%; - // position: fixed; + width: 3%; + position: fixed; z-index: 1; left: 0; overflow-x: hidden; - flex: 1 1 auto; background-color: #fff; background-clip: border-box; @@ -237,8 +238,8 @@ body { } .main { - margin-left: 12%; - width: 73%; + margin-left: 3%; + width: 82%; top: 0; .canvas-container::-webkit-scrollbar { @@ -258,7 +259,7 @@ body { .canvas-container { height: 100%; - width: 73%; + width: 82%; top: 56px; position: fixed; overflow-y: auto; @@ -298,10 +299,10 @@ body { .query-pane { height: 350px; - width: 73%; + width: 82%; position: fixed; - z-index: 1; - left: 12%; + // z-index: 1; + left: 3%; bottom: 0; overflow-x: hidden; flex: 1 1 auto; @@ -366,9 +367,10 @@ body { border-width: 0px 0px 1px 0px; background: white; position: fixed; - z-index: 2; - width: 54%; + z-index: 3; + width: 61.5%; margin-top: 0px; + padding: 0.5px; } .preview-header { @@ -1264,6 +1266,12 @@ body { margin-bottom: 0rem; } +.code-hinter::-webkit-scrollbar { + width: 0; + height: 0; + background: transparent; +} + .codehinter-query-editor-input { .CodeMirror { font-family: 'Roboto', sans-serif; @@ -1303,11 +1311,11 @@ body { .CodeMirror { font-family: 'Roboto', sans-serif; height: 50px!important; + max-height: 300px; } } .CodeMirror-focused { - padding-top: 3px; } .CodeMirror-vscrollbar, .CodeMirror-hscrollbar { @@ -1318,7 +1326,6 @@ body { .CodeMirror-scroll { overflow:hidden !important; - margin-bottom: 0px; } } @@ -1658,6 +1665,7 @@ input:focus-visible { margin-top: -2px; border-bottom-left-radius: 3px; border-bottom-right-radius: 3px; + font-family: 'Source Code Pro', monospace; } .theme-dark { @@ -1666,7 +1674,7 @@ input:focus-visible { } .card-body > :last-child { - color: #c3c3c3!important; + color: #fff!important; } .form-control { @@ -1674,7 +1682,7 @@ input:focus-visible { } .card { - background-color: #2f3c4c!important; + background-color: #324156!important; } .DateInput { @@ -1721,7 +1729,7 @@ input:focus-visible { .modal-content, .modal-header { background-color: #1f2936!important; .text-muted { - color: #c3c3c3!important; + color: #fff!important; } } @@ -1748,7 +1756,7 @@ input:focus-visible { .query-list { .text-muted { - color: #c3c3c3!important; + color: #fff!important; } } @@ -1791,16 +1799,16 @@ input:focus-visible { border: solid rgba(255, 255, 255, 0.09); border-width: 0px 1px 3px 0px; .text-muted { - color: #c3c3c3!important; + color: #fff!important; } } .folder-list { - color: #c3c3c3!important; + color: #fff!important; } .app-title { - color: #c3c3c3!important; + color: #fff!important; } .RichEditor-root { @@ -1809,7 +1817,7 @@ input:focus-visible { } .app-description { - color: #c3c3c3!important; + color: #fff!important; } .btn-light, .btn-outline-light { @@ -1842,7 +1850,7 @@ input:focus-visible { .app-users-list { .text-muted { - color: #c3c3c3!important; + color: #fff!important; } } @@ -1856,7 +1864,7 @@ input:focus-visible { border-width: 0px 0px 1px 0px!important; .text-muted { - color: #c3c3c3!important; + color: #fff!important; } } @@ -1879,7 +1887,7 @@ input:focus-visible { .alert { background: transparent; .text-muted { - color: #c3c3c3!important; + color: #fff!important; } } @@ -1993,4 +2001,4 @@ input:focus-visible { .theme-dark .input-group-text, .theme-dark .markdown > table thead th, .theme-dark .table thead th { background: #1c252f; -} +} \ No newline at end of file diff --git a/frontend/src/index.html b/frontend/src/index.html index be04040702..0de6bd9bc2 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -15,6 +15,7 @@ +
diff --git a/package.json b/package.json index 6617a09077..10ece8ad72 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "start:prod": "npm --prefix server run start:prod", "deploy": "cp -a frontend/build/. public/", "heroku-postbuild": "npm run build && npm run deploy", - "heroku-prebuild": "npm --prefix frontend install && npm --prefix server install " + "heroku-prebuild": "npm --prefix frontend install && npm --prefix server install ", + "cy:run": "cypress run --env db.name=$TEST_PG_DB,db.user=$TEST_PG_USERNAME,db.password=$TEST_PG_PASSWORD", + "cy:open": "cypress open --env db.name=$TEST_PG_DB,db.user=$TEST_PG_USERNAME,db.password=$TEST_PG_PASSWORD" } } diff --git a/server/.version b/server/.version index 09a3acfa13..7ceb04048e 100644 --- a/server/.version +++ b/server/.version @@ -1 +1 @@ -0.6.0 \ No newline at end of file +0.6.1 \ No newline at end of file diff --git a/server/migrations/1625814801415-MaybeCreateExtension.ts b/server/migrations/1625814801415-MaybeCreateExtension.ts new file mode 100644 index 0000000000..4c950f4b28 --- /dev/null +++ b/server/migrations/1625814801415-MaybeCreateExtension.ts @@ -0,0 +1,12 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class MaybeCreateExtension1625814801415 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('CREATE EXTENSION IF NOT EXISTS pgcrypto;') + } + + public async down(queryRunner: QueryRunner): Promise { + } + +} diff --git a/server/ormconfig.ts b/server/ormconfig.ts index a271953425..f47a7380c1 100644 --- a/server/ormconfig.ts +++ b/server/ormconfig.ts @@ -35,7 +35,7 @@ function buildConnectionOptions( uuidExtension: 'pgcrypto', migrationsRun: false, logging: data.ORM_LOGGING || false, - migrations: ['dist/migrations/**/*{.ts,.js}'], + migrations: [__dirname + '/migrations/**/*{.ts,.js}'], cli: { migrationsDir: 'migrations', }, diff --git a/server/package.json b/server/package.json index 6922f7864d..0700f1ad97 100644 --- a/server/package.json +++ b/server/package.json @@ -14,11 +14,11 @@ "start:debug": "nest start --debug --watch", "start:prod": "NODE_ENV=production node dist/src/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "test": "jest", - "test:watch": "jest --watch", - "test:cov": "jest --coverage", - "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "NODE_ENV=test npm run db:reset && jest --runInBand --config ./test/jest-e2e.json ", + "test": "NODE_ENV=test jest", + "test:watch": "NODE_ENV=test jest --watch", + "test:cov": "NODE_ENV=test jest --coverage", + "test:debug": "NODE_ENV=test node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "NODE_ENV=test jest --runInBand --config ./test/jest-e2e.json ", "db:create": "ts-node ./scripts/create-database.ts", "db:drop": "ts-node ./scripts/drop-database.ts", "db:migrate": "npm run typeorm migration:run", diff --git a/server/plugins/datasources/airtable/index.ts b/server/plugins/datasources/airtable/index.ts index aaa7b5c44a..4d530d9169 100644 --- a/server/plugins/datasources/airtable/index.ts +++ b/server/plugins/datasources/airtable/index.ts @@ -44,6 +44,25 @@ export default class AirtableQueryService implements QueryService { result = JSON.parse(response.body); break; + case 'update_record': + + response = await got(`https://api.airtable.com/v0/${baseId}/${tableName}`, { + method: 'patch', + headers: this.authHeader(accessToken), + json: { + "records": [ + { + "id": queryOptions['record_id'], + "fields": JSON.parse(queryOptions['body']) + } + ] + } + }); + + result = JSON.parse(response.body); + + break; + case 'delete_record': const _recordId = queryOptions['record_id']; @@ -66,4 +85,4 @@ export default class AirtableQueryService implements QueryService { } } -} +} \ No newline at end of file diff --git a/server/plugins/datasources/mysql/index.service.spec.ts b/server/plugins/datasources/mysql/index.service.spec.ts new file mode 100644 index 0000000000..9c3e4e7a2a --- /dev/null +++ b/server/plugins/datasources/mysql/index.service.spec.ts @@ -0,0 +1,40 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import MysqlQueryService from '.'; + +describe('MysqlQueryService', () => { + let service: MysqlQueryService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [MysqlQueryService], + }).compile(); + + service = module.get(MysqlQueryService); + }); + + it('should generate the query for bulk update operation', async () => { + const queryOptions = { + table: 'customers', + primary_key_column: 'id', + records: [ + { + id: 1, + name: 'sam', + email: 'sam@example.com' + }, + { + id: 2, + name: 'jon', + email: 'jon@example.com' + } + ] + + }; + + const builtQuery = await service.buildBulkUpdateQuery(queryOptions); + const expectedQuery = "UPDATE customers SET name = 'sam', email = 'sam@example.com' WHERE id = 1; UPDATE customers SET name = 'jon', email = 'jon@example.com' WHERE id = 2;"; + + expect(builtQuery).toBe(expectedQuery); + }); + +}); diff --git a/server/plugins/datasources/mysql/index.ts b/server/plugins/datasources/mysql/index.ts index 4452e79e66..52ecb0ef11 100644 --- a/server/plugins/datasources/mysql/index.ts +++ b/server/plugins/datasources/mysql/index.ts @@ -9,9 +9,20 @@ import { cacheConnection, getCachedConnection } from 'src/helpers/utils.helper'; export default class MysqlQueryService implements QueryService { async run(sourceOptions: any, queryOptions: any, dataSourceId: string, dataSourceUpdatedAt: string): Promise { - - let result = { }; - let query = queryOptions.query; + + let result = { + rows: [] + }; + let query = ''; + + if(queryOptions.mode === 'gui') { + if(queryOptions.operation === 'bulk_update_pkey') { + query = await this.buildBulkUpdateQuery(queryOptions); + } + } else { + query = queryOptions.query; + } + const knexInstance = await this.getConnection(sourceOptions, {}, true, dataSourceId, dataSourceUpdatedAt); try { @@ -44,13 +55,14 @@ export default class MysqlQueryService implements QueryService { password : sourceOptions.password, database : sourceOptions.database, port: sourceOptions.port, + multipleStatements: true } }; return knex(config); } - async getConnection(sourceOptions: any, options:any, checkCache: boolean, dataSourceId?: string, dataSourceUpdatedAt?: string): Promise { + async getConnection(sourceOptions: any, options:any, checkCache: boolean, dataSourceId?: string, dataSourceUpdatedAt?: string): Promise { if(checkCache) { let connection = await getCachedConnection(dataSourceId, dataSourceUpdatedAt); @@ -64,6 +76,29 @@ export default class MysqlQueryService implements QueryService { } else { return await this.buildConnection(sourceOptions); } - + + } + + async buildBulkUpdateQuery(queryOptions: any): Promise { + let queryText = ''; + + const tableName = queryOptions['table']; + const primaryKey = queryOptions['primary_key_column']; + const records = queryOptions['records']; + + for(const record of records ) { + queryText = `${queryText} UPDATE ${tableName} SET`; + + for(const key of Object.keys(record)) { + if(key !== primaryKey) { + queryText = ` ${queryText} ${key} = '${record[key]}',`; + } + } + + queryText = queryText.slice(0, -1); + queryText = `${queryText} WHERE ${primaryKey} = ${record[primaryKey]};`; + } + + return queryText.trim(); } } diff --git a/server/plugins/datasources/postgresql/index.ts b/server/plugins/datasources/postgresql/index.ts index 157b0f8437..850ef9687d 100644 --- a/server/plugins/datasources/postgresql/index.ts +++ b/server/plugins/datasources/postgresql/index.ts @@ -49,6 +49,7 @@ export default class PostgresqlQueryService implements QueryService { database: sourceOptions.database, password: sourceOptions.password, port: sourceOptions.port, + ssl: { rejectUnauthorized: false }, }); } diff --git a/server/scripts/create-database.ts b/server/scripts/create-database.ts index a36219af47..fe0aa9ccd2 100644 --- a/server/scripts/create-database.ts +++ b/server/scripts/create-database.ts @@ -35,12 +35,18 @@ function createDatabase(): void { process.env.PG_DB; exec(createdb, (err, _stdout, _stderr) => { - if (err) { - console.error(err); + if (!err) { + console.log(`Created database ${envVars.PG_DB}`); return; } - console.log(`Created database ${envVars.PG_DB}`); + const errorMessage = `database "${envVars.PG_DB}" already exists`; + + if (err.message.includes(errorMessage)) { + console.log(errorMessage); + } else { + console.error(err); + } }); }); } diff --git a/server/scripts/drop-database.ts b/server/scripts/drop-database.ts index a5e518982c..42589757bd 100644 --- a/server/scripts/drop-database.ts +++ b/server/scripts/drop-database.ts @@ -35,12 +35,18 @@ function dropDatabase(): void { process.env.PG_DB; exec(dropdb, (err, _stdout, _stderr) => { - if (err) { - console.error(err); + if (!err) { + console.log(`Dropped database ${envVars.PG_DB}`); return; } - console.log(`Dropped database ${envVars.PG_DB}`); + const errorMessage = `database "${envVars.PG_DB}" does not exist`; + + if (err.message.includes(errorMessage)) { + console.log(errorMessage); + } else { + console.error(err); + } }); }); } diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 72e58b248b..c6aedd2f95 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -21,28 +21,34 @@ import { MetaModule } from './modules/meta/meta.module'; import { ServeStaticModule } from '@nestjs/serve-static'; import { join } from 'path'; -@Module({ - imports: [ +const imports = [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: [`../.env.${process.env.NODE_ENV}`, '../.env'] + }), + TypeOrmModule.forRoot(ormconfig), + SeedsModule, + AuthModule, + UsersModule, + AppsModule, + FoldersModule, + FolderAppsModule, + DataQueriesModule, + DataSourcesModule, + OrganizationsModule, + CaslModule, + MetaModule +] + +if(process.env.SERVE_CLIENT !== 'false') + imports.unshift( ServeStaticModule.forRoot({ rootPath: join(__dirname, '../../../', 'frontend/build'), - }), - ConfigModule.forRoot({ - isGlobal: true, - envFilePath: [`../.env.${process.env.NODE_ENV}`, '../.env'] - }), - TypeOrmModule.forRoot(ormconfig), - SeedsModule, - AuthModule, - UsersModule, - AppsModule, - FoldersModule, - FolderAppsModule, - DataQueriesModule, - DataSourcesModule, - OrganizationsModule, - CaslModule, - MetaModule - ], + }) + ) + +@Module({ + imports, controllers: [AppController], providers: [AppService, EmailService, SeedsService], }) diff --git a/server/src/controllers/apps.controller.ts b/server/src/controllers/apps.controller.ts index f85cd6459c..fffdabeb76 100644 --- a/server/src/controllers/apps.controller.ts +++ b/server/src/controllers/apps.controller.ts @@ -19,7 +19,7 @@ export class AppsController { @Post() async create(@Request() req) { const params = req.body; - + const app = await this.appsService.create(req.user); return decamelizeKeys(app); } @@ -27,7 +27,7 @@ export class AppsController { @UseGuards(JwtAuthGuard) @Get(':id') async show(@Request() req, @Param() params) { - + const app = await this.appsService.find(params.id); let response = decamelizeKeys(app); @@ -82,7 +82,7 @@ export class AppsController { if(!ability.can('updateParams', app)) { throw new ForbiddenException('you do not have permissions to perform this action'); } - + const result = await this.appsService.update(req.user, params.id, req.body.app); let response = decamelizeKeys(result); @@ -115,20 +115,26 @@ export class AppsController { const folderId = req.query.folder; let apps = []; + let folderCount = 0; if(folderId) { const folder = await this.foldersService.findOne(folderId); apps = await this.foldersService.getAppsFor(req.user, folder, page); + folderCount = await this.foldersService.userAppCount(req.user, folder); } else { apps = await this.appsService.all(req.user, page); - } + }; - const totalCount = await this.appsService.count(req.user); + let totalCount = await this.appsService.count(req.user); + const totalPageCount = folderId ? folderCount : totalCount; + + console.log(folderCount, totalCount); const meta = { - total_pages: Math.round(totalCount/10), + total_pages: Math.ceil(totalPageCount/10), total_count: totalCount, - current_page: parseInt(page || 0) + folder_count: folderCount, + current_page: parseInt(page || 1) } const response = { diff --git a/server/src/controllers/users.controller.ts b/server/src/controllers/users.controller.ts index 952808f472..36294c6364 100644 --- a/server/src/controllers/users.controller.ts +++ b/server/src/controllers/users.controller.ts @@ -1,4 +1,6 @@ -import { Controller, Get, Post, Query, Request, UseGuards } from '@nestjs/common'; +import { Body, Controller, Post, Patch, Request, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from 'src/modules/auth/jwt-auth.guard'; +import { PasswordRevalidateGuard } from 'src/modules/auth/password-revalidate.guard'; import { UsersService } from 'src/services/users.service'; @Controller('users') @@ -13,4 +15,22 @@ export class UsersController { return result; } + @UseGuards(JwtAuthGuard) + @Patch('update') + async update(@Request() req, @Body() body) { + const {firstName, lastName } = body + await this.usersService.update(req.user.id, { firstName, lastName }); + await req.user.reload() + return { + first_name: req.user.firstName, + last_name: req.user.lastName + }; + } + + @UseGuards(JwtAuthGuard, PasswordRevalidateGuard) + @Patch('change_password') + async changePassword(@Request() req, @Body() body) { + const { newPassword } = body + return await this.usersService.update(req.user.id, { password: newPassword }); + } } diff --git a/server/src/entities/app.entity.ts b/server/src/entities/app.entity.ts index fcb69b02be..0802418b3d 100644 --- a/server/src/entities/app.entity.ts +++ b/server/src/entities/app.entity.ts @@ -49,8 +49,10 @@ export class App extends BaseEntity { @AfterInsert() updateSlug() { - const userRepository = getRepository(App); - userRepository.update(this.id, { slug: this.id }) + if (!this.slug) { + const appRepository = getRepository(App); + appRepository.update(this.id, { slug: this.id }) + } } protected definition; @@ -59,12 +61,4 @@ export class App extends BaseEntity { afterLoad(): void { this.definition = this.currentVersion?.definition; } - - @AfterInsert() - generateSlug() { - if (!this.slug) { - this.slug = this.id; - this.save(); - } - } } diff --git a/server/src/entities/folder.entity.ts b/server/src/entities/folder.entity.ts index fe98dd758b..e5a5a08f96 100644 --- a/server/src/entities/folder.entity.ts +++ b/server/src/entities/folder.entity.ts @@ -1,5 +1,6 @@ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn, ManyToMany, OneToMany, AfterLoad } from 'typeorm'; +import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn, ManyToMany, OneToMany, AfterLoad, JoinTable } from 'typeorm'; import { FolderApp } from './folder_app.entity'; +import { App } from './app.entity'; @Entity({ name: "folders" }) export class Folder { @@ -10,23 +11,39 @@ export class Folder { @Column() name: string; - @Column({ name: 'organization_id' }) + @Column({ name: 'organization_id' }) organizationId: string @CreateDateColumn({ default: () => 'now()', name: 'created_at' }) createdAt: Date; - + @UpdateDateColumn({ default: () => 'now()', name: 'updated_at' }) updatedAt: Date; @OneToMany(() => FolderApp, folderApp => folderApp.folder, { eager: true }) folderApps: FolderApp[]; + @ManyToMany(type => App) + @JoinTable({ + name: 'folder_apps', + joinColumn: { + name: 'folder_id' + }, + inverseJoinColumn: { + name: 'app_id' + } + }) + apps: App[]; + + appCount: number; + protected count; @AfterLoad() generateCount(): void { - this.count = this.folderApps.length; + if (this.folderApps) { + this.count = this.folderApps.length; + } } } diff --git a/server/src/entities/user.entity.ts b/server/src/entities/user.entity.ts index 4624253b5b..3fa4cf9969 100644 --- a/server/src/entities/user.entity.ts +++ b/server/src/entities/user.entity.ts @@ -1,10 +1,10 @@ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, BeforeInsert, BeforeUpdate, OneToMany, ManyToOne, JoinColumn, AfterLoad } from 'typeorm'; +import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, BeforeInsert, BeforeUpdate, OneToMany, ManyToOne, JoinColumn, AfterLoad, BaseEntity } from 'typeorm'; import { Organization } from './organization.entity'; const bcrypt = require('bcrypt'); import { OrganizationUser } from './organization_user.entity'; @Entity({ name: "users" }) -export class User { +export class User extends BaseEntity { @BeforeInsert() @BeforeUpdate() diff --git a/server/src/main.ts b/server/src/main.ts index 185e0f257a..266c6fecf2 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -18,7 +18,8 @@ async function bootstrap() { helmet.contentSecurityPolicy({ useDefaults: true, directives: { - 'script-src': ["'self'", "'unsafe-inline'", "'unsafe-eval'"], + 'script-src': ["'self'", "'unsafe-inline'", "'unsafe-eval'", "blob:"], + 'default-src': ["'self'", "blob:"], }, }), ); diff --git a/server/src/modules/auth/password-revalidate.guard.ts b/server/src/modules/auth/password-revalidate.guard.ts new file mode 100644 index 0000000000..8eee2d12c6 --- /dev/null +++ b/server/src/modules/auth/password-revalidate.guard.ts @@ -0,0 +1,29 @@ +import { Injectable, CanActivate, ExecutionContext, Inject, forwardRef } from '@nestjs/common'; +import { UsersService } from '@services/users.service' +import { Observable } from 'rxjs'; +const bcrypt = require('bcrypt') + +@Injectable() +export class PasswordRevalidateGuard implements CanActivate { + + constructor( + private usersService: UsersService + ) {} + + async validateUser(email: string, password: string): Promise { + const user = await this.usersService.findByEmail(email); + + if(!user) return null; + + const isVerified = await bcrypt.compare(password, user.password); + + return isVerified + } + + canActivate( + context: ExecutionContext, + ): boolean | Promise | Observable { + const request = context.switchToHttp().getRequest(); + return this.validateUser(request.user.email, request.body.currentPassword) + } +} diff --git a/server/src/services/apps.service.ts b/server/src/services/apps.service.ts index 7d2ea38dcb..f86b6f4634 100644 --- a/server/src/services/apps.service.ts +++ b/server/src/services/apps.service.ts @@ -6,6 +6,7 @@ import { User } from 'src/entities/user.entity'; import { AppUser } from 'src/entities/app_user.entity'; import { AppVersion } from 'src/entities/app_version.entity'; import { FolderApp } from 'src/entities/folder_app.entity'; +import { Folder } from 'src/entities/folder.entity'; import { DataSource } from 'src/entities/data_source.entity'; import { DataQuery } from 'src/entities/data_query.entity'; @@ -15,7 +16,7 @@ export class AppsService { constructor( @InjectRepository(App) private appsRepository: Repository, - + @InjectRepository(AppVersion) private appVersionsRepository: Repository, @@ -63,10 +64,10 @@ export class AppsService { })); await this.appUsersRepository.save(this.appUsersRepository.create({ - userId: user.id, - appId: app.id, + userId: user.id, + appId: app.id, role: 'admin', - createdAt: new Date(), + createdAt: new Date(), updatedAt: new Date() })); @@ -74,7 +75,7 @@ export class AppsService { } async count(user: User) { - return await this.appsRepository.count({ + return await this.appsRepository.count({ where: { organizationId: user.organizationId, }, @@ -89,7 +90,7 @@ export class AppsService { organizationId: user.organizationId, }, take: 10, - skip: 10 * ( page || 0 ), + skip: 10 * (page - 1), order: { createdAt: 'DESC' } @@ -144,7 +145,7 @@ export class AppsService { relations: ['user'] }); - // serialize + // serialize const serializedUsers = [] for(const appUser of appUsers) { serializedUsers.push({ @@ -176,7 +177,7 @@ export class AppsService { createdAt: new Date(), updatedAt: new Date() })); - + } async updateVersion(user: User, version: AppVersion, definition: any) { diff --git a/server/src/services/email.service.ts b/server/src/services/email.service.ts index 33db152234..af45e01d7a 100644 --- a/server/src/services/email.service.ts +++ b/server/src/services/email.service.ts @@ -62,12 +62,12 @@ export class EmailService {

Hi ${name || ''},

-
-

+ Please use the link below to set up your account and get started. -

+ +
${inviteUrl} -

+

Welcome aboard,
ToolJet Team @@ -90,12 +90,12 @@ export class EmailService {

Hi ${name || ''},

-
-

+ ${sender} has invited you to use ToolJet. Use the link below to set up your account and get started. -

+ +
${`${this.TOOLJET_HOST}/invitations/${invitationtoken}`} -

+

Welcome aboard,
ToolJet Team diff --git a/server/src/services/folders.service.ts b/server/src/services/folders.service.ts index d9925cf0fc..27bdab48d9 100644 --- a/server/src/services/folders.service.ts +++ b/server/src/services/folders.service.ts @@ -44,6 +44,19 @@ export class FoldersService { return await this.foldersRepository.findOneOrFail(folderId); } + async userAppCount(user: User, folder: Folder) { + const result = await this.foldersRepository + .createQueryBuilder('folder') + .where("id = :id", { id: folder.id }) + .loadRelationCountAndMap( + 'folder.appCount', 'folder.apps', 'apps', + qb => qb.andWhere("apps.user_id = :user_id", { user_id: user.id }) + ) + .getMany(); + + return result[0].appCount; + } + async getAppsFor(user: User, folder: Folder, page: number): Promise { const folderApps = await this.folderAppsRepository.find({ where: { @@ -57,7 +70,7 @@ export class FoldersService { }, relations: ['user'], take: 10, - skip: 10 * ( page || 0 ), + skip: 10 * (page - 1), order: { createdAt: 'DESC' } diff --git a/server/src/services/users.service.ts b/server/src/services/users.service.ts index 950db0a357..9553e7e594 100644 --- a/server/src/services/users.service.ts +++ b/server/src/services/users.service.ts @@ -77,12 +77,14 @@ export class UsersService { async update(userId: string, params: any) { - const { forgotPasswordToken, password } = params; + const { forgotPasswordToken, password, firstName, lastName } = params; const hashedPassword = password ? bcrypt.hashSync(password, 10) : undefined; const updateableParams = { forgotPasswordToken, + firstName, + lastName, password: hashedPassword } @@ -91,5 +93,4 @@ export class UsersService { return await this.usersRepository.update(userId, updateableParams); } - } diff --git a/server/test/controllers/users.e2e-spec.ts b/server/test/controllers/users.e2e-spec.ts new file mode 100644 index 0000000000..a1c16bb033 --- /dev/null +++ b/server/test/controllers/users.e2e-spec.ts @@ -0,0 +1,81 @@ +import * as request from 'supertest'; +import { INestApplication } from '@nestjs/common'; +import { authHeaderForUser, clearDB, createUser, createNestAppInstance } from '../test.helper'; + +describe('users controller', () => { + let app: INestApplication; + + beforeEach(async () => { + await clearDB(); + }); + + beforeAll(async () => { + app = await createNestAppInstance(); + }); + + describe('update user', () => { + it('should allow users to update their firstName, lastName and password', async () => { + + const userData = await createUser(app, { email: 'admin@tooljet.io', role: 'admin' }); + const { user } = userData; + + const [firstName, lastName] = ['Daenerys', 'Targaryen', 'drogo666'] + + const response = await request(app.getHttpServer()) + .patch('/users/update') + .set('Authorization', authHeaderForUser(user)) + .send({firstName, lastName}) + + expect(response.statusCode).toBe(200); + + await user.reload(); + + expect(user.firstName).toEqual(firstName) + expect(user.lastName).toEqual(lastName) + }); + }) + + describe('change password', () => { + it('should allow users to update their password', async () => { + + const userData = await createUser(app, { email: 'admin@tooljet.io', role: 'admin' }); + const { user, orgUser } = userData; + + const oldPassword = user.password; + + const response = await request(app.getHttpServer()) + .patch('/users/change_password') + .set('Authorization', authHeaderForUser(user)) + .send({currentPassword: 'password', newPassword: 'new password'}) + + expect(response.statusCode).toBe(200); + + await user.reload(); + + expect(user.password).not.toEqual(oldPassword) + }); + + it('should not allow users to update their password if entered current password is wrong', async () => { + + const userData = await createUser(app, { email: 'admin@tooljet.io', role: 'admin' }); + const { user, orgUser } = userData; + + const oldPassword = user.password; + + const response = await request(app.getHttpServer()) + .patch('/users/change_password') + .set('Authorization', authHeaderForUser(user)) + .send({currentPassword: 'wrong password', newPassword: 'new password'}) + + expect(response.statusCode).toBe(403); + + await user.reload(); + + expect(user.password).toEqual(oldPassword) + }); + }) + + afterAll(async () => { + await app.close(); + }); +});