= ({ data }) => {
{t('serviceFlow.popup.redirectToCustomerSupport')}
)}
{data.stepType === StepType.JumpToService && data.jumpToService?.serviceName && (
- → {data.jumpToService.serviceName}
+
+ → {jumpToServiceName}
+
)}
{data.stepType === StepType.Rule && (
diff --git a/GUI/src/components/FlowBuilder/FlowBuilder.tsx b/GUI/src/components/FlowBuilder/FlowBuilder.tsx
index 7ff089974..634a0c930 100644
--- a/GUI/src/components/FlowBuilder/FlowBuilder.tsx
+++ b/GUI/src/components/FlowBuilder/FlowBuilder.tsx
@@ -11,6 +11,7 @@ import McqBranchSelectModal from 'components/Flow/McqBranchSelectModal';
import nodeTypes from 'components/Flow/NodeTypes';
import useLayout from 'hooks/flow/useLayout';
import useMcqConnect from 'hooks/flow/useMcqConnect';
+import { useNodeHighlight } from 'hooks/flow/useNodeHighlight';
import { useOnNodesDelete } from 'hooks/flow/useOnNodeDelete';
import { ChangeEventHandler, FC, useCallback, useEffect, useState } from 'react';
import '@xyflow/react/dist/style.css';
@@ -34,7 +35,7 @@ type FlowBuilderProps = {
const FlowBuilder: FC = ({ nodes, edges }) => {
useLayout();
- const { getNodes, getEdges, setNodes, setEdges, getNode } = useReactFlow();
+ const { getNodes, getEdges, setNodes, setEdges } = useReactFlow();
const setReactFlowInstance = useNewServiceStore((state) => state.setReactFlowInstance);
const [colorMode, setColorMode] = useState('light');
const { t } = useTranslation();
@@ -63,6 +64,7 @@ const FlowBuilder: FC = ({ nodes, edges }) => {
const { runLayout } = useLayout();
const { pendingConnection, handleConnect, isValidConnection, confirmBranch, cancelBranchSelection } = useMcqConnect();
+ const { displayNodes, displayEdges, handleNodeClick, handlePaneClick } = useNodeHighlight(nodes, edges);
const onConnect = useCallback(
(connection: { source?: string | null; target?: string | null; sourceHandle?: string | null }) => {
@@ -98,16 +100,9 @@ const FlowBuilder: FC = ({ nodes, edges }) => {
);
const onBeforeDelete = useCallback(
- ({ nodes: nodesToDelete, edges: edgesToDelete }: { nodes: Node[]; edges: Edge[] }) => {
+ ({ nodes: nodesToDelete }: { nodes: Node[]; edges: Edge[] }) => {
setDeletedNodes(null);
try {
- if (edgesToDelete.length > 0 && nodesToDelete.length === 0) {
- const shouldPreventDelete = getNode(edgesToDelete[0].source)?.data.stepType === StepType.MultiChoiceQuestion;
- if (shouldPreventDelete) {
- return Promise.resolve(false);
- }
- }
-
if (
nodesToDelete.length === 0 ||
![StepType.MultiChoiceQuestion, StepType.Condition, StepType.Input].includes(
@@ -127,7 +122,7 @@ const FlowBuilder: FC = ({ nodes, edges }) => {
return Promise.resolve(true);
}
},
- [getNode, hasConnectedNodes, setDeletedNodes, setIsDeleteConnectionsModalVisible],
+ [hasConnectedNodes, setDeletedNodes, setIsDeleteConnectionsModalVisible],
);
const handleToggleLasso = useCallback(() => {
@@ -141,8 +136,8 @@ const FlowBuilder: FC = ({ nodes, edges }) => {
return (
<>
= ({ nodes, edges }) => {
await fitView({ duration: 200, padding: 5 });
}}
nodesDraggable={false}
+ onNodeClick={handleNodeClick}
+ onPaneClick={handlePaneClick}
onSelectionChange={onSelectionChange}
onConnect={onConnect}
onEdgesDelete={(edges) => {
@@ -250,6 +247,11 @@ const FlowBuilder: FC = ({ nodes, edges }) => {
emptyBranches={pendingConnection.emptyBranches}
onSelect={confirmBranch}
onClose={cancelBranchSelection}
+ description={
+ pendingConnection.nodeType === StepType.Condition
+ ? t('serviceFlow.condition.emptyPathsMessage', { count: pendingConnection.emptyBranches.length })
+ : undefined
+ }
/>
)}
>
diff --git a/GUI/src/components/FlowElementsPopup/ConditionBuilderContent.tsx b/GUI/src/components/FlowElementsPopup/ConditionBuilderContent.tsx
index 59a2fffe8..7229640ef 100644
--- a/GUI/src/components/FlowElementsPopup/ConditionBuilderContent.tsx
+++ b/GUI/src/components/FlowElementsPopup/ConditionBuilderContent.tsx
@@ -15,7 +15,7 @@ const ConditionBuilderContent: React.FC = () => {
const isYesNoQuestion = useServiceStore((state) => state.isYesNoQuestion);
const rules = useServiceStore((state) => state.rules);
const node = useServiceStore((state) => state.selectedNode);
- const seedGroup = node?.data?.rules || (Array.isArray(rules) && rules.length > 0 ? rules : undefined);
+ const seedGroup = node?.data?.rules || (rules && rules.children.length > 0 ? rules : undefined);
return (
);
};
diff --git a/GUI/src/components/FlowElementsPopup/RuleBuilder/types.tsx b/GUI/src/components/FlowElementsPopup/RuleBuilder/types.tsx
index 1c517e7fc..90f41bba2 100644
--- a/GUI/src/components/FlowElementsPopup/RuleBuilder/types.tsx
+++ b/GUI/src/components/FlowElementsPopup/RuleBuilder/types.tsx
@@ -8,7 +8,14 @@ export interface RuleGroupBuilderProps {
seedGroup?: Group | GroupOrRule[];
}
-export interface Rule {
+export type GroupType = 'and' | 'or';
+
+interface WithConnector {
+ readonly connector?: GroupType;
+ readonly connectorNot?: boolean;
+}
+
+export interface Rule extends WithConnector {
id: string;
field: string;
operator: string;
@@ -19,20 +26,18 @@ export interface Rule {
isValueManual?: boolean;
}
-export type GroupType = 'and' | 'or';
-
-export interface Group {
+export interface Group extends WithConnector {
id: string;
children: GroupOrRule[];
- type: GroupType;
not: boolean;
+ type?: GroupType;
}
export type GroupOrRule = Group | Rule;
export const isInstanceOfRule = (x: GroupOrRule): boolean => 'operator' in x;
-export const getInitialRule = () => {
+export const getInitialRule = (connector: GroupType = 'and') => {
return {
id: uuidv4(),
field: '',
@@ -42,14 +47,24 @@ export const getInitialRule = () => {
valueDragElement: undefined,
isFieldManual: false,
isValueManual: false,
+ connector,
+ connectorNot: false,
};
};
-export const getInitialGroup = () => {
+export const getInitialGroup = (connector: GroupType = 'and') => {
return {
id: uuidv4(),
children: [getInitialRule()],
- type: 'and',
not: false,
+ connector,
+ connectorNot: false,
} as Group;
};
+
+export const withMigratedConnectors = (children: GroupOrRule[], legacyType?: GroupType): GroupOrRule[] =>
+ children.map((child, index) => ({
+ ...child,
+ connector: child.connector ?? (index === 0 ? undefined : (legacyType ?? 'and')),
+ connectorNot: child.connectorNot ?? false,
+ }));
diff --git a/GUI/src/components/FlowElementsPopup/RuleBuilder/useRuleBuilder.tsx b/GUI/src/components/FlowElementsPopup/RuleBuilder/useRuleBuilder.tsx
index 6e9c0276e..50460ea8f 100644
--- a/GUI/src/components/FlowElementsPopup/RuleBuilder/useRuleBuilder.tsx
+++ b/GUI/src/components/FlowElementsPopup/RuleBuilder/useRuleBuilder.tsx
@@ -1,6 +1,8 @@
import { useEffect, useRef, useState } from 'react';
-import { getInitialGroup, getInitialRule, Group, GroupOrRule, GroupType, Rule } from './types';
+import { getInitialGroup, getInitialRule, Group, GroupOrRule, GroupType, Rule, withMigratedConnectors } from './types';
+
+type SeedGroup = Group | GroupOrRule[] | undefined;
interface UseRuleBuilderProps {
group?: Group;
@@ -10,15 +12,21 @@ interface UseRuleBuilderProps {
}
export const useRuleBuilder = (config: UseRuleBuilderProps) => {
- const getSeedGroupChildren = (seedGroup: Group | GroupOrRule[] | undefined): GroupOrRule[] => {
+ const getSeedGroupChildren = (seedGroup: SeedGroup): GroupOrRule[] => {
if (!seedGroup) return [];
if (Array.isArray(seedGroup)) return seedGroup;
return seedGroup.children;
};
- const elementsInitialValue = config.root ? getSeedGroupChildren(config.seedGroup) : config.group!.children;
+ const getSeedGroupLegacyType = (seedGroup: SeedGroup): GroupType | undefined =>
+ seedGroup && !Array.isArray(seedGroup) ? seedGroup.type : undefined;
+
+ const elementsInitialValue = withMigratedConnectors(
+ config.root ? getSeedGroupChildren(config.seedGroup) : config.group!.children,
+ config.root ? getSeedGroupLegacyType(config.seedGroup) : config.group!.type,
+ );
- const isSeedGroupValid = (seedGroup: Group | GroupOrRule[] | undefined): boolean => {
+ const isSeedGroupValid = (seedGroup: SeedGroup): boolean => {
if (!seedGroup) return false;
if (Array.isArray(seedGroup)) return seedGroup.length > 0;
return seedGroup.children?.length > 0;
@@ -41,6 +49,16 @@ export const useRuleBuilder = (config: UseRuleBuilderProps) => {
const onChangeRef = useRef(config.onChange);
onChangeRef.current = config.onChange;
+ useEffect(() => {
+ if (config.root || !config.group) return;
+ setGroupInfo((prev) => ({
+ ...prev,
+ connector: config.group!.connector,
+ connectorNot: config.group!.connectorNot,
+ }));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [config.group?.connector, config.group?.connectorNot]);
+
useEffect(() => {
onChangeRef.current({
...groupInfo,
@@ -67,12 +85,13 @@ export const useRuleBuilder = (config: UseRuleBuilderProps) => {
});
};
- const changeType = (type: GroupType) => () => {
- setGroupInfo({ ...groupInfo, type });
+ const changeConnector = (id: string, connector: GroupType) => {
+ setElements(elements.map((x) => (x.id === id ? { ...x, connector } : x)));
};
- const changeToAnd = changeType('and');
- const changeToOr = changeType('or');
+ const toggleConnectorNot = (id: string) => {
+ setElements(elements.map((x) => (x.id === id ? { ...x, connectorNot: !x.connectorNot } : x)));
+ };
const changeRule = (rule: Rule) => setElementById(rule.id, rule);
@@ -90,8 +109,8 @@ export const useRuleBuilder = (config: UseRuleBuilderProps) => {
addGroup,
remove,
toggleNot,
- changeToAnd,
- changeToOr,
+ changeConnector,
+ toggleConnectorNot,
changeRule,
onSubGroupChange,
};
diff --git a/GUI/src/components/FlowElementsPopup/TextfieldContent.tsx b/GUI/src/components/FlowElementsPopup/TextfieldContent.tsx
index 39fca1517..d9114028a 100644
--- a/GUI/src/components/FlowElementsPopup/TextfieldContent.tsx
+++ b/GUI/src/components/FlowElementsPopup/TextfieldContent.tsx
@@ -1,7 +1,7 @@
import { Node } from '@xyflow/react';
import { t } from 'i18next';
import { CSSProperties, FC, useRef, useState } from 'react';
-import ReactQuill from 'react-quill';
+import ReactQuill from 'react-quill-new';
import { NodeDataProps } from 'types/service-flow';
import { FormRichText, Track } from '..';
diff --git a/GUI/src/components/FlowElementsPopup/index.tsx b/GUI/src/components/FlowElementsPopup/index.tsx
index 16fbb38e2..724ea6c76 100644
--- a/GUI/src/components/FlowElementsPopup/index.tsx
+++ b/GUI/src/components/FlowElementsPopup/index.tsx
@@ -138,9 +138,9 @@ const FlowElementsPopup: React.FC = () => {
case StepType.Input:
case StepType.Condition:
if (node.data?.rules && Array.isArray(node.data.rules.children)) {
- useServiceStore.getState().changeRulesNode(node.data.rules.children);
+ useServiceStore.getState().changeRulesNode(node.data.rules);
} else {
- useServiceStore.getState().changeRulesNode([]);
+ useServiceStore.getState().changeRulesNode(undefined);
}
break;
@@ -309,21 +309,19 @@ const FlowElementsPopup: React.FC = () => {
};
const prepareRulesForSaving = (updatedNode: Node) => {
- const rulesArray = Array.isArray(rules) ? rules : [];
- for (const item of rulesArray) {
- const processRuleField = (obj: GroupOrRule) => {
- if ('field' in obj) obj.field = removeTrailingUnderscores(obj.field);
- else {
- for (const child of obj.children) {
- processRuleField(child);
- }
+ const rootGroup = rules ?? getInitialGroup();
+ const processRuleField = (obj: GroupOrRule) => {
+ if ('field' in obj) obj.field = removeTrailingUnderscores(obj.field);
+ else {
+ for (const child of obj.children) {
+ processRuleField(child);
}
- };
- processRuleField(item);
+ }
+ };
+ for (const child of rootGroup.children) {
+ processRuleField(child);
}
- updatedNode.data.rules = node.data.rules
- ? { ...node.data.rules, children: rulesArray }
- : { ...getInitialGroup(), children: rulesArray };
+ updatedNode.data.rules = rootGroup;
};
const saveMultiChoicePopup = (originalNode: Node, updatedNode: Node) => {
@@ -424,6 +422,7 @@ const FlowElementsPopup: React.FC = () => {
return (
= ({ defaultValue, onChange, quill })
[{ list: 'ordered' }, { list: 'bullet' }],
['link'],
],
+ uploader: false,
};
useEffect(() => {
@@ -50,8 +51,50 @@ const FormRichText: FC = ({ defaultValue, onChange, quill })
};
editorElement.addEventListener('keydown', handleTabKey, true);
+
+ const handleDrop = (e: DragEvent) => {
+ const text = e.dataTransfer?.getData('text/plain');
+ if (!text) return;
+
+ e.preventDefault();
+
+ const docWithCaretPosition = document as Document & {
+ caretPositionFromPoint?(x: number, y: number): { offsetNode: Node; offset: number } | null;
+ };
+
+ let native: Range | null = null;
+ if (typeof docWithCaretPosition.caretPositionFromPoint === 'function') {
+ const position = docWithCaretPosition.caretPositionFromPoint(e.clientX, e.clientY);
+ if (position) {
+ native = document.createRange();
+ native.setStart(position.offsetNode, position.offset);
+ native.setEnd(position.offsetNode, position.offset);
+ }
+ } else if (
+ // Safari has no caretPositionFromPoint; fall back to the deprecated but still
+ // supported caretRangeFromPoint.
+ // eslint-disable-next-line sonarjs/deprecation
+ document.caretRangeFromPoint
+ ) {
+ // eslint-disable-next-line sonarjs/deprecation
+ native = document.caretRangeFromPoint(e.clientX, e.clientY);
+ }
+
+ const normalized = native && quillInstance.selection.normalizeNative(native);
+ const range = normalized
+ ? quillInstance.selection.normalizedToRange(normalized)
+ : quillInstance.getSelection(true);
+ if (!range) return;
+
+ quillInstance.insertText(range.index, text, 'user');
+ quillInstance.setSelection(range.index + text.length, 0, 'user');
+ quillInstance.focus();
+ };
+
+ editorElement.addEventListener('drop', handleDrop);
return () => {
editorElement.removeEventListener('keydown', handleTabKey, true);
+ editorElement.removeEventListener('drop', handleDrop);
quillInstance.off('text-change', normalizeLinks);
};
}, [quill]);
diff --git a/GUI/src/components/NewServiceHeader/NewServiceHeader.scss b/GUI/src/components/NewServiceHeader/NewServiceHeader.scss
index 39668306a..1dc278079 100644
--- a/GUI/src/components/NewServiceHeader/NewServiceHeader.scss
+++ b/GUI/src/components/NewServiceHeader/NewServiceHeader.scss
@@ -33,6 +33,85 @@
box-shadow: 0 4px 4px 0 #00000040;
}
+.naming--trigger {
+ cursor: pointer;
+ font-family: inherit;
+ color: inherit;
+ position: relative;
+
+ &:hover,
+ &:focus {
+ background-color: #e4e4e8;
+ }
+}
+
+.naming__label {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.naming__chevron {
+ position: absolute;
+ right: 20px;
+ top: 50%;
+ transform: translateY(-50%);
+ flex-shrink: 0;
+}
+
+.service-switch-dropdown {
+ background-color: get-color(white);
+ border-radius: 10px;
+ box-shadow: 0px 10px 38px -10px rgba(22, 23, 24, 0.35);
+ z-index: 1000;
+ width: var(--radix-dropdown-menu-trigger-width);
+ max-height: 400px;
+ overflow-y: auto;
+ padding: 6px;
+
+ &__item {
+ padding: 10px 12px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 16px;
+ outline: none;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &:hover,
+ &[data-highlighted] {
+ background-color: get-color(black-coral-0);
+ }
+ }
+
+ &__empty {
+ padding: 10px 12px;
+ color: get-color(black-coral-6);
+ font-size: 14px;
+ }
+}
+
+[data-theme='dark'] .naming--trigger {
+ &:hover,
+ &:focus {
+ background-color: var(--dark-bg-extra-light);
+ }
+}
+
+[data-theme='dark'] .service-switch-dropdown {
+ background-color: var(--dark-bg-secondary);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ color: var(--dark-text-primary);
+
+ &__item {
+ &:hover,
+ &[data-highlighted] {
+ background-color: var(--dark-bg-extra-light);
+ }
+ }
+}
+
.icon_button {
width: fit-content;
padding: 8px 25px;
diff --git a/GUI/src/components/NewServiceHeader/index.test.tsx b/GUI/src/components/NewServiceHeader/index.test.tsx
new file mode 100644
index 000000000..ab3c55af1
--- /dev/null
+++ b/GUI/src/components/NewServiceHeader/index.test.tsx
@@ -0,0 +1,82 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import api from 'services/api-dev';
+import useServiceStore from 'store/new-services.store';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import NewServiceHeader from './index';
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+ initReactI18next: {},
+}));
+
+vi.mock('i18n', () => ({
+ default: { t: (key: string) => key },
+}));
+
+vi.mock('services/api-dev', () => ({
+ default: { get: vi.fn(), post: vi.fn() },
+}));
+
+const mockNavigate = vi.fn();
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom');
+ return { ...actual, useNavigate: () => mockNavigate };
+});
+
+const renderHeader = () =>
+ render(
+
+
+
+ }
+ />
+
+ ,
+ );
+
+describe('NewServiceHeader service switcher dropdown', () => {
+ beforeEach(() => {
+ mockNavigate.mockClear();
+ useServiceStore.setState({ hasUnsavedChanges: false, nextLocation: null, name: 'Current Service' });
+ vi.mocked(api.get).mockResolvedValue({
+ data: [
+ { serviceId: 'current-service-id', name: 'Current Service' },
+ { serviceId: 'other-service-id', name: 'Other Service' },
+ ],
+ });
+ });
+
+ it('lists other services and navigates immediately with no unsaved changes', async () => {
+ const user = userEvent.setup();
+ renderHeader();
+
+ await user.click(screen.getByRole('button', { name: 'Current_Service' }));
+
+ const option = await screen.findByText('Other Service');
+ // The current service should not be offered as a navigation target
+ expect(screen.queryByText('Current Service')).not.toBeInTheDocument();
+
+ await user.click(option);
+
+ await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/edit/other-service-id'));
+ });
+
+ it('defers navigation through the unsaved-changes dialog when there are unsaved changes', async () => {
+ useServiceStore.setState({ hasUnsavedChanges: true });
+ const user = userEvent.setup();
+ renderHeader();
+
+ await user.click(screen.getByRole('button', { name: 'Current_Service' }));
+ const option = await screen.findByText('Other Service');
+ await user.click(option);
+
+ await waitFor(() => expect(useServiceStore.getState().nextLocation).toBe('/edit/other-service-id'));
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+});
diff --git a/GUI/src/components/NewServiceHeader/index.tsx b/GUI/src/components/NewServiceHeader/index.tsx
index 34667dfa9..3907abfa0 100644
--- a/GUI/src/components/NewServiceHeader/index.tsx
+++ b/GUI/src/components/NewServiceHeader/index.tsx
@@ -1,14 +1,17 @@
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { t } from 'i18next';
-import React, { FC, useState } from 'react';
+import React, { FC, useEffect, useState } from 'react';
+import { CgChevronDown, CgChevronUp } from 'react-icons/cg';
import { FaGear } from 'react-icons/fa6';
import { TiArrowLeft } from 'react-icons/ti';
import { useNavigate, useParams } from 'react-router-dom';
import '@buerokratt-ria/header/src/Header.scss';
-import { deleteService } from 'resources/api-constants';
+import { deleteService, getNavigableServicesList } from 'resources/api-constants';
import { ROUTES } from 'resources/routes-constants';
import useServiceStore from 'store/new-services.store';
import useToastStore from 'store/toasts.store';
import { ServiceState } from 'types';
+import { navigateToService } from 'utils/service-navigation-utils';
import { removeTrailingUnderscores } from 'utils/string-util';
import { Button, Modal, Track } from '..';
@@ -20,6 +23,8 @@ import SettingsModal from '../ServiceConfigurationForm';
import './NewServiceHeader.scss';
+type ServiceOption = { readonly name: string; readonly serviceId: string };
+
type NewServiceHeaderProps = {
activeStep: number;
backOnClick: () => void;
@@ -44,8 +49,24 @@ const NewServiceHeader: FC = ({ backOnClick, continueOnCl
const [isDeleting, setIsDeleting] = useState(false);
const [isContinuing, setIsContinuing] = useState(false);
const [isDeleteServiceModalVisible, setIsDeleteServiceModalVisible] = useState(false);
+ const [isServiceDropdownOpen, setIsServiceDropdownOpen] = useState(false);
+ const [services, setServices] = useState([]);
const { id } = useParams();
+ useEffect(() => {
+ if (!isServiceDropdownOpen) return;
+
+ api
+ .get(getNavigableServicesList())
+ .then((res) => {
+ const data: { serviceId: string; name: string }[] = Array.isArray(res.data) ? res.data : [];
+ setServices(data.map((s) => ({ name: s.name, serviceId: s.serviceId })));
+ })
+ .catch(console.error);
+ }, [isServiceDropdownOpen]);
+
+ const otherServices = services.filter((service) => service.serviceId !== id);
+
return (
<>
@@ -67,7 +88,37 @@ const NewServiceHeader: FC = ({ backOnClick, continueOnCl
{t('serviceFlow.serviceConfiguration')}
} size="medium" />