diff --git a/Gemfile b/Gemfile index 342d65e5e6..bd30a43cf9 100644 --- a/Gemfile +++ b/Gemfile @@ -39,6 +39,7 @@ gem "mongo", "~> 2" gem 'aws-sdk', '~> 3' gem 'kaminari' gem 'lockbox' +gem 'graphlient' gem 'tiny_tds' group :development, :test do diff --git a/Gemfile.lock b/Gemfile.lock index d6a2454679..95e616912c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1173,6 +1173,8 @@ GEM multipart-post (>= 1.2, < 3) ruby2_keywords faraday-net_http (1.0.1) + faraday_middleware (1.0.0) + faraday (~> 1.0) ffi (1.15.0) gapic-common (0.4.0) faraday (~> 1.3) @@ -1211,6 +1213,14 @@ GEM multi_json (~> 1.11) os (>= 0.9, < 2.0) signet (~> 0.14) + graphlient (0.5.0) + faraday (>= 1.0) + faraday_middleware + graphql-client + graphql (1.12.12) + graphql-client (0.16.0) + activesupport (>= 3.0) + graphql (~> 1.8) grpc (1.37.0) google-protobuf (~> 3.15) googleapis-common-protos-types (~> 1.0) @@ -1376,6 +1386,7 @@ DEPENDENCIES dotenv-rails elasticsearch google-cloud-firestore + graphlient httparty jbuilder (~> 2.7) jwt diff --git a/app/controllers/apps_controller.rb b/app/controllers/apps_controller.rb index 7fd086beec..328196ff28 100644 --- a/app/controllers/apps_controller.rb +++ b/app/controllers/apps_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class AppsController < ApplicationController - skip_before_action :authenticate_request, only: [:show] + skip_before_action :authenticate_request, only: %i[show slug] def index authorize App @@ -15,10 +15,10 @@ class AppsController < ApplicationController @scope = @folder.apps end - @apps = @scope.order("created_at desc") - .page(params[:page]) - .per(10) - .includes(:user) + @apps = @scope.order('created_at desc') + .page(params[:page]) + .per(10) + .includes(:user) @meta = { total_pages: @apps.total_pages, @@ -30,17 +30,17 @@ class AppsController < ApplicationController def create authorize App - @app = App.create({ - name: "Untitled app", - organization: @current_user.organization, - current_version: AppVersion.new(name: "v0"), - user: @current_user - }) - AppUser.create(app: @app, user: @current_user, role: "admin") + @app = App.create!({ + name: 'Untitled app', + organization: @current_user.organization, + current_version: AppVersion.new(name: 'v0'), + user: @current_user + }) + AppUser.create(app: @app, user: @current_user, role: 'admin') end def show - @app = App.find params[:id] + @app = App.find(params[:id]) # Logic to bypass auth for public apps unless @app.is_public @@ -49,10 +49,28 @@ class AppsController < ApplicationController end end + def slugs + @app = App.find_by(slug: params[:slug]) + + unless @app.is_public + authenticate_request + authorize @app, :show? + end + + render :show + end + def update @app = App.find params[:id] authorize @app - @app.update(params["app"].permit("name", "current_version_id", "is_public")) + + @app.assign_attributes(params[:app].permit(:name, :current_version_id, :is_public, :slug)) + + if @app.valid? + @app.save # renders default status 204 + else + render json: { message: @app.errors.full_messages }, status: 422 + end end def users diff --git a/app/models/app.rb b/app/models/app.rb index f38e633216..2de962af53 100644 --- a/app/models/app.rb +++ b/app/models/app.rb @@ -2,9 +2,19 @@ class App < ApplicationRecord belongs_to :organization - has_many :data_queries - has_many :app_users - has_many :app_versions + has_many :data_queries, dependent: :destroy + has_many :app_users, dependent: :destroy + has_many :app_versions, dependent: :destroy belongs_to :current_version, class_name: "AppVersion", optional: true belongs_to :user, optional: true + + validates :slug, uniqueness: { scope: :organization } + + after_save :set_default_slug_as_id, if: -> { self.slug.blank? } + + private + + def set_default_slug_as_id + self.update_attribute(:slug, self.id) + end end diff --git a/app/services/graphql_query_service.rb b/app/services/graphql_query_service.rb new file mode 100644 index 0000000000..41dd19d9f8 --- /dev/null +++ b/app/services/graphql_query_service.rb @@ -0,0 +1,40 @@ +class GraphqlQueryService + + attr_accessor :data_query, :options, :source_options, :current_user, :data_source + + def initialize(data_query, data_source, options, source_options, current_user) + @data_query = data_query + @options = options + @source_options = source_options + @current_user = current_user + @data_source = data_source + end + + def process + url = source_options['url'] + method = options['method'] || 'GET' + source_headers = (source_options['headers'] || []).reject { |header| header[0].empty? }.to_h + url_params = source_options['url_params'] + encoded_url = url_encoded_with_params(url, url_params) + query = options['query'] + client = Graphlient::Client.new(encoded_url, headers: source_headers) + result = client.query(query) + if result.errors.present? + { code: 422, data: result.errors } + else + { code: 200, data: result.original_hash } + end + end +end + + +def url_encoded_with_params(original_url, url_params) + if url_params.empty? + original_url + else + uri = URI.parse(original_url) + params = URI.decode_www_form(uri.query || '') + url_params + uri.query = URI.encode_www_form(params) + uri.to_s + end +end diff --git a/app/services/query_service.rb b/app/services/query_service.rb index 1e764a690d..74e0f9546f 100644 --- a/app/services/query_service.rb +++ b/app/services/query_service.rb @@ -33,9 +33,9 @@ class QueryService service.process end - private + private def get_query_options(object) - + if object.is_a?(Hash) object.keys.each do |key| @@ -52,7 +52,7 @@ class QueryService variables.each do |variable| object = object.gsub("{{#{variable[0]}}}", options["{{#{variable[0]}}}"].to_s) end - else + else object = object end end diff --git a/app/views/apps/index.json.jbuilder b/app/views/apps/index.json.jbuilder index 8c19750355..3bc682e21c 100644 --- a/app/views/apps/index.json.jbuilder +++ b/app/views/apps/index.json.jbuilder @@ -1,10 +1,11 @@ json.apps do json.array! @apps do |app| json.id app.id + json.slug app.slug json.name app.name json.created_at time_ago_in_words(app.created_at) json.user app.user || {} end end -json.meta @meta.as_json \ No newline at end of file +json.meta @meta.as_json diff --git a/app/views/apps/show.json.jbuilder b/app/views/apps/show.json.jbuilder index ad9b079625..ba0e072a75 100644 --- a/app/views/apps/show.json.jbuilder +++ b/app/views/apps/show.json.jbuilder @@ -1,5 +1,6 @@ json.id @app.id json.name @app.name +json.slug @app.slug json.definition @app.current_version.definition if @app.current_version json.definition {} unless @app.current_version json.current_version_id @app.current_version_id diff --git a/config/application.rb b/config/application.rb index 8469e533ac..cb784188b9 100644 --- a/config/application.rb +++ b/config/application.rb @@ -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.10' +TOOLJET_VERSION = '0.5.11' module ToolJet class Application < Rails::Application diff --git a/config/routes.rb b/config/routes.rb index 291ea936da..99463cfc57 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,6 +3,7 @@ Rails.application.routes.draw do resources :versions, only: %i[index create update] get '/users', to: 'apps#users' + get '/slugs/:slug', to: 'apps#slugs', on: :collection end resources :data_sources, only: %i[create index update] do @@ -54,5 +55,4 @@ Rails.application.routes.draw do get '/health', to: 'probe#health_check' post 'password/forgot', to: 'forgot_password#forgot' post 'password/reset', to: 'forgot_password#reset' - end diff --git a/cypress.json b/cypress.json new file mode 100644 index 0000000000..ce1b43c0d2 --- /dev/null +++ b/cypress.json @@ -0,0 +1,6 @@ +{ + "baseUrl": "http://localhost:8082", + "env": { + "apiUrl": "http://localhost:3000" + } +} \ No newline at end of file diff --git a/cypress/integration/auth.spec.js b/cypress/integration/auth.spec.js new file mode 100644 index 0000000000..7ba3103c65 --- /dev/null +++ b/cypress/integration/auth.spec.js @@ -0,0 +1,37 @@ +describe('User login', () => { + it('should take user to login page', () => { + cy.visit('/login'); + cy.get('.card-title') + .should('have.text', 'Login to your account'); + }); + + it('should redirect unauthenticated user to login page', () => { + cy.visit('/'); + cy.location('pathname').should('equal', '/login'); + }); + + it('should display invalid email or password error', () => { + cy.login('fake_email', 'abcdefg'); + 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'); + cy.get('.card-title') + .should('have.text', 'Create a ToolJet account'); + }) + + it('should sign in the user', () => { + cy.visit('/login'); + cy.login('dev@tooljet.io', 'password'); + cy.location('pathname').should('equal', '/'); + cy.get('.page-title') + .should('have.text', 'All applications'); + }) +}) \ No newline at end of file diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js new file mode 100644 index 0000000000..59b2bab6e4 --- /dev/null +++ b/cypress/plugins/index.js @@ -0,0 +1,22 @@ +/// +// *********************************************************** +// This example plugins/index.js can be used to load plugins +// +// You can change the location of this file or turn off loading +// the plugins file with the 'pluginsFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/plugins-guide +// *********************************************************** + +// This function is called when a project is opened or re-opened (e.g. due to +// the project's config changing) + +/** + * @type {Cypress.PluginConfig} + */ +// eslint-disable-next-line no-unused-vars +module.exports = (on, config) => { + // `on` is used to hook into various events Cypress emits + // `config` is the resolved Cypress config +} diff --git a/cypress/support/commands.js b/cypress/support/commands.js new file mode 100644 index 0000000000..3a9397b05f --- /dev/null +++ b/cypress/support/commands.js @@ -0,0 +1,10 @@ +Cypress.Commands.add('login', (email, password) => { + cy.visit('/login'); + cy.get('[data-testid="emailField"]').type(email); + cy.get('[data-testid="passwordField"]').type(password); + cy.get('[data-testid="loginButton"').click(); +}) + +Cypress.Commands.add('checkToastMessage', (toastId, message) => { + cy.get(`[id=${toastId}]`).should('contain', message); +}); \ No newline at end of file diff --git a/cypress/support/index.js b/cypress/support/index.js new file mode 100644 index 0000000000..d68db96df2 --- /dev/null +++ b/cypress/support/index.js @@ -0,0 +1,20 @@ +// *********************************************************** +// This example support/index.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands' + +// Alternatively you can use CommonJS syntax: +// require('./commands') diff --git a/db/migrate/20210619124759_add_slug_to_apps.rb b/db/migrate/20210619124759_add_slug_to_apps.rb new file mode 100755 index 0000000000..ab47766f82 --- /dev/null +++ b/db/migrate/20210619124759_add_slug_to_apps.rb @@ -0,0 +1,10 @@ +class AddSlugToApps < ActiveRecord::Migration[6.1] + def change + add_column :apps, :slug, :string + add_index :apps, [:organization_id, :slug] + + App.find_each do |app| + app.update(slug: app.id) + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 49adde58d7..5ffd80a70d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_06_17_031153) do +ActiveRecord::Schema.define(version: 2021_06_19_124759) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" @@ -45,7 +45,9 @@ ActiveRecord::Schema.define(version: 2021_06_17_031153) do t.uuid "current_version_id" t.boolean "is_public", default: false t.uuid "user_id" + t.string "slug" t.index ["current_version_id"], name: "index_apps_on_current_version_id" + t.index ["organization_id", "slug"], name: "index_apps_on_organization_id_and_slug" t.index ["organization_id"], name: "index_apps_on_organization_id" t.index ["user_id"], name: "index_apps_on_user_id" end @@ -160,7 +162,6 @@ ActiveRecord::Schema.define(version: 2021_06_17_031153) do t.datetime "updated_at", precision: 6, null: false t.string "role" t.uuid "organization_id" - t.text "image" t.string "invitation_token" t.string "forgot_password_token" t.datetime "forgot_password_token_sent_at" @@ -178,7 +179,6 @@ ActiveRecord::Schema.define(version: 2021_06_17_031153) do add_foreign_key "data_source_user_oauth2s", "data_sources" add_foreign_key "data_source_user_oauth2s", "users" add_foreign_key "data_sources", "apps" - add_foreign_key "endpoints", "integrations" add_foreign_key "folder_apps", "apps" add_foreign_key "folder_apps", "folders" add_foreign_key "folders", "organizations" diff --git a/deploy/ec2/.env b/deploy/ec2/.env new file mode 100644 index 0000000000..46f6a92945 --- /dev/null +++ b/deploy/ec2/.env @@ -0,0 +1,7 @@ +TOOLJET_HOST=http:// +LOCKBOX_MASTER_KEY= +SECRET_KEY_BASE= +PG_DB=tooljet_prod +PG_USER= +PG_HOST= +PG_PASS= diff --git a/deploy/ec2/nginx.conf b/deploy/ec2/nginx.conf new file mode 100644 index 0000000000..09e6953c21 --- /dev/null +++ b/deploy/ec2/nginx.conf @@ -0,0 +1,114 @@ +user www-data; +worker_processes auto; +pid /usr/local/openresty/nginx/logs/nginx.pid; + +events +{ + worker_connections 1024; +} + +http +{ + include mime.types; + default_type application/octet-stream; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + + keepalive_timeout 65; + + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; + + access_log /var/log/openresty/access.log; + error_log /var/log/openresty/error.log; + + gzip on; + gzip_disable "msie6"; + + lua_shared_dict auto_ssl 1m; + lua_shared_dict auto_ssl_settings 64k; + resolver 8.8.8.8 ipv6=off; + + init_by_lua_block + { + auto_ssl = (require "resty.auto-ssl").new() + auto_ssl:set("allow_domain", function(domain) + return true + end) + auto_ssl:init() + } + + init_worker_by_lua_block + { + auto_ssl:init_worker() + } + + server + { + listen 443 ssl; + ssl_certificate_by_lua_block + { + auto_ssl:ssl_certificate() + } + ssl_certificate /etc/ssl/resty-auto-ssl-fallback.crt; + ssl_certificate_key /etc/ssl/resty-auto-ssl-fallback.key; + + + location / + { + root /home/ubuntu/app/frontend/build; + index index.html; + } + + location /_backend_ + { + rewrite /_backend_/(.*) /$1 break; + proxy_pass http://localhost:3000; + proxy_redirect off; + proxy_set_header Host $host; + } + } + + server + { + listen 80; + location /.well-known/acme-challenge/ + { + content_by_lua_block + { + auto_ssl:challenge_server() + } + } + + location / + { + root /home/ubuntu/app/frontend/build; + index index.html; + } + + location /_backend_ + { + rewrite /_backend_/(.*) /$1 break; + proxy_pass http://localhost:3000; + proxy_redirect off; + proxy_set_header Host $host; + } + + } + + server + { + listen 127.0.0.1:8999; + client_body_buffer_size 128k; + client_max_body_size 128k; + + location / + { + content_by_lua_block + { + auto_ssl:hook_server() + } + } + } +} diff --git a/deploy/ec2/puma.service b/deploy/ec2/puma.service new file mode 100644 index 0000000000..b1633f445a --- /dev/null +++ b/deploy/ec2/puma.service @@ -0,0 +1,18 @@ +[Unit] +Description=Puma HTTP Server +After=network.target + +[Service] +Type=simple +User=ubuntu + +WorkingDirectory=/home/ubuntu/app +Environment="RAILS_ENV=production" +EnvironmentFile=/home/ubuntu/app/.env +RestartSec=1 +PIDFile=/home/ubuntu/app/tmp/pids/server.pid +ExecStart=/home/ubuntu/.rbenv/shims/puma -b tcp://0.0.0.0:3000 +Restart=always + +[Install] +WantedBy=multi-user.target diff --git a/deploy/ec2/setup_app b/deploy/ec2/setup_app new file mode 100755 index 0000000000..bb65144e53 --- /dev/null +++ b/deploy/ec2/setup_app @@ -0,0 +1,56 @@ +#!/usr/bin/env ruby + +def ensure_db_connectivity(user = ENV.fetch("PG_USER"), pass = ENV.fetch("PG_PASS"), host = ENV.fetch("PG_HOST")) + cmd = %{psql -d 'postgresql://#{user}:#{pass}@#{host}' -c 'select now()' > /dev/null 2>&1} + res = system(cmd) + if res + puts "Successfully pinged the database!" + else + puts "Can't connect to the database using the credenials provided in the .env file!" + exit(1) + end +end + +def install_script_deps + system("gem install bundler") + system("gem install dotenv") +end + +def load_env + require 'dotenv' + Dir.chdir "/home/ubuntu/app" + Dotenv.load! + Dotenv.require_keys("TOOLJET_HOST", "LOCKBOX_MASTER_KEY", "SECRET_KEY_BASE", "PG_DB", "PG_USER", "PG_HOST", "PG_PASS") +end + +def install_be_app_deps + system("RAILS_ENV=production bundle install") + system("RAILS_ENV=production bundle exec dotenv rails db:create") + system("RAILS_ENV=production bundle exec dotenv rails db:migrate") + system("RAILS_ENV=production bundle exec dotenv rails db:seed") +end + + +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("NODE_ENV=production TOOLJET_SERVER_URL=#{backend_url} npm run-script build") +end + +def start_services + system("sudo systemctl start openresty") + system("bundle binstubs puma && rbenv rehash && sudo systemctl start puma") +end + + +install_script_deps +load_env +ensure_db_connectivity +install_be_app_deps +build_fe +start_services + + +puts "The app will be served at #{ENV.fetch("TOOLJET_HOST")}" diff --git a/deploy/ec2/setup_machine.sh b/deploy/ec2/setup_machine.sh new file mode 100644 index 0000000000..8da65439f7 --- /dev/null +++ b/deploy/ec2/setup_machine.sh @@ -0,0 +1,45 @@ +sudo apt-get -y install --no-install-recommends wget gnupg ca-certificates apt-utils curl +sudo apt-get -y install git +sudo apt-get install -y postgresql-client +wget -O - https://openresty.org/package/pubkey.gpg | sudo apt-key add - +echo "deb http://openresty.org/package/ubuntu bionic main" > openresty.list +sudo mv openresty.list /etc/apt/sources.list.d/ +sudo apt-get update +sudo apt-get -y install --no-install-recommends openresty +curl -fsSL https://deb.nodesource.com/setup_14.x | sudo -E bash - +sudo apt-get install -y nodejs +sudo apt-get install -y git +sudo apt-get install -y rbenv +rbenv init - >> ~/.bashrc +source ~/.bashrc +mkdir -p "$(rbenv root)"/plugins +git clone https://github.com/rbenv/ruby-build.git "$(rbenv root)"/plugins/ruby-build +sudo apt-get install -y curl g++ gcc autoconf automake bison libc6-dev \ + libffi-dev libgdbm-dev libncurses5-dev libsqlite3-dev libtool \ + libyaml-dev make pkg-config sqlite3 zlib1g-dev libgmp-dev \ + libreadline-dev libssl-dev libmysqlclient-dev build-essential \ + freetds-dev libpq-dev +sudo apt-get install -y luarocks +sudo luarocks install lua-resty-auto-ssl +sudo mkdir /etc/resty-auto-ssl +sudo chown -R www-data:www-data /etc/resty-auto-ssl +# Gen fallback certs +sudo openssl rand -out /home/ubuntu/.rnd -hex 256 +sudo chown www-data:www-data /home/ubuntu/.rnd +sudo openssl req -new -newkey rsa:2048 -days 3650 -nodes -x509 \ + -subj '/CN=sni-support-required-for-valid-ssl' \ + -keyout /etc/ssl/resty-auto-ssl-fallback.key \ + -out /etc/ssl/resty-auto-ssl-fallback.crt + +rbenv install 2.7.3 +rbenv global 2.7.3 +mkdir -p ~/app +ssh-keyscan -H github.com >> ~/.ssh/known_hosts +git clone -b main git@github.com:ToolJet/ToolJet.git ~/app && cd ~/app +echo "2.7.3" > .ruby-version +sudo mkdir /var/log/openresty +sudo cp /tmp/puma.service /lib/systemd/system/puma.service +sudo mv /tmp/nginx.conf /usr/local/openresty/nginx/conf/nginx.conf +mv /tmp/.env ~/app/.env +mv /tmp/setup_app ~/app/setup_app +sudo chmod +x ~/app/setup_app diff --git a/deploy/ec2/tool_jet_ubuntu_bionic.pkr.hcl b/deploy/ec2/tool_jet_ubuntu_bionic.pkr.hcl new file mode 100644 index 0000000000..bc207e27d0 --- /dev/null +++ b/deploy/ec2/tool_jet_ubuntu_bionic.pkr.hcl @@ -0,0 +1,55 @@ +packer { + required_plugins { + amazon = { + version = ">= 0.0.1" + source = "github.com/hashicorp/amazon" + } + } +} + +source "amazon-ebs" "ubuntu" { + ami_name = "tooljet_latest_ubuntu_bionic" + instance_type = "t2.medium" + region = "us-west-2" + source_ami_filter { + filters = { + name = "ubuntu/images/hvm-ssd/ubuntu-bionic-18.04-amd64-server-*" + root-device-type = "ebs" + virtualization-type = "hvm" + } + most_recent = true + owners = ["099720109477"] + } + ssh_username = "ubuntu" +} + + +build { + sources = [ + "source.amazon-ebs.ubuntu" + ] + + provisioner "file"{ + source = "puma.service" + destination = "/tmp/puma.service" + } + + provisioner "file"{ + source = "nginx.conf" + destination = "/tmp/nginx.conf" + } + + provisioner "file"{ + source = ".env" + destination = "/tmp/.env" + } + + provisioner "file"{ + source = "setup_app" + destination = "/tmp/setup_app" + } + + provisioner "shell" { + script = "setup_machine.sh" + } +} diff --git a/docker/server.Dockerfile b/docker/server.Dockerfile index 91f6210de0..5425eac8ba 100644 --- a/docker/server.Dockerfile +++ b/docker/server.Dockerfile @@ -14,5 +14,5 @@ RUN gem install bundler && RAILS_ENV=production bundle install --jobs 20 --retry ENV RAILS_ENV=production COPY . ./ - RUN ["chmod", "755", "docker/entrypoints/server.sh"] +ENTRYPOINT ["docker/entrypoints/server.sh"] diff --git a/docs/docs/contributing-guide/setup/docker.md b/docs/docs/contributing-guide/setup/docker.md index 1651f97ef9..34028f8b39 100644 --- a/docs/docs/contributing-guide/setup/docker.md +++ b/docs/docs/contributing-guide/setup/docker.md @@ -13,59 +13,66 @@ Make sure you have the latest version of `docker` and `docker-compose` installed [Official docker-compose installation guide](https://docs.docker.com/compose/install/) We recommend: -```bash -$ docker --version -Docker version 19.03.12, build 48a66213fe -$ docker-compose --version -docker-compose version 1.26.2, build eefe0d31 -``` + ```bash + $ docker --version + Docker version 19.03.12, build 48a66213fe + $ docker-compose --version + docker-compose version 1.26.2, build eefe0d31 + ``` ## Setting up 1. Close the repository -```bash -$ git clone https://github.com/tooljet/tooljet.git -``` + ```bash + $ git clone https://github.com/tooljet/tooljet.git + ``` 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 -``` + ```bash + $ cp .env.example .env + ``` -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`. +3. Populate the keys in the `.env` 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) -Example: -```bash -$ cat .env -TOOLJET_HOST=http://localhost:8082 -LOCKBOX_MASTER_KEY=c92bcc7f112ffbdd131d1fb6c5005e372b8802f85f6c4586e5a88f57a541382841c8c99e5701b84862e448dd5db846f705321a41bd48a0fed1b58b9596a3877f -SECRET_KEY_BASE=4229d5774cfe7f60e75d6b3bf3a1dbb054a696b6d21b6d5de7b73291899797a222265e12c0a8e8d844f83ebacdf9a67ec42584edf1c2b23e1e7813f8a3339041 -``` + `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=1d291a926ddfd221205a23adb4cc1db66cb9fcaf28d97c8c1950e3538e3b9281 + SECRET_KEY_BASE=4229d5774cfe7f60e75d6b3bf3a1dbb054a696b6d21b6d5de7b73291899797a222265e12c0a8e8d844f83ebacdf9a67ec42584edf1c2b23e1e7813f8a3339041 + ``` 4. Build docker images -```bash -$ docker-compose build -``` + ```bash + $ docker-compose build + ``` -4. ToolJet server is built using Ruby on Rails. You have to reset the database if building for the first time. -```bash -$ docker-compose run server rails db:reset -``` +5. ToolJet server is built using Ruby on Rails. You have to reset the database if building for the first time. + ```bash + $ docker-compose run server rails db:reset + ``` -5. Run ToolJet -```bash -$ docker-compose up -``` +6. Run ToolJet + ```bash + $ docker-compose up + ``` -6. The app should now be served locally at http://localhost:8082/. You can login using the default user created. - [ email: dev@tooljet.io - password: password - ] +7. ToolJet should now be served locally at `http://localhost:8082`. You can login using the default user created. + ``` + email: dev@tooljet.io + password: password + ``` -7. To shut down the containers, -```bash -$ docker-compose stop -``` + +8. To shut down the containers, + ```bash + $ docker-compose stop + ``` ## Running Rails tests diff --git a/docs/docs/data-sources/graphql.md b/docs/docs/data-sources/graphql.md new file mode 100644 index 0000000000..2a06d40b87 --- /dev/null +++ b/docs/docs/data-sources/graphql.md @@ -0,0 +1,40 @@ +--- +sidebar_position: 3 +--- + +# GraphQL + + +ToolJet can connect to GraphQL endpoints. We currently support 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. + +ToolJet requires the following to connect to a GraphQL datasource. + +- **URL** + +Following optional parameters are also supported: + + | Type | Description | + | ----------- | ----------- | + | URL params | Additional query string parameters| + | headers | Any headers the GraphQL source requires| + + + +ToolJet - GraphQL connection + +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. + +ToolJet - GraphQL connection + +Click on the 'run' button to run the query. NOTE: Query should be saved before running. + +:::tip +Query results can be transformed using transformations. Read our transformations documentation to see how: [link](/tutorial/transformations) +::: \ No newline at end of file diff --git a/docs/docs/deployment/ec2.md b/docs/docs/deployment/ec2.md new file mode 100644 index 0000000000..efc24f2506 --- /dev/null +++ b/docs/docs/deployment/ec2.md @@ -0,0 +1,67 @@ +--- +sidebar_position: 4 +--- + +# AWS EC2 + +:::info +You should setup a PostgreSQL database manually to be used by the ToolJet server. +::: + +Follow the steps below to deploy ToolJet on AWS EC2 instances. + +1. Setup a PostgreSQL database and make sure that the database is accessible from the EC2 instance. + +2. Login to your AWS management console and go to the EC2 management page. + +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. + +5. Select ToolJet's AMI and bootup an EC2 instance. + + Creating a new security group is recommended. For example, if the installation should receive traffic from the internet, the inbound rules of the security group should look like this: + + protocol| port | allowed_cidr| + ----| ----------- | ----------- | + tcp | 22 | your IP | + tcp | 80 | 0.0.0.0/0 | + tcp | 443 | 0.0.0.0/0 | + + +6. Once the instance boots up, SSH into the instance by running `ssh -i ubuntu@` + +7. Switch to the app directory by running `cd ~/app`. Modify the contents of the `.env` file. ( Eg: `vim .env` ) + + The default `.env` file looks like this: + ``` + TOOLJET_HOST=http:// + LOCKBOX_MASTER_KEY= + SECRET_KEY_BASE= + PG_DB=tooljet_prod + PG_USER= + PG_HOST= + PG_PASS= + ``` + Read [environment variables reference](/docs/deployment/env-vars) + +8. `TOOLJET_HOST` environment variable determines where you can access the ToolJet client. It can either be the public ipv4 address of your instance or a custom domain that you want to use. + + Examples: + `TOOLJET_HOST=http://12.34.56.78` or + `TOOLJET_HOST=https://yourdomain.com` or + `TOOLJET_HOST=https://tooljet.yourdomain.com` + + :::info + We use a [lets encrypt](https://letsencrypt.org/) plugin on top of nginx to create TLS certificates on the fly. + ::: + + :::info + 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. + +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. + +12. You're all done, ToolJet client would now be served at the value you've set in `TOOLJET_HOST`. diff --git a/docs/docs/intro.md b/docs/docs/intro.md index fd01db992b..2539a8d3d1 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -26,7 +26,7 @@ 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. - **[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 +- **[Deploy](/docs/contributing-guide/setup/docker)** - Learn how to deploy ToolJet on Heroku, Kubernetes, etc The references for datasources and widgets: diff --git a/docs/static/img/datasource-reference/graphql-connect.png b/docs/static/img/datasource-reference/graphql-connect.png new file mode 100644 index 0000000000..73c91fa374 Binary files /dev/null and b/docs/static/img/datasource-reference/graphql-connect.png differ diff --git a/docs/static/img/datasource-reference/graphql-query.png b/docs/static/img/datasource-reference/graphql-query.png new file mode 100644 index 0000000000..17becce940 Binary files /dev/null and b/docs/static/img/datasource-reference/graphql-query.png differ diff --git a/frontend/assets/images/icons/editor/datasources/graphql.svg b/frontend/assets/images/icons/editor/datasources/graphql.svg new file mode 100644 index 0000000000..8e353ddbaa --- /dev/null +++ b/frontend/assets/images/icons/editor/datasources/graphql.svg @@ -0,0 +1,71 @@ + + + + diff --git a/frontend/src/App/App.jsx b/frontend/src/App/App.jsx index 6441c2578e..00db582b37 100644 --- a/frontend/src/App/App.jsx +++ b/frontend/src/App/App.jsx @@ -77,7 +77,7 @@ class App extends React.Component { - + diff --git a/frontend/src/Editor/Box.jsx b/frontend/src/Editor/Box.jsx index 000da8aa72..f33061fa2d 100644 --- a/frontend/src/Editor/Box.jsx +++ b/frontend/src/Editor/Box.jsx @@ -32,7 +32,7 @@ const AllComponents = { Multiselect, Modal, Chart, - Map + Map, }; export const Box = function Box({ @@ -51,17 +51,17 @@ export const Box = function Box({ onComponentOptionsChanged, paramUpdated, changeCanDrag, - containerProps + containerProps, }) { const backgroundColor = yellow ? 'yellow' : ''; let styles = { - + height: '100%', }; if (inCanvas) { styles = { - ...styles + ...styles, }; } @@ -85,22 +85,23 @@ export const Box = function Box({ containerProps={containerProps} > ) : ( -
-
+
+
-
-
+
{component.displayName} -
)} diff --git a/frontend/src/Editor/Components/Text.jsx b/frontend/src/Editor/Components/Text.jsx index 3cd4c71670..2894709264 100644 --- a/frontend/src/Editor/Components/Text.jsx +++ b/frontend/src/Editor/Components/Text.jsx @@ -35,7 +35,9 @@ export const Text = function Text({ const computedStyles = { color, width, - height + height, + display: 'flex', + alignItems: 'center' }; return ( diff --git a/frontend/src/Editor/Components/components.js b/frontend/src/Editor/Components/components.js index eb33e9dbe3..dfbe248c29 100644 --- a/frontend/src/Editor/Components/components.js +++ b/frontend/src/Editor/Components/components.js @@ -460,8 +460,8 @@ export const componentTypes = [ loadingState: { type: 'code', displayName: 'Show loading state' } }, defaultSize: { - width: 210, - height: 24 + width: 200, + height: 30 }, events: [ diff --git a/frontend/src/Editor/DataSourceManager/DataSourceTypes.js b/frontend/src/Editor/DataSourceManager/DataSourceTypes.js index 986358f348..a2c3509c21 100644 --- a/frontend/src/Editor/DataSourceManager/DataSourceTypes.js +++ b/frontend/src/Editor/DataSourceManager/DataSourceTypes.js @@ -148,6 +148,22 @@ export const apiSources = [ }, customTesting: true }, + { + name: 'GraphQL', + kind: 'graphql', + options: { + url: { type: 'string' }, + headers: { type: 'array' }, + url_params: { type: 'array' }, + body: { type: 'array' }, + }, + exposedVariables: { + isLoading: {}, + data: {}, + rawData: {} + }, + customTesting: true + }, { name: 'Stripe', kind: 'stripe', diff --git a/frontend/src/Editor/DataSourceManager/DefaultOptions.js b/frontend/src/Editor/DataSourceManager/DefaultOptions.js index b3c4f82e7a..40c9f09ddf 100644 --- a/frontend/src/Editor/DataSourceManager/DefaultOptions.js +++ b/frontend/src/Editor/DataSourceManager/DefaultOptions.js @@ -67,6 +67,11 @@ export const defaultOptions = { headers: { value: [['', '']] }, custom_auth_params: { value: [['', '']] } }, + graphql: { + url: { value: '' }, + headers: { value: [['', '']] }, + url_params: { value: [['', '']] } + }, googlesheets: { access_type: { value: 'read' } }, diff --git a/frontend/src/Editor/DataSourceManager/SourceComponents/Graphql.jsx b/frontend/src/Editor/DataSourceManager/SourceComponents/Graphql.jsx new file mode 100644 index 0000000000..50efef480a --- /dev/null +++ b/frontend/src/Editor/DataSourceManager/SourceComponents/Graphql.jsx @@ -0,0 +1,92 @@ +import React from 'react'; +import Button from 'react-bootstrap/Button'; + +export const Graphql = ({ + optionchanged, createDataSource, options, isSaving +}) => { + + function addNewKeyValuePair(option) { + const newPairs = [...options[option].value, ['', '']]; + optionchanged(option, newPairs); + } + + function removeKeyValuePair(option, index) { + options[option].value.splice(index, 1); + optionchanged(option, options[option].value); + } + + function keyValuePairValueChanged(e, keyIndex, option, index) { + const value = e.target.value; + options[option].value[index][keyIndex] = value; + optionchanged(option, options[option].value); + } + + return ( +
+
+
+ + optionchanged('url', e.target.value)} + value={options.url.value} + /> +
+ + {[{name: 'URL parameters', value: 'url_params'},{name: 'Headers', value: 'headers'}].map((option) => ( +
+
+
+ +
+
+ {(options[option.value].value || []).map((pair, index) => ( +
+ keyValuePairValueChanged(e, 0, option.value, index)} + /> + keyValuePairValueChanged(e, 1, option.value, index)} + /> + { + removeKeyValuePair(option.value, index); + }} + >x +
+ ))} + +
+
+
+ ))} +
+ + +
+
+
+ +
+
+
+ ); +}; diff --git a/frontend/src/Editor/DataSourceManager/SourceComponents/index.js b/frontend/src/Editor/DataSourceManager/SourceComponents/index.js index f2504dcf41..149d8c09a4 100644 --- a/frontend/src/Editor/DataSourceManager/SourceComponents/index.js +++ b/frontend/src/Editor/DataSourceManager/SourceComponents/index.js @@ -10,6 +10,7 @@ import { Slack } from './Slack'; import { Mongodb } from './Mongodb'; import { Dynamodb } from './Dynamodb'; import { Airtable } from './Airtable'; +import { Graphql } from './Graphql'; import { Mssql } from './Mssql'; export const SourceComponents = { @@ -25,5 +26,6 @@ export const SourceComponents = { Mongodb, Dynamodb, Airtable, + Graphql, Mssql }; diff --git a/frontend/src/Editor/DraggableBox.jsx b/frontend/src/Editor/DraggableBox.jsx index 749d15adc8..5274984260 100644 --- a/frontend/src/Editor/DraggableBox.jsx +++ b/frontend/src/Editor/DraggableBox.jsx @@ -5,28 +5,40 @@ import { getEmptyImage } from 'react-dnd-html5-backend'; import { Box } from './Box'; import { Resizable } from 're-resizable'; import { ConfigHandle } from './ConfigHandle'; -import { Rnd } from "react-rnd"; +import { Rnd } from 'react-rnd'; const resizerClasses = { topRight: 'top-right', bottomRight: 'bottom-right', bottomLeft: 'bottom-left', - topLeft: 'top-left' + topLeft: 'top-left', }; const resizerStyles = { topRight: { - width: '12px', height: '12px', right: '-6px', top: '-6px' + width: '12px', + height: '12px', + right: '-6px', + top: '-6px', }, bottomRight: { - width: '12px', height: '12px', right: '-6px', bottom: '-6px' + width: '12px', + height: '12px', + right: '-6px', + bottom: '-6px', }, bottomLeft: { - width: '12px', height: '12px', left: '-6px', bottom: '-6px' + width: '12px', + height: '12px', + left: '-6px', + bottom: '-6px', }, topLeft: { - width: '12px', height: '12px', left: '-6px', top: '-6px' - } + width: '12px', + height: '12px', + left: '-6px', + top: '-6px', + }, }; function getStyles(left, top, isDragging, component) { @@ -39,7 +51,7 @@ function getStyles(left, top, isDragging, component) { // IE fallback: hide the real node using CSS when dragging // because IE will ignore our custom "empty image" drag preview. opacity: isDragging ? 0 : 1, - height: isDragging ? 0 : '' + height: isDragging ? 0 : '', }; } @@ -71,7 +83,7 @@ export const DraggableBox = function DraggableBox({ layouts, scaleValue, deviceWindowWidth, - isSelectedComponent + isSelectedComponent, }) { const [isResizing, setResizing] = useState(false); const [canDrag, setCanDrag] = useState(true); @@ -81,11 +93,17 @@ export const DraggableBox = function DraggableBox({ () => ({ type: ItemTypes.BOX, item: { - id, title, component, zoomLevel, parent, layouts, currentLayout + id, + title, + component, + zoomLevel, + parent, + layouts, + currentLayout, }, collect: (monitor) => ({ - isDragging: monitor.isDragging() - }) + isDragging: monitor.isDragging(), + }), }), [id, title, component, index, zoomLevel, parent, layouts, currentLayout] ); @@ -104,14 +122,14 @@ export const DraggableBox = function DraggableBox({ display: 'inline-block', alignItems: 'center', justifyContent: 'center', - padding: '2px' + padding: '2px', }; let refProps = {}; if (mode === 'edit' && canDrag) { refProps = { - ref: drag + ref: drag, }; } @@ -123,53 +141,60 @@ export const DraggableBox = function DraggableBox({ top: 100, left: 0, width: 445, - height: 500 - } + height: 500, + }; const layoutData = inCanvas ? layouts[currentLayout] || defaultData : defaultData; const [currentLayoutOptions, setCurrentLayoutOptions] = useState(layoutData); useEffect(() => { - console.log(layoutData) + console.log(layoutData); setCurrentLayoutOptions(layoutData); }, [layoutData.height, layoutData.width, layoutData.left, layoutData.top, currentLayout]); - function scaleWidth(width, scaleValue) { + function scaleWidth(width, scaleValue) { let newWidth = width * scaleValue; - if(currentLayout === 'desktop') return newWidth; + if (currentLayout === 'desktop') return newWidth; - const diff = currentLayoutOptions.left + newWidth - deviceWindowWidth; + const diff = currentLayoutOptions.left + newWidth - deviceWindowWidth; - if(diff > 0 ) { + if (diff > 0) { setCurrentLayoutOptions({ ...currentLayoutOptions, - left: currentLayoutOptions.left - diff + left: currentLayoutOptions.left - diff, }); return width; } return newWidth; - } return ( -
+
{inCanvas ? ( -
setMouseOver(true)} onMouseLeave={() => setMouseOver(false)} > - setResizing(true)} resizeHandleClasses={mouseOver ? resizerClasses : {}} resizeHandleStyles={resizerStyles} @@ -181,15 +206,15 @@ export const DraggableBox = function DraggableBox({ }} >
- {mode === 'edit' && mouseOver && - configHandleClicked(id, component)} - /> - } + {mode === 'edit' && mouseOver && ( + configHandleClicked(id, component)} + /> + )}
) : ( -
+
- this.setState( - { - app: data, - isLoading: false, - appDefinition: { ...this.state.appDefinition, ...data.definition }, - }, - () => { - 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({ - appId, 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 + })); } ); }; @@ -124,7 +125,7 @@ class Editor extends React.Component { fetchDataQueries = () => { this.setState( { - loadingDataQueries: true, + loadingDataQueries: true }, () => { dataqueryService.getAll(this.state.appId).then((data) => { @@ -134,15 +135,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] }; }); @@ -164,9 +165,9 @@ class Editor extends React.Component { currentState: { ...this.state.currentState, queries: { - ...queryState, - }, - }, + ...queryState + } + } }); } ); @@ -192,9 +193,9 @@ class Editor extends React.Component { currentState: { ...this.state.currentState, components: { - ...componentState, - }, - }, + ...componentState + } + } }); }; @@ -209,7 +210,7 @@ class Editor extends React.Component { switchSidebarTab = (tabIndex) => { this.setState({ - currentSidebarTab: tabIndex, + currentSidebarTab: tabIndex }); }; @@ -235,11 +236,15 @@ class Editor extends React.Component { if (this.state.selectedComponent.hasOwnProperty('component')) { const { id: selectedComponentId } = this.state.selectedComponent; if (selectedComponentId === component.id) { - this.setState({selectedComponent: null}) + this.setState({ selectedComponent: null }); this.switchSidebarTab(2); } } - } + }; + + handleSlugChange = (newSlug) => { + this.setState({ slug: newSlug }); + }; removeComponent = (component) => { let newDefinition = this.state.appDefinition; @@ -267,10 +272,10 @@ class Editor extends React.Component { [newDefinition.id]: { ...this.state.appDefinition.components[newDefinition.id], component: newDefinition.component, - layouts: newDefinition.layouts, - }, - }, - }, + layouts: newDefinition.layouts + } + } + } }); }; @@ -282,10 +287,10 @@ class Editor extends React.Component { ...this.state.appDefinition.components, [newComponent.id]: { ...this.state.appDefinition.components[newComponent.id], - ...newComponent, - }, - }, - }, + ...newComponent + } + } + } }); }; @@ -354,7 +359,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' }); }); }} @@ -366,7 +371,7 @@ class Editor extends React.Component { )} {isLoading === true && (
-
+
)}
@@ -376,13 +381,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%' }); }; @@ -399,12 +404,30 @@ class Editor extends React.Component { this.setState({ selectedComponent: { id, component } }); }; + filterQueries = (value) => { + if (value) { + const fuse = new Fuse(this.state.dataQueries, { keys: ['name'] }); + const results = fuse.search(value); + this.setState({ + dataQueries: results.map((result) => result.item), + dataQueriesDefaultText: results.length || 'No Queries found.' + }); + } else { + this.fetchDataQueries(); + } + } + + toggleQuerySearch = () => { + this.setState({ showQuerySearchField: !this.state.showQuerySearchField }); + } + render() { const { currentSidebarTab, selectedComponent, appDefinition, appId, + slug, dataSources, loadingDataQueries, dataQueries, @@ -423,9 +446,10 @@ class Editor extends React.Component { currentLayout, deviceWindowWidth, scaleValue, + dataQueriesDefaultText, + showQuerySearchField } = this.state; - - const appLink = `/applications/${appId}`; + const appLink = slug ? `/applications/${slug}` : ''; return (
@@ -508,10 +532,10 @@ class Editor extends React.Component {
-
+
@@ -552,12 +582,12 @@ class Editor extends React.Component { alignItems: 'center', justifyContent: 'center', background: '#f0f0f0', - zIndex: '200', + zIndex: '200' }} maxWidth={showLeftSidebar ? '30%' : '0%'} defaultSize={{ width: '12%', - height: '99%', + height: '99%' }} >
@@ -650,8 +680,7 @@ class Editor extends React.Component { You haven't added data sources yet.
} */} +
+ {showQuerySearchField + &&
+
+
+ this.filterQueries(e.target.value)} + /> +
+
+
+ } + {loadingDataQueries ? (
@@ -739,11 +782,10 @@ class Editor extends React.Component { {dataQueries.length === 0 && (
- You haven't created queries yet.
+ {dataQueriesDefaultText}
-
+
{!loadingDataSources && (
diff --git a/frontend/src/Editor/ManageAppUsers.jsx b/frontend/src/Editor/ManageAppUsers.jsx index fb511680f3..c6debebb99 100644 --- a/frontend/src/Editor/ManageAppUsers.jsx +++ b/frontend/src/Editor/ManageAppUsers.jsx @@ -7,6 +7,7 @@ import { CopyToClipboard } from 'react-copy-to-clipboard'; import 'react-toastify/dist/ReactToastify.css'; import Skeleton from 'react-loading-skeleton'; import SelectSearch, { fuzzySearch } from 'react-select-search'; +import { debounce } from 'lodash'; class ManageAppUsers extends React.Component { constructor(props) { @@ -14,7 +15,8 @@ class ManageAppUsers extends React.Component { this.state = { showModal: false, - app: props.app, + app: { ...props.app }, + slugError: null, isLoading: true, addingUser: false, organizationUsers: [], @@ -61,20 +63,20 @@ class ManageAppUsers extends React.Component { toast.success('Added user successfully', { hideProgressBar: true, position: 'top-center' }); this.fetchAppUsers(); }) - .catch(( { error }) => { + .catch(({ error }) => { this.setState({ addingUser: false }); toast.error(error, { hideProgressBar: true, position: 'top-center' }); }); }; toggleAppVisibility = () => { - const newState = !this.state.app.is_public; + const newState = !this.state.app.is_public; this.setState({ - ischangingVisibility: true + ischangingVisibility: true }); - appService.setVisibility(this.state.app.id, newState).then(data => { - this.setState({ + appService.setVisibility(this.state.app.id, newState).then(data => { + this.setState({ ischangingVisibility: false, app: { ...this.state.app, @@ -82,7 +84,7 @@ class ManageAppUsers extends React.Component { } }); - if(newState) { + if (newState) { toast.success('Application is now public.', { hideProgressBar: true, position: 'top-center' @@ -93,24 +95,42 @@ class ManageAppUsers extends React.Component { position: 'top-center' }); } - }); } - render() { - const { - addingUser, isLoading, users, organizationUsers, newUser - } = this.state; - const shareableLink = `${window.location.origin}/applications/${this.state.app.id}`; + handleSetSlug = (event) => { + const newSlug = event.target.value || null; + appService + .setSlug(this.state.app.id, newSlug) + .then(() => { + this.setState({ slugError: null }); + this.props.handleSlugChange(newSlug); + }) + .catch(({ error }) => { + this.setState({ slugError: error }); + }); + } - return ( + delayedSlugChange = debounce(e => { + this.handleSetSlug(e); + }, 500); + + render() { + const { + addingUser, isLoading, users, organizationUsers, newUser, app, slugError + } = this.state; + const appId = app.id; + const appLink = `${window.location.origin}/applications/`; + const shareableLink = appLink + (this.props.slug || appId); + + return (
- + Users and permissions
@@ -144,7 +164,12 @@ class ManageAppUsers extends React.Component { Get shareable link for this application
- + {appLink} + { e.persist(); this.delayedSlugChange(e); }} + defaultValue={this.props.slug} /> Copy +
{slugError}

@@ -243,8 +269,8 @@ class ManageAppUsers extends React.Component {
- ); - } + ); + } } export { ManageAppUsers }; diff --git a/frontend/src/Editor/QueryManager/QueryEditors/Graphql.jsx b/frontend/src/Editor/QueryManager/QueryEditors/Graphql.jsx new file mode 100644 index 0000000000..64852e001c --- /dev/null +++ b/frontend/src/Editor/QueryManager/QueryEditors/Graphql.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { CodeHinter } from '../../CodeBuilder/CodeHinter'; +import { changeOption } from './utils'; + +class Graphql extends React.Component { + constructor(props) { + super(props); + + this.state = {}; + } + + componentDidMount() { + this.setState({ + options: this.props.options + }); + } + + render() { + const { options } = this.state; + + return ( +
+ {options && ( +
+ changeOption(this, 'query', value)} + /> +
+ )} +
+ ); + } +} + +export { Graphql }; diff --git a/frontend/src/Editor/QueryManager/QueryEditors/index.js b/frontend/src/Editor/QueryManager/QueryEditors/index.js index 739e6c7e7e..25a67da9ff 100644 --- a/frontend/src/Editor/QueryManager/QueryEditors/index.js +++ b/frontend/src/Editor/QueryManager/QueryEditors/index.js @@ -10,6 +10,7 @@ import { Slack } from './Slack'; import { Mongodb } from './Mongodb'; import { Dynamodb } from './Dynamodb'; import { Airtable } from './Airtable'; +import { Graphql } from './Graphql'; import { Mssql } from './Mssql'; export const allSources = { @@ -25,5 +26,6 @@ export const allSources = { Mongodb, Dynamodb, Airtable, + Graphql, Mssql }; diff --git a/frontend/src/Editor/QueryManager/constants.js b/frontend/src/Editor/QueryManager/constants.js index 8528fbf1ca..853babcfa4 100644 --- a/frontend/src/Editor/QueryManager/constants.js +++ b/frontend/src/Editor/QueryManager/constants.js @@ -4,6 +4,7 @@ export const defaultOptions = { query: 'PING' }, mysql: {}, + graphql: {}, firestore: { path: '' }, diff --git a/frontend/src/Editor/Viewer.jsx b/frontend/src/Editor/Viewer.jsx index 71fe7a7b31..b07ccf513b 100644 --- a/frontend/src/Editor/Viewer.jsx +++ b/frontend/src/Editor/Viewer.jsx @@ -37,7 +37,7 @@ class Viewer extends React.Component { } componentDidMount() { - const id = this.props.match.params.id; + const slug = this.props.match.params.slug; const deviceWindowWidth = window.screen.width - 5; const isMobileDevice = deviceWindowWidth < 600; @@ -51,7 +51,7 @@ class Viewer extends React.Component { currentLayout: isMobileDevice ? 'mobile' : 'desktop' }); - appService.getApp(id).then((data) => this.setState( + appService.getAppBySlug(slug).then((data) => this.setState( { app: data, isLoading: false, @@ -69,7 +69,7 @@ class Viewer extends React.Component { const currentUser = authenticationService.currentUserValue; let userVars = { }; - if(currentUser) { + if (currentUser) { userVars = { email: currentUser.email, firstName: currentUser.first_name, @@ -77,7 +77,6 @@ class Viewer extends React.Component { }; } - this.setState({ currentSidebarTab: 2, selectedComponent: null, @@ -93,10 +92,10 @@ class Viewer extends React.Component { } render() { - const { - appDefinition, - showQueryConfirmation, - currentState, + const { + appDefinition, + showQueryConfirmation, + currentState, isLoading, currentLayout, deviceWindowWidth, @@ -131,7 +130,7 @@ class Viewer extends React.Component {
-
+
false} // function not relevant in viewer diff --git a/frontend/src/Editor/WidgetManager.jsx b/frontend/src/Editor/WidgetManager.jsx index c7cee122bb..379cad0015 100644 --- a/frontend/src/Editor/WidgetManager.jsx +++ b/frontend/src/Editor/WidgetManager.jsx @@ -3,39 +3,44 @@ import { DraggableBox } from './DraggableBox'; import Fuse from 'fuse.js'; import { result } from 'lodash'; -export const WidgetManager = function WidgetManager({ - componentTypes, - zoomLevel, - currentLayout -}) { +export const WidgetManager = function WidgetManager({ componentTypes, zoomLevel, currentLayout }) { + const [filteredComponents, setFilteredComponents] = useState(componentTypes); - const [filteredComponents, setFilteredComponents] = useState(componentTypes); - - function filterComponents(value) { - if(value != '') { - const fuse = new Fuse(filteredComponents, { keys: ['component'] }); - const results = fuse.search(value); - setFilteredComponents(results.map((result) => result.item)) - } else { - setFilteredComponents(componentTypes); - } + function filterComponents(value) { + if (value != '') { + const fuse = new Fuse(filteredComponents, { keys: ['component'] }); + const results = fuse.search(value); + setFilteredComponents(results.map((result) => result.item)); + } else { + setFilteredComponents(componentTypes); } + } - function renderComponentCard(component, index) { - return ; - }; - - return
-
- filterComponents(e.target.value)} + function renderComponentCard(component, index) { + return ( + + ); + } + + return ( +
+
+ filterComponents(e.target.value)} + /> +
+
+ {filteredComponents.map((component, i) => renderComponentCard(component, i))} +
-
- {filteredComponents.map((component, i) => renderComponentCard(component, i))} -
-
-} + ); +}; diff --git a/frontend/src/HomePage/HomePage.jsx b/frontend/src/HomePage/HomePage.jsx index 5d1b6a423a..ea3ead08b7 100644 --- a/frontend/src/HomePage/HomePage.jsx +++ b/frontend/src/HomePage/HomePage.jsx @@ -172,7 +172,7 @@ class HomePage extends React.Component { { - toast.error('Invalid username or password', { hideProgressBar: true, position: 'top-center' }); + toast.error('Invalid email or password', { toastId: 'toast-login-auth-error', hideProgressBar: true, position: 'top-center' }); this.setState({ isLoading: false }); } ); @@ -63,6 +63,7 @@ class LoginPage extends React.Component { type="email" className="form-control" placeholder="Enter email" + data-testid="emailField" />
@@ -82,12 +83,13 @@ class LoginPage extends React.Component { className="form-control" placeholder="Password" autoComplete="off" + data-testid="passwordField" />
-
diff --git a/frontend/src/_services/app.service.js b/frontend/src/_services/app.service.js index 637a28f1ca..72975a642c 100644 --- a/frontend/src/_services/app.service.js +++ b/frontend/src/_services/app.service.js @@ -5,10 +5,12 @@ export const appService = { getAll, createApp, getApp, + getAppBySlug, saveApp, getAppUsers, createAppUser, - setVisibility + setVisibility, + setSlug }; function getAll(page, folder) { @@ -29,6 +31,11 @@ function getApp(id) { 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); +} + function saveApp(id, attributes) { const requestOptions = { method: 'PUT', headers: authHeader(), body: JSON.stringify({ app: attributes }) }; return fetch(`${config.apiUrl}/apps/${id}`, requestOptions).then(handleResponse); @@ -54,3 +61,8 @@ function setVisibility(appId, visibility) { const requestOptions = { method: 'PUT', headers: authHeader(), body: JSON.stringify({ app: { is_public: visibility } }) }; return fetch(`${config.apiUrl}/apps/${appId}`, requestOptions).then(handleResponse); } + +function setSlug(appId, slug) { + const requestOptions = { method: 'PUT', headers: authHeader(), body: JSON.stringify({ app: { slug: slug } }) }; + return fetch(`${config.apiUrl}/apps/${appId}`, requestOptions).then(handleResponse); +} diff --git a/frontend/src/_styles/theme.scss b/frontend/src/_styles/theme.scss index 5544023699..ea49f8099f 100644 --- a/frontend/src/_styles/theme.scss +++ b/frontend/src/_styles/theme.scss @@ -11,7 +11,7 @@ body { .resizer-active { border: solid 1px rgb(70, 165, 253)!important; - + .top-right, .top-left, .bottom-right, @@ -22,7 +22,7 @@ body { } } - .resizer-selected { + .resizer-selected { outline-width: thin; outline-style: solid; outline-color: #ffda7e; @@ -33,7 +33,7 @@ body { height: 31px; } - .header { + .header { --tblr-gutter-x: 0rem; } @@ -44,7 +44,7 @@ body { } } - .query-details { + .query-details { margin-top: 34px; } @@ -283,7 +283,7 @@ body { border: solid 1px transparent; } - + } } @@ -343,6 +343,23 @@ body { --tblr-gutter-x: 0rem; } + .query-definition-pane-wrapper { + overflow-x: hidden; + overflow-y: scroll; + height: 100%; + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* Internet Explorer 10+ */ + + &::-webkit-scrollbar { /* WebKit */ + width: 0; + height: 0; + } + + &::-webkit-scrollbar-thumb { + background: transparent; + } + } + .query-definition-pane { .header { border: solid rgba(0, 0, 0, 0.125); @@ -360,23 +377,32 @@ body { } .data-pane { - min-height: 350px; border: solid rgba(0, 0, 0, 0.125); border-width: 0px 1px 0px 0px; + overflow-x: hidden; + overflow-y: scroll; + height: 100%; + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* Internet Explorer 10+ */ + + &::-webkit-scrollbar { /* WebKit */ + width: 0; + height: 0; + } + + &::-webkit-scrollbar-thumb { + background: transparent; + } .queries-container { - - position: fixed; - width: 18.2%; - .queries-header { border: solid rgba(0, 0, 0, 0.125); border-width: 0px 0px 1px 0px; --tblr-gutter-x: 0rem; } - .query-list { - overflow-y: scroll; + .query-list { + // padding-top: 40px; } .query-list::-webkit-scrollbar { @@ -717,7 +743,7 @@ body { .jet-data-table { - thead { + thead { z-index: 2; } @@ -730,10 +756,10 @@ body { background: rgba(lightBlue, 0.25); } - td { + td { min-height: 35px; overflow-x: scroll; - + .text-container { padding: 0px; margin-bottom: 3px; @@ -747,7 +773,7 @@ body { } } - td { + td { .text-container:focus { position: sticky; height: 120px; @@ -765,7 +791,7 @@ body { } } - td { + td { .text-container::-webkit-scrollbar { background: transparent; height: 0; @@ -803,7 +829,7 @@ body { left: 6px; bottom: 8px; } - + } .jet-data-table::-webkit-scrollbar { @@ -827,7 +853,7 @@ body { top: 0px; display: inline-block; - tr { + tr { border-top: none; } } @@ -916,14 +942,14 @@ tr:focus { display: none; } - .custom-select { + .custom-select { .select-search:not(.select-search--multiple) .select-search__select { top: 0px; border: solid #9fa0a1 1px; } } - .tags { + .tags { width: 100%; min-height: 20px; @@ -931,7 +957,7 @@ tr:focus { display: none; } - .tag { + .tag { font-weight: 400; font-size: 0.85rem; @@ -951,11 +977,11 @@ tr:focus { .form-control-plaintext:hover, .form-control-plaintext:focus-visible { outline: none; - + } } - .tags:hover { + .tags:hover { .add-tag-button { display: inline-flex; } @@ -1091,7 +1117,7 @@ tr:focus { display: block; } -.text-muted { +.text-muted { color: #3e525b!important; } @@ -1112,28 +1138,28 @@ body { padding: 15px; height: 100%; } - + .RichEditor-editor { border-top: 1px solid #ddd; cursor: text; font-size: 16px; margin-top: 10px; } - + .RichEditor-editor .public-DraftEditorPlaceholder-root, .RichEditor-editor .public-DraftEditor-content { margin: 0 -15px -15px; padding: 15px; } - + .RichEditor-editor .public-DraftEditor-content { min-height: 100px; } - + .RichEditor-hidePlaceholder .public-DraftEditorPlaceholder-root { display: none; } - + .RichEditor-editor .RichEditor-blockquote { border-left: 5px solid #eee; color: #666; @@ -1142,21 +1168,21 @@ body { margin: 16px 0; padding: 10px 20px; } - + .RichEditor-editor .public-DraftStyleDefault-pre { background-color: rgba(0, 0, 0, 0.05); font-family: 'Inconsolata', 'Menlo', 'Consolas', monospace; font-size: 16px; padding: 20px; } - + .RichEditor-controls { font-family: 'Helvetica', sans-serif; font-size: 14px; margin-bottom: 5px; user-select: none; } - + .RichEditor-styleButton { color: #999; cursor: pointer; @@ -1164,18 +1190,18 @@ body { padding: 2px 0; display: inline-block; } - + .RichEditor-activeButton { color: #5890ff; } - + .transformation-editor { .CodeMirror { min-height: 70px; } } -.chart-data-input { +.chart-data-input { .CodeMirror { min-height: 370px; font-size: 0.8rem; @@ -1254,15 +1280,15 @@ body { height: 50px; } - .CodeMirror-scroll { + .CodeMirror-scroll { position: absolute; top: 0; width: 100%; } } -.field { - .CodeMirror-scroll { +.field { + .CodeMirror-scroll { position: static; top: 0; } @@ -1327,11 +1353,11 @@ body { min-height: 220px; } } - - hr { + + hr { margin: 1rem 0; } - + .query-hinter { min-height: 150px; } @@ -1378,7 +1404,7 @@ body { height: 36px; } - .modal-component { + .modal-component { margin-top: 150px; .modal-body { padding: 0; @@ -1413,7 +1439,7 @@ body { } // } -.apps-table { +.apps-table { .app-title { font-size: 1rem; } @@ -1428,7 +1454,7 @@ body { color: rgba(35, 46, 60, 0.7); } - .nav-item { + .nav-item { font-size: 0.9rem; } @@ -1443,7 +1469,7 @@ body { border-color: transparent; } -.text-widget { +.text-widget { overflow: scroll; } @@ -1457,8 +1483,8 @@ body { box-shadow: none; } -.map-widget { - .place-search-input { +.map-widget { + .place-search-input { box-sizing: border-box; border: 1px solid transparent; width: 240px; @@ -1475,14 +1501,14 @@ body { } } -.events-toggle-active { - .toggle-icon { +.events-toggle-active { + .toggle-icon { transform: rotate(180deg); } } -.events-toggle { - .toggle-icon { +.events-toggle { + .toggle-icon { display: inline-block; margin-left: auto; transition: .3s transform; @@ -1509,18 +1535,18 @@ body { .navbar-nav { .dropdown:hover { - .dropdown-menu { + .dropdown-menu { display: block; } } } -.query-manager-header { - .nav-item { +.query-manager-header { + .nav-item { border-right: solid 1px #dadcde; } - .nav-link { + .nav-link { height: 39px; } } @@ -1533,28 +1559,28 @@ input:focus-visible { border: 1px solid #3c92dc; } -.org-users-page { - .select-search__input{ +.org-users-page { + .select-search__input{ color: #617179; } } -.encrypted-icon { +.encrypted-icon { margin-bottom: 0.25rem; } -.widget-documentation-link { +.widget-documentation-link { position: fixed; bottom: 0; } .components-container { - .draggable-box { + .draggable-box { cursor: move; } } -.column-sort-row { +.column-sort-row { border-radius: 0; } @@ -1576,7 +1602,7 @@ input:focus-visible { -ms-overflow-style: none; } -.sketch-picker { +.sketch-picker { position: absolute; } @@ -1585,3 +1611,13 @@ input:focus-visible { border: solid 1px rgb(223, 223, 223); cursor: pointer; } + +.app-sharing-modal { + .form-control.is-invalid, .was-validated .form-control:invalid { + border-color: #ffb0b0; + } +} + +.widgets-list { + --tblr-gutter-x: 0px!important; +} diff --git a/netlify.toml b/netlify.toml index f2196d1e71..1a7526debd 100644 --- a/netlify.toml +++ b/netlify.toml @@ -4,3 +4,8 @@ [template.environment] NODE_ENV = "production" + +[[redirects]] + from = "/*" + to = "/" + status = 200 \ No newline at end of file