Merge branch 'release/v0.8.1' into main

This commit is contained in:
navaneeth 2021-10-25 22:29:57 +05:30
commit 71c313d585
82 changed files with 1225 additions and 479 deletions

View file

@ -1 +1 @@
0.8.0
0.8.1

View file

@ -63,10 +63,10 @@ Documentation is available at https://docs.tooljet.io.
- [Widget Reference](https://docs.tooljet.io/docs/widgets/button)
## Branching model
We use git-flow branching model. The base branch is `develop`. If you are looking for a stable version, please use the main branch or tags labeled as v1.x.x.
We use the git-flow branching model. The base branch is `develop`. If you are looking for a stable version, please use the main branch or tags labeled as v1.x.x.
## Contributing
Kindly read our [Contributing Guide](CONTRIBUTING.md) to learn and understand about our development process, how to propose bugfixes and improvements, and how to build and test your changes to ToolJet. <br>
Kindly read our [Contributing Guide](CONTRIBUTING.md) to learn and understand about our development process, how to propose bug fixes and improvements, and how to build and test your changes to ToolJet. <br>
## Contributors
<a href="https://github.com/tooljet/tooljet/graphs/contributors">

View file

@ -36,7 +36,7 @@ sudo openssl req -new -newkey rsa:2048 -days 3650 -nodes -x509 \
# Setup nginx config
export SERVER_HOST="${SERVER_HOST:=localhost}"
export SERVER_USER="${SERVER_USER:=www-data}"
VARS_TO_SUBSTITUTE="$SERVER_HOST:$SERVER_USER"
VARS_TO_SUBSTITUTE='$SERVER_HOST:$SERVER_USER'
envsubst "${VARS_TO_SUBSTITUTE}" < /tmp/nginx.conf > /tmp/nginx-substituted.conf
sudo cp /tmp/nginx-substituted.conf /usr/local/openresty/nginx/conf/nginx.conf

View file

@ -0,0 +1,5 @@
{
"label": "Tutorials",
"position": 2,
"collapsed": true
}

View file

@ -0,0 +1,25 @@
---
sidebar_position: 1
---
# Creating Widgets
These are some of the most useful properties and functions passed to the widget
### properties
The `properties` object will contain the configurable properties of a widget, initially obtained from its definition on `components.js`.
The values inside `properties` changes whenever the developer changes it from the inspector panel of ToolJet editor.
### exposedVariables
The `exposedVariables` object will contain the values of all exposed variables as configured in `components.js`.
### setExposedVariable('exposedVariableName', newValue)
This function allows you to update the value of an exposed variable to `newValue`.
### validate(value)
This function validates the `value` passed based on the validation settings configured on the inspector panel for the widget.
It returns an array `[isValid, validationError]`, which represents respectively, whether the `value` passed is valid,
and the error message if there is one.

View file

@ -129,3 +129,13 @@ This is required when client is built separately.
| variable | description |
| ------------------ | ----------------------------------------------------------- |
| TOOLJET_SERVER_URL | the URL of ToolJet server ( eg: https://server.tooljet.io ) |
#### Asset path ( optionally required )
This is required when the assets for the client are to be loaded from elsewhere (eg: CDN).
This can be an absolute path, or relative to main HTML file.
| variable | description |
| ------------------ | ----------------------------------------------------------- |
| ASSET_PATH | the asset path for the website ( eg: https://app.tooljet.io/) |

View file

@ -23,7 +23,7 @@ Date format should be followed as ISO 8601 as mentioned in the [moment documenta
| ----------- | ----------- |
| Format | The format of the date selected by the date picker |
| Enable time selection | Allows to select time if enabled. Time selection is disabled by default |
| Enable date selection | Allos to select date if enabled. Date selection is enabled by default |
| Enable date selection | Allows to select date if enabled. Date selection is enabled by default |
<img class="screenshot-full" src="/img/widgets/datepicker/datepicker-format.gif" alt="ToolJet - Widget Reference - Datepicker" height="420"/>

View file

@ -0,0 +1,19 @@
---
sidebar_position: 20
---
# Divider
Divider widget is used to add separator between components.
<img class="screenshot-full" src="/img/widgets/divider/divider.gif" alt="ToolJet - Widget Reference - Divider" height="420"/>
#### Properties
| properties | description |
| ----------- | ----------- |
| Divider Color | It is used to set the color of the divider. Use hex code to set the background color. |
| Visibility | This property is used to set the visibility of the divider. The property accepts Boolean value. |
| Show on Desktop | This property have toggle switch. If enabled, the divider will display in the desktop view else it will not appear. |
| Show on Mobile | This property have toggle switch. If enabled, the divider will display in the mobile view else it will not appear. |

View file

@ -20,7 +20,7 @@ This event is triggered when an star is clicked.
| Label | The text to be used as the label for the star rating. |
| Number of stars | Initial number of stars in the list on initial load. `default: 5`|
| Default no of selected stars | This property specifies the default count of stars that are selected on the initial load. `default: 5` (integer)|
| Enable half star | Allos selection of half stars if enabled. `default: false` (bool)|
| Enable half star | Allows selection of half stars if enabled. `default: false` (bool)|
| Tooltips |This is used for displaying informative tooltips on each star, and it is mapped to the index of the star. `default: []` (array of strings ) |
| Star Color | Display color of the star. `default: #ffb400` (color hex) |

View file

@ -137,3 +137,9 @@ If the data of a cell is changed, "save changes" button will be shown at the bot
| Show search box | It can be used to show or hide Table Search box. |
| Show download button | Show or hide download button at the Table footer. |
| Show filter button | Show or hide filter button at the Table footer. |
#### Styles
| Style | Description |
| ----------- | ----------- |
| Cell size | This decides the size of table cells. You can choose between a `Compact` size for table cells or a `Spacious` size |

View file

@ -2,7 +2,7 @@
module.exports = {
title: 'ToolJet - Documentation',
tagline: 'Build and deploy internal tools.',
url: 'https://tooljet.io',
url: 'https://docs.tooljet.io',
baseUrl: '/',
onBrokenLinks: 'ignore',
onBrokenMarkdownLinks: 'warn',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

View file

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-forms" width="24" height="24" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 3a3 3 0 0 0 -3 3v12a3 3 0 0 0 3 3"></path>
<path d="M6 3a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3"></path>
<path d="M13 7h7a1 1 0 0 1 1 1v8a1 1 0 0 1 -1 1h-7"></path>
<path d="M5 7h-1a1 1 0 0 0 -1 1v8a1 1 0 0 0 1 1h1"></path>
<path d="M17 12h.01"></path>
<path d="M13 12h.01"></path>
</svg>

After

Width:  |  Height:  |  Size: 598 B

View file

@ -3,7 +3,7 @@ set -eu
export SERVER_HOST="${SERVER_HOST:=server}"
export SERVER_USER="${SERVER_USER:=root}"
VARS_TO_SUBSTITUTE="$SERVER_HOST:$SERVER_USER"
VARS_TO_SUBSTITUTE='$SERVER_HOST:$SERVER_USER'
envsubst "${VARS_TO_SUBSTITUTE}" < /etc/openresty/nginx.conf.template > /etc/openresty/nginx.conf

View file

@ -37,4 +37,12 @@ export const ActionTypes = [
id: 'copy-to-clipboard',
options: [{ name: 'copy-to-clipboard', type: 'text', default: '' }],
},
{
name: 'Set local storage',
id: 'set-localstorage-value',
options: [
{ name: 'key', type: 'code', default: '' },
{ name: 'value', type: 'code', default: '' },
],
},
];

View file

@ -21,9 +21,12 @@ import { ToggleSwitch } from './Components/Toggle';
import { RadioButton } from './Components/RadioButton';
import { StarRating } from './Components/StarRating';
import { Divider } from './Components/Divider';
import { PasswordInput } from './Components/PasswordInput';
import { renderTooltip } from '../_helpers/appUtils';
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
import '@/_styles/custom.scss';
import { resolveProperties, resolveStyles } from './component-properties-resolution';
import { validateWidget } from '@/_helpers/utils';
const AllComponents = {
Button,
@ -48,6 +51,7 @@ const AllComponents = {
RadioButton,
StarRating,
Divider,
PasswordInput,
};
export const Box = function Box({
@ -83,6 +87,16 @@ export const Box = function Box({
}
const ComponentToRender = AllComponents[component.component];
const resolvedProperties = resolveProperties(component, currentState);
const resolvedStyles = resolveStyles(component, currentState);
const exposedVariables = currentState?.components[component.name] ?? {};
const fireEvent = (eventName, options) => onEvent(eventName, { ...options, component });
const validate = (value) =>
validateWidget({
...{ widgetValue: value },
...{ validationObject: component.definition.validation, currentState },
});
return (
<OverlayTrigger
@ -108,6 +122,12 @@ export const Box = function Box({
containerProps={containerProps}
darkMode={darkMode}
removeComponent={removeComponent}
properties={resolvedProperties}
exposedVariables={exposedVariables}
styles={resolvedStyles}
setExposedVariable={(variable, value) => onComponentOptionChanged(component, variable, value)}
fireEvent={fireEvent}
validate={validate}
></ComponentToRender>
) : (
<div className="m-1" style={{ height: '100%' }}>

View file

@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { resolveReferences, resolveWidgetFieldValue } from '@/_helpers/utils';
var tinycolor = require('tinycolor2');
export const Button = function Button({ id, width, height, component, onComponentClick, currentState }) {
export const Button = function Button({ width, height, component, currentState, fireEvent }) {
const [loadingState, setLoadingState] = useState(false);
useEffect(() => {
@ -17,11 +17,13 @@ export const Button = function Button({ id, width, height, component, onComponen
const text = component.definition.properties.text.value;
const backgroundColor = component.definition.styles.backgroundColor.value;
const color = component.definition.styles.textColor.value;
const borderRadius = component.definition.styles.borderRadius?.value ?? 3; // using 2 for backward compatibility
const widgetVisibility = component.definition.styles?.visibility?.value ?? true;
const disabledState = component.definition.styles?.disabledState?.value ?? false;
const parsedDisabledState =
typeof disabledState !== 'boolean' ? resolveWidgetFieldValue(disabledState, currentState) : disabledState;
const parsedBorderRadius = typeof borderRadius !== 'number' ? resolveWidgetFieldValue(borderRadius, currentState) : borderRadius;
let parsedWidgetVisibility = widgetVisibility;
try {
@ -33,6 +35,7 @@ export const Button = function Button({ id, width, height, component, onComponen
const computedStyles = {
backgroundColor,
color,
borderRadius: `${parsedBorderRadius}px`,
width,
height,
display: parsedWidgetVisibility ? '' : 'none',
@ -46,7 +49,7 @@ export const Button = function Button({ id, width, height, component, onComponen
style={computedStyles}
onClick={(event) => {
event.stopPropagation();
onComponentClick(id, component);
fireEvent('onClick');
}}
>
{resolveReferences(text, currentState)}

View file

@ -48,7 +48,9 @@ export const Datepicker = function Datepicker({
}
function onDateChange(event) {
const value = event._isAMomentObject ? event.format(dateFormat.value) : event;
const selectedDateFormat = enableTime ? `${dateFormat.value} LT` : dateFormat.value;
const value = event._isAMomentObject ? event.format(selectedDateFormat) : event;
setDateText(value);
onComponentOptionChanged(component, 'value', value);
}
@ -99,7 +101,7 @@ export const Datepicker = function Datepicker({
<input
{...props}
value={dateText}
className={`input-field form-control ${!isValid ? 'is-invalid' : ''} validation-without-icon`}
className={`input-field form-control ${!isValid ? 'is-invalid' : ''} validation-without-icon px-2`}
/>
);
}}

View file

@ -46,13 +46,16 @@ class StyleButton extends React.Component {
}
}
const BLOCK_TYPES = [
const HEADINGS = [
{ label: 'H1', style: 'header-one' },
{ label: 'H2', style: 'header-two' },
{ label: 'H3', style: 'header-three' },
{ label: 'H4', style: 'header-four' },
{ label: 'H5', style: 'header-five' },
{ label: 'H6', style: 'header-six' },
]
const BLOCK_TYPES = [
{
label: <img src="/assets/images/icons/rich-text-editor/blockquote.svg" style={{ height: '16px' }} />,
style: 'blockquote',
@ -78,6 +81,26 @@ const BlockStyleControls = (props) => {
return (
<>
<div className="dropmenu">
<button className="dropdownbtn px-2" type="button">
Heading
</button>
<div className="dropdown-content bg-white">
{
HEADINGS.map((type) => (
<a className="dropitem m-0 p-0" href="#" key={type.label}>
<StyleButton
key={type.label}
active={type.style === blockType}
label={type.label}
onToggle={props.onToggle}
style={type.style}
/>
</a>
))
}
</div>
</div>
{BLOCK_TYPES.map((type) => (
<StyleButton
key={type.label}
@ -201,7 +224,7 @@ class DraftEditor extends React.Component {
<BlockStyleControls editorState={editorState} onToggle={this.toggleBlockType} />
<InlineStyleControls editorState={editorState} onToggle={this.toggleInlineStyle} />
</div>
<div className={className} onClick={this.focus}>
<div className={className} style={{height: `${this.props.height-60}px`}} onClick={this.focus}>
<Editor
blockStyleFn={getBlockStyle}
customStyleMap={styleMap}

View file

@ -100,12 +100,12 @@ export const DropDown = function DropDown({
onComponentClick(id, component);
}}
>
<div className="col-auto">
<div className="col-auto my-auto">
<label style={{ marginRight: label !== '' ? '1rem' : '0.001rem' }} className="form-label py-1">
{label}
</label>
</div>
<div className="col px-0">
<div className="col px-0 h-100">
<SelectSearch
disabled={parsedDisabledState}
options={selectOptions}

View file

@ -61,12 +61,12 @@ export const Multiselect = function Multiselect({
onComponentClick(id, component);
}}
>
<div className="col-auto">
<div className="col-auto my-auto">
<label style={{ marginRight: '1rem' }} className="form-label py-1">
{label}
</label>
</div>
<div className="col px-0">
<div className="col px-0 h-100">
<SelectSearch
disabled={parsedDisabledState}
options={selectOptions}

View file

@ -52,7 +52,7 @@ export const NumberInput = function NumberInput({
onComponentOptionChanged(component, 'value', parseInt(e.target.value));
}}
type="number"
className="form-control"
className="form-control rounded-0"
placeholder={placeholder}
style={{ width, height, display: parsedWidgetVisibility ? '' : 'none' }}
value={number}

View file

@ -0,0 +1,59 @@
import React, { useState } from 'react';
import { resolveWidgetFieldValue } from '@/_helpers/utils';
export const PasswordInput = ({
id,
width,
height,
component,
onComponentClick,
currentState,
onComponentOptionChanged,
validate,
}) => {
const value = currentState?.components[component?.name]?.value;
const [text, setText] = useState(() => value ?? '');
const placeholder = component.definition.properties.placeholder.value;
const widgetVisibility = component.definition.styles?.visibility?.value ?? true;
const disabledState = component.definition.styles?.disabledState?.value ?? false;
const parsedDisabledState =
typeof disabledState !== 'boolean' ? resolveWidgetFieldValue(disabledState, currentState) : disabledState;
const parsedWidgetVisibility =
typeof widgetVisibility !== 'boolean' ? resolveWidgetFieldValue(widgetVisibility, currentState) : widgetVisibility;
const currentValidState = currentState?.components[component?.name]?.isValid;
const validationData = validate(value);
const { isValid, validationError } = validationData;
if (currentValidState !== isValid) {
onComponentOptionChanged(component, 'isValid', isValid);
}
return (
<div>
<input
disabled={parsedDisabledState}
onClick={(event) => {
event.stopPropagation();
onComponentClick(id, component);
}}
onChange={(e) => {
setText(e.target.value);
onComponentOptionChanged(component, 'value', e.target.value);
}}
type={'password'}
className={`form-control ${!isValid ? 'is-invalid' : ''} validation-without-icon rounded-0`}
placeholder={placeholder}
value={text}
style={{ width, height, display: parsedWidgetVisibility ? '' : 'none' }}
/>
<div className="invalid-feedback">{validationError}</div>
</div>
);
};

View file

@ -21,7 +21,10 @@ export const Pagination = function Pagination({
setPageCount(lastActivePageIndex);
} else if (serverSide || lastActivePageIndex === 0) {
setPageIndex(1);
} else {
gotoPage(lastActivePageIndex + 1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serverSide, lastActivePageIndex]);
function gotoPage(page) {

View file

@ -73,6 +73,8 @@ export function Table({
let tableType = tableTypeProperty ? tableTypeProperty.value : 'table-bordered';
tableType = tableType === '' ? 'table-bordered' : tableType;
const cellSizeType = component.definition.styles.cellSize?.value;
const widgetVisibility = component.definition.styles?.visibility?.value ?? true;
const disabledState = component.definition.styles?.disabledState?.value ?? false;
@ -148,7 +150,7 @@ export function Table({
let newFilters = filters;
newFilters.splice(index, 1);
setFilters(newFilters);
setAllFilters(newFilters);
setAllFilters(newFilters.filter((filter) => filter.id !== ''));
}
function clearFilters() {
@ -754,6 +756,10 @@ export function Table({
}
}, [state.columnResizing.isResizingColumn]);
useEffect(() => {
if (pageCount <= pageIndex) gotoPage(pageCount - 1);
}, [pageCount]);
return (
<div
data-disabled={parsedDisabledState}
@ -840,8 +846,8 @@ export function Table({
undefined
) {
console.log('componentState.changeSet', componentState.changeSet);
cellProps.style.backgroundColor = '#ffffde';
cellProps.style['--tblr-table-accent-bg'] = '#ffffde';
cellProps.style.backgroundColor = darkMode ? '#1c252f' : '#ffffde';
cellProps.style['--tblr-table-accent-bg'] = darkMode ? '#1c252f' : '#ffffde';
}
}
}
@ -857,6 +863,7 @@ export function Table({
'has-multiselect': cell.column.columnType === 'multiselect',
'has-datepicker': cell.column.columnType === 'datepicker',
'align-items-center flex-column': cell.column.columnType === 'selector',
[cellSizeType]: true,
})}
{...cellProps}
>
@ -888,7 +895,7 @@ export function Table({
<div className="col">
{(clientSidePagination || serverSidePagination) && (
<Pagination
lastActivePageIndex={currentState.components[component.name]?.pageIndex ?? 1}
lastActivePageIndex={pageIndex}
serverSide={serverSidePagination}
autoGotoPage={gotoPage}
autoCanNextPage={canNextPage}

View file

@ -1,61 +1,22 @@
import React, { useState, useEffect } from 'react';
import { resolveReferences, resolveWidgetFieldValue } from '@/_helpers/utils';
export const TextArea = function TextArea({
id,
width,
height,
component,
onComponentClick,
currentState,
onComponentOptionChanged,
}) {
const value = component.definition.properties.value ? component.definition.properties.value.value : '';
const [text, setText] = useState(value);
const textProperty = component.definition.properties.value;
let newText = value;
if (textProperty && currentState) {
newText = resolveReferences(textProperty.value, currentState, '');
}
import React, { useEffect } from 'react';
export const TextArea = function TextArea({ width, height, properties, exposedVariables, styles, setExposedVariable }) {
useEffect(() => {
setText(newText);
onComponentOptionChanged(component, 'value', newText);
setExposedVariable('value', properties.value);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [newText]);
const placeholder = component.definition.properties.placeholder.value;
const widgetVisibility = component.definition.styles?.visibility?.value ?? true;
const disabledState = component.definition.styles?.disabledState?.value ?? false;
const parsedDisabledState =
typeof disabledState !== 'boolean' ? resolveWidgetFieldValue(disabledState, currentState) : disabledState;
let parsedWidgetVisibility = widgetVisibility;
try {
parsedWidgetVisibility = resolveReferences(parsedWidgetVisibility, currentState, []);
} catch (err) {
console.log(err);
}
}, [properties.value]);
return (
<textarea
disabled={parsedDisabledState}
onClick={(event) => {
event.stopPropagation();
onComponentClick(id, component);
}}
disabled={styles.disabledState}
onChange={(e) => {
setText(e.target.value);
onComponentOptionChanged(component, 'value', e.target.value);
setExposedVariable('value', e.target.value);
}}
type="text"
className="form-control"
placeholder={placeholder}
style={{ width, height, display: parsedWidgetVisibility ? '' : 'none' }}
value={text}
placeholder={properties.placeholder}
style={{ width, height, resize:'none', display: styles.visibility ? '' : 'none' }}
value={exposedVariables.value}
></textarea>
);
};

View file

@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { resolveReferences, resolveWidgetFieldValue, validateWidget } from '@/_helpers/utils';
import { resolveReferences, resolveWidgetFieldValue } from '@/_helpers/utils';
export const TextInput = function TextInput({
id,
@ -9,6 +9,7 @@ export const TextInput = function TextInput({
onComponentClick,
currentState,
onComponentOptionChanged,
validate,
}) {
const placeholder = component.definition.properties.placeholder.value;
const widgetVisibility = component.definition.styles?.visibility?.value ?? true;
@ -35,11 +36,7 @@ export const TextInput = function TextInput({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [newText]);
const validationData = validateWidget({
validationObject: component.definition.validation,
widgetValue: value,
currentState,
});
const validationData = validate(value);
const { isValid, validationError } = validationData;

View file

@ -37,9 +37,9 @@ export const ToggleSwitch = ({
const toggleSwitchColorProperty = component.definition.styles.toggleSwitchColor;
const toggleSwitchColor = toggleSwitchColorProperty ? toggleSwitchColorProperty.value : '#3c92dc';
const textColor = textColorProperty ? textColorProperty.value : '#000';
const widgetVisibility = component.definition.styles?.visibility?.value ?? true;
const disabledState = component.definition.styles?.disabledState?.value ?? false;
const widgetVisibility = component.definition.styles.visibility?.value ?? true;
const disabledState = component.definition.styles.disabledState?.value ?? false;
const parsedDisabledState =
typeof disabledState !== 'boolean' ? resolveWidgetFieldValue(disabledState, currentState) : disabledState;

View file

@ -48,6 +48,14 @@ export const componentTypes = [
{ name: 'Striped & bordered', value: 'table-striped table-bordered' },
],
},
cellSize: {
type: 'select',
displayName: 'Cell size',
options: [
{ name: 'Compact', value: 'compact' },
{ name: 'Spacious', value: 'spacious' },
],
},
visibility: { type: 'code', displayName: 'Visibility' },
disabledState: { type: 'code', displayName: 'Disable' },
},
@ -93,6 +101,7 @@ export const componentTypes = [
textColor: { value: undefined },
visibility: { value: '{{true}}' },
disabledState: { value: '{{false}}' },
cellSize: { value: 'compact' },
},
},
},
@ -121,6 +130,7 @@ export const componentTypes = [
textColor: { type: 'color', displayName: 'Text color' },
visibility: { type: 'code', displayName: 'Visibility' },
disabledState: { type: 'code', displayName: 'Disable' },
borderRadius: { type: 'code', displayName: 'Border radius' },
},
exposedVariables: {},
definition: {
@ -138,6 +148,7 @@ export const componentTypes = [
backgroundColor: { value: '#3c92dc' },
textColor: { value: '#fff' },
visibility: { value: '{{true}}' },
borderRadius: { value: '{{0}}' },
disabledState: { value: '{{false}}' },
},
},
@ -346,6 +357,57 @@ export const componentTypes = [
},
},
},
{
name: 'PasswordInput',
displayName: 'Password Input',
description: 'Password input field for forms',
component: 'PasswordInput',
defaultSize: {
width: 210,
height: 30,
},
others: {
showOnDesktop: { type: 'toggle', displayName: 'Show on desktop' },
showOnMobile: { type: 'toggle', displayName: 'Show on mobile' },
},
properties: {
placeholder: { type: 'code', displayName: 'Placeholder' },
},
validation: {
regex: { type: 'code', displayName: 'Regex' },
minLength: { type: 'code', displayName: 'Min length' },
maxLength: { type: 'code', displayName: 'Max length' },
customRule: { type: 'code', displayName: 'Custom validation' },
},
events: {},
styles: {
visibility: { type: 'code', displayName: 'Visibility' },
disabledState: { type: 'code', displayName: 'Disable' },
},
exposedVariables: {
value: '',
},
definition: {
others: {
showOnDesktop: { value: true },
showOnMobile: { value: false },
},
properties: {
placeholder: { value: 'password' },
},
validation: {
regex: { value: '' },
minLength: { value: null },
maxLength: { value: null },
customRule: { value: null },
},
events: [],
styles: {
visibility: { value: '{{true}}' },
disabledState: { value: '{{false}}' },
},
},
},
{
name: 'Datepicker',
displayName: 'Date Picker',
@ -777,7 +839,7 @@ export const componentTypes = [
},
properties: {
label: { value: 'Select' },
value: { value: '' },
value: { value: '{{2}}' },
values: { value: '{{[1,2,3]}}' },
display_values: { value: '{{["one", "two", "three"]}}' },
visible: { value: true },
@ -938,6 +1000,9 @@ export const componentTypes = [
defaultMarkers: {
value: `{{ [{"lat": 40.7128, "lng": -73.935242}] }}`,
},
canSearch: {
value: `{{true}}` ,
},
},
addNewMarkers: { value: '{{false}}' },
events: [],

View file

@ -18,6 +18,7 @@ export const ConfigHandle = function ConfigHandle({ id, component, configHandleC
src="/assets/images/icons/menu.svg"
width="8"
height="8"
draggable="false"
/>
{component.name}
</span>
@ -28,6 +29,7 @@ export const ConfigHandle = function ConfigHandle({ id, component, configHandleC
role="button"
className="mx-2"
height="12"
draggable="false"
onClick={() => removeComponent({ id })}
/>
</div>

View file

@ -202,7 +202,7 @@ class DataSourceManager extends React.Component {
{!selectedDataSource && (
<div>
<div className="row row-deck">
<h4 className="text-muted mb-2">DATABASES</h4>
<h4 className="mb-2">DATABASES</h4>
{DataBaseSources.map((dataSource) => (
<div className="col-md-2" key={dataSource.name}>
<div className="card mb-3" role="button" onClick={() => this.selectDataSource(dataSource)}>
@ -225,7 +225,7 @@ class DataSourceManager extends React.Component {
))}
</div>
<div className="row row-deck mt-2">
<h4 className="text-muted mb-2">APIS</h4>
<h4 className="mb-2">APIS</h4>
{ApiSources.map((dataSource) => (
<div className="col-md-2" key={dataSource.name}>
<div className="card" role="button" onClick={() => this.selectDataSource(dataSource)}>

View file

@ -13,7 +13,10 @@
"rawData": []
},
"options": {
"gcp_key": { "type": "string", "encrypted": true }
"gcp_key": {
"type": "string",
"encrypted": true
}
}
},
"properties": {
@ -21,9 +24,12 @@
"$label": "Private key",
"$key": "gcp_key",
"$rows": 15,
"$encrypted": true,
"type": "textarea",
"description": "Enter private key"
}
},
"required": ["gcp_key"]
}
"required": [
"gcp_key"
]
}

View file

@ -309,6 +309,22 @@ class Editor extends React.Component {
});
};
saveAppName = (id, name, notify = false) => {
if (!name.trim()) {
toast.warn("App name can't be empty or whitespace", {
hideProgressBar: true,
position: 'top-center',
});
this.setState({
app: { ...this.state.app, name: this.state.oldName },
});
return;
}
this.saveApp(id, { name }, notify);
};
renderDataSource = (dataSource) => {
const sourceMeta = DataSourceTypes.find((source) => source.kind === dataSource.kind);
return (
@ -445,7 +461,7 @@ class Editor extends React.Component {
};
toggleQueryEditor = () => {
this.setState({ showQueryEditor: !this.state.showQueryEditor });
this.setState((prev) => ({ showQueryEditor: !prev.showQueryEditor }));
this.toolTipRefHide.current.style.display = this.state.showQueryEditor ? 'none' : 'flex';
this.toolTipRefShow.current.style.display = this.state.showQueryEditor ? 'flex' : 'none';
};
@ -473,7 +489,7 @@ class Editor extends React.Component {
};
toggleQuerySearch = () => {
this.setState({ showQuerySearchField: !this.state.showQuerySearchField });
this.setState((prev) => ({ showQuerySearchField: !prev.showQuerySearchField }));
};
onVersionDeploy = (versionId) => {
@ -491,8 +507,8 @@ class Editor extends React.Component {
});
};
toolTipRefHide = createRef(null);
toolTipRefShow = createRef(null);
toolTipRefHide = createRef();
toolTipRefShow = createRef();
render() {
const {
@ -569,8 +585,9 @@ class Editor extends React.Component {
<input
type="text"
style={{ width: '200px', left: '80px', position: 'absolute' }}
onFocus={(e) => this.setState({ oldName: e.target.value })}
onChange={(e) => this.onNameChanged(e.target.value)}
onBlur={(e) => this.saveApp(this.state.app.id, { name: e.target.value })}
onBlur={(e) => this.saveAppName(this.state.app.id, e.target.value)}
className="form-control-plaintext form-control-plaintext-sm"
value={this.state.app.name}
/>
@ -592,12 +609,12 @@ class Editor extends React.Component {
/>
</span>
<span
className={`btn btn-default mx-2`}
className={`btn btn-light mx-2`}
onClick={this.toggleQueryEditor}
data-tip="Show query editor"
data-class="py-1 px-2"
ref={this.toolTipRefShow}
style={{ display: 'none' }}
style={{ display: 'none', opacity: 0.5 }}
>
<img
style={{ transform: 'rotate(-90deg)' }}
@ -612,6 +629,7 @@ class Editor extends React.Component {
<button
type="button"
className="btn btn-light"
data-tip="Desktop view"
onClick={() => this.setState({ currentLayout: 'desktop' })}
disabled={currentLayout === 'desktop'}
>
@ -620,6 +638,7 @@ class Editor extends React.Component {
<button
type="button"
className="btn btn-light"
data-tip="Mobile view"
onClick={() => this.setState({ currentLayout: 'mobile' })}
disabled={currentLayout === 'mobile'}
>

View file

@ -58,6 +58,8 @@ class Chart extends React.Component {
const data = this.state.component.component.definition.properties.data;
const chartType = this.state.component.component.definition.properties.type.value;
return (
<div className="properties-container p-2">
{renderElement(
@ -99,17 +101,19 @@ class Chart extends React.Component {
{renderElement(component, componentMeta, paramUpdated, dataQueries, 'loadingState', 'properties', currentState)}
{renderElement(component, componentMeta, paramUpdated, dataQueries, 'markerColor', 'properties', currentState)}
{chartType !== 'pie' &&
renderElement(component, componentMeta, paramUpdated, dataQueries, 'markerColor', 'properties', currentState)}
{renderElement(
component,
componentMeta,
paramUpdated,
dataQueries,
'showGridLines',
'properties',
currentState
)}
{chartType !== 'pie' &&
renderElement(
component,
componentMeta,
paramUpdated,
dataQueries,
'showGridLines',
'properties',
currentState
)}
</div>
);
}

View file

@ -266,6 +266,33 @@ export const EventManager = ({
</div>
</div>
)}
{event.actionId === 'set-localstorage-value' && (
<>
<div className="row">
<div className="col-3 p-2">Key</div>
<div className="col-9">
<CodeHinter
currentState={currentState}
initialValue={event.key}
onChange={(value) => handlerChanged(index, 'key', value)}
enablePreview={true}
/>
</div>
</div>
<div className="row mt-3">
<div className="col-3 p-2">Value</div>
<div className="col-9">
<CodeHinter
currentState={currentState}
initialValue={event.value}
onChange={(value) => handlerChanged(index, 'value', value)}
enablePreview={true}
/>
</div>
</div>
</>
)}
</div>
</Popover.Content>
</Popover>

View file

@ -50,7 +50,7 @@ export const LeftSidebar = ({
<div className="left-sidebar-stack-bottom">
<LeftSidebarZoom onZoomChanged={onZoomChanged} />
<div className="left-sidebar-item no-border">
<DarkModeToggle switchDarkMode={switchDarkMode} darkMode={darkMode} />
<DarkModeToggle switchDarkMode={switchDarkMode} darkMode={darkMode} tooltipPlacement="right" />
</div>
{/* <LeftSidebarItem icon='support' className='left-sidebar-item' /> */}
</div>

View file

@ -0,0 +1,29 @@
import { resolveReferences } from '@/_helpers/utils';
export const resolveProperties = (component, currentState) => {
if (currentState && currentState.components[component.name]) {
return Object.entries(component.definition.properties).reduce(
(properties, entry) => ({
...properties,
...{ [entry[0]]: resolveReferences(entry[1].value, currentState) },
}),
{}
);
} else return {};
};
export const resolveStyles = (component, currentState) => {
if (currentState && currentState.components[component.name]) {
const styles = component.definition.styles;
return Object.entries(styles).reduce((resolvedStyles, entry) => {
const key = entry[0];
const value = resolveReferences(entry[1].value, currentState);
return {
...resolvedStyles,
...{ [key]: value },
};
}, {});
} else {
return {};
}
};

View file

@ -6,7 +6,16 @@ import Fuse from 'fuse.js';
import { folderService } from '@/_services';
import { toast } from 'react-toastify';
export const AppMenu = function AppMenu({ app, folders, foldersChanged, deleteApp, cloneApp, exportApp }) {
export const AppMenu = function AppMenu({
app,
folders,
foldersChanged,
deleteApp,
cloneApp,
exportApp,
canCreateApp,
canDeleteApp,
}) {
const [addToFolder, setAddToFolder] = useState(false);
const [isAdding, setIsAdding] = useState(false);
@ -68,26 +77,32 @@ export const AppMenu = function AppMenu({ app, folders, foldersChanged, deleteAp
<Popover.Content>
{!addToFolder && (
<div>
<div className="field mb-2">
<span role="button" onClick={() => setAddToFolder(true)}>
Add to folder
</span>
</div>
<div className="field mb-2">
<span className="field mb-2" role="button" onClick={() => cloneApp()}>
Clone app
</span>
</div>
<div className="field mb-2">
<span className="field mb-2" role="button" onClick={() => exportApp()}>
Export app
</span>
</div>
<div className="field mb-2">
<span className="my-3 text-danger" role="button" onClick={() => deleteApp()}>
Delete app
</span>
</div>
{canCreateApp && (
<>
<div className="field mb-2">
<span role="button" onClick={() => setAddToFolder(true)}>
Add to folder
</span>
</div>
<div className="field mb-2">
<span className="field mb-2" role="button" onClick={() => cloneApp()}>
Clone app
</span>
</div>
<div className="field mb-2">
<span className="field mb-2" role="button" onClick={() => exportApp()}>
Export app
</span>
</div>
</>
)}
{canDeleteApp && (
<div className="field mb-2">
<span className="my-3 text-danger" role="button" onClick={() => deleteApp()}>
Delete app
</span>
</div>
)}
</div>
)}

View file

@ -1,6 +1,6 @@
import React from 'react';
export const BlankPage = function BlankPage({ createApp }) {
export const BlankPage = function BlankPage({ createApp, handleImportApp, isImportingApp, fileInput }) {
return (
<div>
<div className="page-wrapper">
@ -13,7 +13,7 @@ export const BlankPage = function BlankPage({ createApp }) {
</div>
<p className="empty-title">You haven&apos;t created any apps yet.</p>
<div className="empty-action">
<a onClick={createApp} className="btn btn-primary text-light">
<a onClick={createApp} className="btn btn-primary text-light mx-1">
<svg
xmlns="http://www.w3.org/2000/svg"
className="icon"
@ -35,7 +35,20 @@ export const BlankPage = function BlankPage({ createApp }) {
<a
href="https://docs.tooljet.io"
target="_blank"
className="btn btn-primary text-light mx-2"
className="btn btn-primary text-light mx-1"
rel="noreferrer"
onChange={handleImportApp}
>
<label>
{isImportingApp && <span className="spinner-border spinner-border-sm me-2" role="status"></span>}
Import an app
<input type="file" ref={fileInput} style={{ display: 'none' }} />
</label>
</a>
<a
href="https://docs.tooljet.io"
target="_blank"
className="btn btn-primary text-light mx-1"
rel="noreferrer"
>
Read documentation

View file

@ -88,11 +88,11 @@ class HomePage extends React.Component {
appService
.createApp()
.then((data) => {
console.log(data);
_self.props.history.push(`/apps/${data.id}`);
})
.catch(({ error }) => {
toast.error(error, { hideProgressBar: true, position: 'top-center' });
_self.setState({ creatingApp: false });
});
};
@ -139,17 +139,16 @@ class HomePage extends React.Component {
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
this.fileInput.value = '';
this.setState({ isExportingApp: false });
})
.catch(({ _error }) => {
.catch((error) => {
toast.error('Could not export the app.', {
hideProgressBar: true,
position: 'top-center',
});
this.fileInput.value = '';
this.setState({ isExportingApp: false });
console.log(_error);
console.log(error);
});
};
@ -172,6 +171,7 @@ class HomePage extends React.Component {
isImportingApp: false,
});
this.fetchApps(this.state.currentPage, this.state.currentFolder.id);
this.fetchFolders();
})
.catch(({ error }) => {
toast.error(`Could not import the app: ${error}`, {
@ -191,15 +191,69 @@ class HomePage extends React.Component {
isImportingApp: false,
});
}
// set file input as null to handle same file upload
event.target.value = null;
};
};
isAppEditable = (app) => {
return app.app_group_permissions.some((p) => p.update);
canUserPerform(user, action, app) {
let permissionGrant;
switch (action) {
case 'create':
permissionGrant = this.canAnyGroupPerformAction('app_create', user.group_permissions);
break;
case 'read':
case 'update':
permissionGrant =
this.canAnyGroupPerformActionOnApp(action, user.app_group_permissions, app) ||
this.isUserOwnerOfApp(user, app);
break;
case 'delete':
permissionGrant =
this.canAnyGroupPerformActionOnApp('delete', user.app_group_permissions, app) ||
this.canAnyGroupPerformAction('app_delete', user.group_permissions) ||
this.isUserOwnerOfApp(user, app);
break;
default:
permissionGrant = false;
break;
}
return permissionGrant;
}
canAnyGroupPerformActionOnApp(action, appGroupPermissions, app) {
if (!appGroupPermissions) {
return false;
}
const permissionsToCheck = appGroupPermissions.filter((permission) => permission.app_id == app.id);
return this.canAnyGroupPerformAction(action, permissionsToCheck);
}
canAnyGroupPerformAction(action, permissions) {
if (!permissions) {
return false;
}
return permissions.some((p) => p[action]);
}
isUserOwnerOfApp(user, app) {
return user.id == app.user_id;
}
canCreateApp = () => {
return this.canUserPerform(this.state.currentUser, 'create');
};
isAppDeletable = (app) => {
return app.app_group_permissions.some((p) => p.delete);
canUpdateApp = (app) => {
return this.canUserPerform(this.state.currentUser, 'update', app);
};
canDeleteApp = (app) => {
return this.canUserPerform(this.state.currentUser, 'delete', app);
};
executeAppDeletion = () => {
@ -260,7 +314,14 @@ class HomePage extends React.Component {
/>
<Header switchDarkMode={this.props.switchDarkMode} darkMode={this.props.darkMode} />
{!isLoading && meta.total_count === 0 && !currentFolder.id && <BlankPage createApp={this.createApp} />}
{!isLoading && meta.total_count === 0 && !currentFolder.id && (
<BlankPage
createApp={this.createApp}
isImportingApp={isImportingApp}
fileInput={this.fileInput}
handleImportApp={this.handleImportApp}
/>
)}
{(isLoading || meta.total_count > 0) && (
<div className="page-body homepage-body">
@ -286,38 +347,43 @@ class HomePage extends React.Component {
{currentFolder.id ? `Folder: ${currentFolder.name}` : 'All applications'}
</h2>
</div>
<div className="col-auto ms-auto d-print-none">
<div className="w-100 ">
<button
className={`btn btn-default d-none d-lg-inline mb-3 ${isImportingApp ? 'btn-loading' : ''}`}
onChange={this.handleImportApp}
>
<label>
Import
<input type="file" ref={this.fileInput} style={{ display: 'none' }} />
</label>
</button>
</div>
</div>
{this.canCreateApp() && (
<>
<div className="col-auto ms-auto d-print-none">
<div className="w-100 ">
<button
className={'btn btn-default d-none d-lg-inline mb-3'}
onChange={this.handleImportApp}
>
<label>
{isImportingApp && (
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
)}
Import
<input type="file" ref={this.fileInput} style={{ display: 'none' }} />
</label>
</button>
</div>
</div>
<div className="col-auto ms-auto d-print-none">
<div className="w-100 ">
<button
className={`btn btn-primary d-none d-lg-inline mb-3 ${creatingApp ? 'btn-loading' : ''}`}
onClick={this.createApp}
>
Create new application
</button>
</div>
</div>
<div className="col-auto ms-auto d-print-none">
<div className="w-100 ">
<button
className={`btn btn-primary d-none d-lg-inline mb-3 ${
creatingApp ? 'btn-loading' : ''
}`}
onClick={this.createApp}
>
Create new application
</button>
</div>
</div>
</>
)}
</div>
<div
className={
currentFolder.count === 0
? 'table-responsive w-100 apps-table mt-3 d-flex align-items-center'
: 'table-responsive w-100 apps-table mt-3'
}
className='table-responsive w-100 apps-table'
style={{ minHeight: '600px' }}
>
<table
@ -357,7 +423,7 @@ class HomePage extends React.Component {
</small>
</td>
<td className="text-muted col-auto pt-4">
{!isLoading && this.isAppEditable(app) && (
{!isLoading && this.canUpdateApp(app) && (
<Link to={`/apps/${app.id}`} className="d-none d-lg-inline">
<OverlayTrigger
placement="top"
@ -436,9 +502,11 @@ class HomePage extends React.Component {
)}
</Link>
{this.isAppDeletable(app) && (
{(this.canCreateApp(app) || this.canDeleteApp(app)) && (
<AppMenu
app={app}
canCreateApp={this.canCreateApp()}
canDeleteApp={this.canDeleteApp(app)}
folders={this.state.folders}
foldersChanged={this.foldersChanged}
deleteApp={() => this.deleteApp(app)}

View file

@ -15,6 +15,7 @@ class LoginPage extends React.Component {
this.state = {
isLoading: false,
showPassword: false,
};
}
@ -22,6 +23,10 @@ class LoginPage extends React.Component {
this.setState({ [event.target.name]: event.target.value });
};
handleOnCheck = () => {
this.setState((prev) => ({ showPassword: !prev.showPassword }));
};
authUser = (e) => {
e.preventDefault();
@ -85,7 +90,7 @@ class LoginPage extends React.Component {
<input
onChange={this.handleChange}
name="password"
type="password"
type={this.state.showPassword ? 'text' : 'password'}
className="form-control"
placeholder="Password"
autoComplete="off"
@ -94,6 +99,18 @@ class LoginPage extends React.Component {
<span className="input-group-text"></span>
</div>
</div>
<div className="form-check">
<input
type="checkbox"
className="form-check-input"
id="check-input"
name="check-input"
onChange={this.handleOnCheck}
/>
<label className="form-check-label" htmlFor="check-input">
show password
</label>
</div>
<div className="form-footer">
<button
data-testid="loginButton"

View file

@ -45,21 +45,25 @@ class ManageGroupPermissionResources extends React.Component {
}
};
fetchGroupAndResources = (groupPermissionId) => {
fetchGroupPermission = (groupPermissionId) => {
groupPermissionService.getGroup(groupPermissionId).then((data) => {
this.setState({
groupPermission: data,
isLoadingGroup: false,
});
this.fetchUsersNotInGroup(groupPermissionId);
this.fetchUsersInGroup(groupPermissionId);
this.fetchAppsNotInGroup(groupPermissionId);
this.fetchAppsInGroup(groupPermissionId);
});
};
fetchGroupAndResources = (groupPermissionId) => {
this.setState({ isLoadingGroup: true });
this.fetchGroupPermission(groupPermissionId);
this.fetchUsersNotInGroup(groupPermissionId);
this.fetchUsersInGroup(groupPermissionId);
this.fetchAppsNotInGroup(groupPermissionId);
this.fetchAppsInGroup(groupPermissionId);
};
fetchUsersNotInGroup = (groupPermissionId) => {
groupPermissionService.getUsersNotInGroup(groupPermissionId).then((data) => {
this.setState({
@ -94,6 +98,22 @@ class ManageGroupPermissionResources extends React.Component {
});
};
updateGroupPermission = (groupPermissionId, params) => {
groupPermissionService
.update(groupPermissionId, params)
.then(() => {
toast.success('Group permissions updated', {
hideProgressBar: true,
position: 'top-center',
});
this.fetchGroupPermission(groupPermissionId);
})
.catch(({ error }) => {
toast.error(error, { hideProgressBar: true, position: 'top-center' });
});
};
updateAppGroupPermission = (app, groupPermissionId, action) => {
const appGroupPermission = app.app_group_permissions.find(
(permission) => permission.group_permission_id === groupPermissionId
@ -154,7 +174,7 @@ class ManageGroupPermissionResources extends React.Component {
addSelectedAppsToGroup = (groupPermissionId, selectedAppIds) => {
this.setState({ isAddingApps: true });
const updateParams = {
selectedAppIds,
add_apps: selectedAppIds,
};
groupPermissionService
.update(groupPermissionId, updateParams)
@ -180,7 +200,7 @@ class ManageGroupPermissionResources extends React.Component {
removeAppFromGroup = (groupPermissionId, appId) => {
const updateParams = {
removeAppIds: [appId],
remove_apps: [appId],
};
groupPermissionService
.update(groupPermissionId, updateParams)
@ -203,7 +223,7 @@ class ManageGroupPermissionResources extends React.Component {
addSelectedUsersToGroup = (groupPermissionId, selectedUserIds) => {
this.setState({ isAddingUsers: true });
const updateParams = {
selectedUserIds,
add_users: selectedUserIds,
};
groupPermissionService
.update(groupPermissionId, updateParams)
@ -229,7 +249,7 @@ class ManageGroupPermissionResources extends React.Component {
removeUserFromGroup = (groupPermissionId, userId) => {
const updateParams = {
removeUserIds: [userId],
remove_users: [userId],
};
groupPermissionService
.update(groupPermissionId, updateParams)
@ -324,9 +344,18 @@ class ManageGroupPermissionResources extends React.Component {
Users
</a>
</li>
<li className="nav-item">
<a
className={`nav-link ${currentTab === 'permissions' ? 'active' : ''}`}
onClick={() => this.setState({ currentTab: 'permissions' })}
>
Permissions
</a>
</li>
</ul>
<div className="card-body">
<div className="tab-content">
{/* Apps Tab */}
<div className={`tab-pane ${currentTab === 'apps' ? 'active show' : ''}`}>
<div className="row">
<div className="col-5">
@ -365,7 +394,7 @@ class ManageGroupPermissionResources extends React.Component {
</tr>
</thead>
<tbody>
{isLoadingApps ? (
{isLoadingGroup || isLoadingApps ? (
<tr>
<td className="col-auto">
<div className="row">
@ -383,7 +412,7 @@ class ManageGroupPermissionResources extends React.Component {
appsInGroup.map((app) => (
<tr key={app.id}>
<td>{app.name}</td>
<td className="text-muted">
<td className="text-secondary">
<div>
<label className="form-check form-check-inline">
<input
@ -431,6 +460,8 @@ class ManageGroupPermissionResources extends React.Component {
</div>
</div>
</div>
{/* Users Tab */}
<div className={`tab-pane ${currentTab === 'users' ? 'active show' : ''}`}>
<div className="row">
<div className="col-5">
@ -469,7 +500,7 @@ class ManageGroupPermissionResources extends React.Component {
</tr>
</thead>
<tbody>
{isLoadingUsers ? (
{isLoadingGroup || isLoadingUsers ? (
<tr>
<td className="col-auto">
<div className="row">
@ -508,6 +539,77 @@ class ManageGroupPermissionResources extends React.Component {
</div>
</div>
</div>
{/* Permissions Tab */}
<div className={`tab-pane ${currentTab === 'permissions' ? 'active show' : ''}`}>
<div>
<div className="table-responsive">
<table className="table table-vcenter">
<thead>
<tr>
<th>Resource</th>
<th>Permissions</th>
<th></th>
</tr>
</thead>
<tbody>
{isLoadingGroup ? (
<tr>
<td className="col-auto">
<div className="row">
<div className="skeleton-line w-10 col mx-3"></div>
</div>
</td>
<td className="col-auto">
<div className="skeleton-line w-10"></div>
</td>
<td className="col-auto">
<div className="skeleton-line w-10"></div>
</td>
</tr>
) : (
<tr>
<td>Apps</td>
<td className="text-muted">
<div>
<label className="form-check form-check-inline">
<input
className="form-check-input"
type="checkbox"
onChange={() => {
this.updateGroupPermission(groupPermission.id, {
app_create: !groupPermission.app_create,
});
}}
checked={groupPermission.app_create}
disabled={groupPermission.group === 'admin'}
/>
<span className="form-check-label">Create</span>
</label>
<label className="form-check form-check-inline">
<input
className="form-check-input"
type="checkbox"
onChange={() => {
this.updateGroupPermission(groupPermission.id, {
app_delete: !groupPermission.app_delete,
});
}}
checked={groupPermission.app_delete}
disabled={groupPermission.group === 'admin'}
/>
<span className="form-check-label">Delete</span>
</label>
</div>
</td>
<td></td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -106,13 +106,12 @@ class ManageOrgUsers extends React.Component {
if (this.handleValidation()) {
let fields = {};
Object.keys(fields).forEach((key) => {
Object.keys(this.state.fields).map((key) => {
fields[key] = '';
});
this.setState({
creatingUser: true,
fields: fields,
});
organizationUserService
@ -125,10 +124,11 @@ class ManageOrgUsers extends React.Component {
.then(() => {
toast.success('User has been created', { hideProgressBar: true, position: 'top-center' });
this.fetchUsers();
this.setState({ creatingUser: false, showNewUserForm: false });
this.setState({ creatingUser: false, showNewUserForm: false, fields: fields });
})
.catch(({ error }) => {
toast.error(error, { hideProgressBar: true, position: 'top-center' });
this.setState({ creatingUser: false });
});
} else {
this.setState({ creatingUser: false, showNewUserForm: true });
@ -259,7 +259,7 @@ class ManageOrgUsers extends React.Component {
</thead>
{isLoading ? (
<tbody className="w-100" style={{ minHeight: '300px' }}>
{Array.from(Array(4)).map((index) => (
{Array.from(Array(4)).map((_item, index) => (
<tr key={index}>
<td className="col-2 p-3">
<div className="row">
@ -299,7 +299,7 @@ class ManageOrgUsers extends React.Component {
</span>
</td>
<td className="text-muted">
<a href="#" className="text-reset user-email">
<a className="text-reset user-email">
{user.email}
</a>
</td>

View file

@ -3,7 +3,11 @@ import { useSpring, animated } from 'react-spring';
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
import Tooltip from 'react-bootstrap/Tooltip';
export const DarkModeToggle = function DarkModeToggle({ darkMode = false, switchDarkMode }) {
export const DarkModeToggle = function DarkModeToggle({
darkMode = false,
switchDarkMode,
tooltipPlacement = 'bottom',
}) {
const toggleDarkMode = () => {
switchDarkMode(!darkMode);
};
@ -43,7 +47,7 @@ export const DarkModeToggle = function DarkModeToggle({ darkMode = false, switch
return (
<OverlayTrigger
placement="bottom"
placement={tooltipPlacement}
delay={{ show: 250, hide: 400 }}
overlay={<Tooltip id="button-tooltip">{darkMode ? 'Activate light mode' : 'Activate dark mode'}</Tooltip>}
>

View file

@ -1,5 +1,6 @@
import React from 'react';
import Input from '@/_ui/Input';
import Textarea from '@/_ui/Textarea';
import Select from '@/_ui/Select';
import Headers from '@/_ui/HttpHeaders';
import OAuth from '@/_ui/OAuth';
@ -17,8 +18,9 @@ const DynamicForm = ({ schema, optionchanged, createDataSource, options, isSavin
switch (type) {
case 'password':
case 'text':
case 'textarea':
return Input;
case 'textarea':
return Textarea;
case 'dropdown':
return Select;
case 'toggle':
@ -96,7 +98,7 @@ const DynamicForm = ({ schema, optionchanged, createDataSource, options, isSavin
return (
<div className="row">
{Object.keys(obj).map((key) => {
const { $label, type } = obj[key];
const { $label, type, $encrypted } = obj[key];
const Element = getElement(type);
@ -105,7 +107,7 @@ const DynamicForm = ({ schema, optionchanged, createDataSource, options, isSavin
{$label && (
<label className="form-label">
{$label}
{type === 'password' && (
{(type === 'password' || $encrypted) && (
<small className="text-green mx-2">
<img
className="mx-2 encrypted-icon"

View file

@ -188,6 +188,12 @@ function executeAction(_ref, event, mode) {
return Promise.resolve();
}
case 'set-localstorage-value': {
const key = resolveReferences(event.key, _ref.state.currentState);
const value = resolveReferences(event.value, _ref.state.currentState);
localStorage.setItem(key, value);
}
}
}
}
@ -262,6 +268,7 @@ export async function onEvent(_ref, eventName, options, mode = 'edit') {
'onChange',
'onSelectionChange',
'onSelect',
'onClick',
].includes(eventName)
) {
const { component } = options;

View file

@ -31,6 +31,9 @@ export function resolve(data, state) {
}
export function resolveReferences(object, state, defaultValue, customObjects = {}, withError = false) {
object = _.clone(object)
const objectType = typeof object;
let error;
switch (objectType) {

View file

@ -27,14 +27,7 @@ function create(group) {
return fetch(`${config.apiUrl}/group_permissions`, requestOptions).then(handleResponse);
}
function update(groupPermissionId, params) {
const body = {
add_apps: params.selectedAppIds,
remove_apps: params.removeAppIds,
add_users: params.selectedUserIds,
remove_users: params.removeUserIds,
};
function update(groupPermissionId, body) {
const requestOptions = {
method: 'PUT',
headers: authHeader(),

View file

@ -26,7 +26,7 @@
.left-sidebar-stack-bottom {
width: 3%;
position: fixed;
bottom: 12vw;
bottom: 4vw;
height: 50px;
text-align: center;
}

View file

@ -318,7 +318,6 @@ body {
height: 350px;
width: 79%;
position: fixed;
// z-index: 1;
left: 3%;
bottom: 0;
overflow-x: hidden;
@ -422,10 +421,6 @@ body {
--tblr-gutter-x: 0rem;
}
.query-list {
// padding-top: 40px;
}
.query-list::-webkit-scrollbar {
width: 0;
background: transparent;
@ -489,7 +484,6 @@ body {
background: #edeff5;
margin: 0px auto;
background-size: 80px 80px;
// background-image: url(/public/images/tile.png);
background-repeat: repeat;
}
}
@ -768,11 +762,6 @@ body {
z-index: 2;
}
.markdown > table > :not(caption) > * > *,
.table > :not(caption) > * > * {
//padding-bottom: 0.1rem;
}
.table-row:hover,
.table-row:focus {
background: rgba(lightBlue, 0.25);
@ -798,6 +787,14 @@ body {
}
}
td.spacious {
min-height: 47px;
}
td.compact {
min-height: 40px;
}
.has-dropdown,
.has-multiselect,
.has-text,
@ -1217,6 +1214,7 @@ body {
.RichEditor-editor .public-DraftEditor-content {
min-height: 100px;
overflow-y: scroll;
}
.RichEditor-hidePlaceholder .public-DraftEditorPlaceholder-root {
@ -1246,6 +1244,60 @@ body {
user-select: none;
}
.dropmenu{
position: relative;
display: inline-block;
margin-right: 16px;
.dropdownbtn{
color: #999;
background:none;
cursor: pointer;
outline: none;
border: none;
}
.dropdown-content {
display: none;
position: absolute;
z-index: 2;
width: 100%;
align-items: center;
border: 1px solid transparent;
border-radius: 4px;
box-shadow: 0 2px 6px 2px rgba(47, 54, 59, 0.15);
a {
text-decoration: none;
width: 100%;
position: relative;
display: block;
span {
text-align: center;
width: 100%;
text-align: center;
padding: 3px 0px;
}
}
}
}
.dropmenu .dropdown-content a:hover{
background-color: rgba(0, 0, 0, 0.05);
}
.dropmenu:hover {
.dropdownbtn{
color: #5890ff;
background-color: rgba(0, 0, 0, 0.05);
border-radius: 4px;
}
.dropdown-content{
display: block;
}
}
.RichEditor-styleButton {
color: #999;
cursor: pointer;
@ -1378,9 +1430,6 @@ body {
}
}
.CodeMirror-focused {
}
.CodeMirror-vscrollbar,
.CodeMirror-hscrollbar {
background: transparent;
@ -1503,11 +1552,9 @@ hr {
}
}
// .draggable-box:hover {
.config-handle {
display: block;
}
// }
.apps-table {
.app-title {
@ -1760,6 +1807,11 @@ input:focus-visible {
}
}
.user-email:hover {
text-decoration: none;
cursor: text;
}
.theme-dark {
.navbar .navbar-nav .active > .nav-link,
.theme-dark .navbar .navbar-nav .nav-link.active,
@ -1942,7 +1994,7 @@ input:focus-visible {
.btn-light,
.btn-outline-light {
background-color: #42546a !important;
--tblr-btn-color-text: #ffffff;
img {
filter: brightness(0) invert(1);
}
@ -1992,6 +2044,10 @@ input:focus-visible {
background-color: #1f2936 !important;
}
.input-icon .input-icon-addon img {
filter: invert(1);
}
.svg-icon {
filter: brightness(0) invert(1);
}
@ -2014,21 +2070,15 @@ input:focus-visible {
.editor .editor-sidebar .inspector .header {
border: solid rgba(255, 255, 255, 0.09) !important;
border-width: 0px 0px 1px 0px !important;
.input-icon .input-icon-addon img {
filter: invert(1);
}
}
.editor .editor-sidebar .inspector .hr-text {
color: #fff !important;
}
.skeleton-line::after {
// background-image: linear-gradient(to right, #232e3c 0, #4c5b79 40%, #4c5b79 80%);
background-image: linear-gradient(to right, #566177 0, #5a6170 40%, #4c5b79 80%);
}
// .btn {
// filter: brightness(1) invert(1);
// }
.hr-text {
color: #fff !important;
}
.skeleton-line::after {
background-image: linear-gradient(to right, #566177 0, #5a6170 40%, #4c5b79 80%);
}
.select-search__input {
color: rgb(224, 224, 224);
@ -2142,21 +2192,11 @@ input:focus-visible {
}
.nav-tabs .nav-link.active {
// background: 0;
border: 0;
border-bottom: 1px solid #206bc4;
font-weight: 400;
// color: #fff;
}
// .nav-tabs .nav-link.active:hover {
// border-bottom: 1px solid #206bc4;
// }
// .nav-tabs .nav-link:hover {
// border: 0;
// }
.table-no-divider {
td {
border-bottom-width: 0px;
@ -2191,8 +2231,21 @@ input[type='text'] {
.dropdown-widget,
.multiselect-widget {
.form-label {
margin-bottom: 0px;
}
.select-search__input {
padding: 0.1375rem 0.75rem;
height: 100%;
}
.select-search__value {
height: 100%;
}
.select-search {
height: 100%
}
.select-search__options {

View file

@ -0,0 +1,10 @@
import React from 'react';
const Input = ({ helpText, ...props }) => (
<>
<textarea {...props} />
{helpText && <small className="text-muted" dangerouslySetInnerHTML={{ __html: helpText }} />}
</>
);
export default Input;

View file

@ -9,11 +9,6 @@ const API_URL = {
development: 'http://localhost:3000',
};
const ASSET_PATH = {
production: 'https://app.tooljet.io/',
development: '/public/',
};
module.exports = {
mode: 'development',
target: 'web',
@ -93,14 +88,13 @@ module.exports = {
historyApiFallback: true,
},
output: {
publicPath: '/',
publicPath: process.env.ASSET_PATH || '/',
path: path.resolve(__dirname, 'build'),
},
externals: {
// global app config object
config: JSON.stringify({
apiUrl: `${API_URL[environment] || ''}/api`,
assetPath: ASSET_PATH[environment],
SERVER_IP: process.env.SERVER_IP,
}),
},

View file

@ -1 +1 @@
0.8.0
0.8.1

View file

@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner, TableColumn } from "typeorm";
export class AddAppCreateAndAppDeleteToGroupPermissions1634724636255
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumn(
"group_permissions",
new TableColumn({
name: "app_create",
type: "boolean",
default: false,
isNullable: false,
})
);
await queryRunner.addColumn(
"group_permissions",
new TableColumn({
name: "app_delete",
type: "boolean",
default: false,
isNullable: false,
})
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn("group_permissions", "app_create");
await queryRunner.dropColumn("group_permissions", "app_delete");
}
}

View file

@ -0,0 +1,28 @@
import { EntityManager, In, MigrationInterface, QueryRunner } from "typeorm";
import { GroupPermission } from "../src/entities/group_permission.entity";
export class BackfillAppCreatePermissionsAsTruthyForAdminGroup1634729050892
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
const entityManager = queryRunner.manager;
const GroupPermissionRepostory =
entityManager.getRepository(GroupPermission);
await GroupPermissionRepostory.update(
{ group: "admin" },
{ appCreate: true, appDelete: true }
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const entityManager = queryRunner.manager;
const GroupPermissionRepostory =
entityManager.getRepository(GroupPermission);
await GroupPermissionRepostory.update(
{ group: "admin" },
{ appCreate: false, appDelete: false }
);
}
}

View file

@ -0,0 +1,44 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { AppVersion } from '../src/entities/app_version.entity';
export class SetTablecellSizeToCompact1634848932643 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const entityManager = queryRunner.manager;
const queryBuilder = queryRunner.connection.createQueryBuilder();
const appVersionRepository = entityManager.getRepository(AppVersion);
const appVersions = await appVersionRepository.find();
for (const version of appVersions) {
const definition = version['definition'];
if (definition) {
const components = definition['components'];
for (const componentId of Object.keys(components)) {
const component = components[componentId];
if (component.component.component === 'Table') {
component.component.definition.styles.cellSize = { value: 'compact' };
components[componentId] = {
...component,
component: {
...component.component,
definition: {
...component.component.definition,
},
},
};
}
}
definition['components'] = components;
version.definition = definition;
await queryBuilder.update(AppVersion).set({ definition }).where('id = :id', { id: version.id }).execute();
}
}
}
public async down(queryRunner: QueryRunner): Promise<void> {}
}

View file

@ -41,6 +41,9 @@ export class App extends BaseEntity {
@Column({ name: 'current_version_id' })
currentVersionId: string;
@Column({ name: 'user_id' })
userId: string;
@CreateDateColumn({ default: () => 'now()', name: 'created_at' })
createdAt: Date;

View file

@ -28,6 +28,12 @@ export class GroupPermission extends BaseEntity {
@Column()
group: string;
@Column({ name: 'app_create', default: false })
appCreate: boolean;
@Column({ name: 'app_delete', default: false })
appDelete: boolean;
@CreateDateColumn({ default: () => 'now()', name: 'created_at' })
createdAt: Date;

View file

@ -17,11 +17,14 @@ import { FoldersService } from '@services/folders.service';
import { Folder } from 'src/entities/folder.entity';
import { FolderApp } from 'src/entities/folder_app.entity';
import { DataSource } from 'src/entities/data_source.entity';
import { AppCloneService } from '@services/app_clone.service';
import { GroupPermission } from 'src/entities/group_permission.entity';
import { AppGroupPermission } from 'src/entities/app_group_permission.entity';
import { UserGroupPermission } from 'src/entities/user_group_permission.entity';
import { AppImportExportService } from '@services/app_import_export.service';
import { DataSourcesService } from '@services/data_sources.service';
import { CredentialsService } from '@services/credentials.service';
import { EncryptionService } from '@services/encryption.service';
import { Credential } from 'src/entities/credential.entity';
@Module({
imports: [
@ -39,10 +42,20 @@ import { AppImportExportService } from '@services/app_import_export.service';
GroupPermission,
AppGroupPermission,
UserGroupPermission,
Credential,
]),
CaslModule,
],
providers: [AppsService, AppUsersService, UsersService, FoldersService, AppCloneService, AppImportExportService],
providers: [
AppsService,
AppUsersService,
UsersService,
FoldersService,
AppImportExportService,
DataSourcesService,
CredentialsService,
EncryptionService,
],
controllers: [AppsController, AppUsersController],
})
export class AppsModule {}

View file

@ -14,12 +14,13 @@ import { OrganizationUsersService } from 'src/services/organization_users.servic
import { ConfigService } from '@nestjs/config';
import { EmailService } from '@services/email.service';
import { GroupPermission } from 'src/entities/group_permission.entity';
import { App } from 'src/entities/app.entity';
@Module({
imports: [
UsersModule,
PassportModule,
TypeOrmModule.forFeature([User, Organization, OrganizationUser, GroupPermission]),
TypeOrmModule.forFeature([User, Organization, OrganizationUser, GroupPermission, App]),
JwtModule.registerAsync({
useFactory: (config: ConfigService) => {
return {

View file

@ -38,8 +38,11 @@ export class AppsAbilityFactory {
async appsActions(user: User, params: any) {
const { can, build } = new AbilityBuilder<Ability<[Actions, Subjects]>>(Ability as AbilityClass<AppsAbility>);
if (await this.usersService.userCan(user, 'create', 'App')) {
if (await this.usersService.userCan(user, 'create', 'User')) {
can('createUsers', App, { organizationId: user.organizationId });
}
if (await this.usersService.userCan(user, 'create', 'App')) {
can('createApp', App);
can('cloneApp', App, { organizationId: user.organizationId });
}

View file

@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { EmailService } from '@services/email.service';
import { OrganizationUsersService } from '@services/organization_users.service';
import { UsersService } from '@services/users.service';
import { App } from 'src/entities/app.entity';
import { Organization } from 'src/entities/organization.entity';
import { OrganizationUser } from 'src/entities/organization_user.entity';
import { User } from 'src/entities/user.entity';
@ -10,7 +11,7 @@ import { AppsAbilityFactory } from './abilities/apps-ability.factory';
import { CaslAbilityFactory } from './casl-ability.factory';
@Module({
imports: [TypeOrmModule.forFeature([User, Organization, OrganizationUser])],
imports: [TypeOrmModule.forFeature([User, Organization, OrganizationUser, App])],
providers: [CaslAbilityFactory, OrganizationUsersService, UsersService, EmailService, AppsAbilityFactory],
exports: [CaslAbilityFactory, AppsAbilityFactory],
})

View file

@ -14,13 +14,13 @@ import { App } from 'src/entities/app.entity';
import { AppVersion } from 'src/entities/app_version.entity';
import { AppUser } from 'src/entities/app_user.entity';
import { FolderApp } from 'src/entities/folder_app.entity';
import { AppCloneService } from '@services/app_clone.service';
import { GroupPermission } from 'src/entities/group_permission.entity';
import { AppGroupPermission } from 'src/entities/app_group_permission.entity';
import { UsersService } from '@services/users.service';
import { User } from 'src/entities/user.entity';
import { OrganizationUser } from 'src/entities/organization_user.entity';
import { Organization } from 'src/entities/organization.entity';
import { AppImportExportService } from '@services/app_import_export.service';
@Module({
imports: [
@ -46,8 +46,8 @@ import { Organization } from 'src/entities/organization.entity';
EncryptionService,
DataSourcesService,
AppsService,
AppCloneService,
UsersService,
AppImportExportService,
],
controllers: [DataQueriesController],
})

View file

@ -14,13 +14,13 @@ import { CaslModule } from '../casl/casl.module';
import { DataQueriesService } from '@services/data_queries.service';
import { DataQuery } from 'src/entities/data_query.entity';
import { FolderApp } from 'src/entities/folder_app.entity';
import { AppCloneService } from '@services/app_clone.service';
import { GroupPermission } from 'src/entities/group_permission.entity';
import { AppGroupPermission } from 'src/entities/app_group_permission.entity';
import { UsersService } from '@services/users.service';
import { User } from 'src/entities/user.entity';
import { OrganizationUser } from 'src/entities/organization_user.entity';
import { Organization } from 'src/entities/organization.entity';
import { AppImportExportService } from '@services/app_import_export.service';
@Module({
imports: [
@ -46,8 +46,8 @@ import { Organization } from 'src/entities/organization.entity';
EncryptionService,
AppsService,
DataQueriesService,
AppCloneService,
UsersService,
AppImportExportService,
],
controllers: [DataSourcesController],
})

View file

@ -11,9 +11,10 @@ import { UsersService } from 'src/services/users.service';
import { CaslModule } from '../casl/casl.module';
import { EmailService } from '@services/email.service';
import { GroupPermission } from 'src/entities/group_permission.entity';
import { App } from 'src/entities/app.entity';
@Module({
imports: [TypeOrmModule.forFeature([Organization, OrganizationUser, User, GroupPermission]), CaslModule],
imports: [TypeOrmModule.forFeature([Organization, OrganizationUser, User, GroupPermission, App]), CaslModule],
providers: [OrganizationsService, OrganizationUsersService, UsersService, EmailService],
controllers: [OrganizationsController, OrganizationUsersController],
})

View file

@ -6,9 +6,10 @@ import { Organization } from '../../entities/organization.entity';
import { User } from '../../entities/user.entity';
import { UsersController } from 'src/controllers/users.controller';
import { OrganizationsModule } from '../organizations/organizations.module';
import { App } from 'src/entities/app.entity';
@Module({
imports: [OrganizationsModule, TypeOrmModule.forFeature([User, Organization, OrganizationUser])],
imports: [OrganizationsModule, TypeOrmModule.forFeature([User, Organization, OrganizationUser, App])],
providers: [UsersService],
controllers: [UsersController],
})

View file

@ -1,137 +0,0 @@
import { Injectable } from '@nestjs/common';
import { EntityManager } from 'typeorm/entity-manager/EntityManager';
import { App } from 'src/entities/app.entity';
import { User } from 'src/entities/user.entity';
import { AppUser } from 'src/entities/app_user.entity';
import { AppVersion } from 'src/entities/app_version.entity';
import { DataSource } from 'src/entities/data_source.entity';
import { DataQuery } from 'src/entities/data_query.entity';
import { Credential } from 'src/entities/credential.entity';
import { GroupPermission } from 'src/entities/group_permission.entity';
import { AppGroupPermission } from 'src/entities/app_group_permission.entity';
@Injectable()
export class AppCloneService {
constructor(private readonly entityManager: EntityManager) {}
async perform(existingApp: App, user: User): Promise<App> {
let clonedApp: App;
await this.entityManager.transaction(async (manager) => {
clonedApp = await this.createClonedAppForUser(manager, existingApp, user);
await this.buildClonedAppAssociations(manager, clonedApp, existingApp);
await this.createAdminGroupPermissions(manager, clonedApp);
});
return clonedApp;
}
async createClonedAppForUser(manager: EntityManager, existingApp: App, currentUser: User): Promise<App> {
const newApp = manager.create(App, {
name: existingApp.name,
organizationId: currentUser.organizationId,
user: currentUser,
});
await manager.save(newApp);
const newAppUser = manager.create(AppUser, {
app: newApp,
user: currentUser,
role: 'admin',
});
await manager.save(newAppUser);
return newApp;
}
async buildClonedAppAssociations(manager: EntityManager, newApp: App, existingApp: App) {
const dataSourceMapping = {};
const newDefinition = existingApp.editingVersion?.definition;
const existingDataSources = await manager.find(DataSource, {
app: existingApp,
});
for (const source of existingDataSources) {
const clonedOptions = await this.cloneOptionsWithNewCredentials(manager, source.options);
const newSource = manager.create(DataSource, {
app: newApp,
name: source.name,
options: clonedOptions,
kind: source.kind,
});
await manager.save(newSource);
dataSourceMapping[source.id] = newSource.id;
}
const existingDataQueries = await manager.find(DataQuery, {
app: existingApp,
});
for (const query of existingDataQueries) {
const newQuery = manager.create(DataQuery, {
app: newApp,
name: query.name,
options: query.options,
kind: query.kind,
dataSourceId: dataSourceMapping[query.dataSourceId],
});
await manager.save(newQuery);
dataSourceMapping[query.id] = newQuery.id;
}
const version = manager.create(AppVersion, {
app: newApp,
definition: newDefinition,
name: 'v0',
});
await manager.save(version);
await manager.update(App, newApp, {
currentVersionId: version.id,
});
}
async cloneOptionsWithNewCredentials(manager: EntityManager, options: any) {
for (const key of Object.keys(options)) {
if ('credential_id' in options[key]) {
const existingCredential = await manager.findOne(Credential, {
id: options[key]['credential_id'],
});
const newCredential = manager.create(Credential, {
valueCiphertext: existingCredential.valueCiphertext,
});
await manager.save(newCredential);
options[key]['credential_id'] = newCredential.id;
}
}
return options;
}
async createAdminGroupPermissions(manager: EntityManager, app: App) {
const orgDefaultGroupPermissions = await manager.find(GroupPermission, {
where: {
organizationId: app.organizationId,
group: 'admin',
},
});
const adminPermissions = {
read: true,
update: true,
delete: true,
};
for (const groupPermission of orgDefaultGroupPermissions) {
const appGroupPermission = manager.create(AppGroupPermission, {
groupPermissionId: groupPermission.id,
appId: app.id,
...adminPermissions,
});
return await manager.save(AppGroupPermission, appGroupPermission);
}
}
}

View file

@ -8,19 +8,21 @@ import { DataQuery } from 'src/entities/data_query.entity';
import { AppVersion } from 'src/entities/app_version.entity';
import { GroupPermission } from 'src/entities/group_permission.entity';
import { AppGroupPermission } from 'src/entities/app_group_permission.entity';
import { Credential } from 'src/entities/credential.entity';
import { DataSourcesService } from './data_sources.service';
@Injectable()
export class AppImportExportService {
constructor(
@InjectRepository(App)
private appsRepository: Repository<App>,
private dataSourcesService: DataSourcesService,
private readonly entityManager: EntityManager
) {}
async export(user: User, id: string): Promise<App> {
const appToExport = this.appsRepository.findOne(id, {
relations: ['dataQueries', 'dataSources', 'appVersions'],
where: { organizationId: user.organizationId },
});
return appToExport;
@ -53,8 +55,10 @@ export class AppImportExportService {
name: appParams.name,
organizationId: user.organizationId,
user: user,
slug: null, // Prevent db unique constraint error. App entity afterload callback will set this as id.
isPublic: true,
slug: null, // Prevent db unique constraint error.
isPublic: false,
createdAt: new Date(),
updatedAt: new Date(),
});
await manager.save(importedApp);
return importedApp;
@ -62,25 +66,32 @@ export class AppImportExportService {
async buildImportedAppAssociations(manager: EntityManager, importedApp: App, appParams: any) {
const dataSourceMapping = {};
const dataQueryMapping = {};
let currentVersionId: string;
const dataSources = appParams?.dataSources || [];
const dataQueries = appParams?.dataQueries || [];
const appVersions = appParams?.appVersions || [];
for (const source of dataSources) {
const newOptions = await this.copyOptionsWithNewCredentials(manager, source.options);
const convertedOptions = this.convertToArrayOfKeyValuePairs(source.options);
// FIXME: credentials if present is created outside this db transaction and
// will not be rolled back if import fails
const newOptions = await this.dataSourcesService.parseOptionsForCreate(convertedOptions);
const newSource = manager.create(DataSource, {
app: importedApp,
name: source.name,
kind: source.kind,
options: newOptions,
createdAt: new Date(),
updatedAt: new Date(),
});
await manager.save(newSource);
dataSourceMapping[source.id] = newSource.id;
}
const newDataQueries = [];
for (const query of dataQueries) {
const newQuery = manager.create(DataQuery, {
app: importedApp,
@ -90,13 +101,24 @@ export class AppImportExportService {
dataSourceId: dataSourceMapping[query.dataSourceId],
});
await manager.save(newQuery);
dataQueryMapping[query.id] = newQuery.id;
newDataQueries.push(newQuery);
}
for (const newQuery of newDataQueries) {
const newOptions = this.replaceDataQueryOptionsWithNewDataQueryIds(newQuery.options, dataQueryMapping);
newQuery.options = newOptions;
await manager.save(newQuery);
}
for (const appVersion of appVersions) {
const version = manager.create(AppVersion, {
app: importedApp,
definition: appVersion.definition,
definition: await this.replaceDataQueryIdWithinDefinitions(appVersion.definition, dataQueryMapping),
name: appVersion.name,
createdAt: new Date(),
updatedAt: new Date(),
});
await manager.save(version);
@ -109,26 +131,6 @@ export class AppImportExportService {
}
}
async copyOptionsWithNewCredentials(manager: EntityManager, options: any) {
for (const key of Object.keys(options)) {
if ('credential_id' in options[key]) {
const existingCredential = await manager.findOne(Credential, {
id: options[key]['credential_id'],
});
if (existingCredential) {
const newCredential = manager.create(Credential, {
valueCiphertext: existingCredential.valueCiphertext,
});
await manager.save(newCredential);
options[key]['credential_id'] = newCredential.id;
}
}
}
return options;
}
async createAdminGroupPermissions(manager: EntityManager, app: App) {
const orgDefaultGroupPermissions = await manager.find(GroupPermission, {
where: {
@ -153,4 +155,61 @@ export class AppImportExportService {
return await manager.save(AppGroupPermission, appGroupPermission);
}
}
convertToArrayOfKeyValuePairs(options): Array<object> {
return Object.keys(options).map((key) => {
return {
key: key,
value: options[key]['value'],
encrypted: options[key]['encrypted'],
};
});
}
replaceDataQueryOptionsWithNewDataQueryIds(options, dataQueryMapping) {
if (options && options.events) {
const replacedEvents = options.events.map((event) => {
if (event.queryId) {
event.queryId = dataQueryMapping[event.queryId];
}
return event;
});
options.events = replacedEvents;
}
return options;
}
replaceDataQueryIdWithinDefinitions(definition, dataQueryMapping) {
if (definition?.components) {
for (const id of Object.keys(definition.components)) {
const component = definition.components[id].component;
if (component?.definition?.events) {
const replacedComponentEvents = component.definition.events.map((event) => {
if (event.queryId) {
event.queryId = dataQueryMapping[event.queryId];
}
return event;
});
component.definition.events = replacedComponentEvents;
}
if (component?.definition?.properties?.actions?.value) {
for (const value of component.definition.properties.actions.value) {
if (value?.events) {
const replacedComponentActionEvents = value.events.map((event) => {
if (event.queryId) {
event.queryId = dataQueryMapping[event.queryId];
}
return event;
});
value.events = replacedComponentActionEvents;
}
}
}
definition.components[id].component = component;
}
}
return definition;
}
}

View file

@ -9,11 +9,11 @@ import { FolderApp } from 'src/entities/folder_app.entity';
import { DataSource } from 'src/entities/data_source.entity';
import { DataQuery } from 'src/entities/data_query.entity';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { AppCloneService } from './app_clone.service';
import { GroupPermission } from 'src/entities/group_permission.entity';
import { AppGroupPermission } from 'src/entities/app_group_permission.entity';
import { UserGroupPermission } from 'src/entities/user_group_permission.entity';
import { UsersService } from './users.service';
import { AppImportExportService } from './app_import_export.service';
@Injectable()
export class AppsService {
@ -42,8 +42,8 @@ export class AppsService {
@InjectRepository(AppGroupPermission)
private appGroupPermissionsRepository: Repository<AppGroupPermission>,
private AppCloneService: AppCloneService,
private usersService: UsersService
private usersService: UsersService,
private appImportExportService: AppImportExportService
) {}
async find(id: string): Promise<App> {
@ -88,12 +88,12 @@ export class AppsService {
})
);
await this.createAdminGroupPermissions(app);
await this.createAppGroupPermissionsForAdmin(app);
return app;
}
async createAdminGroupPermissions(app: App) {
async createAppGroupPermissionsForAdmin(app: App) {
const orgDefaultGroupPermissions = await this.groupPermissionsRepository.find({
where: {
organizationId: app.organizationId,
@ -105,14 +105,14 @@ export class AppsService {
const appGroupPermission = this.appGroupPermissionsRepository.create({
groupPermissionId: groupPermission.id,
appId: app.id,
...this.determineDefaultAppGroupPermissions(groupPermission.group),
...this.fetchDefaultAppGroupPermissions(groupPermission.group),
});
await this.appGroupPermissionsRepository.save(appGroupPermission);
}
}
determineDefaultAppGroupPermissions(group: string): {
fetchDefaultAppGroupPermissions(group: string): {
read: boolean;
update: boolean;
delete: boolean;
@ -128,7 +128,8 @@ export class AppsService {
}
async clone(existingApp: App, user: User): Promise<App> {
const clonedApp = await this.AppCloneService.perform(existingApp, user);
const appWithRelations = await this.appImportExportService.export(user, existingApp.id);
const clonedApp = await this.appImportExportService.import(user, appWithRelations);
return clonedApp;
}
@ -144,9 +145,10 @@ export class AppsService {
)
.where('user_group_permissions.user_id = :userId', { userId: user.id })
.andWhere('app_group_permissions.read = :value', { value: true })
.orWhere('apps.is_public = :value AND apps.organization_id = :organizationId', {
.orWhere('(apps.is_public = :value AND apps.organization_id = :organizationId) OR apps.user_id = :userId', {
value: true,
organizationId: user.organizationId,
userId: user.id,
})
.getCount();
}
@ -155,6 +157,7 @@ export class AppsService {
const viewableAppsQb = await createQueryBuilder(App, 'apps')
.innerJoin('apps.groupPermissions', 'group_permissions')
.innerJoinAndSelect('apps.appGroupPermissions', 'app_group_permissions')
.innerJoinAndSelect('apps.user', 'user')
.innerJoin(
UserGroupPermission,
'user_group_permissions',
@ -162,9 +165,10 @@ export class AppsService {
)
.where('user_group_permissions.user_id = :userId', { userId: user.id })
.andWhere('app_group_permissions.read = :value', { value: true })
.orWhere('apps.is_public = :value AND apps.organization_id = :organizationId', {
.orWhere('(apps.is_public = :value AND apps.organization_id = :organizationId) OR apps.user_id = :userId', {
value: true,
organizationId: user.organizationId,
userId: user.id,
});
// FIXME:

View file

@ -5,6 +5,7 @@ import { JwtService } from '@nestjs/jwt';
import { User } from '../entities/user.entity';
import { OrganizationUsersService } from './organization_users.service';
import { EmailService } from './email.service';
import { decamelizeKeys } from 'humps';
const bcrypt = require('bcrypt');
const uuid = require('uuid');
@ -33,13 +34,16 @@ export class AuthService {
if (user) {
const payload = { username: user.id, sub: user.email };
return {
return decamelizeKeys({
id: user.id,
auth_token: this.jwtService.sign(payload),
email: user.email,
first_name: user.firstName,
last_name: user.lastName,
admin: await this.usersService.hasGroup(user, 'admin'),
};
group_permissions: await this.usersService.groupPermissions(user),
app_group_permissions: await this.usersService.appGroupPermissions(user),
});
} else {
throw new UnauthorizedException('Invalid credentials');
}

View file

@ -55,9 +55,10 @@ export class FoldersService {
)
.where('user_group_permissions.user_id = :userId', { userId: user.id })
.andWhere('app_group_permissions.read = :value', { value: true })
.orWhere('apps.is_public = :value and apps.organization_id = :organizationId', {
.orWhere('(apps.is_public = :value AND apps.organization_id = :organizationId) OR apps.user_id = :userId', {
value: true,
organizationId: user.organizationId,
userId: user.id,
})
.getMany();
const allViewableAppIds = allViewableApps.map((app) => app.id);
@ -122,6 +123,7 @@ export class FoldersService {
viewableApps = [];
} else {
viewableApps = await createQueryBuilder(App, 'apps')
.innerJoinAndSelect('apps.user', 'user')
.innerJoin('apps.groupPermissions', 'group_permissions')
.innerJoinAndSelect('apps.appGroupPermissions', 'app_group_permissions')
.innerJoin(

View file

@ -48,7 +48,7 @@ export class GroupPermissionsService {
if (groupPermission.group == 'admin' || groupPermission.group == 'all_users') {
throw new BadRequestException('Cannot delete default group');
}
getManager().transaction(async (manager) => {
await getManager().transaction(async (manager) => {
const relationalEntitiesToBeDeleted = [AppGroupPermission, UserGroupPermission];
for (const entityToDelete of relationalEntitiesToBeDeleted) {
@ -94,25 +94,37 @@ export class GroupPermissionsService {
organizationId: user.organizationId,
});
await this.appGroupPermissionsRepository.manager.transaction(async (manager) => {
if (body.remove_apps) {
const { app_create, app_delete, add_apps, remove_apps, add_users, remove_users } = body;
await getManager().transaction(async (manager) => {
// update group permissions
const groupPermissionUpdateParams = {
...(typeof app_create === 'boolean' && { appCreate: app_create }),
...(typeof app_delete === 'boolean' && { appDelete: app_delete }),
};
if (Object.keys(groupPermissionUpdateParams).length !== 0) {
await manager.update(GroupPermission, groupPermissionId, groupPermissionUpdateParams);
}
// update app group permissions
if (remove_apps) {
if (groupPermission.group == 'admin') {
throw new BadRequestException('Cannot update admin group');
}
for (const appId of body.remove_apps) {
manager.delete(AppGroupPermission, {
for (const appId of remove_apps) {
await manager.delete(AppGroupPermission, {
appId: appId,
groupPermissionId: groupPermissionId,
});
}
}
if (body.add_apps) {
if (add_apps) {
if (groupPermission.group == 'admin') {
throw new BadRequestException('Cannot update admin group');
}
for (const appId of body.add_apps) {
manager.save(
for (const appId of add_apps) {
await manager.save(
AppGroupPermission,
manager.create(AppGroupPermission, {
appId: appId,
@ -122,10 +134,9 @@ export class GroupPermissionsService {
);
}
}
});
await this.userGroupPermissionsRepository.manager.transaction(async (manager) => {
if (body.remove_users) {
// update user group permissions
if (remove_users) {
for (const userId of body.remove_users) {
const params = {
removeGroups: [groupPermission.group],
@ -134,7 +145,7 @@ export class GroupPermissionsService {
}
}
if (body.add_users) {
if (add_users) {
for (const userId of body.add_users) {
const params = {
addGroups: [groupPermission.group],

View file

@ -15,7 +15,7 @@ export class OrganizationUsersService {
private organizationUsersRepository: Repository<OrganizationUser>,
private usersService: UsersService,
private emailService: EmailService
) {}
) { }
async findOne(id: string): Promise<OrganizationUser> {
return await this.organizationUsersRepository.findOne({ id: id });
@ -28,6 +28,10 @@ export class OrganizationUsersService {
email: params['email'],
};
const existingUser = await this.usersService.findByEmail(userParams.email);
if (existingUser) {
throw new BadRequestException('User with such email already exists.');
}
const user = await this.usersService.create(userParams, currentUser.organization, ['all_users']);
const organizationUser = await this.create(user, currentUser.organization);

View file

@ -62,6 +62,8 @@ export class SeedsService {
const groupPermission = manager.create(GroupPermission, {
organizationId: user.organizationId,
group: group,
appCreate: group == 'admin',
appDelete: group == 'admin',
});
await manager.save(groupPermission);

View file

@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { User } from '../entities/user.entity';
import { Organization } from 'src/entities/organization.entity';
import { App } from 'src/entities/app.entity';
import { createQueryBuilder, EntityManager, getManager, getRepository, In, Repository } from 'typeorm';
import { OrganizationUser } from '../entities/organization_user.entity';
import { AppGroupPermission } from 'src/entities/app_group_permission.entity';
@ -19,7 +20,9 @@ export class UsersService {
@InjectRepository(OrganizationUser)
private organizationUsersRepository: Repository<OrganizationUser>,
@InjectRepository(Organization)
private organizationsRepository: Repository<Organization>
private organizationsRepository: Repository<Organization>,
@InjectRepository(App)
private appsRepository: Repository<App>
) {}
async findOne(id: string): Promise<User> {
@ -229,19 +232,53 @@ export class UsersService {
async userCan(user: User, action: string, entityName: string, resourceId?: string): Promise<boolean> {
switch (entityName) {
case 'App':
if (action == 'create') {
return await this.hasGroup(user, 'admin');
} else {
return this.canAnyGroupPerformAction(action, await this.appGroupPermissions(user, resourceId));
}
return await this.canUserPerformActionOnApp(user, action, resourceId);
case 'User':
return await this.hasGroup(user, 'admin');
default:
return false;
}
}
canAnyGroupPerformAction(action: string, permissions: AppGroupPermission[]): boolean {
return permissions.some((p) => p[action.toLowerCase()]);
async canUserPerformActionOnApp(user: User, action: string, appId?: string): Promise<boolean> {
let permissionGrant: boolean;
switch (action) {
case 'create':
permissionGrant = this.canAnyGroupPerformAction('appCreate', await this.groupPermissions(user));
break;
case 'read':
case 'update':
permissionGrant =
this.canAnyGroupPerformAction(action, await this.appGroupPermissions(user, appId)) ||
(await this.isUserOwnerOfApp(user, appId));
break;
case 'delete':
permissionGrant =
this.canAnyGroupPerformAction('delete', await this.appGroupPermissions(user, appId)) ||
this.canAnyGroupPerformAction('appDelete', await this.groupPermissions(user)) ||
(await this.isUserOwnerOfApp(user, appId));
break;
default:
permissionGrant = false;
break;
}
return permissionGrant;
}
async isUserOwnerOfApp(user, appId): Promise<boolean> {
const app = await this.appsRepository.findOne({
id: appId,
userId: user.id,
});
return !!app;
}
canAnyGroupPerformAction(action: string, permissions: AppGroupPermission[] | GroupPermission[]): boolean {
return permissions.some((p) => p[action]);
}
async groupPermissions(user: User, organizationId?: string): Promise<GroupPermission[]> {
@ -258,15 +295,21 @@ export class UsersService {
return await groupPermissionRepository.find({ organizationId });
}
async appGroupPermissions(user: User, appId: string, organizationId?: string): Promise<AppGroupPermission[]> {
async appGroupPermissions(user: User, appId?: string, organizationId?: string): Promise<AppGroupPermission[]> {
const orgUserGroupPermissions = await this.userGroupPermissions(user, organizationId);
const groupIds = orgUserGroupPermissions.map((p) => p.groupPermissionId);
const appGroupPermissionRepository = getRepository(AppGroupPermission);
return await appGroupPermissionRepository.find({
groupPermissionId: In(groupIds),
appId: appId,
});
if (appId) {
return await appGroupPermissionRepository.find({
groupPermissionId: In(groupIds),
appId: appId,
});
} else {
return await appGroupPermissionRepository.find({
groupPermissionId: In(groupIds),
});
}
}
async userGroupPermissions(user: User, organizationId?: string): Promise<UserGroupPermission[]> {

View file

@ -285,7 +285,7 @@ describe('apps controller', () => {
expect(await AppUser.findOne({ appId: application.id })).toBeUndefined();
});
it('should not be possible for non-admin user to delete an app, cascaded with its versions, queries and data sources', async () => {
it('should be possible for app creator to delete an app', async () => {
const developer = await createUser(app, {
email: 'developer@tooljet.io',
groups: ['all_users', 'developer'],
@ -306,11 +306,35 @@ describe('apps controller', () => {
.delete(`/api/apps/${application.id}`)
.set('Authorization', authHeaderForUser(developer.user));
expect(response.statusCode).toBe(403);
expect(await App.findOne(application.id)).not.toBeUndefined();
expect(response.statusCode).toBe(200);
expect(await App.findOne(application.id)).toBeUndefined();
});
});
it('should not be possible for non admin to delete an app', async () => {
const adminUserData = await createUser(app, {
email: 'admin@tooljet.io',
groups: ['all_users', 'admin'],
});
const application = await createApplication(app, {
name: 'name',
user: adminUserData.user,
});
const developerUserData = await createUser(app, {
email: 'dev@tooljet.io',
groups: ['all_users', 'developer'],
organization: adminUserData.organization,
});
const response = await request(app.getHttpServer())
.delete(`/api/apps/${application.id}`)
.set('Authorization', authHeaderForUser(developerUserData.user));
expect(response.statusCode).toBe(403);
expect(await App.findOne(application.id)).not.toBeUndefined();
});
});
describe('/api/apps/uuid/users', () => {

View file

@ -541,13 +541,13 @@ describe('group permissions controller', () => {
} = await setupOrganizations(nestApp);
const manager = getManager();
const adminGroupPermission = await manager.findOne(GroupPermission, {
const groupPermission = await manager.findOne(GroupPermission, {
where: {
organizationId: organization.id,
group: 'all_users',
},
});
const groupPermissionId = adminGroupPermission.id;
const groupPermissionId = groupPermission.id;
const appGroupPermission = await manager.findOne(AppGroupPermission, {
groupPermissionId,
});
@ -576,13 +576,13 @@ describe('group permissions controller', () => {
} = await setupOrganizations(nestApp);
const manager = getManager();
const adminGroupPermission = await manager.findOne(GroupPermission, {
const groupPermission = await manager.findOne(GroupPermission, {
where: {
organizationId: organization.id,
group: 'all_users',
},
});
const groupPermissionId = adminGroupPermission.id;
const groupPermissionId = groupPermission.id;
const appGroupPermission = await manager.findOne(AppGroupPermission, {
groupPermissionId,
});

View file

@ -14,7 +14,7 @@ import { GroupPermission } from 'src/entities/group_permission.entity';
import { AppImportExportService } from '@services/app_import_export.service';
import { AppGroupPermission } from 'src/entities/app_group_permission.entity';
describe('UsersService', () => {
describe('AppImportExportService', () => {
let nestApp: INestApplication;
let service: AppImportExportService;
@ -135,7 +135,7 @@ describe('UsersService', () => {
expect(importedApp.id == exportedApp.id).toBeFalsy();
expect(importedApp.name).toBe(exportedApp.name);
expect(importedApp.isPublic).toBe(exportedApp.isPublic);
expect(importedApp.isPublic).toBeFalsy();
expect(importedApp.organizationId).toBe(exportedApp.organizationId);
expect(importedApp.currentVersionId).toBe(null);
expect(importedApp.appVersions).toEqual([]);
@ -186,7 +186,7 @@ describe('UsersService', () => {
expect(importedApp.id == exportedApp.id).toBeFalsy();
expect(importedApp.name).toBe(exportedApp.name);
expect(importedApp.isPublic).toBe(exportedApp.isPublic);
expect(importedApp.isPublic).toBeFalsy();
expect(importedApp.organizationId).toBe(exportedApp.organizationId);
expect(importedApp.currentVersionId).toBe(null);

View file

@ -160,16 +160,21 @@ describe('UsersService', () => {
describe('.appGroupPermissions', () => {
it('should return app group permissions for the user', async () => {
const { defaultUser, app } = await setupOrganization(nestApp);
const groupPermissionIdsFromApp = (await service.appGroupPermissions(defaultUser, app.id)).map(
const { adminUser, defaultUser, app } = await setupOrganization(nestApp);
let groupPermissionIdsFromApp = (await service.appGroupPermissions(adminUser, app.id)).map(
(x) => x.groupPermissionId
);
const groupPermissionIds = (await service.groupPermissions(defaultUser))
const adminGroupPermissionIds = (await service.groupPermissions(adminUser))
.filter((x) => x.group == 'admin')
.map((x) => x.id);
expect(new Set(groupPermissionIdsFromApp)).toEqual(new Set(groupPermissionIds));
expect(new Set(groupPermissionIdsFromApp)).toEqual(new Set(adminGroupPermissionIds));
groupPermissionIdsFromApp = (await service.appGroupPermissions(defaultUser, app.id)).map(
(x) => x.groupPermissionId
);
expect(groupPermissionIdsFromApp).toEqual([]);
});
});

View file

@ -215,6 +215,8 @@ export async function maybeCreateDefaultGroupPermissions(nestApp, organizationId
const groupPermission = groupPermissionRepository.create({
organizationId: organizationId,
group: group,
appCreate: group == 'admin',
appDelete: group == 'admin',
});
await groupPermissionRepository.save(groupPermission);
}