Merge branch 'main' into release/marketplace-sprint-11

This commit is contained in:
Ganesh Kumar 2025-05-21 11:08:14 +05:30 committed by GitHub
commit 5e53d6dd7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
102 changed files with 2965 additions and 941 deletions

View file

@ -1 +1 @@
3.12.0
3.13.0

View file

@ -1 +1 @@
3.12.0
3.13.0

@ -1 +1 @@
Subproject commit 518f3334b12a83785fd37dd53b0245d72848211a
Subproject commit 777446d71e78e5941d34353606a12d982820438f

View file

@ -58,6 +58,7 @@
"dotenv": "^16.0.3",
"draft-js": "^0.11.7",
"draft-js-export-html": "^1.4.1",
"draft-js-import-html": "^1.4.1",
"driver.js": "^0.9.8",
"emoji-mart": "^5.5.2",
"file-loader": "^6.2.0",

View file

@ -232,6 +232,7 @@ export const getAllChildComponents = (allComponents, parentId) => {
const childTabId = componentParentId.split('-').at(-1);
if (componentParentId === `${parentId}-${childTabId}`) {
childComponent.isParentTabORCalendar = true;
childComponent.events = useStore.getState().eventsSlice.getEventsByComponentsId(componentId);
childComponents.push(childComponent);
// Recursively find children of the current child component
const childrenOfChild = getAllChildComponents(allComponents, componentId);
@ -242,6 +243,7 @@ export const getAllChildComponents = (allComponents, parentId) => {
if (componentParentId === parentId) {
let childComponent = deepClone(allComponents[componentId]);
childComponent.id = componentId;
childComponent.events = useStore.getState().eventsSlice.getEventsByComponentsId(componentId);
childComponents.push(childComponent);
// Recursively find children of the current child component

View file

@ -0,0 +1,27 @@
/* eslint-disable import/no-unresolved */
import React from 'react';
import { openSearchPanel } from '@codemirror/search';
import './SearchBox.scss';
import { Button as ButtonComponent } from '@/components/ui/Button/Button.jsx';
export const CodeHinterBtns = ({ view, isPanelOpen, renderCopilot }) => {
return (
<div
className="d-flex tw-flex-col align-items-end tw-gap-[2.5px] w-100 position-absolute tw-pt-[4px] tw-pr-[4px]"
style={{ top: isPanelOpen ? '44px' : 0 }}
>
{!isPanelOpen && (
<ButtonComponent
iconOnly
trailingIcon="search01"
size="small"
variant="outline"
ariaLabel="Open search panel"
className="codehinter-search-btn"
onClick={() => openSearchPanel(view)}
/>
)}
{renderCopilot && renderCopilot()}
</div>
);
};

View file

@ -20,10 +20,12 @@ import { PreviewBox } from './PreviewBox';
import { removeNestedDoubleCurlyBraces } from '@/_helpers/utils';
import useStore from '@/AppBuilder/_stores/store';
import { shallow } from 'zustand/shallow';
import { syntaxTree } from '@codemirror/language';
import { search, searchKeymap, searchPanelOpen } from '@codemirror/search';
import { handleSearchPanel, SearchBtn } from './SearchBox';
import { handleSearchPanel } from './SearchBox';
import { useQueryPanelKeyHooks } from './useQueryPanelKeyHooks';
import { isInsideParent } from './utils';
import { CodeHinterBtns } from './CodehinterOverlayTriggers';
const langSupport = Object.freeze({
javascript: javascript(),
@ -66,7 +68,7 @@ const MultiLineCodeEditor = (props) => {
const context = useContext(CodeHinterContext);
const { suggestionList } = createReferencesLookup(context, true);
const { suggestionList: paramList } = createReferencesLookup(context, true);
const currentValueRef = useRef(initialValue);
@ -74,6 +76,7 @@ const MultiLineCodeEditor = (props) => {
const [editorView, setEditorView] = React.useState(null);
const [isSearchPanelOpen, setIsSearchPanelOpen] = React.useState(false);
const { queryPanelKeybindings } = useQueryPanelKeyHooks(onChange, currentValueRef, 'multiline');
const handleOnBlur = () => {
@ -146,8 +149,29 @@ const MultiLineCodeEditor = (props) => {
return suggestion.hint.includes(nearestSubstring);
});
const localVariables = new Set();
// Traverse the syntax tree to extract variable declarations
syntaxTree(context.state).iterate({
enter: (node) => {
// JavaScript: Detect variable declarations (var, let, const)
if (node.name === 'VariableDefinition') {
const varName = context.state.sliceDoc(node.from, node.to);
if (varName && varName.startsWith(nearestSubstring)) localVariables.add(varName);
}
},
});
// Convert Set to an array of completion suggestions
const localVariableSuggestions = [...localVariables].map((varName) => ({
hint: varName,
type: 'variable',
}));
const suggestionList = paramList.filter((paramSuggestion) => paramSuggestion.hint.includes(nearestSubstring));
const suggestions = generateHints(
[...JSLangHints, ...autoSuggestionList, ...suggestionList],
[...localVariableSuggestions, ...JSLangHints, ...autoSuggestionList, ...suggestionList],
null,
nearestSubstring
).map((hint) => {
@ -204,6 +228,7 @@ const MultiLineCodeEditor = (props) => {
return {
from: context.pos,
options: [...suggestions],
filter: false,
};
}
@ -237,7 +262,7 @@ const MultiLineCodeEditor = (props) => {
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const overRideFunction = React.useCallback((context) => autoCompleteExtensionConfig(context), []);
const overRideFunction = React.useCallback((context) => autoCompleteExtensionConfig(context), [paramList]);
const { handleTogglePopupExapand, isOpen, setIsOpen, forceUpdate } = portalProps;
let cyLabel = paramLabel ? paramLabel.toLowerCase().trim().replace(/\s+/g, '-') : props.cyLabel;
@ -258,7 +283,7 @@ const MultiLineCodeEditor = (props) => {
ref={wrapperRef}
>
<div className={`${className} ${darkMode && 'cm-codehinter-dark-themed'}`}>
<SearchBtn view={editorView} />
<CodeHinterBtns view={editorView} isPanelOpen={isSearchPanelOpen} renderCopilot={renderCopilot} />
<CodeHinter.PopupIcon
callback={handleTogglePopupExapand}
icon="portal-open"
@ -266,7 +291,6 @@ const MultiLineCodeEditor = (props) => {
isMultiEditor={true}
isQueryManager={isInsideQueryPane}
/>
{renderCopilot && renderCopilot()}
<CodeHinter.Portal
isCopilotEnabled={false}
@ -326,12 +350,7 @@ const MultiLineCodeEditor = (props) => {
readOnly={readOnly}
editable={editable} //for transformations in query manager
onCreateEditor={(view) => setEditorView(view)}
onUpdate={(view) => {
const icon = document.querySelector('.codehinter-search-btn');
if (searchPanelOpen(view.state)) {
icon.style.display = 'none';
} else icon.style.display = 'block';
}}
onUpdate={(view) => setIsSearchPanelOpen(searchPanelOpen(view.state))}
/>
</div>
{showPreview && (

View file

@ -9,7 +9,6 @@ import {
findPrevious,
replaceNext,
replaceAll,
openSearchPanel,
} from '@codemirror/search';
import './SearchBox.scss';
import InputComponent from '@/components/ui/Input/Index.jsx';
@ -162,22 +161,3 @@ function SearchPanel({ view }) {
</div>
);
}
export const SearchBtn = ({ view }) => {
return (
<div
className="d-flex justify-content-end w-100 position-absolute tw-pt-[3px] tw-pr-[4px] codehinter-search-btn-wrapper"
style={{ top: 0 }}
>
<ButtonComponent
iconOnly
trailingIcon="search01"
size="small"
variant="outline"
ariaLabel="Open search panel"
className="codehinter-search-btn"
onClick={() => openSearchPanel(view)}
/>
</div>
);
};

View file

@ -44,7 +44,5 @@
}
.code-hinter-wrapper .codehinter-search-btn {
display: block;
padding-top: 1px;
z-index: 10000;
z-index: 1000;
}

View file

@ -1,12 +1,18 @@
/* eslint-disable import/no-unresolved */
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState, useContext } from 'react';
import { PreviewBox } from './PreviewBox';
import { ToolTip } from '@/Editor/Inspector/Elements/Components/ToolTip';
import { useTranslation } from 'react-i18next';
import { camelCase, isEmpty, noop, get } from 'lodash';
import CodeMirror from '@uiw/react-codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { autocompletion, completionKeymap, completionStatus, acceptCompletion } from '@codemirror/autocomplete';
import {
autocompletion,
completionKeymap,
completionStatus,
acceptCompletion,
startCompletion,
} from '@codemirror/autocomplete';
import { defaultKeymap } from '@codemirror/commands';
import { keymap } from '@codemirror/view';
import FxButton from '../CodeBuilder/Elements/FxButton';
@ -22,6 +28,8 @@ import CodeHinter from './CodeHinter';
import { removeNestedDoubleCurlyBraces } from '@/_helpers/utils';
import useStore from '@/AppBuilder/_stores/store';
import { shallow } from 'zustand/shallow';
import { CodeHinterContext } from '../CodeBuilder/CodeHinterContext';
import { createReferencesLookup } from '@/_stores/utils';
import { useQueryPanelKeyHooks } from './useQueryPanelKeyHooks';
const SingleLineCodeEditor = ({ componentName, fieldMeta = {}, componentId, ...restProps }) => {
@ -73,6 +81,7 @@ const SingleLineCodeEditor = ({ componentName, fieldMeta = {}, componentId, ...r
if (typeof initialValue === 'string' && (initialValue?.includes('components') || initialValue?.includes('queries'))) {
newInitialValue = replaceIdsWithName(initialValue);
}
//! Re render the component when the componentName changes as the initialValue is not updated
// const { variablesExposedForPreview } = useContext(EditorContext) || {};
@ -199,9 +208,14 @@ const EditorInput = ({
wrapperRef,
showSuggestions,
}) => {
const getServerSideGlobalSuggestions = useStore((state) => state.getServerSideGlobalSuggestions, shallow);
const codeHinterContext = useContext(CodeHinterContext);
const { suggestionList: paramHints } = createReferencesLookup(codeHinterContext, true);
const getSuggestions = useStore((state) => state.getSuggestions, shallow);
const [codeMirrorView, setCodeMirrorView] = useState(undefined);
const getServerSideGlobalSuggestions = useStore((state) => state.getServerSideGlobalSuggestions, shallow);
const { queryPanelKeybindings } = useQueryPanelKeyHooks(onBlurUpdate, currentValue, 'singleline');
const isInsideQueryManager = useMemo(
@ -209,16 +223,16 @@ const EditorInput = ({
[wrapperRef.current]
);
function autoCompleteExtensionConfig(context) {
const hints = getSuggestions();
const hintsWithoutParamHints = getSuggestions();
const serverHints = getServerSideGlobalSuggestions(isInsideQueryManager);
const allHints = {
...hints,
appHints: [...hints.appHints, ...serverHints],
};
let word = context.matchBefore(/\w*/);
const hints = {
...hintsWithoutParamHints,
appHints: [...hintsWithoutParamHints.appHints, ...serverHints, ...paramHints],
};
const totalReferences = (context.state.doc.toString().match(/{{/g) || []).length;
let queryInput = context.state.doc.toString();
@ -247,17 +261,18 @@ const EditorInput = ({
queryInput = '{{' + currentWord + '}}';
}
let completions = getAutocompletion(queryInput, validationType, allHints, totalReferences, originalQueryInput);
let completions = getAutocompletion(queryInput, validationType, hints, totalReferences, originalQueryInput);
return {
from: word.from,
options: completions,
validFor: /^\{\{.*\}\}$/,
filter: false,
};
}
// eslint-disable-next-line react-hooks/exhaustive-deps
const overRideFunction = React.useCallback((context) => autoCompleteExtensionConfig(context), [isInsideQueryManager]);
const overRideFunction = React.useCallback((context) => autoCompleteExtensionConfig(context), [isInsideQueryManager, paramHints]);
const autoCompleteConfig = autocompletion({
override: [overRideFunction],
@ -424,6 +439,9 @@ const EditorInput = ({
ref={previewRef}
>
<CodeMirror
onCreateEditor={(view) => {
setCodeMirrorView(view);
}}
value={currentValue}
placeholder={placeholder}
height={isInsideQueryPane ? '100%' : showLineNumbers ? '400px' : '100%'}
@ -460,11 +478,16 @@ const EditorInput = ({
theme={theme}
indentWithTab={false}
readOnly={disabled}
onKeyDown={(event) => {
if (event.key === 'Backspace') {
startCompletion(codeMirrorView);
}
}}
/>
</div>
</ErrorBoundary>
</CodeHinter.Portal>
</div>
</ErrorBoundary >
</CodeHinter.Portal >
</div >
);
};

View file

@ -67,7 +67,8 @@ export const getAutocompletion = (input, fieldType, hints, totalReferences = 1,
originalQueryInput,
searchInput
);
return orderSuggestions(suggestions, fieldType);
return suggestions;
};
function orderSuggestions(suggestions, validationType) {
@ -90,10 +91,18 @@ export const generateHints = (hints, totalReferences = 1, input, searchText) =>
const hasDepth = currentWord.includes('.');
const lastDepth = getLastSubstring(currentWord);
const displayLabel = getLastDepth(displayedHint);
let displayLabel = getLastDepth(displayedHint);
if (type != 'js_method') {
const currentWordDepth = currentWord.split('.').length;
displayLabel = hint
.split('.')
.slice(currentWordDepth - 1)
.join('.');
}
return {
displayLabel: lastDepth === '' ? displayedHint : displayLabel,
displayLabel,
label: displayedHint,
info: displayedHint,
type: type === 'js_method' ? 'js_methods' : type?.toLowerCase(),
@ -154,40 +163,24 @@ export const generateHints = (hints, totalReferences = 1, input, searchText) =>
};
function filterHintsByDepth(input, hints) {
if (input === '') return hints;
const inputParts = input.split('.');
const inputDepth = inputParts.length + 1;
const inputDepth = input.includes('.') ? input.split('.').length : 0;
const filteredHints = hints.filter((cm) => {
const hintParts = cm.hint.split('.');
let shouldInclude =
(cm.hint.startsWith(input) && hintParts.length === inputDepth + 1) ||
(cm.hint.startsWith(input) && hintParts.length === inputDepth);
const shouldFuzzyMatch = !shouldInclude ? hintParts.length > inputDepth : false;
if (shouldFuzzyMatch) {
// fuzzy match
let matchedDepth = -1;
for (let i = 0; i < hintParts.length; i++) {
if (hintParts[i].includes(input)) {
matchedDepth = i;
break;
}
}
if (matchedDepth !== -1) {
shouldInclude = hintParts.length === matchedDepth + 1;
}
} else if (input.endsWith('.')) {
shouldInclude = cm.hint.startsWith(input) && hintParts.length === inputDepth;
}
return shouldInclude;
const hintsWithDepth = hints.map((hint) => {
const hintParts = hint.hint.split('.');
return {
...hint,
depth: hintParts.length,
};
});
return filteredHints;
const filteredHints = hintsWithDepth.filter((hint) => {
return hint.depth <= inputDepth;
});
const sortedHints = filteredHints.sort((hint1, hint2) => hint1.depth - hint2.depth);
return sortedHints;
}
export function findNearestSubstring(inputStr, currentCurosorPos) {

View file

@ -1,8 +1,20 @@
import React from 'react';
import React, { useState } from 'react';
import Select from '@/_ui/Select';
import { decodeEntities } from '@/_helpers/utils';
import usePopoverObserver from '@/AppBuilder/_hooks/usePopoverObserver';
export const ChangeDataSource = ({ dataSources, onChange, value, isVersionReleased }) => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
usePopoverObserver(
document.getElementsByClassName('query-details')[0],
document.querySelector('.change-data-source-select.react-select__control'),
document.querySelector('.change-data-source-select.react-select__menu'),
isMenuOpen,
() => (document.querySelector('.change-data-source-select.react-select__menu').style.display = 'block'),
() => (document.querySelector('.change-data-source-select.react-select__menu').style.display = 'none')
);
return (
<Select
className="w-100"
@ -14,6 +26,13 @@ export const ChangeDataSource = ({ dataSources, onChange, value, isVersionReleas
}}
useMenuPortal={true}
isDisabled={isVersionReleased}
customClassPrefix="change-data-source-select"
onMenuOpen={() => {
setIsMenuOpen(true);
}}
onMenuClose={() => {
setIsMenuOpen(false);
}}
/>
);
};

View file

@ -31,6 +31,9 @@ class Restapi extends React.Component {
codeHinterHeight: 32, // Default height
};
this.codeHinterRef = React.createRef();
this.isMenuOpenRef = React.createRef();
this.prevIsMenuOpenRef = React.createRef(false);
this.intersectionObserver = null;
this.resizeObserver = null;
}
@ -47,6 +50,9 @@ class Restapi extends React.Component {
if (this.codeHinterRef.current && !this.resizeObserver) {
this.setupResizeObserver();
}
if (!this.intersectionObserver) {
this.setupIntersectionObserver();
}
}
componentDidMount() {
@ -75,6 +81,7 @@ class Restapi extends React.Component {
}, 1000);
this.setupResizeObserver();
this.setupIntersectionObserver();
} catch (error) {
console.log(error);
}
@ -84,6 +91,9 @@ class Restapi extends React.Component {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
if (this.intersectionObserver) {
this.intersectionObserver.disconnect();
}
}
setupResizeObserver() {
@ -132,6 +142,33 @@ class Restapi extends React.Component {
this.resizeObserver.observe(element);
}
setupIntersectionObserver() {
const container = document.getElementsByClassName('query-details')[0];
const trigger = document.querySelector('.restapi-method-select.react-select__control');
if (this.intersectionObserver) {
this.intersectionObserver.disconnect();
}
this.intersectionObserver = new IntersectionObserver(
([entry]) => {
const popover = document.querySelector('.restapi-method-select.react-select__menu');
if (entry.isIntersecting) {
if (this.prevIsMenuOpenRef.current) {
popover.style.display = 'block';
this.prevIsMenuOpenRef.current = false;
}
} else if (this.isMenuOpenRef.current) {
popover.style.display = 'none';
this.prevIsMenuOpenRef.current = true;
}
},
{ root: container, threshold: [0.5] }
);
this.intersectionObserver.observe(trigger);
}
initizalizeRetryNetworkErrorsToggle = () => {
const isRetryNetworkErrorToggleUnused = this.props.options.retry_network_errors === null;
if (isRetryNetworkErrorToggleUnused) {
@ -287,6 +324,13 @@ class Restapi extends React.Component {
height={32}
styles={this.customSelectStyles(this.props.darkMode, 91)}
useCustomStyles={true}
customClassPrefix="restapi-method-select"
onMenuOpen={() => {
this.isMenuOpenRef.current = true;
}}
onMenuClose={() => {
this.isMenuOpenRef.current = false;
}}
/>
</div>
<div

View file

@ -5,14 +5,25 @@ import CodeHinter from '@/AppBuilder/CodeEditor';
import './workflows-query.scss';
import { v4 as uuidv4 } from 'uuid';
import useStore from '@/AppBuilder/_stores/store';
import usePopoverObserver from '@/AppBuilder/_hooks/usePopoverObserver';
export function Workflows({ options, optionsChanged, currentState }) {
const [workflowOptions, setWorkflowOptions] = useState([]);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [_selectedWorkflowId, setSelectedWorkflowId] = useState(undefined);
const [params, setParams] = useState([...(options.params ?? [{ key: '', value: '' }])]);
const appId = useStore((state) => state.app.appId);
usePopoverObserver(
document.getElementsByClassName('query-details')[0],
document.querySelector('.workflow-select.react-select__control'),
document.querySelector('.workflow-select.react-select__menu'),
isMenuOpen,
() => (document.querySelector('.workflow-select.react-select__menu').style.display = 'block'),
() => (document.querySelector('.workflow-select.react-select__menu').style.display = 'none')
);
useEffect(() => {
appsService.getWorkflows(appId).then(({ workflows }) => {
setWorkflowOptions(
@ -50,6 +61,13 @@ export function Workflows({ options, optionsChanged, currentState }) {
customWrap={true}
width="300px"
menuPlacement="bottom"
customClassPrefix="workflow-select"
onMenuOpen={() => {
setIsMenuOpen(true);
}}
onMenuClose={() => {
setIsMenuOpen(false);
}}
/>
<label className="my-2">Params</label>
<div className="grid"></div>

View file

@ -0,0 +1,538 @@
import React, { useState, useEffect } from 'react';
import Accordion from '@/_ui/Accordion';
import { EventManager } from '../EventManager';
import { renderElement } from '../Utils';
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
import Popover from 'react-bootstrap/Popover';
import List from '@/ToolJetUI/List/List';
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd';
import useStore from '@/AppBuilder/_stores/store';
import CodeHinter from '@/AppBuilder/CodeEditor';
import AddNewButton from '@/ToolJetUI/Buttons/AddNewButton/AddNewButton';
import ListGroup from 'react-bootstrap/ListGroup';
import { ButtonSolid } from '@/_ui/AppButton/AppButton';
import SortableList from '@/_components/SortableList';
import Trash from '@/_ui/Icon/solidIcons/Trash';
import { shallow } from 'zustand/shallow';
import Switch from '@/Editor/CodeBuilder/Elements/Switch';
import { usePrevious } from '@dnd-kit/utilities';
export function Steps({ componentMeta, darkMode, ...restProps }) {
const {
layoutPropertyChanged,
component,
dataQueries,
paramUpdated,
currentState,
eventsChanged,
apps,
allComponents,
pages,
} = restProps;
const getResolvedValue = useStore((state) => state.getResolvedValue, shallow);
const isDynamicOptionsEnabled = getResolvedValue(component?.component?.definition?.properties?.advanced?.value);
const variant = component?.component?.definition?.properties?.variant?.value;
const prevVariant = usePrevious(variant)
console.log("variant", component?.component?.definition);
const [options, setOptions] = useState([]);
const [hoveredOptionIndex, setHoveredOptionIndex] = useState(null);
let properties = [];
let additionalActions = [];
let optionsProperties = [];
for (const [key] of Object.entries(componentMeta?.properties)) {
if (componentMeta?.properties[key]?.section === 'additionalActions') {
additionalActions.push(key);
} else if (componentMeta?.properties[key]?.accordian === 'Options') {
optionsProperties.push(key);
} else {
properties.push(key);
}
}
// the default style of "number" & "titles" type are different for completed label
// TODO: Need to revisit this logic when text custom themes are implemented
useEffect(() => {
const completedLabelColor = component?.component?.definition?.styles?.completedLabel?.value;
if (variant !== prevVariant) {
if (variant === "numbers" && completedLabelColor === "#1B1F24") {
paramUpdated({ name: 'completedLabel' }, 'value', "#FFFFFF", 'styles', false, {});
} else if (variant === "titles" && completedLabelColor === "#FFFFFF") {
paramUpdated({ name: 'completedLabel' }, 'value', "#1B1F24", 'styles', false, {});
}
}
}, [variant])
const getItemStyle = (isDragging, draggableStyle) => ({
userSelect: 'none',
...draggableStyle,
});
const updateAllOptionsParams = (options, props) => {
paramUpdated({ name: 'steps' }, 'value', options, 'properties', false, props);
};
const generateNewOptions = () => {
let found = false;
let label = '';
let currentNumber = options.length + 1;
while (!found) {
label = `step ${currentNumber}`;
if (options.find((option) => option.name === label) === undefined) {
found = true;
}
currentNumber += 1;
}
return {
name: label,
id: currentNumber - 1,
tooltip: label,
visible: { value: '{{true}}' },
disabled: { value: '{{false}}' },
};
};
const handleAddOption = () => {
let _option = generateNewOptions();
const _items = [...options, _option];
setOptions(_items);
updateAllOptionsParams(_items);
};
const handleDeleteOption = (index) => {
const _items = options.filter((option, i) => i !== index);
setOptions(_items);
updateAllOptionsParams(_items, { isParamFromDropdownOptions: true });
};
const handleLabelChange = (propertyName, value, index) => {
const _options = options.map((option, i) => {
if (i === index) {
return {
...option,
[propertyName]: value,
};
}
return option;
});
setOptions(_options);
updateAllOptionsParams(_options);
};
const reorderOptions = async (startIndex, endIndex) => {
const result = [...options];
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
setOptions(result);
updateAllOptionsParams(result);
};
const onDragEnd = ({ source, destination }) => {
if (!destination || source?.index === destination?.index) {
return;
}
reorderOptions(source.index, destination.index);
};
const handleOnFxPress = (active, index, key) => {
const _options = options.map((option, i) => {
if (i === index) {
return {
...option,
[key]: {
...option[key],
fxActive: active,
},
};
}
return option;
});
setOptions(_options);
updateAllOptionsParams(_options);
};
const _renderOverlay = (item, index) => {
return (
<Popover className={`${darkMode && 'dark-theme theme-dark'}`} style={{ minWidth: '248px' }}>
<Popover.Body>
<div className="field mb-3" data-cy={`input-and-label-column-name`}>
<label data-cy={`label-column-name`} className="font-weight-500 mb-1 font-size-12">
{'Id'}
</label>
<CodeHinter
type={'basic'}
initialValue={item?.id + ''}
theme={darkMode ? 'monokai' : 'default'}
mode="javascript"
lineNumbers={false}
placeholder={'Option label'}
onChange={(value) => handleLabelChange('id', value, index)}
/>
</div>
<div className="field mb-3" data-cy={`input-and-label-column-name`}>
<label data-cy={`label-column-name`} className="font-weight-500 mb-1 font-size-12">
{'Label'}
</label>
<CodeHinter
type={'basic'}
initialValue={item?.name}
theme={darkMode ? 'monokai' : 'default'}
mode="javascript"
lineNumbers={false}
placeholder={'Option label'}
onChange={(value) => handleLabelChange('name', value, index)}
/>
</div>
<div className="field mb-3" data-cy={`input-and-label-column-name`}>
<label data-cy={`label-column-name`} className="font-weight-500 mb-1 font-size-12">
{'Tooltip'}
</label>
<CodeHinter
type={'basic'}
initialValue={item?.tooltip + ''}
theme={darkMode ? 'monokai' : 'default'}
mode="javascript"
lineNumbers={false}
placeholder={'Tooltip'}
onChange={(value) => handleLabelChange('tooltip', value, index)}
/>
</div>
<div className="field mb-2" data-cy={`input-and-label-column-name`}>
<CodeHinter
initialValue={item?.visible?.value}
theme={darkMode ? 'monokai' : 'default'}
mode="javascript"
lineNumbers={false}
component={component}
type={'fxEditor'}
paramLabel={'Visibility'}
onChange={(value) =>
handleLabelChange(
'visible',
{
value,
},
index
)
}
paramName={'visible'}
onFxPress={(active) => handleOnFxPress(active, index, 'visible')}
fxActive={item?.visible?.fxActive}
fieldMeta={{
type: 'toggle',
displayName: 'Make editable',
}}
paramType={'toggle'}
/>
</div>
<div className="field" data-cy={`input-and-label-column-name`}>
<CodeHinter
initialValue={item?.disabled?.value}
theme={darkMode ? 'monokai' : 'default'}
mode="javascript"
lineNumbers={false}
component={component}
type={'fxEditor'}
paramLabel={'Disable'}
paramName={'disable'}
onChange={(value) => handleLabelChange('disabled', { value }, index)}
onFxPress={(active) => handleOnFxPress(active, index, 'disabled')}
fxActive={item?.disabled?.fxActive}
fieldMeta={{
type: 'toggle',
displayName: 'Make editable',
}}
paramType={'toggle'}
/>
</div>
</Popover.Body>
</Popover>
);
};
const _renderOptions = () => {
return (
<List style={{ marginBottom: '20px' }}>
<DragDropContext
onDragEnd={(result) => {
onDragEnd(result);
}}
>
<Droppable droppableId="droppable">
{({ innerRef, droppableProps, placeholder }) => (
<div className="w-100" {...droppableProps} ref={innerRef}>
{options?.map((item, index) => {
return (
<Draggable key={item.name} draggableId={item.name} index={index}>
{(provided, snapshot) => (
<div
key={index}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={getItemStyle(snapshot.isDragging, provided.draggableProps.style)}
>
<OverlayTrigger
trigger="click"
placement="left"
rootClose
overlay={_renderOverlay(item, index)}
>
<div key={item.name + item.id}>
<ListGroup.Item
style={{ marginBottom: '8px', backgroundColor: 'var(--slate3)' }}
onMouseEnter={() => setHoveredOptionIndex(index)}
onMouseLeave={() => setHoveredOptionIndex(null)}
{...restProps}
>
<div className="row">
<div className="col-auto d-flex align-items-center">
<SortableList.DragHandle show />
</div>
<div className="col text-truncate cursor-pointer" style={{ padding: '0px' }}>
{getResolvedValue(item.name)}
</div>
<div className="col-auto">
{index === hoveredOptionIndex && (
<ButtonSolid
variant="danger"
size="xs"
className={'delete-icon-btn'}
onClick={(e) => {
e.stopPropagation();
handleDeleteOption(index);
}}
>
<span className="d-flex">
<Trash fill={'var(--tomato9)'} width={12} />
</span>
</ButtonSolid>
)}
</div>
</div>
</ListGroup.Item>
</div>
</OverlayTrigger>
</div>
)}
</Draggable>
);
})}
{placeholder}
</div>
)}
</Droppable>
</DragDropContext>
<AddNewButton onClick={handleAddOption} dataCy="add-new-dropdown-option" className="mt-0">
Add new option
</AddNewButton>
</List>
);
};
const isDynamicStepsEnabled = getResolvedValue(component?.component?.definition?.properties?.advanced?.value);
useEffect(() => {
setOptions(constructSteps());
}, [component?.id, isDynamicStepsEnabled]);
const constructSteps = () => {
try {
let optionsValue = isDynamicOptionsEnabled
? component?.component?.definition?.properties?.schema?.value
: component?.component?.definition?.properties?.steps?.value;
let options = [];
if (isDynamicOptionsEnabled || typeof optionsValue === 'string') {
options = getResolvedValue(optionsValue);
} else {
options = optionsValue?.map((option) => option);
}
return options.map((option) => {
const newOption = { ...option };
Object.keys(option).forEach((key) => {
if (typeof option[key]?.value === 'boolean') {
newOption[key]['value'] = `{{${option[key]?.value}}}`;
}
});
if (!('visible' in newOption)) {
newOption['visible'] = { value: '{{true}}' };
}
return newOption;
});
} catch (error) {
return [];
}
};
let items = [];
items.push({
title: 'Steps',
isOpen: true,
children: (
<>
{properties
.filter((property) => !optionsProperties.includes(property))
?.map((property) => {
if (property === 'steps') {
return (
<>
{renderElement(
component,
componentMeta,
paramUpdated,
dataQueries,
'advanced',
'properties',
currentState,
allComponents
)}
{isDynamicStepsEnabled
? renderElement(
component,
componentMeta,
paramUpdated,
dataQueries,
'schema',
'properties',
currentState,
allComponents
)
: _renderOptions()}
</>
);
}
// else if (property === 'variant') {
// return renderTest(
// component,
// componentMeta,
// paramUpdated,
// dataQueries,
// 'variant',
// 'properties',
// currentState,
// allComponents,
// handleLabelChange
// );
// }
return renderElement(
component,
componentMeta,
paramUpdated,
dataQueries,
property,
'properties',
currentState,
allComponents,
darkMode
);
})}
</>
),
});
items.push({
title: 'Events',
isOpen: true,
children: (
<EventManager
sourceId={component?.id}
eventSourceType="component"
eventMetaDefinition={componentMeta}
dataQueries={dataQueries}
components={allComponents}
eventsChanged={eventsChanged}
apps={apps}
darkMode={darkMode}
pages={pages}
/>
),
});
items.push({
title: `Additional Actions`,
isOpen: true,
children: additionalActions.map((property) => {
return renderElement(
component,
componentMeta,
paramUpdated,
dataQueries,
property,
'properties',
currentState,
allComponents,
darkMode,
componentMeta.properties?.[property]?.placeholder
);
}),
});
items.push({
title: 'Devices',
isOpen: true,
children: (
<>
{renderElement(
component,
componentMeta,
layoutPropertyChanged,
dataQueries,
'showOnDesktop',
'others',
currentState,
allComponents
)}
{renderElement(
component,
componentMeta,
layoutPropertyChanged,
dataQueries,
'showOnMobile',
'others',
currentState,
allComponents
)}
</>
),
});
return <Accordion items={items} />;
}
function renderTest(...props) {
const [
component,
componentMeta,
paramUpdated,
dataQueries,
param,
paramType,
currentState,
components = {},
darkMode = false,
placeholder = '',
validationFn,
] = props;
const value = componentMeta?.definition?.properties?.variant?.value;
return (
<div style={{ marginBottom: 8 }}>
<Switch
value={value}
onChange={(e) => {
paramUpdated({ name: 'variant' }, 'value', e, 'properties', false, props);
}}
meta={{
...componentMeta.properties[param],
fullWidth: true,
}}
paramName={param}
isIcon={false}
component={component.component.definition.name}
/>
</div>
);
}

View file

@ -255,7 +255,7 @@ export const PropertiesTabElements = ({
paramType="properties"
/>
</div>
{resolveReferences(column?.isEditable) && (
{(column?.fxActiveFields?.includes('isEditable') || resolveReferences(column?.isEditable)) && (
<ValidationProperties
column={column}
index={index}

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useContext } from 'react';
import React, { useState, useEffect, useContext, useRef } from 'react';
import { ActionTypes } from '@/Editor/ActionTypes';
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
@ -32,6 +32,9 @@ import useStore from '@/AppBuilder/_stores/store';
import { useEventActions, useEvents } from '@/AppBuilder/_stores/slices/eventsSlice';
import ToggleGroup from '@/ToolJetUI/SwitchGroup/ToggleGroup';
import ToggleGroupItem from '@/ToolJetUI/SwitchGroup/ToggleGroupItem';
import usePopoverObserver from '@/AppBuilder/_hooks/usePopoverObserver';
import SolidIcon from '@/_ui/Icon/SolidIcons';
import { components as selectComponents } from 'react-select';
export const EventManager = ({
sourceId,
@ -82,6 +85,8 @@ export const EventManager = ({
const [events, setEvents] = useState([]);
const [focusedEventIndex, setFocusedEventIndex] = useState(null);
const lastFocusedEventIndex = useRef(null);
const shouldSkipOnToggle = useRef(null);
const { t } = useTranslation();
@ -101,8 +106,23 @@ export const EventManager = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(currentEvents)]);
let actionOptions = ActionTypes.map((action) => {
return { name: action.name, value: action.id };
let groupedOptions = ActionTypes.reduce((acc, action) => {
const groupName = action.group;
if (!acc[groupName]) {
acc[groupName] = [];
}
acc[groupName].push({
label: action.name,
value: action.id,
});
return acc;
}, {});
let actionOptions = Object.keys(groupedOptions).map((groupName) => {
return { label: groupName, options: groupedOptions[groupName] };
});
let checkIfClicksAreInsideOf = document.querySelector('.cm-completionListIncompleteBottom');
@ -124,6 +144,46 @@ export const EventManager = ({
}),
};
const actionStyles = {
...styles,
menuList: (base) => ({
...base,
padding: '8px 0 8px 8px',
'&::-webkit-scrollbar': {
width: '10px',
},
'&::-webkit-scrollbar-track': {
background: 'transparent',
},
'&::-webkit-scrollbar-thumb': {
background: '#E4E7EB',
border: '1px solid transparent',
backgroundClip: 'content-box',
},
'&::-webkit-scrollbar-thumb:hover': {
background: '#E4E7EB !important',
border: '1px solid transparent !important',
backgroundClip: 'content-box !important',
},
'&:hover': {
'&::-webkit-scrollbar-thumb': {
background: '#E4E7EB !important',
border: '1px solid transparent !important',
backgroundClip: 'content-box !important',
},
},
}),
group: (base) => ({
...base,
padding: 0,
}),
groupHeading: (base) => ({
...base,
margin: 0,
padding: '0',
}),
};
const actionLookup = Object.fromEntries(ActionTypes.map((actionType) => [actionType.id, actionType]));
let alertTypes = [
@ -394,6 +454,29 @@ export const EventManager = ({
return defaultValue;
};
const formatGroupLabel = (data) => {
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]"
style={{ borderColor: 'var(--border-weak)' }}
></div>
);
};
const CustomOption = (props) => {
return (
<selectComponents.Option {...props}>
<div className="d-flex align-items-center">
<div style={{ width: '16px', marginRight: '6px' }}>
{props.isSelected && <SolidIcon name="tickv3" width="16px" height="16px" />}
</div>
<span>{props.label}</span>
</div>
</selectComponents.Option>
);
};
function eventPopover(event, index) {
return (
<Popover
@ -433,13 +516,17 @@ export const EventManager = ({
<Select
className={`${darkMode ? 'select-search-dark' : 'select-search'} w-100`}
options={actionOptions}
value={event.actionId}
value={actionOptions
.flatMap((group) => group.options)
.find((option) => option.value === event.actionId)}
components={{ Option: CustomOption }}
search={false}
onChange={(value) => handlerChanged(index, 'actionId', value)}
placeholder={t('globals.select', 'Select') + '...'}
styles={styles}
styles={actionStyles}
useMenuPortal={false}
useCustomStyles={true}
formatGroupLabel={formatGroupLabel}
/>
</div>
</div>
@ -1006,10 +1093,21 @@ export const EventManager = ({
placement={popoverPlacement || 'left'}
rootClose={true}
overlay={eventPopover(event.event, index)}
onHide={() => setFocusedEventIndex(null)}
onToggle={(showing) => {
// If the toggle action should be skipped (e.g., due to a previous state change), reset the flag and exit early.
if (shouldSkipOnToggle.current) {
shouldSkipOnToggle.current = false;
return;
}
// If there is already a focused event, set the skip flag to prevent unnecessary state updates.
if (focusedEventIndex !== null && showing) {
shouldSkipOnToggle.current = true;
}
if (showing) {
setFocusedEventIndex(index);
lastFocusedEventIndex.current = index;
} else {
setFocusedEventIndex(null);
}
@ -1018,6 +1116,7 @@ export const EventManager = ({
>
<div
key={index}
id={`${sourceId}-${index}`}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
@ -1061,6 +1160,17 @@ export const EventManager = ({
);
};
const shouldUsePopoverObserver = events.length !== 0 && eventSourceType === 'data_query';
usePopoverObserver(
shouldUsePopoverObserver ? document.getElementsByClassName('query-details')[0] : null,
document.getElementById(`${sourceId}-${lastFocusedEventIndex.current}`),
document.getElementById('popover-basic'),
focusedEventIndex !== null,
() => (document.getElementById('popover-basic').style.display = 'block'),
() => (document.getElementById('popover-basic').style.display = 'none')
);
if (events.length === 0) {
return (
<>

View file

@ -36,6 +36,7 @@ import Inspect from '@/_ui/Icon/solidIcons/Inspect';
import classNames from 'classnames';
import { EMPTY_ARRAY } from '@/_stores/editorStore';
import { Select } from './Components/Select';
import { Steps } from './Components/Steps.jsx';
import { deepClone } from '@/_helpers/utilities/utils.helpers';
import useStore from '@/AppBuilder/_stores/store';
// import { componentTypes } from '@/Editor/WidgetManager/components';
@ -90,6 +91,7 @@ const NEW_REVAMPED_COMPONENTS = [
'VerticalDivider',
'ModalV2',
'Link',
'Steps',
];
export const Inspector = ({ componentDefinitionChanged, darkMode, pages, selectedComponentId }) => {
@ -539,8 +541,8 @@ export const Inspector = ({ componentDefinitionChanged, darkMode, pages, selecte
componentMeta.displayName === 'Toggle Switch (Legacy)'
? 'Toggle (Legacy)'
: componentMeta.displayName === 'Toggle Switch'
? 'Toggle Switch'
: componentMeta.component,
? 'Toggle Switch'
: componentMeta.component,
})}
</small>
</span>
@ -740,6 +742,8 @@ const GetAccordion = React.memo(
case 'DatePickerV2':
case 'TimePicker':
return <DatetimePickerV2 {...restProps} componentName={componentName} />;
case 'Steps':
return <Steps {...restProps} />;
case 'PhoneInput':
return <PhoneInput {...restProps} />;
case 'CurrencyInput':

View file

@ -14,6 +14,9 @@ const NEW_WIDGETS = [
'TimePicker',
'ModalV2',
'TextArea',
'EmailInput',
'PhoneInput',
'CurrencyInput',
];
export const WidgetBox = ({ component, darkMode }) => {

View file

@ -168,6 +168,7 @@ export const Viewer = ({ id: appId, darkMode, moduleId = 'canvas', switchDarkMod
showViewerNavigation={!isPagesSidebarHidden}
handleAppEnvironmentChanged={handleAppEnvironmentChanged}
changeToDarkMode={changeToDarkMode}
switchPage={switchPage}
/>
)}
<div className="sub-section">

View file

@ -3,8 +3,8 @@ export const containerConfig = {
displayName: 'Container',
description: 'Group components',
defaultSize: {
width: 10,
height: 200,
width: 13,
height: 480,
},
component: 'Container',
others: {

View file

@ -4,25 +4,38 @@ export const stepsConfig = {
description: 'Step-by-step navigation aid',
component: 'Steps',
properties: {
variant: {
type: 'switch',
displayName: 'Variant',
validation: { schema: { type: 'string' }, defaultValue: 'titles' },
options: [
{ displayName: 'Label', value: 'titles' },
{ displayName: 'Number', value: 'numbers' },
{ displayName: 'Plain', value: 'plain' },
],
accordian: 'label',
},
schema: {
type: 'code',
displayName: 'Schema',
conditionallyRender: {
key: 'advanced',
value: true,
},
accordian: 'Options',
},
steps: {
type: 'code',
displayName: 'Steps',
displayName: '',
showLabel: false,
validation: {
schema: {
type: 'array',
element: { type: 'object', object: { id: { type: 'number' } } },
element: { type: 'object' },
},
defaultValue: `[{ name: 'step 1'}, {name: 'step 2'}]`,
},
},
currentStep: {
type: 'code',
displayName: 'Current step',
validation: {
schema: { type: 'number' },
defaultValue: 1,
},
},
stepsSelectable: {
type: 'toggle',
displayName: 'Steps selectable',
@ -30,7 +43,38 @@ export const stepsConfig = {
schema: { type: 'boolean' },
defaultValue: false,
},
section: 'additionalActions',
},
disabledState: {
type: 'toggle',
displayName: 'Disable',
validation: { schema: { type: 'boolean' } },
section: 'additionalActions',
},
visibility: {
type: 'toggle',
displayName: 'Visibility',
validation: { schema: { type: 'boolean' }, defaultValue: true },
section: 'additionalActions',
},
advanced: {
type: 'toggle',
displayName: 'Dynamic options',
validation: {
schema: { type: 'boolean' },
defaultValue: true,
},
accordian: 'Options',
},
currentStep: {
type: 'code',
displayName: 'Current step',
validation: {
schema: { type: 'number' },
defaultValue: 1,
},
},
},
defaultSize: {
width: 22,
@ -40,46 +84,126 @@ export const stepsConfig = {
showOnDesktop: { type: 'toggle', displayName: 'Show on desktop' },
showOnMobile: { type: 'toggle', displayName: 'Show on mobile' },
},
actions: [
{
handle: 'setStep',
displayName: 'Set step',
params: [
{
handle: 'option',
displayName: 'Option',
},
],
},
{
handle: 'setVisibility',
displayName: 'Set visibility',
params: [{ handle: 'visible', displayName: 'Value', defaultValue: '{{false}}', type: 'toggle' }],
},
{
handle: 'setDisabled',
displayName: 'Set disabled',
params: [{ handle: 'disable', displayName: 'Value', defaultValue: '{{true}}', type: 'toggle' }],
},
{
handle: 'resetSteps',
displayName: 'Reset steps',
params: [],
},
{
handle: 'setStepVisible',
displayName: 'Set step visible',
params: [
{
handle: 'id',
displayName: 'Step id',
},
{
handle: 'visibility',
displayName: 'visibility',
defaultValue: '{{false}}',
type: 'toggle',
},
],
},
{
handle: 'setStepDisable',
displayName: 'Set step disable',
params: [
{
handle: 'id',
displayName: 'Step id',
},
{
handle: 'disabled',
displayName: 'disabled',
defaultValue: '{{true}}',
type: 'toggle',
},
],
},
],
events: {
onSelect: { displayName: 'On select' },
},
styles: {
color: {
incompletedAccent: {
type: 'colorSwatches',
displayName: 'colorSwatches',
displayName: 'Incompleted accent',
validation: {
schema: { type: 'string' },
defaultValue: '#CCD1D5',
},
accordian: 'steps',
},
incompletedLabel: {
type: 'colorSwatches',
displayName: 'Incompleted label',
validation: {
schema: { type: 'string' },
defaultValue: '#1B1F24',
},
accordian: 'steps',
},
completedAccent: {
type: 'colorSwatches',
displayName: 'Completed accent',
validation: {
schema: { type: 'string' },
defaultValue: 'var(--primary-brand)',
},
accordian: 'steps',
},
textColor: {
completedLabel: {
type: 'colorSwatches',
displayName: 'Text color',
displayName: 'Completed label',
validation: {
schema: { type: 'string' },
defaultValue: '#000000',
defaultValue: '#1B1F24',
},
accordian: 'steps',
},
theme: {
type: 'select',
displayName: 'Theme',
currentStepLabel: {
type: 'colorSwatches',
displayName: 'Current step label',
validation: {
schema: { type: 'string' },
defaultValue: '#1B1F24',
},
accordian: 'steps',
},
padding: {
type: 'switch',
displayName: 'Padding',
validation: {
schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] },
defaultValue: 'default',
},
options: [
{ name: 'titles', value: 'titles' },
{ name: 'numbers', value: 'numbers' },
{ name: 'plain', value: 'plain' },
{ displayName: 'Default', value: 'default' },
{ displayName: 'None', value: 'none' },
],
validation: {
schema: { type: 'string' },
defaultValue: 'titles',
},
},
visibility: {
type: 'toggle',
displayName: 'Visibility',
validation: {
schema: { type: 'boolean' },
defaultValue: true,
},
accordian: 'container',
},
},
exposedVariables: {
@ -92,17 +216,35 @@ export const stepsConfig = {
},
properties: {
steps: {
value: `{{ [{ name: 'step 1', tooltip: 'some tooltip', id: 1},{ name: 'step 2', tooltip: 'some tooltip', id: 2},{ name: 'step 3', tooltip: 'some tooltip', id: 3},{ name: 'step 4', tooltip: 'some tooltip', id: 4},{ name: 'step 5', tooltip: 'some tooltip', id: 5}]}}`,
value: [
{ name: 'step 1', tooltip: '', id: 1, visible: { value: true }, disabled: { value: false } },
{ name: 'step 2', tooltip: '', id: 2, visible: { value: true }, disabled: { value: false } },
{ name: 'step 3', tooltip: '', id: 3, visible: { value: true }, disabled: { value: false } },
{ name: 'step 4', tooltip: '', id: 4, visible: { value: true }, disabled: { value: false } },
{ name: 'step 5', tooltip: '', id: 5, visible: { value: true }, disabled: { value: false } },
],
},
schema: {
value: `{{ [{ name: 'step 1', tooltip: '', id: 1,visible: true, disabled: false},{ name: 'step 2', tooltip: '', id: 2,visible: true, disabled: false},{ name: 'step 3', tooltip: '', id: 3,visible: true, disabled: false},{ name: 'step 4', tooltip: '', id: 4,visible: true, disabled: false},{ name: 'step 5', tooltip: '', id: 5,visible: true, disabled: false}]}}`,
},
disabledState: { value: '{{false}}' },
variant: { value: 'titles' },
currentStep: { value: '{{3}}' },
stepsSelectable: { value: true },
advanced: { value: `{{false}}` },
visibility: { value: '{{true}}' },
},
events: [],
styles: {
visibility: { value: '{{true}}' },
theme: { value: 'titles' },
color: { value: 'var(--primary-brand)' },
textColor: { value: '' },
// color: { value: '' },
// textColor: { value: '' },
padding: { value: 'default' },
incompletedAccent: { value: '#E4E7EB' },
incompletedLabel: { value: '#1B1F24' },
completedAccent: { value: 'var(--primary-brand)' },
completedLabel: { value: '#1B1F24' },
currentStepLabel: { value: '#1B1F24' },
},
},
};

View file

@ -4,9 +4,9 @@ function usePopoverObserver(containerRef, triggerRef, popoverRef, show, onShow,
const prevShow = useRef(false);
// Check if it is a ref or a DOM element
const container = containerRef?.current ? containerRef.current : containerRef;
const trigger = triggerRef?.current ? triggerRef.current : triggerRef;
const popover = popoverRef?.current ? popoverRef.current : popoverRef;
const container = containerRef?.current !== undefined ? containerRef.current : containerRef;
const trigger = triggerRef?.current !== undefined ? triggerRef.current : triggerRef;
const popover = popoverRef?.current !== undefined ? popoverRef.current : popoverRef;
useEffect(() => {
if (!container || !trigger) return;

View file

@ -258,7 +258,11 @@ export const createDataQuerySlice = (set, get) => ({
set((state) => {
state.dataQuery.creatingQueryInProcessId = null;
state.dataQuery.queries.modules[moduleId] = [
{ ...data, data_source_id: queryToClone.data_source_id },
{
...data,
data_source_id: queryToClone.data_source_id,
plugin: { iconFile: queryToClone.plugin?.iconFile, icon_file: queryToClone.plugin?.icon_file },
},
...state.dataQuery.queries.modules[moduleId],
];
});

View file

@ -122,6 +122,7 @@ export const createResolvedSlice = (set, get) => ({
'setVariables'
);
get().updateDependencyValues(`variables.${key}`);
get().checkAndSetTrueBuildSuggestionsFlag();
},
getVariable: (key, moduleId = 'canvas') => {
@ -165,6 +166,7 @@ export const createResolvedSlice = (set, get) => ({
'setPageVariable'
);
get().updateDependencyValues(`page.variables.${key}`);
get().checkAndSetTrueBuildSuggestionsFlag();
},
getPageVariable: (key, moduleId = 'canvas') => {

View file

@ -1,62 +1,36 @@
export const ActionTypes = [
{
name: 'Run query',
id: 'run-query',
options: [{ queryId: '' }],
group: 'run-action',
},
{
name: 'Show Alert',
id: 'show-alert',
options: [{ name: 'message', type: 'text', default: 'Message !' }],
group: 'run-action',
},
{
name: 'Logout',
id: 'logout',
},
{
name: 'Run Query',
id: 'run-query',
options: [{ queryId: '' }],
},
{
name: 'Open Webpage',
id: 'open-webpage',
options: [{ name: 'url', type: 'text', default: 'https://example.com' }],
},
{
name: 'Go to app',
id: 'go-to-app',
name: 'Control component',
id: 'control-component',
options: [
{ name: 'app', type: 'text', default: '' },
{ name: 'queryParams', type: 'code', default: '[]' },
{ name: 'component', type: 'text', default: '' },
{ name: 'action', type: 'text', default: '' },
],
group: 'control-component',
},
{
name: 'Show Modal',
name: 'Show modal',
id: 'show-modal',
options: [{ name: 'modal', type: 'text', default: '' }],
group: 'control-component',
},
{
name: 'Close Modal',
name: 'Close modal',
id: 'close-modal',
options: [{ name: 'modal', type: 'text', default: '' }],
},
{
name: 'Copy to clipboard',
id: 'copy-to-clipboard',
options: [{ name: 'copy-to-clipboard', type: 'text', default: '' }],
},
{
name: 'Set local storage',
id: 'set-localstorage-value',
options: [
{ name: 'key', type: 'code', default: '' },
{ name: 'value', type: 'code', default: '' },
],
},
{
name: 'Generate file',
id: 'generate-file',
options: [
{ name: 'fileType', type: 'text', default: '' },
{ name: 'fileName', type: 'text', default: '' },
{ name: 'data', type: 'code', default: '{{[]}}' },
],
group: 'control-component',
},
{
name: 'Set table page',
@ -69,28 +43,28 @@ export const ActionTypes = [
},
{ name: 'pageIndex', type: 'text', default: '{{1}}' },
],
},
{
name: 'Set variable',
id: 'set-custom-variable',
options: [
{ name: 'key', type: 'code', default: '' },
{ name: 'value', type: 'code', default: '' },
],
},
{
name: 'Unset all variables',
id: 'unset-all-custom-variables',
},
{
name: 'Unset variable',
id: 'unset-custom-variable',
options: [{ name: 'key', type: 'code', default: '' }],
group: 'control-component',
},
{
name: 'Switch page',
id: 'switch-page',
options: [{ name: 'page', type: 'text', default: '' }],
group: 'navigation',
},
{
name: 'Go to app',
id: 'go-to-app',
options: [
{ name: 'app', type: 'text', default: '' },
{ name: 'queryParams', type: 'code', default: '[]' },
],
group: 'navigation',
},
{
name: 'Open webpage',
id: 'open-webpage',
options: [{ name: 'url', type: 'text', default: 'https://example.com' }],
group: 'navigation',
},
{
name: 'Set page variable',
@ -99,10 +73,7 @@ export const ActionTypes = [
{ name: 'key', type: 'code', default: '' },
{ name: 'value', type: 'code', default: '' },
],
},
{
name: 'Unset all page variables',
id: 'unset-all-page-variables',
group: 'variable',
},
{
name: 'Unset page variable',
@ -111,14 +82,61 @@ export const ActionTypes = [
{ name: 'key', type: 'code', default: '' },
{ name: 'value', type: 'code', default: '' },
],
group: 'variable',
},
{
name: 'Control component',
id: 'control-component',
name: 'Unset all page variables',
id: 'unset-all-page-variables',
group: 'variable',
},
{
name: 'Set variable',
id: 'set-custom-variable',
options: [
{ name: 'component', type: 'text', default: '' },
{ name: 'action', type: 'text', default: '' },
{ name: 'key', type: 'code', default: '' },
{ name: 'value', type: 'code', default: '' },
],
group: 'variable',
},
{
name: 'Unset variable',
id: 'unset-custom-variable',
options: [{ name: 'key', type: 'code', default: '' }],
group: 'variable',
},
{
name: 'Unset all variables',
id: 'unset-all-custom-variables',
group: 'variable',
},
{
name: 'Logout',
id: 'logout',
group: 'other',
},
{
name: 'Generate file',
id: 'generate-file',
options: [
{ name: 'fileType', type: 'text', default: '' },
{ name: 'fileName', type: 'text', default: '' },
{ name: 'data', type: 'code', default: '{{[]}}' },
],
group: 'other',
},
{
name: 'Set local storage',
id: 'set-localstorage-value',
options: [
{ name: 'key', type: 'code', default: '' },
{ name: 'value', type: 'code', default: '' },
],
group: 'other',
},
{
name: 'Copy to clipboard',
id: 'copy-to-clipboard',
options: [{ name: 'copy-to-clipboard', type: 'text', default: '' }],
group: 'other',
},
];

View file

@ -1,7 +1,8 @@
/* eslint-disable react/no-string-refs */
import React from 'react';
import { Editor, EditorState, RichUtils, getDefaultKeyBinding, ContentState, convertFromHTML } from 'draft-js';
import { Editor, EditorState, RichUtils, getDefaultKeyBinding } from 'draft-js';
import 'draft-js/dist/Draft.css';
import { stateFromHTML } from 'draft-js-import-html';
import { stateToHTML } from 'draft-js-export-html';
import Loader from '@/ToolJetUI/Loader/Loader';
import DOMPurify from 'dompurify';
@ -150,11 +151,8 @@ const InlineStyleControls = (props) => {
class DraftEditor extends React.Component {
constructor(props) {
super(props);
const blocksFromHTML = convertFromHTML(DOMPurify.sanitize(this.props.defaultValue));
this.state = {
editorState: EditorState.createWithContent(
ContentState.createFromBlockArray(blocksFromHTML.contentBlocks, blocksFromHTML.entityMap)
),
editorState: EditorState.createWithContent(stateFromHTML(DOMPurify.sanitize(this.props.defaultValue))),
};
this.editorContainerRef = React.createRef();
@ -173,6 +171,18 @@ class DraftEditor extends React.Component {
this.toggleInlineStyle = this._toggleInlineStyle.bind(this);
}
componentDidUpdate(prevProps) {
if (prevProps.defaultValue !== this.props.defaultValue) {
const newContentState = stateFromHTML(DOMPurify.sanitize(this.props.defaultValue));
const newEditorState = EditorState.createWithContent(newContentState);
const html = stateToHTML(newContentState);
this.props.handleChange(html);
this.setState({ editorState: newEditorState });
}
}
componentDidMount() {
//For resizing the editor container based on the height of rich text editor controls
this.resizeObserver = new ResizeObserver(() => {
@ -193,11 +203,7 @@ class DraftEditor extends React.Component {
isVisible: this.props.isVisible,
isLoading: this.props.isLoading,
setValue: async (text) => {
const blocksFromHTML = convertFromHTML(DOMPurify.sanitize(text));
const newContentState = ContentState.createFromBlockArray(
blocksFromHTML.contentBlocks,
blocksFromHTML.entityMap
);
const newContentState = stateFromHTML(DOMPurify.sanitize(text));
const newEditorState = EditorState.createWithContent(newContentState);
const html = stateToHTML(newContentState);
this.props.handleChange(html);
@ -226,19 +232,6 @@ class DraftEditor extends React.Component {
}
}
componentDidUpdate(prevProps) {
if (prevProps.defaultValue !== this.props.defaultValue) {
const blocksFromHTML = convertFromHTML(DOMPurify.sanitize(this.props.defaultValue));
const newContentState = ContentState.createFromBlockArray(blocksFromHTML.contentBlocks, blocksFromHTML.entityMap);
const newEditorState = EditorState.createWithContent(newContentState);
const html = stateToHTML(newContentState);
this.props.handleChange(html);
this.setState({ editorState: newEditorState });
}
}
_handleKeyCommand(command, editorState) {
const newState = RichUtils.handleKeyCommand(editorState, command);
if (newState) {

View file

@ -191,7 +191,7 @@ export const RadioButtonV2 = ({
data-cy={`label-${String(componentName).toLowerCase()} `}
data-disabled={isDisabled}
id={String(componentName)}
className={cx('radio-button,', 'd-flex', {
className={cx('radio-button', 'd-flex', {
[alignment === 'top' &&
((labelWidth != 0 && label?.length != 0) ||
(labelAutoWidth && labelWidth == 0 && label && label?.length != 0))
@ -279,7 +279,7 @@ export const RadioButtonV2 = ({
</div>
</div>
<div
className={`${isValid ? '' : visibility ? 'd-flex' : 'none'}`}
className={`${isValid ? 'd-none' : visibility ? 'd-flex' : 'd-none'}`}
style={{
color: 'var(--status-error-strong)',
justifyContent: direction === 'right' ? 'flex-start' : 'flex-end',

View file

@ -1,53 +1,226 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { isExpectedDataType } from '@/_helpers/utils';
import { ToolTip } from '@/_components/ToolTip';
import './Steps.scss';
export const Steps = function Button({ properties, styles, fireEvent, setExposedVariable, height, darkMode, dataCy }) {
const { stepsSelectable } = properties;
const currentStep = isExpectedDataType(properties.currentStep, 'number');
const steps = isExpectedDataType(properties.steps, 'array');
const { color, theme, visibility, boxShadow } = styles;
export const Steps = function Steps({ properties, styles, fireEvent, setExposedVariable, height, darkMode, dataCy }) {
const { stepsSelectable, disabledState } = properties;
const visibility = isExpectedDataType(properties.visibility, 'boolean');
const currentStepId = isExpectedDataType(properties.currentStep, 'number');
const isDynamicStepsEnabled = isExpectedDataType(properties.advanced, 'boolean');
const steps = isDynamicStepsEnabled ? properties.schema : properties.steps;
const { color, boxShadow } = styles;
const textColor = darkMode && styles.textColor === '#000' ? '#fff' : styles.textColor;
const [activeStep, setActiveStep] = useState(null);
const { completedAccent, incompletedAccent, incompletedLabel, completedLabel, currentStepLabel } = styles;
const [stepsArr, setStepsArr] = useState(steps);
const [isVisible, setIsVisible] = useState(visibility);
const [isDisabled, setIsDisabled] = useState(disabledState);
const [activeStepId, setActiveStepId] = useState(currentStepId);
const theme = properties.variant;
const [progressBarWidth, setProgressBarWidth] = useState(0);
const [containerPadding, setContainerPadding] = useState(0);
const [containerWidth, setContainerWidth] = useState(0);
const [filteredSteps, setFilteredSteps] = useState([]);
const firstLabelRef = useRef(null);
const lastLabelRef = useRef(null);
const containerRef = useRef(null);
const currentStepIndex = filteredSteps.findIndex((step) => step.id == activeStepId);
useEffect(() => {
const sanitizedSteps = JSON.parse(JSON.stringify(steps || [])).map((step) => ({
...step,
visible: 'visible' in step ? step.visible : true,
disabled: 'disabled' in step ? step.disabled : false,
}));
const newFilteredSteps = (sanitizedSteps || []).filter((step) => step.visible);
setFilteredSteps(newFilteredSteps);
setStepsArr(sanitizedSteps);
}, [JSON.stringify(steps)]);
// Common function to calculate progress bar width and label padding
const calculateProgressBarWidth = () => {
if (!containerRef.current || theme !== 'titles') return;
const containerWidth = containerRef.current.offsetWidth;
setContainerWidth(containerWidth);
const stepWidth = 20; // width of dot + padding
const totalStepsWidth = filteredSteps.length * stepWidth;
const totalProgressBars = filteredSteps.length - 1;
if (filteredSteps.length === 1) {
setProgressBarWidth(containerWidth);
setContainerPadding(0); // No padding needed for single step
return;
}
// Calculate progress bar width
const progressBarWidth = (containerWidth - totalStepsWidth) / totalProgressBars;
setProgressBarWidth(Math.min(progressBarWidth, (containerWidth - totalStepsWidth) / filteredSteps.length));
// Calculate container padding
if (firstLabelRef.current && lastLabelRef.current) {
const labelWidth = (containerWidth - (filteredSteps.length - 1) - 4) / filteredSteps.length;
const firstLabelWidth = firstLabelRef.current.offsetWidth;
const lastLabelWidth = lastLabelRef.current.offsetWidth;
const maxLabelWidth = Math.max(firstLabelWidth, lastLabelWidth);
const calculatedPadding = (maxLabelWidth / 2) - 1;
setContainerPadding(Math.max(2, calculatedPadding)); // Ensure minimum padding of 2px
}
};
// Add resize observer to track container width and calculate progress bar width
useEffect(() => {
calculateProgressBarWidth();
if (theme !== 'titles') return;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
calculateProgressBarWidth();
}
});
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => resizeObserver.disconnect();
}, [theme, JSON.stringify(steps), filteredSteps]);
// Dynamic styles for theming
const dynamicStyle = {
'--bgColor': styles.color,
'--textColor': textColor,
};
const activeStepHandler = (id) => {
const active = steps.filter((item) => item.id == id);
setExposedVariable('currentStepId', active[0].id);
fireEvent('onSelect');
setActiveStep(active[0].id);
'--completedAccent': completedAccent === '#4368E3' ? 'var(--primary-brand)' : completedAccent,
'--incompletedAccent': incompletedAccent === '#E4E7EB' ? 'var(--surfaces-surface-03)' : incompletedAccent,
'--incompletedLabel': incompletedLabel === '#1B1F24' ? 'var(--text-primary)' : incompletedLabel,
'--completedLabel': completedLabel === '#1B1F24' ? 'var(--text-primary)' : completedLabel,
'--currentStepLabel': currentStepLabel === '#1B1F24' ? 'var(--text-primary)' : currentStepLabel,
};
// Step click handler
const handleStepClick = (id) => {
const step = filteredSteps.find((item) => item.id == id);
if (step && !step.disabled && !isDisabled) {
setActiveStepId(step.id);
fireEvent('onSelect');
}
};
// Expose variables and methods
useEffect(() => {
setActiveStep(currentStep);
setExposedVariable('currentStepId', currentStep);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentStep]);
setExposedVariable('isVisible', isVisible);
setExposedVariable('isDisabled', isDisabled);
setExposedVariable('currentStepId', activeStepId);
setExposedVariable('steps', stepsArr);
setExposedVariable('setStepVisible', (stepId, visibility) => {
setStepsArr((prev) => {
const updatedSteps = prev.map((item) =>
item.id == stepId ? { ...item, visible: visibility } : item
);
setExposedVariable('steps', updatedSteps);
return updatedSteps;
});
});
setExposedVariable('setStepDisable', (stepId, disabled) => {
setStepsArr((prev) => {
const updatedSteps = prev.map((item) =>
item.id == stepId ? { ...item, disabled: disabled } : item
);
setExposedVariable('steps', updatedSteps);
return updatedSteps;
});
});
setExposedVariable('resetSteps', () => {
setActiveStepId(stepsArr.filter((step) => step.visible)?.[0]?.id);
});
setExposedVariable('setStep', (stepId) => {
if (!disabledState) setActiveStepId(stepId);
});
setExposedVariable('setVisibility', (visibility) => setIsVisible(visibility));
setExposedVariable('setDisable', (disabled) => setIsDisabled(disabled));
}, [isVisible, isDisabled, activeStepId, stepsArr, disabledState]);
// Update state from props
useEffect(() => setIsVisible(visibility), [visibility]);
useEffect(() => setIsDisabled(disabledState), [disabledState]);
useEffect(() => setActiveStepId(currentStepId), [currentStepId]);
if (!isVisible) return null;
return (
visibility && (
<div
className={`steps ${theme == 'numbers' && 'steps-counter '}`}
style={{ color: textColor, height, boxShadow }}
data-cy={dataCy}
>
{steps?.map((item) => (
<a
key={item.id}
className={`step-item ${item.id == activeStep && 'active'} ${!stepsSelectable && 'step-item-disabled'} ${
color && `step-${color}`
}`}
data-bs-toggle="tooltip"
title={item?.tooltip}
onClick={() => stepsSelectable && activeStepHandler(item.id)}
style={dynamicStyle}
>
{theme == 'titles' && item.name}
</a>
))}
<div
ref={containerRef}
className={`steps-container ${isDisabled ? 'disabled' : ''} ${filteredSteps.length === 1 ? 'single-step' : ''}`}
style={{
height,
boxShadow,
padding: theme === 'titles' ? `0 ${containerPadding}px` : 2,
paddingTop: theme === 'plain' ? `3px` : theme === 'numbers' ? `2px` : 0,
...dynamicStyle
}}
data-cy={dataCy}
>
<div className={`progress-line-container ${filteredSteps.length === 1 ? 'single-step' : ''}`}>
{filteredSteps.map((step, index) => {
const isStepDisabled = step.disabled;
const isCompleted = index < currentStepIndex;
const isActive = index === currentStepIndex;
const isUpcoming = index > currentStepIndex;
const isFirstStep = index === 0;
const isLastStep = index === filteredSteps.length - 1;
return (
<React.Fragment key={index}> {/* using index as key to avoid issues due to duplicate step ids */}
<ToolTip
show={!step.disabled && !isDisabled && step.tooltip}
message={step.tooltip || ''}
>
<div
onClick={() => stepsSelectable && handleStepClick(step.id)}
className={`milestone ${theme === 'numbers' ? 'numbers' : ''} ${isDisabled || isStepDisabled ? 'disabled' : ''
} ${isCompleted ? 'completed' : isActive ? 'active' : 'incomplete'}`}
>
{theme === 'numbers' ? (
index + 1
) : (
<>
<div
className={`dot ${isCompleted ? 'completed' : isActive ? 'active' : 'incomplete'}`}
style={{
border: `2px solid ${isCompleted ? completedAccent : isActive ? completedAccent : incompletedAccent}`,
backgroundColor: isActive ? 'transparent' : (isCompleted ? completedAccent : incompletedAccent)
}}
/>
{theme === 'titles' && (
<div
ref={isFirstStep ? firstLabelRef : isLastStep ? lastLabelRef : null}
className={`label ${isCompleted ? 'completed' : isActive ? 'active' : 'incomplete'}`}
style={{ maxWidth: `${progressBarWidth}px` }}
>
{step.name}
</div>
)}
</>
)}
</div>
</ToolTip>
{index < filteredSteps.length - 1 && (
<div
className={`step-connector ${isCompleted ? 'completed' : 'incomplete'}`}
/>
)}
</React.Fragment>
);
})}
</div>
)
</div>
);
};

View file

@ -0,0 +1,132 @@
.steps-container {
display: flex;
flex-direction: column;
width: 100%;
opacity: 1;
&.disabled {
opacity: 0.5;
}
&.single-step {
align-items: center;
}
.progress-line-container {
display: flex;
align-items: center;
gap: 2px;
&.single-step {
width: auto;
}
}
.milestone {
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: visible;
transition: all 0.3s ease;
cursor: pointer;
&.numbers {
width: 24px;
height: 24px;
border-radius: 50%;
font-size: 14px;
font-weight: 500;
box-sizing: content-box;
&.completed {
background-color: var(--completedAccent);
color: var(--completedLabel);
border: 2px solid var(--completedAccent);
}
&.active {
color: var(--currentStepLabel);
border: 2px solid var(--completedAccent);
}
&.incomplete {
background-color: var(--incompletedAccent);
color: var(--incompletedLabel);
border: 2px solid var(--incompletedAccent);
}
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
transition: all 0.3s ease;
box-sizing: content-box;
&.completed {
background-color: var(--completedAccent);
border: 2px solid var(--completedAccent);
}
&.active {
background-color: white;
border: 2px solid var(--primary-brand);
}
&.incomplete {
background-color: var(--incompletedAccent);
border: 2px solid var(--incompletedAccent);
}
}
.label {
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 18px;
text-align: center;
margin-top: 2px;
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: max-content;
&.completed {
color: var(--completedLabel);
}
&.active {
color: var(--completedLabel);
}
&.incomplete {
color: var(--incompletedLabel);
}
}
.step-connector {
flex-grow: 1;
height: 2px;
align-self: center;
transition: all 0.3s ease;
&.completed {
background-color: var(--completedAccent);
}
&.incomplete {
background-color: var(--incompletedAccent);
}
}
}

View file

@ -3,8 +3,8 @@ export const containerConfig = {
displayName: 'Container',
description: 'Group components',
defaultSize: {
width: 10,
height: 200,
width: 13,
height: 480,
},
component: 'Container',
others: {

View file

@ -47,7 +47,7 @@ import { verticalDividerConfig } from './verticalDivider';
import { customComponentConfig } from './customComponent';
import { buttonGroupConfig } from './buttonGroup';
import { pdfConfig } from './pdf';
import { stepsConfig } from './steps';
// import { stepsConfig } from './steps';
import { kanbanConfig } from './kanban';
import { colorPickerConfig } from './colorPicker';
import { treeSelectConfig } from './treeSelect';
@ -106,7 +106,7 @@ export {
customComponentConfig,
buttonGroupConfig,
pdfConfig,
stepsConfig,
// stepsConfig,
kanbanConfig,
kanbanBoardConfig, //!Depreciated
colorPickerConfig,

View file

@ -1,108 +0,0 @@
export const stepsConfig = {
name: 'Steps',
displayName: 'Steps',
description: 'Step-by-step navigation aid',
component: 'Steps',
properties: {
steps: {
type: 'code',
displayName: 'Steps',
validation: {
schema: {
type: 'array',
element: { type: 'object', object: { id: { type: 'number' } } },
},
defaultValue: `[{ name: 'step 1'}, {name: 'step 2'}]`,
},
},
currentStep: {
type: 'code',
displayName: 'Current step',
validation: {
schema: { type: 'number' },
defaultValue: 1,
},
},
stepsSelectable: {
type: 'toggle',
displayName: 'Steps selectable',
validation: {
schema: { type: 'boolean' },
defaultValue: false,
},
},
},
defaultSize: {
width: 22,
height: 38,
},
others: {
showOnDesktop: { type: 'toggle', displayName: 'Show on desktop' },
showOnMobile: { type: 'toggle', displayName: 'Show on mobile' },
},
events: {
onSelect: { displayName: 'On select' },
},
styles: {
color: {
type: 'color',
displayName: 'Color',
validation: {
schema: { type: 'string' },
defaultValue: '#000000',
},
},
textColor: {
type: 'color',
displayName: 'Text color',
validation: {
schema: { type: 'string' },
defaultValue: '#000000',
},
},
theme: {
type: 'select',
displayName: 'Theme',
options: [
{ name: 'titles', value: 'titles' },
{ name: 'numbers', value: 'numbers' },
{ name: 'plain', value: 'plain' },
],
validation: {
schema: { type: 'string' },
defaultValue: 'titles',
},
},
visibility: {
type: 'toggle',
displayName: 'Visibility',
validation: {
schema: { type: 'boolean' },
defaultValue: true,
},
},
},
exposedVariables: {
currentStepId: '3',
},
definition: {
others: {
showOnDesktop: { value: '{{true}}' },
showOnMobile: { value: '{{false}}' },
},
properties: {
steps: {
value: `{{ [{ name: 'step 1', tooltip: 'some tooltip', id: 1},{ name: 'step 2', tooltip: 'some tooltip', id: 2},{ name: 'step 3', tooltip: 'some tooltip', id: 3},{ name: 'step 4', tooltip: 'some tooltip', id: 4},{ name: 'step 5', tooltip: 'some tooltip', id: 5}]}}`,
},
currentStep: { value: '{{3}}' },
stepsSelectable: { value: true },
},
events: [],
styles: {
visibility: { value: '{{true}}' },
theme: { value: 'titles' },
color: { value: '' },
textColor: { value: '' },
},
},
};

View file

@ -13,6 +13,7 @@ export const organizationService = {
getWorkspacesLimit,
checkWorkspaceUniqueness,
updateOrganization,
setDefaultWorkspace,
};
function getUsersByValue(searchInput) {
@ -100,3 +101,8 @@ function checkWorkspaceUniqueness(name, slug) {
const query = queryString.stringify({ name, slug });
return fetch(`${config.apiUrl}/organizations/is-unique?${query}`, requestOptions).then(handleResponse);
}
function setDefaultWorkspace(workspaceId) {
const requestOptions = { method: 'PATCH', headers: authHeader(), credentials: 'include' };
return fetch(`${config.apiUrl}/organizations/${workspaceId}/default`, requestOptions).then(handleResponse);
}

View file

@ -7675,29 +7675,33 @@ fieldset:disabled .btn {
}
.rounded {
border-radius: 4px ;
border-radius: 4px;
}
.rounded-0 {
border-radius: 0 !important
}
.rounded-top-left{
.rounded-top-left {
border-top-left-radius: 4px;
}
.rounded-top-left-0{
.rounded-top-left-0 {
border-top-left-radius: 0 !important;
}
.rounded-top-right-0{
.rounded-top-right-0 {
border-top-right-radius: 0 !important;
}
.rounded-bottom-left-0{
.rounded-bottom-left-0 {
border-bottom-left-radius: 0 !important;
}
.rounded-bottom-right-0{
.rounded-bottom-right-0 {
border-bottom-right-radius: 0 !important;
}
.rounded-1 {
border-radius: 2px !important
}
@ -17484,8 +17488,8 @@ a.step-item:hover {
.step-item:not(:first-child):after {
position: absolute;
left: -50%;
width: 100%;
left: calc(-50% + 8px);
width: calc(100% - 16px);
content: "";
transform: translateY(-50%)
}
@ -17498,13 +17502,25 @@ a.step-item:hover {
box-sizing: content-box;
display: block;
content: "";
border: 2px solid #fff;
border: 2px solid transparent;
border-radius: 50%;
transform: translateX(-50%)
}
.step-item.active {
font-weight: 600
.steps.steps-counter {
.step-item:not(:first-child):after {
left: calc(-50% + 16px) !important;
width: calc(100% - 32px) !important;
}
}
.steps-counter .step-item:before {
color:var(--completedLabel) !important;
}
.steps .step-item.active:before{
color : var(--currentStepLabel) !important;
}
.step-item {
font-weight: 500;
}
.step-item.active:before {
@ -17521,7 +17537,7 @@ a.step-item:hover {
}
.step-item.active~.step-item:before {
color: #656d77 !important
color: var(--incompletedLabel) !important
}
.steps-counter {
@ -17549,7 +17565,8 @@ a.step-item:hover {
.steps-counter .step-item:before {
font-size: .75rem;
line-height: 1.5rem;
content: counter(steps)
content: counter(steps);
font-weight: 500 !important;
}
.steps-counter .step-item.active~.step-item:before {
@ -19156,4 +19173,4 @@ img {
background: #1f2936;
border-color: #dadcde
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,23 @@
import React from 'react';
const Moon = ({ fill = '#6A727C', width = '24', className = '', viewBox = '0 0 24 24' }) => {
return (
<svg
width={width}
height={width}
viewBox={viewBox}
className={className}
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16.107 6.64283C15.6139 6.64283 15.2142 6.24309 15.2142 5.74998V5.21426H14.6785C14.1854 5.21426 13.7856 4.81452 13.7856 4.32141C13.7856 3.8283 14.1854 3.42856 14.6785 3.42856H15.2142V2.89285C15.2142 2.39974 15.6139 2 16.107 2C16.6002 2 16.9999 2.39974 16.9999 2.89285V3.42856H17.5356C18.0288 3.42856 18.4285 3.8283 18.4285 4.32141C18.4285 4.81452 18.0288 5.21426 17.5356 5.21426H16.9999V5.74998C16.9999 6.24309 16.6002 6.64283 16.107 6.64283ZM18.7856 11.4642C18.7856 11.9573 19.1853 12.3571 19.6785 12.3571C20.1716 12.3571 20.5713 11.9573 20.5713 11.4642V10.9285H21.107C21.6002 10.9285 21.9999 10.5288 21.9999 10.0357C21.9999 9.54255 21.6002 9.14281 21.107 9.14281H20.5713V8.6071C20.5713 8.11399 20.1716 7.71425 19.6785 7.71425C19.1853 7.71425 18.7856 8.11399 18.7856 8.6071V9.14281H18.2499C17.7568 9.14281 17.357 9.54255 17.357 10.0357C17.357 10.5288 17.7568 10.9285 18.2499 10.9285H18.7856V11.4642ZM6.07263 6.07373C7.96356 4.98201 10.1187 4.74735 12.1075 5.23996C12.7387 5.39633 12.9939 5.95217 12.9996 6.40209C13.0051 6.8352 12.7967 7.33059 12.3328 7.59845C10.4674 8.67541 9.82636 11.1468 10.9769 13.1396C12.1274 15.1324 14.5883 15.8129 16.4536 14.736C16.9176 14.4681 17.4508 14.5354 17.8232 14.7567C18.2099 14.9866 18.5637 15.4855 18.3835 16.1103C17.8158 18.0789 16.5349 19.828 14.644 20.9197C10.6598 23.2201 5.53735 21.7172 3.18302 17.6395C0.828705 13.5616 2.08841 8.37403 6.07263 6.07373Z"
fill={fill}
/>
</svg>
);
};
export default Moon;

View file

@ -234,6 +234,7 @@ import NewTabSmall from './NewTabSmall.jsx';
import Code from './Code.jsx';
import WorkflowV3 from './WorkflowV3.jsx';
import WorkspaceV3 from './WorkspaceV3.jsx';
import Moon from './Moon.jsx';
const Icon = (props) => {
switch (props.name) {
@ -707,6 +708,8 @@ const Icon = (props) => {
return <AICrown {...props} />;
case 'play01':
return <Play01 {...props} />;
case 'moon':
return <Moon {...props} />;
default:
return <Apps {...props} />;
}

View file

@ -24,6 +24,7 @@ export const SelectComponent = ({ options = [], value, onChange, closeMenuOnSele
isDisabled = false,
borderRadius,
openMenuOnFocus = false,
customClassPrefix = '',
} = restProps;
const customStyles = useCustomStyles ? styles : defaultStyles(isDarkMode, width, height, styles, borderRadius);
@ -74,7 +75,7 @@ export const SelectComponent = ({ options = [], value, onChange, closeMenuOnSele
maxMenuHeight={maxMenuHeight}
menuPortalTarget={useMenuPortal ? document.body : menuPortalTarget}
closeMenuOnSelect={closeMenuOnSelect ?? true}
classNamePrefix={`${isDarkMode && 'dark-theme'} ${'react-select'}`}
classNamePrefix={`${customClassPrefix} ${isDarkMode && 'dark-theme'} ${'react-select'}`}
/>
);
};

View file

@ -4,7 +4,7 @@ import { Input } from '../Input';
import { HelperMessage, ValidationMessage } from '../InputUtils/InputUtils';
import Tooltip from '../../Tooltip/Tooltip';
const EditableTitleInput = ({ size, disabled, helperText, onChange, ...restProps }) => {
const EditableTitleInput = ({ size, disabled, helperText, onChange: change, readOnly, placeholder, ...restProps }) => {
const inputRef = useRef(null);
const [tooltipWidth, setTooltipWidth] = useState('auto');
const [isValid, setIsValid] = useState(null);
@ -17,7 +17,7 @@ const EditableTitleInput = ({ size, disabled, helperText, onChange, ...restProps
setIsValid(validateObj.valid);
setMessage(validateObj.message);
}
onChange(e, validateObj);
change(e, validateObj);
};
const inputStyle = `tw-border-transparent hover:tw-border-border-default tw-font-medium tw-pl-[12px] tw-pr-[12px] ${
@ -35,7 +35,14 @@ const EditableTitleInput = ({ size, disabled, helperText, onChange, ...restProps
return (
<div className="tw-relative">
<div className="tw-peer tw-relative" ref={inputRef}>
<Input size={size} disabled={disabled} onChange={handleChange} {...restProps} className={inputStyle} />
<Input
size={size}
disabled={disabled}
placeholder={disabled && readOnly ? readOnly : placeholder}
onChange={handleChange}
{...restProps}
className={inputStyle}
/>
<SolidIcon
name="editable"
width="16px"

View file

@ -33,7 +33,7 @@ const Input = React.forwardRef(({ className, size, type, multiline, response, ro
type={isPasswordField && isPasswordVisible ? 'text' : type}
className={cn(
inputVariants({ size }),
`tw-relative tw-peer tw-flex tw-text-[12px]/[18px] tw-w-full tw-rounded-[8px] tw-border-[1px] tw-border-solid tw-bg-background-surface-layer-01 tw-py-[7px] tw-text-text-default focus-visible:tw-ring-[1px] focus-visible:tw-ring-offset-[1px] focus-visible:tw-ring-border-accent-strong focus-visible:tw-ring-offset-border-accent-strong focus-visible:tw-border-transparent disabled:tw-cursor-not-allowed ${props.styles}`,
`tw-peer tw-flex tw-text-[12px]/[18px] tw-w-full tw-rounded-[8px] tw-border-[1px] tw-border-solid tw-bg-background-surface-layer-01 tw-py-[7px] tw-text-text-default focus-visible:tw-ring-[1px] focus-visible:tw-ring-offset-[1px] focus-visible:tw-ring-border-accent-strong focus-visible:tw-ring-offset-border-accent-strong focus-visible:tw-border-transparent disabled:tw-cursor-not-allowed ${props.styles}`,
className
)}
ref={ref}

View file

@ -1 +1 @@
3.12.0
3.13.0

View file

@ -0,0 +1,66 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { TOOLJET_EDITIONS } from '@modules/app/constants';
import { getCustomEnvVars, getTooljetEdition } from '@helpers/utils.helper';
import { Organization } from '@entities/organization.entity';
import { WORKSPACE_STATUS } from '@modules/users/constants/lifecycle';
export class SetDefaultWorkspace1740401100000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
if (getTooljetEdition() !== TOOLJET_EDITIONS.EE) {
console.log('Skipping migration as it is not EE edition');
return;
}
// Check if default workspace URL is configured
const defaultWorkspaceUrl = getCustomEnvVars('TOOLJET_DEFAULT_WORKSPACE_URL');
if (defaultWorkspaceUrl) {
try {
const url = new URL(defaultWorkspaceUrl);
const pathParts = url.pathname.split('/');
const workspaceSlug = pathParts[pathParts.length - 1];
if (workspaceSlug) {
const organization = await queryRunner.manager.findOne(Organization, {
where: { slug: workspaceSlug, status: WORKSPACE_STATUS.ACTIVE },
select: ['id'],
});
if (organization){
await queryRunner.query(`
UPDATE organizations
SET is_default = true
WHERE slug = $1
`, [workspaceSlug]);
return;
}
console.log(`No active organization found with slug: ${workspaceSlug}`);
}
} catch (err) {
console.log('Invalid TOOLJET_DEFAULT_WORKSPACE_URL format');
}
}
// Set the first created organization as default
await queryRunner.query(`
UPDATE organizations
SET is_default = true
WHERE id = (
SELECT id
FROM organizations
WHERE status = '${WORKSPACE_STATUS.ACTIVE}'
ORDER BY created_at ASC
LIMIT 1
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
if (getTooljetEdition() !== TOOLJET_EDITIONS.EE) {
return;
}
// Unset all default workspaces
await queryRunner.query(`
UPDATE organizations
SET is_default = false;
`);
}
}

View file

@ -0,0 +1,81 @@
import { Component } from '@entities/component.entity';
import { EntityManager, MigrationInterface, QueryRunner } from 'typeorm';
import { processDataInBatches } from '@helpers/migration.helper';
export class StepsV2Migration1742369436314 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const componentTypes = ['Steps'];
const batchSize = 100;
const entityManager = queryRunner.manager;
for (const componentType of componentTypes) {
await processDataInBatches(
entityManager,
async (entityManager: EntityManager) => {
return await entityManager.find(Component, {
where: { type: componentType },
order: { createdAt: 'ASC' },
});
},
async (entityManager: EntityManager, components: Component[]) => {
await this.processUpdates(entityManager, components);
},
batchSize
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {}
private async processUpdates(entityManager, components) {
for (const component of components) {
const properties = component.properties;
const styles = component.styles;
const general = component.general;
const generalStyles = component.generalStyles;
const validation = component.validation;
if (styles.visibility) {
properties.visibility = styles.visibility;
delete styles.visibility;
}
if (styles.theme) {
properties['variant'] = styles.theme;
delete styles.theme;
}
if (styles.color) {
styles['completedAccent'] = styles.color;
}
delete styles.color;
if (styles.textColor) {
styles['completedLabel'] = styles.textColor;
styles['incompletedLabel'] = styles.textColor;
styles['currentStepLabel'] = styles.textColor;
}
delete styles.textColor;
if (properties.steps) {
properties['schema'] = properties.steps;
delete properties.steps;
properties['advanced'] = { value: '{{true}}' };
}
// if (properties.stepsSelectable) {
// properties.disabledState = styles.disabledState;
// delete styles.disabledState;
// }
// if (generalStyles?.boxShadow) {
// styles.boxShadow = generalStyles?.boxShadow;
// delete generalStyles?.boxShadow;
// }
await entityManager.update(Component, component.id, {
properties,
styles,
general,
generalStyles,
validation,
});
}
}
}

@ -1 +1 @@
Subproject commit b9e73f87b9062e06c49c2c73add6b82ba21dcacf
Subproject commit 30dbfa754562d00f8d64181d5006e113798bd668

View file

@ -0,0 +1,30 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
export class AddIsDefaultToOrganizations1740401000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Add is_default column
await queryRunner.addColumn(
'organizations',
new TableColumn({
name: 'is_default',
type: 'boolean',
default: false,
isNullable: false,
})
);
// Create a partial unique index to ensure only one default workspace
await queryRunner.query(`
CREATE UNIQUE INDEX idx_organizations_single_default
ON organizations (is_default)
WHERE is_default = true;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Drop the unique index first
await queryRunner.query(`DROP INDEX IF EXISTS idx_organizations_single_default;`);
// Then drop the column
await queryRunner.dropColumn('organizations', 'is_default');
}
}

View file

@ -10,6 +10,7 @@ import Ajv from 'ajv';
import * as path from 'path';
import * as fs from 'fs';
import { ImportResourcesDto } from '@dto/import-resources.dto';
import { AppImportRequestDto } from '@modules/external-apis/dto';
const ajv = new Ajv({ allErrors: true, coerceTypes: true });
const logger = new Logger('TooljetDatabaseSchemaValidator');
@ -109,3 +110,15 @@ export function ValidateTooljetDatabaseSchema(validationOptions?: ValidationOpti
});
};
}
export function ValidateTooljetDatabaseImportSchema(validationOptions?: ValidationOptions) {
return function (object: AppImportRequestDto, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: ValidateTooljetDatabaseConstraint,
});
};
}

View file

@ -35,6 +35,9 @@ export class Organization extends BaseEntity {
@Column({ name: 'domain' })
domain: string;
@Column({ name: 'is_default', default: false })
isDefault: boolean;
@Column({ name: 'enable_sign_up' })
enableSignUp: boolean;

View file

@ -5,6 +5,8 @@ import { isEmpty } from 'lodash';
import { USER_TYPE } from '@modules/users/constants/lifecycle';
import { ConflictException } from '@nestjs/common';
import { DataBaseConstraints } from './db_constraints.constants';
import { getEnvVars } from 'scripts/database-config-utils';
const semver = require('semver');
@ -449,5 +451,11 @@ export const getSubpath = () => {
};
export function getTooljetEdition(): string {
return process.env.TOOLJET_EDITION?.toLowerCase() || 'ce';
const envVars = getEnvVars();
return envVars['TOOLJET_EDITION']?.toLowerCase() || 'ce';
}
export function getCustomEnvVars(name: string) {
const envVars = getEnvVars();
return envVars[name] || '';
}

View file

@ -21,6 +21,7 @@ import { AppsSubscriber } from './subscribers/apps.subscriber';
import { AiModule } from '@modules/ai/module';
import { AppPermissionsModule } from '@modules/app-permissions/module';
import { RolesRepository } from '@modules/roles/repository';
import { UsersModule } from '@modules/users/module';
@Module({})
export class AppsModule {
static async register(configs: { IS_GET_CONTEXT: boolean }): Promise<DynamicModule> {
@ -55,6 +56,7 @@ export class AppsModule {
await DataSourcesModule.register(configs),
await AiModule.register(configs),
await AppPermissionsModule.register(configs),
await UsersModule.register(configs),
],
controllers: [AppsController],
providers: [
@ -74,7 +76,7 @@ export class AppsModule {
AppImportExportService,
RolesRepository,
],
exports: [AppsUtilService],
exports: [AppsUtilService, AppImportExportService],
};
}
}

View file

@ -2,6 +2,7 @@ import { App } from '@entities/app.entity';
import { Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { SessionAppData } from './types';
import { WorkspaceAppsResponseDto } from '@modules/external-apis/dto';
@Injectable()
export class AppsRepository extends Repository<App> {
@ -63,4 +64,23 @@ export class AppsRepository extends Repository<App> {
},
});
}
async findAllOrganizationApps(organizationId: string): Promise<WorkspaceAppsResponseDto[]> {
return await this.createQueryBuilder('app')
.select([
'app.id AS id',
'app.name AS name',
'app.slug AS slug',
'app.created_at AS createdAt',
'app.organization_id AS organizationId',
'version.id AS versionId',
'version.name AS versionName',
'version.created_at AS versionCreatedAt',
])
.leftJoin('app_versions', 'version', 'version.app_id = app.id')
.where('app.organizationId = :organizationId', { organizationId })
.orderBy('app.created_At', 'ASC')
.orderBy('version.created_at', 'ASC')
.getRawMany();
}
}

View file

@ -29,9 +29,6 @@ import { VersionRepository } from '@modules/versions/repository';
import { AppsRepository } from './repository';
import { FoldersUtilService } from '@modules/folders/util.service';
import { FolderAppsUtilService } from '@modules/folder-apps/util.service';
import { DataQuery } from '@entities/data_query.entity';
import { DataSource } from '@entities/data_source.entity';
import { AppVersion } from '@entities/app_version.entity';
import { PageService } from './services/page.service';
import { EventsService } from './services/event.service';
import { LICENSE_FIELD } from '@modules/licensing/constants';
@ -224,40 +221,7 @@ export class AppsService implements IAppsService {
}
async findTooljetDbTables(appId: string): Promise<{ table_id: string }[]> {
return await dbTransactionWrap(async (manager: EntityManager) => {
const tooljetDbDataQueries = await manager
.createQueryBuilder(DataQuery, 'data_queries')
.innerJoin(DataSource, 'data_sources', 'data_queries.data_source_id = data_sources.id')
.innerJoin(AppVersion, 'app_versions', 'app_versions.id = data_sources.app_version_id')
.where('app_versions.app_id = :appId', { appId })
.andWhere('data_sources.kind = :kind', { kind: 'tooljetdb' })
.getMany();
const uniqTableIds = new Set();
tooljetDbDataQueries.forEach((dq) => {
if (dq.options?.operation === 'join_tables') {
const joinOptions = dq.options?.join_table?.joins ?? [];
(joinOptions || []).forEach((join) => {
const { table, conditions } = join;
if (table) uniqTableIds.add(table);
conditions?.conditionsList?.forEach((condition) => {
const { leftField, rightField } = condition;
if (leftField?.table) {
uniqTableIds.add(leftField?.table);
}
if (rightField?.table) {
uniqTableIds.add(rightField?.table);
}
});
});
}
if (dq.options.table_id) uniqTableIds.add(dq.options.table_id);
});
return [...uniqTableIds].map((table_id) => {
return { table_id };
});
});
return await this.appsUtilService.findTooljetDbTables(appId); //moved to util
}
async getOne(app: App, user: User): Promise<any> {

View file

@ -33,6 +33,7 @@ import { DataSourcesUtilService } from '@modules/data-sources/util.service';
import { DataSourcesRepository } from '@modules/data-sources/repository';
import { AppEnvironmentUtilService } from '@modules/app-environments/util.service';
import { ComponentsService } from './component.service';
import { UsersUtilService } from '@modules/users/util.service';
interface AppResourceMappings {
defaultDataSourceIdMapping: Record<string, string>;
dataQueryMapping: Record<string, string>;
@ -51,7 +52,17 @@ type DefaultDataSourceName =
| 'tooljetdbdefault'
| 'workflowsdefault';
type NewRevampedComponent = 'Text' | 'TextInput' | 'PasswordInput' | 'NumberInput' | 'Table' | 'Button' | 'Checkbox' | 'Divider' | 'VerticalDivider' | 'Link';
type NewRevampedComponent =
| 'Text'
| 'TextInput'
| 'PasswordInput'
| 'NumberInput'
| 'Table'
| 'Button'
| 'Checkbox'
| 'Divider'
| 'VerticalDivider'
| 'Link';
const DefaultDataSourceNames: DefaultDataSourceName[] = [
'restapidefault',
@ -80,9 +91,10 @@ export class AppImportExportService {
protected dataSourcesUtilService: DataSourcesUtilService,
protected dataSourcesRepository: DataSourcesRepository,
protected appEnvironmentUtilService: AppEnvironmentUtilService,
protected usersUtilService: UsersUtilService,
protected readonly entityManager: EntityManager,
protected componentsService: ComponentsService
) { }
) {}
async export(user: User, id: string, searchParams: any = {}): Promise<{ appV2: App }> {
// https://github.com/typeorm/typeorm/issues/3857
@ -94,7 +106,7 @@ export class AppImportExportService {
.createQueryBuilder(App, 'apps')
.where('apps.id = :id AND apps.organization_id = :organizationId', {
id,
organizationId: user.organizationId,
organizationId: user?.organizationId,
});
const appToExport = await queryForAppToExport.getOne();
@ -123,7 +135,7 @@ export class AppImportExportService {
const appEnvironments = await manager
.createQueryBuilder(AppEnvironment, 'app_environments')
.where('app_environments.organizationId = :organizationId', {
organizationId: user.organizationId,
organizationId: user?.organizationId,
})
.orderBy('app_environments.createdAt', 'ASC')
.getMany();
@ -184,13 +196,13 @@ export class AppImportExportService {
const components =
pages.length > 0
? await manager
.createQueryBuilder(Component, 'components')
.leftJoinAndSelect('components.layouts', 'layouts')
.where('components.pageId IN(:...pageId)', {
pageId: pages.map((v) => v.id),
})
.orderBy('components.created_at', 'ASC')
.getMany()
.createQueryBuilder(Component, 'components')
.leftJoinAndSelect('components.layouts', 'layouts')
.where('components.pageId IN(:...pageId)', {
pageId: pages.map((v) => v.id),
})
.orderBy('components.created_at', 'ASC')
.getMany()
: [];
const events = await manager
@ -340,8 +352,8 @@ export class AppImportExportService {
return await catchDbException(async () => {
const importedApp = manager.create(App, {
name: appParams.name,
organizationId: user.organizationId,
userId: user.id,
organizationId: user?.organizationId,
userId: user.id, //fetch super admin user id for EE
slug: null,
icon: appParams.icon,
creationMode: `${isGitApp ? 'GIT' : 'DEFAULT'}`,
@ -762,7 +774,7 @@ export class AppImportExportService {
const { dataQueryMapping } = await this.createDataQueriesForAppVersion(
manager,
user.organizationId,
user?.organizationId,
importingDataQueriesForAppVersion,
importingDataSource,
dataSourceForAppVersion,
@ -1059,10 +1071,10 @@ export class AppImportExportService {
const options =
importingDataSource.kind === 'tooljetdb'
? this.replaceTooljetDbTableIds(
importingQuery.options,
externalResourceMappings['tooljet_database'],
organizationId
)
importingQuery.options,
externalResourceMappings['tooljet_database'],
organizationId
)
: importingQuery.options;
const newQuery = manager.create(DataQuery, {
@ -1153,7 +1165,7 @@ export class AppImportExportService {
appResourceMappings: AppResourceMappings
) {
const defaultDataSourceIds = await this.createDefaultDataSourceForVersion(
user.organizationId,
user?.organizationId,
appResourceMappings.appVersionMapping[appVersion.id],
DefaultDataSourceKinds,
manager
@ -1192,7 +1204,7 @@ export class AppImportExportService {
kind: dataSource.kind,
type: DataSourceTypes.DEFAULT,
scope: 'global',
organizationId: user.organizationId,
organizationId: user?.organizationId,
},
});
};
@ -1203,7 +1215,7 @@ export class AppImportExportService {
kind: dataSource.kind,
type: In([DataSourceTypes.DEFAULT, DataSourceTypes.SAMPLE]),
scope: 'global',
organizationId: user.organizationId,
organizationId: user?.organizationId,
},
});
};
@ -1221,7 +1233,7 @@ export class AppImportExportService {
if (plugin) {
const newDataSource = manager.create(DataSource, {
organizationId: user.organizationId,
organizationId: user?.organizationId,
name: dataSource.name,
kind: dataSource.kind,
type: DataSourceTypes.DEFAULT,
@ -1236,7 +1248,7 @@ export class AppImportExportService {
const createNewGlobalDs = async (ds: DataSource): Promise<DataSource> => {
const newDataSource = manager.create(DataSource, {
organizationId: user.organizationId,
organizationId: user?.organizationId,
name: dataSource.name,
kind: dataSource.kind,
type: DataSourceTypes.DEFAULT,
@ -1264,7 +1276,7 @@ export class AppImportExportService {
) {
appResourceMappings = { ...appResourceMappings };
const currentOrgEnvironments = await this.appEnvironmentUtilService.getAll(
user.organizationId,
user?.organizationId,
appVersion.appId,
manager
);
@ -1326,7 +1338,7 @@ export class AppImportExportService {
appResourceMappings = { ...appResourceMappings };
const { appVersionMapping, appDefaultEnvironmentMapping } = appResourceMappings;
const organization: Organization = await manager.findOne(Organization, {
where: { id: user.organizationId },
where: { id: user?.organizationId },
relations: ['appEnvironments'],
});
let currentEnvironmentId: string;
@ -1545,7 +1557,7 @@ export class AppImportExportService {
// Create default data sources
const defaultDataSourceIds = await this.createDefaultDataSourceForVersion(
user.organizationId,
user?.organizationId,
version.id,
DefaultDataSourceKinds,
manager
@ -1553,7 +1565,7 @@ export class AppImportExportService {
let envIdArray: string[] = [];
const organization: Organization = await manager.findOne(Organization, {
where: { id: user.organizationId },
where: { id: user?.organizationId },
relations: ['appEnvironments'],
});
envIdArray = [...organization.appEnvironments.map((env) => env.id)];
@ -1562,7 +1574,7 @@ export class AppImportExportService {
await Promise.all(
defaultAppEnvironments.map(async (en) => {
const env = manager.create(AppEnvironment, {
organizationId: user.organizationId,
organizationId: user?.organizationId,
name: en.name,
isDefault: en.isDefault,
priority: en.priority,
@ -1627,10 +1639,10 @@ export class AppImportExportService {
options:
dataSourceId == defaultDataSourceIds['tooljetdb']
? this.replaceTooljetDbTableIds(
query.options,
externalResourceMappings['tooljet_database'],
user.organizationId
)
query.options,
externalResourceMappings['tooljet_database'],
user?.organizationId
)
: query.options,
});
await manager.save(newQuery);

View file

@ -95,7 +95,9 @@ export class ComponentsService implements IComponentsService {
if (componentData.type === 'Table' && _.isArray(objValue)) {
return srcValue;
} else if (
(componentData.type === 'DropdownV2' || componentData.type === 'MultiselectV2') &&
(componentData.type === 'DropdownV2' ||
componentData.type === 'MultiselectV2' ||
componentData.type === 'Steps') &&
_.isArray(objValue)
) {
return _.isArray(srcValue) ? srcValue : Object.values(srcValue);

View file

@ -3,8 +3,8 @@ export const containerConfig = {
displayName: 'Container',
description: 'Group components',
defaultSize: {
width: 10,
height: 200,
width: 13,
height: 480,
},
component: 'Container',
others: {

View file

@ -4,25 +4,38 @@ export const stepsConfig = {
description: 'Step-by-step navigation aid',
component: 'Steps',
properties: {
variant: {
type: 'switch',
displayName: 'Variant',
validation: { schema: { type: 'string' }, defaultValue: 'titles' },
options: [
{ displayName: 'Label', value: 'titles' },
{ displayName: 'Number', value: 'numbers' },
{ displayName: 'Plain', value: 'plain' },
],
accordian: 'label',
},
schema: {
type: 'code',
displayName: 'Schema',
conditionallyRender: {
key: 'advanced',
value: true,
},
accordian: 'Options',
},
steps: {
type: 'code',
displayName: 'Steps',
displayName: '',
showLabel: false,
validation: {
schema: {
type: 'array',
element: { type: 'object', object: { id: { type: 'number' } } },
element: { type: 'object' },
},
defaultValue: `[{ name: 'step 1'}, {name: 'step 2'}]`,
},
},
currentStep: {
type: 'code',
displayName: 'Current step',
validation: {
schema: { type: 'number' },
defaultValue: 1,
},
},
stepsSelectable: {
type: 'toggle',
displayName: 'Steps selectable',
@ -30,6 +43,36 @@ export const stepsConfig = {
schema: { type: 'boolean' },
defaultValue: false,
},
section: 'additionalActions',
},
disabledState: {
type: 'toggle',
displayName: 'Disable',
validation: { schema: { type: 'boolean' } },
section: 'additionalActions',
},
visibility: {
type: 'toggle',
displayName: 'Visibility',
validation: { schema: { type: 'boolean' }, defaultValue: true },
section: 'additionalActions',
},
advanced: {
type: 'toggle',
displayName: 'Dynamic options',
validation: {
schema: { type: 'boolean' },
defaultValue: true,
},
accordian: 'Options',
},
currentStep: {
type: 'code',
displayName: 'Current step',
validation: {
schema: { type: 'number' },
defaultValue: 1,
},
},
},
defaultSize: {
@ -40,46 +83,126 @@ export const stepsConfig = {
showOnDesktop: { type: 'toggle', displayName: 'Show on desktop' },
showOnMobile: { type: 'toggle', displayName: 'Show on mobile' },
},
actions: [
{
handle: 'setStep',
displayName: 'Set step',
params: [
{
handle: 'option',
displayName: 'Option',
},
],
},
{
handle: 'setVisibility',
displayName: 'Set visibility',
params: [{ handle: 'visible', displayName: 'Value', defaultValue: '{{false}}', type: 'toggle' }],
},
{
handle: 'setDisabled',
displayName: 'Set disabled',
params: [{ handle: 'disable', displayName: 'Value', defaultValue: '{{true}}', type: 'toggle' }],
},
{
handle: 'resetSteps',
displayName: 'Reset steps',
params: [],
},
{
handle: 'setStepVisible',
displayName: 'Set step visible',
params: [
{
handle: 'id',
displayName: 'Step id',
},
{
handle: 'visibility',
displayName: 'visibility',
defaultValue: '{{false}}',
type: 'toggle',
},
],
},
{
handle: 'setStepDisable',
displayName: 'Set step disable',
params: [
{
handle: 'id',
displayName: 'Step id',
},
{
handle: 'disabled',
displayName: 'disabled',
defaultValue: '{{true}}',
type: 'toggle',
},
],
},
],
events: {
onSelect: { displayName: 'On select' },
},
styles: {
color: {
incompletedAccent: {
type: 'colorSwatches',
displayName: 'Color',
displayName: 'Incompleted accent',
validation: {
schema: { type: 'string' },
defaultValue: '#CCD1D5',
},
accordian: 'steps',
},
incompletedLabel: {
type: 'colorSwatches',
displayName: 'Incompleted label',
validation: {
schema: { type: 'string' },
defaultValue: '#1B1F24',
},
accordian: 'steps',
},
completedAccent: {
type: 'colorSwatches',
displayName: 'Completed accent',
validation: {
schema: { type: 'string' },
defaultValue: 'var(--primary-brand)',
},
accordian: 'steps',
},
textColor: {
completedLabel: {
type: 'colorSwatches',
displayName: 'Text color',
displayName: 'Completed label',
validation: {
schema: { type: 'string' },
defaultValue: '#000000',
defaultValue: '#1B1F24',
},
accordian: 'steps',
},
theme: {
type: 'select',
displayName: 'Theme',
currentStepLabel: {
type: 'colorSwatches',
displayName: 'Current step label',
validation: {
schema: { type: 'string' },
defaultValue: '#1B1F24',
},
accordian: 'steps',
},
padding: {
type: 'switch',
displayName: 'Padding',
validation: {
schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] },
defaultValue: 'default',
},
options: [
{ name: 'titles', value: 'titles' },
{ name: 'numbers', value: 'numbers' },
{ name: 'plain', value: 'plain' },
{ displayName: 'Default', value: 'default' },
{ displayName: 'None', value: 'none' },
],
validation: {
schema: { type: 'string' },
defaultValue: 'titles',
},
},
visibility: {
type: 'toggle',
displayName: 'Visibility',
validation: {
schema: { type: 'boolean' },
defaultValue: true,
},
accordian: 'container',
},
},
exposedVariables: {
@ -92,17 +215,35 @@ export const stepsConfig = {
},
properties: {
steps: {
value: `{{ [{ name: 'step 1', tooltip: 'some tooltip', id: 1},{ name: 'step 2', tooltip: 'some tooltip', id: 2},{ name: 'step 3', tooltip: 'some tooltip', id: 3},{ name: 'step 4', tooltip: 'some tooltip', id: 4},{ name: 'step 5', tooltip: 'some tooltip', id: 5}]}}`,
value: [
{ name: 'step 1', tooltip: '', id: 1, visible: { value: true }, disabled: { value: false } },
{ name: 'step 2', tooltip: '', id: 2, visible: { value: true }, disabled: { value: false } },
{ name: 'step 3', tooltip: '', id: 3, visible: { value: true }, disabled: { value: false } },
{ name: 'step 4', tooltip: '', id: 4, visible: { value: true }, disabled: { value: false } },
{ name: 'step 5', tooltip: '', id: 5, visible: { value: true }, disabled: { value: false } },
],
},
schema: {
value: `{{ [{ name: 'step 1', tooltip: '', id: 1,visible: true, disabled: false},{ name: 'step 2', tooltip: '', id: 2,visible: true, disabled: false},{ name: 'step 3', tooltip: '', id: 3,visible: true, disabled: false},{ name: 'step 4', tooltip: '', id: 4,visible: true, disabled: false},{ name: 'step 5', tooltip: '', id: 5,visible: true, disabled: false}]}}`,
},
disabledState: { value: '{{false}}' },
variant: { value: 'titles' },
currentStep: { value: '{{3}}' },
stepsSelectable: { value: true },
advanced: { value: `{{false}}` },
visibility: { value: '{{true}}' },
},
events: [],
styles: {
visibility: { value: '{{true}}' },
theme: { value: 'titles' },
color: { value: 'var(--primary-brand)' },
textColor: { value: '' },
// color: { value: '' },
// textColor: { value: '' },
padding: { value: 'default' },
incompletedAccent: { value: '#E4E7EB' },
incompletedLabel: { value: '#1B1F24' },
completedAccent: { value: '#4368E3' },
completedLabel: { value: '#1B1F24' },
currentStepLabel: { value: '#1B1F24' },
},
},
};

View file

@ -37,6 +37,9 @@ import { DataSourcesRepository } from '@modules/data-sources/repository';
import { IAppsUtilService } from './interfaces/IUtilService';
import { DataSourcesUtilService } from '@modules/data-sources/util.service';
import { AppVersionUpdateDto } from '@dto/app-version-update.dto';
import { WorkspaceAppsResponseDto } from '@modules/external-apis/dto';
import { DataQuery } from '@entities/data_query.entity';
import { DataSource } from '@entities/data_source.entity';
@Injectable()
export class AppsUtilService implements IAppsUtilService {
@ -487,7 +490,7 @@ export class AppsUtilService implements IAppsUtilService {
if (['Table'].includes(currentComponentData?.component?.component) && isArray(objValue)) {
return srcValue;
} else if (
['DropdownV2', 'MultiselectV2'].includes(currentComponentData?.component?.component) &&
['DropdownV2', 'MultiselectV2', 'Steps'].includes(currentComponentData?.component?.component) &&
isArray(objValue)
) {
return isArray(srcValue) ? srcValue : Object.values(srcValue);
@ -522,4 +525,45 @@ export class AppsUtilService implements IAppsUtilService {
return components;
}
async findAllOrganizationApps(organizationId: string): Promise<WorkspaceAppsResponseDto[]> {
return await this.appRepository.findAllOrganizationApps(organizationId);
}
async findTooljetDbTables(appId: string): Promise<{ table_id: string }[]> {
return await dbTransactionWrap(async (manager: EntityManager) => {
const tooljetDbDataQueries = await manager
.createQueryBuilder(DataQuery, 'data_queries')
.innerJoin(DataSource, 'data_sources', 'data_queries.data_source_id = data_sources.id')
.innerJoin(AppVersion, 'app_versions', 'app_versions.id = data_sources.app_version_id')
.where('app_versions.app_id = :appId', { appId })
.andWhere('data_sources.kind = :kind', { kind: 'tooljetdb' })
.getMany();
const uniqTableIds = new Set();
tooljetDbDataQueries.forEach((dq) => {
if (dq.options?.operation === 'join_tables') {
const joinOptions = dq.options?.join_table?.joins ?? [];
(joinOptions || []).forEach((join) => {
const { table, conditions } = join;
if (table) uniqTableIds.add(table);
conditions?.conditionsList?.forEach((condition) => {
const { leftField, rightField } = condition;
if (leftField?.table) {
uniqTableIds.add(leftField?.table);
}
if (rightField?.table) {
uniqTableIds.add(rightField?.table);
}
});
});
}
if (dq.options.table_id) uniqTableIds.add(dq.options.table_id);
});
return [...uniqTableIds].map((table_id) => {
return { table_id };
});
});
}
}

View file

@ -14,7 +14,7 @@ export class ExternalApiSecurityGuard implements CanActivate {
throw new ForbiddenException('External API is disabled');
}
// Check the authorization header
// // Check the authorization header
const authHeader = request.headers['authorization'];
const externalApiAccessToken = this.configService.get<string>('EXTERNAL_API_ACCESS_TOKEN');

View file

@ -174,7 +174,7 @@ export class OauthService implements IOAuthService {
// Not logging in to specific organization, creating new
const { name, slug } = generateNextNameAndSlug('My workspace');
defaultOrganization = await this.setupOrganizationsUtilService.create(name, slug, null, manager);
defaultOrganization = await this.setupOrganizationsUtilService.create({ name, slug }, null, manager);
userDetails = await this.userRepository.createOrUpdate(
{
@ -221,7 +221,7 @@ export class OauthService implements IOAuthService {
if (!isInviteRedirect) {
// no SSO login enabled organization available for user - creating new one
const { name, slug } = generateNextNameAndSlug('My workspace');
organizationDetails = await this.setupOrganizationsUtilService.create(name, slug, userDetails, manager);
organizationDetails = await this.setupOrganizationsUtilService.create({ name, slug }, userDetails, manager);
await this.userRepository.updateOne(
userDetails.id,
{ defaultOrganizationId: organizationDetails.id },

View file

@ -85,7 +85,7 @@ export class AuthService implements IAuthService {
} else if (allowPersonalWorkspace && !isInviteRedirect) {
// no form login enabled organization available for user - creating new one
const { name, slug } = generateNextNameAndSlug('My workspace');
organization = await this.setupOrganizationsUtilService.create(name, slug, user, manager);
organization = await this.setupOrganizationsUtilService.create({ name, slug }, user, manager);
} else {
if (!isInviteRedirect) throw new UnauthorizedException('User is not assigned to any workspaces');
}

View file

@ -149,7 +149,7 @@ export class AuthUtilService implements IAuthUtilService {
if (!user && allowPersonalWorkspace) {
const { name, slug } = generateNextNameAndSlug('My workspace');
defaultOrganization = await this.setupOrganizationsUtilService.create(name, slug, null, manager);
defaultOrganization = await this.setupOrganizationsUtilService.create({ name, slug }, null, manager);
}
const { source, status } = getUserStatusAndSource(lifecycleEvents.USER_SSO_ACTIVATE, sso);

View file

@ -22,7 +22,7 @@ export class DataQueriesService implements IDataQueriesService {
protected readonly dataQueryRepository: DataQueryRepository,
protected readonly dataQueryUtilService: DataQueriesUtilService,
protected readonly dataSourceRepository: DataSourcesRepository
) {}
) { }
async getAll(versionId: string) {
const queries = await this.dataQueryRepository.getAll(versionId);
@ -30,9 +30,6 @@ export class DataQueriesService implements IDataQueriesService {
// serialize
for (const query of queries) {
if (query.dataSource.type === DataSourceTypes.STATIC) {
delete query['dataSourceId'];
}
delete query['dataSource'];
const decamelizeQuery = decamelizeKeys(query);

View file

@ -37,5 +37,17 @@ export const FEATURES: FeaturesConfig = {
license: LICENSE_FIELD.EXTERNAL_API,
isPublic: true,
},
[FEATURE_KEY.GET_ALL_WORKSPACE_APPS]: {
license: LICENSE_FIELD.EXTERNAL_API,
isPublic: true,
},
[FEATURE_KEY.IMPORT_APP]: {
license: LICENSE_FIELD.EXTERNAL_API,
isPublic: true,
},
[FEATURE_KEY.EXPORT_APP]: {
license: LICENSE_FIELD.EXTERNAL_API,
isPublic: true,
},
},
};

View file

@ -7,4 +7,41 @@ export enum FEATURE_KEY {
UPDATE_USER_WORKSPACE = 'UPDATE_USER_WORKSPACE',
GET_ALL_WORKSPACES = 'GET_ALL_WORKSPACES',
UPDATE_USER_ROLE = 'UPDATE_USER_ROLE',
GET_ALL_WORKSPACE_APPS = 'GET_ALL_WORKSPACE_APPS',
IMPORT_APP = 'IMPORT_APP',
EXPORT_APP = 'EXPORT_APP',
}
export type DefaultDataSourceKind = 'restapi' | 'runjs' | 'runpy' | 'tooljetdb' | 'workflows';
export type NewRevampedComponent =
| 'Text'
| 'TextInput'
| 'PasswordInput'
| 'NumberInput'
| 'Table'
| 'Button'
| 'Checkbox';
export type DefaultDataSourceName =
| 'restapidefault'
| 'runjsdefault'
| 'runpydefault'
| 'tooljetdbdefault'
| 'workflowsdefault';
export const DefaultDataSourceKinds: DefaultDataSourceKind[] = ['restapi', 'runjs', 'runpy', 'tooljetdb', 'workflows'];
export const DefaultDataSourceNames: DefaultDataSourceName[] = [
'restapidefault',
'runjsdefault',
'runpydefault',
'tooljetdbdefault',
'workflowsdefault',
];
export const NewRevampedComponents: NewRevampedComponent[] = [
'Text',
'TextInput',
'PasswordInput',
'NumberInput',
'Table',
'Checkbox',
'Button',
];

View file

@ -1,8 +1,8 @@
import { Controller, Get, Param, UseGuards, Body, Patch, Post, Put, NotFoundException } from '@nestjs/common';
import { ExternalApiSecurityGuard } from './guards/external-api-security.guard';
import { UpdateUserDto, WorkspaceDto, UpdateGivenWorkspaceDto, CreateUserDto } from './dto';
import { IExternalApisController } from './Interfaces/IController';
import { EditUserRoleDto } from '@modules/roles/dto';
import { ExternalApiSecurityGuard } from '@modules/auth/guards/external-api-security.guard';
@Controller('ext')
export class ExternalApisController implements IExternalApisController {

View file

@ -10,10 +10,13 @@ import {
MaxLength,
ValidateIf,
IsNotEmpty,
IsDefined,
IsObject,
} from 'class-validator';
import { Type } from 'class-transformer';
import { Transform, Type } from 'class-transformer';
import { USER_ROLE } from '@modules/group-permissions/constants';
import { TjdbSchemaToLatestVersion } from '@dto/transformers/resource-transformer';
import { ValidateTooljetDatabaseImportSchema } from '@dto/validators/tooljet-database.validator';
export enum Status {
ACTIVE = 'active',
ARCHIVED = 'archived',
@ -131,3 +134,73 @@ export class UpdateUserWorkspaceDto {
@IsOptional()
groups?: GroupDto[];
}
export class VersionDto {
id: string;
name: string;
createdAt?: Date;
}
export class AppWithVersionsDto {
id: string;
name: string;
slug: string;
createdAt: Date;
organizationId: string;
versions: VersionDto[];
versionCount: number;
}
export class WorkspaceAppsResponseDto {
apps: AppWithVersionsDto[];
total: number;
}
export class AppImportRequestDto {
@IsString()
tooljet_version: string;
// TODO: Add transformation and validation for app similar to tooljet_database
@IsOptional()
app: AppImportDto[];
// Optional parameter -> To be provided in import request to import app with custom name.
@IsOptional()
@IsString()
appName: string;
// TJ-DB field
@IsOptional()
// Transform the input data to the latest schema version
// This should be applied first to ensure the data is in
// the correct format before validation
@Transform(TjdbSchemaToLatestVersion)
@ValidateNested({ each: true })
// Ensure each item is properly instantiated as ImportTooljetDatabaseDto
// This is crucial for nested validation to work correctly
@Type(() => ImportTooljetDatabaseDto)
// Custom validator to check against the tooljet database schema
// This should be applied last to validate the transformed
// and instantiated data
@ValidateTooljetDatabaseImportSchema({ each: true })
tooljet_database: ImportTooljetDatabaseDto[];
}
export class AppImportDto {
@IsDefined()
@IsObject()
definition: any;
}
export class ImportTooljetDatabaseDto {
@IsUUID()
id: string;
@IsString()
table_name: string;
@IsDefined()
schema: any;
// @IsOptional()
// data: boolean;
}

View file

@ -1,27 +0,0 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class ExternalApiSecurityGuard implements CanActivate {
constructor(protected configService: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
// Check if external API is enabled
const isExternalApiEnabled = this.configService.get<string>('ENABLE_EXTERNAL_API') === 'true';
if (!isExternalApiEnabled) {
throw new ForbiddenException('External API is disabled');
}
// Check the authorization header
const authHeader = request.headers['authorization'];
const externalApiAccessToken = this.configService.get<string>('EXTERNAL_API_ACCESS_TOKEN');
if (!authHeader || authHeader !== `Basic ${externalApiAccessToken}`) {
throw new ForbiddenException('Unauthorized');
}
return true;
}
}

View file

@ -3,8 +3,12 @@ import { GroupPermissionsModule } from '@modules/group-permissions/module';
import { RolesModule } from '@modules/roles/module';
import { DynamicModule } from '@nestjs/common';
import { getImportPath } from '@modules/app/constants';
import { ExternalApiSecurityGuard } from './guards/external-api-security.guard';
import { RolesRepository } from '@modules/roles/repository';
import { TooljetDbModule } from '@modules/tooljet-db/module';
import { AppsModule } from '@modules/apps/module';
import { OrganizationsModule } from '@modules/organizations/module';
import { VersionModule } from '@modules/versions/module';
import { UsersModule } from '@modules/users/module';
export class ExternalApiModule {
static async register(configs?: { IS_GET_CONTEXT: boolean }): Promise<DynamicModule> {
const importPath = await getImportPath(configs?.IS_GET_CONTEXT);
@ -14,14 +18,16 @@ export class ExternalApiModule {
return {
module: ExternalApiModule,
imports: [await RolesModule.register(configs), await GroupPermissionsModule.register(configs)],
providers: [
ExternalApiUtilService,
ExternalApisService,
ExternalApiSecurityGuard,
FeatureAbilityFactory,
RolesRepository,
imports: [
await UsersModule.register(configs),
await RolesModule.register(configs),
await GroupPermissionsModule.register(configs),
await TooljetDbModule.register(configs),
await AppsModule.register(configs),
await OrganizationsModule.register(configs),
await VersionModule.register(configs),
],
providers: [ExternalApiUtilService, ExternalApisService, FeatureAbilityFactory, RolesRepository],
controllers: [ExternalApisController],
exports: [ExternalApiUtilService],
};

View file

@ -11,6 +11,9 @@ interface Features {
[FEATURE_KEY.UPDATE_USER_WORKSPACE]: FeatureConfig;
[FEATURE_KEY.GET_ALL_WORKSPACES]: FeatureConfig;
[FEATURE_KEY.UPDATE_USER_ROLE]: FeatureConfig;
[FEATURE_KEY.GET_ALL_WORKSPACE_APPS]: FeatureConfig;
[FEATURE_KEY.IMPORT_APP]: FeatureConfig;
[FEATURE_KEY.EXPORT_APP]: FeatureConfig;
}
export interface FeaturesConfig {
@ -22,3 +25,13 @@ export interface ValidateEditUserGroupAdditionObject {
groupsToAddIds: string[];
organizationId: string;
}
export interface AppResourceMappings {
defaultDataSourceIdMapping: Record<string, string>;
dataQueryMapping: Record<string, string>;
appVersionMapping: Record<string, string>;
appEnvironmentMapping: Record<string, string>;
appDefaultEnvironmentMapping: Record<string, string[]>;
pagesMapping: Record<string, string>;
componentsMapping: Record<string, string>;
}

View file

@ -26,7 +26,7 @@ export class LoginConfigsService implements ILoginConfigsService {
throw new NotFoundException();
}
if (!organizationId) {
const result = this.loginConfigsUtilService.constructSSOConfigs();
const result = await this.loginConfigsUtilService.constructSSOConfigs();
return result;
}

View file

@ -49,7 +49,6 @@ export class OnboardingController implements IOnboardingController {
@InitFeature(FEATURE_KEY.SIGNUP)
@UseGuards(
SignupDisableGuard,
AllowPersonalWorkspaceGuard,
UserCountGuard,
EditorUserCountGuard,
FirstUserSignupDisableGuard,

View file

@ -26,6 +26,7 @@ export interface IOnboardingUtilService {
signingUpOrganization: Organization,
userParams: { firstName: string; lastName: string; password: string },
redirectTo?: string,
defaultWorkspace?: Organization,
manager?: EntityManager
): Promise<void>;
processOrganizationSignup(
@ -40,4 +41,10 @@ export interface IOnboardingUtilService {
organizationInviteUrl: string;
}>;
splitName(name: string): { firstName: string; lastName: string };
updateExistingUserDefaultWorkspace(
userParams: { password: string; firstName: string; lastName: string },
existingUser: User,
defaultWorkspace: Organization,
manager?: EntityManager
)
}

View file

@ -119,6 +119,9 @@ export class OnboardingService implements IOnboardingService {
const { firstName, lastName } = names;
const userParams = { email, password, firstName, lastName };
// Find the default workspace
const defaultWorkspace = await this.organizationRepository. getDefaultWorkspaceOfInstance();
if (existingUser) {
// Handling instance and workspace level signup for existing user
return await this.onboardingUtilService.whatIfTheSignUpIsAtTheWorkspaceLevel(
@ -126,9 +129,18 @@ export class OnboardingService implements IOnboardingService {
signingUpOrganization,
userParams,
redirectTo,
defaultWorkspace,
manager
);
} else {
if(defaultWorkspace && !signingUpOrganization) {
return await this.onboardingUtilService.createUserInDefaultWorkspace(
userParams,
defaultWorkspace,
redirectTo,
manager
);
}
return await this.onboardingUtilService.createUserOrPersonalWorkspace(
userParams,
existingUser,
@ -149,8 +161,7 @@ export class OnboardingService implements IOnboardingService {
const result = await dbTransactionWrap(async (manager: EntityManager) => {
// Create first organization
const organization = await this.organizationRepository.createOne(
workspace || 'My workspace',
'my-workspace',
{ name: workspace || 'My workspace', slug: 'my-workspace' },
manager
);
@ -226,7 +237,8 @@ export class OnboardingService implements IOnboardingService {
(await this.instanceSettingsUtilService.getSettings(INSTANCE_USER_SETTINGS.ALLOW_PERSONAL_WORKSPACE)) ===
'true';
if (!(allowPersonalWorkspace || organizationToken)) {
const defaultWorkspace = await this.organizationRepository.getDefaultWorkspaceOfInstance();
if (!(defaultWorkspace || allowPersonalWorkspace || organizationToken)) {
throw new BadRequestException('Invalid invitation link');
}
if (organizationToken) {
@ -251,7 +263,8 @@ export class OnboardingService implements IOnboardingService {
throw new BadRequestException('Please enter password');
}
if (allowPersonalWorkspace) {
const activateDefaultWorkspace = (defaultWorkspace && defaultWorkspace.id === user.defaultOrganizationId) || allowPersonalWorkspace;
if (activateDefaultWorkspace) {
// Getting default workspace
const defaultOrganizationUser: OrganizationUser = user.organizationUsers.find(
(ou) => ou.organizationId === user.defaultOrganizationId
@ -264,6 +277,14 @@ export class OnboardingService implements IOnboardingService {
// Activate default workspace
await this.organizationUsersUtilService.activateOrganization(defaultOrganizationUser, manager);
if(defaultWorkspace && defaultWorkspace.id === user.defaultOrganizationId){
const personalWorkspaces = await this.organizationUsersUtilService.personalWorkspaces(user.id);
for(const personalWorkspace of personalWorkspaces){
// if any personal workspace left. activate those
await this.organizationUsersUtilService.activateOrganization(personalWorkspace, manager);
}
}
if (workspaceName) {
const { slug } = generateNextNameAndSlug('My workspace');
await this.organizationRepository.updateOne(
@ -449,10 +470,10 @@ export class OnboardingService implements IOnboardingService {
onboarding_details: {
status: user.onboardingStatus,
password: isPasswordMandatory(user.source), // Should accept password if user is setting up first time
questions:
(this.configService.get<string>('ENABLE_ONBOARDING_QUESTIONS_FOR_ALL_SIGN_UPS') === 'true' &&
!organizationUser) || // Should ask onboarding questions if first user of the instance. If ENABLE_ONBOARDING_QUESTIONS_FOR_ALL_SIGN_UPS=true, then will ask questions to all signup users
(await this.userRepository.count({ where: { status: USER_STATUS.ACTIVE } })) === 0,
// questions:
// (this.configService.get<string>('ENABLE_ONBOARDING_QUESTIONS_FOR_ALL_SIGN_UPS') === 'true' &&
// !organizationUser) || // Should ask onboarding questions if first user of the instance. If ENABLE_ONBOARDING_QUESTIONS_FOR_ALL_SIGN_UPS=true, then will ask questions to all signup users
// (await this.userRepository.count({ where: { status: USER_STATUS.ACTIVE } })) === 0,
},
};
}
@ -686,8 +707,7 @@ export class OnboardingService implements IOnboardingService {
// Create first organization
const workspaceSlug = generateWorkspaceSlug(workspaceName || 'My workspace');
const organization = await this.setupOrganizationsUtilService.create(
workspaceName || 'My workspace',
workspaceSlug,
{ name: workspaceName || 'My workspace', slug: workspaceSlug },
null,
manager
);

View file

@ -151,6 +151,7 @@ export class OnboardingUtilService implements IOnboardingUtilService {
signingUpOrganization: Organization,
userParams: { firstName: string; lastName: string; password: string },
redirectTo?: string,
defaultWorkspace?: Organization,
manager?: EntityManager
) => {
return dbTransactionWrap(async (manager: EntityManager) => {
@ -251,19 +252,28 @@ export class OnboardingUtilService implements IOnboardingUtilService {
case hasWorkspaceInviteButUserWantsInstanceSignup: {
const firstTimeSignup = ![SOURCE.SIGNUP, SOURCE.WORKSPACE_SIGNUP].includes(existingUser.source as SOURCE);
if (firstTimeSignup) {
if(defaultWorkspace) {
return this.updateExistingUserDefaultWorkspace({
password,
firstName,
lastName
},existingUser, defaultWorkspace, manager);
}
/* Invite user doing instance signup. So reset name fields and set password */
let defaultOrganizationId = existingUser.defaultOrganizationId;
const isPersonalWorkspaceAllowed =
(await this.instanceSettingsUtilService.getSettings(INSTANCE_USER_SETTINGS.ALLOW_PERSONAL_WORKSPACE)) ===
'true';
if (!existingUser.defaultOrganizationId && isPersonalWorkspaceAllowed) {
if (!existingUser.defaultOrganizationId && isPersonalWorkspaceAllowed) {
const personalWorkspaces = await this.organizationUsersUtilService.personalWorkspaces(existingUser.id);
if (personalWorkspaces.length) {
defaultOrganizationId = personalWorkspaces[0].organizationId;
} else {
/* Create a personal workspace for the user */
const { name, slug } = generateNextNameAndSlug('My workspace');
const defaultOrganization = await this.organizationRepository.createOne(name, slug, manager);
const defaultOrganization = await this.organizationRepository.createOne({ name, slug }, manager);
defaultOrganizationId = defaultOrganization.id;
await this.organizationUserRepository.createOne(existingUser, defaultOrganization, true, manager);
}
@ -272,7 +282,6 @@ export class OnboardingUtilService implements IOnboardingUtilService {
userId: existingUser.id,
});
}
await this.userRepository.updateOne(
existingUser.id,
{
@ -398,7 +407,7 @@ export class OnboardingUtilService implements IOnboardingUtilService {
let personalWorkspace: Organization;
if (isPersonalWorkspaceEnabled) {
const { name, slug } = generateNextNameAndSlug('My workspace');
personalWorkspace = await this.setupOrganizationsUtilService.create(name, slug, null, manager);
personalWorkspace = await this.setupOrganizationsUtilService.create({ name, slug }, null, manager);
}
const organizationRole = personalWorkspace ? USER_ROLE.ADMIN : USER_ROLE.END_USER;
@ -604,4 +613,130 @@ export class OnboardingUtilService implements IOnboardingUtilService {
manager
);
}
createUserInDefaultWorkspace = async (
userParams: { email: string; password: string; firstName: string; lastName: string },
defaultWorkspace: Organization,
redirectTo?: string,
manager?: EntityManager
) => {
return await dbTransactionWrap(async (manager: EntityManager) => {
const { email, password, firstName, lastName } = userParams;
if (!defaultWorkspace) {
throw new Error('No default workspace found in the instance');
}
// Create user with end-user role in default workspace
const lifeCycleParms = getUserStatusAndSource(lifecycleEvents.USER_SIGN_UP);
const user = await this.create(
{
email,
password,
...(firstName && { firstName }),
...(lastName && { lastName }),
...lifeCycleParms,
},
defaultWorkspace.id,
USER_ROLE.END_USER,
null,
true,
null,
manager,
false
);
// Create organization user entry
await this.organizationUserRepository.createOne(
user,
defaultWorkspace,
true,
manager,
WORKSPACE_USER_SOURCE.SIGNUP
);
// Validate license
await this.licenseUserService.validateUser(manager);
// Send welcome email
this.eventEmitter.emit('emailEvent', {
type: EMAIL_EVENTS.SEND_WELCOME_EMAIL,
payload: {
to: user.email,
name: user.firstName,
invitationtoken: user.invitationToken,
},
});
return {};
}, manager);
};
updateExistingUserDefaultWorkspace = async (
userParams: { password: string; firstName: string; lastName: string },
existingUser: User,
defaultWorkspace: Organization,
manager?: EntityManager
) => {
return await dbTransactionWrap(async (manager: EntityManager) => {
const { password, firstName, lastName } = userParams;
// Create organization user entry if not exists
const existingOrgUser = await this.organizationUserRepository.findOne({
where: {
userId: existingUser.id,
organizationId: defaultWorkspace.id,
}
});
if(existingOrgUser){
throw new NotAcceptableException(
'The user is already registered. Please check your inbox for the activation link'
);
}
// Update user's default organization ID
await this.userRepository.updateOne(
existingUser.id,
{
password,
firstName,
lastName,
source: SOURCE.SIGNUP,
defaultOrganizationId: defaultWorkspace.id,
},
manager
);
await this.organizationUserRepository.createOne(
existingUser,
defaultWorkspace,
true,
manager,
WORKSPACE_USER_SOURCE.SIGNUP
);
// Add end-user role in default workspace if not already present
await this.rolesUtilService.addUserRole(
defaultWorkspace.id,
{ role: USER_ROLE.END_USER, userId: existingUser.id },
manager
);
// Validate license
await this.licenseUserService.validateUser(manager);
// send welcome email
this.eventEmitter.emit('emailEvent', {
type: EMAIL_EVENTS.SEND_WELCOME_EMAIL,
payload: {
to: existingUser.email,
name: existingUser.firstName,
invitationtoken: existingUser.invitationToken,
},
});
return {};
}, manager);
};
}

View file

@ -31,11 +31,11 @@ export const TJDefaultTheme: Definition = {
light: '#1B1F24',
dark: '#CFD3D8',
},
secondary: {
placeholder: {
light: '#6A727C',
dark: '#858C94',
},
tertiary: {
disabled: {
light: '#ACB2B9',
dark: '#545B64',
},
@ -48,15 +48,15 @@ export const TJDefaultTheme: Definition = {
large: 0,
},
colors: {
primary: {
default: {
light: '#CCD1D5',
dark: '#3C434B',
},
secondary: {
weak: {
light: '#E4E7EB',
dark: '#EEF0F1',
},
tertiary: {
disabled: {
light: '#E4E7EB',
dark: '#F6F8FA',
},
@ -64,15 +64,15 @@ export const TJDefaultTheme: Definition = {
},
systemStatus: {
colors: {
primary: {
success: {
light: '#1E823B',
dark: '#318344',
},
secondary: {
error: {
light: '#D72D39',
dark: '#D03F43',
},
tertiary: {
warning: {
light: '#BF4F03',
dark: '#BA5722',
},
@ -84,6 +84,18 @@ export const TJDefaultTheme: Definition = {
light: '#F6F6F6',
dark: '#121518',
},
surface1: {
light: '#FFFFFF',
dark: '#1E2226',
},
surface2: {
light: '#F6F8FA',
dark: '#2B3036',
},
surface3: {
light: '#E4E7EB',
dark: '#3C434B',
},
},
},
};

View file

@ -33,12 +33,12 @@ class TextColors {
@IsOptional()
@ValidateNested()
@Type(() => Color)
secondary?: Color;
placeholder?: Color;
@IsOptional()
@ValidateNested()
@Type(() => Color)
tertiary?: Color;
disabled?: Color;
}
class Text {
@ -64,17 +64,17 @@ class BorderRadius {
class BorderColors {
@ValidateNested()
@Type(() => Color)
primary: Color;
default: Color;
@IsOptional()
@ValidateNested()
@Type(() => Color)
secondary?: Color;
weak?: Color;
@IsOptional()
@ValidateNested()
@Type(() => Color)
tertiary?: Color;
disabled?: Color;
}
class Border {
@ -90,17 +90,17 @@ class Border {
class SystemStatusColors {
@ValidateNested()
@Type(() => Color)
primary: Color;
success: Color;
@IsOptional()
@ValidateNested()
@Type(() => Color)
secondary?: Color;
error?: Color;
@IsOptional()
@ValidateNested()
@Type(() => Color)
tertiary?: Color;
warning?: Color;
}
class SystemStatus {
@ -121,6 +121,18 @@ class SurfaceColors {
@ValidateNested()
@Type(() => AppBackgroundColor)
appBackground: AppBackgroundColor;
@ValidateNested()
@Type(() => Color)
surface1: Color;
@ValidateNested()
@Type(() => Color)
surface2: Color;
@ValidateNested()
@Type(() => Color)
surface3: Color;
}
class Surface {

View file

@ -7,6 +7,7 @@ import {
lifecycleEvents,
USER_STATUS,
USER_TYPE,
WORKSPACE_USER_SOURCE,
WORKSPACE_USER_STATUS,
} from '@modules/users/constants/lifecycle';
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
@ -212,7 +213,7 @@ export class OrganizationUsersUtilService implements IOrganizationUsersUtilServi
async createDefaultOrganization(manager: EntityManager) {
const { name, slug } = generateNextNameAndSlug('My workspace');
return await this.setupOrganizationsUtilService.create(name, slug, null, manager);
return await this.setupOrganizationsUtilService.create({ name, slug }, null, manager);
}
addUserAsAdmin(userId: string, organizationId: string, manager: EntityManager) {
@ -343,7 +344,7 @@ export class OrganizationUsersUtilService implements IOrganizationUsersUtilServi
async personalWorkspaces(userId: string): Promise<OrganizationUser[]> {
const personalWorkspaces: Partial<OrganizationUser[]> = await this.organizationUsersRepository.find({
select: ['organizationId', 'invitationToken'],
select: ['organizationId', 'invitationToken', 'id'],
where: { userId },
});
const personalWorkspaceArray: OrganizationUser[] = [];
@ -578,4 +579,41 @@ export class OrganizationUsersUtilService implements IOrganizationUsersUtilServi
user.organizationUserSource = organizationUser.source;
return user;
}
addUserToWorkspace = async (
user: User,
workspace: Organization,
manager?: EntityManager
) => {
return await dbTransactionWrap(async (manager: EntityManager) => {
// Create organization user entry if not exists
let existingOrgUser = await this.organizationUsersRepository.findOne({
where: {
userId: user.id,
organizationId: workspace.id,
}
});
if(existingOrgUser){
return existingOrgUser;
}
const organizationUser = await this.organizationUsersRepository.createOne(
user,
workspace,
true,
manager,
WORKSPACE_USER_SOURCE.SIGNUP
);
// Add end-user role in default workspace if not already present
await this.rolesUtilService.addUserRole(
workspace.id,
{ role: USER_ROLE.END_USER, userId: user.id },
manager
);
return organizationUser;
}, manager);
};
}

View file

@ -44,7 +44,7 @@ export class FeatureAbilityFactory extends AbilityFactory<FEATURE_KEY, Subjects>
can([FEATURE_KEY.UPDATE, FEATURE_KEY.GET, FEATURE_KEY.CHECK_UNIQUE], Organization);
}
if (superAdmin) {
can([FEATURE_KEY.WORKSPACE_STATUS_UPDATE], Organization);
can([FEATURE_KEY.WORKSPACE_STATUS_UPDATE, FEATURE_KEY.SET_DEFAULT], Organization);
}
}
}

View file

@ -14,5 +14,6 @@ export const FEATURES: FeaturesConfig = {
[FEATURE_KEY.CHECK_UNIQUE_ONBOARDING]: {
isPublic: true,
},
[FEATURE_KEY.SET_DEFAULT]: {},
},
};

View file

@ -26,4 +26,5 @@ export enum FEATURE_KEY {
CHECK_UNIQUE = 'check_unique',
CREATE = 'create',
CHECK_UNIQUE_ONBOARDING = 'check_unique_onboarding',
SET_DEFAULT = 'set_default',
}

View file

@ -1,4 +1,4 @@
import { Body, Controller, Get, Patch, UseGuards, Query, Param } from '@nestjs/common';
import { Body, Controller, Get, Patch, UseGuards, Query, Param, NotImplementedException } from '@nestjs/common';
import { OrganizationsService } from '@modules/organizations/service';
import { decamelizeKeys } from 'humps';
import { User } from '@modules/app/decorators/user.decorator';
@ -17,7 +17,7 @@ import { OrganizationAuthGuard } from '@modules/session/guards/organization-auth
@Controller('organizations')
@InitModule(MODULES.ORGANIZATIONS)
export class OrganizationsController implements IOrganizationsController {
constructor(private organizationsService: OrganizationsService) {}
constructor(protected organizationsService: OrganizationsService) {}
@InitFeature(FEATURE_KEY.GET)
// TODO: Change to jwt auth guard - check why we need OrganizationAuthGuard here
@ -41,6 +41,15 @@ export class OrganizationsController implements IOrganizationsController {
await this.organizationsService.updateOrganizationNameAndSlug(user.organizationId, organizationUpdateDto);
return;
}
@InitFeature(FEATURE_KEY.SET_DEFAULT)
@UseGuards(JwtAuthGuard, FeatureAbilityGuard)
@Patch(':id/set-default')
async setDefaultWorkspace(@Param('id') id: string) {
await this.organizationsService.setDefaultWorkspace(id);
return;
}
// Note : This endpoint is used for archive/unarchive workspaces.
@InitFeature(FEATURE_KEY.WORKSPACE_STATUS_UPDATE)
@UseGuards(JwtAuthGuard)

View file

@ -11,4 +11,6 @@ export interface IOrganizationsController {
checkWorkspaceUnique(name: string, slug: string): Promise<void>;
checkUniqueWorkspaceName(name: string): Promise<void>;
setDefaultWorkspace(id: string): Promise<void>;
}

View file

@ -1,5 +1,6 @@
import { Organization } from 'src/entities/organization.entity';
import { OrganizationUpdateDto, OrganizationStatusUpdateDto } from '@modules/organizations/dto';
import { EntityManager } from 'typeorm';
export interface IOrganizationsService {
fetchOrganizations(
@ -15,4 +16,8 @@ export interface IOrganizationsService {
updateOrganizationStatus(organizationId: string, updatableData: OrganizationStatusUpdateDto): Promise<Organization>;
checkWorkspaceUniqueness(name: string, slug: string): Promise<void>;
checkWorkspaceNameUniqueness(name: string): Promise<void>;
setDefaultWorkspace(organizationId: string, manager?: EntityManager): Promise<void>;
}

View file

@ -0,0 +1,3 @@
export interface IOrganizationUtilService {
validateWorkspaceExists(workspaceId: string): Promise<void>;
}

View file

@ -2,6 +2,7 @@ import { DynamicModule } from '@nestjs/common';
import { getImportPath } from '@modules/app/constants';
import { InstanceSettingsModule } from '@modules/instance-settings/module';
import { OrganizationRepository } from './repository';
import { AppEnvironmentsModule } from '@modules/app-environments/module';
export class OrganizationsModule {
static async register(configs?: { IS_GET_CONTEXT: boolean }): Promise<DynamicModule> {
@ -9,13 +10,14 @@ export class OrganizationsModule {
const { OrganizationsService } = await import(`${importPath}/organizations/service`);
const { OrganizationsController } = await import(`${importPath}/organizations/controller`);
const { FeatureAbilityFactory } = await import(`${importPath}/organizations/ability`);
const { AppEnvironmentUtilService } = await import(`${importPath}/app-environments/util.service`);
const { OrganizationsUtilService } = await import(`${importPath}/organizations/util.service`);
return {
module: OrganizationsModule,
imports: [await InstanceSettingsModule.register(configs)],
imports: [await InstanceSettingsModule.register(configs), await AppEnvironmentsModule.register(configs)],
controllers: [OrganizationsController],
providers: [OrganizationsService, OrganizationRepository, FeatureAbilityFactory, AppEnvironmentUtilService],
providers: [OrganizationsService, OrganizationRepository, FeatureAbilityFactory, OrganizationsUtilService],
exports: [OrganizationsUtilService],
};
}
}

View file

@ -7,6 +7,7 @@ import { catchDbException, isSuperAdmin } from '@helpers/utils.helper';
import { ConfigScope, SSOType } from '@entities/sso_config.entity';
import { WORKSPACE_STATUS, WORKSPACE_USER_STATUS } from '@modules/users/constants/lifecycle';
import { CONSTRAINTS } from './constants';
import { OrganizationInputs } from '@modules/setup-organization/types/organization-inputs';
@Injectable()
export class OrganizationRepository extends Repository<Organization> {
@ -106,7 +107,8 @@ export class OrganizationRepository extends Repository<Organization> {
}, manager);
}
createOne(name: string, slug: string, manager?: EntityManager): Promise<any> {
createOne(organizationInputs: OrganizationInputs, manager?: EntityManager): Promise<any> {
const { name, slug, isDefault } = organizationInputs;
return dbTransactionWrap((manager: EntityManager) => {
return catchDbException(() => {
return manager.save(
@ -120,6 +122,7 @@ export class OrganizationRepository extends Repository<Organization> {
],
name,
slug,
isDefault,
createdAt: new Date(),
updatedAt: new Date(),
})
@ -201,4 +204,27 @@ export class OrganizationRepository extends Repository<Organization> {
});
});
}
async getDefaultWorkspaceOfInstance(): Promise<Organization>{
return dbTransactionWrap(async (manager: EntityManager) => {
try {
return await manager.findOneOrFail(Organization, {
where: { isDefault: true },
});
} catch (error) {
console.error('No default workspace in this instance');
return null;
}
});
}
async changeDefaultWorkspace(organizationId: string, manager?: EntityManager): Promise<void> {
return await dbTransactionWrap(async (manager: EntityManager) => {
// First, unset any existing default workspace
await manager.update(Organization, { isDefault: true }, { isDefault: false });
// Then set the new default workspace
await manager.update(Organization, { id: organizationId }, { isDefault: true });
}, manager || this.manager);
}
}

View file

@ -1,4 +1,4 @@
import { ConflictException, Injectable, NotAcceptableException } from '@nestjs/common';
import { ConflictException, Injectable, NotAcceptableException, NotImplementedException } from '@nestjs/common';
import { Organization } from 'src/entities/organization.entity';
import { isSuperAdmin } from 'src/helpers/utils.helper';
import { dbTransactionWrap } from 'src/helpers/database.helper';
@ -51,6 +51,11 @@ export class OrganizationsService implements IOrganizationsService {
updatableData: OrganizationStatusUpdateDto
): Promise<Organization> {
return await dbTransactionWrap(async (manager: EntityManager) => {
const organization = await this.organizationRepository.findOne({ where: { id: organizationId } });
if (organization.isDefault) {
throw new NotAcceptableException('Default workspace cannot be archived');
}
await this.organizationRepository.updateOne(organizationId, updatableData, manager);
if (updatableData.status === WORKSPACE_STATUS.ACTIVE) {
await this.licenseOrganizationService.validateOrganization(manager); //Check for only unarchiving
@ -85,4 +90,8 @@ export class OrganizationsService implements IOrganizationsService {
if (result) throw new ConflictException('Workspace name must be unique');
return;
}
async setDefaultWorkspace(organizationId: string, manager?: EntityManager): Promise<void> {
throw new NotImplementedException('This feature is only available in Enterprise Edition');
}
}

View file

@ -9,6 +9,7 @@ interface Features {
[FEATURE_KEY.CREATE]: FeatureConfig;
[FEATURE_KEY.CHECK_UNIQUE_ONBOARDING]: FeatureConfig;
[FEATURE_KEY.WORKSPACE_STATUS_UPDATE]: FeatureConfig;
[FEATURE_KEY.SET_DEFAULT]: FeatureConfig;
}
export interface FeaturesConfig {

View file

@ -0,0 +1,18 @@
import { Injectable } from '@nestjs/common';
import { OrganizationRepository } from './repository';
import { BadRequestException } from '@nestjs/common';
import { IOrganizationUtilService } from './interfaces/IUtilService';
@Injectable()
export class OrganizationsUtilService implements IOrganizationUtilService {
constructor(protected readonly organizationRepository: OrganizationRepository) {}
async validateWorkspaceExists(workspaceId: string) {
const existingWorkspace = await this.organizationRepository.findOne({
where: { id: workspaceId },
});
if (!existingWorkspace) {
throw new BadRequestException(`Invalid workspaceId: ${workspaceId}`);
}
}
}

View file

@ -1,13 +1,13 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { EditUserRoleDto } from './dto';
import { RolesUtilService } from './util.service';
import { ERROR_HANDLER } from '../group-permissions/constants/error';
import { _ } from 'lodash';
import { LicenseUserService } from '@modules/licensing/services/user.service';
import { dbTransactionWrap } from '@helpers/database.helper';
import { EntityManager } from 'typeorm';
import { RolesRepository } from './repository';
import { IRolesService } from './interfaces/IService';
import { EntityManager } from 'typeorm';
import { dbTransactionWrap } from '@helpers/database.helper';
import { LicenseUserService } from '@modules/licensing/services/user.service';
import { ERROR_HANDLER } from '@modules/group-permissions/constants/error';
import { _ } from 'lodash';
@Injectable()
export class RolesService implements IRolesService {

View file

@ -368,8 +368,8 @@ export class SessionUtilService {
async #onboardingFlags(user: User) {
let isFirstUserOnboardingCompleted = true;
let isOnboardingCompleted = true;
const isOnboardingQuestionsEnabled =
this.configService.get<string>('ENABLE_ONBOARDING_QUESTIONS_FOR_ALL_SIGN_UPS') === 'true';
// const isOnboardingQuestionsEnabled =
// this.configService.get<string>('ENABLE_ONBOARDING_QUESTIONS_FOR_ALL_SIGN_UPS') === 'true';
const instanceUsersCount = await this.userRepository.count({
where: { status: USER_STATUS.ACTIVE },
@ -383,14 +383,14 @@ export class SessionUtilService {
}
/* Signed up user check */
if (
instanceUsersCount > 1 &&
isOnboardingQuestionsEnabled &&
user.onboardingStatus !== OnboardingStatus.ONBOARDING_COMPLETED
) {
/* Signed up user went through onboarding flow, didn't complete */
isOnboardingCompleted = false;
}
// if (
// instanceUsersCount > 1 &&
// isOnboardingQuestionsEnabled &&
// user.onboardingStatus !== OnboardingStatus.ONBOARDING_COMPLETED
// ) {
// /* Signed up user went through onboarding flow, didn't complete */
// isOnboardingCompleted = false;
// }
return { isFirstUserOnboardingCompleted, isOnboardingCompleted };
}

View file

@ -29,8 +29,7 @@ export class SetupOrganizationsController implements ISetupOrganizationsControll
@Res({ passthrough: true }) response: Response
) {
const result = await this.setupOrganizationsService.create(
organizationCreateDto.name,
organizationCreateDto.slug,
{ name: organizationCreateDto.name, slug: organizationCreateDto.slug },
user
);

View file

@ -1,7 +1,8 @@
import { User } from 'src/entities/user.entity';
import { Organization } from 'src/entities/organization.entity';
import { EntityManager } from 'typeorm';
import { OrganizationInputs } from '../types/organization-inputs';
export interface ISetupOrganizationsService {
create(name: string, slug: string, user?: User, manager?: EntityManager): Promise<Organization>;
create(organizationInputs: OrganizationInputs, user?: User, manager?: EntityManager): Promise<Organization>;
}

View file

@ -1,7 +1,8 @@
import { User } from 'src/entities/user.entity';
import { EntityManager } from 'typeorm';
import { Organization } from '@entities/organization.entity';
import { OrganizationInputs } from '../types/organization-inputs';
export interface ISetupOrganizationsUtilService {
create(name: string, slug: string, user?: User, manager?: EntityManager): Promise<Organization>;
create(organizationInputs: OrganizationInputs, user?: User, manager?: EntityManager): Promise<Organization>;
}

View file

@ -4,12 +4,13 @@ import { User } from 'src/entities/user.entity';
import { EntityManager } from 'typeorm';
import { SetupOrganizationsUtilService } from './util.service';
import { ISetupOrganizationsService } from './interfaces/IService';
import { OrganizationInputs } from './types/organization-inputs';
@Injectable()
export class SetupOrganizationsService implements ISetupOrganizationsService {
constructor(protected readonly setupOrganizationsUtilService: SetupOrganizationsUtilService) {}
async create(name: string, slug: string, user?: User, manager?: EntityManager): Promise<Organization> {
return this.setupOrganizationsUtilService.create(name, slug, user, manager);
async create(organizationInputs: OrganizationInputs, user?: User, manager?: EntityManager): Promise<Organization> {
return this.setupOrganizationsUtilService.create(organizationInputs, user, manager);
}
}

View file

@ -0,0 +1,5 @@
export interface OrganizationInputs {
name: string;
slug: string;
isDefault?: boolean;
}

View file

@ -15,6 +15,7 @@ import { OrganizationUsersRepository } from '@modules/organization-users/reposit
import { SampleDataSourceService } from '@modules/data-sources/services/sample-ds.service';
import { ISetupOrganizationsUtilService } from './interfaces/IUtilService';
import { TooljetDbTableOperationsService } from '@modules/tooljet-db/services/tooljet-db-table-operations.service';
import { OrganizationInputs } from './types/organization-inputs';
@Injectable()
export class SetupOrganizationsUtilService implements ISetupOrganizationsUtilService {
@ -31,9 +32,9 @@ export class SetupOrganizationsUtilService implements ISetupOrganizationsUtilSer
protected readonly organizationUserRepository: OrganizationUsersRepository
) {}
async create(name: string, slug: string, user?: User, manager?: EntityManager): Promise<Organization> {
async create(organizationInputs: OrganizationInputs, user?: User, manager?: EntityManager): Promise<Organization> {
return await dbTransactionWrap(async (manager: EntityManager) => {
const organization = await this.organizationRepository.createOne(name, slug, manager);
const organization = await this.organizationRepository.createOne(organizationInputs, manager);
await this.appEnvironmentUtilService.createDefaultEnvironments(organization.id, manager);
await this.groupPermissionUtilService.createDefaultGroups(organization.id, manager);

View file

@ -16,6 +16,7 @@ export class UsersModule {
imports: [await SessionModule.register(configs)],
controllers: [UsersController],
providers: [UsersService, UserRepository, UsersUtilService, FeatureAbilityFactory],
exports: [UsersUtilService],
};
}
}

View file

@ -18,6 +18,7 @@ import { Organization } from '@entities/organization.entity';
import { OrganizationUser } from '@entities/organization_user.entity';
import { isSuperAdmin } from '@helpers/utils.helper';
import * as uuid from 'uuid';
import { USER_ROLE } from '@modules/group-permissions/constants';
type UserFilterOptions = { searchText?: string; status?: string; page?: number };
@ -168,6 +169,18 @@ export class UserRepository extends Repository<User> {
await manager.upsert(UserDetails, updatableParams, conflictsPaths);
}
async getUserWithAdminRole(organizationId: string, manager?: EntityManager): Promise<User | null> {
return dbTransactionWrap((manager: EntityManager) => {
return manager
.createQueryBuilder(User, 'user')
.innerJoin('user.userGroups', 'groupUsers')
.innerJoin('groupUsers.group', 'group')
.where('group.name = :groupName', { groupName: USER_ROLE.ADMIN })
.andWhere('group.organizationId = :organizationId', { organizationId })
.getOne();
}, manager || this.manager);
}
async findByEmail(
email: string,
organizationId?: string,

View file

@ -3,4 +3,5 @@ import { AppVersionUpdateDto } from '@dto/app-version-update.dto';
export interface IVersionUtilService {
updateVersion(appVersion: AppVersion, appVersionUpdateDto: AppVersionUpdateDto): Promise<void>;
fetchVersions(appId: string): Promise<AppVersion[]>;
}

Some files were not shown because too many files have changed in this diff Show more