Merge branch 'release/v0.5.12' into main

This commit is contained in:
navaneeth 2021-07-01 12:46:21 +05:30
commit 22212a16bb
65 changed files with 721 additions and 185 deletions

View file

@ -49,6 +49,12 @@ class AppsController < ApplicationController
end
end
def destroy
app = App.find params[:id]
app.update(current_version: nil)
app.destroy
end
def slugs
@app = App.find_by(slug: params[:slug])

View file

@ -14,23 +14,27 @@ class OrganizationUsersController < ApplicationController
password = SecureRandom.uuid
org = @current_user.organization
user = User.create(
first_name: first_name,
last_name: last_name,
email: email,
password: password,
password_confirmation: password,
organization: org,
invitation_token: SecureRandom.uuid
)
if User.find_by(email: email).present?
render json: { message: "Email address is already taken" }, status: :unprocessable_entity
else
user = User.create(
first_name: first_name,
last_name: last_name,
email: email,
password: password,
password_confirmation: password,
organization: org,
invitation_token: SecureRandom.uuid
)
org_user = OrganizationUser.new(
role: role,
user: user,
organization: org
)
org_user = OrganizationUser.new(
role: role,
user: user,
organization: org
)
UserMailer.with(user: user, sender: @current_user).invitation_email.deliver if org_user.save
UserMailer.with(user: user, sender: @current_user).invitation_email.deliver if org_user.save
end
end
def change_role

View file

@ -3,8 +3,10 @@
class App < ApplicationRecord
belongs_to :organization
has_many :data_queries, dependent: :destroy
has_many :data_sources, dependent: :destroy
has_many :app_users, dependent: :destroy
has_many :app_versions, dependent: :destroy
has_many :folder_apps, dependent: :destroy
belongs_to :current_version, class_name: "AppVersion", optional: true
belongs_to :user, optional: true

View file

@ -48,7 +48,7 @@ class ElasticsearchQueryService
if operation == 'search'
index = options['index']
query = JSON.parse(options[:query])
query = JSON.parse(options['query'])
data = connection.search(index: index, body: query)
end

View file

@ -41,6 +41,23 @@ class GooglesheetsQueryService
end
error = result.code != 200
data = result
end
if operation === 'delete_row'
spreadsheet_id = options['spreadsheet_id']
sheet = options['sheet']
row_index = options['row_index'].to_i
result = delete_row_from_sheet(spreadsheet_id, sheet, row_index, access_token)
if result.code === 401
access_token = refresh_access_token
result = delete_row_from_sheet(spreadsheet_id, sheet, row_index, access_token)
end
data = result
error = result.code != 200
end
if operation === 'read'
@ -123,6 +140,30 @@ class GooglesheetsQueryService
'application/json', "Authorization": "Bearer #{access_token}" })
end
def delete_row_from_sheet(spreadsheet_id, sheet, row_index, access_token)
data = {
"requests": [
{
"deleteDimension": {
"range": {
"sheetId": sheet,
"dimension": "ROWS",
"startIndex": row_index - 1,
"endIndex": row_index
}
}
}
]
}.to_json
result = HTTParty.post(
"https://sheets.googleapis.com/v4/spreadsheets/#{spreadsheet_id}:batchUpdate",
body: data,
headers: { "Content-Type": 'application/json',
"Authorization": "Bearer #{access_token}" }
)
end
def get_spreadsheet_info(spreadsheet_id, access_token)
result = HTTParty.get("https://sheets.googleapis.com/v4/spreadsheets/#{spreadsheet_id}",

View file

@ -19,7 +19,7 @@ require 'rails/test_unit/railtie'
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
TOOLJET_VERSION = '0.5.11'
TOOLJET_VERSION = '0.5.12'
module ToolJet
class Application < Rails::Application

View file

@ -1,5 +1,5 @@
Rails.application.routes.draw do
resources :apps, only: %i[index create show update] do
resources :apps, only: %i[index create show update destroy] do
resources :versions, only: %i[index create update]
get '/users', to: 'apps#users'

View file

@ -12,14 +12,14 @@ describe('User login', () => {
it('should display invalid email or password error', () => {
cy.login('fake_email', 'abcdefg');
cy.checkToastMessage('toast-login-auth-error', 'Invalid email or password')
cy.checkToastMessage('toast-login-auth-error', 'Invalid email or password');
});
it('should take user to the forgot password page', () => {
cy.visit('/forgot-password');
cy.get('.card-title')
.should('have.text', 'Forgot Password');
})
});
it('should take user to the signup page', () => {
cy.visit('/signup');
@ -34,4 +34,22 @@ describe('User login', () => {
cy.get('.page-title')
.should('have.text', 'All applications');
})
it('should display error if email is not found for "Forgot password"', () => {
cy.visit('/forgot-password');
cy.get('[data-testid="emailField"]').type('abc@def.com');
cy.get('[data-testid="submitButton"').click();
cy.checkToastMessage('toast-forgot-password-email-error', 'Email address is not associated with a ToolJet cloud account.');
});
it('should send reset password confirmation code to email', () => {
cy.intercept('POST', '/password/forgot').as('forgotPasswordConfirmationCode');
cy.visit('/forgot-password');
cy.get('[data-testid="emailField"]').type('dev@tooljet.io');
cy.get('[data-testid="submitButton"').click();
cy.wait('@forgotPasswordConfirmationCode').its('response.statusCode').should('eq', 200);
cy.checkToastMessage('toast-forgot-password-confirmation-code', 'We\'ve sent the confirmation code to your email address');
});
})

View file

@ -0,0 +1,57 @@
describe('Dashboard', () => {
// we can use these values to log in
const email = 'dev@tooljet.io';
const password = 'password';
beforeEach(() => {
cy.login(email, password);
})
it('site header is visible with nav items', () => {
cy.get('.navbar')
.find('.navbar-nav')
.should('be.visible');
});
it('Users tab should navigate to Users page', () => {
cy.get('.navbar')
.find('.navbar-nav')
.find('li').not('.active')
.click();
cy.location('pathname').should('equal', '/users');
cy.get('.page-title').should('have.text', 'Users & Permissions');
cy.get('[data-testid="usersTable"]').should('be.visible');
})
it('Apps tab should navigate to Apps page', () => {
cy.get('.navbar')
.find('.navbar-nav')
.find('li.active')
.click();
cy.location('pathname').should('equal', '/');
cy.get('.page-title').should('have.text', 'All applications');
cy.get('[data-testid="appsTable"]').should('be.visible');
})
it('should show User avatar and logout the user when user clicks logout', () => {
cy.get('[data-testid="userAvatarHeader"]').should('be.visible');
// TODO - Add functionality to detect when user hovers over the avatar,
// Issues with hover functionality and hide/show of dom elements
})
it('Application folders list is visible', () => {
cy.get('[data-testid="applicationFoldersList"]')
.should('be.visible');
});
it('Count bubble for "All applications should equal number of rows in table', () => {
cy.get('[data-testid="allApplicationsCount"]').then(($countBubble) => {
cy.get('[data-testid="appsTable"]')
.wait(500)
.find("tr")
.then((row) => {
expect(Number($countBubble.text())).to.equal(row.length)
});
});
});
})

View file

@ -7,4 +7,4 @@ Cypress.Commands.add('login', (email, password) => {
Cypress.Commands.add('checkToastMessage', (toastId, message) => {
cy.get(`[id=${toastId}]`).should('contain', message);
});
});

View file

@ -0,0 +1,5 @@
class AddIndexToOrganizationUsers < ActiveRecord::Migration[6.1]
def change
add_index :organization_users, [:organization_id, :user_id], unique: true, if_not_exists: true
end
end

3
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2021_06_19_124759) do
ActiveRecord::Schema.define(version: 2021_06_30_165919) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
@ -142,6 +142,7 @@ ActiveRecord::Schema.define(version: 2021_06_19_124759) do
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.string "status", default: "invited"
t.index ["organization_id", "user_id"], name: "index_organization_users_on_organization_id_and_user_id", unique: true
t.index ["organization_id"], name: "index_organization_users_on_organization_id"
t.index ["user_id"], name: "index_organization_users_on_user_id"
end

View file

@ -5,3 +5,4 @@ PG_DB=tooljet_prod
PG_USER=<pg user name>
PG_HOST=<pg host>
PG_PASS=<pg user password>
RAILS_LOG_TO_STDOUT=true

View file

@ -58,7 +58,7 @@ http
location /
{
root /home/ubuntu/app/frontend/build;
index index.html;
try_files $uri $uri/ /index.html;
}
location /_backend_
@ -84,7 +84,7 @@ http
location /
{
root /home/ubuntu/app/frontend/build;
index index.html;
try_files $uri $uri/ /index.html;
}
location /_backend_

View file

@ -35,7 +35,7 @@ def build_fe
backend_url = "#{ENV.fetch("TOOLJET_HOST")}/_backend_"
front_end_working_dir = "/home/ubuntu/app/frontend"
Dir.chdir front_end_working_dir
system("npm install")
system("npm install --only=production")
system("NODE_ENV=production TOOLJET_SERVER_URL=#{backend_url} npm run-script build")
end

View file

@ -8,9 +8,9 @@ packer {
}
source "amazon-ebs" "ubuntu" {
ami_name = "tooljet_latest_ubuntu_bionic"
instance_type = "t2.medium"
region = "us-west-2"
ami_name = "${var.ami_name}"
instance_type = "${var.instance_type}"
region = "${var.ami_region}"
source_ami_filter {
filters = {
name = "ubuntu/images/hvm-ssd/ubuntu-bionic-18.04-amd64-server-*"

View file

@ -0,0 +1,14 @@
variable "ami_name" {
type = string
}
variable "instance_type" {
type = string
default = "t2.medium"
}
variable "ami_region" {
type = string
default = "us-west-2"
}

2
docker/entrypoints/server.sh Normal file → Executable file
View file

@ -1,4 +1,4 @@
#!/bin/bash
#!/bin/sh
set -e
bundle check || bundle install
rake db:create

View file

@ -15,4 +15,4 @@ ENV RAILS_ENV=production
COPY . ./
RUN ["chmod", "755", "docker/entrypoints/server.sh"]
ENTRYPOINT ["docker/entrypoints/server.sh"]
ENTRYPOINT ["./docker/entrypoints/server.sh"]

View file

@ -32,9 +32,13 @@ Follow these steps to setup and run ToolJet on Mac OS. Open terminal and run the
gem install bundler:2.1.4
```
### Install Node.js
### Install Node.js ( version: v14.9.0 )
```bash
$ brew install node
$ brew install nvm
$ export NVM_DIR=~/.nvm
$ source $(brew --prefix nvm)/nvm.sh
$ nvm install 14.9.0
$ nvm use 14.9.0
```
@ -56,13 +60,17 @@ Follow these steps to setup and run ToolJet on Mac OS. Open terminal and run the
```
3. ## Populate the keys in the env file.
Run `openssl rand -hex 64` to create secure secrets and use them as the values for `LOCKBOX_MASTER_KEY` and `SECRET_KEY_BASE`.
:::info
`SECRET_KEY_BASE` requires a 64 byte key. (If you have `openssl` installed, run `openssl rand -hex 64` to create a 64 byte secure random key)
`LOCKBOX_MASTER_KEY` requires a 32 byte key. (Run `openssl rand -hex 32` to create a 32 byte secure random key)
:::
Example:
```bash
$ cat .env
TOOLJET_HOST=http://localhost:8082
LOCKBOX_MASTER_KEY=c92bcc7f112ffbdd131d1fb6c5005e372b8802f85f6c4586e5a88f57a541382841c8c99e5701b84862e448dd5db846f705321a41bd48a0fed1b58b9596a3877f
LOCKBOX_MASTER_KEY=1d291a926ddfd221205a23adb4cc1db66cb9fcaf28d97c8c1950e3538e3b9281
SECRET_KEY_BASE=4229d5774cfe7f60e75d6b3bf3a1dbb054a696b6d21b6d5de7b73291899797a222265e12c0a8e8d844f83ebacdf9a67ec42584edf1c2b23e1e7813f8a3339041
```
@ -105,8 +113,13 @@ Follow these steps to setup and run ToolJet on Mac OS. Open terminal and run the
```ruby
OrganizationUser.create(user: User.first, organization: Organization.first, role: 'admin', status: 'active')
```
8. ## Install webpack
```bash
$ npm install --save-dev webpack
$ npm install --save-dev webpack-cli
```
8. ## Running the React frontend ( Client )
9. ## Running the React frontend ( Client )
```bash
$ cd ./frontend && npm start
```

View file

@ -74,6 +74,45 @@ We recommend:
$ docker-compose stop
```
## Making changes to the codebase
If you make any changes to the codebase/pull the latest changes from upstream, the tooljet server container would hot reload the application without you doing anything.
Caveat:
1. If the changes include database migrations or new gem additions in the Gemfile, you would need to restart the ToolJet server container by running `docker-compose restart server`.
2. If you need to add a new binary or system libary to the container itself, you would need to add those dependencies in `docker/server.Dockerfile.dev` and then rebuild the ToolJet server image. You can do that by running `docker-compose build server`. Once that completes you can start everything normally with `docker-compose up`.
Example:
Let's say you need to install the `imagemagick` binary in your ToolJet server's container. You'd then need to make sure that `apt` installs `imagemagick` while building the image. The Dockerfile at `docker/server.Dockerfile.dev` for the server would then look something like this:
```
FROM ruby:2.7.3-buster
#Notice the newly added imagemagick package
RUN apt update && apt install -y \
build-essential \
postgresql \
freetds-dev \
imagemagick
RUN mkdir -p /app
WORKDIR /app
COPY Gemfile Gemfile.lock ./
RUN gem install bundler && bundle install --jobs 20 --retry 5
ENV RAILS_ENV=development
COPY . ./
RUN ["chmod", "755", "docker/entrypoints/server.sh"]
```
Once you've updated the Dockerfile, rebuild the image by running `docker-compose build server`. After building the new image, start the services by running `docker-compose up`.
## Running Rails tests
To run all the tests

View file

@ -42,13 +42,17 @@ Follow these steps to setup and run ToolJet on Ubuntu. Open terminal and run the
3. ## Populate the keys in the env file.
Run `openssl rand -hex 64` to create secure secrets and use them as the values for `LOCKBOX_MASTER_KEY` and `SECRET_KEY_BASE`.
:::info
`SECRET_KEY_BASE` requires a 64 byte key. (If you have `openssl` installed, run `openssl rand -hex 64` to create a 64 byte secure random key)
`LOCKBOX_MASTER_KEY` requires a 32 byte key. (Run `openssl rand -hex 32` to create a 32 byte secure random key)
:::
Example:
```bash
$ cat .env
TOOLJET_HOST=http://localhost:8082
LOCKBOX_MASTER_KEY=c92bcc7f112ffbdd131d1fb6c5005e372b8802f85f6c4586e5a88f57a541382841c8c99e5701b84862e448dd5db846f705321a41bd48a0fed1b58b9596a3877f
LOCKBOX_MASTER_KEY=1d291a926ddfd221205a23adb4cc1db66cb9fcaf28d97c8c1950e3538e3b9281
SECRET_KEY_BASE=4229d5774cfe7f60e75d6b3bf3a1dbb054a696b6d21b6d5de7b73291899797a222265e12c0a8e8d844f83ebacdf9a67ec42584edf1c2b23e1e7813f8a3339041
```

View file

@ -5,17 +5,17 @@ sidebar_position: 3
# GraphQL
ToolJet can connect to GraphQL endpoints. We currently support queries and mutations.
ToolJet can connect to GraphQL endpoints to execute queries and mutations.
## Connection
To add a new GraphQL datasource, click on the '+' button on data sources panel at the left-bottom corner of the app editor. Select GraphQL from the modal that pops up.
To add a new GraphQL datasource, click the `+` button on data sources panel at the bottom-left corner of the app builder and then select GraphQL from the modal that pops up.
ToolJet requires the following to connect to a GraphQL datasource.
- **URL**
- URL of the GraphQL endpoint
Following optional parameters are also supported:
The following optional parameters are also supported:
| Type | Description |
| ----------- | ----------- |
@ -24,14 +24,14 @@ Following optional parameters are also supported:
<img src="/img/datasource-reference/graphql-connect.png" alt="ToolJet - GraphQL connection" height="250"/>
<img class="screenshot-full" src="/img/datasource-reference/graphql/add-source.gif" alt="ToolJet - GraphQL connection" height="420"/>
Click on the 'Save' button to save the datasource.
## Querying GraphQL
Click on '+' button of the query manager at the bottom panel of the editor and select the GraphQL endpoint added in the previous step as the datasource.
Click on `+` button of the query manager at the bottom panel of the editor and select the GraphQL endpoint added in the previous step as the datasource.
<img src="/img/datasource-reference/graphql-query.png" alt="ToolJet - GraphQL connection" height="250"/>
<img class="screenshot-full" src="/img/datasource-reference/graphql-query.png" alt="ToolJet - GraphQL connection" height="420"/>
Click on the 'run' button to run the query. NOTE: Query should be saved before running.

View file

@ -16,7 +16,7 @@ Follow the steps below to deploy ToolJet on AWS EC2 instances.
3. Under the `Images` section, click on the `AMIs` button.
4. Now, from the AMI search page, select the search type as "Public Images" and input `AMI Name : tooljet_latest_ubuntu_bionic` in the search bar.
4. Now, from the AMI search page, select the search type as "Public Images" and input `AMI Name : tooljet_v0.5.11.ubuntu_bionic` in the search bar.
5. Select ToolJet's AMI and bootup an EC2 instance.
@ -60,7 +60,7 @@ Follow the steps below to deploy ToolJet on AWS EC2 instances.
Please make sure that `TOOLJET_HOST` starts with either `http://` or `https://`
:::
9. Once you've configured the `.env` file, run `./setup_app.rb`. This script will install all the dependencies of ToolJet and then will start the required services.
9. Once you've configured the `.env` file, run `./setup_app`. This script will install all the dependencies of ToolJet and then will start the required services.
10. If you've set a custom domain for `TOOLJET_HOST`, add a `A record` entry in your DNS settings to point to the IP address of the EC2 instance.

View file

@ -28,7 +28,20 @@ ToolJet server uses PostgreSQL as the database.
| PG_PASS | password |
#### Lockbox configuration ( required )
ToolJet server uses lockbox to encrypt datasource credentials. You should set the environment variable `LOCKBOX_MASTER_KEY`.
ToolJet server uses lockbox to encrypt datasource credentials. You should set the environment variable `LOCKBOX_MASTER_KEY` with a 32 byte hexadecimal string.
#### Application Secret ( required )
ToolJet server uses a secure 64 byte hexadecimal string to encrypt session cookies. You should set the environment variable `SECRET_KEY_BASE`.
:::tip
If you have `openssl` installed, you can run the following commands to generate the the value for `LOCKBOX_MASTER_KEY` and `SECRET_KEY_BASE`.
For `LOCKBOX_MASTER_KEY` use `openssl rand -hex 32`
For `SECRET_KEY_BASE` use `openssl rand -hex 64`
:::
#### Disabling signups ( optional )

View file

@ -12,12 +12,12 @@ Follow the steps below to deploy ToolJet server on a Kubernetes cluster.
1. Setup a PostgreSQL database.
2. Create a Kubernetes secret with name `server`. For the minimal setup, ToolJet requires pg_host, pg_db, pg_user, pg_password, secret_key_base & lockbox_key keys in the secret. ( Read [environment variables reference](/docs/deployment/env-vars) )
2. Create a Kubernetes secret with name `server`. For the minimal setup, ToolJet requires `pg_host`, `pg_db`, `pg_user`, `pg_password`, `secret_key_base` & `lockbox_key` keys in the secret. ( Read [environment variables reference](/docs/deployment/env-vars) )
3. Create a Kubernetes deployment
```bash
$ kubectl apply -f https://github.com/ToolJet/ToolJet/blob/main/deploy/kubernetes/server-deployment.yaml
$ kubectl apply -f https://raw.githubusercontent.com/ToolJet/ToolJet/main/deploy/kubernetes/server-deployment.yaml
```
:::info
@ -30,15 +30,7 @@ The file given above is just a template and might not suit production environmen
$ kubectl get pods
```
You should be able to see that the `tooljet-server` pod has been created.
```bash
$ kubectl port-forward <pod-name> 3000:3000
```
Use this command to forward the port 3000 of server on the pod to the port 3000 of your local machine.
5. You can setup load balancers or Kubernetes services to publish the Kubernetes deployment that you've created. This step varies with cloud providers.
5. Create a Kubernetes services to publish the Kubernetes deployment that you've created. This step varies with cloud providers. We have a [template](https://raw.githubusercontent.com/ToolJet/ToolJet/main/deploy/kubernetes/server-service.yaml) for exposing the ToolJet server as a service using an AWS loadbalancer.
Examples:
Application load balancing on Amazon EKS: https://docs.aws.amazon.com/eks/latest/userguide/alb-ingress.html
GKE Ingress for HTTP(S) Load Balancing: https://cloud.google.com/kubernetes-engine/docs/concepts/ingress

View file

@ -24,7 +24,7 @@ Here is a video explaining how to build a Redis GUI using ToolJet in 3 minutes:
These resources will help you to quickly build and deploy apps using ToolJet:
- **[Setup](/docs/setup/architecture)** - Learn how to setup ToolJet locally using docker.
- **[Setup](/docs/deployment/architecture)** - Learn how to setup ToolJet locally using docker.
- **[Basic Tutorial](/docs/tutorial/creating-app)** - Learn how to build simple UI and connect to data sources.
- **[Deploy](/docs/contributing-guide/setup/docker)** - Learn how to deploy ToolJet on Heroku, Kubernetes, etc

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

View file

@ -55,12 +55,12 @@ class App extends React.Component {
return (
<Router history={history}>
<div>
{updateAvailable && <div class="alert alert-info alert-dismissible" role="alert">
<h3 class="mb-1">Update available</h3>
{updateAvailable && <div className="alert alert-info alert-dismissible" role="alert">
<h3 className="mb-1">Update available</h3>
<p>A new version of ToolJet has been released.</p>
<div class="btn-list">
<a href="https://docs.tooljet.io/docs/setup/updating" target="_blank" class="btn btn-info">Read release notes & update</a>
<a onClick={() => { tooljetService.skipVersion(); this.setState({ updateAvailable: false }); }} class="btn">Skip this version</a>
<div className="btn-list">
<a href="https://docs.tooljet.io/docs/setup/updating" target="_blank" className="btn btn-info">Read release notes & update</a>
<a onClick={() => { tooljetService.skipVersion(); this.setState({ updateAvailable: false }); }} className="btn">Skip this version</a>
</div>
</div>}

View file

@ -20,6 +20,13 @@ export const ActionTypes = [
{ name: 'url', type: 'text', default: 'https://example.com' }
]
},
{
name: 'Go to app',
id: 'go-to-app',
options: [
{ name: 'app', type: 'url', default: 'https://app.tooljet.io/applications/app-id' }
]
},
{
name: 'Show Modal',
id: 'show-modal',

View file

@ -15,6 +15,8 @@ import { Multiselect } from './Components/Multiselect';
import { Modal } from './Components/Modal';
import { Chart } from './Components/Chart';
import { Map } from './Components/Map';
import { renderTooltip } from '../_helpers/appUtils';
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
const AllComponents = {
Button,
@ -68,6 +70,12 @@ export const Box = function Box({
const ComponentToRender = AllComponents[component.component];
return (
<OverlayTrigger
placement="top"
delay={{ show: 500, hide: 0 }}
trigger={!inCanvas? ['hover', 'focus']: null}
overlay={(props) => renderTooltip({props, text: `${component.description}`})}
>
<div style={{ ...styles, backgroundColor }} role={preview ? 'BoxPreview' : 'Box'}>
{inCanvas ? (
<ComponentToRender
@ -106,5 +114,6 @@ export const Box = function Box({
</div>
)}
</div>
</OverlayTrigger>
);
};

View file

@ -10,9 +10,20 @@ import 'codemirror/addon/hint/show-hint.css';
import 'codemirror/theme/base16-light.css';
import 'codemirror/theme/duotone-light.css';
import { getSuggestionKeys, onBeforeChange, handleChange } from './utils';
import { resolveReferences } from '@/_helpers/utils';
export function CodeHinter({
initialValue, onChange, currentState, mode, theme, lineNumbers, className, placeholder, ignoreBraces
initialValue,
onChange,
currentState,
mode,
theme,
lineNumbers,
className,
placeholder,
ignoreBraces,
enablePreview,
height
}) {
const options = {
lineNumbers: lineNumbers,
@ -26,29 +37,41 @@ export function CodeHinter({
};
const [realState, setRealState] = useState(currentState);
const [currentValue, setCurrentValue] = useState(initialValue);
useEffect(() => {
setRealState(currentState);
}, [currentState.components]);
let suggestions = useMemo(() => {
return getSuggestionKeys(currentState);
}, [currentState.components, currentState.queries]);
return getSuggestionKeys(realState);
}, [realState.components, realState.queries]);
function valueChanged(editor, onChange, suggestions, ignoreBraces) {
handleChange(editor, onChange, suggestions, ignoreBraces);
setCurrentValue(editor.getValue());
}
return (
<div className={`code-hinter ${className || 'codehinter-default-input'}`}>
<div className={`code-hinter ${className || 'codehinter-default-input'}`} key={suggestions.length}>
<CodeMirror
value={initialValue}
realState={realState}
scrollbarStyle={null}
height={height || '100%'}
onBlur={(editor) => {
const value = editor.getValue();
onChange(value);
}}
onChange={(editor) => handleChange(editor, onChange, suggestions, ignoreBraces)}
onChange={(editor) => valueChanged(editor, onChange, suggestions, ignoreBraces)}
onBeforeChange={(editor, change) => onBeforeChange(editor, change, ignoreBraces)}
options={options}
/>
{enablePreview &&
<div className="dynamic-variable-preview bg-azure-lt px-2 py-1">
{resolveReferences(currentValue, realState)}
</div>
}
</div>
);
}

View file

@ -98,6 +98,7 @@ export function handleChange(editor, onChange, suggestions, ignoreBraces = false
const options = {
alignWithWord: true,
completeSingle: false,
hint: function () {
return {
from: { line: cursor.line, ch: cursor.ch - currentWord.length },
@ -109,4 +110,4 @@ export function handleChange(editor, onChange, suggestions, ignoreBraces = false
if (canShowHint(editor, ignoreBraces)) {
editor.showHint(options);
}
};
};

View file

@ -41,7 +41,7 @@ export const DropDown = function DropDown({
const currentValueProperty = component.definition.properties.value;
const value = currentValueProperty ? currentValueProperty.value : '';
const [currentValue, setCurrentValue] = useState(value);
const [currentValue, setCurrentValue] = useState('');
let newValue = value;
if (currentValueProperty && currentState) {
@ -52,6 +52,10 @@ export const DropDown = function DropDown({
setCurrentValue(newValue);
}, [newValue]);
useEffect(() => {
onComponentOptionChanged(component, 'value', currentValue);
}, [currentValue]);
return (
<div className="row" style={{ width, height }} onClick={() => onComponentClick(id, component)}>
<div className="col-auto">
@ -63,7 +67,7 @@ export const DropDown = function DropDown({
value={currentValue}
search={true}
onChange={(newVal) => {
onComponentOptionChanged(component, 'value', newVal);
setCurrentValue(newVal);
}}
filterOptions={fuzzySearch}
placeholder="Select.."

View file

@ -5,12 +5,12 @@ export const CustomSelect = ({ options, value, multiple, disabled, onChange }) =
function renderValue(valueProps) {
if(valueProps) {
return valueProps.value.split(', ').map((value) => <span {...valueProps} class="badge bg-blue-lt p-2 mx-1">{value}</span>);
return valueProps.value.split(', ').map((value) => <span {...valueProps} className="badge bg-blue-lt p-2 mx-1">{value}</span>);
}
}
return (
<div class="custom-select">
<div className="custom-select">
<SelectSearch
options={options}
printOptions="on-focus"

View file

@ -625,7 +625,7 @@ export function Table({
<span data-tip="Filter data" className="btn btn-light btn-sm p-1 mx-2" onClick={() => showFilters()}>
<img src="/assets/images/icons/filter.svg" width="13" height="13" />
{filters.length > 0 &&
<a class="badge bg-azure" style={{width: '4px', height: '4px', marginTop: '5px'}}></a>
<a className="badge bg-azure" style={{width: '4px', height: '4px', marginTop: '5px'}}></a>
}
</span>
<span

View file

@ -28,7 +28,7 @@ export const Tags = ({ value, onChange }) => {
}
function renderTag(text) {
return <span class="col-auto badge bg-blue-lt p-2 mx-1 tag mb-2">
return <span className="col-auto badge bg-blue-lt p-2 mx-1 tag mb-2">
{text}
<span className="badge badge-pill bg-red-lt remove-tag-button" onClick={() => removeTag(text)}>
x
@ -37,17 +37,17 @@ export const Tags = ({ value, onChange }) => {
}
return (
<div class="tags row">
<div className="tags row">
{value.map((item) => {
return renderTag(item)
})}
{!showForm &&
<span class="col-auto badge bg-green-lt mx-1 add-tag-button" onClick={() => setShowForm(true)}>{'+'}</span>
<span className="col-auto badge bg-green-lt mx-1 add-tag-button" onClick={() => setShowForm(true)}>{'+'}</span>
}
{showForm &&
<span class="col-auto badge bg-green-lt mx-1">
<span className="col-auto badge bg-green-lt mx-1">
<input
type="text"
autoFocus

View file

@ -568,7 +568,7 @@ export const componentTypes = [
description: 'Select one value from options',
defaultSize: {
width: 200,
height: 30
height: 37
},
component: 'DropDown',
others: {
@ -588,7 +588,7 @@ export const componentTypes = [
},
exposedVariables: {
value: {}
value: null
},
definition: {
others: {

View file

@ -310,7 +310,7 @@ export const Container = ({
{appLoading && (
<div className="mx-auto mt-5 w-50 p-5">
<center>
<div class="spinner-border text-azure" role="status"></div>
<div className="spinner-border text-azure" role="status"></div>
</center>
</div>
)}

View file

@ -31,6 +31,7 @@ import ReactTooltip from 'react-tooltip';
import { Resizable } from 're-resizable';
import { WidgetManager } from './WidgetManager';
import Fuse from 'fuse.js';
import queryString from 'query-string';
class Editor extends React.Component {
constructor(props) {
@ -51,6 +52,7 @@ class Editor extends React.Component {
this.state = {
currentUser: authenticationService.currentUserValue,
app: {},
allComponentTypes: componentTypes,
queryPaneHeight: '30%',
isLoading: true,
@ -72,9 +74,10 @@ class Editor extends React.Component {
components: {},
globals: {
currentUser: userVars,
urlparams: {}
urlparams: JSON.parse(JSON.stringify(queryString.parse(props.location.search)))
}
},
apps: [],
dataQueriesDefaultText: 'You haven\'t created queries yet.',
showQuerySearchField: false
};
@ -82,6 +85,7 @@ class Editor extends React.Component {
componentDidMount() {
const appId = this.props.match.params.id;
this.fetchApps(0);
appService.getApp(appId).then((data) => this.setState(
{
@ -176,6 +180,13 @@ class Editor extends React.Component {
);
};
fetchApps = (page) => {
appService.getAll(page).then((data) => this.setState({
apps: data.apps,
isLoading: false
}));
}
computeComponentState = (components) => {
let componentState = {};
const currentComponents = this.state.currentState.components;
@ -421,6 +432,13 @@ class Editor extends React.Component {
this.setState({ showQuerySearchField: !this.state.showQuerySearchField });
}
onVersionDeploy = (versionId) => {
this.setState({ app: {
...this.state.app,
current_version_id: versionId
}})
}
render() {
const {
currentSidebarTab,
@ -447,7 +465,8 @@ class Editor extends React.Component {
deviceWindowWidth,
scaleValue,
dataQueriesDefaultText,
showQuerySearchField
showQuerySearchField,
apps
} = this.state;
const appLink = slug ? `/applications/${slug}` : '';
@ -553,20 +572,26 @@ class Editor extends React.Component {
</div>
<div className="navbar-nav flex-row order-md-last">
<div className="nav-item dropdown d-none d-md-flex me-3">
{app
{app.id
&& <ManageAppUsers
app={app}
slug={slug}
handleSlugChange={this.handleSlugChange} />}
</div>
<div className="nav-item dropdown d-none d-md-flex me-3">
<a href={appLink} target="_blank" className="btn btn-sm" rel="noreferrer">
<a href={appLink} target="_blank" className={`btn btn-sm ${app?.current_version_id ? '': 'disabled'}`} rel="noreferrer">
Launch
</a>
</div>
<div className="nav-item dropdown me-2">
{this.state.app && (
<SaveAndPreview appId={app.id} appName={app.name} appDefinition={appDefinition} app={app} />
{app.id && (
<SaveAndPreview
appId={app.id}
appName={app.name}
appDefinition={appDefinition}
app={app}
onVersionDeploy={this.onVersionDeploy}
/>
)}
</div>
</div>
@ -866,6 +891,7 @@ class Editor extends React.Component {
currentState={currentState}
allComponents={appDefinition.components}
key={selectedComponent.id}
apps={apps}
></Inspector>
) : (
<div className="mt-5 p-2">Please select a component to inspect</div>

View file

@ -372,7 +372,7 @@ class Table extends React.Component {
<div className="text">{item.name}</div>
</div>
<div className="col-auto">
<span class="badge bg-red-lt" onClick={() => this.removeColumn(index)}>x</span>
<span className="badge bg-red-lt" onClick={() => this.removeColumn(index)}>x</span>
</div>
</div>
</OverlayTrigger>
@ -403,7 +403,7 @@ class Table extends React.Component {
{renderElement(component, componentMeta, paramUpdated, dataQueries, 'serverSidePagination', 'properties', currentState)}
{renderElement(component, componentMeta, paramUpdated, dataQueries, 'serverSideSearch', 'properties', currentState)}
<div class="hr-text">Events</div>
<div className="hr-text">Events</div>
{renderEvent(component, eventUpdated, dataQueries, eventOptionUpdated, 'onRowClicked', componentMeta.events.onRowClicked, currentState, components)}
{renderEvent(component, eventUpdated, dataQueries, eventOptionUpdated, 'onPageChanged', componentMeta.events.onPageChanged, currentState, components)}
@ -411,7 +411,7 @@ class Table extends React.Component {
{renderQuerySelector(component, dataQueries, eventOptionUpdated, 'onBulkUpdate', componentMeta.events.onBulkUpdate)}
<div class="hr-text">Style</div>
<div className="hr-text">Style</div>
</div>
{renderElement(component, componentMeta, paramUpdated, dataQueries, 'loadingState', 'properties', currentState)}

View file

@ -13,7 +13,8 @@ export const EventSelector = ({
extraData,
eventMeta,
currentState,
components
components,
apps
}) => {
const [open, setOpen] = useState(false);
@ -48,6 +49,17 @@ export const EventSelector = ({
return modalOptions;
}
function getAllApps() {
let appsOptionsList = [];
apps.map((item) => {
appsOptionsList.push({
name: item.name,
value: item.id
})
})
return appsOptionsList;
}
function eventChanged(param, value, extraData) {
if(value === 'none') {
eventUpdated(param, null, null);
@ -73,7 +85,7 @@ export const EventSelector = ({
{eventMeta.displayName}
</div>
<div className={`col-auto events-toggle ${open ? 'events-toggle-active' : ''}`}>
<span class="toggle-icon"></span>
<span className="toggle-icon"></span>
</div>
</div>
</label>
@ -112,6 +124,22 @@ export const EventSelector = ({
</div>
)}
{definition.actionId === 'go-to-app' && (
<div className="p-1">
<label className="form-label mt-1">App</label>
<SelectSearch
options={getAllApps()}
search={true}
value={definition.options.slug}
onChange={(value) => {
eventOptionUpdated(param, 'slug', value, extraData);
}}
filterOptions={fuzzySearch}
placeholder="Select.."
/>
</div>
)}
{definition.actionId === 'show-modal' && (
<div className="p-1">
<label className="form-label mt-1">Modal</label>

View file

@ -16,7 +16,8 @@ export const Inspector = ({
removeComponent,
allComponents,
componentChanged,
currentState
currentState,
apps
}) => {
const selectedComponent = { id: selectedComponentId, component: allComponents[selectedComponentId].component, layouts: allComponents[selectedComponentId].layouts}
@ -226,10 +227,10 @@ export const Inspector = ({
{!['Table', 'Chart'].includes(componentMeta.component) &&
<div className="properties-container p-2">
{Object.keys(componentMeta.properties).map((property) => renderElement(component, componentMeta, paramUpdated, dataQueries, property, 'properties', currentState, components))}
<div class="hr-text">Style</div>
<div className="hr-text">Style</div>
{Object.keys(componentMeta.styles).map((style) => renderElement(component, componentMeta, paramUpdated, dataQueries, style, 'styles', currentState, components))}
<div class="hr-text">Events</div>
{Object.keys(componentMeta.events).map((eventName) => renderEvent(component, eventUpdated, dataQueries, eventOptionUpdated, eventName, componentMeta.events[eventName], currentState, components))}
<div className="hr-text">Events</div>
{Object.keys(componentMeta.events).map((eventName) => renderEvent(component, eventUpdated, dataQueries, eventOptionUpdated, eventName, componentMeta.events[eventName], currentState, components, apps))}
@ -237,7 +238,7 @@ export const Inspector = ({
}
{/* Show on desktop & show on mobile params */}
<div class="hr-text">Layout</div>
<div className="hr-text">Layout</div>
<div className="properties-container p-2 pb-3 mb-5">
{renderElement(component, componentMeta, layoutPropertyChanged, dataQueries, 'showOnDesktop', 'others', currentState, components)}
{renderElement(component, componentMeta, layoutPropertyChanged, dataQueries, 'showOnMobile', 'others', currentState, components)}

View file

@ -38,7 +38,7 @@ export const QuerySelector = ({
{eventMeta.displayName}
</div>
<div className={`col-auto events-toggle ${open ? 'events-toggle-active' : ''}`}>
<span class="toggle-icon"></span>
<span className="toggle-icon"></span>
</div>
</div>
</label>

View file

@ -51,7 +51,7 @@ export function renderElement(component, componentMeta, paramUpdated, dataQuerie
);
}
export function renderEvent(component, eventUpdated, dataQueries, eventOptionUpdated, eventName, eventMeta, currentState, components) {
export function renderEvent(component, eventUpdated, dataQueries, eventOptionUpdated, eventName, eventMeta, currentState, components, apps) {
let definition = component.component.definition.events[eventName];
definition = definition || { };
@ -65,6 +65,7 @@ export function renderEvent(component, eventUpdated, dataQueries, eventOptionUpd
eventOptionUpdated={eventOptionUpdated}
currentState={currentState}
components={components}
apps={apps}
/>
);
}

View file

@ -18,6 +18,7 @@ class ManageAppUsers extends React.Component {
app: { ...props.app },
slugError: null,
isLoading: true,
isSlugVerificationInProgress: false,
addingUser: false,
organizationUsers: [],
newUser: {}
@ -100,14 +101,22 @@ class ManageAppUsers extends React.Component {
handleSetSlug = (event) => {
const newSlug = event.target.value || null;
this.setState({ isSlugVerificationInProgress: true });
appService
.setSlug(this.state.app.id, newSlug)
.then(() => {
this.setState({ slugError: null });
this.setState({
slugError: null,
isSlugVerificationInProgress: false
});
this.props.handleSlugChange(newSlug);
})
.catch(({ error }) => {
this.setState({ slugError: error });
this.setState({
slugError: error,
isSlugVerificationInProgress: false
});
});
}
@ -117,11 +126,19 @@ class ManageAppUsers extends React.Component {
render() {
const {
addingUser, isLoading, users, organizationUsers, newUser, app, slugError
addingUser,
isLoading,
users,
organizationUsers,
newUser,
app,
slugError,
isSlugVerificationInProgress
} = this.state;
const appId = app.id;
const appLink = `${window.location.origin}/applications/`;
const shareableLink = appLink + (this.props.slug || appId);
const slugButtonClass = isSlugVerificationInProgress? '' : slugError !== null ? 'is-invalid' : 'is-valid';
return (
<div>
@ -165,11 +182,18 @@ class ManageAppUsers extends React.Component {
</label>
<div className="input-group">
<span className="input-group-text">{appLink}</span>
<input type="text"
className={`form-control form-control-sm ${ slugError !== null ? 'is-invalid' : 'is-valid'}`}
placeholder={appId}
onChange={(e) => { e.persist(); this.delayedSlugChange(e); }}
defaultValue={this.props.slug} />
<div className="input-with-icon">
<input type="text"
className={`form-control form-control-sm ${slugButtonClass}`}
placeholder={appId}
onChange={(e) => { e.persist(); this.delayedSlugChange(e); }}
defaultValue={this.props.slug} />
{ isSlugVerificationInProgress && (
<div className="icon-container">
<div className="spinner-border text-azure spinner-border-sm" role="status"></div>
</div>
)}
</div>
<span className="input-group-text">
<CopyToClipboard
text={shareableLink}

View file

@ -61,6 +61,7 @@ class Googlesheets extends React.Component {
{ value: 'read', name: 'Read data from a spreadsheet' },
{ value: 'append', name: 'Append data to a spreadsheet' },
{ value: 'info', name: 'Get spreadsheet info' },
{ value: 'delete_row', name: 'Delete row from a spreadsheet' },
]}
value={this.state.options.operation}
search={true}
@ -71,7 +72,7 @@ class Googlesheets extends React.Component {
placeholder="Select.."
/>
</div>
{['read', 'append'].includes(this.state.options.operation) && (
{['read', 'append', 'delete_row'].includes(this.state.options.operation) && (
<div>
<div className="mb-3 mt-2 row">
<div className="col">
@ -117,6 +118,24 @@ class Googlesheets extends React.Component {
</div>
)}
{this.state.options.operation === 'delete_row' && (
<div>
<div className="mb-3 mt-2 row">
<div className="col">
<label className="form-label">Delete row number</label>
<input
type="text"
value={this.state.options.row_index}
onChange={(e) => {
changeOption(this, 'row_index', e.target.value);
}}
className="form-control"
/>
</div>
</div>
</div>
)}
{this.state.options.operation === 'info' && (
<div className="mb-3 mt-2">
<label className="form-label">Spreadsheet ID</label>

View file

@ -52,6 +52,8 @@ class Postgresql extends React.Component {
theme="duotone-light"
lineNumbers={true}
className="query-hinter"
enablePreview
height="120px"
onChange={(value) => changeOption(this, 'query', value)}
/>
</div>

View file

@ -147,7 +147,7 @@ class Stripe extends React.Component {
<div>
{loadingSpec &&
<div className="p-3">
<div class="spinner-border spinner-border-sm text-azure mx-2" role="status"></div>
<div className="spinner-border spinner-border-sm text-azure mx-2" role="status"></div>
Please wait whle we load the OpenAPI specification for Stripe.
</div>
}

View file

@ -363,7 +363,7 @@ class QueryManager extends React.Component {
<div className="mb-3 mt-2">
{previewLoading && (
<center>
<div class="spinner-border text-azure mt-5" role="status"></div>
<div className="spinner-border text-azure mt-5" role="status"></div>
</center>
)}
{previewLoading === false && (

View file

@ -14,7 +14,7 @@ class SaveAndPreview extends React.Component {
showModal: false,
appId: props.appId,
isLoading: true,
showVersionForm: false
showVersionForm: false,
};
}
@ -65,7 +65,10 @@ class SaveAndPreview extends React.Component {
appService.saveApp(this.props.appId, { name: this.props.appName, current_version_id: versionId }).then(() => {
this.setState({ isDeploying: false });
toast.success('Version Deployed', { hideProgressBar: true, position: 'top-center' });
this.props.onVersionDeploy(versionId);
});
};
render() {
@ -75,13 +78,20 @@ class SaveAndPreview extends React.Component {
return (
<div>
{!showModal && (
<button className="btn btn-primary btn-sm" onClick={() => this.setState({ showModal: true })}>
Deploy
</button>
)}
<button className="btn btn-primary btn-sm" onClick={() => this.setState({ showModal: true })}>
Deploy
</button>
<Modal show={this.state.showModal} size="md" backdrop="static" centered={true} keyboard={true}>
<Modal
show={this.state.showModal}
size="md"
backdrop="static"
centered={true}
keyboard={true}
enforceFocus={false}
animation={false}
onEscapeKeyDown={() => this.hideModal()}
>
<Modal.Header>
<Modal.Title>Versions and deployments</Modal.Title>
<div>

View file

@ -14,6 +14,7 @@ import {
onEvent,
runQuery
} from '@/_helpers/appUtils';
import queryString from 'query-string';
class Viewer extends React.Component {
constructor(props) {
@ -36,8 +37,16 @@ class Viewer extends React.Component {
};
}
componentDidMount() {
const slug = this.props.match.params.slug;
componentWillReceiveProps(nextProps) {
let slug = nextProps.match.params.slug;
if(this.state.app?.slug != slug) {
this.setState({app: {}, appDefinition: {}}, () => {
this.loadApplication(slug);
});
}
}
loadApplication = (slug) => {
const deviceWindowWidth = window.screen.width - 5;
const isMobileDevice = deviceWindowWidth < 600;
@ -85,12 +94,17 @@ class Viewer extends React.Component {
components: {},
globals: {
current_user: userVars,
urlparams: {}
urlparams: JSON.parse(JSON.stringify(queryString.parse(this.props.location.search)))
}
}
});
}
componentDidMount() {
const slug = this.props.match.params.slug;
this.loadApplication(slug);
}
render() {
const {
appDefinition,
@ -136,13 +150,13 @@ class Viewer extends React.Component {
appDefinitionChanged={() => false} // function not relevant in viewer
snapToGrid={true}
appLoading={isLoading}
onEvent={(eventName, options) => onEvent(this, eventName, options)}
onEvent={(eventName, options) => onEvent(this, eventName, options, 'view')}
mode="view"
scaleValue={scaleValue}
deviceWindowWidth={deviceWindowWidth}
currentLayout={currentLayout}
currentState={this.state.currentState}
onComponentClick={(id, component) => onComponentClick(this, id, component)}
onComponentClick={(id, component) => onComponentClick(this, id, component, 'view')}
onComponentOptionChanged={(component, optionName, value) => onComponentOptionChanged(this, component, optionName, value)
}
onComponentOptionsChanged={(component, options) => onComponentOptionsChanged(this, component, options)

View file

@ -29,9 +29,9 @@ class ForgotPassword extends React.Component {
.then((res) => res.json())
.then((res) => {
if (res.error) {
toast.error(res.error);
toast.error(res.error, { toastId: 'toast-forgot-password-email-error' });
} else {
toast.success(res.message);
toast.success(res.message, { toastId: 'toast-forgot-password-confirmation-code' });
this.props.history.push('/reset-password');
}
})
@ -59,10 +59,12 @@ class ForgotPassword extends React.Component {
type="email"
className="form-control"
placeholder="Enter email"
data-testid="emailField"
/>
</div>
<div className="form-footer">
<button
data-testid="submitButton"
className={`btn btn-primary w-100 ${isLoading ? 'btn-loading' : ''}`}
onClick={this.handleClick}
>

View file

@ -6,7 +6,7 @@ import { folderService } from '@/_services';
import { toast } from 'react-toastify';
export const AppMenu = function AppMenu({
app, folders, foldersChanged
app, folders, foldersChanged, deleteApp
}) {
const [addToFolder, setAddToFolder] = useState(false);
@ -40,7 +40,7 @@ export const AppMenu = function AppMenu({
return <OverlayTrigger
trigger="click"
placement="right"
placement="top"
rootClose
onToggle={(status) => handleToggle(status)}
overlay={
@ -48,9 +48,15 @@ export const AppMenu = function AppMenu({
{/* <Popover.Title as="h3">brrr</Popover.Title> */}
<Popover.Content>
{!addToFolder &&
<div className="field mb-2">
<span role="button" onClick={() => setAddToFolder(true)}>Add to folder </span>
<div>
<div className="field mb-2">
<span role="button" onClick={() => setAddToFolder(true)}>Add to folder </span>
</div>
<div className="field mb-2">
<span className="my-3 text-danger" role="button" onClick={() => deleteApp()}>Delete app </span>
</div>
</div>
}
{addToFolder &&

View file

@ -4,21 +4,21 @@ export const BlankPage = function BlankPage({
createApp
}) {
return (<div>
<div class="page-wrapper">
<div class="container-xl">
<div className="page-wrapper">
<div className="container-xl">
</div>
<div class="page-body">
<div class="container-xl d-flex flex-column justify-content-center">
<div class="empty">
<div class="empty-img"><img src="/assets/images/blank.svg" height="128" alt="" />
<div className="page-body">
<div className="container-xl d-flex flex-column justify-content-center">
<div className="empty">
<div className="empty-img"><img src="/assets/images/blank.svg" height="128" alt="" />
</div>
<p class="empty-title">You haven't created any apps yet.</p>
<div class="empty-action">
<a onClick={createApp} class="btn btn-primary text-light">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
<p className="empty-title">You haven't created any apps yet.</p>
<div className="empty-action">
<a onClick={createApp} className="btn btn-primary text-light">
<svg xmlns="http://www.w3.org/2000/svg" className="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
Create your first app
</a>
<a href="https://docs.tooljet.io" target="_blank" class="btn btn-primary text-light mx-2">
<a href="https://docs.tooljet.io" target="_blank" className="btn btn-primary text-light mx-2">
Read documentation
</a>
</div>

View file

@ -41,11 +41,11 @@ export const Folders = function Folders({
<div className="px-1 py-2" style={{minHeight: '200px'}}>
{[1,2,3,4, 5].map(element => {
return (<div className="row">
<div class="col p-1">
<div class="skeleton-line w-100"></div>
<div className="col p-1">
<div className="skeleton-line w-100"></div>
</div>
<div class="col-2 pt-1">
<div class="skeleton-line w-100"></div>
<div className="col-2 pt-1">
<div className="skeleton-line w-100"></div>
</div>
</div>)
})}
@ -53,26 +53,26 @@ export const Folders = function Folders({
)}
{!isLoading && (
<div className="list-group list-group-transparent mb-3">
<div data-testid="applicationFoldersList" className="list-group list-group-transparent mb-3">
<a
class={`list-group-item list-group-item-action d-flex align-items-center ${!activeFolder.id ? 'active' : ''}`}
className={`list-group-item list-group-item-action d-flex align-items-center ${!activeFolder.id ? 'active' : ''}`}
onClick={() => handleFolderChange({})}
>
All applications
<small className="text-muted ms-auto">
<span class="badge bg-azure-lt">{totalCount}</span>
<span className="badge bg-azure-lt" data-testid="allApplicationsCount">{totalCount}</span>
</small>
</a>
{folders.map((folder) =>
<a
class={`list-group-item list-group-item-action d-flex align-items-center ${activeFolder.id === folder.id ? 'active' : ''}`}
className={`list-group-item list-group-item-action d-flex align-items-center ${activeFolder.id === folder.id ? 'active' : ''}`}
onClick={() => handleFolderChange(folder)}
>
{folder.name}
<small className="text-muted ms-auto">
<span class="badge bg-azure-lt">{folder.count}</span>
<span className="badge bg-azure-lt">{folder.count}</span>
</small>
</a>
)}

View file

@ -7,6 +7,8 @@ import { AppMenu } from './AppMenu';
import { BlankPage } from './BlankPage';
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
import { renderTooltip } from '@/_helpers/appUtils';
import { ConfirmDialog } from '@/_components';
import { toast } from 'react-toastify';
class HomePage extends React.Component {
constructor(props) {
@ -18,6 +20,7 @@ class HomePage extends React.Component {
isLoading: true,
creatingApp: false,
currentFolder: {},
showAppDeletionConfirmation: false,
apps: [],
folders: [],
meta: {
@ -56,6 +59,7 @@ class HomePage extends React.Component {
}
pageChanged = (page) => {
this.setState({ currentPage: page });
this.fetchApps(page, this.state.currentFolder.id);
}
@ -77,13 +81,49 @@ class HomePage extends React.Component {
});
};
deleteApp = (app) => {
this.setState({ showAppDeletionConfirmation: true, appToBeDeleted: app })
}
executeAppDeletion = () => {
this.setState({ isDeletingApp: true });
appService.deleteApp(this.state.appToBeDeleted.id).then((data) => {
toast.info('App deleted successfully.', {
hideProgressBar: true,
position: 'top-center'
});
this.setState({
isDeletingApp: false,
appToBeDeleted: null,
showAppDeletionConfirmation: false
});
this.fetchApps(this.state.currentPage || 0, this.state.currentFolder.id)
}).catch(({ error }) => {
toast.error('Could not delete the app.', { hideProgressBar: true, position: 'top-center' });
this.setState({
isDeletingApp: false,
appToBeDeleted: null,
showAppDeletionConfirmation: false
});
});
;
}
render() {
const {
apps, isLoading, creatingApp, meta, currentFolder
apps, isLoading, creatingApp, meta, currentFolder, showAppDeletionConfirmation, isDeletingApp
} = this.state;
return (
<div className="wrapper home-page">
<ConfirmDialog
show={showAppDeletionConfirmation}
message={'The app and the associated data will be permanently deleted, do you want to continue?'}
confirmButtonLoading={isDeletingApp}
onConfirm={() => this.executeAppDeletion()}
onCancel={() => {}}
/>
<Header
/>
@ -124,23 +164,24 @@ class HomePage extends React.Component {
<div className={currentFolder.count == 0 ? 'table-responsive bg-white w-100 apps-table mt-3 d-flex align-items-center' : 'table-responsive bg-white w-100 apps-table mt-3'} style={{minHeight: '600px'}}>
<table
class="table table-vcenter">
data-testid="appsTable"
className="table table-vcenter">
<tbody>
{isLoading && (
<>
{Array.from(Array(10)).map(() => (
<tr class="row">
<td class="col-3 p-3">
<div class="skeleton-line w-10"></div>
<div class="skeleton-line w-10"></div>
<tr className="row">
<td className="col-3 p-3">
<div className="skeleton-line w-10"></div>
<div className="skeleton-line w-10"></div>
</td>
<td class="col p-3">
<td className="col p-3">
</td>
<td class="text-muted col-auto col-1 pt-4">
<div class="skeleton-line"></div>
<td className="text-muted col-auto col-1 pt-4">
<div className="skeleton-line"></div>
</td>
<td class="text-muted col-auto col-1 pt-4">
<div class="skeleton-line"></div>
<td className="text-muted col-auto col-1 pt-4">
<div className="skeleton-line"></div>
</td>
</tr>
))}
@ -152,12 +193,12 @@ class HomePage extends React.Component {
<>
{apps.map((app) => (
<tr class="row">
<td class="col p-3">
<tr className="row">
<td className="col p-3">
<span className="app-title mb-3">{app.name}</span> <br />
<small className="pt-2">created {app.created_at} ago by {app.user.first_name} {app.user.last_name} </small>
</td>
<td class="text-muted col-auto pt-4">
<td className="text-muted col-auto pt-4">
<Link
to={`/apps/${app.id}`}
className="d-none d-lg-inline"
@ -166,7 +207,7 @@ class HomePage extends React.Component {
placement="top"
overlay={(props) => renderTooltip({props, text: 'Open in app builder'})}
>
<span class="badge bg-green-lt">
<span className="badge bg-green-lt">
Edit
</span>
</OverlayTrigger>
@ -179,7 +220,7 @@ class HomePage extends React.Component {
placement="top"
overlay={(props) => renderTooltip({props, text: 'Open in app viewer'})}
>
<span class="badge bg-blue-lt mx-2">launch</span>
<span className="badge bg-blue-lt mx-2">launch</span>
</OverlayTrigger>
</Link>
@ -188,6 +229,7 @@ class HomePage extends React.Component {
app={app}
folders={this.state.folders}
foldersChanged={this.foldersChanged}
deleteApp={() => this.deleteApp(app)}
/>
</td>
</tr>))

View file

@ -86,11 +86,17 @@ class ManageOrgUsers extends React.Component {
const { firstName, lastName, email, role } = this.state.newUser;
organizationUserService.create(firstName, lastName, email, role).then(() => {
this.setState({ creatingUser: false, showNewUserForm: false, newUser: {} });
toast.success('User has been created', { hideProgressBar: true, position: 'top-center' });
this.fetchUsers();
});
organizationUserService
.create(firstName, lastName, email, role)
.then(() => {
this.setState({ creatingUser: false, showNewUserForm: false, newUser: {} });
toast.success('User has been created', { hideProgressBar: true, position: 'top-center' });
this.fetchUsers();
})
.catch(({ error }) => {
toast.error(error, { hideProgressBar: true, position: 'top-center' });
this.setState({ creatingUser: false, showNewUserForm: true, newUser: {} });
});
};
logout = () => {
@ -212,7 +218,7 @@ class ManageOrgUsers extends React.Component {
<div className="container-xl">
<div className="card">
<div className="card-table table-responsive table-bordered">
<table className="table table-vcenter" disabled={true}>
<table data-testid="usersTable" className="table table-vcenter" disabled={true}>
<thead>
<tr>
<th>Name</th>
@ -230,7 +236,7 @@ class ManageOrgUsers extends React.Component {
<tr>
<td className="col-2 p-3">
<div className="row">
<div class="skeleton-image col-auto" style={{ width: '25px', height: '25px' }}></div>
<div className="skeleton-image col-auto" style={{ width: '25px', height: '25px' }}></div>
<div className="skeleton-line w-10 col mx-3"></div>
</div>
</td>

View file

@ -0,0 +1,43 @@
import React, { useState, useEffect } from 'react';
import Modal from 'react-bootstrap/Modal';
import Button from 'react-bootstrap/Button';
export function ConfirmDialog({
show, message, onConfirm, onCancel, confirmButtonLoading
}) {
const [showModal, setShow] = useState(show);
useEffect(() => {
setShow(show);
}, [show]);
const handleClose = () => {
setShow(false);
};
const handleConfirm = () => {
onConfirm();
};
const handleCancel = () => {
onCancel();
handleClose();
};
return (
<>
<Modal show={showModal} onHide={handleClose} size="sm" centered={true}>
<div className="modal-status bg-danger"></div>
<Modal.Body>{message}</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={handleCancel}>
Cancel
</Button>
<Button variant="danger" autoFocus className={`${confirmButtonLoading ? 'btn-loading' : ''}`} onClick={handleConfirm}>
Yes
</Button>
</Modal.Footer>
</Modal>
</>
);
}

View file

@ -31,7 +31,7 @@ export const Header = function Header({
</Link>
</h1>
<ul class="navbar-nav d-none d-lg-flex">
<ul className="navbar-nav d-none d-lg-flex">
<li className={`nav-item mx-3 ${pahtName === '/' ? 'active' : ''}`}>
<Link to={'/'} className="nav-link">
<span className="nav-link-icon d-md-none d-lg-inline-block">
@ -61,16 +61,17 @@ export const Header = function Header({
className="nav-link d-flex lh-1 text-reset p-0"
data-bs-toggle="dropdown"
aria-label="Open user menu"
data-testid="userAvatarHeader"
>
<div className="d-none d-xl-block ps-2">
<span class="avatar bg-azure-lt">
<span className="avatar bg-azure-lt">
{first_name ? first_name[0] : ''}
{last_name ? last_name[0] : ''}
</span>
</div>
</a>
<div className="dropdown-menu dropdown-menu-end dropdown-menu-arrow">
<a onClick={logout} className="dropdown-item">
<a data-testId="logoutBtn" onClick={logout} className="dropdown-item">
Logout
</a>
</div>

View file

@ -1,3 +1,4 @@
export * from './PrivateRoute';
export * from './Pagination';
export * from './Header';
export * from './ConfirmDialog';

View file

@ -5,6 +5,7 @@ import { dataqueryService } from '@/_services';
import _ from 'lodash';
import moment from 'moment';
import Tooltip from 'react-bootstrap/Tooltip';
import { history } from '@/_helpers';
export function setStateAsync(_ref, state) {
return new Promise((resolve) => {
@ -58,9 +59,9 @@ export function runTransformation(_ref, rawData, transformation) {
return result;
}
export function onComponentClick(_ref, id, component) {
export function onComponentClick(_ref, id, component, mode = 'edit') {
const onClickEvent = component.definition.events.onClick;
executeAction(_ref, onClickEvent);
executeAction(_ref, onClickEvent, mode);
}
export function onQueryConfirm(_ref, queryConfirmationData) {
@ -85,7 +86,7 @@ async function copyToClipboard(text) {
}
};
function executeAction(_ref, event) {
function executeAction(_ref, event, mode) {
if (event) {
if (event.actionId === 'show-alert') {
const message = resolveReferences(event.options.message, _ref.state.currentState);
@ -97,6 +98,20 @@ function executeAction(_ref, event) {
window.open(url, '_blank');
}
if (event.actionId === 'go-to-app') {
const slug = resolveReferences(event.options.slug, _ref.state.currentState);
const url = `/applications/${slug}`;
if(mode === 'view') {
_ref.props.history.push(url);
} else {
if(confirm("The app will be opened in a new tab as the action is triggered from the editor.")) {
window.open(url, '_blank');
}
}
}
if (event.actionId === 'run-query') {
const { queryId, queryName } = event.options;
return runQuery(_ref, queryId, queryName);
@ -129,7 +144,7 @@ function executeAction(_ref, event) {
}
}
export function onEvent(_ref, eventName, options) {
export function onEvent(_ref, eventName, options, mode = 'edit') {
let _self = _ref;
console.log('Event: ', eventName);
@ -149,7 +164,7 @@ export function onEvent(_ref, eventName, options) {
}
}, () => {
if (event.actionId) {
executeAction(_self, event);
executeAction(_self, event, mode);
}
});
}
@ -172,7 +187,7 @@ export function onEvent(_ref, eventName, options) {
}, () => {
if(event) {
if (event.actionId) {
executeAction(_self, event);
executeAction(_self, event, mode);
}
} else {
console.log('No action is associated with this event');
@ -185,7 +200,7 @@ export function onEvent(_ref, eventName, options) {
const event = (eventName === 'onCheck') ? component.definition.events.onCheck : component.definition.events.onUnCheck;
if (event.actionId) {
executeAction(_self, event);
executeAction(_self, event, mode);
}
}
@ -194,7 +209,7 @@ export function onEvent(_ref, eventName, options) {
const event = component.definition.events[eventName];
if (event.actionId) {
executeAction(_self, event);
executeAction(_self, event, mode);
}
}
@ -203,14 +218,14 @@ export function onEvent(_ref, eventName, options) {
const event = component.definition.events[eventName];
if (event.actionId) {
executeAction(_self, event);
executeAction(_self, event, mode);
}
}
if (eventName === 'onBulkUpdate') {
return new Promise(function (resolve, reject) {
onComponentOptionChanged(_self, options.component, 'isSavingChanges', true);
executeAction(_self, { actionId: 'run-query', ...options.component.definition.events.onBulkUpdate }).then(() => {
executeAction(_self, { actionId: 'run-query', ...options.component.definition.events.onBulkUpdate }, mode).then(() => {
onComponentOptionChanged(_self, options.component, 'isSavingChanges', false);
resolve();
});

View file

@ -4,6 +4,7 @@ import { authHeader, handleResponse } from '@/_helpers';
export const appService = {
getAll,
createApp,
deleteApp,
getApp,
getAppBySlug,
saveApp,
@ -31,6 +32,11 @@ function getApp(id) {
return fetch(`${config.apiUrl}/apps/${id}`, requestOptions).then(handleResponse);
}
function deleteApp(id) {
const requestOptions = { method: 'DELETE', headers: authHeader() };
return fetch(`${config.apiUrl}/apps/${id}`, requestOptions).then(handleResponse);
}
function getAppBySlug(slug) {
const requestOptions = { method: 'GET', headers: authHeader() };
return fetch(`${config.apiUrl}/apps/slugs/${slug}`, requestOptions).then(handleResponse);

View file

@ -1621,3 +1621,23 @@ input:focus-visible {
.widgets-list {
--tblr-gutter-x: 0px!important;
}
.input-with-icon {
position: relative;
display: flex;
flex: 1;
.icon-container {
position: absolute;
right: 10px;
top: calc(50% - 10px);
z-index: 3;
}
}
.dynamic-variable-preview {
height: 30px;
margin-top: -2px;
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
}

View file

@ -28,6 +28,21 @@ class OrganizationUsersControllerTest < ActionDispatch::IntegrationTest
end
end
test 'org admins cannot create org users if email already exists' do
post '/organization_users', params: org_user_params, as: :json, headers: auth_header(@admin)
post '/organization_users', params: org_user_params, as: :json, headers: auth_header(@admin)
assert_response 422
assert_equal "Email address is already taken", JSON.parse(response.body)['message']
end
test 'OrganizationUser should be unique per organization and user' do
assert_raises(ActiveRecord::RecordNotUnique) do
org_user = OrganizationUser.new(organization: @org, user: @admin, role: 'admin', status: 'active')
org_user.save
end
end
test 'cannot create org users if not admin' do
assert_no_difference 'OrganizationUser.count' do
post '/organization_users', params: org_user_params, as: :json, headers: auth_header(@developer)