diff --git a/Composer/packages/client/src/components/ProjectTree/ExpandableNode.tsx b/Composer/packages/client/src/components/ProjectTree/ExpandableNode.tsx index 35785a0368..ec293483c8 100644 --- a/Composer/packages/client/src/components/ProjectTree/ExpandableNode.tsx +++ b/Composer/packages/client/src/components/ProjectTree/ExpandableNode.tsx @@ -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'; @@ -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; @@ -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) { @@ -62,11 +75,11 @@ export const ExpandableNode = ({ children, summary, detailsRef, depth = 0, onTog } return ( -
+
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */} { 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 : []} diff --git a/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx b/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx index c1fb2a85a6..bd5c6c9e8e 100644 --- a/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx +++ b/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx @@ -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'; @@ -32,7 +33,7 @@ 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 -------------------- // @@ -71,10 +72,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 -------------------- // @@ -225,7 +230,7 @@ export const ProjectTree: React.FC = ({ // 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 @@ -315,7 +320,7 @@ export const ProjectTree: React.FC = ({ @@ -323,6 +328,7 @@ export const ProjectTree: React.FC = ({ hasChildren 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] : []} @@ -376,7 +382,8 @@ export const ProjectTree: React.FC = ({ }, dialog: DialogInfo, projectId: string, - dialogLink: TreeLink + dialogLink: TreeLink, + depth: number ): React.ReactNode => { const link: TreeLink = { projectId: rootProjectId, @@ -399,6 +406,7 @@ export const ProjectTree: React.FC = ({ isActive={doesLinkMatch(link, selectedLink)} isMenuOpen={isMenuOpen} link={link} + marginLeft={depth * INDENT_PER_LEVEL} menu={ options.showDelete ? [ @@ -430,7 +438,13 @@ export const ProjectTree: React.FC = ({ 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) => { @@ -443,7 +457,8 @@ export const ProjectTree: React.FC = ({ { ...tr, index, displayName: getTriggerName(tr), warningContent, errorContent }, dialog, projectId, - dialogLink + dialogLink, + depth ); }); }; @@ -499,7 +514,7 @@ export const ProjectTree: React.FC = ({ summary={renderTriggerGroupHeader(groupDisplayName, dialog, projectId)} onToggle={(newState) => setPageElement(key, newState)} > -
{renderTriggerList(triggers, dialog, projectId, link)}
+
{renderTriggerList(triggers, dialog, projectId, link, 1)}
); }; @@ -520,7 +535,7 @@ export const ProjectTree: React.FC = ({ 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 = ( @@ -650,6 +665,7 @@ export const ProjectTree: React.FC = ({ defaultState={getPageElement(key)} depth={startDepth} detailsRef={dialog.isRoot ? addMainDialogRef : undefined} + isActive={doesLinkMatch(dialogLink, selectedLink)} summary={summaryElement} onToggle={(newState) => setPageElement(key, newState)} > diff --git a/Composer/packages/client/src/components/ProjectTree/constants.ts b/Composer/packages/client/src/components/ProjectTree/constants.ts index a447dd3f8e..307b7eb639 100644 --- a/Composer/packages/client/src/components/ProjectTree/constants.ts +++ b/Composer/packages/client/src/components/ProjectTree/constants.ts @@ -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; diff --git a/Composer/packages/client/src/components/ProjectTree/helpers.ts b/Composer/packages/client/src/components/ProjectTree/helpers.ts index 673dbf4f6d..45e1f88201 100644 --- a/Composer/packages/client/src/components/ProjectTree/helpers.ts +++ b/Composer/packages/client/src/components/ProjectTree/helpers.ts @@ -13,3 +13,13 @@ export const doesLinkMatch = (linkInTree?: Partial, selectedLink?: Par linkInTree.luFileId === selectedLink.luFileId ); }; + +export const isChildTriggerLinkSelected = (linkInTree?: Partial, selectedLink?: Partial) => { + if (linkInTree == null || selectedLink == null) return false; + return linkInTree.skillId === selectedLink.skillId && linkInTree.dialogId === selectedLink.dialogId; +}; + +export const isChildDialogLinkSelected = (linkInTree?: Partial, selectedLink?: Partial) => { + if (linkInTree == null || selectedLink == null) return false; + return linkInTree.skillId === selectedLink.skillId; +}; diff --git a/Composer/packages/client/src/components/ProjectTree/treeItem.tsx b/Composer/packages/client/src/components/ProjectTree/treeItem.tsx index 899a0086a4..9d80b3729a 100644 --- a/Composer/packages/client/src/components/ProjectTree/treeItem.tsx +++ b/Composer/packages/client/src/components/ProjectTree/treeItem.tsx @@ -3,9 +3,9 @@ /** @jsx jsx */ import { jsx, css } from '@emotion/core'; -import React, { useState } from 'react'; -import { FontWeights } from '@uifabric/styling'; +import React, { useState, useCallback } from 'react'; import { FontSizes } from '@uifabric/fluent-theme'; +import { DefaultPalette } from '@uifabric/styling'; import { OverflowSet, IOverflowSetItemProps } from 'office-ui-fabric-react/lib/OverflowSet'; import { TooltipHost, DirectionalHint } from 'office-ui-fabric-react/lib/Tooltip'; import { ContextualMenuItemType, IContextualMenuItem } from 'office-ui-fabric-react/lib/ContextualMenu'; @@ -22,7 +22,7 @@ import isEmpty from 'lodash/isEmpty'; import uniqueId from 'lodash/uniqueId'; import { TreeLink, TreeMenuItem } from './ProjectTree'; -import { SUMMARY_ARROW_SPACE } from './constants'; +import { SUMMARY_ARROW_SPACE, THREE_DOTS_ICON_WIDTH } from './constants'; // -------------------- Styles -------------------- // @@ -67,51 +67,62 @@ export const menuStyle: Partial = { export const moreButton = (isActive: boolean): IButtonStyles => { return { root: { - padding: '4px 4px 0 4px', alignSelf: 'stretch', visibility: isActive ? 'visible' : 'hidden', - height: 'auto', - width: '16px', + height: 24, + width: 24, color: '#000', }, menuIcon: { fontSize: '12px', - color: '#000', + color: NeutralColors.gray160, + }, + rootHovered: { + color: DefaultPalette.accent, + selectors: { + '.ms-Button-menuIcon': { + fontWeight: 600, + }, + }, }, }; }; -const navItem = ( - isActive: boolean, - isBroken: boolean, - padLeft: number, - isAnyMenuOpen: boolean, - menuOpenHere: boolean -) => css` +const navContainer = (isAnyMenuOpen: boolean, isActive: boolean, menuOpenHere: boolean, textWidth: number) => css` + ${isAnyMenuOpen + ? '' + : `&:hover { + background: ${isActive ? NeutralColors.gray40 : NeutralColors.gray20}; + + .dialog-more-btn { + visibility: visible; + } + .action-btn { + visibility: visible; + } + .treeItem-text { + max-width: ${textWidth}px; + } + }`}; + background: ${isActive ? NeutralColors.gray30 : menuOpenHere ? '#f2f2f2' : 'transparent'}; +`; + +const navItem = (isBroken: boolean, padLeft: number, marginLeft: number, isActive: boolean) => css` label: navItem; position: relative; height: 24px; font-size: 12px; padding-left: ${padLeft}px; - color: ${isActive ? NeutralColors.white : '#545454'}; - background: ${isActive ? '#0078d4' : menuOpenHere ? '#f2f2f2' : 'transparent'}; + margin-left: ${marginLeft}px; opacity: ${isBroken ? 0.5 : 1}; - font-weight: ${isActive ? FontWeights.semibold : FontWeights.regular}; - display: flex; flex-direction: row; align-items: center; - ${isAnyMenuOpen - ? '' - : `&:hover { - color: #545454; - background: #f2f2f2; - - .dialog-more-btn { - visibility: visible; - } - }`} + :hover { + background: ${isActive ? NeutralColors.gray40 : NeutralColors.gray20}; + } + background: ${isActive ? NeutralColors.gray30 : NeutralColors.white}; &:focus { outline: none; @@ -143,7 +154,6 @@ export const overflowSet = (isBroken: boolean) => css` width: 100%; height: 100%; box-sizing: border-box; - line-height: 24px; justify-content: space-between; display: flex; i { @@ -154,6 +164,7 @@ export const overflowSet = (isBroken: boolean) => css` const moreButtonContainer = { root: { lineHeight: '1', + display: 'flex' as 'flex', }, }; @@ -201,11 +212,13 @@ const itemName = (nameWidth: number) => css` const calloutRootStyle = css` padding: 11px; `; + // -------------------- TreeItem -------------------- // -interface ITreeItemProps { +type ITreeItemProps = { link: TreeLink; isActive?: boolean; + isChildSelected?: boolean; isSubItemActive?: boolean; onSelect?: (link: TreeLink) => void; icon?: string; @@ -213,12 +226,13 @@ interface ITreeItemProps { textWidth?: number; extraSpace?: number; padLeft?: number; + marginLeft?: number; hasChildren?: boolean; menu?: TreeMenuItem[]; menuOpenCallback?: (cb: boolean) => void; isMenuOpen?: boolean; showErrors?: boolean; -} +}; const renderTreeMenuItem = (link: TreeLink) => (item: TreeMenuItem) => { if (item.label === '') { @@ -341,105 +355,10 @@ const DiagnosticIcons = (props: { ); }; -const onRenderItem = (textWidth: number, showErrors: boolean) => (item: IOverflowSetItemProps) => { - const { diagnostics = [], projectId, skillId, onErrorClick } = item; - - let warningContent = ''; - let errorContent = ''; - - if (showErrors) { - const warnings: Diagnostic[] = diagnostics.filter( - (diag: Diagnostic) => diag.severity === DiagnosticSeverity.Warning - ); - const errors: Diagnostic[] = diagnostics.filter((diag: Diagnostic) => diag.severity === DiagnosticSeverity.Error); - - warningContent = warnings.map((diag) => diag.message).join(','); - - errorContent = errors.map((diag) => diag.message).join(','); - } - - return ( -
-
- {item.icon != null && ( - - )} - {item.displayName} - {showErrors && ( - - )} -
-
- ); -}; - -const onRenderOverflowButton = ( - isActive: boolean, - menuOpenCallback: (cb: boolean) => void, - setThisItemSelected: (sel: boolean) => void -) => { - const moreLabel = formatMessage('Actions'); - return (overflowItems: IContextualMenuItem[] | undefined) => { - if (overflowItems == null) return null; - return ( - - { - setThisItemSelected(true); - menuOpenCallback(true); - }, - onMenuDismissed: () => { - setThisItemSelected(false); - menuOpenCallback(false); - }, - }} - role="cell" - styles={moreButton(isActive)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.stopPropagation(); - } - }} - /> - - ); - }; -}; - export const TreeItem: React.FC = ({ link, isActive = false, + isChildSelected = false, icon, dialogName, onSelect, @@ -448,12 +367,12 @@ export const TreeItem: React.FC = ({ menu = [], extraSpace = 0, padLeft = 0, + marginLeft = 0, menuOpenCallback = () => {}, isMenuOpen = false, showErrors = true, }) => { const [thisItemSelected, setThisItemSelected] = useState(false); - const a11yLabel = `${dialogName ?? '$Root'}_${link.displayName}`; const overflowMenu = menu.map(renderTreeMenuItem(link)); @@ -462,42 +381,172 @@ export const TreeItem: React.FC = ({ const isBroken = !!link.botError; const spacerWidth = hasChildren ? 0 : SUMMARY_ARROW_SPACE + extraSpace; + const overflowIconWidthOnHover = overflowMenu.length > 0 ? THREE_DOTS_ICON_WIDTH : 0; + + const overflowIconWidthActiveOrChildSelected = + (isActive || isChildSelected) && overflowMenu.length > 0 ? THREE_DOTS_ICON_WIDTH : 0; + + const onRenderItem = useCallback( + (maxTextWidth: number, showErrors: boolean) => (item: IOverflowSetItemProps) => { + const { diagnostics = [], projectId, skillId, onErrorClick } = item; + + let warningContent = ''; + let errorContent = ''; + if (showErrors) { + const warnings: Diagnostic[] = diagnostics.filter( + (diag: Diagnostic) => diag.severity === DiagnosticSeverity.Warning + ); + const errors: Diagnostic[] = diagnostics.filter( + (diag: Diagnostic) => diag.severity === DiagnosticSeverity.Error + ); + + warningContent = warnings.map((diag) => diag.message).join(','); + + errorContent = errors.map((diag) => diag.message).join(','); + } + + return ( +
+
+ {item.icon != null && ( + + )} + + {item.displayName} + + {showErrors && ( + + )} +
+
+ ); + }, + [textWidth, spacerWidth, extraSpace, overflowIconWidthActiveOrChildSelected, showErrors] + ); + + const onRenderOverflowButton = useCallback( + ( + isActive: boolean, + isChildSelected: boolean, + menuOpenCallback: (cb: boolean) => void, + setThisItemSelected: (sel: boolean) => void + ) => { + const moreLabel = formatMessage('More options'); + return (overflowItems: IContextualMenuItem[] | undefined) => { + if (overflowItems == null) return null; + return ( + + { + setThisItemSelected(true); + menuOpenCallback(true); + }, + onMenuDismissed: () => { + setThisItemSelected(false); + menuOpenCallback(false); + }, + }} + role="cell" + styles={moreButton(isActive || isChildSelected)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.stopPropagation(); + } + }} + /> + + ); + }; + }, + [isActive, isChildSelected, menuOpenCallback, setThisItemSelected] + ); + return (
{ - onSelect?.(link); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - onSelect?.(link); - } - }} + css={navContainer( + isMenuOpen, + isActive, + thisItemSelected, + textWidth - spacerWidth + extraSpace - overflowIconWidthOnHover + )} > -
- +
{ + onSelect?.(link); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + onSelect?.(link); + } + }} + > +
+ +
); };