Merge pull request #12894 from ToolJet/appbuilder/sprint-12

Appbuilder/sprint 12
This commit is contained in:
Johnson Cherian 2025-05-20 10:34:07 +05:30 committed by GitHub
commit cb7eb39334
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 2208 additions and 689 deletions

@ -1 +1 @@
Subproject commit 280578f99c45224428f78ee16285b62f4c3631fd
Subproject commit 1b77a556709211daed8924821383db9dccc95eb5

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

@ -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

@ -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 69bdefb1f3f1d35bd6e7231e50799ff10a77a60f
Subproject commit 8155e72286b253042ede33cab64a5099d441ff44

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

@ -490,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);

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

@ -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 {