From b5d08402ec48b909261ebad810b5a9e9859803c7 Mon Sep 17 00:00:00 2001 From: Akshay Date: Thu, 21 Sep 2023 11:54:03 +0530 Subject: [PATCH 1/8] Add support for bigint (#7458) --- frontend/src/TooljetDatabase/Forms/EditRowForm.jsx | 1 + frontend/src/TooljetDatabase/Forms/RowForm.jsx | 1 + frontend/src/TooljetDatabase/Table/index.jsx | 2 ++ frontend/src/TooljetDatabase/constants.js | 1 + server/src/dto/tooljet-db.dto.ts | 2 +- 5 files changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/TooljetDatabase/Forms/EditRowForm.jsx b/frontend/src/TooljetDatabase/Forms/EditRowForm.jsx index c5898fc1c4..9dccfde238 100644 --- a/frontend/src/TooljetDatabase/Forms/EditRowForm.jsx +++ b/frontend/src/TooljetDatabase/Forms/EditRowForm.jsx @@ -156,6 +156,7 @@ const RenderElement = ({ columnName, dataType, isPrimaryKey, defaultValue, value switch (dataType) { case 'character varying': case 'integer': + case 'bigint': case 'serial': case 'double precision': return ( diff --git a/frontend/src/TooljetDatabase/Forms/RowForm.jsx b/frontend/src/TooljetDatabase/Forms/RowForm.jsx index 79df84b77e..f43afd9891 100644 --- a/frontend/src/TooljetDatabase/Forms/RowForm.jsx +++ b/frontend/src/TooljetDatabase/Forms/RowForm.jsx @@ -51,6 +51,7 @@ const RowForm = ({ onCreate, onClose }) => { switch (dataType) { case 'character varying': case 'integer': + case 'bigint': case 'serial': case 'double precision': return ( diff --git a/frontend/src/TooljetDatabase/Table/index.jsx b/frontend/src/TooljetDatabase/Table/index.jsx index 5fee5fb8a1..0cc2b90c09 100644 --- a/frontend/src/TooljetDatabase/Table/index.jsx +++ b/frontend/src/TooljetDatabase/Table/index.jsx @@ -109,6 +109,8 @@ const Table = ({ openCreateRowDrawer, openCreateColumnDrawer }) => { switch (type) { case 'integer': return 'int'; + case 'bigint': + return 'bigint'; case 'character varying': return 'varchar'; case 'boolean': diff --git a/frontend/src/TooljetDatabase/constants.js b/frontend/src/TooljetDatabase/constants.js index 5409c8584e..d5f8701b7f 100644 --- a/frontend/src/TooljetDatabase/constants.js +++ b/frontend/src/TooljetDatabase/constants.js @@ -1,6 +1,7 @@ export const dataTypes = [ { value: 'character varying', label: 'varchar' }, { value: 'integer', label: 'int' }, + { value: 'bigint', label: 'bigint' }, { value: 'double precision', label: 'float' }, { value: 'boolean', label: 'boolean' }, ]; diff --git a/server/src/dto/tooljet-db.dto.ts b/server/src/dto/tooljet-db.dto.ts index 7a79482d3f..3ccc737608 100644 --- a/server/src/dto/tooljet-db.dto.ts +++ b/server/src/dto/tooljet-db.dto.ts @@ -49,7 +49,7 @@ export class MatchTypeConstraint implements ValidatorConstraintInterface { return typeof value === 'string'; } - if (relatedType === 'integer' || relatedType === 'double precision') { + if (relatedType === 'integer' || relatedType === 'bigint' || relatedType === 'double precision') { const isInt = Number.isInteger(value); const isFloat = !Number.isInteger(value) && !isNaN(value); return isInt || isFloat; From b0a72614d68ede64de39055d358603b82a1923cf Mon Sep 17 00:00:00 2001 From: Akshay Date: Wed, 27 Sep 2023 13:29:42 +0530 Subject: [PATCH 2/8] update plugin deletion error message (#7503) --- .../src/MarketplacePage/InstalledPlugins.jsx | 16 +++++++++++++++- frontend/src/_components/ConfirmDialog.jsx | 3 ++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/frontend/src/MarketplacePage/InstalledPlugins.jsx b/frontend/src/MarketplacePage/InstalledPlugins.jsx index 1066c8552c..181ca0e71d 100644 --- a/frontend/src/MarketplacePage/InstalledPlugins.jsx +++ b/frontend/src/MarketplacePage/InstalledPlugins.jsx @@ -109,15 +109,29 @@ const InstalledPluginCard = ({ plugin, marketplacePlugin, fetchPlugins, isDevMod toast.success(`${capitalizeFirstLetter(name)} reloaded`); }; + const pluginDeleteMessage = ( + <> + Deleting {capitalizeFirstLetter(name)} plugin will result in the permanent removal of all + associated datasources and its dataqueries. This action cannot be undone. Are you sure you wish to proceed with + the deletion? + + ); + return ( <>
diff --git a/frontend/src/_components/ConfirmDialog.jsx b/frontend/src/_components/ConfirmDialog.jsx index 4dc30e59a3..66c3f8b66b 100644 --- a/frontend/src/_components/ConfirmDialog.jsx +++ b/frontend/src/_components/ConfirmDialog.jsx @@ -17,6 +17,7 @@ export function ConfirmDialog({ cancelButtonText = 'Cancel', backdropClassName, onCloseIconClick, + footerStyle, }) { darkMode = darkMode ?? (localStorage.getItem('darkMode') === 'true' || false); const [showModal, setShow] = useState(show); @@ -73,7 +74,7 @@ export function ConfirmDialog({ {message} - + {cancelButtonText ?? t('globals.cancel', 'Cancel')} From 03a7f54b476cb912d3df8e7f377e83b54a3521bd Mon Sep 17 00:00:00 2001 From: Ganesh Kumar <40178541+ganesh8056@users.noreply.github.com> Date: Wed, 27 Sep 2023 13:40:02 +0530 Subject: [PATCH 3/8] Bug/installed plugins cardsize different (#7506) * fix: plugins card will be of same height * stylefix:removed additional margin top for plugin card * fix:module error has been fixed --- frontend/package-lock.json | 91 ------------------- .../src/MarketplacePage/InstalledPlugins.jsx | 4 +- .../src/MarketplacePage/MarketplaceCard.jsx | 4 +- frontend/src/_styles/theme.scss | 7 ++ 4 files changed, 11 insertions(+), 95 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e44e76c767..d8c2c5848d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14248,50 +14248,6 @@ "dev": true, "license": "ISC" }, - "../plugins/node_modules/mysql": { - "version": "2.18.1", - "license": "MIT", - "dependencies": { - "bignumber.js": "9.0.0", - "readable-stream": "2.3.7", - "safe-buffer": "5.1.2", - "sqlstring": "2.3.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "../plugins/node_modules/mysql/node_modules/bignumber.js": { - "version": "9.0.0", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "../plugins/node_modules/mysql/node_modules/readable-stream": { - "version": "2.3.7", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "../plugins/node_modules/mysql/node_modules/safe-buffer": { - "version": "5.1.2", - "license": "MIT" - }, - "../plugins/node_modules/mysql/node_modules/string_decoder": { - "version": "1.1.1", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "../plugins/node_modules/napi-build-utils": { "version": "1.0.2", "license": "MIT", @@ -17818,13 +17774,6 @@ "version": "1.0.3", "license": "BSD-3-Clause" }, - "../plugins/node_modules/sqlstring": { - "version": "2.3.1", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "../plugins/node_modules/sshpk": { "version": "1.17.0", "license": "MIT", @@ -20141,7 +20090,6 @@ "dependencies": { "@tooljet-plugins/common": "file:../common", "knex": "^0.95.15", - "mysql": "^2.18.1", "mysql2": "^3.6.0", "react": "^17.0.2", "rimraf": "^3.0.2" @@ -67962,7 +67910,6 @@ "requires": { "@tooljet-plugins/common": "file:../common", "knex": "^0.95.15", - "mysql": "^2.18.1", "mysql2": "^3.6.0", "react": "^17.0.2", "rimraf": "^3.0.2" @@ -73332,41 +73279,6 @@ "version": "0.0.8", "dev": true }, - "mysql": { - "version": "2.18.1", - "requires": { - "bignumber.js": "9.0.0", - "readable-stream": "2.3.7", - "safe-buffer": "5.1.2", - "sqlstring": "2.3.1" - }, - "dependencies": { - "bignumber.js": { - "version": "9.0.0" - }, - "readable-stream": { - "version": "2.3.7", - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "safe-buffer": { - "version": "5.1.2" - }, - "string_decoder": { - "version": "1.1.1", - "requires": { - "safe-buffer": "~5.1.0" - } - } - } - }, "napi-build-utils": { "version": "1.0.2", "optional": true @@ -75740,9 +75652,6 @@ "sprintf-js": { "version": "1.0.3" }, - "sqlstring": { - "version": "2.3.1" - }, "sshpk": { "version": "1.17.0", "requires": { diff --git a/frontend/src/MarketplacePage/InstalledPlugins.jsx b/frontend/src/MarketplacePage/InstalledPlugins.jsx index 181ca0e71d..0b11923c0e 100644 --- a/frontend/src/MarketplacePage/InstalledPlugins.jsx +++ b/frontend/src/MarketplacePage/InstalledPlugins.jsx @@ -135,7 +135,7 @@ const InstalledPluginCard = ({ plugin, marketplacePlugin, fetchPlugins, isDevMod />
-
+
@@ -177,7 +177,7 @@ const InstalledPluginCard = ({ plugin, marketplacePlugin, fetchPlugins, isDevMod )}
-
+
diff --git a/frontend/src/MarketplacePage/MarketplaceCard.jsx b/frontend/src/MarketplacePage/MarketplaceCard.jsx index 5a6146cdde..e31f388101 100644 --- a/frontend/src/MarketplacePage/MarketplaceCard.jsx +++ b/frontend/src/MarketplacePage/MarketplaceCard.jsx @@ -45,7 +45,7 @@ export const MarketplaceCard = ({ id, name, repo, description, version, isInstal return (
-
+
@@ -57,7 +57,7 @@ export const MarketplaceCard = ({ id, name, repo, description, version, isInstal
{description}
-
+
v{version} diff --git a/frontend/src/_styles/theme.scss b/frontend/src/_styles/theme.scss index 5eaf15fb4d..80f04bf8cf 100644 --- a/frontend/src/_styles/theme.scss +++ b/frontend/src/_styles/theme.scss @@ -10924,6 +10924,13 @@ tbody { border: 1px solid var(--slate3); box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05); border-radius: 6px; + + .card-body-alignment { + min-height: 145px; + display: flex; + flex-direction: column; + justify-content: space-between; + } } .template-source-name { From 9cca9c5e3630d200a61348dca2aef4a72cd0ee36 Mon Sep 17 00:00:00 2001 From: Ganesh Kumar <40178541+ganesh8056@users.noreply.github.com> Date: Wed, 27 Sep 2023 15:05:40 +0530 Subject: [PATCH 4/8] tjdb dropdown menu flickering issue fixed (#7483) --- .../TableListItem/ActionsPopover/index.jsx | 43 ++++++++----------- .../TooljetDatabase/TableListItem/index.jsx | 36 ++++++++++++++-- frontend/src/_styles/theme.scss | 8 ---- 3 files changed, 52 insertions(+), 35 deletions(-) diff --git a/frontend/src/TooljetDatabase/TableListItem/ActionsPopover/index.jsx b/frontend/src/TooljetDatabase/TableListItem/ActionsPopover/index.jsx index b5c6228553..ae576fb50e 100644 --- a/frontend/src/TooljetDatabase/TableListItem/ActionsPopover/index.jsx +++ b/frontend/src/TooljetDatabase/TableListItem/ActionsPopover/index.jsx @@ -7,8 +7,10 @@ import EditIcon from './Icons/Edit.svg'; import DeleteIcon from './Icons/Delete.svg'; import SolidIcon from '@/_ui/Icon/SolidIcons'; -export const ListItemPopover = ({ onEdit, onDelete, darkMode }) => { - const [open, setOpen] = React.useState(false); +export const ListItemPopover = ({ onEdit, onDelete, darkMode, onMenuToggle }) => { + const closeMenu = () => { + document.body.click(); + }; const popover = ( @@ -20,8 +22,9 @@ export const ListItemPopover = ({ onEdit, onDelete, darkMode }) => {
{ - setOpen(false); + onClick={(event) => { + event.stopPropagation(); + closeMenu(); onEdit(); }} > @@ -38,7 +41,14 @@ export const ListItemPopover = ({ onEdit, onDelete, darkMode }) => {
-
+
{ + closeMenu(); + onDelete(); + }} + > Delete
@@ -47,27 +57,12 @@ export const ListItemPopover = ({ onEdit, onDelete, darkMode }) => { ); return ( -
- { - setOpen(isOpen); - }} - show={open} - rootClose - trigger="click" - placement="bottom" - overlay={popover} - transition={false} - > + +
- -
+
+ ); }; diff --git a/frontend/src/TooljetDatabase/TableListItem/index.jsx b/frontend/src/TooljetDatabase/TableListItem/index.jsx index 5c1e9fea40..c8499cf636 100644 --- a/frontend/src/TooljetDatabase/TableListItem/index.jsx +++ b/frontend/src/TooljetDatabase/TableListItem/index.jsx @@ -1,6 +1,5 @@ -import React, { useState, useContext } from 'react'; +import React, { useState, useContext, useCallback, useEffect } from 'react'; import cx from 'classnames'; - import { toast } from 'react-hot-toast'; import { tooljetDatabaseService } from '@/_services'; import { ListItemPopover } from './ActionsPopover'; @@ -14,6 +13,9 @@ export const ListItem = ({ active, onClick, text = '', onDeleteCallback }) => { const { organizationId, columns, selectedTable, setTables, setSelectedTable } = useContext(TooljetDatabaseContext); const [isEditTableDrawerOpen, setIsEditTableDrawerOpen] = useState(false); const darkMode = localStorage.getItem('darkMode') === 'true'; + const [isHovered, setIsHovered] = useState(false); + const [showDropDownMenu, setShowDropDownMenu] = useState(false); + const [focused, setFocused] = useState(false); function updateSelectedTable(tableObj) { setSelectedTable(tableObj); @@ -39,8 +41,23 @@ export const ListItem = ({ active, onClick, text = '', onDeleteCallback }) => { return acc; }, {}); + const onMenuToggle = useCallback( + (status) => { + setShowDropDownMenu(!!status); + !status && !isHovered && setFocused(false); + }, + [isHovered] + ); + + useEffect(() => { + !showDropDownMenu && setFocused(!!isHovered); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isHovered]); + return (
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} className={cx( 'table-list-item mb-1 rounded-3 d-inline-flex align-items-center justify-content-between h-4 list-group-item cursor-pointer list-group-item-action border-0 py-1', { @@ -59,7 +76,20 @@ export const ListItem = ({ active, onClick, text = '', onDeleteCallback }) => { {text} - setIsEditTableDrawerOpen(true)} onDelete={handleDeleteTable} darkMode={darkMode} /> + {focused && ( +
+ { + setShowDropDownMenu(false); + setIsEditTableDrawerOpen(true); + }} + onDelete={handleDeleteTable} + darkMode={darkMode} + onMenuToggle={onMenuToggle} + /> +
+ )} + Date: Thu, 28 Sep 2023 15:31:18 +0530 Subject: [PATCH 5/8] bump to v2.19.0 --- .version | 2 +- frontend/.version | 2 +- server/.version | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.version b/.version index cf8690732f..ef0f38abe1 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -2.18.0 +2.19.0 diff --git a/frontend/.version b/frontend/.version index 00db22659e..ef0f38abe1 100644 --- a/frontend/.version +++ b/frontend/.version @@ -1 +1 @@ -2.17.5 +2.19.0 diff --git a/server/.version b/server/.version index cf8690732f..ef0f38abe1 100644 --- a/server/.version +++ b/server/.version @@ -1 +1 @@ -2.18.0 +2.19.0 From 72147a0fda3fe692f01e4b6e03441d06425f52f1 Mon Sep 17 00:00:00 2001 From: Ganesh Kumar <40178541+ganesh8056@users.noreply.github.com> Date: Thu, 28 Sep 2023 19:58:47 +0530 Subject: [PATCH 6/8] Feature: Tooljetdb join operations (#7263) * basic and static join query executed * tooljetDB Join operation flow - work inprogress * complete flow for tooljetdb join pending testing and minor changes * updated constructHavingStatement method logic to support aggregation function and added comments * worki in progress tooljetDB Join * feat: added basic layout for tjdb join fields * feat: dropdown support for icons * feat: working on where condition ui in join * feat: added base layout for filter and sort in tooljetdb join * feat: added multi select support and minor style changes * feat: support default value for selectbox * feat: dd select styling added * style: override vanilla dd select styles with tj styles * fix: fixed minor UI issues in select box * feat: added select section layout * feat: added hooks state for join options * feat: load all added tables columns * feat: working on where section logic * feat: join constraints UI * feat: filter condition dropdowns added * feat: join widget for join query op in tjdb * feat: sort section base UI * feat: select widget for join query in tjdb * feat: filter section add option and delete option done * feat: update filter condition logic added * feat: added onchange event for operator and rhs values update * feat: added sort dropdown for tjdb join * feat: base logic for Filters in join query * fix: removed comments and added validation for fetching table details * feat: add limit option logic * feat: backend api has been integrated for tooljetdb joins * added icons to solid icons * fix: jsconfig auto save lint fix * fix: update from table when selected table changes * feat: added from to join table options in tjdb dq * fix: added fetching tables list for JSON in backend * fix: fixed json data for join query * fix: temp fix for fields with empty object * feat: added icon support for dd select * fix: added default state to avoid error in conditionlist * fix: limit tables selection to already joined tables in tjdb join * fix: empty values to orderBy, filters and limit will remove the option from json * fix: in json first level empty value scenario has been handled * fix: select in tooljetdb join query can have multiple columns with same name handled by adding prefix tablename_ to the column name * fix: restrict selectable tables in join contraints * feat: reset join constraints when invlaid joins added * fix: empty values will not be allowed UI validation * fix: codehinter border has been removed * fix: recalculate join data when join tables change * fix: corrected options length calc for showing search box * fix: filter table dropdown must contain only selected tables from join section * fix: empty values validation has been removed * fix: add from attribute to join options * fix: alias is added to all the table column * feat: selected option in Select section will be at the top * fix: reset joins when selected table changed * fix: drop down focus ui * feat: autoselect all columns by defualt for join select * feat: restrict column selection to same datatype * fix: removed blank table names from select * feat: added tooltip for info * fix: removed duplicate tooltip * fix: add button in table dropdown * fix: added from table object back * feat: tjdb join select dropdown select all cols by default * fix: add new table button name corrected * feat: no table selected error message * feat: add select style for select dropdown * style: updated dropdown select style to match new theme * feat: added alert modal for deleting joins * feat: hardcode operator since once one option available at the moment * style: fix icon styles for dropdown * feat: created reusable confirm dialogue * fix: fixed bug for nested dropdowns * fix; cache select components to prevent unnecessory rerenders * feat: reused the common popup on updating the tables * fix: info popup will trigger only if table is already exists * fix: fixed bug that caused edit to break for tjdb join * style: fixed spacing for tjdb join components * fix: select section all options cant be deselected issue fixed * fix: add info icon for empty filter and sort component * feat: offset fature for joins has been added * fix: layout fixed to incorporate filter dropdown with text * fix: basic validation in UI for mandatory and non-mandatory fields * feat: more options added for filter in joins * fix: added filter option for regular expression * fix: fixed wrong autoupdate of join fields * style: updated badge color w.r.t theme * fix: removed the commented code * style fixes * refactor: changed tooljetdb join logic based on tableId instead of name * fix: joins table value is not been shown after save * fix: CSS design fix and removed not required commented codes * feat: tableid to table name mapping in error * fix: errors will be shown in the debugger for tooljetdb join * stylefix: container for join sort and select made full width * stylefix: changed CTA test in popup spacing issue adjusted * fix: few PR review comments to refactor has been done * fix:random id generator has been removed and uuid has been used * feat: Select all functionality in Select Drop down has been added * fix: first time AND operator has been removed * fix:Sort Section - Removed table were listed in the drop down * fix: add more in join section deleting newly created joins * fix: select section total selected count was wrong * stylefix: dropdown menu height has been reduced * fix: sort section on join query will have prefix table name along with column name * feat: changed the select drop down with add new table option * fix: center align text only for join operator drop down * fix join icons to be centred * reduce chevron icon size * fix:error handling by status code * feat: added placeholder for empty select box * fix: fixed the PR comments --------- Co-authored-by: Johnson Cherian Co-authored-by: Akshay Sasidharan --- frontend/jsconfig.json | 14 +- .../QueryEditors/TooljetDatabase/Confirm.jsx | 86 ++++ .../TooljetDatabase/DropDownSelect.jsx | 248 +++++++++ .../TooljetDatabase/JoinConstraint.jsx | 412 +++++++++++++++ .../TooljetDatabase/JoinSelect.jsx | 151 ++++++ .../QueryEditors/TooljetDatabase/JoinSort.jsx | 150 ++++++ .../TooljetDatabase/JoinTable.jsx | 483 ++++++++++++++++++ .../TooljetDatabase/SelectBox.jsx | 302 +++++++++++ .../TooljetDatabase/ToolJetDbOperations.jsx | 358 +++++++++++-- .../TooljetDatabase/operations.js | 80 ++- .../QueryEditors/TooljetDatabase/util.js | 23 + frontend/src/_helpers/appUtils.js | 61 ++- .../src/_services/tooljetDatabase.service.js | 5 + frontend/src/_styles/queryManager.scss | 50 ++ .../src/_ui/Icon/solidIcons/FullOuterJoin.jsx | 19 + .../src/_ui/Icon/solidIcons/Information.jsx | 3 +- .../src/_ui/Icon/solidIcons/InnerJoinIcon.jsx | 19 + .../_ui/Icon/solidIcons/LeftOuterJoinIcon.jsx | 19 + frontend/src/_ui/Icon/solidIcons/Remove.jsx | 3 +- .../_ui/Icon/solidIcons/RightOuterJoin.jsx | 19 + frontend/src/_ui/Icon/solidIcons/Tick.jsx | 3 +- frontend/src/_ui/Icon/solidIcons/index.js | 12 + .../src/controllers/tooljet_db.controller.ts | 12 + .../abilities/tooljet-db-ability.factory.ts | 2 + .../import_export_resources.module.ts | 2 + .../src/services/postgrest_proxy.service.ts | 2 +- server/src/services/tooljet_db.service.ts | 218 +++++++- 27 files changed, 2675 insertions(+), 81 deletions(-) create mode 100644 frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/Confirm.jsx create mode 100644 frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/DropDownSelect.jsx create mode 100644 frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/JoinConstraint.jsx create mode 100644 frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/JoinSelect.jsx create mode 100644 frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/JoinSort.jsx create mode 100644 frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/JoinTable.jsx create mode 100644 frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/SelectBox.jsx create mode 100644 frontend/src/_ui/Icon/solidIcons/FullOuterJoin.jsx create mode 100644 frontend/src/_ui/Icon/solidIcons/InnerJoinIcon.jsx create mode 100644 frontend/src/_ui/Icon/solidIcons/LeftOuterJoinIcon.jsx create mode 100644 frontend/src/_ui/Icon/solidIcons/RightOuterJoin.jsx diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json index 5afc0ca613..19289d519b 100644 --- a/frontend/jsconfig.json +++ b/frontend/jsconfig.json @@ -1,8 +1,10 @@ { - "compilerOptions": { - "baseUrl": "./src", - "paths": { - "@/*": ["./*"] - } + "compilerOptions": { + "baseUrl": "./src", + "paths": { + "@/*": [ + "./*" + ] } - } \ No newline at end of file + } +} \ No newline at end of file diff --git a/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/Confirm.jsx b/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/Confirm.jsx new file mode 100644 index 0000000000..02ece6b299 --- /dev/null +++ b/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/Confirm.jsx @@ -0,0 +1,86 @@ +import React, { useCallback, useState } from 'react'; +import Modal from 'react-bootstrap/Modal'; + +function useConfirm() { + const [show, setShow] = useState(false); + const [message, setMessage] = useState(''); + const [heading, setHeading] = useState('Confirm action?'); + const [handleConfirm, setHandleConfirm] = useState(null); + + const confirm = (message, heading) => { + return new Promise((resolve) => { + setMessage(message); + setHeading(heading); + setShow(true); + + const confirmCallback = (result) => { + setShow(false); + resolve(result); + }; + + setHandleConfirm(() => confirmCallback); + }); + }; + + const ConfirmDialog = useCallback( + ({ confirmButtonText = '', cancelButtonText = '', darkMode }) => { + return ( + handleConfirm(false)} + centered + size="sm" + contentClassName={darkMode ? 'theme-dark dark-theme' : ''} + > + + {heading || 'Confirm action ?'} + handleConfirm(false)} + className="cursor-pointer" + width="33" + height="33" + viewBox="0 0 33 33" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + + + + {message} + + + + + + ); + }, + [show, message, heading, handleConfirm] + ); + + return { confirm, ConfirmDialog }; +} + +export default useConfirm; diff --git a/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/DropDownSelect.jsx b/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/DropDownSelect.jsx new file mode 100644 index 0000000000..41d39663f0 --- /dev/null +++ b/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/DropDownSelect.jsx @@ -0,0 +1,248 @@ +import React, { useEffect, useRef, useState } from 'react'; +import SelectBox from './SelectBox'; +import cx from 'classnames'; +import useShowPopover from '@/_hooks/useShowPopover'; +import { Badge, OverlayTrigger, Popover } from 'react-bootstrap'; +import { ButtonSolid } from '@/_ui/AppButton/AppButton'; +import CheveronDown from '@/_ui/Icon/bulkIcons/CheveronDown'; +import Remove from '@/_ui/Icon/bulkIcons/Remove'; +import { v4 as uuidv4 } from 'uuid'; +import { isEmpty } from 'lodash'; + +const DropDownSelect = ({ + darkMode, + disabled, + options, + isMulti, + addBtnLabel, + onAdd, + onChange, + value, + renderSelected, + emptyError, + shouldCenterAlignText = false, + showPlaceHolder = false, +}) => { + const popoverId = useRef(`dd-select-${uuidv4()}`); + const popoverBtnId = useRef(`dd-select-btn-${uuidv4()}`); + const [showMenu, setShowMenu] = useShowPopover(false, `#${popoverId.current}`, `#${popoverBtnId.current}`); + const [selected, setSelected] = useState(value); + const selectRef = useRef(); + const [isOverflown, setIsOverflown] = useState(false); + + useEffect(() => { + if (showMenu) { + // selectRef.current.focus(); + } + }, [showMenu]); + + useEffect(() => { + if (Array.isArray(value) || selected?.value !== value?.value || selected?.label !== value?.label) { + setSelected(value); + } + }, [value]); + + useEffect(() => { + // onChange && onChange(selected); + const badges = document.querySelectorAll('.dd-select-value-badge'); + if (isEmpty(badges)) { + return () => {}; + } + let isNewOverFlown = false; + for (let i = 0; i < badges.length; i++) { + const el = badges[i]; + isNewOverFlown = el.clientWidth - el.scrollWidth < 0; + if (isOverflown) { + break; + } + } + if (isNewOverFlown !== isOverflown) { + setIsOverflown(isNewOverFlown); + } + }, [selected]); + + function checkElementPosition() { + const selectControl = document.getElementById(popoverBtnId.current); + if (!selectControl) { + return 'top-start'; + } + + const elementRect = selectControl.getBoundingClientRect(); + + // Check proximity to top + const halfScreenHeight = window.innerHeight / 2; + + if (elementRect.top <= halfScreenHeight) { + return 'bottom-start'; + } + + return 'top-start'; + } + + function isValidInput(input) { + if (!input) return false; + if (Array.isArray(input)) { + return input.length ? true : false; + } + if (typeof input === 'object' && !Array.isArray(input)) { + if (!Object.keys(input).length) return false; + if (!input.value) return false; + return true; + } + return true; + } + + return ( + + { + setIsOverflown(false); + onChange && onChange(values); + setSelected(values); + }} + selected={selected} + closePopup={() => setShowMenu(false)} + onAdd={onAdd} + addBtnLabel={addBtnLabel} + emptyError={emptyError} + /> + + } + > + + { + e.stopPropagation(); + if (disabled) { + return; + } + setShowMenu((show) => !show); + }} + className={cx( + { + 'justify-content-start': !shouldCenterAlignText, + 'justify-content-centre': shouldCenterAlignText, + }, + 'tdb-dropdown-btn', + 'gap-0', + 'w-100', + 'border-0', + 'rounded-0', + 'position-relative', + 'font-weight-normal', + 'px-1' + )} + data-cy={`show-ds-popover-button`} + > +
+ {renderSelected && renderSelected(selected)} + + {!renderSelected && isValidInput(selected) ? ( + Array.isArray(selected) ? ( + !isOverflown && ( + + ) + ) : ( + selected?.label + ) + ) : showPlaceHolder ? ( + Select.. + ) : ( + '' + )} + {!renderSelected && isOverflown && !Array.isArray(selected) && ( + + {selected?.length} selected + { + setSelected([]); + onChange && onChange([]); + e.preventDefault(); + e.stopPropagation(); + }} + > + + + + )} +
+
+ +
+
+
+
+ ); +}; + +function MultiSelectValueBadge({ options, selected, setSelected, onChange }) { + if (options?.length === selected?.length && selected?.length !== 0) { + // Filter Options without 'Select All' + const optionsWithoutSelectAll = options.filter((option) => option.value !== 'SELECT ALL'); + return ( + + All {optionsWithoutSelectAll?.length} selected + { + e.stopPropagation(); + setSelected([]); + onChange([]); + e.preventDefault(); + }} + > + + + + ); + } + + return selected.map((option) => ( + + {option.label} + { + setSelected((selected) => { + onChange && onChange(selected.filter((opt) => opt.value !== option.value)); + return selected.filter((opt) => opt.value !== option.value); + }); + e.preventDefault(); + e.stopPropagation(); + }} + > + + + + )); +} + +export default DropDownSelect; diff --git a/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/JoinConstraint.jsx b/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/JoinConstraint.jsx new file mode 100644 index 0000000000..0b742dc9bd --- /dev/null +++ b/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/JoinConstraint.jsx @@ -0,0 +1,412 @@ +import React, { useContext } from 'react'; +import { Container, Row, Col } from 'react-bootstrap'; +import { TooljetDatabaseContext } from '@/TooljetDatabase/index'; +import DropDownSelect from './DropDownSelect'; +import { ButtonSolid } from '@/_ui/AppButton/AppButton'; +import AddRectangle from '@/_ui/Icon/bulkIcons/AddRectangle'; +import Trash from '@/_ui/Icon/solidIcons/Trash'; +import Remove from '@/_ui/Icon/solidIcons/Remove'; +import Information from '@/_ui/Icon/solidIcons/Information'; +import Icon from '@/_ui/Icon/solidIcons/index'; +import set from 'lodash/set'; +import { cloneDeep, isEmpty } from 'lodash'; +import { getPrivateRoute } from '@/_helpers/routes'; +import { useNavigate } from 'react-router-dom'; +import useConfirm from './Confirm'; + +const JoinConstraint = ({ darkMode, index, onRemove, onChange, data }) => { + const { selectedTableId, tables, joinOptions, findTableDetails } = useContext(TooljetDatabaseContext); + const joinType = data?.joinType; + const baseTableDetails = (selectedTableId && findTableDetails(selectedTableId)) || {}; + const conditionsList = isEmpty(data?.conditions?.conditionsList) ? [{}] : data?.conditions?.conditionsList; + + const operator = data?.conditions?.operator; + const leftFieldTable = conditionsList?.[0]?.leftField?.table || selectedTableId; + const rightFieldTable = conditionsList?.[0]?.rightField?.table; + + const navigate = useNavigate(); + const { confirm, ConfirmDialog } = useConfirm(); + + const tableSet = new Set(); + (joinOptions || []) + .filter((_join, i) => i < index) + .forEach((join) => { + const { table, conditions } = join; + tableSet.add(table); + conditions?.conditionsList?.forEach((condition) => { + const { leftField, rightField } = condition; + if (leftField?.table) { + tableSet.add(leftField?.table); + } + if (rightField?.table) { + tableSet.add(rightField?.table); + } + }); + }); + tableSet.add(selectedTableId); + + const leftTableList = [...tableSet] + .filter((table) => table !== rightFieldTable) + .map((t) => { + const tableDetails = findTableDetails(t); + return { label: tableDetails?.table_name ?? '', value: t }; + }); + + const tableList = tables + .filter((table) => ![...tableSet, leftFieldTable].includes(table.table_id)) + .map((t) => { + return { label: t?.table_name ?? '', value: t.table_id }; + }); + + return ( + + + + + Selected Table + + + + Joining Table + + {index !== 0 && ( + + { + const result = await confirm( + 'Deleting a join will also delete its associated conditions. Are you sure you want to continue ?', + 'Delete' + ); + if (result) onRemove(); + }} + > + + + + )} + + + +
Join
+ + + {index ? ( + { + let result = false; + if (leftFieldTable.length) { + result = await confirm( + 'Changing the table will also delete its associated conditions. Are you sure you want to continue?', + 'Change table?' + ); + } else { + result = true; + } + + if (result) { + const newData = cloneDeep({ ...data }); + const { conditionsList = [{}] } = newData?.conditions || {}; + const newConditionsList = conditionsList.map((condition) => { + const newCondition = { ...condition }; + set(newCondition, 'leftField.table', value?.value); + set(newCondition, 'operator', '='); //should we removed when we have more options + return newCondition; + }); + set(newData, 'conditions.conditionsList', newConditionsList); + // set(newData, 'table', value?.value); + onChange(newData); + } + }} + onAdd={() => navigate(getPrivateRoute('database'))} + addBtnLabel={'Add new table'} + value={leftTableList.find((val) => val?.value === leftFieldTable)} + /> + ) : ( +
{baseTableDetails?.table_name ?? ''}
+ )} + + + onChange({ ...data, joinType: value?.value })} + value={staticJoinOperationsList.find((val) => val.value === joinType)} + renderSelected={(selected) => + selected ? ( +
+ +
+ ) : ( + '' + ) + } + /> + + + { + let result = true; + if (rightFieldTable?.length) { + result = await confirm( + 'Changing the table will also delete its associated conditions. Are you sure you want to continue?', + 'Change table?' + ); + } + + if (result) { + const newData = cloneDeep({ ...data }); + const { conditionsList = [] } = newData?.conditions || {}; + const newConditionsList = conditionsList.map((condition) => { + const newCondition = { ...condition }; + set(newCondition, 'rightField.table', value?.value); + set(newCondition, 'operator', '='); //should we removed when we have more options + return newCondition; + }); + set(newData, 'conditions.conditionsList', newConditionsList); + set(newData, 'table', value?.value); + onChange(newData); + } + }} + onAdd={() => navigate(getPrivateRoute('database'))} + addBtnLabel={'Add new table'} + value={tableList.find((val) => val?.value === rightFieldTable)} + /> + +
+ {conditionsList.map((condition, index) => ( + { + const newData = cloneDeep(data); + set(newData, 'conditions.operator', value); + onChange(newData); + }} + onChange={(value) => { + const newConditionsList = conditionsList.map((con, i) => { + if (i === index) { + return value; + } + return con; + }); + const newData = cloneDeep(data); + set(newData, 'conditions.conditionsList', newConditionsList); + onChange(newData); + }} + onRemove={() => { + const newConditionsList = conditionsList.filter((_cond, i) => i !== index); + const newData = cloneDeep(data); + set(newData, 'conditions.conditionsList', newConditionsList); + onChange(newData); + }} + /> + ))} + + + { + const newData = { ...data }; + set(newData, 'conditions.conditionsList', [...conditionsList, { operator: '=' }]); + onChange(newData); + }} + > + +    Add more + + + + +
+ ); +}; + +const JoinOn = ({ + condition, + leftFieldTable, + rightFieldTable, + darkMode, + index, + onChange, + groupOperator, + onOperatorChange, + onRemove, +}) => { + const { tableInfo, findTableDetails } = useContext(TooljetDatabaseContext); + const { operator, leftField, rightField } = condition; + const leftFieldColumn = leftField?.columnName; + const rightFieldColumn = rightField?.columnName; + + const leftFieldTableDetails = (leftFieldTable && findTableDetails(leftFieldTable)) || {}; + const rightFieldTableDetails = (rightFieldTable && findTableDetails(rightFieldTable)) || {}; + + const leftFieldOptions = leftFieldTableDetails?.table_name + ? tableInfo[leftFieldTableDetails.table_name]?.map((col) => ({ label: col.Header, value: col.Header })) ?? [] + : []; + const selectedLeftField = leftFieldTableDetails?.table_name + ? tableInfo[leftFieldTableDetails.table_name]?.find((col) => col.Header === leftFieldColumn) ?? [] + : {}; + + const rightFieldOptions = rightFieldTableDetails?.table_name + ? tableInfo[rightFieldTableDetails.table_name] + ?.filter((col) => { + if (selectedLeftField?.dataType) { + return col.dataType === selectedLeftField.dataType; + } + return true; + }) + .map((col) => ({ label: col.Header, value: col.Header })) || [] + : []; + + const _operators = [{ label: '=', value: '=' }]; + + const groupOperators = [ + { value: 'AND', label: 'AND' }, + { value: 'OR', label: 'OR' }, + ]; + + return ( + + 1 + // ? 'The operation is defined by the first condition' + // : 'This operation will define all the following conditions' + // } + > + {index == 1 && ( + op.value === groupOperator)} + onChange={(value) => { + onOperatorChange && onOperatorChange(value?.value); + }} + /> + )} + {index == 0 &&
On
} + {index > 1 && ( +
+ {groupOperator} +
+ )} + + + + + No table selected +
+ } + value={leftFieldOptions.find((opt) => opt.value === leftFieldColumn)} + onChange={(value) => { + onChange && + onChange({ + ...condition, + leftField: { + ...condition.leftField, + columnName: value?.value, + type: 'Column', + table: leftFieldTable, + }, + }); + }} + /> + + + {/* op.value === operator)} + onChange={(value) => { + onChange && onChange({ ...condition, operator: value?.value }); + }} + /> */} + + {/* Above line is commented and value is hardcoded as below */} + +
{operator}
+ + +
+ + + {rightFieldTable ? 'No columns of the same data type' : 'No table selected'} +
+ } + darkMode={darkMode} + value={rightFieldOptions.find((opt) => opt.value === rightFieldColumn)} + onChange={(value) => { + onChange && + onChange({ + ...condition, + rightField: { + ...condition.rightField, + columnName: value?.value, + type: 'Column', + table: rightFieldTable, + }, + }); + }} + /> +
+ {index > 0 && ( + + + + )} + + + {/* {index > 0 && ( + + )} */} + + ); +}; + +// Base Component for Join Drop Down ---------- +const staticJoinOperationsList = [ + { label: 'Inner Join', value: 'INNER', icon: 'innerjoin' }, + { label: 'Left Join', value: 'LEFT', icon: 'leftouterjoin' }, + { label: 'Right Join', value: 'RIGHT', icon: 'rightouterjoin' }, + { label: 'Full Outer Join', value: 'FULL OUTER', icon: 'fullouterjoin' }, +]; + +export default JoinConstraint; diff --git a/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/JoinSelect.jsx b/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/JoinSelect.jsx new file mode 100644 index 0000000000..4239e7ef0a --- /dev/null +++ b/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/JoinSelect.jsx @@ -0,0 +1,151 @@ +import React, { useContext } from 'react'; +import { Col, Container, Row } from 'react-bootstrap'; +import { TooljetDatabaseContext } from '@/TooljetDatabase/index'; +import DropDownSelect from './DropDownSelect'; +import { cloneDeep } from 'lodash'; +import SolidIcon from '@/_ui/Icon/SolidIcons'; + +export default function JoinSelect({ darkMode }) { + const { joinOptions, tableInfo, joinTableOptions, joinTableOptionsChange, findTableDetails } = + useContext(TooljetDatabaseContext); + + const joinSelectOptions = cloneDeep(joinTableOptions['fields']) || []; + const setJoinSelectOptions = (fields) => { + joinTableOptionsChange('fields', fields); + }; + + const tableSet = new Set(); + (joinOptions || []).forEach((join) => { + const { table, conditions } = join; + tableSet.add(table); + conditions?.conditionsList?.forEach((condition) => { + const { leftField, rightField } = condition; + if (leftField?.table) { + tableSet.add(leftField?.table); + } + if (rightField?.table) { + tableSet.add(rightField?.table); + } + }); + }); + + const tables = [...tableSet].filter((table) => !!table); + const tableOptions = {}; + for (let index = 0; index < tables.length; index++) { + const tableId = tables[index]; + + const tableDetails = findTableDetails(tableId); + if (tableDetails?.table_name) { + tableOptions[tableId] = (tableInfo[tableDetails.table_name] || []).map((column) => ({ + label: column.Header, + value: column.Header, + })); + } + } + + // When column name are same, alias has been added + const handleChange = (columns, table) => { + const unchangedSelectFields = []; + const prevSelectedFields = []; + joinSelectOptions.forEach((t) => { + if (t.table !== table) unchangedSelectFields.push(t); + if (t.table === table) prevSelectedFields.push(t); + }); + + // Select All & Deselect Functionality + const allColumnsOfTable = tableOptions[table] ?? []; + const columnsWithoutSelectAllOption = columns.filter((column) => column.value !== 'SELECT ALL'); + const isSelectAllExists = columns.findIndex((column) => column.value === 'SELECT ALL') >= 0; + + let newSelectFields = [...unchangedSelectFields]; + if ( + (!isSelectAllExists && prevSelectedFields.length !== columnsWithoutSelectAllOption.length) || + (isSelectAllExists && prevSelectedFields.length === allColumnsOfTable.length) + ) + columnsWithoutSelectAllOption.forEach((column) => newSelectFields.push({ name: column?.value, table })); + // Push all the Columns When Select All options is clicked + if (isSelectAllExists && allColumnsOfTable.length && prevSelectedFields.length !== allColumnsOfTable.length) + allColumnsOfTable.forEach((column) => newSelectFields.push({ name: column?.value, table })); + + newSelectFields = newSelectFields.map((field) => { + if (newSelectFields.filter(({ name }) => name === field.name).length > 1 && !('alias' in field)) { + return { + ...field, + // alias: field.table + '_' + field.name, + }; + } + + return { + ...field, + // ...(!('alias' in field) && { alias: field.table + '_' + field.name }), + }; + }); + setJoinSelectOptions(newSelectFields); + }; + + return ( + + {tables.length ? ( + tables.map((table) => { + const respectiveTableSelectedOptions = joinSelectOptions.filter((val) => val?.table === table); + const respectiveTableOptions = tableOptions[table] ?? []; + return ( + + +
{findTableDetails(table)?.table_name ?? ''}
+ + + { + const aChecked = joinSelectOptions.some((item) => item.name === a.value && item.table === table); + const bChecked = joinSelectOptions.some((item) => item.name === b.value && item.table === table); + if (aChecked && !bChecked) { + return -1; + } + if (!aChecked && bChecked) { + return 1; + } + return 0; + }) ?? []), + ]} + darkMode={darkMode} + isMulti + onChange={(values) => handleChange(values, table)} + value={[ + ...(respectiveTableOptions?.length === respectiveTableSelectedOptions?.length && + respectiveTableSelectedOptions?.length !== 0 + ? [ + { + label: 'Select All', + value: 'SELECT ALL', + }, + ] + : []), + ...respectiveTableSelectedOptions.map((column) => ({ value: column?.name, label: column?.name })), + ]} + /> + +
+ ); + }) + ) : ( + +
+ Tables are not + selected +
+
+ )} +
+ ); +} diff --git a/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/JoinSort.jsx b/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/JoinSort.jsx new file mode 100644 index 0000000000..32b72335fe --- /dev/null +++ b/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/JoinSort.jsx @@ -0,0 +1,150 @@ +import React, { useContext } from 'react'; +import { Col, Container, Row } from 'react-bootstrap'; +import { TooljetDatabaseContext } from '@/TooljetDatabase/index'; +import DropDownSelect from './DropDownSelect'; +import { ButtonSolid } from '@/_ui/AppButton/AppButton'; +import Trash from '@/_ui/Icon/solidIcons/Trash'; +import AddRectangle from '@/_ui/Icon/bulkIcons/AddRectangle'; +import { isEmpty } from 'lodash'; +import SolidIcon from '@/_ui/Icon/SolidIcons'; + +export default function JoinSort({ darkMode }) { + const { tableInfo, joinOrderByOptions, setJoinOrderByOptions, joinOptions, findTableDetails } = + useContext(TooljetDatabaseContext); + + const tableSet = new Set(); + (joinOptions || []).forEach((join) => { + const { table, conditions } = join; + tableSet.add(table); + conditions?.conditionsList?.forEach((condition) => { + const { leftField, rightField } = condition; + if (leftField?.table) { + tableSet.add(leftField?.table); + } + if (rightField?.table) { + tableSet.add(rightField?.table); + } + }); + }); + + const tables = [...tableSet]; + const tableList = []; + + tables.forEach((tableId) => { + const tableDetails = findTableDetails(tableId); + if (tableDetails?.table_name && tableInfo[tableDetails.table_name]) { + const tableDetailsForDropDown = { + label: tableDetails.table_name, + value: tableId, + options: + tableInfo[tableDetails.table_name]?.map((columns) => ({ + label: columns.Header, + value: columns.Header + '_' + tableId, + table: tableId, + })) || [], + }; + tableList.push(tableDetailsForDropDown); + } + }); + + const sortbyConstants = [ + { label: 'Ascending', value: 'ASC' }, + { label: 'Descending', value: 'DESC' }, + ]; + + return ( + + {isEmpty(joinOrderByOptions) ? ( + +
+ There are no + conditions +
+
+ ) : ( + joinOrderByOptions.map((options, i) => { + const tableDetails = options?.table ? findTableDetails(options?.table) : ''; + return ( + + + { + setJoinOrderByOptions( + joinOrderByOptions.map((sortBy, index) => { + if (i === index) { + return { + ...sortBy, + columnName: option?.label, + table: option.table, + }; + } + return sortBy; + }) + ); + }} + /> + + +
+ opt.value === options.direction)} + onChange={(option) => { + setJoinOrderByOptions( + joinOrderByOptions.map((sortBy, index) => { + if (i === index) { + return { + ...sortBy, + direction: option?.value, + }; + } + return sortBy; + }) + ); + }} + /> +
+ setJoinOrderByOptions(joinOrderByOptions.filter((opt, idx) => idx !== i))} + > + + + +
+ ); + }) + )} + {/* Dynamically render below Row */} + + + setJoinOrderByOptions([...joinOrderByOptions, {}])}> + +    Add more + + + +
+ ); +} diff --git a/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/JoinTable.jsx b/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/JoinTable.jsx new file mode 100644 index 0000000000..0e80238287 --- /dev/null +++ b/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/JoinTable.jsx @@ -0,0 +1,483 @@ +import React, { useContext } from 'react'; +import { CodeHinter } from '@/Editor/CodeBuilder/CodeHinter'; +import { Col, Container, Row } from 'react-bootstrap'; +import { ButtonSolid } from '@/_ui/AppButton/AppButton'; +import Trash from '@/_ui/Icon/solidIcons/Trash'; +import AddRectangle from '@/_ui/Icon/bulkIcons/AddRectangle'; +import { clone } from 'lodash'; +import { TooljetDatabaseContext } from '@/TooljetDatabase/index'; +import DropDownSelect from './DropDownSelect'; +import JoinConstraint from './JoinConstraint'; +import JoinSelect from './JoinSelect'; +import JoinSort from './JoinSort'; +import SolidIcon from '@/_ui/Icon/SolidIcons'; +import { filterOperatorOptions, nullOperatorOptions } from './util'; + +export const JoinTable = React.memo(({ darkMode }) => { + return ( +
+ +
+ ); +}); + +const SelectTableMenu = ({ darkMode }) => { + const { + selectedTableId, + joinOptions, + setJoinOptions: setJoins, + joinTableOptions, + joinTableOptionsChange, + deleteJoinTableOptions, + } = useContext(TooljetDatabaseContext); + + const joins = clone(joinOptions); + + const handleJoinChange = (newJoin, index) => { + const updatedJoin = joinOptions.map((join, i) => { + if (i === index) return newJoin; + return join; + }); + + const cleanedJoin = []; + const tableSet = new Set(); + (updatedJoin || []).forEach((join, i) => { + const { conditions } = join; + let leftTable, rightTable; + conditions?.conditionsList?.forEach((condition) => { + const { leftField = {}, rightField = {} } = condition; + if (leftField?.table) leftTable = leftField?.table; + if (rightField?.table) rightTable = rightField?.table; + }); + + if ((tableSet.has(leftTable) && !tableSet.has(rightTable)) || i === 0) { + if (leftTable) tableSet.add(leftTable); + if (rightTable) tableSet.add(rightTable); + cleanedJoin.push({ ...join }); + } + }); + // tableSet.add(selectedTable); + setJoins(cleanedJoin); + }; + + const calcUpdatedJoins = (updatedJoin) => { + const cleanedJoin = []; + const tableSet = new Set(); + (updatedJoin || []).forEach((join, i) => { + const { _table, conditions } = join; + let leftTable, rightTable; + conditions?.conditionsList?.forEach((condition) => { + const { leftField, rightField } = condition; + if (leftField?.table) { + // tableSet.add(leftField?.table); + leftTable = leftField?.table; + } + if (rightField?.table) { + // tableSet.add(rightField?.table); + rightTable = rightField?.table; + } + }); + if ((tableSet.has(leftTable) && !tableSet.has(rightTable)) || i === 0) { + tableSet.add(leftTable); + tableSet.add(rightTable); + cleanedJoin.push({ ...join }); + } + }); + return cleanedJoin; + }; + + return ( +
+ {/* Join Section */} +
+ +
+ {joins.map((join, joinIndex) => ( + handleJoinChange(value, joinIndex)} + onRemove={() => setJoins(calcUpdatedJoins(joins.filter((join, index) => index !== joinIndex)))} + /> + ))} + + + setJoins([ + ...joins, + { + id: new Date().getTime(), + conditions: { + operator: 'AND', + conditionsList: [ + { + operator: '=', + leftField: { table: selectedTableId }, + }, + ], + }, + joinType: 'INNER', + }, + ]) + } + > + +    Add another table + + +
+
+ {/* Filter Section */} +
+ +
+ +
+
+ {/* Sort Section */} +
+ +
+ +
+
+ {/* Limit Section */} +
+ +
+ { + if (value.length) { + joinTableOptionsChange('limit', value); + } else { + deleteJoinTableOptions('limit'); + } + }} + /> +
+
+ {/* Offset Section */} +
+ +
+ { + if (value.length) { + joinTableOptionsChange('offset', value); + } else { + deleteJoinTableOptions('offset'); + } + }} + /> +
+
+ {/* Select Section */} +
+ +
+ +
+
+
+ ); +}; + +// Component to Render Filter Section +const RenderFilterSection = ({ darkMode }) => { + const { tableInfo, joinTableOptions, joinTableOptionsChange, deleteJoinTableOptions, joinOptions, findTableDetails } = + useContext(TooljetDatabaseContext); + const { conditions = {} } = joinTableOptions; + const { conditionsList = [] } = conditions; + + function handleWhereFilterChange(conditionsEdited) { + joinTableOptionsChange('conditions', conditionsEdited); + } + + function addNewFilterConditionEntry() { + let editedFilterCondition = {}; + + const emptyConditionTemplate = { operator: '=', leftField: {}, rightField: {} }; + + // First time populate operator & conditionList details + if (!Object.keys(conditions).length) { + editedFilterCondition = { + operator: 'AND', + conditionsList: [{ ...emptyConditionTemplate }], + }; + } else { + editedFilterCondition = { + ...conditions, + conditionsList: [...conditionsList, { ...emptyConditionTemplate }], + }; + } + + handleWhereFilterChange(editedFilterCondition); + } + + function removeFilterConditionEntry(index) { + if (!Object.keys(conditions).length || !conditionsList.length) return; + + // If there is one condition left, then make the 'conditions' state to default. + let editedFilterConditions = {}; + if (conditionsList.length > 1) { + editedFilterConditions = { + ...conditions, + conditionsList: conditionsList.filter((condition, i) => i !== index), + }; + } + + if (Object.keys(editedFilterConditions).length === 0) { + deleteJoinTableOptions('conditions'); + } else { + handleWhereFilterChange(editedFilterConditions); + } + } + + function updateFilterConditionEntry(type, indexToUpdate, valueToUpdate) { + if (!Object.keys(conditions).length || !conditionsList.length) return; + // type: Column | Value | Operator + + // @desc : Input Need for Each Type + // Column -> table, columnName, isLeftSideCondition + // Value -> value, isLeftSideCondition + // Operator -> operator + + const editedConditionList = conditionsList.map((conditionDetail, index) => { + if (indexToUpdate === index) { + switch (type) { + case 'Column': + return valueToUpdate.isLeftSideCondition + ? { + ...conditionDetail, + leftField: { + columnName: valueToUpdate.columnName, + table: valueToUpdate.table, + type: 'Column', + }, + } + : { + ...conditionDetail, + rightField: { + columnName: valueToUpdate.columnName, + table: valueToUpdate.table, + type: 'Column', + }, + }; + case 'Value': + return valueToUpdate.isLeftSideCondition + ? { + ...conditionDetail, + leftField: { + value: valueToUpdate.value, + type: 'Value', + }, + } + : { + ...conditionDetail, + rightField: { + value: valueToUpdate.value, + type: 'Value', + }, + }; + case 'Operator': + return { + ...conditionDetail, + ...((conditionDetail.operator === 'IS' || valueToUpdate.operator === 'IS') && { + rightField: { + value: '', + type: 'Value', + }, + }), + operator: valueToUpdate.operator, + }; + default: + return conditionDetail; + } + } + return conditionDetail; + }); + handleWhereFilterChange({ ...conditions, conditionsList: [...editedConditionList] }); + } + + function updateOperatorForConditions(changedOperator) { + let editedFilterConditions = { ...conditions, operator: changedOperator }; + handleWhereFilterChange(editedFilterConditions); + } + + const tableSet = new Set(); + (joinOptions || []).forEach((join) => { + const { table, conditions } = join; + tableSet.add(table); + conditions?.conditionsList?.forEach((condition) => { + const { leftField, rightField } = condition; + if (leftField?.table) { + tableSet.add(leftField?.table); + } + if (rightField?.table) { + tableSet.add(rightField?.table); + } + }); + }); + + const tables = [...tableSet]; + const tableList = []; + + tables.forEach((tableId) => { + const tableDetails = findTableDetails(tableId); + if (tableDetails?.table_name && tableInfo[tableDetails.table_name]) { + const tableDetailsForDropDown = { + label: tableDetails.table_name, + value: tableId, + options: + tableInfo[tableDetails.table_name]?.map((columns) => ({ + label: columns.Header, + value: columns.Header + '-' + tableId, + table: tableId, + })) || [], + }; + tableList.push(tableDetailsForDropDown); + } + }); + + const groupOperators = [ + { value: 'AND', label: 'AND' }, + { value: 'OR', label: 'OR' }, + ]; + + const filterComponents = conditionsList.map((conditionDetail, index) => { + const { operator = '', leftField = {}, rightField = {} } = conditionDetail; + const LeftSideTableDetails = leftField?.table ? findTableDetails(leftField?.table) : ''; + return ( + + + {index === 1 && ( + updateOperatorForConditions(change?.value)} + options={groupOperators} + darkMode={darkMode} + value={groupOperators.find((op) => op.value === conditions.operator)} + /> + )} + {index === 0 &&
Where
} + {index > 1 &&
{conditions?.operator}
} + + + + updateFilterConditionEntry('Column', index, { + table: newValue.table, + columnName: newValue.label, + isLeftSideCondition: true, + }) + } + value={{ + label: LeftSideTableDetails?.table_name + ? LeftSideTableDetails?.table_name + '.' + leftField?.columnName + : leftField?.columnName, + value: leftField?.columnName && leftField?.table ? leftField?.columnName + '-' + leftField?.table : '', + table: leftField?.table, + }} + options={tableList} + darkMode={darkMode} + /> + + + updateFilterConditionEntry('Operator', index, { operator: change?.value })} + value={filterOperatorOptions.find((op) => op.value === operator)} + options={filterOperatorOptions} + darkMode={darkMode} + /> + + +
+ {operator === 'IS' ? ( + + updateFilterConditionEntry('Value', index, { value: change?.value, isLeftSideCondition: false }) + } + options={nullOperatorOptions} + darkMode={darkMode} + value={nullOperatorOptions.find((op) => op.value === rightField.value)} + /> + ) : ( + + updateFilterConditionEntry('Value', index, { value: newValue, isLeftSideCondition: false }) + } + /> + )} +
+ removeFilterConditionEntry(index)} + > + + + +
+ ); + }); + + return ( + + {conditionsList.length === 0 && ( + +
+ There are no + conditions +
+
+ )} + {filterComponents} + + + addNewFilterConditionEntry()}> + +    Add more + + + +
+ ); +}; diff --git a/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/SelectBox.jsx b/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/SelectBox.jsx new file mode 100644 index 0000000000..96d7d5c090 --- /dev/null +++ b/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/SelectBox.jsx @@ -0,0 +1,302 @@ +import React, { isValidElement, useCallback, useState } from 'react'; +import Select, { components } from 'react-select'; +import { isEmpty } from 'lodash'; +import { authenticationService } from '@/_services'; +import { ButtonSolid } from '@/_ui/AppButton/AppButton'; +import Search from '@/_ui/Icon/solidIcons/Search'; +import { Form } from 'react-bootstrap'; +import SolidIcon from '@/_ui/Icon/SolidIcons'; + +function DataSourceSelect({ + darkMode, + isDisabled, + selectRef, + closePopup, + options, + isMulti, + onSelect, + onAdd, + addBtnLabel, + selected, + emptyError, +}) { + const handleChangeDataSource = (source) => { + onSelect && onSelect(source); + closePopup && !isMulti && closePopup(); + }; + + let optionsCount = options.length; + + options.forEach((item) => { + if (item.options && item.options.length > 0) { + optionsCount += item.options.length; + } + }); + + return ( +
+ + handleTableNameSelect(value)} - width="100%" - // useMenuPortal={false} - useCustomStyles={true} - styles={computeSelectStyles(darkMode, '100%')} + darkMode={darkMode} + onChange={(value) => { + value?.value && handleTableNameSelect(value?.value); + }} + onAdd={() => navigate(getPrivateRoute('database'))} + addBtnLabel={'Add new table'} + value={generateListForDropdown(tables).find((val) => val?.value === selectedTableId)} />
@@ -231,20 +486,15 @@ const ToolJetDbOperations = ({ optionchanged, options, darkMode, isHorizontalLay className={cx({ 'col-4': !isHorizontalLayout, 'd-flex': isHorizontalLayout })} > -
- { + const file = e.target.files[0]; + setFileData(file); + handleFileChange(file); + }} + accept=".csv" + type="file" + className="form-control" + data-cy="input-field-bulk-upload" + /> +
    {acceptedFiles}
+ {fileData?.name &&
    {` ${fileData?.name} - ${fileData?.size} bytes`}
} +
+ {errors.client.length > 0 && ( + <> +
+ + Kindly check the file and try again! +
+
+ {errors.client} +
+ + )} + {errors.server.length > 0 && ( + <> +
+ + Kindly check the file and try again! +
+
+ {errors.server} +
+ + )} +
+ + ); +} diff --git a/frontend/src/TooljetDatabase/Drawers/BulkUploadDrawer/index.jsx b/frontend/src/TooljetDatabase/Drawers/BulkUploadDrawer/index.jsx new file mode 100644 index 0000000000..346cc365ec --- /dev/null +++ b/frontend/src/TooljetDatabase/Drawers/BulkUploadDrawer/index.jsx @@ -0,0 +1,144 @@ +import React, { useState, useContext, useCallback, useRef } from 'react'; +import Drawer from '@/_ui/Drawer'; +import { toast } from 'react-hot-toast'; +import { TooljetDatabaseContext } from '../../index'; +import { ButtonSolid } from '@/_ui/AppButton/AppButton'; +import { FileDropzone } from './FileDropzone'; +import SolidIcon from '@/_ui/Icon/SolidIcons'; + +function BulkUploadDrawer({ + isBulkUploadDrawerOpen, + setIsBulkUploadDrawerOpen, + bulkUploadFile, + handleBulkUploadFileChange, + handleBulkUpload, + isBulkUploading, + errors, +}) { + const [isDownloadingTemplate, setIsDownloadingTemplate] = useState(false); + const { columns, selectedTable } = useContext(TooljetDatabaseContext); + const hiddenFileInput = useRef(null); + + const onDrop = useCallback((acceptedFiles) => { + const file = acceptedFiles[0]; + if (Math.round(file.size / 1024) > 2 * 1024) { + toast.error('File size cannot exceed more than 2MB'); + } else { + handleBulkUploadFileChange(file); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleTemplateDownload = () => { + setIsDownloadingTemplate(true); + + return setTimeout(() => { + // Create a CSV content string with the column names as the header row + const headerRow = columns.map((col) => col.Header).join(','); + const csvContent = [headerRow].join('\n'); + // Create a Blob with the CSV content + const blob = new Blob([csvContent], { type: 'text/csv' }); + // Create a temporary URL for the Blob + const href = URL.createObjectURL(blob); + // Create a link element to trigger the download + const link = document.createElement('a'); + link.href = href; + link.download = `${selectedTable.table_name}.csv`; + // Trigger the download + link.click(); + + setIsDownloadingTemplate(false); + + // Clean up + document.body.removeChild(link); + window.URL.revokeObjectURL(href); + }, 500); + }; + + const handleClick = () => { + hiddenFileInput.current.click(); + }; + + return ( + <> + + + setIsBulkUploadDrawerOpen(false)} + position="right" + drawerStyle={{ 'overflow-y': 'hidden' }} + > +
+
+

+ Bulk upload data +

+
+
+
+
+
+
+ +
+
+

+ Download the template to add your data or format your file in the same as the template. ToolJet + won’t be able to recognise files in any other format. +

+ + Download Template + +
+
+
+ +
+
+
+
+
+ setIsBulkUploadDrawerOpen(false)}> + Cancel + + 0 || errors.server.length > 0} + data-cy={`save-changes-button`} + onClick={handleBulkUpload} + fill="#fff" + leftIcon="floppydisk" + loading={isBulkUploading} + > + Upload data + +
+
+
+ + ); +} +export default BulkUploadDrawer; diff --git a/frontend/src/TooljetDatabase/Drawers/CreateColumnDrawer/index.jsx b/frontend/src/TooljetDatabase/Drawers/CreateColumnDrawer/index.jsx index 9009d65aa0..f92f240478 100644 --- a/frontend/src/TooljetDatabase/Drawers/CreateColumnDrawer/index.jsx +++ b/frontend/src/TooljetDatabase/Drawers/CreateColumnDrawer/index.jsx @@ -13,7 +13,7 @@ const CreateColumnDrawer = ({ setIsCreateColumnDrawerOpen, isCreateColumnDrawerO <> setIsCreateRowDrawerOpen(false)} position="right"> { - tooljetDatabaseService.findOne(organizationId, selectedTable.id).then(({ headers, data = [], error }) => { - if (error) { - toast.error(error?.message ?? `Failed to fetch table "${selectedTable.table_name}"`); - return; - } + tooljetDatabaseService + .findOne(organizationId, selectedTable.id, 'order=id.desc') + .then(({ headers, data = [], error }) => { + if (error) { + toast.error(error?.message ?? `Failed to fetch table "${selectedTable.table_name}"`); + return; + } - if (Array.isArray(data) && data?.length > 0) { - const totalContentRangeRecords = headers['content-range'].split('/')[1] || 0; - setTotalRecords(totalContentRangeRecords); - setSelectedTableData(data); - } - }); + if (Array.isArray(data) && data?.length > 0) { + const totalContentRangeRecords = headers['content-range'].split('/')[1] || 0; + setTotalRecords(totalContentRangeRecords); + setSelectedTableData(data); + } + }); setIsCreateRowDrawerOpen(false); }} onClose={() => setIsCreateRowDrawerOpen(false)} diff --git a/frontend/src/TooljetDatabase/Drawers/EditRowDrawer/index.jsx b/frontend/src/TooljetDatabase/Drawers/EditRowDrawer/index.jsx index 6152f7fdba..564cd10359 100644 --- a/frontend/src/TooljetDatabase/Drawers/EditRowDrawer/index.jsx +++ b/frontend/src/TooljetDatabase/Drawers/EditRowDrawer/index.jsx @@ -12,14 +12,21 @@ const EditRowDrawer = ({ isCreateRowDrawerOpen, setIsCreateRowDrawerOpen }) => { <> diff --git a/frontend/src/TooljetDatabase/Sort/index.jsx b/frontend/src/TooljetDatabase/Sort/index.jsx index b400f5eb4b..12c190f768 100644 --- a/frontend/src/TooljetDatabase/Sort/index.jsx +++ b/frontend/src/TooljetDatabase/Sort/index.jsx @@ -3,7 +3,7 @@ import cx from 'classnames'; import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; import Popover from 'react-bootstrap/Popover'; import { SortForm } from '../Forms/SortForm'; -import { pluralize } from '@/_helpers/utils'; +// import { pluralize } from '@/_helpers/utils'; import { isEmpty } from 'lodash'; import { useMounted } from '@/_hooks/use-mount'; import SolidIcon from '@/_ui/Icon/SolidIcons'; @@ -102,9 +102,9 @@ const Sort = ({ filters, setFilters, handleBuildSortQuery, resetSortQuery }) => fill={areFiltersApplied ? '#46A758' : show ? '#3E63DD' : '#889096'} />   Sort - {areFiltersApplied && ( + {/* {areFiltersApplied && ( ed by {pluralize(Object.values(filters).filter(checkIsFilterObjectEmpty).length, 'column')} - )} + )} */} ); diff --git a/frontend/src/TooljetDatabase/Table/index.jsx b/frontend/src/TooljetDatabase/Table/index.jsx index 0cc2b90c09..93f1ad37cd 100644 --- a/frontend/src/TooljetDatabase/Table/index.jsx +++ b/frontend/src/TooljetDatabase/Table/index.jsx @@ -57,7 +57,7 @@ const Table = ({ openCreateRowDrawer, openCreateColumnDrawer }) => { }; const fetchTableData = (queryParams = '', pagesize = 50, pagecount = 1) => { - const defaultQueryParams = `limit=${pagesize}&offset=${(pagecount - 1) * pagesize}`; + const defaultQueryParams = `limit=${pagesize}&offset=${(pagecount - 1) * pagesize}&order=id.desc`; let params = queryParams ? queryParams : defaultQueryParams; setLoading(true); diff --git a/frontend/src/TooljetDatabase/TableListItem/ActionsPopover/index.jsx b/frontend/src/TooljetDatabase/TableListItem/ActionsPopover/index.jsx index ae576fb50e..73fd16ebc8 100644 --- a/frontend/src/TooljetDatabase/TableListItem/ActionsPopover/index.jsx +++ b/frontend/src/TooljetDatabase/TableListItem/ActionsPopover/index.jsx @@ -7,7 +7,7 @@ import EditIcon from './Icons/Edit.svg'; import DeleteIcon from './Icons/Delete.svg'; import SolidIcon from '@/_ui/Icon/SolidIcons'; -export const ListItemPopover = ({ onEdit, onDelete, darkMode, onMenuToggle }) => { +export const ListItemPopover = ({ onEdit, onDelete, darkMode, handleExportTable, onMenuToggle }) => { const closeMenu = () => { document.body.click(); }; @@ -31,6 +31,21 @@ export const ListItemPopover = ({ onEdit, onDelete, darkMode, onMenuToggle }) => Edit
+
+
+ +
+
{ + closeMenu(); + handleExportTable(); + }} + > + Export table +
+
{/*
diff --git a/frontend/src/TooljetDatabase/TableListItem/index.jsx b/frontend/src/TooljetDatabase/TableListItem/index.jsx index c8499cf636..960216a7b8 100644 --- a/frontend/src/TooljetDatabase/TableListItem/index.jsx +++ b/frontend/src/TooljetDatabase/TableListItem/index.jsx @@ -1,7 +1,7 @@ import React, { useState, useContext, useCallback, useEffect } from 'react'; import cx from 'classnames'; import { toast } from 'react-hot-toast'; -import { tooljetDatabaseService } from '@/_services'; +import { tooljetDatabaseService, appService } from '@/_services'; import { ListItemPopover } from './ActionsPopover'; import { TooljetDatabaseContext } from '../index'; import { ToolTip } from '@/_components'; @@ -21,6 +21,33 @@ export const ListItem = ({ active, onClick, text = '', onDeleteCallback }) => { setSelectedTable(tableObj); } + const handleExportTable = () => { + 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', + }); + }); + }; + const handleDeleteTable = async () => { const shouldDelete = confirm(`Are you sure you want to delete the table "${text}"?`); if (shouldDelete) { @@ -85,6 +112,7 @@ export const ListItem = ({ active, onClick, text = '', onDeleteCallback }) => { }} onDelete={handleDeleteTable} darkMode={darkMode} + handleExportTable={handleExportTable} onMenuToggle={onMenuToggle} />
diff --git a/frontend/src/TooljetDatabase/TooljetDatabasePage/index.jsx b/frontend/src/TooljetDatabase/TooljetDatabasePage/index.jsx index 36d0c12919..1f17d49152 100644 --- a/frontend/src/TooljetDatabase/TooljetDatabasePage/index.jsx +++ b/frontend/src/TooljetDatabase/TooljetDatabasePage/index.jsx @@ -1,18 +1,19 @@ -import React, { useState, useContext } from 'react'; +import React, { useState, useContext, useEffect } from 'react'; import cx from 'classnames'; import Table from '../Table'; import CreateColumnDrawer from '../Drawers/CreateColumnDrawer'; import CreateRowDrawer from '../Drawers/CreateRowDrawer'; import EditRowDrawer from '../Drawers/EditRowDrawer'; +import BulkUploadDrawer from '../Drawers/BulkUploadDrawer'; import Filter from '../Filter'; 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'; +import { tooljetDatabaseService } from '@/_services'; +import { pluralize } from '@/_helpers/utils'; const TooljetDatabasePage = ({ totalTables }) => { const { @@ -27,11 +28,65 @@ const TooljetDatabasePage = ({ totalTables }) => { sortFilters, setSortFilters, organizationId, + setTotalRecords, + setSelectedTableData, } = useContext(TooljetDatabaseContext); const [isCreateRowDrawerOpen, setIsCreateRowDrawerOpen] = useState(false); + const [isBulkUploadDrawerOpen, setIsBulkUploadDrawerOpen] = useState(false); const [isEditRowDrawerOpen, setIsEditRowDrawerOpen] = useState(false); const [isCreateColumnDrawerOpen, setIsCreateColumnDrawerOpen] = useState(false); + const [bulkUploadFile, setBulkUploadFile] = useState(null); + const [isBulkUploading, setIsBulkUploading] = useState(false); + const [errors, setErrors] = useState({ client: [], server: [] }); + const [uploadResult, setUploadResult] = useState(null); + + useEffect(() => { + setErrors({ client: [], server: [] }); + handleFileValidation(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bulkUploadFile]); + + useEffect(() => { + if (!isBulkUploadDrawerOpen) { + setErrors({ client: [], server: [] }); + setBulkUploadFile(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isBulkUploadDrawerOpen]); + + useEffect(() => { + if (isEmpty(selectedTable)) return; + + const reloadTableData = async () => { + const { headers, data, error } = await tooljetDatabaseService.findOne( + organizationId, + selectedTable.id, + 'order=id.desc' + ); + + if (error) { + toast.error(error?.message ?? 'Something went wrong'); + return; + } + const totalRecords = headers['content-range'].split('/')[1] || 0; + + if (Array.isArray(data)) { + setTotalRecords(totalRecords); + setSelectedTableData(data); + } + }; + + setIsBulkUploading(false); + setBulkUploadFile(null); + setIsBulkUploadDrawerOpen(false); + setQueryFilters({}); + resetFilterQuery(); + setSortFilters({}); + resetSortQuery(); + reloadTableData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [uploadResult]); const EmptyState = () => { return ( @@ -56,31 +111,54 @@ 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', - }); + const handleFileValidation = () => { + const fileValidationErrors = []; + + if (bulkUploadFile && bulkUploadFile.size / 1024 > 2 * 1024) { + fileValidationErrors.push('File size cannot exceed 2mb'); + } + + setErrors({ server: [], client: fileValidationErrors }); + }; + + const handleBulkUpload = async (event) => { + event.preventDefault(); + setErrors({ client: [], server: [] }); + setIsBulkUploading(true); + + const formData = new FormData(); + formData.append('file', bulkUploadFile); + try { + const { error, data } = await tooljetDatabaseService.bulkUpload( + organizationId, + selectedTable.table_name, + formData + ); + + if (error) { + setErrors({ ...errors, ...{ server: error.message } }); + setIsBulkUploading(false); + toast.error('Upload failed!', { position: 'top-center' }); + return; + } + + const { processed_rows: processedRows, rows_inserted: rowsInserted, rows_updated: rowsUpdated } = data.result; + const toastMessage = + `${pluralize(rowsInserted, 'new row')} added, ` + `${pluralize(rowsUpdated, 'row')} updated.`; + + toast.success(toastMessage, { + position: 'top-center', }); + + setUploadResult({ processedRows, rowsInserted, rowsUpdated }); + } catch (error) { + toast.error(error.errors, { position: 'top-center' }); + setIsBulkUploading(false); + } + }; + + const handleBulkUploadFileChange = (file) => { + setBulkUploadFile(file); }; return ( @@ -92,28 +170,15 @@ const TooljetDatabasePage = ({ totalTables }) => { <>
-
+
-
+
{columns?.length > 0 && ( <> - - - { isCreateRowDrawerOpen={isEditRowDrawerOpen} setIsCreateRowDrawerOpen={setIsEditRowDrawerOpen} /> + )}
+
+
+
+ +
+
+ +
+
+
diff --git a/frontend/src/TooljetDatabase/usePostgrestQueryBuilder.jsx b/frontend/src/TooljetDatabase/usePostgrestQueryBuilder.jsx index 7fca6489cf..19f375eba0 100644 --- a/frontend/src/TooljetDatabase/usePostgrestQueryBuilder.jsx +++ b/frontend/src/TooljetDatabase/usePostgrestQueryBuilder.jsx @@ -25,10 +25,14 @@ export const usePostgrestQueryBuilder = ({ organizationId, selectedTable, setSel }; const updateSelectedTableData = async () => { + const sortQuery = isEmpty(postgrestQueryBuilder.current.sortQuery.url.toString()) + ? 'order=id.desc' + : postgrestQueryBuilder.current.sortQuery.url.toString(); + const query = postgrestQueryBuilder.current.filterQuery.url.toString() + '&' + - postgrestQueryBuilder.current.sortQuery.url.toString() + + sortQuery + '&' + postgrestQueryBuilder.current.paginationQuery.url.toString(); diff --git a/frontend/src/_helpers/http-client.js b/frontend/src/_helpers/http-client.js index f12ed47b06..cb27800a69 100644 --- a/frontend/src/_helpers/http-client.js +++ b/frontend/src/_helpers/http-client.js @@ -34,7 +34,7 @@ class HttpClient { const endpoint = urlJoin(this.host, this.namespace, url); const options = { method, - headers: this.headers, + headers: { ...this.headers }, credentials: 'include', }; let session = authenticationService.currentSessionValue; @@ -48,9 +48,13 @@ class HttpClient { } options.headers['tj-workspace-id'] = session?.current_organization_id; + if (data) { - options.body = JSON.stringify(data); + // fetch library generates content type with boundary for form data + data instanceof FormData && delete options.headers['content-type']; + options.body = data instanceof FormData ? data : JSON.stringify(data); } + const response = await fetch(endpoint, options); const payload = { status: response.status, diff --git a/frontend/src/_services/tooljetDatabase.service.js b/frontend/src/_services/tooljetDatabase.service.js index 36d6b5df56..3663ff7a18 100644 --- a/frontend/src/_services/tooljetDatabase.service.js +++ b/frontend/src/_services/tooljetDatabase.service.js @@ -4,30 +4,34 @@ const tooljetAdapter = new HttpClient(); function findOne(headers, tableId, query = '') { tooljetAdapter.headers = { ...tooljetAdapter.headers, ...headers }; - return tooljetAdapter.get(`/tooljet_db/proxy/${tableId}?${query}`, headers); + return tooljetAdapter.get(`/tooljet-db/proxy/${tableId}?${query}`, headers); } function findAll(organizationId) { - return tooljetAdapter.get(`/tooljet_db/organizations/${organizationId}/tables`); + return tooljetAdapter.get(`/tooljet-db/organizations/${organizationId}/tables`); } function createTable(organizationId, tableName, columns) { - return tooljetAdapter.post(`/tooljet_db/organizations/${organizationId}/table`, { + return tooljetAdapter.post(`/tooljet-db/organizations/${organizationId}/table`, { table_name: tableName, columns, }); } function viewTable(organizationId, tableName) { - return tooljetAdapter.get(`/tooljet_db/organizations/${organizationId}/table/${tableName}`); + return tooljetAdapter.get(`/tooljet-db/organizations/${organizationId}/table/${tableName}`); +} + +function bulkUpload(organizationId, tableName, file) { + return tooljetAdapter.post(`/tooljet-db/organizations/${organizationId}/table/${tableName}/bulk-upload`, file); } function createRow(headers, tableId, data) { - return tooljetAdapter.post(`/tooljet_db/proxy/${tableId}`, data, headers); + return tooljetAdapter.post(`/tooljet-db/proxy/${tableId}`, data, headers); } function createColumn(organizationId, tableId, columnName, dataType, defaultValue) { - return tooljetAdapter.post(`/tooljet_db/organizations/${organizationId}/table/${tableId}/column`, { + return tooljetAdapter.post(`/tooljet-db/organizations/${organizationId}/table/${tableId}/column`, { column: { column_name: columnName, data_type: dataType, @@ -37,7 +41,7 @@ function createColumn(organizationId, tableId, columnName, dataType, defaultValu } function updateTable(organizationId, tableName, columns) { - return tooljetAdapter.patch(`/tooljet_db/${organizationId}/perform`, { + return tooljetAdapter.patch(`/tooljet-db/${organizationId}/perform`, { action: 'update_table', table_name: tableName, columns, @@ -45,7 +49,7 @@ function updateTable(organizationId, tableName, columns) { } function renameTable(organizationId, tableName, newTableName) { - return tooljetAdapter.patch(`/tooljet_db/organizations/${organizationId}/table/${tableName}`, { + return tooljetAdapter.patch(`/tooljet-db/organizations/${organizationId}/table/${tableName}`, { action: 'rename_table', table_name: tableName, new_table_name: newTableName, @@ -53,23 +57,23 @@ function renameTable(organizationId, tableName, newTableName) { } function updateRows(headers, tableId, data, query = '') { - return tooljetAdapter.patch(`/tooljet_db/proxy/${tableId}?${query}`, data, headers); + return tooljetAdapter.patch(`/tooljet-db/proxy/${tableId}?${query}`, data, headers); } function deleteRows(headers, tableId, query = '') { - return tooljetAdapter.delete(`/tooljet_db/proxy/${tableId}?${query}`, headers); + return tooljetAdapter.delete(`/tooljet-db/proxy/${tableId}?${query}`, headers); } function deleteColumn(organizationId, tableName, columnName) { - return tooljetAdapter.delete(`/tooljet_db/organizations/${organizationId}/table/${tableName}/column/${columnName}`); + return tooljetAdapter.delete(`/tooljet-db/organizations/${organizationId}/table/${tableName}/column/${columnName}`); } function deleteTable(organizationId, tableName) { - return tooljetAdapter.delete(`/tooljet_db/organizations/${organizationId}/table/${tableName}`); + return tooljetAdapter.delete(`/tooljet-db/organizations/${organizationId}/table/${tableName}`); } function joinTables(organizationId, data) { - return tooljetAdapter.post(`tooljet_db/organizations/${organizationId}/join`, data); + return tooljetAdapter.post(`tooljet-db/organizations/${organizationId}/join`, data); } export const tooljetDatabaseService = { @@ -85,5 +89,6 @@ export const tooljetDatabaseService = { deleteColumn, deleteTable, renameTable, + bulkUpload, joinTables, }; diff --git a/frontend/src/_styles/theme.scss b/frontend/src/_styles/theme.scss index 6e25755790..fa21410e7e 100644 --- a/frontend/src/_styles/theme.scss +++ b/frontend/src/_styles/theme.scss @@ -4609,16 +4609,16 @@ input[type="text"] { color: $white !important; .modal-title { - color: $white !important; + color: $white !important; } .tj-version-wrap-sub-footer { - background-color: $bg-dark-light !important; + background-color: $bg-dark-light !important; border-top: 1px solid #3A3F42 !important; p { - color: $white !important; + color: $white !important; } } @@ -4629,7 +4629,9 @@ input[type="text"] { } .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 { @@ -6583,9 +6585,9 @@ input.hide-input-arrows { box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03); border-radius: 4px; border: 1px solid var(--slate3) !important; - left: 109px !important; - top: 8px !important; - position: absolute !important; + // left: 109px !important; + // top: 8px !important; + // position: absolute !important; .card-body, @@ -6603,9 +6605,9 @@ input.hide-input-arrows { box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03); border-radius: 4px; border: 1px solid var(--slate3) !important; - left: 193px !important; - top: 10px !important; - position: absolute !important; + // left: 193px !important; + // top: 10px !important; + // position: absolute !important; .card-body, @@ -8717,7 +8719,7 @@ tbody { } } -.tj-db-operaions-header { +.tj-db-operations-header { height: 48px; padding: 0 !important; display: flex; @@ -8726,19 +8728,19 @@ tbody { .row { margin-left: 0px; + width: 98%; } - .col { + .col-8 { padding-left: 0px; display: flex; - gap: 8px; + gap: 12px; align-items: center; } } .add-new-column-btn { margin-left: 16px; - width: 144px !important; height: 28px; border-radius: 6px; padding: 0 !important; @@ -8751,7 +8753,7 @@ tbody { } .tj-db-filter-btn { - width: 81px; + width: 100%; height: 28px; border-radius: 6px; background: transparent; @@ -8769,7 +8771,7 @@ tbody { justify-content: center !important; align-items: center !important; padding: 4px 16px !important; - width: 171px !important; + width: 100% !important; height: 28px !important; background: var(--grass2) !important; border-radius: 6px !important; @@ -8777,19 +8779,22 @@ tbody { .tj-db-filter-btn-active, .tj-db-sort-btn-active { - width: 81px !important; + display: flex !important; + flex-direction: row !important; + justify-content: center !important; + align-items: center !important; + padding: 4px 16px !important; + width: 100% !important; height: 28px !important; - background: var(--indigo4) !important; - border: 1px solid var(--indigo9) !important; border-radius: 6px !important; - justify-content: center; + background: var(--indigo4) !important; + //border: 1px solid var(--indigo9) !important; color: var(--indigo9) !important; } .tj-db-header-add-new-row-btn { - width: 125px; height: 28px; - background: var(--indigo3); + background: transparent; border-radius: 6px !important; display: flex; flex-direction: row; @@ -8798,13 +8803,13 @@ tbody { gap: 6px; border: none; - span { - color: var(--indigo9); + padding: span { + //color: var(--indigo9); } } .tj-db-sort-btn { - width: 75px; + width: 100%; height: 28px; background: transparent; color: var(--slate12); @@ -8812,6 +8817,7 @@ tbody { display: flex; align-items: center; justify-content: center; + margin: 0 } .edit-row-btn { @@ -8820,6 +8826,7 @@ tbody { border: none; display: flex; align-items: center; + justify-content: center; } .workspace-variable-header { @@ -9679,7 +9686,6 @@ tbody { padding: 60px 0px; gap: 36px; width: 486px; - height: 244px; border: 2px dashed var(--indigo9); border-radius: 6px; align-items: center; @@ -10417,6 +10423,7 @@ tbody { .upload-user-form span.file-upload-error { color: var(--tomato10) !important; + margin-top: 12px 0px 0px 0px; } .tj-onboarding-phone-input { @@ -10605,7 +10612,7 @@ tbody { } .export-table-button { - width: 135px; + display: flex; align-items: center; justify-content: center; @@ -10613,7 +10620,7 @@ tbody { #global-settings-popover.theme-dark { - background-color: $bg-dark-light !important; + background-color: $bg-dark-light !important; border: 1px solid #2B2F31; .global-popover-text { @@ -10621,7 +10628,7 @@ tbody { } .maximum-canvas-width-input-select { - background-color: $bg-dark-light !important; + background-color: $bg-dark-light !important; border: 1px solid #324156; color: $white; } @@ -11580,6 +11587,14 @@ tbody { .custom-gap-6{ gap:6px } + +// ToolJet Database buttons + +.ghost-black-operation { + border: 1px solid transparent !important; + padding: 4px 10px; +} + .custom-gap-2{ gap:2px } @@ -11598,4 +11613,4 @@ tbody { border-bottom: none !important; } } -} \ No newline at end of file +} diff --git a/frontend/src/_ui/AppButton/AppButton.scss b/frontend/src/_ui/AppButton/AppButton.scss index 7f61321213..9276abd7a9 100644 --- a/frontend/src/_ui/AppButton/AppButton.scss +++ b/frontend/src/_ui/AppButton/AppButton.scss @@ -64,13 +64,14 @@ .tj-primary-btn { border-radius: 6px; border: 1px solid rgba(255, 255, 255, 0.00); - background: var(--indigo9) ; + background: var(--indigo9); box-shadow: 0px 2px 0px 0px rgba(0, 0, 0, 0.04); color: #FDFDFE; + &:hover { border: 1px solid rgba(255, 255, 255, 0.00); - background: var(--indigo10) ; - color: #FDFDFE; + background: var(--indigo10); + color: #FDFDFE; } &:focus-visible { @@ -93,6 +94,7 @@ color: var(--indigo9) !important; border: 1px solid rgba(255, 255, 255, 0.00) !important; background: var(--indigo3, #F0F4FF) !important; + &:hover { color: var(--indigo10) !important; border: 1px solid rgba(255, 255, 255, 0.00) !important; @@ -115,7 +117,8 @@ /* Focus rings/Indigo/light */ box-shadow: 0px 0px 0px 4px var(--indigo6); - }} + } +} .tj-tertiary-btn { color: var(--slate12); @@ -128,22 +131,27 @@ background: var(--slate4, #ECEEF0) !important; } - &:active,&:focus,&.always-active-btn { + &:active, + &:focus, + &.always-active-btn { border: 1px solid var(--slate7, #D7DBDF) !important; background: var(--slate5) !important; color: var(--slate12); border: 1px solid var(--slate7); } + &:active { - background: transparent; + background: var(--slate1); box-shadow: none; color: var(--slate12) !important; } &:focus-visible { - color: var(--slate11) !important; - box-shadow: 0px 0px 0px 4px var(--slate6) !important; + background: var(--slate1); + color: var(--slate11); + outline: 1px solid var(--slate8); + box-shadow: 0px 0px 0px 4px var(--slate6); outline: none; border: 1px solid var(--slate8, #C1C8CD) !important; background: var(--slate1, #FBFCFD) !important; @@ -188,7 +196,8 @@ color: var(--slate11); border: 1px solid rgba(255, 255, 255, 0.00) !important; } - &:focus-visible{ + + &:focus-visible { color: var(--slate11) !important; // background: var(--base); // border: none; @@ -197,13 +206,17 @@ border: 1px solid rgba(255, 255, 255, 0.00) !important; background: var(--slate1, #FBFCFD) !important; } - &:active,&:focus,&.always-active-btn { + + &:active, + &:focus, + &.always-active-btn { color: var(--slate12) !important; border: 1px solid rgba(255, 255, 255, 0.00) !important; background: var(--slate5, #E6E8EB) !important; box-shadow: none !important; } - &:disabled{ + + &:disabled { background: rgba(255, 255, 255, 0.00) !important; border: 1px solid rgba(255, 255, 255, 0.00) !important; color: var(--slate8) !important; diff --git a/frontend/src/_ui/Drawer/index.jsx b/frontend/src/_ui/Drawer/index.jsx index f99f28830e..006db171cf 100644 --- a/frontend/src/_ui/Drawer/index.jsx +++ b/frontend/src/_ui/Drawer/index.jsx @@ -23,6 +23,7 @@ const Drawer = ({ onClose, position = 'left', removeWhenClosed = true, + drawerStyle, }) => { const bodyRef = useRef(document.querySelector('body')); const portalRootRef = useRef(document.getElementById('tooljet-drawer-root') || createPortalRoot()); @@ -79,7 +80,7 @@ const Drawer = ({ return createPortal( - +
-
+
{children}
diff --git a/frontend/src/_ui/Icon/solidIcons/ReloadError.jsx b/frontend/src/_ui/Icon/solidIcons/ReloadError.jsx new file mode 100644 index 0000000000..055525eb4e --- /dev/null +++ b/frontend/src/_ui/Icon/solidIcons/ReloadError.jsx @@ -0,0 +1,24 @@ +import React from 'react'; + +const ReloadError = ({ fill = '#E54D2E', width = '16', height = '17', className = '', viewBox = '0 0 16 17' }) => ( + + + + + +); + +export default ReloadError; diff --git a/frontend/src/_ui/Icon/solidIcons/index.js b/frontend/src/_ui/Icon/solidIcons/index.js index 5a9f2ef761..911c291694 100644 --- a/frontend/src/_ui/Icon/solidIcons/index.js +++ b/frontend/src/_ui/Icon/solidIcons/index.js @@ -78,6 +78,7 @@ import Play from './Play.jsx'; import Plus from './Plus.jsx'; import Plus01 from './Plus01.jsx'; import Reload from './Reload.jsx'; +import ReloadError from './ReloadError.jsx'; import Remove from './Remove.jsx'; import Remove01 from './Remove01.jsx'; import RemoveRectangle from './RemoveRectangle.jsx'; @@ -311,6 +312,8 @@ const Icon = (props) => { return ; case 'reload': return ; + case 'reloaderror': + return ; case 'remove': return ; case 'remove01': diff --git a/server/src/controllers/tooljet_db.controller.ts b/server/src/controllers/tooljet_db.controller.ts index f9df7eba6d..05fa0a7f9d 100644 --- a/server/src/controllers/tooljet_db.controller.ts +++ b/server/src/controllers/tooljet_db.controller.ts @@ -1,4 +1,21 @@ -import { All, Controller, Req, Res, Next, UseGuards, Get, Post, Body, Param, Delete, Patch } from '@nestjs/common'; +import { + All, + Controller, + Req, + Res, + Next, + UseGuards, + Get, + Post, + Body, + Param, + Delete, + Patch, + UseInterceptors, + UploadedFile, + BadRequestException, +} from '@nestjs/common'; +import { Express } from 'express'; import { JwtAuthGuard } from 'src/modules/auth/jwt-auth.guard'; import { ActiveWorkspaceGuard } from 'src/modules/auth/active-workspace.guard'; import { TooljetDbService } from '@services/tooljet_db.service'; @@ -10,12 +27,17 @@ import { Action, TooljetDbAbility } from 'src/modules/casl/abilities/tooljet-db- 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'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { TooljetDbBulkUploadService } from '@services/tooljet_db_bulk_upload.service'; -@Controller('tooljet_db') +const MAX_CSV_FILE_SIZE = 1024 * 1024 * 2; // 2MB + +@Controller('tooljet-db') export class TooljetDbController { constructor( private readonly tooljetDbService: TooljetDbService, - private readonly postgrestProxyService: PostgrestProxyService + private readonly postgrestProxyService: PostgrestProxyService, + private readonly tooljetDbBulkUploadService: TooljetDbBulkUploadService ) {} @All('/proxy/*') @@ -98,6 +120,21 @@ export class TooljetDbController { return decamelizeKeys({ result }); } + @UseInterceptors(FileInterceptor('file')) + @Post('/organizations/:organizationId/table/:tableName/bulk-upload') + async bulkUpload( + @Param('organizationId') organizationId, + @Param('tableName') tableName, + @UploadedFile() file: Express.Multer.File + ) { + if (file.size > MAX_CSV_FILE_SIZE) { + throw new BadRequestException('File size cannot be greater than 2MB'); + } + const result = await this.tooljetDbBulkUploadService.perform(organizationId, tableName, file.buffer); + + return decamelizeKeys({ result }); + } + @Post('/organizations/:organizationId/join') @UseGuards(TooljetDbGuard) @CheckPolicies((ability: TooljetDbAbility) => ability.can(Action.JoinTables, 'all')) 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 eb37cc84ab..739355577a 100644 --- a/server/src/modules/casl/abilities/tooljet-db-ability.factory.ts +++ b/server/src/modules/casl/abilities/tooljet-db-ability.factory.ts @@ -13,6 +13,7 @@ export enum Action { DropTable = 'dropTable', AddColumn = 'addColumn', DropColumn = 'dropColumn', + BulkUpload = 'bulkUpload', JoinTables = 'joinTables', } @@ -37,6 +38,7 @@ export class TooljetDbAbilityFactory { can(Action.AddColumn, 'all'); can(Action.DropColumn, 'all'); can(Action.RenameTable, 'all'); + can(Action.BulkUpload, 'all'); } if (isPublicAppRequest || isUserLoggedin) { diff --git a/server/src/modules/tooljet_db/tooljet_db.module.ts b/server/src/modules/tooljet_db/tooljet_db.module.ts index fb98e5d95a..eb7c554ebe 100644 --- a/server/src/modules/tooljet_db/tooljet_db.module.ts +++ b/server/src/modules/tooljet_db/tooljet_db.module.ts @@ -7,10 +7,17 @@ import { TooljetDbService } from '@services/tooljet_db.service'; import { CredentialsService } from '@services/credentials.service'; import { EncryptionService } from '@services/encryption.service'; import { PostgrestProxyService } from '@services/postgrest_proxy.service'; +import { TooljetDbBulkUploadService } from '@services/tooljet_db_bulk_upload.service'; @Module({ imports: [TypeOrmModule.forFeature([Credential]), CaslModule], controllers: [TooljetDbController], - providers: [TooljetDbService, PostgrestProxyService, EncryptionService, CredentialsService], + providers: [ + TooljetDbService, + TooljetDbBulkUploadService, + PostgrestProxyService, + EncryptionService, + CredentialsService, + ], }) export class TooljetDbModule {} diff --git a/server/src/services/postgrest_proxy.service.ts b/server/src/services/postgrest_proxy.service.ts index 0aaf06fd46..215db8df1d 100644 --- a/server/src/services/postgrest_proxy.service.ts +++ b/server/src/services/postgrest_proxy.service.ts @@ -26,7 +26,7 @@ export class PostgrestProxyService { private httpProxy = proxy(this.configService.get('PGRST_HOST'), { proxyReqPathResolver: function (req) { - const path = '/api/tooljet_db'; + const path = '/api/tooljet-db'; const pathRegex = new RegExp(`${maybeSetSubPath(path)}/proxy`); const parts = req.url.split('?'); const queryString = parts[1]; diff --git a/server/src/services/tooljet_db.service.ts b/server/src/services/tooljet_db.service.ts index cf26e72799..0b422e71a4 100644 --- a/server/src/services/tooljet_db.service.ts +++ b/server/src/services/tooljet_db.service.ts @@ -3,7 +3,19 @@ import { EntityManager, In, QueryFailedError } from 'typeorm'; import { InjectEntityManager } from '@nestjs/typeorm'; import { InternalTable } from 'src/entities/internal_table.entity'; import { isString, isEmpty } from 'lodash'; -import { PostgrestProxyService } from '@services/postgrest_proxy.service'; + +export type TableColumnSchema = { + column_name: string; + data_type: SupportedDataTypes; + column_default: string | null; + character_maximum_length: number | null; + numeric_precision: number | null; + is_nullable: 'YES' | 'NO'; + constraint_type: string | null; + keytype: string | null; +}; + +export type SupportedDataTypes = 'character varying' | 'integer' | 'bigint' | 'serial' | 'double precision' | 'boolean'; @Injectable() export class TooljetDbService { @@ -11,8 +23,7 @@ export class TooljetDbService { private readonly manager: EntityManager, @Optional() @InjectEntityManager('tooljetDb') - private tooljetDbManager: EntityManager, - private readonly postgrestProxyService: PostgrestProxyService + private readonly tooljetDbManager: EntityManager ) {} async perform(organizationId: string, action: string, params = {}) { @@ -38,7 +49,7 @@ export class TooljetDbService { } } - private async viewTable(organizationId: string, params) { + private async viewTable(organizationId: string, params): Promise { const { table_name: tableName, id: id } = params; const internalTable = await this.manager.findOne(InternalTable, { diff --git a/server/src/services/tooljet_db_bulk_upload.service.ts b/server/src/services/tooljet_db_bulk_upload.service.ts new file mode 100644 index 0000000000..74536e00e0 --- /dev/null +++ b/server/src/services/tooljet_db_bulk_upload.service.ts @@ -0,0 +1,200 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { EntityManager } from 'typeorm'; +import { InternalTable } from 'src/entities/internal_table.entity'; +import * as csv from 'fast-csv'; +import { SupportedDataTypes, TableColumnSchema, TooljetDbService } from './tooljet_db.service'; +import { InjectEntityManager } from '@nestjs/typeorm'; +import { isEmpty } from 'lodash'; +import { pipeline } from 'stream/promises'; +import { PassThrough } from 'stream'; + +const MAX_ROW_COUNT = 1000; + +@Injectable() +export class TooljetDbBulkUploadService { + constructor( + private readonly manager: EntityManager, + @InjectEntityManager('tooljetDb') + private readonly tooljetDbManager: EntityManager, + private readonly tooljetDbService: TooljetDbService + ) {} + + async perform(organizationId: string, tableName: string, fileBuffer: Buffer) { + const internalTable = await this.manager.findOne(InternalTable, { + select: ['id'], + where: { organizationId, tableName }, + }); + + if (!internalTable) { + throw new NotFoundException(`Table ${tableName} not found`); + } + + const internalTableColumnSchema = await this.tooljetDbService.perform(organizationId, 'view_table', { + table_name: tableName, + }); + + return await this.bulkUploadCsv(internalTable.id, internalTableColumnSchema, fileBuffer); + } + + async bulkUploadCsv( + internalTableId: string, + internalTableColumnSchema: TableColumnSchema[], + fileBuffer: Buffer + ): Promise<{ processedRows: number; rowsInserted: number; rowsUpdated: number }> { + const csvStream = csv.parseString(fileBuffer.toString(), { + headers: true, + ignoreEmpty: true, + strictColumnHandling: true, + discardUnmappedColumns: true, + }); + const rowsToInsert = []; + const rowsToUpdate = []; + const idstoUpdate = new Set(); + let rowsProcessed = 0; + + const passThrough = new PassThrough(); + + csvStream + .on('headers', (headers) => this.validateHeadersAsColumnSubset(internalTableColumnSchema, headers, csvStream)) + .transform((row) => this.validateAndParseColumnDataType(internalTableColumnSchema, row, rowsProcessed, csvStream)) + .on('data', (row) => { + rowsProcessed++; + if (row.id) { + if (idstoUpdate.has(row.id)) { + throw new BadRequestException(`Duplicate 'id' value found on row[${rowsProcessed + 1}]: ${row.id}`); + } + + idstoUpdate.add(row.id); + rowsToUpdate.push(row); + } else { + rowsToInsert.push(row); + } + }) + .on('error', (error) => { + csvStream.destroy(); + passThrough.emit('error', new BadRequestException(error)); + }) + .on('end', () => { + passThrough.emit('end'); + }); + + await pipeline(passThrough, csvStream); + + await this.tooljetDbManager.transaction(async (tooljetDbManager) => { + await this.bulkInsertRows(tooljetDbManager, rowsToInsert, internalTableId); + await this.bulkUpdateRows(tooljetDbManager, rowsToUpdate, internalTableId); + }); + + return { processedRows: rowsProcessed, rowsInserted: rowsToInsert.length, rowsUpdated: rowsToUpdate.length }; + } + + async bulkUpdateRows(tooljetDbManager: EntityManager, rowsToUpdate: unknown[], internalTableId: string) { + if (isEmpty(rowsToUpdate)) return; + + const updateQueries = rowsToUpdate.map((row) => { + const columnNames = Object.keys(rowsToUpdate[0]); + const setClauses = columnNames + .map((column) => { + return `${column} = $${columnNames.indexOf(column) + 1}`; + }) + .join(', '); + + return { + text: `UPDATE "${internalTableId}" SET ${setClauses} WHERE id = $${columnNames.indexOf('id') + 1}`, + values: columnNames.map((column) => row[column]), + }; + }); + + for (const updateQuery of updateQueries) { + await tooljetDbManager.query(updateQuery.text, updateQuery.values); + } + } + + async bulkInsertRows(tooljetDbManager: EntityManager, rowsToInsert: unknown[], internalTableId: string) { + if (isEmpty(rowsToInsert)) return; + + const insertQueries = rowsToInsert.map((row, index) => { + return { + text: `INSERT INTO "${internalTableId}" (${Object.keys(row).join(', ')}) VALUES (${Object.values(row).map( + (_, index) => `$${index + 1}` + )})`, + values: Object.values(row), + }; + }); + + for (const insertQuery of insertQueries) { + await tooljetDbManager.query(insertQuery.text, insertQuery.values); + } + } + + async validateHeadersAsColumnSubset( + internalTableColumnSchema: TableColumnSchema[], + headers: string[], + csvStream: csv.CsvParserStream, csv.ParserRow> + ) { + const internalTableColumns = new Set(internalTableColumnSchema.map((c) => c.column_name)); + const columnsInCsv = new Set(headers); + const isSubset = (subset: Set, superset: Set) => [...subset].every((item) => superset.has(item)); + + if (!isSubset(columnsInCsv, internalTableColumns)) { + const columnsNotIntable = [...columnsInCsv].filter((element) => !internalTableColumns.has(element)); + + csvStream.emit('error', `Columns ${columnsNotIntable.join(',')} not found in table`); + } + } + + validateAndParseColumnDataType( + internalTableColumnSchema: TableColumnSchema[], + row: unknown, + rowsProcessed: number, + csvStream: csv.CsvParserStream, csv.ParserRow> + ) { + if (rowsProcessed >= MAX_ROW_COUNT) csvStream.emit('error', `Row count cannot be greater than ${MAX_ROW_COUNT}`); + + try { + const columnsInCsv = Object.keys(row); + const transformedRow = columnsInCsv.reduce((result, columnInCsv) => { + const columnDetails = internalTableColumnSchema.find((colDetails) => colDetails.column_name === columnInCsv); + const convertedValue = this.validateDataType(row[columnInCsv], columnDetails.data_type); + + if (convertedValue) result[columnInCsv] = this.validateDataType(row[columnInCsv], columnDetails.data_type); + + return result; + }, {}); + + return transformedRow; + } catch (error) { + csvStream.emit('error', `Data type error at row[${rowsProcessed + 1}]: ${error}`); + } + } + + validateDataType(columnValue: string, supportedDataType: SupportedDataTypes) { + if (!columnValue) return null; + + switch (supportedDataType) { + case 'boolean': + return this.validateBoolean(columnValue); + case 'integer': + case 'double precision': + case 'bigint': + return this.validateNumber(columnValue, supportedDataType); + default: + return columnValue; + } + } + + validateBoolean(str: string) { + const parsedString = str.toLowerCase().trim(); + if (parsedString === 'true' || parsedString === 'false') return str; + + throw `${str} is not a valid boolean string`; + } + + validateNumber(str: string, dataType: 'integer' | 'bigint' | 'double precision') { + if (dataType === 'integer' && !isNaN(parseInt(str, 10))) return str; + if (dataType === 'double precision' && !isNaN(parseFloat(str))) return str; + if (dataType === 'bigint' && typeof BigInt(str) === 'bigint') return str; + + throw `${str} is not a valid ${dataType}`; + } +} From dea78173f6bc823b19a71752a7634927a68109b8 Mon Sep 17 00:00:00 2001 From: Kiran Ashok Date: Fri, 29 Sep 2023 10:58:15 +0530 Subject: [PATCH 8/8] Hotfix :: Components not showing up in dropdown for csa using page event handler (#7539) * fix :: componenets not showing up on page events for csa * remove comment --- frontend/src/Editor/Inspector/EventManager.jsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/frontend/src/Editor/Inspector/EventManager.jsx b/frontend/src/Editor/Inspector/EventManager.jsx index 2142538b86..390a7b6515 100644 --- a/frontend/src/Editor/Inspector/EventManager.jsx +++ b/frontend/src/Editor/Inspector/EventManager.jsx @@ -121,19 +121,18 @@ export const EventManager = ({ }); return componentOptions; } - // currently blocking items inside subcontainer because they can't be controlled through event manager - // use components instead of currentState?.components to get all the components in canvas + function getComponentOptionsOfComponentsWithActions(componentType = '') { let componentOptions = []; - Object.values(currentState?.components || {}).forEach((value) => { + Object.keys(components || {}).forEach((key) => { const targetComponentMeta = componentTypes.find( - (componentType) => components[value.id]?.component?.component === componentType?.component + (componentType) => components[key].component.component === componentType.component ); if ((targetComponentMeta?.actions?.length ?? 0) > 0) { - if (componentType === '' || components[value.id].component.component === componentType) { + if (componentType === '' || components[key].component.component === componentType) { componentOptions.push({ - name: components[value.id].component.name, - value: value.id, + name: components[key].component.name, + value: key, }); } }