diff --git a/.gitignore b/.gitignore index 034334ea22..abbae288af 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ /frontend/cypress/videos .idea/* +ti-* diff --git a/frontend/assets/translations/en.json b/frontend/assets/translations/en.json index c4ee0384d9..32de4b0102 100644 --- a/frontend/assets/translations/en.json +++ b/frontend/assets/translations/en.json @@ -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", diff --git a/frontend/src/Editor/CodeBuilder/Elements/FxButton.jsx b/frontend/src/Editor/CodeBuilder/Elements/FxButton.jsx index 303b6965db..c97f7f5381 100644 --- a/frontend/src/Editor/CodeBuilder/Elements/FxButton.jsx +++ b/frontend/src/Editor/CodeBuilder/Elements/FxButton.jsx @@ -8,7 +8,7 @@ export default function FxButton({ active, onPress, dataCy }) { onClick={onPress} data-cy={`${dataCy}-fx-button`} > - Fx + fx ); } diff --git a/frontend/src/Editor/Header/GlobalSettings.jsx b/frontend/src/Editor/Header/GlobalSettings.jsx index 290cce981d..6426fd2fd6 100644 --- a/frontend/src/Editor/Header/GlobalSettings.jsx +++ b/frontend/src/Editor/Header/GlobalSettings.jsx @@ -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 = (
- - - -
+
-
+
{t('leftSidebar.Settings.hideHeader', 'Hide header for launched apps')} -
+
globalSettingsChanged('hideHeader', e.target.checked)} />
+ + {t('leftSidebar.Settings.hideHeader', 'Hide header for launched apps')} +
-
+
{t('leftSidebar.Settings.maintenanceMode', 'Maintenance mode')} -
+
setConfirmationShow(true)} />
+ + {t('leftSidebar.Settings.maintenanceMode', 'Maintenance mode')} +
-
+
{t('leftSidebar.Settings.maxWidthOfCanvas', 'Max width of canvas')} -
+
{ const width = e.target.value; @@ -109,8 +114,8 @@ export const GlobalSettings = ({ value={canvasMaxWidth} /> - px
*/} @@ -175,7 +179,7 @@ export const GlobalSettings = ({ )} {forceCodeBox && (
setShowPicker(true)} >
-
{canvasBackgroundColor}
+
{canvasBackgroundColor}
)}
+
+

Export app

+ +
@@ -244,6 +266,18 @@ export const GlobalSettings = ({ onCancel={() => setConfirmationShow(false)} darkMode={darkMode} /> + {isExportingApp && app.hasOwnProperty('id') && ( + { + setIsExportingApp(false); + }} + customClassName="modal-version-lists" + title={'Select a version to export'} + app={app} + darkMode={darkMode} + /> + )} { if (show) setShow('settings'); diff --git a/frontend/src/Editor/Header/index.js b/frontend/src/Editor/Header/index.js index d8f0334a86..3df9ab8e67 100644 --- a/frontend/src/Editor/Header/index.js +++ b/frontend/src/Editor/Header/index.js @@ -88,6 +88,7 @@ export default function EditorHeader({ darkMode={darkMode} toggleAppMaintenance={toggleAppMaintenance} is_maintenance_on={is_maintenance_on} + app={app} />
diff --git a/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/ToolJetDbOperations.jsx b/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/ToolJetDbOperations.jsx index 6682b66068..88e67e896d 100644 --- a/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/ToolJetDbOperations.jsx +++ b/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/ToolJetDbOperations.jsx @@ -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
setExportTjDb(!exportTjDb)} /> +

Export ToolJet table schema

+
+ + exportApp(app, null, exportTjDb, tables)} + > Export All - - + exportApp(app.id, versionId)} + onClick={() => exportApp(app, versionId, exportTjDb, tables)} > Export selected version - + ) : ( @@ -154,11 +191,12 @@ function InputRadioField({ checked = undefined, key = undefined, setVersionId, + className, }) { return ( {versionName} - {`Created on ${moment(versionCreatedAt).format( - 'Do MMM YYYY' - )}`} + {`Created on ${moment( + versionCreatedAt + ).format('Do MMM YYYY')}`} ); diff --git a/frontend/src/HomePage/HomePage.jsx b/frontend/src/HomePage/HomePage.jsx index b34ae10d47..93a8f85047 100644 --- a/frontend/src/HomePage/HomePage.jsx +++ b/frontend/src/HomePage/HomePage.jsx @@ -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, }); diff --git a/frontend/src/TooljetDatabase/Drawers/CreateColumnDrawer/index.jsx b/frontend/src/TooljetDatabase/Drawers/CreateColumnDrawer/index.jsx index 8bdca77660..9009d65aa0 100644 --- a/frontend/src/TooljetDatabase/Drawers/CreateColumnDrawer/index.jsx +++ b/frontend/src/TooljetDatabase/Drawers/CreateColumnDrawer/index.jsx @@ -25,7 +25,7 @@ const CreateColumnDrawer = ({ setIsCreateColumnDrawerOpen, isCreateColumnDrawerO setIsCreateColumnDrawerOpen(false)} position="right"> { - 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; } diff --git a/frontend/src/TooljetDatabase/Drawers/CreateRowDrawer/index.jsx b/frontend/src/TooljetDatabase/Drawers/CreateRowDrawer/index.jsx index 6d4ace041a..78dd13967c 100644 --- a/frontend/src/TooljetDatabase/Drawers/CreateRowDrawer/index.jsx +++ b/frontend/src/TooljetDatabase/Drawers/CreateRowDrawer/index.jsx @@ -23,9 +23,9 @@ const CreateRowDrawer = ({ isCreateRowDrawerOpen, setIsCreateRowDrawerOpen }) => setIsCreateRowDrawerOpen(false)} position="right"> { - 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; } diff --git a/frontend/src/TooljetDatabase/Drawers/CreateTableDrawer/index.jsx b/frontend/src/TooljetDatabase/Drawers/CreateTableDrawer/index.jsx index be71638e6f..b80193aecb 100644 --- a/frontend/src/TooljetDatabase/Drawers/CreateTableDrawer/index.jsx +++ b/frontend/src/TooljetDatabase/Drawers/CreateTableDrawer/index.jsx @@ -27,7 +27,7 @@ export default function CreateTableDrawer() {
setIsCreateTableDrawerOpen(false)} position="right"> { + 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); diff --git a/frontend/src/TooljetDatabase/Drawers/EditRowDrawer/index.jsx b/frontend/src/TooljetDatabase/Drawers/EditRowDrawer/index.jsx index 7a9f544ec6..6152f7fdba 100644 --- a/frontend/src/TooljetDatabase/Drawers/EditRowDrawer/index.jsx +++ b/frontend/src/TooljetDatabase/Drawers/EditRowDrawer/index.jsx @@ -30,9 +30,9 @@ const EditRowDrawer = ({ isCreateRowDrawerOpen, setIsCreateRowDrawerOpen }) => { setIsCreateRowDrawerOpen(false)} position="right"> { - 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; } diff --git a/frontend/src/TooljetDatabase/ExportSchema/ExportSchema.jsx b/frontend/src/TooljetDatabase/ExportSchema/ExportSchema.jsx new file mode 100644 index 0000000000..943100bfe1 --- /dev/null +++ b/frontend/src/TooljetDatabase/ExportSchema/ExportSchema.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import SolidIcon from '@/_ui/Icon/SolidIcons'; + +function ExportSchema({ onClick }) { + return ( + + ); +} + +export default ExportSchema; diff --git a/frontend/src/TooljetDatabase/Forms/ColumnForm.jsx b/frontend/src/TooljetDatabase/Forms/ColumnForm.jsx index 1454b0fa42..8198bce1d1 100644 --- a/frontend/src/TooljetDatabase/Forms/ColumnForm.jsx +++ b/frontend/src/TooljetDatabase/Forms/ColumnForm.jsx @@ -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; } diff --git a/frontend/src/TooljetDatabase/Forms/ColumnsForm.jsx b/frontend/src/TooljetDatabase/Forms/ColumnsForm.jsx index d49ecfa223..9af60e9802 100644 --- a/frontend/src/TooljetDatabase/Forms/ColumnsForm.jsx +++ b/frontend/src/TooljetDatabase/Forms/ColumnsForm.jsx @@ -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'} />
{ diff --git a/frontend/src/TooljetDatabase/Forms/RowForm.jsx b/frontend/src/TooljetDatabase/Forms/RowForm.jsx index 2b3a1b5174..79df84b77e 100644 --- a/frontend/src/TooljetDatabase/Forms/RowForm.jsx +++ b/frontend/src/TooljetDatabase/Forms/RowForm.jsx @@ -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}"`); diff --git a/frontend/src/TooljetDatabase/Forms/TableForm.jsx b/frontend/src/TooljetDatabase/Forms/TableForm.jsx index 7465265751..561e34c4ee 100644 --- a/frontend/src/TooljetDatabase/Forms/TableForm.jsx +++ b/frontend/src/TooljetDatabase/Forms/TableForm.jsx @@ -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(); }; diff --git a/frontend/src/TooljetDatabase/Table/index.jsx b/frontend/src/TooljetDatabase/Table/index.jsx index 7e1731f46e..5fee5fb8a1 100644 --- a/frontend/src/TooljetDatabase/Table/index.jsx +++ b/frontend/src/TooljetDatabase/Table/index.jsx @@ -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}"`); } }; diff --git a/frontend/src/TooljetDatabase/TableList/index.jsx b/frontend/src/TooljetDatabase/TableList/index.jsx index 21ed8440f3..98aa19ede1 100644 --- a/frontend/src/TooljetDatabase/TableList/index.jsx +++ b/frontend/src/TooljetDatabase/TableList/index.jsx @@ -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 = () => {
{loading && } {!loading && - filteredTables?.map(({ table_name }, index) => ( + filteredTables?.map(({ id, table_name }, index) => ( { - setSelectedTable(table_name); + setSelectedTable({ table_name, id }); updateSidebarNAV(table_name); }} /> diff --git a/frontend/src/TooljetDatabase/TableListItem/index.jsx b/frontend/src/TooljetDatabase/TableListItem/index.jsx index 886fa06b8e..5c1e9fea40 100644 --- a/frontend/src/TooljetDatabase/TableListItem/index.jsx +++ b/frontend/src/TooljetDatabase/TableListItem/index.jsx @@ -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)} /> diff --git a/frontend/src/TooljetDatabase/TooljetDatabasePage/index.jsx b/frontend/src/TooljetDatabase/TooljetDatabasePage/index.jsx index baeda46249..36d0c12919 100644 --- a/frontend/src/TooljetDatabase/TooljetDatabasePage/index.jsx +++ b/frontend/src/TooljetDatabase/TooljetDatabasePage/index.jsx @@ -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 (
{totalTables === 0 && } - - {selectedTable && ( + {!isEmpty(selectedTable) && ( <>
-
+
{ handleBuildSortQuery={handleBuildSortQuery} resetSortQuery={resetSortQuery} /> + { 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); diff --git a/frontend/src/TooljetDatabase/usePostgrestQueryBuilder.jsx b/frontend/src/TooljetDatabase/usePostgrestQueryBuilder.jsx index 735ab623e9..7fca6489cf 100644 --- a/frontend/src/TooljetDatabase/usePostgrestQueryBuilder.jsx +++ b/frontend/src/TooljetDatabase/usePostgrestQueryBuilder.jsx @@ -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); diff --git a/frontend/src/_helpers/appUtils.js b/frontend/src/_helpers/appUtils.js index d8dd78b29f..85dfd5cc9b 100644 --- a/frontend/src/_helpers/appUtils.js +++ b/frontend/src/_helpers/appUtils.js @@ -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); } diff --git a/frontend/src/_services/app.service.js b/frontend/src/_services/app.service.js index 4d980262a2..0329954eac 100644 --- a/frontend/src/_services/app.service.js +++ b/frontend/src/_services/app.service.js @@ -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', diff --git a/frontend/src/_services/tooljetDatabase.service.js b/frontend/src/_services/tooljetDatabase.service.js index 43ab169b14..8ff35752af 100644 --- a/frontend/src/_services/tooljetDatabase.service.js +++ b/frontend/src/_services/tooljetDatabase.service.js @@ -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, diff --git a/frontend/src/_styles/theme.scss b/frontend/src/_styles/theme.scss index bbd14394ac..caec8f9444 100644 --- a/frontend/src/_styles/theme.scss +++ b/frontend/src/_styles/theme.scss @@ -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; } -} \ No newline at end of file +} diff --git a/frontend/src/_ui/Icon/solidIcons/new-export-1682542240466.json b/frontend/src/_ui/Icon/solidIcons/new-export-1682542240466.json new file mode 100644 index 0000000000..0e283d4824 --- /dev/null +++ b/frontend/src/_ui/Icon/solidIcons/new-export-1682542240466.json @@ -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" +} \ No newline at end of file diff --git a/server/data-migrations/1679604241777-ReplaceTooljetDbTableNamesWithId.ts b/server/data-migrations/1679604241777-ReplaceTooljetDbTableNamesWithId.ts new file mode 100644 index 0000000000..e669df0947 --- /dev/null +++ b/server/data-migrations/1679604241777-ReplaceTooljetDbTableNamesWithId.ts @@ -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 { + 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 {} +} diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 0eee447aa2..5d709f5819 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -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, ]; diff --git a/server/src/controllers/apps.controller.ts b/server/src/controllers/apps.controller.ts index dc9a00d34b..45873792d0 100644 --- a/server/src/controllers/apps.controller.ts +++ b/server/src/controllers/apps.controller.ts @@ -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 }; + } } diff --git a/server/src/controllers/import_export_resources.controller.ts b/server/src/controllers/import_export_resources.controller.ts new file mode 100644 index 0000000000..f1b5b1828c --- /dev/null +++ b/server/src/controllers/import_export_resources.controller.ts @@ -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 }; + } +} diff --git a/server/src/controllers/tooljet_db.controller.ts b/server/src/controllers/tooljet_db.controller.ts index 9d6c2c985e..956ecce253 100644 --- a/server/src/controllers/tooljet_db.controller.ts +++ b/server/src/controllers/tooljet_db.controller.ts @@ -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, diff --git a/server/src/dto/clone-resources.dto.ts b/server/src/dto/clone-resources.dto.ts new file mode 100644 index 0000000000..5dc6c3ab7a --- /dev/null +++ b/server/src/dto/clone-resources.dto.ts @@ -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; +} diff --git a/server/src/dto/export-resources.dto.ts b/server/src/dto/export-resources.dto.ts new file mode 100644 index 0000000000..aa3a766376 --- /dev/null +++ b/server/src/dto/export-resources.dto.ts @@ -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; +} diff --git a/server/src/dto/import-resources.dto.ts b/server/src/dto/import-resources.dto.ts new file mode 100644 index 0000000000..50cefcfb21 --- /dev/null +++ b/server/src/dto/import-resources.dto.ts @@ -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; +} diff --git a/server/src/dto/tooljet-db.dto.ts b/server/src/dto/tooljet-db.dto.ts index 5a379a6bfa..7a79482d3f 100644 --- a/server/src/dto/tooljet-db.dto.ts +++ b/server/src/dto/tooljet-db.dto.ts @@ -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 { diff --git a/server/src/entities/data_source.entity.ts b/server/src/entities/data_source.entity.ts index e79282558a..1a22e8df29 100644 --- a/server/src/entities/data_source.entity.ts +++ b/server/src/entities/data_source.entity.ts @@ -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; diff --git a/server/src/main.ts b/server/src/main.ts index 31d83fb943..c506400fbe 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -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, diff --git a/server/src/modules/casl/abilities/tooljet-db-ability.factory.ts b/server/src/modules/casl/abilities/tooljet-db-ability.factory.ts index a4a7bfd193..a29e135d65 100644 --- a/server/src/modules/casl/abilities/tooljet-db-ability.factory.ts +++ b/server/src/modules/casl/abilities/tooljet-db-ability.factory.ts @@ -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 as AbilityClass); - 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'); diff --git a/server/src/modules/casl/tooljet-db.guard.ts b/server/src/modules/casl/tooljet-db.guard.ts index 27c0854ca4..c2a2d12165 100644 --- a/server/src/modules/casl/tooljet-db.guard.ts +++ b/server/src/modules/casl/tooljet-db.guard.ts @@ -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 { const policyHandlers = this.reflector.get(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)); } diff --git a/server/src/modules/import_export_resources/import_export_resources.module.ts b/server/src/modules/import_export_resources/import_export_resources.module.ts new file mode 100644 index 0000000000..a920608b23 --- /dev/null +++ b/server/src/modules/import_export_resources/import_export_resources.module.ts @@ -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 {} diff --git a/server/src/services/app_import_export.service.ts b/server/src/services/app_import_export.service.ts index 4b66108d07..050e1b98b8 100644 --- a/server/src/services/app_import_export.service.ts +++ b/server/src/services/app_import_export.service.ts @@ -1,21 +1,35 @@ import { BadRequestException, Injectable } from '@nestjs/common'; +import { isEmpty } from 'lodash'; import { App } from 'src/entities/app.entity'; -import { EntityManager } from 'typeorm'; -import { User } from 'src/entities/user.entity'; -import { DataSource } from 'src/entities/data_source.entity'; -import { DataQuery } from 'src/entities/data_query.entity'; -import { AppVersion } from 'src/entities/app_version.entity'; -import { GroupPermission } from 'src/entities/group_permission.entity'; +import { AppEnvironment } from 'src/entities/app_environments.entity'; import { AppGroupPermission } from 'src/entities/app_group_permission.entity'; +import { AppVersion } from 'src/entities/app_version.entity'; +import { DataQuery } from 'src/entities/data_query.entity'; +import { DataSource } from 'src/entities/data_source.entity'; +import { DataSourceOptions } from 'src/entities/data_source_options.entity'; +import { GroupPermission } from 'src/entities/group_permission.entity'; +import { User } from 'src/entities/user.entity'; +import { EntityManager } from 'typeorm'; import { DataSourcesService } from './data_sources.service'; import { dbTransactionWrap, defaultAppEnvironments, truncateAndReplace } from 'src/helpers/utils.helper'; -import { isEmpty } from 'lodash'; -import { AppEnvironment } from 'src/entities/app_environments.entity'; -import { DataSourceOptions } from 'src/entities/data_source_options.entity'; import { AppEnvironmentService } from './app_environments.service'; import { convertAppDefinitionFromSinglePageToMultiPage } from '../../lib/single-page-to-and-from-multipage-definition-conversion'; import { DataSourceScopes, DataSourceTypes } from 'src/helpers/data_source.constants'; import { Organization } from 'src/entities/organization.entity'; +import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; +import { Plugin } from 'src/entities/plugin.entity'; + +interface AppResourceMappings { + defaultDataSourceIdMapping: Record; + dataQueryMapping: Record; + appVersionMapping: Record; + appEnvironmentMapping: Record; + appDefaultEnvironmentMapping: Record; +} + +type DefaultDataSourceKind = 'restapi' | 'runjs' | 'runpy' | 'tooljetdb'; + +const DefaultDataSourceKinds: DefaultDataSourceKind[] = ['restapi', 'runjs', 'runpy', 'tooljetdb']; @Injectable() export class AppImportExportService { @@ -29,7 +43,7 @@ export class AppImportExportService { // https://github.com/typeorm/typeorm/issues/3857 // Making use of query builder // filter by search params - const { versionId = undefined } = searchParams; + const versionId = searchParams?.version_id; return await dbTransactionWrap(async (manager: EntityManager) => { const queryForAppToExport = manager .createQueryBuilder(App, 'apps') @@ -39,7 +53,7 @@ export class AppImportExportService { }); const appToExport = await queryForAppToExport.getOne(); - const queryAppVersions = await manager + const queryAppVersions = manager .createQueryBuilder(AppVersion, 'app_versions') .where('app_versions.appId = :appId', { appId: appToExport.id, @@ -90,6 +104,9 @@ export class AppImportExportService { .where('data_queries.dataSourceId IN(:...dataSourceId)', { dataSourceId: dataSources?.map((v) => v.id), }) + .andWhere('data_queries.appVersionId IN(:...versionId)', { + versionId: appVersions.map((v) => v.id), + }) .orderBy('data_queries.created_at', 'ASC') .getMany(); @@ -121,7 +138,7 @@ export class AppImportExportService { }); } - async import(user: User, appParamsObj: any): Promise { + async import(user: User, appParamsObj: any, externalResourceMappings = {}): Promise { if (typeof appParamsObj !== 'object') { throw new BadRequestException('Invalid params for app import'); } @@ -144,7 +161,13 @@ export class AppImportExportService { await dbTransactionWrap(async (manager) => { importedApp = await this.createImportedAppForUser(manager, schemaUnifiedAppParams, user); - await this.buildImportedAppAssociations(manager, importedApp, schemaUnifiedAppParams, user); + await this.setupImportedAppAssociations( + manager, + importedApp, + schemaUnifiedAppParams, + user, + externalResourceMappings + ); await this.createAdminGroupPermissions(manager, importedApp); }); @@ -172,154 +195,510 @@ export class AppImportExportService { return importedApp; } + extractImportDataFromAppParams(appParams: Record): { + importingDataSources: DataSource[]; + importingDataQueries: DataQuery[]; + importingAppVersions: AppVersion[]; + importingAppEnvironments: AppEnvironment[]; + importingDataSourceOptions: DataSourceOptions[]; + importingDefaultAppEnvironmentId: string; + } { + const importingDataSources = appParams?.dataSources || []; + const importingDataQueries = appParams?.dataQueries || []; + const importingAppVersions = appParams?.appVersions || []; + const importingAppEnvironments = appParams?.appEnvironments || []; + const importingDataSourceOptions = appParams?.dataSourceOptions || []; + const importingDefaultAppEnvironmentId = importingAppEnvironments.find( + (env: { isDefault: any }) => env.isDefault + )?.id; + + return { + importingDataSources, + importingDataQueries, + importingAppVersions, + importingAppEnvironments, + importingDataSourceOptions, + importingDefaultAppEnvironmentId, + }; + } + /* * With new multi-env changes. the imported apps will not have any released versions from now (if the importing schema has any currentVersionId). * All version's default environment will be development or least priority environment only. */ - async buildImportedAppAssociations(manager: EntityManager, importedApp: App, appParams: any, user: User) { - const dataSourceMapping = {}; - const defaultDataSourceIdMapping = {}; - const dataQueryMapping = {}; - const appVersionMapping = {}; - const appEnvironmentMapping = {}; - const appDefaultEnvironmentMapping = {}; - const dataSources = appParams?.dataSources || []; - const dataQueries = appParams?.dataQueries || []; - const appVersions = appParams?.appVersions || []; - const appEnvironments = appParams?.appEnvironments || []; - const dataSourceOptions = appParams?.dataSourceOptions || []; - const newDataQueries = []; - let defaultAppEnvironmentId: string; - let currentEnvironmentId: string; - - if (!appVersions?.length) { - // Old version without app version - // Handle exports prior to 0.12.0 - - let envIdArray: string[] = []; - - const organization: Organization = await manager.findOne(Organization, { - where: { id: user.organizationId }, - relations: ['appEnvironments'], - }); - envIdArray = [...organization.appEnvironments.map((env) => env.id)]; - - if (!envIdArray.length) { - await Promise.all( - defaultAppEnvironments.map(async (en) => { - const env = manager.create(AppEnvironment, { - organizationId: user.organizationId, - name: en.name, - isDefault: en.isDefault, - priority: en.priority, - createdAt: new Date(), - updatedAt: new Date(), - }); - await manager.save(env); - if (defaultAppEnvironments.length === 1 || en.priority === 1) { - currentEnvironmentId = env.id; - } - envIdArray.push(env.id); - }) - ); - } else { - //get starting env from the organization environments list - const { appEnvironments } = organization; - if (appEnvironments.length === 1) currentEnvironmentId = appEnvironments[0].id; - else { - appEnvironments.map((appEnvironment) => { - if (appEnvironment.priority === 1) currentEnvironmentId = appEnvironment.id; - }); - } - } - - const version = manager.create(AppVersion, { - appId: importedApp.id, - definition: appParams.definition, - name: 'v1', - currentEnvironmentId, - createdAt: new Date(), - updatedAt: new Date(), - }); - await manager.save(version); - - // Create default data sources - const defaultDataSourceIds = await this.createDefaultDataSourceForVersion( - user.organizationId, - version.id, - [], - manager - ); - - for (const source of dataSources) { - const convertedOptions = this.convertToArrayOfKeyValuePairs(source.options); - - const newSource = manager.create(DataSource, { - name: source.name, - kind: source.kind, - appVersionId: version.id, - }); - await manager.save(newSource); - dataSourceMapping[source.id] = newSource.id; - - await Promise.all( - envIdArray.map(async (envId) => { - let newOptions; - if (source.options) { - newOptions = await this.dataSourcesService.parseOptionsForCreate(convertedOptions, true, manager); - } - - const dsOption = manager.create(DataSourceOptions, { - environmentId: envId, - dataSourceId: newSource.id, - options: newOptions, - createdAt: new Date(), - updatedAt: new Date(), - }); - await manager.save(dsOption); - }) - ); - } - - const newDataQueries = []; - for (const query of dataQueries) { - const dataSourceId = dataSourceMapping[query.dataSourceId]; - const newQuery = manager.create(DataQuery, { - name: query.name, - options: query.options, - dataSourceId: !dataSourceId ? defaultDataSourceIds[query.kind] : dataSourceId, - appVersionId: query.appVersionId, - }); - await manager.save(newQuery); - dataQueryMapping[query.id] = newQuery.id; - newDataQueries.push(newQuery); - } - - for (const newQuery of newDataQueries) { - const newOptions = this.replaceDataQueryOptionsWithNewDataQueryIds(newQuery.options, dataQueryMapping); - newQuery.options = newOptions; - await manager.save(newQuery); - } - - await manager.update( - AppVersion, - { id: version.id }, - { definition: this.replaceDataQueryIdWithinDefinitions(version.definition, dataQueryMapping) } - ); - + async setupImportedAppAssociations( + manager: EntityManager, + importedApp: App, + appParams: any, + user: User, + externalResourceMappings: Record + ) { + // Old version without app version + // Handle exports prior to 0.12.0 + // TODO: have version based conditional based on app versions + // isLessThanExportVersion(appParams.tooljet_version, 'v0.12.0') + if (!appParams?.appVersions) { + await this.performLegacyAppImport(manager, importedApp, appParams, externalResourceMappings, user); return; } - // With version support v1 & v2 - // create new app versions - for (const appVersion of appVersions) { - let envIdArray: string[] = []; - const organization: Organization = await manager.findOne(Organization, { - where: { id: user.organizationId }, - relations: ['appEnvironments'], + let appResourceMappings: AppResourceMappings = { + defaultDataSourceIdMapping: {}, + dataQueryMapping: {}, + appVersionMapping: {}, + appEnvironmentMapping: {}, + appDefaultEnvironmentMapping: {}, + }; + + const { + importingDataSources, + importingDataQueries, + importingAppVersions, + importingAppEnvironments, + importingDataSourceOptions, + importingDefaultAppEnvironmentId, + } = this.extractImportDataFromAppParams(appParams); + + const { appDefaultEnvironmentMapping, appVersionMapping } = await this.createAppVersionsForImportedApp( + manager, + user, + importedApp, + importingAppVersions, + appResourceMappings + ); + appResourceMappings.appDefaultEnvironmentMapping = appDefaultEnvironmentMapping; + appResourceMappings.appVersionMapping = appVersionMapping; + + appResourceMappings = await this.setupAppVersionAssociations( + manager, + importingAppVersions, + user, + appResourceMappings, + externalResourceMappings, + importingAppEnvironments, + importingDataSources, + importingDataSourceOptions, + importingDataQueries, + importingDefaultAppEnvironmentId + ); + + for (const importingAppVersion of importingAppVersions) { + const updatedDefinition = this.replaceDataQueryIdWithinDefinitions( + importingAppVersion.definition, + appResourceMappings.dataQueryMapping + ); + await manager.update( + AppVersion, + { id: appResourceMappings.appVersionMapping[importingAppVersion.id] }, + { + definition: updatedDefinition, + } + ); + } + + await this.setEditingVersionAsLatestVersion(manager, appResourceMappings.appVersionMapping, importingAppVersions); + } + + async setupAppVersionAssociations( + manager: EntityManager, + importingAppVersions: AppVersion[], + user: User, + appResourceMappings: AppResourceMappings, + externalResourceMappings: Record, + importingAppEnvironments: AppEnvironment[], + importingDataSources: DataSource[], + importingDataSourceOptions: DataSourceOptions[], + importingDataQueries: DataQuery[], + importingDefaultAppEnvironmentId: string + ): Promise { + appResourceMappings = { ...appResourceMappings }; + + for (const importingAppVersion of importingAppVersions) { + const { appEnvironmentMapping } = await this.associateAppEnvironmentsToAppVersion( + manager, + user, + importingAppEnvironments, + importingAppVersion, + appResourceMappings + ); + appResourceMappings.appEnvironmentMapping = appEnvironmentMapping; + + const importingDataSourcesForAppVersion = await this.rejectMarketplacePluginsNotInstalled( + manager, + importingDataSources + ); + + const importingDataQueriesForAppVersion = importingDataQueries.filter( + (dq: { dataSourceId: string; appVersionId: string }) => dq.appVersionId === importingAppVersion.id + ); + + const { defaultDataSourceIdMapping } = await this.createDefaultDatasourcesForAppVersion( + manager, + importingAppVersion, + user, + appResourceMappings + ); + appResourceMappings.defaultDataSourceIdMapping = defaultDataSourceIdMapping; + + // associate data sources and queries for each of the app versions + for (const importingDataSource of importingDataSourcesForAppVersion) { + const dataSourceForAppVersion = await this.findOrCreateDataSourceForAppVersion( + manager, + importingDataSource, + appResourceMappings.appVersionMapping[importingAppVersion.id], + user + ); + + // TODO: Have version based conditional based on app versions + // currently we are checking on existence of keys and handling + // imports accordingly. Would be pragmatic to do: + // isLessThanExportVersion(appParams.tooljet_version, 'v2.0.0') + // Will need to have JSON schema setup for each versions + if (importingDataSource.options) { + const convertedOptions = this.convertToArrayOfKeyValuePairs(importingDataSource.options); + + await Promise.all( + appResourceMappings.appDefaultEnvironmentMapping[importingAppVersion.id].map(async (envId: any) => { + if (this.isExistingDataSource(dataSourceForAppVersion)) return; + + const newOptions = await this.dataSourcesService.parseOptionsForCreate(convertedOptions, true, manager); + const dsOption = manager.create(DataSourceOptions, { + environmentId: envId, + dataSourceId: dataSourceForAppVersion.id, + options: newOptions, + createdAt: new Date(), + updatedAt: new Date(), + }); + await manager.save(dsOption); + }) + ); + } + + await this.createDataSourceOptionsForExistingAppEnvs( + manager, + importingAppVersion, + dataSourceForAppVersion, + importingDataSourceOptions, + importingDataSource, + importingAppEnvironments, + appResourceMappings, + importingDefaultAppEnvironmentId + ); + + const { dataQueryMapping } = await this.createDataQueriesForAppVersion( + manager, + importingDataQueriesForAppVersion, + importingDataSource, + dataSourceForAppVersion, + importingAppVersion, + appResourceMappings, + externalResourceMappings + ); + appResourceMappings.dataQueryMapping = dataQueryMapping; + } + } + + return appResourceMappings; + } + + async rejectMarketplacePluginsNotInstalled( + manager: EntityManager, + importingDataSources: DataSource[] + ): Promise { + const pluginsFound = new Set(); + + const isPluginInstalled = async (kind: string): Promise => { + if (pluginsFound.has(kind)) return true; + + const pluginExists = !!(await manager.findOne(Plugin, { where: { pluginId: kind } })); + + if (pluginExists) pluginsFound.add(kind); + + return pluginExists; + }; + + const filteredDataSources: DataSource[] = []; + + for (const ds of importingDataSources) { + const isPlugin = !!ds.pluginId; + if (!isPlugin || (isPlugin && (await isPluginInstalled(ds.kind)))) { + filteredDataSources.push(ds); + } + } + + return filteredDataSources; + } + + async createDataQueriesForAppVersion( + manager: EntityManager, + importingDataQueriesForAppVersion: DataQuery[], + importingDataSource: DataSource, + dataSourceForAppVersion: DataSource, + importingAppVersion: AppVersion, + appResourceMappings: AppResourceMappings, + externalResourceMappings: { [x: string]: any } + ) { + appResourceMappings = { ...appResourceMappings }; + const newDataQueries = importingDataQueriesForAppVersion + .filter((dq: { dataSourceId: any }) => dq.dataSourceId === importingDataSource.id) + .map((importingQuery: { options: any; name: any }) => { + const options = + importingDataSource.kind === 'tooljetdb' + ? this.replaceTooljetDbTableIds(importingQuery.options, externalResourceMappings['tooljet_database']) + : importingQuery.options; + + return manager.create(DataQuery, { + name: importingQuery.name, + options, + dataSourceId: dataSourceForAppVersion.id, + appVersionId: appResourceMappings.appVersionMapping[importingAppVersion.id], + }); }); - envIdArray = [...organization.appEnvironments.map((env) => env.id)]; - if (appEnvironments.length > 0) defaultAppEnvironmentId = appEnvironments.find((env: any) => env.isDefault)?.id; + + await Promise.all(newDataQueries.map((newQuery: any) => manager.save(newQuery))); + + newDataQueries.forEach( + (newQuery: { id: string }) => (appResourceMappings.dataQueryMapping[newQuery.id] = newQuery.id) + ); + + for (const newQuery of newDataQueries) { + const newOptions = this.replaceDataQueryOptionsWithNewDataQueryIds( + newQuery.options, + appResourceMappings.dataQueryMapping + ); + newQuery.options = newOptions; + await manager.save(newQuery); + } + return appResourceMappings; + } + + isExistingDataSource(dataSourceForAppVersion: DataSource): boolean { + return !!dataSourceForAppVersion.createdAt; + } + + async createDataSourceOptionsForExistingAppEnvs( + manager: EntityManager, + appVersion: AppVersion, + dataSourceForAppVersion: DataSource, + dataSourceOptions: DataSourceOptions[], + importingDataSource: DataSource, + appEnvironments: AppEnvironment[], + appResourceMappings: AppResourceMappings, + defaultAppEnvironmentId: string + ) { + appResourceMappings = { ...appResourceMappings }; + const importingDatasourceOptionsForAppVersion = dataSourceOptions.filter( + (dso: { dataSourceId: string }) => dso.dataSourceId === importingDataSource.id + ); + // create the datasource options for datasource if other environments present which is not in the export + if (appEnvironments?.length !== appResourceMappings.appDefaultEnvironmentMapping[appVersion.id].length) { + const availableEnvironments = importingDatasourceOptionsForAppVersion.map( + (option) => appResourceMappings.appEnvironmentMapping[option.environmentId] + ); + const otherEnvironmentsIds = appResourceMappings.appDefaultEnvironmentMapping[appVersion.id].filter( + (defaultEnv) => !availableEnvironments.includes(defaultEnv) + ); + const defaultEnvDsOption = importingDatasourceOptionsForAppVersion.find( + (dso) => dso.environmentId === defaultAppEnvironmentId + ); + for (const otherEnvironmentId of otherEnvironmentsIds) { + await this.createDatasourceOption( + manager, + defaultEnvDsOption.options, + otherEnvironmentId, + dataSourceForAppVersion.id + ); + } + } + + // create datasource options only for newly created datasources + for (const importingDataSourceOption of importingDatasourceOptionsForAppVersion) { + if (importingDataSourceOption?.environmentId in appResourceMappings.appEnvironmentMapping) { + const existingDataSourceOptions = await manager.findOne(DataSourceOptions, { + where: { + dataSourceId: dataSourceForAppVersion.id, + environmentId: appResourceMappings.appEnvironmentMapping[importingDataSourceOption.environmentId], + }, + }); + + !existingDataSourceOptions && + (await this.createDatasourceOption( + manager, + importingDataSourceOption.options, + appResourceMappings.appEnvironmentMapping[importingDataSourceOption.environmentId], + dataSourceForAppVersion.id + )); + } + } + } + + async createDefaultDatasourcesForAppVersion( + manager: EntityManager, + appVersion: AppVersion, + user: User, + appResourceMappings: AppResourceMappings + ) { + const defaultDataSourceIds = await this.createDefaultDataSourceForVersion( + user.organizationId, + appResourceMappings.appVersionMapping[appVersion.id], + DefaultDataSourceKinds, + manager + ); + appResourceMappings.defaultDataSourceIdMapping[appVersion.id] = defaultDataSourceIds; + + return appResourceMappings; + } + + async findOrCreateDataSourceForAppVersion( + manager: EntityManager, + dataSource: DataSource, + appVersionId: string, + user: User + ): Promise { + const isDefaultDatasource = DefaultDataSourceKinds.includes(dataSource.kind as DefaultDataSourceKind); + const isPlugin = !!dataSource.pluginId; + + if (isDefaultDatasource) { + const createdDefaultDatasource = await manager.findOne(DataSource, { + where: { + appVersionId, + kind: dataSource.kind, + type: DataSourceTypes.STATIC, + scope: 'local', + }, + }); + + return createdDefaultDatasource; + } + + const globalDataSourceWithSameIdExists = async (dataSource: DataSource) => { + return await manager.findOne(DataSource, { + where: { + id: dataSource.id, + kind: dataSource.kind, + type: DataSourceTypes.DEFAULT, + scope: 'global', + organizationId: user.organizationId, + }, + }); + }; + const globalDataSourceWithSameNameExists = async (dataSource: DataSource) => { + return await manager.findOne(DataSource, { + where: { + name: dataSource.name, + kind: dataSource.kind, + type: DataSourceTypes.DEFAULT, + scope: 'global', + organizationId: user.organizationId, + }, + }); + }; + const existingDatasource = + (await globalDataSourceWithSameIdExists(dataSource)) || (await globalDataSourceWithSameNameExists(dataSource)); + + if (existingDatasource) return existingDatasource; + + const createDsFromPluginInstalled = async (ds: DataSource): Promise => { + const plugin = await manager.findOneOrFail(Plugin, { + where: { + pluginId: dataSource.kind, + }, + }); + + if (plugin) { + const newDataSource = manager.create(DataSource, { + organizationId: user.organizationId, + name: dataSource.name, + kind: dataSource.kind, + type: DataSourceTypes.DEFAULT, + appVersionId, + scope: 'global', + pluginId: plugin.id, + }); + await manager.save(newDataSource); + + return newDataSource; + } + }; + + const createNewGlobalDs = async (ds: DataSource): Promise => { + const newDataSource = manager.create(DataSource, { + organizationId: user.organizationId, + name: dataSource.name, + kind: dataSource.kind, + type: DataSourceTypes.DEFAULT, + appVersionId, + scope: 'global', + pluginId: null, + }); + await manager.save(newDataSource); + + return newDataSource; + }; + + if (isPlugin) { + return await createDsFromPluginInstalled(dataSource); + } else { + return await createNewGlobalDs(dataSource); + } + } + + async associateAppEnvironmentsToAppVersion( + manager: EntityManager, + user: User, + appEnvironments: Record[], + appVersion: AppVersion, + appResourceMappings: AppResourceMappings + ) { + appResourceMappings = { ...appResourceMappings }; + const currentOrgEnvironments = await this.appEnvironmentService.getAll(user.organizationId, manager); + + if (!appEnvironments?.length) { + currentOrgEnvironments.map((env) => (appResourceMappings.appEnvironmentMapping[env.id] = env.id)); + } else if (appEnvironments?.length && appEnvironments[0]?.appVersionId) { + const appVersionedEnvironments = appEnvironments.filter( + (appEnv: { appVersionId: string }) => appEnv.appVersionId === appVersion.id + ); + for (const currentOrgEnv of currentOrgEnvironments) { + const appEnvironment = appVersionedEnvironments.filter( + (appEnv: { name: string }) => appEnv.name === currentOrgEnv.name + )[0]; + if (appEnvironment) { + appResourceMappings.appEnvironmentMapping[appEnvironment.id] = currentOrgEnv.id; + } + } + } else { + //For apps imported on v2 where organizationId not available + for (const currentOrgEnv of currentOrgEnvironments) { + const appEnvironment = appEnvironments.filter( + (appEnv: { name: string }) => appEnv.name === currentOrgEnv.name + )[0]; + if (appEnvironment) { + appResourceMappings.appEnvironmentMapping[appEnvironment.id] = currentOrgEnv.id; + } + } + } + + return appResourceMappings; + } + + async createAppVersionsForImportedApp( + manager: EntityManager, + user: User, + importedApp: App, + appVersions: AppVersion[], + appResourceMappings: AppResourceMappings + ) { + appResourceMappings = { ...appResourceMappings }; + const { appVersionMapping, appDefaultEnvironmentMapping } = appResourceMappings; + const organization: Organization = await manager.findOne(Organization, { + where: { id: user.organizationId }, + relations: ['appEnvironments'], + }); + let currentEnvironmentId: string; + + for (const appVersion of appVersions) { + const appEnvIds: string[] = [...organization.appEnvironments.map((env) => env.id)]; //app is exported to CE if (defaultAppEnvironments.length === 1) { @@ -339,237 +718,19 @@ export class AppImportExportService { }); await manager.save(version); - appDefaultEnvironmentMapping[appVersion.id] = envIdArray; + appDefaultEnvironmentMapping[appVersion.id] = appEnvIds; appVersionMapping[appVersion.id] = version.id; } - // associate App environments for each of the app versions - for (const appVersion of appVersions) { - const currentOrgEnvironments = await this.appEnvironmentService.getAll(user.organizationId, manager); - - if (!appEnvironments?.length) { - currentOrgEnvironments.map((env) => (appEnvironmentMapping[env.id] = env.id)); - } else if (appEnvironments?.length && appEnvironments[0]?.appVersionId) { - const appVersionedEnvironments = appEnvironments.filter((appEnv) => appEnv.appVersionId === appVersion.id); - for (const currentOrgEnv of currentOrgEnvironments) { - const appEnvironment = appVersionedEnvironments.filter((appEnv) => appEnv.name === currentOrgEnv.name)[0]; - if (appEnvironment) { - appEnvironmentMapping[appEnvironment.id] = currentOrgEnv.id; - } - } - } else { - //For apps imported on v2 where organizationId not available - for (const currentOrgEnv of currentOrgEnvironments) { - const appEnvironment = appEnvironments.filter((appEnv) => appEnv.name === currentOrgEnv.name)[0]; - if (appEnvironment) { - appEnvironmentMapping[appEnvironment.id] = currentOrgEnv.id; - } - } - } - - const dsKindsToCreate = []; - - if (!dataSources?.some((ds) => ds.kind === 'restapi' && ds.type === DataSourceTypes.STATIC)) { - dsKindsToCreate.push('restapi'); - } - - if (!dataSources?.some((ds) => ds.kind === 'runjs' && ds.type === DataSourceTypes.STATIC)) { - dsKindsToCreate.push('runjs'); - } - - if (!dataSources?.some((ds) => ds.kind === 'tooljetdb' && ds.type === DataSourceTypes.STATIC)) { - dsKindsToCreate.push('tooljetdb'); - } - - if (!dataSources?.some((ds) => ds.kind === 'runpy' && ds.type === DataSourceTypes.STATIC)) { - dsKindsToCreate.push('runpy'); - } - - if (dsKindsToCreate.length > 0) { - // Create default data sources - defaultDataSourceIdMapping[appVersion.id] = await this.createDefaultDataSourceForVersion( - user.organizationId, - appVersionMapping[appVersion.id], - dsKindsToCreate, - manager - ); - } - - let dataSourcesToIterate = dataSources.map((ds) => ds.appVersionId); // 0.9.0 -> add all data sources & queries to all versions - let dataQueriesToIterate = dataQueries; - - if (dataSources[0]?.appVersionId || dataQueries[0]?.appVersionId) { - // v1 - Data queries without dataSourceId present - dataSourcesToIterate = dataSources?.filter((ds) => ds.appVersionId === appVersion.id); - dataQueriesToIterate = dataQueries?.filter((dq) => !dq.dataSourceId && dq.appVersionId === appVersion.id); - } - - // associate data sources and queries for each of the app versions - for (const source of dataSourcesToIterate) { - const newSource = manager.create(DataSource, { - name: source.name, - kind: source.kind, - type: source.type || DataSourceTypes.DEFAULT, - appVersionId: appVersionMapping[appVersion.id], - pluginId: source?.pluginId || null, - }); - await manager.save(newSource); - - if (source.options) { - // v1 - const convertedOptions = this.convertToArrayOfKeyValuePairs(source.options); - - await Promise.all( - appDefaultEnvironmentMapping[appVersion.id].map(async (envId) => { - const newOptions = await this.dataSourcesService.parseOptionsForCreate(convertedOptions, true, manager); - const dsOption = manager.create(DataSourceOptions, { - environmentId: envId, - dataSourceId: newSource.id, - options: newOptions, - createdAt: new Date(), - updatedAt: new Date(), - }); - await manager.save(dsOption); - }) - ); - } - - const localDatasourceOptions = dataSourceOptions.filter((dso) => dso.dataSourceId === source.id); - //create the options for current datasource if the datasource doesn't have any environment ds-options - if (appEnvironments?.length !== appDefaultEnvironmentMapping[appVersion.id].length) { - const availableEnvironments = localDatasourceOptions.map( - (option: any) => appEnvironmentMapping[option.environmentId] - ); - const otherEnvironmentsIds = appDefaultEnvironmentMapping[appVersion.id].filter( - (defaultEnv: any) => !availableEnvironments.includes(defaultEnv) - ); - const defaultEnvDsOption = localDatasourceOptions.find( - (dso: any) => dso.environmentId === defaultAppEnvironmentId - ); - for (const otherEnvironmentId of otherEnvironmentsIds) { - await this.createDatasourceOption(manager, defaultEnvDsOption.options, otherEnvironmentId, newSource.id); - } - } - - for (const dataSourceOption of localDatasourceOptions) { - if (dataSourceOption?.environmentId in appEnvironmentMapping) { - await this.createDatasourceOption( - manager, - dataSourceOption.options, - appEnvironmentMapping[dataSourceOption.environmentId], - newSource.id - ); - } - } - - for (const query of dataQueries.filter((dq) => dq.dataSourceId === source.id)) { - const newQuery = manager.create(DataQuery, { - name: query.name, - options: query.options, - dataSourceId: newSource.id, - appVersionId: appVersionMapping[appVersion.id], - }); - await manager.save(newQuery); - dataQueryMapping[query.id] = newQuery.id; - newDataQueries.push(newQuery); - } - } - - for (const query of dataQueriesToIterate) { - // for v1 - const newQuery = manager.create(DataQuery, { - name: query.name, - options: query.options, - dataSourceId: defaultDataSourceIdMapping[appVersion.id][query.kind], - appVersionId: appVersionMapping[appVersion.id], - }); - await manager.save(newQuery); - dataQueryMapping[query.id] = newQuery.id; - newDataQueries.push(newQuery); - } - } - - //Convert Global DataSources to Local - const globalDataSourcesToIterate = dataSources?.filter((ds) => ds.scope === DataSourceScopes.GLOBAL); - - for (const appVersion of appVersions) { - for (const source of globalDataSourcesToIterate) { - const newSource = manager.create(DataSource, { - name: source.name, - kind: source.kind, - type: source.type || DataSourceTypes.DEFAULT, - pluginId: source?.pluginId || null, - appVersionId: appVersionMapping[appVersion.id], - }); - await manager.save(newSource); - - const globalDatasourceOptions = dataSourceOptions.filter((dso) => dso.dataSourceId === source.id); - //create the options for current datasource if the datasource doesn't have any environment ds-options - if (appEnvironments?.length !== appDefaultEnvironmentMapping[appVersion.id].length) { - const availableEnvironments = globalDatasourceOptions.map( - (option: any) => appEnvironmentMapping[option.environmentId] - ); - const otherEnvironmentsIds = appDefaultEnvironmentMapping[appVersion.id].filter( - (defaultEnv: any) => !availableEnvironments.includes(defaultEnv) - ); - const defaultEnvDsOption = globalDatasourceOptions.find( - (dso: any) => dso.environmentId === defaultAppEnvironmentId - ); - for (const otherEnvironmentId of otherEnvironmentsIds) { - await this.createDatasourceOption(manager, defaultEnvDsOption.options, otherEnvironmentId, newSource.id); - } - } - - for (const dataSourceOption of globalDatasourceOptions) { - if (dataSourceOption?.environmentId in appEnvironmentMapping) { - await this.createDatasourceOption( - manager, - dataSourceOption.options, - appEnvironmentMapping[dataSourceOption.environmentId], - newSource.id - ); - } - } - for (const query of dataQueries.filter( - (dq) => dq.dataSourceId === source.id && dq.appVersionId === appVersion.id - )) { - const newQuery = manager.create(DataQuery, { - name: query.name, - options: query.options, - dataSourceId: newSource.id, - appVersionId: appVersionMapping[appVersion.id], - }); - await manager.save(newQuery); - dataQueryMapping[query.id] = newQuery.id; - newDataQueries.push(newQuery); - } - } - } - - for (const newQuery of newDataQueries) { - const newOptions = this.replaceDataQueryOptionsWithNewDataQueryIds(newQuery.options, dataQueryMapping); - newQuery.options = newOptions; - await manager.save(newQuery); - } - - for (const appVersion of appVersions) { - await manager.update( - AppVersion, - { id: appVersionMapping[appVersion.id] }, - { definition: this.replaceDataQueryIdWithinDefinitions(appVersion.definition, dataQueryMapping) } - ); - } - - await this.setEditingVersionAsLatestVersion(manager, appVersionMapping, appVersions); + return appResourceMappings; } async createDefaultDataSourceForVersion( organizationId: string, versionId: string, - kinds: string[] = ['restapi', 'runjs', 'tooljetdb'], + kinds: DefaultDataSourceKind[], manager: EntityManager ): Promise { - //create default data sources const response = {}; for (const defaultSource of kinds) { const dataSource = await this.dataSourcesService.createDefaultDataSource(defaultSource, versionId, null, manager); @@ -613,7 +774,12 @@ export class AppImportExportService { } } - async createDatasourceOption(manager: EntityManager, options: any, environmentId: string, dataSourceId: string) { + async createDatasourceOption( + manager: EntityManager, + options: Record, + environmentId: string, + dataSourceId: string + ) { const convertedOptions = this.convertToArrayOfKeyValuePairs(options); const newOptions = await this.dataSourcesService.parseOptionsForCreate(convertedOptions, true, manager); const dsOption = manager.create(DataSourceOptions, { @@ -626,7 +792,7 @@ export class AppImportExportService { await manager.save(dsOption); } - convertToArrayOfKeyValuePairs(options): Array { + convertToArrayOfKeyValuePairs(options: Record): Array { if (!options) return; return Object.keys(options).map((key) => { return { @@ -637,9 +803,12 @@ export class AppImportExportService { }); } - replaceDataQueryOptionsWithNewDataQueryIds(options, dataQueryMapping) { + replaceDataQueryOptionsWithNewDataQueryIds( + options: { events: Record[] }, + dataQueryMapping: Record + ) { if (options && options.events) { - const replacedEvents = options.events.map((event) => { + const replacedEvents = options.events.map((event: { queryId: string }) => { if (event.queryId) { event.queryId = dataQueryMapping[event.queryId]; } @@ -650,11 +819,14 @@ export class AppImportExportService { return options; } - replaceDataQueryIdWithinDefinitions(definition, dataQueryMapping) { + replaceDataQueryIdWithinDefinitions( + definition: QueryDeepPartialEntity, + dataQueryMapping: Record + ): QueryDeepPartialEntity { if (definition?.pages) { for (const pageId of Object.keys(definition?.pages)) { if (definition.pages[pageId].events) { - const replacedPageEvents = definition.pages[pageId].events.map((event) => { + const replacedPageEvents = definition.pages[pageId].events.map((event: { queryId: string }) => { if (event.queryId) { event.queryId = dataQueryMapping[event.queryId]; } @@ -667,7 +839,7 @@ export class AppImportExportService { const component = definition.pages[pageId].components[id].component; if (component?.definition?.events) { - const replacedComponentEvents = component.definition.events.map((event) => { + const replacedComponentEvents = component.definition.events.map((event: { queryId: string }) => { if (event.queryId) { event.queryId = dataQueryMapping[event.queryId]; } @@ -679,7 +851,7 @@ export class AppImportExportService { if (component?.definition?.properties?.actions?.value) { for (const value of component.definition.properties.actions.value) { if (value?.events) { - const replacedComponentActionEvents = value.events.map((event) => { + const replacedComponentActionEvents = value.events.map((event: { queryId: string }) => { if (event.queryId) { event.queryId = dataQueryMapping[event.queryId]; } @@ -693,7 +865,7 @@ export class AppImportExportService { if (component?.component === 'Table') { for (const column of component?.definition?.properties?.columns?.value ?? []) { if (column?.events) { - const replacedComponentActionEvents = column.events.map((event) => { + const replacedComponentActionEvents = column.events.map((event: { queryId: string }) => { if (event.queryId) { event.queryId = dataQueryMapping[event.queryId]; } @@ -711,12 +883,143 @@ export class AppImportExportService { } return definition; } + + async performLegacyAppImport( + manager: EntityManager, + importedApp: App, + appParams: any, + externalResourceMappings: any, + user: any + ) { + const dataSourceMapping = {}; + const dataQueryMapping = {}; + const dataSources = appParams?.dataSources || []; + const dataQueries = appParams?.dataQueries || []; + let currentEnvironmentId = null; + + const version = manager.create(AppVersion, { + appId: importedApp.id, + definition: appParams.definition, + name: 'v1', + currentEnvironmentId, + createdAt: new Date(), + updatedAt: new Date(), + }); + await manager.save(version); + + // Create default data sources + const defaultDataSourceIds = await this.createDefaultDataSourceForVersion( + user.organizationId, + version.id, + DefaultDataSourceKinds, + manager + ); + let envIdArray: string[] = []; + + const organization: Organization = await manager.findOne(Organization, { + where: { id: user.organizationId }, + relations: ['appEnvironments'], + }); + envIdArray = [...organization.appEnvironments.map((env) => env.id)]; + + if (!envIdArray.length) { + await Promise.all( + defaultAppEnvironments.map(async (en) => { + const env = manager.create(AppEnvironment, { + organizationId: user.organizationId, + name: en.name, + isDefault: en.isDefault, + priority: en.priority, + createdAt: new Date(), + updatedAt: new Date(), + }); + await manager.save(env); + if (defaultAppEnvironments.length === 1 || en.priority === 1) { + currentEnvironmentId = env.id; + } + envIdArray.push(env.id); + }) + ); + } else { + //get starting env from the organization environments list + const { appEnvironments } = organization; + if (appEnvironments.length === 1) currentEnvironmentId = appEnvironments[0].id; + else { + appEnvironments.map((appEnvironment) => { + if (appEnvironment.priority === 1) currentEnvironmentId = appEnvironment.id; + }); + } + } + + for (const source of dataSources) { + const convertedOptions = this.convertToArrayOfKeyValuePairs(source.options); + + const newSource = manager.create(DataSource, { + name: source.name, + kind: source.kind, + appVersionId: version.id, + }); + await manager.save(newSource); + dataSourceMapping[source.id] = newSource.id; + + await Promise.all( + envIdArray.map(async (envId) => { + let newOptions: Record; + if (source.options) { + newOptions = await this.dataSourcesService.parseOptionsForCreate(convertedOptions, true, manager); + } + + const dsOption = manager.create(DataSourceOptions, { + environmentId: envId, + dataSourceId: newSource.id, + options: newOptions, + createdAt: new Date(), + updatedAt: new Date(), + }); + await manager.save(dsOption); + }) + ); + } + + const newDataQueries = []; + for (const query of dataQueries) { + const dataSourceId = dataSourceMapping[query.dataSourceId]; + const newQuery = manager.create(DataQuery, { + name: query.name, + dataSourceId: !dataSourceId ? defaultDataSourceIds[query.kind] : dataSourceId, + appVersionId: query.appVersionId, + options: + dataSourceId == defaultDataSourceIds['tooljetdb'] + ? this.replaceTooljetDbTableIds(query.options, externalResourceMappings['tooljet_database']) + : query.options, + }); + await manager.save(newQuery); + dataQueryMapping[query.id] = newQuery.id; + newDataQueries.push(newQuery); + } + + for (const newQuery of newDataQueries) { + const newOptions = this.replaceDataQueryOptionsWithNewDataQueryIds(newQuery.options, dataQueryMapping); + newQuery.options = newOptions; + await manager.save(newQuery); + } + + await manager.update( + AppVersion, + { id: version.id }, + { definition: this.replaceDataQueryIdWithinDefinitions(version.definition, dataQueryMapping) } + ); + } + + replaceTooljetDbTableIds(queryOptions: any, tooljetDatabaseMapping: any) { + return { ...queryOptions, table_id: tooljetDatabaseMapping[queryOptions.table_id]?.id }; + } } function convertSinglePageSchemaToMultiPageSchema(appParams: any) { const appParamsWithMultipageSchema = { ...appParams, - appVersions: appParams.appVersions?.map((appVersion) => ({ + appVersions: appParams.appVersions?.map((appVersion: { definition: any }) => ({ ...appVersion, definition: convertAppDefinitionFromSinglePageToMultiPage(appVersion.definition), })), diff --git a/server/src/services/apps.service.ts b/server/src/services/apps.service.ts index a3f8c68b3f..a55a90cd72 100644 --- a/server/src/services/apps.service.ts +++ b/server/src/services/apps.service.ts @@ -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 }; + }); + }); + } } diff --git a/server/src/services/import_export_resources.service.ts b/server/src/services/import_export_resources.service.ts new file mode 100644 index 0000000000..2ca413c15e --- /dev/null +++ b/server/src/services/import_export_resources.service.ts @@ -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; + } +} diff --git a/server/src/services/postgrest_proxy.service.ts b/server/src/services/postgrest_proxy.service.ts index 9cc3391fb6..edd818fc49 100644 --- a/server/src/services/postgrest_proxy.service.ts +++ b/server/src/services/postgrest_proxy.service.ts @@ -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('PG_USER')); req.headers = {}; @@ -25,8 +26,8 @@ export class PostgrestProxyService { private httpProxy = proxy(this.configService.get('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, ''); diff --git a/server/src/services/tooljet_db.service.ts b/server/src/services/tooljet_db.service.ts index c8e9d985fd..24c6f5cca0 100644 --- a/server/src/services/tooljet_db.service.ts +++ b/server/src/services/tooljet_db.service.ts @@ -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); diff --git a/server/src/services/tooljet_db_import_export_service.ts b/server/src/services/tooljet_db_import_export_service.ts new file mode 100644 index 0000000000..c91260b4b9 --- /dev/null +++ b/server/src/services/tooljet_db_import_export_service.ts @@ -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 { + const dtoColumns = new Set(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(internalTableColumnSchema.map((c) => c.column_name)); + const isSubset = (subset: Set, superset: Set) => [...subset].every((item) => superset.has(item)); + + return isSubset(dtoColumns, internalTableColumns); + } +} diff --git a/server/test/services/app_import_export.service.spec.ts b/server/test/services/app_import_export.service.spec.ts index c4299ae55a..51ee5528ad 100644 --- a/server/test/services/app_import_export.service.spec.ts +++ b/server/test/services/app_import_export.service.spec.ts @@ -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, {