mirror of
https://github.com/ToolJet/ToolJet
synced 2026-05-23 08:58:26 +00:00
Merge branch 'release/v0.5.12' into main
This commit is contained in:
commit
22212a16bb
65 changed files with 721 additions and 185 deletions
|
|
@ -49,6 +49,12 @@ class AppsController < ApplicationController
|
|||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
app = App.find params[:id]
|
||||
app.update(current_version: nil)
|
||||
app.destroy
|
||||
end
|
||||
|
||||
def slugs
|
||||
@app = App.find_by(slug: params[:slug])
|
||||
|
||||
|
|
|
|||
|
|
@ -14,23 +14,27 @@ class OrganizationUsersController < ApplicationController
|
|||
password = SecureRandom.uuid
|
||||
org = @current_user.organization
|
||||
|
||||
user = User.create(
|
||||
first_name: first_name,
|
||||
last_name: last_name,
|
||||
email: email,
|
||||
password: password,
|
||||
password_confirmation: password,
|
||||
organization: org,
|
||||
invitation_token: SecureRandom.uuid
|
||||
)
|
||||
if User.find_by(email: email).present?
|
||||
render json: { message: "Email address is already taken" }, status: :unprocessable_entity
|
||||
else
|
||||
user = User.create(
|
||||
first_name: first_name,
|
||||
last_name: last_name,
|
||||
email: email,
|
||||
password: password,
|
||||
password_confirmation: password,
|
||||
organization: org,
|
||||
invitation_token: SecureRandom.uuid
|
||||
)
|
||||
|
||||
org_user = OrganizationUser.new(
|
||||
role: role,
|
||||
user: user,
|
||||
organization: org
|
||||
)
|
||||
org_user = OrganizationUser.new(
|
||||
role: role,
|
||||
user: user,
|
||||
organization: org
|
||||
)
|
||||
|
||||
UserMailer.with(user: user, sender: @current_user).invitation_email.deliver if org_user.save
|
||||
UserMailer.with(user: user, sender: @current_user).invitation_email.deliver if org_user.save
|
||||
end
|
||||
end
|
||||
|
||||
def change_role
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@
|
|||
class App < ApplicationRecord
|
||||
belongs_to :organization
|
||||
has_many :data_queries, dependent: :destroy
|
||||
has_many :data_sources, dependent: :destroy
|
||||
has_many :app_users, dependent: :destroy
|
||||
has_many :app_versions, dependent: :destroy
|
||||
has_many :folder_apps, dependent: :destroy
|
||||
belongs_to :current_version, class_name: "AppVersion", optional: true
|
||||
belongs_to :user, optional: true
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ class ElasticsearchQueryService
|
|||
|
||||
if operation == 'search'
|
||||
index = options['index']
|
||||
query = JSON.parse(options[:query])
|
||||
query = JSON.parse(options['query'])
|
||||
data = connection.search(index: index, body: query)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,23 @@ class GooglesheetsQueryService
|
|||
end
|
||||
|
||||
error = result.code != 200
|
||||
data = result
|
||||
end
|
||||
|
||||
if operation === 'delete_row'
|
||||
spreadsheet_id = options['spreadsheet_id']
|
||||
sheet = options['sheet']
|
||||
row_index = options['row_index'].to_i
|
||||
|
||||
result = delete_row_from_sheet(spreadsheet_id, sheet, row_index, access_token)
|
||||
|
||||
if result.code === 401
|
||||
access_token = refresh_access_token
|
||||
result = delete_row_from_sheet(spreadsheet_id, sheet, row_index, access_token)
|
||||
end
|
||||
|
||||
data = result
|
||||
error = result.code != 200
|
||||
end
|
||||
|
||||
if operation === 'read'
|
||||
|
|
@ -123,6 +140,30 @@ class GooglesheetsQueryService
|
|||
'application/json', "Authorization": "Bearer #{access_token}" })
|
||||
end
|
||||
|
||||
def delete_row_from_sheet(spreadsheet_id, sheet, row_index, access_token)
|
||||
data = {
|
||||
"requests": [
|
||||
{
|
||||
"deleteDimension": {
|
||||
"range": {
|
||||
"sheetId": sheet,
|
||||
"dimension": "ROWS",
|
||||
"startIndex": row_index - 1,
|
||||
"endIndex": row_index
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}.to_json
|
||||
|
||||
result = HTTParty.post(
|
||||
"https://sheets.googleapis.com/v4/spreadsheets/#{spreadsheet_id}:batchUpdate",
|
||||
body: data,
|
||||
headers: { "Content-Type": 'application/json',
|
||||
"Authorization": "Bearer #{access_token}" }
|
||||
)
|
||||
end
|
||||
|
||||
def get_spreadsheet_info(spreadsheet_id, access_token)
|
||||
|
||||
result = HTTParty.get("https://sheets.googleapis.com/v4/spreadsheets/#{spreadsheet_id}",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ require 'rails/test_unit/railtie'
|
|||
# you've limited to :test, :development, or :production.
|
||||
Bundler.require(*Rails.groups)
|
||||
|
||||
TOOLJET_VERSION = '0.5.11'
|
||||
TOOLJET_VERSION = '0.5.12'
|
||||
|
||||
module ToolJet
|
||||
class Application < Rails::Application
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
Rails.application.routes.draw do
|
||||
resources :apps, only: %i[index create show update] do
|
||||
resources :apps, only: %i[index create show update destroy] do
|
||||
resources :versions, only: %i[index create update]
|
||||
|
||||
get '/users', to: 'apps#users'
|
||||
|
|
|
|||
|
|
@ -12,14 +12,14 @@ describe('User login', () => {
|
|||
|
||||
it('should display invalid email or password error', () => {
|
||||
cy.login('fake_email', 'abcdefg');
|
||||
cy.checkToastMessage('toast-login-auth-error', 'Invalid email or password')
|
||||
cy.checkToastMessage('toast-login-auth-error', 'Invalid email or password');
|
||||
});
|
||||
|
||||
it('should take user to the forgot password page', () => {
|
||||
cy.visit('/forgot-password');
|
||||
cy.get('.card-title')
|
||||
.should('have.text', 'Forgot Password');
|
||||
})
|
||||
});
|
||||
|
||||
it('should take user to the signup page', () => {
|
||||
cy.visit('/signup');
|
||||
|
|
@ -34,4 +34,22 @@ describe('User login', () => {
|
|||
cy.get('.page-title')
|
||||
.should('have.text', 'All applications');
|
||||
})
|
||||
|
||||
it('should display error if email is not found for "Forgot password"', () => {
|
||||
cy.visit('/forgot-password');
|
||||
cy.get('[data-testid="emailField"]').type('abc@def.com');
|
||||
cy.get('[data-testid="submitButton"').click();
|
||||
cy.checkToastMessage('toast-forgot-password-email-error', 'Email address is not associated with a ToolJet cloud account.');
|
||||
});
|
||||
|
||||
it('should send reset password confirmation code to email', () => {
|
||||
cy.intercept('POST', '/password/forgot').as('forgotPasswordConfirmationCode');
|
||||
|
||||
cy.visit('/forgot-password');
|
||||
cy.get('[data-testid="emailField"]').type('dev@tooljet.io');
|
||||
cy.get('[data-testid="submitButton"').click();
|
||||
|
||||
cy.wait('@forgotPasswordConfirmationCode').its('response.statusCode').should('eq', 200);
|
||||
cy.checkToastMessage('toast-forgot-password-confirmation-code', 'We\'ve sent the confirmation code to your email address');
|
||||
});
|
||||
})
|
||||
57
cypress/integration/dashboard.spec.js
Normal file
57
cypress/integration/dashboard.spec.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
describe('Dashboard', () => {
|
||||
// we can use these values to log in
|
||||
const email = 'dev@tooljet.io';
|
||||
const password = 'password';
|
||||
|
||||
beforeEach(() => {
|
||||
cy.login(email, password);
|
||||
})
|
||||
|
||||
it('site header is visible with nav items', () => {
|
||||
cy.get('.navbar')
|
||||
.find('.navbar-nav')
|
||||
.should('be.visible');
|
||||
});
|
||||
|
||||
it('Users tab should navigate to Users page', () => {
|
||||
cy.get('.navbar')
|
||||
.find('.navbar-nav')
|
||||
.find('li').not('.active')
|
||||
.click();
|
||||
cy.location('pathname').should('equal', '/users');
|
||||
cy.get('.page-title').should('have.text', 'Users & Permissions');
|
||||
cy.get('[data-testid="usersTable"]').should('be.visible');
|
||||
})
|
||||
|
||||
it('Apps tab should navigate to Apps page', () => {
|
||||
cy.get('.navbar')
|
||||
.find('.navbar-nav')
|
||||
.find('li.active')
|
||||
.click();
|
||||
cy.location('pathname').should('equal', '/');
|
||||
cy.get('.page-title').should('have.text', 'All applications');
|
||||
cy.get('[data-testid="appsTable"]').should('be.visible');
|
||||
})
|
||||
|
||||
it('should show User avatar and logout the user when user clicks logout', () => {
|
||||
cy.get('[data-testid="userAvatarHeader"]').should('be.visible');
|
||||
// TODO - Add functionality to detect when user hovers over the avatar,
|
||||
// Issues with hover functionality and hide/show of dom elements
|
||||
})
|
||||
|
||||
it('Application folders list is visible', () => {
|
||||
cy.get('[data-testid="applicationFoldersList"]')
|
||||
.should('be.visible');
|
||||
});
|
||||
|
||||
it('Count bubble for "All applications should equal number of rows in table', () => {
|
||||
cy.get('[data-testid="allApplicationsCount"]').then(($countBubble) => {
|
||||
cy.get('[data-testid="appsTable"]')
|
||||
.wait(500)
|
||||
.find("tr")
|
||||
.then((row) => {
|
||||
expect(Number($countBubble.text())).to.equal(row.length)
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
|
|
@ -7,4 +7,4 @@ Cypress.Commands.add('login', (email, password) => {
|
|||
|
||||
Cypress.Commands.add('checkToastMessage', (toastId, message) => {
|
||||
cy.get(`[id=${toastId}]`).should('contain', message);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
class AddIndexToOrganizationUsers < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
add_index :organization_users, [:organization_id, :user_id], unique: true, if_not_exists: true
|
||||
end
|
||||
end
|
||||
3
db/schema.rb
generated
3
db/schema.rb
generated
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 2021_06_19_124759) do
|
||||
ActiveRecord::Schema.define(version: 2021_06_30_165919) do
|
||||
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
|
|
@ -142,6 +142,7 @@ ActiveRecord::Schema.define(version: 2021_06_19_124759) do
|
|||
t.datetime "created_at", precision: 6, null: false
|
||||
t.datetime "updated_at", precision: 6, null: false
|
||||
t.string "status", default: "invited"
|
||||
t.index ["organization_id", "user_id"], name: "index_organization_users_on_organization_id_and_user_id", unique: true
|
||||
t.index ["organization_id"], name: "index_organization_users_on_organization_id"
|
||||
t.index ["user_id"], name: "index_organization_users_on_user_id"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,3 +5,4 @@ PG_DB=tooljet_prod
|
|||
PG_USER=<pg user name>
|
||||
PG_HOST=<pg host>
|
||||
PG_PASS=<pg user password>
|
||||
RAILS_LOG_TO_STDOUT=true
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ http
|
|||
location /
|
||||
{
|
||||
root /home/ubuntu/app/frontend/build;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /_backend_
|
||||
|
|
@ -84,7 +84,7 @@ http
|
|||
location /
|
||||
{
|
||||
root /home/ubuntu/app/frontend/build;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /_backend_
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ def build_fe
|
|||
backend_url = "#{ENV.fetch("TOOLJET_HOST")}/_backend_"
|
||||
front_end_working_dir = "/home/ubuntu/app/frontend"
|
||||
Dir.chdir front_end_working_dir
|
||||
system("npm install")
|
||||
system("npm install --only=production")
|
||||
system("NODE_ENV=production TOOLJET_SERVER_URL=#{backend_url} npm run-script build")
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ packer {
|
|||
}
|
||||
|
||||
source "amazon-ebs" "ubuntu" {
|
||||
ami_name = "tooljet_latest_ubuntu_bionic"
|
||||
instance_type = "t2.medium"
|
||||
region = "us-west-2"
|
||||
ami_name = "${var.ami_name}"
|
||||
instance_type = "${var.instance_type}"
|
||||
region = "${var.ami_region}"
|
||||
source_ami_filter {
|
||||
filters = {
|
||||
name = "ubuntu/images/hvm-ssd/ubuntu-bionic-18.04-amd64-server-*"
|
||||
|
|
|
|||
14
deploy/ec2/variables.pkr.hcl
Normal file
14
deploy/ec2/variables.pkr.hcl
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
|
||||
variable "ami_name" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "instance_type" {
|
||||
type = string
|
||||
default = "t2.medium"
|
||||
}
|
||||
|
||||
variable "ami_region" {
|
||||
type = string
|
||||
default = "us-west-2"
|
||||
}
|
||||
2
docker/entrypoints/server.sh
Normal file → Executable file
2
docker/entrypoints/server.sh
Normal file → Executable file
|
|
@ -1,4 +1,4 @@
|
|||
#!/bin/bash
|
||||
#!/bin/sh
|
||||
set -e
|
||||
bundle check || bundle install
|
||||
rake db:create
|
||||
|
|
|
|||
|
|
@ -15,4 +15,4 @@ ENV RAILS_ENV=production
|
|||
|
||||
COPY . ./
|
||||
RUN ["chmod", "755", "docker/entrypoints/server.sh"]
|
||||
ENTRYPOINT ["docker/entrypoints/server.sh"]
|
||||
ENTRYPOINT ["./docker/entrypoints/server.sh"]
|
||||
|
|
|
|||
|
|
@ -32,9 +32,13 @@ Follow these steps to setup and run ToolJet on Mac OS. Open terminal and run the
|
|||
gem install bundler:2.1.4
|
||||
```
|
||||
|
||||
### Install Node.js
|
||||
### Install Node.js ( version: v14.9.0 )
|
||||
```bash
|
||||
$ brew install node
|
||||
$ brew install nvm
|
||||
$ export NVM_DIR=~/.nvm
|
||||
$ source $(brew --prefix nvm)/nvm.sh
|
||||
$ nvm install 14.9.0
|
||||
$ nvm use 14.9.0
|
||||
|
||||
```
|
||||
|
||||
|
|
@ -56,13 +60,17 @@ Follow these steps to setup and run ToolJet on Mac OS. Open terminal and run the
|
|||
```
|
||||
|
||||
3. ## Populate the keys in the env file.
|
||||
Run `openssl rand -hex 64` to create secure secrets and use them as the values for `LOCKBOX_MASTER_KEY` and `SECRET_KEY_BASE`.
|
||||
:::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)
|
||||
|
||||
`LOCKBOX_MASTER_KEY` requires a 32 byte key. (Run `openssl rand -hex 32` to create a 32 byte secure random key)
|
||||
:::
|
||||
|
||||
Example:
|
||||
```bash
|
||||
$ cat .env
|
||||
TOOLJET_HOST=http://localhost:8082
|
||||
LOCKBOX_MASTER_KEY=c92bcc7f112ffbdd131d1fb6c5005e372b8802f85f6c4586e5a88f57a541382841c8c99e5701b84862e448dd5db846f705321a41bd48a0fed1b58b9596a3877f
|
||||
LOCKBOX_MASTER_KEY=1d291a926ddfd221205a23adb4cc1db66cb9fcaf28d97c8c1950e3538e3b9281
|
||||
SECRET_KEY_BASE=4229d5774cfe7f60e75d6b3bf3a1dbb054a696b6d21b6d5de7b73291899797a222265e12c0a8e8d844f83ebacdf9a67ec42584edf1c2b23e1e7813f8a3339041
|
||||
```
|
||||
|
||||
|
|
@ -105,8 +113,13 @@ Follow these steps to setup and run ToolJet on Mac OS. Open terminal and run the
|
|||
```ruby
|
||||
OrganizationUser.create(user: User.first, organization: Organization.first, role: 'admin', status: 'active')
|
||||
```
|
||||
8. ## Install webpack
|
||||
```bash
|
||||
$ npm install --save-dev webpack
|
||||
$ npm install --save-dev webpack-cli
|
||||
```
|
||||
|
||||
8. ## Running the React frontend ( Client )
|
||||
9. ## Running the React frontend ( Client )
|
||||
```bash
|
||||
$ cd ./frontend && npm start
|
||||
```
|
||||
|
|
|
|||
|
|
@ -74,6 +74,45 @@ We recommend:
|
|||
$ docker-compose stop
|
||||
```
|
||||
|
||||
## Making changes to the codebase
|
||||
|
||||
If you make any changes to the codebase/pull the latest changes from upstream, the tooljet server container would hot reload the application without you doing anything.
|
||||
|
||||
Caveat:
|
||||
|
||||
1. If the changes include database migrations or new gem additions in the Gemfile, you would need to restart the ToolJet server container by running `docker-compose restart server`.
|
||||
|
||||
2. If you need to add a new binary or system libary to the container itself, you would need to add those dependencies in `docker/server.Dockerfile.dev` and then rebuild the ToolJet server image. You can do that by running `docker-compose build server`. Once that completes you can start everything normally with `docker-compose up`.
|
||||
|
||||
Example:
|
||||
Let's say you need to install the `imagemagick` binary in your ToolJet server's container. You'd then need to make sure that `apt` installs `imagemagick` while building the image. The Dockerfile at `docker/server.Dockerfile.dev` for the server would then look something like this:
|
||||
```
|
||||
FROM ruby:2.7.3-buster
|
||||
|
||||
#Notice the newly added imagemagick package
|
||||
RUN apt update && apt install -y \
|
||||
build-essential \
|
||||
postgresql \
|
||||
freetds-dev \
|
||||
imagemagick
|
||||
|
||||
|
||||
RUN mkdir -p /app
|
||||
WORKDIR /app
|
||||
|
||||
COPY Gemfile Gemfile.lock ./
|
||||
RUN gem install bundler && bundle install --jobs 20 --retry 5
|
||||
|
||||
ENV RAILS_ENV=development
|
||||
|
||||
COPY . ./
|
||||
|
||||
RUN ["chmod", "755", "docker/entrypoints/server.sh"]
|
||||
|
||||
```
|
||||
Once you've updated the Dockerfile, rebuild the image by running `docker-compose build server`. After building the new image, start the services by running `docker-compose up`.
|
||||
|
||||
|
||||
## Running Rails tests
|
||||
|
||||
To run all the tests
|
||||
|
|
|
|||
|
|
@ -42,13 +42,17 @@ Follow these steps to setup and run ToolJet on Ubuntu. Open terminal and run the
|
|||
|
||||
|
||||
3. ## Populate the keys in the env file.
|
||||
Run `openssl rand -hex 64` to create secure secrets and use them as the values for `LOCKBOX_MASTER_KEY` and `SECRET_KEY_BASE`.
|
||||
:::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)
|
||||
|
||||
`LOCKBOX_MASTER_KEY` requires a 32 byte key. (Run `openssl rand -hex 32` to create a 32 byte secure random key)
|
||||
:::
|
||||
|
||||
Example:
|
||||
```bash
|
||||
$ cat .env
|
||||
TOOLJET_HOST=http://localhost:8082
|
||||
LOCKBOX_MASTER_KEY=c92bcc7f112ffbdd131d1fb6c5005e372b8802f85f6c4586e5a88f57a541382841c8c99e5701b84862e448dd5db846f705321a41bd48a0fed1b58b9596a3877f
|
||||
LOCKBOX_MASTER_KEY=1d291a926ddfd221205a23adb4cc1db66cb9fcaf28d97c8c1950e3538e3b9281
|
||||
SECRET_KEY_BASE=4229d5774cfe7f60e75d6b3bf3a1dbb054a696b6d21b6d5de7b73291899797a222265e12c0a8e8d844f83ebacdf9a67ec42584edf1c2b23e1e7813f8a3339041
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -5,17 +5,17 @@ sidebar_position: 3
|
|||
# GraphQL
|
||||
|
||||
|
||||
ToolJet can connect to GraphQL endpoints. We currently support queries and mutations.
|
||||
ToolJet can connect to GraphQL endpoints to execute queries and mutations.
|
||||
|
||||
## Connection
|
||||
|
||||
To add a new GraphQL datasource, click on the '+' button on data sources panel at the left-bottom corner of the app editor. Select GraphQL from the modal that pops up.
|
||||
To add a new GraphQL datasource, click the `+` button on data sources panel at the bottom-left corner of the app builder and then select GraphQL from the modal that pops up.
|
||||
|
||||
ToolJet requires the following to connect to a GraphQL datasource.
|
||||
|
||||
- **URL**
|
||||
- URL of the GraphQL endpoint
|
||||
|
||||
Following optional parameters are also supported:
|
||||
The following optional parameters are also supported:
|
||||
|
||||
| Type | Description |
|
||||
| ----------- | ----------- |
|
||||
|
|
@ -24,14 +24,14 @@ Following optional parameters are also supported:
|
|||
|
||||
|
||||
|
||||
<img src="/img/datasource-reference/graphql-connect.png" alt="ToolJet - GraphQL connection" height="250"/>
|
||||
<img class="screenshot-full" src="/img/datasource-reference/graphql/add-source.gif" alt="ToolJet - GraphQL connection" height="420"/>
|
||||
|
||||
Click on the 'Save' button to save the datasource.
|
||||
|
||||
## Querying GraphQL
|
||||
Click on '+' button of the query manager at the bottom panel of the editor and select the GraphQL endpoint added in the previous step as the datasource.
|
||||
Click on `+` button of the query manager at the bottom panel of the editor and select the GraphQL endpoint added in the previous step as the datasource.
|
||||
|
||||
<img src="/img/datasource-reference/graphql-query.png" alt="ToolJet - GraphQL connection" height="250"/>
|
||||
<img class="screenshot-full" src="/img/datasource-reference/graphql-query.png" alt="ToolJet - GraphQL connection" height="420"/>
|
||||
|
||||
Click on the 'run' button to run the query. NOTE: Query should be saved before running.
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ Follow the steps below to deploy ToolJet on AWS EC2 instances.
|
|||
|
||||
3. Under the `Images` section, click on the `AMIs` button.
|
||||
|
||||
4. Now, from the AMI search page, select the search type as "Public Images" and input `AMI Name : tooljet_latest_ubuntu_bionic` in the search bar.
|
||||
4. Now, from the AMI search page, select the search type as "Public Images" and input `AMI Name : tooljet_v0.5.11.ubuntu_bionic` in the search bar.
|
||||
|
||||
5. Select ToolJet's AMI and bootup an EC2 instance.
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ Follow the steps below to deploy ToolJet on AWS EC2 instances.
|
|||
Please make sure that `TOOLJET_HOST` starts with either `http://` or `https://`
|
||||
:::
|
||||
|
||||
9. Once you've configured the `.env` file, run `./setup_app.rb`. This script will install all the dependencies of ToolJet and then will start the required services.
|
||||
9. Once you've configured the `.env` file, run `./setup_app`. This script will install all the dependencies of ToolJet and then will start the required services.
|
||||
|
||||
10. If you've set a custom domain for `TOOLJET_HOST`, add a `A record` entry in your DNS settings to point to the IP address of the EC2 instance.
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,20 @@ ToolJet server uses PostgreSQL as the database.
|
|||
| PG_PASS | password |
|
||||
|
||||
#### Lockbox configuration ( required )
|
||||
ToolJet server uses lockbox to encrypt datasource credentials. You should set the environment variable `LOCKBOX_MASTER_KEY`.
|
||||
ToolJet server uses lockbox to encrypt datasource credentials. You should set the environment variable `LOCKBOX_MASTER_KEY` with a 32 byte hexadecimal string.
|
||||
|
||||
|
||||
#### Application Secret ( required )
|
||||
ToolJet server uses a secure 64 byte hexadecimal string to encrypt session cookies. You should set the environment variable `SECRET_KEY_BASE`.
|
||||
|
||||
|
||||
:::tip
|
||||
If you have `openssl` installed, you can run the following commands to generate the the value for `LOCKBOX_MASTER_KEY` and `SECRET_KEY_BASE`.
|
||||
|
||||
For `LOCKBOX_MASTER_KEY` use `openssl rand -hex 32`
|
||||
For `SECRET_KEY_BASE` use `openssl rand -hex 64`
|
||||
:::
|
||||
|
||||
|
||||
#### Disabling signups ( optional )
|
||||
|
||||
|
|
|
|||
|
|
@ -12,12 +12,12 @@ Follow the steps below to deploy ToolJet server on a Kubernetes cluster.
|
|||
|
||||
1. Setup a PostgreSQL database.
|
||||
|
||||
2. Create a Kubernetes secret with name `server`. For the minimal setup, ToolJet requires pg_host, pg_db, pg_user, pg_password, secret_key_base & lockbox_key keys in the secret. ( Read [environment variables reference](/docs/deployment/env-vars) )
|
||||
2. Create a Kubernetes secret with name `server`. For the minimal setup, ToolJet requires `pg_host`, `pg_db`, `pg_user`, `pg_password`, `secret_key_base` & `lockbox_key` keys in the secret. ( Read [environment variables reference](/docs/deployment/env-vars) )
|
||||
|
||||
3. Create a Kubernetes deployment
|
||||
|
||||
```bash
|
||||
$ kubectl apply -f https://github.com/ToolJet/ToolJet/blob/main/deploy/kubernetes/server-deployment.yaml
|
||||
$ kubectl apply -f https://raw.githubusercontent.com/ToolJet/ToolJet/main/deploy/kubernetes/server-deployment.yaml
|
||||
```
|
||||
|
||||
:::info
|
||||
|
|
@ -30,15 +30,7 @@ The file given above is just a template and might not suit production environmen
|
|||
$ kubectl get pods
|
||||
```
|
||||
|
||||
You should be able to see that the `tooljet-server` pod has been created.
|
||||
|
||||
```bash
|
||||
$ kubectl port-forward <pod-name> 3000:3000
|
||||
```
|
||||
|
||||
Use this command to forward the port 3000 of server on the pod to the port 3000 of your local machine.
|
||||
|
||||
5. You can setup load balancers or Kubernetes services to publish the Kubernetes deployment that you've created. This step varies with cloud providers.
|
||||
5. Create a Kubernetes services to publish the Kubernetes deployment that you've created. This step varies with cloud providers. We have a [template](https://raw.githubusercontent.com/ToolJet/ToolJet/main/deploy/kubernetes/server-service.yaml) for exposing the ToolJet server as a service using an AWS loadbalancer.
|
||||
Examples:
|
||||
Application load balancing on Amazon EKS: https://docs.aws.amazon.com/eks/latest/userguide/alb-ingress.html
|
||||
GKE Ingress for HTTP(S) Load Balancing: https://cloud.google.com/kubernetes-engine/docs/concepts/ingress
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ Here is a video explaining how to build a Redis GUI using ToolJet in 3 minutes:
|
|||
|
||||
These resources will help you to quickly build and deploy apps using ToolJet:
|
||||
|
||||
- **[Setup](/docs/setup/architecture)** - Learn how to setup ToolJet locally using docker.
|
||||
- **[Setup](/docs/deployment/architecture)** - Learn how to setup ToolJet locally using docker.
|
||||
- **[Basic Tutorial](/docs/tutorial/creating-app)** - Learn how to build simple UI and connect to data sources.
|
||||
- **[Deploy](/docs/contributing-guide/setup/docker)** - Learn how to deploy ToolJet on Heroku, Kubernetes, etc
|
||||
|
||||
|
|
|
|||
BIN
docs/static/img/datasource-reference/graphql/add-source.gif
vendored
Normal file
BIN
docs/static/img/datasource-reference/graphql/add-source.gif
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 MiB |
|
|
@ -55,12 +55,12 @@ class App extends React.Component {
|
|||
return (
|
||||
<Router history={history}>
|
||||
<div>
|
||||
{updateAvailable && <div class="alert alert-info alert-dismissible" role="alert">
|
||||
<h3 class="mb-1">Update available</h3>
|
||||
{updateAvailable && <div className="alert alert-info alert-dismissible" role="alert">
|
||||
<h3 className="mb-1">Update available</h3>
|
||||
<p>A new version of ToolJet has been released.</p>
|
||||
<div class="btn-list">
|
||||
<a href="https://docs.tooljet.io/docs/setup/updating" target="_blank" class="btn btn-info">Read release notes & update</a>
|
||||
<a onClick={() => { tooljetService.skipVersion(); this.setState({ updateAvailable: false }); }} class="btn">Skip this version</a>
|
||||
<div className="btn-list">
|
||||
<a href="https://docs.tooljet.io/docs/setup/updating" target="_blank" className="btn btn-info">Read release notes & update</a>
|
||||
<a onClick={() => { tooljetService.skipVersion(); this.setState({ updateAvailable: false }); }} className="btn">Skip this version</a>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,13 @@ export const ActionTypes = [
|
|||
{ name: 'url', type: 'text', default: 'https://example.com' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Go to app',
|
||||
id: 'go-to-app',
|
||||
options: [
|
||||
{ name: 'app', type: 'url', default: 'https://app.tooljet.io/applications/app-id' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Show Modal',
|
||||
id: 'show-modal',
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import { Multiselect } from './Components/Multiselect';
|
|||
import { Modal } from './Components/Modal';
|
||||
import { Chart } from './Components/Chart';
|
||||
import { Map } from './Components/Map';
|
||||
import { renderTooltip } from '../_helpers/appUtils';
|
||||
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
|
||||
|
||||
const AllComponents = {
|
||||
Button,
|
||||
|
|
@ -68,6 +70,12 @@ export const Box = function Box({
|
|||
const ComponentToRender = AllComponents[component.component];
|
||||
|
||||
return (
|
||||
<OverlayTrigger
|
||||
placement="top"
|
||||
delay={{ show: 500, hide: 0 }}
|
||||
trigger={!inCanvas? ['hover', 'focus']: null}
|
||||
overlay={(props) => renderTooltip({props, text: `${component.description}`})}
|
||||
>
|
||||
<div style={{ ...styles, backgroundColor }} role={preview ? 'BoxPreview' : 'Box'}>
|
||||
{inCanvas ? (
|
||||
<ComponentToRender
|
||||
|
|
@ -106,5 +114,6 @@ export const Box = function Box({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,9 +10,20 @@ import 'codemirror/addon/hint/show-hint.css';
|
|||
import 'codemirror/theme/base16-light.css';
|
||||
import 'codemirror/theme/duotone-light.css';
|
||||
import { getSuggestionKeys, onBeforeChange, handleChange } from './utils';
|
||||
import { resolveReferences } from '@/_helpers/utils';
|
||||
|
||||
export function CodeHinter({
|
||||
initialValue, onChange, currentState, mode, theme, lineNumbers, className, placeholder, ignoreBraces
|
||||
initialValue,
|
||||
onChange,
|
||||
currentState,
|
||||
mode,
|
||||
theme,
|
||||
lineNumbers,
|
||||
className,
|
||||
placeholder,
|
||||
ignoreBraces,
|
||||
enablePreview,
|
||||
height
|
||||
}) {
|
||||
const options = {
|
||||
lineNumbers: lineNumbers,
|
||||
|
|
@ -26,29 +37,41 @@ export function CodeHinter({
|
|||
};
|
||||
|
||||
const [realState, setRealState] = useState(currentState);
|
||||
const [currentValue, setCurrentValue] = useState(initialValue);
|
||||
|
||||
useEffect(() => {
|
||||
setRealState(currentState);
|
||||
}, [currentState.components]);
|
||||
|
||||
let suggestions = useMemo(() => {
|
||||
return getSuggestionKeys(currentState);
|
||||
}, [currentState.components, currentState.queries]);
|
||||
return getSuggestionKeys(realState);
|
||||
}, [realState.components, realState.queries]);
|
||||
|
||||
function valueChanged(editor, onChange, suggestions, ignoreBraces) {
|
||||
handleChange(editor, onChange, suggestions, ignoreBraces);
|
||||
setCurrentValue(editor.getValue());
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`code-hinter ${className || 'codehinter-default-input'}`}>
|
||||
<div className={`code-hinter ${className || 'codehinter-default-input'}`} key={suggestions.length}>
|
||||
<CodeMirror
|
||||
value={initialValue}
|
||||
realState={realState}
|
||||
scrollbarStyle={null}
|
||||
height={height || '100%'}
|
||||
onBlur={(editor) => {
|
||||
const value = editor.getValue();
|
||||
onChange(value);
|
||||
}}
|
||||
onChange={(editor) => handleChange(editor, onChange, suggestions, ignoreBraces)}
|
||||
onChange={(editor) => valueChanged(editor, onChange, suggestions, ignoreBraces)}
|
||||
onBeforeChange={(editor, change) => onBeforeChange(editor, change, ignoreBraces)}
|
||||
options={options}
|
||||
/>
|
||||
{enablePreview &&
|
||||
<div className="dynamic-variable-preview bg-azure-lt px-2 py-1">
|
||||
{resolveReferences(currentValue, realState)}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ export function handleChange(editor, onChange, suggestions, ignoreBraces = false
|
|||
|
||||
const options = {
|
||||
alignWithWord: true,
|
||||
completeSingle: false,
|
||||
hint: function () {
|
||||
return {
|
||||
from: { line: cursor.line, ch: cursor.ch - currentWord.length },
|
||||
|
|
@ -109,4 +110,4 @@ export function handleChange(editor, onChange, suggestions, ignoreBraces = false
|
|||
if (canShowHint(editor, ignoreBraces)) {
|
||||
editor.showHint(options);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export const DropDown = function DropDown({
|
|||
|
||||
const currentValueProperty = component.definition.properties.value;
|
||||
const value = currentValueProperty ? currentValueProperty.value : '';
|
||||
const [currentValue, setCurrentValue] = useState(value);
|
||||
const [currentValue, setCurrentValue] = useState('');
|
||||
|
||||
let newValue = value;
|
||||
if (currentValueProperty && currentState) {
|
||||
|
|
@ -52,6 +52,10 @@ export const DropDown = function DropDown({
|
|||
setCurrentValue(newValue);
|
||||
}, [newValue]);
|
||||
|
||||
useEffect(() => {
|
||||
onComponentOptionChanged(component, 'value', currentValue);
|
||||
}, [currentValue]);
|
||||
|
||||
return (
|
||||
<div className="row" style={{ width, height }} onClick={() => onComponentClick(id, component)}>
|
||||
<div className="col-auto">
|
||||
|
|
@ -63,7 +67,7 @@ export const DropDown = function DropDown({
|
|||
value={currentValue}
|
||||
search={true}
|
||||
onChange={(newVal) => {
|
||||
onComponentOptionChanged(component, 'value', newVal);
|
||||
setCurrentValue(newVal);
|
||||
}}
|
||||
filterOptions={fuzzySearch}
|
||||
placeholder="Select.."
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ export const CustomSelect = ({ options, value, multiple, disabled, onChange }) =
|
|||
|
||||
function renderValue(valueProps) {
|
||||
if(valueProps) {
|
||||
return valueProps.value.split(', ').map((value) => <span {...valueProps} class="badge bg-blue-lt p-2 mx-1">{value}</span>);
|
||||
return valueProps.value.split(', ').map((value) => <span {...valueProps} className="badge bg-blue-lt p-2 mx-1">{value}</span>);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="custom-select">
|
||||
<div className="custom-select">
|
||||
<SelectSearch
|
||||
options={options}
|
||||
printOptions="on-focus"
|
||||
|
|
|
|||
|
|
@ -625,7 +625,7 @@ export function Table({
|
|||
<span data-tip="Filter data" className="btn btn-light btn-sm p-1 mx-2" onClick={() => showFilters()}>
|
||||
<img src="/assets/images/icons/filter.svg" width="13" height="13" />
|
||||
{filters.length > 0 &&
|
||||
<a class="badge bg-azure" style={{width: '4px', height: '4px', marginTop: '5px'}}></a>
|
||||
<a className="badge bg-azure" style={{width: '4px', height: '4px', marginTop: '5px'}}></a>
|
||||
}
|
||||
</span>
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export const Tags = ({ value, onChange }) => {
|
|||
}
|
||||
|
||||
function renderTag(text) {
|
||||
return <span class="col-auto badge bg-blue-lt p-2 mx-1 tag mb-2">
|
||||
return <span className="col-auto badge bg-blue-lt p-2 mx-1 tag mb-2">
|
||||
{text}
|
||||
<span className="badge badge-pill bg-red-lt remove-tag-button" onClick={() => removeTag(text)}>
|
||||
x
|
||||
|
|
@ -37,17 +37,17 @@ export const Tags = ({ value, onChange }) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div class="tags row">
|
||||
<div className="tags row">
|
||||
{value.map((item) => {
|
||||
return renderTag(item)
|
||||
})}
|
||||
|
||||
{!showForm &&
|
||||
<span class="col-auto badge bg-green-lt mx-1 add-tag-button" onClick={() => setShowForm(true)}>{'+'}</span>
|
||||
<span className="col-auto badge bg-green-lt mx-1 add-tag-button" onClick={() => setShowForm(true)}>{'+'}</span>
|
||||
}
|
||||
|
||||
{showForm &&
|
||||
<span class="col-auto badge bg-green-lt mx-1">
|
||||
<span className="col-auto badge bg-green-lt mx-1">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
|
|
|
|||
|
|
@ -568,7 +568,7 @@ export const componentTypes = [
|
|||
description: 'Select one value from options',
|
||||
defaultSize: {
|
||||
width: 200,
|
||||
height: 30
|
||||
height: 37
|
||||
},
|
||||
component: 'DropDown',
|
||||
others: {
|
||||
|
|
@ -588,7 +588,7 @@ export const componentTypes = [
|
|||
|
||||
},
|
||||
exposedVariables: {
|
||||
value: {}
|
||||
value: null
|
||||
},
|
||||
definition: {
|
||||
others: {
|
||||
|
|
|
|||
|
|
@ -310,7 +310,7 @@ export const Container = ({
|
|||
{appLoading && (
|
||||
<div className="mx-auto mt-5 w-50 p-5">
|
||||
<center>
|
||||
<div class="spinner-border text-azure" role="status"></div>
|
||||
<div className="spinner-border text-azure" role="status"></div>
|
||||
</center>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import ReactTooltip from 'react-tooltip';
|
|||
import { Resizable } from 're-resizable';
|
||||
import { WidgetManager } from './WidgetManager';
|
||||
import Fuse from 'fuse.js';
|
||||
import queryString from 'query-string';
|
||||
|
||||
class Editor extends React.Component {
|
||||
constructor(props) {
|
||||
|
|
@ -51,6 +52,7 @@ class Editor extends React.Component {
|
|||
|
||||
this.state = {
|
||||
currentUser: authenticationService.currentUserValue,
|
||||
app: {},
|
||||
allComponentTypes: componentTypes,
|
||||
queryPaneHeight: '30%',
|
||||
isLoading: true,
|
||||
|
|
@ -72,9 +74,10 @@ class Editor extends React.Component {
|
|||
components: {},
|
||||
globals: {
|
||||
currentUser: userVars,
|
||||
urlparams: {}
|
||||
urlparams: JSON.parse(JSON.stringify(queryString.parse(props.location.search)))
|
||||
}
|
||||
},
|
||||
apps: [],
|
||||
dataQueriesDefaultText: 'You haven\'t created queries yet.',
|
||||
showQuerySearchField: false
|
||||
};
|
||||
|
|
@ -82,6 +85,7 @@ class Editor extends React.Component {
|
|||
|
||||
componentDidMount() {
|
||||
const appId = this.props.match.params.id;
|
||||
this.fetchApps(0);
|
||||
|
||||
appService.getApp(appId).then((data) => this.setState(
|
||||
{
|
||||
|
|
@ -176,6 +180,13 @@ class Editor extends React.Component {
|
|||
);
|
||||
};
|
||||
|
||||
fetchApps = (page) => {
|
||||
appService.getAll(page).then((data) => this.setState({
|
||||
apps: data.apps,
|
||||
isLoading: false
|
||||
}));
|
||||
}
|
||||
|
||||
computeComponentState = (components) => {
|
||||
let componentState = {};
|
||||
const currentComponents = this.state.currentState.components;
|
||||
|
|
@ -421,6 +432,13 @@ class Editor extends React.Component {
|
|||
this.setState({ showQuerySearchField: !this.state.showQuerySearchField });
|
||||
}
|
||||
|
||||
onVersionDeploy = (versionId) => {
|
||||
this.setState({ app: {
|
||||
...this.state.app,
|
||||
current_version_id: versionId
|
||||
}})
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
currentSidebarTab,
|
||||
|
|
@ -447,7 +465,8 @@ class Editor extends React.Component {
|
|||
deviceWindowWidth,
|
||||
scaleValue,
|
||||
dataQueriesDefaultText,
|
||||
showQuerySearchField
|
||||
showQuerySearchField,
|
||||
apps
|
||||
} = this.state;
|
||||
const appLink = slug ? `/applications/${slug}` : '';
|
||||
|
||||
|
|
@ -553,20 +572,26 @@ class Editor extends React.Component {
|
|||
</div>
|
||||
<div className="navbar-nav flex-row order-md-last">
|
||||
<div className="nav-item dropdown d-none d-md-flex me-3">
|
||||
{app
|
||||
{app.id
|
||||
&& <ManageAppUsers
|
||||
app={app}
|
||||
slug={slug}
|
||||
handleSlugChange={this.handleSlugChange} />}
|
||||
</div>
|
||||
<div className="nav-item dropdown d-none d-md-flex me-3">
|
||||
<a href={appLink} target="_blank" className="btn btn-sm" rel="noreferrer">
|
||||
<a href={appLink} target="_blank" className={`btn btn-sm ${app?.current_version_id ? '': 'disabled'}`} rel="noreferrer">
|
||||
Launch
|
||||
</a>
|
||||
</div>
|
||||
<div className="nav-item dropdown me-2">
|
||||
{this.state.app && (
|
||||
<SaveAndPreview appId={app.id} appName={app.name} appDefinition={appDefinition} app={app} />
|
||||
{app.id && (
|
||||
<SaveAndPreview
|
||||
appId={app.id}
|
||||
appName={app.name}
|
||||
appDefinition={appDefinition}
|
||||
app={app}
|
||||
onVersionDeploy={this.onVersionDeploy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -866,6 +891,7 @@ class Editor extends React.Component {
|
|||
currentState={currentState}
|
||||
allComponents={appDefinition.components}
|
||||
key={selectedComponent.id}
|
||||
apps={apps}
|
||||
></Inspector>
|
||||
) : (
|
||||
<div className="mt-5 p-2">Please select a component to inspect</div>
|
||||
|
|
|
|||
|
|
@ -372,7 +372,7 @@ class Table extends React.Component {
|
|||
<div className="text">{item.name}</div>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<span class="badge bg-red-lt" onClick={() => this.removeColumn(index)}>x</span>
|
||||
<span className="badge bg-red-lt" onClick={() => this.removeColumn(index)}>x</span>
|
||||
</div>
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
|
|
@ -403,7 +403,7 @@ class Table extends React.Component {
|
|||
{renderElement(component, componentMeta, paramUpdated, dataQueries, 'serverSidePagination', 'properties', currentState)}
|
||||
{renderElement(component, componentMeta, paramUpdated, dataQueries, 'serverSideSearch', 'properties', currentState)}
|
||||
|
||||
<div class="hr-text">Events</div>
|
||||
<div className="hr-text">Events</div>
|
||||
|
||||
{renderEvent(component, eventUpdated, dataQueries, eventOptionUpdated, 'onRowClicked', componentMeta.events.onRowClicked, currentState, components)}
|
||||
{renderEvent(component, eventUpdated, dataQueries, eventOptionUpdated, 'onPageChanged', componentMeta.events.onPageChanged, currentState, components)}
|
||||
|
|
@ -411,7 +411,7 @@ class Table extends React.Component {
|
|||
|
||||
{renderQuerySelector(component, dataQueries, eventOptionUpdated, 'onBulkUpdate', componentMeta.events.onBulkUpdate)}
|
||||
|
||||
<div class="hr-text">Style</div>
|
||||
<div className="hr-text">Style</div>
|
||||
</div>
|
||||
|
||||
{renderElement(component, componentMeta, paramUpdated, dataQueries, 'loadingState', 'properties', currentState)}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ export const EventSelector = ({
|
|||
extraData,
|
||||
eventMeta,
|
||||
currentState,
|
||||
components
|
||||
components,
|
||||
apps
|
||||
}) => {
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
|
@ -48,6 +49,17 @@ export const EventSelector = ({
|
|||
return modalOptions;
|
||||
}
|
||||
|
||||
function getAllApps() {
|
||||
let appsOptionsList = [];
|
||||
apps.map((item) => {
|
||||
appsOptionsList.push({
|
||||
name: item.name,
|
||||
value: item.id
|
||||
})
|
||||
})
|
||||
return appsOptionsList;
|
||||
}
|
||||
|
||||
function eventChanged(param, value, extraData) {
|
||||
if(value === 'none') {
|
||||
eventUpdated(param, null, null);
|
||||
|
|
@ -73,7 +85,7 @@ export const EventSelector = ({
|
|||
{eventMeta.displayName}
|
||||
</div>
|
||||
<div className={`col-auto events-toggle ${open ? 'events-toggle-active' : ''}`}>
|
||||
<span class="toggle-icon"></span>
|
||||
<span className="toggle-icon"></span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
|
@ -112,6 +124,22 @@ export const EventSelector = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{definition.actionId === 'go-to-app' && (
|
||||
<div className="p-1">
|
||||
<label className="form-label mt-1">App</label>
|
||||
<SelectSearch
|
||||
options={getAllApps()}
|
||||
search={true}
|
||||
value={definition.options.slug}
|
||||
onChange={(value) => {
|
||||
eventOptionUpdated(param, 'slug', value, extraData);
|
||||
}}
|
||||
filterOptions={fuzzySearch}
|
||||
placeholder="Select.."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{definition.actionId === 'show-modal' && (
|
||||
<div className="p-1">
|
||||
<label className="form-label mt-1">Modal</label>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ export const Inspector = ({
|
|||
removeComponent,
|
||||
allComponents,
|
||||
componentChanged,
|
||||
currentState
|
||||
currentState,
|
||||
apps
|
||||
}) => {
|
||||
|
||||
const selectedComponent = { id: selectedComponentId, component: allComponents[selectedComponentId].component, layouts: allComponents[selectedComponentId].layouts}
|
||||
|
|
@ -226,10 +227,10 @@ export const Inspector = ({
|
|||
{!['Table', 'Chart'].includes(componentMeta.component) &&
|
||||
<div className="properties-container p-2">
|
||||
{Object.keys(componentMeta.properties).map((property) => renderElement(component, componentMeta, paramUpdated, dataQueries, property, 'properties', currentState, components))}
|
||||
<div class="hr-text">Style</div>
|
||||
<div className="hr-text">Style</div>
|
||||
{Object.keys(componentMeta.styles).map((style) => renderElement(component, componentMeta, paramUpdated, dataQueries, style, 'styles', currentState, components))}
|
||||
<div class="hr-text">Events</div>
|
||||
{Object.keys(componentMeta.events).map((eventName) => renderEvent(component, eventUpdated, dataQueries, eventOptionUpdated, eventName, componentMeta.events[eventName], currentState, components))}
|
||||
<div className="hr-text">Events</div>
|
||||
{Object.keys(componentMeta.events).map((eventName) => renderEvent(component, eventUpdated, dataQueries, eventOptionUpdated, eventName, componentMeta.events[eventName], currentState, components, apps))}
|
||||
|
||||
|
||||
|
||||
|
|
@ -237,7 +238,7 @@ export const Inspector = ({
|
|||
}
|
||||
|
||||
{/* Show on desktop & show on mobile params */}
|
||||
<div class="hr-text">Layout</div>
|
||||
<div className="hr-text">Layout</div>
|
||||
<div className="properties-container p-2 pb-3 mb-5">
|
||||
{renderElement(component, componentMeta, layoutPropertyChanged, dataQueries, 'showOnDesktop', 'others', currentState, components)}
|
||||
{renderElement(component, componentMeta, layoutPropertyChanged, dataQueries, 'showOnMobile', 'others', currentState, components)}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export const QuerySelector = ({
|
|||
{eventMeta.displayName}
|
||||
</div>
|
||||
<div className={`col-auto events-toggle ${open ? 'events-toggle-active' : ''}`}>
|
||||
<span class="toggle-icon"></span>
|
||||
<span className="toggle-icon"></span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export function renderElement(component, componentMeta, paramUpdated, dataQuerie
|
|||
);
|
||||
}
|
||||
|
||||
export function renderEvent(component, eventUpdated, dataQueries, eventOptionUpdated, eventName, eventMeta, currentState, components) {
|
||||
export function renderEvent(component, eventUpdated, dataQueries, eventOptionUpdated, eventName, eventMeta, currentState, components, apps) {
|
||||
let definition = component.component.definition.events[eventName];
|
||||
definition = definition || { };
|
||||
|
||||
|
|
@ -65,6 +65,7 @@ export function renderEvent(component, eventUpdated, dataQueries, eventOptionUpd
|
|||
eventOptionUpdated={eventOptionUpdated}
|
||||
currentState={currentState}
|
||||
components={components}
|
||||
apps={apps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class ManageAppUsers extends React.Component {
|
|||
app: { ...props.app },
|
||||
slugError: null,
|
||||
isLoading: true,
|
||||
isSlugVerificationInProgress: false,
|
||||
addingUser: false,
|
||||
organizationUsers: [],
|
||||
newUser: {}
|
||||
|
|
@ -100,14 +101,22 @@ class ManageAppUsers extends React.Component {
|
|||
|
||||
handleSetSlug = (event) => {
|
||||
const newSlug = event.target.value || null;
|
||||
this.setState({ isSlugVerificationInProgress: true });
|
||||
|
||||
appService
|
||||
.setSlug(this.state.app.id, newSlug)
|
||||
.then(() => {
|
||||
this.setState({ slugError: null });
|
||||
this.setState({
|
||||
slugError: null,
|
||||
isSlugVerificationInProgress: false
|
||||
});
|
||||
this.props.handleSlugChange(newSlug);
|
||||
})
|
||||
.catch(({ error }) => {
|
||||
this.setState({ slugError: error });
|
||||
this.setState({
|
||||
slugError: error,
|
||||
isSlugVerificationInProgress: false
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -117,11 +126,19 @@ class ManageAppUsers extends React.Component {
|
|||
|
||||
render() {
|
||||
const {
|
||||
addingUser, isLoading, users, organizationUsers, newUser, app, slugError
|
||||
addingUser,
|
||||
isLoading,
|
||||
users,
|
||||
organizationUsers,
|
||||
newUser,
|
||||
app,
|
||||
slugError,
|
||||
isSlugVerificationInProgress
|
||||
} = this.state;
|
||||
const appId = app.id;
|
||||
const appLink = `${window.location.origin}/applications/`;
|
||||
const shareableLink = appLink + (this.props.slug || appId);
|
||||
const slugButtonClass = isSlugVerificationInProgress? '' : slugError !== null ? 'is-invalid' : 'is-valid';
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -165,11 +182,18 @@ class ManageAppUsers extends React.Component {
|
|||
</label>
|
||||
<div className="input-group">
|
||||
<span className="input-group-text">{appLink}</span>
|
||||
<input type="text"
|
||||
className={`form-control form-control-sm ${ slugError !== null ? 'is-invalid' : 'is-valid'}`}
|
||||
placeholder={appId}
|
||||
onChange={(e) => { e.persist(); this.delayedSlugChange(e); }}
|
||||
defaultValue={this.props.slug} />
|
||||
<div className="input-with-icon">
|
||||
<input type="text"
|
||||
className={`form-control form-control-sm ${slugButtonClass}`}
|
||||
placeholder={appId}
|
||||
onChange={(e) => { e.persist(); this.delayedSlugChange(e); }}
|
||||
defaultValue={this.props.slug} />
|
||||
{ isSlugVerificationInProgress && (
|
||||
<div className="icon-container">
|
||||
<div className="spinner-border text-azure spinner-border-sm" role="status"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="input-group-text">
|
||||
<CopyToClipboard
|
||||
text={shareableLink}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ class Googlesheets extends React.Component {
|
|||
{ value: 'read', name: 'Read data from a spreadsheet' },
|
||||
{ value: 'append', name: 'Append data to a spreadsheet' },
|
||||
{ value: 'info', name: 'Get spreadsheet info' },
|
||||
{ value: 'delete_row', name: 'Delete row from a spreadsheet' },
|
||||
]}
|
||||
value={this.state.options.operation}
|
||||
search={true}
|
||||
|
|
@ -71,7 +72,7 @@ class Googlesheets extends React.Component {
|
|||
placeholder="Select.."
|
||||
/>
|
||||
</div>
|
||||
{['read', 'append'].includes(this.state.options.operation) && (
|
||||
{['read', 'append', 'delete_row'].includes(this.state.options.operation) && (
|
||||
<div>
|
||||
<div className="mb-3 mt-2 row">
|
||||
<div className="col">
|
||||
|
|
@ -117,6 +118,24 @@ class Googlesheets extends React.Component {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{this.state.options.operation === 'delete_row' && (
|
||||
<div>
|
||||
<div className="mb-3 mt-2 row">
|
||||
<div className="col">
|
||||
<label className="form-label">Delete row number</label>
|
||||
<input
|
||||
type="text"
|
||||
value={this.state.options.row_index}
|
||||
onChange={(e) => {
|
||||
changeOption(this, 'row_index', e.target.value);
|
||||
}}
|
||||
className="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{this.state.options.operation === 'info' && (
|
||||
<div className="mb-3 mt-2">
|
||||
<label className="form-label">Spreadsheet ID</label>
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ class Postgresql extends React.Component {
|
|||
theme="duotone-light"
|
||||
lineNumbers={true}
|
||||
className="query-hinter"
|
||||
enablePreview
|
||||
height="120px"
|
||||
onChange={(value) => changeOption(this, 'query', value)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ class Stripe extends React.Component {
|
|||
<div>
|
||||
{loadingSpec &&
|
||||
<div className="p-3">
|
||||
<div class="spinner-border spinner-border-sm text-azure mx-2" role="status"></div>
|
||||
<div className="spinner-border spinner-border-sm text-azure mx-2" role="status"></div>
|
||||
Please wait whle we load the OpenAPI specification for Stripe.
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -363,7 +363,7 @@ class QueryManager extends React.Component {
|
|||
<div className="mb-3 mt-2">
|
||||
{previewLoading && (
|
||||
<center>
|
||||
<div class="spinner-border text-azure mt-5" role="status"></div>
|
||||
<div className="spinner-border text-azure mt-5" role="status"></div>
|
||||
</center>
|
||||
)}
|
||||
{previewLoading === false && (
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ class SaveAndPreview extends React.Component {
|
|||
showModal: false,
|
||||
appId: props.appId,
|
||||
isLoading: true,
|
||||
showVersionForm: false
|
||||
showVersionForm: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -65,7 +65,10 @@ class SaveAndPreview extends React.Component {
|
|||
appService.saveApp(this.props.appId, { name: this.props.appName, current_version_id: versionId }).then(() => {
|
||||
this.setState({ isDeploying: false });
|
||||
toast.success('Version Deployed', { hideProgressBar: true, position: 'top-center' });
|
||||
|
||||
this.props.onVersionDeploy(versionId);
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
render() {
|
||||
|
|
@ -75,13 +78,20 @@ class SaveAndPreview extends React.Component {
|
|||
|
||||
return (
|
||||
<div>
|
||||
{!showModal && (
|
||||
<button className="btn btn-primary btn-sm" onClick={() => this.setState({ showModal: true })}>
|
||||
Deploy
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-primary btn-sm" onClick={() => this.setState({ showModal: true })}>
|
||||
Deploy
|
||||
</button>
|
||||
|
||||
<Modal show={this.state.showModal} size="md" backdrop="static" centered={true} keyboard={true}>
|
||||
<Modal
|
||||
show={this.state.showModal}
|
||||
size="md"
|
||||
backdrop="static"
|
||||
centered={true}
|
||||
keyboard={true}
|
||||
enforceFocus={false}
|
||||
animation={false}
|
||||
onEscapeKeyDown={() => this.hideModal()}
|
||||
>
|
||||
<Modal.Header>
|
||||
<Modal.Title>Versions and deployments</Modal.Title>
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
onEvent,
|
||||
runQuery
|
||||
} from '@/_helpers/appUtils';
|
||||
import queryString from 'query-string';
|
||||
|
||||
class Viewer extends React.Component {
|
||||
constructor(props) {
|
||||
|
|
@ -36,8 +37,16 @@ class Viewer extends React.Component {
|
|||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const slug = this.props.match.params.slug;
|
||||
componentWillReceiveProps(nextProps) {
|
||||
let slug = nextProps.match.params.slug;
|
||||
if(this.state.app?.slug != slug) {
|
||||
this.setState({app: {}, appDefinition: {}}, () => {
|
||||
this.loadApplication(slug);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
loadApplication = (slug) => {
|
||||
|
||||
const deviceWindowWidth = window.screen.width - 5;
|
||||
const isMobileDevice = deviceWindowWidth < 600;
|
||||
|
|
@ -85,12 +94,17 @@ class Viewer extends React.Component {
|
|||
components: {},
|
||||
globals: {
|
||||
current_user: userVars,
|
||||
urlparams: {}
|
||||
urlparams: JSON.parse(JSON.stringify(queryString.parse(this.props.location.search)))
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const slug = this.props.match.params.slug;
|
||||
this.loadApplication(slug);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
appDefinition,
|
||||
|
|
@ -136,13 +150,13 @@ class Viewer extends React.Component {
|
|||
appDefinitionChanged={() => false} // function not relevant in viewer
|
||||
snapToGrid={true}
|
||||
appLoading={isLoading}
|
||||
onEvent={(eventName, options) => onEvent(this, eventName, options)}
|
||||
onEvent={(eventName, options) => onEvent(this, eventName, options, 'view')}
|
||||
mode="view"
|
||||
scaleValue={scaleValue}
|
||||
deviceWindowWidth={deviceWindowWidth}
|
||||
currentLayout={currentLayout}
|
||||
currentState={this.state.currentState}
|
||||
onComponentClick={(id, component) => onComponentClick(this, id, component)}
|
||||
onComponentClick={(id, component) => onComponentClick(this, id, component, 'view')}
|
||||
onComponentOptionChanged={(component, optionName, value) => onComponentOptionChanged(this, component, optionName, value)
|
||||
}
|
||||
onComponentOptionsChanged={(component, options) => onComponentOptionsChanged(this, component, options)
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@ class ForgotPassword extends React.Component {
|
|||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
toast.error(res.error, { toastId: 'toast-forgot-password-email-error' });
|
||||
} else {
|
||||
toast.success(res.message);
|
||||
toast.success(res.message, { toastId: 'toast-forgot-password-confirmation-code' });
|
||||
this.props.history.push('/reset-password');
|
||||
}
|
||||
})
|
||||
|
|
@ -59,10 +59,12 @@ class ForgotPassword extends React.Component {
|
|||
type="email"
|
||||
className="form-control"
|
||||
placeholder="Enter email"
|
||||
data-testid="emailField"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-footer">
|
||||
<button
|
||||
data-testid="submitButton"
|
||||
className={`btn btn-primary w-100 ${isLoading ? 'btn-loading' : ''}`}
|
||||
onClick={this.handleClick}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { folderService } from '@/_services';
|
|||
import { toast } from 'react-toastify';
|
||||
|
||||
export const AppMenu = function AppMenu({
|
||||
app, folders, foldersChanged
|
||||
app, folders, foldersChanged, deleteApp
|
||||
}) {
|
||||
|
||||
const [addToFolder, setAddToFolder] = useState(false);
|
||||
|
|
@ -40,7 +40,7 @@ export const AppMenu = function AppMenu({
|
|||
|
||||
return <OverlayTrigger
|
||||
trigger="click"
|
||||
placement="right"
|
||||
placement="top"
|
||||
rootClose
|
||||
onToggle={(status) => handleToggle(status)}
|
||||
overlay={
|
||||
|
|
@ -48,9 +48,15 @@ export const AppMenu = function AppMenu({
|
|||
{/* <Popover.Title as="h3">brrr</Popover.Title> */}
|
||||
<Popover.Content>
|
||||
{!addToFolder &&
|
||||
<div className="field mb-2">
|
||||
<span role="button" onClick={() => setAddToFolder(true)}>Add to folder </span>
|
||||
<div>
|
||||
<div className="field mb-2">
|
||||
<span role="button" onClick={() => setAddToFolder(true)}>Add to folder </span>
|
||||
</div>
|
||||
<div className="field mb-2">
|
||||
<span className="my-3 text-danger" role="button" onClick={() => deleteApp()}>Delete app </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
{addToFolder &&
|
||||
|
|
|
|||
|
|
@ -4,21 +4,21 @@ export const BlankPage = function BlankPage({
|
|||
createApp
|
||||
}) {
|
||||
return (<div>
|
||||
<div class="page-wrapper">
|
||||
<div class="container-xl">
|
||||
<div className="page-wrapper">
|
||||
<div className="container-xl">
|
||||
</div>
|
||||
<div class="page-body">
|
||||
<div class="container-xl d-flex flex-column justify-content-center">
|
||||
<div class="empty">
|
||||
<div class="empty-img"><img src="/assets/images/blank.svg" height="128" alt="" />
|
||||
<div className="page-body">
|
||||
<div className="container-xl d-flex flex-column justify-content-center">
|
||||
<div className="empty">
|
||||
<div className="empty-img"><img src="/assets/images/blank.svg" height="128" alt="" />
|
||||
</div>
|
||||
<p class="empty-title">You haven't created any apps yet.</p>
|
||||
<div class="empty-action">
|
||||
<a onClick={createApp} class="btn btn-primary text-light">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
|
||||
<p className="empty-title">You haven't created any apps yet.</p>
|
||||
<div className="empty-action">
|
||||
<a onClick={createApp} className="btn btn-primary text-light">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
|
||||
Create your first app
|
||||
</a>
|
||||
<a href="https://docs.tooljet.io" target="_blank" class="btn btn-primary text-light mx-2">
|
||||
<a href="https://docs.tooljet.io" target="_blank" className="btn btn-primary text-light mx-2">
|
||||
Read documentation
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -41,11 +41,11 @@ export const Folders = function Folders({
|
|||
<div className="px-1 py-2" style={{minHeight: '200px'}}>
|
||||
{[1,2,3,4, 5].map(element => {
|
||||
return (<div className="row">
|
||||
<div class="col p-1">
|
||||
<div class="skeleton-line w-100"></div>
|
||||
<div className="col p-1">
|
||||
<div className="skeleton-line w-100"></div>
|
||||
</div>
|
||||
<div class="col-2 pt-1">
|
||||
<div class="skeleton-line w-100"></div>
|
||||
<div className="col-2 pt-1">
|
||||
<div className="skeleton-line w-100"></div>
|
||||
</div>
|
||||
</div>)
|
||||
})}
|
||||
|
|
@ -53,26 +53,26 @@ export const Folders = function Folders({
|
|||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<div className="list-group list-group-transparent mb-3">
|
||||
<div data-testid="applicationFoldersList" className="list-group list-group-transparent mb-3">
|
||||
|
||||
<a
|
||||
class={`list-group-item list-group-item-action d-flex align-items-center ${!activeFolder.id ? 'active' : ''}`}
|
||||
className={`list-group-item list-group-item-action d-flex align-items-center ${!activeFolder.id ? 'active' : ''}`}
|
||||
|
||||
onClick={() => handleFolderChange({})}
|
||||
>
|
||||
All applications
|
||||
<small className="text-muted ms-auto">
|
||||
<span class="badge bg-azure-lt">{totalCount}</span>
|
||||
<span className="badge bg-azure-lt" data-testid="allApplicationsCount">{totalCount}</span>
|
||||
</small>
|
||||
</a>
|
||||
{folders.map((folder) =>
|
||||
<a
|
||||
class={`list-group-item list-group-item-action d-flex align-items-center ${activeFolder.id === folder.id ? 'active' : ''}`}
|
||||
className={`list-group-item list-group-item-action d-flex align-items-center ${activeFolder.id === folder.id ? 'active' : ''}`}
|
||||
onClick={() => handleFolderChange(folder)}
|
||||
>
|
||||
{folder.name}
|
||||
<small className="text-muted ms-auto">
|
||||
<span class="badge bg-azure-lt">{folder.count}</span>
|
||||
<span className="badge bg-azure-lt">{folder.count}</span>
|
||||
</small>
|
||||
</a>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { AppMenu } from './AppMenu';
|
|||
import { BlankPage } from './BlankPage';
|
||||
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
|
||||
import { renderTooltip } from '@/_helpers/appUtils';
|
||||
import { ConfirmDialog } from '@/_components';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
class HomePage extends React.Component {
|
||||
constructor(props) {
|
||||
|
|
@ -18,6 +20,7 @@ class HomePage extends React.Component {
|
|||
isLoading: true,
|
||||
creatingApp: false,
|
||||
currentFolder: {},
|
||||
showAppDeletionConfirmation: false,
|
||||
apps: [],
|
||||
folders: [],
|
||||
meta: {
|
||||
|
|
@ -56,6 +59,7 @@ class HomePage extends React.Component {
|
|||
}
|
||||
|
||||
pageChanged = (page) => {
|
||||
this.setState({ currentPage: page });
|
||||
this.fetchApps(page, this.state.currentFolder.id);
|
||||
}
|
||||
|
||||
|
|
@ -77,13 +81,49 @@ class HomePage extends React.Component {
|
|||
});
|
||||
};
|
||||
|
||||
deleteApp = (app) => {
|
||||
this.setState({ showAppDeletionConfirmation: true, appToBeDeleted: app })
|
||||
}
|
||||
|
||||
executeAppDeletion = () => {
|
||||
this.setState({ isDeletingApp: true });
|
||||
appService.deleteApp(this.state.appToBeDeleted.id).then((data) => {
|
||||
toast.info('App deleted successfully.', {
|
||||
hideProgressBar: true,
|
||||
position: 'top-center'
|
||||
});
|
||||
this.setState({
|
||||
isDeletingApp: false,
|
||||
appToBeDeleted: null,
|
||||
showAppDeletionConfirmation: false
|
||||
});
|
||||
this.fetchApps(this.state.currentPage || 0, this.state.currentFolder.id)
|
||||
}).catch(({ error }) => {
|
||||
toast.error('Could not delete the app.', { hideProgressBar: true, position: 'top-center' });
|
||||
this.setState({
|
||||
isDeletingApp: false,
|
||||
appToBeDeleted: null,
|
||||
showAppDeletionConfirmation: false
|
||||
});
|
||||
});
|
||||
;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
apps, isLoading, creatingApp, meta, currentFolder
|
||||
apps, isLoading, creatingApp, meta, currentFolder, showAppDeletionConfirmation, isDeletingApp
|
||||
} = this.state;
|
||||
return (
|
||||
<div className="wrapper home-page">
|
||||
|
||||
<ConfirmDialog
|
||||
show={showAppDeletionConfirmation}
|
||||
message={'The app and the associated data will be permanently deleted, do you want to continue?'}
|
||||
confirmButtonLoading={isDeletingApp}
|
||||
onConfirm={() => this.executeAppDeletion()}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
|
||||
<Header
|
||||
|
||||
/>
|
||||
|
|
@ -124,23 +164,24 @@ class HomePage extends React.Component {
|
|||
|
||||
<div className={currentFolder.count == 0 ? 'table-responsive bg-white w-100 apps-table mt-3 d-flex align-items-center' : 'table-responsive bg-white w-100 apps-table mt-3'} style={{minHeight: '600px'}}>
|
||||
<table
|
||||
class="table table-vcenter">
|
||||
data-testid="appsTable"
|
||||
className="table table-vcenter">
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<>
|
||||
{Array.from(Array(10)).map(() => (
|
||||
<tr class="row">
|
||||
<td class="col-3 p-3">
|
||||
<div class="skeleton-line w-10"></div>
|
||||
<div class="skeleton-line w-10"></div>
|
||||
<tr className="row">
|
||||
<td className="col-3 p-3">
|
||||
<div className="skeleton-line w-10"></div>
|
||||
<div className="skeleton-line w-10"></div>
|
||||
</td>
|
||||
<td class="col p-3">
|
||||
<td className="col p-3">
|
||||
</td>
|
||||
<td class="text-muted col-auto col-1 pt-4">
|
||||
<div class="skeleton-line"></div>
|
||||
<td className="text-muted col-auto col-1 pt-4">
|
||||
<div className="skeleton-line"></div>
|
||||
</td>
|
||||
<td class="text-muted col-auto col-1 pt-4">
|
||||
<div class="skeleton-line"></div>
|
||||
<td className="text-muted col-auto col-1 pt-4">
|
||||
<div className="skeleton-line"></div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -152,12 +193,12 @@ class HomePage extends React.Component {
|
|||
<>
|
||||
|
||||
{apps.map((app) => (
|
||||
<tr class="row">
|
||||
<td class="col p-3">
|
||||
<tr className="row">
|
||||
<td className="col p-3">
|
||||
<span className="app-title mb-3">{app.name}</span> <br />
|
||||
<small className="pt-2">created {app.created_at} ago by {app.user.first_name} {app.user.last_name} </small>
|
||||
</td>
|
||||
<td class="text-muted col-auto pt-4">
|
||||
<td className="text-muted col-auto pt-4">
|
||||
<Link
|
||||
to={`/apps/${app.id}`}
|
||||
className="d-none d-lg-inline"
|
||||
|
|
@ -166,7 +207,7 @@ class HomePage extends React.Component {
|
|||
placement="top"
|
||||
overlay={(props) => renderTooltip({props, text: 'Open in app builder'})}
|
||||
>
|
||||
<span class="badge bg-green-lt">
|
||||
<span className="badge bg-green-lt">
|
||||
Edit
|
||||
</span>
|
||||
</OverlayTrigger>
|
||||
|
|
@ -179,7 +220,7 @@ class HomePage extends React.Component {
|
|||
placement="top"
|
||||
overlay={(props) => renderTooltip({props, text: 'Open in app viewer'})}
|
||||
>
|
||||
<span class="badge bg-blue-lt mx-2">launch</span>
|
||||
<span className="badge bg-blue-lt mx-2">launch</span>
|
||||
|
||||
</OverlayTrigger>
|
||||
</Link>
|
||||
|
|
@ -188,6 +229,7 @@ class HomePage extends React.Component {
|
|||
app={app}
|
||||
folders={this.state.folders}
|
||||
foldersChanged={this.foldersChanged}
|
||||
deleteApp={() => this.deleteApp(app)}
|
||||
/>
|
||||
</td>
|
||||
</tr>))
|
||||
|
|
|
|||
|
|
@ -86,11 +86,17 @@ class ManageOrgUsers extends React.Component {
|
|||
|
||||
const { firstName, lastName, email, role } = this.state.newUser;
|
||||
|
||||
organizationUserService.create(firstName, lastName, email, role).then(() => {
|
||||
this.setState({ creatingUser: false, showNewUserForm: false, newUser: {} });
|
||||
toast.success('User has been created', { hideProgressBar: true, position: 'top-center' });
|
||||
this.fetchUsers();
|
||||
});
|
||||
organizationUserService
|
||||
.create(firstName, lastName, email, role)
|
||||
.then(() => {
|
||||
this.setState({ creatingUser: false, showNewUserForm: false, newUser: {} });
|
||||
toast.success('User has been created', { hideProgressBar: true, position: 'top-center' });
|
||||
this.fetchUsers();
|
||||
})
|
||||
.catch(({ error }) => {
|
||||
toast.error(error, { hideProgressBar: true, position: 'top-center' });
|
||||
this.setState({ creatingUser: false, showNewUserForm: true, newUser: {} });
|
||||
});
|
||||
};
|
||||
|
||||
logout = () => {
|
||||
|
|
@ -212,7 +218,7 @@ class ManageOrgUsers extends React.Component {
|
|||
<div className="container-xl">
|
||||
<div className="card">
|
||||
<div className="card-table table-responsive table-bordered">
|
||||
<table className="table table-vcenter" disabled={true}>
|
||||
<table data-testid="usersTable" className="table table-vcenter" disabled={true}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
|
|
@ -230,7 +236,7 @@ class ManageOrgUsers extends React.Component {
|
|||
<tr>
|
||||
<td className="col-2 p-3">
|
||||
<div className="row">
|
||||
<div class="skeleton-image col-auto" style={{ width: '25px', height: '25px' }}></div>
|
||||
<div className="skeleton-image col-auto" style={{ width: '25px', height: '25px' }}></div>
|
||||
<div className="skeleton-line w-10 col mx-3"></div>
|
||||
</div>
|
||||
</td>
|
||||
|
|
|
|||
43
frontend/src/_components/ConfirmDialog.jsx
Normal file
43
frontend/src/_components/ConfirmDialog.jsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import Modal from 'react-bootstrap/Modal';
|
||||
import Button from 'react-bootstrap/Button';
|
||||
|
||||
export function ConfirmDialog({
|
||||
show, message, onConfirm, onCancel, confirmButtonLoading
|
||||
}) {
|
||||
const [showModal, setShow] = useState(show);
|
||||
|
||||
useEffect(() => {
|
||||
setShow(show);
|
||||
}, [show]);
|
||||
|
||||
const handleClose = () => {
|
||||
setShow(false);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onCancel();
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal show={showModal} onHide={handleClose} size="sm" centered={true}>
|
||||
<div className="modal-status bg-danger"></div>
|
||||
<Modal.Body>{message}</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" autoFocus className={`${confirmButtonLoading ? 'btn-loading' : ''}`} onClick={handleConfirm}>
|
||||
Yes
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -31,7 +31,7 @@ export const Header = function Header({
|
|||
</Link>
|
||||
</h1>
|
||||
|
||||
<ul class="navbar-nav d-none d-lg-flex">
|
||||
<ul className="navbar-nav d-none d-lg-flex">
|
||||
<li className={`nav-item mx-3 ${pahtName === '/' ? 'active' : ''}`}>
|
||||
<Link to={'/'} className="nav-link">
|
||||
<span className="nav-link-icon d-md-none d-lg-inline-block">
|
||||
|
|
@ -61,16 +61,17 @@ export const Header = function Header({
|
|||
className="nav-link d-flex lh-1 text-reset p-0"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-label="Open user menu"
|
||||
data-testid="userAvatarHeader"
|
||||
>
|
||||
<div className="d-none d-xl-block ps-2">
|
||||
<span class="avatar bg-azure-lt">
|
||||
<span className="avatar bg-azure-lt">
|
||||
{first_name ? first_name[0] : ''}
|
||||
{last_name ? last_name[0] : ''}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
<div className="dropdown-menu dropdown-menu-end dropdown-menu-arrow">
|
||||
<a onClick={logout} className="dropdown-item">
|
||||
<a data-testId="logoutBtn" onClick={logout} className="dropdown-item">
|
||||
Logout
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './PrivateRoute';
|
||||
export * from './Pagination';
|
||||
export * from './Header';
|
||||
export * from './ConfirmDialog';
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { dataqueryService } from '@/_services';
|
|||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
import Tooltip from 'react-bootstrap/Tooltip';
|
||||
import { history } from '@/_helpers';
|
||||
|
||||
export function setStateAsync(_ref, state) {
|
||||
return new Promise((resolve) => {
|
||||
|
|
@ -58,9 +59,9 @@ export function runTransformation(_ref, rawData, transformation) {
|
|||
return result;
|
||||
}
|
||||
|
||||
export function onComponentClick(_ref, id, component) {
|
||||
export function onComponentClick(_ref, id, component, mode = 'edit') {
|
||||
const onClickEvent = component.definition.events.onClick;
|
||||
executeAction(_ref, onClickEvent);
|
||||
executeAction(_ref, onClickEvent, mode);
|
||||
}
|
||||
|
||||
export function onQueryConfirm(_ref, queryConfirmationData) {
|
||||
|
|
@ -85,7 +86,7 @@ async function copyToClipboard(text) {
|
|||
}
|
||||
};
|
||||
|
||||
function executeAction(_ref, event) {
|
||||
function executeAction(_ref, event, mode) {
|
||||
if (event) {
|
||||
if (event.actionId === 'show-alert') {
|
||||
const message = resolveReferences(event.options.message, _ref.state.currentState);
|
||||
|
|
@ -97,6 +98,20 @@ function executeAction(_ref, event) {
|
|||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
if (event.actionId === 'go-to-app') {
|
||||
const slug = resolveReferences(event.options.slug, _ref.state.currentState);
|
||||
|
||||
const url = `/applications/${slug}`;
|
||||
|
||||
if(mode === 'view') {
|
||||
_ref.props.history.push(url);
|
||||
} else {
|
||||
if(confirm("The app will be opened in a new tab as the action is triggered from the editor.")) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.actionId === 'run-query') {
|
||||
const { queryId, queryName } = event.options;
|
||||
return runQuery(_ref, queryId, queryName);
|
||||
|
|
@ -129,7 +144,7 @@ function executeAction(_ref, event) {
|
|||
}
|
||||
}
|
||||
|
||||
export function onEvent(_ref, eventName, options) {
|
||||
export function onEvent(_ref, eventName, options, mode = 'edit') {
|
||||
let _self = _ref;
|
||||
console.log('Event: ', eventName);
|
||||
|
||||
|
|
@ -149,7 +164,7 @@ export function onEvent(_ref, eventName, options) {
|
|||
}
|
||||
}, () => {
|
||||
if (event.actionId) {
|
||||
executeAction(_self, event);
|
||||
executeAction(_self, event, mode);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -172,7 +187,7 @@ export function onEvent(_ref, eventName, options) {
|
|||
}, () => {
|
||||
if(event) {
|
||||
if (event.actionId) {
|
||||
executeAction(_self, event);
|
||||
executeAction(_self, event, mode);
|
||||
}
|
||||
} else {
|
||||
console.log('No action is associated with this event');
|
||||
|
|
@ -185,7 +200,7 @@ export function onEvent(_ref, eventName, options) {
|
|||
const event = (eventName === 'onCheck') ? component.definition.events.onCheck : component.definition.events.onUnCheck;
|
||||
|
||||
if (event.actionId) {
|
||||
executeAction(_self, event);
|
||||
executeAction(_self, event, mode);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -194,7 +209,7 @@ export function onEvent(_ref, eventName, options) {
|
|||
const event = component.definition.events[eventName];
|
||||
|
||||
if (event.actionId) {
|
||||
executeAction(_self, event);
|
||||
executeAction(_self, event, mode);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -203,14 +218,14 @@ export function onEvent(_ref, eventName, options) {
|
|||
const event = component.definition.events[eventName];
|
||||
|
||||
if (event.actionId) {
|
||||
executeAction(_self, event);
|
||||
executeAction(_self, event, mode);
|
||||
}
|
||||
}
|
||||
|
||||
if (eventName === 'onBulkUpdate') {
|
||||
return new Promise(function (resolve, reject) {
|
||||
onComponentOptionChanged(_self, options.component, 'isSavingChanges', true);
|
||||
executeAction(_self, { actionId: 'run-query', ...options.component.definition.events.onBulkUpdate }).then(() => {
|
||||
executeAction(_self, { actionId: 'run-query', ...options.component.definition.events.onBulkUpdate }, mode).then(() => {
|
||||
onComponentOptionChanged(_self, options.component, 'isSavingChanges', false);
|
||||
resolve();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { authHeader, handleResponse } from '@/_helpers';
|
|||
export const appService = {
|
||||
getAll,
|
||||
createApp,
|
||||
deleteApp,
|
||||
getApp,
|
||||
getAppBySlug,
|
||||
saveApp,
|
||||
|
|
@ -31,6 +32,11 @@ function getApp(id) {
|
|||
return fetch(`${config.apiUrl}/apps/${id}`, requestOptions).then(handleResponse);
|
||||
}
|
||||
|
||||
function deleteApp(id) {
|
||||
const requestOptions = { method: 'DELETE', headers: authHeader() };
|
||||
return fetch(`${config.apiUrl}/apps/${id}`, requestOptions).then(handleResponse);
|
||||
}
|
||||
|
||||
function getAppBySlug(slug) {
|
||||
const requestOptions = { method: 'GET', headers: authHeader() };
|
||||
return fetch(`${config.apiUrl}/apps/slugs/${slug}`, requestOptions).then(handleResponse);
|
||||
|
|
|
|||
|
|
@ -1621,3 +1621,23 @@ input:focus-visible {
|
|||
.widgets-list {
|
||||
--tblr-gutter-x: 0px!important;
|
||||
}
|
||||
|
||||
.input-with-icon {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
||||
.icon-container {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: calc(50% - 10px);
|
||||
z-index: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.dynamic-variable-preview {
|
||||
height: 30px;
|
||||
margin-top: -2px;
|
||||
border-bottom-left-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,21 @@ class OrganizationUsersControllerTest < ActionDispatch::IntegrationTest
|
|||
end
|
||||
end
|
||||
|
||||
test 'org admins cannot create org users if email already exists' do
|
||||
post '/organization_users', params: org_user_params, as: :json, headers: auth_header(@admin)
|
||||
post '/organization_users', params: org_user_params, as: :json, headers: auth_header(@admin)
|
||||
|
||||
assert_response 422
|
||||
assert_equal "Email address is already taken", JSON.parse(response.body)['message']
|
||||
end
|
||||
|
||||
test 'OrganizationUser should be unique per organization and user' do
|
||||
assert_raises(ActiveRecord::RecordNotUnique) do
|
||||
org_user = OrganizationUser.new(organization: @org, user: @admin, role: 'admin', status: 'active')
|
||||
org_user.save
|
||||
end
|
||||
end
|
||||
|
||||
test 'cannot create org users if not admin' do
|
||||
assert_no_difference 'OrganizationUser.count' do
|
||||
post '/organization_users', params: org_user_params, as: :json, headers: auth_header(@developer)
|
||||
|
|
|
|||
Loading…
Reference in a new issue