diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index b0ee35a49..d6495ec71 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -17,6 +17,9 @@ jobs: env: QORUS_TOKEN: ${{ secrets.QORUS_TOKEN }} # Steps represent a sequence of tasks that will be executed as part of the job + concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true steps: # Get current time for the commit - name: Get current time @@ -73,11 +76,9 @@ jobs: run: | yarn test - - name: Serve Storybook and run tests + - name: Run story tests run: | - npx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \ - "npx http-server storybook-static --port 6006 --silent" \ - "npx wait-on tcp:6006 && yarn test-storybook" + yarn vitest src/stories - name: Publish to Chromatic id: chromatic_publish diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 428fd4021..d7a526b8f 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -30,11 +30,14 @@ const StorybookWrapper = ({ context, Story }: any) => { ); React.useEffect(() => { - if (context.args.isFullIDE) { + if (context.args.isFullIDE || context.args.useRealWebSockets) { // @ts-ignore window._useWebsocketsInStorybook = true; + } else { + // @ts-ignore + window._useWebsocketsInStorybook = false; } - }, [context.args.isFullIDE]); + }, [context.args.isFullIDE, context.args.useRealWebSockets]); // @ts-ignore React.useEffect(() => { diff --git a/.vscode/settings.json b/.vscode/settings.json index 5106f028f..a40c5760c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,6 @@ { // Turn off tsc task auto detection since we have the necessary tasks as npm scripts "typescript.tsc.autoDetect": "off", - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib", + "deepscan.enable": true } diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index 407a63d93..a329a7eda 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/__tests__/fields/select.test.tsx b/__tests__/fields/select.test.tsx index 0133ace2b..cb56001a9 100644 --- a/__tests__/fields/select.test.tsx +++ b/__tests__/fields/select.test.tsx @@ -20,7 +20,7 @@ test('renders with default items', () => { render( - ); @@ -42,7 +42,7 @@ test('renders with default items and value', () => { render( - ); @@ -56,7 +56,7 @@ test('renders with default items, onChange is fired', () => { render( - ); @@ -79,7 +79,7 @@ test('renders with 1 item, auto selects it', () => { render( - ); @@ -95,7 +95,7 @@ test('renders with default items with description and value', () render( - ); @@ -120,9 +120,9 @@ test('renders with default items and value, with forced dropdown name.includes('filter me')} /> diff --git a/chromatic.config.json b/chromatic.config.json index 25e183fe5..0207bd6be 100644 --- a/chromatic.config.json +++ b/chromatic.config.json @@ -1,5 +1,6 @@ { "projectId": "Project:65aa373e9d8605ecea107ce1", "projectToken": "chpt_1aaa74dd9c1b035", - "zip": true + "zip": true, + "onlyChanged": true } diff --git a/package.json b/package.json index db8d7a0ed..654da7817 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,9 @@ "@monaco-editor/react": "^4.6.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.3", "@qoretechnologies/python-parser": "^0.4.10", - "@qoretechnologies/reqore": "^0.48.18", - "@qoretechnologies/reqraft": "^0.6.9", - "@qoretechnologies/ts-toolkit": "^0.4.8", + "@qoretechnologies/reqore": "^0.48.24", + "@qoretechnologies/reqraft": "^0.6.10", + "@qoretechnologies/ts-toolkit": "0.4.13", "@sentry/browser": "^7.109.0", "@svgr/webpack": "^5.5.0", "@testing-library/jest-dom": "^5.16.5", diff --git a/src/App.tsx b/src/App.tsx index 4cdc0e81e..16ab823b6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -318,6 +318,7 @@ const App: FunctionComponent = ({ {tab === 'Interfaces' && } {!tab || (tab == 'CreateInterface' && )} +
diff --git a/src/Topbar.tsx b/src/Topbar.tsx index 060aac4b6..98ed3623c 100644 --- a/src/Topbar.tsx +++ b/src/Topbar.tsx @@ -10,11 +10,14 @@ import { useReqraftStorage } from '@qoretechnologies/reqraft'; import { useState } from 'react'; import { GlobalSearch } from './components/GlobalSearch'; import { GlobalSettings } from './components/GlobalSettings'; +import { useWhyDidYouUpdate } from './hooks/useWhyDidYouUpdate'; export const Topbar = () => { const [isSidebarOpen, update] = useReqraftStorage('sidebar-open', true, false); const [isSettingsOpen, setIsSettingsOpen] = useState(false); + useWhyDidYouUpdate('TopBar', { isSidebarOpen, isSettingsOpen }); + return ( <> {isSettingsOpen && ( diff --git a/src/common/common.scss b/src/common/common.scss index 09802c6e2..73a874836 100644 --- a/src/common/common.scss +++ b/src/common/common.scss @@ -12,7 +12,6 @@ a:hover, a:focus, a:visited, a:active { - text-decoration: none; color: inherit; } diff --git a/src/common/vscode.ts b/src/common/vscode.ts index 4c683395d..a628dbf77 100644 --- a/src/common/vscode.ts +++ b/src/common/vscode.ts @@ -414,20 +414,24 @@ export const vscode = action: 'select-items-response', testData: [ { - name: 'Item 1', + display_name: 'Item 1', + value: 'Item 1', desc: 'Item 1 description', }, { - name: 'Item 2', + display_name: 'Item 2', + value: 'Item 2', desc: 'Item 2 description', }, { - name: 'Item 3', + display_name: 'Item 3', + value: 'Item 3', desc: 'Item 3 description', filterMe: true, }, { - name: 'Item 4', + display_name: 'Item 4', + value: 'Item 4', desc: 'Item 4 description', }, ], diff --git a/src/components/AllowedValues/index.tsx b/src/components/AllowedValues/index.tsx new file mode 100644 index 000000000..114d01670 --- /dev/null +++ b/src/components/AllowedValues/index.tsx @@ -0,0 +1,175 @@ +import { ReqoreMultiSelect } from '@qoretechnologies/reqore'; +import { IReqoreButtonProps } from '@qoretechnologies/reqore/dist/components/Button'; +import { IReqorePanelProps } from '@qoretechnologies/reqore/dist/components/Panel'; +import { TQorusType } from '@qoretechnologies/ts-toolkit'; +import { size as count } from 'lodash'; +import { memo, useCallback, useMemo } from 'react'; +import { apiHost } from '../../common/vscode'; +import { useSavedValues } from '../../hooks/useSavedValues'; +import { ConnectionManagement } from '../ConnectionManagement'; +import Select, { ISelectFieldItem } from '../Field/select'; + +export interface IFieldAllowedValuesProps + extends Pick { + items: ISelectFieldItem[]; + type: TQorusType; + value: unknown; + name?: string; + onChange: (name: string, value: unknown) => void; + allowCreation?: boolean; + showDescription?: boolean; + app?: string; + action?: string; + showSavedValues?: boolean; +} + +export const FieldAllowedValues = memo( + ({ + items = [], + type, + onChange, + value, + allowCreation, + showSavedValues, + showDescription, + size, + disabled, + app, + action, + name, + readOnly, + }: IFieldAllowedValuesProps) => { + const savedValues = useSavedValues(type); + + const getAllowedItemActionsByType = useCallback( + ( + type: TQorusType, + item: unknown, + items: ISelectFieldItem[], + metadata?: ISelectFieldItem['metadata'] + ): IReqorePanelProps['actions'] => { + switch (type) { + case 'connection': { + return [ + { + as: ConnectionManagement, + props: { + selectedConnection: item, + //onChange: (value) => handleChange(name, value), + allowedValues: items, + redirectUri: `${apiHost}grant`, + app, + metadata, + action, + compact: true, + size, + }, + }, + ]; + } + default: { + return []; + } + } + }, + [size, app, action] + ); + + const fullItems = useMemo(() => { + let result: ISelectFieldItem[] = [ + ...items.map(({ metadata, ...rest }) => ({ + actions: getAllowedItemActionsByType(type, value, items, metadata), + ...rest, + })), + ]; + + if (showSavedValues && !readOnly && !disabled) { + result = [ + ...result, + ...savedValues.map(({ display_name, value, short_desc, actions }) => ({ + display_name: display_name.value, + short_desc: short_desc.value, + value: value.value, + icon: 'SaveLine', + badge: 'Saved', + actions, + })), + ] as ISelectFieldItem[]; + } + + return result; + }, [items, savedValues, type, value, showSavedValues, disabled, readOnly]); + + const handleMultiSelectChange = useCallback( + (value: string[]) => { + onChange(name, value); + }, + [onChange] + ); + + const multiSelectItems = useMemo( + () => + items.map((item) => ({ + value: item.value, + label: item.display_name, + description: item.short_desc, + intent: item.intent, + })), + [JSON.stringify(items)] + ); + + if (!count(fullItems) || type === 'enum' || (!count(items) && !showSavedValues)) { + return null; + } + + if (type === 'list' && count(items)) { + return ( + + ); + } + + // These are simple allowed values + if (count(items) && !allowCreation) { + return ( + + ); + } +); diff --git a/src/components/AppCatalogue/index.tsx b/src/components/AppCatalogue/index.tsx index e39c54b54..09cbb9086 100644 --- a/src/components/AppCatalogue/index.tsx +++ b/src/components/AppCatalogue/index.tsx @@ -1,4 +1,4 @@ -import { ReqoreCollection } from '@qoretechnologies/reqore'; +import { ReqoreCollection, ReqoreSpan } from '@qoretechnologies/reqore'; import { IReqoreCollectionProps } from '@qoretechnologies/reqore/dist/components/Collection'; import { IReqoreCollectionItemProps } from '@qoretechnologies/reqore/dist/components/Collection/item'; import { IReqorePanelProps } from '@qoretechnologies/reqore/dist/components/Panel'; @@ -6,6 +6,7 @@ import { IReqoreIconName } from '@qoretechnologies/reqore/dist/types/icons'; import timeago from 'epoch-timeago'; import { map, size } from 'lodash'; import { useCallback, useMemo, useState } from 'react'; +import { QorusPurpleIntent } from '../../constants/util'; import { IFSMVariable, TAppAndAction } from '../../containers/InterfaceCreator/fsm'; import { IActionSet } from '../../containers/InterfaceCreator/fsm/ActionSetDialog'; import { getStateCategory, getStateColor } from '../../containers/InterfaceCreator/fsm/state'; @@ -65,6 +66,8 @@ export interface IApp { required_options: string[]; //a list of connection options that must be filled in by the user to create the connection }; actions?: IAppAction[]; + sort?: string; + useBuiltInColors?: boolean; collectionActions?: (app: IApp) => IReqoreCollectionItemProps['actions']; } @@ -111,7 +114,10 @@ export const AppCatalogue = ({ image, } : undefined, - onClick: () => setSelectedAppName(undefined), + onClick: () => { + setSelectedAppName(undefined); + setQuery(''); + }, }, ], }; @@ -123,7 +129,6 @@ export const AppCatalogue = ({ icon: selectedApp.icon || 'BlazeLine', leftIconProps: { image: selectedApp.logo, - rounded: true, }, minimal: true, }); @@ -178,8 +183,8 @@ export const AppCatalogue = ({ const getActionLogo = (action) => { if (size(action.metadata?.states) === 1) { // Get the first state from the states object - const firstStateKey = Object.keys(action.metadata?.states)[0]; - const firstState = action.metadata?.states[firstStateKey]; + const firstStateKey = Object.keys(action.metadata.states)[0]; + const firstState = action.metadata.states[firstStateKey]; return getAppAndAction(apps, firstState.action?.value?.app)?.app?.logo; } @@ -187,6 +192,8 @@ export const AppCatalogue = ({ return action.logo || selectedApp.logo; }; + const handleQueryChange = useCallback((q) => setQuery(q), []); + if (selectedApp) { return ( setQuery(q)} + onQueryChange={handleQueryChange} inputInTitle={false} responsiveTitle={false} responsiveActions={false} @@ -208,17 +215,23 @@ export const AppCatalogue = ({ items={getFilteredActions(selectedApp.actions).map( (action): IReqoreCollectionItemProps => ({ label: action.display_name, - content: <>{action.short_desc}, + content: ( + {action.short_desc} + ), tags: buildActionTags(action), iconImage: getActionLogo(action), icon: action.icon || selectedApp.icon, iconColor: action.iconColor || selectedApp.iconColor, + contentEffect: { gradient: { direction: 'to right bottom', colors: { - 50: 'main', - 200: selectedApp.builtin ? '#6f1977' : 'info:lighten:2', + 50: '#292929', + 200: + selectedApp.builtin || selectedApp.useBuiltInColors + ? QorusPurpleIntent + : 'info:lighten:2', }, }, }, @@ -226,7 +239,9 @@ export const AppCatalogue = ({ onClick: () => onActionSelect(action, selectedApp), iconProps: { size: 'huge', - rounded: true, + effect: { + opacity: 1, + }, }, actions: [...(action.actions?.(action) || [])], }) @@ -240,6 +255,8 @@ export const AppCatalogue = ({ filterable fill sortable={sortable} + sortKeys={{ sort: 'Type & Name' }} + defaultSortBy='sort' zoomable padded={false} minColumnWidth='300px' @@ -247,7 +264,7 @@ export const AppCatalogue = ({ inputInTitle={false} inputProps={{ fluid: true }} defaultZoom={0.5} - onQueryChange={(q) => setQuery(q)} + onQueryChange={handleQueryChange} defaultQuery={query.toString()} selectedIcon='StarFill' showSelectedFirst @@ -255,7 +272,7 @@ export const AppCatalogue = ({ items={apps.map((app) => ({ label: app.display_name, badge: size(getFilteredActions(app.actions)), - content: <>{app.short_desc}, + content: {app.short_desc}, selected: favorites.includes(app.name), iconImage: app.logo, icon: app.icon, @@ -264,8 +281,8 @@ export const AppCatalogue = ({ gradient: { direction: 'to right bottom', colors: { - 50: 'main', - 200: app.builtin ? '#6f1977' : 'info:lighten:2', + 50: '#292929', + 200: app.builtin || app.useBuiltInColors ? QorusPurpleIntent : 'info:lighten:2', }, }, }, @@ -281,6 +298,9 @@ export const AppCatalogue = ({ }, ...(app.collectionActions?.(app) || []), ], + metadata: { + sort: app.sort, + }, minimal: true, searchString: `${app.actions.reduce( (acc, action) => `${acc} ${action.display_name.toLowerCase()}`, @@ -288,8 +308,24 @@ export const AppCatalogue = ({ )}`, iconProps: { size: 'huge', + effect: { + opacity: 1, + }, + }, + onClick: () => { + // Only clear the query if there is no action found for the query + if (query) { + const hasActionsMatchingQuery = app.actions.some((action) => + action.display_name.toLowerCase().includes(query.toString().toLowerCase()) + ); + + if (!hasActionsMatchingQuery) { + setQuery(''); + } + } + + setSelectedAppName(app.name); }, - onClick: () => setSelectedAppName(app.name), }))} /> ); diff --git a/src/components/ConnectionManagement/ManagementModal.tsx b/src/components/ConnectionManagement/ManagementModal.tsx index f603f93f5..da744f8d5 100644 --- a/src/components/ConnectionManagement/ManagementModal.tsx +++ b/src/components/ConnectionManagement/ManagementModal.tsx @@ -120,6 +120,8 @@ export const ConnectionManagementModal = memo( blur={3} onClose={onClose} minimal + responsiveActions={false} + responsiveTitle={false} label={selectedConnection ? 'Edit connection' : `New ${app.display_name} connection`} iconImage={app.logo} iconProps={{ rounded: true, size: '35px' }} @@ -165,7 +167,7 @@ export const ConnectionManagementModal = memo( ) : ( <> {!selectedConnection && ( - + {app.display_name} connection requires some options to be set before it can be created and authorized diff --git a/src/components/CreateInterfaceFromText/modal.tsx b/src/components/CreateInterfaceFromText/modal.tsx index d8d09c024..de0dad5ee 100644 --- a/src/components/CreateInterfaceFromText/modal.tsx +++ b/src/components/CreateInterfaceFromText/modal.tsx @@ -4,7 +4,6 @@ import { ReqoreModal, ReqoreP, ReqoreTextarea, - ReqoreVerticalSpacer, } from '@qoretechnologies/reqore'; import { useEffect, useState } from 'react'; @@ -19,20 +18,17 @@ export interface ICreateInterfaceFromTextProps extends IReqoreModalProps { type?: string; } -export const CreateInterfaceFromTextModal = ({ - type, - onClose, -}: ICreateInterfaceFromTextProps) => { +export const CreateInterfaceFromTextModal = ({ type, onClose }: ICreateInterfaceFromTextProps) => { const [text, setText] = useState(''); const navigate = useNavigate(); - const { load, loading, data, error } = useFetch({ + const { load, loading, data, error, errorData } = useFetch({ url: `${interfaceToPlural[type]}/createDraftFromText`, method: 'POST', }); useEffect(() => { - if (data && !error) { + if (data && !errorData) { onClose(); navigate(`/CreateInterface/${type}?draftId=${(data as any).draft_id}`); } @@ -45,6 +41,7 @@ export const CreateInterfaceFromTextModal = ({ return ( - - Simply input your text and let our intelligent system generate a - ready-to-use interface for you. It's like magic, but real. Get ready - to supercharge your development process! + + Simply input your text and let our intelligent system generate a ready-to-use interface + for you. It's like magic, but real. Get ready to supercharge your development process! {error && ( - - The prompt lacks specific details for creating an automated - workflow. Please provide specific actions or scenarios you would - like to automate. For example, 'Send an email when a new file is - added to a directory' or 'React to a new message in a Discord - channel and send a notification. + + {errorData?.desc || + "The prompt lacks specific details for creating an automated workflow. Please provide specific actions or scenarios you would like to automate. For example, 'Send an email when a new file is added to a directory' or 'React to a new message in a Discord channel and send a notification."} )} setText(e.target.value), 300)} transparent /> - ); diff --git a/src/components/Description/index.tsx b/src/components/Description/index.tsx index f9cfaa8fc..4bb1049d9 100644 --- a/src/components/Description/index.tsx +++ b/src/components/Description/index.tsx @@ -1,11 +1,17 @@ import { + ReqoreH1, + ReqoreH2, + ReqoreH3, + ReqoreH4, + ReqoreH5, + ReqoreH6, ReqoreSpan, ReqoreTextEffect, ReqoreVerticalSpacer, - useReqoreProperty, } from '@qoretechnologies/reqore'; import { IReqoreParagraphProps, ReqoreP } from '@qoretechnologies/reqore/dist/components/Paragraph'; import { TQorusType } from '@qoretechnologies/ts-toolkit'; +import { useCallback, useMemo, useState } from 'react'; import ReactMarkdown from 'react-markdown'; export interface IDescriptionProps extends IReqoreParagraphProps { @@ -16,6 +22,16 @@ export interface IDescriptionProps extends IReqoreParagraphProps { type?: TQorusType; } +const MarkdownLink = (props) => { + return ( + + ); +}; + export const Description = ({ shortDescription, longDescription, @@ -24,69 +40,84 @@ export const Description = ({ type, ...rest }: IDescriptionProps) => { - const addModal = useReqoreProperty('addModal'); + const [showLongDescription, setShowLongDescription] = useState(false); - if (!shortDescription && !longDescription && !type) { + if (!shortDescription && !longDescription) { return null; } - let finalShortDescription = shortDescription || longDescription; - const isShortDescriptionTooLong = finalShortDescription?.length > maxShortDescriptionLength; + const actualShortDescription = shortDescription || longDescription; + const isShortDescriptionTooLong = actualShortDescription?.length > maxShortDescriptionLength; - finalShortDescription = isShortDescriptionTooLong - ? `${finalShortDescription.slice(0, maxShortDescriptionLength)}...` - : finalShortDescription; + const finalShownDescription = isShortDescriptionTooLong + ? `${actualShortDescription.slice(0, maxShortDescriptionLength)}` + : actualShortDescription; let finalLongDescription = - longDescription || (isShortDescriptionTooLong ? shortDescription : null); + longDescription && longDescription !== actualShortDescription + ? longDescription + : isShortDescriptionTooLong + ? actualShortDescription + : null; + + const handleDescriptionClick = useCallback(() => { + setShowLongDescription((prev) => !prev); + }, []); - const handleDescriptionClick = () => { - if (finalLongDescription) { - addModal({ - children: {finalLongDescription}, - minimal: true, - blur: 1, - }); + const effect = useMemo(() => ({ italic: true, opacity: 0.7 }), []); + const renderToggle = useCallback(() => { + if (!finalLongDescription) { + return null; } - }; + + return ( + { + e.stopPropagation(); + handleDescriptionClick?.(); + }} + > + {showLongDescription ? '...less' : '...more'} + + ); + }, [showLongDescription]); return ( <> {margin === 'both' || margin === 'top' ? : null} - {type ? ( + {showLongDescription ? ( <> - , + span: (options) => , + h1: ReqoreH1, + h2: ReqoreH2, + h3: ReqoreH3, + h4: ReqoreH4, + h5: ReqoreH5, + h6: ReqoreH6, + a: MarkdownLink, }} > - [{type}] - {' '} + {finalLongDescription} + + {renderToggle()} - ) : null} - {finalShortDescription}{' '} - {finalLongDescription ? ( - { - e.stopPropagation(); - handleDescriptionClick?.(); - }} - > - [ more ] - - ) : null} + ) : ( + + {finalShownDescription} {finalLongDescription ? renderToggle() : null} + + )} {margin === 'both' || margin === 'bottom' ? : null} diff --git a/src/components/ExpressionBuilder/argumentWrapper.tsx b/src/components/ExpressionBuilder/argumentWrapper.tsx index c54b3c86a..fde1daedb 100644 --- a/src/components/ExpressionBuilder/argumentWrapper.tsx +++ b/src/components/ExpressionBuilder/argumentWrapper.tsx @@ -97,6 +97,7 @@ export const ExpressionBuilderArgumentWrapper = ({ onTypeChange(value); }} flat + hideItemCount labelEffect={{ uppercase: true, spaced: 1, diff --git a/src/components/ExpressionBuilder/index.tsx b/src/components/ExpressionBuilder/index.tsx index 91754e1c8..36908fc24 100644 --- a/src/components/ExpressionBuilder/index.tsx +++ b/src/components/ExpressionBuilder/index.tsx @@ -17,6 +17,7 @@ import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/T import { getReadableColorFrom } from '@qoretechnologies/reqore/dist/helpers/colors'; import { clone, cloneDeep, get, isArray, set, size, unset } from 'lodash'; import { darken, rgba } from 'polished'; +import { useCallback, useMemo } from 'react'; import { useAsyncRetry } from 'react-use'; import styled, { css } from 'styled-components'; import { fetchData } from '../../helpers/functions'; @@ -131,221 +132,233 @@ export const Expression = ({ returnType, }: IExpressionProps) => { const types = useQorusTypes(); - const theme = useReqoreTheme(); const [firstArgument, ...rest] = value.value.args; - const expressions = useAsyncRetry(async () => { + const _expressions = useAsyncRetry(async () => { const data = await fetchData(`/system/expressions`); return data.data; }, []); - if (expressions.loading || types.loading) { - return ( - - - Loading... - - - ); - } + const expressions = useMemo( + () => ({ + ..._expressions, + value: _expressions.value || [], + }), + [_expressions] + ); + + const updateType = useCallback( + (val: IQorusType, conformsCurrentType?: boolean) => { + if (conformsCurrentType) { + onValueChange( + { + ...value, + value: { + ...value.value, + args: [ + { + ...value.value.args[0], + type: val, + }, + ...value.value.args.slice(1), + ], + }, + }, + path + ); + + return; + } - const updateType = (val: IQorusType, conformsCurrentType?: boolean) => { - if (conformsCurrentType) { onValueChange( { - ...value, value: { - ...value.value, args: [ { - ...value.value.args[0], type: val, }, - ...value.value.args.slice(1), ], }, }, path ); + }, + [onValueChange, JSON.stringify(value), path] + ); - return; - } + const addMissingArgs = useCallback( + (expression: string, args: IExpression[] = []): IExpression[] => { + const newArgs = [...args]; + // Check if this expression has variable arguments + const selectedExpression = expressions.value?.find((exp) => exp.name === expression); - onValueChange( - { - value: { - args: [ - { - type: val, - }, - ], - }, - }, - path - ); - }; - - const addMissingArgs = (expression: string, args: IExpression[] = []): IExpression[] => { - const newArgs = [...args]; - // Check if this expression has variable arguments - const selectedExpression = expressions.value?.find((exp) => exp.name === expression); + // If the value has more than 1 argument, return it as is + // It means that the arguments have been added + if (size(newArgs) > 1) { + return newArgs; + } - // If the value has more than 1 argument, return it as is - // It means that the arguments have been added - if (size(newArgs) > 1) { - return newArgs; - } - - if (selectedExpression.varargs) { - newArgs.push({}); - } else { - // Check if any of the arguments have a default value - selectedExpression.args.forEach((arg, index) => { - if (index !== 0) { - if (arg.default_value) { - newArgs.push({ - value: arg.default_value, - type: arg.type.types_accepted[0], - }); - } else { - newArgs.push({}); + if (selectedExpression.varargs) { + newArgs.push({}); + } else { + // Check if any of the arguments have a default value + selectedExpression.args.forEach((arg, index) => { + if (index !== 0) { + if (arg.default_value) { + newArgs.push({ + value: arg.default_value, + type: arg.type.types_accepted[0], + }); + } else { + newArgs.push({}); + } } - } - }); - } + }); + } - return newArgs; - }; + return newArgs; + }, + [JSON.stringify(expressions.value)] + ); - const updateExp = (val: string) => { - const args = addMissingArgs(val, [value.value.args[0]]); + const updateExp = useCallback( + (val: string) => { + const args = addMissingArgs(val, [value.value.args[0]]); - onValueChange( - { - ...value, - value: { - ...value.value, - exp: val, - args, + onValueChange( + { + ...value, + value: { + ...value.value, + exp: val, + args, + }, }, - }, - path - ); - }; + path + ); + }, + [addMissingArgs, onValueChange, JSON.stringify(value), path] + ); - const wrapExpression = (expression: string) => { - onValueChange( - { - value: { - exp: expression, - args: [value], + const wrapExpression = useCallback( + (expression: string) => { + onValueChange( + { + value: { + exp: expression, + args: [value], + }, + is_expression: true, }, - is_expression: true, - }, - path - ); - }; + path + ); + }, + [onValueChange, JSON.stringify(value), path] + ); - const unwrapExpression = () => { + const unwrapExpression = useCallback(() => { onValueChange(value.value.args[0], path); - }; + }, [onValueChange, JSON.stringify(value), path]); - const updateExpToAndOr = (val: 'AND' | 'OR') => { - onValueChange( - { - value: { - exp: val, - args: [value, ExpressionDefaultValue], + const updateExpToAndOr = useCallback( + (val: 'AND' | 'OR') => { + onValueChange( + { + value: { + exp: val, + args: [value, ExpressionDefaultValue], + }, + is_expression: true, }, - is_expression: true, - }, - path - ); - }; + path + ); + }, + [onValueChange, JSON.stringify(value), path] + ); - const removeVarArg = (index: number) => { - const args = value.value.args.filter((_, i) => i !== index); + const removeVarArg = useCallback( + (index: number) => { + const args = value.value.args.filter((_, i) => i !== index); - onValueChange( - { - ...value, - value: { - ...value.value, - args, + onValueChange( + { + ...value, + value: { + ...value.value, + args, + }, }, - }, - path - ); - }; + path + ); + }, + [onValueChange, JSON.stringify(value), path] + ); + + const updateArg = useCallback( + ( + val: any, + index: number = 0, + type?: IQorusType, + isFunction?: boolean, + isRequired?: boolean + ) => { + const args = clone(value.value.args); + const newVal = clone(val); + + if (newVal?.exp) { + newVal.args = addMissingArgs(newVal.exp, newVal.args); + } - const updateArg = ( - val: any, - index: number = 0, - type?: IQorusType, - isFunction?: boolean, - isRequired?: boolean - ) => { - const args = clone(value.value.args); - const newVal = clone(val); - - if (newVal?.exp) { - newVal.args = addMissingArgs(newVal.exp, newVal.args); - } - - args[index] = { - ...args[index], - value: newVal, - type: type || args[index]?.type, - is_expression: isFunction, - required: isRequired, - }; - - onValueChange( - { - ...value, - value: { - ...value.value, - args, + args[index] = { + ...args[index], + value: newVal, + type: type || args[index]?.type, + is_expression: isFunction, + required: isRequired, + }; + + onValueChange( + { + ...value, + value: { + ...value.value, + args, + }, }, - }, - path - ); - }; + path + ); + }, + [addMissingArgs, onValueChange, JSON.stringify(value), path] + ); - const handleRemoveClick = () => { + const handleRemoveClick = useCallback(() => { onValueChange?.(undefined, path, true); - }; + }, [onValueChange, path]); - const getArgumentType = ( - schema: TExpressionSchemaArg, - argumentType?: IQorusType, - firstArgument?: IExpression - ) => { - if (argumentType) { - return argumentType; - } + const getArgumentType = useCallback( + (schema: TExpressionSchemaArg, argumentType?: IQorusType, firstArgument?: IExpression) => { + if (argumentType) { + return argumentType; + } - if (firstArgument?.is_expression) { - return expressions.value?.find((exp) => exp.name === firstArgument.value.exp)?.return_type; - } + if (firstArgument?.is_expression && firstArgument?.value?.exp) { + return expressions.value?.find((exp) => exp.name === firstArgument.value.exp)?.return_type; + } - if (firstArgument.type && schema.type.types_accepted.includes(firstArgument.type)) { - return firstArgument.type; - } + if (firstArgument?.type && schema.type.types_accepted.includes(firstArgument.type)) { + return firstArgument.type; + } - return schema.type.types_accepted[0]; - }; + return schema.type.types_accepted[0]; + }, + [JSON.stringify(expressions.value)] + ); - const selectedExpression = expressions.value?.find((exp) => exp.name === value.value.exp); + const selectedExpression = useMemo( + () => expressions.value?.find((exp) => exp.name === value.value.exp), + [JSON.stringify(value), JSON.stringify(expressions.value)] + ); const firstArgSchema = selectedExpression?.args[0]; const firstParamType = firstArgument?.is_expression ? firstArgSchema?.type?.types_accepted[0] @@ -370,6 +383,86 @@ export const Expression = ({ returnType === expressionReturnType || !returnType; + const defaultItems = useMemo( + () => + expressions.value + ?.filter((exp) => exp.subtype !== 2) + .map((exp) => ({ + name: exp.name, + value: exp.name, + display_name: exp.display_name, + short_desc: exp.desc, + badge: exp.symbol, + })), + [JSON.stringify(expressions.value)] + ); + + const handleOperatorChange = useCallback( + (_name, value) => { + updateExp(value); + }, + [updateExp] + ); + + const handleAddArgumentClick = useCallback(() => { + updateArg(undefined, size(value.value.args), undefined, false); + }, [updateArg, JSON.stringify(value)]); + + const handleWrapExpressionClick = useCallback( + (_name, value) => { + wrapExpression(value); + }, + [wrapExpression] + ); + + const handleUpdateExpToAnd = useCallback(() => { + updateExpToAndOr('AND'); + }, [updateExpToAndOr]); + + const handleUpdateExpToOr = useCallback(() => { + updateExpToAndOr('OR'); + }, [updateExpToAndOr]); + + const handleUpdateTypeChange = useCallback( + (value) => { + updateType(value === 'context' ? undefined : value, true); + }, + [updateType] + ); + + const handleFirstParamChange = useCallback( + (_name, value, type, isFunction) => { + if (type !== 'any' && type !== 'auto') { + updateArg( + value, + 0, + isFunction ? undefined : type, + isFunction, + selectedExpression?.args?.[0]?.required + ); + } + }, + [updateArg, JSON.stringify(selectedExpression)] + ); + + if (expressions.loading || types.loading) { + return ( + + + Loading... + + + ); + } + return ( exp.subtype !== 2) - .map((exp) => ({ - name: exp.name, - value: exp.name, - display_name: exp.display_name, - short_desc: exp.desc, - badge: exp.symbol, - }))} - onChange={(_name, value) => { - updateExp(value); - }} + defaultItems={defaultItems} + onChange={handleOperatorChange} showDescription='tooltip' /> } @@ -427,9 +512,7 @@ export const Expression = ({ icon: 'AddLine', fixed: true, disabled: !validateField('expression', value), - onClick: () => { - updateArg(undefined, size(value.value.args), undefined, false); - }, + onClick: handleAddArgumentClick, show: !!selectedExpression && selectedExpression.varargs === true, }, { @@ -442,18 +525,9 @@ export const Expression = ({ showRightIcon: false, fluid: false, fixed: true, - defaultItems: expressions.value - ?.filter((exp) => exp.subtype !== 2) - .map((exp) => ({ - name: exp.name, - value: exp.name, - display_name: exp.display_name, - short_desc: exp.desc, - badge: exp.symbol, - })), - onChange: (_name, value) => { - wrapExpression(value); - }, + defaultItems, + hideItemCount: true, + onChange: handleWrapExpressionClick, showDescription: 'tooltip', tooltip: 'Wrap this expression in another expression', }, @@ -492,9 +566,7 @@ export const Expression = ({ icon: 'AddLine', fixed: true, show: !group || group === 'OR', - onClick: () => { - updateExpToAndOr('AND'); - }, + onClick: handleUpdateExpToAnd, }, { flat: true, @@ -504,9 +576,7 @@ export const Expression = ({ icon: 'AddLine', show: !group || group === 'AND', fixed: true, - onClick: () => { - updateExpToAndOr('OR'); - }, + onClick: handleUpdateExpToOr, }, ], }, @@ -536,9 +606,7 @@ export const Expression = ({ expressions={expressions.value} arg={firstArgument} schema={firstArgSchema} - onTypeChange={(value) => { - updateType(value === 'context' ? undefined : value, true); - }} + onTypeChange={handleUpdateTypeChange} label='Arg #1' > { - if (type !== 'any' && type !== 'auto') { - updateArg( - value, - 0, - isFunction ? undefined : type, - isFunction, - selectedExpression?.args?.[0]?.required - ); - } - }} + onChange={handleFirstParamChange} templates={localTemplates} allowTemplates allowCustomValues={!!firstArgument?.type} @@ -660,57 +718,60 @@ export const ExpressionBuilder = ({ const templates = useTemplates(!isChild, localTemplates); const theme = useReqoreTheme(); - if (templates.loading) { - return ( - - Loading... - - ); - } + const handleChange = useCallback( + (newValue: IExpression, newPath: string, remove?: boolean) => { + if (onChange) { + if (!newPath) { + onChange(newValue, remove); + return; + } - const handleChange = (newValue: IExpression, newPath: string, remove?: boolean) => { - if (onChange) { - if (!newPath) { - onChange(newValue, remove); - return; - } + let clonedValue = cloneDeep(value); - let clonedValue = cloneDeep(value); + if (remove) { + unset(clonedValue, newPath); + const pathArray = newPath.split('.'); - if (remove) { - unset(clonedValue, newPath); - const pathArray = newPath.split('.'); + pathArray.pop(); - pathArray.pop(); + const parentPath = pathArray.join('.'); + let parent = get(clonedValue, parentPath); - const parentPath = pathArray.join('.'); - let parent = get(clonedValue, parentPath); + parent = parent.filter((item: any) => item); - parent = parent.filter((item: any) => item); + if (size(parent) === 1) { + pathArray.pop(); - if (size(parent) === 1) { - pathArray.pop(); + const grandParentPath = pathArray.join('.'); - const grandParentPath = pathArray.join('.'); + if (grandParentPath === '') { + onChange(parent[0].value, remove); + return; + } - if (grandParentPath === '') { - onChange(parent[0].value, remove); - return; + set(clonedValue, grandParentPath, parent[0].value); + } else { + set(clonedValue, parentPath, parent); } - - set(clonedValue, grandParentPath, parent[0].value); } else { - set(clonedValue, parentPath, parent); + set(clonedValue, newPath, newValue); } - } else { - set(clonedValue, newPath, newValue); + + onChange(clonedValue, remove); + } else if (onValueChange) { + onValueChange(newValue, newPath, remove); } + }, + [JSON.stringify(value), onChange, onValueChange] + ); - onChange(clonedValue, remove); - } else if (onValueChange) { - onValueChange(newValue, newPath, remove); - } - }; + if (templates.loading) { + return ( + + Loading... + + ); + } if (value.is_expression && (value.value.exp === 'AND' || value.value.exp === 'OR')) { return ( diff --git a/src/components/Field/arrayAuto.tsx b/src/components/Field/arrayAuto.tsx index cfe280a35..a29182ead 100644 --- a/src/components/Field/arrayAuto.tsx +++ b/src/components/Field/arrayAuto.tsx @@ -2,13 +2,12 @@ import { ReqoreButton, ReqoreControlGroup, ReqorePanel, - ReqoreVerticalSpacer, useReqoreProperty, } from '@qoretechnologies/reqore'; import map from 'lodash/map'; import reduce from 'lodash/reduce'; import size from 'lodash/size'; -import React, { FunctionComponent, useEffect, useState } from 'react'; +import { FunctionComponent, useEffect, useState } from 'react'; import { IField } from '.'; import { IFieldChange } from '../FieldWrapper'; import AutoField from './auto'; @@ -20,7 +19,7 @@ export const allowedTypes: string[] = ['string', 'int', 'float', 'date']; const ArrayAutoField: FunctionComponent = ({ name, onChange, - value = [''], + value, default_value, display_name, ...rest @@ -118,46 +117,46 @@ const ArrayAutoField: FunctionComponent = ({ // Render list of auto fields return ( - <> + {map(values, (val: string | number, idx: string): typeof StyledPairField => ( - - - confirmAction({ - onConfirm: () => handleRemoveClick(Number(idx)), - }), - minimal: true, - }, - ]} - > - handleChange(idx, value)} - /> - - - + + confirmAction({ + onConfirm: () => handleRemoveClick(Number(idx)), + }), + minimal: true, + }, + ]} + > + handleChange(idx, value)} + /> + ))} = ({ customTheme={{ main: '#22273b', }} + disabled={rest.disabled || rest.readOnly} > Add new item for "{display_name || name}" - + ); }; diff --git a/src/components/Field/auto.tsx b/src/components/Field/auto.tsx index 5f6b7fe8e..f3f33eae9 100644 --- a/src/components/Field/auto.tsx +++ b/src/components/Field/auto.tsx @@ -4,7 +4,6 @@ import { ReqoreMessage, ReqoreTag, } from '@qoretechnologies/reqore'; -import { IReqorePanelProps } from '@qoretechnologies/reqore/dist/components/Panel'; import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/Textarea'; import { IWithReqoreSize } from '@qoretechnologies/reqore/dist/types/global'; import { ReqraftObjectFormField } from '@qoretechnologies/reqraft/dist/components/form/fields/object/Object'; @@ -20,6 +19,8 @@ import { getValueOrDefaultValue, maybeParseYaml, } from '../../helpers/validations'; +import { useWhyDidYouUpdate } from '../../hooks/useWhyDidYouUpdate'; +import { FieldAllowedValues } from '../AllowedValues'; import { ConnectionManagement } from '../ConnectionManagement'; import { IField } from '../FieldWrapper'; import Loader from '../Loader'; @@ -43,6 +44,7 @@ import Options, { IOptionsSchema, IQorusType } from './systemOptions'; export interface IAutoFieldProps extends IField, IWithReqoreSize { default_value_desc?: string; + uniqueName?: string; arg_schema?: string | IOptionsSchema; display_name?: string; @@ -56,6 +58,9 @@ export interface IAutoFieldProps extends IField, IWithReqoreSize { allowed_values?: ISelectFieldItem[]; allowed_values_creatable?: boolean; + allowSaving?: boolean; + showSavedValues?: boolean; + app?: string; action?: string; @@ -110,6 +115,9 @@ function AutoField({ allowedTypes, element_type, disableManagement, + allowSaving, + showSavedValues, + uniqueName, ...rest }: IAutoFieldProps & T) { const [currentType, setType] = useState(defaultInternalType || null); @@ -120,6 +128,33 @@ function AutoField({ ); const [error, setError] = useState(); + useWhyDidYouUpdate(`Auto field ${name} ${currentType}`, { + name, + onChange, + value, + default_value, + defaultType, + defaultInternalType, + requestFieldData, + type, + t, + noSoft, + path, + arg_schema, + column, + level, + canBeNull, + isConfigItem, + isVariable, + allowedTypes, + element_type, + disableManagement, + allowSaving, + showSavedValues, + uniqueName, + ...rest, + }); + // Some arg schemas are not provided as objects, but as strings // so we need to fetch them useEffect(() => { @@ -254,34 +289,6 @@ function AutoField({ handleChange(name, isSetToNull ? undefined : null); }; - const getAllowedItemActionsByType = ( - type: IQorusType, - item: string - ): IReqorePanelProps['actions'] => { - switch (type) { - case 'connection': { - return [ - { - as: ConnectionManagement, - props: { - selectedConnection: item, - //onChange: (value) => handleChange(name, value), - allowedValues: rest.allowed_values, - redirectUri: `${apiHost}grant`, - app: rest.app, - action: rest.action, - compact: true, - size: rest.size, - }, - }, - ]; - } - default: { - return []; - } - } - }; - const renderConnectionManagement = () => { if (type !== 'connection' || disableManagement) { return null; @@ -314,6 +321,26 @@ function AutoField({ return ; } + const renderAllowedValues = (currentType: IQorusType) => { + return ( + + ); + }; + const renderField = (currentType: IQorusType) => { // If this field is set to null if (isSetToNull) { @@ -346,322 +373,302 @@ function AutoField({ ); } - if (rest.allowed_values && currentType !== 'enum') { - if (currentType === 'list') { - return ( - ({ - name: name || value, - value, - ...rest, - }))} - name={name} - onChange={handleChange} - value={value} - size={rest.size} - disabled={rest.disabled} - canCreateItems={rest.allowed_values_creatable} - /> - ); - } - - return ( - ({ - name: name || value, - actions: getAllowedItemActionsByType(currentType, name || value), - ...rest, - }))} - value={value} - autoSelect - name={name} - onChange={handleChange} - type={currentType} - fluid - fixed={false} - showDescription={rest.showDescription} - style={{ width: '100%' }} - size={rest.size} - disabled={rest.disabled} - /> - ); + if (rest.allowed_values && !rest.allowed_values_creatable) { + return null; } - // Render the field based on the type - switch (currentType) { - case 'string': - case 'softstring': - case 'data': - case 'binary': - return ( - - ); - case 'richtext': { - return ( - { - handleChange(name, value); - }} - value={value} - tagsListProps={{}} - /> - ); - } - case 'bool': - case 'softbool': - case 'boolean': - return ( - - ); - case 'date': - return ( - - ); - case 'hash': - case 'hash': { - if (finalArgSchema) { - const transformedValue: Record< - string, - { type: TQorusType; value: any; is_expression?: boolean } - > = fixOldArgSchemaData( - typeof value === 'string' ? maybeParseYaml(value) : value, - finalArgSchema + const renderFieldComponent = () => { + // Render the field based on the type + switch (currentType) { + case 'string': + case 'softstring': + case 'data': + case 'binary': + return ( + + ); + case 'richtext': { + return ( + { + handleChange(name, value); + }} + value={value} + tagsListProps={{}} + /> + ); + } + case 'bool': + case 'softbool': + case 'boolean': + return ( + + ); + case 'date': + return ( + ); + case 'hash': + case 'hash': { + if (finalArgSchema) { + const transformedValue: Record< + string, + { type: TQorusType; value: any; is_expression?: boolean } + > = fixOldArgSchemaData( + typeof value === 'string' ? maybeParseYaml(value) : value, + finalArgSchema + ); + + return ( + { + handleChange(name, value, 'hash'); + }} + columns={1} + minColumnWidth='min-content' + allowSaving={allowSaving} + showSavedValues={showSavedValues} + /> + ); + } return ( - handleChange(name, data)} + dataType='yaml' + resultDataType='yaml' + type='object' + fill + /> + ); + } + case 'list': + case 'softlist': + case 'softlist': + case 'list': { + if (element_type) { + return ( + { + if (!size(value)) { + return handleChange(name, undefined); + } + + handleChange(name, level === 0 ? jsyaml.dump(value) : value); + }} + /> + ); + } + + return ( + handleChange(name, data)} + dataType='yaml' + resultDataType='yaml' + type='array' + {...rest} + fill + /> + ); + } + case 'int': + case 'integer': + case 'softint': + case 'float': + case 'softfloat': + case 'number': + return ( + + ); + case 'option_hash': + return ( + + ); + case 'byte-size': + return ( + + ); + case 'enum': + return ( + + ); + case 'select-string': { + return ( + + ); + } + case 'multi-select': { + return ; + } + case 'mapper': + case 'workflow': + case 'service': + case 'job': + case 'value-map': { + return ( + + ); + } + case 'connection': { + return ( + + ); + } + case 'data-provider': { + return ( + + ); + } + case 'file-as-string': { + return ( + { - handleChange(name, value, 'hash'); + return_message={{ + action: 'creator-return-resources', + object_type: 'files', + return_value: 'resources', }} /> ); } - - return ( - handleChange(name, data)} - dataType='yaml' - resultDataType='yaml' - type='object' - fill - /> - ); - } - case 'list': - case 'softlist': - case 'softlist': - case 'list': { - if (element_type) { + case 'rgbcolor': { return ( - - handleChange(name, level === 0 ? jsyaml.dump(value) : value) - } + onChange={handleChange} /> ); } - - return ( - handleChange(name, data)} - dataType='yaml' - resultDataType='yaml' - type='array' - {...rest} - fill - /> - ); - } - case 'int': - case 'integer': - case 'softint': - case 'float': - case 'softfloat': - case 'number': - return ( - - ); - case 'option_hash': - return ( - - ); - case 'byte-size': - return ( - - ); - case 'enum': - return ( - - ); - case 'select-string': { - return ( - - ); - } - case 'multi-select': { - return ; - } - case 'mapper': - case 'workflow': - case 'service': - case 'job': - case 'value-map': { - return ( - - ); - } - case 'connection': { - return ( - - ); - } - case 'data-provider': { - return ( - - ); - } - case 'file-as-string': { - return ( - - ); - } - case 'rgbcolor': { - return ( - - ); + case 'any': + return null; + case 'auto': + return ( + + ); + default: + return ; } - case 'any': - return null; - case 'auto': - return ( - - ); - default: - return ; - } + }; + + return ( + + {renderFieldComponent()} + + ); }; const showPicker = @@ -676,20 +683,20 @@ function AutoField({ allowedTypes || (!noSoft ? [ - { name: 'bool' }, - { name: 'softbool' }, - { name: 'date' }, - { name: 'string' }, - { name: 'softstring' }, - { name: 'binary' }, - { name: 'float' }, - { name: 'softfloat' }, - { name: 'list' }, - { name: 'softlist' }, - { name: 'hash' }, - { name: 'int' }, - { name: 'softint' }, - { name: 'rgbcolor' }, + { value: 'bool' }, + { value: 'softbool' }, + { value: 'date' }, + { value: 'string' }, + { value: 'softstring' }, + { value: 'binary' }, + { value: 'float' }, + { value: 'softfloat' }, + { value: 'list' }, + { value: 'softlist' }, + { value: 'hash' }, + { value: 'int' }, + { value: 'softint' }, + { value: 'rgbcolor' }, ] : DefaultNoSoftTypes); @@ -732,8 +739,8 @@ function AutoField({ }} /> )} - {renderField(currentInternalType)} + {renderAllowedValues(currentInternalType)} {canBeNull && ( { +export const ColorField = ({ value, onChange, name, disabled, read_only }: IColorFieldProps) => { return ( setName(value)} - placeholder="Give your data provider a unique name" + placeholder='Give your data provider a unique name' /> setDesc(value)} - placeholder="Describe your data provider for easier identification" + placeholder='Describe your data provider for easier identification' /> @@ -109,18 +110,37 @@ export const DataProviderFavorites = ({ const existsInFavorites = find(favorites, (favorite) => isEqual(favorite.value, currentProvider)); const isBuiltInFavorite = existsInFavorites?.builtIn; - const handleAddNewFavorite = () => { + const handleAddNewFavorite = useCallback(() => { setIsAdding(true); - }; + }, []); - const handleRemoveFavorite = () => { + const handleRemoveFavorite = useCallback(() => { if (existsInFavorites && !existsInFavorites.builtIn) { deleteFavorite(existsInFavorites.id); } - }; + }, [JSON.stringify(existsInFavorites)]); const hasTypeAndName = currentProvider?.type && currentProvider?.name; + useWhyDidYouUpdate('favorites', { + currentProvider, + onFavoriteApply, + defaultFavorites, + localOnly, + record, + ...rest, + addNewFavorite, + count, + deleteAllFavorites, + deleteFavorite, + favorites, + confirmAction, + existsInFavorites, + isBuiltInFavorite, + handleAddNewFavorite, + handleRemoveFavorite, + }); + return ( <> {isAdding && ( @@ -138,11 +158,11 @@ export const DataProviderFavorites = ({ /> )} {showFavorites && ( - setShowFavorites(false)}> + setShowFavorites(false)}> ({ @@ -154,7 +174,7 @@ export const DataProviderFavorites = ({ <> {favoriteData.desc && ( <> - {favoriteData.desc} + {favoriteData.desc} )} @@ -197,13 +217,13 @@ export const DataProviderFavorites = ({ /> )} - + {size(favorites) ? ( setShowFavorites(true)} - className="data-provider-show-favorites" + className='data-provider-show-favorites' > Select from favorites @@ -216,17 +236,17 @@ export const DataProviderFavorites = ({ isBuiltInFavorite ? PositiveColorEffect : existsInFavorites - ? NegativeColorEffect - : SelectorColorEffect + ? NegativeColorEffect + : SelectorColorEffect } onClick={ isBuiltInFavorite ? undefined : existsInFavorites - ? handleRemoveFavorite - : handleAddNewFavorite + ? handleRemoveFavorite + : handleAddNewFavorite } - className="data-provider-add-favorite" + className='data-provider-add-favorite' disabled={isBuiltInFavorite || !hasTypeAndName} > {existsInFavorites diff --git a/src/components/Field/connectors/index.tsx b/src/components/Field/connectors/index.tsx index efe081533..451d7f63e 100644 --- a/src/components/Field/connectors/index.tsx +++ b/src/components/Field/connectors/index.tsx @@ -1,14 +1,9 @@ -import { - ReqoreMessage, - ReqoreSpinner, - ReqoreTree, - useReqoreProperty, -} from '@qoretechnologies/reqore'; +import { ReqoreSpinner, ReqoreTree, useReqoreProperty } from '@qoretechnologies/reqore'; import jsyaml from 'js-yaml'; import { cloneDeep, isEqual, map, omit, reduce } from 'lodash'; import size from 'lodash/size'; -import React, { useContext, useState } from 'react'; -import { useDebounce } from 'react-use'; +import React, { useCallback, useContext, useMemo, useState } from 'react'; +import { useUpdateEffect } from 'react-use'; import compose from 'recompose/compose'; import { TTranslator } from '../../../App'; import { QogOpenedStateContext } from '../../../containers/InterfaceCreator/fsm/stateDetail'; @@ -18,6 +13,7 @@ import { validateField } from '../../../helpers/validations'; import withInitialDataConsumer from '../../../hocomponents/withInitialDataConsumer'; import withTextContext from '../../../hocomponents/withTextContext'; import { TDataProviderFavorites } from '../../../hooks/useGetDataProviderFavorites'; +import { useWhyDidYouUpdate } from '../../../hooks/useWhyDidYouUpdate'; import Loader from '../../Loader'; import SubField from '../../SubField'; import { ApiCallArgs } from '../apiCallArgs'; @@ -218,7 +214,7 @@ export const getUrlFromProvider: ( const endsInSubtype = path.endsWith('/request') || path.endsWith('/response'); const hasSubtype = subtype || endsInSubtype; const finalPath = hasSubtype - ? `${path.replace('/response', '').replace('/request', '')}${subtype}` + ? `${path.replace('/response', '').replace('/request', '')}${subtype ? `/${subtype}` : ''}` : path; // Build the suffix @@ -389,92 +385,134 @@ const ConnectorField: React.FC = ({ const addModal = useReqoreProperty('addModal'); - const applyFavorite = (favorite: IProviderType, storedRecord?: any) => { + useWhyDidYouUpdate('connectors field', { + title, + onChange, + name, + value, + initialData, + inline, + providerType, + minimal, + isConfigItem, + requiresRequest, + recordType, + isPipeline, + isMessage, + isVariable, + isEvent, + isTransaction, + readOnly, + disableSearchOptions, + disableMessageOptions, + disableTransactionOptions, + info, + t, + favorites, + localOnlyFavorites, + record, + setRecord, + setFields, + hide, + addModal, + connectedActionsTemplates, + optionProvider, + nodes, + provider, + isLoading, + isEditing, + availableOptions, + }); + + const applyFavorite = useCallback((favorite: IProviderType, storedRecord?: any) => { setProvider(favorite.type); setOptionProvider(favorite); setChildren(buildChildren(favorite)); setRecord?.(storedRecord); - }; + }, []); - const clear = () => { + const clear = useCallback(() => { setIsEditing(false); setOptionProvider(null); onChange?.(name, undefined); - }; + }, [name, onChange]); - const reset = () => { + const reset = useCallback(() => { setChildren([]); setProvider(null); setOptionProvider(null); setIsLoading(false); onChange?.(name, undefined); - }; + }, [name, onChange]); - useDebounce( - () => { - if (!isEditing) { - if (!optionProvider) { - onChange?.(name, undefined); - return; - } + useUpdateEffect(() => { + if (!isEditing) { + if (!optionProvider) { + onChange?.(name, undefined); + return; + } - const val = { ...optionProvider }; + const val = { ...optionProvider }; - delete val.up; - delete val.optionsChanged; - delete val.searchOptionsChanged; + delete val.up; + delete val.optionsChanged; + delete val.searchOptionsChanged; - if (val.type !== 'factory') { - delete val.options; - } + if (val.type !== 'factory') { + delete val.options; + } - const keys = Object.keys(val); + const keys = Object.keys(val); - for (const key of keys) { - if (val[key] === undefined || val[key] === null || val[key] === false) { - delete val[key]; - } + for (const key of keys) { + if (val[key] === undefined || val[key] === null || val[key] === false) { + delete val[key]; } + } - if (isConfigItem) { - // Add type from optionProvider and get value from all nodes and join them by / - const type = val.type; - const newNodes = cloneDeep(nodes); - - if (type === 'factory') { - let options = reduce( - val.options, - (newOptions, optionData, optionName) => { - return `${newOptions}${optionName}=${optionData.value},`; - }, - '' - ); - // Remove the last comma from options - options = options.substring(0, options.length - 1); - - if (newNodes[0]) { - newNodes[0].value = `${newNodes[0].value}{${options}}`; - } + if (isConfigItem) { + // Add type from optionProvider and get value from all nodes and join them by / + const type = val.type; + const newNodes = cloneDeep(nodes); - const value = newNodes.map((node) => node.value).join('/'); + if (type === 'factory') { + let options = reduce( + val.options, + (newOptions, optionData, optionName) => { + return `${newOptions}${optionName}=${optionData.value},`; + }, + '' + ); + // Remove the last comma from options + options = options.substring(0, options.length - 1); - onChange?.(name, `${type}/${value}${val.optionsChanged ? `?options_changed` : ''}`); - } else { - const value = nodes.map((node) => node.value).join('/'); - onChange?.(name, `${type}/${value}`); + if (newNodes[0]) { + newNodes[0].value = `${newNodes[0].value}{${options}}`; } + + const value = newNodes.map((node) => node.value).join('/'); + + onChange?.(name, `${type}/${value}${val.optionsChanged ? `?options_changed` : ''}`); } else { - onChange?.(name, val); + const value = nodes.map((node) => node.value).join('/'); + onChange?.(name, `${type}/${value}`); } + } else { + onChange?.(name, val); } - }, - 30, - [JSON.stringify(optionProvider), isEditing] - ); + } + }, [JSON.stringify(optionProvider)]); - if (!initialData.qorus_instance) { - return {t('ActiveInstanceProvidersConnectors')}; - } + const handleSetOptionProvider = useCallback((data) => { + setOptionProvider(data); + }, []); + + const style = useMemo( + () => ({ + display: inline ? 'inline-block' : 'block', + }), + [inline] + ); if (connectedActionsTemplates.loading) { return ; @@ -546,9 +584,7 @@ const ConnectorField: React.FC = ({ setChildren={setChildren} provider={provider} setProvider={setProvider} - setOptionProvider={(data) => { - setOptionProvider(data); - }} + setOptionProvider={handleSetOptionProvider} isLoading={isLoading} setIsLoading={setIsLoading} optionProvider={optionProvider} @@ -562,9 +598,7 @@ const ConnectorField: React.FC = ({ type={providerType} compact requiresRequest={requiresRequest} - style={{ - display: inline ? 'inline-block' : 'block', - }} + style={style} onResetClick={reset} recordType={recordType} isPipeline={isPipeline} diff --git a/src/components/Field/date.tsx b/src/components/Field/date.tsx index 016362519..80b6d96cf 100644 --- a/src/components/Field/date.tsx +++ b/src/components/Field/date.tsx @@ -1,18 +1,11 @@ -import { - DatePicker, - ReqoreInput, - useReqoreTheme, -} from '@qoretechnologies/reqore'; +import { DatePicker, ReqoreInput, useReqoreTheme } from '@qoretechnologies/reqore'; import { FunctionComponent } from 'react'; import useMount from 'react-use/lib/useMount'; import compose from 'recompose/compose'; import styled from 'styled-components'; import { TTranslator } from '../../App'; import { getValueOrDefaultValue } from '../../helpers/validations'; -import { - addMessageListener, - postMessage, -} from '../../hocomponents/withMessageHandler'; +import { addMessageListener, postMessage } from '../../hocomponents/withMessageHandler'; import withTextContext from '../../hocomponents/withTextContext'; import { IField, IFieldChange } from '../FieldWrapper'; @@ -44,10 +37,7 @@ const DateField: FunctionComponent = ({ useMount(() => { // Populate default value if (default_value) { - onChange( - name, - getValueOrDefaultValue(value, default_value || new Date(), false) - ); + onChange(name, getValueOrDefaultValue(value, default_value || new Date(), false)); } // Get backend data if (get_message && return_message) { @@ -68,6 +58,7 @@ const DateField: FunctionComponent = ({ return ( = ({ onChange?.(name, localValue); } }, - 300, + 200, [localValue, onChange] ); diff --git a/src/components/Field/multiSelect.tsx b/src/components/Field/multiSelect.tsx index fedeb44fb..6c1e10338 100644 --- a/src/components/Field/multiSelect.tsx +++ b/src/components/Field/multiSelect.tsx @@ -195,7 +195,7 @@ const MultiSelectField: FunctionComponent ({ ...item, - value: item.name, + value: item.display_name || item.name, description: item.short_desc, }) )} diff --git a/src/components/Field/number.tsx b/src/components/Field/number.tsx index bb3bcc0ca..41608096d 100644 --- a/src/components/Field/number.tsx +++ b/src/components/Field/number.tsx @@ -64,8 +64,8 @@ const NumberField = ({ // Clear the input on reset click const handleResetClick = useCallback((): void => { - onChange(name, null); - }, []); + onChange(name, 0); + }, [onChange, name]); const handleItemSelect = useCallback((item) => onChange(name, item.value), [onChange]); diff --git a/src/components/Field/optionFieldMessages.tsx b/src/components/Field/optionFieldMessages.tsx index cbc9f0db0..721982903 100644 --- a/src/components/Field/optionFieldMessages.tsx +++ b/src/components/Field/optionFieldMessages.tsx @@ -1,8 +1,9 @@ import { ReqoreControlGroup, ReqoreTag, ReqoreVerticalSpacer } from '@qoretechnologies/reqore'; import { IReqoreTagProps } from '@qoretechnologies/reqore/dist/components/Tag'; -import { size } from 'lodash'; +import { flatten, size } from 'lodash'; import { useMemo } from 'react'; -import { getOptionsFromRequiredGroups } from '../../helpers/options'; +import { AttentionIntent } from '../../constants/util'; +import { getRequiredOptionMessage } from '../../helpers/options'; import { hasAllDependenciesFullfilled, validateField, @@ -23,7 +24,10 @@ export const OptionFieldMessages = ({ name, allOptions, }: IOptionFieldMessagesProps) => { - const optionSchema = useMemo(() => schema[name], [schema, option]); + const optionSchema = useMemo( + () => schema[name], + [JSON.stringify(schema), JSON.stringify(option)] + ); const messages: IReqoreTagProps[] = useMemo(() => { const result: IReqoreTagProps[] = []; @@ -43,19 +47,38 @@ export const OptionFieldMessages = ({ } if (!validateOptionWithRequiredGroups(allOptions, schema, optionSchema.required_groups)) { + const requiredOptionsMessage = getRequiredOptionMessage( + schema, + optionSchema.required_groups, + name + ); + result.push({ - label: `This field or ${getOptionsFromRequiredGroups(schema, optionSchema.required_groups, name).join(' or ')} is required`, - intent: 'warning', + label: requiredOptionsMessage, + intent: AttentionIntent, }); } } if (!hasAllDependenciesFullfilled(optionSchema.depends_on, allOptions, schema)) { - result.push({ label: 'Some dependencies are not fulfilled', intent: 'warning' }); + const dependsOn = flatten(optionSchema.depends_on) + .filter((option) => !!schema[option]) + .map((option) => schema[option].display_name || option) + .join(', '); + + result.push({ + label: `Some dependencies are not fulfilled: ${dependsOn}`, + intent: 'warning', + }); } return result; - }, [schema, option, allOptions, optionSchema]); + }, [ + JSON.stringify(schema), + JSON.stringify(option), + JSON.stringify(allOptions), + JSON.stringify(optionSchema), + ]); if (!size(messages)) { return null; diff --git a/src/components/Field/richText.tsx b/src/components/Field/richText.tsx index 7cbb56c22..c73e0729d 100644 --- a/src/components/Field/richText.tsx +++ b/src/components/Field/richText.tsx @@ -4,12 +4,14 @@ import { } from '@qoretechnologies/reqore/dist/components/RichTextEditor'; import { IReqoreTagProps } from '@qoretechnologies/reqore/dist/components/Tag'; import { map, size } from 'lodash'; -import { memo, useCallback, useMemo } from 'react'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { useDebounce } from 'react-use'; import { useContextSelector } from 'use-context-selector'; import { interfaceIcons, interfaceKindToName } from '../../constants/interfaces'; import { QorusPurpleIntent } from '../../constants/util'; import { InterfacesContext } from '../../context/interfaces'; +import { useWhyDidYouUpdate } from '../../hooks/useWhyDidYouUpdate'; import { IFieldChange } from '../FieldWrapper'; import { IOptionsSchemaArg } from './systemOptions'; @@ -39,16 +41,31 @@ export const RichTextField = memo( }: IRichTextFieldProps) => { const interfaces = useContextSelector(InterfacesContext, (value) => value.interfaces); const navigate = useNavigate(); + const [localValue, setLocalValue] = useState(value || default_value); + + useWhyDidYouUpdate('RichTextField', { name, value, default_value, id, richText, ...rest }); const handleChange = (value: any): void => { if (JSON.stringify(value) === '[{"type":"paragraph","children":[{"text":""}]}]') { - onChange?.(''); + setLocalValue(''); return; } - onChange?.(value); + setLocalValue(value); }; + useDebounce( + () => { + onChange?.(localValue); + }, + 200, + [JSON.stringify(localValue)] + ); + + useEffect(() => { + setLocalValue(value); + }, [JSON.stringify(value)]); + const handleGetTagProps = useCallback((tag): IReqoreTagProps => { const value = tag.value?.toString(); @@ -77,7 +94,7 @@ export const RichTextField = memo( return {}; }, []); - const _value = value || default_value; + const _value = localValue; const formattedValue: IReqoreRichTextEditorProps['value'] = typeof _value !== 'object' ? [ @@ -86,7 +103,9 @@ export const RichTextField = memo( children: [{ text: _value || '' }], }, ] - : _value; + : _value === null + ? undefined + : _value; const tags = useMemo< IReqoreRichTextEditorProps['tags'] @@ -133,7 +152,6 @@ export const RichTextField = memo( return ( ); } diff --git a/src/components/Field/select.tsx b/src/components/Field/select.tsx index d5706e681..ebba340ab 100644 --- a/src/components/Field/select.tsx +++ b/src/components/Field/select.tsx @@ -6,50 +6,40 @@ import { ReqoreMenu, ReqoreMenuItem, ReqoreMessage, + ReqoreP, ReqoreTag, + ReqoreVerticalSpacer, } from '@qoretechnologies/reqore'; -import { IReqoreButtonProps } from '@qoretechnologies/reqore/dist/components/Button'; +import { IReqoreButtonProps, TReqoreBadge } from '@qoretechnologies/reqore/dist/components/Button'; import { IReqoreCollectionItemProps } from '@qoretechnologies/reqore/dist/components/Collection/item'; import { IReqoreControlGroupProps } from '@qoretechnologies/reqore/dist/components/ControlGroup'; -import { IReqoreMenuItemProps } from '@qoretechnologies/reqore/dist/components/Menu/item'; -import { IReqorePanelProps } from '@qoretechnologies/reqore/dist/components/Panel'; -import { TReqoreIntent } from '@qoretechnologies/reqore/dist/constants/theme'; +import { IReqoreDropdownItem } from '@qoretechnologies/reqore/dist/components/Dropdown/list'; import { IReqoreIconName } from '@qoretechnologies/reqore/dist/types/icons'; +import { IQorusAllowedValue } from '@qoretechnologies/ts-toolkit'; import { capitalize, get, size } from 'lodash'; -import React, { useEffect, useState } from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; import ReactMarkdown from 'react-markdown'; -import useMount from 'react-use/lib/useMount'; +import { useMount } from 'react-use'; import { compose } from 'recompose'; -import styled from 'styled-components'; import { addMessageListener, postMessage } from '../../hocomponents/withMessageHandler'; import withTextContext from '../../hocomponents/withTextContext'; +import { useWhyDidYouUpdate } from '../../hooks/useWhyDidYouUpdate'; import CustomDialog from '../CustomDialog'; import FieldEnhancer from '../FieldEnhancer'; import { IField } from '../FieldWrapper'; import { PositiveColorEffect } from './multiPair'; -import { IOptionsSchemaArg } from './systemOptions'; -export interface ISelectFieldItem { - name?: string; - value?: string; - display_name?: string; - short_desc?: string; - disabled?: boolean; - desc?: string; - intent?: TReqoreIntent; - badge?: IReqorePanelProps['badge']; - messages?: IOptionsSchemaArg['messages']; - actions?: IReqorePanelProps['actions']; - metadata?: { - [key: string]: any; +export interface ISelectFieldItem + extends IQorusAllowedValue<{ needs_auth?: boolean; oauth2_auth_code?: boolean; - }; + }> { + name?: unknown; } -export interface ISelectField extends IField { +export interface ISelectField extends Omit { defaultItems?: ISelectFieldItem[]; - predicate?: (name: string) => boolean; + predicate?: (name: unknown) => boolean; placeholder?: string; disabled?: boolean; position?: any; @@ -69,65 +59,17 @@ export interface ISelectField extends IField { showPlaceholder?: boolean; showRightIcon?: boolean; minimal?: boolean; + hideItemCount?: boolean; } -export const StyledDialogSelectItem = styled.div` - &:not(:last-child) { - margin-bottom: 10px; - } - - max-height: 150px; - overflow: hidden; - position: relative; - - &:before { - content: ''; - display: block; - height: 100%; - width: 100%; - position: absolute; - // Linear gradient from top transparent to bottom white - background: linear-gradient( - to bottom, - rgba(255, 255, 255, 0) 0%, - rgba(255, 255, 255, 0) 30%, - rgba(255, 255, 255, 1) 100% - ); - z-index: 10; - } - - background-color: #fff; - - border: 1px solid #ddd; - border-radius: 4px; - padding: 10px; - transition: all 0.2s; - - &:hover, - &.selected { - cursor: pointer; - transform: scale(0.98); - box-shadow: 0 0 10px -6px #555; - } - - &.selected { - border: 2px solid #7fba27; - } - - h5 { - margin: 0; - padding: 0; - font-size: 14px; - } - - p { - margin: 0; - padding: 0; - font-size: 12px; - } -`; +const fixItems = (items: (ISelectFieldItem & { name?: unknown })[] = []): ISelectFieldItem[] => + items.map((item) => ({ + ...item, + display_name: item.display_name || item.name?.toString() || item.value?.toString(), + value: item.value || item.name, + })); -const SelectField: React.FC = ({ +const SelectField = ({ get_message, return_message, name, @@ -155,109 +97,61 @@ const SelectField: React.FC = showDescription = true, showPlaceholder = true, showRightIcon = true, + hideItemCount, ...rest -}) => { - const [items, setItems] = useState(defaultItems || []); +}: ISelectField & Omit & IReqoreControlGroupProps) => { + const [items, setItems] = useState(fixItems(defaultItems || [])); const [query, setQuery] = useState(''); const [appliedFilters, setAppliedFilters] = useState([]); const [isSelectDialogOpen, setSelectDialogOpen] = useState(false); const [listener, setListener] = useState(null); - const [hasProcessor, setHasProcessor] = useState( - requestFieldData ? requestFieldData('processor', 'selected') : false - ); - const [isProcessorSelected, setIsProcessorSelected] = useState( - requestFieldData ? requestFieldData('processor', 'selected') : false - ); useMount(() => { handleClick(); }); useEffect(() => { - if (hasProcessor && name === 'base-class-name') { - listener && listener(); + handleClick(); + }, [listener]); + + useEffect(() => { + listener?.(); + + if (return_message) { setListener(() => addMessageListener( return_message.action, (data: any) => { - const newItems = get(data, 'objects'); - - if (data.object_type === 'processor-base-class') { - setItems(get(data, 'objects')); - - // Check if the current value is a correct processor class - // Remove the value if not - if (value && !newItems.find((item) => item.name === value)) { - onChange(name, null); - } else { - onChange(name, value); - } + // Check if this is the correct + // object type + if (!return_message.object_type || data.object_type === return_message.object_type) { + setItems(fixItems(get(data, return_message.return_value))); } }, true ) ); - } else { - listener && listener(); - if (return_message) { - setListener(() => - addMessageListener( - return_message.action, - (data: any) => { - // Check if this is the correct - // object type - if (!return_message.object_type || data.object_type === return_message.object_type) { - setItems(get(data, return_message.return_value)); - } - }, - true - ) - ); - } } - }, [hasProcessor, return_message?.object_type]); - - useEffect(() => { - setIsProcessorSelected(requestFieldData ? requestFieldData('processor', 'selected') : false); - }); - - // Check if the processor field exists on every change - useEffect(() => { - if (isProcessorSelected) { - if (!hasProcessor) { - setHasProcessor(true); - } - } else { - if (hasProcessor) { - setHasProcessor(false); - } - } - }, [isProcessorSelected]); + }, [return_message?.object_type]); useEffect(() => { if (defaultItems) { - setItems(defaultItems); + setItems(fixItems(defaultItems)); } - }, [defaultItems]); - - useEffect(() => { - handleClick(); - }, [listener]); - - const handleEditSubmit: (_defaultName: string, val: string) => void = (_defaultName, val) => { - handleSelectClick({ name: val }); - handleClick(); - }; + }, [JSON.stringify(defaultItems)]); - const handleSelectClick: (item: any) => void = (item) => { - if (item === value) { - return; - } - // Set the selected item - onChange(name, item.name); - }; + const handleSelectClick = useCallback( + (item: Partial) => { + if (item === value) { + return; + } + // Set the selected item + onChange?.(name, item.value); + }, + [name, onChange, value] + ); - const handleClick: () => void = () => { + const handleClick = useCallback(() => { let className: string; if (requestFieldData) { @@ -267,19 +161,7 @@ const SelectField: React.FC = className = classClassName || cName; } - if (hasProcessor && name === 'base-class-name') { - // Get only the processor related classes - postMessage( - 'creator-get-objects', - { - object_type: 'processor-base-class', - lang: requestFieldData('lang', 'value') || 'qore', - iface_kind, - class_name: className, - }, - true - ); - } else if (get_message) { + if (get_message) { get_message.message_data = get_message.message_data || {}; // Get the list of items from backend postMessage( @@ -294,122 +176,242 @@ const SelectField: React.FC = true ); } - }; + }, [get_message, requestFieldData, iface_kind]); - let filteredItems: ISelectFieldItem[] = items; + const handleEditSubmit: (_defaultName: string, val: string) => void = useCallback( + (_defaultName, val) => { + handleSelectClick({ value: val }); + }, + [handleSelectClick] + ); - // If we should run the items thru predicate - if (predicate) { - filteredItems = filteredItems.filter((item) => predicate(item.name)); - } + const getItemDescription = useCallback( + (itemValue: unknown) => { + const item = items.find((item) => item.value === itemValue); - const getItemShortDescription = (itemName: string, defaultDesc: string = '') => { - if (showDescription !== true) { - return null; - } + return item?.desc || item?.short_desc; + }, + [items] + ); - if (!itemName) { - return defaultDesc; - } + const filteredItems: ISelectFieldItem[] = useMemo((): ISelectFieldItem[] => { + return items.filter((item: any) => { + let isMatch = true; - const item = items.find((item) => item.name === itemName || item.value === itemName); + if (query) { + isMatch = item.label.toLowerCase().includes(query.toLowerCase()); + } - return item?.short_desc || (item?.desc ? 'Hover to see description' : defaultDesc); - }; + if (appliedFilters.length > 0) { + isMatch = appliedFilters.some((filter) => item[filter]); + } - const getItemDescription = (itemName) => { - const item = items.find((item) => item.name === itemName); + if (predicate) { + isMatch = predicate(item.value); + } - return item?.desc || item?.short_desc; - }; + return isMatch; + }); + }, [query, appliedFilters, predicate, items]); + + const reqoreItems: IReqoreDropdownItem[] = useMemo(() => { + return filteredItems.map((item) => ({ + label: item.display_name || (item.value as string), + description: getItemDescription(item.value), + value: item.value?.toString(), + selected: item.value === value, + intent: item.intent, + disabled: item.disabled, + onClick: () => handleSelectClick(item), + })); + }, [JSON.stringify(filteredItems), value]) as IReqoreDropdownItem[]; + + const getItemShortDescription = useCallback( + (itemName: string, defaultDesc: string = '') => { + if (showDescription !== true) { + return null; + } - const getItemDescriptionTooltip = (itemName) => { - if (showDescription !== 'tooltip') { - return undefined; - } + if (!itemName) { + return defaultDesc; + } - return { - delay: 300, - content: {getItemDescription(itemName)}, - maxWidth: '50vh', - }; - }; - - const reqoreItems: IReqoreMenuItemProps[] = filteredItems.map((item) => ({ - label: item.display_name || item.name, - description: getItemDescription(item.name), - value: item.name, - selected: item.name === value, - intent: item.intent, - disabled: item.disabled, - onClick: () => handleSelectClick(item), - })); + const item = items.find((item) => item.display_name === itemName || item.value === itemName); + + return item?.short_desc || (item?.desc ? 'Hover to see description' : defaultDesc); + }, + [items, showDescription] + ); + + const getItemDescriptionTooltip = useCallback( + (itemName: string) => { + if (showDescription !== 'tooltip') { + return undefined; + } + + return { + delay: 300, + content: ( + {getItemDescription(itemName)} + ), + maxWidth: '50vh', + }; + }, + [showDescription, getItemDescription] + ); /** * It returns true if any of the items in the data array have a desc property * @param data - The data that we're going to be checking. * @returns A boolean value. */ - const hasItemsWithDesc = (data: ISelectFieldItem[]) => { + const hasItemsWithDesc = useCallback((data: ISelectFieldItem[]) => { return data.some((item) => item.desc || item.short_desc); - }; - - const hasError = (data: ISelectFieldItem[], value: string) => { - if (!value) { - return hasItemsWithError(data); - } - - const item = data.find((item) => item.name === value); - - return ( - item?.intent === 'danger' || !!item?.messages?.find((message) => message.intent === 'danger') - ); - }; + }, []); - const hasWarning = (data: ISelectFieldItem[], value: string) => { - if (!value) { - return hasItemsWithWarning(data); - } - - const item = data.find((item) => item.name === value); - - return ( - item?.intent === 'warning' || - !!item?.messages?.find((message) => message.intent === 'warning') || - item?.metadata?.needs_auth - ); - }; - - const hasItemsWithError = (data: ISelectFieldItem[]) => { + const hasItemsWithError = useCallback((data: ISelectFieldItem[]) => { return data.some( (item) => item.intent === 'danger' || item.messages?.find((message) => message.intent === 'danger') ); - }; + }, []); + + const hasError = useCallback( + (data: ISelectFieldItem[], value: unknown) => { + if (!value) { + return hasItemsWithError(data); + } + + const item = data.find((item) => item.value === value); - const hasItemsWithWarning = (data: ISelectFieldItem[]) => { + return ( + item?.intent === 'danger' || + !!item?.messages?.find((message) => message.intent === 'danger') + ); + }, + [hasItemsWithError] + ); + + const hasItemsWithWarning = useCallback((data: ISelectFieldItem[]) => { return data.some( (item) => item.intent === 'warning' || item.messages?.find((message) => message.intent === 'warning') || item.metadata?.needs_auth ); - }; + }, []); - const getLabel = (items: ISelectFieldItem[], value: string) => { - return ( - items?.find((item) => item.name === value || item.value === value)?.display_name || value - ); - }; + const hasWarning = useCallback( + (data: ISelectFieldItem[], value: unknown) => { + if (!value) { + return hasItemsWithWarning(data); + } + + const item = data.find((item) => item.value === value); + + return ( + item?.intent === 'warning' || + !!item?.messages?.find((message) => message.intent === 'warning') || + item?.metadata?.needs_auth + ); + }, + [hasItemsWithWarning] + ); + + const getLabel = useCallback((items: ISelectFieldItem[], value: string) => { + return items?.find((item) => item.value === value)?.display_name || value; + }, []); + + const getIcon = useCallback( + ( + items: ISelectFieldItem[], + value: unknown + ): Pick => { + const item = items?.find((item) => item.value === value); + + if (item?.image) { + return { + icon: 'CheckboxBlankLine', + leftIconProps: { + image: item.image, + size: '30px', + rounded: true, + }, + }; + } + + return { + icon: item?.icon || (hasError(items, value) ? 'ErrorWarningLine' : icon), + }; + }, + [icon] + ); + + const itemCount: TReqoreBadge = useMemo( + () => + hideItemCount + ? undefined + : { + label: size(items), + align: 'right', + }, + [size(items), hideItemCount] + ); + + useWhyDidYouUpdate(`Select field ${name}`, { + get_message, + return_message, + name, + description, + onChange, + value, + defaultItems, + t, + predicate, + placeholder, + disabled, + requestFieldData, + warningMessageOnEmpty, + autoSelect, + reference, + iface_kind, + context, + editOnly, + target_dir, + forceDropdown, + asMenu, + icon, + filters, + className, + showDescription, + showPlaceholder, + showRightIcon, + hideItemCount, + items, + query, + appliedFilters, + isSelectDialogOpen, + filteredItems, + reqoreItems, + hasItemsWithDesc, + hasError, + hasWarning, + hasItemsWithError, + hasItemsWithWarning, + getLabel, + getIcon, + itemCount, + ...rest, + }); if (autoSelect && filteredItems.length === 1 && !filteredItems[0].disabled) { - // Automaticaly select the first item - if (!disabled && filteredItems[0].name !== value && !value) { + // Automatically select the first item + if (!disabled && filteredItems[0].value !== value && !value) { handleSelectClick(filteredItems[0]); } - const itemHasError = hasError(items, filteredItems[0].name); - const itemHasWarning = hasWarning(items, filteredItems[0].name); + const itemHasError = hasError(items, filteredItems[0].value); + const itemHasWarning = hasWarning(items, filteredItems[0].value); // Show readonly string return ( @@ -417,25 +419,12 @@ const SelectField: React.FC = fluid flat={false} className={className} - label={value || filteredItems[0].name} + label={value || filteredItems[0].value} description={getItemShortDescription(value)} - tooltip={ - !!getItemDescription(value) - ? { - delay: 300, - content: {getItemDescription(value)}, - maxWidth: '50vh', - } - : undefined - } + tooltip={getItemDescriptionTooltip(value)} readOnly fixed - icon={ - filteredItems[0].desc - ? icon || - (hasError(items, value || filteredItems[0].name) ? 'ErrorWarningLine' : undefined) - : undefined - } + {...getIcon(filteredItems, filteredItems[0].value)} {...rest} intent={value ? 'info' : rest.intent} effect={ @@ -467,22 +456,6 @@ const SelectField: React.FC = ); } - const filterItems = (items: ISelectFieldItem[]): ISelectFieldItem[] => { - return items.filter((item: any) => { - let isMatch = true; - - if (query) { - isMatch = item.label.toLowerCase().includes(query.toLowerCase()); - } - - if (appliedFilters.length > 0) { - isMatch = appliedFilters.some((filter) => item[filter]); - } - - return isMatch; - }); - }; - return ( <> = clearOnFocus: true, }, }} - items={filterItems(filteredItems).map( + items={filteredItems.map( (item): IReqoreCollectionItemProps => ({ - label: item.display_name || item.name, + label: item.display_name || item.value?.toString(), content: ( <> + + {(item.messages || []).map(({ intent, title, content }, index) => ( = {content} ))} - {getItemDescription(item.name)} + + + {getItemDescription(item.value)} + + ), flat: false, - size: 'small', - minimal: false, - selected: item.name === value, - intent: item.intent, + minimal: true, + selected: item.value !== undefined && item.value !== null && item.value === value, + intent: + item.value !== undefined && item.value !== null && item.value === value + ? 'info' + : item.intent, badge: item.badge, actions: item.actions, + icon: item.icon, + iconImage: item.image, + iconProps: { + size: '30px', + rounded: true, + }, tooltip: !!item.desc ? { delay: 800, - content: {getItemDescription(item.name)}, + content: {getItemDescription(item.value)}, maxWidth: '70vw', } : undefined, headerEffect: { color: '#ffffff' }, - contentEffect: item.name === value ? { gradient: { colors: 'main' } } : undefined, onClick: !item.disabled ? (e) => { if (e.currentTarget.contains(e.target)) { @@ -577,7 +574,7 @@ const SelectField: React.FC = }))} /> - {!filteredItems || filteredItems.length === 0 ? ( + {filteredItems.length === 0 ? ( ) : null} = fluid compact={rest.compact} key={value} - icon={icon || (hasError(items, value) ? 'ErrorWarningLine' : undefined)} + badge={itemCount} + {...getIcon(items, value)} rightIcon={showRightIcon ? 'ListUnordered' : undefined} onClick={() => setSelectDialogOpen(true)} description={getItemShortDescription(value, 'Select from available values')} @@ -633,8 +631,8 @@ const SelectField: React.FC = {filteredItems.map((item) => ( = ) : ( = ); }; -export default compose(withTextContext())(SelectField) as React.FC< - ISelectField & - IField & - Omit & - Omit ->; +export default memo( + compose(withTextContext())(SelectField) as React.FC< + ISelectField & + Omit & + Omit & + Omit + > +); diff --git a/src/components/Field/systemOptions.tsx b/src/components/Field/systemOptions.tsx index b95a3bd01..b310b727b 100644 --- a/src/components/Field/systemOptions.tsx +++ b/src/components/Field/systemOptions.tsx @@ -15,7 +15,6 @@ import { IReqoreCollectionItemProps } from '@qoretechnologies/reqore/dist/compon import { IReqorePanelProps } from '@qoretechnologies/reqore/dist/components/Panel'; import { IReqoreTextareaProps } from '@qoretechnologies/reqore/dist/components/Textarea'; import { TReqoreIntent } from '@qoretechnologies/reqore/dist/constants/theme'; -import { IReqoreAutoFocusRules } from '@qoretechnologies/reqore/dist/hooks/useAutoFocus'; import { IQorusFormField, IQorusFormFieldMessage, @@ -29,21 +28,23 @@ import { TQorusFormOperatorValue, TQorusType, } from '@qoretechnologies/ts-toolkit'; -import { cloneDeep, findKey, forEach, isEqual, last } from 'lodash'; +import { cloneDeep, findKey, flatten, forEach, isEqual, last } from 'lodash'; import isArray from 'lodash/isArray'; import map from 'lodash/map'; import reduce from 'lodash/reduce'; import size from 'lodash/size'; -import React, { useCallback, useContext, useEffect, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import useMount from 'react-use/lib/useMount'; import useUpdateEffect from 'react-use/lib/useUpdateEffect'; import { isObject } from 'util'; import { TextContext } from '../../context/text'; import { fetchData, insertAtIndex } from '../../helpers/functions'; +import { getRequiredOptionMessage } from '../../helpers/options'; import { hasAllDependenciesFullfilled, validateField } from '../../helpers/validations'; import { useTemplates } from '../../hooks/useTemplates'; +import { useWhyDidYouUpdate } from '../../hooks/useWhyDidYouUpdate'; import { Description } from '../Description'; -import AutoField from './auto'; +import auto from './auto'; import { NegativeColorEffect, PositiveColorEffect } from './multiPair'; import { OptionFieldMessages } from './optionFieldMessages'; import SelectField, { ISelectFieldItem } from './select'; @@ -95,14 +96,20 @@ export const fixOptions = ( (option.required && !fixedValue[name]) || (option.required && option.default_value && !option.value) ) { - fixedValue[name] = { - is_expression: fixedValue[name]?.is_expression, + const obj: IQorusFormField = { + //@ts-expect-error need to fix types type: getType(option.type, operators, fixedValue[name]?.op), value: (typeof fixedValue[name] === 'object' ? fixedValue[name]?.value : fixedValue[name]) ?? option.value ?? option.default_value, }; + + if (fixedValue[name]?.is_expression) { + obj.is_expression = true; + } + + fixedValue[name] = obj; } }); @@ -110,13 +117,19 @@ export const fixOptions = ( fixedValue, (newValue, option, optionName) => { if (!isObject(option) || !option?.type) { + const obj: IQorusFormField = { + //@ts-expect-error need to fix types + type: getType(options?.[optionName]?.type, operators, option?.op), + value: option, + }; + + if (option?.is_expression) { + obj.is_expression = true; + } + return { ...newValue, - [optionName]: { - type: getType(options?.[optionName]?.type, operators, option?.op), - value: option, - is_expression: option?.is_expression, - }, + [optionName]: obj, }; } @@ -141,7 +154,7 @@ export const flattenOptions = (options: IOptions): TFlatOptions => { ); }; -export type IQorusType = TQorusType | 'context' | 'select-string'; +export type IQorusType = TQorusType | 'context' | 'select-string' | 'enum'; export type TOperatorValue = TQorusFormOperatorValue; @@ -150,12 +163,11 @@ export type IOptions = TQorusForm; export type TFlatOptions = TQorusFlatForm; -export interface IOptionFieldMessage extends IQorusFormFieldMessage {} +export interface IOptionFieldMessage extends IQorusFormFieldMessage {} -export interface IOptionsSchemaArg - extends IQorusFormFieldSchema {} +export interface IOptionsSchemaArg extends IQorusFormFieldSchema {} -export interface IOptionsSchema extends IQorusFormSchema {} +export interface IOptionsSchema extends IQorusFormSchema {} export interface IOperator extends IQorusFormOperator {} @@ -165,6 +177,7 @@ export interface IOptionsOnChangeMeta extends IQorusFormFieldOnChangeMeta {} export interface IOptionsProps extends Omit { name: string; + uniqueName?: string; url?: string; customUrl?: string; value?: IOptions | TFlatOptions; @@ -188,6 +201,9 @@ export interface IOptionsProps extends Omit stringTemplates?: IReqoreTextareaProps['templates']; interfaceContext?: string; templateFieldProps?: ITemplateFieldProps; + allowSaving?: boolean; + showSavedValues?: boolean; + compact?: boolean; } export const getTypeAndCanBeNull = ( @@ -216,6 +232,7 @@ export const getTypeAndCanBeNull = ( const Options = ({ name, + uniqueName, value, onChange, onSingleOptionsChange, @@ -231,6 +248,9 @@ const Options = ({ readOnly, allowTemplates = true, templateFieldProps, + allowSaving, + showSavedValues, + compact, ...rest }: IOptionsProps) => { const t: any = useContext(TextContext); @@ -239,6 +259,15 @@ const Options = ({ const confirmAction = useReqoreProperty('confirmAction'); const [error, setError] = useState(null); const [loading, setLoading] = useState(rest.options ? false : true); + const [showFieldTypes, setShowFieldTypes] = useState(false); + const [localValue, setLocalValue] = useState<{ + fields: IOptions | TFlatOptions; + meta?: IOptionsOnChangeMeta; + }>({ + fields: fixOptions(value, options), + meta: undefined, + }); + const unavailableOptionsCount = useRef(0); const getUrl = () => customUrl || `/options/${url}`; @@ -254,7 +283,7 @@ const Options = ({ setOptions({}); return; } - onChange?.(name, fixOptions(value, data.data)); + setLocalValue?.({ fields: fixOptions(value, data.data), meta: undefined }); // Save the new options if (!operatorsUrl) { setLoading(false); @@ -263,6 +292,7 @@ const Options = ({ onOptionsLoaded?.(data.data); })(); } + if (operatorsUrl) { (async () => { setOperators(undefined); @@ -303,11 +333,15 @@ const Options = ({ } setOptions(data.data); onOptionsLoaded?.(data.data); - onChange?.(name, fixOptions({}, data.data)); + setLocalValue?.({ fields: fixOptions({}, data.data), meta: undefined }); })(); } }, [url, customUrl]); + useEffect(() => { + onChange?.(name, size(localValue.fields) ? localValue.fields : undefined, localValue.meta); + }, [JSON.stringify(localValue.fields), JSON.stringify(localValue.meta)]); + useEffect(() => { setOptions(rest.options); setLoading(false); @@ -318,7 +352,7 @@ const Options = ({ const fixedValue = fixOptions(value, options); if (!isEqual(fixedValue, value)) { - onChange?.(name, fixedValue); + setLocalValue?.({ fields: fixedValue, meta: undefined }); } }, [JSON.stringify(options), JSON.stringify(value)]); @@ -345,86 +379,92 @@ const Options = ({ const templates = useTemplates(allowTemplates, rest.stringTemplates); const handleValueChange = useCallback( - ( - optionName: string, - currentValue: any = {}, - val?: any, - type?: string, - isFunction?: boolean - ) => { - // Check if this option is already added - if (!currentValue[optionName]) { - // If it's not, add potential default operators - const defaultOperators: TOperatorValue = reduce( - operators || {}, - (filteredOperators: TOperatorValue, operator, operatorKey) => { - if (operator.selected) { - return [...(filteredOperators as string[]), operatorKey]; - } - - return filteredOperators; - }, - [] - ); - // If there are default operators, add them to the value - if (defaultOperators?.length) { - onChange?.(name, { - ...currentValue, - [optionName]: { - type, - value: val, - op: defaultOperators, + (optionName: string, val?: any, _type?: string, isFunction?: boolean) => { + setLocalValue(({ fields = {} }) => { + const type = + _type || + getTypeAndCanBeNull(fields[optionName]?.type, options[optionName].allowed_values).type; + // Check if this option is already added + if (!fields[optionName]) { + // If it's not, add potential default operators + const defaultOperators: TOperatorValue = reduce( + operators || {}, + (filteredOperators: TOperatorValue, operator, operatorKey) => { + if (operator.selected) { + return [...(filteredOperators as string[]), operatorKey]; + } + + return filteredOperators; }, - }); - - return; + [] + ); + // If there are default operators, add them to the value + if (defaultOperators?.length) { + return { + fields: { + ...fields, + [optionName]: { + type, + value: val, + op: defaultOperators, + }, + }, + }; + } } - } - const updatedValue: IOptions = { - ...currentValue, - [optionName]: { - ...currentValue[optionName], - type, - value: val, - }, - }; + const updatedValue: IOptions = { + ...fields, + [optionName]: { + ...fields[optionName], + type, + value: val, + }, + }; - if (isFunction) { - updatedValue[optionName].is_expression = true; - } else { - delete updatedValue[optionName].is_expression; - } + if (isFunction) { + updatedValue[optionName].is_expression = true; + } else { + delete updatedValue[optionName].is_expression; + } - const meta: IOptionsOnChangeMeta = {}; + const meta: IOptionsOnChangeMeta = {}; + + // Check if this option has dependents and if the value has changed + // If it has, call the onDependableOptionChange function + if ( + options[optionName].has_dependents && + val !== undefined && + val !== fields[optionName]?.value + ) { + // We also need to remove the value from all dependants + forEach(options, (option, name) => { + if ( + option.depends_on && + flatten(option.depends_on).includes(optionName) && + updatedValue[name] + ) { + updatedValue[name].value = undefined; + } + }); - // Check if this option has dependents and if the value has changed - // If it has, call the onDependableOptionChange function - if ( - options[optionName].has_dependents && - val !== undefined && - val !== currentValue[optionName]?.value - ) { - // We also need to remove the value from all dependants - forEach(options, (option, name) => { - if (option.depends_on?.includes(optionName) && updatedValue[name]) { - updatedValue[name].value = undefined; - } - }); + onDependableOptionChange?.(optionName, val, updatedValue, options); + } - onDependableOptionChange?.(optionName, val, updatedValue, options); - } + // Check if this option has on_change events + if (size(options[optionName].on_change)) { + meta.events = options[optionName].on_change; + } - // Check if this option has on_change events - if (size(options[optionName].on_change)) { - meta.events = options[optionName].on_change; - } + onSingleOptionsChange?.(optionName, updatedValue[optionName]); - onSingleOptionsChange?.(optionName, updatedValue[optionName]); - onChange?.(name, updatedValue, meta); + return { + fields: updatedValue, + meta, + }; + }); }, [ - onChange, onSingleOptionsChange, onDependableOptionChange, JSON.stringify(options), @@ -432,191 +472,216 @@ const Options = ({ ] ); - const handleOperatorChange = ( - optionName: string, - currentValue: IOptions, - operator: string, - index: number - ) => { - onChange?.(name, { - ...currentValue, - [optionName]: { - ...currentValue[optionName], - op: fixOperatorValue(currentValue[optionName].op).map((op, idx) => { - if (idx === index) { - return operator; - } - return op as string; - }), - }, - }); - }; + const handleOperatorChange = useCallback( + (optionName: string, currentValue: IOptions, operator: string, index: number) => { + setLocalValue?.({ + fields: { + ...currentValue, + [optionName]: { + ...currentValue[optionName], + op: fixOperatorValue(currentValue[optionName].op).map((op, idx) => { + if (idx === index) { + return operator; + } + return op as string; + }), + }, + }, + meta: undefined, + }); + }, + [] + ); // Add empty operator at the provider index - const handleAddOperator = (optionName, currentValue: IOptions, index: number) => { - onChange?.(name, { - ...currentValue, - [optionName]: { - ...currentValue[optionName], - op: insertAtIndex(fixOperatorValue(currentValue[optionName].op), index, null), + const handleAddOperator = useCallback((optionName, currentValue: IOptions, index: number) => { + setLocalValue?.({ + fields: { + ...currentValue, + [optionName]: { + ...currentValue[optionName], + op: insertAtIndex(fixOperatorValue(currentValue[optionName].op), index, null), + }, }, + meta: undefined, }); - }; + }, []); - const handleRemoveOperator = (optionName, currentValue: IOptions, index: number) => { - onChange?.(name, { - ...currentValue, - [optionName]: { - ...currentValue[optionName], - op: fixOperatorValue(currentValue[optionName].op).filter((_op, idx) => idx !== index), + const handleRemoveOperator = useCallback((optionName, currentValue: IOptions, index: number) => { + setLocalValue?.({ + fields: { + ...currentValue, + [optionName]: { + ...currentValue[optionName], + op: fixOperatorValue(currentValue[optionName].op).filter((_op, idx) => idx !== index), + }, }, + meta: undefined, }); - }; + }, []); - const removeValue = () => { - onChange?.(name, undefined); - }; + const removeValue = useCallback(() => { + setLocalValue?.({ fields: undefined, meta: undefined }); + }, []); - const buildBadges = useCallback((option: IOptionsSchemaArg): IReqorePanelProps['badge'] => { - const badges: IReqorePanelProps['badge'] = []; + const buildBadges = useCallback( + (option: IOptionsSchemaArg, optionName: string): IReqorePanelProps['badge'] => { + const badges: IReqorePanelProps['badge'] = []; - if (option.required || option.required_groups) { - badges.push({ - icon: 'Asterisk', - leftIconProps: { - size: 'tiny', - }, - iconColor: 'danger:lighten:7', - tooltip: t('This option is required'), - }); - } + if (option.required || option.required_groups) { + badges.push({ + icon: 'Asterisk', + leftIconProps: { + size: 'tiny', + }, + iconColor: option.required_groups ? 'warning:lighten:7' : 'danger:lighten:7', + tooltip: getRequiredOptionMessage(options, option.required_groups, optionName), + }); + } - if (option.has_dependents) { - badges.push({ - icon: 'LinkUnlink', - intent: 'info', - tooltip: t( - 'Other options depend on this option, changing it may result in configuration changes.' - ), - }); - } + if (option.has_dependents) { + badges.push({ + icon: 'LinkUnlink', + intent: 'info', + tooltip: t( + 'Other options depend on this option, changing it may result in configuration changes.' + ), + }); + } - return badges; - }, []); + return badges; + }, + [options] + ); - const fixedValue: IOptions = value || {}; - const removeSelectedOption = (optionName: string) => { - const newValue = cloneDeep(value); + const fixedValue: IOptions = localValue.fields || {}; - delete newValue?.[optionName]; + const removeSelectedOption = useCallback((optionName: string) => { + setLocalValue?.(({ fields }) => { + const newFields = cloneDeep(fields); - onChange?.(name, newValue); - }; + delete newFields[optionName]; - const addSelectedOption = (optionName: string) => { - handleValueChange( - optionName, - fixedValue, - options[optionName].default_value, - getTypeAndCanBeNull(options[optionName].type, options[optionName].allowed_values).type - ); - }; + return { + fields: newFields, + meta: undefined, + }; + }); + }, []); - let unavailableOptionsCount = 0; - const availableOptions: IOptions = Object.keys(fixedValue) - .sort((a, b) => { - const aSort = options[a]?.sort || 0; - const bSort = options[b]?.sort || 0; - - return aSort - bSort; - }) - .reduce((newValue, optionName) => { - const option = fixedValue[optionName]; - // Check if this option is in the options schema - // do not add it if not - if (!options?.[optionName]) { - unavailableOptionsCount += 1; - return newValue; - } + const handleAddOptionalFieldChange = useCallback( + (_name, optionName: string) => { + handleValueChange( + optionName, + options[optionName].default_value, + getTypeAndCanBeNull(options[optionName].type, options[optionName].allowed_values).type + ); + }, + [JSON.stringify(options), handleValueChange] + ); - if (!isObject(option)) { - return { - ...newValue, - [optionName]: { - type: getType(options[optionName].type, operators, option?.op), - value: option, - }, - }; - } + const availableOptions: IOptions = useMemo(() => { + if (!options) { + return {}; + } + // Reset the unavailable options count + unavailableOptionsCount.current = 0; + + return Object.keys(fixedValue) + .sort((a, b) => { + const aSort = options[a]?.sort || 0; + const bSort = options[b]?.sort || 0; + + return aSort - bSort; + }) + .reduce((newValue, optionName) => { + const option = fixedValue[optionName]; + // Check if this option is in the options schema + // do not add it if not + if (!options?.[optionName]) { + unavailableOptionsCount.current += 1; + return newValue; + } - return { ...newValue, [optionName]: option }; - }, {}); - - const handleOptionChange = useCallback( - (optionName, val, givenType, isFunction) => { - if (val !== undefined && val !== availableOptions[optionName]?.value) { - handleValueChange( - optionName, - fixedValue, - val, - givenType || - getTypeAndCanBeNull( - availableOptions[optionName]?.type, - options[optionName].allowed_values - ).type, - isFunction - ); - } - }, - [ - JSON.stringify(availableOptions), - JSON.stringify(fixedValue), - JSON.stringify(options), - handleValueChange, - ] + if (!isObject(option)) { + return { + ...newValue, + [optionName]: { + type: getType(options[optionName].type, operators, option?.op), + value: option, + }, + }; + } + + return { ...newValue, [optionName]: option }; + }, {}); + }, [ + JSON.stringify(fixedValue), + JSON.stringify(options), + unavailableOptionsCount.current, + JSON.stringify(operators), + ]); + + const filteredOptions: IOptionsSchema = useMemo( + () => + reduce( + options, + (newOptions, option, name) => { + if (name in fixedValue) { + return newOptions; + } + + return { ...newOptions, [name]: option }; + }, + {} + ), + [JSON.stringify(options), JSON.stringify(fixedValue)] ); - const filteredOptions: IOptionsSchema = reduce( - options, - (newOptions, option, name) => { - if (fixedValue && name in fixedValue) { - return newOptions; + const isOptionValid = useCallback( + (optionName: string, type: IQorusType, optionValue: any) => { + // If the option is not required and undefined it's valid :) + if ( + !options[optionName].required && + !options[optionName].required_groups && + (optionValue === undefined || optionValue === '') + ) { + return true; } - return { ...newOptions, [name]: option }; + return validateField(getType(type), optionValue, { + isFunction: localValue.fields[optionName]?.is_expression, + has_to_have_value: true, + optionSchema: options, + options: availableOptions, + ...options[optionName], + } as any); }, - {} + [JSON.stringify(options), JSON.stringify(availableOptions), JSON.stringify(localValue.fields)] ); - const isOptionValid = (optionName: string, type: IQorusType, optionValue: any) => { - // If the option is not required and undefined it's valid :) - if ( - !options[optionName].required && - !options[optionName].required_groups && - (optionValue === undefined || optionValue === '') - ) { - return true; - } + const getIntent = useCallback( + (name, type, value, op): TReqoreIntent => { + const intent = + isOptionValid(name, type, value) && (operatorsUrl ? !!op : true) + ? undefined + : recordRequiresSearchOptions + ? 'info' + : 'danger'; - return validateField(getType(type), optionValue, { - isFunction: value[optionName]?.is_expression, - has_to_have_value: true, - optionSchema: options, - options: availableOptions, - ...options[optionName], - }); - }; + return intent || options[name].intent; + }, + [isOptionValid, recordRequiresSearchOptions, JSON.stringify(options), operatorsUrl] + ); - const getIntent = (name, type, value, op): TReqoreIntent => { - const intent = - isOptionValid(name, type, value) && (operatorsUrl ? !!op : true) - ? undefined - : recordRequiresSearchOptions - ? 'info' - : 'danger'; + const handleShowFieldTypesClick = useCallback(() => setShowFieldTypes((prev) => !prev), []); - return intent || options[name].intent; - }; + useWhyDidYouUpdate(`Options: ${name}`, { + value, + onChange, + onSingleOptionsChange, + onDependableOptionChange, + }); if ((operatorsUrl && !operators) || (!rest.options && !options) || templates.loading || loading) { return ( @@ -670,20 +735,25 @@ const Options = ({ ) : null} 1} minimal contentRenderer={(children) => ( <> - {unavailableOptionsCount ? ( + {unavailableOptionsCount.current ? ( <> - {`${unavailableOptionsCount} option(s) hidden because they are not supported on the current instance`} + {`${unavailableOptionsCount.current} option(s) hidden because they are not supported on the current instance`} @@ -697,6 +767,7 @@ const Options = ({ defaultItems={Object.keys(filteredOptions).map( (option): ISelectFieldItem => ({ name: option, + value: option, desc: options[option].desc, short_desc: options[option].short_desc, disabled: options[option].disabled, @@ -710,31 +781,39 @@ const Options = ({ main: '#22273b', }} minimal - onChange={(_name, value) => addSelectedOption(value)} + onChange={handleAddOptionalFieldChange} placeholder={`${t(placeholder || 'AddNewOption')} (${size(filteredOptions)})`} /> ) : null} )} - badge={size(fixedValue)} - intent={isValid === false ? 'danger' : undefined} + showLayoutSwitch={false} style={{ width: '100%' }} - defaultZoom={0.5} items={map( availableOptions, ({ type, ...other }, optionName): IReqoreCollectionItemProps => ({ label: options[optionName]?.display_name || optionName, + description: showFieldTypes ? `<${options[optionName].type}>` : undefined, + descriptionEffect: { + opacity: 0.5, + }, customTheme: { main: 'main:darken:1', }, + flat: false, icon: !isOptionValid(optionName, type, other.value) ? 'SpamFill' : undefined, + iconColor: !isOptionValid(optionName, type, other.value) + ? 'danger:lighten:5' + : undefined, transparent: false, intent: getIntent(optionName, type, other.value, other.op), - badge: buildBadges(options[optionName]), + badge: buildBadges(options[optionName], optionName), className: 'system-option', + size: 'small', actions: [ { + size: 'tiny', icon: 'DeleteBinLine', intent: 'danger', className: 'options-optional-remove', @@ -755,7 +834,6 @@ const Options = ({ content: ( <> @@ -778,8 +856,10 @@ const Options = ({ ({ ...operator, + value: operator.name, }))} disabled={readOnly} value={operator && `${operators?.[operator].name}`} @@ -821,15 +901,16 @@ const Options = ({ ) : null} ); diff --git a/src/components/Field/template.tsx b/src/components/Field/template.tsx index 3d4d7f278..9e7aa6d52 100644 --- a/src/components/Field/template.tsx +++ b/src/components/Field/template.tsx @@ -12,17 +12,10 @@ import { } from '@qoretechnologies/reqore/dist/components/Textarea'; import { size } from 'lodash'; import { memo, useCallback, useEffect, useMemo, useState } from 'react'; -import { useAsyncRetry, useUpdateEffect } from 'react-use'; -import { - ExpressionBuilder, - IExpression, - IExpressionSchema, -} from '../../components/ExpressionBuilder'; -import { - fetchData, - filterTemplatesByType, - getExpressionArgumentType, -} from '../../helpers/functions'; +import { useUpdateEffect } from 'react-use'; +import { ExpressionBuilder, IExpression } from '../../components/ExpressionBuilder'; +import { filterTemplatesByType, getExpressionArgumentType } from '../../helpers/functions'; +import { useExpressions } from '../../hooks/useExpressions'; import { useQorusTypes } from '../../hooks/useQorusTypes'; import { useWhyDidYouUpdate } from '../../hooks/useWhyDidYouUpdate'; import Auto from '../Field/auto'; @@ -30,6 +23,7 @@ import BooleanField from '../Field/boolean'; import DateField from '../Field/date'; import LongStringField from '../Field/longString'; import Number from '../Field/number'; +import { SaveValueButton } from '../SaveValueButton'; import { RichTextField } from './richText'; import Select from './select'; @@ -101,6 +95,7 @@ export const getTemplateValue = (value?: string) => { export interface ITemplateFieldProps { value?: any; name?: string; + uniqueName?: string; onChange?: (name: string, value: any, type?: IQorusType, isFunction?: boolean) => void; // React element component?: React.FC; @@ -149,18 +144,9 @@ export const TemplateField = memo( ...rest }: ITemplateFieldProps) => { const qorusTypes = useQorusTypes(); + const functions = useExpressions(allowFunctions); const type = rest.type || rest.defaultType; - const functions = useAsyncRetry(async () => { - if (!allowFunctions) { - return []; - } - - const data = await fetchData(`/system/expressions`); - - return data.data; - }, [type]); - const [isTemplate, setIsTemplate] = useState( isValueTemplate(value) || !allowCustomValues ); @@ -249,6 +235,131 @@ export const TemplateField = memo( [name, onChange] ); + const hasOnlyAllowedValues = useMemo( + () => size(rest.allowed_values) && !rest.allowed_values_creatable, + [rest.allowed_values, rest.allowed_values_creatable] + ); + + const canSaveValue = useMemo( + () => + rest.allowSaving && + (!rest.allowed_values || rest.allowed_values_creatable) && + !rest.readonly && + !rest.disabled, + [ + rest.allowSaving, + rest.allowed_values, + rest.allowed_values_creatable, + rest.readonly, + rest.disabled, + ] + ); + + const handleRemoveTemplateClick = useCallback(() => { + if (allowCustomValues) { + setIsTemplate(false); + } + + setTemplateValue(null); + onChange?.(name, undefined); + }, [allowCustomValues, name, onChange]); + + const handleSelectFunctionChange = useCallback( + (_n, item) => { + const func = functions.value.find((f) => f.name === item); + + setIsTemplate(false); + setTemplateValue(null); + + onChange?.( + name, + { + exp: func.name, + args: [ + { + type: type || getExpressionArgumentType(func.args[0], qorusTypes.value), + value, + }, + ], + }, + undefined, + true + ); + }, + [JSON.stringify(functions.value), JSON.stringify(qorusTypes.value), name, onChange, type] + ); + + const handleTemplateToggleClick = useCallback(() => { + setIsTemplate(true); + setTemplateValue(null); + onChange(name, undefined); + }, [onChange, name]); + + const handleExpressionChange = useCallback( + (expressionValue: IExpression | undefined, remove: boolean) => { + onChange(name, expressionValue?.value || value?.args[0]?.value, type, !remove); + }, + [name, onChange, type, value] + ); + + const renderControls = useCallback(() => { + const showFunctionsDropdown = allowFunctions && !hasOnlyAllowedValues && !rest.readonly; + const showTemplatesButton = showTemplateToggle && !isTemplate; + + if (showFunctionsDropdown || canSaveValue || showTemplatesButton) { + return ( + + {showFunctionsDropdown ? ( + { - const func = functions.value.find((f) => f.name === item); - setIsTemplate(false); - setTemplateValue(null); - - onChange( - name, - { - exp: func.name, - args: [ - { - type: type || getExpressionArgumentType(func.args[0], qorusTypes.value), - value, - }, - ], - }, - undefined, - true - ); - }} - /> - ) : null} - - {showTemplateToggle && !isTemplate ? ( - { - setIsTemplate(true); - setTemplateValue(null); - onChange(name, undefined); - }} - /> - ) : null} + {renderControls()} ); } diff --git a/src/components/FieldWrapper/index.tsx b/src/components/FieldWrapper/index.tsx index b533b539e..f52c596ac 100644 --- a/src/components/FieldWrapper/index.tsx +++ b/src/components/FieldWrapper/index.tsx @@ -314,6 +314,7 @@ export interface IField { onDelete?: () => any; }; iface_kind?: string; + level?: number; } export declare type IFieldChange = ( diff --git a/src/components/SaveValueButton/index.tsx b/src/components/SaveValueButton/index.tsx new file mode 100644 index 000000000..fbfc6ec92 --- /dev/null +++ b/src/components/SaveValueButton/index.tsx @@ -0,0 +1,156 @@ +import { ReqoreButton, ReqoreModal } from '@qoretechnologies/reqore'; +import { IReqoreButtonProps } from '@qoretechnologies/reqore/dist/components/Button'; +import { IReqoreModalProps } from '@qoretechnologies/reqore/dist/components/Modal'; +import { + IReqorePanelAction, + IReqorePanelBottomAction, +} from '@qoretechnologies/reqore/dist/components/Panel'; +import { IQorusFormField, TQorusType } from '@qoretechnologies/ts-toolkit'; +import { isUndefined } from 'lodash'; +import { useCallback, useMemo, useState } from 'react'; +import { validateField } from '../../helpers/validations'; +import { useQorusStorage } from '../../hooks/useQorusStorage'; +import Options, { IOptionsSchema } from '../Field/systemOptions'; + +export interface ISaveValueButtonProps extends Omit { + value: any; + type: TQorusType; + id: string; +} + +export interface ISaveValueModalProps extends IReqoreModalProps { + value: any; + onSave: (metadata: ISaveValueMetadata) => void; +} + +export interface ISaveValueMetadata { + display_name: IQorusFormField; + short_desc: IQorusFormField; + value: IQorusFormField; + id: IQorusFormField; + actions?: IReqorePanelAction[]; +} + +const SaveValueMetadataSchema: IOptionsSchema = { + display_name: { + type: 'string', + display_name: 'Display Name', + short_desc: 'This is the name that will be displayed in the list of saved values', + required: true, + }, + short_desc: { + display_name: 'Short Description', + short_desc: 'This is a short description of the saved value', + type: 'string', + preselected: true, + }, +}; + +export const SaveValueButton = ({ value, type, id, ...rest }: ISaveValueButtonProps) => { + const [storage = [], setStorage] = useQorusStorage('savedValues'); + const [isSaving, setIsSaving] = useState(); + + const handleClick = useCallback(() => { + setIsSaving(true); + }, []); + + const handleClose = useCallback(() => { + setIsSaving(false); + }, []); + + const handleSaveClick = useCallback( + (metadata: ISaveValueMetadata) => { + setStorage([ + ...storage, + { + ...metadata, + short_desc: { + type: 'string', + value: metadata.short_desc.value || 'No description', + }, + value: { + type, + value, + }, + id: { + type: 'string', + value: id, + }, + }, + ]); + setIsSaving(false); + }, + [storage, value, type] + ); + + if (type === 'boolean' || type === 'bool') { + return null; + } + + return ( + <> + {isSaving ? ( + + ) : null} + + + ); +}; + +export const SaveValueModal = ({ value, onSave, ...rest }: ISaveValueModalProps) => { + const [metadata, setMetadata] = useState(); + + const handleMetadataChange = useCallback((_name, metadata: ISaveValueMetadata) => { + setMetadata(metadata); + }, []); + + const bottomActions = useMemo( + () => + [ + { + label: 'Save', + intent: 'success', + position: 'right', + minimal: true, + icon: 'CheckLine', + onClick: () => { + onSave(metadata); + }, + disabled: !validateField('system-options', metadata, { + optionSchema: SaveValueMetadataSchema, + }), + }, + ] as IReqorePanelBottomAction[], + [metadata] + ); + + return ( + + + + ); +}; diff --git a/src/components/SubField/index.tsx b/src/components/SubField/index.tsx index 9e58878dc..f0cc643df 100644 --- a/src/components/SubField/index.tsx +++ b/src/components/SubField/index.tsx @@ -89,6 +89,7 @@ const SubField: React.FC = ({ collapsible={collapsible} unMountContentOnCollapse={false} responsiveTitle={false} + responsiveActions={false} actions={[ ...actions, diff --git a/src/components/TruncatedMessage/index.tsx b/src/components/TruncatedMessage/index.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/src/constants/util.ts b/src/constants/util.ts index 4d770b9e5..e9316139e 100644 --- a/src/constants/util.ts +++ b/src/constants/util.ts @@ -3,7 +3,7 @@ import { IReqoreTheme } from '@qoretechnologies/reqore/dist/constants/theme'; export const defaultReqoreTheme: Partial = { main: '#222222', - intents: { success: '#4a7110', custom1: '#6e1977' }, + intents: { success: '#4a7110', custom1: '#762f7e', custom2: '#b34e1d' }, }; export const defaultReqoreOptions = { @@ -16,3 +16,4 @@ export const defaultReqoreOptions = { }; export const QorusPurpleIntent = ReqoreIntents.CUSTOM1; +export const AttentionIntent = ReqoreIntents.CUSTOM2; diff --git a/src/containers/InterfaceCreator/fsm/ActionExec.tsx b/src/containers/InterfaceCreator/fsm/ActionExec.tsx index 20cd52245..cac2d2d11 100644 --- a/src/containers/InterfaceCreator/fsm/ActionExec.tsx +++ b/src/containers/InterfaceCreator/fsm/ActionExec.tsx @@ -21,12 +21,14 @@ export interface IQodexActionExecProps { actionName: string; options?: IOptions; id: string | number; + onExecute?: (response: any) => void; + defaultResponse?: any; } export const QodexActionExec = memo( - ({ appName, actionName, options, id }: IQodexActionExecProps) => { + ({ appName, actionName, options, id, onExecute, defaultResponse }: IQodexActionExecProps) => { const { action } = useGetAppActionData(appName, actionName); - const [response, setResponse] = useState(undefined); + const [response, setResponse] = useState(defaultResponse); const [error, setError] = useState(undefined); const [loadingResponse, setLoading] = useState(false); const { loading, data, load } = useFetchActionOptions({ @@ -34,7 +36,7 @@ export const QodexActionExec = memo( options, onStart: () => { setError(undefined); - setResponse(undefined); + setResponse(defaultResponse); }, }); @@ -55,6 +57,7 @@ export const QodexActionExec = memo( if (response.ok) { setResponse(response.data); + onExecute?.(response.data); } else { setError(response.error); } @@ -82,6 +85,7 @@ export const QodexActionExec = memo( - {!areOptionsValid() && ( + {!areOptionsValid() && !defaultResponse ? ( <> {action.action_code_str === 'EVENT' ? ( @@ -130,7 +134,7 @@ export const QodexActionExec = memo( )} - )} + ) : null} {areOptionsValid() && !response ? ( <> {loadingResponse || loading ? ( @@ -152,7 +156,7 @@ export const QodexActionExec = memo( {error} )} - {response && } + {response && } ); diff --git a/src/containers/InterfaceCreator/fsm/AppActionOptions.tsx b/src/containers/InterfaceCreator/fsm/AppActionOptions.tsx index cdb950cb5..56ca9d8ef 100644 --- a/src/containers/InterfaceCreator/fsm/AppActionOptions.tsx +++ b/src/containers/InterfaceCreator/fsm/AppActionOptions.tsx @@ -122,6 +122,21 @@ export const QodexAppActionOptions = memo( }); }, []); + const handleOptionsChange = useCallback( + (_name, newValue: IOptions, meta: Record) => { + if (meta?.events) { + meta.events.forEach((event) => { + if (event === 'refetch') { + load(newValue); + } + }); + } + + setValue(newValue); + }, + [] + ); + useEffect(() => { if (size(connectedStates)) { // Load and Set the initial templates @@ -190,19 +205,11 @@ export const QodexAppActionOptions = memo( options={options} value={value} onDependableOptionChange={handleDependableOptionChange} - onChange={(_name, newValue, meta) => { - if (meta?.events) { - meta.events.forEach((event) => { - if (event === 'refetch') { - load(newValue); - } - }); - } - - setValue(newValue); - }} - name='options' + onChange={handleOptionsChange} + name={`${appName}.${actionName}`} sortable={false} + allowSaving + showSavedValues stringTemplates={templates} /> )} diff --git a/src/containers/InterfaceCreator/fsm/AppSelector.tsx b/src/containers/InterfaceCreator/fsm/AppSelector.tsx index b29661ef3..eb37d399e 100644 --- a/src/containers/InterfaceCreator/fsm/AppSelector.tsx +++ b/src/containers/InterfaceCreator/fsm/AppSelector.tsx @@ -1,4 +1,3 @@ -import { ReqoreColumn, ReqoreColumns } from '@qoretechnologies/reqore'; import { map } from 'lodash'; import { useCallback, useMemo } from 'react'; import { IFSMStates, TFSMVariables } from '.'; @@ -58,6 +57,7 @@ export const AppSelector = ({ const apps = useMemo(() => { if (!value) return []; + return filterApps(value, 'builtin', false, type); }, [value]); @@ -76,6 +76,7 @@ export const AppSelector = ({ display_name: 'Variables', short_desc: 'Variables', name: 'variables', + sort: '_Variables', builtin: true, collectionActions() { return [ @@ -139,50 +140,32 @@ export const AppSelector = ({ ); return ( - - - { - if (app.name === 'action_sets') { - onActionSetSelect(changeStateIdsToGenerated(action.metadata?.states)); - } else { - onActionSelect({ ...action, type: 'appaction' }, app); - } - }} - label='Applications' - favorites={favorites} - onFavoriteClick={handleFavoriteClick} - type={type} - /> - - - - onActionSelect( - { - ...action, - type: - action.action === 'schedule' || - action.action === 'on-demand' || - action.action === 'webhook' - ? 'appaction' - : (action.action as TAction), - }, - app - ) - } - label='Built in modules' - onFavoriteClick={handleFavoriteClick} - favorites={favorites} - type={type} - /> - - + { + if (app.name === 'action_sets') { + onActionSetSelect(changeStateIdsToGenerated(action.metadata?.states)); + } else { + onActionSelect( + { + ...action, + type: + action.action === 'schedule' || + action.action === 'on-demand' || + action.action === 'webhook' || + app.builtin !== true + ? 'appaction' + : (action.action as TAction), + }, + app + ); + } + }} + label='Applications' + favorites={favorites} + onFavoriteClick={handleFavoriteClick} + type={type} + /> ); }; diff --git a/src/containers/InterfaceCreator/fsm/Fields.tsx b/src/containers/InterfaceCreator/fsm/Fields.tsx index 5379949ec..cfb5e5876 100644 --- a/src/containers/InterfaceCreator/fsm/Fields.tsx +++ b/src/containers/InterfaceCreator/fsm/Fields.tsx @@ -1,9 +1,11 @@ import { ReqoreVerticalSpacer } from '@qoretechnologies/reqore'; import Push from 'push.js'; +import { useCallback, useMemo } from 'react'; import { useAsyncRetry } from 'react-use'; import Options, { IOptionsSchema, TFlatOptions, + fixOptions, flattenOptions, } from '../../../components/Field/systemOptions'; import Loader from '../../../components/Loader'; @@ -25,10 +27,7 @@ export interface IQogNotificationStorageItem { start?: boolean; end?: boolean; } -export type TQogNotificationStorageItems = Record< - number | string, - IQogNotificationStorageItem ->; +export type TQogNotificationStorageItems = Record; export const QodexFields = ({ value, @@ -54,27 +53,44 @@ export const QodexFields = ({ return fields; }); - if (loading) { - return ; - } - - const settingsSchema: IOptionsSchema = { - startNotification: { - sort: 1, - type: 'bool', - display_name: 'Start Notification', - short_desc: 'Whether to send a notification when this Qog starts', - preselected: true, + const handleChange = useCallback( + (_name, metadata) => { + onChange(flattenOptions(metadata)); }, + [onChange] + ); - endNotification: { - sort: 2, - type: 'bool', - display_name: 'End Notification', - short_desc: 'Whether to send a notification when this Qog ends', - preselected: true, + const handleSettingsChange = useCallback( + (_name, metadata) => { + onSettingsChange(flattenOptions(metadata)); }, - }; + [onSettingsChange] + ); + + const settingsSchema: IOptionsSchema = useMemo( + () => ({ + startNotification: { + sort: 1, + type: 'bool', + display_name: 'Start Notification', + short_desc: 'Whether to send a notification when this Qog starts', + preselected: true, + }, + + endNotification: { + sort: 2, + type: 'bool', + display_name: 'End Notification', + short_desc: 'Whether to send a notification when this Qog ends', + preselected: true, + }, + }), + [] + ); + + if (loading) { + return ; + } return ( <> @@ -88,10 +104,8 @@ export const QodexFields = ({ name='fsm-fields' placeholder='More...' options={fields} - value={value} - onChange={(_name, metadata) => { - onChange(flattenOptions(metadata)); - }} + value={fixOptions(value, fields)} + onChange={handleChange} /> @@ -105,10 +119,8 @@ export const QodexFields = ({ label='Your Qog Settings' name='fsm-settings' options={settingsSchema} - value={settings} - onChange={(_name, metadata) => { - onSettingsChange(flattenOptions(metadata)); - }} + value={fixOptions(settings, settingsSchema)} + onChange={handleSettingsChange} /> ); diff --git a/src/containers/InterfaceCreator/fsm/index.tsx b/src/containers/InterfaceCreator/fsm/index.tsx index 3f6340f13..c0d547383 100644 --- a/src/containers/InterfaceCreator/fsm/index.tsx +++ b/src/containers/InterfaceCreator/fsm/index.tsx @@ -189,6 +189,7 @@ export type TFSMStateAction = { export interface IFSMState { name?: string; + example_output?: any; desc?: string; key?: string; corners?: IStateCorners; @@ -1310,6 +1311,10 @@ export const FSMView: React.FC = ({ [areStatesCompatible] ); + const handleFieldsChange = useCallback((fields) => { + setMetadata(fields); + }, []); + const handleStateClick = useCallback( async (id: string) => { if (selectedState) { @@ -2243,7 +2248,12 @@ export const FSMView: React.FC = ({ }, }} blur={5} + icon='AddCircleLine' + iconProps={{ + size: '60px', + }} label={isFirstTriggerState ? undefined : 'Add new action'} + description={isFirstTriggerState ? undefined : 'Select an action to add to your flow'} onClose={() => setIsAddingNewStateAt(undefined)} width='90vw' height='90vh' @@ -2251,6 +2261,9 @@ export const FSMView: React.FC = ({ style={{ userSelect: 'none', }} + contentStyle={{ + overflow: 'hidden', + }} > {isFirstTriggerState && ( <> @@ -2761,7 +2774,7 @@ export const FSMView: React.FC = ({ id: 'auto-align-states', icon: 'Apps2Line', show: isMetadataHidden, - flat: !checkOverlap(states), + flat: false, effect: checkOverlap(states) ? { gradient: { @@ -3344,9 +3357,7 @@ export const FSMView: React.FC = ({ settings={settings} onSettingsChange={setSettings} value={omit(metadata, ['target_dir', 'autovar', 'globalvar', 'localvar'])} - onChange={(fields) => { - setMetadata(fields); - }} + onChange={handleFieldsChange} /> {renderVariables(true)} diff --git a/src/containers/InterfaceCreator/fsm/state.tsx b/src/containers/InterfaceCreator/fsm/state.tsx index 3d4f29e95..1e0cbc655 100644 --- a/src/containers/InterfaceCreator/fsm/state.tsx +++ b/src/containers/InterfaceCreator/fsm/state.tsx @@ -745,6 +745,8 @@ const FSMState: React.FC = ({ label={getStateType({ type, action, id, ...rest }, app, appAction)} labelEffect={{ color: isMissingApp ? 'danger:lighten:10' : undefined, + textSize: 'tiny', + uppercase: true, }} /> @@ -769,6 +771,8 @@ const FSMState: React.FC = ({ label={stateActionDescription} labelEffect={{ weight: 'light', + textSize: 'tiny', + uppercase: true, }} /> diff --git a/src/containers/InterfaceCreator/fsm/stateDetail.tsx b/src/containers/InterfaceCreator/fsm/stateDetail.tsx index 668724a93..167108518 100644 --- a/src/containers/InterfaceCreator/fsm/stateDetail.tsx +++ b/src/containers/InterfaceCreator/fsm/stateDetail.tsx @@ -97,7 +97,7 @@ export const FSMStateDetail = memo( ); const [blockLogicType, setBlockLogicType] = useState<'fsm' | 'custom'>( - data.fsm ? 'fsm' : 'custom' + data?.fsm ? 'fsm' : 'custom' ); const [actionType, setActionType] = useState(data?.action?.type || 'none'); @@ -374,6 +374,7 @@ export const FSMStateDetail = memo( { - console.log('submit', _id, data); handleSubmitClick(data); }} data={data} diff --git a/src/containers/InterfaceCreator/fsm/stateDialog.tsx b/src/containers/InterfaceCreator/fsm/stateDialog.tsx index 27611134c..ac6257570 100644 --- a/src/containers/InterfaceCreator/fsm/stateDialog.tsx +++ b/src/containers/InterfaceCreator/fsm/stateDialog.tsx @@ -200,6 +200,13 @@ const FSMStateDialog: React.FC = ({ [JSON.stringify(data.action?.value)] ); + const handleExampleExecute = useCallback( + (response) => { + handleDataUpdate('example_output', response); + }, + [handleDataUpdate] + ); + const statesForTemplates = useMemo(() => { return getStatesForTemplates(id || data.keyId, otherStates); }, [data.keyId, JSON.stringify(otherStates)]); @@ -378,13 +385,15 @@ const FSMStateDialog: React.FC = ({ onChange={handleAppActionFieldChange} id={id} /> - + ); diff --git a/src/containers/InterfaceCreator/mapperView.tsx b/src/containers/InterfaceCreator/mapperView.tsx index a290bd821..d58541f0f 100644 --- a/src/containers/InterfaceCreator/mapperView.tsx +++ b/src/containers/InterfaceCreator/mapperView.tsx @@ -1,10 +1,6 @@ -import { - ReqoreMessage, - ReqoreVerticalSpacer, - useReqore, -} from '@qoretechnologies/reqore'; +import { ReqoreMessage, ReqoreVerticalSpacer, useReqore } from '@qoretechnologies/reqore'; import { cloneDeep, omit, size } from 'lodash'; -import { FunctionComponent, useContext, useState } from 'react'; +import { FunctionComponent, useContext, useMemo, useState } from 'react'; import { useLifecycles, useUpdateEffect } from 'react-use'; import compose from 'recompose/compose'; import mapProps from 'recompose/mapProps'; @@ -87,10 +83,7 @@ const MapperView: FunctionComponent = ({ } ); - const fixedOptions = omit(mapper?.mapper_options, [ - 'mapper-input', - 'mapper-output', - ]); + const fixedOptions = omit(mapper?.mapper_options, ['mapper-input', 'mapper-output']); const newMapper = cloneDeep(mapper); if (newMapper) { @@ -119,21 +112,44 @@ const MapperView: FunctionComponent = ({ } }, [JSON.stringify(omit(mapperData, ['isContextLoaded']))]); + const isMapperInvalid = useMemo(() => { + if (!mapper) { + return false; + } + + if ( + !mapper.mapper_options?.['mapper-input']?.type || + !mapper.mapper_options?.['mapper-output']?.type + ) { + return true; + } + + return false; + }, [mapper]); + return ( <> + {isMapperInvalid && ( + + This mapper is deprecated and can no longer be managed + + )} {inputsError && ( - {t(inputsError)} + + {t(inputsError)} + )} {outputsError && ( - {t(outputsError)} + + {t(outputsError)} + )} - {inputsError || outputsError ? ( - - ) : null} + {inputsError || outputsError ? : null} {!showMapperConnections && ( = ({ )?.value } context={ - selectedFields.mapper[interfaceIndex].find( - (field: IField) => field.name === 'context' - )?.value + selectedFields.mapper[interfaceIndex].find((field: IField) => field.name === 'context') + ?.value } isEditing={isEditing || !!mapper} onSubmitSuccess={onSubmitSuccess} diff --git a/src/containers/Mapper/index.tsx b/src/containers/Mapper/index.tsx index 5dc609f27..6c7d02d7a 100644 --- a/src/containers/Mapper/index.tsx +++ b/src/containers/Mapper/index.tsx @@ -46,7 +46,7 @@ import withInitialDataConsumer from '../../hocomponents/withInitialDataConsumer' import withMapperConsumer from '../../hocomponents/withMapperConsumer'; import withTextContext from '../../hocomponents/withTextContext'; -const FIELD_HEIGHT = 31; +const FIELD_HEIGHT = 38; const FIELD_MARGIN = 5; export const TYPE_COLORS = { int: '#3a9c52', @@ -76,9 +76,9 @@ export const StyledMapperWrapper = styled.div` margin: 0 auto; `; -export const StyledFieldsWrapper: React.FC< - IReqoreControlGroupProps & { width?: string } -> = styled(ReqoreControlGroup)` +export const StyledFieldsWrapper: React.FC = styled( + ReqoreControlGroup +)` width: ${({ width }) => width || '300px'} !important; `; @@ -121,8 +121,7 @@ export const StyledMapperFieldWrapper = styled(ReqoreControlGroup)` right: 5px; `}; top: ${FIELD_HEIGHT}px; - height: ${childrenCount * (FIELD_HEIGHT + FIELD_MARGIN) - - FIELD_HEIGHT / 2}px; + height: ${childrenCount * (FIELD_HEIGHT + FIELD_MARGIN) - FIELD_HEIGHT / 2}px; background-color: ${({ theme }) => theme.intents.muted}; z-index: 0; } @@ -154,9 +153,7 @@ export const StyledMapperFieldWrapper = styled(ReqoreControlGroup)` : null} `; -export const StyledMapperField: React.FC = styled( - ReqoreButton -)``; +export const StyledMapperField: React.FC = styled(ReqoreButton)``; const StyledLine = styled.line` stroke-width: 1px; @@ -188,18 +185,8 @@ export interface IMapperCreatorProps { outputsLoading: boolean; setOutputsLoading: (loading: boolean) => void; onBackClick: () => void; - addField: ( - fieldsType: string, - path: string, - name: string, - data?: any - ) => void; - editField: ( - fieldsType: string, - path: string, - name: string, - data?: any - ) => void; + addField: (fieldsType: string, path: string, name: string, data?: any) => void; + editField: (fieldsType: string, path: string, name: string, data?: any) => void; isFormValid: boolean; setMapperKeys: (keys: any) => void; mapperKeys: any; @@ -310,16 +297,11 @@ const MapperCreator: React.FC = ({ (newResult, relation, outputField) => { if (relation.context) { // check if the field exists in inputs - const contextInputFieldName = getStaticDataFieldname( - relation.context - ); + const contextInputFieldName = getStaticDataFieldname(relation.context); if ( hasStaticDataField(relation.context) && - (!contextFields || - !contextFields.find( - (cF) => cF.path === contextInputFieldName - )) + (!contextFields || !contextFields.find((cF) => cF.path === contextInputFieldName)) ) { hasFixedContext = true; return { @@ -356,11 +338,11 @@ const MapperCreator: React.FC = ({ return

Loading context...

; } - const saveRelationData: ( - outputPath: string, - data: any, - merge?: boolean - ) => void = (outputPath, data, merge) => { + const saveRelationData: (outputPath: string, data: any, merge?: boolean) => void = ( + outputPath, + data, + merge + ) => { setRelations((current) => { const result = { ...current }; // Check if this output already exists @@ -403,11 +385,7 @@ const MapperCreator: React.FC = ({ ...newRelations, [relationOutput]: omit( rel, - usesContext - ? ['context'] - : isInputHash - ? ['use_input_record'] - : ['name'] + usesContext ? ['context'] : isInputHash ? ['use_input_record'] : ['name'] ), }; } @@ -418,10 +396,7 @@ const MapperCreator: React.FC = ({ ); }; - const removeFieldRelations: (path: string, type: string) => void = ( - path, - type - ) => { + const removeFieldRelations: (path: string, type: string) => void = (path, type) => { // Remove the selected relation // @ts-ignore setRelations((current) => @@ -447,11 +422,11 @@ const MapperCreator: React.FC = ({ ); }; - const renameFieldRelation: ( - oldPath: string, - newPath: string, - type: string - ) => void = (oldPath, newPath, type) => { + const renameFieldRelation: (oldPath: string, newPath: string, type: string) => void = ( + oldPath, + newPath, + type + ) => { // Remove the selected relation // @ts-ignore setRelations((current) => @@ -530,9 +505,7 @@ const MapperCreator: React.FC = ({ types.includes('any') || output.type.types_accepted.includes('any') || (size(types) <= size(output.type.types_accepted) && - output.type.types_accepted.some((type: string) => - types.includes(type) - )) + output.type.types_accepted.some((type: string) => types.includes(type))) ) .forEach((output) => { if (isAvailableForDrop(output.path)) { @@ -554,17 +527,12 @@ const MapperCreator: React.FC = ({ const uniqueRoles: string[] = reduce( relations[outputPath], (roles, _value, key) => - mapperKeys[key].unique_roles - ? [...roles, ...mapperKeys[key].unique_roles] - : roles, + mapperKeys[key].unique_roles ? [...roles, ...mapperKeys[key].unique_roles] : roles, [] ); // Check if none of the keys roles & a * role isn't // yet included - if ( - unique_roles.every((role) => !uniqueRoles.includes(role)) && - !uniqueRoles.includes('*') - ) { + if (unique_roles.every((role) => !uniqueRoles.includes(role)) && !uniqueRoles.includes('*')) { return true; } return false; @@ -588,9 +556,7 @@ const MapperCreator: React.FC = ({ const field = fieldTypes[type].find((input) => input.path === name); if (field) { // Return the color - return TYPE_COLORS[ - field.type.types_returned[0].replace(//g, '') - ]; + return TYPE_COLORS[field.type.types_returned[0].replace(//g, '')]; } return null; }; @@ -699,9 +665,7 @@ const MapperCreator: React.FC = ({ else { saveRelationData( outputPath, - usesContext - ? { context: `$static:{${inputPath}}` } - : { name: inputPath }, + usesContext ? { context: `$static:{${inputPath}}` } : { name: inputPath }, true ); } @@ -736,9 +700,7 @@ const MapperCreator: React.FC = ({ // Create the mapper field options type list const relationTypeList = []; forEach(mapper.fields, (relationData, outputFieldName) => { - const outputField = flattenedOutputs.find( - (o) => o.path === outputFieldName - ); + const outputField = flattenedOutputs.find((o) => o.path === outputFieldName); // Go through the data in the output field relation forEach(relationData, (_, relationName) => { relationTypeList.push({ @@ -808,8 +770,7 @@ const MapperCreator: React.FC = ({ relations, (relation, outputPath) => outputPath === path && - ('name' in relation || - ('context' in relation && relation.context.startsWith('$static:'))) + ('name' in relation || ('context' in relation && relation.context.startsWith('$static:'))) ); }; @@ -869,11 +830,7 @@ const MapperCreator: React.FC = ({ icon: 'DeleteBinLine', effect: NegativeColorEffect, onClick: () => { - handleClick(selectedField.fieldType)( - selectedField, - false, - true - ); + handleClick(selectedField.fieldType)(selectedField, false, true); setSelectedField(undefined); }, show: selectedField.isCustom === true, @@ -946,9 +903,7 @@ const MapperCreator: React.FC = ({ style={{ width: '100%', marginTop: - isFromConnectors || - isEditing || - (hideInputSelector && hideOutputSelector) + isFromConnectors || isEditing || (hideInputSelector && hideOutputSelector) ? 0 : '15px', padding: 10, @@ -976,9 +931,7 @@ const MapperCreator: React.FC = ({ }} tooltip={{ content: ( -

- {getUrlFromProvider('input')} -

+

{getUrlFromProvider('input')}

), }} actions={[ @@ -1007,18 +960,14 @@ const MapperCreator: React.FC = ({ field={input} hasRelation={hasInputRelation(input.path)} id={index + 1} - lastChildIndex={ - getLastChildIndex(input, flattenedInputs) - index - } + lastChildIndex={getLastChildIndex(input, flattenedInputs) - index} onClick={() => { setSelectedField({ ...input, fieldType: 'inputs', }); }} - hasAvailableOutput={hasAvailableRelation( - input.type.types_returned - )} + hasAvailableOutput={hasAvailableRelation(input.type.types_returned)} /> )) : null} @@ -1031,19 +980,17 @@ const MapperCreator: React.FC = ({ : t('MapperNoInputFields')} ) : null} - {!inputsError && - hideInputSelector && - inputOptionProvider?.can_manage_fields && ( - handleClick('inputs')()} - > - {t('AddNewField')} - - )} + {!inputsError && hideInputSelector && inputOptionProvider?.can_manage_fields && ( + handleClick('inputs')()} + > + {t('AddNewField')} + + )} {size(flattenedContextInputs) !== 0 && ( '} @@ -1058,11 +1005,7 @@ const MapperCreator: React.FC = ({ wordBreak: 'break-all', }} tooltip={{ - content: ( -

- {t('StaticDataFieldDesc')} -

- ), + content:

{t('StaticDataFieldDesc')}

, }} /> )} @@ -1075,9 +1018,7 @@ const MapperCreator: React.FC = ({ {...input} field={input} id={(flattenedInputs?.length || 0) + (index + 1)} - lastChildIndex={ - getLastChildIndex(input, flattenedContextInputs) - index - } + lastChildIndex={getLastChildIndex(input, flattenedContextInputs) - index} usesContext hasRelation={hasInputRelation(input.path)} onClick={() => { @@ -1086,9 +1027,7 @@ const MapperCreator: React.FC = ({ fieldType: 'inputs', }); }} - hasAvailableOutput={hasAvailableRelation( - input.type.types_returned - )} + hasAvailableOutput={hasAvailableRelation(input.type.types_returned)} /> )) : null} @@ -1098,10 +1037,7 @@ const MapperCreator: React.FC = ({ = ({ stroke={theme.intents.success} x1={0} y1={ - (flattenedInputs.findIndex( - (input) => input.path === relation.name - ) + + (flattenedInputs.findIndex((input) => input.path === relation.name) + 1) * (FIELD_HEIGHT + FIELD_MARGIN) - (FIELD_HEIGHT / 2 + FIELD_MARGIN) + - getProviderInfoHeight('input-provider-info') + getProviderInfoHeight('input-provider-info') + + 4 } x2={300} y2={ - (flattenedOutputs.findIndex( - (output) => output.path === outputPath - ) + + (flattenedOutputs.findIndex((output) => output.path === outputPath) + 1) * (FIELD_HEIGHT + FIELD_MARGIN) - (FIELD_HEIGHT / 2 + FIELD_MARGIN) + - getProviderInfoHeight('input-provider-info') + getProviderInfoHeight('input-provider-info') + + 4 } /> @@ -1143,17 +1077,13 @@ const MapperCreator: React.FC = ({ <> - removeRelation(outputPath, false, true) - } + onClick={() => removeRelation(outputPath, false, true)} stroke={theme.intents.success} x1={0} y1={20} x2={300} y2={ - (flattenedOutputs.findIndex( - (output) => output.path === outputPath - ) + + (flattenedOutputs.findIndex((output) => output.path === outputPath) + 1) * (FIELD_HEIGHT + FIELD_MARGIN) - (FIELD_HEIGHT / 2 + FIELD_MARGIN) + @@ -1168,22 +1098,16 @@ const MapperCreator: React.FC = ({ <> - removeRelation(outputPath, true, true) - } + onClick={() => removeRelation(outputPath, true, true)} stroke={theme.intents.success} x1={0} y1={ getProviderInfoHeight('input-provider-info') + getProviderInfoHeight('input-provider-info') + (size(flattenedInputs) + - (inputOptionProvider?.can_manage_fields - ? 1 - : 0) + + (inputOptionProvider?.can_manage_fields ? 1 : 0) + flattenedContextInputs.findIndex( - (input) => - input.path === - getStaticDataFieldname(relation.context) + (input) => input.path === getStaticDataFieldname(relation.context) ) + 1) * (FIELD_HEIGHT + FIELD_MARGIN) - @@ -1191,9 +1115,7 @@ const MapperCreator: React.FC = ({ } x2={300} y2={ - (flattenedOutputs.findIndex( - (output) => output.path === outputPath - ) + + (flattenedOutputs.findIndex((output) => output.path === outputPath) + 1) * (FIELD_HEIGHT + FIELD_MARGIN) - (FIELD_HEIGHT / 2 + FIELD_MARGIN) + @@ -1215,16 +1137,12 @@ const MapperCreator: React.FC = ({ getProviderInfoHeight('input-provider-info') + getProviderInfoHeight('input-provider-info') + (size(flattenedInputs) + - (inputOptionProvider?.can_manage_fields - ? 1 - : 0)) * + (inputOptionProvider?.can_manage_fields ? 1 : 0)) * (FIELD_HEIGHT + FIELD_MARGIN) } x2={300} y2={ - (flattenedOutputs.findIndex( - (output) => output.path === outputPath - ) + + (flattenedOutputs.findIndex((output) => output.path === outputPath) + 1) * (FIELD_HEIGHT + FIELD_MARGIN) - (FIELD_HEIGHT / 2 + FIELD_MARGIN) + @@ -1268,9 +1186,7 @@ const MapperCreator: React.FC = ({ ]} tooltip={{ content: ( -

- {getUrlFromProvider('output')} -

+

{getUrlFromProvider('output')}

), }} intent={outputsError ? 'danger' : undefined} @@ -1289,9 +1205,7 @@ const MapperCreator: React.FC = ({ onDrop={handleDrop} id={index + 1} accepts={output.type.types_accepted} - lastChildIndex={ - getLastChildIndex(output, flattenedOutputs) - index - } + lastChildIndex={getLastChildIndex(output, flattenedOutputs) - index} onClick={() => { setSelectedField({ ...output, @@ -1306,26 +1220,20 @@ const MapperCreator: React.FC = ({ : null} {!outputsError && size(flattenedOutputs) === 0 && - !( - hideOutputSelector && outputOptionProvider?.can_manage_fields - ) ? ( - - {t('MapperNoOutputFields')} - + !(hideOutputSelector && outputOptionProvider?.can_manage_fields) ? ( + {t('MapperNoOutputFields')} ) : null} - {!outputsError && - hideOutputSelector && - outputOptionProvider?.can_manage_fields && ( - handleClick('outputs')()} - > - {t('AddNewField')} - - )} + {!outputsError && hideOutputSelector && outputOptionProvider?.can_manage_fields && ( + handleClick('outputs')()} + > + {t('AddNewField')} + + )} diff --git a/src/containers/Mapper/provider.tsx b/src/containers/Mapper/provider.tsx index 3a9489f79..96017695c 100644 --- a/src/containers/Mapper/provider.tsx +++ b/src/containers/Mapper/provider.tsx @@ -10,7 +10,7 @@ import { cloneDeep, last, omit, reduce } from 'lodash'; import map from 'lodash/map'; import nth from 'lodash/nth'; import size from 'lodash/size'; -import { FC, useCallback, useContext, useState } from 'react'; +import { FC, useCallback, useContext, useMemo, useState } from 'react'; import ReactMarkdown from 'react-markdown'; import CustomDialog from '../../components/CustomDialog'; import { TRecordType } from '../../components/Field/connectors'; @@ -22,6 +22,7 @@ import { TextContext } from '../../context/text'; import { fetchData } from '../../helpers/functions'; import { validateField } from '../../helpers/validations'; import withInitialDataConsumer from '../../hocomponents/withInitialDataConsumer'; +import { useWhyDidYouUpdate } from '../../hooks/useWhyDidYouUpdate'; import { submitControl } from '../InterfaceCreator/controls'; export interface IProviderProps { @@ -182,6 +183,44 @@ const MapperProvider: FC = ({ const t = useContext(TextContext); let realProviders = cloneDeep(providers); + useWhyDidYouUpdate('provider', { + provider, + setProvider, + nodes, + setChildren, + isLoading, + setIsLoading, + record, + setRecord, + setFields, + clear, + initialData, + setMapperKeys, + setOptionProvider, + title, + type, + hide, + compact, + canSelectNull, + style, + isConfigItem, + options, + searchOptions, + requiresRequest, + optionsChanged, + searchOptionsChanged, + onResetClick, + optionProvider, + recordType, + isPipeline, + isMessage, + isVariable, + availableOptions, + readOnly, + isEvent, + isTransaction, + }); + // Omit type and factory from the list of realProviders if is config item if (isConfigItem) { realProviders = omit(realProviders, ['type', 'factory']); @@ -204,11 +243,7 @@ const MapperProvider: FC = ({ const filterChildren = (children: any[]) => { return children.filter((child) => { if (isPipeline || recordType) { - return ( - child.has_record || - child.children_can_support_records || - child.has_provider - ); + return child.has_record || child.children_can_support_records || child.has_provider; } if (requiresRequest) { @@ -231,9 +266,7 @@ const MapperProvider: FC = ({ } if (isEvent) { - return ( - child.supports_observable || child.children_can_support_observers - ); + return child.supports_observable || child.children_can_support_observers; } return true; @@ -249,17 +282,16 @@ const MapperProvider: FC = ({ } }; - const handleProviderChange = (provider) => { + const handleProviderChange = useCallback((_name: string, provider: string) => { setProvider((current) => { // Fetch the url of the provider (async () => { // Clear the data - clear && clear(true); + clear?.(true); // Set loading setIsLoading(true); // Select the provider data - const { url, filter, inputFilter, outputFilter, withDetails } = - realProviders[provider]; + const { url, filter, inputFilter, outputFilter, withDetails } = realProviders[provider]; // Get the data let { data, error } = await fetchData(`${url}`); @@ -319,7 +351,7 @@ const MapperProvider: FC = ({ // Set the provider return provider; }); - }; + }, []); const buildOptions = () => { let customOptionString = ''; @@ -441,8 +473,7 @@ const MapperProvider: FC = ({ up: record?.data?.up !== false, can_manage_fields: record?.data?.can_manage_fields, transaction_management: record?.data?.transaction_management, - subtype: - value === 'request' || value === 'response' ? value : undefined, + subtype: value === 'request' || value === 'response' ? value : undefined, path: `${url}/${value}` .replace(`${name}`, '') .replace(`${realProviders[provider].url}/`, '') @@ -473,17 +504,15 @@ const MapperProvider: FC = ({ customOptionString && customOptionString !== '' && size(options) ? `${newSuffix}${realProviders[provider].withDetails ? '&' : '?'}` : itemIndex === 1 - ? value === 'request' || value === 'response' - ? `?${buildOptions()}` - : `?action=childDetails&${buildOptions()}` - : newSuffix; + ? value === 'request' || value === 'response' + ? `?${buildOptions()}` + : `?action=childDetails&${buildOptions()}` + : newSuffix; // Fetch the data const splitter = `${suffixString.includes('?') ? '&' : '?'}`; const { data = {}, error } = await fetchData( - `${url}/${value}${suffixString}${ - type === 'outputs' ? `${splitter}soft=true` : '' - }` + `${url}/${value}${suffixString}${type === 'outputs' ? `${splitter}soft=true` : ''}` ); handleCallError(error); @@ -524,8 +553,7 @@ const MapperProvider: FC = ({ up: data.up !== false, transaction_management: data?.transaction_management, record_requires_search_options: data?.record_requires_search_options, - subtype: - value === 'request' || value === 'response' ? value : undefined, + subtype: value === 'request' || value === 'response' ? value : undefined, path: `${url}/${value}` .replace(`${name}`, '') .replace(`${realProviders[provider].url}/`, '') @@ -565,25 +593,19 @@ const MapperProvider: FC = ({ : `action=childDetails&${buildOptions()}` : buildOptions(); - const splitter = `${ - realProviders[provider].recordSuffix.includes('?') ? '&' : '?' - }`; + const splitter = `${realProviders[provider].recordSuffix.includes('?') ? '&' : '?'}`; suffixString = customOptionString && customOptionString !== '' ? `${suffix}${ data.has_record ? realProviders[provider].recordSuffix : '' }${splitter}${customOptionString}` : `${newSuffix}${ - data.has_record || data.has_type - ? realProviders[provider].recordSuffix - : '' + data.has_record || data.has_type ? realProviders[provider].recordSuffix : '' }${splitter}${childDetailsSuffix}`; // Fetch the record const record = await fetchData( - `${url}/${value}${suffixString}${ - type === 'outputs' ? `${splitter}soft=true` : '' - }` + `${url}/${value}${suffixString}${type === 'outputs' ? `${splitter}soft=true` : ''}` ); handleCallError(record.error); @@ -610,8 +632,7 @@ const MapperProvider: FC = ({ transaction_management: data.transaction_management, record_requires_search_options: data.record_requires_search_options, can_manage_fields: record.data?.can_manage_fields, - subtype: - value === 'request' || value === 'response' ? value : undefined, + subtype: value === 'request' || value === 'response' ? value : undefined, descriptions: [...descriptions, data?.desc], path: `${url}/${value}` .replace(`${name}`, '') @@ -627,11 +648,7 @@ const MapperProvider: FC = ({ if (data.has_type || isConfigItem) { // Set the record data setRecord && - setRecord( - !realProviders[provider].requiresRecord - ? record.data.fields - : record.data - ); + setRecord(!realProviders[provider].requiresRecord ? record.data.fields : record.data); // } } @@ -721,8 +738,7 @@ const MapperProvider: FC = ({ supports_observable: data.supports_observable, transaction_management: data.transaction_management, record_requires_search_options: data.record_requires_search_options, - subtype: - value === 'request' || value === 'response' ? value : undefined, + subtype: value === 'request' || value === 'response' ? value : undefined, descriptions: [...descriptions, data?.desc], path: `${url}/${value}` .replace(`${name}`, '') @@ -738,11 +754,7 @@ const MapperProvider: FC = ({ setRecord && setRecord(data.fields); } // Check if there is a record - else if ( - isConfigItem || - data.has_record || - !realProviders[provider].requiresRecord - ) { + else if (isConfigItem || data.has_record || !realProviders[provider].requiresRecord) { setIsLoading(true); if (type === 'outputs' && data.mapper_keys) { // Save the mapper keys @@ -756,9 +768,7 @@ const MapperProvider: FC = ({ : `?action=childDetails&${buildOptions()}` : buildOptions(); - const splitter = `${ - realProviders[provider].recordSuffix.includes('?') ? '&' : '?' - }`; + const splitter = `${realProviders[provider].recordSuffix.includes('?') ? '&' : '?'}`; const newSuffix = suffix; suffixString = customOptionString && customOptionString !== '' @@ -766,16 +776,12 @@ const MapperProvider: FC = ({ data.has_record ? realProviders[provider].recordSuffix : '' }${splitter}${customOptionString}` : `${newSuffix}${ - data.has_record || data.has_type - ? realProviders[provider].recordSuffix - : '' + data.has_record || data.has_type ? realProviders[provider].recordSuffix : '' }${splitter}${childDetailsSuffix}`; // Fetch the record const record = await fetchData( - `${url}/${value}${suffixString}${ - type === 'outputs' ? `${splitter}soft=true` : '' - }` + `${url}/${value}${suffixString}${type === 'outputs' ? `${splitter}soft=true` : ''}` ); handleCallError(record.error); @@ -800,8 +806,7 @@ const MapperProvider: FC = ({ supports_observable: data.supports_observable, transaction_management: data.transaction_management, record_requires_search_options: data.record_requires_search_options, - subtype: - value === 'request' || value === 'response' ? value : undefined, + subtype: value === 'request' || value === 'response' ? value : undefined, descriptions: [...descriptions, data?.desc], path: `${url}/${value}` .replace(`${name}`, '') @@ -815,18 +820,14 @@ const MapperProvider: FC = ({ }; // Set the record data setRecord && - setRecord( - !realProviders[provider].requiresRecord - ? record.data.fields - : record.data - ); + setRecord(!realProviders[provider].requiresRecord ? record.data.fields : record.data); // } setOptionProvider(newOptionProvider); setChildren(newItems); }; - const getDefaultItems = useCallback( + const defaultItems = useMemo( () => map(realProviders, ({ name, desc }) => ({ name, desc })).filter((prov) => prov.name === 'null' ? canSelectNull : true @@ -834,16 +835,15 @@ const MapperProvider: FC = ({ [] ); + const filters = useMemo(() => ['supports_read', 'supports_request', 'has_record', 'up'], []); + const getDescription = () => { if (last(nodes)?.value) { - return last(nodes)?.values?.find((val) => val.name === last(nodes)?.value) - ?.desc; + return last(nodes)?.values?.find((val) => val.name === last(nodes)?.value)?.desc; } // Return the same as above only from the second to last item - return nth(nodes, -2)?.values?.find( - (val) => val.name === nth(nodes, -2)?.value - )?.desc; + return nth(nodes, -2)?.values?.find((val) => val.name === nth(nodes, -2)?.value)?.desc; }; const description = getDescription(); @@ -875,30 +875,20 @@ const MapperProvider: FC = ({ - setWildcardDiagram((cur) => ({ ...cur, value })) - } + onChange={(_name, value) => setWildcardDiagram((cur) => ({ ...cur, value }))} value={wildcardDiagram.value} /> )} - + { - handleProviderChange(value); - }} + defaultItems={defaultItems} + onChange={handleProviderChange} value={provider} /> {nodes.map((child, index) => ( @@ -910,12 +900,7 @@ const MapperProvider: FC = ({ name={`provider-${type ? `${type}-` : ''}${index}`} disabled={isLoading || readOnly} className='provider-selector' - filters={[ - 'supports_read', - 'supports_request', - 'has_record', - 'up', - ]} + filters={filters} defaultItems={child.values.map((child) => ({ ...child, intent: child.up === false ? 'danger' : undefined, @@ -970,9 +955,7 @@ const MapperProvider: FC = ({ onClick={() => { const customOptionString = buildOptions(); // Get the child data - const { url, suffix } = child.values.find( - (val) => val.name === child.value - ); + const { url, suffix } = child.values.find((val) => val.name === child.value); // If the value is a wildcard present a dialog that the user has to fill if (child.value === '*') { setWildcardDiagram({ @@ -1009,9 +992,7 @@ const MapperProvider: FC = ({ onClick={() => { const customOptionString = buildOptions(); // Get the child data - const { url, suffix } = child.values.find( - (val) => val.name === child.value - ); + const { url, suffix } = child.values.find((val) => val.name === child.value); // If the value is a wildcard present a dialog that the user has to fill if (child.value === '*') { setWildcardDiagram({ @@ -1068,11 +1049,9 @@ const MapperProvider: FC = ({ const index = size(result) - 1; const { value, values } = lastChild; // Get the child data - const { url, suffix, provider_info } = values.find( - (val) => { - return val.name === value; - } - ); + const { url, suffix, provider_info } = values.find((val) => { + return val.name === value; + }); // If the value is a wildcard present a dialog that the user has to fill if (value === '*') { @@ -1098,7 +1077,7 @@ const MapperProvider: FC = ({ // If there are no children then we need to reset the provider if (size(result) === 0) { - handleProviderChange(provider); + handleProviderChange(null, provider); } return result; @@ -1136,23 +1115,12 @@ const MapperProvider: FC = ({ ) : null} {errorMessage && ( - + {errorMessage} )} {warningMessage && ( - + {warningMessage} )} diff --git a/src/helpers/fsm.ts b/src/helpers/fsm.ts index 5cc2802d5..15a5e6be8 100644 --- a/src/helpers/fsm.ts +++ b/src/helpers/fsm.ts @@ -585,7 +585,7 @@ export const removeTransitionsWithStateId = ( export const isFSMNameValid: (name: string) => boolean = (name) => validateField('string', name); export const isFSMBlockConfigValid = (data: IFSMState): boolean => { - return size(data['block-config']) === 0 || validateField('system-options', data['block-config']); + return size(data['block-config']) !== 0 && validateField('system-options', data['block-config']); }; export const isFSMActionValid = ( diff --git a/src/helpers/functions.ts b/src/helpers/functions.ts index e80a443df..e0abde9cb 100644 --- a/src/helpers/functions.ts +++ b/src/helpers/functions.ts @@ -6,7 +6,7 @@ import { } from '@qoretechnologies/reqore/dist/components/Textarea'; import { TReqoreIntent } from '@qoretechnologies/reqore/dist/constants/theme'; import { IReqoreNotificationData } from '@qoretechnologies/reqore/dist/containers/ReqoreProvider'; -import { TQorusForm } from '@qoretechnologies/ts-toolkit'; +import { TQorusForm, TQorusType } from '@qoretechnologies/ts-toolkit'; import { reduce } from 'lodash'; import cloneDeep from 'lodash/cloneDeep'; import forEach from 'lodash/forEach'; @@ -653,10 +653,58 @@ export const getDraftId = (data: IQorusInterface['data'], interfaceId?: string) return data?.id ?? interfaceId; }; -export const QorusTypeCompatibilityTable = { - string: ['string', 'binary', 'number', 'boolean', 'date', 'int', 'bool', 'float'], +export const QorusTypeCompatibilityTable: Partial> = { + string: [ + 'string', + 'binary', + 'number', + 'boolean', + 'date', + 'int', + 'bool', + 'float', + 'connection', + 'long-string', + 'email', + 'job', + 'mapper', + 'service', + 'url', + 'workflow', + ], number: ['number', 'int', 'float'], data: ['string', 'binary'], + richtext: [ + 'string', + 'binary', + 'number', + 'boolean', + 'date', + 'int', + 'bool', + 'float', + 'richtext', + 'connection', + 'long-string', + 'email', + 'job', + 'mapper', + 'service', + 'url', + 'workflow', + ], +}; + +export const areQorusTypesCompatible = (mainType: TQorusType, checkType: TQorusType): boolean => { + if (mainType === checkType) { + return true; + } + + if (!QorusTypeCompatibilityTable[mainType]) { + return false; + } + + return QorusTypeCompatibilityTable[mainType].includes(checkType); }; export const filterTemplatesByType = ( diff --git a/src/helpers/options.ts b/src/helpers/options.ts index a3c509404..13b305af6 100644 --- a/src/helpers/options.ts +++ b/src/helpers/options.ts @@ -1,3 +1,4 @@ +import { IQorusFormFieldSchema } from '@qoretechnologies/ts-toolkit'; import { IOptionsSchema } from '../components/Field/systemOptions'; export const getOptionsFromRequiredGroups = ( @@ -11,3 +12,19 @@ export const getOptionsFromRequiredGroups = ( }) .filter((option) => option !== currentOption); }; + +export const getRequiredOptionMessage = ( + schema: IOptionsSchema, + groups: IQorusFormFieldSchema['required_groups'], + currentField: string +): string => { + if (schema[currentField].required) { + return 'This field is required'; + } + + const requiredOptions = getOptionsFromRequiredGroups(schema, groups, currentField) + .map((option) => schema[option].display_name) + .join(' or '); + + return `This field or ${requiredOptions} is required`; +}; diff --git a/src/hocomponents/withMapper.tsx b/src/hocomponents/withMapper.tsx index ace13db1f..e402f87ba 100644 --- a/src/hocomponents/withMapper.tsx +++ b/src/hocomponents/withMapper.tsx @@ -7,11 +7,7 @@ import { Messages } from '../constants/messages'; import { formatFields } from '../containers/InterfaceCreator/typeView'; import { providers } from '../containers/Mapper/provider'; import { MapperContext } from '../context/mapper'; -import { - callBackendBasic, - fetchData, - insertUrlPartBeforeQuery, -} from '../helpers/functions'; +import { callBackendBasic, fetchData, insertUrlPartBeforeQuery } from '../helpers/functions'; import { fixRelations, flattenFields } from '../helpers/mapper'; import withFieldsConsumer from './withFieldsConsumer'; import withInitialDataConsumer from './withInitialDataConsumer'; @@ -35,8 +31,7 @@ export default () => (Component: FunctionComponent): FunctionComponent => { const EnhancedComponent: FunctionComponent = (props: any) => { const [mapper, setMapper] = useState(props.mapper); - const [showMapperConnections, setShowMapperConnections] = - useState(false); + const [showMapperConnections, setShowMapperConnections] = useState(false); const [inputs, setInputs] = useState(null); const [contextInputs, setContextInputs] = useState(null); const [outputs, setOutputs] = useState(null); @@ -77,10 +72,8 @@ export default () => is_api_call: boolean; }>(null); const [mapperKeys, setMapperKeys] = useState(null); - const [hideInputSelector, setHideInputSelector] = - useState(false); - const [hideOutputSelector, setHideOutputSelector] = - useState(false); + const [hideInputSelector, setHideInputSelector] = useState(false); + const [hideOutputSelector, setHideOutputSelector] = useState(false); const [inputsError, setInputsError] = useState(null); const [outputsError, setOutputsError] = useState(null); const [wrongKeysCount, setWrongKeysCount] = useState(0); @@ -160,10 +153,10 @@ export default () => setIsContextLoaded(true); }; - const getUrlFromProvider: ( - fieldType: 'input' | 'output', - provider?: any - ) => string = (fieldType, provider) => { + const getUrlFromProvider: (fieldType: 'input' | 'output', provider?: any) => string = ( + fieldType, + provider + ) => { const prov = provider ? provider : fieldType === 'input' @@ -171,31 +164,18 @@ export default () => : outputOptionProvider; // If the provider is an api call, we need to add /request or /response at the end - const url = prov.is_api_call - ? fieldType === 'input' - ? '/response' - : '/request' - : ''; - const providerUrl = getRealUrlFromProvider( - prov, - undefined, - undefined, - undefined - ); + const url = prov.is_api_call ? (fieldType === 'input' ? '/response' : '/request') : ''; + const providerUrl = getRealUrlFromProvider(prov, undefined, undefined, undefined); return insertUrlPartBeforeQuery( `${providerUrl}${ - fieldType === 'output' - ? `${providerUrl.includes('?') ? '&' : '?'}soft=true` - : '' + fieldType === 'output' ? `${providerUrl.includes('?') ? '&' : '?'}soft=true` : '' }`, url ); }; - const getProviderUrl: (fieldType: 'input' | 'output') => string = ( - fieldType - ) => { + const getProviderUrl: (fieldType: 'input' | 'output') => string = (fieldType) => { // Get the mapper options data const provider = mapper.mapper_options[`mapper-${fieldType}`]; // Save the provider options @@ -205,28 +185,16 @@ export default () => setOutputOptionProvider(provider); } - return getUrlFromProvider( - fieldType, - mapper.mapper_options[`mapper-${fieldType}`] - ); + return getUrlFromProvider(fieldType, mapper.mapper_options[`mapper-${fieldType}`]); }; - const getMapperKeysUrl: (fieldType: 'input' | 'output') => string = ( - fieldType - ) => { + const getMapperKeysUrl: (fieldType: 'input' | 'output') => string = (fieldType) => { // Get the mapper options data - const { - type, - name, - path = '', - subtype, - } = mapper.mapper_options[`mapper-${fieldType}`]; + const { type, name, path = '', subtype } = mapper.mapper_options[`mapper-${fieldType}`]; // Get the rules for the given provider const { url, suffix } = providers[type]; // Build the URL - const newUrl = `${url}/${name}${suffix}${addTrailingSlash( - path - )}/mapper_keys`; + const newUrl = `${url}/${name}${suffix}${addTrailingSlash(path)}/mapper_keys`; // Build the URL based on the provider type return newUrl.replace('/request', '').replace('/response', ''); }; @@ -300,15 +268,12 @@ export default () => const inputs = await fetchData(inputUrl); // If one of the connections is down - if (inputs.error) { + if (!inputs.ok) { setInputsError(inputs.error && 'InputConnError'); // Save the inputs & outputs setInputs( formatFields( - insertCustomFields( - {}, - mapper.mapper_options['mapper-input']['custom-fields'] || {} - ) + insertCustomFields({}, mapper.mapper_options['mapper-input']['custom-fields'] || {}) ) ); // Cancel loading @@ -342,14 +307,11 @@ export default () => // Fetch the input and output fields const outputs = await fetchData(`${outputUrl}`); // If one of the connections is down - if (outputs.error) { + if (!outputs.ok) { console.error(outputs); setOutputs( formatFields( - insertCustomFields( - {}, - mapper.mapper_options['mapper-output']['custom-fields'] || {} - ) + insertCustomFields({}, mapper.mapper_options['mapper-output']['custom-fields'] || {}) ) ); // Cancel loading @@ -390,18 +352,15 @@ export default () => const url = getUrlFromProvider(null, staticData); // Send the URL to backend - const listener = addMessageListener( - Messages.RETURN_FIELDS_FROM_TYPE, - ({ data }) => { - if (data) { - // Save the inputs if the data exist - setContextInputs(data.fields || data); - maybeApplyStoredDraft(); - } - - listener(); + const listener = addMessageListener(Messages.RETURN_FIELDS_FROM_TYPE, ({ data }) => { + if (data) { + // Save the inputs if the data exist + setContextInputs(data.fields || data); + maybeApplyStoredDraft(); } - ); + + listener(); + }); // Ask backend for the fields for this particular type postMessage(Messages.GET_FIELDS_FROM_TYPE, { ...staticData, @@ -439,8 +398,7 @@ export default () => // Cancel loading setOutputsLoading(false); } - const mapperContext = - mapper.interfaceContext || props.currentMapperContext; + const mapperContext = mapper.interfaceContext || props.currentMapperContext; // If this mapper has context if (mapperContext) { // If the context also has the static data @@ -467,8 +425,7 @@ export default () => data[data.custom_data.iface_kind]['staticdata-type'] ) { // Save the static data - const staticData = - data[data.custom_data.iface_kind]['staticdata-type']; + const staticData = data[data.custom_data.iface_kind]['staticdata-type']; // Get all the needed data from static data getFieldsFromStaticData(staticData); setIsContextLoaded(true); @@ -533,11 +490,7 @@ export default () => }); }; - const updateRelations = ( - type: 'inputs' | 'outputs', - oldName: string, - newName: string - ) => { + const updateRelations = (type: 'inputs' | 'outputs', oldName: string, newName: string) => { setRelations((cur) => { let result = { ...cur }; @@ -555,8 +508,7 @@ export default () => if (relationOutputName.includes(`${oldName}.`)) { return { ...newResult, - [relationOutputName.replace(`${oldName}.`, `${newName}.`)]: - relation, + [relationOutputName.replace(`${oldName}.`, `${newName}.`)]: relation, }; } @@ -663,10 +615,7 @@ export default () => // Check if the code matches the removed code // or if the removed mapper code is empty // which means all code needs to be removed - if ( - !removedMapperCode || - removedMapperCode.includes(mapperCodeName) - ) { + if (!removedMapperCode || removedMapperCode.includes(mapperCodeName)) { // Delete the code delete newRelationData.code; } @@ -709,11 +658,7 @@ export default () => setInputProvider, outputProvider, setOutputProvider, - relations: fixRelations( - relations, - flattenFields(outputs), - flattenFields(inputs) - ), + relations: fixRelations(relations, flattenFields(outputs), flattenFields(inputs)), setRelations, inputsLoading, setInputsLoading, diff --git a/src/hooks/useActionSets.tsx b/src/hooks/useActionSets.tsx index 9a8de579a..4a06479cd 100644 --- a/src/hooks/useActionSets.tsx +++ b/src/hooks/useActionSets.tsx @@ -1,5 +1,6 @@ import { find, size, some } from 'lodash'; import { IApp, IAppAction } from '../components/AppCatalogue'; +import { QorusPurpleIntent } from '../constants/util'; import { IActionSet } from '../containers/InterfaceCreator/fsm/ActionSetDialog'; import { changeStateIdsToGenerated, removeTransitionsFromStateGroup } from '../helpers/fsm'; import { useQorusStorage } from './useQorusStorage'; @@ -24,7 +25,9 @@ export const buildAppFromActionSets = ( display_name: 'Saved Favorites', name: 'action_sets', icon: 'StarFill', - iconColor: 'info', + sort: '__ActionSets', + iconColor: QorusPurpleIntent, + useBuiltInColors: true, short_desc: 'States and groups of states you saved as favorites', builtin: false, is_action_set: true, @@ -41,6 +44,7 @@ export const buildAppFromActionSets = ( action: firstState.id, short_desc: firstState.desc, action_code_str, + app: 'action_sets', actions: () => [ { diff --git a/src/hooks/useApps.tsx b/src/hooks/useApps.tsx index f9a442d53..d7975e1f7 100644 --- a/src/hooks/useApps.tsx +++ b/src/hooks/useApps.tsx @@ -1,3 +1,5 @@ +import { size } from 'lodash'; +import { useMemo } from 'react'; import { useAsyncRetry } from 'react-use'; import { IApp } from '../components/AppCatalogue'; import { TAppsContext } from '../context/apps'; @@ -13,8 +15,22 @@ export const useApps = (): TAppsContext => { const { app, ...rest } = useActionSets(); + // Add sorting key to the apps so we can sort builtin apps first + const appsWithSortKey = useMemo(() => { + if (size(apps.value)) { + const allApps = [app, ...apps.value]; + + return allApps.map((app) => ({ + ...app, + sort: app.sort || (app.builtin ? `_${app.display_name}` : app.display_name), + })); + } + + return []; + }, [apps.value, app]); + return { - apps: apps.value ? [app, ...apps.value] : [], + apps: appsWithSortKey, loading: apps.loading, error: apps.error, ...rest, diff --git a/src/hooks/useExpressions.tsx b/src/hooks/useExpressions.tsx new file mode 100644 index 000000000..25032db15 --- /dev/null +++ b/src/hooks/useExpressions.tsx @@ -0,0 +1,40 @@ +import { useMemo } from 'react'; +import { useAsyncRetry } from 'react-use'; +import { IExpressionSchema } from '../components/ExpressionBuilder'; +import { ISelectFieldItem } from '../components/Field/select'; +import { fetchData } from '../helpers/functions'; + +export interface IUseTypes { + loading: boolean; + error?: Error; + retry: () => void; + value?: IExpressionSchema[]; + valueForSelect?: ISelectFieldItem[]; +} + +export const useExpressions = (allow?: boolean): IUseTypes => { + const functions = useAsyncRetry(async () => { + if (!allow) { + return []; + } + + const data = await fetchData(`/system/expressions`); + + return data.data; + }, [allow]); + + const functionsDefaultItems = useMemo(() => { + return ( + functions.value?.map((func) => ({ + ...func, + value: func.name, + })) || [] + ); + }, [functions.value]); + + return { + ...functions, + value: functions.value || [], + valueForSelect: functionsDefaultItems, + }; +}; diff --git a/src/hooks/useGetDataProviderFavorites.tsx b/src/hooks/useGetDataProviderFavorites.tsx index 0b7063c18..8e65c6bcb 100644 --- a/src/hooks/useGetDataProviderFavorites.tsx +++ b/src/hooks/useGetDataProviderFavorites.tsx @@ -1,9 +1,10 @@ import { cloneDeep, reduce, size } from 'lodash'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useAsyncRetry } from 'react-use'; import shortid from 'shortid'; import { IProviderType } from '../components/Field/connectors'; import { fetchData } from '../helpers/functions'; +import { useWhyDidYouUpdate } from './useWhyDidYouUpdate'; export interface IDataProviderFavorite { name?: string; @@ -35,69 +36,90 @@ export const useGetDataProviderFavorites = ( return data.data; }, []); + useWhyDidYouUpdate('useGetDataProviderFavorites', { + storage, + value, + loading, + error, + favorites, + localOnly, + }); + useEffect(() => { if (value && !localOnly) { setStorage(value); } }, [value, localOnly]); - const addNewFavorite = async (provider: IDataProviderFavorite, id?: string) => { - const newId = id || shortid.generate(); - const updatedStorage = { - ...storage, - vscode: { - ...storage.vscode, - dataProviderFavorites: { - ...storage.vscode?.dataProviderFavorites, - [newId]: { - ...provider, - id: newId, + const addNewFavorite = useCallback( + async (provider: IDataProviderFavorite, id?: string) => { + const newId = id || shortid.generate(); + const updatedStorage = { + ...storage, + vscode: { + ...storage.vscode, + dataProviderFavorites: { + ...storage.vscode?.dataProviderFavorites, + [newId]: { + ...provider, + id: newId, + }, }, }, - }, - }; + }; - udpateStorage(updatedStorage); - }; + udpateStorage(updatedStorage); + }, + [JSON.stringify(storage)] + ); - const deleteFavorite = async (id: string) => { - const updatedStorage = cloneDeep(storage); + const deleteFavorite = useCallback( + async (id: string) => { + const updatedStorage = cloneDeep(storage); - delete updatedStorage.vscode.dataProviderFavorites[id]; + delete updatedStorage.vscode.dataProviderFavorites[id]; - udpateStorage(updatedStorage); - }; + udpateStorage(updatedStorage); + }, + [JSON.stringify(storage)] + ); - const deleteAllFavorites = async () => { + const deleteAllFavorites = useCallback(async () => { const updatedStorage = cloneDeep(storage); updatedStorage.vscode.dataProviderFavorites = {}; udpateStorage(updatedStorage); - }; - - const udpateStorage = async (updatedStorage: any) => { - if (!localOnly) { - await fetchData('/users/_current_/', 'PUT', { storage: updatedStorage }); - } - - setStorage(updatedStorage); - }; - - const favs: TDataProviderFavorites = { - ...(storage.vscode?.dataProviderFavorites || {}), - ...reduce( - favorites, - (newFavorites, favorite, id) => ({ - ...newFavorites, - [id]: { - ...favorite, - builtIn: true, - }, - }), - {} - ), - }; + }, [JSON.stringify(storage)]); + + const udpateStorage = useCallback( + async (updatedStorage: any) => { + if (!localOnly) { + await fetchData('/users/_current_/', 'PUT', { storage: updatedStorage }); + } + + setStorage(updatedStorage); + }, + [localOnly] + ); + + const favs: TDataProviderFavorites = useMemo( + () => ({ + ...(storage.vscode?.dataProviderFavorites || {}), + ...reduce( + favorites, + (newFavorites, favorite, id) => ({ + ...newFavorites, + [id]: { + ...favorite, + builtIn: true, + }, + }), + {} + ), + }), + [JSON.stringify(storage), JSON.stringify(favorites)] + ); return { loading, diff --git a/src/hooks/useQorusStorage.tsx b/src/hooks/useQorusStorage.tsx index 4ef626a27..845cd0022 100644 --- a/src/hooks/useQorusStorage.tsx +++ b/src/hooks/useQorusStorage.tsx @@ -3,17 +3,11 @@ import { InterfacesContext } from '../context/interfaces'; export type TQorusStorageHook = [T, (newStorage: T) => void]; -export function useQorusStorage( - path: string, - defaultValue?: T -): TQorusStorageHook { +export function useQorusStorage(path: string, defaultValue?: T): TQorusStorageHook { const { getStorage, updateStorage } = useContextSelector( InterfacesContext, ({ getStorage, updateStorage }) => ({ getStorage, updateStorage }) ); - return [ - getStorage(path, defaultValue), - (newStorage: T) => updateStorage(path, newStorage), - ]; + return [getStorage?.(path, defaultValue), (newStorage: T) => updateStorage(path, newStorage)]; } diff --git a/src/hooks/useQorusTypes.tsx b/src/hooks/useQorusTypes.tsx index 1fe756879..1dd5baf51 100644 --- a/src/hooks/useQorusTypes.tsx +++ b/src/hooks/useQorusTypes.tsx @@ -22,5 +22,8 @@ export const useQorusTypes = (): IUseTypes => { return (await fetchData(`/system/qorus-type-info`)).data || []; }, []); - return types; + return { + ...types, + value: types.value || [], + }; }; diff --git a/src/hooks/useSavedValues.tsx b/src/hooks/useSavedValues.tsx new file mode 100644 index 000000000..82c19e16d --- /dev/null +++ b/src/hooks/useSavedValues.tsx @@ -0,0 +1,47 @@ +import { TQorusType } from '@qoretechnologies/ts-toolkit'; +import { useCallback, useMemo } from 'react'; +import { ISaveValueMetadata } from '../components/SaveValueButton'; +import { areQorusTypesCompatible } from '../helpers/functions'; +import { useQorusStorage } from './useQorusStorage'; + +export type TSavedValues = ISaveValueMetadata[]; + +export const useSavedValues = (type?: TQorusType): TSavedValues => { + const [storage = [], setStorage] = useQorusStorage('savedValues'); + + // Add the ability to remove saved values + const handleDeleteClick = useCallback( + (id: string) => { + const newStorage = storage.filter((item) => item.id.value !== id); + setStorage(newStorage); + }, + [storage] + ); + + let items: TSavedValues = useMemo( + () => + storage.map( + (item): ISaveValueMetadata => ({ + ...item, + actions: [ + { + icon: 'DeleteBinLine', + intent: 'danger', + tooltip: 'Remove saved value', + minimal: true, + size: 'small', + className: 'saved-value-delete', + onClick: () => handleDeleteClick(item.id.value), + }, + ], + }) + ), + [storage, handleDeleteClick] + ); + + if (type) { + items = items.filter((item) => areQorusTypesCompatible(type, item.value.type)); + } + + return items; +}; diff --git a/src/hooks/useWhyDidYouUpdate.tsx b/src/hooks/useWhyDidYouUpdate.tsx index f2df0a3ec..b0f5ffd9d 100644 --- a/src/hooks/useWhyDidYouUpdate.tsx +++ b/src/hooks/useWhyDidYouUpdate.tsx @@ -1,3 +1,4 @@ +import { isEqual } from 'lodash'; import { useEffect, useRef } from 'react'; export function useWhyDidYouUpdate(name, props) { @@ -5,7 +6,7 @@ export function useWhyDidYouUpdate(name, props) { // ... for comparison next time this hook runs. const previousProps: any = useRef(); useEffect(() => { - if (process.env.NODE_ENV !== 'production' && process.env.REACT_APP_DEBUG_IDE === 'true') { + if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV === 'storybook') { if (previousProps.current) { // Get all keys from previous and current props const allKeys = Object.keys({ ...previousProps.current, ...props }); @@ -14,7 +15,7 @@ export function useWhyDidYouUpdate(name, props) { // Iterate through keys allKeys.forEach((key) => { // If previous is different from current - if (previousProps.current[key] !== props[key]) { + if (!isEqual(previousProps.current[key], props[key])) { // Add to changesObj changesObj[key] = { from: previousProps.current[key], diff --git a/src/index.tsx b/src/index.tsx index 83c0ceb22..daa57be10 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,7 +2,6 @@ import { ReqoreUIProvider } from '@qoretechnologies/reqore'; import { IReqoreUIProviderProps } from '@qoretechnologies/reqore/dist/containers/UIProvider'; import { initializeReqraft } from '@qoretechnologies/reqraft'; import * as Sentry from '@sentry/browser'; -import { fontFace } from 'polished'; import { useState } from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; @@ -39,19 +38,14 @@ const GlobalStyle = createGlobalStyle` height: 100%; padding: 0; margin: 0; - font-family: 'NeoSansPro', monospace; + // Use system font + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; } * { - font-family: 'NeoSansPro', monospace; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; } - ${fontFace({ - fontFamily: 'NeoSansPro', - fontFilePath: './fonts/NeoSansPro-Regular', - fileFormats: ['otf'], - })} - .reqore-tree, .reqore-tree-textarea { height: 100%; } diff --git a/src/providers/Interfaces.tsx b/src/providers/Interfaces.tsx index d59df42eb..8b7eb6d31 100644 --- a/src/providers/Interfaces.tsx +++ b/src/providers/Interfaces.tsx @@ -27,12 +27,11 @@ export const InterfacesProvider = ({ const [interfaces, setInterfaces] = useState>({}); const [storage, setStorage] = useState({}); - const addNotification = useReqoreProperty('addNotification'); const addModal = useReqoreProperty('addModal'); const removeModal = useReqoreProperty('removeModal'); const navigate = useNavigate(); - const { value, loading, error, retry } = useAsyncRetry(async () => { + const { value, loading } = useAsyncRetry(async () => { if (process.env.NODE_ENV === 'storybook') { return _injectedStorage; } diff --git a/src/stories/Components/AppCatalogue.stories.tsx b/src/stories/Components/AppCatalogue.stories.tsx index 88bcd3cb0..9cc226980 100644 --- a/src/stories/Components/AppCatalogue.stories.tsx +++ b/src/stories/Components/AppCatalogue.stories.tsx @@ -1,5 +1,5 @@ import { StoryObj } from '@storybook/react'; -import { fireEvent } from '@storybook/test'; +import { expect, fireEvent } from '@storybook/test'; import { AppCatalogue, IApp } from '../../components/AppCatalogue'; import { buildAppFromActionSets } from '../../hooks/useActionSets'; import apps from '../Data/apps.json'; @@ -31,7 +31,6 @@ const actionSetsApp = buildAppFromActionSets([ }, initial: true, name: 'Save Intent Info', - display_name: 'Qorus Built-In Action', desc: '', type: 'state', id: 'djsGWd6mm', @@ -57,7 +56,6 @@ const actionSetsApp = buildAppFromActionSets([ }, initial: false, name: 'Send Discord Message', - display_name: 'Send Discord Message', desc: 'Send a message to a Discord channel', type: 'state', id: '1qSA-sVVn', @@ -96,7 +94,6 @@ const actionSetsApp = buildAppFromActionSets([ initial: false, is_event_trigger: false, name: 'Get User Info', - display_name: 'Get User Info', desc: 'Get info about the current user', type: 'state', id: 'ZO2l-u06b', @@ -145,7 +142,6 @@ const actionSetsApp = buildAppFromActionSets([ }, initial: false, name: 'Send Discord Message', - display_name: 'Send Discord Message', desc: 'Send a message to a Discord channel', type: 'state', id: '1qSA-sVVn', @@ -184,7 +180,6 @@ const actionSetsApp = buildAppFromActionSets([ initial: false, is_event_trigger: false, name: 'Get User Info', - display_name: 'Get User Info', desc: 'Get info about the current user', type: 'state', id: 'ZO2l-u06b', @@ -245,7 +240,7 @@ export const WithActionSets: StoryObj = { args: { apps: typedAppsWithActionSets, }, - play: async (args) => { + play: async () => { await _testsClickButton({ label: 'Saved Favorites', parent: '.reqore-panel', @@ -268,6 +263,18 @@ export const DefaultQuery: StoryObj = { }, play: async () => { await fireEvent.click(document.querySelectorAll('.reqore-collection-item')[0]); + await expect(document.querySelectorAll('.reqore-collection-item')).toHaveLength(1); + }, +}; + +export const SearchMatchesOnlyApp: StoryObj = { + args: { + ...Basic.args, + defaultQuery: 'Microsoft', + }, + play: async () => { + await fireEvent.click(document.querySelectorAll('.reqore-collection-item')[0]); + await expect(document.querySelectorAll('.reqore-collection-item')).toHaveLength(5); }, }; diff --git a/src/stories/Components/Description.stories.tsx b/src/stories/Components/Description.stories.tsx index a5d77e9ab..b4b022b16 100644 --- a/src/stories/Components/Description.stories.tsx +++ b/src/stories/Components/Description.stories.tsx @@ -1,5 +1,5 @@ import { StoryObj } from '@storybook/react'; -import { expect, fireEvent, within } from "@storybook/test"; +import { expect, fireEvent, within } from '@storybook/test'; import { Description } from '../../components/Description'; import { sleep } from '../Tests/utils'; import { StoryMeta } from '../types'; @@ -38,13 +38,22 @@ export const ShortDescriptionWithMaxLength: Story = { export const LongDescriptionOnly: Story = { args: { // Add markdown long description - longDescription: `# This is a long description with markdown support and a [link](https://www.google.com)`, + longDescription: `# This is a long description with markdown support and a [link](https://www.google.com), and it is very long, so it will be truncated, but you can click to see more, and it is very long, so it will be truncated, but you can click to see more, and it is very long, so it will be truncated, but you can click to see more, and it is very long, so it will be truncated, but you can click to see more, and it is very long, so it will be truncated, but you can click to see more, and it is very long, so it will be truncated, but you can click to see more, and it is very long, so it will be truncated, but you can click to see more, and it is very long, so it will be truncated, but you can click to see more`, + maxShortDescriptionLength: 100, }, play: async ({ canvasElement, ...rest }) => { await fireEvent.click(document.querySelector('.description-more')); }, }; +export const ShortAndLongDescriptionDefault: Story = { + args: { + shortDescription: 'This is a short description', + // Add markdown long description + longDescription: `# This is a long description with markdown support and a [link](https://www.google.com)`, + }, +}; + export const ShortAndLongDescription: Story = { args: { shortDescription: 'This is a short description', diff --git a/src/stories/Components/SaveValueButton.stories.tsx b/src/stories/Components/SaveValueButton.stories.tsx new file mode 100644 index 000000000..f8057c95e --- /dev/null +++ b/src/stories/Components/SaveValueButton.stories.tsx @@ -0,0 +1,88 @@ +import { ReqoreTree } from '@qoretechnologies/reqore'; +import { StoryObj } from '@storybook/react'; +import { fireEvent } from '@storybook/test'; +import { IOptions } from '../../components/Field/systemOptions'; +import { SaveValueButton } from '../../components/SaveValueButton'; +import { useQorusStorage } from '../../hooks/useQorusStorage'; +import { InterfacesProvider } from '../../providers/Interfaces'; +import { _testsClickButton, _testsWaitForText, sleep } from '../Tests/utils'; +import { StoryMeta } from '../types'; + +const Comp = (args) => { + const [storage, setStorage] = useQorusStorage('savedValues'); + + return ( + <> + + {storage && } + + ); +}; + +const meta = { + component: SaveValueButton, + title: 'Components/Save Value Button', + render: (args) => ( + + + + ), +} as StoryMeta; + +export default meta; +export type Story = StoryObj; + +export const Default: Story = {}; +export const ModalOpened: Story = { + args: { + value: 'Some string', + type: 'string', + id: 'some-id', + }, + play: async () => { + await _testsClickButton({ selector: '.save-value' }); + await _testsWaitForText('Display Name'); + await _testsWaitForText('Short Description'); + }, +}; + +export const ValueCanBeSaved: Story = { + args: { + value: 'Some string', + type: 'string', + id: 'some-id', + }, + play: async () => { + await _testsClickButton({ selector: '.save-value' }); + await _testsWaitForText('Display Name'); + await _testsWaitForText('Short Description'); + + await fireEvent.change(document.querySelectorAll('.system-option textarea')[0], { + target: { + value: 'My saved value', + }, + }); + + await fireEvent.change(document.querySelectorAll('.system-option textarea')[1], { + target: { + value: 'Will this work?', + }, + }); + + // Debounce sleep + await sleep(550); + + await _testsClickButton({ label: 'Save' }); + + await _testsWaitForText('"My saved value"'); + }, +}; + +export const ComplexValueCanBeSaved: Story = { + ...ValueCanBeSaved, + args: { + value: { some: 'this is an object' }, + type: 'hash', + id: 'some-id', + }, +}; diff --git a/src/stories/Components/Sidebar.stories.tsx b/src/stories/Components/Sidebar.stories.tsx index a478a03f6..30bd15ab5 100644 --- a/src/stories/Components/Sidebar.stories.tsx +++ b/src/stories/Components/Sidebar.stories.tsx @@ -1,10 +1,7 @@ import { StoryObj } from '@storybook/react'; import { Sidebar } from '../../components/Sidebar'; import { InterfacesProvider } from '../../providers/Interfaces'; -import { - storiesStorageMockEmpty, - storiesStorageMockWithSidebarSize, -} from '../Data/storage'; +import { storiesStorageMockEmpty, storiesStorageMockWithSidebarSize } from '../Data/storage'; import { StoryMeta } from '../types'; const meta = { diff --git a/src/stories/Data/storage.ts b/src/stories/Data/storage.ts index 9decfda25..af153f92c 100644 --- a/src/stories/Data/storage.ts +++ b/src/stories/Data/storage.ts @@ -35,3 +35,29 @@ export const storiesStorageMockWithDisabledAiModal = [ }, }, ]; + +export const storiesStorageMockWithSavedValues = [ + { + url: 'https://hq.qoretechnologies.com:8092/api/latest/users/_current_/storage', + method: 'GET', + status: 200, + response: { + ide: { + savedValues: [ + { + display_name: { type: 'string', value: 'My First Saved Value' }, + short_desc: { type: 'string', value: 'This is the first saved value' }, + value: { type: 'string', value: 'first-saved-value' }, + id: { type: 'string', value: 'first-saved-value' }, + }, + { + display_name: { type: 'string', value: 'My Second Saved Value' }, + short_desc: { type: 'string', value: 'This is the second saved value' }, + value: { type: 'hash', value: { some: 'value' } }, + id: { type: 'string', value: 'second-saved-value' }, + }, + ], + }, + }, + }, +]; diff --git a/src/stories/Fields/Auto.stories.tsx b/src/stories/Fields/Auto.stories.tsx index 519e21635..4d9092b4d 100644 --- a/src/stories/Fields/Auto.stories.tsx +++ b/src/stories/Fields/Auto.stories.tsx @@ -7,19 +7,115 @@ import Loader from '../../components/Loader'; import { useTemplates } from '../../hooks/useTemplates'; import { InterfacesProvider } from '../../providers/Interfaces'; import { _testsClickButton, _testsWaitForText, sleep } from '../Tests/utils'; +import { StoryMeta } from '../types'; -export default { +const meta = { component: Auto, title: 'Fields/Auto', + render: (args) => { + return ( + + + + ); + }, +} as StoryMeta; + +export default meta; + +type Story = StoryObj; + +const AutoCompWithSaveButton = (args) => { + const [value, setValue] = useState(args.value); + + return ( + { + setValue(value); + }} + /> + ); }; -export const Default: StoryObj = {}; -export const Connection: StoryObj = { +export const Default: Story = {}; +export const StringWithAllowedValues: Story = { + args: { + allowSaving: true, + defaultType: 'string', + allowed_values: [ + { + display_name: 'test', + value: 'test', + short_desc: 'This is a test', + }, + { + display_name: 'test 2', + value: 'test 2', + intent: 'info', + }, + ], + }, +}; +export const StringWithSuggestedValues: Story = { + render: (args) => { + return ( + + + + ); + }, + args: { + defaultType: 'string', + allowSaving: true, + allowed_values_creatable: true, + allowed_values: [ + { + display_name: 'test', + value: 'test', + short_desc: 'This is a test', + }, + { + display_name: 'test 2', + value: 'test 2', + intent: 'info', + }, + ], + }, +}; + +export const StringWithSuggestedAndSavedValues: Story = { + ...StringWithSuggestedValues, + args: { + ...StringWithSuggestedValues.args, + showSavedValues: true, + }, +}; + +export const Connection: Story = { args: { defaultType: 'connection', }, }; -export const RichText: StoryObj = { +export const RichText: Story = { render: (args) => { const [value, setValue] = useState(args.value); const templates = useTemplates(true); @@ -61,17 +157,17 @@ export const RichText: StoryObj = { ); }, }; -export const ConnectionWithAllowedValues: StoryObj = { +export const ConnectionWithAllowedValues: Story = { args: { defaultType: 'connection', allowed_values: [ { - name: 'test', + display_name: 'test', value: 'test', short_desc: 'This is a test', }, { - name: 'test 2', + display_name: 'test 2', value: 'test 2', intent: 'info', desc: 'This is a test 2', @@ -84,23 +180,38 @@ export const ConnectionWithAllowedValues: StoryObj = { }, }; -export const Hash: StoryObj = { +export const Hash: Story = { args: { value: jsyaml.dump({ key: 'value' }), }, }; -export const ListWithAllowedValues: StoryObj = { +export const ListWithAllowedValues: Story = { + render: (args) => { + const [value, setValue] = useState(args.value); + + return ( + + { + setValue(value); + }} + /> + + ); + }, args: { defaultType: 'list', allowed_values: [ { - name: 'test', + display_name: 'Test 1', value: 'test', short_desc: 'This is a test', }, { - name: 'test 2', + display_name: 'And test 2', value: 'test 2', intent: 'info', }, @@ -108,7 +219,7 @@ export const ListWithAllowedValues: StoryObj = { }, }; -export const ListWithElementType: StoryObj = { +export const ListWithElementType: Story = { render: (args) => { const [value, setValue] = useState(args.value); diff --git a/src/stories/Fields/DataProvider/Favorites.stories.tsx b/src/stories/Fields/DataProvider/Favorites.stories.tsx new file mode 100644 index 000000000..798fab8ad --- /dev/null +++ b/src/stories/Fields/DataProvider/Favorites.stories.tsx @@ -0,0 +1,37 @@ +import { StoryObj } from '@storybook/react'; +import { DataProviderFavorites } from '../../../components/Field/connectors/favorites'; +import { StoryMeta } from '../../types'; + +const meta = { + component: DataProviderFavorites, + title: 'Fields/DataProvider/Favorites', +} as StoryMeta; + +export default meta; + +export const Basic: StoryObj = { + args: { + defaultFavorites: { + test: { + id: 'test', + value: { + type: 'datasource', + name: 'omquser', + transaction_management: true, + record_requires_search_options: false, + path: '/bb_local', + supports_request: false, + supports_read: true, + supports_update: true, + supports_create: true, + supports_delete: true, + supports_messages: 'NONE', + descriptions: [ + 'Data provider for database `pgsql:omquser@omquser`; use the search API with the `sql` and `args` arguments to execute record-based queries', + 'Record-based data provider for db table `public.bb_local`; supports create, read/search, update, delete, upsert, and bulk operations', + ], + }, + }, + }, + }, +}; diff --git a/src/stories/Fields/List.stories.tsx b/src/stories/Fields/List.stories.tsx index 32b84aed1..85e60cae6 100644 --- a/src/stories/Fields/List.stories.tsx +++ b/src/stories/Fields/List.stories.tsx @@ -30,10 +30,12 @@ export const ListWithAllowedValues: Story = { allowed_values: [ { name: 'test', + display_name: 'Test', value: 'test', short_desc: 'This is a test', }, { + display_name: 'Test 2', name: 'test 2', value: 'test 2', intent: 'info', @@ -111,8 +113,8 @@ export const ListWithElementTypeAndArgSchema: Story = { type: 'int', display_name: 'Schema option 2', allowed_values: [ - { name: 500, short_desc: 'Allowed value 1' }, - { name: 700, short_desc: 'Allowed value 2' }, + { value: 500, short_desc: 'Allowed value 1', display_name: 'Allowed value 1' }, + { value: 700, short_desc: 'Allowed value 2', display_name: 'Allowed value 2' }, ], required: true, }, @@ -143,9 +145,10 @@ export const ListWithElementTypeAndArgSchema: Story = { }, }; -export const ItemsCanBeAddedAndRemoved = { +export const ItemsCanBeAddedAndRemoved: Story = { ...ListWithElementType, play: async () => { + await _testsClickButton({ label: 'Add new item for "My list"' }); await waitFor(() => expect(document.querySelectorAll('.array-auto-item').length).toBe(1)); await _testsClickButton({ label: 'Add new item for "My list"' }); await _testsClickButton({ label: 'Add new item for "My list"' }); diff --git a/src/stories/Fields/Options/Messages.stories.tsx b/src/stories/Fields/Options/Messages.stories.tsx index 3591cbcad..7d874a26a 100644 --- a/src/stories/Fields/Options/Messages.stories.tsx +++ b/src/stories/Fields/Options/Messages.stories.tsx @@ -47,14 +47,17 @@ export const MissingDependencies: Story = { args: { schema: { someOption: { + display_name: 'Some Option', type: 'string', required: true, }, anotherOption: { + display_name: 'Another Option', type: 'string', required: true, }, test: { + display_name: 'Test', type: 'string', required: true, depends_on: ['someOption', 'anotherOption'], @@ -82,14 +85,17 @@ export const RequiredGroupUnfilled: Story = { args: { schema: { someOption: { + display_name: 'Some Option', type: 'string', required_groups: ['group1'], }, anotherOption: { + display_name: 'Another Option', type: 'string', required_groups: ['group1'], }, test: { + display_name: 'Test', type: 'string', required_groups: ['group1'], }, diff --git a/src/stories/Fields/Options/Options.stories.tsx b/src/stories/Fields/Options/Options.stories.tsx index 7b330eddb..ba09e7bcb 100644 --- a/src/stories/Fields/Options/Options.stories.tsx +++ b/src/stories/Fields/Options/Options.stories.tsx @@ -1,22 +1,29 @@ -import { Meta, StoryObj } from '@storybook/react'; +import { StoryObj } from '@storybook/react'; import { expect, fireEvent, fn, waitFor, within } from '@storybook/test'; import jsyaml from 'js-yaml'; import { useState } from 'react'; import Options, { IOptionsSchema } from '../../../components/Field/systemOptions'; import { validateField } from '../../../helpers/validations'; +import { InterfacesProvider } from '../../../providers/Interfaces'; import { TestOptionsWithRequiredGroups } from '../../Data/options'; import { _testsChangeRichText, + _testsClickButton, _testsWaitForText, + _testsWaitForTextsCount, _testsWaitForTextToNotExist, sleep, } from '../../Tests/utils'; +import { StoryMeta } from '../../types'; const meta = { component: Options, title: 'Fields/Options', args: { onChange: fn(), + reqoreOptions: { + customPortalId: '#custom-portal', + }, }, parameters: { chromatic: { @@ -27,20 +34,23 @@ const meta = { const [val, setValue] = useState(value); return ( - { - setValue(v); - onChange(_n, v, meta); - }} - isValid={validateField('system-options', val, { - optionSchema: rest.options, - })} - /> + +
+ { + setValue(v); + onChange(_n, v, meta); + }} + isValid={validateField('system-options', val, { + optionSchema: rest.options, + })} + /> + ); }, -} as Meta; +} as StoryMeta; export default meta; @@ -152,6 +162,28 @@ const getOptions = (allOptional: boolean = false): IOptionsSchema => ({ required: !allOptional, supports_templates: true, }, + optionWithAllowedValuesCreatable: { + type: 'number', + display_name: 'Fillable option with allowed values', + allowed_values: [ + { + display_name: 'Allowed value 1', + short_desc: 'Allowed value 1', + desc: 'Allowed value 1', + value: 10, + }, + { + display_name: 'Allowed value 2', + short_desc: 'Allowed value 2', + desc: 'Allowed value 2', + value: 20, + }, + ], + required: !allOptional, + supports_templates: true, + supports_expressions: true, + allowed_values_creatable: true, + }, optionWithBrokenAllowedValues: { type: 'string', supports_templates: true, @@ -296,7 +328,7 @@ export const Basic: StoryObj = { }); await waitFor( () => - expect(document.querySelectorAll('.reqore-collection-item.system-option').length).toBe(19), + expect(document.querySelectorAll('.reqore-collection-item.system-option').length).toBe(24), { timeout: 10000, } @@ -353,6 +385,15 @@ export const OptionalOpened: StoryObj = { }, }; +export const WithTypesShown: StoryObj = { + ...Basic, + play: async ({ canvasElement, ...rest }) => { + await Basic.play({ canvasElement, ...rest }); + await _testsClickButton({ selector: '.fields-show-types' }); + await _testsWaitForText(''); + }, +}; + export const WithRequiredGroups: StoryObj = { args: { minColumnWidth: '300px', @@ -399,9 +440,13 @@ export const OptionDependsOnOptionOrAnotherOption: StoryObj = { timeout: 10000, }); - await _testsWaitForText('Some dependencies are not fulfilled'); + await _testsWaitForText( + 'Some dependencies are not fulfilled: Required Option 2, Required Option 5' + ); await _testsChangeRichText('I have value', 5); - await _testsWaitForTextToNotExist('Some dependencies are not fulfilled'); + await _testsWaitForTextToNotExist( + 'Some dependencies are not fulfilled: Required Option 2, Required Option 5' + ); }, }; @@ -424,9 +469,9 @@ export const OptionDependsOnOptionInRequiredGroup: StoryObj = { timeout: 10000, }); - await _testsWaitForText('Some dependencies are not fulfilled'); + await _testsWaitForText('Some dependencies are not fulfilled: Required Option 2'); await _testsChangeRichText('I have value', 1); - await _testsWaitForTextToNotExist('Some dependencies are not fulfilled'); + await _testsWaitForTextToNotExist('Some dependencies are not fulfilled: Required Option 2'); }, }; @@ -497,7 +542,7 @@ export const OptionsWithOnChangeTriggerEvents: StoryObj = { options: { optionWithRefetchAndReset: { type: 'string', - on_change: ['refetch', 'reset'], + on_change: ['refetch'], }, option2: { type: 'string' }, }, @@ -521,7 +566,7 @@ export const OptionsWithOnChangeTriggerEvents: StoryObj = { option2: { type: 'string', value: 'option2' }, }, { - events: ['refetch', 'reset'], + events: ['refetch'], } ); }, @@ -589,3 +634,56 @@ export const OptionWithExpression: StoryObj = { await _testsWaitForText('substr()', undefined, 2); }, }; + +export const WithSavingAllowed: StoryObj = { + ...Basic, + args: { + ...Basic.args, + allowSaving: true, + showSavedValues: true, + }, +}; + +export const SavedValuesAreShownCorrectly: StoryObj = { + ...WithSavingAllowed, + args: { + ...WithSavingAllowed.args, + storage: { + savedValues: [ + { + display_name: { type: 'string', value: 'Saved value 1' }, + short_desc: { type: 'string', value: 'Saved value 1' }, + value: { type: 'string', value: 'saved-1' }, + id: { type: 'string', value: 'saved-1' }, + }, + { + display_name: { type: 'string', value: 'Saved value 2' }, + short_desc: { type: 'string', value: 'Saved value 2' }, + value: { type: 'hash', value: { myValue: 1 } }, + id: { type: 'string', value: 'saved-2' }, + }, + { + display_name: { type: 'string', value: 'Saved value 3' }, + short_desc: { type: 'string', value: 'Saved value 3' }, + value: { type: 'richtext', value: 'test' }, + id: { type: 'string', value: 'saved-3' }, + }, + ], + }, + }, + play: async () => { + await _testsWaitForTextsCount('Saved & Suggested Values', undefined, 12); + }, +}; + +export const SavedValueCanBeDeleted: StoryObj = { + ...SavedValuesAreShownCorrectly, + play: async ({ canvasElement, ...rest }) => { + await SavedValuesAreShownCorrectly.play({ canvasElement, ...rest }); + await _testsClickButton({ label: 'Saved & Suggested Values', nth: 9 }); + await _testsClickButton({ selector: '.saved-value-delete', nth: 0 }); + await _testsWaitForTextToNotExist('Saved value 1'); + await _testsClickButton({ selector: '.reqore-drawer-close-button' }); + await _testsWaitForTextsCount('Saved & Suggested Values', undefined, 4); + }, +}; diff --git a/src/stories/Fields/Select.stories.tsx b/src/stories/Fields/Select.stories.tsx index a8265525a..3f3177e06 100644 --- a/src/stories/Fields/Select.stories.tsx +++ b/src/stories/Fields/Select.stories.tsx @@ -2,7 +2,7 @@ import { StoryObj } from '@storybook/react'; import { expect, fireEvent } from '@storybook/test'; import { useState } from 'react'; import SelectField from '../../components/Field/select'; -import { sleep } from '../Tests/utils'; +import { _testsClickButton, sleep } from '../Tests/utils'; export default { component: SelectField, @@ -14,25 +14,35 @@ export const Items: StoryObj = { args: { defaultItems: [ { - name: 'Item 1', + display_name: 'Item 1', + value: 'item1', }, { - name: 'Item 2', + display_name: 'Item 2', + value: 'item2', }, ], }, + play: async () => { + await sleep(500); + await _testsClickButton({ label: 'PleaseSelect' }); + }, }; export const ItemsWithDescription: StoryObj = { args: { defaultItems: [ { - name: 'Item 1', + display_name: 'Item 1', desc: 'This is item 1', + value: 'item1', + icon: 'MoneyEuroCircleFill', }, { - name: 'Item 2', + display_name: 'Item 2', desc: 'This is item 2', + value: 'item2', + image: 'https://avatars.githubusercontent.com/u/8861481?v=4', }, ], }, @@ -40,9 +50,7 @@ export const ItemsWithDescription: StoryObj = { await fireEvent.click(document.querySelector('.reqore-button')!); await expect(document.querySelector('.reqore-modal')).toBeInTheDocument(); - await expect( - document.querySelectorAll('.reqore-collection-item').length - ).toBe(2); + await expect(document.querySelectorAll('.reqore-collection-item').length).toBe(2); }, }; @@ -50,7 +58,8 @@ export const ItemsWithDescriptionAndMessages: StoryObj = { args: { defaultItems: [ { - name: 'Item 1', + display_name: 'Item 1', + value: 'item1', desc: 'This is item 1', messages: [ { @@ -61,7 +70,8 @@ export const ItemsWithDescriptionAndMessages: StoryObj = { ], }, { - name: 'Item 2', + display_name: 'Item 2', + value: 'item2', desc: 'This is item 2', messages: [ { @@ -79,17 +89,13 @@ export const ItemsWithDescriptionAndMessages: StoryObj = { }, }; -export const OpenedItemsWithDescriptionAndMessages: StoryObj< - typeof SelectField -> = { +export const OpenedItemsWithDescriptionAndMessages: StoryObj = { ...ItemsWithDescriptionAndMessages, play: async () => { await fireEvent.click(document.querySelector('.reqore-button')!); await expect(document.querySelector('.reqore-modal')).toBeInTheDocument(); - await expect( - document.querySelectorAll('.reqore-collection-item').length - ).toBe(2); + await expect(document.querySelectorAll('.reqore-collection-item').length).toBe(2); }, }; @@ -97,23 +103,28 @@ export const DisabledItemsWithIntent: StoryObj = { args: { defaultItems: [ { - name: 'Item 1', + display_name: 'Item 1', + value: 'item1', }, { - name: 'Item 2', + display_name: 'Item 2', + value: 'item2', }, { - name: 'Disabled item', + display_name: 'Disabled item', disabled: true, + value: 'item3', }, { - name: 'Item with intent', + display_name: 'Item with intent', intent: 'success', + value: 'item4', }, { - name: 'Disabled Item with Intent', + display_name: 'Disabled Item with Intent', intent: 'danger', disabled: true, + value: 'item5', }, ], }, @@ -121,40 +132,41 @@ export const DisabledItemsWithIntent: StoryObj = { await sleep(500); await fireEvent.click(document.querySelector('.reqore-button')!); await sleep(500); - await expect( - document.querySelectorAll('.reqore-popover-content').length - ).toBe(1); + await expect(document.querySelectorAll('.reqore-popover-content').length).toBe(1); }, }; -export const DisabledItemsWithIntentAndDescriptions: StoryObj< - typeof SelectField -> = { +export const DisabledItemsWithIntentAndDescriptions: StoryObj = { args: { defaultItems: [ { - name: 'Item 1', + display_name: 'Item 1', desc: 'This is item 1', + value: 'item1', }, { - name: 'Item 2', + display_name: 'Item 2', short_desc: 'This is item 2', + value: 'item2', }, { - name: 'Disabled item', + display_name: 'Disabled item', disabled: true, short_desc: 'This is item 2', + value: 'item3', }, { - name: 'Item with intent', + display_name: 'Item with intent', intent: 'success', short_desc: 'This is item 2', + value: 'item4', }, { - name: 'Disabled Item with Intent', + display_name: 'Disabled Item with Intent', intent: 'danger', disabled: true, short_desc: 'This is item 2', + value: 'item5', }, ], }, @@ -162,21 +174,21 @@ export const DisabledItemsWithIntentAndDescriptions: StoryObj< await fireEvent.click(document.querySelector('.reqore-button')!); await expect(document.querySelector('.reqore-modal')).toBeInTheDocument(); - await expect( - document.querySelectorAll('.reqore-collection-item').length - ).toBe(5); + await expect(document.querySelectorAll('.reqore-collection-item').length).toBe(5); }, }; export const WithValue: StoryObj = { args: { - value: 'Item 2', + value: 'item2', defaultItems: [ { - name: 'Item 1', + display_name: 'Item 1', + value: 'item1', }, { - name: 'Item 2', + display_name: 'Item 2', + value: 'item2', }, ], }, @@ -184,24 +196,25 @@ export const WithValue: StoryObj = { await sleep(300); await fireEvent.click(document.querySelector('.reqore-button')!); await sleep(500); - await expect( - document.querySelectorAll('.reqore-popover-content').length - ).toBe(1); + await expect(document.querySelectorAll('.reqore-popover-content').length).toBe(1); }, }; export const WithValueAndErrors: StoryObj = { args: { - value: 'Item 2', + value: 'item2', defaultItems: [ { - name: 'Item 1', + display_name: 'Item 1', desc: 'This is item 1', intent: 'danger', + value: 'item1', }, { - name: 'Item 2', + display_name: 'Item 2', desc: 'This is item 1', + value: 'item2', + image: 'https://avatars.githubusercontent.com/u/8861481?v=4', }, ], }, @@ -209,16 +222,18 @@ export const WithValueAndErrors: StoryObj = { export const WithValueAndErrorsSelected: StoryObj = { args: { - value: 'Item 1', + value: 'item1', defaultItems: [ { - name: 'Item 1', + display_name: 'Item 1', desc: 'This is item 1', intent: 'danger', + value: 'item1', }, { - name: 'Item 2', + display_name: 'Item 2', desc: 'This is item 1', + value: 'item2', }, ], }, @@ -226,18 +241,20 @@ export const WithValueAndErrorsSelected: StoryObj = { export const WithValueAndWarningsSelected: StoryObj = { args: { - value: 'Item 1', + value: 'item1', defaultItems: [ { - name: 'Item 1', + display_name: 'Item 1', desc: 'This is item 1', metadata: { needs_auth: true, }, + value: 'item1', }, { - name: 'Item 2', + display_name: 'Item 2', desc: 'This is item 1', + value: 'item2', }, ], }, @@ -259,7 +276,8 @@ export const AutoSelect: StoryObj = { args: { defaultItems: [ { - name: 'Item 1', + display_name: 'Item 1', + value: 'item1', }, ], }, @@ -270,8 +288,9 @@ export const AutoSelectWithShortDescriptions: StoryObj = { args: { defaultItems: [ { - name: 'Item 1', + display_name: 'Item 1', short_desc: 'Short item 1 description', + value: 'item1', }, ], }, @@ -282,8 +301,9 @@ export const AutoSelectWithDescriptions: StoryObj = { args: { defaultItems: [ { - name: 'Item 1', + display_name: 'Item 1', desc: 'This is item 1', + value: 'item1', }, ], }, diff --git a/src/stories/Fields/Template.stories.tsx b/src/stories/Fields/Template.stories.tsx index 9cadf2002..cd4ccb85e 100644 --- a/src/stories/Fields/Template.stories.tsx +++ b/src/stories/Fields/Template.stories.tsx @@ -23,6 +23,7 @@ export const StringComponent: StoryObj = { args: { component: LongStringField, value: 'Some string', + type: 'string', }, }; diff --git a/src/stories/Interfaces/Mapper/Mapper.stories.tsx b/src/stories/Interfaces/Mapper/Mapper.stories.tsx index aab65ddc2..66659304e 100644 --- a/src/stories/Interfaces/Mapper/Mapper.stories.tsx +++ b/src/stories/Interfaces/Mapper/Mapper.stories.tsx @@ -137,6 +137,11 @@ export const ChangesCanBeDiscarded: Story = { await _testsExpectFieldsCountToMatch(2, true, 'mapper'); await _testsSelectItemFromDropdown(undefined, 'SelectAll', 'Optional fields available (8)')(); await _testsExpectFieldsCountToMatch(10, true, 'mapper'); + + await fireEvent.change(document.querySelectorAll('.reqore-input')[1], { + target: { value: 'desc' }, + }); + await _testsClickButton({ selector: '.interface-reset-changes' }); await _testsConfirmDialog(); await _testsExpectFieldsCountToMatch(2, true, 'mapper'); diff --git a/src/stories/Tests/FSM/ActionSets.stories.tsx b/src/stories/Tests/FSM/ActionSets.stories.tsx index d1a60dfa4..56489cb82 100644 --- a/src/stories/Tests/FSM/ActionSets.stories.tsx +++ b/src/stories/Tests/FSM/ActionSets.stories.tsx @@ -12,6 +12,7 @@ import { _testsSelectAppOrAction, _testsSelectFromAppCatalogue, _testsSelectStateByLabel, + _testsWaitForTextToNotExist, sleep, } from '../utils'; import { SwitchesToBuilder } from './Basic.stories'; @@ -73,6 +74,9 @@ export const CreateNewSet: StoryFSM = { export const CreateNewSetWithEventTrigger: StoryFSM = { ...Existing, + parameters: { + chromatic: { disable: true }, + }, play: async ({ canvasElement, openAfter = true, ...rest }) => { const canvas = within(canvasElement); await SwitchesToBuilder.play({ canvasElement, ...rest }); @@ -106,12 +110,6 @@ export const CreateNewSetWithEventTrigger: StoryFSM = { await waitFor(() => expect(document.querySelector('.reqore-modal')).not.toBeInTheDocument(), { timeout: 10000, }); - - if (openAfter) { - await _testsDeleteState('Schedule'); - await _testsOpenAppCatalogue(undefined, 500, 200); - await _testsSelectAppOrAction(canvas, 'Saved Favorites'); - } }, }; @@ -133,6 +131,10 @@ export const AddNewSet: StoryFSM = { export const AddNewSetWithEventTrigger: StoryFSM = { ...Existing, + tags: ['!test'], + parameters: { + chromatic: { disable: true }, + }, play: async ({ canvasElement, ...rest }) => { const canvas = within(canvasElement); await CreateNewSetWithEventTrigger.play({ @@ -143,11 +145,17 @@ export const AddNewSetWithEventTrigger: StoryFSM = { await _testsDeleteState('Schedule'); + await sleep(5000); + + await _testsWaitForTextToNotExist('Schedule'); + await _testsOpenAppCatalogue(undefined, 500, 200); + await sleep(500); + await _testsSelectFromAppCatalogue(canvas, undefined, 'Saved Favorites', 'With Event Trigger'); - await sleep(200); + await sleep(500); await expect(document.querySelectorAll('.fsm-state').length).toBe(5); }, @@ -247,7 +255,7 @@ export const SaveStateAsFavorite: StoryFSM = { await _testsSelectAppOrAction(canvas, 'Saved Favorites'); await expect( canvas.queryAllByText('Get User Info', { - selector: '.fsm-app-selector h4', + selector: '.fsm-app-selector h4 span', }) ).toHaveLength(1); }, diff --git a/src/stories/Tests/FSM/Regressions.stories.tsx b/src/stories/Tests/FSM/Regressions.stories.tsx index 7b88f221a..392ac62bf 100644 --- a/src/stories/Tests/FSM/Regressions.stories.tsx +++ b/src/stories/Tests/FSM/Regressions.stories.tsx @@ -13,7 +13,6 @@ import { sleep, } from '../utils'; import { SwitchesToBuilder } from './Basic.stories'; -import { NewIfStateWithExpression } from './States.stories'; const meta = { component: FSMView, @@ -48,18 +47,18 @@ export const MultipleOptionsWithOneAllowedValue: StoryFSM = { }, }; -export const SubExpressionCanBeAddedInIfState: StoryFSM = { - parameters: { - chromatic: { disable: true }, - }, - play: async (context) => { - const canvas = within(context.canvasElement); - await NewIfStateWithExpression.play(context); +// export const SubExpressionCanBeAddedInIfState: StoryFSM = { +// parameters: { +// chromatic: { disable: true }, +// }, +// play: async (context) => { +// const canvas = within(context.canvasElement); +// await NewIfStateWithExpression.play(context); - // Select concat from the list - await _testsSelectItemFromCollection(canvas, 'equals', undefined, '.function-selector')(); - }, -}; +// // Select concat from the list +// await _testsSelectItemFromCollection(canvas, 'equals', undefined, '.function-selector')(); +// }, +// }; export const OptionalFieldCanBeRemoved: StoryFSM = { args: { diff --git a/src/stories/Tests/FSM/Transitions.stories.tsx b/src/stories/Tests/FSM/Transitions.stories.tsx index 14623cdeb..25ee4c28c 100644 --- a/src/stories/Tests/FSM/Transitions.stories.tsx +++ b/src/stories/Tests/FSM/Transitions.stories.tsx @@ -30,9 +30,7 @@ export const TransitionCanBeDeleted: StoryFSM = { await waitFor( () => { - expect( - document.querySelectorAll('.fsm-delete-transition') - ).toHaveLength(1); + expect(document.querySelectorAll('.fsm-delete-transition')).toHaveLength(1); }, { timeout: 5000 } ); @@ -63,9 +61,7 @@ export const TransitionsCanBeReset: StoryFSM = { await waitFor( () => { - expect( - document.querySelectorAll('.fsm-delete-transition') - ).toHaveLength(1); + expect(document.querySelectorAll('.fsm-delete-transition')).toHaveLength(1); }, { timeout: 5000 } ); @@ -83,9 +79,7 @@ export const TransitionsCanBeReset: StoryFSM = { await waitFor( () => { - expect( - document.querySelectorAll('.fsm-delete-transition') - ).toHaveLength(1); + expect(document.querySelectorAll('.fsm-delete-transition')).toHaveLength(1); }, { timeout: 5000 } ); diff --git a/src/stories/Tests/FSM/Variables.stories.tsx b/src/stories/Tests/FSM/Variables.stories.tsx index 75acfe16f..42b03e0ea 100644 --- a/src/stories/Tests/FSM/Variables.stories.tsx +++ b/src/stories/Tests/FSM/Variables.stories.tsx @@ -56,11 +56,11 @@ export const NewVariableState: StoryFSM = { await sleep(100); - await fireEvent.click(canvas.getAllByText('Variables', { selector: 'h4' })[0]); + await fireEvent.click(canvas.getAllByText('Variables', { selector: 'h4 span' })[0]); await waitFor( async () => { - await expect(canvas.getByText('testVariable', { selector: 'h4' })).toBeInTheDocument(); + await expect(canvas.getByText('testVariable', { selector: 'h4 span' })).toBeInTheDocument(); }, { timeout: 5000, diff --git a/src/stories/Tests/FSM/States.stories.tsx b/src/stories/Tests/FSM/_States.NOPE.tsx similarity index 97% rename from src/stories/Tests/FSM/States.stories.tsx rename to src/stories/Tests/FSM/_States.NOPE.tsx index 99a459300..672f978ca 100644 --- a/src/stories/Tests/FSM/States.stories.tsx +++ b/src/stories/Tests/FSM/_States.NOPE.tsx @@ -52,7 +52,7 @@ export const NewStateFromVariable: StoryFSM = { const canvas = within(canvasElement); await NewVariableState.play({ canvasElement, ...rest }); - await fireEvent.click(canvas.getByText(`testVariable`, { selector: 'h4' })); + await fireEvent.click(canvas.getByText(`testVariable`, { selector: 'h4 span' })); await waitFor(() => expect(document.querySelector('.fsm-state-detail')).toBeInTheDocument(), { timeout: 15000, }); @@ -193,19 +193,24 @@ export const NewWhileState: StoryFSM = { } ); + await sleep(3000); + // Fill the required option await waitFor( async () => { - await fireEvent.change(document.querySelectorAll('.system-option .reqore-textarea')[0], { - target: { - value: 'This is a test', - }, - }); + await fireEvent.change( + document.querySelectorAll('.fsm-state-detail .system-option .reqore-textarea')[0], + { + target: { + value: 'This is a test', + }, + } + ); }, - { timeout: 5000 } + { timeout: 10000 } ); - await sleep(400); + await sleep(3000); await waitFor( async () => await expect(document.querySelector('.state-next-button')).toBeEnabled(), @@ -298,7 +303,7 @@ export const NewTransactionState: StoryFSM = { } ); - await sleep(200); + await sleep(2000); await fireEvent.click(document.querySelector('.fsm-state-detail .provider-type-selector')); await fireEvent.click(canvas.getByText('factory')); @@ -381,7 +386,9 @@ export const NewTransactionState: StoryFSM = { await sleep(1500); - await waitFor(() => expect(canvas.getByText('trans', { selector: 'h4' })).toBeInTheDocument()); + await waitFor(() => + expect(canvas.getByText('trans', { selector: 'h4 span' })).toBeInTheDocument() + ); await waitFor(_testsSubmitFSMState('state-transaction-submit-button'), { timeout: 5000, @@ -417,6 +424,15 @@ export const NewIfState: StoryFSM = { await _testsWaitForStateToReSave(); + await waitFor( + async () => { + await expect(document.querySelector('#condition-field')).toBeInTheDocument(); + }, + { + timeout: 10000, + } + ); + await fireEvent.change(document.querySelector('#condition-field'), { target: { value: 'asfg condition' }, }); diff --git a/src/stories/Tests/Fields/Options.stories.tsx b/src/stories/Tests/Fields/Options.stories.tsx index 14d1f2837..e02a821b7 100644 --- a/src/stories/Tests/Fields/Options.stories.tsx +++ b/src/stories/Tests/Fields/Options.stories.tsx @@ -1,8 +1,9 @@ -import { Meta, StoryObj } from '@storybook/react'; +import { StoryObj } from '@storybook/react'; import { expect, fireEvent, fn, waitFor, within } from '@storybook/test'; import { useState } from 'react'; import Options from '../../../components/Field/systemOptions'; import { Basic } from '../../Fields/Options/Options.stories'; +import { StoryMeta } from '../../types'; import { sleep } from '../utils'; const meta = { @@ -25,7 +26,7 @@ const meta = { /> ); }, -} as Meta; +} as StoryMeta; export default meta; diff --git a/src/stories/Tests/utils.ts b/src/stories/Tests/utils.ts index 1f3bf1b28..669c30922 100644 --- a/src/stories/Tests/utils.ts +++ b/src/stories/Tests/utils.ts @@ -148,10 +148,10 @@ export async function _testsManageVariableFromCatalogue(variableName: string) { } export async function _testsSelectAppOrAction(canvas, appOrAction: string) { - await waitFor(() => canvas.getByText(appOrAction, { selector: '.fsm-app-selector h4' }), { - timeout: 10000, + await waitFor(() => canvas.getByText(appOrAction, { selector: '.fsm-app-selector h4 span' }), { + timeout: 100000, }); - await fireEvent.click(canvas.getByText(appOrAction, { selector: '.fsm-app-selector h4' })); + await fireEvent.click(canvas.getByText(appOrAction, { selector: '.fsm-app-selector h4 span' })); } export async function _testsOpenAppCatalogueFromState( @@ -407,7 +407,7 @@ export async function _testsDeleteState(name: string) { timeout: 5000, }); await fireEvent.click(document.querySelector('.state-delete-button')); - await sleep(200); + await sleep(1200); await _testsConfirmDialog(); } @@ -438,19 +438,19 @@ export async function _testsDoubleClickState(name, options = {}) { export async function _testsClickState(name: string, options = {}, nth: number = 0) { await fireEvent.mouseOver( - screen.getAllByText(name, { selector: `.fsm-state h4` })[nth].closest('.fsm-state'), + screen.getAllByText(name, { selector: `.fsm-state h4 span` })[nth].closest('.fsm-state'), options ); await sleep(100); await fireEvent.mouseDown( - screen.getAllByText(name, { selector: `.fsm-state h4` })[nth].closest('.fsm-state'), + screen.getAllByText(name, { selector: `.fsm-state h4 span` })[nth].closest('.fsm-state'), { ...options, timeStamp: 0, } ); await fireEvent.mouseUp( - screen.getAllByText(name, { selector: `.fsm-state h4` })[nth].closest('.fsm-state'), + screen.getAllByText(name, { selector: `.fsm-state h4 span` })[nth].closest('.fsm-state'), { ...options, timeStamp: 100, @@ -459,7 +459,7 @@ export async function _testsClickState(name: string, options = {}, nth: number = } export function _testsGetStateByLabel(label: string, nth: number = 0) { - return screen.getAllByText(label, { selector: `.fsm-state h4` })[nth].closest('.fsm-state'); + return screen.getAllByText(label, { selector: `.fsm-state h4 span` })[nth].closest('.fsm-state'); } export async function _testsClickStateByLabel(canvas, label, options = {}) { @@ -528,6 +528,19 @@ export async function _testsWaitForText( ); } +export async function _testsWaitForTextsCount(text: string, selector?: string, count: number = 1) { + await waitFor( + () => { + const texts = screen.queryAllByText(text, { selector }); + + return expect(texts, `Expected text ${text} with count ${count}`).toHaveLength(count); + }, + { + timeout: 10000, + } + ); +} + export async function _testsWaitForTextToNotExist( text: string, selector?: string, diff --git a/src/stories/Views/ActionExec.stories.tsx b/src/stories/Views/ActionExec.stories.tsx index 3ad3885e4..8b0e85468 100644 --- a/src/stories/Views/ActionExec.stories.tsx +++ b/src/stories/Views/ActionExec.stories.tsx @@ -69,6 +69,33 @@ export const Action: Story = { }, }; +export const ActionWithDefaultValue: Story = { + args: { + appName: 'Discord', + actionName: 'user-info', + id: shortid.generate(), + defaultResponse: { + id: '12345', + name: 'John Doe', + age: 30, + email: 'john.doe@example.com', + isActive: true, + balance: '1000.00', + createdAt: '2023-10-01T12:00:00Z', + address: { + street: '123 Main St', + city: 'Anytown', + zipCode: '12345', + }, + preferences: { + theme: 'dark', + notifications: true, + }, + tags: ['tag1', 'tag2', 'tag3'], + }, + }, +}; + export const ActionFilled: Story = { args: { id: shortid.generate(), diff --git a/src/stories/Views/FSM.stories.tsx b/src/stories/Views/FSM.stories.tsx index 04a59f34f..dd993f47e 100644 --- a/src/stories/Views/FSM.stories.tsx +++ b/src/stories/Views/FSM.stories.tsx @@ -235,6 +235,8 @@ export const MultipleDeepVariableStates: StoryFSM = { await _testsClickState('State 2'); + await sleep(1000); + await waitFor( async () => await expect(document.querySelector('.state-next-button')).toBeDisabled(), { @@ -245,15 +247,20 @@ export const MultipleDeepVariableStates: StoryFSM = { // Fill the required option await waitFor( async () => { - await fireEvent.change(document.querySelectorAll('.system-option .reqore-textarea')[0], { - target: { - value: 'This is a test', - }, - }); + await fireEvent.change( + document.querySelectorAll('.fsm-state-detail .system-option .reqore-textarea')[0], + { + target: { + value: 'This is a test', + }, + } + ); }, { timeout: 5000 } ); + await sleep(3000); + await waitFor( async () => await expect(document.querySelector('.state-next-button')).toBeEnabled(), { @@ -273,6 +280,8 @@ export const MultipleDeepVariableStates: StoryFSM = { await _testsClickStateByLabel(canvas, 'State 2.State 3'); + await sleep(1000); + await waitFor( async () => await expect(document.querySelector('.state-next-button')).toBeDisabled(), { @@ -280,18 +289,25 @@ export const MultipleDeepVariableStates: StoryFSM = { } ); + await sleep(1000); + // Fill the required option await waitFor( async () => { - await fireEvent.change(document.querySelectorAll('.system-option .reqore-textarea')[1], { - target: { - value: 'This is a test 2', - }, - }); + await fireEvent.change( + document.querySelectorAll('.fsm-state-detail .system-option .reqore-textarea')[1], + { + target: { + value: 'This is a test 2', + }, + } + ); }, { timeout: 5000 } ); + await sleep(3000); + await waitFor( async () => await expect(document.querySelector('.state-next-button')).toBeEnabled(), { diff --git a/vite.config.ts b/vite.config.ts index 51f330ead..1e6b0bd97 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,4 +10,7 @@ export default defineConfig({ 'process.env.REACT_APP_QORUS_TOKEN': '"2f58cd78-a400-4d98-8de2-90fbaa6f805d"', 'process.env': process.env, }, + test: { + silent: true, + }, }); diff --git a/vitest.workspace.ts b/vitest.workspace.ts index acd9a02c4..f4a666e64 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -18,6 +18,7 @@ export default defineWorkspace([ name: 'chromium', provider: 'playwright', }, + testTimeout: 300000, // Make sure to adjust this pattern to match your stories files. include: ['**/*.stories.?(m)[jt]s?(x)'], setupFiles: ['.storybook/vitest.setup.ts'], diff --git a/yarn.lock b/yarn.lock index b5a222e9a..db408c7af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5765,9 +5765,9 @@ __metadata: languageName: node linkType: hard -"@qoretechnologies/reqore@npm:^0.48.18": - version: 0.48.18 - resolution: "@qoretechnologies/reqore@npm:0.48.18" +"@qoretechnologies/reqore@npm:^0.48.24": + version: 0.48.24 + resolution: "@qoretechnologies/reqore@npm:0.48.24" dependencies: "@internationalized/date": "npm:^3.5.3" "@popperjs/core": "npm:^2.11.6" @@ -5798,13 +5798,13 @@ __metadata: peerDependencies: react: ^18.2.0 react-dom: ^18.2.0 - checksum: 10c0/85c49a5abfaaa30695d76482f8628e7d5fba810f7589b4a3ccb1f543b889259a178fd2901a7004e9bdd5425191cf23cd0e63efd9cb5e6aff82b8f7230543cea0 + checksum: 10c0/6f791b452d684c169f0694ee92bc5e8d219eb9857d0aa0e13143acc3e3c7657e292f230528fe3a5c59ee7c114289085104fb876ee62f043cd488a85a431e1709 languageName: node linkType: hard -"@qoretechnologies/reqraft@npm:^0.6.9": - version: 0.6.9 - resolution: "@qoretechnologies/reqraft@npm:0.6.9" +"@qoretechnologies/reqraft@npm:^0.6.10": + version: 0.6.10 + resolution: "@qoretechnologies/reqraft@npm:0.6.10" dependencies: "@tanstack/react-query": "npm:4" classnames: "npm:^2.2.6" @@ -5825,20 +5825,22 @@ __metadata: "@qoretechnologies/reqore": ">=0.48.8-beta" react: ^18.3.1 react-dom: ^18.3.1 - checksum: 10c0/48c994ba09c34fec52fed5ffde61cba3db6b82c44ea39e3aee2f82c606faa931b318dd256fba877c2e3514bb6459b0a2e81a2c3991f17474ffb63c27df881569 + checksum: 10c0/afa5d84011cc122b67a439d89cbbbccd58a4ae8b0461ca2a5657a3ea2fa2162efc6c4913719200ecf8afc86e51cc11fb7897ac5012cd70799121df6abdfc0c5f languageName: node linkType: hard -"@qoretechnologies/ts-toolkit@npm:^0.4.8": - version: 0.4.8 - resolution: "@qoretechnologies/ts-toolkit@npm:0.4.8" +"@qoretechnologies/ts-toolkit@npm:0.4.13": + version: 0.4.13 + resolution: "@qoretechnologies/ts-toolkit@npm:0.4.13" dependencies: async: "npm:^3.2.4" cron-validator: "npm:^1.3.1" js-yaml: "npm:^4.1.0" lodash: "npm:^4.17.21" react-markdown: "npm:^8.0.4" - checksum: 10c0/4163aa6995102a354d57c9b0a2054dce96ce5384c21136c11a23a7d8f189687e6a865518675150ff11cbb7e5617418fb6971d45dc8d4e03b1488c39eb87b21b9 + peerDependencies: + "@qoretechnologies/reqore": ^0.48.0 + checksum: 10c0/7e8587b718ea685c38a3b8759481f3b2e640de0fa82a0bf20b80e8982dd890319890c4b3d5ea791ef367ee9cfc5b1447dbbc3000eca55e1fed308a70737f952b languageName: node linkType: hard @@ -25076,9 +25078,9 @@ __metadata: "@monaco-editor/react": "npm:^4.6.0" "@pmmmwh/react-refresh-webpack-plugin": "npm:^0.5.3" "@qoretechnologies/python-parser": "npm:^0.4.10" - "@qoretechnologies/reqore": "npm:^0.48.18" - "@qoretechnologies/reqraft": "npm:^0.6.9" - "@qoretechnologies/ts-toolkit": "npm:^0.4.8" + "@qoretechnologies/reqore": "npm:^0.48.24" + "@qoretechnologies/reqraft": "npm:^0.6.10" + "@qoretechnologies/ts-toolkit": "npm:0.4.13" "@sentry/browser": "npm:^7.109.0" "@storybook/addon-actions": "npm:^8.5.0-alpha.9" "@storybook/addon-essentials": "npm:^8.5.0-alpha.9"