From dceb4a157299f7972a2667f053b92b2e716d7f9e Mon Sep 17 00:00:00 2001 From: Jason Yee <446031+jwsy@users.noreply.github.com> Date: Thu, 17 Jun 2021 23:44:37 -0400 Subject: [PATCH 01/16] Minor typo (#273) --- docs/docs/intro.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/intro.md b/docs/docs/intro.md index fd01db992b..2539a8d3d1 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -26,7 +26,7 @@ These resources will help you to quickly build and deploy apps using ToolJet: - **[Setup](/docs/setup/architecture)** - Learn how to setup ToolJet locally using docker. - **[Basic Tutorial](/docs/tutorial/creating-app)** - Learn how to build simple UI and connect to data sources. -- **[Deploy](/docs/contributing-guide/setup/docker)** - Learn how to deploy TooLjet on Heroku, Kubernetes, etc +- **[Deploy](/docs/contributing-guide/setup/docker)** - Learn how to deploy ToolJet on Heroku, Kubernetes, etc The references for datasources and widgets: From 13d638784d8692fb3658ff5a4f7338e85c3f7eb8 Mon Sep 17 00:00:00 2001 From: Prasad Walvekar Date: Fri, 18 Jun 2021 11:03:13 +0530 Subject: [PATCH 02/16] BugFix: CSS changes to make Queries div scrollable (#272) * CSS changes to make Queries div scrollable * Hide scrollbars using css --- frontend/src/Editor/Editor.jsx | 2 +- frontend/src/_styles/theme.scss | 38 +++++++++++++++++++++++++++------ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/frontend/src/Editor/Editor.jsx b/frontend/src/Editor/Editor.jsx index 03e1ac74d3..949ce9558b 100644 --- a/frontend/src/Editor/Editor.jsx +++ b/frontend/src/Editor/Editor.jsx @@ -755,7 +755,7 @@ class Editor extends React.Component { )} -
+
{!loadingDataSources && (
diff --git a/frontend/src/_styles/theme.scss b/frontend/src/_styles/theme.scss index 5544023699..9d9f21ea2f 100644 --- a/frontend/src/_styles/theme.scss +++ b/frontend/src/_styles/theme.scss @@ -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,15 +377,24 @@ 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; @@ -376,7 +402,7 @@ body { } .query-list { - overflow-y: scroll; + // padding-top: 40px; } .query-list::-webkit-scrollbar { From cad763fc27facbe2017e04afba93db5678442d85 Mon Sep 17 00:00:00 2001 From: Viraj Bahulkar Date: Fri, 18 Jun 2021 17:53:02 +0530 Subject: [PATCH 03/16] Fix sidebar widgets spacing issue (#277) --- frontend/src/Editor/Box.jsx | 35 +++++---- frontend/src/Editor/DraggableBox.jsx | 107 ++++++++++++++++---------- frontend/src/Editor/WidgetManager.jsx | 67 ++++++++-------- 3 files changed, 120 insertions(+), 89 deletions(-) diff --git a/frontend/src/Editor/Box.jsx b/frontend/src/Editor/Box.jsx index 000da8aa72..f33061fa2d 100644 --- a/frontend/src/Editor/Box.jsx +++ b/frontend/src/Editor/Box.jsx @@ -32,7 +32,7 @@ const AllComponents = { Multiselect, Modal, Chart, - Map + Map, }; export const Box = function Box({ @@ -51,17 +51,17 @@ export const Box = function Box({ onComponentOptionsChanged, paramUpdated, changeCanDrag, - containerProps + containerProps, }) { const backgroundColor = yellow ? 'yellow' : ''; let styles = { - + height: '100%', }; if (inCanvas) { styles = { - ...styles + ...styles, }; } @@ -85,22 +85,23 @@ export const Box = function Box({ containerProps={containerProps} > ) : ( -
-
+
+
-
-
+
{component.displayName} -
)} diff --git a/frontend/src/Editor/DraggableBox.jsx b/frontend/src/Editor/DraggableBox.jsx index 749d15adc8..fcb51303db 100644 --- a/frontend/src/Editor/DraggableBox.jsx +++ b/frontend/src/Editor/DraggableBox.jsx @@ -5,28 +5,40 @@ import { getEmptyImage } from 'react-dnd-html5-backend'; import { Box } from './Box'; import { Resizable } from 're-resizable'; import { ConfigHandle } from './ConfigHandle'; -import { Rnd } from "react-rnd"; +import { Rnd } from 'react-rnd'; const resizerClasses = { topRight: 'top-right', bottomRight: 'bottom-right', bottomLeft: 'bottom-left', - topLeft: 'top-left' + topLeft: 'top-left', }; const resizerStyles = { topRight: { - width: '12px', height: '12px', right: '-6px', top: '-6px' + width: '12px', + height: '12px', + right: '-6px', + top: '-6px', }, bottomRight: { - width: '12px', height: '12px', right: '-6px', bottom: '-6px' + width: '12px', + height: '12px', + right: '-6px', + bottom: '-6px', }, bottomLeft: { - width: '12px', height: '12px', left: '-6px', bottom: '-6px' + width: '12px', + height: '12px', + left: '-6px', + bottom: '-6px', }, topLeft: { - width: '12px', height: '12px', left: '-6px', top: '-6px' - } + width: '12px', + height: '12px', + left: '-6px', + top: '-6px', + }, }; function getStyles(left, top, isDragging, component) { @@ -39,7 +51,7 @@ function getStyles(left, top, isDragging, component) { // IE fallback: hide the real node using CSS when dragging // because IE will ignore our custom "empty image" drag preview. opacity: isDragging ? 0 : 1, - height: isDragging ? 0 : '' + height: isDragging ? 0 : '', }; } @@ -71,7 +83,7 @@ export const DraggableBox = function DraggableBox({ layouts, scaleValue, deviceWindowWidth, - isSelectedComponent + isSelectedComponent, }) { const [isResizing, setResizing] = useState(false); const [canDrag, setCanDrag] = useState(true); @@ -81,11 +93,17 @@ export const DraggableBox = function DraggableBox({ () => ({ type: ItemTypes.BOX, item: { - id, title, component, zoomLevel, parent, layouts, currentLayout + id, + title, + component, + zoomLevel, + parent, + layouts, + currentLayout, }, collect: (monitor) => ({ - isDragging: monitor.isDragging() - }) + isDragging: monitor.isDragging(), + }), }), [id, title, component, index, zoomLevel, parent, layouts, currentLayout] ); @@ -104,14 +122,14 @@ export const DraggableBox = function DraggableBox({ display: 'inline-block', alignItems: 'center', justifyContent: 'center', - padding: '2px' + padding: '2px', }; let refProps = {}; if (mode === 'edit' && canDrag) { refProps = { - ref: drag + ref: drag, }; } @@ -123,53 +141,60 @@ export const DraggableBox = function DraggableBox({ top: 100, left: 0, width: 445, - height: 500 - } + height: 500, + }; const layoutData = inCanvas ? layouts[currentLayout] || defaultData : defaultData; const [currentLayoutOptions, setCurrentLayoutOptions] = useState(layoutData); useEffect(() => { - console.log(layoutData) + console.log(layoutData); setCurrentLayoutOptions(layoutData); }, [layoutData.height, layoutData.width, layoutData.left, layoutData.top, currentLayout]); - function scaleWidth(width, scaleValue) { + function scaleWidth(width, scaleValue) { let newWidth = width * scaleValue; - if(currentLayout === 'desktop') return newWidth; + if (currentLayout === 'desktop') return newWidth; - const diff = currentLayoutOptions.left + newWidth - deviceWindowWidth; + const diff = currentLayoutOptions.left + newWidth - deviceWindowWidth; - if(diff > 0 ) { + if (diff > 0) { setCurrentLayoutOptions({ ...currentLayoutOptions, - left: currentLayoutOptions.left - diff + left: currentLayoutOptions.left - diff, }); return width; } return newWidth; - } return ( -
+
{inCanvas ? ( -
setMouseOver(true)} onMouseLeave={() => setMouseOver(false)} > - setResizing(true)} resizeHandleClasses={mouseOver ? resizerClasses : {}} resizeHandleStyles={resizerStyles} @@ -181,15 +206,15 @@ export const DraggableBox = function DraggableBox({ }} >
- {mode === 'edit' && mouseOver && - configHandleClicked(id, component)} - /> - } + {mode === 'edit' && mouseOver && ( + configHandleClicked(id, component)} + /> + )}
) : ( -
+
result.item)) - } else { - setFilteredComponents(componentTypes); - } + function filterComponents(value) { + if (value != '') { + const fuse = new Fuse(filteredComponents, { keys: ['component'] }); + const results = fuse.search(value); + setFilteredComponents(results.map((result) => result.item)); + } else { + setFilteredComponents(componentTypes); } + } - function renderComponentCard(component, index) { - return ; - }; - - return
-
- filterComponents(e.target.value)} + function renderComponentCard(component, index) { + return ( + + ); + } + + return ( +
+
+ filterComponents(e.target.value)} + /> +
+
+ {filteredComponents.map((component, i) => renderComponentCard(component, i))} +
-
- {filteredComponents.map((component, i) => renderComponentCard(component, i))} -
-
-} + ); +}; From d1beda83629547e3a384eb96d3eb2334b58cf4c2 Mon Sep 17 00:00:00 2001 From: navaneeth Date: Fri, 18 Jun 2021 18:43:26 +0530 Subject: [PATCH 04/16] Minor UI fixes --- frontend/src/Editor/DraggableBox.jsx | 2 +- frontend/src/_styles/theme.scss | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/Editor/DraggableBox.jsx b/frontend/src/Editor/DraggableBox.jsx index fcb51303db..5274984260 100644 --- a/frontend/src/Editor/DraggableBox.jsx +++ b/frontend/src/Editor/DraggableBox.jsx @@ -172,7 +172,7 @@ export const DraggableBox = function DraggableBox({ } return ( -
+
{inCanvas ? (
Date: Fri, 18 Jun 2021 23:24:38 +0530 Subject: [PATCH 05/16] Feature: Ability to search queries (#278) --- frontend/src/Editor/Editor.jsx | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/frontend/src/Editor/Editor.jsx b/frontend/src/Editor/Editor.jsx index 949ce9558b..2417ef3cce 100644 --- a/frontend/src/Editor/Editor.jsx +++ b/frontend/src/Editor/Editor.jsx @@ -28,6 +28,7 @@ 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) { @@ -72,6 +73,7 @@ class Editor extends React.Component { urlparams: {}, }, }, + dataQueriesDefaultText: 'You haven\'t created queries yet.' }; } @@ -399,6 +401,19 @@ 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(); + } + } + render() { const { currentSidebarTab, @@ -423,6 +438,7 @@ class Editor extends React.Component { currentLayout, deviceWindowWidth, scaleValue, + dataQueriesDefaultText } = this.state; const appLink = `/applications/${appId}`; @@ -727,6 +743,19 @@ class Editor extends React.Component {
+
+
+
+ this.filterQueries(e.target.value)} + /> +
+
+
+ {loadingDataQueries ? (
@@ -739,7 +768,7 @@ class Editor extends React.Component { {dataQueries.length === 0 && (
- You haven't created queries yet.
+ {dataQueriesDefaultText}
- {/* {} */} +
-
-
-
- this.filterQueries(e.target.value)} - /> + {showQuerySearchField && +
+
+
+ this.filterQueries(e.target.value)} + /> +
-
+ } {loadingDataQueries ? (
diff --git a/frontend/src/Editor/WidgetManager.jsx b/frontend/src/Editor/WidgetManager.jsx index bf198d691f..379cad0015 100644 --- a/frontend/src/Editor/WidgetManager.jsx +++ b/frontend/src/Editor/WidgetManager.jsx @@ -38,7 +38,7 @@ export const WidgetManager = function WidgetManager({ componentTypes, zoomLevel, onChange={(e) => filterComponents(e.target.value)} />
-
+
{filteredComponents.map((component, i) => renderComponentCard(component, i))}
From 12db1b9e4c40bffc575b277b0b4eedc72c63e316 Mon Sep 17 00:00:00 2001 From: Ashish Date: Tue, 22 Jun 2021 10:52:21 +0530 Subject: [PATCH 07/16] [Docs] AWS EC2 deployment documentation (#282) Co-authored-by: navaneeth --- docs/docs/deployment/ec2.md | 67 +++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 docs/docs/deployment/ec2.md diff --git a/docs/docs/deployment/ec2.md b/docs/docs/deployment/ec2.md new file mode 100644 index 0000000000..efc24f2506 --- /dev/null +++ b/docs/docs/deployment/ec2.md @@ -0,0 +1,67 @@ +--- +sidebar_position: 4 +--- + +# AWS EC2 + +:::info +You should setup a PostgreSQL database manually to be used by the ToolJet server. +::: + +Follow the steps below to deploy ToolJet on AWS EC2 instances. + +1. Setup a PostgreSQL database and make sure that the database is accessible from the EC2 instance. + +2. Login to your AWS management console and go to the EC2 management page. + +3. Under the `Images` section, click on the `AMIs` button. + +4. Now, from the AMI search page, select the search type as "Public Images" and input `AMI Name : tooljet_latest_ubuntu_bionic` in the search bar. + +5. Select ToolJet's AMI and bootup an EC2 instance. + + Creating a new security group is recommended. For example, if the installation should receive traffic from the internet, the inbound rules of the security group should look like this: + + protocol| port | allowed_cidr| + ----| ----------- | ----------- | + tcp | 22 | your IP | + tcp | 80 | 0.0.0.0/0 | + tcp | 443 | 0.0.0.0/0 | + + +6. Once the instance boots up, SSH into the instance by running `ssh -i ubuntu@` + +7. Switch to the app directory by running `cd ~/app`. Modify the contents of the `.env` file. ( Eg: `vim .env` ) + + The default `.env` file looks like this: + ``` + TOOLJET_HOST=http:// + LOCKBOX_MASTER_KEY= + SECRET_KEY_BASE= + PG_DB=tooljet_prod + PG_USER= + PG_HOST= + PG_PASS= + ``` + Read [environment variables reference](/docs/deployment/env-vars) + +8. `TOOLJET_HOST` environment variable determines where you can access the ToolJet client. It can either be the public ipv4 address of your instance or a custom domain that you want to use. + + Examples: + `TOOLJET_HOST=http://12.34.56.78` or + `TOOLJET_HOST=https://yourdomain.com` or + `TOOLJET_HOST=https://tooljet.yourdomain.com` + + :::info + We use a [lets encrypt](https://letsencrypt.org/) plugin on top of nginx to create TLS certificates on the fly. + ::: + + :::info + Please make sure that `TOOLJET_HOST` starts with either `http://` or `https://` + ::: + +9. Once you've configured the `.env` file, run `./setup_app.rb`. This script will install all the dependencies of ToolJet and then will start the required services. + +10. If you've set a custom domain for `TOOLJET_HOST`, add a `A record` entry in your DNS settings to point to the IP address of the EC2 instance. + +12. You're all done, ToolJet client would now be served at the value you've set in `TOOLJET_HOST`. From 7881b192000ed6c4b1d404fa8b9a10ea71763437 Mon Sep 17 00:00:00 2001 From: Ashish Date: Tue, 22 Jun 2021 11:06:09 +0530 Subject: [PATCH 08/16] =?UTF-8?q?Feature:=20AWS=20EC2=20deployment=20?= =?UTF-8?q?=F0=9F=8E=89=20=20(#284)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/ec2/.env | 7 ++ deploy/ec2/nginx.conf | 114 ++++++++++++++++++++++ deploy/ec2/puma.service | 18 ++++ deploy/ec2/setup_app | 56 +++++++++++ deploy/ec2/setup_machine.sh | 45 +++++++++ deploy/ec2/tool_jet_ubuntu_bionic.pkr.hcl | 55 +++++++++++ 6 files changed, 295 insertions(+) create mode 100644 deploy/ec2/.env create mode 100644 deploy/ec2/nginx.conf create mode 100644 deploy/ec2/puma.service create mode 100755 deploy/ec2/setup_app create mode 100644 deploy/ec2/setup_machine.sh create mode 100644 deploy/ec2/tool_jet_ubuntu_bionic.pkr.hcl diff --git a/deploy/ec2/.env b/deploy/ec2/.env new file mode 100644 index 0000000000..46f6a92945 --- /dev/null +++ b/deploy/ec2/.env @@ -0,0 +1,7 @@ +TOOLJET_HOST=http:// +LOCKBOX_MASTER_KEY= +SECRET_KEY_BASE= +PG_DB=tooljet_prod +PG_USER= +PG_HOST= +PG_PASS= diff --git a/deploy/ec2/nginx.conf b/deploy/ec2/nginx.conf new file mode 100644 index 0000000000..09e6953c21 --- /dev/null +++ b/deploy/ec2/nginx.conf @@ -0,0 +1,114 @@ +user www-data; +worker_processes auto; +pid /usr/local/openresty/nginx/logs/nginx.pid; + +events +{ + worker_connections 1024; +} + +http +{ + include mime.types; + default_type application/octet-stream; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + + keepalive_timeout 65; + + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; + + access_log /var/log/openresty/access.log; + error_log /var/log/openresty/error.log; + + gzip on; + gzip_disable "msie6"; + + lua_shared_dict auto_ssl 1m; + lua_shared_dict auto_ssl_settings 64k; + resolver 8.8.8.8 ipv6=off; + + init_by_lua_block + { + auto_ssl = (require "resty.auto-ssl").new() + auto_ssl:set("allow_domain", function(domain) + return true + end) + auto_ssl:init() + } + + init_worker_by_lua_block + { + auto_ssl:init_worker() + } + + server + { + listen 443 ssl; + ssl_certificate_by_lua_block + { + auto_ssl:ssl_certificate() + } + ssl_certificate /etc/ssl/resty-auto-ssl-fallback.crt; + ssl_certificate_key /etc/ssl/resty-auto-ssl-fallback.key; + + + location / + { + root /home/ubuntu/app/frontend/build; + index index.html; + } + + location /_backend_ + { + rewrite /_backend_/(.*) /$1 break; + proxy_pass http://localhost:3000; + proxy_redirect off; + proxy_set_header Host $host; + } + } + + server + { + listen 80; + location /.well-known/acme-challenge/ + { + content_by_lua_block + { + auto_ssl:challenge_server() + } + } + + location / + { + root /home/ubuntu/app/frontend/build; + index index.html; + } + + location /_backend_ + { + rewrite /_backend_/(.*) /$1 break; + proxy_pass http://localhost:3000; + proxy_redirect off; + proxy_set_header Host $host; + } + + } + + server + { + listen 127.0.0.1:8999; + client_body_buffer_size 128k; + client_max_body_size 128k; + + location / + { + content_by_lua_block + { + auto_ssl:hook_server() + } + } + } +} diff --git a/deploy/ec2/puma.service b/deploy/ec2/puma.service new file mode 100644 index 0000000000..b1633f445a --- /dev/null +++ b/deploy/ec2/puma.service @@ -0,0 +1,18 @@ +[Unit] +Description=Puma HTTP Server +After=network.target + +[Service] +Type=simple +User=ubuntu + +WorkingDirectory=/home/ubuntu/app +Environment="RAILS_ENV=production" +EnvironmentFile=/home/ubuntu/app/.env +RestartSec=1 +PIDFile=/home/ubuntu/app/tmp/pids/server.pid +ExecStart=/home/ubuntu/.rbenv/shims/puma -b tcp://0.0.0.0:3000 +Restart=always + +[Install] +WantedBy=multi-user.target diff --git a/deploy/ec2/setup_app b/deploy/ec2/setup_app new file mode 100755 index 0000000000..bb65144e53 --- /dev/null +++ b/deploy/ec2/setup_app @@ -0,0 +1,56 @@ +#!/usr/bin/env ruby + +def ensure_db_connectivity(user = ENV.fetch("PG_USER"), pass = ENV.fetch("PG_PASS"), host = ENV.fetch("PG_HOST")) + cmd = %{psql -d 'postgresql://#{user}:#{pass}@#{host}' -c 'select now()' > /dev/null 2>&1} + res = system(cmd) + if res + puts "Successfully pinged the database!" + else + puts "Can't connect to the database using the credenials provided in the .env file!" + exit(1) + end +end + +def install_script_deps + system("gem install bundler") + system("gem install dotenv") +end + +def load_env + require 'dotenv' + Dir.chdir "/home/ubuntu/app" + Dotenv.load! + Dotenv.require_keys("TOOLJET_HOST", "LOCKBOX_MASTER_KEY", "SECRET_KEY_BASE", "PG_DB", "PG_USER", "PG_HOST", "PG_PASS") +end + +def install_be_app_deps + system("RAILS_ENV=production bundle install") + system("RAILS_ENV=production bundle exec dotenv rails db:create") + system("RAILS_ENV=production bundle exec dotenv rails db:migrate") + system("RAILS_ENV=production bundle exec dotenv rails db:seed") +end + + +def build_fe + backend_url = "#{ENV.fetch("TOOLJET_HOST")}/_backend_" + front_end_working_dir = "/home/ubuntu/app/frontend" + Dir.chdir front_end_working_dir + system("npm install") + system("NODE_ENV=production TOOLJET_SERVER_URL=#{backend_url} npm run-script build") +end + +def start_services + system("sudo systemctl start openresty") + system("bundle binstubs puma && rbenv rehash && sudo systemctl start puma") +end + + +install_script_deps +load_env +ensure_db_connectivity +install_be_app_deps +build_fe +start_services + + +puts "The app will be served at #{ENV.fetch("TOOLJET_HOST")}" diff --git a/deploy/ec2/setup_machine.sh b/deploy/ec2/setup_machine.sh new file mode 100644 index 0000000000..8da65439f7 --- /dev/null +++ b/deploy/ec2/setup_machine.sh @@ -0,0 +1,45 @@ +sudo apt-get -y install --no-install-recommends wget gnupg ca-certificates apt-utils curl +sudo apt-get -y install git +sudo apt-get install -y postgresql-client +wget -O - https://openresty.org/package/pubkey.gpg | sudo apt-key add - +echo "deb http://openresty.org/package/ubuntu bionic main" > openresty.list +sudo mv openresty.list /etc/apt/sources.list.d/ +sudo apt-get update +sudo apt-get -y install --no-install-recommends openresty +curl -fsSL https://deb.nodesource.com/setup_14.x | sudo -E bash - +sudo apt-get install -y nodejs +sudo apt-get install -y git +sudo apt-get install -y rbenv +rbenv init - >> ~/.bashrc +source ~/.bashrc +mkdir -p "$(rbenv root)"/plugins +git clone https://github.com/rbenv/ruby-build.git "$(rbenv root)"/plugins/ruby-build +sudo apt-get install -y curl g++ gcc autoconf automake bison libc6-dev \ + libffi-dev libgdbm-dev libncurses5-dev libsqlite3-dev libtool \ + libyaml-dev make pkg-config sqlite3 zlib1g-dev libgmp-dev \ + libreadline-dev libssl-dev libmysqlclient-dev build-essential \ + freetds-dev libpq-dev +sudo apt-get install -y luarocks +sudo luarocks install lua-resty-auto-ssl +sudo mkdir /etc/resty-auto-ssl +sudo chown -R www-data:www-data /etc/resty-auto-ssl +# Gen fallback certs +sudo openssl rand -out /home/ubuntu/.rnd -hex 256 +sudo chown www-data:www-data /home/ubuntu/.rnd +sudo openssl req -new -newkey rsa:2048 -days 3650 -nodes -x509 \ + -subj '/CN=sni-support-required-for-valid-ssl' \ + -keyout /etc/ssl/resty-auto-ssl-fallback.key \ + -out /etc/ssl/resty-auto-ssl-fallback.crt + +rbenv install 2.7.3 +rbenv global 2.7.3 +mkdir -p ~/app +ssh-keyscan -H github.com >> ~/.ssh/known_hosts +git clone -b main git@github.com:ToolJet/ToolJet.git ~/app && cd ~/app +echo "2.7.3" > .ruby-version +sudo mkdir /var/log/openresty +sudo cp /tmp/puma.service /lib/systemd/system/puma.service +sudo mv /tmp/nginx.conf /usr/local/openresty/nginx/conf/nginx.conf +mv /tmp/.env ~/app/.env +mv /tmp/setup_app ~/app/setup_app +sudo chmod +x ~/app/setup_app diff --git a/deploy/ec2/tool_jet_ubuntu_bionic.pkr.hcl b/deploy/ec2/tool_jet_ubuntu_bionic.pkr.hcl new file mode 100644 index 0000000000..bc207e27d0 --- /dev/null +++ b/deploy/ec2/tool_jet_ubuntu_bionic.pkr.hcl @@ -0,0 +1,55 @@ +packer { + required_plugins { + amazon = { + version = ">= 0.0.1" + source = "github.com/hashicorp/amazon" + } + } +} + +source "amazon-ebs" "ubuntu" { + ami_name = "tooljet_latest_ubuntu_bionic" + instance_type = "t2.medium" + region = "us-west-2" + source_ami_filter { + filters = { + name = "ubuntu/images/hvm-ssd/ubuntu-bionic-18.04-amd64-server-*" + root-device-type = "ebs" + virtualization-type = "hvm" + } + most_recent = true + owners = ["099720109477"] + } + ssh_username = "ubuntu" +} + + +build { + sources = [ + "source.amazon-ebs.ubuntu" + ] + + provisioner "file"{ + source = "puma.service" + destination = "/tmp/puma.service" + } + + provisioner "file"{ + source = "nginx.conf" + destination = "/tmp/nginx.conf" + } + + provisioner "file"{ + source = ".env" + destination = "/tmp/.env" + } + + provisioner "file"{ + source = "setup_app" + destination = "/tmp/setup_app" + } + + provisioner "shell" { + script = "setup_machine.sh" + } +} From 2eaff6e8b6285eef49f8939887f8b21e69f1b3c9 Mon Sep 17 00:00:00 2001 From: Ashish Date: Tue, 22 Jun 2021 19:47:00 +0530 Subject: [PATCH 09/16] =?UTF-8?q?Feature:=20support=20for=20GraphQL=20data?= =?UTF-8?q?=20sources=20=20=F0=9F=8E=89=20=20(#288)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feature: Add GraphQL data sources * Querying graphql * use graphlient gem to talk to a graphql datasource * use the source headers and params while initializing the graphql-client * remove unnecessary body field from the graphql source addition modal * add documentation for graphql datasource setup Co-authored-by: navaneeth --- Gemfile | 1 + Gemfile.lock | 11 +++ app/services/graphql_query_service.rb | 40 ++++++++ app/services/query_service.rb | 6 +- docs/docs/data-sources/graphql.md | 40 ++++++++ .../datasource-reference/graphql-connect.png | Bin 0 -> 19402 bytes .../datasource-reference/graphql-query.png | Bin 0 -> 32672 bytes .../icons/editor/datasources/graphql.svg | 71 ++++++++++++++ .../DataSourceManager/DataSourceTypes.js | 16 +++ .../DataSourceManager/DefaultOptions.js | 5 + .../SourceComponents/Graphql.jsx | 92 ++++++++++++++++++ .../SourceComponents/index.js | 2 + .../QueryManager/QueryEditors/Graphql.jsx | 41 ++++++++ .../Editor/QueryManager/QueryEditors/index.js | 2 + frontend/src/Editor/QueryManager/constants.js | 1 + 15 files changed, 325 insertions(+), 3 deletions(-) create mode 100644 app/services/graphql_query_service.rb create mode 100644 docs/docs/data-sources/graphql.md create mode 100644 docs/static/img/datasource-reference/graphql-connect.png create mode 100644 docs/static/img/datasource-reference/graphql-query.png create mode 100644 frontend/assets/images/icons/editor/datasources/graphql.svg create mode 100644 frontend/src/Editor/DataSourceManager/SourceComponents/Graphql.jsx create mode 100644 frontend/src/Editor/QueryManager/QueryEditors/Graphql.jsx diff --git a/Gemfile b/Gemfile index 342d65e5e6..bd30a43cf9 100644 --- a/Gemfile +++ b/Gemfile @@ -39,6 +39,7 @@ gem "mongo", "~> 2" gem 'aws-sdk', '~> 3' gem 'kaminari' gem 'lockbox' +gem 'graphlient' gem 'tiny_tds' group :development, :test do diff --git a/Gemfile.lock b/Gemfile.lock index d6a2454679..95e616912c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1173,6 +1173,8 @@ GEM multipart-post (>= 1.2, < 3) ruby2_keywords faraday-net_http (1.0.1) + faraday_middleware (1.0.0) + faraday (~> 1.0) ffi (1.15.0) gapic-common (0.4.0) faraday (~> 1.3) @@ -1211,6 +1213,14 @@ GEM multi_json (~> 1.11) os (>= 0.9, < 2.0) signet (~> 0.14) + graphlient (0.5.0) + faraday (>= 1.0) + faraday_middleware + graphql-client + graphql (1.12.12) + graphql-client (0.16.0) + activesupport (>= 3.0) + graphql (~> 1.8) grpc (1.37.0) google-protobuf (~> 3.15) googleapis-common-protos-types (~> 1.0) @@ -1376,6 +1386,7 @@ DEPENDENCIES dotenv-rails elasticsearch google-cloud-firestore + graphlient httparty jbuilder (~> 2.7) jwt diff --git a/app/services/graphql_query_service.rb b/app/services/graphql_query_service.rb new file mode 100644 index 0000000000..41dd19d9f8 --- /dev/null +++ b/app/services/graphql_query_service.rb @@ -0,0 +1,40 @@ +class GraphqlQueryService + + attr_accessor :data_query, :options, :source_options, :current_user, :data_source + + def initialize(data_query, data_source, options, source_options, current_user) + @data_query = data_query + @options = options + @source_options = source_options + @current_user = current_user + @data_source = data_source + end + + def process + url = source_options['url'] + method = options['method'] || 'GET' + source_headers = (source_options['headers'] || []).reject { |header| header[0].empty? }.to_h + url_params = source_options['url_params'] + encoded_url = url_encoded_with_params(url, url_params) + query = options['query'] + client = Graphlient::Client.new(encoded_url, headers: source_headers) + result = client.query(query) + if result.errors.present? + { code: 422, data: result.errors } + else + { code: 200, data: result.original_hash } + end + end +end + + +def url_encoded_with_params(original_url, url_params) + if url_params.empty? + original_url + else + uri = URI.parse(original_url) + params = URI.decode_www_form(uri.query || '') + url_params + uri.query = URI.encode_www_form(params) + uri.to_s + end +end diff --git a/app/services/query_service.rb b/app/services/query_service.rb index 1e764a690d..74e0f9546f 100644 --- a/app/services/query_service.rb +++ b/app/services/query_service.rb @@ -33,9 +33,9 @@ class QueryService service.process end - private + private def get_query_options(object) - + if object.is_a?(Hash) object.keys.each do |key| @@ -52,7 +52,7 @@ class QueryService variables.each do |variable| object = object.gsub("{{#{variable[0]}}}", options["{{#{variable[0]}}}"].to_s) end - else + else object = object end end diff --git a/docs/docs/data-sources/graphql.md b/docs/docs/data-sources/graphql.md new file mode 100644 index 0000000000..2a06d40b87 --- /dev/null +++ b/docs/docs/data-sources/graphql.md @@ -0,0 +1,40 @@ +--- +sidebar_position: 3 +--- + +# GraphQL + + +ToolJet can connect to GraphQL endpoints. We currently support queries and mutations. + +## Connection + +To add a new GraphQL datasource, click on the '+' button on data sources panel at the left-bottom corner of the app editor. Select GraphQL from the modal that pops up. + +ToolJet requires the following to connect to a GraphQL datasource. + +- **URL** + +Following optional parameters are also supported: + + | Type | Description | + | ----------- | ----------- | + | URL params | Additional query string parameters| + | headers | Any headers the GraphQL source requires| + + + +ToolJet - GraphQL connection + +Click on the 'Save' button to save the datasource. + +## Querying GraphQL +Click on '+' button of the query manager at the bottom panel of the editor and select the GraphQL endpoint added in the previous step as the datasource. + +ToolJet - GraphQL connection + +Click on the 'run' button to run the query. NOTE: Query should be saved before running. + +:::tip +Query results can be transformed using transformations. Read our transformations documentation to see how: [link](/tutorial/transformations) +::: \ No newline at end of file diff --git a/docs/static/img/datasource-reference/graphql-connect.png b/docs/static/img/datasource-reference/graphql-connect.png new file mode 100644 index 0000000000000000000000000000000000000000..73c91fa374b25b79ec955dfb77d7bcb59b582a17 GIT binary patch literal 19402 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ð^Pn@=lW zC@3inj~-0jsmh1EotM~`UGWdS&jO>m-Awtl=!U3L^15Uzwe@K_+#dF%v!PH~=<1LI z=4iQ|bY_ex4p1)~=9{$*7U4%KsX+3?fcNY#;g#-(asnY@Vi4UI*dd{zx`0j^j2b7# z!0P!=bWp=iE|+J!XKgUSlpN>~ZLTnKOg7@HPb?`zW^o=FpP9uO*atoj!3SC zkI)y34L6pf$Ms;;S>LTDc=ZO7RBA$tyVtP=U`1aqs=?Fv?qIX#>tB;Eq;7}*2oeW= zC9?D}4lZ~4Y-eqqbS^-Jt*ulxWhN$L{ldKNpp5>-BzM7scr}Fj&K2W%;LxY7k9@Hmf>gCeYB(Y%g&C z@D`JTEFY|SH$J+OSjwKp#ld+Y|KS620@bTr_tx+K$bu^aE#63>iLPl{ZhnhFDXcc& zEeq@RXjN)s{` zf_6i-rfXSyM?l>Q5Qs%}e=*k0Q<1KS$7YC#h(I%5Z*aVlIV{Us-}5L@l^SgH*TD)Y zS%Y?(6#SjSzp>p2G-RF~&ebD_J1s9?fNCs#64MB%YC8|A9kXNzlY=5z3M6Z2U>K>JGDTNNE)*3#W{g4OE~^T{TdNbx_7 z<6?d`I5WdHYmA~kSDN$2MFu6-*V>-xi1HFKsUY-^-T)bw`AmtCnqf!8oX(|dy|{;y zZEL`>T7#{|lBd3}g@xJHQmfLb#4U;P-KociJ z@~jl@B_krzqFrw%#FG)~U50o^#t$bac?1gu<7;AgwQ&>)BH;r%2HoDp5g81-mfpQk zDjc7T9*t4J%8h0~)twB$ACOS~d^{2nj#F_#;&Q{G3qoxBUBT67=PNeJ&MMS+exNw> zKGt(t>J8-gqVfuR;5rIwFq8P4Pxd>GBDTs;);H5~aulYz{{R8jW$lV=VuP=bUlNzz zuVp7igEy_BGKtP7Z?4Pk(zUJv_a%9S%+`}#IrjvBcI%bEf?ofL21(87O}zRni!D%d zR9upvJ*~T1-Qk^hFszUDb$KTUuE?ARNvWf+X;i z)c^y__F%g$Z*D=`-%c-Rohd$hf)x|DJ^26K1UQpEKub|8&0I=$isCV{Xz0nttAJM6%D%5r zQrUu-%_aCa_xXILgN=Ox;>!pa8=$CMlObb{skEDz{&}*6M#edmdiMk)j^F6`IfZ(u z&F)xX)uW%X$CyuIxeREcx)>c@iY!wviMc%dRMzbOm&ZEozIVd_nv@yAjlts(y21jH+7-WLSA+dCD(5>xy>zr5{(IK+ZME9 zrR4_I2X=qv1y#AgcT^1l_2 z&B*_^;<1b83|R2hQbzAHH!C5##rkGyDwX7r5@Wf&^V|HDz7q>uQ1N~+gg1^bDRtag z?A(>aeSwQ@`1x4KYy39me-Ua|R&FR`Bq&J1r6kT6)GpWWoYsHWy3G38Y9#PX+-yHR zt(%!4-np~jazAvN$GIsfvzWTjxNV48x<)31_wqZ`r5lYdmFVu zDQVVA-C{0gW=?8q4HXSZCfr3HEhu3Ub9-m+!pfS8ohTrM8a@gOLq#s!oKD008mQm0jW^Oc1z_LFu?@HBX`vEepfWNIDZm?epZ@#Y zKSxq(5r#$p`J+?nW*PJZoFVx0-w+x4&y63YHEl!<33J5(?l6aL*?&$|xqjakl? zuv>J`#x^b)R}O_Q9$GTNq#zr$6v%jz8p0n!dUCd10pWQ~2F@6qrG_zy9#RIOBuG%D zAhdGWS(FNfWx#G6ulNIv&4Kqlt&kNc>z%3UZc1-8Dg*0~eBSmy_p^>c-;F`L8F8@W zP4sX%eolaAiui$!18B3i$X9HxdV!IeqLSwNXMp)jmioI;_P_bn4*wiK;XDe!2+`BN zKvny(74it_{mO60d}vfghLe+1MzrgOfDwbPk#SB}t|YjPpeNPyCv-KK;N$+@kEJ)D z{XhN5kbh9U{|2l0|K+=JMa+O<{5GfknecAEbeq}-*;5!8a~G!kYLWVg0>Svln#B4S zsp!RjJ$#S~COy)1jc~>R^?Bws(^H{HRxiZF%(sE8?|;A2hGlStIhN1Q<#j^N_e)Jh z;@$(E8R?wLe(8|D=GonJ*q?_YTcUq&+tYQDMf=}9cqF=%9(mDa87 z5T#tXCvNwdu%U#P%56O9py70vv)qRjWB$C*ZB%>AoPKNc!ItXJSHUzCwv>;gM4rU? zvefpgaHZ~1zNvrpIMpIUOzICO;S2F_PGj%ZmoNVOfXJbhEPRu;NhZIYkl<8NZ>_f5 znnBbhGUJK+XZai%Vbv$uj~yr0tj$z3m{=hpg=_p?nyx|6PUaYB3!;_*$LNd1^jS_-v z!V9XJCB=_CQ#-QbsVF}5oY=Iv+e*H}BJd}=x-95fl_V1&^~rq38>ER9($jTa!y{A^ zlzAy~5=d2_If@+jaJOrtY3h+;Gq%CXsf}JYSfgnwZle=p5#@#XTQh^>qz69j{go`N z(B~oeoL)TYa1<|AGI-ofzj9q_>TJo%aaKL+_K6C&@N&I3v&DpmUhAgt>})}6huT(M zqfu0IaV9TujvdrN&#j5?{>i#lBzXnJ`AW&l>PIT-N2213XE&4h;1kb7gqkP(S#IEi zI3Zt3&k|c=z2gacYzz12hz?Ez)(5=p?7H9jp6cbAu>CyQ#8>g7a&~AMuP|F^E#GX$ zFU6gmA{zioccu4MqnkGjRav7mi0Ou%g4o%Ded{Fcs^GVx1uIG3kL}HjRg<~-&AsFq zeA+o1Hl1e8Ee@zkl@nICi8_4RNyse{R8VL?DsFDawlIcZ$yPx_BuSS9cPXt zUI2S+&f)VytIjy}?k zOcp(WD3XzxOj3F^Z^vXaM>DWKx}b{0HdYeip8_}UE)u@fyPT$I#$HYAQ}hhI`TXvs z*=ZeHaQy^@5QNzCp5b+U zIZ*wK>GLuIzJ@pmuD^Xzm{)5r%X{F~>4bfHOFzBmUv~Z&@8|E=SKoWGC(pgQ`Iw&d;1f&SkA@qbIy@aCDq!S>t zKtc~a)X>R4`Tp*E|Np(~zWeUKWo4~%&YYRqd-lxiJ^Qow`J$nwM0u0(CItlr@(=)ZoERFXsY;6CYc$@ArF?d#XCqi5A;NRJF&Abl?_R~s*|xra4{t&6L( zwScFkhqbkfr=6=8{!)t^1;s;(*T5IrzG>^z-l25+$DP}@YA)adLBuT;@mJuoRU!);t~nlZc&gQ>1dG%Zkt#7Nuj)Gf5dM@6gg3x_n8I5-AZPUP1Z zI15^j@LtN1wV~R>Gh4`XxkRbvSL}2jcjSxluOBqZ%DxP{Br7x3zwX9d@A#k>{N=0{KSCFwyKJRlLV z^9ZSy={Wa$>~G(2<7{@_(=u9~qrK*+&nUBkxOO6a zT!j;sU^c){oxd5qQr03dig*Agb>Bb4`6lSF z+02;WAi}}TF^5X=JNQI`w`a+Yk$=#l4af$b1FY|bSYFA zI%@4~ussZ8)q6~wJM-he$?5ycKvw45O><%7VtNgf65mWXr%ujqS$d$MTAOj+DzmPl z_g${OoG>r&ow~TNZ@AuMsBa^t95zj_>Rq21a$Zzer~w?JVrlf9JaV4!a=cyo6lY6k zC#|}+hyKM$OhD$S>iEMUy1>=SQK4gdJ>5?zfyed>39rLAZVxpc%3Ndv z0k`$a+|7@AO}Gmv*DEN|uW+$7jasEcdXD6FHGSA~Ks8Xyjc=e^%NCy3;0C2j9~4?S z={Gol75(Bnq$|djyR`hT)(qdzZct4f`fycFMz(6F6EALSayQ*eBo@V@FLE-?TAKvR zmQ^IwO1J5a3|ZG=s++N%#Ck4W4)(>Kj3y7iq0}77$SV~b&@Kp^8Yy=GDH%I9K-aI=E)q~0`9scs(j zr4M^BOkiz4nO*5aUYs8Bf@%=gZZyu!4Zkm_%Pggo2)e+sIvO+Cj zo1ymynMS_9U@9{B{?q`QpeF&d7b3;B8xgK6dLsDx0kCK-Ry+$u9X$%GuI`D9`D*-3 zPb?(UW!YA3}UzrAxhsgBPk>NH0GX1Z>kR_f*pP)7ImX@l>$X1r6Q5LIrq=6FlvVG(qdvV<>84nO*CT?-QGzRT3*p=m>s@y;(aF6xDlON%CO|O9L^eCbc~g6V20) zwdk~;?=MN)GmbT%%msXUT`RXgL@$Jq$nw~Z&s=|6oz|vV<*5d4E%iM@ zD~JKA{pZ(l_ZGO-LpG{$W(|+o_rvrsYSoaD692VNF?{}*qf8vO1zdcRUz=(A78W70 zalVd>se~3x?-GP7`@MT6O_1fm%kM|EYuAro&|?qxXmDQ{<+t}m!pBv_0)2Zp>Gv2V z&Wj^i{GD6NTKW2tRZKJe&Lw59sxkYt4|^Vno^C&08ac4tXYwTC>cs%%_lkqgm-w&U zW<!*d+1?#K07q>E6DOB;@TKeWi_QCD0MDhlqyqKyscPa!wj|l}&Hl-n^AU>k z>?+d?Ma@30NDe+ikG)ca?x@gft35lSj_H-Js z8^;zuI-m-MW+DwW4z^44C!ga_I{m?;5dpc_VU8wCCdPADt-gV%JJ9b17OJZLOW=eT&$;`l2QomoprNRMbOR~2>~*)^MezG;YM87@r?4sQ8N;fgJk z3yQnF_$hBgIIhfNZAqKh+&V8ZHx>Z&KutzX2zYC|7}WMXSsz_f+=G1C6!qy44VwZ1 zANd{{xF4ax_u%aN0pfYrZZk2L02B@)@tL03PqyusFM|jTZoW>IYJ^zqwRZ4SuVeoP z3k}xqkW=BMgXgXzP+6>aa#fTN&3^*ENvX=AE?VE*r2?QxIN%E&c5L_|pK@u;h?D zVpkqv;o`U7iO2X?sUy<@OpQ#j!K|#mu<8BNLOeWAcqBx!iMg+*W!Ljo!eNFkF+T_b zr!!$!-r-dYCN|~s)YRP@Un4!nu6ij`iHIjR#mBw$`V1M@ z;O*&xJC7mJ5zu9jHo`#`IjtFf)S9;_{Rm7^210j5UxI z#{!t~$Q;?R4#{SE8+kAaCA`YCYNRMqojiKToSWWB=}Ui1@in{pQ*UB zkv@HA>~(WD@G#3!yrz&(s?ItH#HrkyTgQR@*o^;9eLz?4WRT#f7T`JR?P(|`?R1ZL z$QNK8{6)aut`QgOP~Ct%xO*W)UU^WmcG}m+HT~6aOeOvI=sMzVPrwPJ&1`=rC?H?{ z=xEd^W21f*t~1)DF7Kljk@~9F8N6WA|9YTY^$tMOv8Ks$SokDB{u!=0*p&Tkxy3oy zg+ACD4Y7)PwdX8el1x}qX8?m$+{GP0$ohJw{Rn*(73Dp5BI77$hn|H?2r%;--LYPJ z()FzQ;g6IptJ$+kruuvYp;Hgba)m%3uv%2iKpTL(4EnS}o7p@jH+itE=}=u(>%IFv zOtnc094$85!5v^Ed|)dIZ!sa-pZ;N=qN`1~#S*adAqV|97!#56LUySXzNse;V}diK$s60HM~bsbO1yNwNmUG;Pc@#m))t$Ym8iA*jFCsotUpJDNSr$WxX_P@qE>W)CfATJC z%EQsbWV)urh@G9CLkQwfnQz4Qk_U5s-q5gfGx8%-JEF5)yu*mu8{J~?tq8^rg{5q< zC7Dikp4eT~*kjrBINgH5sC1a*ck(OaDV4yww@nR?S}%JB{7&)1;=s*YL-t=TxWpZt z!OcXOaoU0fiLOTRHoS6My={2{WgB8jD+A$z8yFo9C`9)J=c;>SQ~uc|T2)&44^`d~ zwS=@L2RllIc+!2bwTj!Ut}L>p*<+$ouUNt$CuLss*4quL?bVP2;Ut38WG;07`q0S6 zJ)}byfDQhNnC$B8lX>Sb=-2fOO|!M{F<`vh#o-YPSZ zaajJVY1!XwmnLKqL}3gnnL3-NK5^6upEfqjFw>u~N|G;mn!^(zp1`KNK7+uF>vZY&~DpaxC^fmm7#CB*-Iq*r6_1-KQ;k%k=qD5$FKHHt?+$ z0YO2BqutO8m#*Dr7I`<5d$t&oOe3~58i~YV&tD&?oou8!W-~P;%E|srn?Oq^@yLLKw9Z;|?~9xkkJmG` z9A~$AE?+8V1e7mss>REJqtv#?z7%p*9_6wsGBMD>N_%@eZ@(IXKsW;W^c<%;d#}e_ z7vGR@qD*0)7|K97jjhoIy{S7#e`vjT;~9(Al`U|ACNSqzOj%3k0aS_^%9BD&rj&K=TZR^hXL2ZfhR%2(yePTvDdE)A{yM zD)xHn(}Rs3t=pZFvJ|HSBTE&%O#wY>Vxg}}B#h0+fY%!IaQI>$$npp1hZIu)d=P`c{-i*^FWXT) zn)Fg3R$he|{*>;Vn;Y;~_UWl%@AONT?fitYrIiM@-I;NJO)W(98b*XPPL_)s-rBgk z26yIh{aX`hSM}wBa{22GCYwF4AA0Nd7;G-1EDC#do|WMSJG+B(NwuPP!R3#9*0Sd!b}Hg} zP0-ZZ_DzjZ5%;yO?r*&^J6cTn+KLhlA07>svHhJ$Ga$LQhG~IY;=#9SZ4y!1_ws^? zNMcaXuf0wa+Op^JLH4VkWsg2xS#hK^|F`XR zYy4X6S;ZLN`>=58Vzb>%XfGo?Z|)bh*~X#3lTWwTGryGw!HrsCZ!iJ?&KlZUv4M`_=U?(+Tu_w{Y@V)$^RZ);h=T?7d}kIwL|>fA?A2@uIKw!Y6nK_p z25%Oc(+H*bq5FopIhR3g34Sx*+RFkFfa}-{(|VA2!@NvwN4lplVPwL|aQ>6+k?{zc*fneV zo}Dp@oqeAx+yt3{YQ?R;Drw4f*Gy(iyn8t1hK=+;lJ)Ufj#Q9Um_*b{K6}=UM&pM4 zGN*{Y6gG@rugMsy(L6g`Wr#=Uziml9m<_>Z5~m3bh2_IS8#oU3&vov?1>R%K`p5mL z_@KHSxAE~WXMyU0>;r=I#*(~6emHu3)5#Kj>e^lIH=A>WPFd{(!TEg3z1~yz^MKv4 zg)93^Da?gGm?jTb^<^8qRn>kCR?Y>>tMOzJe{Dt~bT?`v@Ph&Pi<8Hf38_p<;15Yj;;WjS&)q|gkrrXHAVuEow}B7><}FIQ@r{GpRp>)IYa z*6%x0AO%4B1=Nc#IHBXUhO&H9gqs6r^O6z^7Fh+y&xZ-ds;{)Kw^&n5#!mWfg@cz| z|5hncr9bhpg2pwUqUj`c0RSxm=1sgq4;H*UFaWJtcuZVq%^YI<5TO1;qOrhsd%#Q( zi!F|Ti2J{JTNcESbnHt3|~ym?8bIg9e1Aeu5U1)G>AJwsvE)bVjDBa{iKGfm1C8V{6bht zYUXDJ#+aPe7*Z6r6Jm9WffX z)yAX}%=_f1?Vx30RNCbM07e6NYWp~NtqZKT`Pln1_<`U2rCKv2YM2B~vK^#k8k`*Z zHWe=NySALRQjLKL8~mMgoJIDrWxhwW$vk6G0itcZj$zlGEx!8xXP!czVuuPl?v zeDZ99X&|u1JfsLE+<-e0#|B~Bcsib5kf>*J4f#nzi&^na+o$fe)F-aGOnO6N!A(Jp z=QV1b)8_d1U)8qV`!jsz4xb9F)!w6{%g=kjEbVj3pf}FW&s}RfmMbjr?fU!qhc788 zW^Mu@mUq}D#~75ZH%ybE<^KpBlWS2>xLpL0ar#i2e-LXowsf_C)%5e|+mrFKk!m{V zqo*`Pl?!CBIaF+jbLCJakmT`L?GMe~U~$${N@cq5`9~eMv7(6`987FKH3(7&s~uwq zCjP5|2O+ooIAmPFkJM@Zf;+cAxFeJzXVa>7mDq6G(0b`Pk*101F0;O=rh%RrRxk(v zgN>rblJvAcU%f)Y(0!|zqr;SXNMIiO3d4l_>7l2tS}DrL6>n&)QZ~%h)|oEsXY4W8 zCCE3_-OK6g!^n7gD2@3;8nQJ02g*MC_c@mt@lMzaET+Bc zL1gxGn7d}ulHlDKIEb1WzN><4Q6hte=Kcv72 zMd)y+mjk&#?LQMYMcVU^@ z-`q&%pxGng`~hDN!hG$1!pN1($9y^_#^k6|X!LTzrR4KhOj7%e?{^8MCw^KhAo`E5 zohRu%TTX**UFuOBH3x?VM5z|=qTZOyVNj`w)mqwXz4haP&dSgBe8ESnl`*;nHe(SX z#@L?HOi2Nx2@s}5-y{ZJOg#0E3R$1w9mY$PRhB^{_{4<{_gXU#a3hWi($vkX4L#h7 zOl=v*b5WH^+{VNW4vrw8fc{}y?@@kFM#!fB*@5^}<)GL*;H%9g*;&27qh+4OnYb}r z>3EtRcd$E$&<}TXn*=qDD#%>lw?VyHBLN+h!vtVZ^bvBq-FSnGDVKc5d}Ud$!E>}i{-9V z^^^_En~oiwdal8{?LuHfpo4hh;U977y)&ZsTJ>-|)7U`3{&&iK_R+JP;;*goVX%&)SW`FH9nNX1V2i1>6~D7V(nqlZ*6f(S?TF=RIYvu6N7x zgnU=Bg{bZyrw5&Z!cKQ|WlrY)c%38%8OSZRPQv`t4EWl#tWGV0Rab+Wn@(uKo+t$8E=7Qr_0uc7E6k;&gCz z)O`Q?%ewse6aX-Jj%53Cq8jXjs=}paMpQMG`puR;eb0{L9xpu~Xeb*s5wYlg-Hp5) zle&*(LNK()N)p3lmzLq1Dc-8Zid>3Dnkz6I9wkD_il+bLFyN(V>KhnToY8wO{thpbT!>@;Z)mq79OXdcnz}L0dw25N_{_AK zpzqdMG1M@=-KJcnu07vj?sGcYyD#mCn9-bb^2@YIF(zeDd}QWtUyuDL5WK?tkez)q zT}x_B7vC*VlUe+#eHg7G#BMPl^w{(MRj=p~2&8Vm8P`Ij15|GziWQaSYlw)$2%@zA zM~98@Ys|=(*`@4x-2~A*Q4uUYWMB8M~FdLKTzajw#gl^n%`o z`D}I5dT3J9OaY}q^tT@pV5Jv^{@)>xAHqY!88-5|S#hcS+oaa;V{h z8=LGlz>%I#C(Bubbup>QpF#{!awyP61$Y$yt~u#U!WjlQc<7e$RH?LODvx+Eiq?$9 zsW%61ICm~fc&@@+!G@i;sYmNTrZRFKtxyoPj0xCOo(MC6hx~mVxYuXEA!BqDK#sMZ zvsJ0fYy(wQqcStGH+-HHl%2V2xqFYEUka$?D}FIOkm$kKBQbW>YArB3FKab%U1bZ+swfUoY*!eE%DAPGhE^BAa^P*Zf{FkDNgx~5c)RLaE{M@!ncQYl)utzgA4 zDpEXtcb`q3!dX$1R^BXgChJ!>Y?V-Y<=?uBQdMo1!#Hc*48`v5Z`)9?QBW+0s>!CO zoL|c_*@9mq^~9xXMc#@3E0g@655rLSrGBJXvk#t)vagLLqyn-U_SqSpTzI z{BRQ#BdL$jdkwl$F)8QmCs@aUaRO**nl){)6d8eXh=xj8WbMDin_Fo zmnsM~tu9iww?r#c(4KFdZsdH9-jMTON;k7k0g{Y;b>9=JejuCuRkEe#k3ffVkaXo=z)kGGk?1Q)~ey&$1QuaJeX zBgZ9qk;e5(X|w66sS0&J2mis|0o}(YC!-T7?ZkRw&s?I!yNPnAyaAAF;Lc_$yY$reCM#xc^dL;NH{wd);4r+*tkB&a|$1M^o-U zA)Jk=+%j#p@v?BUJI&%Rcr?oThTpF1W=5Dz?=s!0&F#&g_Dl5P>!vlO8gu~ULG#W; zs?y$ig2Zad?$y_7hS4T%7S9~NDsJG71{o<0D<_7eGD*vj;@5Wd+m9MvSDPH3lE7zo zt45!$!_rF~UJst+kM%`%$5iDAGuO|EUp-14Z44e**w#9^oi4Ljtqq^vl#n`Jw!4V* zQjXS%h`71I&K`k4DD^P;=V)41jst#|`&OA>UlV+qLQ`?9@wpMK!Md~MT|+e!VU zRPvyz;&XX$t>|DDl(A8YS(fBo z9@-{S>XADQ<=|Drw{0UuT3sX+Fyoq4&HfP*a1ekA61C_LXO;IrYk9-K`hT*v4?_IU zuCoHx@L;XGr2(roQ`}1!pFWwEgs0-YX%40?B`zy(-0)K-C-c+8nY=%*>}L|jcj?3V zEaY9Z*x7>>GX04wVAIr-Armw0aWBS7-qXe#XBUI}_z6|@;HcDqz3*b1O9OJv+@Dx( zR;4D=z6d5Q#*j-9Fj#$ky}P@6XOJ`pKX;K z%ov>l%i&)Ajo3Fj?O0Ii*+r5zO=})q`(&rCy!9;=7c}Lk=^o>@Lsl*`${{h=+1Tgq z$uGTi86&G*!BI{2kHLo=(qdI$0O^@R?Gr->`TD={lnSEnz+g2m9#BO8E@L4n^&e^g zHW^tNhqYC7J}pIyo{=7YWfqCiA@#((Mhjqn@~fp0T9IE9%>;d(s;N_*UsKu$eMTy6 zawM4(iBVn_0b3D93n)839M;3nht7XU!9KS#3!tI17h#-4kaFBYEz^%zMig_{*k!6H zl}U{#2Di)E|D6~4A9A(+gX#OXM7jUk1N_g;&Hu=;lTJ@x*R5$YloJvA@+lw5qOPzY;vsT_Asq}gzbxNcsXqNC%y0VBWivZF$503X~SS=l{# zaslxTRqB2nI$4d9^e`i#jdGvb`1d^gu@_jKRHW6?nd}Jjv+mQ~R!)(Tm?&$yMV>yl z$771u9TzitWt8j&nlCgA>yH)*%F2%2lk+ufEPLT@Z$Ku}pWw*;5h~S7Nl7=6w}_(J zmhB58q0*IX5`*FYaO`DVntV$$GqyZL&!AGz&MBAX{c}3akNyDrgwH^5*z1vsfw&>9G8nTqyOEs-h6?;L6wE&kw#8wdaH<n3H@FrdlKnnMLrH?UGh}@sG*HA*L&a- z-pU3mq)J_NE96don9XN+@UyN?!^qoU*Iwp1dHBx*-20yA=cn~vn#j*F&+^5uS|ELa zRGYM$?r~wXpqxS&EMvmTiByxq`L`(Gk1Sh}*@s~z-k1L5BB{FQr}Qs@K-wlv+aj1t zRW+D&JR$9XLd)X6BY)&Mc|8=4JFYN@{dgR{-tuP^%kSDg)pq*Vm{CR)@j1``Zmh83zcoo3QI>+I!N;Gv z-xmw?P@)rUGfOl8#`GCyHyy2wmjXxr3Wk3MHG_-_SPTBoNq&FLi?p?~I6RRgNkptS z=|pc8CS<20au3IE-txFr5>lpO>8TTI;9Ol<)JJKf;Fd*wL>B*pbQKbkVHB@=gDU#V z{<8?kQwf=$&*1qJfIQcxTUOOpzZ<*>o3;mV%pu6sy+oK*rqGGvAuMxL64xzG_H)ojc}3`E!zC`;ZjkN zcb`wXl9k&*BqASZug+~>$R6>Hl{A3C?fOSA3mdY2E@YXRO}^m@#}v@*hCz5R*_%t0 z6KglUW0P*P+{**&(}6)ir=wdw>xB^JvvkXn6Spr} z)>80Rv+*ch*72)CzS`EQiB81P;BOuI$+s?>!@SRdxqe2gtD?&Iwa=VNyxxc*-2rq7 zVy)P8mVFaonF+=cP6?m8a+LOw5$q9!zxI538|dLXlG|TiTu?MxYzTrk_fjGd@vKPl zQVC%#HH+;s);Atnrn|R+Y$)*Fefr8iXKaB*Ryv>|fKldbj}c%mI4}}| z=JpGv53#E&EA#dx@G4#?T*z_ss{eV4rOaIG-QbXSmW!j*eDJt{s7(h6kC#fG&UsCN z$cC7EXEjMkgaKU{joC7PcI*5ZcKTr7QqtVyL(VbKYjw$Q_4NX*X~oUQwBE>W%*PmT zlf;pPYH8%1h$c3EA^0nrNolqzrQwF7*;lt*uI3O#e6)Z-ZJqBl^s_v0y=&K)z*hN7 z*Y>|i#yzJ+0!_s{<}_MY=*yxaQn!44QQ14EXNwG(u9HkC>uyyfhI_-faJtER{zYU+ zS@V|{UN1@1vgS!!i>n=(ke;Lija+A?UrIT33%rnFI=M9I(HYCf$h4MnTkf}%4lQ1T zIsjz3i+sRDy|^)L=qi2umWCBPnkO4hsYM-_VPIoFu`o;jSoBlNhGmf`S{>DFQHiFt zd|q3KN!pV9W5DZ5I=~DC@6qA879+S+kVt=y+;jDyAy3AZqRrkjr~9w`I~=f;__}mY&ejxnyQ) zj%OWNvVP_A^@yS644J1!kwOoay)*B;l{z*RY{DfNsjN?B0@et<9J4^$bfig%vQSxB zt%#18Zh|2Qn9I9gQr(lJBaQ~OVZIq*t@gHLyXv?B08P4*0DWWQ!jgvK`i%-wW<7=5 zXJg@SMdelfPPK_n^7wgbaD z?OpLzDRd$T-Euwz_4if0y(a9im$}5U9OSPW!ym}u9Z*~O*=w|Ze@!lbcBbZtNe=G4 zeDQRC0FRd6wp(~BL&FWC53a_e&6o{^4ze_xxT44S=a7pPg5G9%g8o?(bAt_^uK8pj zMLW*CJl;d?XmxaS%+2*k{Cc_lUyfU72Nwsl|9SREh3Xnr-2uZyXZSymHI;&^On(Ec z)NF*V;02P&I}x_3h%N$?Ex_b=M|$t}l^ylh5mVoq-CulGZH(ihRwFp#Kak#r&U3u< zy2HXY&GpgC5zj|1(fSYY_kVqGF|H;VRYBga0l2eMRgqMfp0C{-Xj$wvA@c4F@hkmu z$HCcN(J)N!?HlE9FH&H;TDLrJyO>x+3EJN#)p(9}fBjKoovJCM;O|c!>JmPS+f+Ar z2JC+p4rQF(AxsA>34!Gue{fn_ss@$lV0q2kzL#IP3IJ3?z0gSv6BDX7?K4Stg%2y# zvNK=_wnBadav~E6m1E2@c`C0IN6)<+x2{}LqI9DHD1lb`%Y;35Qr=KmzKf}QKWq^; zg(94|k(o$!?=rrwgJZqVu#v3;0#<7$CF$4)DacNOCqF>}y!_pT5zIlvpOLJqj6dSb zgHjEEz+Q%11ZyS>Zh7HzSZ6@zh*g=HX$cwG8_^zZKae{J#6w+&nLbmd$@J^j)fc}8 zu6rY}STn{%nWL_4J((e4n3yAKEN{u@%!|x{kvq!xIzZE@z5ax1bfUT=w5_i0q4Nng zPwzCYXRT+WTD;m!?zEj-~LV{qVA|Yua1PTi$-RKLwGW>)4zU}0_P~+ zh(`H~)Y~%(*yv1E-mF~x(4WG%o=5-maQ_E$#<|P3x#uh9OVuQ*TL-v5*Rxk&vNs%G z7T42b?eFSCa(Uext96agn2O5y%n>r;Ko{PW$J}n{;ftR85O+6Hl@{fsV>!a%%_kL> z%1#+!F~}goZU}NOq|)rAQ|;TQlb<#@MHf{E?|ZZVc0PSiq7qtzYq*{bS>96=YC`r5 zx5g%?=%Mbjtj<=9_NNtjtr7F|4)DS&W-m;y78%sIyLXh~d0VLVj$0p7fiy2#6v$-U z?T)J>b#&P2-Uo6En#`U?I65hnA?TtMnPHRX9LdoNC50tV{J3vZ?>vJ*O7cg%V9#_c zAq7SXK{^|;V-f8`e1dXFxE#L75J&X^9?>V*7ChPFxq7& zk6RA7HobOiT61MZUe5m)^yy&%Fs6ZT628HAmBi>|EEMM8;^IOICAnj3Qc@>tNRAB2 z+r9cZ|tTQ^fC`k$3y&kn9TBQ}L>HIHc5OQ7b;ALU{ zoIsIuO+vEaLDhwHMN>JK?oUDN0)E$?F&Z6Ak-1RoZ3p}@)-X2r&|!)|it~&Q8e@U1 zWK%GxYs|n#atDHA1w|&Oy`y*{<|;hNn&42F-gFwx_l)oR_!dQ2PYSu4!BJzezkf|N z-&*X)M7ueM4%*S8Y`}F@|D54l+%-S`6sNWQaVakBYpLW}StCc-RQJti)Z*x5YVr}W z>PiyudNuEXn}P=LM-so<`L&dpk1VepFsrIe2cmV2Pst<%{RBTc!1zQ(jXV!%BO#Dh zms;_>631v}TKcJOYB!BykUip=_qJttM|fTJYBITB<|Kahk8>=@7M11LpIL^~^ts=C z9}#X4?9Q(JDvGkMt{Kc#%XX2mCNC#Dno>Wh>W(aFf7LRGnkH>je<3Nz)(ne){!4df z_0@5`JdK9MMBr%_S9Qr8RYkSzP&`CU>g!k|nf$i?5t@xL=GH8uj)7wmP6X1=!2>ml zp#JC39$EwaCxp#JPY<4|Em_L1DIvo_&y}*p{ZIYo&HVleRECNveI$4NzgiCu%r*6@ z9q_e?n{Oh1i}Sd@Iyj`TAPafWyxvZ8EPp2~RfX|EOY`WVx613=@|t2AKy7#Z?ppl# z#2sOWH@VwiC;wp+7L6!`NBXm^`ye{RgMF)r$dN2N^_^339U##1`q!DER2!&^ z_Ph|iT5$X3(+zZuzxVqhfmO1{HuHGwjfR~dA7A-VXS;t@Z@!qQe%rG9s&`__%>yUc z)~c}EJMPT*1F~7Bu;V7VrP*WXHP470l{fd`)kVdRTK+E4tbn@$$do44Q{NQG(UCs5FfS2wcDqk0lR}1dmw!q9*~N@>H(@vVp78p+5If^9&GtPGP8Ts)L{@+3&>elP;L{!&{G}Tp2 zS)rDjUVgBQJDY~^lY01NmH`)Bb6&i*qx>LvJ4_mkXR;{d?Gmck8nGw6o+%puHQkB& zzF5tIRQS;71%sGPy_26!9ws-%zE@%YXaTnu^Ya_u@z9E7d)FB%bm@v<5PgE`x7hM=4Ymm*$%o(lg@M~o&z|8!cj00o)QSpv)M@4tJ!l;7%8 zf)tk$<5C`w;3Bmr4cJ<};9d^c$`$`ADV?mdp9i?1hKtDJJ{16vY5ccxFaCEB*MI5D z|Gz+y{yR-Ovw1qHZ;?ZP#X=J}JQz@v3fuJRhoYFeNbng2#mAKq@+Ix%NAl%=rOEz38}*0H z1y>y1y;{`=WO7m|(Os$>M(C{?*>ezqc@_0~7Ab{F&!g zPS>z?mTIWQ0lBB^w=2o&ZLotxwVNOQ@TB94EJbKaAc(O*H zG!|30^4{~V0*7%X>qvLzW}~xC87`wpF6lTQHk3s zoeSY+6HYY|u+^Dgp~J4Iu}(yDb9Q$53a~X@Nms>yLJO1j60ej-0Th=pLI&QSsEN>Gb=^g^dl= z;P~?1S4f99_0&%dVW`$bV)|%#OkdM8H!O4jbLOt6SludKO8hR~FC+h3m2phgt+fII z57#a`-A;NLX@+|W#(Ez;ln0bAl^(65%EUIxybe3|QhYXgS9n>Zv55JMMNukAx3hMY zV`JhfJpRl%T)gGryq2FzSHCIes(SrS^ln@b$VnPgKNGcT9@c9lmO$M=to9_x`GS9E zIR+7g#^cOzETaC$Ev0*y!{bi)L4x$fI37r&={~Dx(%ydo(Geqmeyvc;9IZ=gF81JL zP8EhB2I~DGYgqBl#a_RCL|AlUP()&h#1wex5?;p7dZ3$5C?w!7*W6!|(~*E{e}n>- z^jUeilsxFC@Xlh*J;m}cYD%KC5+Ul^p5Ppw%Ktv<(KY2<8seakk3!YZvHaYrBN0_uNcNmKMImbffQ@&~&5aHIaQy(4R(mcaL$h?={d0iCsHwN0b*iad-yBB&*2wmF#Il-c0(5OaJ5((N*S> ziTyUf_L-mdW<7A|Adj!^WNguEOw&5bWewY8}v?*PI(%Y6Gj_Xsq7et74m#6?Q);FY<{GI7d} zfL~LhmnUM|nea`odIfCq+OJe3zHJ}7VO0chjc}G4hkZXUSz36f>m@4q#rX~JYvFpf zp3M7D4muUc{TwP;+5WR;-(`3Qi)wn4Ap$(6wfQeoO2Jz2`Td(fxfcD$Trp&qf0>wRD7m6nYZvCzTJN;-4m{RRwmKq)V4JG35pf`9Vu23f|kXNpJ zVvp4*$_#uc{%9ep23}j6Clvz`;1h&xK1@*!_Odwn4lMES^QSxvM#Ww5{xgty6Yc@& zudaHQNBUNwVuW4xF}u$2AH`jc(I{1ah7=o2V|ZjaWB4mWef={Dq3G)DS-;5byK+V;X)h6D^Z__)_e`k0YU3=9L zyfRk|wIc)uALq!2Ob3feu*vc+O_XdIQ(ihFR*I2N9uy8apOH$d4lo0J1=bp8XYa7Q zw)!_n^m0t+@R-hK2_?S56Jnka40Y6z!3A4}=UuxhxSg~*30@yXo&4GT0DktC^bv9X z$rh(8nAmK)GI;w7th{O9@TqK+yhK+S#;Uu~oT>J~cIuuk%z6dmygKFDD*lx_l>FM<^ zST~wI3~YY&Xk>KsxCI~bPsPx(*Y!z~h43PFGM+%vK|=7^?9zX3Dd^Zf{p*JF;s2c* zo`e6Ry{`(2vuU@@7bCb!&;$!kaEA!)J~+Xh!CjINVDMmrLvR_~eIU3F?(Q(az~FvJ zzJ2!9U;A#?xj9wuMc2IDJ=I-L*W10GwN@K|@K^@?kY|5ee=P$Uk$}x!2NgQ@C#!+@ zJVRDrkIof?Lwb+YivM6IkLRI?3R(iRiOI?5KCKG~8|k+Orh!-uS2S`gxFWk=tK(&V z5^0ashBmf|O-)Vr_N>uge8+~Cumyh6cC)!$in;w8W>4;E)HO6f4D9TNZQK6*)iSFG z6D$LjEZ!oUOWXQFfBU$+QPI#~WMX2IK`Zjn=)g~MK>fSiR>Wl6(HxS&u}*T+i->I!|pfvzr`nneM)cttKAb>^*$^I_CIHY$2I%UzZupiA5f*= z{{7{Dp^(85C@of%0H$O;>fI-e!FW(>RSIv;{nypuJ!=QW@CN?}A;C*NO~;hRqtVJ<`D>M+xm z+rvfpkg9OCo*!)zMeo0wc#7I*92r4bb1EJB8;`-d5n;0{#*UcV3wc%>MBrc4cwL#f z>&yTWL?HMoPJSH^P=cW6jXr{vy$bk}*ZSAv;F+mxymWC$TLHJVWlsHTz|CFa1A1&eF&kX_JY5Te|B% zAJv30uUv!Kv1Y2+zQ7tKvf<8RSCygZzqzoMaLxyNlKzOMKj^(u`h?w!sBsq zkL7h-cW0Iq&#KtZ3`Wm9=*&4w}|kBnOjEoQWYy%LXAkRm7_g z^kL|!oKDE3!3QpL{|4U^uKkcMcEnP@sTyJtv(VS=B4qCX_}ye>q6Ly)b-o={^q|A! zDk6ApZ{PpG?R@hzQ--wJ>^q|0yDP(NOWbzPaQ<9u>Rfy0Vf^T2MuFxp(hO>9!a&Nz zR-}$&m=W|P%aw0T*(hXD&A%l%>&qGpmm3FkpYD+XoFdoIN7taHwkjo!!rdWF(%SJU zHQUU~AtvG8o$7Br1dt@=4yutU`^Q%xBQfsps=sBR8dIP&0K%Dk8ucONI-c)>@D4D;IGWi3p~ za$^a6hqRLi_qt_y*W#u|oL08|v9rX(-JY0LaqjXDqLx0tPZ(Z_`^y>9pl#}BT-~We z=G*PHd~s~@6heWEh_$+(%=WA%akAP>w`T`m6wqWnMlV#%M6lXytE(M16#H934-}T5%I!JzCtl^?}^%;dmK%IQD8=IUYI~49*)aMS-bRCPPU0kd@=)D5$ z8e^UirJ*QYcN>IBBQ4cnHAyAO#EVTyQ$F2$_AtNX>%1%Jp`V{G)+#jy=dsASwAQDR ze=#eKn>L;s;J?E>9Weo!K8E7-LDThn(S&T9`O=4ikaT*W(w#8FQBC4b-^400Gzsdh zyb8<)Y(^|KmkUHmgU>H#l3M_jUE5*uX^oh#d7DGnf=$`ouM}iXlZ`K_cdRdU(?g`7 z60dssgiE#KPOby_}9(!9UgZvKoyw6&z+CkSbx zxW82QpeQa|5vqZ|Ox5G&KJ}(ATS0_x^rt~8vlJqDyJ+H!oZr8?NoF#DSGzm=nIF7V zM22Qvk|Hl0v8Wyfl8!-sfrCF=807XQ@9U%?f9B;*4&HYEYrew3}5zWH)=NrQ!-UxB?pKc--rD9kY zrOYvY^+2#Ye;2AntsQ1FMG7nDi6!9Om3VkhzeIq1%*9};Gt8!Ij_n#FdPDn5hM8xq zyTa)juCEq%p3^b0_CDh>{^qo<{D zJ75tbZDoVDzm44@&@3-EA>84B<14xzihNSj(yqg`+ z_Z!b9^`k)(Ibq3_?wIp#+v&5Dzx+Er`c+3fAuXQo*p#{!%#P|+TI?hUllziF)a>h7 z?#zA=27|_iSzh~;9wMM;g}MyyV$?%m-&m=_lgVan?uvmf1^lbSQ)xZyKWMknG1KNT zkH}^^%T3c}7xJa1ZAETQ6)H5JAwG&>I`fp`d0Nl2FyBcZt}!88Y%F-$Fa#QG7DHBg z$-hb9XM|)!t+F&uIOq`pmS>xmCq9#2@_MfXFv3AoE2KRrEm zQz;b>Lcvj`kV;8h03A1`!m?@uFEZny#=E2NUG%ITja1a?@&(Z1Hx2J*ly((x5^hK4 zdu*&l1j{*IIlhC7-kBWi<~s&jyNX;7Q7#rWZ{@N-m1Gx7>cT65k34iM&FAaf?ebk| zEDnDksagfA)hXGXoOfKSX9aNiyBi^~KPu6RLnLc3tsmy|Ru$^egocot_GEFZhAGs& zv#J_o^4ap9i<&R`UCRQEES>_${YZ3+HJ~++yk?ttr{w1;w0!-Z!iy8m&aJ?zH6cEO z{9YH*S}I{1>G3gMcEpKwB;OKXF|J|jhKT8P-{zM~Vak*{UVZs%A8P7SS1EsgzRYj8 zHcu-H0%&Bv2l3)Y^Y0E9c}}wjLGcrLUh|(fwYq9p`{FQ&;oxre&BHelF$l(SY$usy zKX_Yd+W`AIGLR@jD4c4sLm~wP>g-FXWfgT`U1MF&(a;tu({k~2Op(5CAA`07n1WdD zB$}7nlET0)TuMT9M(2G#u;Z|MEiwO8*V7=49)oxc^JuzMfs6^4%Rz;(mwf&F5Af(| zJZkMh)CX2}g=^ScjoZ@mo{7bOno74FQ^IduFC0*!J?k)=>YiWjz4wG^-T=J|mhEPX z2;ovf7Lc)%z@`r zTQR;?o8Pu2#4Bgks{l(xZm9(IZ@-vfmo%l+-X?XDx+c3CFSbd3I%${VLy(vPj#Zsh zI#-jJfle(wK<$fx=2{otFJ@oFt(0+54C`~YxcrZQONd%u1e~W0&}KC=&rebI+wk(e zc{5U$ws%(&7TFCDt~KWZvKo3Z4EwF@O%7Hd-~E%l z$f?LOcUEnxOr7G2*g-$R6=T&A8&z-;v4jT{nGni={k9z+L6R+71O9TA>)k+Idow(h zkG%-!{;P`JCS+aizzvD$e4`scyrg?7Cw0E^GOv7lFxML#b<9$~551RVil*J$DW1f3 zZB&?T^g4VVoYVHT!Ece8AX*lB(QzV2(~^UX76w;aCY+kr~E@%h4qrOT5-g4#TDdg6RVM9D{Vlw|bO z;0G_{_VAW`1XrIh)U7M8UT~d=6R7>RQLS6X09r<0aF3&jS^m+BqAKdGpY& z81lBvhJ=x}&Qc@P(CE+!u7I>2=nrtj>hL#g68=>-8b$%BYb~}fd;vRL*yKjKj9Bg+ z()zhv$gsY2^HBzh(?--g*43X~*d)M6jESn8Z>~sclh@*6$UJ3RnZiNfEBSfiYI}X) z!A;84K_nYy0^h+R4|M?wQoN{JJ~mLrPxeNd@(tTL}94~)(+ z_F+T3?aU-224eH>R~Qc!rTLZ2%8>zDs0j6NoR}-_FFo&V{4MEQ8?BCanVx~ginN&w z^a9f&V?E3l_nkSHsqO;65$FA4&@badbv210wuE^{sHNCR4Db-1ck z_`R04>|K%mvzx?5ujOXZR|@gg0iMprLxWJKg&ww<8f$B@G>d=45MDn4NI99_MdkH$ zu~I|c!UVUSyucGpiP@;Mo0Dr9*4^{ch&m>Wu|`>!La$tjC>LLR9AAO(oN7=b1qeLmH(`t-Sz$KBlwldQ4Se@fyrV&6V;j zK4Qbkk=$2Muth|~d1*@QP%@Vo-;ulIz)oJ>`kpSDd#_zCVt9R;J%x9I+Z^66CW|d^ z?TW*|j)TkWUX}or1FIFS!~jtI&Y*n=!Lx1sfgUen!({07Cp=tSLTIz2+M;`VOyK?I zL33(Wy}lac2IWSU@%ewjh(udDJ_-pRA^xwYVSVC;u*#A$h>WDmOjC^2zc^ywSe^mA zl@)WjtJ4g(k8^Cy!`xoMPClY|4vUm4-%#z@YYyQ|P|sT|04?5;K%C#ngak76R^LX7 z#+NK%sp?uQFuj#Pb&OgV1+Saa!{n2!08gUv>mR%x20c)io#UHPq9pQ3MwRV zH#bp0jZ+j`J+E55nq&g>?IOa-TY}_w3Nnb&sA;~bWxOW4^=fz|hNcKySlh)DQ)qa( zymg`EX?3m=G(THtx}0K+j8uepnf#MIZnU!!8A;?yF6>W!*zWJMt%zl|BI^x z;BB)0(hddNI??e!(e6q2H~QR=W$xk3f26KHi@n$ z=KYi$sn1pX4`1gS+aE>rFj13&b+a9%%H zLr<*6UPZX|CyeezMHG)K*SU%IyyYnTxakJRmPU%{ypbPeaO51Gai=A^j& ze6a-%0%Q90xL066Pu>^inddzLydgLe=w#G}C7b{5))zU_%7@MPrBA)2chw1}+J=z% z9NJRrnk}=u!xVF;$sVnvxe-btNfA6H->@@0NP4enKgvW5{f$8|p=+e$RGaKO{<2n5 z60;cvM!7sG(UNT2u+lzcA#YMA`Bc?vW>5op>C{DD4nC6<@2o*ma4d~4b&pj5w(0j! z9Y@!!L)B#cHr`TGw`O1b%6nPl<0_QVeg~&FY<*2bL%lXrv%6JL{asCgwh$*{V7r|IzFBA4RZevNQZVn#S5QFqHx0hr zw9JIdMj{s5)K=u-9sSK7wDsyu=BM?ahBKroX0Bn(^SjS+rIfmPDflZbp5d#>ZNL58 zT%ED({S$B^zY(M&XifG zYIoETnHui6!{sH7&t<8Dm31%Co>$5%5 zgUHbSwLml4gp=;jk9WvXCMIHeXP4)Fs9f5%=oI z5+dv?CqR5%ycD*RvJaZhPVbo;NfSDG>8T}YnJBc{@L%z(R{Q{Q?2U}CFCfB#Tdns; zCuSiv-tH}-7hoBDyqMPmkyE+MgFrzKw?~E0*d$A1v&B_&k7HpVXAFT%IE5)4a~E!5 zJY~vB;L{jsQU3Y%qrmTDv#6{kB@Ene74V4DlyNSs_g7+b1Ah{nNN86k$I2Etehe!b4o-r!?MSGx3!Qx%>A z{dVTEqS%n2T3HE4$KxW`)P7!$>EBB#oAMiEj7dzO_YE>3o`UDKI=S)sY9HbaxwUiG z64caz3VPSBt<_(y!h7izbh%yarYqC33-*)-Hbt6Fe~j--Nxj7l+4JU!xp(8rY0I@L z8E(>`SH%SvqjYSeOH+fHSR$>ob&K>jMoUc!!QfOT`V?6zNaFM;yH8B&P|V?t7mxj+ zru##O&~BPgpqO9YI_F^W=T4R8{J5TNCp}CNdJmhnAYELm2D}&|nTffC68vCxro~s} z%(~8pT^qEowZV6aN#@!EUGWDlvyRcP8(>q4NBRIkxN(T54w0O-d<|DLl@7W+x#DDb zF$8d}RzgJM2mIJcpBg<4_DFYH4_bJsDR6T8xSB`aZ!kbGd70Umq@XG-mA=6i@D)wTI7e~-gQ5Q6^2?87LQ|Pw zlyAtV(cfEIgG%-M+BYu(Y&DJbzO{zA?lJ(nXT0y(#c916>(T&vpxb(VlRtZdy%$C% zk!9Kri$0;t$Xf7T>WrAw5L$`7)8AxMjrZM#`O!FJp7@Y|$z=1E%}oQX{e7oHKNb!6dO)kl84_R1i}}dK?cX+enPZ6EtY{90a#+|gE1esB++hm$uFj>BOV&*jg%-CQL&b4k6tG8qFi!KdZ;L&0$Y4^BYdLkE_GlkW3_0{8N{BSWW|siLOofh z5!8VbtX9Eiq#aWk+9Vs4DSIP}NkG7gXt*sToFp4}Ir{zV;4@In!)tpj4}mqKc$zUW zD}67!A>4ZNhP}z5D^ZSad4N`RQV1I@*~?EHOJ#m7PDy#7mnOd7E|7B1WAP`nd;?L>ehw6_1m^ zu`g`q^?;PZKjvXhN7a0f-;f#K;jq(Ts;Q!jXW1J@W{o6GT(DLxEwv7QyQU6O<9sbw zX?@-vr98l^oOk_8rE<96Eg6k*PW5ekUd*^1rkgtRgF!>u_d3=*GewS;$h+&dG z=ofwHErR%E6KppRQDc6NsBHeuI!lL!M}tQzSqmn`0CX(03^)M1M+-a?hQ+TUYt*c1 z(V5wORS(}NuNuG#Gno?r4lnNBbT|~8x2Oq7I5y>JUSK~)#cnSqs%Q4W-ho}=FpY6o zXjoC0hb=N2f4+>?deUafrtJa(#wft|yH}qirf^^_k!KX7Ze|gZ#}1R8tS%qD1-Y8U z8U*Df@gY`?i@V>(5D{tmlO~y1+OUC8!`K+^gb@ox>eAz5H80@uALHSDmVg*msB zmAc+#U8mK-Y&05pz4?};t&i>OFKX?H$-8eCs@gJxvC7tCr6t)|8ET1wW=csIdRs5%8=vDG8U3-eQ*|ne_FaXU`IDr9o-YxqeLBC6Nn_ z1<7^j49AWKM+G?^=(hw%q_=uDJCDr{w5y89;oGZz%F9#PT{v}hago57o|&kA&(Uc6 z6Nz$XPWZ4)q+aUR>JX-iC*|kMl$;7QwpL9{q@7}4j0+NMoZItxF%;gjuIlKxi>ex; zPF=liCqkH#K&zzGVA+RwD-0qsBQhm4g%i4wCjsp z2JdTeWD%a+BG-Ka>52;ShV%2*)X6-TEXr*6;5$B_@oD^ad(0&ILj2O$IC-Xun~APV z7E6BHQwU#u8p;kN431k=lfK@XtZEjd5ELLqeVw)TJ9K->Y^{wVWO@(bNZGUSV2^c07LF{M~S_I1- zl-njx$KpLxZo!jdV_!@Q^ZgB*k0w$s^l6lIrNTP9YP=y*jurLQ#z^B1wLo)vjWOw5 ztfzzrXFRT({igxp;KBxL9dSLYYKvu&2b1lgg&6?Hkjd-|+yAyiRb&d+UILO9zZOV>4O^kQd5)5%q1rDP=T zG_j{glRUBlzHxBMfjwgGB9HN+qVC)f9n<&DayeN9;G|Wd^+-sNmWZhQZP?x1S4<)< z!7Au&wYXe|T!3GmZsh0J0Wqfz$GWsvwdF*`?nX19_F`nIKhp_L_BB09gTDE7bxxG$ zx>xOo*_^+S%F7E{oNF@f32-mc!2m2T6zsGojl2KOrqo}mI@OBPVktd%);v3iF65r! zc||XH+THj|Xe_T}fv2}T9>_c!5pM$%_K8i#@v|Aa)gCxhZ$uBVvA3h~^V^?*>|7S; zO1V8~dsWqd@PNR%<_mR8Ct2Bvp%uQa#_J%xNpPBujp!WQpMjs6j_3A1Gbz?jBwaGR z!CPvcFW6l_WH6Q>M|9Db{dsM2H?Q6jZM*ar1=@*0>MI@B9Y<4Zufecj80V$r z%VZ2Q5q`f*)A$QoGZaJ5Vc_ayR%Bs_M4$f@Wov{0Hc+JGs!N3st}sf=Lm*}Jcw%zw zO;Sjp!5hP)BF7RWu$XFf?Nfeg)p_+S*pN1tHt@b-bH?O=h=NRgXc;4=VG7mX!*F9w zBlvU9e)=bJUm7*~+llBKcIr>;2RFX+Y898hIcyk9t;fH!6Mu{}p-iQF&$mH5yE0oJ zzejVdha%~pu??$6p27JR!b6oJ{2go$Kn*=KW;`%)d&|u^54W0#`MRsl>>sC>BM`_m z6&l;b$j@c)Mi1r-%;L(Z0_HBVT)fgdzO=#5ShkB7EF5%n$idph?IfaQFQ3kU)7Ly2 zgxbcBxVM+v=E+LU90OvqIwVATSFuX4CIsx%RD!n5T&MO*za`}aNVRlzY8TD_ZvVD$ zpil$bkB}SnN+l9h@W$if(A%hRH~IqW){ikiGa;qoH_S%gRaV?7vPIYApqvYuffeF| z?|O1H{CPCU;BZPW(ky}Pj7!m8%L{o7xy(sCT4|YA4Bl(-nw#2sqDtz^YY9I9CC(%~ z#zaH@(!zA1tZMPZ$6r}3t$C$-g?>M`M}~9}?NmZh!Ff5fKgj3E>)C@`to@!>{0qXo zyV-k|4)D=Za_gCe`$DDLO(mD!XFjToR<`LZeRer^o8@oSn;^ehbZ2zI@;NqK}!Lm1Kkid z;N|ADOZP+rJZqG-Iz)QFuDkg==6n=8Qiws5&(TWEMfF71F`dJb%cvJlhXU|%SDu{f zf+XonGvMMvk4Fk)1KbYuMQ`e!wq~ZycNU85If7DOzLZ}ro2@$t?g*sL_G%3Xa_4DV&droba@sCKo^LGA=J zysqBe_wzcbm0?`Azip5^vx}FmJBb=cci%S<+O{PEOpmr8LNn7ROBj#IZ|?Yc9+j6} zVs>gAz}d06RNb-pljPIavnCqKZToQYyT*fsRE%{(;s}D4IcdKZjtTon!^62nA$oGp zcWQjj$5%$OHkdIjt^W6_BxSC|^GGgm68518t{|-J6bFzgHxRT?=v&n7kk58^C=j-$sm1{V1;(B3TK}OzS`>0_;?fS@!DcDs2 z$_VGnQoDWZ))Khp^>-|~3j)cqp*Rl5fphyLA05lhsR~1>@@iKOQ*G<+n1VG2B4@y=D_(Jv`E(|MyPZEZ*dB7 z;yZ5F(?rp-vZgsH;gk%M>8ZNegS(Thdj z_nDSj2(>&M$xNdgdL2Aj5&NnjaMls$U82cGdir9C^#PmSyI}y>pK=(y<)I-M!{*5@ zenLmKnFD=t1bgFIUD!jKjL$!n27ti+X-&+DQ~&HMk~eq*dtfd6s~~vdgZtm?n}6B+ zPo6k^`Pa$uiO-q)iK#Zt-j*dkqegw{*wx z!j|>)c}=o_P{j4{qOXX;uR53+5SFh$+;A$~Vj<-^%uDclJ*$ckzA&o?_|8$R_P%)>&VpU#zC zY$6aBLR4~)j8I3*>6PC;31L-aCC5Z9{8vtEXIW6)ypzoeZJB3zq6YC=H&*p$`trei zs)RlvHGegFQrC!FoS4gUtya0F+Owg3pX7OIRW+^+N=xTcd6#weMG(7K$8err18%O3 z=}}z?XJ%tTWM)BRg^rCb!!3TXF2p)}>vu+beXQWt;qaCRXTT zzXd<(gySF;U>oA>QsEy;*H|B>SKYQ!R4Se%Xgzbcu5} zEK@9N!!4R7%wsv+%$Wvb*G$2-BzXByOnw|^{z~b*!qJkdgH-d!Yh1G8s^k5;slpg~ z2|>wC^DGCYCcklT%_KWvGZ`LS0X!u(6QOjfWr8-^np>5NDmU4oFMTD&_iG+z^&%NNB+SuzObw;WnwNFz?zq|M|~%_5+$FT4HkoCzt(K?_#y7 z`x^;y9H?g<+sc@?yFC|FeR>w;rq69 zc7Ikg9X85{(b1^)KM!&Yd6J-Yo8( zd$rrdewu3AE_HnVeCT*~e0rIC|9+q{5b84ORp~t)LO^Yd4=YabRouGC_8+MnHMrU) zwr#uugFrLI-cp8%QI}Su*CF;X@Q=DwDP)B0fH@S^m%*5f#v; zWxi}RhCT)*I;JRrW9E07kO36|oHb*CzaU?8AHGm2MAs?h9>^l6{$Qm#?u=hT+S!A# zoCf|RZfmr1o*;B`23D}~+VjRA%6~L8+yd~ukpuhR6OIEdv$Mst1nIl{p~9!%)4R5I zhg0T`5R_qa35wzb?jol*WVU6Q5wM%d1HGTzeumaMeMM&Z2rRWU?(VB2Ws$ys0>g}f z!RFNT!8$kmg8TO^U9-T62BfUMYcd*Km4r;l*qd~^Whuln0#NrQ($>3yskT}))PVjj z*SgWI=;T_n87`F_)ePA*|HB}?I+?^2b+6r6LXEV) z`DfW{E@@VG7{3>1?QUDN2w7sHjv)5ywcNYg zr2t`SYCORx(&YwiiYl)|eIGc1lvI9&@Hfz-#^D_Ssz(i(K`bR9bi+J40P;=b^KKM= zaIG?y!|Ti)_q|ED#y9C1b^wXze;?K2J+ z9u2!gd!ep|y{BeAAy9XGn}m8w=NU)B)W8cwjUER3G>a_t4*rYMGffQhc1QIhw9 zX(?ssw0-)iDy&UjCf4y1O;vCmBaD6}N2jO`6*>P{cB$F3%w1ESHVqGx&a!!*A8Sv8 zuK{z*OTTUYX$m$KXky#0*|+DbF6|u?_x8d5+)^G+M%S13!J_{Q7dv`sOsbB5=5IWY;8V~{h%0b zn>AP4qJrM!+=3}B}T%*60`T|>ig=6-PHn6c(!npwzF z4#Cf)j{%jE?dy0N!_XY77mhwW7TFj0aYRrC4t7b2Y^&LAJ$zORl{E-$t+L+Ff;>&b zrc>v%?%H5d{EEO(x%Q)e-T>*g)uN*s*%%Tmsoq?2MiqMlOYo)NNWjY`Svh@L4F$Fa zn)FrcI9|#~=?})cD16@?Je=f8ehH!*(huy5Oz`ygPgF-*ueL0BXa<8w{*kIFM*Bft zTOTC!*Ao3+qqVyQ2jeqF{_QF6pEQ8k<82!WH|=N@96i%4_4cA&Ku=vcSH=B|Z1X^{ zPv)}E#Q8Nv0M*N7Zx65GRg)6GRH2q6$w@;%yzkj%jowAbi5CxUy=?x_eCx)b$28!0 zLUq04WwJcwm=+9Di>q-mv288%l5wMnt+;K}3`W^0Y{^jU+$J`^ zuYSv$Ths9k!9DbRzwp5QZW$KT!9X&rfiy9$9}Fh`OcDfAJB}feW0BD|CNTzyu9oz% z4D^$zEaPHIrhtdyS1sW#OWZ@qyxtUQ&(nJos$NBT8CCer+Z{4C3&`j3a*t>WliL~qW zi4J3~y4pqhOIG9sSdagtfC%{MCqbA@kFue7XVt#2zkI-qK69PqtVhF6zt|ul|7A7K zl33l%h%eG|T)AeAZ;NQFA^L4!u0X)Pf|g}wMTuvLDyQW5gdQswZyM0p)YAI#$;r>p z&b{{shK{}C)Fy$Qs(8Ebo?HKe8yz1FQbk{~MrAz<1YduWV{Oe8luC{}`}LtEI3X|T zE6DWGRmHiSThmDOBztN5BNeTw9@|C1OC8{#!`0tHI%V^%%@dW>zUoJhD4{ zM7fJa452Yw^7Lv{CXBsR&VuBh1hgSS#!yX*h@1*dORq#Ydq z@L~x~K_<%6=kYE>k|xC^MZGbb(MIjz$)O&a%FeD`b|p$5bbx%iFb+ajLs3_<2=3>aGk2%sz12nq&tuS>vPZ<_~VnNrh2ip zDa%i3jhbr0))!x=+o~EAds|RG@nyV>A0Flc8?=y_kuP{0ncI3f4F*J{z%00tDn~B) z*xP#}9n&nQZP%E5*=uU+ROGrs#)rq!UE6gH)Kn))U@?C}_MYIet8+KXBoII-M7lH1YU16@6+Xvj<`N znm;EE>er) zXuTv2p!8tuKkJ!tHaUEC<|>Dnp(1a_%jSzcMGoFmQ>QyaD>}xWJxjQhb3G7pgYWsS zdbSI_ey#l(@FB=cJRTyX})U!}CLD6&w9m|I|fF z>7FDM!dUOjG72;eTax{~{>$2?u|vb-LyZgJ7t=$M-d z4O<7tb%THUvf=8pQpY`B?z<&5H-5uZ8Pi`z<))^Y%lALQ`C2qKcf7o}#2Qn`tGNyOq@&VSUOv30#pV#KRCVbc&E?qv-|h#{p4#I zEhF8evSAeC$^8lR00RE2ZCXLRV*I}%qyMLc%tnImMv3hIx)41%ih3g!=#e4({s+r{ z$*4kD@BHKC(0MRSk=)kzNmKvI%I<5CC+I&ueOo9a$ity4>}Ht%ryl3;z>YFvG7OK{ zgdVea;>bh$FIwP;kdTVn+EV0;N9oo;ziH*=ZB9Wkzn$iQxap<~{%6_u&miA%&qFxD_nAcb$K09w`B0-aCl)k}s(xep0{vCm2++ z_$*m=3`4jd^~pWD7FeSG*^{sSbU~oPU;TBH`hg7oZ1^1$NWAB|SHX`$I^!DQy2qg% zMX8Oo_YF}M@IFggddTM0e(t6A + + + diff --git a/frontend/src/Editor/DataSourceManager/DataSourceTypes.js b/frontend/src/Editor/DataSourceManager/DataSourceTypes.js index 986358f348..a2c3509c21 100644 --- a/frontend/src/Editor/DataSourceManager/DataSourceTypes.js +++ b/frontend/src/Editor/DataSourceManager/DataSourceTypes.js @@ -148,6 +148,22 @@ export const apiSources = [ }, customTesting: true }, + { + name: 'GraphQL', + kind: 'graphql', + options: { + url: { type: 'string' }, + headers: { type: 'array' }, + url_params: { type: 'array' }, + body: { type: 'array' }, + }, + exposedVariables: { + isLoading: {}, + data: {}, + rawData: {} + }, + customTesting: true + }, { name: 'Stripe', kind: 'stripe', diff --git a/frontend/src/Editor/DataSourceManager/DefaultOptions.js b/frontend/src/Editor/DataSourceManager/DefaultOptions.js index b3c4f82e7a..40c9f09ddf 100644 --- a/frontend/src/Editor/DataSourceManager/DefaultOptions.js +++ b/frontend/src/Editor/DataSourceManager/DefaultOptions.js @@ -67,6 +67,11 @@ export const defaultOptions = { headers: { value: [['', '']] }, custom_auth_params: { value: [['', '']] } }, + graphql: { + url: { value: '' }, + headers: { value: [['', '']] }, + url_params: { value: [['', '']] } + }, googlesheets: { access_type: { value: 'read' } }, diff --git a/frontend/src/Editor/DataSourceManager/SourceComponents/Graphql.jsx b/frontend/src/Editor/DataSourceManager/SourceComponents/Graphql.jsx new file mode 100644 index 0000000000..50efef480a --- /dev/null +++ b/frontend/src/Editor/DataSourceManager/SourceComponents/Graphql.jsx @@ -0,0 +1,92 @@ +import React from 'react'; +import Button from 'react-bootstrap/Button'; + +export const Graphql = ({ + optionchanged, createDataSource, options, isSaving +}) => { + + function addNewKeyValuePair(option) { + const newPairs = [...options[option].value, ['', '']]; + optionchanged(option, newPairs); + } + + function removeKeyValuePair(option, index) { + options[option].value.splice(index, 1); + optionchanged(option, options[option].value); + } + + function keyValuePairValueChanged(e, keyIndex, option, index) { + const value = e.target.value; + options[option].value[index][keyIndex] = value; + optionchanged(option, options[option].value); + } + + return ( +
+
+
+ + optionchanged('url', e.target.value)} + value={options.url.value} + /> +
+ + {[{name: 'URL parameters', value: 'url_params'},{name: 'Headers', value: 'headers'}].map((option) => ( +
+
+
+ +
+
+ {(options[option.value].value || []).map((pair, index) => ( +
+ keyValuePairValueChanged(e, 0, option.value, index)} + /> + keyValuePairValueChanged(e, 1, option.value, index)} + /> + { + removeKeyValuePair(option.value, index); + }} + >x +
+ ))} + +
+
+
+ ))} +
+ + +
+
+
+ +
+
+
+ ); +}; diff --git a/frontend/src/Editor/DataSourceManager/SourceComponents/index.js b/frontend/src/Editor/DataSourceManager/SourceComponents/index.js index f2504dcf41..149d8c09a4 100644 --- a/frontend/src/Editor/DataSourceManager/SourceComponents/index.js +++ b/frontend/src/Editor/DataSourceManager/SourceComponents/index.js @@ -10,6 +10,7 @@ import { Slack } from './Slack'; import { Mongodb } from './Mongodb'; import { Dynamodb } from './Dynamodb'; import { Airtable } from './Airtable'; +import { Graphql } from './Graphql'; import { Mssql } from './Mssql'; export const SourceComponents = { @@ -25,5 +26,6 @@ export const SourceComponents = { Mongodb, Dynamodb, Airtable, + Graphql, Mssql }; diff --git a/frontend/src/Editor/QueryManager/QueryEditors/Graphql.jsx b/frontend/src/Editor/QueryManager/QueryEditors/Graphql.jsx new file mode 100644 index 0000000000..64852e001c --- /dev/null +++ b/frontend/src/Editor/QueryManager/QueryEditors/Graphql.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { CodeHinter } from '../../CodeBuilder/CodeHinter'; +import { changeOption } from './utils'; + +class Graphql extends React.Component { + constructor(props) { + super(props); + + this.state = {}; + } + + componentDidMount() { + this.setState({ + options: this.props.options + }); + } + + render() { + const { options } = this.state; + + return ( +
+ {options && ( +
+ changeOption(this, 'query', value)} + /> +
+ )} +
+ ); + } +} + +export { Graphql }; diff --git a/frontend/src/Editor/QueryManager/QueryEditors/index.js b/frontend/src/Editor/QueryManager/QueryEditors/index.js index 739e6c7e7e..25a67da9ff 100644 --- a/frontend/src/Editor/QueryManager/QueryEditors/index.js +++ b/frontend/src/Editor/QueryManager/QueryEditors/index.js @@ -10,6 +10,7 @@ import { Slack } from './Slack'; import { Mongodb } from './Mongodb'; import { Dynamodb } from './Dynamodb'; import { Airtable } from './Airtable'; +import { Graphql } from './Graphql'; import { Mssql } from './Mssql'; export const allSources = { @@ -25,5 +26,6 @@ export const allSources = { Mongodb, Dynamodb, Airtable, + Graphql, Mssql }; diff --git a/frontend/src/Editor/QueryManager/constants.js b/frontend/src/Editor/QueryManager/constants.js index 8528fbf1ca..853babcfa4 100644 --- a/frontend/src/Editor/QueryManager/constants.js +++ b/frontend/src/Editor/QueryManager/constants.js @@ -4,6 +4,7 @@ export const defaultOptions = { query: 'PING' }, mysql: {}, + graphql: {}, firestore: { path: '' }, From cf4b9ce8ee0c97109b6fda3013d86b4a7c97253b Mon Sep 17 00:00:00 2001 From: Prasad Walvekar Date: Tue, 22 Jun 2021 20:02:22 +0530 Subject: [PATCH 10/16] Fix text widget alignment (#260) --- frontend/src/Editor/Components/Text.jsx | 4 +++- frontend/src/Editor/Components/components.js | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/Editor/Components/Text.jsx b/frontend/src/Editor/Components/Text.jsx index 3cd4c71670..2894709264 100644 --- a/frontend/src/Editor/Components/Text.jsx +++ b/frontend/src/Editor/Components/Text.jsx @@ -35,7 +35,9 @@ export const Text = function Text({ const computedStyles = { color, width, - height + height, + display: 'flex', + alignItems: 'center' }; return ( diff --git a/frontend/src/Editor/Components/components.js b/frontend/src/Editor/Components/components.js index eb33e9dbe3..dfbe248c29 100644 --- a/frontend/src/Editor/Components/components.js +++ b/frontend/src/Editor/Components/components.js @@ -460,8 +460,8 @@ export const componentTypes = [ loadingState: { type: 'code', displayName: 'Show loading state' } }, defaultSize: { - width: 210, - height: 24 + width: 200, + height: 30 }, events: [ From b30b0b0b103722f2c55cc2235da742ba0a1f0ed1 Mon Sep 17 00:00:00 2001 From: Akshay Date: Tue, 22 Jun 2021 20:03:13 +0530 Subject: [PATCH 11/16] Feature: Friendly URLs for applications (#285) * add migration to create slug for apps * update migration file to include default slug population * update schemafile * add capability to get app by slug * add capability to frontend to set and fetch app by slug * remove unrelated changes from lint * respond default 204 itself when success else render error * make input field to show error when slug taken * wip: show error message * Cleanup * add new route to fetch app by slug * add slug to show json jbuilder * update react components to fetch app by both id and slug * fix launch link not being set Co-authored-by: navaneeth --- app/controllers/apps_controller.rb | 46 +++-- app/models/app.rb | 16 +- app/views/apps/index.json.jbuilder | 3 +- app/views/apps/show.json.jbuilder | 1 + config/routes.rb | 2 +- db/migrate/20210619124759_add_slug_to_apps.rb | 10 ++ db/schema.rb | 6 +- frontend/src/App/App.jsx | 2 +- frontend/src/Editor/Editor.jsx | 162 +++++++++--------- frontend/src/Editor/ManageAppUsers.jsx | 62 +++++-- frontend/src/Editor/Viewer.jsx | 17 +- frontend/src/HomePage/HomePage.jsx | 2 +- frontend/src/_services/app.service.js | 14 +- frontend/src/_styles/theme.scss | 124 +++++++------- 14 files changed, 277 insertions(+), 190 deletions(-) create mode 100755 db/migrate/20210619124759_add_slug_to_apps.rb diff --git a/app/controllers/apps_controller.rb b/app/controllers/apps_controller.rb index 7fd086beec..328196ff28 100644 --- a/app/controllers/apps_controller.rb +++ b/app/controllers/apps_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class AppsController < ApplicationController - skip_before_action :authenticate_request, only: [:show] + skip_before_action :authenticate_request, only: %i[show slug] def index authorize App @@ -15,10 +15,10 @@ class AppsController < ApplicationController @scope = @folder.apps end - @apps = @scope.order("created_at desc") - .page(params[:page]) - .per(10) - .includes(:user) + @apps = @scope.order('created_at desc') + .page(params[:page]) + .per(10) + .includes(:user) @meta = { total_pages: @apps.total_pages, @@ -30,17 +30,17 @@ class AppsController < ApplicationController def create authorize App - @app = App.create({ - name: "Untitled app", - organization: @current_user.organization, - current_version: AppVersion.new(name: "v0"), - user: @current_user - }) - AppUser.create(app: @app, user: @current_user, role: "admin") + @app = App.create!({ + name: 'Untitled app', + organization: @current_user.organization, + current_version: AppVersion.new(name: 'v0'), + user: @current_user + }) + AppUser.create(app: @app, user: @current_user, role: 'admin') end def show - @app = App.find params[:id] + @app = App.find(params[:id]) # Logic to bypass auth for public apps unless @app.is_public @@ -49,10 +49,28 @@ class AppsController < ApplicationController end end + def slugs + @app = App.find_by(slug: params[:slug]) + + unless @app.is_public + authenticate_request + authorize @app, :show? + end + + render :show + end + def update @app = App.find params[:id] authorize @app - @app.update(params["app"].permit("name", "current_version_id", "is_public")) + + @app.assign_attributes(params[:app].permit(:name, :current_version_id, :is_public, :slug)) + + if @app.valid? + @app.save # renders default status 204 + else + render json: { message: @app.errors.full_messages }, status: 422 + end end def users diff --git a/app/models/app.rb b/app/models/app.rb index f38e633216..2de962af53 100644 --- a/app/models/app.rb +++ b/app/models/app.rb @@ -2,9 +2,19 @@ class App < ApplicationRecord belongs_to :organization - has_many :data_queries - has_many :app_users - has_many :app_versions + has_many :data_queries, dependent: :destroy + has_many :app_users, dependent: :destroy + has_many :app_versions, dependent: :destroy belongs_to :current_version, class_name: "AppVersion", optional: true belongs_to :user, optional: true + + validates :slug, uniqueness: { scope: :organization } + + after_save :set_default_slug_as_id, if: -> { self.slug.blank? } + + private + + def set_default_slug_as_id + self.update_attribute(:slug, self.id) + end end diff --git a/app/views/apps/index.json.jbuilder b/app/views/apps/index.json.jbuilder index 8c19750355..3bc682e21c 100644 --- a/app/views/apps/index.json.jbuilder +++ b/app/views/apps/index.json.jbuilder @@ -1,10 +1,11 @@ json.apps do json.array! @apps do |app| json.id app.id + json.slug app.slug json.name app.name json.created_at time_ago_in_words(app.created_at) json.user app.user || {} end end -json.meta @meta.as_json \ No newline at end of file +json.meta @meta.as_json diff --git a/app/views/apps/show.json.jbuilder b/app/views/apps/show.json.jbuilder index ad9b079625..ba0e072a75 100644 --- a/app/views/apps/show.json.jbuilder +++ b/app/views/apps/show.json.jbuilder @@ -1,5 +1,6 @@ json.id @app.id json.name @app.name +json.slug @app.slug json.definition @app.current_version.definition if @app.current_version json.definition {} unless @app.current_version json.current_version_id @app.current_version_id diff --git a/config/routes.rb b/config/routes.rb index 291ea936da..99463cfc57 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,6 +3,7 @@ Rails.application.routes.draw do resources :versions, only: %i[index create update] get '/users', to: 'apps#users' + get '/slugs/:slug', to: 'apps#slugs', on: :collection end resources :data_sources, only: %i[create index update] do @@ -54,5 +55,4 @@ Rails.application.routes.draw do get '/health', to: 'probe#health_check' post 'password/forgot', to: 'forgot_password#forgot' post 'password/reset', to: 'forgot_password#reset' - end diff --git a/db/migrate/20210619124759_add_slug_to_apps.rb b/db/migrate/20210619124759_add_slug_to_apps.rb new file mode 100755 index 0000000000..ab47766f82 --- /dev/null +++ b/db/migrate/20210619124759_add_slug_to_apps.rb @@ -0,0 +1,10 @@ +class AddSlugToApps < ActiveRecord::Migration[6.1] + def change + add_column :apps, :slug, :string + add_index :apps, [:organization_id, :slug] + + App.find_each do |app| + app.update(slug: app.id) + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 49adde58d7..5ffd80a70d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_06_17_031153) do +ActiveRecord::Schema.define(version: 2021_06_19_124759) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" @@ -45,7 +45,9 @@ ActiveRecord::Schema.define(version: 2021_06_17_031153) do t.uuid "current_version_id" t.boolean "is_public", default: false t.uuid "user_id" + t.string "slug" t.index ["current_version_id"], name: "index_apps_on_current_version_id" + t.index ["organization_id", "slug"], name: "index_apps_on_organization_id_and_slug" t.index ["organization_id"], name: "index_apps_on_organization_id" t.index ["user_id"], name: "index_apps_on_user_id" end @@ -160,7 +162,6 @@ ActiveRecord::Schema.define(version: 2021_06_17_031153) do t.datetime "updated_at", precision: 6, null: false t.string "role" t.uuid "organization_id" - t.text "image" t.string "invitation_token" t.string "forgot_password_token" t.datetime "forgot_password_token_sent_at" @@ -178,7 +179,6 @@ ActiveRecord::Schema.define(version: 2021_06_17_031153) do add_foreign_key "data_source_user_oauth2s", "data_sources" add_foreign_key "data_source_user_oauth2s", "users" add_foreign_key "data_sources", "apps" - add_foreign_key "endpoints", "integrations" add_foreign_key "folder_apps", "apps" add_foreign_key "folder_apps", "folders" add_foreign_key "folders", "organizations" diff --git a/frontend/src/App/App.jsx b/frontend/src/App/App.jsx index 6441c2578e..00db582b37 100644 --- a/frontend/src/App/App.jsx +++ b/frontend/src/App/App.jsx @@ -77,7 +77,7 @@ class App extends React.Component { - +
diff --git a/frontend/src/Editor/Editor.jsx b/frontend/src/Editor/Editor.jsx index ad7057a12a..5e1e0078fd 100644 --- a/frontend/src/Editor/Editor.jsx +++ b/frontend/src/Editor/Editor.jsx @@ -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,7 +24,7 @@ import { onQueryConfirm, onQueryCancel, runQuery, - setStateAsync, + setStateAsync } from '@/_helpers/appUtils'; import { Confirm } from './Viewer/Confirm'; import ReactTooltip from 'react-tooltip'; @@ -43,7 +45,7 @@ class Editor extends React.Component { userVars = { email: currentUser.email, firstName: currentUser.first_name, - lastName: currentUser.last_name, + lastName: currentUser.last_name }; } @@ -63,15 +65,15 @@ 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 @@ -81,45 +83,41 @@ class Editor extends React.Component { 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 + })); } ); }; @@ -127,7 +125,7 @@ class Editor extends React.Component { fetchDataQueries = () => { this.setState( { - loadingDataQueries: true, + loadingDataQueries: true }, () => { dataqueryService.getAll(this.state.appId).then((data) => { @@ -137,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] }; }); @@ -167,9 +165,9 @@ class Editor extends React.Component { currentState: { ...this.state.currentState, queries: { - ...queryState, - }, - }, + ...queryState + } + } }); } ); @@ -195,9 +193,9 @@ class Editor extends React.Component { currentState: { ...this.state.currentState, components: { - ...componentState, - }, - }, + ...componentState + } + } }); }; @@ -212,7 +210,7 @@ class Editor extends React.Component { switchSidebarTab = (tabIndex) => { this.setState({ - currentSidebarTab: tabIndex, + currentSidebarTab: tabIndex }); }; @@ -238,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; @@ -270,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 + } + } + } }); }; @@ -285,10 +287,10 @@ class Editor extends React.Component { ...this.state.appDefinition.components, [newComponent.id]: { ...this.state.appDefinition.components[newComponent.id], - ...newComponent, - }, - }, - }, + ...newComponent + } + } + } }); }; @@ -357,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' }); }); }} @@ -369,7 +371,7 @@ class Editor extends React.Component { )} {isLoading === true && (
-
+
)}
@@ -379,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%' }); }; @@ -403,20 +405,20 @@ class Editor extends React.Component { }; filterQueries = (value) => { - if(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 }); + this.setState({ showQuerySearchField: !this.state.showQuerySearchField }); } render() { @@ -425,6 +427,7 @@ class Editor extends React.Component { selectedComponent, appDefinition, appId, + slug, dataSources, loadingDataQueries, dataQueries, @@ -446,8 +449,7 @@ class Editor extends React.Component { dataQueriesDefaultText, showQuerySearchField } = this.state; - - const appLink = `/applications/${appId}`; + const appLink = slug ? `/applications/${slug}` : ''; return (
@@ -530,10 +532,10 @@ class Editor extends React.Component {
-
+
@@ -574,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%' }} >
@@ -672,8 +680,7 @@ class Editor extends React.Component { You haven't added data sources yet.
- {showQuerySearchField && -
+ {showQuerySearchField + &&
{dataQueriesDefaultText}
- + Users and permissions
@@ -144,7 +164,12 @@ class ManageAppUsers extends React.Component { Get shareable link for this application
- + {appLink} + { e.persist(); this.delayedSlugChange(e); }} + defaultValue={this.props.slug} /> Copy +
{slugError}

@@ -243,8 +269,8 @@ class ManageAppUsers extends React.Component {
- ); - } + ); + } } export { ManageAppUsers }; diff --git a/frontend/src/Editor/Viewer.jsx b/frontend/src/Editor/Viewer.jsx index 71fe7a7b31..b07ccf513b 100644 --- a/frontend/src/Editor/Viewer.jsx +++ b/frontend/src/Editor/Viewer.jsx @@ -37,7 +37,7 @@ class Viewer extends React.Component { } componentDidMount() { - const id = this.props.match.params.id; + const slug = this.props.match.params.slug; const deviceWindowWidth = window.screen.width - 5; const isMobileDevice = deviceWindowWidth < 600; @@ -51,7 +51,7 @@ class Viewer extends React.Component { currentLayout: isMobileDevice ? 'mobile' : 'desktop' }); - appService.getApp(id).then((data) => this.setState( + appService.getAppBySlug(slug).then((data) => this.setState( { app: data, isLoading: false, @@ -69,7 +69,7 @@ class Viewer extends React.Component { const currentUser = authenticationService.currentUserValue; let userVars = { }; - if(currentUser) { + if (currentUser) { userVars = { email: currentUser.email, firstName: currentUser.first_name, @@ -77,7 +77,6 @@ class Viewer extends React.Component { }; } - this.setState({ currentSidebarTab: 2, selectedComponent: null, @@ -93,10 +92,10 @@ class Viewer extends React.Component { } render() { - const { - appDefinition, - showQueryConfirmation, - currentState, + const { + appDefinition, + showQueryConfirmation, + currentState, isLoading, currentLayout, deviceWindowWidth, @@ -131,7 +130,7 @@ class Viewer extends React.Component {
-
+
false} // function not relevant in viewer diff --git a/frontend/src/HomePage/HomePage.jsx b/frontend/src/HomePage/HomePage.jsx index 5d1b6a423a..ea3ead08b7 100644 --- a/frontend/src/HomePage/HomePage.jsx +++ b/frontend/src/HomePage/HomePage.jsx @@ -172,7 +172,7 @@ class HomePage extends React.Component { Date: Tue, 22 Jun 2021 22:48:55 +0530 Subject: [PATCH 12/16] Chore: reference the entrypoint file in the dockerfile (#292) --- docker/server.Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/server.Dockerfile b/docker/server.Dockerfile index 91f6210de0..5425eac8ba 100644 --- a/docker/server.Dockerfile +++ b/docker/server.Dockerfile @@ -14,5 +14,5 @@ RUN gem install bundler && RAILS_ENV=production bundle install --jobs 20 --retry ENV RAILS_ENV=production COPY . ./ - RUN ["chmod", "755", "docker/entrypoints/server.sh"] +ENTRYPOINT ["docker/entrypoints/server.sh"] From 097992336763ead1fcd0dd99312d454092df4f54 Mon Sep 17 00:00:00 2001 From: Navaneeth Pk Date: Tue, 22 Jun 2021 23:20:08 +0530 Subject: [PATCH 13/16] Chore: Add rewrite rules to netlify config (#294) * Rewrite rules for netlify --- netlify.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/netlify.toml b/netlify.toml index f2196d1e71..1a7526debd 100644 --- a/netlify.toml +++ b/netlify.toml @@ -4,3 +4,8 @@ [template.environment] NODE_ENV = "production" + +[[redirects]] + from = "/*" + to = "/" + status = 200 \ No newline at end of file From cff1ff85bc352f662e6e28095ecebd5963f5169f Mon Sep 17 00:00:00 2001 From: Prasad Walvekar Date: Tue, 22 Jun 2021 23:22:37 +0530 Subject: [PATCH 14/16] [Tests] Add cypress tests for login page (#291) --- cypress.json | 6 +++++ cypress/integration/auth.spec.js | 37 ++++++++++++++++++++++++++++ cypress/plugins/index.js | 22 +++++++++++++++++ cypress/support/commands.js | 10 ++++++++ cypress/support/index.js | 20 +++++++++++++++ frontend/src/LoginPage/LoginPage.jsx | 6 +++-- 6 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 cypress.json create mode 100644 cypress/integration/auth.spec.js create mode 100644 cypress/plugins/index.js create mode 100644 cypress/support/commands.js create mode 100644 cypress/support/index.js diff --git a/cypress.json b/cypress.json new file mode 100644 index 0000000000..ce1b43c0d2 --- /dev/null +++ b/cypress.json @@ -0,0 +1,6 @@ +{ + "baseUrl": "http://localhost:8082", + "env": { + "apiUrl": "http://localhost:3000" + } +} \ No newline at end of file diff --git a/cypress/integration/auth.spec.js b/cypress/integration/auth.spec.js new file mode 100644 index 0000000000..7ba3103c65 --- /dev/null +++ b/cypress/integration/auth.spec.js @@ -0,0 +1,37 @@ +describe('User login', () => { + it('should take user to login page', () => { + cy.visit('/login'); + cy.get('.card-title') + .should('have.text', 'Login to your account'); + }); + + it('should redirect unauthenticated user to login page', () => { + cy.visit('/'); + cy.location('pathname').should('equal', '/login'); + }); + + it('should display invalid email or password error', () => { + cy.login('fake_email', 'abcdefg'); + cy.checkToastMessage('toast-login-auth-error', 'Invalid email or password') + }); + + it('should take user to the forgot password page', () => { + cy.visit('/forgot-password'); + cy.get('.card-title') + .should('have.text', 'Forgot Password'); + }) + + it('should take user to the signup page', () => { + cy.visit('/signup'); + cy.get('.card-title') + .should('have.text', 'Create a ToolJet account'); + }) + + it('should sign in the user', () => { + cy.visit('/login'); + cy.login('dev@tooljet.io', 'password'); + cy.location('pathname').should('equal', '/'); + cy.get('.page-title') + .should('have.text', 'All applications'); + }) +}) \ No newline at end of file diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js new file mode 100644 index 0000000000..59b2bab6e4 --- /dev/null +++ b/cypress/plugins/index.js @@ -0,0 +1,22 @@ +/// +// *********************************************************** +// This example plugins/index.js can be used to load plugins +// +// You can change the location of this file or turn off loading +// the plugins file with the 'pluginsFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/plugins-guide +// *********************************************************** + +// This function is called when a project is opened or re-opened (e.g. due to +// the project's config changing) + +/** + * @type {Cypress.PluginConfig} + */ +// eslint-disable-next-line no-unused-vars +module.exports = (on, config) => { + // `on` is used to hook into various events Cypress emits + // `config` is the resolved Cypress config +} diff --git a/cypress/support/commands.js b/cypress/support/commands.js new file mode 100644 index 0000000000..3a9397b05f --- /dev/null +++ b/cypress/support/commands.js @@ -0,0 +1,10 @@ +Cypress.Commands.add('login', (email, password) => { + cy.visit('/login'); + cy.get('[data-testid="emailField"]').type(email); + cy.get('[data-testid="passwordField"]').type(password); + cy.get('[data-testid="loginButton"').click(); +}) + +Cypress.Commands.add('checkToastMessage', (toastId, message) => { + cy.get(`[id=${toastId}]`).should('contain', message); +}); \ No newline at end of file diff --git a/cypress/support/index.js b/cypress/support/index.js new file mode 100644 index 0000000000..d68db96df2 --- /dev/null +++ b/cypress/support/index.js @@ -0,0 +1,20 @@ +// *********************************************************** +// This example support/index.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands' + +// Alternatively you can use CommonJS syntax: +// require('./commands') diff --git a/frontend/src/LoginPage/LoginPage.jsx b/frontend/src/LoginPage/LoginPage.jsx index cd68a2714e..68516326c4 100644 --- a/frontend/src/LoginPage/LoginPage.jsx +++ b/frontend/src/LoginPage/LoginPage.jsx @@ -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" />
@@ -82,12 +83,13 @@ class LoginPage extends React.Component { className="form-control" placeholder="Password" autoComplete="off" + data-testid="passwordField" />
-
From 8369553460c4e453309bcafa823d98267e0a41b6 Mon Sep 17 00:00:00 2001 From: Ashish Date: Wed, 23 Jun 2021 14:48:07 +0530 Subject: [PATCH 15/16] [Docs] update docker setup guide to mention the secret key size (#296) --- docs/docs/contributing-guide/setup/docker.md | 85 +++++++++++--------- 1 file changed, 46 insertions(+), 39 deletions(-) diff --git a/docs/docs/contributing-guide/setup/docker.md b/docs/docs/contributing-guide/setup/docker.md index 1651f97ef9..34028f8b39 100644 --- a/docs/docs/contributing-guide/setup/docker.md +++ b/docs/docs/contributing-guide/setup/docker.md @@ -13,59 +13,66 @@ Make sure you have the latest version of `docker` and `docker-compose` installed [Official docker-compose installation guide](https://docs.docker.com/compose/install/) We recommend: -```bash -$ docker --version -Docker version 19.03.12, build 48a66213fe -$ docker-compose --version -docker-compose version 1.26.2, build eefe0d31 -``` + ```bash + $ docker --version + Docker version 19.03.12, build 48a66213fe + $ docker-compose --version + docker-compose version 1.26.2, build eefe0d31 + ``` ## Setting up 1. Close the repository -```bash -$ git clone https://github.com/tooljet/tooljet.git -``` + ```bash + $ git clone https://github.com/tooljet/tooljet.git + ``` 2. Create a `.env` file by copying `.env.example`. More information on the variables that can be set is given here: env variable reference -```bash -$ cp .env.example .env -``` + ```bash + $ cp .env.example .env + ``` -3. Populate the keys in the `.env` file. Run `openssl rand -hex 64` to create secure secrets and use them as the values for `LOCKBOX_MASTER_KEY` and `SECRET_KEY_BASE`. +3. Populate the keys in the `.env` file. + :::info + `SECRET_KEY_BASE` requires a 64 byte key. (If you have `openssl` installed, run `openssl rand -hex 64` to create a 64 byte secure random key) -Example: -```bash -$ cat .env -TOOLJET_HOST=http://localhost:8082 -LOCKBOX_MASTER_KEY=c92bcc7f112ffbdd131d1fb6c5005e372b8802f85f6c4586e5a88f57a541382841c8c99e5701b84862e448dd5db846f705321a41bd48a0fed1b58b9596a3877f -SECRET_KEY_BASE=4229d5774cfe7f60e75d6b3bf3a1dbb054a696b6d21b6d5de7b73291899797a222265e12c0a8e8d844f83ebacdf9a67ec42584edf1c2b23e1e7813f8a3339041 -``` + `LOCKBOX_MASTER_KEY` requires a 32 byte key. (Run `openssl rand -hex 32` to create a 32 byte secure random key) + ::: + + Example: + ```bash + $ cat .env + TOOLJET_HOST=http://localhost:8082 + LOCKBOX_MASTER_KEY=1d291a926ddfd221205a23adb4cc1db66cb9fcaf28d97c8c1950e3538e3b9281 + SECRET_KEY_BASE=4229d5774cfe7f60e75d6b3bf3a1dbb054a696b6d21b6d5de7b73291899797a222265e12c0a8e8d844f83ebacdf9a67ec42584edf1c2b23e1e7813f8a3339041 + ``` 4. Build docker images -```bash -$ docker-compose build -``` + ```bash + $ docker-compose build + ``` -4. ToolJet server is built using Ruby on Rails. You have to reset the database if building for the first time. -```bash -$ docker-compose run server rails db:reset -``` +5. ToolJet server is built using Ruby on Rails. You have to reset the database if building for the first time. + ```bash + $ docker-compose run server rails db:reset + ``` -5. Run ToolJet -```bash -$ docker-compose up -``` +6. Run ToolJet + ```bash + $ docker-compose up + ``` -6. The app should now be served locally at http://localhost:8082/. You can login using the default user created. - [ email: dev@tooljet.io - password: password - ] +7. ToolJet should now be served locally at `http://localhost:8082`. You can login using the default user created. + ``` + email: dev@tooljet.io + password: password + ``` -7. To shut down the containers, -```bash -$ docker-compose stop -``` + +8. To shut down the containers, + ```bash + $ docker-compose stop + ``` ## Running Rails tests From 966ff7ba42ef9a71c0fe8e5d4100935539c7f7bb Mon Sep 17 00:00:00 2001 From: navaneeth Date: Wed, 23 Jun 2021 15:45:31 +0530 Subject: [PATCH 16/16] Bump ToolJet version --- config/application.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/application.rb b/config/application.rb index 8469e533ac..cb784188b9 100644 --- a/config/application.rb +++ b/config/application.rb @@ -19,7 +19,7 @@ require 'rails/test_unit/railtie' # you've limited to :test, :development, or :production. Bundler.require(*Rails.groups) -TOOLJET_VERSION = '0.5.10' +TOOLJET_VERSION = '0.5.11' module ToolJet class Application < Rails::Application