Skip to content
This repository was archived by the owner on Jul 9, 2025. It is now read-only.
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

/** @jsx jsx */
import { jsx } from '@emotion/core';
import { NeutralColors } from '@uifabric/fluent-theme';
import { createSvgIcon } from '@fluentui/react-icons';

const addDialogSvgIcon = (
<svg fill="none" height="24" viewBox="0 0 50 39" width="15" xmlns="http://www.w3.org/2000/svg">
<path
d="M38.1353 27.6268V30.0021H42.8801V34.7469H45.2553V30.0021H50.0001V27.6268H45.2553V22.8821H42.8801V27.6268H38.1353Z"
fill={NeutralColors.gray160}
/>
<path
d="M30.9862 23.8355H35.7533V35.7533H23.8355V23.8355H28.6026V19.0684H7.15066V23.8355H11.9178V35.7533H0V23.8355H4.76711V16.6849H16.6849V11.9178H11.9178V0H23.8355V11.9178H19.0684V16.6849H30.9862V23.8355ZM14.3013 2.38355V9.53421H21.452V2.38355H14.3013ZM9.53421 33.3698V26.2191H2.38355V33.3698H9.53421ZM33.3698 33.3698V26.2191H26.2191V33.3698H33.3698Z"
fill={NeutralColors.gray160}
/>
</svg>
);

const AddDialogIcon = createSvgIcon({ svg: () => addDialogSvgIcon, displayName: 'AddDialogIcon' });
export default AddDialogIcon;
Comment thread
liweitian marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

/** @jsx jsx */
import { jsx } from '@emotion/core';
import { NeutralColors } from '@uifabric/fluent-theme';
import { createSvgIcon } from '@fluentui/react-icons';

const addTriggerSvgIcon = (
<svg fill="none" height="24" viewBox="0 0 50 39" width="15" xmlns="http://www.w3.org/2000/svg">
<path
d="M23.8584 18.7502H39.9717L8.72168 50.0002H1.6416L11.0166 31.2502H0.0546875L15.6797 0.000244141H33.2334L23.8584 18.7502ZM7.40332 46.8752L32.4033 21.8752H18.8047L28.1797 3.12524H17.6084L5.1084 28.1252H16.0703L6.69531 46.8752H7.40332Z"
fill={NeutralColors.gray160}
/>
<path
d="M27.3335 39.9992V43.3358H33.9986V50.0008H37.3352V43.3358H44.0002V39.9992H37.3352V33.3341H33.9986V39.9992H27.3335Z"
fill={NeutralColors.gray160}
/>
</svg>
);

const AddTriggerIcon = createSvgIcon({ svg: () => addTriggerSvgIcon, displayName: 'AddTriggerIcon' });
export default AddTriggerIcon;
Comment thread
liweitian marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
/** @jsx jsx */
import { jsx, css } from '@emotion/core';
import { useState, MouseEvent, KeyboardEvent } from 'react';
import { NeutralColors } from '@uifabric/fluent-theme';

import { INDENT_PER_LEVEL } from './constants';

Expand All @@ -14,18 +15,22 @@ type Props = {
detailsRef?: (el: HTMLElement | null) => void;
onToggle?: (newState: boolean) => void;
defaultState?: boolean;
isActive?: boolean;
};

const summaryStyle = css`
const summaryStyle = (depth: number, isActive: boolean) => css`
label: summary;
display: flex;
padding-left: 12px;
padding-left: ${depth * INDENT_PER_LEVEL + 12}px;
padding-top: 6px;
:hover {
background: ${isActive ? NeutralColors.gray40 : NeutralColors.gray20};
}
background: ${isActive ? NeutralColors.gray30 : NeutralColors.white};
`;

const nodeStyle = (depth: number) => css`
const nodeStyle = css`
margin-top: 2px;
margin-left: ${depth * INDENT_PER_LEVEL}px;
`;

const TRIANGLE_SCALE = 0.6;
Expand All @@ -42,7 +47,15 @@ const detailsStyle = css`
}
`;

export const ExpandableNode = ({ children, summary, detailsRef, depth = 0, onToggle, defaultState = true }: Props) => {
export const ExpandableNode = ({
children,
summary,
detailsRef,
depth = 0,
onToggle,
defaultState = true,
isActive = false,
}: Props) => {
const [isExpanded, setExpanded] = useState(defaultState);

function setExpandedWithCallback(newState: boolean) {
Expand All @@ -62,11 +75,11 @@ export const ExpandableNode = ({ children, summary, detailsRef, depth = 0, onTog
}

return (
<div css={nodeStyle(depth)} data-testid="dialog">
<div css={nodeStyle} data-testid="dialog">
<details ref={detailsRef} css={detailsStyle} open={isExpanded}>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */}
<summary
css={summaryStyle}
css={summaryStyle(depth, isActive)}
data-testid={'summaryTag'}
role="button"
tabIndex={0}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ import TelemetryClient from '../../telemetry/TelemetryClient';
import { createBotSettingUrl, navigateTo } from '../../utils/navigation';

import { TreeLink, ProjectTreeOptions } from './ProjectTree';
import { doesLinkMatch } from './helpers';
import { isChildDialogLinkSelected, doesLinkMatch } from './helpers';
import { TreeItem } from './treeItem';
import AddDialogIcon from './AddDialogIcon';

const icons = {
BOT: 'CubeShape',
Expand Down Expand Up @@ -155,18 +156,28 @@ export const ProjectHeader = (props: ProjectHeaderProps) => {

const menu = generateMenuItems();

const onRenderActionIcon = (props: { className?: string; style?: React.CSSProperties }) => {
const { className, style } = props;
return <AddDialogIcon className={className} style={style} />;
};

const shouldRenderAddDialogIcon = !isRemote && !!options.showMenu;

return (
<span key={name} css={headerCSS('bot-header')} data-testid={`BotHeader-${name}`} role="grid">
<TreeItem
actionIconText={shouldRenderAddDialogIcon ? 'Add Dialog' : ''}
hasChildren={!isRemote}
icon={isRemote ? icons.EXTERNAL_SKILL : icons.BOT}
isActive={doesLinkMatch(link, selectedLink)}
isChildSelected={isChildDialogLinkSelected(link, selectedLink)}
isMenuOpen={isMenuOpen}
link={link}
menu={options.showMenu ? menu : []}
menuOpenCallback={setMenuOpen}
showErrors={options.showErrors}
textWidth={textWidth}
onRenderActionIcon={shouldRenderAddDialogIcon ? onRenderActionIcon : undefined}
onSelect={options.showCommonLinks ? undefined : handleOnSelect}
/>
</span>
Expand Down
56 changes: 43 additions & 13 deletions Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

/** @jsx jsx */
import React, { useCallback, useState, useRef } from 'react';
import { NeutralColors } from '@uifabric/fluent-theme';
import { jsx, css } from '@emotion/core';
import { SearchBox } from 'office-ui-fabric-react/lib/SearchBox';
import { FocusZone, FocusZoneDirection } from 'office-ui-fabric-react/lib/FocusZone';
Expand All @@ -28,11 +29,12 @@ import { useFeatureFlag } from '../../utils/hooks';
import { LoadingSpinner } from '../LoadingSpinner';
import TelemetryClient from '../../telemetry/TelemetryClient';

import AddTriggerIcon from './AddTriggerIcon';
import { TreeItem } from './treeItem';
import { ExpandableNode } from './ExpandableNode';
import { INDENT_PER_LEVEL } from './constants';
import { ProjectTreeHeader, ProjectTreeHeaderMenuItem } from './ProjectTreeHeader';
import { doesLinkMatch } from './helpers';
import { isChildTriggerLinkSelected, doesLinkMatch } from './helpers';
import { ProjectHeader } from './ProjectHeader';

// -------------------- Styles -------------------- //
Expand Down Expand Up @@ -71,10 +73,14 @@ const tree = css`
label: tree;
`;

const headerCSS = (label: string) => css`
const headerCSS = (label: string, isActive?: boolean) => css`
margin-top: -6px;
width: 100%;
label: ${label};
:hover {
background: ${isActive ? NeutralColors.gray40 : NeutralColors.gray20};
}
background: ${isActive ? NeutralColors.gray30 : NeutralColors.white};
`;

// -------------------- Helper functions -------------------- //
Expand Down Expand Up @@ -225,7 +231,7 @@ export const ProjectTree: React.FC<Props> = ({
// TODO Refactor to make sure tree is not generated until a new trigger/dialog is added. #5462
const createSubtree = useCallback(() => {
return projectCollection.map(createBotSubtree);
}, [projectCollection, selectedLink]);
}, [projectCollection, selectedLink, leftSplitWidth]);

if (rootProjectId == null) {
// this should only happen before a project is loaded in, so it won't last very long
Expand All @@ -250,7 +256,13 @@ export const ProjectTree: React.FC<Props> = ({
onSelect?.(link);
};

const renderDialogHeader = (skillId: string, dialog: DialogInfo, depth: number, isPvaSchema: boolean) => {
const renderDialogHeader = (
skillId: string,
dialog: DialogInfo,
depth: number,
isPvaSchema: boolean,
shouldShowActionButton: boolean
) => {
const diagnostics: Diagnostic[] = notificationMap[rootProjectId][dialog.id];
const dialogLink: TreeLink = {
dialogId: dialog.id,
Expand Down Expand Up @@ -285,6 +297,11 @@ export const ProjectTree: React.FC<Props> = ({
},
};

const onRenderActionIcon = (props: { className?: string; style?: React.CSSProperties }) => {
const { className, style } = props;
return <AddTriggerIcon className={className} style={style} />;
};

if (!isPvaSchema) {
menu.splice(1, 0, QnAMenuItem);
}
Expand Down Expand Up @@ -315,21 +332,24 @@ export const ProjectTree: React.FC<Props> = ({
<span
key={dialog.id}
ref={dialog.isRoot ? addMainDialogRef : null}
css={headerCSS('dialog-header')}
css={headerCSS('dialog-header', doesLinkMatch(dialogLink, selectedLink))}
data-testid={`DialogHeader-${dialog.displayName}`}
role="grid"
>
<TreeItem
hasChildren
actionIconText={shouldShowActionButton ? 'Add Trigger' : ''}
icon={isFormDialog ? icons.FORM_DIALOG : icons.DIALOG}
isActive={doesLinkMatch(dialogLink, selectedLink)}
isChildSelected={isChildTriggerLinkSelected(dialogLink, selectedLink)}
isMenuOpen={isMenuOpen}
link={dialogLink}
menu={options.showMenu ? menu : options.showQnAMenu ? [QnAMenuItem] : []}
menuOpenCallback={setMenuOpen}
padLeft={depth * LEVEL_PADDING}
showErrors={false}
textWidth={leftSplitWidth - TREE_PADDING}
onRenderActionIcon={shouldShowActionButton ? onRenderActionIcon : undefined}
onSelect={handleOnSelect}
/>
</span>
Expand Down Expand Up @@ -376,7 +396,8 @@ export const ProjectTree: React.FC<Props> = ({
},
dialog: DialogInfo,
projectId: string,
dialogLink: TreeLink
dialogLink: TreeLink,
depth: number
): React.ReactNode => {
const link: TreeLink = {
projectId: rootProjectId,
Expand All @@ -399,6 +420,7 @@ export const ProjectTree: React.FC<Props> = ({
isActive={doesLinkMatch(link, selectedLink)}
isMenuOpen={isMenuOpen}
link={link}
marginLeft={depth * INDENT_PER_LEVEL}
menu={
options.showDelete
? [
Expand Down Expand Up @@ -430,7 +452,13 @@ export const ProjectTree: React.FC<Props> = ({
return scope.toLowerCase().includes(filter.toLowerCase());
};

const renderTriggerList = (triggers: ITrigger[], dialog: DialogInfo, projectId: string, dialogLink: TreeLink) => {
const renderTriggerList = (
triggers: ITrigger[],
dialog: DialogInfo,
projectId: string,
dialogLink: TreeLink,
depth: number
) => {
return triggers
.filter((tr) => filterMatch(dialog.displayName) || filterMatch(getTriggerName(tr)))
.map((tr) => {
Expand All @@ -443,7 +471,8 @@ export const ProjectTree: React.FC<Props> = ({
{ ...tr, index, displayName: getTriggerName(tr), warningContent, errorContent },
dialog,
projectId,
dialogLink
dialogLink,
depth
);
});
};
Expand Down Expand Up @@ -499,7 +528,7 @@ export const ProjectTree: React.FC<Props> = ({
summary={renderTriggerGroupHeader(groupDisplayName, dialog, projectId)}
onToggle={(newState) => setPageElement(key, newState)}
>
<div>{renderTriggerList(triggers, dialog, projectId, link)}</div>
<div>{renderTriggerList(triggers, dialog, projectId, link, 1)}</div>
</ExpandableNode>
);
};
Expand All @@ -520,7 +549,7 @@ export const ProjectTree: React.FC<Props> = ({
const renderDialogTriggers = (dialog: DialogInfo, projectId: string, startDepth: number, dialogLink: TreeLink) => {
return dialogIsFormDialog(dialog)
? renderDialogTriggersByProperty(dialog, projectId, startDepth + 1)
: renderTriggerList(dialog.triggers, dialog, projectId, dialogLink);
: renderTriggerList(dialog.triggers, dialog, projectId, dialogLink, 1);
};

const renderLgImport = (
Expand Down Expand Up @@ -628,7 +657,7 @@ export const ProjectTree: React.FC<Props> = ({
return [
...commonLink,
...filteredDialogs.map((dialog: DialogInfo) => {
const { summaryElement, dialogLink } = renderDialogHeader(projectId, dialog, 0, bot.isPvaSchema);
const { summaryElement, dialogLink } = renderDialogHeader(projectId, dialog, 0, bot.isPvaSchema, true);
const key = 'dialog-' + dialog.id;
let lgImports, luImports;
if (options.showLgImports) {
Expand All @@ -650,6 +679,7 @@ export const ProjectTree: React.FC<Props> = ({
defaultState={getPageElement(key)}
depth={startDepth}
detailsRef={dialog.isRoot ? addMainDialogRef : undefined}
isActive={doesLinkMatch(dialogLink, selectedLink)}
summary={summaryElement}
onToggle={(newState) => setPageElement(key, newState)}
>
Expand All @@ -661,15 +691,15 @@ export const ProjectTree: React.FC<Props> = ({
</ExpandableNode>
);
} else {
return renderDialogHeader(projectId, dialog, 1, bot.isPvaSchema).summaryElement;
return renderDialogHeader(projectId, dialog, 1, bot.isPvaSchema, false).summaryElement;
}
}),
];
} else {
return [
...commonLink,
...filteredDialogs.map(
(dialog: DialogInfo) => renderDialogHeader(projectId, dialog, 1, bot.isPvaSchema).summaryElement
(dialog: DialogInfo) => renderDialogHeader(projectId, dialog, 1, bot.isPvaSchema, false).summaryElement
),
];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@

export const SUMMARY_ARROW_SPACE = 28; // the rough pixel size of the dropdown arrow to the left of a Details/Summary element
export const INDENT_PER_LEVEL = 16;
export const ACTION_ICON_WIDTH = 28;
export const THREE_DOTS_ICON_WIDTH = 28;
10 changes: 10 additions & 0 deletions Composer/packages/client/src/components/ProjectTree/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,13 @@ export const doesLinkMatch = (linkInTree?: Partial<TreeLink>, selectedLink?: Par
linkInTree.luFileId === selectedLink.luFileId
);
};

export const isChildTriggerLinkSelected = (linkInTree?: Partial<TreeLink>, selectedLink?: Partial<TreeLink>) => {
if (linkInTree == null || selectedLink == null) return false;
return linkInTree.skillId === selectedLink.skillId && linkInTree.dialogId === selectedLink.dialogId;
};

export const isChildDialogLinkSelected = (linkInTree?: Partial<TreeLink>, selectedLink?: Partial<TreeLink>) => {
if (linkInTree == null || selectedLink == null) return false;
return linkInTree.skillId === selectedLink.skillId;
};
Loading