Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: remodel ProjectTree for greater clarity #4714

Merged
merged 12 commits into from
Nov 9, 2020
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@
import { jsx, css } from '@emotion/core';
import { useState, MouseEvent, KeyboardEvent } from 'react';

import { INDENT_PER_LEVEL } from './constants';

type Props = {
children: React.ReactNode;
summary: React.ReactNode;
depth?: number;
detailsRef?: (el: HTMLElement | null) => void;
onToggle?: (newState: boolean) => void;
defaultState?: boolean;
};

const summaryStyle = css`
Expand All @@ -20,33 +24,40 @@ const summaryStyle = css`
`;

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

const TRIANGLE_SCALE = 0.6;

const detailsStyle = css`
&:not([open]) > summary::-webkit-details-marker {
transform: scaleX(${TRIANGLE_SCALE});
min-width: 10px;
}

&[open] > summary::-webkit-details-marker {
transform: scaleY(${TRIANGLE_SCALE});
min-width: 10px;
}
`;

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

function setExpandedWithCallback(newState: boolean) {
setExpanded(newState);
onToggle?.(newState);
}

function handleClick(ev: MouseEvent) {
if ((ev.target as Element)?.tagName.toLowerCase() === 'summary') {
setExpanded(!isExpanded);
setExpandedWithCallback(!isExpanded);
}
ev.preventDefault();
}

function handleKey(ev: KeyboardEvent) {
if (ev.key === 'Enter' || ev.key === 'Space') setExpanded(!isExpanded);
if (ev.key === 'Enter' || ev.key === 'Space') setExpandedWithCallback(!isExpanded);
}

return (
Expand Down
95 changes: 70 additions & 25 deletions Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Licensed under the MIT License.

/** @jsx jsx */
import React, { useCallback, useState, useEffect } from 'react';
import React, { useCallback, useState, useEffect, useRef } from 'react';
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 @@ -20,6 +20,7 @@ import {
rootBotProjectIdSelector,
botProjectSpaceSelector,
jsonSchemaFilesByProjectIdSelector,
pageElementState,
} from '../../recoilModel';
import { getFriendlyName } from '../../utils/dialogUtil';
import { triggerNotSupported } from '../../utils/dialogValidator';
Expand All @@ -28,6 +29,7 @@ import { LoadingSpinner } from '../LoadingSpinner';

import { TreeItem } from './treeItem';
import { ExpandableNode } from './ExpandableNode';
import { INDENT_PER_LEVEL } from './constants';

// -------------------- Styles -------------------- //

Expand Down Expand Up @@ -65,8 +67,6 @@ const tree = css`
label: tree;
`;

const SUMMARY_ARROW_SPACE = 28; // the rough pixel size of the dropdown arrow to the left of a Details/Summary element

// -------------------- Helper functions -------------------- //

const getTriggerIndex = (trigger: ITrigger, dialog: DialogInfo): number => {
Expand Down Expand Up @@ -139,6 +139,8 @@ type Props = {
defaultSelected?: Partial<TreeLink>;
};

const TREE_PADDING = 100; // the horizontal space taken up by stuff in the tree other than text or indentation

export const ProjectTree: React.FC<Props> = ({
onSelectAllLink: onAllSelected = undefined,
showTriggers = true,
Expand All @@ -148,7 +150,16 @@ export const ProjectTree: React.FC<Props> = ({
onSelect,
defaultSelected,
}) => {
const { onboardingAddCoachMarkRef, navigateToFormDialogSchema } = useRecoilValue(dispatcherState);
const { onboardingAddCoachMarkRef, navigateToFormDialogSchema, setPageElementState } = useRecoilValue(
dispatcherState
);
const treeRef = useRef<HTMLDivElement>(null);

const pageElements = useRecoilValue(pageElementState).design;
const leftSplitWidth = pageElements?.leftSplitWidth ?? treeRef?.current?.clientWidth ?? 0;
const getPageElement = (name: string) => pageElements?.[name];
const setPageElement = (name: string, value: any) =>
setPageElementState('design', { ...pageElements, [name]: value });

const [filter, setFilter] = useState('');
const formDialogComposerFeatureEnabled = useFeatureFlag('FORM_DIALOG');
Expand Down Expand Up @@ -254,18 +265,19 @@ export const ProjectTree: React.FC<Props> = ({
>
<TreeItem
showProps
forceIndent={bot.isRemote ? SUMMARY_ARROW_SPACE : 0}
hasChildren={!bot.isRemote}
icon={bot.isRemote ? icons.EXTERNAL_SKILL : icons.BOT}
isActive={doesLinkMatch(link, selectedLink)}
link={link}
menu={[{ label: formatMessage('Create/edit skill manifest'), onClick: () => {} }]}
textWidth={leftSplitWidth - TREE_PADDING}
onSelect={handleOnSelect}
/>
</span>
);
};

const renderDialogHeader = (skillId: string, dialog: DialogInfo) => {
const renderDialogHeader = (skillId: string, dialog: DialogInfo, depth: number) => {
const warningContent = notificationMap[skillId][dialog.id]
?.filter((diag) => diag.severity === DiagnosticSeverity.Warning)
.map((diag) => diag.message)
Expand Down Expand Up @@ -301,8 +313,8 @@ export const ProjectTree: React.FC<Props> = ({
role="grid"
>
<TreeItem
hasChildren
showProps
forceIndent={showTriggers ? 0 : SUMMARY_ARROW_SPACE}
icon={isFormDialog ? icons.FORM_DIALOG : icons.DIALOG}
isActive={doesLinkMatch(dialogLink, selectedLink)}
link={dialogLink}
Expand All @@ -329,6 +341,7 @@ export const ProjectTree: React.FC<Props> = ({
]
: []),
]}
textWidth={leftSplitWidth - TREE_PADDING}
onSelect={handleOnSelect}
/>
</span>
Expand All @@ -337,7 +350,13 @@ export const ProjectTree: React.FC<Props> = ({
};
};

const renderTrigger = (item: any, dialog: DialogInfo, projectId: string, dialogLink?: TreeLink): React.ReactNode => {
const renderTrigger = (
item: any,
dialog: DialogInfo,
projectId: string,
dialogLink: TreeLink,
depth: number
): React.ReactNode => {
const link: TreeLink = {
projectId: rootProjectId,
skillId: projectId === rootProjectId ? undefined : projectId,
Expand All @@ -354,7 +373,7 @@ export const ProjectTree: React.FC<Props> = ({
<TreeItem
key={`${item.id}_${item.index}`}
dialogName={dialog.displayName}
forceIndent={48}
extraSpace={INDENT_PER_LEVEL}
icon={icons.TRIGGER}
isActive={doesLinkMatch(link, selectedLink)}
link={link}
Expand All @@ -367,6 +386,7 @@ export const ProjectTree: React.FC<Props> = ({
},
},
]}
textWidth={leftSplitWidth - TREE_PADDING}
onSelect={handleOnSelect}
/>
);
Expand All @@ -382,7 +402,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 @@ -395,17 +421,18 @@ export const ProjectTree: React.FC<Props> = ({
{ ...tr, index, displayName: getTriggerName(tr), warningContent, errorContent },
dialog,
projectId,
dialogLink
dialogLink,
depth
);
});
};

const renderTriggerGroupHeader = (displayName: string, dialog: DialogInfo, projectId: string) => {
const renderTriggerGroupHeader = (displayName: string, dialog: DialogInfo, projectId: string, depth: number) => {
const link: TreeLink = {
dialogId: dialog.id,
displayName,
isRoot: false,
projectId: projectId,
projectId,
};
return (
<span
Expand All @@ -416,12 +443,12 @@ export const ProjectTree: React.FC<Props> = ({
`}
role="grid"
>
<TreeItem showProps forceIndent={0} isSubItemActive={false} link={link} />
<TreeItem showProps isSubItemActive={false} link={link} textWidth={leftSplitWidth - TREE_PADDING} />
</span>
);
};

// renders a named expandible node with the triggers as items underneath
// renders a named expandable node with the triggers as items underneath
const renderTriggerGroup = (
projectId: string,
dialog: DialogInfo,
Expand All @@ -432,14 +459,21 @@ export const ProjectTree: React.FC<Props> = ({
const groupDisplayName =
groupName === NoGroupingTriggerGroupName ? formatMessage('form-wide operations') : groupName;
const key = `${projectId}.${dialog.id}.group-${groupName}`;
const link: TreeLink = {
dialogId: dialog.id,
displayName: groupName,
isRoot: false,
projectId,
};

return (
<ExpandableNode
key={key}
depth={startDepth}
summary={renderTriggerGroupHeader(groupDisplayName, dialog, projectId)}
summary={renderTriggerGroupHeader(groupDisplayName, dialog, projectId, startDepth + 1)}
onToggle={(newState) => setPageElement(key, newState)}
>
<div>{renderTriggerList(triggers, dialog, projectId)}</div>
<div>{renderTriggerList(triggers, dialog, projectId, link, startDepth + 1)}</div>
</ExpandableNode>
);
};
Expand All @@ -457,10 +491,10 @@ export const ProjectTree: React.FC<Props> = ({
});
};

const renderDialogTriggers = (dialog: DialogInfo, projectId: string, startDepth: number, dialogLink?: TreeLink) => {
const renderDialogTriggers = (dialog: DialogInfo, projectId: string, startDepth: number, dialogLink: TreeLink) => {
return dialogIsFormDialog(dialog)
? renderDialogTriggersByProperty(dialog, projectId, startDepth)
: renderTriggerList(dialog.triggers, dialog, projectId, dialogLink);
? renderDialogTriggersByProperty(dialog, projectId, startDepth + 1)
: renderTriggerList(dialog.triggers, dialog, projectId, dialogLink, startDepth + 1);
};

const createDetailsTree = (bot: BotInProject, startDepth: number) => {
Expand All @@ -477,27 +511,36 @@ export const ProjectTree: React.FC<Props> = ({

if (showTriggers) {
return filteredDialogs.map((dialog: DialogInfo) => {
const { summaryElement, dialogLink } = renderDialogHeader(projectId, dialog);
const { summaryElement, dialogLink } = renderDialogHeader(projectId, dialog, startDepth);
const key = 'dialog-' + dialog.id;
return (
<ExpandableNode
key={dialog.id}
key={key}
defaultState={getPageElement(key)}
depth={startDepth}
detailsRef={dialog.isRoot ? addMainDialogRef : undefined}
summary={summaryElement}
onToggle={(newState) => setPageElement(key, newState)}
>
<div>{renderDialogTriggers(dialog, projectId, startDepth + 1, dialogLink)}</div>
</ExpandableNode>
);
});
} else {
return filteredDialogs.map((dialog: DialogInfo) => renderDialogHeader(projectId, dialog));
return filteredDialogs.map((dialog: DialogInfo) => renderDialogHeader(projectId, dialog, startDepth));
}
};

const createBotSubtree = (bot: BotInProject & { hasWarnings: boolean }) => {
const key = 'bot-' + bot.projectId;
if (showDialogs && !bot.isRemote) {
return (
<ExpandableNode key={bot.projectId} summary={renderBotHeader(bot)}>
<ExpandableNode
key={key}
defaultState={getPageElement(key)}
summary={renderBotHeader(bot)}
onToggle={(newState) => setPageElement(key, newState)}
>
<div>{createDetailsTree(bot, 1)}</div>
</ExpandableNode>
);
Expand All @@ -513,6 +556,7 @@ export const ProjectTree: React.FC<Props> = ({

return (
<div
ref={treeRef}
aria-label={formatMessage('Navigation pane')}
className="ProjectTree"
css={root}
Expand Down Expand Up @@ -548,8 +592,9 @@ export const ProjectTree: React.FC<Props> = ({
<div css={tree}>
{onAllSelected != null ? (
<TreeItem
forceIndent={SUMMARY_ARROW_SPACE}
hasChildren={false}
link={{ displayName: formatMessage('All'), projectId: rootProjectId, isRoot: true }}
textWidth={leftSplitWidth - TREE_PADDING}
onSelect={onAllSelected}
/>
) : null}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

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;
Loading