Feature: Import export tjdb schema (#5752)

* add ability to import export app and tjdb schema

* init

* feat ::global settings popover new ui

* feat :: ui for version export modal

* fix :: import export modal

* cleanup

* ui updates

* header footer style fixes

* closing settings modal while showing export modal

* style fix header

* feat :: added button to download table schema

* fix :: styling for fx

* add ability to import and export apps with tjdb schema

* handle duplicate table in workspace

* fix table rename

* fix selected table on edit and delete

* fix invalid toast on table delete

* fix column default value

* handle exports to strip '::' and quotes

* make import/export backward compatible

* handle page redirects based on resource import

* handle import without tjdb schema

* fix column delete and addition

* make data migrations to be run per organizations

* wip

* update migration

* fix credentials to be included

* fix specific version export

* make use of apps ability for import export resource

* fix import navigation

* fix lint

* fix failing tests

* fix lint

* enable tjdb for public apps

* update export error message on tjdb table blank

* fix table not selected after creation

* fix :: styling for imp exp modal , and functionality bug fixes after dev merge

* fixes blank slate and columns selection

* fix table delete

* fix invalid toast on table edit

* fix column information missing tjdb query manager

* make ds imports to either reuse global or create

* export only unique table ids

* create default datasources if not present in export data

* reuse existing table on imports

* add timestamp to table name if name already exists

* add ability to clone with tjdb

* make imports work with marketplace plugin

* skip dataqueries for which plugins are not installed

* fix filter input width

* fix failing spec

* fix marketplace plugin installation in diff workspaces

* fix check for plugin installed in workspace

* fix export when table name is empty

---------

Co-authored-by: stepinfwd <stepinfwd@gmail.com>
This commit is contained in:
Akshay 2023-08-28 21:23:15 +05:30 committed by GitHub
parent d265cd792b
commit c6fe0aa45e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
54 changed files with 2024 additions and 734 deletions

1
.gitignore vendored
View file

@ -36,3 +36,4 @@
/frontend/cypress/videos /frontend/cypress/videos
.idea/* .idea/*
ti-*

View file

@ -935,9 +935,10 @@
"tip": "Global Settings", "tip": "Global Settings",
"hideHeader": "Hide header for launched apps", "hideHeader": "Hide header for launched apps",
"maintenanceMode": "Maintenance mode", "maintenanceMode": "Maintenance mode",
"maxWidthOfCanvas": "Max width of canvas", "maxWidthOfCanvas": "Max canvas width",
"maxHeightOfCanvas": "Max height of canvas", "maxHeightOfCanvas": "Max canvas height",
"backgroundColorOfCanvas": "Background color of canvas" "backgroundColorOfCanvas": "Canvas BG",
"exportApp": "Export app"
}, },
"Back": { "Back": {
"text": "Back", "text": "Back",

View file

@ -8,7 +8,7 @@ export default function FxButton({ active, onPress, dataCy }) {
onClick={onPress} onClick={onPress}
data-cy={`${dataCy}-fx-button`} data-cy={`${dataCy}-fx-button`}
> >
Fx fx
</div> </div>
); );
} }

View file

@ -2,7 +2,6 @@ import React from 'react';
import cx from 'classnames'; import cx from 'classnames';
import { SketchPicker } from 'react-color'; import { SketchPicker } from 'react-color';
import { Confirm } from '../Viewer/Confirm'; import { Confirm } from '../Viewer/Confirm';
import { HeaderSection } from '@/_ui/LeftSidebar';
import { LeftSidebarItem } from '../LeftSidebar/SidebarItem'; import { LeftSidebarItem } from '../LeftSidebar/SidebarItem';
import FxButton from '../CodeBuilder/Elements/FxButton'; import FxButton from '../CodeBuilder/Elements/FxButton';
import { CodeHinter } from '../CodeBuilder/CodeHinter'; import { CodeHinter } from '../CodeBuilder/CodeHinter';
@ -10,6 +9,7 @@ import { resolveReferences } from '@/_helpers/utils';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import _ from 'lodash'; import _ from 'lodash';
import Popover from '@/_ui/Popover'; import Popover from '@/_ui/Popover';
import ExportAppModal from '../../HomePage/ExportAppModal';
import { useCurrentState } from '@/_stores/currentStateStore'; import { useCurrentState } from '@/_stores/currentStateStore';
import { useAppVersionStore } from '@/_stores/appVersionStore'; import { useAppVersionStore } from '@/_stores/appVersionStore';
import { shallow } from 'zustand/shallow'; import { shallow } from 'zustand/shallow';
@ -20,6 +20,7 @@ export const GlobalSettings = ({
darkMode, darkMode,
toggleAppMaintenance, toggleAppMaintenance,
is_maintenance_on, is_maintenance_on,
app,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { hideHeader, canvasMaxWidth, canvasMaxWidthType, canvasBackgroundColor, backgroundFxQuery } = globalSettings; const { hideHeader, canvasMaxWidth, canvasMaxWidthType, canvasBackgroundColor, backgroundFxQuery } = globalSettings;
@ -29,6 +30,7 @@ export const GlobalSettings = ({
const [realState, setRealState] = React.useState(currentState); const [realState, setRealState] = React.useState(currentState);
const [showConfirmation, setConfirmationShow] = React.useState(false); const [showConfirmation, setConfirmationShow] = React.useState(false);
const [show, setShow] = React.useState(''); const [show, setShow] = React.useState('');
const [isExportingApp, setIsExportingApp] = React.useState(false);
const { isVersionReleased } = useAppVersionStore( const { isVersionReleased } = useAppVersionStore(
(state) => ({ (state) => ({
isVersionReleased: state.isVersionReleased, isVersionReleased: state.isVersionReleased,
@ -58,16 +60,13 @@ export const GlobalSettings = ({
const popoverContent = ( const popoverContent = (
<div id="global-settings-popover" className={cx({ 'theme-dark': darkMode, disabled: isVersionReleased })}> <div id="global-settings-popover" className={cx({ 'theme-dark': darkMode, disabled: isVersionReleased })}>
<div bsPrefix="global-settings-popover"> <div bsPrefix="global-settings-popover">
<HeaderSection darkMode={darkMode}> <div>
<HeaderSection.PanelHeader title="Global settings" />
</HeaderSection>
<div className="card-body">
<div> <div>
<div className="d-flex mb-3"> <div className="d-flex justify-content-start">
<span data-cy={`label-hide-header-for-launched-apps`}> <span data-cy={`label-hide-header-for-launched-apps`}>
{t('leftSidebar.Settings.hideHeader', 'Hide header for launched apps')} {t('leftSidebar.Settings.hideHeader', 'Hide header for launched apps')}
</span> </span>
<div className="ms-auto form-check form-switch position-relative"> <div className="form-check form-switch">
<input <input
data-cy={`toggle-hide-header-for-launched-apps`} data-cy={`toggle-hide-header-for-launched-apps`}
className="form-check-input" className="form-check-input"
@ -76,12 +75,15 @@ export const GlobalSettings = ({
onChange={(e) => globalSettingsChanged('hideHeader', e.target.checked)} onChange={(e) => globalSettingsChanged('hideHeader', e.target.checked)}
/> />
</div> </div>
<span className="global-popover-text">
{t('leftSidebar.Settings.hideHeader', 'Hide header for launched apps')}
</span>
</div> </div>
<div className="d-flex mb-3"> <div className="d-flex justify-content-start">
<span data-cy={`label-maintenance-mode`}> <span data-cy={`label-maintenance-mode`}>
{t('leftSidebar.Settings.maintenanceMode', 'Maintenance mode')} {t('leftSidebar.Settings.maintenanceMode', 'Maintenance mode')}
</span> </span>
<div className="ms-auto form-check form-switch position-relative"> <div className="form-check form-switch">
<input <input
data-cy={`toggle-maintenance-mode`} data-cy={`toggle-maintenance-mode`}
className="form-check-input" className="form-check-input"
@ -90,17 +92,20 @@ export const GlobalSettings = ({
onChange={() => setConfirmationShow(true)} onChange={() => setConfirmationShow(true)}
/> />
</div> </div>
<span className="global-popover-text">
{t('leftSidebar.Settings.maintenanceMode', 'Maintenance mode')}
</span>
</div> </div>
<div className="d-flex mb-3"> <div className="d-flex mb-3 global-popover-div-wrap ">
<span data-cy={`label-max-canvas-width`} className="w-full m-auto"> <span data-cy={`label-max-canvas-width`} className="w-full m-auto">
{t('leftSidebar.Settings.maxWidthOfCanvas', 'Max width of canvas')} {t('leftSidebar.Settings.maxWidthOfCanvas', 'Max width of canvas')}
</span> </span>
<div className="position-relative"> <div className="global-popover-div-wrap global-popover-div-wrap-width">
<div className="input-with-icon"> <div className="input-with-icon">
<input <input
data-cy="maximum-canvas-width-input-field" data-cy="maximum-canvas-width-input-field"
type="text" type="text"
className={`form-control form-control-sm`} className={`form-control form-control-sm maximum-canvas-width-input-field`}
placeholder={'0'} placeholder={'0'}
onChange={(e) => { onChange={(e) => {
const width = e.target.value; const width = e.target.value;
@ -109,8 +114,8 @@ export const GlobalSettings = ({
value={canvasMaxWidth} value={canvasMaxWidth}
/> />
<select <select
className="maximum-canvas-width-input-select"
data-cy={`dropdown-max-canvas-width-type`} data-cy={`dropdown-max-canvas-width-type`}
className="form-select"
aria-label="Select canvas width type" aria-label="Select canvas width type"
onChange={(event) => { onChange={(event) => {
const newCanvasMaxWidthType = event.currentTarget.value; const newCanvasMaxWidthType = event.currentTarget.value;
@ -136,7 +141,7 @@ export const GlobalSettings = ({
<span className="w-full m-auto" data-cy={`label-max-canvas-height`}> <span className="w-full m-auto" data-cy={`label-max-canvas-height`}>
{t('leftSidebar.Settings.maxHeightOfCanvas', 'Max height of canvas')} {t('leftSidebar.Settings.maxHeightOfCanvas', 'Max height of canvas')}
</span> </span>
<div className="position-relative"> <div className="global-popover-div-wrap global-popover-div-wrap-width">
<div className="input-with-icon"> <div className="input-with-icon">
<input <input
data-cy="maximum-canvas-height-input-field" data-cy="maximum-canvas-height-input-field"
@ -149,7 +154,6 @@ export const GlobalSettings = ({
}} }}
value={canvasMaxHeight} value={canvasMaxHeight}
/> />
<span className="input-group-text">px</span>
</div> </div>
</div> </div>
</div> */} </div> */}
@ -175,7 +179,7 @@ export const GlobalSettings = ({
)} )}
{forceCodeBox && ( {forceCodeBox && (
<div <div
className="row mx-0 form-control form-control-sm canvas-background-holder" className="form-control form-control-sm canvas-background-holder"
onClick={() => setShowPicker(true)} onClick={() => setShowPicker(true)}
> >
<div <div
@ -183,15 +187,13 @@ export const GlobalSettings = ({
className="col-auto" className="col-auto"
style={{ style={{
float: 'right', float: 'right',
width: '20px', width: '13.33px',
height: '20px', height: '13.33px',
backgroundColor: canvasBackgroundColor, backgroundColor: canvasBackgroundColor,
border: `0.25px solid ${ borderRadius: '4px',
['#ffffff', '#fff', '#1f2936'].includes(canvasBackgroundColor) && '#c5c8c9'
}`,
}} }}
></div> ></div>
<div className="col">{canvasBackgroundColor}</div> <div className="">{canvasBackgroundColor}</div>
</div> </div>
)} )}
<div <div
@ -225,6 +227,26 @@ export const GlobalSettings = ({
</div> </div>
</div> </div>
</div> </div>
<div className="d-flex align-items-center global-popover-div-wrap">
<p className="global-popover-text">Export app</p>
<button
className="export-app-btn"
onClick={() => {
setIsExportingApp(true);
document.getElementById('maintenance-app-modal').click();
}}
>
<svg width="16" height="16" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.5 5.78906V17.2891V18.0391C2.5 19.834 3.98027 21.2891 5.77519 21.2891C7.5425 21.2891 9 19.8564 9 18.0891C9 17.6472 9.35817 17.2891 9.8 17.2891H18.5V5.78906C18.5 4.13221 17.1569 2.78906 15.5 2.78906H5.5C3.84315 2.78906 2.5 4.13221 2.5 5.78906ZM9.75 9.61723C9.70334 9.65231 9.65858 9.69108 9.61612 9.73355L8.03033 11.3193C7.73744 11.6122 7.26256 11.6122 6.96967 11.3193C6.67678 11.0264 6.67678 10.5516 6.96967 10.2587L8.55546 8.67289C9.6294 7.59895 11.3706 7.59895 12.4445 8.67289L14.0303 10.2587C14.3232 10.5516 14.3232 11.0264 14.0303 11.3193C13.7374 11.6122 13.2626 11.6122 12.9697 11.3193L11.3839 9.73355C11.3414 9.69108 11.2967 9.65231 11.25 9.61723V13.789C11.25 14.2032 10.9142 14.539 10.5 14.539C10.0858 14.539 9.75 14.2032 9.75 13.789V9.61723ZM22.3766 19.7789C21.9361 21.5093 20.3675 22.7891 18.5 22.7891H6.5C8.36748 22.7891 9.93606 21.5093 10.3766 19.7789C10.5128 19.2437 10.9477 18.7891 11.5 18.7891H21.5C22.0523 18.7891 22.5128 19.2437 22.3766 19.7789Z"
fill="#3E63DD"
/>
</svg>
<span style={{ paddingLeft: '6px' }}>Export this app</span>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -244,6 +266,18 @@ export const GlobalSettings = ({
onCancel={() => setConfirmationShow(false)} onCancel={() => setConfirmationShow(false)}
darkMode={darkMode} darkMode={darkMode}
/> />
{isExportingApp && app.hasOwnProperty('id') && (
<ExportAppModal
show={isExportingApp}
closeModal={() => {
setIsExportingApp(false);
}}
customClassName="modal-version-lists"
title={'Select a version to export'}
app={app}
darkMode={darkMode}
/>
)}
<Popover <Popover
handleToggle={(show) => { handleToggle={(show) => {
if (show) setShow('settings'); if (show) setShow('settings');

View file

@ -88,6 +88,7 @@ export default function EditorHeader({
darkMode={darkMode} darkMode={darkMode}
toggleAppMaintenance={toggleAppMaintenance} toggleAppMaintenance={toggleAppMaintenance}
is_maintenance_on={is_maintenance_on} is_maintenance_on={is_maintenance_on}
app={app}
/> />
<EditAppName appId={app.id} appName={app.name} onNameChanged={onNameChanged} /> <EditAppName appId={app.id} appName={app.name} onNameChanged={onNameChanged} />
</div> </div>

View file

@ -22,7 +22,8 @@ const ToolJetDbOperations = ({ optionchanged, options, darkMode, isHorizontalLay
const [operation, setOperation] = useState(options['operation'] || ''); const [operation, setOperation] = useState(options['operation'] || '');
const [columns, setColumns] = useState([]); const [columns, setColumns] = useState([]);
const [tables, setTables] = useState([]); const [tables, setTables] = useState([]);
const [selectedTable, setSelectedTable] = useState(options['table_name']); const [selectedTableId, setSelectedTableId] = useState(options['table_id']);
const [selectedTableName, setSelectedTableName] = useState(null);
const [listRowsOptions, setListRowsOptions] = useState(() => options['list_rows'] || {}); const [listRowsOptions, setListRowsOptions] = useState(() => options['list_rows'] || {});
const [updateRowsOptions, setUpdateRowsOptions] = useState( const [updateRowsOptions, setUpdateRowsOptions] = useState(
options['update_rows'] || { columns: {}, where_filters: {} } options['update_rows'] || { columns: {}, where_filters: {} }
@ -38,6 +39,19 @@ const ToolJetDbOperations = ({ optionchanged, options, darkMode, isHorizontalLay
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
useEffect(() => {
if (tables.length > 0) {
const tableInfo = tables.find((table) => table.table_id == selectedTableId);
tableInfo && setSelectedTableName(tableInfo.table_name);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tables]);
useEffect(() => {
selectedTableName && fetchTableInformation(selectedTableName);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTableName]);
useEffect(() => { useEffect(() => {
if (mounted) { if (mounted) {
optionchanged('operation', operation); optionchanged('operation', operation);
@ -90,8 +104,10 @@ const ToolJetDbOperations = ({ optionchanged, options, darkMode, isHorizontalLay
setTables, setTables,
columns, columns,
setColumns, setColumns,
selectedTable, selectedTableId,
setSelectedTable, setSelectedTableId,
selectedTableName,
setSelectedTableName,
listRowsOptions, listRowsOptions,
setListRowsOptions, setListRowsOptions,
limitOptionChanged, limitOptionChanged,
@ -102,7 +118,16 @@ const ToolJetDbOperations = ({ optionchanged, options, darkMode, isHorizontalLay
updateRowsOptions, updateRowsOptions,
handleUpdateRowsOptionsChange, handleUpdateRowsOptionsChange,
}), }),
[organizationId, tables, columns, selectedTable, listRowsOptions, deleteRowsOptions, updateRowsOptions] [
organizationId,
tables,
columns,
selectedTableName,
selectedTableId,
listRowsOptions,
deleteRowsOptions,
updateRowsOptions,
]
); );
const fetchTables = async () => { const fetchTables = async () => {
@ -114,12 +139,14 @@ const ToolJetDbOperations = ({ optionchanged, options, darkMode, isHorizontalLay
} }
if (Array.isArray(data?.result)) { if (Array.isArray(data?.result)) {
setTables(data.result.map((table) => table.table_name) || []); const selectedTableInfo = data.result.find((table) => table.id === options['table_id']);
if (selectedTable) { selectedTableInfo && setSelectedTableId(selectedTableInfo.id);
console.log('fetchTableInformation'); setTables(
fetchTableInformation(selectedTable); data.result.map((table) => {
} return { table_name: table.table_name, table_id: table.id };
}) || []
);
} }
}; };
@ -144,21 +171,22 @@ const ToolJetDbOperations = ({ optionchanged, options, darkMode, isHorizontalLay
} }
}; };
const generateListForDropdown = (list) => { const generateListForDropdown = (tableList) => {
return list.map((value) => return tableList.map((tableMap) =>
Object.fromEntries([ Object.fromEntries([
['name', value], ['name', tableMap.table_name],
['value', value], ['value', tableMap.table_id],
]) ])
); );
}; };
const handleTableNameSelect = (tableName) => { const handleTableNameSelect = (tableId) => {
setSelectedTable(tableName); setSelectedTableId(tableId);
fetchTableInformation(tableName); const { table_name: tableName } = tables.find((t) => t.table_id === tableId);
tableName && setSelectedTableName(tableName);
optionchanged('organization_id', organizationId); optionchanged('organization_id', organizationId);
optionchanged('table_name', tableName); optionchanged('table_id', tableId);
}; };
const getComponent = () => { const getComponent = () => {
@ -185,7 +213,7 @@ const ToolJetDbOperations = ({ optionchanged, options, darkMode, isHorizontalLay
<div className={cx({ 'flex-grow-1': isHorizontalLayout })}> <div className={cx({ 'flex-grow-1': isHorizontalLayout })}>
<Select <Select
options={generateListForDropdown(tables)} options={generateListForDropdown(tables)}
value={selectedTable} value={selectedTableId}
onChange={(value) => handleTableNameSelect(value)} onChange={(value) => handleTableNameSelect(value)}
width="100%" width="100%"
// useMenuPortal={false} // useMenuPortal={false}

View file

@ -8,16 +8,16 @@ export const tooljetDbOperations = {
perform, perform,
}; };
async function perform(queryOptions, organizationId, currentState) { async function perform(dataQuery, currentState) {
switch (queryOptions.operation) { switch (dataQuery.options.operation) {
case 'list_rows': case 'list_rows':
return listRows(queryOptions, organizationId, currentState); return listRows(dataQuery, currentState);
case 'create_row': case 'create_row':
return createRow(queryOptions, organizationId, currentState); return createRow(dataQuery, currentState);
case 'update_rows': case 'update_rows':
return updateRows(queryOptions, organizationId, currentState); return updateRows(dataQuery, currentState);
case 'delete_rows': case 'delete_rows':
return deleteRows(queryOptions, organizationId, currentState); return deleteRows(dataQuery, currentState);
default: default:
return { return {
@ -52,8 +52,8 @@ function buildPostgrestQuery(filters) {
return postgrestQueryBuilder.url.toString(); return postgrestQueryBuilder.url.toString();
} }
async function listRows(queryOptions, organizationId, currentState) { async function listRows(dataQuery, currentState) {
let query = []; const queryOptions = dataQuery.options;
const resolvedOptions = resolveReferences(queryOptions, currentState); const resolvedOptions = resolveReferences(queryOptions, currentState);
if (hasEqualWithNull(resolvedOptions, 'list_rows')) { if (hasEqualWithNull(resolvedOptions, 'list_rows')) {
return { return {
@ -64,7 +64,8 @@ async function listRows(queryOptions, organizationId, currentState) {
data: {}, data: {},
}; };
} }
const { table_name: tableName, list_rows: listRows } = resolvedOptions; const { table_id: tableId, list_rows: listRows } = resolvedOptions;
let query = [];
if (!isEmpty(listRows)) { if (!isEmpty(listRows)) {
const { limit, where_filters: whereFilters, order_filters: orderFilters } = listRows; const { limit, where_filters: whereFilters, order_filters: orderFilters } = listRows;
@ -86,19 +87,23 @@ async function listRows(queryOptions, organizationId, currentState) {
!isEmpty(orderQuery) && query.push(orderQuery); !isEmpty(orderQuery) && query.push(orderQuery);
!isEmpty(limit) && query.push(`limit=${limit}`); !isEmpty(limit) && query.push(`limit=${limit}`);
} }
return await tooljetDatabaseService.findOne(organizationId, tableName, query.join('&')); const headers = { 'data-query-id': dataQuery.id };
return await tooljetDatabaseService.findOne(headers, tableId, query.join('&'));
} }
async function createRow(queryOptions, organizationId, currentState) { async function createRow(dataQuery, currentState) {
const queryOptions = dataQuery.options;
const resolvedOptions = resolveReferences(queryOptions, currentState); const resolvedOptions = resolveReferences(queryOptions, currentState);
const columns = Object.values(resolvedOptions.create_row).reduce((acc, colOpts) => { const columns = Object.values(resolvedOptions.create_row).reduce((acc, colOpts) => {
if (isEmpty(colOpts.column)) return acc; if (isEmpty(colOpts.column)) return acc;
return { ...acc, ...{ [colOpts.column]: colOpts.value } }; return { ...acc, ...{ [colOpts.column]: colOpts.value } };
}, {}); }, {});
return await tooljetDatabaseService.createRow(organizationId, resolvedOptions.table_name, columns); const headers = { 'data-query-id': dataQuery.id };
return await tooljetDatabaseService.createRow(headers, resolvedOptions.table_id, columns);
} }
async function updateRows(queryOptions, organizationId, currentState) { async function updateRows(dataQuery, currentState) {
const queryOptions = dataQuery.options;
const resolvedOptions = resolveReferences(queryOptions, currentState); const resolvedOptions = resolveReferences(queryOptions, currentState);
if (hasEqualWithNull(resolvedOptions, 'update_rows')) { if (hasEqualWithNull(resolvedOptions, 'update_rows')) {
return { return {
@ -109,7 +114,7 @@ async function updateRows(queryOptions, organizationId, currentState) {
data: {}, data: {},
}; };
} }
const { table_name: tableName, update_rows: updateRows } = resolvedOptions; const { table_id: tableId, update_rows: updateRows } = resolvedOptions;
const { where_filters: whereFilters, columns } = updateRows; const { where_filters: whereFilters, columns } = updateRows;
let query = []; let query = [];
@ -121,10 +126,12 @@ async function updateRows(queryOptions, organizationId, currentState) {
!isEmpty(whereQuery) && query.push(whereQuery); !isEmpty(whereQuery) && query.push(whereQuery);
return await tooljetDatabaseService.updateRows(organizationId, tableName, body, query.join('&') + '&order=id'); const headers = { 'data-query-id': dataQuery.id };
return await tooljetDatabaseService.updateRows(headers, tableId, body, query.join('&') + '&order=id');
} }
async function deleteRows(queryOptions, organizationId, currentState) { async function deleteRows(dataQuery, currentState) {
const queryOptions = dataQuery.options;
const resolvedOptions = resolveReferences(queryOptions, currentState); const resolvedOptions = resolveReferences(queryOptions, currentState);
if (hasEqualWithNull(resolvedOptions, 'delete_rows')) { if (hasEqualWithNull(resolvedOptions, 'delete_rows')) {
return { return {
@ -135,7 +142,7 @@ async function deleteRows(queryOptions, organizationId, currentState) {
data: {}, data: {},
}; };
} }
const { table_name: tableName, delete_rows: deleteRows = { whereFilters: {} } } = resolvedOptions; const { table_id: tableId, delete_rows: deleteRows = { whereFilters: {} } } = resolvedOptions;
const { where_filters: whereFilters, limit = 1 } = deleteRows; const { where_filters: whereFilters, limit = 1 } = deleteRows;
let query = []; let query = [];
@ -163,5 +170,6 @@ async function deleteRows(queryOptions, organizationId, currentState) {
!isEmpty(whereQuery) && query.push(whereQuery); !isEmpty(whereQuery) && query.push(whereQuery);
limit && limit !== '' && query.push(`limit=${limit}&order=id`); limit && limit !== '' && query.push(`limit=${limit}&order=id`);
return await tooljetDatabaseService.deleteRow(organizationId, tableName, query.join('&')); const headers = { 'data-query-id': dataQuery.id };
return await tooljetDatabaseService.deleteRows(headers, tableId, query.join('&'));
} }

View file

@ -592,7 +592,6 @@ class ViewerComponent extends React.Component {
return ( return (
<div className="viewer wrapper"> <div className="viewer wrapper">
<Confirm <Confirm
darkMode={this.props.darkMode}
show={queryConfirmationList.length > 0} show={queryConfirmationList.length > 0}
message={'Do you want to run this query?'} message={'Do you want to run this query?'}
onConfirm={(queryConfirmationData) => onQueryConfirmOrCancel(this, queryConfirmationData, true, 'view')} onConfirm={(queryConfirmationData) => onQueryConfirmOrCancel(this, queryConfirmationData, true, 'view')}

View file

@ -18,7 +18,10 @@ export const ListItem = ({ dataSource, key, active, onDelete, updateSelectedData
}; };
const sourceMeta = getSourceMetaData(dataSource); const sourceMeta = getSourceMetaData(dataSource);
const icon = getSvgIcon(sourceMeta.kind.toLowerCase(), 24, 24, dataSource?.plugin?.iconFile?.data);
// sourceMeta would be missing on development setup when switching between branches
// if ds is already in branch while not available in another
const icon = getSvgIcon(sourceMeta?.kind?.toLowerCase(), 24, 24, dataSource?.plugin?.iconFile?.data);
const focusModal = () => { const focusModal = () => {
const element = document.getElementsByClassName('form-control-plaintext form-control-plaintext-sm')[0]; const element = document.getElementsByClassName('form-control-plaintext form-control-plaintext-sm')[0];

View file

@ -1,20 +1,23 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { default as BootstrapModal } from 'react-bootstrap/Modal'; import { default as BootstrapModal } from 'react-bootstrap/Modal';
import moment from 'moment'; import moment from 'moment';
import { appService } from '../_services/app.service'; import { appService } from '@/_services/app.service';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { ButtonSolid } from '@/_components/AppButton';
export default function ExportAppModal({ title, show, closeModal, customClassName, app, darkMode }) { export default function ExportAppModal({ title, show, closeModal, customClassName, app, darkMode }) {
const currentVersion = app.editing_version; const currentVersion = app?.editing_version;
const [versions, getVersions] = useState(undefined); const [versions, setVersions] = useState(undefined);
const [versionId, setVersionId] = useState(currentVersion.id); const [tables, setTables] = useState(undefined);
const [versionId, setVersionId] = useState(currentVersion?.id);
const [exportTjDb, setExportTjDb] = useState(true);
useEffect(() => { useEffect(() => {
async function fetchAppVersions() { async function fetchAppVersions() {
try { try {
const fetchVersions = await appService.getVersions(app.id); const fetchVersions = await appService.getVersions(app.id);
const { versions } = await fetchVersions; const { versions } = fetchVersions;
getVersions(versions); setVersions(versions);
} catch (error) { } catch (error) {
toast.error('Could not fetch the versions.', { toast.error('Could not fetch the versions.', {
position: 'top-center', position: 'top-center',
@ -22,13 +25,40 @@ export default function ExportAppModal({ title, show, closeModal, customClassNam
closeModal(); closeModal();
} }
} }
async function fetchAppTables() {
try {
const fetchTables = await appService.getTables(app.id);
const { tables } = fetchTables;
setTables(tables);
} catch (error) {
toast.error('Could not fetch the tables.', {
position: 'top-center',
});
closeModal();
}
}
fetchAppVersions(); fetchAppVersions();
fetchAppTables();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const exportApp = (appId, versionId = undefined) => { const exportApp = (app, versionId, exportTjDb, tables) => {
const appOpts = {
app: [
{
id: app.id,
...(versionId && { search_params: { version_id: versionId } }),
},
],
};
const requestBody = {
...appOpts,
...(exportTjDb && { tooljet_database: tables }),
organization_id: app.organization_id,
};
appService appService
.exportApp(appId, versionId) .exportResource(requestBody)
.then((data) => { .then((data) => {
const appName = app.name.replace(/\s+/g, '-').toLowerCase(); const appName = app.name.replace(/\s+/g, '-').toLowerCase();
const fileName = `${appName}-export-${new Date().getTime()}`; const fileName = `${appName}-export-${new Date().getTime()}`;
@ -44,8 +74,8 @@ export default function ExportAppModal({ title, show, closeModal, customClassNam
document.body.removeChild(link); document.body.removeChild(link);
closeModal(); closeModal();
}) })
.catch(() => { .catch((error) => {
toast.error('Could not export the app.', { toast.error(`Could not export app: ${error.data.message}`, {
position: 'top-center', position: 'top-center',
}); });
closeModal(); closeModal();
@ -55,11 +85,10 @@ export default function ExportAppModal({ title, show, closeModal, customClassNam
return ( return (
<BootstrapModal <BootstrapModal
onHide={() => closeModal(false)} onHide={() => closeModal(false)}
contentClassName={`home-modal-component ${customClassName ? ` ${customClassName}` : ''} ${ contentClassName={`home-modal-component home-version-modal-component ${
darkMode && 'dark-theme' customClassName ? ` ${customClassName}` : ''
}`} } ${darkMode && 'dark-theme'}`}
show={show} show={show}
size="md"
backdrop={true} backdrop={true}
keyboard={true} keyboard={true}
enforceFocus={false} enforceFocus={false}
@ -68,7 +97,7 @@ export default function ExportAppModal({ title, show, closeModal, customClassNam
centered centered
data-cy={'modal-component'} data-cy={'modal-component'}
> >
<BootstrapModal.Header className="border-bottom"> <BootstrapModal.Header>
<BootstrapModal.Title data-cy={`${title.toLowerCase().replace(/\s+/g, '-')}-title`}> <BootstrapModal.Title data-cy={`${title.toLowerCase().replace(/\s+/g, '-')}-title`}>
{title} {title}
</BootstrapModal.Title> </BootstrapModal.Title>
@ -82,27 +111,28 @@ export default function ExportAppModal({ title, show, closeModal, customClassNam
{Array.isArray(versions) ? ( {Array.isArray(versions) ? (
<> <>
<BootstrapModal.Body> <BootstrapModal.Body>
<div className="py-2"> <div>
<div className="current-version py-2" data-cy="current-version-section"> <div className="current-version " data-cy="current-version-section">
<span className="text-muted" data-cy="current-version-label"> <span data-cy="current-version-label" className="current-version-label">
Current Version Current Version
</span> </span>
<InputRadioField <InputRadioField
versionId={currentVersion.id} versionId={currentVersion?.id}
data-cy={`${currentVersion.id.toLowerCase().replace(/\s+/g, '-')}-value`} data-cy={`${currentVersion?.id.toLowerCase().replace(/\s+/g, '-')}-value`}
versionName={currentVersion.name} versionName={currentVersion?.name}
versionCreatedAt={currentVersion.createdAt} versionCreatedAt={currentVersion?.createdAt}
checked={versionId === currentVersion.id} checked={versionId === currentVersion?.id}
setVersionId={setVersionId} setVersionId={setVersionId}
className="current-version-wrap"
/> />
</div> </div>
{versions.length >= 2 ? ( {versions.length >= 2 ? (
<div className="other-versions py-2" data-cy="other-version-section"> <div className="other-versions" data-cy="other-version-section">
<span className="text-muted" data-cy="other-version-label"> <span data-cy="other-version-label" className="other-version-label">
Other Versions Other Versions
</span> </span>
{versions.map((version) => { {versions.map((version) => {
if (version.id !== currentVersion.id) { if (version.id !== currentVersion?.id) {
return ( return (
<InputRadioField <InputRadioField
versionId={version.id} versionId={version.id}
@ -112,32 +142,39 @@ export default function ExportAppModal({ title, show, closeModal, customClassNam
key={version.name} key={version.name}
checked={versionId === version.id} checked={versionId === version.id}
setVersionId={setVersionId} setVersionId={setVersionId}
className="other-version-wrap"
/> />
); );
} }
})} })}
</div> </div>
) : ( ) : (
<div className="other-versions py-2" data-cy="other-version-section"> <div className="other-versions" data-cy="other-version-section">
<span className="text-muted" data-cy="no-other-versions-found-text"> <span data-cy="no-other-versions-found-text">No other versions found</span>
No other versions found
</span>
</div> </div>
)} )}
</div> </div>
</BootstrapModal.Body> </BootstrapModal.Body>
<BootstrapModal.Footer className="export-app-modal-footer d-flex justify-content-end border-top align-items-center py-2"> <div className="tj-version-wrap-sub-footer">
<span role="button" className="btn btn-light" data-cy="export-all-button" onClick={() => exportApp(app.id)}> <input type="checkbox" checked={exportTjDb} onChange={() => setExportTjDb(!exportTjDb)} />
<p>Export ToolJet table schema</p>
</div>
<BootstrapModal.Footer className="export-app-modal-footer d-flex justify-content-end align-items-center ">
<ButtonSolid
className="import-export-footer-btns"
variant="tertiary"
data-cy="export-all-button"
onClick={() => exportApp(app, null, exportTjDb, tables)}
>
Export All Export All
</span> </ButtonSolid>
<span <ButtonSolid
role="button" className="import-export-footer-btns"
className="btn btn-primary"
data-cy="export-selected-version-button" data-cy="export-selected-version-button"
onClick={() => exportApp(app.id, versionId)} onClick={() => exportApp(app, versionId, exportTjDb, tables)}
> >
Export selected version Export selected version
</span> </ButtonSolid>
</BootstrapModal.Footer> </BootstrapModal.Footer>
</> </>
) : ( ) : (
@ -154,11 +191,12 @@ function InputRadioField({
checked = undefined, checked = undefined,
key = undefined, key = undefined,
setVersionId, setVersionId,
className,
}) { }) {
return ( return (
<span <span
key={key} key={key}
className="version-wrapper my-2 py-2 cursor-pointer" className={`version-wrapper cursor-pointer ${className}`}
data-cy={`${String(versionName).toLowerCase().replace(/\s+/g, '-')}-version-wrapper`} data-cy={`${String(versionName).toLowerCase().replace(/\s+/g, '-')}-version-wrapper`}
> >
<input <input
@ -178,9 +216,9 @@ function InputRadioField({
style={{ paddingLeft: '0.75rem' }} style={{ paddingLeft: '0.75rem' }}
> >
<span data-cy={`${String(versionName).toLowerCase().replace(/\s+/g, '-')}-text`}>{versionName}</span> <span data-cy={`${String(versionName).toLowerCase().replace(/\s+/g, '-')}-text`}>{versionName}</span>
<span className="text-secondary" data-cy="created-date-label">{`Created on ${moment(versionCreatedAt).format( <span className="export-creation-date tj-text-sm" data-cy="created-date-label">{`Created on ${moment(
'Do MMM YYYY' versionCreatedAt
)}`}</span> ).format('Do MMM YYYY')}`}</span>
</label> </label>
</span> </span>
); );

View file

@ -14,7 +14,7 @@ import HomeHeader from './Header';
import Modal from './Modal'; import Modal from './Modal';
import configs from './Configs/AppIcon.json'; import configs from './Configs/AppIcon.json';
import { withTranslation } from 'react-i18next'; import { withTranslation } from 'react-i18next';
import { sample } from 'lodash'; import { sample, isEmpty } from 'lodash';
import ExportAppModal from './ExportAppModal'; import ExportAppModal from './ExportAppModal';
import Footer from './Footer'; import Footer from './Footer';
import { OrganizationList } from '@/_components/OrganizationManager/List'; import { OrganizationList } from '@/_components/OrganizationManager/List';
@ -141,11 +141,11 @@ class HomePageComponent extends React.Component {
cloneApp = (app) => { cloneApp = (app) => {
this.setState({ isCloningApp: true }); this.setState({ isCloningApp: true });
appService appService
.cloneApp(app.id) .cloneResource({ app: [{ id: app.id }], organization_id: getWorkspaceId() })
.then((data) => { .then((data) => {
toast.success('App cloned successfully.'); toast.success('App cloned successfully.');
this.setState({ isCloningApp: false }); this.setState({ isCloningApp: false });
this.props.navigate(`/${getWorkspaceId()}/apps/${data.id}`); this.props.navigate(`/${getWorkspaceId()}/apps/${data.imports.app[0].id}`);
}) })
.catch(({ _error }) => { .catch(({ _error }) => {
toast.error('Could not clone the app.'); toast.error('Could not clone the app.');
@ -165,24 +165,35 @@ class HomePageComponent extends React.Component {
const fileContent = event.target.result; const fileContent = event.target.result;
this.setState({ isImportingApp: true }); this.setState({ isImportingApp: true });
try { try {
const requestBody = JSON.parse(fileContent); const organization_id = getWorkspaceId();
let importJSON = JSON.parse(fileContent);
// For backward compatibility with legacy app import
const isLegacyImport = isEmpty(importJSON.tooljet_version);
if (isLegacyImport) {
importJSON = { app: [{ definition: importJSON }], tooljet_version: importJSON.tooljetVersion };
}
const requestBody = { organization_id, ...importJSON };
appService appService
.importApp(requestBody) .importResource(requestBody)
.then((data) => { .then((data) => {
toast.success('App imported successfully.'); toast.success('Imported successfully.');
this.setState({ this.setState({
isImportingApp: false, isImportingApp: false,
}); });
this.props.navigate(`/${getWorkspaceId()}/apps/${data.id}`); if (!isEmpty(data.imports.app)) {
this.props.navigate(`/${getWorkspaceId()}/apps/${data.imports.app[0].id}`);
} else if (!isEmpty(data.imports.tooljet_database)) {
this.props.navigate(`/${getWorkspaceId()}/database`);
}
}) })
.catch(({ error }) => { .catch(({ error }) => {
toast.error(`Could not import the app: ${error}`); toast.error(`Could not import: ${error}`);
this.setState({ this.setState({
isImportingApp: false, isImportingApp: false,
}); });
}); });
} catch (error) { } catch (error) {
toast.error(`Could not import the app: ${error}`); toast.error(`Could not import: ${error}`);
this.setState({ this.setState({
isImportingApp: false, isImportingApp: false,
}); });

View file

@ -25,7 +25,7 @@ const CreateColumnDrawer = ({ setIsCreateColumnDrawerOpen, isCreateColumnDrawerO
<Drawer isOpen={isCreateColumnDrawerOpen} onClose={() => setIsCreateColumnDrawerOpen(false)} position="right"> <Drawer isOpen={isCreateColumnDrawerOpen} onClose={() => setIsCreateColumnDrawerOpen(false)} position="right">
<CreateColumnForm <CreateColumnForm
onCreate={() => { onCreate={() => {
tooljetDatabaseService.viewTable(organizationId, selectedTable).then(({ data = [], error }) => { tooljetDatabaseService.viewTable(organizationId, selectedTable.table_name).then(({ data = [], error }) => {
if (error) { if (error) {
toast.error(error?.message ?? `Error fetching columns for table "${selectedTable}"`); toast.error(error?.message ?? `Error fetching columns for table "${selectedTable}"`);
return; return;
@ -43,9 +43,9 @@ const CreateColumnDrawer = ({ setIsCreateColumnDrawerOpen, isCreateColumnDrawerO
); );
} }
}); });
tooljetDatabaseService.findOne(organizationId, selectedTable).then(({ data = [], error }) => { tooljetDatabaseService.findOne(organizationId, selectedTable.id).then(({ data = [], error }) => {
if (error) { if (error) {
toast.error(error?.message ?? `Failed to fetch table "${selectedTable}"`); toast.error(error?.message ?? `Failed to fetch table "${selectedTable.table_name}"`);
return; return;
} }

View file

@ -23,9 +23,9 @@ const CreateRowDrawer = ({ isCreateRowDrawerOpen, setIsCreateRowDrawerOpen }) =>
<Drawer isOpen={isCreateRowDrawerOpen} onClose={() => setIsCreateRowDrawerOpen(false)} position="right"> <Drawer isOpen={isCreateRowDrawerOpen} onClose={() => setIsCreateRowDrawerOpen(false)} position="right">
<CreateRowForm <CreateRowForm
onCreate={() => { onCreate={() => {
tooljetDatabaseService.findOne(organizationId, selectedTable).then(({ headers, data = [], error }) => { tooljetDatabaseService.findOne(organizationId, selectedTable.id).then(({ headers, data = [], error }) => {
if (error) { if (error) {
toast.error(error?.message ?? `Failed to fetch table "${selectedTable}"`); toast.error(error?.message ?? `Failed to fetch table "${selectedTable.table_name}"`);
return; return;
} }

View file

@ -27,7 +27,7 @@ export default function CreateTableDrawer() {
</div> </div>
<Drawer isOpen={isCreateTableDrawerOpen} onClose={() => setIsCreateTableDrawerOpen(false)} position="right"> <Drawer isOpen={isCreateTableDrawerOpen} onClose={() => setIsCreateTableDrawerOpen(false)} position="right">
<CreateTableForm <CreateTableForm
onCreate={(tableName) => { onCreate={(tableInfo) => {
tooljetDatabaseService.findAll(organizationId).then(({ data = [], error }) => { tooljetDatabaseService.findAll(organizationId).then(({ data = [], error }) => {
if (error) { if (error) {
toast.error(error?.message ?? 'Failed to fetch tables'); toast.error(error?.message ?? 'Failed to fetch tables');
@ -35,9 +35,9 @@ export default function CreateTableDrawer() {
} }
if (Array.isArray(data?.result) && data.result.length > 0) { if (Array.isArray(data?.result) && data.result.length > 0) {
setSelectedTable({ table_name: tableInfo.table_name, id: tableInfo.id });
updateSidebarNAV(tableInfo.table_name);
setTables(data.result || []); setTables(data.result || []);
setSelectedTable(tableName);
updateSidebarNAV(tableName);
} }
}); });
setIsCreateTableDrawerOpen(false); setIsCreateTableDrawerOpen(false);

View file

@ -30,9 +30,9 @@ const EditRowDrawer = ({ isCreateRowDrawerOpen, setIsCreateRowDrawerOpen }) => {
<Drawer isOpen={isCreateRowDrawerOpen} onClose={() => setIsCreateRowDrawerOpen(false)} position="right"> <Drawer isOpen={isCreateRowDrawerOpen} onClose={() => setIsCreateRowDrawerOpen(false)} position="right">
<EditRowForm <EditRowForm
onEdit={() => { onEdit={() => {
tooljetDatabaseService.findOne(organizationId, selectedTable).then(({ headers, data = [], error }) => { tooljetDatabaseService.findOne(organizationId, selectedTable.id).then(({ headers, data = [], error }) => {
if (error) { if (error) {
toast.error(error?.message ?? `Failed to fetch table "${selectedTable}"`); toast.error(error?.message ?? `Failed to fetch table "${selectedTable.table_name}"`);
return; return;
} }

View file

@ -0,0 +1,13 @@
import React from 'react';
import SolidIcon from '@/_ui/Icon/SolidIcons';
function ExportSchema({ onClick }) {
return (
<button className={`export-table-button tj-text-xsm font-weight-500 ghost-black-operation`} onClick={onClick}>
<SolidIcon name="arrowsortrectangle" width="14" fill={'#889096'} />
&nbsp;&nbsp;Export table
</button>
);
}
export default ExportSchema;

View file

@ -36,7 +36,7 @@ const ColumnForm = ({ onCreate, onClose }) => {
const { error } = await tooljetDatabaseService.createColumn( const { error } = await tooljetDatabaseService.createColumn(
organizationId, organizationId,
selectedTable, selectedTable.table_name,
columnName, columnName,
dataType, dataType,
defaultValue defaultValue
@ -45,7 +45,7 @@ const ColumnForm = ({ onCreate, onClose }) => {
setFetching(false); setFetching(false);
if (error) { if (error) {
toast.error(error?.message ?? `Failed to create a new column in "${selectedTable}" table`); toast.error(error?.message ?? `Failed to create a new column in "${selectedTable.table_name}" table`);
return; return;
} }

View file

@ -63,15 +63,15 @@ const ColumnsForm = ({ columns, setColumns }) => {
className="form-control" className="form-control"
placeholder="Enter name" placeholder="Enter name"
data-cy={`name-input-field-${columns[index].column_name}`} data-cy={`name-input-field-${columns[index].column_name}`}
disabled={columns[index].constraint === 'PRIMARY KEY'} disabled={columns[index].constraint_type === 'PRIMARY KEY'}
/> />
</div> </div>
<div className="col-3" data-cy="type-dropdown-field" style={{ marginRight: '16px' }}> <div className="col-3" data-cy="type-dropdown-field" style={{ marginRight: '16px' }}>
<Select <Select
width="120px" width="120px"
isDisabled={columns[index].constraint === 'PRIMARY KEY'} isDisabled={columns[index].constraint_type === 'PRIMARY KEY'}
useMenuPortal={false} useMenuPortal={false}
options={columns[index].constraint === 'PRIMARY KEY' ? primaryKeydataTypes : dataTypes} options={columns[index].constraint_type === 'PRIMARY KEY' ? primaryKeydataTypes : dataTypes}
value={columns[index].data_type} value={columns[index].data_type}
onChange={(value) => { onChange={(value) => {
const prevColumns = { ...columns }; const prevColumns = { ...columns };
@ -85,18 +85,18 @@ const ColumnsForm = ({ columns, setColumns }) => {
onChange={(e) => { onChange={(e) => {
e.persist(); e.persist();
const prevColumns = { ...columns }; const prevColumns = { ...columns };
prevColumns[index].default = e.target.value; prevColumns[index].column_default = e.target.value;
setColumns(prevColumns); setColumns(prevColumns);
}} }}
value={columns[index].default} value={columns[index].column_default}
type="text" type="text"
className="form-control" className="form-control"
data-cy="default-input-field" data-cy="default-input-field"
placeholder="NULL" placeholder="NULL"
disabled={columns[index].constraint === 'PRIMARY KEY' || columns[index].data_type === 'serial'} disabled={columns[index].constraint_type === 'PRIMARY KEY' || columns[index].data_type === 'serial'}
/> />
</div> </div>
{columns[index].constraint === 'PRIMARY KEY' && ( {columns[index].constraint_type === 'PRIMARY KEY' && (
<div className="col-2"> <div className="col-2">
<span <span
className={`badge badge-outline ${darkMode ? 'text-white' : 'text-indigo'}`} className={`badge badge-outline ${darkMode ? 'text-white' : 'text-indigo'}`}
@ -107,7 +107,7 @@ const ColumnsForm = ({ columns, setColumns }) => {
</div> </div>
)} )}
<div className="col-1 cursor-pointer" data-cy="column-delete-icon" onClick={() => handleDelete(index)}> <div className="col-1 cursor-pointer" data-cy="column-delete-icon" onClick={() => handleDelete(index)}>
{columns[index].constraint !== 'PRIMARY KEY' && <DeleteIcon />} {columns[index].constraint_type !== 'PRIMARY KEY' && <DeleteIcon />}
</div> </div>
</div> </div>
</div> </div>

View file

@ -49,10 +49,10 @@ const EditRowForm = ({ onEdit, onClose }) => {
const handleSubmit = async () => { const handleSubmit = async () => {
setFetching(true); setFetching(true);
const query = `id=eq.${selectedRow}&order=id`; const query = `id=eq.${selectedRow}&order=id`;
const { error } = await tooljetDatabaseService.updateRows(organizationId, selectedTable, rowData, query); const { error } = await tooljetDatabaseService.updateRows(organizationId, selectedTable.id, rowData, query);
if (error) { if (error) {
toast.error(error?.message ?? `Failed to create a new column table "${selectedTable}"`); toast.error(error?.message ?? `Failed to create a new column table "${selectedTable.table_name}"`);
return; return;
} }
setFetching(false); setFetching(false);

View file

@ -70,11 +70,11 @@ export const FilterForm = ({ filters, setFilters, index, column = '', operator =
customWrap={true} customWrap={true}
/> />
</div> </div>
<div className="col-4 tj-app-input"> <div className="col-4">
<input <input
value={filterInputValue} value={filterInputValue}
type="text" type="text"
className="form-control" className="form-control css-zz6spl-container"
data-cy="value-input-field" data-cy="value-input-field"
placeholder="Value" placeholder="Value"
onChange={(event) => { onChange={(event) => {

View file

@ -34,7 +34,7 @@ const RowForm = ({ onCreate, onClose }) => {
const handleSubmit = async () => { const handleSubmit = async () => {
setFetching(true); setFetching(true);
const { error } = await tooljetDatabaseService.createRow(organizationId, selectedTable, data); const { error } = await tooljetDatabaseService.createRow(organizationId, selectedTable.id, data);
setFetching(false); setFetching(false);
if (error) { if (error) {
toast.error(error?.message ?? `Failed to create a new column table "${selectedTable}"`); toast.error(error?.message ?? `Failed to create a new column table "${selectedTable}"`);

View file

@ -8,15 +8,15 @@ import { isEmpty } from 'lodash';
import { BreadCrumbContext } from '@/App/App'; import { BreadCrumbContext } from '@/App/App';
const TableForm = ({ const TableForm = ({
selectedTable = '', selectedTable = {},
selectedColumns = { 0: { column_name: 'id', data_type: 'serial', constraint: 'PRIMARY KEY' } }, selectedColumns = { 0: { column_name: 'id', data_type: 'serial', constraint_type: 'PRIMARY KEY' } },
onCreate, onCreate,
onEdit, onEdit,
onClose, onClose,
updateSelectedTable, updateSelectedTable,
}) => { }) => {
const [fetching, setFetching] = useState(false); const [fetching, setFetching] = useState(false);
const [tableName, setTableName] = useState(selectedTable); const [tableName, setTableName] = useState(selectedTable.table_name);
const [columns, setColumns] = useState(selectedColumns); const [columns, setColumns] = useState(selectedColumns);
const { organizationId } = useContext(TooljetDatabaseContext); const { organizationId } = useContext(TooljetDatabaseContext);
const isEditMode = !isEmpty(selectedTable); const isEditMode = !isEmpty(selectedTable);
@ -56,7 +56,7 @@ const TableForm = ({
} }
setFetching(true); setFetching(true);
const { error } = await tooljetDatabaseService.createTable(organizationId, tableName, Object.values(columns)); const { error, data } = await tooljetDatabaseService.createTable(organizationId, tableName, Object.values(columns));
setFetching(false); setFetching(false);
if (error) { if (error) {
toast.error(error?.message ?? `Failed to create a new table "${tableName}"`); toast.error(error?.message ?? `Failed to create a new table "${tableName}"`);
@ -64,14 +64,14 @@ const TableForm = ({
} }
toast.success(`${tableName} created successfully`); toast.success(`${tableName} created successfully`);
onCreate && onCreate(tableName); onCreate && onCreate({ id: data.result.id, table_name: tableName });
}; };
const handleEdit = async () => { const handleEdit = async () => {
if (!validateTableName()) return; if (!validateTableName()) return;
setFetching(true); setFetching(true);
const { error } = await tooljetDatabaseService.renameTable(organizationId, selectedTable, tableName); const { error } = await tooljetDatabaseService.renameTable(organizationId, selectedTable.table_name, tableName);
setFetching(false); setFetching(false);
if (error) { if (error) {
@ -81,7 +81,7 @@ const TableForm = ({
toast.success(`${tableName} edited successfully`); toast.success(`${tableName} edited successfully`);
updateSidebarNAV(tableName); updateSidebarNAV(tableName);
updateSelectedTable(tableName); updateSelectedTable({ ...selectedTable, table_name: tableName });
onEdit && onEdit(); onEdit && onEdit();
}; };

View file

@ -1,7 +1,7 @@
import React, { useEffect, useState, useContext } from 'react'; import React, { useEffect, useState, useContext, useRef } from 'react';
import cx from 'classnames'; import cx from 'classnames';
import { useTable, useRowSelect } from 'react-table'; import { useTable, useRowSelect } from 'react-table';
import { isBoolean } from 'lodash'; import { isBoolean, isEmpty } from 'lodash';
import { tooljetDatabaseService } from '@/_services'; import { tooljetDatabaseService } from '@/_services';
import { TooljetDatabaseContext } from '../index'; import { TooljetDatabaseContext } from '../index';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
@ -29,26 +29,31 @@ const Table = ({ openCreateRowDrawer, openCreateColumnDrawer }) => {
const [isEditColumnDrawerOpen, setIsEditColumnDrawerOpen] = useState(false); const [isEditColumnDrawerOpen, setIsEditColumnDrawerOpen] = useState(false);
const [selectedColumn, setSelectedColumn] = useState(); const [selectedColumn, setSelectedColumn] = useState();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const prevSelectedTableRef = useRef({});
const fetchTableMetadata = () => { const fetchTableMetadata = () => {
tooljetDatabaseService.viewTable(organizationId, selectedTable).then(({ data = [], error }) => { if (!isEmpty(selectedTable)) {
if (error) { tooljetDatabaseService.viewTable(organizationId, selectedTable.table_name).then(({ data = [], error }) => {
toast.error(error?.message ?? `Error fetching metadata for table "${selectedTable}"`); if (error) {
return; toast.error(error?.message ?? `Error fetching metadata for table "${selectedTable.table_name}"`);
} return;
}
if (data?.result?.length > 0) { if (data?.result?.length > 0) {
setColumns( setColumns(
data?.result.map(({ column_name, data_type, keytype, ...rest }) => ({ data?.result.map(({ column_name, data_type, keytype, ...rest }) => ({
Header: column_name, Header: column_name,
accessor: column_name, accessor: column_name,
dataType: data_type, dataType: data_type,
isPrimaryKey: keytype?.toLowerCase() === 'primary key', isPrimaryKey: keytype?.toLowerCase() === 'primary key',
...rest, ...rest,
})) }))
); );
} }
}); });
} else {
setColumns([]);
}
}; };
const fetchTableData = (queryParams = '', pagesize = 50, pagecount = 1) => { const fetchTableData = (queryParams = '', pagesize = 50, pagecount = 1) => {
@ -56,10 +61,10 @@ const Table = ({ openCreateRowDrawer, openCreateColumnDrawer }) => {
let params = queryParams ? queryParams : defaultQueryParams; let params = queryParams ? queryParams : defaultQueryParams;
setLoading(true); setLoading(true);
tooljetDatabaseService.findOne(organizationId, selectedTable, params).then(({ headers, data = [], error }) => { tooljetDatabaseService.findOne(organizationId, selectedTable.id, params).then(({ headers, data = [], error }) => {
setLoading(false); setLoading(false);
if (error) { if (error) {
toast.error(error?.message ?? `Error fetching table "${selectedTable}" data`); toast.error(error?.message ?? `Error fetching table "${selectedTable.table_name}" data`);
return; return;
} }
const totalContentRangeRecords = headers['content-range'].split('/')[1] || 0; const totalContentRangeRecords = headers['content-range'].split('/')[1] || 0;
@ -76,9 +81,10 @@ const Table = ({ openCreateRowDrawer, openCreateColumnDrawer }) => {
}; };
useEffect(() => { useEffect(() => {
if (selectedTable) { if (prevSelectedTableRef.current.id !== selectedTable.id && !isEmpty(selectedTable)) {
onSelectedTableChange(); onSelectedTableChange();
} }
prevSelectedTableRef.current = selectedTable;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTable]); }, [selectedTable]);
@ -163,14 +169,14 @@ const Table = ({ openCreateRowDrawer, openCreateColumnDrawer }) => {
let query = `?${primaryKey.accessor}=in.(${deletionKeys.toString()})`; let query = `?${primaryKey.accessor}=in.(${deletionKeys.toString()})`;
const { error } = await tooljetDatabaseService.deleteRow(organizationId, selectedTable, query); const { error } = await tooljetDatabaseService.deleteRows(organizationId, selectedTable.id, query);
if (error) { if (error) {
toast.error(error?.message ?? `Error deleting rows from table "${selectedTable}"`); toast.error(error?.message ?? `Error deleting rows from table "${selectedTable.table_name}"`);
return; return;
} }
toast.success(`Deleted ${selectedRows.length} rows from table "${selectedTable}"`); toast.success(`Deleted ${selectedRows.length} rows from table "${selectedTable.table_name}"`);
fetchTableData(); fetchTableData();
} }
}; };
@ -178,13 +184,13 @@ const Table = ({ openCreateRowDrawer, openCreateColumnDrawer }) => {
const handleDeleteColumn = async (columnName) => { const handleDeleteColumn = async (columnName) => {
const shouldDelete = confirm(`Are you sure you want to delete the column "${columnName}"?`); const shouldDelete = confirm(`Are you sure you want to delete the column "${columnName}"?`);
if (shouldDelete) { if (shouldDelete) {
const { error } = await tooljetDatabaseService.deleteColumn(organizationId, selectedTable, columnName); const { error } = await tooljetDatabaseService.deleteColumn(organizationId, selectedTable.table_name, columnName);
if (error) { if (error) {
toast.error(error?.message ?? `Error deleting column "${columnName}" from table "${selectedTable}"`); toast.error(error?.message ?? `Error deleting column "${columnName}" from table "${selectedTable}"`);
return; return;
} }
await fetchTableMetadata(); await fetchTableMetadata();
toast.success(`Deleted ${columnName} from table "${selectedTable}"`); toast.success(`Deleted ${columnName} from table "${selectedTable.table_name}"`);
} }
}; };

View file

@ -27,10 +27,14 @@ const List = () => {
return; return;
} }
if (Array.isArray(data?.result)) { if (!isEmpty(data?.result)) {
setTables(data.result || []); setTables(data.result || []);
setSelectedTable(data?.result[0]?.table_name); setSelectedTable({ table_name: data.result[0].table_name, id: data.result[0].id });
updateSidebarNAV(data?.result[0]?.table_name); updateSidebarNAV(data.result[0].table_name);
} else {
setTables([]);
setSelectedTable({});
updateSidebarNAV(null);
} }
} }
@ -39,6 +43,12 @@ const List = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
useEffect(() => {
const renamedTableList = tables.map((table) => (table.id === selectedTable.id ? selectedTable : table));
setTables(renamedTableList);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTable]);
let filteredTables = [...tables]; let filteredTables = [...tables];
if (!isEmpty(searchParam)) { if (!isEmpty(searchParam)) {
@ -78,14 +88,14 @@ const List = () => {
<div className="list-group mb-3"> <div className="list-group mb-3">
{loading && <Skeleton count={3} height={22} />} {loading && <Skeleton count={3} height={22} />}
{!loading && {!loading &&
filteredTables?.map(({ table_name }, index) => ( filteredTables?.map(({ id, table_name }, index) => (
<ListItem <ListItem
key={index} key={index}
active={table_name === selectedTable} active={id === selectedTable.id}
text={table_name} text={table_name}
onDeleteCallback={fetchTables} onDeleteCallback={fetchTables}
onClick={() => { onClick={() => {
setSelectedTable(table_name); setSelectedTable({ table_name, id });
updateSidebarNAV(table_name); updateSidebarNAV(table_name);
}} }}
/> />

View file

@ -15,8 +15,8 @@ export const ListItem = ({ active, onClick, text = '', onDeleteCallback }) => {
const [isEditTableDrawerOpen, setIsEditTableDrawerOpen] = useState(false); const [isEditTableDrawerOpen, setIsEditTableDrawerOpen] = useState(false);
const darkMode = localStorage.getItem('darkMode') === 'true'; const darkMode = localStorage.getItem('darkMode') === 'true';
function updateSelectedTable(tablename) { function updateSelectedTable(tableObj) {
setSelectedTable(tablename); setSelectedTable(tableObj);
} }
const handleDeleteTable = async () => { const handleDeleteTable = async () => {
@ -70,14 +70,7 @@ export const ListItem = ({ active, onClick, text = '', onDeleteCallback }) => {
selectedColumns={formColumns} selectedColumns={formColumns}
selectedTable={selectedTable} selectedTable={selectedTable}
updateSelectedTable={updateSelectedTable} updateSelectedTable={updateSelectedTable}
onEdit={() => { onEdit={() => setIsEditTableDrawerOpen(false)}
tooljetDatabaseService.findAll(organizationId).then(({ data = [] }) => {
if (Array.isArray(data?.result) && data.result.length > 0) {
setTables(data.result || []);
}
});
setIsEditTableDrawerOpen(false);
}}
onClose={() => setIsEditTableDrawerOpen(false)} onClose={() => setIsEditTableDrawerOpen(false)}
/> />
</Drawer> </Drawer>

View file

@ -9,6 +9,10 @@ import Sort from '../Sort';
import Sidebar from '../Sidebar'; import Sidebar from '../Sidebar';
import { TooljetDatabaseContext } from '../index'; import { TooljetDatabaseContext } from '../index';
import EmptyFoldersIllustration from '@assets/images/icons/no-queries-added.svg'; import EmptyFoldersIllustration from '@assets/images/icons/no-queries-added.svg';
import ExportSchema from '../ExportSchema/ExportSchema';
import { appService } from '@/_services/app.service';
import { toast } from 'react-hot-toast';
import { isEmpty } from 'lodash';
const TooljetDatabasePage = ({ totalTables }) => { const TooljetDatabasePage = ({ totalTables }) => {
const { const {
@ -22,6 +26,7 @@ const TooljetDatabasePage = ({ totalTables }) => {
setQueryFilters, setQueryFilters,
sortFilters, sortFilters,
setSortFilters, setSortFilters,
organizationId,
} = useContext(TooljetDatabaseContext); } = useContext(TooljetDatabaseContext);
const [isCreateRowDrawerOpen, setIsCreateRowDrawerOpen] = useState(false); const [isCreateRowDrawerOpen, setIsCreateRowDrawerOpen] = useState(false);
@ -51,19 +56,45 @@ const TooljetDatabasePage = ({ totalTables }) => {
); );
}; };
const exportTable = () => {
appService
.exportResource({
tooljet_database: [{ table_id: selectedTable.id }],
organization_id: organizationId,
})
.then((data) => {
const tableName = selectedTable.table_name.replace(/\s+/g, '-').toLowerCase();
const fileName = `${tableName}-export-${new Date().getTime()}`;
// simulate link click download
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const href = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = href;
link.download = fileName + '.json';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
})
.catch(() => {
toast.error('Could not export table.', {
position: 'top-center',
});
});
};
return ( return (
<div className="row gx-0"> <div className="row gx-0">
<Sidebar /> <Sidebar />
<div className={cx('col animation-fade database-page-content-wrap')}> <div className={cx('col animation-fade database-page-content-wrap')}>
{totalTables === 0 && <EmptyState />} {totalTables === 0 && <EmptyState />}
{!isEmpty(selectedTable) && (
{selectedTable && (
<> <>
<div className="database-table-header-wrapper"> <div className="database-table-header-wrapper">
<div className="card border-0"> <div className="card border-0">
<div className="card-body tj-db-operaions-header"> <div className="card-body tj-db-operaions-header">
<div className="row align-items-center"> <div className="row align-items-center">
<div className="col"> <div className="col d-flex">
<CreateColumnDrawer <CreateColumnDrawer
isCreateColumnDrawerOpen={isCreateColumnDrawerOpen} isCreateColumnDrawerOpen={isCreateColumnDrawerOpen}
setIsCreateColumnDrawerOpen={setIsCreateColumnDrawerOpen} setIsCreateColumnDrawerOpen={setIsCreateColumnDrawerOpen}
@ -82,6 +113,7 @@ const TooljetDatabasePage = ({ totalTables }) => {
handleBuildSortQuery={handleBuildSortQuery} handleBuildSortQuery={handleBuildSortQuery}
resetSortQuery={resetSortQuery} resetSortQuery={resetSortQuery}
/> />
<ExportSchema onClick={exportTable} />
<CreateRowDrawer <CreateRowDrawer
isCreateRowDrawerOpen={isCreateRowDrawerOpen} isCreateRowDrawerOpen={isCreateRowDrawerOpen}
setIsCreateRowDrawerOpen={setIsCreateRowDrawerOpen} setIsCreateRowDrawerOpen={setIsCreateRowDrawerOpen}

View file

@ -38,7 +38,7 @@ export const TooljetDatabase = (props) => {
const [columns, setColumns] = useState([]); const [columns, setColumns] = useState([]);
const [tables, setTables] = useState([]); const [tables, setTables] = useState([]);
const [searchParam, setSearchParam] = useState(''); const [searchParam, setSearchParam] = useState('');
const [selectedTable, setSelectedTable] = useState(''); const [selectedTable, setSelectedTable] = useState({});
const [selectedTableData, setSelectedTableData] = useState([]); const [selectedTableData, setSelectedTableData] = useState([]);
const [totalRecords, setTotalRecords] = useState(0); const [totalRecords, setTotalRecords] = useState(0);

View file

@ -32,7 +32,7 @@ export const usePostgrestQueryBuilder = ({ organizationId, selectedTable, setSel
'&' + '&' +
postgrestQueryBuilder.current.paginationQuery.url.toString(); postgrestQueryBuilder.current.paginationQuery.url.toString();
const { headers, data, error } = await tooljetDatabaseService.findOne(organizationId, selectedTable, query); const { headers, data, error } = await tooljetDatabaseService.findOne(organizationId, selectedTable.id, query);
if (error) { if (error) {
toast.error(error?.message ?? 'Something went wrong'); toast.error(error?.message ?? 'Something went wrong');
@ -83,6 +83,7 @@ export const usePostgrestQueryBuilder = ({ organizationId, selectedTable, setSel
}; };
const resetAll = () => { const resetAll = () => {
console.log('resetAll');
postgrestQueryBuilder.current.sortQuery = new PostgrestQueryBuilder(); postgrestQueryBuilder.current.sortQuery = new PostgrestQueryBuilder();
postgrestQueryBuilder.current.paginationQuery.limit(50); postgrestQueryBuilder.current.paginationQuery.limit(50);

View file

@ -828,12 +828,7 @@ export function previewQuery(_ref, query, calledFromQuery = false, parameters =
hasParamSupport hasParamSupport
); );
} else if (query.kind === 'tooljetdb') { } else if (query.kind === 'tooljetdb') {
const currentSessionValue = authenticationService.currentSessionValue; queryExecutionPromise = tooljetDbOperations.perform(query, getCurrentState());
queryExecutionPromise = tooljetDbOperations.perform(
query.options,
currentSessionValue?.current_organization_id,
getCurrentState()
);
} else if (query.kind === 'runpy') { } else if (query.kind === 'runpy') {
queryExecutionPromise = executeRunPycode(_ref, query.options.code, query, true, 'edit'); queryExecutionPromise = executeRunPycode(_ref, query.options.code, query, true, 'edit');
} else { } else {
@ -961,12 +956,7 @@ export function runQuery(_ref, queryId, queryName, confirmed = undefined, mode =
} else if (query.kind === 'runpy') { } else if (query.kind === 'runpy') {
queryExecutionPromise = executeRunPycode(_self, query.options.code, query, false, mode); queryExecutionPromise = executeRunPycode(_self, query.options.code, query, false, mode);
} else if (query.kind === 'tooljetdb') { } else if (query.kind === 'tooljetdb') {
const currentSessionValue = authenticationService.currentSessionValue; queryExecutionPromise = tooljetDbOperations.perform(query, getCurrentState());
queryExecutionPromise = tooljetDbOperations.perform(
query.options,
currentSessionValue?.current_organization_id,
getCurrentState()
);
} else { } else {
queryExecutionPromise = dataqueryService.run(queryId, options, query?.options); queryExecutionPromise = dataqueryService.run(queryId, options, query?.options);
} }

View file

@ -8,6 +8,9 @@ export const appService = {
cloneApp, cloneApp,
exportApp, exportApp,
importApp, importApp,
exportResource,
importResource,
cloneResource,
changeIcon, changeIcon,
deleteApp, deleteApp,
getApp, getApp,
@ -22,6 +25,7 @@ export const appService = {
setPasswordFromToken, setPasswordFromToken,
acceptInvite, acceptInvite,
getVersions, getVersions,
getTables,
}; };
function getConfig() { function getConfig() {
@ -56,6 +60,38 @@ function exportApp(id, versionId) {
); );
} }
function exportResource(body) {
const requestOptions = {
method: 'POST',
headers: authHeader(),
body: JSON.stringify(body),
credentials: 'include',
};
return fetch(`${config.apiUrl}/v2/resources/export`, requestOptions).then(handleResponse);
}
function importResource(body) {
const requestOptions = {
method: 'POST',
headers: authHeader(),
credentials: 'include',
body: JSON.stringify(body),
};
return fetch(`${config.apiUrl}/v2/resources/import`, requestOptions).then(handleResponse);
}
function cloneResource(body) {
const requestOptions = {
method: 'POST',
headers: authHeader(),
body: JSON.stringify(body),
credentials: 'include',
};
return fetch(`${config.apiUrl}/v2/resources/clone`, requestOptions).then(handleResponse);
}
function getVersions(id) { function getVersions(id) {
const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include' }; const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include' };
return fetch(`${config.apiUrl}/apps/${id}/versions`, requestOptions).then(handleResponse); return fetch(`${config.apiUrl}/apps/${id}/versions`, requestOptions).then(handleResponse);
@ -66,6 +102,11 @@ function importApp(body) {
return fetch(`${config.apiUrl}/apps/import`, requestOptions).then(handleResponse); return fetch(`${config.apiUrl}/apps/import`, requestOptions).then(handleResponse);
} }
function getTables(id) {
const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include' };
return fetch(`${config.apiUrl}/apps/${id}/tables`, requestOptions).then(handleResponse);
}
function changeIcon(icon, appId) { function changeIcon(icon, appId) {
const requestOptions = { const requestOptions = {
method: 'PUT', method: 'PUT',

View file

@ -2,8 +2,9 @@ import HttpClient from '@/_helpers/http-client';
const tooljetAdapter = new HttpClient(); const tooljetAdapter = new HttpClient();
function findOne(organizationId, tableName, query = '') { function findOne(headers, tableId, query = '') {
return tooljetAdapter.get(`/tooljet_db/organizations/${organizationId}/proxy/\${${tableName}}?${query}`); tooljetAdapter.headers = { ...tooljetAdapter.headers, ...headers };
return tooljetAdapter.get(`/tooljet_db/proxy/${tableId}?${query}`, headers);
} }
function findAll(organizationId) { function findAll(organizationId) {
@ -21,16 +22,16 @@ function viewTable(organizationId, tableName) {
return tooljetAdapter.get(`/tooljet_db/organizations/${organizationId}/table/${tableName}`); return tooljetAdapter.get(`/tooljet_db/organizations/${organizationId}/table/${tableName}`);
} }
function createRow(organizationId, tableName, data) { function createRow(headers, tableId, data) {
return tooljetAdapter.post(`/tooljet_db/organizations/${organizationId}/proxy/\${${tableName}}`, data); return tooljetAdapter.post(`/tooljet_db/proxy/${tableId}`, data, headers);
} }
function createColumn(organizationId, tableName, columnName, dataType, defaultValue) { function createColumn(organizationId, tableId, columnName, dataType, defaultValue) {
return tooljetAdapter.post(`/tooljet_db/organizations/${organizationId}/table/${tableName}/column`, { return tooljetAdapter.post(`/tooljet_db/organizations/${organizationId}/table/${tableId}/column`, {
column: { column: {
column_name: columnName, column_name: columnName,
data_type: dataType, data_type: dataType,
default: defaultValue, column_default: defaultValue,
}, },
}); });
} }
@ -51,12 +52,12 @@ function renameTable(organizationId, tableName, newTableName) {
}); });
} }
function updateRows(organizationId, tableName, data, query = '') { function updateRows(headers, tableId, data, query = '') {
return tooljetAdapter.patch(`/tooljet_db/organizations/${organizationId}/proxy/\${${tableName}}?${query}`, data); return tooljetAdapter.patch(`/tooljet_db/proxy/${tableId}?${query}`, data, headers);
} }
function deleteRow(organizationId, tableName, query = '') { function deleteRows(headers, tableId, query = '') {
return tooljetAdapter.delete(`/tooljet_db/organizations/${organizationId}/proxy/\${${tableName}}?${query}`); return tooljetAdapter.delete(`/tooljet_db/proxy/${tableId}?${query}`, headers);
} }
function deleteColumn(organizationId, tableName, columnName) { function deleteColumn(organizationId, tableName, columnName) {
@ -76,7 +77,7 @@ export const tooljetDatabaseService = {
createColumn, createColumn,
updateTable, updateTable,
updateRows, updateRows,
deleteRow, deleteRows,
deleteColumn, deleteColumn,
deleteTable, deleteTable,
renameTable, renameTable,

View file

@ -1307,15 +1307,17 @@ button {
} }
.fx-button { .fx-button {
font-weight: 400; color: #3E63DD;
font-size: 13px; font-family: 'IBM Plex Mono';
color: #61656c; font-style: italic;
font-weight: 500;
font-size: 12px;
line-height: 20px;
} }
.fx-button:hover, .fx-button:hover,
.fx-button.active { .fx-button.active {
font-weight: 600; font-weight: 600;
color: $primary-light;
cursor: pointer; cursor: pointer;
} }
@ -1729,7 +1731,6 @@ button {
.select-search-dark__input { .select-search-dark__input {
display: block; display: block;
width: 100%; width: 100%;
// padding: 0.4375rem 0.75rem;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 400; font-weight: 400;
line-height: 1.4285714; line-height: 1.4285714;
@ -4536,10 +4537,8 @@ input[type="text"] {
.canvas-background-holder { .canvas-background-holder {
display: flex; display: flex;
justify-content: space-between;
min-width: 120px; min-width: 120px;
margin: auto; margin: auto;
padding: 10px;
} }
.canvas-background-picker { .canvas-background-picker {
@ -4792,8 +4791,30 @@ input[type="text"] {
background-color: $bg-dark-light !important; background-color: $bg-dark-light !important;
color: $white !important; color: $white !important;
.modal-title {
color: $white !important;
}
.tj-version-wrap-sub-footer {
background-color: $bg-dark-light !important;
border-top: 1px solid #3A3F42 !important;
p {
color: $white !important;
}
}
.current-version-wrap,
.other-version-wrap {
background: transparent !important;
}
.modal-header { .modal-header {
background-color: $bg-dark-light !important; background-color: $bg-dark-light !important;
color: $white !important;
border-bottom: 2px solid #3A3F42 !important;
} }
.btn-close { .btn-close {
@ -5225,22 +5246,23 @@ div#driver-page-overlay {
.canvas-codehinter-container { .canvas-codehinter-container {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
width: 158px;
height: 32px;
} }
.hinter-canvas-input { .hinter-canvas-input {
display: flex;
width: 120px;
height: 32px;
margin-top: 1px;
.canvas-hinter-wrap { .canvas-hinter-wrap {
width: 135px; width: 120px;
height: 42px !important; height: 32px;
} }
} }
.hinter-canvas-input { .hinter-canvas-input {
width: 180px !important;
display: flex;
padding: 4px;
height: 41.2px !important;
margin-top: 1px;
.CodeMirror-sizer { .CodeMirror-sizer {
border-right-width: 1px !important; border-right-width: 1px !important;
} }
@ -5253,35 +5275,37 @@ div#driver-page-overlay {
.canvas-codehinter-container { .canvas-codehinter-container {
.code-hinter-col { .code-hinter-col {
margin-bottom: 1px !important; margin-bottom: 1px !important;
width: 136px;
height: 32px;
} }
} }
.fx-canvas { .fx-canvas {
background: #1c252f;
padding: 2px; padding: 2px;
display: flex; display: flex;
height: 41px;
border: solid 1px rgba(255, 255, 255, 0.09) !important;
border-radius: 4px; border-radius: 4px;
justify-content: center; justify-content: center;
font-weight: 400; font-weight: 400;
align-items: center;
div { div {
background: #1c252f !important; background: #121212 !important;
width: 35px !important;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
height: 36px; width: 39px;
height: 32px;
}
.code-hinter-wrapper {
width: 120px !important;
height: 32px;
} }
} }
.fx-canvas-light { .fx-canvas-light {
background: #f4f6fa !important;
border: 1px solid #dadcde !important;
div { div {
background: #f4f6fa !important; background: #fff !important;
} }
} }
@ -6920,7 +6944,31 @@ tbody {
} }
} }
.import-export-footer-btns {
margin: 0px !important;
}
.home-version-modal-component {
border-bottom-right-radius: 0px !important;
border-bottom-left-radius: 0px !important;
box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08),
0px 4px 6px -2px rgba(16, 24, 40, 0.03) !important;
}
.current-version-label,
.other-version-label {
color: var(--slate11);
}
.home-modal-component.modal-version-lists { .home-modal-component.modal-version-lists {
width: 466px;
height: 668px;
background: var(--base);
box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03);
border-top-right-radius: 6px;
border-top-right-radius: 6px;
.modal-header { .modal-header {
.btn-close { .btn-close {
top: auto; top: auto;
@ -6936,16 +6984,66 @@ tbody {
overflow: auto; overflow: auto;
} }
.export-creation-date {
color: var(--slate11);
}
.modal-footer, .modal-footer,
.modal-header { .modal-header {
height: 10%; padding-bottom: 24px;
padding: 12px 28px;
gap: 10px;
width: 466px;
height: 56px;
background-color: var(--base);
}
.modal-footer {
padding: 24px 32px;
gap: 8px;
width: 466px;
height: 88px;
}
.tj-version-wrap-sub-footer {
display: flex;
flex-direction: row;
padding: 16px 28px;
gap: 10px;
height: 52px;
background: var(--base);
border-top: 1px solid var(--slate5);
border-bottom: 1px solid var(--slate5);
p {
font-weight: 400;
font-size: 14px;
line-height: 20px;
color: var(--slate12);
}
} }
.version-wrapper { .version-wrapper {
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;
padding: 0.75rem 0.25rem; padding: 0.75rem 0.25rem;
border: 1px solid var(--slate7); }
.current-version-wrap,
.other-version-wrap {
span:first-child {
color: var(--slate12) !important;
}
}
.current-version-wrap {
background: var(--indigo3) !important;
margin-bottom: 24px;
border-radius: 6px;
margin-top: 8px;
} }
} }
@ -7733,7 +7831,14 @@ tbody {
} }
.maximum-canvas-height-input-field { .maximum-canvas-height-input-field {
width: 90px; width: 156px;
height: 32px;
padding: 6px 10px;
gap: 17px;
background: #FFFFFF;
border: 1px solid #D7DBDF;
border-radius: 6px;
} }
.layout-header { .layout-header {
@ -10619,6 +10724,166 @@ tbody {
} }
} }
#global-settings-popover {
padding: 24px;
gap: 20px;
max-width: 377px !important;
height: 316px !important;
background: #FFFFFF;
border: 1px solid #E6E8EB;
box-shadow: 0px 32px 64px -12px rgba(16, 24, 40, 0.14);
border-radius: 6px;
margin-top: -13px;
.input-with-icon {
justify-content: end;
}
.form-check-input {
padding-right: 8px;
}
.global-popover-div-wrap-width {
width: 156px !important;
}
.form-switch {
margin-bottom: 20px;
}
.global-popover-div-wrap {
padding: 0px;
gap: 75px;
width: 329px;
height: 32px;
margin-bottom: 20px !important;
justify-content: space-between;
&:last-child {
margin-bottom: 0px !important;
}
}
}
.global-popover-text {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 500;
font-size: 12px;
line-height: 20px;
color: #11181C;
}
.maximum-canvas-width-input-select {
padding: 6px 10px;
gap: 17px;
width: 60px;
height: 32px;
background: #FFFFFF;
border: 1px solid #D7DBDF;
border-radius: 0px 6px 6px 0px;
}
.maximum-canvas-width-input-field {
padding: 6px 10px;
gap: 17px;
width: 97px;
height: 32px;
background: #FFFFFF;
border: 1px solid #D7DBDF;
border-top-left-radius: 6px;
border-bottom-left-radius: 6px;
border-right: none !important;
}
.canvas-background-holder {
padding: 6px 10px;
gap: 6px;
width: 120px;
height: 32px;
background: #FFFFFF;
display: flex;
align-items: center;
border: 1px solid #D7DBDF;
border-radius: 6px;
flex-direction: row;
}
.export-app-btn {
flex-direction: row;
justify-content: center;
align-items: center;
padding: 6px 16px;
gap: 6px;
width: 158px;
height: 32px;
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 600;
font-size: 14px;
line-height: 20px;
color: #3E63DD;
background: #F0F4FF;
border-radius: 6px;
border: none;
}
.tj-btn-tertiary {
padding: 10px 20px;
gap: 8px;
width: 112px;
height: 40px;
background: #FFFFFF;
border: 1px solid #D7DBDF;
border-radius: 6px;
&:hover {
border: 1px solid #C1C8CD;
color: #687076;
}
&:active {
border: 1px solid #11181C;
color: #11181C;
}
}
.export-table-button {
width: 135px;
display: flex;
align-items: center;
justify-content: center;
}
#global-settings-popover.theme-dark {
background-color: $bg-dark-light !important;
border: 1px solid #2B2F31;
.global-popover-text {
color: #fff !important;
}
.maximum-canvas-width-input-select {
background-color: $bg-dark-light !important;
border: 1px solid #324156;
color: $white;
}
.export-app-btn {
background: #192140;
}
.fx-canvas div {
background-color: transparent !important;
}
}
.released-version-popup-container { .released-version-popup-container {
width: 100%; width: 100%;
position: absolute; position: absolute;
@ -10926,10 +11191,6 @@ tbody {
} }
} }
.confirm-dialogue-modal {
background: var(--base);
}
.theme-dark { .theme-dark {
.icon-widget-popover { .icon-widget-popover {
.search-box-wrapper input { .search-box-wrapper input {
@ -10960,20 +11221,8 @@ tbody {
} }
} }
.confirm-dialogue-modal {
.workspace-folder-modal { background: var(--base);
.tj-app-input {
padding-bottom: 0px !important;
}
.tj-input-error {
height: 32px;
color: #ED5F00;
font-weight: 400;
font-size: 10px;
height: 0px;
padding: 4px 0px 20px 0px;
}
} }
.table-editor-component-row { .table-editor-component-row {
@ -11230,4 +11479,4 @@ tbody {
background-color: #F1F3F5; background-color: #F1F3F5;
color: #C1C8CD; color: #C1C8CD;
} }
} }

View file

@ -0,0 +1,23 @@
{
"tooljet_database": [
{
"id": "a7426184-4d4d-4191-9f74-d668cc779ef9",
"table_name": "new",
"schema": {
"columns": [
{
"column_name": "id",
"data_type": "integer",
"column_default": "nextval('\"a7426184-4d4d-4191-9f74-d668cc779ef9_id_seq\"'::regclass)",
"character_maximum_length": null,
"numeric_precision": 32,
"is_nullable": "NO",
"constraint_type": "PRIMARY KEY",
"keytype": "PRIMARY KEY"
}
]
}
}
],
"tooljet_version": "2.4.9"
}

View file

@ -0,0 +1,69 @@
import { DataSource } from 'src/entities/data_source.entity';
import { MigrationInterface, QueryRunner } from 'typeorm';
import { isEmpty } from 'lodash';
import { InternalTable } from 'src/entities/internal_table.entity';
import { Organization } from 'src/entities/organization.entity';
import { DataQuery } from 'src/entities/data_query.entity';
export class ReplaceTooljetDbTableNamesWithId1679604241777 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
let progress = 0;
const entityManager = queryRunner.manager;
const organizations = await entityManager.find(Organization, { select: ['id'] });
const orgCount = organizations.length;
console.log(`Total Organizations: ${orgCount}`);
for (const organization of organizations) {
console.log(
`ReplaceTooljetDbTableNamesWithId1679604241777 Progress ${Math.round((progress / orgCount) * 100)} %`
);
console.log(`Replacing for organization ${organization.name}: ${organization.id}`);
const tjDbDataSources = await entityManager
.createQueryBuilder(DataSource, 'data_sources')
.select(['data_sources.id', 'data_sources.appVersionId', 'apps.id', 'apps.name'])
.innerJoin('data_sources.appVersion', 'app_versions')
.innerJoin('app_versions.app', 'apps', 'apps.organizationId = :organizationId', {
organizationId: organization.id,
})
.where('data_sources.kind = :kind', { kind: 'tooljetdb' })
.getRawMany();
const tjDbDataSourcesCount = tjDbDataSources.length;
console.log(`TjDb datasources: ${tjDbDataSourcesCount}`);
for (const tjDbSource of tjDbDataSources) {
console.log(`App ${tjDbSource.apps_name}: ${tjDbSource.apps_id}`);
const dataQueriesToReplaceWithIds = await entityManager.find(DataQuery, {
where: { dataSourceId: tjDbSource.data_sources_id },
select: ['id', 'options'],
});
console.log(`TjDb dataqueries: ${dataQueriesToReplaceWithIds.length}`);
for (const dataQuery of dataQueriesToReplaceWithIds) {
const options = dataQuery.options;
const { table_name: tableName } = options;
const internalTable = await entityManager.findOne(InternalTable, {
where: { organizationId: organization.id, tableName },
select: ['id', 'tableName'],
});
// There was a bug wherein if the table name had changed, the name in app definition
// will not be changed. So there could be occurences where table with the name on
// app definition won't be found. In such cases we don't make any change for that
// query. User will have to explicitly link the table again for that query in the
// editor after this migration is run.
if (isEmpty(internalTable)) continue;
dataQuery.options = { ...options, table_id: internalTable.id };
await dataQuery.save();
}
}
progress++;
}
}
public async down(queryRunner: QueryRunner): Promise<void> {}
}

View file

@ -43,6 +43,7 @@ import { AppEnvironmentsModule } from './modules/app_environments/app_environmen
import { OrganizationConstantModule } from './modules/organization_constants/organization_constants.module'; import { OrganizationConstantModule } from './modules/organization_constants/organization_constants.module';
import { RequestContextModule } from './modules/request_context/request-context.module'; import { RequestContextModule } from './modules/request_context/request-context.module';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ImportExportResourcesModule } from './modules/import_export_resources/import_export_resources.module';
const imports = [ const imports = [
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
@ -97,6 +98,7 @@ const imports = [
PluginsModule, PluginsModule,
EventsModule, EventsModule,
AppEnvironmentsModule, AppEnvironmentsModule,
ImportExportResourcesModule,
CopilotModule, CopilotModule,
OrganizationConstantModule, OrganizationConstantModule,
]; ];

View file

@ -355,4 +355,18 @@ export class AppsController {
const appUser = await this.appsService.update(app.id, appUpdateDto); const appUser = await this.appsService.update(app.id, appUpdateDto);
return decamelizeKeys(appUser); return decamelizeKeys(appUser);
} }
@UseGuards(JwtAuthGuard)
@UseInterceptors(ValidAppInterceptor)
@Get(':id/tables')
async tables(@User() user, @AppDecorator() app: App) {
const ability = await this.appsAbilityFactory.appsActions(user, app.id);
if (!ability.can('cloneApp', app)) {
throw new ForbiddenException('You do not have permissions to perform this action');
}
const result = await this.appsService.findTooljetDbTables(app.id);
return { tables: result };
}
} }

View file

@ -0,0 +1,61 @@
import { Controller, Post, UseGuards, Body, ForbiddenException } from '@nestjs/common';
import { JwtAuthGuard } from '../../src/modules/auth/jwt-auth.guard';
import { User } from 'src/decorators/user.decorator';
import { ExportResourcesDto } from '@dto/export-resources.dto';
import { ImportResourcesDto } from '@dto/import-resources.dto';
import { ImportExportResourcesService } from '@services/import_export_resources.service';
import { App } from 'src/entities/app.entity';
import { AppsAbilityFactory } from 'src/modules/casl/abilities/apps-ability.factory';
import { CloneResourcesDto } from '@dto/clone-resources.dto';
@Controller({
path: 'resources',
version: '2',
})
export class ImportExportResourcesController {
constructor(
private importExportResourcesService: ImportExportResourcesService,
private appsAbilityFactory: AppsAbilityFactory
) {}
@UseGuards(JwtAuthGuard)
@Post('/export')
async export(@User() user, @Body() exportResourcesDto: ExportResourcesDto) {
const ability = await this.appsAbilityFactory.appsActions(user);
if (!ability.can('createApp', App)) {
throw new ForbiddenException('You do not have permissions to perform this action');
}
const result = await this.importExportResourcesService.export(user, exportResourcesDto);
return {
...result,
tooljet_version: globalThis.TOOLJET_VERSION,
};
}
@UseGuards(JwtAuthGuard)
@Post('/import')
async import(@User() user, @Body() importResourcesDto: ImportResourcesDto) {
const ability = await this.appsAbilityFactory.appsActions(user);
if (!ability.can('cloneApp', App)) {
throw new ForbiddenException('You do not have permissions to perform this action');
}
const imports = await this.importExportResourcesService.import(user, importResourcesDto);
return { imports, success: true };
}
@UseGuards(JwtAuthGuard)
@Post('/clone')
async clone(@User() user, @Body() cloneResourcesDto: CloneResourcesDto) {
const ability = await this.appsAbilityFactory.appsActions(user);
if (!ability.can('cloneApp', App)) {
throw new ForbiddenException('You do not have permissions to perform this action');
}
const imports = await this.importExportResourcesService.clone(user, cloneResourcesDto);
return { imports, success: true };
}
}

View file

@ -9,63 +9,64 @@ import { CheckPolicies } from 'src/modules/casl/check_policies.decorator';
import { Action, TooljetDbAbility } from 'src/modules/casl/abilities/tooljet-db-ability.factory'; import { Action, TooljetDbAbility } from 'src/modules/casl/abilities/tooljet-db-ability.factory';
import { TooljetDbGuard } from 'src/modules/casl/tooljet-db.guard'; import { TooljetDbGuard } from 'src/modules/casl/tooljet-db.guard';
import { CreatePostgrestTableDto, RenamePostgrestTableDto, PostgrestTableColumnDto } from '@dto/tooljet-db.dto'; import { CreatePostgrestTableDto, RenamePostgrestTableDto, PostgrestTableColumnDto } from '@dto/tooljet-db.dto';
import { OrganizationAuthGuard } from 'src/modules/auth/organization-auth.guard';
@Controller('tooljet_db/organizations') @Controller('tooljet_db')
@UseGuards(JwtAuthGuard, ActiveWorkspaceGuard)
export class TooljetDbController { export class TooljetDbController {
constructor( constructor(
private readonly tooljetDbService: TooljetDbService, private readonly tooljetDbService: TooljetDbService,
private readonly postgrestProxyService: PostgrestProxyService private readonly postgrestProxyService: PostgrestProxyService
) {} ) {}
@All('/:organizationId/proxy/*') @All('/proxy/*')
@UseGuards(OrganizationAuthGuard, TooljetDbGuard)
@CheckPolicies((ability: TooljetDbAbility) => ability.can(Action.ProxyPostgrest, 'all')) @CheckPolicies((ability: TooljetDbAbility) => ability.can(Action.ProxyPostgrest, 'all'))
async proxy(@Req() req, @Res() res, @Next() next, @Param('organizationId') organizationId) { async proxy(@Req() req, @Res() res, @Next() next) {
return this.postgrestProxyService.perform(req, res, next, organizationId); return this.postgrestProxyService.perform(req, res, next);
} }
@Get('/:organizationId/tables') @Get('/organizations/:organizationId/tables')
@UseGuards(TooljetDbGuard) @UseGuards(JwtAuthGuard, ActiveWorkspaceGuard, TooljetDbGuard)
@CheckPolicies((ability: TooljetDbAbility) => ability.can(Action.ViewTables, 'all')) @CheckPolicies((ability: TooljetDbAbility) => ability.can(Action.ViewTables, 'all'))
async tables(@Param('organizationId') organizationId) { async tables(@Param('organizationId') organizationId) {
const result = await this.tooljetDbService.perform(organizationId, 'view_tables'); const result = await this.tooljetDbService.perform(organizationId, 'view_tables');
return decamelizeKeys({ result }); return decamelizeKeys({ result });
} }
@Get('/:organizationId/table/:tableName') @Get('/organizations/:organizationId/table/:tableName')
@UseGuards(TooljetDbGuard) @UseGuards(JwtAuthGuard, ActiveWorkspaceGuard, TooljetDbGuard)
@CheckPolicies((ability: TooljetDbAbility) => ability.can(Action.ViewTable, 'all')) @CheckPolicies((ability: TooljetDbAbility) => ability.can(Action.ViewTable, 'all'))
async table(@Body() body, @Param('organizationId') organizationId, @Param('tableName') tableName) { async table(@Body() body, @Param('organizationId') organizationId, @Param('tableName') tableName) {
const result = await this.tooljetDbService.perform(organizationId, 'view_table', { table_name: tableName }); const result = await this.tooljetDbService.perform(organizationId, 'view_table', { table_name: tableName });
return decamelizeKeys({ result }); return decamelizeKeys({ result });
} }
@Post('/:organizationId/table') @Post('/organizations/:organizationId/table')
@UseGuards(TooljetDbGuard) @UseGuards(JwtAuthGuard, ActiveWorkspaceGuard, TooljetDbGuard)
@CheckPolicies((ability: TooljetDbAbility) => ability.can(Action.CreateTable, 'all')) @CheckPolicies((ability: TooljetDbAbility) => ability.can(Action.CreateTable, 'all'))
async createTable(@Body() createTableDto: CreatePostgrestTableDto, @Param('organizationId') organizationId) { async createTable(@Body() createTableDto: CreatePostgrestTableDto, @Param('organizationId') organizationId) {
const result = await this.tooljetDbService.perform(organizationId, 'create_table', createTableDto); const result = await this.tooljetDbService.perform(organizationId, 'create_table', createTableDto);
return decamelizeKeys({ result }); return decamelizeKeys({ result });
} }
@Patch('/:organizationId/table/:tableName') @Patch('/organizations/:organizationId/table/:tableName')
@UseGuards(TooljetDbGuard) @UseGuards(JwtAuthGuard, ActiveWorkspaceGuard, TooljetDbGuard)
@CheckPolicies((ability: TooljetDbAbility) => ability.can(Action.RenameTable, 'all')) @CheckPolicies((ability: TooljetDbAbility) => ability.can(Action.RenameTable, 'all'))
async renameTable(@Body() renameTableDto: RenamePostgrestTableDto, @Param('organizationId') organizationId) { async renameTable(@Body() renameTableDto: RenamePostgrestTableDto, @Param('organizationId') organizationId) {
const result = await this.tooljetDbService.perform(organizationId, 'rename_table', renameTableDto); const result = await this.tooljetDbService.perform(organizationId, 'rename_table', renameTableDto);
return decamelizeKeys({ result }); return decamelizeKeys({ result });
} }
@Delete('/:organizationId/table/:tableName') @Delete('/organizations/:organizationId/table/:tableName')
@UseGuards(TooljetDbGuard) @UseGuards(JwtAuthGuard, ActiveWorkspaceGuard, TooljetDbGuard)
@CheckPolicies((ability: TooljetDbAbility) => ability.can(Action.DropTable, 'all')) @CheckPolicies((ability: TooljetDbAbility) => ability.can(Action.DropTable, 'all'))
async dropTable(@Param('organizationId') organizationId, @Param('tableName') tableName) { async dropTable(@Param('organizationId') organizationId, @Param('tableName') tableName) {
const result = await this.tooljetDbService.perform(organizationId, 'drop_table', { table_name: tableName }); const result = await this.tooljetDbService.perform(organizationId, 'drop_table', { table_name: tableName });
return decamelizeKeys({ result }); return decamelizeKeys({ result });
} }
@Post('/:organizationId/table/:tableName/column') @Post('/organizations/:organizationId/table/:tableName/column')
@UseGuards(TooljetDbGuard) @UseGuards(JwtAuthGuard, ActiveWorkspaceGuard, TooljetDbGuard)
@CheckPolicies((ability: TooljetDbAbility) => ability.can(Action.AddColumn, 'all')) @CheckPolicies((ability: TooljetDbAbility) => ability.can(Action.AddColumn, 'all'))
async addColumn( async addColumn(
@Body('column') columnDto: PostgrestTableColumnDto, @Body('column') columnDto: PostgrestTableColumnDto,
@ -80,8 +81,8 @@ export class TooljetDbController {
return decamelizeKeys({ result }); return decamelizeKeys({ result });
} }
@Delete('/:organizationId/table/:tableName/column/:columnName') @Delete('/organizations/:organizationId/table/:tableName/column/:columnName')
@UseGuards(TooljetDbGuard) @UseGuards(JwtAuthGuard, ActiveWorkspaceGuard, TooljetDbGuard)
@CheckPolicies((ability: TooljetDbAbility) => ability.can(Action.DropColumn, 'all')) @CheckPolicies((ability: TooljetDbAbility) => ability.can(Action.DropColumn, 'all'))
async dropColumn( async dropColumn(
@Param('organizationId') organizationId, @Param('organizationId') organizationId,

View file

@ -0,0 +1,22 @@
import { IsUUID, IsOptional } from 'class-validator';
export class CloneResourcesDto {
@IsOptional()
app: CloneAppDto[];
@IsOptional()
tooljet_database: CloneTooljetDatabaseDto[];
@IsUUID()
organization_id: string;
}
export class CloneAppDto {
@IsUUID()
id: string;
}
export class CloneTooljetDatabaseDto {
@IsUUID()
id: string;
}

View file

@ -0,0 +1,28 @@
import { IsUUID, IsOptional } from 'class-validator';
export class ExportResourcesDto {
@IsOptional()
app: ExportAppDto[];
@IsOptional()
tooljet_database: ExportTooljetDatabaseDto[];
@IsUUID()
organization_id: string;
}
export class ExportAppDto {
@IsUUID()
id: string;
@IsOptional()
search_params: any;
}
export class ExportTooljetDatabaseDto {
@IsUUID()
table_id: string;
// @IsOptional()
// data: boolean;
}

View file

@ -0,0 +1,34 @@
import { IsUUID, IsOptional, IsString, IsDefined } from 'class-validator';
export class ImportResourcesDto {
@IsUUID()
organization_id: string;
@IsString()
tooljet_version: string;
@IsOptional()
app: ImportAppDto[];
@IsOptional()
tooljet_database: ImportTooljetDatabaseDto[];
}
export class ImportAppDto {
@IsDefined()
definition: any;
}
export class ImportTooljetDatabaseDto {
@IsUUID()
id: string;
@IsString()
table_name: string;
@IsDefined()
schema: any;
// @IsOptional()
// data: boolean;
}

View file

@ -140,7 +140,7 @@ export class PostgrestTableColumnDto {
@Transform(({ value }) => sanitizeInput(value)) @Transform(({ value }) => sanitizeInput(value))
@IsOptional() @IsOptional()
@Validate(SQLInjectionValidator) @Validate(SQLInjectionValidator)
constraint: string; constraint_type: string;
@IsOptional() @IsOptional()
@Transform(({ value, obj }) => { @Transform(({ value, obj }) => {
@ -151,7 +151,7 @@ export class PostgrestTableColumnDto {
message: 'Default value must match the data type', message: 'Default value must match the data type',
}) })
@Validate(SQLInjectionValidator, { message: 'Default value does not support special characters except "." and "@"' }) @Validate(SQLInjectionValidator, { message: 'Default value does not support special characters except "." and "@"' })
default: string | number | boolean; column_default: string | number | boolean;
} }
export class RenamePostgrestTableDto { export class RenamePostgrestTableDto {

View file

@ -57,7 +57,9 @@ export class DataSource extends BaseEntity {
@UpdateDateColumn({ default: () => 'now()', name: 'updated_at' }) @UpdateDateColumn({ default: () => 'now()', name: 'updated_at' })
updatedAt: Date; updatedAt: Date;
@ManyToOne(() => AppVersion, (appVersion) => appVersion.id, { onDelete: 'CASCADE' }) @ManyToOne(() => AppVersion, (appVersion) => appVersion.id, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'app_version_id' }) @JoinColumn({ name: 'app_version_id' })
appVersion: AppVersion; appVersion: AppVersion;

View file

@ -113,6 +113,10 @@ async function bootstrap() {
app.use(json({ limit: '50mb' })); app.use(json({ limit: '50mb' }));
app.use(urlencoded({ extended: true, limit: '50mb', parameterLimit: 1000000 })); app.use(urlencoded({ extended: true, limit: '50mb', parameterLimit: 1000000 }));
app.useStaticAssets(join(__dirname, 'assets'), { prefix: (UrlPrefix ? UrlPrefix : '/') + 'assets' }); app.useStaticAssets(join(__dirname, 'assets'), { prefix: (UrlPrefix ? UrlPrefix : '/') + 'assets' });
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: VERSION_NEUTRAL,
});
app.enableVersioning({ app.enableVersioning({
type: VersioningType.URI, type: VersioningType.URI,

View file

@ -2,6 +2,7 @@ import { User } from 'src/entities/user.entity';
import { AbilityBuilder, Ability, AbilityClass, ExtractSubjectType } from '@casl/ability'; import { AbilityBuilder, Ability, AbilityClass, ExtractSubjectType } from '@casl/ability';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { UsersService } from 'src/services/users.service'; import { UsersService } from 'src/services/users.service';
import { isEmpty } from 'lodash';
export enum Action { export enum Action {
ProxyPostgrest = 'proxyPostgrest', ProxyPostgrest = 'proxyPostgrest',
@ -24,7 +25,10 @@ export class TooljetDbAbilityFactory {
async actions(user: User, params: any) { async actions(user: User, params: any) {
const { can, build } = new AbilityBuilder<Ability<[Action, Subjects]>>(Ability as AbilityClass<TooljetDbAbility>); const { can, build } = new AbilityBuilder<Ability<[Action, Subjects]>>(Ability as AbilityClass<TooljetDbAbility>);
const isAdmin = await this.usersService.hasGroup(user, 'admin', params.oraganizationId); const { organizationId, dataQuery } = params;
const isPublicAppRequest = isEmpty(organizationId) && !isEmpty(dataQuery) && dataQuery.app.isPublic;
const isUserLoggedin = !isEmpty(user) && !isEmpty(organizationId);
const isAdmin = !isEmpty(user) ? await this.usersService.hasGroup(user, 'admin', params.organizationId) : false;
if (isAdmin) { if (isAdmin) {
can(Action.CreateTable, 'all'); can(Action.CreateTable, 'all');
@ -33,7 +37,11 @@ export class TooljetDbAbilityFactory {
can(Action.DropColumn, 'all'); can(Action.DropColumn, 'all');
can(Action.RenameTable, 'all'); can(Action.RenameTable, 'all');
} }
can(Action.ProxyPostgrest, 'all');
if (isPublicAppRequest || isUserLoggedin) {
can(Action.ProxyPostgrest, 'all');
}
can(Action.ViewTables, 'all'); can(Action.ViewTables, 'all');
can(Action.ViewTable, 'all'); can(Action.ViewTable, 'all');

View file

@ -3,17 +3,36 @@ import { Reflector } from '@nestjs/core';
import { TooljetDbAbility, TooljetDbAbilityFactory } from './abilities/tooljet-db-ability.factory'; import { TooljetDbAbility, TooljetDbAbilityFactory } from './abilities/tooljet-db-ability.factory';
import { CHECK_POLICIES_KEY } from './check_policies.decorator'; import { CHECK_POLICIES_KEY } from './check_policies.decorator';
import { PolicyHandler } from './policyhandler.interface'; import { PolicyHandler } from './policyhandler.interface';
import { isEmpty } from 'lodash';
import { EntityManager } from 'typeorm';
import { DataQuery } from 'src/entities/data_query.entity';
@Injectable() @Injectable()
export class TooljetDbGuard implements CanActivate { export class TooljetDbGuard implements CanActivate {
constructor(private reflector: Reflector, private tooljetDbAbilityFactory: TooljetDbAbilityFactory) {} constructor(
private reflector: Reflector,
private tooljetDbAbilityFactory: TooljetDbAbilityFactory,
private manager: EntityManager
) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const policyHandlers = this.reflector.get<PolicyHandler[]>(CHECK_POLICIES_KEY, context.getHandler()) || []; const policyHandlers = this.reflector.get<PolicyHandler[]>(CHECK_POLICIES_KEY, context.getHandler()) || [];
const { user, params } = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const dataQueryId = request.headers['data-query-id'];
const organizationId = request.headers['tj-workspace-id'] == 'null' ? null : request.headers['tj-workspace-id'];
const isPublicAppRequest = isEmpty(organizationId);
const ability = await this.tooljetDbAbilityFactory.actions(user, params); let dataQuery: DataQuery;
if (isPublicAppRequest && !isEmpty(dataQueryId)) {
dataQuery = await this.manager.findOne(DataQuery, {
where: { id: dataQueryId },
relations: ['apps'],
});
}
request.dataQuery = dataQuery;
const ability = await this.tooljetDbAbilityFactory.actions(request.user, { dataQuery, organizationId });
return policyHandlers.every((handler) => this.execPolicyHandler(handler, ability)); return policyHandlers.every((handler) => this.execPolicyHandler(handler, ability));
} }

View file

@ -0,0 +1,50 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ImportExportResourcesController } from '@controllers/import_export_resources.controller';
import { TooljetDbService } from '@services/tooljet_db.service';
import { ImportExportResourcesService } from '@services/import_export_resources.service';
import { AppImportExportService } from '@services/app_import_export.service';
import { TooljetDbImportExportService } from '@services/tooljet_db_import_export_service';
import { DataSourcesService } from '@services/data_sources.service';
import { AppEnvironmentService } from '@services/app_environments.service';
import { Plugin } from 'src/entities/plugin.entity';
import { PluginsHelper } from 'src/helpers/plugins.helper';
import { CredentialsService } from '@services/credentials.service';
import { DataSource } from 'src/entities/data_source.entity';
import { tooljetDbOrmconfig } from '../../../ormconfig';
import { PluginsModule } from '../plugins/plugins.module';
import { EncryptionService } from '@services/encryption.service';
import { Credential } from '../../../src/entities/credential.entity';
import { CaslModule } from '../casl/casl.module';
import { AppsService } from '@services/apps.service';
import { App } from 'src/entities/app.entity';
import { AppVersion } from 'src/entities/app_version.entity';
import { AppUser } from 'src/entities/app_user.entity';
const imports = [
PluginsModule,
CaslModule,
TypeOrmModule.forFeature([AppUser, AppVersion, App, Credential, Plugin, DataSource]),
];
if (process.env.ENABLE_TOOLJET_DB === 'true') {
imports.unshift(TypeOrmModule.forRoot(tooljetDbOrmconfig));
}
@Module({
imports,
controllers: [ImportExportResourcesController],
providers: [
EncryptionService,
ImportExportResourcesService,
AppImportExportService,
TooljetDbImportExportService,
DataSourcesService,
AppEnvironmentService,
TooljetDbService,
PluginsHelper,
AppsService,
CredentialsService,
],
})
export class ImportExportResourcesModule {}

File diff suppressed because it is too large Load diff

View file

@ -654,4 +654,22 @@ export class AppsService {
}; };
}); });
} }
async findTooljetDbTables(appId: string): Promise<{ table_id: string }[]> {
return await dbTransactionWrap(async (manager: EntityManager) => {
const tooljetDbDataQueries = await manager
.createQueryBuilder(DataQuery, 'data_queries')
.innerJoin(DataSource, 'data_sources', 'data_queries.data_source_id = data_sources.id')
.innerJoin(AppVersion, 'app_versions', 'app_versions.id = data_sources.app_version_id')
.where('app_versions.app_id = :appId', { appId })
.andWhere('data_sources.kind = :kind', { kind: 'tooljetdb' })
.getMany();
const uniqTableIds = [...new Set(tooljetDbDataQueries.map((dq) => dq.options['table_id']))];
return uniqTableIds.map((table_id) => {
return { table_id };
});
});
}
} }

View file

@ -0,0 +1,88 @@
import { Injectable } from '@nestjs/common';
import { User } from 'src/entities/user.entity';
import { ExportResourcesDto } from '@dto/export-resources.dto';
import { AppImportExportService } from './app_import_export.service';
import { TooljetDbImportExportService } from './tooljet_db_import_export_service';
import { ImportResourcesDto } from '@dto/import-resources.dto';
import { AppsService } from './apps.service';
import { CloneResourcesDto } from '@dto/clone-resources.dto';
import { isEmpty } from 'lodash';
@Injectable()
export class ImportExportResourcesService {
constructor(
private readonly appImportExportService: AppImportExportService,
private readonly appsService: AppsService,
private readonly tooljetDbImportExportService: TooljetDbImportExportService
) {}
async export(user: User, exportResourcesDto: ExportResourcesDto) {
const resourcesExport = {};
if (exportResourcesDto.tooljet_database) {
resourcesExport['tooljet_database'] = [];
for (const tjdb of exportResourcesDto.tooljet_database) {
!isEmpty(tjdb) &&
resourcesExport['tooljet_database'].push(
await this.tooljetDbImportExportService.export(exportResourcesDto.organization_id, tjdb)
);
}
}
if (exportResourcesDto.app) {
resourcesExport['app'] = [];
for (const app of exportResourcesDto.app) {
resourcesExport['app'].push({
definition: await this.appImportExportService.export(user, app.id, app.search_params),
});
}
}
return resourcesExport;
}
async import(user: User, importResourcesDto: ImportResourcesDto, cloning = false) {
const tableNameMapping = {};
const imports = { app: [], tooljet_database: [] };
if (importResourcesDto.tooljet_database) {
for (const tjdbImportDto of importResourcesDto.tooljet_database) {
const createdTable = await this.tooljetDbImportExportService.import(
importResourcesDto.organization_id,
tjdbImportDto,
cloning
);
tableNameMapping[tjdbImportDto.id] = createdTable;
imports.tooljet_database.push(createdTable);
}
}
if (importResourcesDto.app) {
for (const appImportDto of importResourcesDto.app) {
user.organizationId = importResourcesDto.organization_id;
const createdApp = await this.appImportExportService.import(user, appImportDto.definition, {
tooljet_database: tableNameMapping,
});
imports.app.push({ id: createdApp.id, name: createdApp.name });
}
}
return imports;
}
async clone(user: User, cloneResourcesDto: CloneResourcesDto) {
const tablesForApp = await this.appsService.findTooljetDbTables(cloneResourcesDto.app[0].id);
const exportResourcesDto = new ExportResourcesDto();
exportResourcesDto.organization_id = cloneResourcesDto.organization_id;
exportResourcesDto.app = [{ id: cloneResourcesDto.app[0].id, search_params: null }];
exportResourcesDto.tooljet_database = tablesForApp;
const resourceExport = await this.export(user, exportResourcesDto);
resourceExport['organization_id'] = cloneResourcesDto.organization_id;
const clonedResource = await this.import(user, resourceExport as ImportResourcesDto, true);
return clonedResource;
}
}

View file

@ -11,7 +11,8 @@ import { maybeSetSubPath } from '../helpers/utils.helper';
export class PostgrestProxyService { export class PostgrestProxyService {
constructor(private readonly manager: EntityManager, private readonly configService: ConfigService) {} constructor(private readonly manager: EntityManager, private readonly configService: ConfigService) {}
async perform(req, res, next, organizationId) { async perform(req, res, next) {
const organizationId = req.headers['tj-workspace-id'] || req.dataQuery?.app?.organizationId;
req.url = await this.replaceTableNamesAtPlaceholder(req, organizationId); req.url = await this.replaceTableNamesAtPlaceholder(req, organizationId);
const authToken = 'Bearer ' + this.signJwtPayload(this.configService.get<string>('PG_USER')); const authToken = 'Bearer ' + this.signJwtPayload(this.configService.get<string>('PG_USER'));
req.headers = {}; req.headers = {};
@ -25,8 +26,8 @@ export class PostgrestProxyService {
private httpProxy = proxy(this.configService.get<string>('PGRST_HOST'), { private httpProxy = proxy(this.configService.get<string>('PGRST_HOST'), {
proxyReqPathResolver: function (req) { proxyReqPathResolver: function (req) {
const path = '/api/tooljet_db/organizations'; const path = '/api/tooljet_db';
const pathRegex = new RegExp(`${maybeSetSubPath(path)}/.{36}/proxy`); const pathRegex = new RegExp(`${maybeSetSubPath(path)}/proxy`);
const parts = req.url.split('?'); const parts = req.url.split('?');
const queryString = parts[1]; const queryString = parts[1];
const updatedPath = parts[0].replace(pathRegex, ''); const updatedPath = parts[0].replace(pathRegex, '');

View file

@ -1,4 +1,4 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException, Optional } from '@nestjs/common';
import { EntityManager } from 'typeorm'; import { EntityManager } from 'typeorm';
import { InjectEntityManager } from '@nestjs/typeorm'; import { InjectEntityManager } from '@nestjs/typeorm';
import { InternalTable } from 'src/entities/internal_table.entity'; import { InternalTable } from 'src/entities/internal_table.entity';
@ -8,6 +8,7 @@ import { isString } from 'lodash';
export class TooljetDbService { export class TooljetDbService {
constructor( constructor(
private readonly manager: EntityManager, private readonly manager: EntityManager,
@Optional()
@InjectEntityManager('tooljetDb') @InjectEntityManager('tooljetDb')
private tooljetDbManager: EntityManager private tooljetDbManager: EntityManager
) {} ) {}
@ -34,30 +35,47 @@ export class TooljetDbService {
} }
private async viewTable(organizationId: string, params) { private async viewTable(organizationId: string, params) {
const { table_name: tableName } = params; const { table_name: tableName, id: id } = params;
const internalTable = await this.manager.findOne(InternalTable, { const internalTable = await this.manager.findOne(InternalTable, {
where: { organizationId, tableName }, where: {
organizationId,
...(tableName && { tableName }),
...(id && { id }),
},
}); });
if (!internalTable) throw new NotFoundException('Internal table not found: ' + tableName); if (!internalTable) throw new NotFoundException('Internal table not found: ' + tableName);
return await this.tooljetDbManager.query( return await this.tooljetDbManager.query(
`SELECT c.COLUMN_NAME, c.DATA_TYPE, c.Column_default, c.character_maximum_length, c.numeric_precision, c.is_nullable `
,CASE WHEN pk.COLUMN_NAME IS NOT NULL THEN 'PRIMARY KEY' ELSE '' END AS KeyType SELECT c.COLUMN_NAME, c.DATA_TYPE,
FROM INFORMATION_SCHEMA.COLUMNS c CASE
LEFT JOIN ( WHEN pk.CONSTRAINT_TYPE = 'PRIMARY KEY'
SELECT ku.TABLE_CATALOG,ku.TABLE_SCHEMA,ku.TABLE_NAME,ku.COLUMN_NAME THEN c.Column_default
FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS AS tc WHEN c.Column_default LIKE '%::%'
INNER JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE AS ku THEN replace(substring(c.Column_default from '^''?(.*?)''?::'), '''', '')
ON tc.CONSTRAINT_TYPE = 'PRIMARY KEY' ELSE c.Column_default
AND tc.CONSTRAINT_NAME = ku.CONSTRAINT_NAME END AS Column_default,
) pk c.character_maximum_length, c.numeric_precision, c.is_nullable,
ON c.TABLE_CATALOG = pk.TABLE_CATALOG pk.CONSTRAINT_TYPE,
AND c.TABLE_SCHEMA = pk.TABLE_SCHEMA CASE
AND c.TABLE_NAME = pk.TABLE_NAME WHEN pk.COLUMN_NAME IS NOT NULL THEN 'PRIMARY KEY'
AND c.COLUMN_NAME = pk.COLUMN_NAME ELSE ''
WHERE c.TABLE_NAME = '${internalTable.id}' END AS KeyType
ORDER BY c.TABLE_SCHEMA,c.TABLE_NAME, c.ORDINAL_POSITION; FROM INFORMATION_SCHEMA.COLUMNS c
LEFT JOIN (
SELECT ku.TABLE_CATALOG,ku.TABLE_SCHEMA,ku.TABLE_NAME,ku.COLUMN_NAME, tc.CONSTRAINT_TYPE
FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS AS tc
INNER JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE AS ku
ON tc.CONSTRAINT_NAME = ku.CONSTRAINT_NAME
) pk
ON c.TABLE_CATALOG = pk.TABLE_CATALOG
AND c.TABLE_SCHEMA = pk.TABLE_SCHEMA
AND c.TABLE_NAME = pk.TABLE_NAME
AND c.COLUMN_NAME = pk.COLUMN_NAME
WHERE c.TABLE_NAME = '${internalTable.id}'
ORDER BY c.TABLE_SCHEMA,c.TABLE_NAME, c.ORDINAL_POSITION;
` `
); );
} }
@ -65,7 +83,7 @@ export class TooljetDbService {
private async viewTables(organizationId: string) { private async viewTables(organizationId: string) {
return await this.manager.find(InternalTable, { return await this.manager.find(InternalTable, {
where: { organizationId }, where: { organizationId },
select: ['tableName'], select: ['id', 'tableName'],
order: { tableName: 'ASC' }, order: { tableName: 'ASC' },
}); });
} }
@ -76,28 +94,26 @@ export class TooljetDbService {
} }
private async createTable(organizationId: string, params) { private async createTable(organizationId: string, params) {
let primaryKeyExist = false;
// primary keys are only supported as serial type
params.columns = params.columns.map((column) => {
if (column['constraint_type'] === 'PRIMARY KEY') {
primaryKeyExist = true;
return { ...column, data_type: 'serial', column_default: null };
}
return column;
});
if (!primaryKeyExist) {
throw new BadRequestException();
}
const { const {
table_name: tableName, table_name: tableName,
columns: [column, ...restColumns], columns: [column, ...restColumns],
} = params; } = params;
// Validate first column -> should be primary key with name: id
if (
!(
column &&
column['column_name'] === 'id' &&
column['data_type'] === 'serial' &&
column['constraint'] === 'PRIMARY KEY'
)
) {
throw new BadRequestException();
}
// Validate other columns -> should not be a primary key
if (restColumns && restColumns.some((rc) => rc['constraint'] === 'PRIMARY_KEY')) {
throw new BadRequestException();
}
const queryRunner = this.manager.connection.createQueryRunner(); const queryRunner = this.manager.connection.createQueryRunner();
await queryRunner.connect(); await queryRunner.connect();
await queryRunner.startTransaction(); await queryRunner.startTransaction();
@ -111,14 +127,14 @@ export class TooljetDbService {
const createTableString = `CREATE TABLE "${internalTable.id}" `; const createTableString = `CREATE TABLE "${internalTable.id}" `;
let query = `${column['column_name']} ${column['data_type']}`; let query = `${column['column_name']} ${column['data_type']}`;
if (column['default']) query += ` DEFAULT ${this.addQuotesIfString(column['default'])}`; if (column['column_default']) query += ` DEFAULT ${this.addQuotesIfString(column['column_default'])}`;
if (column['constraint']) query += ` ${column['constraint']}`; if (column['constraint_type']) query += ` ${column['constraint_type']}`;
if (restColumns) if (restColumns)
for (const col of restColumns) { for (const col of restColumns) {
query += `, ${col['column_name']} ${col['data_type']}`; query += `, ${col['column_name']} ${col['data_type']}`;
if (col['default']) query += ` DEFAULT ${this.addQuotesIfString(col['default'])}`; if (col['column_default']) query += ` DEFAULT ${this.addQuotesIfString(col['column_default'])}`;
if (col['constraint']) query += ` ${col['constraint']}`; if (col['constraint_type']) query += ` ${col['constraint_type']}`;
} }
// if tooljetdb query fails in this connection, we must rollback internal table // if tooljetdb query fails in this connection, we must rollback internal table
@ -126,7 +142,7 @@ export class TooljetDbService {
await this.tooljetDbManager.query(createTableString + '(' + query + ');'); await this.tooljetDbManager.query(createTableString + '(' + query + ');');
await queryRunner.commitTransaction(); await queryRunner.commitTransaction();
return true; return { id: internalTable.id, table_name: tableName };
} catch (err) { } catch (err) {
await queryRunner.rollbackTransaction(); await queryRunner.rollbackTransaction();
throw err; throw err;
@ -194,7 +210,7 @@ export class TooljetDbService {
if (!internalTable) throw new NotFoundException('Internal table not found: ' + tableName); if (!internalTable) throw new NotFoundException('Internal table not found: ' + tableName);
let query = `ALTER TABLE "${internalTable.id}" ADD ${column['column_name']} ${column['data_type']}`; let query = `ALTER TABLE "${internalTable.id}" ADD ${column['column_name']} ${column['data_type']}`;
if (column['default']) query += ` DEFAULT ${this.addQuotesIfString(column['default'])}`; if (column['column_default']) query += ` DEFAULT ${this.addQuotesIfString(column['column_default'])}`;
if (column['constraint']) query += ` ${column['constraint']};`; if (column['constraint']) query += ` ${column['constraint']};`;
const result = await this.tooljetDbManager.query(query); const result = await this.tooljetDbManager.query(query);

View file

@ -0,0 +1,66 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ExportTooljetDatabaseDto } from '@dto/export-resources.dto';
import { ImportTooljetDatabaseDto } from '@dto/import-resources.dto';
import { TooljetDbService } from './tooljet_db.service';
import { EntityManager } from 'typeorm';
import { InternalTable } from 'src/entities/internal_table.entity';
@Injectable()
export class TooljetDbImportExportService {
constructor(private readonly tooljetDbService: TooljetDbService, private readonly manager: EntityManager) {}
async export(organizationId: string, tjDbDto: ExportTooljetDatabaseDto) {
const internalTable = await this.manager.findOne(InternalTable, {
where: { organizationId, id: tjDbDto.table_id },
});
if (!internalTable) throw new NotFoundException('Tooljet database table not found');
const columnSchema = await this.tooljetDbService.perform(organizationId, 'view_table', {
id: tjDbDto.table_id,
});
return {
id: internalTable.id,
table_name: internalTable.tableName,
schema: { columns: columnSchema },
};
}
async import(organizationId: string, tjDbDto: ImportTooljetDatabaseDto, cloning = false) {
const internalTableWithSameNameExists = await this.manager.findOne(InternalTable, {
where: {
tableName: tjDbDto.table_name,
organizationId,
},
});
if (
cloning &&
internalTableWithSameNameExists &&
(await this.isTableColumnsSubset(internalTableWithSameNameExists, tjDbDto))
)
return { id: internalTableWithSameNameExists.id, name: internalTableWithSameNameExists.tableName };
const tableName = internalTableWithSameNameExists
? `${tjDbDto.table_name}_${new Date().getTime()}`
: tjDbDto.table_name;
return await this.tooljetDbService.perform(organizationId, 'create_table', {
table_name: tableName,
...tjDbDto.schema,
});
}
async isTableColumnsSubset(internalTable: InternalTable, tjDbDto: ImportTooljetDatabaseDto): Promise<boolean> {
const dtoColumns = new Set<string>(tjDbDto.schema.columns.map((c) => c.column_name));
const internalTableColumnSchema = await this.tooljetDbService.perform(internalTable.organizationId, 'view_table', {
id: internalTable.id,
});
const internalTableColumns = new Set<string>(internalTableColumnSchema.map((c) => c.column_name));
const isSubset = (subset: Set<string>, superset: Set<string>) => [...subset].every((item) => superset.has(item));
return isSubset(dtoColumns, internalTableColumns);
}
}

View file

@ -102,6 +102,8 @@ describe('AppImportExportService', () => {
}); });
const dataQuery1 = await createDataQuery(nestApp, { const dataQuery1 = await createDataQuery(nestApp, {
dataSource: dataSource1, dataSource: dataSource1,
appVersion: appVersion1,
name: 'test_query_1',
kind: 'test_kind', kind: 'test_kind',
}); });
@ -115,6 +117,7 @@ describe('AppImportExportService', () => {
name: 'test_name_2', name: 'test_name_2',
}); });
const dataQuery2 = await createDataQuery(nestApp, { const dataQuery2 = await createDataQuery(nestApp, {
appVersion: appVersion2,
dataSource: dataSource2, dataSource: dataSource2,
name: 'test_query_2', name: 'test_query_2',
}); });
@ -123,7 +126,7 @@ describe('AppImportExportService', () => {
where: { id: application.id }, where: { id: application.id },
}); });
let { appV2: result } = await service.export(adminUser, exportedApp.id, { versionId: appVersion1.id }); let { appV2: result } = await service.export(adminUser, exportedApp.id, { version_id: appVersion1.id });
expect(result.id).toBe(exportedApp.id); expect(result.id).toBe(exportedApp.id);
expect(result.name).toBe(exportedApp.name); expect(result.name).toBe(exportedApp.name);
@ -137,7 +140,7 @@ describe('AppImportExportService', () => {
expect(result.appVersions.length).toBe(1); expect(result.appVersions.length).toBe(1);
expect(result.appVersions[0].name).toEqual(appVersion1.name); expect(result.appVersions[0].name).toEqual(appVersion1.name);
const res = await service.export(adminUser, exportedApp.id, { versionId: appVersion2.id }); const res = await service.export(adminUser, exportedApp.id, { version_id: appVersion2.id });
result = res.appV2; result = res.appV2;
expect(result.id).toBe(exportedApp.id); expect(result.id).toBe(exportedApp.id);
@ -246,6 +249,7 @@ describe('AppImportExportService', () => {
//create default dataQuery //create default dataQuery
await createDataQuery(nestApp, { await createDataQuery(nestApp, {
dataSource: firstDs, dataSource: firstDs,
appVersion: applicationVersion,
options: {}, options: {},
}); });
@ -263,11 +267,9 @@ describe('AppImportExportService', () => {
const appVersion = importedApp.appVersions[0]; const appVersion = importedApp.appVersions[0];
expect(appVersion.appId).toEqual(importedApp.id); expect(appVersion.appId).toEqual(importedApp.id);
const dataSource = importedApp['dataSources'].reverse()[0];
expect(dataSource['appVersionId']).toEqual(appVersion.id);
const dataQuery = importedApp['dataQueries'][0]; const dataQuery = importedApp['dataQueries'][0];
expect(dataQuery['dataSourceId']).toEqual(dataSource.id); const dataSourceForTheDataQuery = importedApp['dataSources'].find((ds) => ds.id === dataQuery.dataSourceId);
expect(dataSourceForTheDataQuery).toBeDefined();
// assert all fields except primary keys, foreign keys and timestamps are same // assert all fields except primary keys, foreign keys and timestamps are same
const deleteFieldsNotToCheck = (entity) => { const deleteFieldsNotToCheck = (entity) => {
@ -287,10 +289,9 @@ describe('AppImportExportService', () => {
const importedDataQueries = importedApp['dataQueries'].map((query) => deleteFieldsNotToCheck(query)); const importedDataQueries = importedApp['dataQueries'].map((query) => deleteFieldsNotToCheck(query));
const exportedDataQueries = exportedApp['dataQueries'].map((query) => deleteFieldsNotToCheck(query)); const exportedDataQueries = exportedApp['dataQueries'].map((query) => deleteFieldsNotToCheck(query));
expect(importedAppVersions).toEqual(exportedAppVersions); expect(new Set(importedAppVersions)).toEqual(new Set(exportedAppVersions));
console.log('inside', importedDataSources, exportedDataSources); expect(new Set(importedDataSources)).toEqual(new Set(exportedDataSources));
expect(importedDataSources).toEqual(exportedDataSources); expect(new Set(importedDataQueries)).toEqual(new Set(exportedDataQueries));
expect(importedDataQueries).toEqual(exportedDataQueries);
// assert group permissions are valid // assert group permissions are valid
const appGroupPermissions = await getManager().find(AppGroupPermission, { const appGroupPermissions = await getManager().find(AppGroupPermission, {