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
.idea/*
ti-*

View file

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

View file

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

View file

@ -2,7 +2,6 @@ import React from 'react';
import cx from 'classnames';
import { SketchPicker } from 'react-color';
import { Confirm } from '../Viewer/Confirm';
import { HeaderSection } from '@/_ui/LeftSidebar';
import { LeftSidebarItem } from '../LeftSidebar/SidebarItem';
import FxButton from '../CodeBuilder/Elements/FxButton';
import { CodeHinter } from '../CodeBuilder/CodeHinter';
@ -10,6 +9,7 @@ import { resolveReferences } from '@/_helpers/utils';
import { useTranslation } from 'react-i18next';
import _ from 'lodash';
import Popover from '@/_ui/Popover';
import ExportAppModal from '../../HomePage/ExportAppModal';
import { useCurrentState } from '@/_stores/currentStateStore';
import { useAppVersionStore } from '@/_stores/appVersionStore';
import { shallow } from 'zustand/shallow';
@ -20,6 +20,7 @@ export const GlobalSettings = ({
darkMode,
toggleAppMaintenance,
is_maintenance_on,
app,
}) => {
const { t } = useTranslation();
const { hideHeader, canvasMaxWidth, canvasMaxWidthType, canvasBackgroundColor, backgroundFxQuery } = globalSettings;
@ -29,6 +30,7 @@ export const GlobalSettings = ({
const [realState, setRealState] = React.useState(currentState);
const [showConfirmation, setConfirmationShow] = React.useState(false);
const [show, setShow] = React.useState('');
const [isExportingApp, setIsExportingApp] = React.useState(false);
const { isVersionReleased } = useAppVersionStore(
(state) => ({
isVersionReleased: state.isVersionReleased,
@ -58,16 +60,13 @@ export const GlobalSettings = ({
const popoverContent = (
<div id="global-settings-popover" className={cx({ 'theme-dark': darkMode, disabled: isVersionReleased })}>
<div bsPrefix="global-settings-popover">
<HeaderSection darkMode={darkMode}>
<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`}>
{t('leftSidebar.Settings.hideHeader', 'Hide header for launched apps')}
</span>
<div className="ms-auto form-check form-switch position-relative">
<div className="form-check form-switch">
<input
data-cy={`toggle-hide-header-for-launched-apps`}
className="form-check-input"
@ -76,12 +75,15 @@ export const GlobalSettings = ({
onChange={(e) => globalSettingsChanged('hideHeader', e.target.checked)}
/>
</div>
<span className="global-popover-text">
{t('leftSidebar.Settings.hideHeader', 'Hide header for launched apps')}
</span>
</div>
<div className="d-flex mb-3">
<div className="d-flex justify-content-start">
<span data-cy={`label-maintenance-mode`}>
{t('leftSidebar.Settings.maintenanceMode', 'Maintenance mode')}
</span>
<div className="ms-auto form-check form-switch position-relative">
<div className="form-check form-switch">
<input
data-cy={`toggle-maintenance-mode`}
className="form-check-input"
@ -90,17 +92,20 @@ export const GlobalSettings = ({
onChange={() => setConfirmationShow(true)}
/>
</div>
<span className="global-popover-text">
{t('leftSidebar.Settings.maintenanceMode', 'Maintenance mode')}
</span>
</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">
{t('leftSidebar.Settings.maxWidthOfCanvas', 'Max width of canvas')}
</span>
<div className="position-relative">
<div className="global-popover-div-wrap global-popover-div-wrap-width">
<div className="input-with-icon">
<input
data-cy="maximum-canvas-width-input-field"
type="text"
className={`form-control form-control-sm`}
className={`form-control form-control-sm maximum-canvas-width-input-field`}
placeholder={'0'}
onChange={(e) => {
const width = e.target.value;
@ -109,8 +114,8 @@ export const GlobalSettings = ({
value={canvasMaxWidth}
/>
<select
className="maximum-canvas-width-input-select"
data-cy={`dropdown-max-canvas-width-type`}
className="form-select"
aria-label="Select canvas width type"
onChange={(event) => {
const newCanvasMaxWidthType = event.currentTarget.value;
@ -136,7 +141,7 @@ export const GlobalSettings = ({
<span className="w-full m-auto" data-cy={`label-max-canvas-height`}>
{t('leftSidebar.Settings.maxHeightOfCanvas', 'Max height of canvas')}
</span>
<div className="position-relative">
<div className="global-popover-div-wrap global-popover-div-wrap-width">
<div className="input-with-icon">
<input
data-cy="maximum-canvas-height-input-field"
@ -149,7 +154,6 @@ export const GlobalSettings = ({
}}
value={canvasMaxHeight}
/>
<span className="input-group-text">px</span>
</div>
</div>
</div> */}
@ -175,7 +179,7 @@ export const GlobalSettings = ({
)}
{forceCodeBox && (
<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)}
>
<div
@ -183,15 +187,13 @@ export const GlobalSettings = ({
className="col-auto"
style={{
float: 'right',
width: '20px',
height: '20px',
width: '13.33px',
height: '13.33px',
backgroundColor: canvasBackgroundColor,
border: `0.25px solid ${
['#ffffff', '#fff', '#1f2936'].includes(canvasBackgroundColor) && '#c5c8c9'
}`,
borderRadius: '4px',
}}
></div>
<div className="col">{canvasBackgroundColor}</div>
<div className="">{canvasBackgroundColor}</div>
</div>
)}
<div
@ -225,6 +227,26 @@ export const GlobalSettings = ({
</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>
@ -244,6 +266,18 @@ export const GlobalSettings = ({
onCancel={() => setConfirmationShow(false)}
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
handleToggle={(show) => {
if (show) setShow('settings');

View file

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

View file

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

View file

@ -8,16 +8,16 @@ export const tooljetDbOperations = {
perform,
};
async function perform(queryOptions, organizationId, currentState) {
switch (queryOptions.operation) {
async function perform(dataQuery, currentState) {
switch (dataQuery.options.operation) {
case 'list_rows':
return listRows(queryOptions, organizationId, currentState);
return listRows(dataQuery, currentState);
case 'create_row':
return createRow(queryOptions, organizationId, currentState);
return createRow(dataQuery, currentState);
case 'update_rows':
return updateRows(queryOptions, organizationId, currentState);
return updateRows(dataQuery, currentState);
case 'delete_rows':
return deleteRows(queryOptions, organizationId, currentState);
return deleteRows(dataQuery, currentState);
default:
return {
@ -52,8 +52,8 @@ function buildPostgrestQuery(filters) {
return postgrestQueryBuilder.url.toString();
}
async function listRows(queryOptions, organizationId, currentState) {
let query = [];
async function listRows(dataQuery, currentState) {
const queryOptions = dataQuery.options;
const resolvedOptions = resolveReferences(queryOptions, currentState);
if (hasEqualWithNull(resolvedOptions, 'list_rows')) {
return {
@ -64,7 +64,8 @@ async function listRows(queryOptions, organizationId, currentState) {
data: {},
};
}
const { table_name: tableName, list_rows: listRows } = resolvedOptions;
const { table_id: tableId, list_rows: listRows } = resolvedOptions;
let query = [];
if (!isEmpty(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(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 columns = Object.values(resolvedOptions.create_row).reduce((acc, colOpts) => {
if (isEmpty(colOpts.column)) return acc;
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);
if (hasEqualWithNull(resolvedOptions, 'update_rows')) {
return {
@ -109,7 +114,7 @@ async function updateRows(queryOptions, organizationId, currentState) {
data: {},
};
}
const { table_name: tableName, update_rows: updateRows } = resolvedOptions;
const { table_id: tableId, update_rows: updateRows } = resolvedOptions;
const { where_filters: whereFilters, columns } = updateRows;
let query = [];
@ -121,10 +126,12 @@ async function updateRows(queryOptions, organizationId, currentState) {
!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);
if (hasEqualWithNull(resolvedOptions, 'delete_rows')) {
return {
@ -135,7 +142,7 @@ async function deleteRows(queryOptions, organizationId, currentState) {
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;
let query = [];
@ -163,5 +170,6 @@ async function deleteRows(queryOptions, organizationId, currentState) {
!isEmpty(whereQuery) && query.push(whereQuery);
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 (
<div className="viewer wrapper">
<Confirm
darkMode={this.props.darkMode}
show={queryConfirmationList.length > 0}
message={'Do you want to run this query?'}
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 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 element = document.getElementsByClassName('form-control-plaintext form-control-plaintext-sm')[0];

View file

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

View file

@ -14,7 +14,7 @@ import HomeHeader from './Header';
import Modal from './Modal';
import configs from './Configs/AppIcon.json';
import { withTranslation } from 'react-i18next';
import { sample } from 'lodash';
import { sample, isEmpty } from 'lodash';
import ExportAppModal from './ExportAppModal';
import Footer from './Footer';
import { OrganizationList } from '@/_components/OrganizationManager/List';
@ -141,11 +141,11 @@ class HomePageComponent extends React.Component {
cloneApp = (app) => {
this.setState({ isCloningApp: true });
appService
.cloneApp(app.id)
.cloneResource({ app: [{ id: app.id }], organization_id: getWorkspaceId() })
.then((data) => {
toast.success('App cloned successfully.');
this.setState({ isCloningApp: false });
this.props.navigate(`/${getWorkspaceId()}/apps/${data.id}`);
this.props.navigate(`/${getWorkspaceId()}/apps/${data.imports.app[0].id}`);
})
.catch(({ _error }) => {
toast.error('Could not clone the app.');
@ -165,24 +165,35 @@ class HomePageComponent extends React.Component {
const fileContent = event.target.result;
this.setState({ isImportingApp: true });
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
.importApp(requestBody)
.importResource(requestBody)
.then((data) => {
toast.success('App imported successfully.');
toast.success('Imported successfully.');
this.setState({
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 }) => {
toast.error(`Could not import the app: ${error}`);
toast.error(`Could not import: ${error}`);
this.setState({
isImportingApp: false,
});
});
} catch (error) {
toast.error(`Could not import the app: ${error}`);
toast.error(`Could not import: ${error}`);
this.setState({
isImportingApp: false,
});

View file

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

View file

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

View file

@ -27,7 +27,7 @@ export default function CreateTableDrawer() {
</div>
<Drawer isOpen={isCreateTableDrawerOpen} onClose={() => setIsCreateTableDrawerOpen(false)} position="right">
<CreateTableForm
onCreate={(tableName) => {
onCreate={(tableInfo) => {
tooljetDatabaseService.findAll(organizationId).then(({ data = [], error }) => {
if (error) {
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) {
setSelectedTable({ table_name: tableInfo.table_name, id: tableInfo.id });
updateSidebarNAV(tableInfo.table_name);
setTables(data.result || []);
setSelectedTable(tableName);
updateSidebarNAV(tableName);
}
});
setIsCreateTableDrawerOpen(false);

View file

@ -30,9 +30,9 @@ const EditRowDrawer = ({ isCreateRowDrawerOpen, setIsCreateRowDrawerOpen }) => {
<Drawer isOpen={isCreateRowDrawerOpen} onClose={() => setIsCreateRowDrawerOpen(false)} position="right">
<EditRowForm
onEdit={() => {
tooljetDatabaseService.findOne(organizationId, selectedTable).then(({ headers, data = [], error }) => {
tooljetDatabaseService.findOne(organizationId, selectedTable.id).then(({ headers, data = [], error }) => {
if (error) {
toast.error(error?.message ?? `Failed to fetch table "${selectedTable}"`);
toast.error(error?.message ?? `Failed to fetch table "${selectedTable.table_name}"`);
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(
organizationId,
selectedTable,
selectedTable.table_name,
columnName,
dataType,
defaultValue
@ -45,7 +45,7 @@ const ColumnForm = ({ onCreate, onClose }) => {
setFetching(false);
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;
}

View file

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

View file

@ -49,10 +49,10 @@ const EditRowForm = ({ onEdit, onClose }) => {
const handleSubmit = async () => {
setFetching(true);
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) {
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;
}
setFetching(false);

View file

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

View file

@ -34,7 +34,7 @@ const RowForm = ({ onCreate, onClose }) => {
const handleSubmit = async () => {
setFetching(true);
const { error } = await tooljetDatabaseService.createRow(organizationId, selectedTable, data);
const { error } = await tooljetDatabaseService.createRow(organizationId, selectedTable.id, data);
setFetching(false);
if (error) {
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';
const TableForm = ({
selectedTable = '',
selectedColumns = { 0: { column_name: 'id', data_type: 'serial', constraint: 'PRIMARY KEY' } },
selectedTable = {},
selectedColumns = { 0: { column_name: 'id', data_type: 'serial', constraint_type: 'PRIMARY KEY' } },
onCreate,
onEdit,
onClose,
updateSelectedTable,
}) => {
const [fetching, setFetching] = useState(false);
const [tableName, setTableName] = useState(selectedTable);
const [tableName, setTableName] = useState(selectedTable.table_name);
const [columns, setColumns] = useState(selectedColumns);
const { organizationId } = useContext(TooljetDatabaseContext);
const isEditMode = !isEmpty(selectedTable);
@ -56,7 +56,7 @@ const TableForm = ({
}
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);
if (error) {
toast.error(error?.message ?? `Failed to create a new table "${tableName}"`);
@ -64,14 +64,14 @@ const TableForm = ({
}
toast.success(`${tableName} created successfully`);
onCreate && onCreate(tableName);
onCreate && onCreate({ id: data.result.id, table_name: tableName });
};
const handleEdit = async () => {
if (!validateTableName()) return;
setFetching(true);
const { error } = await tooljetDatabaseService.renameTable(organizationId, selectedTable, tableName);
const { error } = await tooljetDatabaseService.renameTable(organizationId, selectedTable.table_name, tableName);
setFetching(false);
if (error) {
@ -81,7 +81,7 @@ const TableForm = ({
toast.success(`${tableName} edited successfully`);
updateSidebarNAV(tableName);
updateSelectedTable(tableName);
updateSelectedTable({ ...selectedTable, table_name: tableName });
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 { useTable, useRowSelect } from 'react-table';
import { isBoolean } from 'lodash';
import { isBoolean, isEmpty } from 'lodash';
import { tooljetDatabaseService } from '@/_services';
import { TooljetDatabaseContext } from '../index';
import { toast } from 'react-hot-toast';
@ -29,26 +29,31 @@ const Table = ({ openCreateRowDrawer, openCreateColumnDrawer }) => {
const [isEditColumnDrawerOpen, setIsEditColumnDrawerOpen] = useState(false);
const [selectedColumn, setSelectedColumn] = useState();
const [loading, setLoading] = useState(false);
const prevSelectedTableRef = useRef({});
const fetchTableMetadata = () => {
tooljetDatabaseService.viewTable(organizationId, selectedTable).then(({ data = [], error }) => {
if (error) {
toast.error(error?.message ?? `Error fetching metadata for table "${selectedTable}"`);
return;
}
if (!isEmpty(selectedTable)) {
tooljetDatabaseService.viewTable(organizationId, selectedTable.table_name).then(({ data = [], error }) => {
if (error) {
toast.error(error?.message ?? `Error fetching metadata for table "${selectedTable.table_name}"`);
return;
}
if (data?.result?.length > 0) {
setColumns(
data?.result.map(({ column_name, data_type, keytype, ...rest }) => ({
Header: column_name,
accessor: column_name,
dataType: data_type,
isPrimaryKey: keytype?.toLowerCase() === 'primary key',
...rest,
}))
);
}
});
if (data?.result?.length > 0) {
setColumns(
data?.result.map(({ column_name, data_type, keytype, ...rest }) => ({
Header: column_name,
accessor: column_name,
dataType: data_type,
isPrimaryKey: keytype?.toLowerCase() === 'primary key',
...rest,
}))
);
}
});
} else {
setColumns([]);
}
};
const fetchTableData = (queryParams = '', pagesize = 50, pagecount = 1) => {
@ -56,10 +61,10 @@ const Table = ({ openCreateRowDrawer, openCreateColumnDrawer }) => {
let params = queryParams ? queryParams : defaultQueryParams;
setLoading(true);
tooljetDatabaseService.findOne(organizationId, selectedTable, params).then(({ headers, data = [], error }) => {
tooljetDatabaseService.findOne(organizationId, selectedTable.id, params).then(({ headers, data = [], error }) => {
setLoading(false);
if (error) {
toast.error(error?.message ?? `Error fetching table "${selectedTable}" data`);
toast.error(error?.message ?? `Error fetching table "${selectedTable.table_name}" data`);
return;
}
const totalContentRangeRecords = headers['content-range'].split('/')[1] || 0;
@ -76,9 +81,10 @@ const Table = ({ openCreateRowDrawer, openCreateColumnDrawer }) => {
};
useEffect(() => {
if (selectedTable) {
if (prevSelectedTableRef.current.id !== selectedTable.id && !isEmpty(selectedTable)) {
onSelectedTableChange();
}
prevSelectedTableRef.current = selectedTable;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTable]);
@ -163,14 +169,14 @@ const Table = ({ openCreateRowDrawer, openCreateColumnDrawer }) => {
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) {
toast.error(error?.message ?? `Error deleting rows from table "${selectedTable}"`);
toast.error(error?.message ?? `Error deleting rows from table "${selectedTable.table_name}"`);
return;
}
toast.success(`Deleted ${selectedRows.length} rows from table "${selectedTable}"`);
toast.success(`Deleted ${selectedRows.length} rows from table "${selectedTable.table_name}"`);
fetchTableData();
}
};
@ -178,13 +184,13 @@ const Table = ({ openCreateRowDrawer, openCreateColumnDrawer }) => {
const handleDeleteColumn = async (columnName) => {
const shouldDelete = confirm(`Are you sure you want to delete the column "${columnName}"?`);
if (shouldDelete) {
const { error } = await tooljetDatabaseService.deleteColumn(organizationId, selectedTable, columnName);
const { error } = await tooljetDatabaseService.deleteColumn(organizationId, selectedTable.table_name, columnName);
if (error) {
toast.error(error?.message ?? `Error deleting column "${columnName}" from table "${selectedTable}"`);
return;
}
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;
}
if (Array.isArray(data?.result)) {
if (!isEmpty(data?.result)) {
setTables(data.result || []);
setSelectedTable(data?.result[0]?.table_name);
updateSidebarNAV(data?.result[0]?.table_name);
setSelectedTable({ table_name: data.result[0].table_name, id: data.result[0].id });
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
}, []);
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];
if (!isEmpty(searchParam)) {
@ -78,14 +88,14 @@ const List = () => {
<div className="list-group mb-3">
{loading && <Skeleton count={3} height={22} />}
{!loading &&
filteredTables?.map(({ table_name }, index) => (
filteredTables?.map(({ id, table_name }, index) => (
<ListItem
key={index}
active={table_name === selectedTable}
active={id === selectedTable.id}
text={table_name}
onDeleteCallback={fetchTables}
onClick={() => {
setSelectedTable(table_name);
setSelectedTable({ table_name, id });
updateSidebarNAV(table_name);
}}
/>

View file

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

View file

@ -9,6 +9,10 @@ import Sort from '../Sort';
import Sidebar from '../Sidebar';
import { TooljetDatabaseContext } from '../index';
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 {
@ -22,6 +26,7 @@ const TooljetDatabasePage = ({ totalTables }) => {
setQueryFilters,
sortFilters,
setSortFilters,
organizationId,
} = useContext(TooljetDatabaseContext);
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 (
<div className="row gx-0">
<Sidebar />
<div className={cx('col animation-fade database-page-content-wrap')}>
{totalTables === 0 && <EmptyState />}
{selectedTable && (
{!isEmpty(selectedTable) && (
<>
<div className="database-table-header-wrapper">
<div className="card border-0">
<div className="card-body tj-db-operaions-header">
<div className="row align-items-center">
<div className="col">
<div className="col d-flex">
<CreateColumnDrawer
isCreateColumnDrawerOpen={isCreateColumnDrawerOpen}
setIsCreateColumnDrawerOpen={setIsCreateColumnDrawerOpen}
@ -82,6 +113,7 @@ const TooljetDatabasePage = ({ totalTables }) => {
handleBuildSortQuery={handleBuildSortQuery}
resetSortQuery={resetSortQuery}
/>
<ExportSchema onClick={exportTable} />
<CreateRowDrawer
isCreateRowDrawerOpen={isCreateRowDrawerOpen}
setIsCreateRowDrawerOpen={setIsCreateRowDrawerOpen}

View file

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

View file

@ -32,7 +32,7 @@ export const usePostgrestQueryBuilder = ({ organizationId, selectedTable, setSel
'&' +
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) {
toast.error(error?.message ?? 'Something went wrong');
@ -83,6 +83,7 @@ export const usePostgrestQueryBuilder = ({ organizationId, selectedTable, setSel
};
const resetAll = () => {
console.log('resetAll');
postgrestQueryBuilder.current.sortQuery = new PostgrestQueryBuilder();
postgrestQueryBuilder.current.paginationQuery.limit(50);

View file

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

View file

@ -8,6 +8,9 @@ export const appService = {
cloneApp,
exportApp,
importApp,
exportResource,
importResource,
cloneResource,
changeIcon,
deleteApp,
getApp,
@ -22,6 +25,7 @@ export const appService = {
setPasswordFromToken,
acceptInvite,
getVersions,
getTables,
};
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) {
const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include' };
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);
}
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) {
const requestOptions = {
method: 'PUT',

View file

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

View file

@ -1307,15 +1307,17 @@ button {
}
.fx-button {
font-weight: 400;
font-size: 13px;
color: #61656c;
color: #3E63DD;
font-family: 'IBM Plex Mono';
font-style: italic;
font-weight: 500;
font-size: 12px;
line-height: 20px;
}
.fx-button:hover,
.fx-button.active {
font-weight: 600;
color: $primary-light;
cursor: pointer;
}
@ -1729,7 +1731,6 @@ button {
.select-search-dark__input {
display: block;
width: 100%;
// padding: 0.4375rem 0.75rem;
font-size: 0.875rem;
font-weight: 400;
line-height: 1.4285714;
@ -4536,10 +4537,8 @@ input[type="text"] {
.canvas-background-holder {
display: flex;
justify-content: space-between;
min-width: 120px;
margin: auto;
padding: 10px;
}
.canvas-background-picker {
@ -4792,8 +4791,30 @@ input[type="text"] {
background-color: $bg-dark-light !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 {
background-color: $bg-dark-light !important;
background-color: $bg-dark-light !important;
color: $white !important;
border-bottom: 2px solid #3A3F42 !important;
}
.btn-close {
@ -5225,22 +5246,23 @@ div#driver-page-overlay {
.canvas-codehinter-container {
display: flex;
flex-direction: row;
width: 158px;
height: 32px;
}
.hinter-canvas-input {
display: flex;
width: 120px;
height: 32px;
margin-top: 1px;
.canvas-hinter-wrap {
width: 135px;
height: 42px !important;
width: 120px;
height: 32px;
}
}
.hinter-canvas-input {
width: 180px !important;
display: flex;
padding: 4px;
height: 41.2px !important;
margin-top: 1px;
.CodeMirror-sizer {
border-right-width: 1px !important;
}
@ -5253,35 +5275,37 @@ div#driver-page-overlay {
.canvas-codehinter-container {
.code-hinter-col {
margin-bottom: 1px !important;
width: 136px;
height: 32px;
}
}
.fx-canvas {
background: #1c252f;
padding: 2px;
display: flex;
height: 41px;
border: solid 1px rgba(255, 255, 255, 0.09) !important;
border-radius: 4px;
justify-content: center;
font-weight: 400;
align-items: center;
div {
background: #1c252f !important;
width: 35px !important;
background: #121212 !important;
display: flex;
justify-content: center;
align-items: center;
height: 36px;
width: 39px;
height: 32px;
}
.code-hinter-wrapper {
width: 120px !important;
height: 32px;
}
}
.fx-canvas-light {
background: #f4f6fa !important;
border: 1px solid #dadcde !important;
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 {
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 {
.btn-close {
top: auto;
@ -6936,16 +6984,66 @@ tbody {
overflow: auto;
}
.export-creation-date {
color: var(--slate11);
}
.modal-footer,
.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 {
display: flex;
justify-content: flex-start;
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 {
width: 90px;
width: 156px;
height: 32px;
padding: 6px 10px;
gap: 17px;
background: #FFFFFF;
border: 1px solid #D7DBDF;
border-radius: 6px;
}
.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 {
width: 100%;
position: absolute;
@ -10926,10 +11191,6 @@ tbody {
}
}
.confirm-dialogue-modal {
background: var(--base);
}
.theme-dark {
.icon-widget-popover {
.search-box-wrapper input {
@ -10960,20 +11221,8 @@ tbody {
}
}
.workspace-folder-modal {
.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;
}
.confirm-dialogue-modal {
background: var(--base);
}
.table-editor-component-row {
@ -11230,4 +11479,4 @@ tbody {
background-color: #F1F3F5;
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 { RequestContextModule } from './modules/request_context/request-context.module';
import { ScheduleModule } from '@nestjs/schedule';
import { ImportExportResourcesModule } from './modules/import_export_resources/import_export_resources.module';
const imports = [
ScheduleModule.forRoot(),
@ -97,6 +98,7 @@ const imports = [
PluginsModule,
EventsModule,
AppEnvironmentsModule,
ImportExportResourcesModule,
CopilotModule,
OrganizationConstantModule,
];

View file

@ -355,4 +355,18 @@ export class AppsController {
const appUser = await this.appsService.update(app.id, appUpdateDto);
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 { TooljetDbGuard } from 'src/modules/casl/tooljet-db.guard';
import { CreatePostgrestTableDto, RenamePostgrestTableDto, PostgrestTableColumnDto } from '@dto/tooljet-db.dto';
import { OrganizationAuthGuard } from 'src/modules/auth/organization-auth.guard';
@Controller('tooljet_db/organizations')
@UseGuards(JwtAuthGuard, ActiveWorkspaceGuard)
@Controller('tooljet_db')
export class TooljetDbController {
constructor(
private readonly tooljetDbService: TooljetDbService,
private readonly postgrestProxyService: PostgrestProxyService
) {}
@All('/:organizationId/proxy/*')
@All('/proxy/*')
@UseGuards(OrganizationAuthGuard, TooljetDbGuard)
@CheckPolicies((ability: TooljetDbAbility) => ability.can(Action.ProxyPostgrest, 'all'))
async proxy(@Req() req, @Res() res, @Next() next, @Param('organizationId') organizationId) {
return this.postgrestProxyService.perform(req, res, next, organizationId);
async proxy(@Req() req, @Res() res, @Next() next) {
return this.postgrestProxyService.perform(req, res, next);
}
@Get('/:organizationId/tables')
@UseGuards(TooljetDbGuard)
@Get('/organizations/:organizationId/tables')
@UseGuards(JwtAuthGuard, ActiveWorkspaceGuard, TooljetDbGuard)
@CheckPolicies((ability: TooljetDbAbility) => ability.can(Action.ViewTables, 'all'))
async tables(@Param('organizationId') organizationId) {
const result = await this.tooljetDbService.perform(organizationId, 'view_tables');
return decamelizeKeys({ result });
}
@Get('/:organizationId/table/:tableName')
@UseGuards(TooljetDbGuard)
@Get('/organizations/:organizationId/table/:tableName')
@UseGuards(JwtAuthGuard, ActiveWorkspaceGuard, TooljetDbGuard)
@CheckPolicies((ability: TooljetDbAbility) => ability.can(Action.ViewTable, 'all'))
async table(@Body() body, @Param('organizationId') organizationId, @Param('tableName') tableName) {
const result = await this.tooljetDbService.perform(organizationId, 'view_table', { table_name: tableName });
return decamelizeKeys({ result });
}
@Post('/:organizationId/table')
@UseGuards(TooljetDbGuard)
@Post('/organizations/:organizationId/table')
@UseGuards(JwtAuthGuard, ActiveWorkspaceGuard, TooljetDbGuard)
@CheckPolicies((ability: TooljetDbAbility) => ability.can(Action.CreateTable, 'all'))
async createTable(@Body() createTableDto: CreatePostgrestTableDto, @Param('organizationId') organizationId) {
const result = await this.tooljetDbService.perform(organizationId, 'create_table', createTableDto);
return decamelizeKeys({ result });
}
@Patch('/:organizationId/table/:tableName')
@UseGuards(TooljetDbGuard)
@Patch('/organizations/:organizationId/table/:tableName')
@UseGuards(JwtAuthGuard, ActiveWorkspaceGuard, TooljetDbGuard)
@CheckPolicies((ability: TooljetDbAbility) => ability.can(Action.RenameTable, 'all'))
async renameTable(@Body() renameTableDto: RenamePostgrestTableDto, @Param('organizationId') organizationId) {
const result = await this.tooljetDbService.perform(organizationId, 'rename_table', renameTableDto);
return decamelizeKeys({ result });
}
@Delete('/:organizationId/table/:tableName')
@UseGuards(TooljetDbGuard)
@Delete('/organizations/:organizationId/table/:tableName')
@UseGuards(JwtAuthGuard, ActiveWorkspaceGuard, TooljetDbGuard)
@CheckPolicies((ability: TooljetDbAbility) => ability.can(Action.DropTable, 'all'))
async dropTable(@Param('organizationId') organizationId, @Param('tableName') tableName) {
const result = await this.tooljetDbService.perform(organizationId, 'drop_table', { table_name: tableName });
return decamelizeKeys({ result });
}
@Post('/:organizationId/table/:tableName/column')
@UseGuards(TooljetDbGuard)
@Post('/organizations/:organizationId/table/:tableName/column')
@UseGuards(JwtAuthGuard, ActiveWorkspaceGuard, TooljetDbGuard)
@CheckPolicies((ability: TooljetDbAbility) => ability.can(Action.AddColumn, 'all'))
async addColumn(
@Body('column') columnDto: PostgrestTableColumnDto,
@ -80,8 +81,8 @@ export class TooljetDbController {
return decamelizeKeys({ result });
}
@Delete('/:organizationId/table/:tableName/column/:columnName')
@UseGuards(TooljetDbGuard)
@Delete('/organizations/:organizationId/table/:tableName/column/:columnName')
@UseGuards(JwtAuthGuard, ActiveWorkspaceGuard, TooljetDbGuard)
@CheckPolicies((ability: TooljetDbAbility) => ability.can(Action.DropColumn, 'all'))
async dropColumn(
@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))
@IsOptional()
@Validate(SQLInjectionValidator)
constraint: string;
constraint_type: string;
@IsOptional()
@Transform(({ value, obj }) => {
@ -151,7 +151,7 @@ export class PostgrestTableColumnDto {
message: 'Default value must match the data type',
})
@Validate(SQLInjectionValidator, { message: 'Default value does not support special characters except "." and "@"' })
default: string | number | boolean;
column_default: string | number | boolean;
}
export class RenamePostgrestTableDto {

View file

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

View file

@ -113,6 +113,10 @@ async function bootstrap() {
app.use(json({ limit: '50mb' }));
app.use(urlencoded({ extended: true, limit: '50mb', parameterLimit: 1000000 }));
app.useStaticAssets(join(__dirname, 'assets'), { prefix: (UrlPrefix ? UrlPrefix : '/') + 'assets' });
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: VERSION_NEUTRAL,
});
app.enableVersioning({
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 { Injectable } from '@nestjs/common';
import { UsersService } from 'src/services/users.service';
import { isEmpty } from 'lodash';
export enum Action {
ProxyPostgrest = 'proxyPostgrest',
@ -24,7 +25,10 @@ export class TooljetDbAbilityFactory {
async actions(user: User, params: any) {
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) {
can(Action.CreateTable, 'all');
@ -33,7 +37,11 @@ export class TooljetDbAbilityFactory {
can(Action.DropColumn, 'all');
can(Action.RenameTable, 'all');
}
can(Action.ProxyPostgrest, 'all');
if (isPublicAppRequest || isUserLoggedin) {
can(Action.ProxyPostgrest, 'all');
}
can(Action.ViewTables, '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 { CHECK_POLICIES_KEY } from './check_policies.decorator';
import { PolicyHandler } from './policyhandler.interface';
import { isEmpty } from 'lodash';
import { EntityManager } from 'typeorm';
import { DataQuery } from 'src/entities/data_query.entity';
@Injectable()
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> {
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));
}

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 {
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);
const authToken = 'Bearer ' + this.signJwtPayload(this.configService.get<string>('PG_USER'));
req.headers = {};
@ -25,8 +26,8 @@ export class PostgrestProxyService {
private httpProxy = proxy(this.configService.get<string>('PGRST_HOST'), {
proxyReqPathResolver: function (req) {
const path = '/api/tooljet_db/organizations';
const pathRegex = new RegExp(`${maybeSetSubPath(path)}/.{36}/proxy`);
const path = '/api/tooljet_db';
const pathRegex = new RegExp(`${maybeSetSubPath(path)}/proxy`);
const parts = req.url.split('?');
const queryString = parts[1];
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 { InjectEntityManager } from '@nestjs/typeorm';
import { InternalTable } from 'src/entities/internal_table.entity';
@ -8,6 +8,7 @@ import { isString } from 'lodash';
export class TooljetDbService {
constructor(
private readonly manager: EntityManager,
@Optional()
@InjectEntityManager('tooljetDb')
private tooljetDbManager: EntityManager
) {}
@ -34,30 +35,47 @@ export class TooljetDbService {
}
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, {
where: { organizationId, tableName },
where: {
organizationId,
...(tableName && { tableName }),
...(id && { id }),
},
});
if (!internalTable) throw new NotFoundException('Internal table not found: ' + tableName);
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
FROM INFORMATION_SCHEMA.COLUMNS c
LEFT JOIN (
SELECT ku.TABLE_CATALOG,ku.TABLE_SCHEMA,ku.TABLE_NAME,ku.COLUMN_NAME
FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS AS tc
INNER JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE AS ku
ON tc.CONSTRAINT_TYPE = 'PRIMARY KEY'
AND 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;
`
SELECT c.COLUMN_NAME, c.DATA_TYPE,
CASE
WHEN pk.CONSTRAINT_TYPE = 'PRIMARY KEY'
THEN c.Column_default
WHEN c.Column_default LIKE '%::%'
THEN replace(substring(c.Column_default from '^''?(.*?)''?::'), '''', '')
ELSE c.Column_default
END AS Column_default,
c.character_maximum_length, c.numeric_precision, c.is_nullable,
pk.CONSTRAINT_TYPE,
CASE
WHEN pk.COLUMN_NAME IS NOT NULL THEN 'PRIMARY KEY'
ELSE ''
END AS KeyType
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) {
return await this.manager.find(InternalTable, {
where: { organizationId },
select: ['tableName'],
select: ['id', 'tableName'],
order: { tableName: 'ASC' },
});
}
@ -76,28 +94,26 @@ export class TooljetDbService {
}
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 {
table_name: tableName,
columns: [column, ...restColumns],
} = 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();
await queryRunner.connect();
await queryRunner.startTransaction();
@ -111,14 +127,14 @@ export class TooljetDbService {
const createTableString = `CREATE TABLE "${internalTable.id}" `;
let query = `${column['column_name']} ${column['data_type']}`;
if (column['default']) query += ` DEFAULT ${this.addQuotesIfString(column['default'])}`;
if (column['constraint']) query += ` ${column['constraint']}`;
if (column['column_default']) query += ` DEFAULT ${this.addQuotesIfString(column['column_default'])}`;
if (column['constraint_type']) query += ` ${column['constraint_type']}`;
if (restColumns)
for (const col of restColumns) {
query += `, ${col['column_name']} ${col['data_type']}`;
if (col['default']) query += ` DEFAULT ${this.addQuotesIfString(col['default'])}`;
if (col['constraint']) query += ` ${col['constraint']}`;
if (col['column_default']) query += ` DEFAULT ${this.addQuotesIfString(col['column_default'])}`;
if (col['constraint_type']) query += ` ${col['constraint_type']}`;
}
// 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 queryRunner.commitTransaction();
return true;
return { id: internalTable.id, table_name: tableName };
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
@ -194,7 +210,7 @@ export class TooljetDbService {
if (!internalTable) throw new NotFoundException('Internal table not found: ' + tableName);
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']};`;
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, {
dataSource: dataSource1,
appVersion: appVersion1,
name: 'test_query_1',
kind: 'test_kind',
});
@ -115,6 +117,7 @@ describe('AppImportExportService', () => {
name: 'test_name_2',
});
const dataQuery2 = await createDataQuery(nestApp, {
appVersion: appVersion2,
dataSource: dataSource2,
name: 'test_query_2',
});
@ -123,7 +126,7 @@ describe('AppImportExportService', () => {
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.name).toBe(exportedApp.name);
@ -137,7 +140,7 @@ describe('AppImportExportService', () => {
expect(result.appVersions.length).toBe(1);
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;
expect(result.id).toBe(exportedApp.id);
@ -246,6 +249,7 @@ describe('AppImportExportService', () => {
//create default dataQuery
await createDataQuery(nestApp, {
dataSource: firstDs,
appVersion: applicationVersion,
options: {},
});
@ -263,11 +267,9 @@ describe('AppImportExportService', () => {
const appVersion = importedApp.appVersions[0];
expect(appVersion.appId).toEqual(importedApp.id);
const dataSource = importedApp['dataSources'].reverse()[0];
expect(dataSource['appVersionId']).toEqual(appVersion.id);
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
const deleteFieldsNotToCheck = (entity) => {
@ -287,10 +289,9 @@ describe('AppImportExportService', () => {
const importedDataQueries = importedApp['dataQueries'].map((query) => deleteFieldsNotToCheck(query));
const exportedDataQueries = exportedApp['dataQueries'].map((query) => deleteFieldsNotToCheck(query));
expect(importedAppVersions).toEqual(exportedAppVersions);
console.log('inside', importedDataSources, exportedDataSources);
expect(importedDataSources).toEqual(exportedDataSources);
expect(importedDataQueries).toEqual(exportedDataQueries);
expect(new Set(importedAppVersions)).toEqual(new Set(exportedAppVersions));
expect(new Set(importedDataSources)).toEqual(new Set(exportedDataSources));
expect(new Set(importedDataQueries)).toEqual(new Set(exportedDataQueries));
// assert group permissions are valid
const appGroupPermissions = await getManager().find(AppGroupPermission, {