Merge branch 'appbuilder/sprint-14' into snapping-react-moveable

This commit is contained in:
Nakul Nagargade 2025-06-26 15:40:16 +05:30
commit 6257ec8a78
96 changed files with 1469 additions and 1002 deletions

@ -1 +1 @@
Subproject commit 387bb6e55bc6a7600b7125bb9e22ca2b17dfe65d
Subproject commit 9458c8d66f29f8334765b5757dd096139a8d53d2

View file

@ -143,10 +143,11 @@ export const Container = React.memo(
if (canvasWidth !== undefined) {
if (componentType === 'Listview' && listViewMode == 'grid') return canvasWidth / columns - 2;
if (id === 'canvas') return canvasWidth;
return getSubContainerWidthAfterPadding(canvasWidth, componentType, id);
return getSubContainerWidthAfterPadding(canvasWidth, componentType, id, realCanvasRef);
}
return realCanvasRef?.current?.offsetWidth;
}
const gridWidth = getContainerCanvasWidth() / NO_OF_GRIDS;
useEffect(() => {

View file

@ -39,3 +39,5 @@ export const DROPPABLE_PARENTS = new Set([
export const TAB_CANVAS_PADDING = 7.5;
export const MODAL_CANVAS_PADDING = 5;
export const LISTVIEW_CANVAS_PADDING = 7;

View file

@ -15,6 +15,7 @@ import {
BOX_PADDING,
TAB_CANVAS_PADDING,
MODAL_CANVAS_PADDING,
LISTVIEW_CANVAS_PADDING,
} from './appCanvasConstants';
export function snapToGrid(canvasWidth, x, y) {
@ -779,7 +780,7 @@ export const getSubContainerIdWithSlots = (parentId) => {
return cleanParentId;
};
export const getSubContainerWidthAfterPadding = (canvasWidth, componentType, componentId) => {
export const getSubContainerWidthAfterPadding = (canvasWidth, componentType, componentId, realCanvasRef) => {
let padding = 2; //Need to update this 2 to correct value for other subcontainers
if (componentType === 'Container' || componentType === 'Form') {
padding = 2 * CONTAINER_FORM_CANVAS_PADDING + 2 * SUBCONTAINER_CANVAS_BORDER_WIDTH + 2 * BOX_PADDING;
@ -797,5 +798,8 @@ export const getSubContainerWidthAfterPadding = (canvasWidth, componentType, com
padding = 2 * MODAL_CANVAS_PADDING;
}
}
if (componentType === 'Listview') {
padding = 2 * LISTVIEW_CANVAS_PADDING + 5; // 5 is accounting for scrollbar
}
return canvasWidth - padding;
};

View file

@ -58,7 +58,7 @@ const MultiLineCodeEditor = (props) => {
const replaceIdsWithName = useStore((state) => state.replaceIdsWithName, shallow);
const wrapperRef = useRef(null);
const getSuggestions = useStore((state) => state.getSuggestions, shallow);
const getServerSideGlobalSuggestions = useStore((state) => state.getServerSideGlobalSuggestions, shallow);
const getServerSideGlobalResolveSuggestions = useStore((state) => state.getServerSideGlobalResolveSuggestions, shallow);
const isInsideQueryPane = !!document.querySelector('.code-hinter-wrapper')?.closest('.query-details');
const isInsideQueryManager = useMemo(
@ -116,7 +116,7 @@ const MultiLineCodeEditor = (props) => {
const hints = getSuggestions();
const serverHints = getServerSideGlobalSuggestions(isInsideQueryManager);
const serverHints = getServerSideGlobalResolveSuggestions(isInsideQueryManager);
const allHints = {
...hints,

View file

@ -98,7 +98,7 @@ export const PreviewBox = ({
const [largeDataset, setLargeDataset] = useState(false);
const globals = useStore((state) => state.getAllExposedValues(moduleId).constants || {}, shallow);
const secrets = useStore((state) => state.getSecrets(), shallow);
const globalServerConstantsRegex = /^\{\{.*globals\.server.*\}\}$/;
const globalServerConstantsRegex = /\{\{.*globals\.server.*\}\}/;
const getPreviewContent = (content, type) => {
if (content === undefined || content === null) return currentValue;
@ -251,7 +251,10 @@ const RenderResolvedValue = ({
isServerConstant = false,
isLargeDataset,
}) => {
const isServerSideGlobalEnabled = useStore((state) => !!state?.license?.featureAccess?.serverSideGlobal, shallow);
const isServerSideGlobalResolveEnabled = useStore(
(state) => !!state?.license?.featureAccess?.serverSideGlobalResolve,
shallow
);
const computeCoersionPreview = (resolvedValue, coersionData) => {
if (coersionData?.typeBeforeCoercion === coersionData?.typeAfterCoercion) return resolvedValue;
@ -276,7 +279,7 @@ const RenderResolvedValue = ({
: previewType;
const previewContent = isServerConstant
? isServerSideGlobalEnabled
? isServerSideGlobalResolveEnabled
? 'Server variables would be resolved at runtime'
: 'Server variables are only available in paid plans'
: isSecretConstant

View file

@ -216,7 +216,7 @@ const EditorInput = ({
const getSuggestions = useStore((state) => state.getSuggestions, shallow);
const [codeMirrorView, setCodeMirrorView] = useState(undefined);
const getServerSideGlobalSuggestions = useStore((state) => state.getServerSideGlobalSuggestions, shallow);
const getServerSideGlobalResolveSuggestions = useStore((state) => state.getServerSideGlobalResolveSuggestions, shallow);
const { queryPanelKeybindings } = useQueryPanelKeyHooks(onBlurUpdate, currentValue, 'singleline');
@ -226,7 +226,7 @@ const EditorInput = ({
);
function autoCompleteExtensionConfig(context) {
const hintsWithoutParamHints = getSuggestions();
const serverHints = getServerSideGlobalSuggestions(isInsideQueryManager);
const serverHints = getServerSideGlobalResolveSuggestions(isInsideQueryManager);
let word = context.matchBefore(/\w*/);

View file

@ -10,12 +10,13 @@ import AppModeToggle from './AppModeToggle';
import { ThemeSelect } from '@/modules/Appbuilder/components';
import MaintenanceMode from './MaintenanceMode';
import HideHeaderToggle from './HideHeaderToggle';
import { ModuleProvider } from '@/AppBuilder/_contexts/ModuleContext';
const GlobalSettings = ({ darkMode }) => {
const shouldFreeze = useStore((state) => state.getShouldFreeze());
return (
<>
<ModuleProvider moduleId={'canvas'}>
<div>
<div bsPrefix="global-settings-popover" className="global-settings-panel">
<HeaderSection>
@ -44,7 +45,7 @@ const GlobalSettings = ({ darkMode }) => {
</div>
</div>
</div>
</>
</ModuleProvider>
);
};

View file

@ -12,11 +12,12 @@ import './style.scss';
import { SortableTree } from './Tree/SortableTree';
import { PageGroupMenu } from './AddPageButton';
import { PageHandlerMenu } from './PageHandlerMenu.jsx';
import AppPermissionsModal from '@/modules/Appbuilder/components/AppPermissionsModal';
import { EditModal } from './EditModal';
import { SettingsModal } from './SettingsModal';
import { DeletePageConfirmationModal } from './DeletePageConfirmationModal';
import SolidIcon from '@/_ui/Icon/SolidIcons';
import PagePermission from './PagePermission';
import { appPermissionService } from '@/_services';
export const PageMenu = ({ darkMode, switchPage, pinned, setPinned }) => {
const showAddNewPageInput = useStore((state) => state.showAddNewPageInput);
@ -27,6 +28,12 @@ export const PageMenu = ({ darkMode, switchPage, pinned, setPinned }) => {
const shouldFreeze = useStore((state) => state.getShouldFreeze());
const enableReleasedVersionPopupState = useStore((state) => state.enableReleasedVersionPopupState);
const closePageEditPopover = useStore((state) => state.closePageEditPopover);
const editingPageId = useStore((state) => state.editingPage?.id);
const editingPageName = useStore((state) => state.editingPage?.name);
const showPagePermissionModal = useStore((state) => state.showPagePermissionModal);
const togglePagePermissionModal = useStore((state) => state.togglePagePermissionModal);
const updatePageWithPermissions = useStore((state) => state.updatePageWithPermissions);
useEffect(() => {
return () => {
closePageEditPopover();
@ -95,7 +102,21 @@ export const PageMenu = ({ darkMode, switchPage, pinned, setPinned }) => {
>
<div>
<PageHandlerMenu darkMode={darkMode} />
{isLicensed ? <PagePermission darkMode={darkMode} /> : <></>}
{isLicensed && (
<AppPermissionsModal
modalType="page"
resourceId={editingPageId}
resourceName={editingPageName}
showModal={showPagePermissionModal}
toggleModal={togglePagePermissionModal}
darkMode={darkMode}
fetchPermission={(id, appId) => appPermissionService.getPagePermission(appId, id)}
createPermission={(id, appId, body) => appPermissionService.createPagePermission(appId, id, body)}
updatePermission={(id, appId, body) => appPermissionService.updatePagePermission(appId, id, body)}
deletePermission={(id, appId) => appPermissionService.deletePagePermission(appId, id)}
onSuccess={(data) => updatePageWithPermissions(editingPageId, data)}
/>
)}
<EditModal darkMode={darkMode} />
<SettingsModal darkMode={darkMode} />
<DeletePageConfirmationModal darkMode={darkMode} />

View file

@ -1,509 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import { components } from 'react-select';
import ModalBase from '@/_ui/Modal';
import Select from '@/_ui/Select';
import SolidIcon from '@/_ui/Icon/SolidIcons';
import useStore from '@/AppBuilder/_stores/store';
import { appPermissionService } from '@/_services';
import { ConfirmDialog } from '@/_components';
import toast from 'react-hot-toast';
import Spinner from '@/_ui/Spinner';
import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext';
const PERMISSION_TYPES = {
single: 'SINGLE',
group: 'GROUP',
all: 'ALL',
};
export default function PagePermission({ darkMode }) {
const { moduleId } = useModuleContext();
const showPagePermissionModal = useStore((state) => state.showPagePermissionModal);
const togglePagePermissionModal = useStore((state) => state.togglePagePermissionModal);
const editingPage = useStore((state) => state.editingPage);
const appId = useStore((state) => state.appStore.modules[moduleId].app.appId);
const selectedUserGroups = useStore((state) => state.selectedUserGroups);
const setSelectedUserGroups = useStore((state) => state.setSelectedUserGroups);
const selectedUsers = useStore((state) => state.selectedUsers);
const setSelectedUsers = useStore((state) => state.setSelectedUsers);
const pagePermission = useStore((state) => state.pagePermission);
const setPagePermission = useStore((state) => state.setPagePermission);
const updatePageWithPermissions = useStore((state) => state.updatePageWithPermissions);
const [pagePermissionType, setPagePermissionType] = useState('all');
const [showUserGroupSelect, toggleUserGroupSelect] = useState(false);
const [showUsersSelect, toggleUsersSelect] = useState(false);
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isPermissionsLoading, setPermissionsLoading] = useState(true);
const [initialSelectedGroups, setInitialSelectedGroups] = useState([]);
const [initialSelectedUsers, setInitialSelectedUsers] = useState([]);
const [initalPagePermissionType, setInitialPagePermissionType] = useState('all');
useEffect(() => {
if (!showPagePermissionModal) return;
const fetchPagePermission = () => {
appPermissionService.getPagePermission(appId, editingPage?.id).then((data) => {
if (data) {
if (data[0] && data[0]?.type === PERMISSION_TYPES.group) {
const groups =
data[0]?.groups?.map((user) => ({
label: user?.permissionGroup?.name,
value: user?.permissionGroup?.id,
count: user?.permissionGroup?.count,
})) ?? [];
setPagePermissionType(data[0]?.type?.toLowerCase());
setInitialPagePermissionType(data[0]?.type?.toLowerCase());
setPagePermission(data);
toggleUserGroupSelect(true);
setInitialSelectedGroups(groups);
data?.length && setSelectedUserGroups(groups);
} else if (data[0] && data[0]?.type === PERMISSION_TYPES.single) {
const users =
data[0]?.users?.map(({ user }) => {
const firstName = user.firstName || '';
const lastName = user.lastName || '';
return {
value: user.id,
label: `${firstName} ${lastName}`.trim(),
email: user.email,
initials: `${firstName[0] || ''}${lastName[0] || ''}`.toUpperCase(),
};
}) ?? [];
setPagePermissionType(data[0]?.type?.toLowerCase());
setInitialPagePermissionType(data[0]?.type?.toLowerCase());
setPagePermission(data);
toggleUsersSelect(true);
setInitialSelectedUsers(users);
data?.length && setSelectedUsers(users);
}
}
setPermissionsLoading(false);
});
};
fetchPagePermission();
}, [showPagePermissionModal]);
const isSelectionUnchanged = useMemo(() => {
if (pagePermissionType === 'group') {
if (!selectedUserGroups.length) return true;
const current = selectedUserGroups
.map((g) => g.value)
.sort()
.join(',');
const initial = initialSelectedGroups
.map((g) => g.value)
.sort()
.join(',');
return current === initial;
} else if (pagePermissionType === 'single') {
if (!selectedUsers.length) return true;
const current = selectedUsers
.map((u) => u.value)
.sort()
.join(',');
const initial = initialSelectedUsers
.map((u) => u.value)
.sort()
.join(',');
return current === initial;
} else {
if (!pagePermission?.length) {
return true;
} else {
return initalPagePermissionType == pagePermissionType;
}
}
}, [
pagePermissionType,
selectedUserGroups,
initialSelectedGroups,
selectedUsers,
initialSelectedUsers,
initalPagePermissionType,
]);
const permissionTypeOptions = useMemo(
() => [
{
label: 'All users with access to the app',
value: 'all',
icon: 'globe',
},
{
label: 'Users',
value: 'single',
icon: 'user',
},
{
label: 'User groups',
value: 'group',
icon: 'usergroup',
},
],
[]
);
const handlePermissionTypeChange = (value) => {
switch (value) {
case 'group': {
toggleUserGroupSelect(true);
toggleUsersSelect(false);
setPagePermissionType('group');
break;
}
case 'single': {
toggleUsersSelect(true);
toggleUserGroupSelect(false);
setPagePermissionType('single');
break;
}
case 'all': {
toggleUsersSelect(false);
toggleUserGroupSelect(false);
setPagePermissionType('all');
}
}
};
const handlePagePermissionModalClose = () => {
togglePagePermissionModal(false);
toggleUserGroupSelect(false);
toggleUsersSelect(false);
setPagePermissionType('all');
setPagePermission(null);
setSelectedUsers([]);
setSelectedUserGroups([]);
setInitialSelectedGroups([]);
setInitialSelectedUsers([]);
};
const createPagePermission = () => {
const body = {
pageId: editingPage?.id,
type: PERMISSION_TYPES[pagePermissionType],
...(pagePermissionType === 'group'
? { groups: selectedUserGroups.map((group) => group?.value) }
: { users: selectedUsers.map((user) => user?.value) }),
};
setIsLoading(true);
appPermissionService
.createPagePermission(appId, editingPage?.id, body)
.then((data) => {
toast.success('Permission successfully created!', {
className: 'text-nowrap w-auto mw-100',
});
updatePageWithPermissions(editingPage?.id, data);
})
.catch(() => {
toast.error('Permission could not be created. Please try again!', {
className: 'text-nowrap w-auto mw-100',
});
})
.finally(() => {
setIsLoading(false);
handlePagePermissionModalClose();
});
};
const updatePagePermission = () => {
const body = {
pageId: editingPage?.id,
type: PERMISSION_TYPES[pagePermissionType],
...(pagePermissionType === 'group'
? { groups: selectedUserGroups.map((group) => group?.value) }
: { users: selectedUsers.map((user) => user?.value) }),
};
setIsLoading(true);
appPermissionService
.updatePagePermission(appId, editingPage?.id, body)
.then((data) => {
toast.success('Permission successfully updated!', {
className: 'text-nowrap w-auto mw-100',
});
updatePageWithPermissions(editingPage?.id, data);
})
.catch(() => {
toast.error('Permission could not be updated. Please try again!', {
className: 'text-nowrap w-auto mw-100',
});
})
.finally(() => {
setIsLoading(false);
handlePagePermissionModalClose();
});
};
const deletePagePermission = () => {
setIsLoading(true);
appPermissionService
.deletePagePermission(appId, editingPage?.id)
.then((data) => {
toast.success('Permission successfully deleted!', {
className: 'text-nowrap w-auto mw-100',
});
updatePageWithPermissions(editingPage?.id, []);
})
.catch(() => {
toast.error('Permission could not be deleted. Please try again!', {
className: 'text-nowrap w-auto mw-100',
});
setShowConfirmDelete(false);
togglePagePermissionModal(true);
})
.finally(() => {
setIsLoading(false);
setShowConfirmDelete(false);
});
};
const renderPermissionTypeOptions = ({ label, icon }) => {
return (
<div className="row permission-type-select" style={{ padding: '0px 8px' }}>
<div className="col-auto">
<SolidIcon width="20" name={icon} />
</div>
<div className="col">
<span>{label}</span>
</div>
</div>
);
};
return (
<>
<ModalBase
title={
<div className="my-3">
<span className="tj-text-md font-weight-500">Page permission</span>
</div>
}
handleConfirm={!pagePermission ? createPagePermission : updatePagePermission}
show={showPagePermissionModal}
isLoading={isLoading}
handleClose={handlePagePermissionModalClose}
confirmBtnProps={{
title: pagePermission
? 'Save changes'
: pagePermissionType === 'all'
? 'Default permission'
: 'Create permission',
disabled: isPermissionsLoading || isSelectionUnchanged,
tooltipMessage: '',
leftIcon: pagePermission && 'save',
className: 'action-btn-page-permission',
}}
darkMode={darkMode}
className="page-permissions-modal"
>
<div className="page-permission">
{isPermissionsLoading ? (
<div className="spinner-center">
<Spinner />
</div>
) : (
<>
<div className="info-container">
<div className="col-md-1 info-btn">
<SolidIcon name="informationcircle" fill="#3E63DD" />
</div>
<div className="col-md-11">
<div className="message">
<p style={{ lineHeight: '18px' }}>
Only selected users will be allowed to access this page. Read docs to know more.
</p>
</div>
</div>
</div>
<label className="form-label">Type</label>
<Select
options={permissionTypeOptions}
value={pagePermissionType}
width={'100%'}
customOption={renderPermissionTypeOptions}
useMenuPortal={false}
onChange={handlePermissionTypeChange}
/>
{showUserGroupSelect && <UserGroupSelect />}
{showUsersSelect && <UserSelect />}
</>
)}
</div>
</ModalBase>
{showConfirmDelete && (
<ConfirmDialog
title={'Delete page permission'}
show={showConfirmDelete}
message={
'Deleting the permission will allow all users with access to the app to view this page. Are you sure you want to continue?'
}
confirmButtonLoading={isLoading}
onConfirm={() => deletePagePermission()}
onCancel={() => setShowConfirmDelete(false)}
confirmButtonText={'Delete'}
darkMode={darkMode}
confirmButtonIcon={'trash'}
confirmButtonIconWidth="20"
confirmButtonIconFill={'var(--slate3)'}
/>
)}
</>
);
}
const UserGroupSelect = () => {
const { moduleId } = useModuleContext();
const appId = useStore((state) => state.appStore.modules[moduleId].app.appId);
const selectedUserGroups = useStore((state) => state.selectedUserGroups);
const setSelectedUserGroups = useStore((state) => state.setSelectedUserGroups);
const [userGroups, setUserGroups] = useState([]);
useEffect(() => {
const fetchUserGroups = () => {
appPermissionService.getUsers(appId, 'user-groups').then((data) => {
if (data?.length) {
const groups = [];
data.map((group) => {
groups.push({ value: group.id, label: group.name, count: group.count });
});
setUserGroups(groups);
}
});
};
fetchUserGroups();
}, []);
const CustomOption = (props) => {
const { data, isFocused, isSelected } = props;
return (
<components.Option {...props}>
<div className={`user-select-option ${isFocused ? 'focused' : ''}`}>
<input
style={{ width: '1.2rem', height: '1.2rem', borderRadius: '6px !important' }}
type={'checkbox'}
className="form-check-input"
checked={isSelected}
/>
<div className="group-info">
<div className="name">{data.label}</div>
<div className="count">{data.count} users</div>
</div>
</div>
</components.Option>
);
};
return (
<div>
<label className="form-label mt-3">User groups</label>
<Select
isMulti={true}
options={userGroups}
value={selectedUserGroups}
width={'100%'}
closeMenuOnSelect={false}
components={{ Option: CustomOption, MenuList: CustomMenuList }}
useMenuPortal={false}
hideSelectedOptions={false}
onChange={(groups) => setSelectedUserGroups(groups)}
info="Only user groups with access to this application can be selected"
/>
</div>
);
};
const UserSelect = () => {
const { moduleId } = useModuleContext();
const appId = useStore((state) => state.appStore.modules[moduleId].app.appId);
const editingPage = useStore((state) => state.editingPage);
const selectedUsers = useStore((state) => state.selectedUsers);
const setSelectedUsers = useStore((state) => state.setSelectedUsers);
const [users, setUsers] = useState([]);
useEffect(() => {
const fetchUsers = () => {
appPermissionService.getUsers(appId, 'users').then((data) => {
if (data?.length) {
const users = [];
data.map((user) => {
const firstName = user.firstName || '';
const lastName = user.lastName || '';
users.push({
value: user.id,
label: `${firstName} ${lastName}`.trim(),
email: user.email,
initials: `${firstName[0] || ''}${lastName[0] || ''}`.toUpperCase(),
});
});
setUsers(users);
}
});
};
fetchUsers();
}, []);
const CustomOption = (props) => {
const { data, isFocused, isSelected } = props;
return (
<components.Option {...props}>
<div className={`user-select-option ${isFocused ? 'focused' : ''}`}>
<input
style={{ width: '1.2rem', height: '1.2rem', borderRadius: '6px !important' }}
type={'checkbox'}
className="form-check-input"
checked={isSelected}
/>
<div className="avatar">{data.initials}</div>
<div className="user-info">
<div className="name">{data.label}</div>
<div className="email">{data.email}</div>
</div>
</div>
</components.Option>
);
};
const selectStyles = {
option: (base) => ({
...base,
padding: '8px 0px',
}),
};
return (
<div>
<label className="form-label mt-3">Users</label>
<Select
isMulti={true}
options={users}
value={selectedUsers}
width={'100%'}
useMenuPortal={false}
closeMenuOnSelect={false}
components={{ Option: CustomOption, MenuList: CustomMenuList }}
styles={selectStyles}
hideSelectedOptions={false}
info="Only user with access to this application can be selected"
onChange={(users) => {
setSelectedUsers(users);
}}
/>
</div>
);
};
const CustomMenuList = (props) => {
const { info } = props.selectProps;
return (
<components.MenuList {...props}>
<div className="info-container" style={{ marginLeft: '12px', marginRight: '12px', marginTop: '8px' }}>
<div className="col-md-1 info-btn">
<SolidIcon name="informationcircle" fill="#3E63DD" />
</div>
<div className="col-md-11">
<div className="message">
<p style={{ lineHeight: '18px' }}>{info}</p>
</div>
</div>
</div>
{props.children}
</components.MenuList>
);
};

View file

@ -289,115 +289,3 @@
}
}
}
.page-permission {
.info-container {
display: flex;
width: auto;
height: auto;
padding: 10px 12px 8px 12px;
border: 1px solid var(--slate5);
background: var(--slate2);
border-radius: 6px 6px 6px 6px;
margin-bottom: 13px;
margin-top: 0px;
}
.permission-type-select {
align-items: center;
.col-auto {
padding-right: 0px;
}
}
}
.page-permissions-modal {
#header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.react-select__option {
padding: 8px 0px;
input {
margin-right: 10px;
}
}
.react-select__menu-list {
overflow-y: unset !important;
}
.user-select-option {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
&.focused {
background-color: #f3f4f6; // Tailwind's gray-100 vibe
}
.avatar {
background-color: var(--slate5); // light gray
color: var(--slate12); // dark text
font-weight: 500;
font-size: 16px;
width: 36px;
height: 36px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
flex-shrink: 0;
}
.user-info {
display: flex;
flex-direction: column;
.name {
font-weight: 500;
font-size: 14px;
color: var(--slate12);
}
.email {
font-size: 12px;
color: var(--slate10);
}
}
.group-info {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
.name {
font-weight: 400;
font-size: 14px;
color: var(--slate12);
}
.count {
font-size: 12px;
color: var(--slate9);
}
}
}
}
.page-permission {
.spinner-center {
min-height: 250px;
}
}
.modal-base .modal-footer .action-btn-page-permission svg path {
fill: var(--indigo1) !important;
}

View file

@ -1,10 +1,10 @@
import React, { useState, useCallback } from 'react';
import React, { useState } from 'react';
import { Tooltip } from 'react-tooltip';
import { ToolTip } from '@/_components/ToolTip';
import { updateQuerySuggestions } from '@/_helpers/appUtils';
// import { Confirm } from '../Viewer/Confirm';
import { toast } from 'react-hot-toast';
import { shallow } from 'zustand/shallow';
import Copy from '@/_ui/Icon/solidIcons/Copy';
import DataSourceIcon from '../QueryManager/Components/DataSourceIcon';
import { isQueryRunnable, decodeEntities } from '@/_helpers/utils';
import { canDeleteDataSource, canReadDataSource, canUpdateDataSource } from '@/_helpers';
@ -12,13 +12,10 @@ import useStore from '@/AppBuilder/_stores/store';
//TODO: Remove this
import { Confirm } from '@/Editor/Viewer/Confirm';
// TODO: enable delete query confirmation popup
import { debounce } from 'lodash';
import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext';
import { Button as ButtonComponent } from '@/components/ui/Button/Button.jsx';
import SolidIcon from '@/_ui/Icon/SolidIcons';
export const QueryCard = ({ dataQuery, darkMode = false, localDs }) => {
const { moduleId } = useModuleContext();
const appId = useStore((state) => state.appStore.modules[moduleId].app.appId);
const isQuerySelected = useStore((state) => state.queryPanel.isQuerySelected(dataQuery.id), shallow);
const setSelectedQuery = useStore((state) => state.queryPanel.setSelectedQuery);
const checkExistingQueryName = useStore((state) => state.dataQuery.checkExistingQueryName);
@ -26,9 +23,16 @@ export const QueryCard = ({ dataQuery, darkMode = false, localDs }) => {
const isDeletingQueryInProcess = useStore((state) => state.dataQuery.isDeletingQueryInProcess);
const renameQuery = useStore((state) => state.dataQuery.renameQuery);
const deleteDataQueries = useStore((state) => state.dataQuery.deleteDataQueries);
const duplicateQuery = useStore((state) => state.dataQuery.duplicateQuery);
const setPreviewData = useStore((state) => state.queryPanel.setPreviewData);
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
const shouldFreeze = useStore((state) => state.getShouldFreeze());
const renamingQueryId = useStore((state) => state.queryPanel.renamingQueryId);
const deletingQueryId = useStore((state) => state.queryPanel.deletingQueryId);
const setRenamingQuery = useStore((state) => state.queryPanel.setRenamingQuery);
const deleteDataQuery = useStore((state) => state.queryPanel.deleteDataQuery);
const isRenaming = renamingQueryId === dataQuery.id;
const isDeleting = deletingQueryId === dataQuery.id;
const hasPermissions =
selectedDataSourceScope === 'global'
? canUpdateDataSource(dataQuery?.data_source_id) ||
@ -36,57 +40,77 @@ export const QueryCard = ({ dataQuery, darkMode = false, localDs }) => {
canDeleteDataSource()
: true;
const shouldFreeze = useStore((state) => state.getShouldFreeze());
const [renamingQuery, setRenamingQuery] = useState(false);
const deleteDataQuery = (e) => {
e.stopPropagation();
setShowDeleteConfirmation(true);
};
const toggleQueryHandlerMenu = useStore((state) => state.queryPanel.toggleQueryHandlerMenu);
const featureAccess = useStore((state) => state?.license?.featureAccess, shallow);
const licenseValid = !featureAccess?.licenseStatus?.isExpired && featureAccess?.licenseStatus?.isLicenseValid;
const isRestricted = dataQuery.permissions && dataQuery.permissions.length !== 0;
const updateQueryName = (dataQuery, newName) => {
const { name } = dataQuery;
if (name === newName) {
return setRenamingQuery(false);
return setRenamingQuery(null);
}
const isNewQueryNameAlreadyExists = checkExistingQueryName(newName);
if (newName && !isNewQueryNameAlreadyExists) {
renameQuery(dataQuery?.id, newName);
setRenamingQuery(false);
setRenamingQuery(null);
updateQuerySuggestions(name, newName);
} else {
if (isNewQueryNameAlreadyExists) {
toast.error('Query name already exists');
}
setRenamingQuery(false);
setRenamingQuery(null);
}
};
const executeDataQueryDeletion = () => {
setShowDeleteConfirmation(false);
deleteDataQuery(null);
deleteDataQueries(dataQuery?.id);
setPreviewData(null);
};
// To prevent user clicking from continuous clicks
const debouncedDuplicateQuery = useCallback(
debounce((queryId, appId) => {
duplicateQuery(queryId, appId);
setPreviewData(null);
}, 500),
[duplicateQuery]
);
const getTooltip = () => {
const permission = dataQuery.permissions?.[0];
if (!permission) return null;
const users = permission.groups || permission.users || [];
if (users.length === 0) return null;
const isSingle = permission.type === 'SINGLE';
const isGroup = permission.type === 'GROUP';
if (isSingle) {
return users.length === 1
? `Access restricted to ${users[0].user.email}`
: `Access restricted to ${users.length} users`;
}
if (isGroup) {
return users.length === 1
? `Access restricted to ${users[0].permission_group?.name || users[0].permissionGroup?.name} group`
: `Access restricted to ${users.length} user groups`;
}
return null;
};
return (
<>
<div
className={`row query-row pe-2 ${darkMode && 'dark-theme'}` + (isQuerySelected ? ' query-row-selected' : '')}
key={dataQuery.id}
onClick={() => {
onClick={(e) => {
if (isQuerySelected) return;
setSelectedQuery(dataQuery?.id);
setPreviewData(null);
const menuBtn = document.getElementById(`query-handler-menu-${dataQuery?.id}`);
if (menuBtn.contains(e.target)) {
e.stopPropagation();
} else {
toggleQueryHandlerMenu(false);
}
setTimeout(() => {
setSelectedQuery(dataQuery?.id);
setPreviewData(null);
}, 0);
}}
role="button"
>
@ -94,7 +118,7 @@ export const QueryCard = ({ dataQuery, darkMode = false, localDs }) => {
<DataSourceIcon source={dataQuery} height={16} />
</div>
<div className="col query-row-query-name">
{renamingQuery ? (
{isRenaming ? (
<input
data-cy={`query-edit-input-field`}
className={`query-name query-name-input-field border-indigo-09 bg-transparent ${
@ -121,7 +145,12 @@ export const QueryCard = ({ dataQuery, darkMode = false, localDs }) => {
data-tooltip-dynamic="true"
>
{decodeEntities(dataQuery.name)}
</span>{' '}
</span>
<ToolTip message={getTooltip()} show={licenseValid && isRestricted}>
<div className="d-flex align-items-center" style={{ marginLeft: '8px', marginRight: 'auto' }}>
{licenseValid && isRestricted && <SolidIcon width="16" name="lock" fill="var(--icon-strong)" />}
</div>
</ToolTip>{' '}
{!isQueryRunnable(dataQuery) && <small className="mx-2 text-secondary">Draft</small>}
{localDs && (
<>
@ -143,80 +172,24 @@ export const QueryCard = ({ dataQuery, darkMode = false, localDs }) => {
</div>
)}
</div>
{!shouldFreeze && isQuerySelected && (
<div className="col-auto query-rename-delete-btn">
<div
className={`col-auto ${(renamingQuery || !hasPermissions) && 'd-none'} rename-query`}
onClick={() => setRenamingQuery(true)}
>
<span className="d-flex" data-tooltip-id="query-card-btn-tooltip" data-tooltip-content="Rename query">
<svg
data-cy={`edit-query-${dataQuery.name.toLowerCase()}`}
width="100%"
height="100%"
viewBox="0 0 19 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M13.7087 1.40712C14.29 0.826221 15.0782 0.499893 15.9 0.499893C16.7222 0.499893 17.5107 0.82651 18.0921 1.40789C18.6735 1.98928 19.0001 2.7778 19.0001 3.6C19.0001 4.42197 18.6737 5.21028 18.0926 5.79162C18.0924 5.79178 18.0928 5.79145 18.0926 5.79162L16.8287 7.06006C16.7936 7.11191 16.753 7.16118 16.7071 7.20711C16.6621 7.25215 16.6138 7.292 16.563 7.32665L9.70837 14.2058C9.52073 14.3942 9.26584 14.5 9 14.5H6C5.44772 14.5 5 14.0523 5 13.5V10.5C5 10.2342 5.10585 9.97927 5.29416 9.79163L12.1733 2.93697C12.208 2.88621 12.2478 2.83794 12.2929 2.79289C12.3388 2.74697 12.3881 2.70645 12.4399 2.67132L13.7079 1.40789C13.7082 1.40763 13.7084 1.40738 13.7087 1.40712ZM13.0112 4.92545L7 10.9153V12.5H8.58474L14.5745 6.48876L13.0112 4.92545ZM15.9862 5.07202L14.428 3.51376L15.1221 2.82211C15.3284 2.6158 15.6082 2.49989 15.9 2.49989C16.1918 2.49989 16.4716 2.6158 16.6779 2.82211C16.8842 3.02842 17.0001 3.30823 17.0001 3.6C17.0001 3.89177 16.8842 4.17158 16.6779 4.37789L15.9862 5.07202ZM0.87868 5.37868C1.44129 4.81607 2.20435 4.5 3 4.5H4C4.55228 4.5 5 4.94772 5 5.5C5 6.05228 4.55228 6.5 4 6.5H3C2.73478 6.5 2.48043 6.60536 2.29289 6.79289C2.10536 6.98043 2 7.23478 2 7.5V16.5C2 16.7652 2.10536 17.0196 2.29289 17.2071C2.48043 17.3946 2.73478 17.5 3 17.5H12C12.2652 17.5 12.5196 17.3946 12.7071 17.2071C12.8946 17.0196 13 16.7652 13 16.5V15.5C13 14.9477 13.4477 14.5 14 14.5C14.5523 14.5 15 14.9477 15 15.5V16.5C15 17.2957 14.6839 18.0587 14.1213 18.6213C13.5587 19.1839 12.7957 19.5 12 19.5H3C2.20435 19.5 1.44129 19.1839 0.87868 18.6213C0.31607 18.0587 0 17.2957 0 16.5V7.5C0 6.70435 0.31607 5.94129 0.87868 5.37868Z"
fill="#11181C"
/>
</svg>
</span>
</div>
<div
className={`col-auto rename-query ${!hasPermissions && 'd-none'}`}
onClick={() => debouncedDuplicateQuery(dataQuery?.id, appId)}
>
<span className="d-flex" data-tooltip-id="query-card-btn-tooltip" data-tooltip-content="Duplicate query">
<Copy height={16} width={16} viewBox="0 5 20 20" />
</span>
</div>
<div className="col-auto">
{isDeletingQueryInProcess ? (
<div className="px-2">
<div className="text-center spinner-border spinner-border-sm" role="status"></div>
</div>
) : (
<span
className={`delete-query ${!hasPermissions && 'd-none'}`}
onClick={deleteDataQuery}
data-tooltip-id="query-card-btn-tooltip"
data-tooltip-content="Delete query"
>
<span className="d-flex">
<svg
data-cy={`delete-query-${dataQuery.name.toLowerCase()}`}
width="100%"
height="100%"
viewBox="0 0 18 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.58579 0.585786C5.96086 0.210714 6.46957 0 7 0H11C11.5304 0 12.0391 0.210714 12.4142 0.585786C12.7893 0.960859 13 1.46957 13 2V4H15.9883C15.9953 3.99993 16.0024 3.99993 16.0095 4H17C17.5523 4 18 4.44772 18 5C18 5.55228 17.5523 6 17 6H16.9201L15.9997 17.0458C15.9878 17.8249 15.6731 18.5695 15.1213 19.1213C14.5587 19.6839 13.7957 20 13 20H5C4.20435 20 3.44129 19.6839 2.87868 19.1213C2.32687 18.5695 2.01223 17.8249 2.00035 17.0458L1.07987 6H1C0.447715 6 0 5.55228 0 5C0 4.44772 0.447715 4 1 4H1.99054C1.9976 3.99993 2.00466 3.99993 2.0117 4H5V2C5 1.46957 5.21071 0.960859 5.58579 0.585786ZM3.0868 6L3.99655 16.917C3.99885 16.9446 4 16.9723 4 17C4 17.2652 4.10536 17.5196 4.29289 17.7071C4.48043 17.8946 4.73478 18 5 18H13C13.2652 18 13.5196 17.8946 13.7071 17.7071C13.8946 17.5196 14 17.2652 14 17C14 16.9723 14.0012 16.9446 14.0035 16.917L14.9132 6H3.0868ZM11 4H7V2H11V4ZM6.29289 10.7071C5.90237 10.3166 5.90237 9.68342 6.29289 9.29289C6.68342 8.90237 7.31658 8.90237 7.70711 9.29289L9 10.5858L10.2929 9.29289C10.6834 8.90237 11.3166 8.90237 11.7071 9.29289C12.0976 9.68342 12.0976 10.3166 11.7071 10.7071L10.4142 12L11.7071 13.2929C12.0976 13.6834 12.0976 14.3166 11.7071 14.7071C11.3166 15.0976 10.6834 15.0976 10.2929 14.7071L9 13.4142L7.70711 14.7071C7.31658 15.0976 6.68342 15.0976 6.29289 14.7071C5.90237 14.3166 5.90237 13.6834 6.29289 13.2929L7.58579 12L6.29289 10.7071Z"
fill="#DB4324"
/>
</svg>
</span>
</span>
)}
</div>
<Tooltip id="query-card-btn-tooltip" className="tooltip" />
</div>
)}
<div className={`col-auto query-rename-delete-btn ${!shouldFreeze && isQuerySelected ? 'd-flex' : 'd-none'}`}>
<ButtonComponent
iconOnly
leadingIcon="morevertical01"
onClick={(e) => toggleQueryHandlerMenu(true, `query-handler-menu-${dataQuery?.id}`)}
size="small"
variant="outline"
className=""
id={`query-handler-menu-${dataQuery?.id}`}
/>
</div>
</div>
<Confirm
show={showDeleteConfirmation}
show={isDeleting}
message={'Do you really want to delete this query?'}
confirmButtonLoading={isDeletingQueryInProcess}
onConfirm={executeDataQueryDeletion}
onCancel={() => setShowDeleteConfirmation(false)}
onCancel={() => deleteDataQuery(null)}
darkMode={darkMode}
/>
</>

View file

@ -0,0 +1,174 @@
import React, { useCallback } from 'react';
import { Overlay, Popover } from 'react-bootstrap';
import useStore from '@/AppBuilder/_stores/store';
import classNames from 'classnames';
import Edit from '@/_ui/Icon/bulkIcons/Edit';
import Trash from '@/_ui/Icon/solidIcons/Trash';
import Copy from '@/_ui/Icon/solidIcons/Copy';
import SolidIcon from '@/_ui/Icon/SolidIcons';
import { shallow } from 'zustand/shallow';
import { ToolTip } from '@/_components/ToolTip';
import { debounce } from 'lodash';
import usePopoverObserver from '@/AppBuilder/_hooks/usePopoverObserver';
import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext';
const QueryCardMenu = ({ darkMode }) => {
const { moduleId } = useModuleContext();
const appId = useStore((state) => state.appStore.modules[moduleId].app.appId);
const selectedQuery = useStore((state) => state.queryPanel.selectedQuery);
const toggleQueryPermissionModal = useStore((state) => state.queryPanel.toggleQueryPermissionModal);
const featureAccess = useStore((state) => state?.license?.featureAccess, shallow);
const licenseValid = !featureAccess?.licenseStatus?.isExpired && featureAccess?.licenseStatus?.isLicenseValid;
const targetBtnForMenu = useStore((state) => state.queryPanel.targetBtnForMenu);
const targetElement = document.getElementById(targetBtnForMenu);
const showQueryHandlerMenu = useStore((state) => state.queryPanel.showQueryHandlerMenu);
const toggleQueryHandlerMenu = useStore((state) => state.queryPanel.toggleQueryHandlerMenu);
const duplicateQuery = useStore((state) => state.dataQuery.duplicateQuery);
const setPreviewData = useStore((state) => state.queryPanel.setPreviewData);
const setRenamingQuery = useStore((state) => state.queryPanel.setRenamingQuery);
const deleteDataQuery = useStore((state) => state.queryPanel.deleteDataQuery);
const QUERY_MENU_OPTIONS = [
{
label: 'Rename',
value: 'rename',
icon: <Edit width={16} />,
showTooltip: false,
},
{
label: 'Duplicate',
value: 'duplicate',
icon: <Copy width={16} />,
showTooltip: false,
},
{
label: 'Query permission',
value: 'permission',
icon: (
<img
alt="permission-icon"
src="assets/images/icons/editor/left-sidebar/authorization.svg"
width="16"
height="16"
/>
),
trailingIcon: <SolidIcon width={16} name="enterprisecrown" className="mx-1" />,
},
{
label: 'Delete',
value: 'delete',
icon: <Trash width={16} fill={'#E54D2E'} />,
showTooltip: false,
},
];
// To prevent user clicking from continuous clicks
const debouncedDuplicateQuery = useCallback(
debounce((queryId, appId) => {
duplicateQuery(queryId, appId);
setPreviewData(null);
}, 500),
[duplicateQuery]
);
const handleQueryMenuActions = (value) => {
if (value === 'rename') {
setRenamingQuery(selectedQuery?.id);
}
if (value === 'duplicate') {
debouncedDuplicateQuery(selectedQuery?.id, appId);
}
if (value === 'permission') {
if (!licenseValid) return;
toggleQueryPermissionModal(true);
}
if (value === 'delete') {
deleteDataQuery(selectedQuery?.id);
}
toggleQueryHandlerMenu(false);
};
usePopoverObserver(
document.getElementsByClassName('query-list')[0],
targetElement,
document.getElementById('query-list-menu'),
showQueryHandlerMenu,
() => (document.getElementById('query-list-menu').style.display = 'block'),
() => (document.getElementById('query-list-menu').style.display = 'none')
);
return (
<Overlay
placement="bottom-start"
target={targetElement}
show={showQueryHandlerMenu}
rootClose
onHide={() => toggleQueryHandlerMenu(false)}
popperConfig={{
modifiers: [
{
name: 'flip',
options: {
fallbackPlacements: ['top-start'],
flipVariations: true,
allowedAutoPlacements: ['top', 'bottom'],
boundary: 'viewport',
},
},
{
name: 'offset',
options: {
offset: [0, 3],
},
},
],
}}
>
{(props) => (
<Popover {...props} id="query-list-menu" className={darkMode && 'dark-theme'}>
<Popover.Body bsPrefix="list-item-popover-body">
{QUERY_MENU_OPTIONS.map((option) => {
const optionBody = (
<div
data-cy={`component-inspector-${String(option?.value).toLowerCase()}-button`}
className="list-item-popover-option"
key={option?.value}
onClick={(e) => {
e.stopPropagation();
handleQueryMenuActions(option.value);
}}
>
<div className="list-item-popover-menu-option-icon">{option.icon}</div>
<div
className={classNames('list-item-option-menu-label', {
'color-tomato9': option.value === 'delete',
'color-disabled': option.value === 'permission' && !licenseValid,
})}
>
{option?.label}
</div>
{option.value === 'permission' && !licenseValid && option.trailingIcon && option.trailingIcon}
</div>
);
return option.value === 'permission' ? (
<ToolTip
key={option.value}
message={'Component permissions are available only in paid plans'}
placement="left"
show={!licenseValid}
>
{optionBody}
</ToolTip>
) : (
optionBody
);
})}
</Popover.Body>
</Popover>
)}
</Overlay>
);
};
export default QueryCardMenu;

View file

@ -16,6 +16,10 @@ import DataSourceSelect from '../QueryManager/Components/DataSourceSelect';
import { OverlayTrigger, Popover } from 'react-bootstrap';
import FolderEmpty from '@/_ui/Icon/solidIcons/FolderEmpty';
import useStore from '@/AppBuilder/_stores/store';
import AppPermissionsModal from '@/modules/Appbuilder/components/AppPermissionsModal';
import { shallow } from 'zustand/shallow';
import { appPermissionService } from '@/_services';
import QueryCardMenu from './QueryCardMenu';
export const QueryDataPane = ({ darkMode }) => {
const { t } = useTranslation();
@ -34,6 +38,12 @@ export const QueryDataPane = ({ darkMode }) => {
function isDataSourceLocal(dataQuery) {
return dataSources.some((dataSource) => dataSource.id === dataQuery.data_source_id);
}
const featureAccess = useStore((state) => state?.license?.featureAccess, shallow);
const licenseValid = !featureAccess?.licenseStatus?.isExpired && featureAccess?.licenseStatus?.isLicenseValid;
const selectedQuery = useStore((state) => state.queryPanel.selectedQuery);
const showQueryPermissionModal = useStore((state) => state.queryPanel.showQueryPermissionModal);
const toggleQueryPermissionModal = useStore((state) => state.queryPanel.toggleQueryPermissionModal);
const setQueries = useStore((state) => state.dataQuery.setQueries);
useEffect(() => {
setQueryPanelSearchTerm(searchTermForFilters);
@ -171,6 +181,33 @@ export const QueryDataPane = ({ darkMode }) => {
{filteredQueries.map((query) => (
<QueryCard key={query.id} dataQuery={query} darkMode={darkMode} localDs={!!isDataSourceLocal(query)} />
))}
<QueryCardMenu darkMode={darkMode} />
{licenseValid && (
<AppPermissionsModal
modalType="query"
resourceId={selectedQuery?.id}
resourceName={selectedQuery?.name}
showModal={showQueryPermissionModal}
toggleModal={toggleQueryPermissionModal}
darkMode={darkMode}
fetchPermission={(id, appId) => appPermissionService.getQueryPermission(appId, id)}
createPermission={(id, appId, body) => appPermissionService.createQueryPermission(appId, id, body)}
updatePermission={(id, appId, body) => appPermissionService.updateQueryPermission(appId, id, body)}
deletePermission={(id, appId) => appPermissionService.deleteQueryPermission(appId, id)}
onSuccess={(data) => {
const updatedDataQueries = dataQueries.map((query) => {
if (query.id === selectedQuery.id) {
return {
...query,
permissions: data.length === 0 || data.length === undefined ? [] : [data[0]],
};
}
return query;
});
setQueries(updatedDataQueries);
}}
/>
)}
</div>
<Tooltip
id="query-card-name-tooltip"

View file

@ -467,7 +467,7 @@ export const EventManager = ({
if (data.label === 'run-action') return;
return (
<div
className="tw-border-x-0 tw-border-t-0 tw-border-b-[0.5px] tw-border-solid tw-my-[4px]"
className="tw-border-x-0 tw-border-t-0 tw-border-b-[1px] tw-border-solid tw-my-[4px]"
style={{ borderColor: 'var(--border-weak)' }}
></div>
);

View file

@ -47,11 +47,6 @@ export const containerConfig = {
defaultValue: true,
},
},
headerHeight: {
type: 'numberInput',
displayName: 'Header height',
validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 },
},
},
defaultChildren: [
{
@ -61,10 +56,10 @@ export const containerConfig = {
top: 20,
left: 1,
height: 40,
width: 20,
},
displayName: 'ContainerText',
properties: ['text'],
slotName: 'header',
accessorKey: 'text',
styles: ['fontWeight', 'textSize', 'textColor'],
defaultValue: {

View file

@ -13,7 +13,7 @@ export const listviewConfig = {
top: 15,
left: 3,
height: 100,
width: 7,
width: 4,
},
properties: ['source'],
accessorKey: 'imageURL',
@ -24,6 +24,7 @@ export const listviewConfig = {
top: 50,
left: 11,
height: 30,
width: 4,
},
properties: ['text'],
accessorKey: 'text',
@ -49,12 +50,14 @@ export const listviewConfig = {
data: {
type: 'code',
displayName: 'List data',
schema: {
type: 'union',
schemas: [
{ type: 'array', element: { type: 'object' } },
{ type: 'array', element: { type: 'string' } },
],
validation: {
schema: {
type: 'union',
schemas: [
{ type: 'array', element: { type: 'object' } },
{ type: 'array', element: { type: 'string' } },
],
},
defaultValue: "[{text: 'Sample text 1'}]",
},
},

View file

@ -92,18 +92,6 @@ export const modalV2Config = {
accordian: 'Data',
validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 400 },
},
headerHeight: {
type: 'numberInput',
displayName: 'Header height',
accordian: 'Data',
validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 },
},
footerHeight: {
type: 'numberInput',
displayName: 'Footer height',
accordian: 'Data',
validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 },
},
hideOnEsc: { type: 'toggle', displayName: 'Close on escape key', section: 'additionalActions' },
closeOnClickingOutside: { type: 'toggle', displayName: 'Close on clicking outside', section: 'additionalActions' },
hideCloseButton: { type: 'toggle', displayName: 'Hide close button', section: 'additionalActions' },

View file

@ -275,7 +275,7 @@ export const tableConfig = {
showOnMobile: { type: 'toggle', displayName: 'Show on mobile' },
},
defaultSize: {
width: 35,
width: 25,
height: 456,
},
events: {

View file

@ -9,6 +9,8 @@ import {
} from '@/AppBuilder/AppCanvas/appCanvasConstants';
import useStore from '@/AppBuilder/_stores/store';
import './container.scss';
import { useActiveSlot } from '@/AppBuilder/_hooks/useActiveSlot';
import { HorizontalSlot } from '@/AppBuilder/Widgets/Form/Components/HorizontalSlot';
export const Container = ({
id,
@ -33,8 +35,13 @@ export const Container = ({
shallow
);
const isEditing = useStore((state) => state.currentMode === 'edit');
const setComponentProperty = useStore((state) => state.setComponentProperty, shallow);
const activeSlot = useActiveSlot(isEditing ? id : null); // Track the active slot for this widget
const { borderRadius, borderColor, boxShadow } = styles;
const { headerHeight = 80 } = properties;
const headerMaxHeight = parseInt(height, 10) - 100 - 10;
const contentBgColor = useMemo(() => {
return {
backgroundColor:
@ -65,9 +72,9 @@ export const Container = ({
const containerHeaderStyles = {
flexShrink: 0,
padding: `${CONTAINER_FORM_CANVAS_PADDING}px ${CONTAINER_FORM_CANVAS_PADDING}px 3px ${CONTAINER_FORM_CANVAS_PADDING}px`,
maxHeight: `${headerMaxHeight}px`,
...headerBgColor,
};
const containerContentStyles = {
overflow: 'hidden auto',
display: 'flex',
@ -75,6 +82,11 @@ export const Container = ({
padding: `${CONTAINER_FORM_CANVAS_PADDING}px`,
};
const updateHeaderSizeInStore = ({ newHeight }) => {
const _height = parseInt(newHeight, 10);
setComponentProperty(id, `headerHeight`, _height, 'properties', 'value', false);
};
return (
<div
className={`jet-container ${isLoading ? 'jet-container-loading' : ''}`}
@ -87,17 +99,19 @@ export const Container = ({
) : (
<>
{properties.showHeader && (
<div style={containerHeaderStyles} className="wj-container-header">
<ContainerComponent
id={`${id}-header`}
styles={{ ...headerBgColor, height: `${headerHeight}px` }}
canvasHeight={headerHeight / 10}
canvasWidth={width}
allowContainerSelect={true}
darkMode={darkMode}
componentType="Container"
/>
</div>
<HorizontalSlot
slotName={'header'}
slotStyle={containerHeaderStyles}
isEditing={isEditing}
id={`${id}-header`}
height={headerHeight}
width={width}
darkMode={darkMode}
isDisabled={isDisabled}
isActive={activeSlot === `${id}-header`}
onResize={updateHeaderSizeInStore}
componentType="Container"
/>
)}
<div style={containerContentStyles}>
<ContainerComponent

View file

@ -1,7 +1,7 @@
import React, { useEffect } from 'react';
import { Container as SubContainer } from '@/AppBuilder/AppCanvas/Container';
import { showGridLinesOnSlot, hideGridLinesOnSlot } from '@/AppBuilder/AppCanvas/Grid/gridUtils';
import { useResizable } from '@/AppBuilder/_hooks/useMoveable';
import { showGridLines, hideGridLines } from '@/AppBuilder/AppCanvas/Grid/gridUtils';
import { useSubContainerResizable } from '@/AppBuilder/_hooks/useSubContainerResizable';
export const HorizontalSlot = React.memo(
({
@ -16,10 +16,10 @@ export const HorizontalSlot = React.memo(
onResize,
isEditing,
maxHeight,
componentType,
}) => {
const parsedHeight = parseInt(height, 10);
const { getRootProps, getHandleProps, getResizeState } = useResizable({
const { getRootProps, getHandleProps, getResizeState } = useSubContainerResizable({
initialHeight: parsedHeight,
initialWidth: '100%', // Now respects parent's width
minHeight: 10,
@ -34,12 +34,11 @@ export const HorizontalSlot = React.memo(
});
const { height: resizedHeight, isDragging } = getResizeState();
useEffect(() => {
if (isDragging) {
showGridLinesOnSlot(id);
showGridLines();
} else {
hideGridLinesOnSlot(id);
hideGridLines();
}
}, [isDragging, id]);
@ -50,7 +49,10 @@ export const HorizontalSlot = React.memo(
};
return (
<div className={`jet-form-${slotName} wj-form-${slotName}`} style={slotStyle}>
<div
className={`jet-${componentType?.toLowerCase()}-${slotName} wj-${componentType?.toLowerCase()}-${slotName}`}
style={slotStyle}
>
<div
className={`resizable-slot only-${slotName} ${isActive ? 'active' : ''} ${isEditing && 'is-editing'} ${
isDragging ? 'dragging' : ''
@ -68,7 +70,7 @@ export const HorizontalSlot = React.memo(
backgroundColor: 'transparent',
overflow: 'hidden',
}}
componentType="Form"
componentType={componentType}
/>
{isEditing && <div className="resize-handle" {...getHandleProps()} style={resizeStyle} />}
</div>

View file

@ -341,6 +341,7 @@ export const Form = function Form(props) {
isDisabled={isDisabled}
isActive={activeSlot === `${id}-header`}
onResize={updateHeaderSizeInStore}
componentType="Form"
/>
)}
@ -417,6 +418,7 @@ export const Form = function Form(props) {
isDisabled={isDisabled}
onResize={updateFooterSizeInStore}
isActive={activeSlot === `${id}-footer`}
componentType="Form"
/>
)}
</form>

View file

@ -81,7 +81,6 @@ export const Item = React.memo(
>
<div className="subcontainer-container" onMouseDown={(e) => e.stopPropagation()}>
<SubContainer
parentComponent={component}
canvasWidth={Number(cardWidth) || 300}
canvasHeight={Number(cardHeight) || 100}
id={id}
@ -90,11 +89,7 @@ export const Item = React.memo(
backgroundColor: 'var(--base)',
}}
darkMode={darkMode}
// parentName={component.name}
// customResolvables={{ cardData: cardDataAsObj[value] }}
// {...containerProps}
// readOnly={isDragActive || !isFirstItem}
// parentRef={parentRef}
componentType="Kanban"
/>
</div>
<span className="handle-container">

View file

@ -55,6 +55,8 @@ export const Listview = function Listview({
display: visibility ? 'flex' : 'none',
borderRadius: borderRadius ?? 0,
boxShadow,
padding: '7px 2px 7px 7px',
scrollbarGutter: 'stable',
};
const computeCanvasBackgroundColor = useMemo(() => {
@ -235,7 +237,6 @@ export const Listview = function Listview({
// Update the customResolvables with the new listItems
if (listItems.length > 0) updateCustomResolvables(id, listItems, 'listItem', moduleId);
}
return (
<div
data-disabled={disabledState}
@ -243,10 +244,9 @@ export const Listview = function Listview({
id={id}
ref={parentRef}
style={computedStyles}
// onClick={() => containerProps.onComponentClick(id, component)}
data-cy={dataCy}
>
<div className={`row w-100 m-0 ${enablePagination && 'pagination-margin-bottom-last-child'}`}>
<div className={`w-100 m-0 ${enablePagination && 'pagination-margin-bottom-last-child'}`}>
{filteredData.map((listItem, index) => (
<div
className={`list-item ${mode == 'list' && 'w-100'} ${showBorder && mode == 'list' ? 'border-bottom' : ''}`}

View file

@ -1,35 +1,55 @@
import React from 'react';
import { default as BootstrapModal } from 'react-bootstrap/Modal';
import { Container as SubContainer } from '@/AppBuilder/AppCanvas/Container';
import { getCanvasHeight } from '@/AppBuilder/Widgets/ModalV2/helpers/utils';
import { HorizontalSlot } from '@/AppBuilder/Widgets/Form/Components/HorizontalSlot';
import { MODAL_CANVAS_PADDING } from '@/AppBuilder/AppCanvas/appCanvasConstants';
export const ModalFooter = React.memo(({ id, isDisabled, customStyles, darkMode, width, footerHeight, onClick }) => {
const canvasFooterHeight = getCanvasHeight(footerHeight);
return (
<BootstrapModal.Footer style={{ ...customStyles.modalFooter }} data-cy={`modal-footer`} onClick={onClick}>
<SubContainer
id={`${id}-footer`}
canvasHeight={canvasFooterHeight}
canvasWidth={width}
allowContainerSelect={false}
darkMode={darkMode}
styles={{
margin: 0,
backgroundColor: 'transparent',
overflowX: 'hidden',
overflowY: isDisabled ? 'hidden' : 'auto',
}}
componentType="ModalV2"
/>
{isDisabled && (
<div
id={`${id}-footer-disabled`}
className="tj-modal-disabled-overlay"
style={{ height: footerHeight || '100%' }}
onClick={onClick}
onDrop={(e) => e.stopPropagation()}
export const ModalFooter = React.memo(
({
id,
isDisabled,
customStyles,
darkMode,
width,
footerHeight,
onClick,
isEditing,
updateFooterSizeInStore,
activeSlot,
footerMaxHeight,
isFullScreen,
}) => {
const canvasFooterHeight = getCanvasHeight(footerHeight);
return (
<BootstrapModal.Footer style={{ ...customStyles.modalFooter }} data-cy={`modal-footer`} onClick={onClick}>
<HorizontalSlot
slotName={'footer'}
slotStyle={{
width: `100%`,
padding: `${4.5}px ${MODAL_CANVAS_PADDING}px`,
margin: '0px',
}}
isEditing={isEditing}
id={`${id}-footer`}
height={canvasFooterHeight}
width={width}
darkMode={darkMode}
isDisabled={isDisabled}
isActive={activeSlot === `${id}-footer`}
onResize={updateFooterSizeInStore}
componentType="ModalV2"
maxHeight={isFullScreen ? undefined : footerMaxHeight}
/>
)}
</BootstrapModal.Footer>
);
});
{isDisabled && (
<div
id={`${id}-footer-disabled`}
className="tj-modal-disabled-overlay"
style={{ height: footerHeight || '100%' }}
onClick={onClick}
onDrop={(e) => e.stopPropagation()}
/>
)}
</BootstrapModal.Footer>
);
}
);

View file

@ -1,27 +1,49 @@
import React from 'react';
import { default as BootstrapModal } from 'react-bootstrap/Modal';
import { Container as SubContainer } from '@/AppBuilder/AppCanvas/Container';
import { getCanvasHeight } from '@/AppBuilder/Widgets/ModalV2/helpers/utils';
import { HorizontalSlot } from '@/AppBuilder/Widgets/Form/Components/HorizontalSlot';
import { MODAL_CANVAS_PADDING } from '@/AppBuilder/AppCanvas/appCanvasConstants';
export const ModalHeader = React.memo(
({ id, isDisabled, customStyles, hideCloseButton, darkMode, width, onHideModal, headerHeight, onClick }) => {
({
id,
isDisabled,
customStyles,
hideCloseButton,
darkMode,
width,
onHideModal,
headerHeight,
onClick,
isEditing,
updateHeaderSizeInStore,
activeSlot,
headerMaxHeight,
isFullScreen,
}) => {
const canvasHeaderHeight = getCanvasHeight(headerHeight);
// console.log(headerMaxHeight, 'headerMaxHeight');
return (
<BootstrapModal.Header style={{ ...customStyles.modalHeader }} data-cy={`modal-header`} onClick={onClick}>
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<SubContainer
id={`${id}-header`}
canvasHeight={canvasHeaderHeight}
canvasWidth={width}
allowContainerSelect={false}
darkMode={darkMode}
styles={{
backgroundColor: 'transparent',
overflowX: 'hidden',
overflowY: isDisabled ? 'hidden' : 'auto',
<HorizontalSlot
slotName={'header'}
slotStyle={{
height: `100%`,
padding: `${4.5}px ${MODAL_CANVAS_PADDING}px`,
maxHeight: isFullScreen ? `${headerMaxHeight}` : `${headerMaxHeight}px`,
minHeight: '10px',
}}
isEditing={isEditing}
id={`${id}-header`}
height={canvasHeaderHeight}
width={width}
darkMode={darkMode}
isDisabled={isDisabled}
isActive={activeSlot === `${id}-header`}
onResize={updateHeaderSizeInStore}
componentType="ModalV2"
maxHeight={isFullScreen ? undefined : headerMaxHeight}
/>
</div>
{isDisabled && (

View file

@ -4,7 +4,8 @@ import { Container as SubContainer } from '@/AppBuilder/AppCanvas/Container';
import { ConfigHandle } from '@/AppBuilder/AppCanvas/ConfigHandle/ConfigHandle';
import { ModalHeader } from '@/AppBuilder/Widgets/ModalV2/Components/Header';
import { ModalFooter } from '@/AppBuilder/Widgets/ModalV2/Components/Footer';
import useStore from '@/AppBuilder/_stores/store';
import { useActiveSlot } from '@/AppBuilder/_hooks/useActiveSlot';
export const ModalWidget = ({ ...restProps }) => {
const {
customStyles,
@ -24,8 +25,31 @@ export const ModalWidget = ({ ...restProps }) => {
headerHeight,
footerHeight,
onSelectModal,
modalHeight,
isFullScreen,
} = restProps['modalProps'];
const isEditing = useStore((state) => state.currentMode === 'edit');
const setComponentProperty = useStore((state) => state.setComponentProperty);
const activeSlot = useActiveSlot(isEditing ? id : null); // Track the active slot for this widget
const _modalHeight = isFullScreen ? '100vh' : `${modalHeight}px`;
const headerMaxHeight = isFullScreen
? `calc(${_modalHeight} - ${footerHeight} - 100px - 10px)`
: parseInt(_modalHeight, 10) - parseInt(footerHeight, 10) - 100 - 10;
const footerMaxHeight = isFullScreen
? `calc(${_modalHeight} - ${headerHeight} - 100px - 10px)`
: parseInt(_modalHeight, 10) - parseInt(headerHeight, 10) - 100 - 10;
const updateHeaderSizeInStore = ({ newHeight }) => {
const _height = parseInt(newHeight, 10);
setComponentProperty(id, `headerHeight`, _height, 'properties', 'value', false);
};
const updateFooterSizeInStore = ({ newHeight }) => {
const _height = parseInt(newHeight, 10);
setComponentProperty(id, `footerHeight`, _height, 'properties', 'value', false);
};
// When the modal body is clicked capture it and use the callback to set the selected component as modal
const handleModalSlotClick = (event) => {
const clickedComponentId = event.target.getAttribute('component-id');
@ -53,10 +77,21 @@ export const ModalWidget = ({ ...restProps }) => {
};
}, []);
useEffect(() => {
setTimeout(() => {
const modalContent = document.querySelector(`.tj-modal-content-${id}`);
if (restProps.show && modalContent) {
modalContent.style.setProperty('height', _modalHeight, 'important');
modalContent.style.setProperty('max-height', isFullScreen ? '100%' : modalHeight, 'important');
}
}, 100);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [modalHeight, restProps.show, isFullScreen]);
return (
<BootstrapModal
{...restProps}
contentClassName="modal-component tj-modal--container tj-modal-widget-content"
contentClassName={`modal-component tj-modal--container tj-modal-widget-content tj-modal-content-${id}`}
animation={true}
onEscapeKeyDown={(e) => {
e.preventDefault();
@ -87,6 +122,11 @@ export const ModalWidget = ({ ...restProps }) => {
onHideModal={onHideModal}
headerHeight={headerHeight}
onClick={handleModalSlotClick}
isEditing={isEditing}
updateHeaderSizeInStore={updateHeaderSizeInStore}
activeSlot={activeSlot}
headerMaxHeight={headerMaxHeight}
isFullScreen={isFullScreen}
/>
)}
<BootstrapModal.Body style={{ ...customStyles.modalBody }} ref={parentRef} id={id} data-cy={`modal-body`}>
@ -128,6 +168,11 @@ export const ModalWidget = ({ ...restProps }) => {
width={modalWidth}
footerHeight={footerHeight}
onClick={handleModalSlotClick}
isEditing={isEditing}
updateFooterSizeInStore={updateFooterSizeInStore}
activeSlot={activeSlot}
footerMaxHeight={footerMaxHeight}
isFullScreen={isFullScreen}
/>
)}
</BootstrapModal>

View file

@ -14,7 +14,6 @@ import {
} from '@/AppBuilder/Widgets/ModalV2/helpers/utils';
import { createModalStyles } from '@/AppBuilder/Widgets/ModalV2/helpers/stylesFactory';
import { onShowSideEffects, onHideSideEffects } from '@/AppBuilder/Widgets/ModalV2/helpers/sideEffects';
import '@/AppBuilder/Widgets/ModalV2/style.scss';
import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext';
@ -238,6 +237,7 @@ export const ModalV2 = function Modal({
modalBodyHeight: computedCanvasHeight,
modalWidth,
onSelectModal: setSelectedComponentAsModal,
isFullScreen,
}}
/>
</div>

View file

@ -29,16 +29,12 @@ export function createModalStyles({
modalHeader: {
backgroundColor:
['#fff', '#ffffffff'].includes(headerBackgroundColor) && darkMode ? '#1F2837' : headerBackgroundColor,
height: headerHeightPx,
overflowY: isDisabledModal ? 'hidden' : 'auto',
padding: `${4.5}px ${MODAL_CANVAS_PADDING}px`,
},
modalFooter: {
backgroundColor:
['#fff', '#ffffffff'].includes(footerBackgroundColor) && darkMode ? '#1F2837' : footerBackgroundColor,
height: footerHeightPx,
overflowY: isDisabledModal ? 'hidden' : 'auto',
padding: `${4.5}px ${MODAL_CANVAS_PADDING}px`,
},
buttonStyles: {
backgroundColor: triggerButtonBackgroundColor,

View file

@ -49,6 +49,7 @@
.modal-header {
padding: 0;
position: relative;
min-height: 40px;
}
.modal-body {
@ -62,5 +63,11 @@
border-top: 1px solid var(--border-weak);
overflow-x: hidden;
width: 100%;
}
}
.jet-modalv2-footer .resize-handle {
top: -4px;
bottom: unset;
}

View file

@ -632,7 +632,7 @@ const useAppData = (
setSecrets(orgSecrets);
}
const queryData = await dataqueryService.getAll(currentVersionId);
const queryData = await dataqueryService.getAll(currentVersionId, mode);
const dataQueries = queryData.data_queries;
dataQueries.forEach((query) => normalizeQueryTransformationOptions(query));
setQueries(dataQueries, moduleId);

View file

@ -15,7 +15,7 @@ const defaultProps = {
isReverseVerticalDrag: false,
};
export const useResizable = (options = {}) => {
export const useSubContainerResizable = (options = {}) => {
const props = { ...defaultProps, ...options };
const parentRef = useRef(null);
const [isDragging, setIsDragging] = useState(false); // ✅ Track dragging state
@ -115,7 +115,6 @@ export const useResizable = (options = {}) => {
// Get the updated height and width from the DOM instead of relying on state
const finalHeight = parentRef.current ? parseInt(parentRef.current.clientHeight) : parseInt(height);
const finalWidth = parentRef.current ? parseInt(parentRef.current.clientWidth) : parseInt(width);
props.onDragEnd({ newHeight: finalHeight, newWidth: finalWidth });
}
};
@ -132,4 +131,4 @@ export const useResizable = (options = {}) => {
return { rootRef: parentRef, getRootProps, getHandleProps, getResizeState };
};
export default useResizable;
export default useSubContainerResizable;

View file

@ -16,6 +16,11 @@ const initialState = {
pageSwitchInProgress: false,
isTJDarkMode: localStorage.getItem('darkMode') === 'true',
isViewer: false,
isComponentLayoutReady: false,
appPermission: {
selectedUsers: [],
selectedUserGroups: [],
},
appStore: {
modules: {
canvas: {
@ -276,4 +281,12 @@ export const createAppSlice = (set, get) => ({
return get().appStore.modules[moduleId].app.homePageId;
},
updateIsTJDarkMode: (newMode) => set({ isTJDarkMode: newMode }, false, 'updateIsTJDarkMode'),
setSelectedUserGroups: (groups) =>
set((state) => {
state.appPermission.selectedUserGroups = groups;
}),
setSelectedUsers: (users) =>
set((state) => {
state.appPermission.selectedUsers = users;
}),
});

View file

@ -36,11 +36,11 @@ export const createCodeHinterSlice = (set, get) => ({
setSuggestions({ appHints: suggestionList, jsHints: jsHints });
},
getSuggestions: () => get().suggestions,
getServerSideGlobalSuggestions: (isInsideQueryManager) => {
const isServerSideGlobalEnabled = !!get()?.license?.featureAccess?.serverSideGlobal;
getServerSideGlobalResolveSuggestions: (isInsideQueryManager) => {
const isServerSideGlobalResolveEnabled = !!get()?.license?.featureAccess?.serverSideGlobalResolve;
const serverHints = [];
const hints = get().getSuggestions();
if (isInsideQueryManager && isServerSideGlobalEnabled) {
if (isInsideQueryManager && isServerSideGlobalResolveEnabled) {
serverHints.push({ hint: 'globals.server', type: 'Object' });
hints?.appHints?.forEach((appHint) => {
if (appHint?.hint?.startsWith('globals.currentUser')) {

View file

@ -1,4 +1,4 @@
import { dataqueryService } from '@/_services';
import { dataqueryService, appPermissionService } from '@/_services';
import { getDefaultOptions } from '@/_stores/storeHelper';
import { v4 as uuidv4 } from 'uuid';
import _, { isEmpty, throttle } from 'lodash';
@ -270,7 +270,6 @@ export const createDataQuerySlice = (set, get) => ({
)
.then((data) => {
set((state) => {
state.dataQuery.creatingQueryInProcessId = null;
state.dataQuery.queries.modules[moduleId] = [
{
...data,
@ -308,6 +307,42 @@ export const createDataQuerySlice = (set, get) => ({
};
createAppVersionEventHandlers(newEvent, moduleId);
});
if (queryToClone.permissions && queryToClone.permissions.length !== 0) {
const body = {
type: queryToClone.permissions[0]?.type,
...(queryToClone.permissions[0]?.type === 'GROUP'
? {
groups: (queryToClone.permissions[0]?.groups || queryToClone.permissions[0]?.users || []).map(
(group) => group.permissionGroupsId || group.permission_groups_id
),
}
: { users: queryToClone.permissions[0]?.users.map((user) => user.userId || user.user_id) }),
};
appPermissionService
.createQueryPermission(appId, data.id, body)
.then((newQuery) => {
const dataQueries = get().dataQuery.queries.modules[moduleId];
const updatedDataQueries = dataQueries.map((query) => {
if (query.id === data.id) {
return {
...query,
permissions: newQuery.length === 0 || newQuery.length === undefined ? [] : [newQuery[0]],
};
}
return query;
});
get().dataQuery.setQueries(updatedDataQueries);
})
.catch(() => {
toast.error('Permission could not be created. Please try again!', {
className: 'text-nowrap w-auto mw-100',
});
});
}
set((state) => {
state.dataQuery.creatingQueryInProcessId = null;
});
})
.catch((error) => {
console.error('error', error);
@ -438,7 +473,10 @@ export const createDataQuerySlice = (set, get) => ({
const queries = get().dataQuery.queries.modules[moduleId];
try {
for (const query of queries) {
if ((query.options.runOnPageLoad || query.options.run_on_page_load) && isQueryRunnable(query)) {
if (
(query.options?.runOnPageLoad || query.options?.run_on_page_load) &&
(query.restricted || isQueryRunnable(query))
) {
await get().queryPanel.runQuery(
query.id,
query.name,

View file

@ -100,10 +100,6 @@ export const createPageMenuSlice = (set, get) => {
pageSettingSelected: false,
pageSettings: {},
showPagePermissionModal: false,
permissionPage: null,
selectedUserGroups: [],
selectedUsers: [],
pagePermission: null,
toggleSearch: (show) =>
set((state) => {
@ -432,26 +428,12 @@ export const createPageMenuSlice = (set, get) => {
}
},
setPagePermission: (pagePermission) =>
set((state) => {
state.pagePermission = pagePermission;
}),
togglePagePermissionModal: (show) => {
set((state) => {
state.showPagePermissionModal = show;
});
},
setSelectedUserGroups: (groups) =>
set((state) => {
state.selectedUserGroups = groups;
}),
setSelectedUsers: (users) =>
set((state) => {
state.selectedUsers = users;
}),
setEditingPage: (page) =>
set((state) => {
state.editingPage = page;

View file

@ -26,6 +26,12 @@ const initialState = {
loadingDataQueries: false,
isPreviewQueryLoading: false,
queryPanelSearchTem: '',
showQueryPermissionModal: false,
targetBtnForMenu: null,
showQueryHandlerMenu: false,
showDeleteConfirmation: false,
renamingQueryId: null,
deletingQueryId: null,
};
export const createQueryPanelSlice = (set, get) => ({
@ -223,6 +229,7 @@ export const createQueryPanelSlice = (set, get) => ({
selectedEnvironment,
isPublicAccess,
currentVersionId,
currentMode,
} = get();
const {
queryPreviewData,
@ -352,14 +359,15 @@ export const createQueryPanelSlice = (set, get) => ({
let queryExecutionPromise = null;
if (query.kind === 'runjs') {
queryExecutionPromise = executeMultilineJS(query.options.code, query?.id, false, mode, parameters, moduleId);
queryExecutionPromise = executeMultilineJS(query.options?.code, query?.id, false, mode, parameters, moduleId);
} else if (query.kind === 'runpy') {
queryExecutionPromise = executeRunPycode(query.options.code, query, false, mode, queryState, moduleId);
queryExecutionPromise = executeRunPycode(query.options?.code, query, false, mode, queryState, moduleId);
} else if (query.kind === 'workflows') {
queryExecutionPromise = executeWorkflow(
moduleId,
query.options.workflowId,
query.options.blocking,
query,
query.options?.workflowId,
query.options?.blocking,
query.options?.params,
(currentAppEnvironmentId ?? environmentId) || selectedEnvironment?.id //TODO: currentAppEnvironmentId may no longer required. Need to check
);
@ -374,7 +382,8 @@ export const createQueryPanelSlice = (set, get) => ({
options,
query?.options,
versionId,
!isPublicAccess ? (currentAppEnvironmentId ?? environmentId) || selectedEnvironment?.id : undefined //TODO: currentAppEnvironmentId may no longer required. Need to check
!isPublicAccess ? (currentAppEnvironmentId ?? environmentId) || selectedEnvironment?.id : undefined, //TODO: currentAppEnvironmentId may no longer required. Need to check
currentMode
);
}
@ -447,7 +456,7 @@ export const createQueryPanelSlice = (set, get) => ({
queryId,
{
isLoading: false,
...(query.kind === 'restapi'
...(query.kind === 'restapi' || data.data.type === 'tj-401'
? {
metadata: data.metadata,
request: data.data.requestObject,
@ -725,6 +734,28 @@ export const createQueryPanelSlice = (set, get) => ({
const {
queryPanel: { evaluatePythonCode },
} = get();
if (query.restricted) {
return {
status: 'failed',
message: 'Query could not be completed',
description: 'Response code 401 (Unauthorized)',
data: {
type: 'tj-401',
responseObject: {
statusCode: 401,
responseBody: 'Unauthorized Access',
},
},
metadata: {
response: {
statusCode: 401,
responseBody: 'Unauthorized Access',
},
},
};
}
return { data: await evaluatePythonCode({ code, query, isPreview, mode, currentState }) };
},
@ -948,12 +979,33 @@ export const createQueryPanelSlice = (set, get) => ({
// queries: updatedQueries,
// });
},
executeWorkflow: async (moduleId = 'canvas', workflowId, _blocking = false, params = {}, appEnvId) => {
executeWorkflow: async (moduleId = 'canvas', query, workflowId, _blocking = false, params = {}, appEnvId) => {
const { getAppId, getAllExposedValues } = get();
const appId = getAppId('canvas');
const currentState = getAllExposedValues(moduleId);
const resolvedParams = get().resolveReferences(moduleId, params, currentState, {}, {});
if (query.restricted) {
return {
status: 'failed',
message: 'Query could not be completed',
description: 'Response code 401 (Unauthorized)',
data: {
type: 'tj-401',
responseObject: {
statusCode: 401,
responseBody: 'Unauthorized Access',
},
},
metadata: {
response: {
statusCode: 401,
responseBody: 'Unauthorized Access',
},
},
};
}
try {
const response = await workflowExecutionsService.execute(workflowId, resolvedParams, appId, appEnvId);
return { data: response.result, status: 'ok' };
@ -1004,6 +1056,27 @@ export const createQueryPanelSlice = (set, get) => ({
const queryDetails = dataQuery.queries.modules?.[moduleId].find((q) => q.id === queryId);
if (queryDetails.restricted) {
return {
status: 'failed',
message: 'Query could not be completed',
description: 'Response code 401 (Unauthorized)',
data: {
type: 'tj-401',
responseObject: {
statusCode: 401,
responseBody: 'Unauthorized Access',
},
},
metadata: {
response: {
statusCode: 401,
responseBody: 'Unauthorized Access',
},
},
};
}
const defaultParams =
queryDetails?.options?.parameters?.reduce(
(paramObj, param) => ({
@ -1154,5 +1227,24 @@ export const createQueryPanelSlice = (set, get) => ({
};
previewQuery(query, false, undefined, moduleId);
},
toggleQueryPermissionModal: (show) => {
set((state) => {
state.queryPanel.showQueryPermissionModal = show;
});
},
toggleQueryHandlerMenu: (show, id) => {
set((state) => {
if (show) state.queryPanel.targetBtnForMenu = id;
state.queryPanel.showQueryHandlerMenu = show;
});
},
setRenamingQuery: (queryId) =>
set((state) => {
state.queryPanel.renamingQueryId = queryId;
}),
deleteDataQuery: (queryId) =>
set((state) => {
state.queryPanel.deletingQueryId = queryId;
}),
},
});

View file

@ -47,11 +47,6 @@ export const containerConfig = {
defaultValue: true,
},
},
headerHeight: {
type: 'numberInput',
displayName: 'Header height',
validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 },
},
},
defaultChildren: [
{
@ -61,10 +56,10 @@ export const containerConfig = {
top: 20,
left: 1,
height: 40,
width: 20,
},
displayName: 'ContainerText',
properties: ['text'],
slotName: 'header',
accessorKey: 'text',
styles: ['fontWeight', 'textSize', 'textColor'],
defaultValue: {

View file

@ -13,7 +13,7 @@ export const listviewConfig = {
top: 15,
left: 3,
height: 100,
width: 7,
width: 4,
},
properties: ['source'],
accessorKey: 'imageURL',
@ -24,6 +24,7 @@ export const listviewConfig = {
top: 50,
left: 11,
height: 30,
width: 4,
},
properties: ['text'],
accessorKey: 'text',
@ -127,7 +128,7 @@ export const listviewConfig = {
},
styles: {
backgroundColor: {
type: 'color',
type: 'colorSwatches',
displayName: 'Background color',
validation: {
schema: { type: 'string' },
@ -135,7 +136,7 @@ export const listviewConfig = {
},
},
borderColor: {
type: 'color',
type: 'colorSwatches',
displayName: 'Border color',
validation: {
schema: { type: 'string' },

View file

@ -92,18 +92,6 @@ export const modalV2Config = {
accordian: 'Data',
validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 400 },
},
headerHeight: {
type: 'numberInput',
displayName: 'Header height',
accordian: 'Data',
validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 },
},
footerHeight: {
type: 'numberInput',
displayName: 'Footer height',
accordian: 'Data',
validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 },
},
hideOnEsc: { type: 'toggle', displayName: 'Close on escape key', section: 'additionalActions' },
closeOnClickingOutside: { type: 'toggle', displayName: 'Close on clicking outside', section: 'additionalActions' },
hideCloseButton: { type: 'toggle', displayName: 'Hide close button', section: 'additionalActions' },

View file

@ -275,7 +275,7 @@ export const tableConfig = {
showOnMobile: { type: 'toggle', displayName: 'Show on mobile' },
},
defaultSize: {
width: 35,
width: 25,
height: 456,
},
events: {

View file

@ -381,7 +381,7 @@ class HomePageComponent extends React.Component {
}
};
importFile = async (importJSON, appName, skipPagePermissionsGroupCheck = false) => {
importFile = async (importJSON, appName, skipPermissionsGroupCheck = false) => {
this.setState({ isImportingApp: true });
// For backward compatibility with legacy app import
const organization_id = this.state.currentUser?.organization_id;
@ -397,7 +397,7 @@ class HomePageComponent extends React.Component {
const requestBody = {
organization_id,
...importJSON,
skip_page_permissions_group_check: skipPagePermissionsGroupCheck,
skip_permissions_group_check: skipPermissionsGroupCheck,
};
let installedPluginsInfo = [];
try {

View file

@ -64,6 +64,7 @@ button:focus:not(:focus-visible) {
padding: 10px 14px;
cursor: pointer;
display: flex;
align-items: center;
&:hover {
background-color: var(--slate3);
@ -78,6 +79,10 @@ button:focus:not(:focus-visible) {
.color-tomato9 {
color: var(--tomato9)
}
.color-disabled {
color: var(--text-disabled);
}
}
}

View file

@ -43,7 +43,7 @@ const usePortal = ({ children, ...restProps }) => {
isCopilotEnabled={isCopilotEnabled}
>
<div
className={`editor-container ${optionalProps.cls ?? ''}`}
className={`editor-container codehinter-popup ${optionalProps.cls ?? ''}`}
key={key}
data-cy={`codehinder-popup-input-field`}
>

View file

@ -7,6 +7,10 @@ export const appPermissionService = {
createPagePermission,
updatePagePermission,
deletePagePermission,
getQueryPermission,
createQueryPermission,
updateQueryPermission,
deleteQueryPermission,
};
function getPagePermission(appId, pageId) {
@ -47,3 +51,41 @@ function deletePagePermission(appId, pageId) {
};
return fetch(`${config.apiUrl}/app-permissions/${appId}/pages/${pageId}`, requestOptions).then(handleResponse);
}
function getQueryPermission(appId, queryId) {
const requestOptions = {
method: 'GET',
headers: authHeader(),
credentials: 'include',
};
return fetch(`${config.apiUrl}/app-permissions/${appId}/queries/${queryId}`, requestOptions).then(handleResponse);
}
function createQueryPermission(appId, queryId, body) {
const requestOptions = {
method: 'POST',
headers: authHeader(),
credentials: 'include',
body: JSON.stringify(body),
};
return fetch(`${config.apiUrl}/app-permissions/${appId}/queries/${queryId}`, requestOptions).then(handleResponse);
}
function updateQueryPermission(appId, queryId, body) {
const requestOptions = {
method: 'PUT',
headers: authHeader(),
credentials: 'include',
body: JSON.stringify(body),
};
return fetch(`${config.apiUrl}/app-permissions/${appId}/queries/${queryId}`, requestOptions).then(handleResponse);
}
function deleteQueryPermission(appId, queryId) {
const requestOptions = {
method: 'DELETE',
headers: authHeader(),
credentials: 'include',
};
return fetch(`${config.apiUrl}/app-permissions/${appId}/queries/${queryId}`, requestOptions).then(handleResponse);
}

View file

@ -13,9 +13,9 @@ export const dataqueryService = {
bulkUpdateQueryOptions,
};
function getAll(appVersionId) {
function getAll(appVersionId, mode) {
const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include' };
return fetch(`${config.apiUrl}/data-queries/${appVersionId}`, requestOptions).then(handleResponse);
return fetch(`${config.apiUrl}/data-queries/${appVersionId}?mode=${mode}`, requestOptions).then(handleResponse);
}
function create(app_id, app_version_id, name, kind, options, data_source_id, plugin_id) {
@ -72,7 +72,7 @@ function del(id, versionId) {
return fetch(`${config.apiUrl}/data-queries/${id}/versions/${versionId}`, requestOptions).then(handleResponse);
}
function run(queryId, resolvedOptions, options, versionId, environmentId) {
function run(queryId, resolvedOptions, options, versionId, environmentId, mode) {
const body = {
resolvedOptions: resolvedOptions,
options: options,
@ -80,7 +80,7 @@ function run(queryId, resolvedOptions, options, versionId, environmentId) {
let url = `${config.apiUrl}/data-queries/${queryId}/versions/${versionId}/run${
environmentId && environmentId !== 'undefined' ? `/${environmentId}` : ''
}`;
}?mode=${mode}`;
//For public/released apps
if (!environmentId || !versionId) {

View file

@ -166,7 +166,7 @@ $border-radius: 4px;
}
.query-row:hover .query-rename-delete-btn {
display: flex;
display: flex !important;
}
.query-row {
@ -189,12 +189,10 @@ $border-radius: 4px;
}
.query-rename-delete-btn {
display: none;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-left: 2px;
width: 66px;
width: 22px;
height: 20px;
}

View file

@ -4274,7 +4274,6 @@ input[type="text"] {
.jet-listview::-webkit-scrollbar-track {
background: transparent;
}
.jet-listview::-webkit-scrollbar-thumb {
@ -16048,9 +16047,7 @@ textarea.tj-text-input-widget {
}
.jet-listview {
&:hover {
scrollbar-color: #6a727c4d;
&:hover {
&::-webkit-scrollbar-thumb {
background-color: #6a727c4d !important;
}
@ -16070,7 +16067,6 @@ textarea.tj-text-input-widget {
.jet-listview {
&:hover {
scrollbar-color: #6a727c4d;
&::-webkit-scrollbar-thumb {
background-color: #6a727c4d !important;
@ -18951,6 +18947,14 @@ section.ai-message-prompt-input-wrapper {
}
}
.codehinter-popup {
.single-line-codehinter-input {
.cm-editor {
max-height: 100% !important;
}
}
}
.missing-groups-modal {
.modal-body {
padding: 16px;

View file

@ -0,0 +1,21 @@
import React from 'react';
const MoreVertical01 = ({ fill = '#11181C', width = '12', height = '13', className = '', viewBox = '0 0 12 13' }) => (
<svg
width={width}
height={height}
viewBox={viewBox}
className={className}
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.00354 2.53516C5.00354 1.98287 5.45126 1.53516 6.00354 1.53516C6.55582 1.53516 7.00354 1.98287 7.00354 2.53516C7.00354 3.08744 6.55582 3.53516 6.00354 3.53516C5.45126 3.53516 5.00354 3.08744 5.00354 2.53516ZM5.00354 6.03516C5.00354 5.48287 5.45126 5.03516 6.00354 5.03516C6.55582 5.03516 7.00354 5.48287 7.00354 6.03516C7.00354 6.58744 6.55582 7.03516 6.00354 7.03516C5.45126 7.03516 5.00354 6.58744 5.00354 6.03516ZM5.00354 9.53516C5.00354 8.98287 5.45126 8.53516 6.00354 8.53516C6.55582 8.53516 7.00354 8.98287 7.00354 9.53516C7.00354 10.0874 6.55582 10.5352 6.00354 10.5352C5.45126 10.5352 5.00354 10.0874 5.00354 9.53516Z"
fill={fill}
/>
</svg>
);
export default MoreVertical01;

View file

@ -239,6 +239,7 @@ import EnterpriseCrown from './EnterrpiseCrown.jsx';
import FileCode from './FileCode.jsx';
import Corners from './Corners.jsx';
import Moon from './Moon.jsx';
import MoreVertical01 from './MoreVertical01.jsx';
import RemoveFolder from './RemoveFolder.jsx';
const Icon = (props) => {
@ -725,6 +726,8 @@ const Icon = (props) => {
return <Play01 {...props} />;
case 'moon':
return <Moon {...props} />;
case 'morevertical01':
return <MoreVertical01 {...props} />;
default:
return <Apps {...props} />;
}

View file

@ -0,0 +1,8 @@
import React from 'react';
import { withEditionSpecificComponent } from '@/modules/common/helpers/withEditionSpecificComponent';
const AppPermissionsModal = () => {
return <></>;
};
export default withEditionSpecificComponent(AppPermissionsModal, 'Appbuilder');

View file

@ -0,0 +1 @@
export { default } from './AppPermissionsModal';

View file

@ -4,6 +4,7 @@ import LogoNavDropdown from './LogoNavDropdown';
import AppEnvironments from './AppEnvironments';
import ThemeSelect from './ThemeSelect';
import ColorSwatches from './ColorSwatches';
import AppPermissionsModal from './AppPermissionsModal';
import ComponentModuleTab from './ComponentModuleTab';
export {
@ -13,5 +14,6 @@ export {
AppEnvironments,
ThemeSelect,
ColorSwatches,
AppPermissionsModal,
ComponentModuleTab,
};

@ -1 +1 @@
Subproject commit 9e988341aeb7be4348bd7d893a4389507785281c
Subproject commit e76477d30eb21df5d188ca204c520df75fddd529

View file

@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
export class CreateQueryPermissions1747759439358 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'query_permissions',
columns: [
{
name: 'id',
type: 'uuid',
isGenerated: true,
default: 'gen_random_uuid()',
isPrimary: true,
},
{
name: 'query_id',
type: 'uuid',
isNullable: false,
},
{
name: 'type',
type: 'enum',
enum: ['SINGLE', 'GROUP'],
},
{
name: 'created_at',
type: 'timestamp',
isNullable: false,
default: 'now()',
},
],
}),
true
);
await queryRunner.createForeignKey(
'query_permissions',
new TableForeignKey({
columnNames: ['query_id'],
referencedColumnNames: ['id'],
referencedTableName: 'data_queries',
onDelete: 'CASCADE',
})
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('query_permissions');
}
}

View file

@ -0,0 +1,76 @@
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
export class CreateQueryUsers1747763331564 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'query_users',
columns: [
{
name: 'id',
type: 'uuid',
isGenerated: true,
default: 'gen_random_uuid()',
isPrimary: true,
},
{
name: 'query_permissions_id',
type: 'uuid',
isNullable: false,
},
{
name: 'user_id',
type: 'uuid',
isNullable: true,
},
{
name: 'permission_groups_id',
type: 'uuid',
isNullable: true,
},
{
name: 'created_at',
type: 'timestamp',
isNullable: false,
default: 'now()',
},
],
}),
true
);
await queryRunner.createForeignKey(
'query_users',
new TableForeignKey({
columnNames: ['query_permissions_id'],
referencedColumnNames: ['id'],
referencedTableName: 'query_permissions',
onDelete: 'CASCADE',
})
);
await queryRunner.createForeignKey(
'query_users',
new TableForeignKey({
columnNames: ['user_id'],
referencedColumnNames: ['id'],
referencedTableName: 'users',
onDelete: 'CASCADE',
})
);
await queryRunner.createForeignKey(
'query_users',
new TableForeignKey({
columnNames: ['permission_groups_id'],
referencedColumnNames: ['id'],
referencedTableName: 'permission_groups',
onDelete: 'CASCADE',
})
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('query_users');
}
}

View file

@ -31,7 +31,7 @@ export class ImportResourcesDto {
@IsOptional()
@IsBoolean()
skip_page_permissions_group_check?: boolean;
skip_permissions_group_check?: boolean;
}
export class ImportAppDto {

View file

@ -10,11 +10,13 @@ import {
JoinTable,
ManyToMany,
AfterLoad,
OneToMany,
} from 'typeorm';
import { App } from './app.entity';
import { AppVersion } from './app_version.entity';
import { DataSource } from './data_source.entity';
import { Plugin } from './plugin.entity';
import { QueryPermission } from './query_permissions.entity';
@Entity({ name: 'data_queries' })
export class DataQuery extends BaseEntity {
@ -81,6 +83,9 @@ export class DataQuery extends BaseEntity {
app: App;
@OneToMany(() => QueryPermission, (permission) => permission.query)
permissions: QueryPermission[];
@AfterLoad()
updatePlugin() {
if (this.plugins?.length) this.plugin = this.plugins[0];

View file

@ -14,6 +14,7 @@ import { GroupUsers } from './group_users.entity';
import { GranularPermissions } from './granular_permissions.entity';
import { GROUP_PERMISSIONS_TYPE } from '@modules/group-permissions/constants';
import { PageUser } from './page_users.entity';
import { QueryUser } from './query_users.entity';
@Entity({ name: 'permission_groups' })
export class GroupPermissions extends BaseEntity {
@ -72,5 +73,8 @@ export class GroupPermissions extends BaseEntity {
@OneToMany(() => PageUser, (pageUser) => pageUser.permissionGroup)
pageUsers: PageUser[];
@OneToMany(() => QueryUser, (queryUser) => queryUser.permissionGroup)
queryUsers: QueryUser[];
disabled?: boolean;
}

View file

@ -0,0 +1,29 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, OneToMany } from 'typeorm';
import { DataQuery } from './data_query.entity';
import { QueryUser } from './query_users.entity';
import { PAGE_PERMISSION_TYPE } from '@modules/app-permissions/constants';
@Entity('query_permissions')
export class QueryPermission {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'query_id', type: 'uuid', nullable: false })
queryId: string;
@Column({
type: 'enum',
enum: PAGE_PERMISSION_TYPE,
})
type: PAGE_PERMISSION_TYPE;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@ManyToOne(() => DataQuery, (query) => query.permissions, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'query_id' })
query: DataQuery;
@OneToMany(() => QueryUser, (queryUser) => queryUser.queryPermission)
users: QueryUser[];
}

View file

@ -0,0 +1,34 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn } from 'typeorm';
import { User } from './user.entity';
import { QueryPermission } from './query_permissions.entity';
import { GroupPermissions } from './group_permissions.entity';
@Entity('query_users')
export class QueryUser {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'query_permissions_id', type: 'uuid' })
queryPermissionsId: string;
@Column({ name: 'user_id', type: 'uuid', nullable: true })
userId: string | null;
@Column({ name: 'permission_groups_id', type: 'uuid', nullable: true })
permissionGroupsId: string | null;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@ManyToOne(() => QueryPermission, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'query_permissions_id' })
queryPermission: QueryPermission;
@ManyToOne(() => User, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'user_id' })
user: User;
@ManyToOne(() => GroupPermissions, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'permission_groups_id' })
permissionGroup: GroupPermissions;
}

View file

@ -30,6 +30,7 @@ import { AiConversation } from './ai_conversation.entity';
import { AiResponseVote } from './ai_response_vote.entity';
import { USER_ROLE } from '@modules/group-permissions/constants';
import { PageUser } from './page_users.entity';
import { QueryUser } from './query_users.entity';
@Entity({ name: 'users' })
export class User extends BaseEntity {
@ -188,6 +189,9 @@ export class User extends BaseEntity {
@OneToMany(() => PageUser, (pageUser) => pageUser.user)
pageUsers: PageUser[];
@OneToMany(() => QueryUser, (queryUser) => queryUser.user)
queryUsers: QueryUser[];
organizationId: string;
invitedOrganizationId: string;
organizationIds?: Array<string>;

View file

@ -1,7 +1,7 @@
export const APP_ERROR_TYPE = {
IMPORT_EXPORT_SERVICE: {
UNSUPPORTED_VERSION_ERROR: 'Apps built on later versions of ToolJet cannot be imported',
PAGE_PERMISSION_GROUP_ERROR: 'Following groups are missing from the workspace',
PERMISSION_GROUP_ERROR: 'Following groups are missing from the workspace',
PERMISSION_CHECK: 'permission-check',
},
};

View file

@ -38,6 +38,10 @@ export class FeatureAbilityFactory extends AbilityFactory<FEATURE_KEY, Subjects>
FEATURE_KEY.CREATE_PAGE_PERMISSIONS,
FEATURE_KEY.UPDATE_PAGE_PERMISSIONS,
FEATURE_KEY.DELETE_PAGE_PERMISSIONS,
FEATURE_KEY.FETCH_QUERY_PERMISSIONS,
FEATURE_KEY.CREATE_QUERY_PERMISSIONS,
FEATURE_KEY.UPDATE_QUERY_PERMISSIONS,
FEATURE_KEY.DELETE_QUERY_PERMISSIONS,
],
App
);
@ -56,6 +60,10 @@ export class FeatureAbilityFactory extends AbilityFactory<FEATURE_KEY, Subjects>
FEATURE_KEY.CREATE_PAGE_PERMISSIONS,
FEATURE_KEY.UPDATE_PAGE_PERMISSIONS,
FEATURE_KEY.DELETE_PAGE_PERMISSIONS,
FEATURE_KEY.FETCH_QUERY_PERMISSIONS,
FEATURE_KEY.CREATE_QUERY_PERMISSIONS,
FEATURE_KEY.UPDATE_QUERY_PERMISSIONS,
FEATURE_KEY.DELETE_QUERY_PERMISSIONS,
],
App
);
@ -66,7 +74,15 @@ export class FeatureAbilityFactory extends AbilityFactory<FEATURE_KEY, Subjects>
isAllAppsViewable ||
(userAppPermissions?.viewableAppsId?.length && appId && userAppPermissions.viewableAppsId.includes(appId))
) {
can([FEATURE_KEY.FETCH_USERS, FEATURE_KEY.FETCH_USER_GROUPS, FEATURE_KEY.FETCH_PAGE_PERMISSIONS], App);
can(
[
FEATURE_KEY.FETCH_USERS,
FEATURE_KEY.FETCH_USER_GROUPS,
FEATURE_KEY.FETCH_PAGE_PERMISSIONS,
FEATURE_KEY.FETCH_QUERY_PERMISSIONS,
],
App
);
}
}
}

View file

@ -10,5 +10,9 @@ export const FEATURES: FeaturesConfig = {
[FEATURE_KEY.CREATE_PAGE_PERMISSIONS]: {},
[FEATURE_KEY.UPDATE_PAGE_PERMISSIONS]: {},
[FEATURE_KEY.DELETE_PAGE_PERMISSIONS]: {},
[FEATURE_KEY.FETCH_QUERY_PERMISSIONS]: {},
[FEATURE_KEY.CREATE_QUERY_PERMISSIONS]: {},
[FEATURE_KEY.UPDATE_QUERY_PERMISSIONS]: {},
[FEATURE_KEY.DELETE_QUERY_PERMISSIONS]: {},
},
};

View file

@ -4,6 +4,12 @@ export enum PAGE_PERMISSION_TYPE {
ALL = 'ALL',
}
export enum PERMISSION_ENTITY_TYPE {
PAGE = 'PAGE',
QUERY = 'QUERY',
COMPONENT = 'COMPONENT',
}
export enum FEATURE_KEY {
FETCH_USERS = 'fetch_users',
FETCH_USER_GROUPS = 'fetch_user_groups',
@ -11,4 +17,8 @@ export enum FEATURE_KEY {
CREATE_PAGE_PERMISSIONS = 'create_page_permissions',
UPDATE_PAGE_PERMISSIONS = 'update_page_permissions',
DELETE_PAGE_PERMISSIONS = 'delete_page_permissions',
FETCH_QUERY_PERMISSIONS = 'fetch_query_permissions',
CREATE_QUERY_PERMISSIONS = 'create_query_permissions',
UPDATE_QUERY_PERMISSIONS = 'update_query_permissions',
DELETE_QUERY_PERMISSIONS = 'delete_query_permissions',
}

View file

@ -8,7 +8,7 @@ import { MODULES } from '@modules/app/constants/modules';
import { InitFeature } from '@modules/app/decorators/init-feature.decorator';
import { FEATURE_KEY } from './constants';
import { JwtAuthGuard } from '@modules/session/guards/jwt-auth.guard';
import { CreatePagePermissionDto } from './dto';
import { CreatePermissionDto } from './dto';
@InitModule(MODULES.APP_PERMISSIONS)
@UseGuards(JwtAuthGuard, FeatureAbilityGuard)
@ -53,7 +53,7 @@ export class AppPermissionsController implements IAppPermissionsController {
@User() user,
@Param('appId') appId: string,
@Param('pageId') pageId: string,
@Body() body: CreatePagePermissionDto,
@Body() body: CreatePermissionDto,
@Res({ passthrough: true }) response: Response
): Promise<any> {
throw new NotFoundException();
@ -65,7 +65,7 @@ export class AppPermissionsController implements IAppPermissionsController {
@User() user,
@Param('appId') appId: string,
@Param('pageId') pageId: string,
@Body() body: CreatePagePermissionDto,
@Body() body: CreatePermissionDto,
@Res({ passthrough: true }) response: Response
): Promise<any> {
throw new NotFoundException();
@ -81,4 +81,50 @@ export class AppPermissionsController implements IAppPermissionsController {
): Promise<any> {
throw new NotFoundException();
}
@InitFeature(FEATURE_KEY.FETCH_QUERY_PERMISSIONS)
@Get(':appId/queries/:queryId')
async fetchQueryPermissions(
@User() user,
@Param('appId') appId: string,
@Param('queryId') queryId: string,
@Res({ passthrough: true }) response: Response
): Promise<any> {
throw new NotFoundException();
}
@InitFeature(FEATURE_KEY.CREATE_QUERY_PERMISSIONS)
@Post(':appId/queries/:queryId')
async createQueryPermissions(
@User() user,
@Param('appId') appId: string,
@Param('queryId') queryId: string,
@Body() body: CreatePermissionDto,
@Res({ passthrough: true }) response: Response
): Promise<any> {
throw new NotFoundException();
}
@InitFeature(FEATURE_KEY.UPDATE_QUERY_PERMISSIONS)
@Put(':appId/queries/:queryId')
async updateQueryPermissions(
@User() user,
@Param('appId') appId: string,
@Param('queryId') queryId: string,
@Body() body: CreatePermissionDto,
@Res({ passthrough: true }) response: Response
): Promise<any> {
throw new NotFoundException();
}
@InitFeature(FEATURE_KEY.DELETE_QUERY_PERMISSIONS)
@Delete(':appId/queries/:queryId')
async deleteQueryPermissions(
@User() user,
@Param('appId') appId: string,
@Param('queryId') queryId: string,
@Res({ passthrough: true }) response: Response
): Promise<any> {
throw new NotFoundException();
}
}

View file

@ -2,10 +2,10 @@ import { IsUUID, IsEnum, IsArray, IsString, IsOptional, ValidateIf } from 'class
import { Type } from 'class-transformer';
import { PAGE_PERMISSION_TYPE } from '../constants';
export class CreatePagePermissionDto {
export class CreatePermissionDto {
@IsUUID(4)
@IsOptional()
pageId: string;
id: string;
@IsEnum(PAGE_PERMISSION_TYPE)
type: PAGE_PERMISSION_TYPE;

View file

@ -1,6 +1,6 @@
import { User } from '@entities/user.entity';
import { Response } from 'express';
import { CreatePagePermissionDto } from '../dto';
import { CreatePermissionDto } from '../dto';
export interface IAppPermissionsController {
fetchUsers(user: User, appId: string, response: Response): Promise<any>;
@ -13,7 +13,7 @@ export interface IAppPermissionsController {
user: User,
appId: string,
pageId: string,
body: CreatePagePermissionDto,
body: CreatePermissionDto,
response: Response
): Promise<any>;
@ -21,9 +21,29 @@ export interface IAppPermissionsController {
user: User,
appId: string,
pageId: string,
body: CreatePagePermissionDto,
body: CreatePermissionDto,
response: Response
): Promise<any>;
deletePagePermissions(user: User, appId: string, pageId: string, response: Response): Promise<any>;
fetchQueryPermissions(user: User, appId: string, queryId: string, response: Response): Promise<any>;
createQueryPermissions(
user: User,
appId: string,
queryId: string,
body: CreatePermissionDto,
response: Response
): Promise<any>;
updateQueryPermissions(
user: User,
appId: string,
queryId: string,
body: CreatePermissionDto,
response: Response
): Promise<any>;
deleteQueryPermissions(user: User, appId: string, queryId: string, response: Response): Promise<any>;
}

View file

@ -1,16 +1,23 @@
import { User } from '@entities/user.entity';
import { CreatePagePermissionDto } from '../dto';
import { CreatePermissionDto } from '../dto';
import { PERMISSION_ENTITY_TYPE } from '../constants';
export interface IAppPermissionsService {
fetchUsers(appId: string, user: User): Promise<any>;
fetchUserGroups(appId: string, user: User): Promise<any>;
fetchPagePermissions(pageId: string): Promise<any>;
fetchAppPermissions(type: PERMISSION_ENTITY_TYPE, id: string): Promise<any>;
createPagePermissions(pageId: string, body: CreatePagePermissionDto): Promise<any>;
createAppPermissions(type: PERMISSION_ENTITY_TYPE, id: string, body: CreatePermissionDto): Promise<any>;
updatePagePermissions(appId: string, pageId: string, body: CreatePagePermissionDto, user: User): Promise<any>;
updateAppPermissions(
type: PERMISSION_ENTITY_TYPE,
appId: string,
id: string,
body: CreatePermissionDto,
user: User
): Promise<any>;
deletePagePermissions(pageId: string): Promise<any>;
deleteAppPermissions(type: PERMISSION_ENTITY_TYPE, id: string): Promise<any>;
}

View file

@ -1,13 +1,17 @@
import { User } from '@entities/user.entity';
import { GroupPermissions } from '@entities/group_permissions.entity';
import { CreatePagePermissionDto } from '../dto';
import { CreatePermissionDto } from '../dto';
export interface IUtilService {
getUsersWithViewAccess(appId: string, organizationId: string): Promise<User[]>;
getUserGroupsWithViewAccess(appId: string, organizationId: string): Promise<GroupPermissions[]>;
createPagePermission(pageId: string, body: CreatePagePermissionDto): Promise<any>;
createPagePermission(pageId: string, body: CreatePermissionDto): Promise<any>;
updatePagePermission(pageId: string, body: CreatePagePermissionDto): Promise<any>;
updatePagePermission(pageId: string, body: CreatePermissionDto): Promise<any>;
createQueryPermission(queryId: string, body: CreatePermissionDto): Promise<any>;
updateQueryPermission(queryId: string, body: CreatePermissionDto): Promise<any>;
}

View file

@ -7,8 +7,12 @@ import { User } from '@entities/user.entity';
import { RolesRepository } from '@modules/roles/repository';
import { PageUsersRepository } from './repositories/page-users.repository';
import { PagePermissionsRepository } from './repositories/page-permissions.repository';
import { QueryUsersRepository } from './repositories/query-users.repository';
import { QueryPermissionsRepository } from './repositories/query-permissions.repository';
import { PageUser } from '@entities/page_users.entity';
import { PagePermission } from '@entities/page_permissions.entity';
import { QueryUser } from '@entities/query_users.entity';
import { QueryPermission } from '@entities/query_permissions.entity';
export class AppPermissionsModule {
static async register(configs: { IS_GET_CONTEXT: boolean }): Promise<DynamicModule> {
@ -19,7 +23,9 @@ export class AppPermissionsModule {
return {
module: AppPermissionsModule,
imports: [TypeOrmModule.forFeature([GroupPermissions, User, PageUser, PagePermission])],
imports: [
TypeOrmModule.forFeature([GroupPermissions, User, PageUser, PagePermission, QueryUser, QueryPermission]),
],
controllers: [AppPermissionsController],
providers: [
AppPermissionsService,
@ -27,6 +33,8 @@ export class AppPermissionsModule {
RolesRepository,
PageUsersRepository,
PagePermissionsRepository,
QueryUsersRepository,
QueryPermissionsRepository,
FeatureAbilityFactory,
],
exports: [AppPermissionsUtilService, AppPermissionsService],

View file

@ -0,0 +1,58 @@
import { QueryPermission } from '@entities/query_permissions.entity';
import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager, Repository } from 'typeorm';
import { QueryUsersRepository } from './query-users.repository';
import { dbTransactionWrap } from '@helpers/database.helper';
import { PAGE_PERMISSION_TYPE } from '../constants';
@Injectable()
export class QueryPermissionsRepository extends Repository<QueryPermission> {
constructor(private dataSource: DataSource, private readonly queryUsersRepository: QueryUsersRepository) {
super(QueryPermission, dataSource.createEntityManager());
}
async getQueryPermissions(queryId: string, manager?: EntityManager): Promise<QueryPermission[]> {
return dbTransactionWrap(async (manager: EntityManager) => {
const queryPermissions = await manager.find(QueryPermission, {
where: { queryId },
relations: ['users', 'users.user', 'users.permissionGroup'],
});
return queryPermissions.map((permission) => {
if (permission.type === PAGE_PERMISSION_TYPE.GROUP) {
return {
...permission,
groups: permission.users,
users: undefined,
};
}
return permission;
});
}, manager || this.manager);
}
async createQueryPermissions(
queryId: string,
type: PAGE_PERMISSION_TYPE,
manager?: EntityManager
): Promise<QueryPermission> {
return dbTransactionWrap(async (manager: EntityManager) => {
const existingPermission = await manager.findOne(QueryPermission, { where: { queryId } });
if (existingPermission) {
throw new Error(`Query permission already exists for Query id: ${queryId}`);
}
const queryPermission = manager.create(QueryPermission, {
queryId,
type,
});
return manager.save(queryPermission);
}, manager || this.manager);
}
async deleteQueryPermissions(queryId: string, manager?: EntityManager): Promise<void> {
return dbTransactionWrap(async (manager: EntityManager) => {
await manager.delete(QueryPermission, { queryId });
}, manager || this.manager);
}
}

View file

@ -0,0 +1,83 @@
import { QueryUser } from '@entities/query_users.entity';
import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager, Repository } from 'typeorm';
import { dbTransactionWrap } from '@helpers/database.helper';
import { QueryPermission } from '@entities/query_permissions.entity';
@Injectable()
export class QueryUsersRepository extends Repository<QueryUser> {
constructor(private dataSource: DataSource) {
super(QueryUser, dataSource.createEntityManager());
}
async createQueryUsersWithSingle(
queryPermissionsId: string,
users: string[],
manager?: EntityManager
): Promise<QueryUser[]> {
return dbTransactionWrap(async (manager: EntityManager) => {
const queryUsers = users.map((userId) => {
return manager.create(QueryUser, {
queryPermissionsId,
userId,
permissionGroupsId: null,
});
});
return manager.save(queryUsers);
}, manager || this.manager);
}
async createQueryUsersWithGroup(
queryPermissionsId: string,
groups: string[],
manager?: EntityManager
): Promise<QueryUser[]> {
return dbTransactionWrap(async (manager: EntityManager) => {
const queryUsers = groups.map((permissionGroupsId) => {
return manager.create(QueryUser, {
queryPermissionsId,
permissionGroupsId,
userId: null,
});
});
return manager.save(queryUsers);
}, manager || this.manager);
}
async checkQueryUserWithGroup(
queryPermission: QueryPermission,
userId: string,
manager?: EntityManager
): Promise<boolean> {
return dbTransactionWrap(async (manager: EntityManager) => {
const result = await manager
.createQueryBuilder(QueryUser, 'query_users')
.innerJoin('query_users.permissionGroup', 'group')
.innerJoin('group.groupUsers', 'groupUser')
.where('query_users.queryPermission = :permissionId', {
permissionId: queryPermission.id,
})
.andWhere('groupUser.userId = :userId', { userId })
.getOne();
return !!result;
}, manager || this.manager);
}
async checkQueryUserWithSingle(
queryPermission: QueryPermission,
userId: string,
manager?: EntityManager
): Promise<boolean> {
return dbTransactionWrap(async (manager: EntityManager) => {
const queryUser = await manager.findOne(QueryUser, {
where: {
queryPermission: { id: queryPermission.id },
userId,
},
});
return !!queryUser;
}, manager || this.manager);
}
}

View file

@ -13,19 +13,19 @@ export class AppPermissionsService implements IAppPermissionsService {
throw new Error('Method not implemented.');
}
async fetchPagePermissions(pageId) {
async fetchAppPermissions(type, id) {
throw new Error('Method not implemented.');
}
async createPagePermissions(pageId, body) {
async createAppPermissions(type, id, body) {
throw new Error('Method not implemented.');
}
async updatePagePermissions(appId, pageId, body, user) {
async updateAppPermissions(type, appId, id, body, user) {
throw new Error('Method not implemented.');
}
async deletePagePermissions(pageId) {
async deleteAppPermissions(type, id) {
throw new Error('Method not implemented.');
}
}

View file

@ -9,6 +9,10 @@ interface Features {
[FEATURE_KEY.CREATE_PAGE_PERMISSIONS]: FeatureConfig;
[FEATURE_KEY.UPDATE_PAGE_PERMISSIONS]: FeatureConfig;
[FEATURE_KEY.DELETE_PAGE_PERMISSIONS]: FeatureConfig;
[FEATURE_KEY.FETCH_QUERY_PERMISSIONS]: FeatureConfig;
[FEATURE_KEY.CREATE_QUERY_PERMISSIONS]: FeatureConfig;
[FEATURE_KEY.UPDATE_QUERY_PERMISSIONS]: FeatureConfig;
[FEATURE_KEY.DELETE_QUERY_PERMISSIONS]: FeatureConfig;
}
export interface FeaturesConfig {

View file

@ -2,7 +2,7 @@ import { User } from '@entities/user.entity';
import { IUtilService } from './interfaces/IUtilService';
import { Injectable } from '@nestjs/common';
import { GroupPermissions } from '@entities/group_permissions.entity';
import { CreatePagePermissionDto } from './dto';
import { CreatePermissionDto } from './dto';
@Injectable()
export class AppPermissionsUtilService implements IUtilService {
@ -16,11 +16,19 @@ export class AppPermissionsUtilService implements IUtilService {
throw new Error('Method not implemented.');
}
async createPagePermission(pageId: string, body: CreatePagePermissionDto): Promise<any> {
async createPagePermission(pageId: string, body: CreatePermissionDto): Promise<any> {
throw new Error('Method not implemented.');
}
async updatePagePermission(pageId: string, body: CreatePagePermissionDto): Promise<any> {
async updatePagePermission(pageId: string, body: CreatePermissionDto): Promise<any> {
throw new Error('Method not implemented.');
}
async createQueryPermission(queryId: string, body: CreatePermissionDto): Promise<any> {
throw new Error('Method not implemented.');
}
async updateQueryPermission(queryId: string, body: CreatePermissionDto): Promise<any> {
throw new Error('Method not implemented.');
}
}

View file

@ -39,6 +39,8 @@ import { PAGE_PERMISSION_TYPE } from '@modules/app-permissions/constants';
import { PagePermission } from '@entities/page_permissions.entity';
import { PageUser } from '@entities/page_users.entity';
import { UsersUtilService } from '@modules/users/util.service';
import { QueryPermission } from '@entities/query_permissions.entity';
import { QueryUser } from '@entities/query_users.entity';
interface AppResourceMappings {
defaultDataSourceIdMapping: Record<string, string>;
dataQueryMapping: Record<string, string>;
@ -161,6 +163,9 @@ export class AppImportExportService {
if (dataSources?.length) {
dataQueries = await manager
.createQueryBuilder(DataQuery, 'data_queries')
.leftJoinAndSelect('data_queries.permissions', 'permission')
.leftJoinAndSelect('permission.users', 'queryUser')
.leftJoinAndSelect('queryUser.permissionGroup', 'permissionGroup')
.where('data_queries.dataSourceId IN(:...dataSourceId)', {
dataSourceId: dataSources?.map((v) => v.id),
})
@ -213,6 +218,21 @@ export class AppImportExportService {
};
});
const queriesWithPermissionGroups = dataQueries.map((query) => {
const groupPermission = query.permissions.find((perm) => perm.type === 'GROUP');
return {
...query,
permissions: groupPermission
? {
permissionGroup: groupPermission.users
.map((user) => user.permissionGroup?.name)
.filter((name): name is string => Boolean(name)),
}
: undefined,
};
});
const components =
pages.length > 0
? await manager
@ -236,7 +256,7 @@ export class AppImportExportService {
appToExport['components'] = components;
appToExport['pages'] = pagesWithPermissionGroups;
appToExport['events'] = events;
appToExport['dataQueries'] = dataQueries;
appToExport['dataQueries'] = queriesWithPermissionGroups;
appToExport['dataSources'] = dataSources;
appToExport['appVersions'] = appVersions;
appToExport['appEnvironments'] = appEnvironments;
@ -1119,7 +1139,15 @@ export class AppImportExportService {
});
await manager.save(newQuery);
if (importingQuery.permissions) {
newQuery.permissions = importingQuery.permissions;
}
appResourceMappings.dataQueryMapping[importingQuery.id] = newQuery.id;
//create query permissions of query if flag enabled in dto
await this.createQueryPermissionsForGroups(newQuery, organizationId, manager);
}
return appResourceMappings;
@ -1355,7 +1383,7 @@ export class AppImportExportService {
return pageSettings;
}
async checkIfGroupPermissionsExist(pages, organizationId) {
async checkIfGroupPermissionsExist(pages, queries, organizationId) {
const allGroupNames = new Set<string>();
for (const page of pages) {
@ -1365,6 +1393,15 @@ export class AppImportExportService {
}
}
for (const query of queries) {
const groupNames = query.permissions?.permissionGroup || [];
for (const name of groupNames) {
if (!allGroupNames.has(name)) {
allGroupNames.add(name);
}
}
}
if (!allGroupNames.size) return;
return await dbTransactionWrap(async (manager: EntityManager) => {
@ -1425,6 +1462,41 @@ export class AppImportExportService {
await manager.save(pageUsers);
}
async createQueryPermissionsForGroups(query, organizationId: string, manager: EntityManager) {
const groupNames = query.permissions?.permissionGroup || [];
if (!groupNames.length) return;
const existingGroups = await manager
.createQueryBuilder(GroupPermissions, 'gp')
.where('gp.name IN (:...names)', { names: groupNames })
.andWhere('gp.organizationId = :organizationId', { organizationId })
.getMany();
const groupMap = new Map(existingGroups.map((g) => [g.name, g]));
// Filter to only existing group names
const validGroupNames = groupNames.filter((name) => groupMap.has(name));
// If no valid group names exist, do not create permissions
if (!validGroupNames.length) return;
const permission = manager.create(QueryPermission, {
queryId: query.id,
type: PAGE_PERMISSION_TYPE.GROUP,
});
const savedPermission = await manager.save(permission);
const queryUsers = validGroupNames.map((name) =>
manager.create(QueryUser, {
queryPermissionsId: savedPermission.id,
permissionGroupsId: groupMap.get(name).id,
})
);
await manager.save(queryUsers);
}
async createAppVersionsForImportedApp(
manager: EntityManager,
user: User,

View file

@ -47,11 +47,6 @@ export const containerConfig = {
defaultValue: true,
},
},
headerHeight: {
type: 'numberInput',
displayName: 'Header height',
validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 },
},
},
defaultChildren: [
{
@ -61,10 +56,10 @@ export const containerConfig = {
top: 20,
left: 1,
height: 40,
width: 20,
},
displayName: 'ContainerText',
properties: ['text'],
slotName: 'header',
accessorKey: 'text',
styles: ['fontWeight', 'textSize', 'textColor'],
defaultValue: {

View file

@ -13,7 +13,7 @@ export const listviewConfig = {
top: 15,
left: 3,
height: 100,
width: 7,
width: 4,
},
properties: ['source'],
accessorKey: 'imageURL',
@ -24,6 +24,7 @@ export const listviewConfig = {
top: 50,
left: 11,
height: 30,
width: 4,
},
properties: ['text'],
accessorKey: 'text',
@ -50,7 +51,13 @@ export const listviewConfig = {
type: 'code',
displayName: 'List data',
validation: {
schema: { type: 'union', schemas: [{ type: 'array', element: { type: 'object' } },{ type: 'array', element: { type: 'string' } }] },
schema: {
type: 'union',
schemas: [
{ type: 'array', element: { type: 'object' } },
{ type: 'array', element: { type: 'string' } },
],
},
defaultValue: "[{text: 'Sample text 1'}]",
},
},

View file

@ -92,18 +92,6 @@ export const modalV2Config = {
accordian: 'Data',
validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 400 },
},
headerHeight: {
type: 'numberInput',
displayName: 'Header height',
accordian: 'Data',
validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 },
},
footerHeight: {
type: 'numberInput',
displayName: 'Footer height',
accordian: 'Data',
validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 },
},
hideOnEsc: { type: 'toggle', displayName: 'Close on escape key', section: 'additionalActions' },
closeOnClickingOutside: { type: 'toggle', displayName: 'Close on clicking outside', section: 'additionalActions' },
hideCloseButton: { type: 'toggle', displayName: 'Hide close button', section: 'additionalActions' },

View file

@ -1,4 +1,4 @@
import { Controller, Get, Param, Body, Post, Patch, Delete, UseGuards, Put, Res } from '@nestjs/common';
import { Controller, Get, Param, Body, Post, Patch, Delete, UseGuards, Put, Res, Query } from '@nestjs/common';
import { JwtAuthGuard } from '@modules/session/guards/jwt-auth.guard';
import { DataQueriesService } from './service';
import { User, UserEntity } from '@modules/app/decorators/user.decorator';
@ -30,8 +30,8 @@ export class DataQueriesController implements IDataQueriesController {
@InitFeature(FEATURE_KEY.GET)
@UseGuards(JwtAuthGuard, ValidateAppVersionGuard, ValidateQueryAppGuard, AppFeatureAbilityGuard)
@Get(':versionId')
index(@Param('versionId') versionId: string) {
return this.dataQueriesService.getAll(versionId);
index(@User() user: UserEntity, @Param('versionId') versionId: string, @Query('mode') mode?: string) {
return this.dataQueriesService.getAll(user, versionId, mode);
}
@InitFeature(FEATURE_KEY.CREATE)
@ -113,7 +113,8 @@ export class DataQueriesController implements IDataQueriesController {
@Body() updateDataQueryDto: UpdateDataQueryDto,
@Ability() ability: AppAbility,
@DataSource() dataSource: DataSourceEntity,
@Res({ passthrough: true }) response: Response
@Res({ passthrough: true }) response: Response,
@Query('mode') mode?: string
) {
return this.dataQueriesService.runQueryOnBuilder(
user,
@ -122,7 +123,8 @@ export class DataQueriesController implements IDataQueriesController {
updateDataQueryDto,
ability,
dataSource,
response
response,
mode
);
}

View file

@ -7,7 +7,7 @@ import { App } from '@entities/app.entity';
import { UpdateSourceDto } from '../dto';
import { Response } from 'express';
export interface IDataQueriesController {
index(versionId: string): Promise<object>;
index(user: UserEntity, versionId: string, mode?: string): Promise<object>;
create(
user: UserEntity,
@ -36,7 +36,8 @@ export interface IDataQueriesController {
updateDataQueryDto: UpdateDataQueryDto,
ability: AppAbility,
dataSource: DataSourceEntity,
response: Response
response: Response,
mode?: string
): Promise<object>;
runQuery(

View file

@ -5,7 +5,7 @@ import { CreateDataQueryDto, IUpdatingReferencesOptions, UpdateDataQueryDto } fr
import { DataQuery } from '@entities/data_query.entity';
export interface IDataQueriesService {
getAll(versionId: string): Promise<{ data_queries: object[] }>;
getAll(user: User, versionId: string, mode?: string): Promise<{ data_queries: object[] }>;
create(user: User, dataSource: DataSource, dataQueryDto: CreateDataQueryDto): Promise<object>;
@ -22,7 +22,8 @@ export interface IDataQueriesService {
updateDataQueryDto: UpdateDataQueryDto,
ability: object,
dataSource: DataSource,
response: Response
response: Response,
mode?: string
): Promise<object>;
runQueryForApp(

View file

@ -14,7 +14,8 @@ export interface IDataQueriesUtilService {
dataQuery: any,
queryOptions: object,
response: Response,
environmentId?: string
environmentId?: string,
mode?: string
): Promise<object>;
fetchServiceAndParsedParams(

View file

@ -9,6 +9,8 @@ import { FeatureAbilityFactory as AppFeatureAbilityFactory } from './ability/app
import { FeatureAbilityFactory as DataSourceFeatureAbilityFactory } from './ability/data-source';
import { AppsRepository } from '@modules/apps/repository';
import { OrganizationRepository } from '@modules/organizations/repository';
import { LicenseModule } from '@modules/licensing/module';
import { AppPermissionsModule } from '@modules/app-permissions/module';
export class DataQueriesModule {
static async register(configs?: { IS_GET_CONTEXT: boolean }): Promise<DynamicModule> {
@ -19,7 +21,12 @@ export class DataQueriesModule {
return {
module: DataQueriesModule,
imports: [await AppEnvironmentsModule.register(configs), await DataSourcesModule.register(configs)],
imports: [
await AppEnvironmentsModule.register(configs),
await DataSourcesModule.register(configs),
await LicenseModule.forRoot(configs),
await AppPermissionsModule.register(configs),
],
providers: [
DataQueryRepository,
VersionRepository,

View file

@ -50,6 +50,25 @@ export class DataQueryRepository extends Repository<DataQuery> {
});
}
getAllWithPermissions(appVersionId: string): Promise<DataQuery[]> {
return dbTransactionWrap((manager: EntityManager) => {
return manager
.createQueryBuilder(DataQuery, 'data_query')
.innerJoinAndSelect('data_query.dataSource', 'data_source')
.leftJoinAndSelect('data_query.plugins', 'plugins')
.leftJoinAndSelect('plugins.iconFile', 'iconFile')
.leftJoinAndSelect('plugins.manifestFile', 'manifestFile')
.leftJoinAndSelect('data_query.permissions', 'permission')
.leftJoinAndSelect('permission.users', 'queryUser')
.leftJoinAndSelect('queryUser.user', 'user')
.leftJoinAndSelect('queryUser.permissionGroup', 'group')
.where('data_source.appVersionId = :appVersionId', { appVersionId })
.where('data_query.app_version_id = :appVersionId', { appVersionId })
.orderBy('data_query.updatedAt', 'DESC')
.getMany();
});
}
async createOne(data: Partial<DataQuery>, manager?: EntityManager): Promise<DataQuery> {
return dbTransactionWrap((manager: EntityManager) => {
const newDataQuery = manager.create(DataQuery, {

View file

@ -24,7 +24,7 @@ export class DataQueriesService implements IDataQueriesService {
protected readonly dataSourceRepository: DataSourcesRepository
) { }
async getAll(versionId: string) {
async getAll(user: User, versionId: string, mode?: string) {
const queries = await this.dataQueryRepository.getAll(versionId);
const serializedQueries = [];
@ -127,7 +127,8 @@ export class DataQueriesService implements IDataQueriesService {
updateDataQueryDto: UpdateDataQueryDto,
ability: AppAbility,
dataSource: DataSource,
response: Response
response: Response,
mode?: string
) {
const { options, resolvedOptions } = updateDataQueryDto;
@ -138,7 +139,7 @@ export class DataQueriesService implements IDataQueriesService {
dataQuery['options'] = options;
}
return this.runAndGetResult(user, dataQuery, resolvedOptions, response, environmentId);
return this.runAndGetResult(user, dataQuery, resolvedOptions, response, environmentId, mode);
}
async runQueryForApp(user: User, dataQueryId: string, updateDataQueryDto: UpdateDataQueryDto, response: Response) {
@ -153,17 +154,25 @@ export class DataQueriesService implements IDataQueriesService {
return this.runAndGetResult(user, dataQuery, options, response, environmentId);
}
private async runAndGetResult(
protected async runAndGetResult(
user: User,
dataQuery: DataQuery,
resolvedOptions: object,
response: Response,
environmentId?: string
environmentId?: string,
mode?: string
): Promise<object> {
let result = {};
try {
result = await this.dataQueryUtilService.runQuery(user, dataQuery, resolvedOptions, response, environmentId);
result = await this.dataQueryUtilService.runQuery(
user,
dataQuery,
resolvedOptions,
response,
environmentId,
mode
);
} catch (error) {
if (error.constructor.name === 'QueryError') {
result = {

View file

@ -63,7 +63,8 @@ export class DataQueriesUtilService implements IDataQueriesUtilService {
dataQuery: any,
queryOptions: object,
response: Response,
environmentId?: string
environmentId?: string,
mode?: string
): Promise<object> {
let result;
const queryStatus = new DataQueryStatus();
@ -320,7 +321,7 @@ export class DataQueriesUtilService implements IDataQueriesUtilService {
return { service, sourceOptions, parsedQueryOptions };
}
private getCurrentUserToken = (isMultiAuthEnabled: boolean, tokenData: any, userId: string, isAppPublic: boolean) => {
protected getCurrentUserToken = (isMultiAuthEnabled: boolean, tokenData: any, userId: string, isAppPublic: boolean) => {
if (isMultiAuthEnabled) {
if (!tokenData || !Array.isArray(tokenData)) return null;
return !isAppPublic

View file

@ -70,15 +70,17 @@ export class ImportExportResourcesService {
let tableNameMapping = {};
const imports = { app: [], tooljet_database: [], tableNameMapping: {} };
const importingVersion = importResourcesDto.tooljet_version;
const skipPagePermissionsGroupCheck = importResourcesDto.skip_page_permissions_group_check;
const skipPermissionsGroupCheck = importResourcesDto.skip_permissions_group_check;
if (!isEmpty(importResourcesDto.app) && !skipPagePermissionsGroupCheck) {
if (!isEmpty(importResourcesDto.app) && !skipPermissionsGroupCheck) {
for (const appImportDto of importResourcesDto.app) {
let appParams = appImportDto.definition;
if (appParams?.appV2) {
appParams = { ...appParams.appV2 };
const pages = appParams?.pages;
pages?.length && (await this.appImportExportService.checkIfGroupPermissionsExist(pages, user.organizationId));
const queries = appParams?.dataQueries;
(pages?.length || queries?.length) &&
(await this.appImportExportService.checkIfGroupPermissionsExist(pages, queries, user.organizationId));
}
}
}

View file

@ -19,7 +19,7 @@ export default class LicenseBase {
private _isCustomStyling: boolean;
private _isWhiteLabelling: boolean;
private _isCustomThemes: boolean;
private _isServerSideGlobal: boolean;
private _isServerSideGlobalResolve: boolean;
private _isMultiEnvironment: boolean;
private _isMultiPlayerEdit: boolean;
private _isComments: boolean;
@ -65,7 +65,7 @@ export default class LicenseBase {
this._isCustomStyling = true;
this._isWhiteLabelling = true;
this._isCustomThemes = true;
this._isServerSideGlobal = true;
this._isServerSideGlobalResolve = true;
this._isLicenseValid = true;
this._isMultiEnvironment = true;
this._isAi = true;
@ -108,7 +108,7 @@ export default class LicenseBase {
this._isWhiteLabelling = this.getFeatureValue('whiteLabelling');
this._isAppWhiteLabelling = this.getFeatureValue('appWhiteLabelling');
this._isCustomThemes = this.getFeatureValue('customThemes');
this._isServerSideGlobal = this.getFeatureValue('serverSideGlobal');
this._isServerSideGlobalResolve = this.getFeatureValue('serverSideGlobalResolve');
this._isMultiEnvironment = this.getFeatureValue('multiEnvironment');
this._isMultiPlayerEdit = this.getFeatureValue('multiPlayerEdit');
this._isComments = this.getFeatureValue('comments');
@ -285,11 +285,11 @@ export default class LicenseBase {
return this._isCustomThemes;
}
public get serverSideGlobal(): boolean {
public get serverSideGlobalResolve(): boolean {
if (this.IsBasicPlan) {
return !!this.BASIC_PLAN_TERMS.features?.serverSideGlobal;
return !!this.BASIC_PLAN_TERMS.features?.serverSideGlobalResolve;
}
return this._isServerSideGlobal;
return this._isServerSideGlobalResolve;
}
public get externalApis(): boolean {
if (this.IsBasicPlan) {
@ -340,7 +340,7 @@ export default class LicenseBase {
customStyling: this.customStyling,
whiteLabelling: this.whiteLabelling,
customThemes: this.customThemes,
serverSideGlobal: this.serverSideGlobal,
serverSideGlobalResolve: this.serverSideGlobalResolve,
multiEnvironment: this.multiEnvironment,
multiPlayerEdit: this.multiPlayerEdit,
gitSync: this.gitSync,
@ -370,7 +370,7 @@ export default class LicenseBase {
samlEnabled: this.saml,
customStylingEnabled: this.customStyling,
customThemesEnabled: this.customThemes,
serverSideGlobalEnabled: this.serverSideGlobal,
serverSideGlobalResolveEnabled: this.serverSideGlobalResolve,
multiEnvironmentEnabled: this.multiEnvironment,
multiPlayerEditEnabled: this.multiPlayerEdit,
commentsEnabled: this.comments,

View file

@ -25,7 +25,7 @@ export const BASIC_PLAN_TERMS: Partial<Terms> = {
gitSync: false,
comments: false,
customThemes: false,
serverSideGlobal: false,
serverSideGlobalResolve: false,
},
domains: [],
workflows: {

View file

@ -104,7 +104,7 @@ export enum LICENSE_FIELD {
CUSTOM_STYLE = 'customStylingEnabled',
WHITE_LABEL = 'whitelabellingEnabled',
CUSTOM_THEMES = 'customThemeEnabled',
SERVER_SIDE_GLOBAL = 'serverSideGlobalEnabled',
SERVER_SIDE_GLOBAL = 'serverSideGlobalResolveEnabled',
AUDIT_LOGS = 'auditLogsEnabled',
MAX_DURATION_FOR_AUDIT_LOGS = 'maxDaysForAuditLogs',
MULTI_ENVIRONMENT = 'multiEnvironmentEnabled',

View file

@ -60,7 +60,7 @@ export function getLicenseFieldValue(type: LICENSE_FIELD, licenseInstance: Licen
return licenseInstance.customThemes;
case LICENSE_FIELD.SERVER_SIDE_GLOBAL:
return licenseInstance.serverSideGlobal;
return licenseInstance.serverSideGlobalResolve;
case LICENSE_FIELD.EXTERNAL_API:
return licenseInstance.externalApis;

View file

@ -27,7 +27,7 @@ export interface Terms {
gitSync?: boolean;
comments?: boolean;
customThemes?: boolean;
serverSideGlobal?: boolean;
serverSideGlobalResolve?: boolean;
ai?: boolean;
externalApi?: boolean;
appWhiteLabelling?: boolean;

View file

@ -107,6 +107,21 @@ export class VersionRepository extends Repository<AppVersion> {
}, manager || this.manager);
}
async findDataQueriesForVersionWithPermissions(appVersionId: string, manager?: EntityManager): Promise<DataQuery[]> {
return dbTransactionWrap((manager: EntityManager) => {
return manager
.createQueryBuilder(DataQuery, 'query')
.where('query.appVersionId = :appVersionId', { appVersionId })
.leftJoinAndSelect('query.dataSource', 'dataSource')
.leftJoinAndSelect('query.permissions', 'permission')
.leftJoinAndSelect('permission.users', 'queryUser')
.leftJoinAndSelect('queryUser.user', 'user')
.leftJoinAndSelect('queryUser.permissionGroup', 'group')
.select(['query', 'dataSource.kind', 'permission', 'queryUser', 'user', 'group'])
.getMany();
}, manager || this.manager);
}
async findVersion(id: string, manager?: EntityManager): Promise<AppVersion> {
return await dbTransactionWrap(async (manager: EntityManager) => {
const appVersion = await manager.findOneOrFail(AppVersion, {