Merge branch 'release/v0.5.11' into main

This commit is contained in:
navaneeth 2021-06-23 15:45:40 +05:30
commit e71d14c61e
52 changed files with 1314 additions and 333 deletions

View file

@ -39,6 +39,7 @@ gem "mongo", "~> 2"
gem 'aws-sdk', '~> 3'
gem 'kaminari'
gem 'lockbox'
gem 'graphlient'
gem 'tiny_tds'
group :development, :test do

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
View file

@ -0,0 +1,6 @@
{
"baseUrl": "http://localhost:8082",
"env": {
"apiUrl": "http://localhost:3000"
}
}

View 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
View 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
}

View 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
View 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')

View 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
View file

@ -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
View 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
View 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
View 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
View 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")}"

View 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

View 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"
}
}

View file

@ -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"]

View file

@ -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

View 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)
:::

View 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`.

View file

@ -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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View 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

View file

@ -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>

View file

@ -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>
)}

View file

@ -35,7 +35,9 @@ export const Text = function Text({
const computedStyles = {
color,
width,
height
height,
display: 'flex',
alignItems: 'center'
};
return (

View file

@ -460,8 +460,8 @@ export const componentTypes = [
loadingState: { type: 'code', displayName: 'Show loading state' }
},
defaultSize: {
width: 210,
height: 24
width: 200,
height: 30
},
events: [

View file

@ -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',

View file

@ -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' }
},

View file

@ -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>
);
};

View file

@ -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
};

View file

@ -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}

View file

@ -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&apos;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&apos;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>

View file

@ -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 };

View 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 };

View file

@ -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
};

View file

@ -4,6 +4,7 @@ export const defaultOptions = {
query: 'PING'
},
mysql: {},
graphql: {},
firestore: {
path: ''
},

View file

@ -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

View file

@ -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>
}
);
};

View file

@ -172,7 +172,7 @@ class HomePage extends React.Component {
</OverlayTrigger>
</Link>
<Link
to={`/applications/${app.id}`}
to={`/applications/${app.slug}`}
target="_blank"
>
<OverlayTrigger

View file

@ -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>

View file

@ -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);
}

View file

@ -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;
}

View file

@ -4,3 +4,8 @@
[template.environment]
NODE_ENV = "production"
[[redirects]]
from = "/*"
to = "/"
status = 200