mirror of
https://github.com/ToolJet/ToolJet
synced 2026-05-23 17:08:34 +00:00
Merge branch 'release/v0.5.11' into main
This commit is contained in:
commit
e71d14c61e
52 changed files with 1314 additions and 333 deletions
1
Gemfile
1
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
|
||||
|
|
|
|||
11
Gemfile.lock
11
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
40
app/services/graphql_query_service.rb
Normal file
40
app/services/graphql_query_service.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
json.meta @meta.as_json
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
6
cypress.json
Normal file
6
cypress.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"baseUrl": "http://localhost:8082",
|
||||
"env": {
|
||||
"apiUrl": "http://localhost:3000"
|
||||
}
|
||||
}
|
||||
37
cypress/integration/auth.spec.js
Normal file
37
cypress/integration/auth.spec.js
Normal file
|
|
@ -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');
|
||||
})
|
||||
})
|
||||
22
cypress/plugins/index.js
Normal file
22
cypress/plugins/index.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/// <reference types="cypress" />
|
||||
// ***********************************************************
|
||||
// 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
|
||||
}
|
||||
10
cypress/support/commands.js
Normal file
10
cypress/support/commands.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
20
cypress/support/index.js
Normal file
20
cypress/support/index.js
Normal file
|
|
@ -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')
|
||||
10
db/migrate/20210619124759_add_slug_to_apps.rb
Executable file
10
db/migrate/20210619124759_add_slug_to_apps.rb
Executable file
|
|
@ -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
|
||||
6
db/schema.rb
generated
6
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_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"
|
||||
|
|
|
|||
7
deploy/ec2/.env
Normal file
7
deploy/ec2/.env
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
TOOLJET_HOST=http://<example>
|
||||
LOCKBOX_MASTER_KEY=<example>
|
||||
SECRET_KEY_BASE=<example>
|
||||
PG_DB=tooljet_prod
|
||||
PG_USER=<pg user name>
|
||||
PG_HOST=<pg host>
|
||||
PG_PASS=<pg user password>
|
||||
114
deploy/ec2/nginx.conf
Normal file
114
deploy/ec2/nginx.conf
Normal file
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
deploy/ec2/puma.service
Normal file
18
deploy/ec2/puma.service
Normal file
|
|
@ -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
|
||||
56
deploy/ec2/setup_app
Executable file
56
deploy/ec2/setup_app
Executable file
|
|
@ -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")}"
|
||||
45
deploy/ec2/setup_machine.sh
Normal file
45
deploy/ec2/setup_machine.sh
Normal file
|
|
@ -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
|
||||
55
deploy/ec2/tool_jet_ubuntu_bionic.pkr.hcl
Normal file
55
deploy/ec2/tool_jet_ubuntu_bionic.pkr.hcl
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
40
docs/docs/data-sources/graphql.md
Normal file
40
docs/docs/data-sources/graphql.md
Normal file
|
|
@ -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|
|
||||
|
||||
|
||||
|
||||
<img src="/img/datasource-reference/graphql-connect.png" alt="ToolJet - GraphQL connection" height="250"/>
|
||||
|
||||
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.
|
||||
|
||||
<img src="/img/datasource-reference/graphql-query.png" alt="ToolJet - GraphQL connection" height="250"/>
|
||||
|
||||
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)
|
||||
:::
|
||||
67
docs/docs/deployment/ec2.md
Normal file
67
docs/docs/deployment/ec2.md
Normal file
|
|
@ -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 <path_to_pem_file> ubuntu@<public_ip_of_the_instance>`
|
||||
|
||||
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://<example>
|
||||
LOCKBOX_MASTER_KEY=<example>
|
||||
SECRET_KEY_BASE=<example>
|
||||
PG_DB=tooljet_prod
|
||||
PG_USER=<pg user name>
|
||||
PG_HOST=<pg host>
|
||||
PG_PASS=<pg user password>
|
||||
```
|
||||
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`.
|
||||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
BIN
docs/static/img/datasource-reference/graphql-connect.png
vendored
Normal file
BIN
docs/static/img/datasource-reference/graphql-connect.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
docs/static/img/datasource-reference/graphql-query.png
vendored
Normal file
BIN
docs/static/img/datasource-reference/graphql-query.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
71
frontend/assets/images/icons/editor/datasources/graphql.svg
Normal file
71
frontend/assets/images/icons/editor/datasources/graphql.svg
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="GraphQL_Logo" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
|
||||
y="0px" viewBox="0 0 400 400" enable-background="new 0 0 400 400" xml:space="preserve">
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
|
||||
<rect x="122" y="-0.4" transform="matrix(-0.866 -0.5 0.5 -0.866 163.3196 363.3136)" fill="#E535AB" width="16.6" height="320.3"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<rect x="39.8" y="272.2" fill="#E535AB" width="320.3" height="16.6"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
|
||||
<rect x="37.9" y="312.2" transform="matrix(-0.866 -0.5 0.5 -0.866 83.0693 663.3409)" fill="#E535AB" width="185" height="16.6"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
|
||||
<rect x="177.1" y="71.1" transform="matrix(-0.866 -0.5 0.5 -0.866 463.3409 283.0693)" fill="#E535AB" width="185" height="16.6"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
|
||||
<rect x="122.1" y="-13" transform="matrix(-0.5 -0.866 0.866 -0.5 126.7903 232.1221)" fill="#E535AB" width="16.6" height="185"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
|
||||
<rect x="109.6" y="151.6" transform="matrix(-0.5 -0.866 0.866 -0.5 266.0828 473.3766)" fill="#E535AB" width="320.3" height="16.6"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<rect x="52.5" y="107.5" fill="#E535AB" width="16.6" height="185"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<rect x="330.9" y="107.5" fill="#E535AB" width="16.6" height="185"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
|
||||
<rect x="262.4" y="240.1" transform="matrix(-0.5 -0.866 0.866 -0.5 126.7953 714.2875)" fill="#E535AB" width="14.5" height="160.9"/>
|
||||
</g>
|
||||
</g>
|
||||
<path fill="#E535AB" d="M369.5,297.9c-9.6,16.7-31,22.4-47.7,12.8c-16.7-9.6-22.4-31-12.8-47.7c9.6-16.7,31-22.4,47.7-12.8
|
||||
C373.5,259.9,379.2,281.2,369.5,297.9"/>
|
||||
<path fill="#E535AB" d="M90.9,137c-9.6,16.7-31,22.4-47.7,12.8c-16.7-9.6-22.4-31-12.8-47.7c9.6-16.7,31-22.4,47.7-12.8
|
||||
C94.8,99,100.5,120.3,90.9,137"/>
|
||||
<path fill="#E535AB" d="M30.5,297.9c-9.6-16.7-3.9-38,12.8-47.7c16.7-9.6,38-3.9,47.7,12.8c9.6,16.7,3.9,38-12.8,47.7
|
||||
C61.4,320.3,40.1,314.6,30.5,297.9"/>
|
||||
<path fill="#E535AB" d="M309.1,137c-9.6-16.7-3.9-38,12.8-47.7c16.7-9.6,38-3.9,47.7,12.8c9.6,16.7,3.9,38-12.8,47.7
|
||||
C340.1,159.4,318.7,153.7,309.1,137"/>
|
||||
<path fill="#E535AB" d="M200,395.8c-19.3,0-34.9-15.6-34.9-34.9c0-19.3,15.6-34.9,34.9-34.9c19.3,0,34.9,15.6,34.9,34.9
|
||||
C234.9,380.1,219.3,395.8,200,395.8"/>
|
||||
<path fill="#E535AB" d="M200,74c-19.3,0-34.9-15.6-34.9-34.9c0-19.3,15.6-34.9,34.9-34.9c19.3,0,34.9,15.6,34.9,34.9
|
||||
C234.9,58.4,219.3,74,200,74"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
|
@ -77,7 +77,7 @@ class App extends React.Component {
|
|||
<Route path = "/reset-password" component ={ResetPassword} />
|
||||
<Route path="/invitations/:token" component={InvitationPage} />
|
||||
<PrivateRoute exact path="/apps/:id" component={Editor} />
|
||||
<PrivateRoute exact path="/applications/:id" component={Viewer} />
|
||||
<PrivateRoute exact path="/applications/:slug" component={Viewer} />
|
||||
<PrivateRoute exact path="/oauth2/authorize" component={Authorize} />
|
||||
<PrivateRoute exact path="/users" component={ManageOrgUsers} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
></ComponentToRender>
|
||||
) : (
|
||||
<div className="p-1 m-1">
|
||||
<div className="component-image-holder p-3">
|
||||
<div className="m-1" style={{ height: '100%' }}>
|
||||
<div
|
||||
className="component-image-holder p-3 d-flex flex-column justify-content-center"
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
<center>
|
||||
<div
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
backgroundSize: 'contain',
|
||||
backgroundImage: `url(/assets/images/icons/widgets/${component.name.toLowerCase()}.svg)`,
|
||||
backgroundRepeat: 'no-repeat'
|
||||
}}
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
backgroundSize: 'contain',
|
||||
backgroundImage: `url(/assets/images/icons/widgets/${component.name.toLowerCase()}.svg)`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
></div>
|
||||
</center>
|
||||
<span className="component-title">{component.displayName}</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,9 @@ export const Text = function Text({
|
|||
const computedStyles = {
|
||||
color,
|
||||
width,
|
||||
height
|
||||
height,
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -460,8 +460,8 @@ export const componentTypes = [
|
|||
loadingState: { type: 'code', displayName: 'Show loading state' }
|
||||
},
|
||||
defaultSize: {
|
||||
width: 210,
|
||||
height: 24
|
||||
width: 200,
|
||||
height: 30
|
||||
},
|
||||
events: [
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div>
|
||||
<div className="row">
|
||||
<div className="col-md-12 mb-3">
|
||||
<label className="form-label">URL</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://api.example.com/v1/"
|
||||
className="form-control"
|
||||
onChange={(e) => optionchanged('url', e.target.value)}
|
||||
value={options.url.value}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{[{name: 'URL parameters', value: 'url_params'},{name: 'Headers', value: 'headers'}].map((option) => (
|
||||
<div className="mb-3" key={option}>
|
||||
<div className="row g-2">
|
||||
<div className="col-md-2">
|
||||
<label className="form-label pt-2">{option.name}</label>
|
||||
</div>
|
||||
<div className="col-md-10">
|
||||
{(options[option.value].value || []).map((pair, index) => (
|
||||
<div className="input-group" key={index}>
|
||||
<input
|
||||
type="text"
|
||||
value={pair[0]}
|
||||
className="form-control"
|
||||
placeholder="key"
|
||||
autoComplete="off"
|
||||
onChange={(e) => keyValuePairValueChanged(e, 0, option.value, index)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={pair[1]}
|
||||
className="form-control"
|
||||
placeholder="value"
|
||||
autoComplete="off"
|
||||
onChange={(e) => keyValuePairValueChanged(e, 1, option.value, index)}
|
||||
/>
|
||||
<span
|
||||
className="input-group-text"
|
||||
role="button"
|
||||
onClick={() => {
|
||||
removeKeyValuePair(option.value, index);
|
||||
}}
|
||||
>x</span>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-sm btn-outline-azure" onClick={() => addNewKeyValuePair(option.value)}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="row mt-3">
|
||||
<div className="col"></div>
|
||||
<div className="col-auto">
|
||||
<Button className="m-2" disabled={isSaving} variant="primary" onClick={createDataSource}>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className={inCanvas ? '' : 'col-md-6 text-center align-items-center'}>
|
||||
<div className={inCanvas ? '' : 'col-md-6 text-center align-items-center clearfix mb-3'}>
|
||||
{inCanvas ? (
|
||||
<div
|
||||
style={getStyles(left, top, isDragging, component)}
|
||||
className="draggable-box"
|
||||
<div
|
||||
style={getStyles(left, top, isDragging, component)}
|
||||
style={{ height: '100%' }}
|
||||
className="draggable-box "
|
||||
onMouseOver={() => setMouseOver(true)}
|
||||
onMouseLeave={() => setMouseOver(false)}
|
||||
>
|
||||
|
||||
<Rnd
|
||||
style={{ ...style }}
|
||||
size={{ width: scaleWidth(currentLayoutOptions.width, scaleValue) + 6, height: currentLayoutOptions.height + 6}}
|
||||
position={{ x: currentLayoutOptions ? currentLayoutOptions.left : 0, y: currentLayoutOptions ? currentLayoutOptions.top : 0 }}
|
||||
size={{
|
||||
width: scaleWidth(currentLayoutOptions.width, scaleValue) + 6,
|
||||
height: currentLayoutOptions.height + 6,
|
||||
}}
|
||||
position={{
|
||||
x: currentLayoutOptions ? currentLayoutOptions.left : 0,
|
||||
y: currentLayoutOptions ? currentLayoutOptions.top : 0,
|
||||
}}
|
||||
defaultSize={{}}
|
||||
className={`resizer ${isSelectedComponent && !mouseOver ? 'resizer-selected' : ''} ${mouseOver ? 'resizer-active' : ''} `}
|
||||
className={`resizer ${isSelectedComponent && !mouseOver ? 'resizer-selected' : ''} ${
|
||||
mouseOver ? 'resizer-active' : ''
|
||||
} `}
|
||||
onResize={() => setResizing(true)}
|
||||
resizeHandleClasses={mouseOver ? resizerClasses : {}}
|
||||
resizeHandleStyles={resizerStyles}
|
||||
|
|
@ -181,15 +206,15 @@ export const DraggableBox = function DraggableBox({
|
|||
}}
|
||||
>
|
||||
<div ref={preview} role="DraggableBox" style={isResizing ? { opacity: 0.5 } : { opacity: 1 }}>
|
||||
{mode === 'edit' && mouseOver &&
|
||||
<ConfigHandle
|
||||
id={id}
|
||||
removeComponent={removeComponent}
|
||||
dragRef={refProps.ref}
|
||||
component={component}
|
||||
configHandleClicked={(id, component) => configHandleClicked(id, component)}
|
||||
/>
|
||||
}
|
||||
{mode === 'edit' && mouseOver && (
|
||||
<ConfigHandle
|
||||
id={id}
|
||||
removeComponent={removeComponent}
|
||||
dragRef={refProps.ref}
|
||||
component={component}
|
||||
configHandleClicked={(id, component) => configHandleClicked(id, component)}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
component={component}
|
||||
id={id}
|
||||
|
|
@ -210,7 +235,7 @@ export const DraggableBox = function DraggableBox({
|
|||
</Rnd>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={drag} role="DraggableBox" className="draggable-box">
|
||||
<div ref={drag} role="DraggableBox" className="draggable-box" style={{ height: '100%' }}>
|
||||
<Box
|
||||
component={component}
|
||||
id={id}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import React from 'react';
|
||||
import { datasourceService, dataqueryService, appService, authenticationService } from '@/_services';
|
||||
import {
|
||||
datasourceService, dataqueryService, appService, authenticationService
|
||||
} from '@/_services';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { Container } from './Container';
|
||||
|
|
@ -22,12 +24,13 @@ import {
|
|||
onQueryConfirm,
|
||||
onQueryCancel,
|
||||
runQuery,
|
||||
setStateAsync,
|
||||
setStateAsync
|
||||
} from '@/_helpers/appUtils';
|
||||
import { Confirm } from './Viewer/Confirm';
|
||||
import ReactTooltip from 'react-tooltip';
|
||||
import { Resizable } from 're-resizable';
|
||||
import { WidgetManager } from './WidgetManager';
|
||||
import Fuse from 'fuse.js';
|
||||
|
||||
class Editor extends React.Component {
|
||||
constructor(props) {
|
||||
|
|
@ -42,7 +45,7 @@ class Editor extends React.Component {
|
|||
userVars = {
|
||||
email: currentUser.email,
|
||||
firstName: currentUser.first_name,
|
||||
lastName: currentUser.last_name,
|
||||
lastName: currentUser.last_name
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -62,61 +65,59 @@ class Editor extends React.Component {
|
|||
scaleValue: 1,
|
||||
deviceWindowWidth: 450,
|
||||
appDefinition: {
|
||||
components: null,
|
||||
components: null
|
||||
},
|
||||
currentState: {
|
||||
queries: {},
|
||||
components: {},
|
||||
globals: {
|
||||
currentUser: userVars,
|
||||
urlparams: {},
|
||||
},
|
||||
urlparams: {}
|
||||
}
|
||||
},
|
||||
dataQueriesDefaultText: 'You haven\'t created queries yet.',
|
||||
showQuerySearchField: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const appId = this.props.match.params.id;
|
||||
|
||||
appService.getApp(appId).then((data) =>
|
||||
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 && (
|
||||
<div className="px-2">
|
||||
<div class="text-center spinner-border spinner-border-sm" role="status"></div>
|
||||
<div className="text-center spinner-border spinner-border-sm" role="status"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -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 (
|
||||
<div className="editor wrapper">
|
||||
|
|
@ -508,10 +532,10 @@ class Editor extends React.Component {
|
|||
</button>
|
||||
</div>
|
||||
<div className="layout-buttons">
|
||||
<div class="btn-group" role="group" aria-label="Basic example">
|
||||
<div className="btn-group" role="group" aria-label="Basic example">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-light"
|
||||
className="btn btn-light"
|
||||
onClick={() => this.setState({ currentLayout: 'desktop' })}
|
||||
disabled={currentLayout === 'desktop'}
|
||||
>
|
||||
|
|
@ -519,7 +543,7 @@ class Editor extends React.Component {
|
|||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-light"
|
||||
className="btn btn-light"
|
||||
onClick={() => this.setState({ currentLayout: 'mobile' })}
|
||||
disabled={currentLayout === 'mobile'}
|
||||
>
|
||||
|
|
@ -528,7 +552,13 @@ class Editor extends React.Component {
|
|||
</div>
|
||||
</div>
|
||||
<div className="navbar-nav flex-row order-md-last">
|
||||
<div className="nav-item dropdown d-none d-md-flex me-3">{app && <ManageAppUsers app={app} />}</div>
|
||||
<div className="nav-item dropdown d-none d-md-flex me-3">
|
||||
{app
|
||||
&& <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">
|
||||
Launch
|
||||
|
|
@ -536,7 +566,7 @@ class Editor extends React.Component {
|
|||
</div>
|
||||
<div className="nav-item dropdown me-2">
|
||||
{this.state.app && (
|
||||
<SaveAndPreview appId={appId} appName={app.name} appDefinition={appDefinition} app={app} />
|
||||
<SaveAndPreview appId={app.id} appName={app.name} appDefinition={appDefinition} app={app} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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%'
|
||||
}}
|
||||
>
|
||||
<div className="left-sidebar">
|
||||
|
|
@ -650,8 +680,7 @@ class Editor extends React.Component {
|
|||
You haven't added data sources yet. <br />
|
||||
<button
|
||||
className="btn btn-sm btn-outline-azure mt-3"
|
||||
onClick={() =>
|
||||
this.setState({ showDataSourceManagerModal: true, selectedDataSource: null })
|
||||
onClick={() => this.setState({ showDataSourceManagerModal: true, selectedDataSource: null })
|
||||
}
|
||||
>
|
||||
add datasource
|
||||
|
|
@ -679,11 +708,9 @@ class Editor extends React.Component {
|
|||
scaleValue={scaleValue}
|
||||
appLoading={isLoading}
|
||||
onEvent={(eventName, options) => onEvent(this, eventName, options)}
|
||||
onComponentOptionChanged={(component, optionName, value) =>
|
||||
onComponentOptionChanged(this, component, optionName, value)
|
||||
onComponentOptionChanged={(component, optionName, value) => onComponentOptionChanged(this, component, optionName, value)
|
||||
}
|
||||
onComponentOptionsChanged={(component, options) =>
|
||||
onComponentOptionsChanged(this, component, options)
|
||||
onComponentOptionsChanged={(component, options) => onComponentOptionsChanged(this, component, options)
|
||||
}
|
||||
currentState={this.state.currentState}
|
||||
configHandleClicked={this.configHandleClicked}
|
||||
|
|
@ -702,7 +729,7 @@ class Editor extends React.Component {
|
|||
style={{
|
||||
height: showQueryEditor ? this.state.queryPaneHeight : '0px',
|
||||
width: !showLeftSidebar ? '85%' : '',
|
||||
left: !showLeftSidebar ? '0' : '',
|
||||
left: !showLeftSidebar ? '0' : ''
|
||||
}}
|
||||
>
|
||||
<div className="row main-row">
|
||||
|
|
@ -713,9 +740,9 @@ class Editor extends React.Component {
|
|||
<h5 className="py-1 px-3 text-muted">QUERIES</h5>
|
||||
</div>
|
||||
<div className="col-auto px-3">
|
||||
{/* {<button className="btn btn-sm btn-light mx-2">
|
||||
<img className="p-1" src="/search.svg" width="17" height="17"/>
|
||||
</button>} */}
|
||||
<button className="btn btn-sm btn-light mx-2" onClick={this.toggleQuerySearch}>
|
||||
<img className="py-1" src="/assets/images/icons/lens.svg" width="17" height="17"/>
|
||||
</button>
|
||||
|
||||
<span
|
||||
data-tip="Add new query"
|
||||
|
|
@ -727,6 +754,22 @@ class Editor extends React.Component {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{showQuerySearchField
|
||||
&& <div className="row mt-2 pt-1 px-2">
|
||||
<div className="col-12">
|
||||
<div className="queries-search">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control mb-2"
|
||||
placeholder="Search…"
|
||||
autoFocus
|
||||
onChange={(e) => this.filterQueries(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
{loadingDataQueries ? (
|
||||
<div className="p-5">
|
||||
<center>
|
||||
|
|
@ -739,11 +782,10 @@ class Editor extends React.Component {
|
|||
{dataQueries.length === 0 && (
|
||||
<div className="mt-5">
|
||||
<center>
|
||||
<span className="text-muted">You haven't created queries yet.</span> <br />
|
||||
<span className="text-muted">{dataQueriesDefaultText}</span> <br />
|
||||
<button
|
||||
className="btn btn-sm btn-outline-azure mt-3"
|
||||
onClick={() =>
|
||||
this.setState({ selectedQuery: {}, editingQuery: false, addingQuery: true })
|
||||
onClick={() => this.setState({ selectedQuery: {}, editingQuery: false, addingQuery: true })
|
||||
}
|
||||
>
|
||||
create query
|
||||
|
|
@ -755,7 +797,7 @@ class Editor extends React.Component {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-9">
|
||||
<div className="col-md-9 query-definition-pane-wrapper">
|
||||
{!loadingDataSources && (
|
||||
<div className="query-definition-pane">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div>
|
||||
<button className="btn btn-sm" onClick={() => this.setState({ showModal: true })}>
|
||||
{' '}
|
||||
Share
|
||||
</button>
|
||||
|
||||
<Modal show={this.state.showModal} size="lg" backdrop="static" centered={true} keyboard={true} onEscapeKeyDown={this.hideModal}>
|
||||
<Modal show={this.state.showModal} size="lg" backdrop="static" centered={true} keyboard={true} onEscapeKeyDown={this.hideModal} className="app-sharing-modal">
|
||||
<Modal.Header>
|
||||
<Modal.Title>Users and permissions</Modal.Title>
|
||||
<div>
|
||||
|
|
@ -144,7 +164,12 @@ class ManageAppUsers extends React.Component {
|
|||
<small>Get shareable link for this application</small>
|
||||
</label>
|
||||
<div className="input-group">
|
||||
<input type="text" className="form-control form-control-sm" value={shareableLink} />
|
||||
<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} />
|
||||
<span className="input-group-text">
|
||||
<CopyToClipboard
|
||||
text={shareableLink}
|
||||
|
|
@ -157,6 +182,7 @@ class ManageAppUsers extends React.Component {
|
|||
<button className="btn btn-light btn-sm">Copy</button>
|
||||
</CopyToClipboard>
|
||||
</span>
|
||||
<div className="invalid-feedback">{slugError}</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
|
|
@ -243,8 +269,8 @@ class ManageAppUsers extends React.Component {
|
|||
</Modal.Footer>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { ManageAppUsers };
|
||||
|
|
|
|||
41
frontend/src/Editor/QueryManager/QueryEditors/Graphql.jsx
Normal file
41
frontend/src/Editor/QueryManager/QueryEditors/Graphql.jsx
Normal file
|
|
@ -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 (
|
||||
<div>
|
||||
{options && (
|
||||
<div className="mb-3 mt-2">
|
||||
<CodeHinter
|
||||
currentState={this.props.currentState}
|
||||
initialValue={options.query}
|
||||
mode="sql"
|
||||
theme="duotone-light"
|
||||
lineNumbers={true}
|
||||
className="query-hinter"
|
||||
onChange={(value) => changeOption(this, 'query', value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { Graphql };
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export const defaultOptions = {
|
|||
query: 'PING'
|
||||
},
|
||||
mysql: {},
|
||||
graphql: {},
|
||||
firestore: {
|
||||
path: ''
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
<div className="sub-section">
|
||||
<div className="main">
|
||||
<div className="canvas-container align-items-center">
|
||||
<div className="canvas-area" style={{width: currentLayout === 'desktop' ? '1292px' : `${deviceWindowWidth}px`}}>
|
||||
<div className="canvas-area" style={{ width: currentLayout === 'desktop' ? '1292px' : `${deviceWindowWidth}px` }}>
|
||||
<Container
|
||||
appDefinition={appDefinition}
|
||||
appDefinitionChanged={() => false} // function not relevant in viewer
|
||||
|
|
|
|||
|
|
@ -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 <DraggableBox key={index} index={index} component={component} zoomLevel={zoomLevel} currentLayout={currentLayout} />;
|
||||
};
|
||||
|
||||
return <div className="components-container m-2">
|
||||
<div className="input-icon">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control mb-2"
|
||||
placeholder="Search…"
|
||||
onChange={(e) => filterComponents(e.target.value)}
|
||||
function renderComponentCard(component, index) {
|
||||
return (
|
||||
<DraggableBox
|
||||
key={index}
|
||||
index={index}
|
||||
component={component}
|
||||
zoomLevel={zoomLevel}
|
||||
currentLayout={currentLayout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="components-container m-2">
|
||||
<div className="input-icon">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control mb-2"
|
||||
placeholder="Search…"
|
||||
onChange={(e) => filterComponents(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="widgets-list col-sm-12 col-lg-12 row">
|
||||
{filteredComponents.map((component, i) => renderComponentCard(component, i))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sm-12 col-lg-12 row">
|
||||
{filteredComponents.map((component, i) => renderComponentCard(component, i))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ class HomePage extends React.Component {
|
|||
</OverlayTrigger>
|
||||
</Link>
|
||||
<Link
|
||||
to={`/applications/${app.id}`}
|
||||
to={`/applications/${app.slug}`}
|
||||
target="_blank"
|
||||
>
|
||||
<OverlayTrigger
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class LoginPage extends React.Component {
|
|||
this.setState({ isLoading: false });
|
||||
},
|
||||
() => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
|
|
@ -82,12 +83,13 @@ class LoginPage extends React.Component {
|
|||
className="form-control"
|
||||
placeholder="Password"
|
||||
autoComplete="off"
|
||||
data-testid="passwordField"
|
||||
/>
|
||||
<span className="input-group-text"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-footer">
|
||||
<button className={`btn btn-primary w-100 ${isLoading ? 'btn-loading' : ''}`} onClick={this.authUser}>
|
||||
<button data-testid="loginButton" className={`btn btn-primary w-100 ${isLoading ? 'btn-loading' : ''}`} onClick={this.authUser}>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,3 +4,8 @@
|
|||
|
||||
[template.environment]
|
||||
NODE_ENV = "production"
|
||||
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/"
|
||||
status = 200
|
||||
Loading…
Reference in a new issue