• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

microsoft / BotFramework-Composer / 8946411146

28 Mar 2024 01:12PM UTC coverage: 54.424% (-0.06%) from 54.482%
8946411146

push

github

web-flow
Merge pull request #9713 from OEvgeny/chore/update-electron-26

chore: update dependencies

7577 of 18338 branches covered (41.32%)

Branch coverage included in aggregate %.

939 of 1507 new or added lines in 170 files covered. (62.31%)

8 existing lines in 8 files now uncovered.

19745 of 31864 relevant lines covered (61.97%)

26.54 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

56.8
/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx
1
// Copyright (c) Microsoft Corporation.
2
// Licensed under the MIT License.
3

4
/** @jsx jsx */
5
import React, { useCallback, useState, useRef } from 'react';
7✔
6
import { NeutralColors } from '@fluentui/theme';
7✔
7
import { jsx, css } from '@emotion/react';
15✔
8
import { FocusZone, FocusZoneDirection } from '@fluentui/react/lib/FocusZone';
7✔
9
import formatMessage from 'format-message';
7✔
10
import { DialogInfo, ITrigger, Diagnostic, DiagnosticSeverity, LanguageFileImport, getFriendlyName } from '@bfc/shared';
7✔
11
import debounce from 'lodash/debounce';
7✔
12
import throttle from 'lodash/throttle';
7✔
13
import { useRecoilValue } from 'recoil';
7✔
14
import { extractSchemaProperties, groupTriggersByPropertyReference, NoGroupingTriggerGroupName } from '@bfc/indexers';
7✔
15
import isEqual from 'lodash/isEqual';
7✔
16
import { Announced } from '@fluentui/react/lib/Announced';
7✔
17
import { useId } from '@fluentui/react-hooks';
7✔
18

19
import {
20
  dispatcherState,
21
  rootBotProjectIdSelector,
22
  TreeDataPerProject,
23
  jsonSchemaFilesByProjectIdSelector,
24
  pageElementState,
25
  projectTreeSelectorFamily,
26
} from '../../recoilModel';
7✔
27
import { triggerNotSupported } from '../../utils/dialogValidator';
7✔
28
import { useFeatureFlag } from '../../utils/hooks';
7✔
29
import { LoadingSpinner } from '../LoadingSpinner';
7✔
30
import TelemetryClient from '../../telemetry/TelemetryClient';
7✔
31
import { getBaseName } from '../../utils/fileUtil';
7✔
32

33
import { TreeItem } from './treeItem';
7✔
34
import { ExpandableNode } from './ExpandableNode';
7✔
35
import { INDENT_PER_LEVEL, TREE_PADDING } from './constants';
7✔
36
import { ProjectTreeHeader, ProjectTreeHeaderMenuItem } from './ProjectTreeHeader';
7✔
37
import { isChildTriggerLinkSelected, doesLinkMatch } from './helpers';
7✔
38
import { ProjectHeader } from './ProjectHeader';
7✔
39
import { ProjectTreeOptions, TreeLink, TreeMenuItem } from './types';
40
import { TopicsList } from './TopicsList';
7✔
41

42
// -------------------- Styles -------------------- //
43

44
const root = css`
45
  width: 100%;
46
  height: 100%;
47
  box-sizing: border-box;
48
  overflow: hidden;
49
  .ms-List-cell {
50
    min-height: 36px;
51
  }
52
`;
53

54
const focusStyle = css`
55
  height: 100%;
56
  position: relative;
57
`;
58

59
const tree = css`
60
  height: calc(100% - 45px);
61
  overflow-y: auto;
62
  margin-left: -1px; // remove 1px overflow caused by Split view component calculations
63
  label: tree;
64
`;
65

66
export const headerCSS = (label: string, isActive?: boolean) => css`
7✔
67
  width: 100%;
68
  label: ${label};
69
  :hover {
70
    background: ${isActive ? NeutralColors.gray40 : NeutralColors.gray20};
15!
71
  }
72
  background: ${isActive ? NeutralColors.gray30 : NeutralColors.white};
15!
73
`;
74

75
// -------------------- Helper functions -------------------- //
76

77
const getTriggerIndex = (trigger: ITrigger, dialog: DialogInfo): number => {
7✔
78
  return dialog.triggers.indexOf(trigger);
60✔
79
};
80

81
// sort trigger groups so that NoGroupingTriggerGroupName is last
82
const sortTriggerGroups = (x: string, y: string): number => {
7✔
83
  if (x === NoGroupingTriggerGroupName && y !== NoGroupingTriggerGroupName) {
×
84
    return 1;
×
85
  } else if (y === NoGroupingTriggerGroupName && x !== NoGroupingTriggerGroupName) {
×
86
    return -1;
×
87
  }
88

89
  return x.localeCompare(y);
×
90
};
91

92
// -------------------- ProjectTree -------------------- //
93
function getTriggerName(trigger: ITrigger): string {
94
  return trigger.displayName || getFriendlyName({ $kind: trigger.type });
60✔
95
}
96

97
type Props = {
98
  navLinks?: TreeLink[];
99
  headerMenu?: ProjectTreeHeaderMenuItem[];
100
  onSelect?: (link: TreeLink) => void;
101
  onBotDeleteDialog?: (projectId: string, dialogId: string) => void;
102
  onBotCreateDialog?: (projectId: string) => void;
103
  onBotStart?: (projectId: string) => void;
104
  onBotStop?: (projectId: string) => void;
105
  onBotEditManifest?: (projectId: string) => void;
106
  onBotExportZip?: (projectId: string) => void;
107
  onBotRemoveSkill?: (skillId: string) => void;
108
  onDialogCreateTrigger?: (projectId: string, dialogId: string) => void;
109
  onDialogDeleteTrigger?: (projectId: string, dialogId: string, index: number) => void;
110
  onErrorClick?: (projectId: string, skillId: string, diagnostic: Diagnostic) => void;
111
  selectedLink?: Partial<TreeLink>;
112
  options?: ProjectTreeOptions;
113
  headerAriaLabel?: string;
114
  headerPlaceholder?: string;
115
};
116

117
export const ProjectTree: React.FC<Props> = ({
7✔
118
  headerMenu = [],
17✔
119
  onBotDeleteDialog = () => {},
15✔
120
  onDialogDeleteTrigger = () => {},
15✔
121
  onSelect,
122
  onBotCreateDialog = () => {},
17✔
123
  onBotStart = () => {},
17✔
124
  onBotStop = () => {},
17✔
125
  onBotEditManifest = () => {},
17✔
126
  onBotExportZip = () => {},
17✔
127
  onBotRemoveSkill = () => {},
17✔
128
  onDialogCreateTrigger = () => {},
17✔
129
  onErrorClick = () => {},
17✔
130
  selectedLink,
131
  options = {
11✔
132
    showDelete: true,
133
    showDialogs: true,
134
    showLgImports: false,
135
    showLuImports: false,
136
    showMenu: true,
137
    showQnAMenu: true,
138
    showErrors: true,
139
    showCommonLinks: false,
140
    showRemote: true,
141
    showTriggers: true,
142
  },
143
  headerAriaLabel = '',
8✔
144
  headerPlaceholder = '',
8✔
145
}) => {
146
  const { onboardingAddCoachMarkRef, navigateToFormDialogSchema, setPageElementState, createQnADialogBegin } =
20✔
147
    useRecoilValue(dispatcherState);
148
  const treeRef = useRef<HTMLDivElement>(null);
20✔
149

150
  const pageElements = useRecoilValue(pageElementState).dialogs;
20✔
151
  const leftSplitWidth = pageElements?.leftSplitWidth ?? treeRef?.current?.clientWidth ?? 0;
20!
152
  const getPageElement = (name: string) => pageElements?.[name];
28!
153
  const setPageElement = (name: string, value) => setPageElementState('dialogs', { ...pageElements, [name]: value });
20✔
154

155
  const [filter, setFilter] = useState('');
20✔
156

157
  const [isMenuOpen, setMenuOpen] = useState<boolean>(false);
20✔
158
  const formDialogComposerFeatureEnabled = useFeatureFlag('FORM_DIALOG');
20✔
159

160
  const notificationMap: { [projectId: string]: { [dialogId: string]: Diagnostic[] } } = {};
20✔
161
  const lgImportsByProjectByDialog: Record<string, Record<string, LanguageFileImport[]>> = {};
20✔
162
  const luImportsByProjectByDialog: Record<string, Record<string, LanguageFileImport[]>> = {};
20✔
163

164
  const debouncedTelemetry = useRef(debounce(() => TelemetryClient.track('ProjectTreeFilterUsed'), 1000)).current;
20✔
165

166
  const delayedSetFilter = throttle((newValue) => {
20✔
167
    setFilter(newValue);
×
168
    debouncedTelemetry();
×
169
  }, 200);
170

171
  const addMainDialogRef = useCallback((mainDialog) => onboardingAddCoachMarkRef({ mainDialog }), []);
20✔
172

173
  const rootProjectId = useRecoilValue(rootBotProjectIdSelector);
20✔
174
  const projectCollection: TreeDataPerProject[] = useRecoilValue(projectTreeSelectorFamily);
20✔
175
  const jsonSchemaFilesByProjectId = useRecoilValue(jsonSchemaFilesByProjectIdSelector);
20✔
176

177
  const projectTreeNavLabelId = useId('project-tree-nav-label');
20✔
178

179
  // TODO Refactor to make sure tree is not generated until a new trigger/dialog is added. #5462
180
  const createSubtree = useCallback(() => {
20✔
181
    return projectCollection.map(createBotSubtree);
11✔
182
  }, [projectCollection, selectedLink, leftSplitWidth, filter]);
183

184
  if (rootProjectId == null) {
20✔
185
    // this should only happen before a project is loaded in, so it won't last very long
186
    return <LoadingSpinner />;
9✔
187
  }
188

189
  const dialogIsFormDialog = (dialog: DialogInfo) => {
11✔
190
    return formDialogComposerFeatureEnabled && dialog.isFormDialog;
45!
191
  };
192

193
  const formDialogSchemaExists = (projectId: string, dialog: DialogInfo) => {
11✔
194
    return (
195
      dialogIsFormDialog(dialog) &&
15!
196
      !!projectCollection?.find((s) => s.projectId === projectId)?.formDialogSchemas.find((fd) => fd.id === dialog.id)
×
197
    );
198
  };
199

200
  const handleOnSelect = (link: TreeLink) => {
11✔
201
    // Skip state change when link not changed.
202
    if (isEqual(link, selectedLink)) return;
2!
203

204
    onSelect?.(link);
2!
205
  };
206

207
  const renderDialogHeader = (skillId: string, dialog: DialogInfo, depth: number, isPvaSchema: boolean) => {
11✔
208
    const diagnostics: Diagnostic[] = notificationMap[rootProjectId][dialog.id];
15✔
209
    const dialogLink: TreeLink = {
15✔
210
      dialogId: dialog.id,
211
      displayName: dialog.displayName,
212
      isRoot: dialog.isRoot,
213
      diagnostics,
214
      projectId: rootProjectId,
215
      skillId: skillId === rootProjectId ? undefined : skillId,
15✔
216
      isRemote: false,
217
    };
218
    const menu: TreeMenuItem[] = [
15✔
219
      {
220
        label: formatMessage('Add new trigger'),
221
        icon: 'Add',
222
        onClick: () => {
223
          onDialogCreateTrigger(skillId, dialog.id);
×
224
          TelemetryClient.track('AddNewTriggerStarted');
225
        },
226
      },
227
      {
228
        label: '',
229
        onClick: () => {},
230
      },
231
    ];
232

233
    const QnAMenuItem = {
15✔
234
      label: formatMessage('Add QnA Maker knowledge base'),
235
      icon: 'Add',
236
      onClick: () => {
237
        createQnADialogBegin({ projectId: skillId, dialogId: dialog.id });
×
238
        TelemetryClient.track('AddNewKnowledgeBaseStarted');
239
      },
240
    };
241

242
    if (!isPvaSchema) {
15✔
243
      menu.splice(1, 0, QnAMenuItem);
15✔
244
    }
245

246
    const isFormDialog = dialogIsFormDialog(dialog);
15✔
247
    const showEditSchema = formDialogSchemaExists(skillId, dialog);
15✔
248

249
    if (!dialog.isRoot && options.showDelete) {
15✔
250
      menu.push({
2✔
251
        label: formatMessage('Remove this dialog'),
252
        onClick: () => {
253
          onBotDeleteDialog?.(skillId, dialog.id);
×
254
        },
255
      });
256
    }
257

258
    if (showEditSchema) {
15!
259
      menu.push({
×
260
        label: formatMessage('Edit schema'),
261
        icon: 'Edit',
262
        onClick: (link: TreeLink) =>
263
          navigateToFormDialogSchema({ projectId: link.skillId ?? link.projectId, schemaId: link.dialogId }),
×
264
      });
265
    }
266

267
    return {
15✔
268
      summaryElement: (
269
        <span
270
          key={dialog.id}
271
          ref={dialog.isRoot ? addMainDialogRef : null}
15✔
272
          css={headerCSS('dialog-header', doesLinkMatch(dialogLink, selectedLink))}
273
          data-testid={`DialogHeader-${dialog.displayName}`}
274
        >
275
          <TreeItem
276
            hasChildren
277
            isActive={doesLinkMatch(dialogLink, selectedLink)}
278
            isChildSelected={isChildTriggerLinkSelected(dialogLink, selectedLink)}
279
            isMenuOpen={isMenuOpen}
280
            itemType={isFormDialog ? 'form dialog' : 'dialog'}
15!
281
            link={dialogLink}
282
            menu={options.showMenu ? menu : options.showQnAMenu ? [QnAMenuItem] : []}
15!
283
            menuOpenCallback={setMenuOpen}
284
            showErrors={false}
285
            textWidth={leftSplitWidth - TREE_PADDING}
286
            onSelect={handleOnSelect}
287
          />
288
        </span>
289
      ),
290
      dialogLink,
291
    };
292
  };
293

294
  const renderCommonDialogHeader = (skillId: string) => {
11✔
295
    const dialogLink: TreeLink = {
×
296
      dialogId: 'common',
297
      displayName: formatMessage('Common'),
298
      isRoot: false,
299
      diagnostics: [],
300
      projectId: rootProjectId,
301
      skillId: skillId === rootProjectId ? undefined : skillId,
×
302
      isRemote: false,
303
    };
304

305
    return (
306
      <span key={'common'} ref={null} css={headerCSS('common-dialog-header')} data-testid={`DialogHeader-Common`}>
307
        <TreeItem
308
          hasChildren
309
          isActive={doesLinkMatch(dialogLink, selectedLink)}
310
          isMenuOpen={isMenuOpen}
311
          itemType={'dialog'}
312
          link={dialogLink}
313
          menuOpenCallback={setMenuOpen}
314
          showErrors={false}
315
          textWidth={leftSplitWidth - TREE_PADDING}
316
          onSelect={handleOnSelect}
317
        />
318
      </span>
319
    );
320
  };
321

322
  const renderTrigger = (
11✔
323
    item: ITrigger & {
324
      index: number;
325
      displayName: string;
326
      warningContent: string | (() => string);
327
      errorContent: boolean;
328
    },
329
    dialog: DialogInfo,
330
    projectId: string,
331
    dialogLink: TreeLink,
332
  ): React.ReactNode => {
333
    const link: TreeLink = {
60✔
334
      projectId: rootProjectId,
335
      skillId: projectId === rootProjectId ? undefined : projectId,
60✔
336
      dialogId: dialog.id,
337
      trigger: item.index,
338
      displayName: item.displayName,
339
      diagnostics: [],
340
      isRoot: false,
341
      parentLink: dialogLink,
342
      isRemote: false,
343
    };
344

345
    return (
346
      <TreeItem
347
        key={`${item.id}_${item.index}`}
348
        dialogName={dialog.displayName}
349
        extraSpace={16}
350
        isActive={doesLinkMatch(link, selectedLink)}
351
        isMenuOpen={isMenuOpen}
352
        itemType={'trigger'}
353
        link={link}
354
        menu={
355
          options.showDelete
60!
356
            ? [
357
                {
358
                  label: formatMessage('Remove this trigger'),
359
                  icon: 'Delete',
360
                  onClick: (link) => {
361
                    onDialogDeleteTrigger?.(projectId, link.dialogId ?? '', link.trigger ?? 0);
×
362
                  },
363
                },
364
              ]
365
            : []
366
        }
367
        menuOpenCallback={setMenuOpen}
368
        role="treeitem"
369
        showErrors={options.showErrors}
370
        textWidth={leftSplitWidth - TREE_PADDING}
371
        onSelect={handleOnSelect}
372
      />
373
    );
374
  };
375

376
  const onFilter = (newValue?: string): void => {
11✔
377
    if (typeof newValue === 'string') {
×
378
      delayedSetFilter(newValue);
×
379
    }
380
  };
381

382
  const filterMatch = (scope: string): boolean => {
11✔
383
    return scope.toLowerCase().includes(filter.toLowerCase());
60✔
384
  };
385

386
  const renderTriggerList = (triggers: ITrigger[], dialog: DialogInfo, projectId: string, dialogLink: TreeLink) => {
11✔
387
    return triggers
15✔
388
      .filter((tr) => filterMatch(dialog.displayName) || filterMatch(getTriggerName(tr)))
60!
389
      .map((tr) => {
390
        const index = getTriggerIndex(tr, dialog);
60✔
391
        const warningContent = triggerNotSupported(dialog, tr);
60✔
392
        const errorContent = notificationMap[projectId][dialog.id].some(
393
          (diag) => diag.severity === DiagnosticSeverity.Error && diag.path?.match(RegExp(`triggers\\[${index}\\]`)),
52!
394
        );
395
        return renderTrigger(
60✔
396
          { ...tr, index, displayName: getTriggerName(tr), warningContent, errorContent },
397
          dialog,
398
          projectId,
399
          dialogLink,
400
        );
401
      });
402
  };
403

404
  const renderTriggerGroupHeader = (displayName: string, dialog: DialogInfo, projectId: string) => {
11✔
405
    const link: TreeLink = {
×
406
      dialogId: dialog.id,
407
      displayName,
408
      isRoot: false,
409
      diagnostics: [],
410
      projectId,
411
      isRemote: false,
412
    };
413
    return (
414
      <span css={headerCSS('trigger-group-header')}>
415
        <TreeItem
416
          hasChildren
417
          isMenuOpen={isMenuOpen}
418
          isSubItemActive={false}
419
          itemType={'trigger group'}
420
          link={link}
421
          menuOpenCallback={setMenuOpen}
422
          showErrors={options.showErrors}
423
          textWidth={leftSplitWidth - TREE_PADDING}
424
        />
425
      </span>
426
    );
427
  };
428

429
  // renders a named expandable node with the triggers as items underneath
430
  const renderTriggerGroup = (
11✔
431
    projectId: string,
432
    dialog: DialogInfo,
433
    groupName: string,
434
    triggers: ITrigger[],
435
    startDepth: number,
436
  ) => {
437
    const groupDisplayName =
438
      groupName === NoGroupingTriggerGroupName ? formatMessage('form-wide operations') : groupName;
×
439
    const key = `${projectId}.${dialog.id}.group-${groupName}`;
×
440
    const link: TreeLink = {
×
441
      dialogId: dialog.id,
442
      displayName: groupName,
443
      isRoot: false,
444
      projectId,
445
      diagnostics: [],
446
      isRemote: false,
447
    };
448

449
    return (
×
450
      <ExpandableNode
451
        key={key}
452
        depth={startDepth}
453
        summary={renderTriggerGroupHeader(groupDisplayName, dialog, projectId)}
454
        onToggle={(newState) => setPageElement(key, newState)}
×
455
      >
456
        <div role="group">{renderTriggerList(triggers, dialog, projectId, link)}</div>
457
      </ExpandableNode>
458
    );
459
  };
460

461
  // renders triggers grouped by the schema property they are associated with.
462
  const renderDialogTriggersByProperty = (dialog: DialogInfo, projectId: string, startDepth: number) => {
11✔
463
    const jsonSchemaFiles = jsonSchemaFilesByProjectId[projectId];
×
464
    const dialogSchemaProperties = extractSchemaProperties(dialog, jsonSchemaFiles);
×
465
    const groupedTriggers = groupTriggersByPropertyReference(dialog, {
×
466
      validProperties: dialogSchemaProperties,
467
      allowMultiParent: true,
468
    });
469

470
    const triggerGroups = Object.keys(groupedTriggers);
×
471

472
    return triggerGroups.sort(sortTriggerGroups).map((triggerGroup) => {
×
473
      return renderTriggerGroup(projectId, dialog, triggerGroup, groupedTriggers[triggerGroup], startDepth);
×
474
    });
475
  };
476

477
  const renderDialogTriggers = (dialog: DialogInfo, projectId: string, startDepth: number, dialogLink: TreeLink) => {
11✔
478
    return dialogIsFormDialog(dialog)
15!
479
      ? renderDialogTriggersByProperty(dialog, projectId, startDepth + 1)
480
      : renderTriggerList(dialog.triggers, dialog, projectId, dialogLink);
481
  };
482

483
  // flatten lg imports url is same to dialog, to match correct link need render it as dialog
484
  const renderLgImportAsDialog = (item: LanguageFileImport, projectId: string): React.ReactNode => {
11✔
485
    const link: TreeLink = {
×
486
      projectId: rootProjectId,
487
      skillId: projectId === rootProjectId ? undefined : projectId,
×
488
      dialogId: getBaseName(item.id),
489
      displayName: item.displayName ?? item.id,
×
490
      diagnostics: [],
491
      isRoot: false,
492
      isRemote: false,
493
    };
494

495
    return (
496
      <TreeItem
497
        key={`lg_${item.id}`}
498
        extraSpace={INDENT_PER_LEVEL}
499
        isActive={doesLinkMatch(link, selectedLink)}
500
        isMenuOpen={isMenuOpen}
501
        itemType={'dialog'}
502
        link={link}
503
        menu={[]}
504
        menuOpenCallback={setMenuOpen}
505
        showErrors={options.showErrors}
506
        textWidth={leftSplitWidth - TREE_PADDING}
507
        onSelect={handleOnSelect}
508
      />
509
    );
510
  };
511

512
  const renderLgImport = (item: LanguageFileImport, projectId: string, dialogId: string): React.ReactNode => {
11✔
513
    const link: TreeLink = {
×
514
      projectId: rootProjectId,
515
      skillId: projectId === rootProjectId ? undefined : projectId,
×
516
      lgFileId: item.id,
517
      dialogId,
518
      displayName: item.displayName ?? item.id,
×
519
      diagnostics: [],
520
      isRoot: false,
521
      isRemote: false,
522
    };
523

524
    return (
525
      <TreeItem
526
        key={`lg_${item.id}`}
527
        extraSpace={INDENT_PER_LEVEL}
528
        isActive={doesLinkMatch(link, selectedLink)}
529
        isMenuOpen={isMenuOpen}
530
        itemType={'dialog'}
531
        link={link}
532
        menu={[]}
533
        menuOpenCallback={setMenuOpen}
534
        showErrors={options.showErrors}
535
        textWidth={leftSplitWidth - TREE_PADDING}
536
        onSelect={handleOnSelect}
537
      />
538
    );
539
  };
540

541
  const renderLgImports = (dialog: DialogInfo, projectId: string) => {
11✔
542
    return lgImportsByProjectByDialog[projectId][dialog.id]
×
543
      .filter((lgImport) => filterMatch(dialog.displayName) || filterMatch(lgImport.displayName))
×
544
      .map((lgImport) => {
545
        return renderLgImport(lgImport, projectId, dialog.id);
×
546
      });
547
  };
548

549
  const renderLuImportAsDialog = (item: LanguageFileImport, projectId: string): React.ReactNode => {
11✔
550
    const link: TreeLink = {
×
551
      projectId: rootProjectId,
552
      skillId: projectId === rootProjectId ? undefined : projectId,
×
553
      dialogId: getBaseName(item.id),
554
      displayName: item.displayName ?? item.id,
×
555
      diagnostics: [],
556
      isRoot: false,
557
      isRemote: false,
558
    };
559

560
    return (
561
      <TreeItem
562
        key={`lu_${item.id}`}
563
        extraSpace={INDENT_PER_LEVEL}
564
        isActive={doesLinkMatch(link, selectedLink)}
565
        isMenuOpen={isMenuOpen}
566
        itemType={'dialog'}
567
        link={link}
568
        menu={[]}
569
        menuOpenCallback={setMenuOpen}
570
        showErrors={options.showErrors}
571
        textWidth={leftSplitWidth - TREE_PADDING}
572
        onSelect={handleOnSelect}
573
      />
574
    );
575
  };
576

577
  const renderLuImport = (item: LanguageFileImport, projectId: string, dialogId: string): React.ReactNode => {
11✔
578
    const link: TreeLink = {
×
579
      projectId: rootProjectId,
580
      skillId: projectId === rootProjectId ? undefined : projectId,
×
581
      luFileId: item.id,
582
      displayName: item.displayName ?? item.id,
×
583
      dialogId,
584
      diagnostics: [],
585
      isRoot: false,
586
      isRemote: false,
587
    };
588

589
    return (
590
      <TreeItem
591
        key={`lu_${item.id}`}
592
        extraSpace={INDENT_PER_LEVEL}
593
        isActive={doesLinkMatch(link, selectedLink)}
594
        isMenuOpen={isMenuOpen}
595
        itemType={'dialog'}
596
        link={link}
597
        menu={[]}
598
        menuOpenCallback={setMenuOpen}
599
        showErrors={options.showErrors}
600
        textWidth={leftSplitWidth - TREE_PADDING}
601
        onSelect={handleOnSelect}
602
      />
603
    );
604
  };
605

606
  const renderLuImports = (dialog: DialogInfo, projectId: string) => {
11✔
607
    return luImportsByProjectByDialog[projectId][dialog.id]
×
608
      .filter((luImport) => filterMatch(dialog.displayName) || filterMatch(luImport.displayName))
×
609
      .map((luImport) => {
610
        return renderLuImport(luImport, projectId, dialog.id);
×
611
      });
612
  };
613

614
  const createDetailsTree = (bot: TreeDataPerProject, startDepth: number) => {
11✔
615
    const { projectId, lgImportsList, luImportsList } = bot;
13✔
616
    const dialogs = bot.sortedDialogs;
13✔
617
    const topics = bot.topics ?? [];
13!
618

619
    const filteredDialogs =
620
      filter == null || filter.length === 0
26✔
621
        ? dialogs
13✔
622
        : dialogs.filter(
623
            (dialog) =>
624
              filterMatch(dialog.displayName) ||
×
NEW
625
              dialog.triggers.some((trigger) => filterMatch(getTriggerName(trigger))),
×
626
          );
627
    // eventually we will filter on topic trigger phrases
628
    const filteredTopics =
629
      filter == null || filter.length === 0 ? topics : topics.filter((topic) => filterMatch(topic.displayName));
✔
630
    const commonLink = options.showCommonLinks ? [renderCommonDialogHeader(projectId)] : [];
13!
631

632
    const importedLgLinks = options.showLgImports
13!
633
      ? lgImportsList.map((file) => renderLgImportAsDialog(file, projectId))
×
634
      : [];
635
    const importedLuLinks = options.showLuImports
13!
636
      ? luImportsList.map((file) => renderLuImportAsDialog(file, projectId))
×
637
      : [];
638

639
    return [
13✔
640
      ...commonLink,
641
      ...importedLgLinks,
642
      ...importedLuLinks,
643
      ...filteredDialogs.map((dialog: DialogInfo) => {
644
        const { summaryElement, dialogLink } = renderDialogHeader(projectId, dialog, 0, bot.isPvaSchema);
15✔
645
        const key = 'dialog-' + dialog.id;
15✔
646

647
        let lgImports, luImports;
648
        if (options.showLgImports) {
15!
649
          lgImports = renderLgImports(dialog, projectId);
×
650
        }
651

652
        if (options.showLuImports) {
15!
653
          luImports = renderLuImports(dialog, projectId);
×
654
        }
655

656
        if (options.showTriggers) {
15!
657
          return (
15✔
658
            <ExpandableNode
659
              key={key}
660
              defaultState={getPageElement(key)}
661
              depth={startDepth}
662
              isActive={doesLinkMatch(dialogLink, selectedLink)}
663
              summary={summaryElement}
664
              onToggle={(newState) => setPageElement(key, newState)}
×
665
            >
666
              <div>{renderDialogTriggers(dialog, projectId, startDepth + 1, dialogLink)}</div>
667
            </ExpandableNode>
668
          );
669
        } else if (options.showLgImports && lgImports.length > 0 && dialog.isFormDialog) {
×
670
          return (
×
671
            <ExpandableNode
672
              key={key}
673
              defaultState={getPageElement(key)}
674
              depth={startDepth}
675
              isActive={doesLinkMatch(dialogLink, selectedLink)}
676
              summary={summaryElement}
677
              onToggle={(newState) => setPageElement(key, newState)}
×
678
            >
679
              <div>{lgImports}</div>
680
            </ExpandableNode>
681
          );
682
        } else if (options.showLuImports && luImports.length > 0 && dialog.isFormDialog) {
×
683
          return (
×
684
            <ExpandableNode
685
              key={key}
686
              defaultState={getPageElement(key)}
687
              depth={startDepth}
688
              isActive={doesLinkMatch(dialogLink, selectedLink)}
689
              summary={summaryElement}
690
              onToggle={(newState) => setPageElement(key, newState)}
×
691
            >
692
              <div>{luImports}</div>
693
            </ExpandableNode>
694
          );
695
        } else {
696
          return renderDialogHeader(projectId, dialog, 1, bot.isPvaSchema).summaryElement;
×
697
        }
698
      }),
699
      filteredTopics.length > 0 && (
13✔
700
        <TopicsList
701
          key={`pva-topics-${projectId}`}
702
          projectId={projectId}
703
          textWidth={leftSplitWidth - TREE_PADDING}
704
          topics={filteredTopics}
705
          onToggle={(newState) => setPageElement('pva-topics', newState)}
×
706
        />
707
      ),
708
    ];
709
  };
710

711
  const createBotSubtree = (bot: TreeDataPerProject) => {
11✔
712
    notificationMap[bot.projectId] = {};
13✔
713

714
    for (const dialog of bot.sortedDialogs) {
13✔
715
      const dialogId = dialog.id;
15✔
716
      notificationMap[bot.projectId][dialogId] = dialog.diagnostics;
15✔
717

718
      if (!lgImportsByProjectByDialog[bot.projectId]) {
15✔
719
        lgImportsByProjectByDialog[bot.projectId] = {};
13✔
720
      }
721

722
      lgImportsByProjectByDialog[bot.projectId][dialogId] = bot.lgImports[dialog.id];
15✔
723

724
      if (!luImportsByProjectByDialog[bot.projectId]) {
15✔
725
        luImportsByProjectByDialog[bot.projectId] = {};
13✔
726
      }
727
      luImportsByProjectByDialog[bot.projectId][dialogId] = bot.luImports[dialog.id];
15✔
728
    }
729

730
    const key = 'bot-' + bot.projectId;
13✔
731
    const projectHeader = (
732
      <ProjectHeader
733
        key={`${key}-header`}
734
        botError={bot.botError}
735
        handleOnSelect={handleOnSelect}
736
        isMenuOpen={isMenuOpen}
737
        isRemote={bot.isRemote}
738
        isRootBot={bot.isRootBot}
739
        name={bot.name}
740
        options={options}
741
        projectId={bot.projectId}
742
        selectedLink={selectedLink}
743
        setMenuOpen={setMenuOpen}
744
        textWidth={leftSplitWidth - TREE_PADDING}
745
        onBotCreateDialog={onBotCreateDialog}
746
        onBotDeleteDialog={onBotDeleteDialog}
747
        onBotEditManifest={onBotEditManifest}
748
        onBotExportZip={onBotExportZip}
749
        onBotRemoveSkill={onBotRemoveSkill}
750
        onBotStart={onBotStart}
751
        onBotStop={onBotStop}
752
        onErrorClick={onErrorClick}
753
      />
754
    );
755
    if (options.showDialogs && !bot.isRemote && !bot.botError) {
13!
756
      return (
13✔
757
        <ExpandableNode
758
          key={key}
759
          defaultState={getPageElement(key)}
760
          summary={projectHeader}
761
          onToggle={(newState) => setPageElement(key, newState)}
×
762
        >
763
          <div>{createDetailsTree(bot, 1)}</div>
764
        </ExpandableNode>
765
      );
766
    } else if (options.showRemote) {
×
767
      return <ExpandableNode key={key} summary={projectHeader} />;
768
    } else {
769
      return null;
×
770
    }
771
  };
772

773
  const projectTree = createSubtree();
11✔
774

775
  return (
11✔
776
    <div
777
      ref={treeRef}
778
      aria-labelledby={projectTreeNavLabelId}
779
      className="ProjectTree"
780
      css={root}
781
      data-testid="ProjectTree"
782
    >
783
      <ProjectTreeHeader
784
        ariaLabel={headerAriaLabel}
785
        filterValue={filter}
786
        menu={headerMenu}
787
        placeholder={headerPlaceholder}
788
        onFilter={onFilter}
789
      />
790
      <FocusZone isCircularNavigation css={focusStyle} direction={FocusZoneDirection.vertical}>
791
        <Announced
792
          id={projectTreeNavLabelId}
793
          message={formatMessage(
794
            `Navigation pane, {
795
              hasFilter, select,
796
                false {}
797
                other {search results for "{filter}":}
798
            } {
799
            dialogNum, plural,
800
                =0 {no bots have}
801
                =1 {one bot has}
802
              other {# bots have}
803
            } been found.
804
            {
805
              dialogNum, select,
806
                  0 {}
807
                other {Press down arrow key to navigate the search results}
808
            }`,
809
            { dialogNum: projectCollection.length, filter: filter, hasFilter: !!filter },
810
          )}
811
        />
812
        <div css={tree}>{projectTree}</div>
813
      </FocusZone>
814
    </div>
815
  );
816
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc