Skip to content
This repository was archived by the owner on Jul 9, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Composer/cypress/support/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ Cypress.Commands.add('copyBot', (bot, name) => {
});

Cypress.Commands.add('addEventHandler', handler => {
cy.getByTestId('AddNewTrigger').click();
cy.get('[data-testid="ProjectTree"]').within(() => {
cy.getByText(/New Trigger ../).click();
});
cy.get(`[data-testid="triggerTypeDropDown"]`).click();
cy.getByText(handler).click();
if (handler === 'Handle a Dialog Event') {
Expand Down
225 changes: 146 additions & 79 deletions Composer/packages/client/src/components/ProjectTree/index.tsx
Original file line number Diff line number Diff line change
@@ -1,107 +1,174 @@
import React, { useMemo } from 'react';
import { ActionButton, IIconProps } from 'office-ui-fabric-react';
import formatMessage from 'format-message';
import React, { useRef, useMemo, useState } from 'react';
import { cloneDeep } from 'lodash';
import {
GroupedList,
IGroup,
IGroupHeaderProps,
IGroupRenderProps,
IGroupedList,
ActionButton,
IIconProps,
SearchBox,
} from 'office-ui-fabric-react';
import formatMessage from 'format-message';

import { DialogInfo, ITrigger } from '../../store/types';
import { getFriendlyName, createSelectedPath } from '../../utils';

import { addButton, root } from './styles';
import { groupListStyle, addButton, root, searchBox } from './styles';
import { TreeItem } from './treeItem';

interface ProjectTreeProps {
export function createGroupItem(dialog: DialogInfo, currentId: string, position: number) {
return {
key: dialog.id,
name: dialog.displayName,
level: 1,
startIndex: position,
count: dialog.triggers.length,
hasMoreData: true,
isCollapsed: dialog.id !== currentId,
data: dialog,
};
}

export function createItem(trigger: ITrigger, index: number) {
return {
...trigger,
index,
displayName: trigger.displayName || getFriendlyName({ $type: trigger.type }),
};
}

export function sortDialog(dialogs: DialogInfo[]) {
const dialogsCopy = cloneDeep(dialogs);
return dialogsCopy.sort((x, y) => {
if (x.isRoot) {
return -1;
} else if (y.isRoot) {
return 1;
} else {
return 0;
}
});
}

export function createGroup(
dialogs: DialogInfo[],
dialogId: string,
filter: string
): { items: any[]; groups: IGroup[] } {
let position = 0;
const result = dialogs
.filter(dialog => {
return dialog.displayName.toLowerCase().indexOf(filter.toLowerCase()) > -1;
})
.reduce(
(result: { items: any[]; groups: IGroup[] }, dialog) => {
result.groups.push(createGroupItem(dialog, dialogId, position));
position += dialog.triggers.length;
dialog.triggers.forEach((item, index) => {
result.items.push(createItem(item, index));
});
return result;
},
{ items: [], groups: [] }
);
return result;
}

interface IProjectTreeProps {
dialogs: DialogInfo[];
dialogId: string;
selected: string;
isOpen: boolean;
onAdd: () => void;
openNewTriggerModal: () => void;
onSelect: (id: string, selected?: string) => void;
onAddTrigger: (id: string, type: string, index: number) => void;
onDeleteDialog: (id: string) => void;
onDeleteTrigger: (id: string, index: number) => void;
openNewTriggerModal: () => void;
onDeleteDialog: (id: string) => void;
onAdd: () => void;
}

const addIconProps: IIconProps = {
iconName: 'CircleAddition',
styles: { root: { fontSize: '12px' } },
};

export const ProjectTree: React.FC<ProjectTreeProps> = props => {
const { dialogs, onAdd, dialogId, selected, onSelect, onDeleteDialog, onDeleteTrigger, openNewTriggerModal } = props;
export const ProjectTree: React.FC<IProjectTreeProps> = props => {
const groupRef: React.RefObject<IGroupedList> = useRef(null);
const { dialogs, dialogId, selected, openNewTriggerModal, onSelect, onDeleteTrigger, onDeleteDialog, onAdd } = props;
const [filter, setFilter] = useState('');

const showName = (trigger: ITrigger) => {
if (!trigger.displayName) {
return getFriendlyName({ $type: trigger.type });
}
return trigger.displayName;
};
const sortedDialogs = useMemo(() => {
return sortDialog(dialogs);
}, [dialogs]);

//put the Main to the first
const links = useMemo<DialogInfo[]>(() => {
const dialogsCopy = cloneDeep(dialogs);
return dialogsCopy.reduce((result: DialogInfo[], item) => {
if (item.isRoot) {
result = [item, ...result];
} else {
result.push(item);
const onRenderHeader = (props: IGroupHeaderProps) => {
const toggleCollapse = (): void => {
if (groupRef.current && props.group) {
groupRef.current.toggleCollapseAll(true);
props.onToggleCollapse!(props.group);
if (dialogId !== props.group.key) {
onSelect(props.group.key);
}
}
return result;
}, []);
}, [dialogs]);
};
return (
<TreeItem
link={props.group!.data}
depth={0}
isActive={!props.group!.isCollapsed}
onSelect={toggleCollapse}
onDelete={onDeleteDialog}
/>
);
};

function onRenderCell(nestingDepth?: number, item?: any): React.ReactNode {
return (
<TreeItem
link={item}
depth={nestingDepth}
isActive={createSelectedPath(item!.index) === selected}
onSelect={() => onSelect(dialogId, createSelectedPath(item.index))}
onDelete={() => onDeleteTrigger(dialogId, item.index)}
/>
);
}

const onRenderShowAll = () => {
return (
<ActionButton css={addButton(1)} iconProps={addIconProps} onClick={openNewTriggerModal}>
{formatMessage('New Trigger ..')}
</ActionButton>
);
};

const onFilter = (newValue: string): void => {
setFilter(newValue);
};

return (
<div css={root} data-testid="ProjectTree">
<ul>
{links.map(link => {
return (
<li key={link.id}>
<TreeItem
link={link}
depth={0}
isActive={dialogId === link.id}
activeNode={dialogId}
onSelect={() => {
if (dialogId !== link.id) {
onSelect(link.id);
}
}}
onDelete={onDeleteDialog}
/>
<ul>
{dialogId === link.id &&
link.triggers.map((trigger, index) => {
const current = createSelectedPath(index);
trigger.displayName = showName(trigger);
return (
<li key={trigger.id}>
<TreeItem
link={trigger}
showName={item => showName(item)}
depth={1}
isActive={current === selected}
onSelect={() => onSelect(link.id, createSelectedPath(index))}
onDelete={() => onDeleteTrigger(link.id, index)}
/>
</li>
);
})}
</ul>
{dialogId === link.id && (
<ActionButton
data-testid="AddNewTrigger"
tabIndex={1}
iconProps={addIconProps}
css={addButton(1)}
onClick={openNewTriggerModal}
>
{formatMessage('New Trigger ..')}
</ActionButton>
)}
</li>
);
})}
</ul>
<SearchBox
placeholder={formatMessage('Filter Dialogs')}
styles={searchBox}
onChange={onFilter}
iconProps={{ iconName: 'Filter' }}
/>
<GroupedList
{...createGroup(sortedDialogs, dialogId, filter)}
onRenderCell={onRenderCell}
componentRef={groupRef}
groupProps={
{
onRenderHeader: onRenderHeader,
onRenderShowAll: onRenderShowAll,
showEmptyGroups: true,
showAllProps: false,
isAllGroupsCollapsed: true,
} as Partial<IGroupRenderProps>
}
styles={groupListStyle}
/>
<ActionButton tabIndex={1} iconProps={addIconProps} css={addButton(0)} onClick={onAdd}>
{formatMessage('New Dialog ..')}
</ActionButton>
Expand Down
28 changes: 19 additions & 9 deletions Composer/packages/client/src/components/ProjectTree/styles.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
import { css } from '@emotion/core';
import { FontWeights } from '@uifabric/styling';
import { IButtonStyles, ICalloutContentStyles } from 'office-ui-fabric-react';
import { NeutralColors, FontSizes } from '@uifabric/fluent-theme';
import { IButtonStyles, ICalloutContentStyles, IGroupedListStyles } from 'office-ui-fabric-react';

export const groupListStyle: Partial<IGroupedListStyles> = {
root: {
width: '100%',
boxSizing: 'border-box',
},
};

export const searchBox = {
root: {
outline: 'none',
border: 'none',
borderBottom: '1px solid #edebe9',
height: '45px',
},
};

export const root = css`
width: 180px;
border-right: 1px solid #c4c4c4;
box-sizing: border-box;
overflow-y: auto;

ul,
li {
list-style: none;
padding: 0px;
margin: 0px;
cursor: pointer;
}
`;

export const navItem = (isActive: boolean, depth: number) => css`
Expand Down Expand Up @@ -52,6 +61,7 @@ export const itemText = (depth: number) => css`
white-space: nowrap;
overflow: hidden;
text-align: left;
cursor: pointer;
`;

export const moreButton: IButtonStyles = {
Expand Down