From 9033c5475e07c7672f8f144ae8d98a219b3f3451 Mon Sep 17 00:00:00 2001 From: Patrik Hellgren Date: Thu, 11 Nov 2021 00:36:00 +0100 Subject: [PATCH 1/6] Added feature to add action button to terms --- .../docs/controls/ModernTaxonomyPicker.md | 86 ++++++- .../ModernTaxonomyPicker.tsx | 7 +- .../TaxonomyPanelContents.module.scss | 18 ++ .../TaxonomyPanelContents.tsx | 221 +++++++++++------- .../controlsTest/ControlsTestWebPart.ts | 2 +- .../ControlsTest_SingleComponent.tsx | 87 ++++++- .../components/IControlsTestProps.ts | 4 + 7 files changed, 320 insertions(+), 105 deletions(-) diff --git a/docs/documentation/docs/controls/ModernTaxonomyPicker.md b/docs/documentation/docs/controls/ModernTaxonomyPicker.md index 187ef958c..0909af0d8 100644 --- a/docs/documentation/docs/controls/ModernTaxonomyPicker.md +++ b/docs/documentation/docs/controls/ModernTaxonomyPicker.md @@ -35,11 +35,12 @@ import { ModernTaxonomyPicker } from "@pnp/spfx-controls-react/lib/ModernTaxonom ```TypeScript + termSetId="f233d4b7-68fb-41ef-8b58-2af0bafc0d38" + panelTitle="Select Term" + label="Taxonomy Picker" + context={this.props.context} + onChange={this.onTaxPickerChange} +/> ``` - With the `onChange` property you can capture the event of when the terms in the picker has changed: @@ -50,6 +51,78 @@ private onTaxPickerChange(terms : ITermInfo[]) { } ``` +## Advanced example +Custom rendering of a More actions button that displays a context menu for each term in the term set and the term set itself and with different options for the terms and the term set. This could for example be used to add terms to an open term set. It also shows how to set the initialsValues property when just knowing the name and the id of the term, the rest of the term properties must be provided but doesn't need to be the correct values. + +```TypeScript + { + const menuIcon: IIconProps = { iconName: 'MoreVertical', "aria-label": "More actions", style: { fontSize: "medium" } }; + if (termInfo) { + const menuProps: IContextualMenuProps = { + items: [ + { + key: 'addTerm', + text: 'Add Term', + iconProps: { iconName: 'Tag' }, + onClick: () => onContextualMenuClick(termInfo.id) + }, + { + key: 'deleteTerm', + text: 'Delete term', + iconProps: { iconName: 'Untag' }, + onClick: () => onContextualMenuClick(termInfo.id) + }, + ], + }; + + return ( + | React.KeyboardEvent, button?: IButtonProps) => { + this.setState({clickedActionTerm: termInfo}); + }} + onAfterMenuDismiss={() => this.setState({clickedActionTerm: null})} + /> + ); + } + else { + const menuProps: IContextualMenuProps = { + items: [ + { + key: 'addTerm', + text: 'Add term', + iconProps: { iconName: 'Tag' }, + onClick: () => onContextualMenuClick(termSetInfo.id) + }, + ], + }; + return ( + + ); + } + }} +/> +``` + ## Implementation The ModernTaxonomyPicker control can be configured with the following properties: @@ -70,5 +143,8 @@ The ModernTaxonomyPicker control can be configured with the following properties | customPanelWidth | number | no | Custom panel width in pixels. | | termPickerProps | IModernTermPickerProps | no | Custom properties for the term picker (More info: [IBasePickerProps interface](https://developer.microsoft.com/en-us/fluentui#/controls/web/pickers#IBasePickerProps)). | | themeVariant | IReadonlyTheme | no | The current loaded SharePoint theme/section background (More info: [Supporting section backgrounds](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/web-parts/guidance/supporting-section-backgrounds)). | +| isLightDismiss | boolean | no | Whether the panel can be light dismissed. | +| isBlocking | boolean | no | Whether the panel uses a modal overlay or not. | +| onRenderActionButton | function | no | Optional custom renderer for adding e.g. a button with additional actions to the terms in the tree view. | ![](https://telemetry.sharepointpnp.com/sp-dev-fx-controls-react/wiki/controls/TaxonomyPicker) diff --git a/src/controls/modernTaxonomyPicker/ModernTaxonomyPicker.tsx b/src/controls/modernTaxonomyPicker/ModernTaxonomyPicker.tsx index c830e5dc2..c16fa604e 100644 --- a/src/controls/modernTaxonomyPicker/ModernTaxonomyPicker.tsx +++ b/src/controls/modernTaxonomyPicker/ModernTaxonomyPicker.tsx @@ -60,6 +60,9 @@ export interface IModernTaxonomyPickerProps { customPanelWidth?: number; themeVariant?: IReadonlyTheme; termPickerProps?: Optional; + isLightDismiss?: boolean; + isBlocking?: boolean; + onRenderActionButton?: (termStoreInfo: ITermStoreInfo, termSetInfo: ITermSetInfo, termInfo?: ITermInfo) => JSX.Element; } export function ModernTaxonomyPicker(props: IModernTaxonomyPickerProps) { @@ -235,7 +238,8 @@ export function ModernTaxonomyPicker(props: IModernTaxonomyPickerProps) { hasCloseButton={true} closeButtonAriaLabel={strings.ModernTaxonomyPickerPanelCloseButtonText} onDismiss={onClosePanel} - isLightDismiss={true} + isLightDismiss={props.isLightDismiss} + isBlocking={props.isBlocking} type={props.customPanelWidth ? PanelType.custom : PanelType.medium} customWidth={props.customPanelWidth ? `${props.customPanelWidth}px` : undefined} headerText={props.panelTitle} @@ -273,6 +277,7 @@ export function ModernTaxonomyPicker(props: IModernTaxonomyPickerProps) { languageTag={currentLanguageTag} themeVariant={props.themeVariant} termPickerProps={props.termPickerProps} + onRenderActionButton={props.onRenderActionButton} /> ) diff --git a/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.module.scss b/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.module.scss index b5d6d31a5..65bec7646 100644 --- a/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.module.scss +++ b/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.module.scss @@ -55,4 +55,22 @@ height: 48px; line-height: 48px; } + + .taxonomyItemFocusZone { + display: flex; + align-items: center; + width: 100%; + } + + .taxonomyItemHeader { + width: 100%; + } + + .taxonomyItemHeader .actionButtonContainer > * { + opacity: 0; + } + + .taxonomyItemHeader:hover .actionButtonContainer > *, .taxonomyItemHeader:focus .actionButtonContainer > *, .taxonomyItemHeader .actionButtonContainer:focus-within > * { + opacity: 1; + } } diff --git a/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.tsx b/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.tsx index 3eb9577d0..18476fefa 100644 --- a/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.tsx +++ b/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.tsx @@ -1,38 +1,51 @@ import * as React from 'react'; import styles from './TaxonomyPanelContents.module.scss'; -import { Checkbox, ICheckboxStyleProps, ICheckboxStyles } from 'office-ui-fabric-react/lib/Checkbox'; -import { ChoiceGroup, IChoiceGroupOption, IChoiceGroupOptionStyleProps, IChoiceGroupOptionStyles } from 'office-ui-fabric-react/lib/ChoiceGroup'; -import { - GroupedList, - GroupHeader, - IGroup, - IGroupFooterProps, - IGroupHeaderProps, - IGroupHeaderStyleProps, - IGroupHeaderStyles, - IGroupRenderProps, - IGroupShowAllProps -} from 'office-ui-fabric-react/lib/GroupedList'; -import { IBasePickerStyleProps, IBasePickerStyles, IPickerItemProps, ISuggestionItemProps } from 'office-ui-fabric-react/lib/Pickers'; -import { - ILabelStyleProps, - ILabelStyles, - Label -} from 'office-ui-fabric-react/lib/Label'; -import { - ILinkStyleProps, - ILinkStyles, - Link -} from 'office-ui-fabric-react/lib/Link'; -import { IListProps } from 'office-ui-fabric-react/lib/List'; -import { IRenderFunction, IStyleFunctionOrObject, Selection, SelectionMode } from 'office-ui-fabric-react/lib/Utilities'; -import { ISpinnerStyleProps, ISpinnerStyles, Spinner } from 'office-ui-fabric-react/lib/Spinner'; -import { SelectionZone } from 'office-ui-fabric-react/lib/Selection'; -import { - ITermInfo, - ITermSetInfo, - ITermStoreInfo -} from '@pnp/sp/taxonomy'; +import { Checkbox, + ChoiceGroup, + FocusZone, + FocusZoneDirection, + getRTLSafeKeyCode, + GroupedList, + GroupHeader, + IBasePickerStyleProps, + IBasePickerStyles, + ICheckboxStyleProps, + ICheckboxStyles, + IChoiceGroupOption, + IChoiceGroupOptionStyleProps, + IChoiceGroupOptionStyles, + IChoiceGroupStyleProps, + IChoiceGroupStyles, + IGroup, + IGroupFooterProps, + IGroupHeaderProps, + IGroupHeaderStyleProps, + IGroupHeaderStyles, + IGroupRenderProps, + IGroupShowAllProps, + ILabelStyleProps, + ILabelStyles, + ILinkStyleProps, + ILinkStyles, + IListProps, + IPickerItemProps, + IRenderFunction, + ISpinnerStyleProps, + ISpinnerStyles, + IStyleFunctionOrObject, + ISuggestionItemProps, + KeyCodes, + Label, + Link, + Selection, + SelectionMode, + SelectionZone, + Spinner + } from 'office-ui-fabric-react'; +import { ITermInfo, + ITermSetInfo, + ITermStoreInfo + } from '@pnp/sp/taxonomy'; import { Guid } from '@microsoft/sp-core-library'; import { BaseComponentContext } from '@microsoft/sp-component-base'; import { css } from '@uifabric/utilities/lib/css'; @@ -62,6 +75,7 @@ export interface ITaxonomyPanelContentsProps { languageTag: string; themeVariant?: IReadonlyTheme; termPickerProps?: Optional; + onRenderActionButton?: (termStoreInfo: ITermStoreInfo, termSetInfo: ITermSetInfo, termInfo?: ITermInfo) => JSX.Element; } export function TaxonomyPanelContents(props: ITaxonomyPanelContentsProps): React.ReactElement { @@ -245,77 +259,89 @@ export function TaxonomyPanelContents(props: ITaxonomyPanelContentsProps): React const childIsSelected = isChildSelected(groupHeaderProps.group.children); if (groupHeaderProps.group.level === 0) { - const labelStyles: IStyleFunctionOrObject = { root: { fontWeight: childIsSelected ? "bold" : "normal" } }; + const labelStyles: IStyleFunctionOrObject = {root: {width: "100%", fontWeight: childIsSelected ? "bold" : "normal"}}; return ( - + + +
+ {props.onRenderActionButton && props.onRenderActionButton(props.termStoreInfo, props.termSetInfo, props.anchorTermInfo)} +
+
); } const isDisabled = groupHeaderProps.group.data.term.isAvailableForTagging.filter((t) => t.setId === props.termSetId.toString())[0].isAvailable === false; const isSelected = selection.isKeySelected(groupHeaderProps.group.key); - const selectionProps = { - "data-selection-index": selection.getItems().findIndex((term) => term.id === groupHeaderProps.group.key) - }; - if (props.allowMultipleSelections) { - if (isDisabled) { - selectionProps["data-selection-disabled"] = true; - } - else { - selectionProps["data-selection-toggle"] = true; - } - - const selectedStyles: IStyleFunctionOrObject = { root: { pointerEvents: 'none' } }; + const checkBoxStyles: IStyleFunctionOrObject = {root: { flex: "1" } }; if (isSelected || childIsSelected) { - selectedStyles.label = { fontWeight: 'bold' }; + checkBoxStyles.label = { fontWeight: 'bold' }; } else { - selectedStyles.label = { fontWeight: 'normal' }; + checkBoxStyles.label = { fontWeight: 'normal' }; } return ( -
+ {p.label} } + onChange={(ev?: React.FormEvent, checked?: boolean) => { + selection.setKeySelected(groupHeaderProps.group.key, checked, false); + }} /> -
+
+ {props.onRenderActionButton && props.onRenderActionButton(props.termStoreInfo, props.termSetInfo, groupHeaderProps.group.data.term)} +
+ ); } else { - const selectedStyle: IStyleFunctionOrObject = isSelected || childIsSelected ? { root: { marginTop: 0 }, choiceFieldWrapper: { fontWeight: 'bold', } } : { root: { marginTop: 0 }, choiceFieldWrapper: { fontWeight: 'normal' } }; + const choiceGroupOptionStyles: IStyleFunctionOrObject = isSelected || childIsSelected ? { root: {marginTop: 0}, choiceFieldWrapper: { fontWeight: 'bold', flex: '1' }, field: { width: '100%'} } : { root: {marginTop: 0}, choiceFieldWrapper: { fontWeight: 'normal', flex: '1' }, field: { width: '100%'} }; const options: IChoiceGroupOption[] = [{ - key: groupHeaderProps.group.key, - text: groupHeaderProps.group.name, - styles: selectedStyle, - onRenderLabel: (p) => - - {p.text} - - }]; - - if (isDisabled) { - selectionProps["data-selection-disabled"] = true; - } - else { - selectionProps["data-selection-select"] = true; - } + key: groupHeaderProps.group.key, + text: groupHeaderProps.group.name, + styles: choiceGroupOptionStyles, + onRenderLabel: (p) => + + {p.text} + , + onClick: () => { + selection.setAllSelected(false); + selection.setKeySelected(groupHeaderProps.group.key, true, false); + } + }]; + + const choiceGroupStyles: IStyleFunctionOrObject = { root: { flex: "1" }, applicationRole: { width: "100%" } }; return ( -
- -
+ + +
+ {props.onRenderActionButton && props.onRenderActionButton(props.termStoreInfo, props.termSetInfo, groupHeaderProps.group.data.term)} +
+
); } }; @@ -330,14 +356,28 @@ export function TaxonomyPanelContents(props: ITaxonomyPanelContentsProps): React root: { height: 42 }, }; + const isDisabled = headerProps.group.data.term && headerProps.group.data.term.isAvailableForTagging.filter((t) => t.setId === props.termSetId.toString())[0].isAvailable === false; + return ( , group: IGroup) => { + if ((ev.keyCode == 32 || ev.keyCode == 13) && !isDisabled) { + if (props.allowMultipleSelections) { + selection.toggleKeySelected(headerProps.group.key); + } + else { + selection.setAllSelected(false); + selection.setKeySelected(headerProps.group.key, true, false); + } + } + }} /> ); }; @@ -421,7 +461,11 @@ export function TaxonomyPanelContents(props: ITaxonomyPanelContentsProps): React } }; - const termPickerStyles: IStyleFunctionOrObject = { root: { paddingTop: 4, paddingBottom: 4, paddingRight: 4, minheight: 34 }, input: { minheight: 34 }, text: { minheight: 34, borderStyle: 'none', borderWidth: '0px' } }; + const shouldEnterInnerZone = (ev: React.KeyboardEvent): boolean => { + return ev.which === getRTLSafeKeyCode(KeyCodes.right); + }; + + const termPickerStyles: IStyleFunctionOrObject = { root: {paddingTop: 4, paddingBottom: 4, paddingRight: 4, minheight: 34}, input: {minheight: 34}, text: { minheight: 34, borderStyle: 'none', borderWidth: '0px' } }; return (
@@ -442,25 +486,22 @@ export function TaxonomyPanelContents(props: ITaxonomyPanelContentsProps): React placeholder: props.placeHolder || strings.ModernTaxonomyPickerDefaultPlaceHolder }} onRenderSuggestionsItem={props.termPickerProps?.onRenderSuggestionsItem ?? props.onRenderSuggestionsItem} - onRenderItem={props.onRenderItem ?? props.onRenderItem} + onRenderItem={props.onRenderItem} themeVariant={props.themeVariant} />
- - ) => false} - /> - + ) => false} + data-is-focusable={true} + focusZoneProps={{direction: FocusZoneDirection.vertical, shouldEnterInnerZone: shouldEnterInnerZone}} + />
); diff --git a/src/webparts/controlsTest/ControlsTestWebPart.ts b/src/webparts/controlsTest/ControlsTestWebPart.ts index 6db036a85..3775a3efc 100644 --- a/src/webparts/controlsTest/ControlsTestWebPart.ts +++ b/src/webparts/controlsTest/ControlsTestWebPart.ts @@ -79,7 +79,7 @@ export default class ControlsTestWebPart extends BaseClientSideWebPart = React.createElement( - ControlsTest, + ControlsTest_SingleComponent, { themeVariant: this._themeVariant, diff --git a/src/webparts/controlsTest/components/ControlsTest_SingleComponent.tsx b/src/webparts/controlsTest/components/ControlsTest_SingleComponent.tsx index 44ca82683..1f788242f 100644 --- a/src/webparts/controlsTest/components/ControlsTest_SingleComponent.tsx +++ b/src/webparts/controlsTest/components/ControlsTest_SingleComponent.tsx @@ -3,7 +3,7 @@ import styles from './ControlsTest.module.scss'; import { IControlsTestProps, IControlsTestState } from './IControlsTestProps'; import { FileTypeIcon, IconType, ApplicationType, ImageSize } from '../../../FileTypeIcon'; import { Dropdown, IDropdownOption } from 'office-ui-fabric-react/lib/components/Dropdown'; -import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/components/Button'; +import { PrimaryButton, DefaultButton, IconButton, IButtonProps } from 'office-ui-fabric-react/lib/components/Button'; import { DialogType } from 'office-ui-fabric-react/lib/components/Dialog'; import { Placeholder } from '../../../Placeholder'; import { ListView, IViewField, SelectionMode, GroupOrder, IGrouping } from '../../../ListView'; @@ -14,7 +14,7 @@ import { TaxonomyPicker, IPickerTerms, UpdateType } from '../../../TaxonomyPicke import { ListPicker } from '../../../ListPicker'; import { IFrameDialog } from '../../../IFrameDialog'; import { IFramePanel } from '../../../IFramePanel'; -import { PanelType } from 'office-ui-fabric-react/lib/Panel'; +import { Panel, PanelType } from 'office-ui-fabric-react/lib/Panel'; import { Environment, EnvironmentType, DisplayMode } from '@microsoft/sp-core-library'; import { SecurityTrimmedControl, PermissionLevel } from '../../../SecurityTrimmedControl'; import { SPPermission } from '@microsoft/sp-page-context'; @@ -58,6 +58,8 @@ import { FilePicker, IFilePickerResult } from '../../../FilePicker'; import { FolderExplorer, IFolder, IBreadcrumbItem } from '../../../FolderExplorer'; import { Pagination } from '../../../controls/pagination'; import { ModernTaxonomyPicker } from '../../../ModernTaxonomyPicker'; +import { IContextualMenuProps, IIconProps } from 'office-ui-fabric-react'; +import { ITermInfo, ITermSetInfo, ITermStoreInfo } from '@pnp/sp/taxonomy'; /** * The sample data below was randomly generated (except for the title). It is used by the grid layout @@ -137,8 +139,10 @@ export default class ControlsTest extends React.Component { + this.setState({termPanelIsOpen: true, actionTermId: id}); + }; + return (
alert(values.map((value) => `${value?.id} - ${value?.labels[0].name}`).join("\n"))} + // initialValues={[{labels: [{name: "Subprocess A1", isDefault: true, languageTag: "en-US"}], id: "29eced8f-cf08-454b-bd9e-6443bc0a0f5e", childrenCount: 0, createdDateTime: "", lastModifiedDateTime: "", descriptions: [], customSortOrder: [], properties: [], localProperties: [], isDeprecated: false, isAvailableForTagging: [], topicRequested: false}]} + // onChange={(values) => alert(values.map((value) => `${value?.id} - ${value?.labels[0].name}`).join("\n"))} disabled={false} - customPanelWidth={400} + customPanelWidth={700} + isLightDismiss={false} + isBlocking={false} + onRenderActionButton={(termStoreInfo: ITermStoreInfo, termSetInfo: ITermSetInfo, termInfo?: ITermInfo) => { + const menuIcon: IIconProps = { iconName: 'MoreVertical', "aria-label": "More actions", style: { fontSize: "medium" } }; + if (termInfo) { + const menuProps: IContextualMenuProps = { + items: [ + { + key: 'addTerm', + text: 'Add Term', + iconProps: { iconName: 'Tag' }, + onClick: () => onContextualMenuClick(termInfo.id) + }, + { + key: 'deleteTerm', + text: 'Delete term', + iconProps: { iconName: 'Untag' }, + onClick: () => onContextualMenuClick(termInfo.id) + }, + ], + }; + + return ( + | React.KeyboardEvent, button?: IButtonProps) => { + this.setState({clickedActionTerm: termInfo}); + }} + onAfterMenuDismiss={() => this.setState({clickedActionTerm: null})} + /> + ); + } + else { + const menuProps: IContextualMenuProps = { + items: [ + { + key: 'addTerm', + text: 'Add term', + iconProps: { iconName: 'Tag' }, + onClick: () => onContextualMenuClick(termSetInfo.id) + }, + ], + }; + return ( + + ); + } + }} /> + this.setState({termPanelIsOpen: false, actionTermId: null})} + hasCloseButton={true} + isLightDismiss={false} + isBlocking={false} + > + {this.state.actionTermId && this.state.actionTermId} +
); } diff --git a/src/webparts/controlsTest/components/IControlsTestProps.ts b/src/webparts/controlsTest/components/IControlsTestProps.ts index 018e5b9f9..f2414de84 100644 --- a/src/webparts/controlsTest/components/IControlsTestProps.ts +++ b/src/webparts/controlsTest/components/IControlsTestProps.ts @@ -8,6 +8,7 @@ import { IReadonlyTheme, } from "@microsoft/sp-component-base"; +import { ITermInfo } from '@pnp/sp/taxonomy'; export interface IControlsTestProps { context: WebPartContext; description: string; @@ -45,4 +46,7 @@ export interface IControlsTestState { selectedTeamChannels:ITag[]; filePickerDefaultFolderAbsolutePath?: string; errorMessage?: string; + termPanelIsOpen?: boolean; + actionTermId?: string; + clickedActionTerm?: ITermInfo; } From d8265f3115e3dc76d1b12c075d6596bc76a9abeb Mon Sep 17 00:00:00 2001 From: Patrik Hellgren Date: Fri, 12 Nov 2021 01:19:28 +0100 Subject: [PATCH 2/6] Refactored TaxonomyTree into a separate component --- .../docs/controls/ModernTaxonomyPicker.md | 45 +- .../ModernTaxonomyPicker.tsx | 4 +- .../TaxonomyPanelContents.module.scss | 61 --- .../TaxonomyPanelContents.tsx | 420 +---------------- .../taxonomyTree/TaxonomyTree.module.scss | 62 +++ .../taxonomyTree/TaxonomyTree.tsx | 445 ++++++++++++++++++ 6 files changed, 566 insertions(+), 471 deletions(-) create mode 100644 src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.module.scss create mode 100644 src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.tsx diff --git a/docs/documentation/docs/controls/ModernTaxonomyPicker.md b/docs/documentation/docs/controls/ModernTaxonomyPicker.md index 0909af0d8..7fd9b34cb 100644 --- a/docs/documentation/docs/controls/ModernTaxonomyPicker.md +++ b/docs/documentation/docs/controls/ModernTaxonomyPicker.md @@ -68,7 +68,7 @@ Custom rendering of a More actions button that displays a context menu for each customPanelWidth={700} isLightDismiss={false} isBlocking={false} - onRenderActionButton={(termStoreInfo: ITermStoreInfo, termSetInfo: ITermSetInfo, termInfo?: ITermInfo) => { + onRenderActionButton={(termStoreInfo: ITermStoreInfo, termSetInfo: ITermSetInfo, termInfo?: ITermInfo): JSX.Element => { const menuIcon: IIconProps = { iconName: 'MoreVertical', "aria-label": "More actions", style: { fontSize: "medium" } }; if (termInfo) { const menuProps: IContextualMenuProps = { @@ -147,4 +147,45 @@ The ModernTaxonomyPicker control can be configured with the following properties | isBlocking | boolean | no | Whether the panel uses a modal overlay or not. | | onRenderActionButton | function | no | Optional custom renderer for adding e.g. a button with additional actions to the terms in the tree view. | -![](https://telemetry.sharepointpnp.com/sp-dev-fx-controls-react/wiki/controls/TaxonomyPicker) +## Standalone TaxonomyTree control + +You can also use the `TaxonomyTree` control separately to just render a stand-alone tree-view of a term set with action buttons. + +- Use the `TaxonomyTree` control in your code as follows: + Initialize the taxonomy service and state, load basic info from term store and display the `TaxonomyTree` component. + +```TypeScript + const taxonomyService = new SPTaxonomyService(props.context); + const [terms, setTerms] = React.useState(); + const [currentTermStoreInfo, setCurrentTermStoreInfo] = React.useState(); + const [currentTermSetInfo, setCurrentTermSetInfo] = React.useState(); + const [currentLanguageTag, setCurrentLanguageTag] = React.useState(""); + + React.useEffect(() => { + sp.setup(props.context); + taxonomyService.getTermStoreInfo() + .then((termStoreInfo) => { + setCurrentTermStoreInfo(termStoreInfo); + setCurrentLanguageTag(props.context.pageContext.cultureInfo.currentUICultureName !== '' ? + props.context.pageContext.cultureInfo.currentUICultureName : + currentTermStoreInfo.defaultLanguageTag); + }); + taxonomyService.getTermSetInfo(Guid.parse(props.termSetId)) + .then((termSetInfo) => { + setCurrentTermSetInfo(termSetInfo); + }); + }, []); + + +``` + +![](https://telemetry.sharepointpnp.com/sp-dev-fx-controls-react/wiki/controls/ModernTaxonomyPicker) diff --git a/src/controls/modernTaxonomyPicker/ModernTaxonomyPicker.tsx b/src/controls/modernTaxonomyPicker/ModernTaxonomyPicker.tsx index c16fa604e..e269259b2 100644 --- a/src/controls/modernTaxonomyPicker/ModernTaxonomyPicker.tsx +++ b/src/controls/modernTaxonomyPicker/ModernTaxonomyPicker.tsx @@ -66,7 +66,7 @@ export interface IModernTaxonomyPickerProps { } export function ModernTaxonomyPicker(props: IModernTaxonomyPickerProps) { - const [taxonomyService] = React.useState(() => new SPTaxonomyService(props.context)); + const taxonomyService = new SPTaxonomyService(props.context); const [panelIsOpen, setPanelIsOpen] = React.useState(false); const [selectedOptions, setSelectedOptions] = React.useState([]); const [selectedPanelOptions, setSelectedPanelOptions] = React.useState([]); @@ -265,8 +265,6 @@ export function ModernTaxonomyPicker(props: IModernTaxonomyPickerProps) { anchorTermInfo={currentAnchorTermInfo} termSetInfo={currentTermSetInfo} termStoreInfo={currentTermStoreInfo} - context={props.context} - termSetId={Guid.parse(props.termSetId)} pageSize={50} selectedPanelOptions={selectedPanelOptions} setSelectedPanelOptions={setSelectedPanelOptions} diff --git a/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.module.scss b/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.module.scss index 65bec7646..b49ab0a0b 100644 --- a/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.module.scss +++ b/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.module.scss @@ -1,36 +1,6 @@ @import '~office-ui-fabric-react/dist/sass/References.scss'; .taxonomyPanelContents { - .choiceOption { - color: "[theme: bodyText, default: #323130]"; - display: inline-block; - padding-inline-start: 26px; - } - - .disabledChoiceOption { - color: "[theme: disabledBodyText, default: #323130]"; - display: inline-block; - padding-inline-start: 26px; - } - - .selectedChoiceOption { - font-weight: bold; - } - - .checkbox { - color: "[theme: bodyText, default: #323130]"; - margin-inline-start: 4px; - } - - .disabledCheckbox { - color: "[theme: disabledBodyText, default: #323130]"; - margin-inline-start: 4px; - } - - .selectedCheckbox { - font-weight: bold; - } - .taxonomyTreeSelector { border-bottom-color: blue; border-bottom-style: solid; @@ -42,35 +12,4 @@ font-size: 18px; font-weight: 100; } - - .spinnerContainer { - height: 48px; - line-height: 48px; - display: flex; - justify-content: center; - align-items: center; - } - - .loadMoreContainer { - height: 48px; - line-height: 48px; - } - - .taxonomyItemFocusZone { - display: flex; - align-items: center; - width: 100%; - } - - .taxonomyItemHeader { - width: 100%; - } - - .taxonomyItemHeader .actionButtonContainer > * { - opacity: 0; - } - - .taxonomyItemHeader:hover .actionButtonContainer > *, .taxonomyItemHeader:focus .actionButtonContainer > *, .taxonomyItemHeader .actionButtonContainer:focus-within > * { - opacity: 1; - } } diff --git a/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.tsx b/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.tsx index 18476fefa..3825c5516 100644 --- a/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.tsx +++ b/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.tsx @@ -1,65 +1,28 @@ import * as React from 'react'; import styles from './TaxonomyPanelContents.module.scss'; -import { Checkbox, - ChoiceGroup, - FocusZone, - FocusZoneDirection, - getRTLSafeKeyCode, - GroupedList, - GroupHeader, - IBasePickerStyleProps, +import { IBasePickerStyleProps, IBasePickerStyles, - ICheckboxStyleProps, - ICheckboxStyles, - IChoiceGroupOption, - IChoiceGroupOptionStyleProps, - IChoiceGroupOptionStyles, - IChoiceGroupStyleProps, - IChoiceGroupStyles, - IGroup, - IGroupFooterProps, - IGroupHeaderProps, - IGroupHeaderStyleProps, - IGroupHeaderStyles, - IGroupRenderProps, - IGroupShowAllProps, - ILabelStyleProps, - ILabelStyles, - ILinkStyleProps, - ILinkStyles, - IListProps, IPickerItemProps, - IRenderFunction, - ISpinnerStyleProps, - ISpinnerStyles, IStyleFunctionOrObject, ISuggestionItemProps, - KeyCodes, Label, - Link, Selection, - SelectionMode, - SelectionZone, - Spinner } from 'office-ui-fabric-react'; import { ITermInfo, ITermSetInfo, ITermStoreInfo } from '@pnp/sp/taxonomy'; import { Guid } from '@microsoft/sp-core-library'; -import { BaseComponentContext } from '@microsoft/sp-component-base'; -import { css } from '@uifabric/utilities/lib/css'; import * as strings from 'ControlStrings'; import { useForceUpdate } from '@uifabric/react-hooks'; import { ModernTermPicker } from '../modernTermPicker/ModernTermPicker'; import { IReadonlyTheme } from "@microsoft/sp-component-base"; import { IModernTermPickerProps } from '../modernTermPicker/ModernTermPicker.types'; import { Optional } from '../ModernTaxonomyPicker'; +import { TaxonomyTree } from '../taxonomyTree/TaxonomyTree'; export interface ITaxonomyPanelContentsProps { - context: BaseComponentContext; allowMultipleSelections?: boolean; - termSetId: Guid; pageSize: number; selectedPanelOptions: ITermInfo[]; setSelectedPanelOptions: React.Dispatch>; @@ -79,8 +42,6 @@ export interface ITaxonomyPanelContentsProps { } export function TaxonomyPanelContents(props: ITaxonomyPanelContentsProps): React.ReactElement { - const [groupsLoading, setGroupsLoading] = React.useState([]); - const [groups, setGroups] = React.useState([]); const [terms, setTerms] = React.useState(props.selectedPanelOptions?.length > 0 ? [...props.selectedPanelOptions] : []); const forceUpdate = useForceUpdate(); @@ -101,355 +62,6 @@ export function TaxonomyPanelContents(props: ITaxonomyPanelContentsProps): React return s; }, [terms]); - React.useEffect(() => { - let termRootName = ""; - if (props.anchorTermInfo) { - let anchorTermNames = props.anchorTermInfo.labels.filter((name) => name.languageTag === props.languageTag && name.isDefault); - if (anchorTermNames.length === 0) { - anchorTermNames = props.anchorTermInfo.labels.filter((name) => name.languageTag === props.termStoreInfo.defaultLanguageTag && name.isDefault); - } - termRootName = anchorTermNames[0].name; - } - else { - let termSetNames = props.termSetInfo.localizedNames.filter((name) => name.languageTag === props.languageTag); - if (termSetNames.length === 0) { - termSetNames = props.termSetInfo.localizedNames.filter((name) => name.languageTag === props.termStoreInfo.defaultLanguageTag); - } - termRootName = termSetNames[0].name; - } - const rootGroup: IGroup = { - name: termRootName, - key: props.anchorTermInfo ? props.anchorTermInfo.id : props.termSetInfo.id, - startIndex: -1, - count: 50, - level: 0, - isCollapsed: false, - data: { skiptoken: '' }, - hasMoreData: (props.anchorTermInfo ? props.anchorTermInfo.childrenCount : props.termSetInfo.childrenCount) > 0 - }; - setGroups([rootGroup]); - setGroupsLoading((prevGroupsLoading) => [...prevGroupsLoading, props.termSetInfo.id]); - if (props.termSetInfo.childrenCount > 0) { - props.onLoadMoreData(props.termSetId, props.anchorTermInfo ? Guid.parse(props.anchorTermInfo.id) : Guid.empty, '', true) - .then((loadedTerms) => { - const grps: IGroup[] = loadedTerms.value.map(term => { - let termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.languageTag && termLabel.isDefault === true)); - if (termNames.length === 0) { - termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.termStoreInfo.defaultLanguageTag && termLabel.isDefault === true)); - } - const g: IGroup = { - name: termNames[0]?.name, - key: term.id, - startIndex: -1, - count: 50, - level: 1, - isCollapsed: true, - data: { skiptoken: '', term: term }, - hasMoreData: term.childrenCount > 0, - }; - if (g.hasMoreData) { - g.children = []; - } - return g; - }); - setTerms((prevTerms) => { - const nonExistingTerms = loadedTerms.value.filter((term) => prevTerms.every((prevTerm) => prevTerm.id !== term.id)); - return [...prevTerms, ...nonExistingTerms]; - }); - rootGroup.children = grps; - rootGroup.data.skiptoken = loadedTerms.skiptoken; - rootGroup.hasMoreData = loadedTerms.skiptoken !== ''; - setGroupsLoading((prevGroupsLoading) => prevGroupsLoading.filter((value) => value !== props.termSetId.toString())); - setGroups([rootGroup]); - }); - } - }, []); - - const onToggleCollapse = (group: IGroup): void => { - if (group.isCollapsed === true) { - setGroups((prevGroups) => { - const recurseGroups = (currentGroup: IGroup) => { - if (currentGroup.key === group.key) { - currentGroup.isCollapsed = false; - } - if (currentGroup.children?.length > 0) { - for (const child of currentGroup.children) { - recurseGroups(child); - } - } - }; - let newGroupsState: IGroup[] = []; - for (const prevGroup of prevGroups) { - recurseGroups(prevGroup); - newGroupsState.push(prevGroup); - } - - return newGroupsState; - }); - - if (group.children && group.children.length === 0) { - setGroupsLoading((prevGroupsLoading) => [...prevGroupsLoading, group.key]); - group.data.isLoading = true; - - props.onLoadMoreData(props.termSetId, Guid.parse(group.key), '', true) - .then((loadedTerms) => { - const grps: IGroup[] = loadedTerms.value.map(term => { - let termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.languageTag && termLabel.isDefault === true)); - if (termNames.length === 0) { - termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.termStoreInfo.defaultLanguageTag && termLabel.isDefault === true)); - } - const g: IGroup = { - name: termNames[0]?.name, - key: term.id, - startIndex: -1, - count: 50, - level: group.level + 1, - isCollapsed: true, - data: { skiptoken: '', term: term }, - hasMoreData: term.childrenCount > 0, - }; - if (g.hasMoreData) { - g.children = []; - } - return g; - }); - - setTerms((prevTerms) => { - const nonExistingTerms = loadedTerms.value.filter((term) => prevTerms.every((prevTerm) => prevTerm.id !== term.id)); - return [...prevTerms, ...nonExistingTerms]; - }); - - group.children = grps; - group.data.skiptoken = loadedTerms.skiptoken; - group.hasMoreData = loadedTerms.skiptoken !== ''; - setGroupsLoading((prevGroupsLoading) => prevGroupsLoading.filter((value) => value !== group.key)); - }); - } - } - else { - setGroups((prevGroups) => { - const recurseGroups = (currentGroup: IGroup) => { - if (currentGroup.key === group.key) { - currentGroup.isCollapsed = true; - } - if (currentGroup.children?.length > 0) { - for (const child of currentGroup.children) { - recurseGroups(child); - } - } - }; - let newGroupsState: IGroup[] = []; - for (const prevGroup of prevGroups) { - recurseGroups(prevGroup); - newGroupsState.push(prevGroup); - } - - return newGroupsState; - }); - - } - }; - - const onRenderTitle = (groupHeaderProps: IGroupHeaderProps) => { - const isChildSelected = (children: IGroup[]): boolean => { - let aChildIsSelected = children && children.some((child) => selection.isKeySelected(child.key) || isChildSelected(child.children)); - return aChildIsSelected; - }; - - const childIsSelected = isChildSelected(groupHeaderProps.group.children); - - if (groupHeaderProps.group.level === 0) { - const labelStyles: IStyleFunctionOrObject = {root: {width: "100%", fontWeight: childIsSelected ? "bold" : "normal"}}; - return ( - - -
- {props.onRenderActionButton && props.onRenderActionButton(props.termStoreInfo, props.termSetInfo, props.anchorTermInfo)} -
-
- ); - } - - const isDisabled = groupHeaderProps.group.data.term.isAvailableForTagging.filter((t) => t.setId === props.termSetId.toString())[0].isAvailable === false; - const isSelected = selection.isKeySelected(groupHeaderProps.group.key); - - if (props.allowMultipleSelections) { - const checkBoxStyles: IStyleFunctionOrObject = {root: { flex: "1" } }; - if (isSelected || childIsSelected) { - checkBoxStyles.label = { fontWeight: 'bold' }; - } - else { - checkBoxStyles.label = { fontWeight: 'normal' }; - } - - return ( - - - {p.label} - } - onChange={(ev?: React.FormEvent, checked?: boolean) => { - selection.setKeySelected(groupHeaderProps.group.key, checked, false); - }} - /> -
- {props.onRenderActionButton && props.onRenderActionButton(props.termStoreInfo, props.termSetInfo, groupHeaderProps.group.data.term)} -
-
- ); - } - else { - const choiceGroupOptionStyles: IStyleFunctionOrObject = isSelected || childIsSelected ? { root: {marginTop: 0}, choiceFieldWrapper: { fontWeight: 'bold', flex: '1' }, field: { width: '100%'} } : { root: {marginTop: 0}, choiceFieldWrapper: { fontWeight: 'normal', flex: '1' }, field: { width: '100%'} }; - const options: IChoiceGroupOption[] = [{ - key: groupHeaderProps.group.key, - text: groupHeaderProps.group.name, - styles: choiceGroupOptionStyles, - onRenderLabel: (p) => - - {p.text} - , - onClick: () => { - selection.setAllSelected(false); - selection.setKeySelected(groupHeaderProps.group.key, true, false); - } - }]; - - const choiceGroupStyles: IStyleFunctionOrObject = { root: { flex: "1" }, applicationRole: { width: "100%" } }; - - return ( - - -
- {props.onRenderActionButton && props.onRenderActionButton(props.termStoreInfo, props.termSetInfo, groupHeaderProps.group.data.term)} -
-
- ); - } - }; - - const onRenderHeader = (headerProps: IGroupHeaderProps): JSX.Element => { - const groupHeaderStyles: IStyleFunctionOrObject = { - expand: { height: 42, visibility: !headerProps.group.children || headerProps.group.level === 0 ? "hidden" : "visible", fontSize: 14 }, - expandIsCollapsed: { visibility: !headerProps.group.children || headerProps.group.level === 0 ? "hidden" : "visible", fontSize: 14 }, - check: { display: 'none' }, - headerCount: { display: 'none' }, - groupHeaderContainer: { height: 36, paddingTop: 3, paddingBottom: 3, paddingLeft: 3, paddingRight: 3, alignItems: 'center', }, - root: { height: 42 }, - }; - - const isDisabled = headerProps.group.data.term && headerProps.group.data.term.isAvailableForTagging.filter((t) => t.setId === props.termSetId.toString())[0].isAvailable === false; - - return ( - , group: IGroup) => { - if ((ev.keyCode == 32 || ev.keyCode == 13) && !isDisabled) { - if (props.allowMultipleSelections) { - selection.toggleKeySelected(headerProps.group.key); - } - else { - selection.setAllSelected(false); - selection.setKeySelected(headerProps.group.key, true, false); - } - } - }} - /> - ); - }; - - const onRenderFooter = (footerProps: IGroupFooterProps): JSX.Element => { - if ((footerProps.group.hasMoreData || footerProps.group.children && footerProps.group.children.length === 0) && !footerProps.group.isCollapsed) { - - if (groupsLoading.some(value => value === footerProps.group.key)) { - const spinnerStyles: IStyleFunctionOrObject = { circle: { verticalAlign: 'middle' } }; - return ( -
- -
- ); - } - const linkStyles: IStyleFunctionOrObject = { root: { fontSize: '14px', paddingLeft: (footerProps.groupLevel + 1) * 20 + 62 } }; - return ( -
- { - setGroupsLoading((prevGroupsLoading) => [...prevGroupsLoading, footerProps.group.key]); - props.onLoadMoreData(props.termSetId, footerProps.group.key === props.termSetId.toString() ? Guid.empty : Guid.parse(footerProps.group.key), footerProps.group.data.skiptoken, true) - .then((loadedTerms) => { - const grps: IGroup[] = loadedTerms.value.map(term => { - let termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.languageTag && termLabel.isDefault === true)); - if (termNames.length === 0) { - termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.termStoreInfo.defaultLanguageTag && termLabel.isDefault === true)); - } - const g: IGroup = { - name: termNames[0]?.name, - key: term.id, - startIndex: -1, - count: 50, - level: footerProps.group.level + 1, - isCollapsed: true, - data: { skiptoken: '', term: term }, - hasMoreData: term.childrenCount > 0, - }; - if (g.hasMoreData) { - g.children = []; - } - return g; - }); - setTerms((prevTerms) => { - const nonExistingTerms = loadedTerms.value.filter((term) => prevTerms.every((prevTerm) => prevTerm.id !== term.id)); - return [...prevTerms, ...nonExistingTerms]; - }); - footerProps.group.children = [...footerProps.group.children, ...grps]; - footerProps.group.data.skiptoken = loadedTerms.skiptoken; - footerProps.group.hasMoreData = loadedTerms.skiptoken !== ''; - setGroupsLoading((prevGroupsLoading) => prevGroupsLoading.filter((value) => value !== footerProps.group.key)); - }); - }} - styles={linkStyles}> - {strings.ModernTaxonomyPickerLoadMoreText} - -
- ); - } - return null; - }; - - const onRenderShowAll: IRenderFunction = () => { - return null; - }; - - const groupProps: IGroupRenderProps = { - onRenderFooter: onRenderFooter, - onRenderHeader: onRenderHeader, - showEmptyGroups: true, - onRenderShowAll: onRenderShowAll, - }; - const onPickerChange = (items?: ITermInfo[]): void => { const itemsToAdd = items.filter((item) => terms.every((term) => term.id !== item.id)); setTerms((prevTerms) => [...prevTerms, ...itemsToAdd]); @@ -461,10 +73,6 @@ export function TaxonomyPanelContents(props: ITaxonomyPanelContentsProps): React } }; - const shouldEnterInnerZone = (ev: React.KeyboardEvent): boolean => { - return ev.which === getRTLSafeKeyCode(KeyCodes.right); - }; - const termPickerStyles: IStyleFunctionOrObject = { root: {paddingTop: 4, paddingBottom: 4, paddingRight: 4, minheight: 34}, input: {minheight: 34}, text: { minheight: 34, borderStyle: 'none', borderWidth: '0px' } }; return ( @@ -492,17 +100,19 @@ export function TaxonomyPanelContents(props: ITaxonomyPanelContentsProps): React -
- ) => false} - data-is-focusable={true} - focusZoneProps={{direction: FocusZoneDirection.vertical, shouldEnterInnerZone: shouldEnterInnerZone}} - /> -
+ ); } diff --git a/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.module.scss b/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.module.scss new file mode 100644 index 000000000..8f075b064 --- /dev/null +++ b/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.module.scss @@ -0,0 +1,62 @@ +@import '~office-ui-fabric-react/dist/sass/References.scss'; + +.choiceOption { + color: "[theme: bodyText, default: #323130]"; + display: inline-block; + padding-inline-start: 26px; +} + +.disabledChoiceOption { + color: "[theme: disabledBodyText, default: #323130]"; + display: inline-block; + padding-inline-start: 26px; +} + +.selectedChoiceOption { + font-weight: bold; +} + +.checkbox { + color: "[theme: bodyText, default: #323130]"; + margin-inline-start: 4px; +} + +.disabledCheckbox { + color: "[theme: disabledBodyText, default: #323130]"; + margin-inline-start: 4px; +} + +.selectedCheckbox { + font-weight: bold; +} + +.spinnerContainer { + height: 48px; + line-height: 48px; + display: flex; + justify-content: center; + align-items: center; +} + +.loadMoreContainer { + height: 48px; + line-height: 48px; +} + +.taxonomyItemFocusZone { + display: flex; + align-items: center; + width: 100%; +} + +.taxonomyItemHeader { + width: 100%; +} + +.taxonomyItemHeader .actionButtonContainer > * { + opacity: 0; +} + +.taxonomyItemHeader:hover .actionButtonContainer > *, .taxonomyItemHeader:focus .actionButtonContainer > *, .taxonomyItemHeader .actionButtonContainer:focus-within > * { + opacity: 1; +} diff --git a/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.tsx b/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.tsx new file mode 100644 index 000000000..1dd3ad6a8 --- /dev/null +++ b/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.tsx @@ -0,0 +1,445 @@ +import * as React from 'react'; +import { Checkbox, + ChoiceGroup, + css, + FocusZone, + FocusZoneDirection, + getRTLSafeKeyCode, + GroupedList, + GroupHeader, + ICheckboxStyleProps, + ICheckboxStyles, + IChoiceGroupOption, + IChoiceGroupOptionStyleProps, + IChoiceGroupOptionStyles, + IChoiceGroupStyleProps, + IChoiceGroupStyles, + IGroup, + IGroupFooterProps, + IGroupHeaderProps, + IGroupHeaderStyleProps, + IGroupHeaderStyles, + IGroupRenderProps, + IGroupShowAllProps, + ILabelStyleProps, + ILabelStyles, + ILinkStyleProps, + ILinkStyles, + IListProps, + IRenderFunction, + ISpinnerStyleProps, + ISpinnerStyles, + IStyleFunctionOrObject, + KeyCodes, + Label, + Link, + Selection, + Spinner + } from 'office-ui-fabric-react'; +import * as strings from 'ControlStrings'; +import { IReadonlyTheme } from '@microsoft/sp-component-base'; +import { Guid } from '@microsoft/sp-core-library'; +import { ITermInfo, ITermSetInfo, ITermStoreInfo } from '@pnp/sp/taxonomy'; +import styles from './TaxonomyTree.module.scss'; + +export interface ITaxonomyTreeProps { + allowMultipleSelections?: boolean; + pageSize: number; + onLoadMoreData: (termSetId: Guid, parentTermId?: Guid, skiptoken?: string, hideDeprecatedTerms?: boolean, pageSize?: number) => Promise<{ value: ITermInfo[], skiptoken: string }>; + anchorTermInfo?: ITermInfo; + termSetInfo: ITermSetInfo; + termStoreInfo: ITermStoreInfo; + languageTag: string; + themeVariant?: IReadonlyTheme; + onRenderActionButton?: (termStoreInfo: ITermStoreInfo, termSetInfo: ITermSetInfo, termInfo?: ITermInfo) => JSX.Element; + terms: ITermInfo[]; + setTerms: React.Dispatch>; + selection?: Selection; +} + +export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement { + const [groupsLoading, setGroupsLoading] = React.useState([]); + const [groups, setGroups] = React.useState([]); + + React.useEffect(() => { + let termRootName = ""; + if (props.anchorTermInfo) { + let anchorTermNames = props.anchorTermInfo.labels.filter((name) => name.languageTag === props.languageTag && name.isDefault); + if (anchorTermNames.length === 0) { + anchorTermNames = props.anchorTermInfo.labels.filter((name) => name.languageTag === props.termStoreInfo.defaultLanguageTag && name.isDefault); + } + termRootName = anchorTermNames[0].name; + } + else { + let termSetNames = props.termSetInfo.localizedNames.filter((name) => name.languageTag === props.languageTag); + if (termSetNames.length === 0) { + termSetNames = props.termSetInfo.localizedNames.filter((name) => name.languageTag === props.termStoreInfo.defaultLanguageTag); + } + termRootName = termSetNames[0].name; + } + const rootGroup: IGroup = { + name: termRootName, + key: props.anchorTermInfo ? props.anchorTermInfo.id : props.termSetInfo.id, + startIndex: -1, + count: 50, + level: 0, + isCollapsed: false, + data: { skiptoken: '' }, + hasMoreData: (props.anchorTermInfo ? props.anchorTermInfo.childrenCount : props.termSetInfo.childrenCount) > 0 + }; + setGroups([rootGroup]); + setGroupsLoading((prevGroupsLoading) => [...prevGroupsLoading, props.termSetInfo.id]); + if (props.termSetInfo.childrenCount > 0) { + props.onLoadMoreData(Guid.parse(props.termSetInfo.id), props.anchorTermInfo ? Guid.parse(props.anchorTermInfo.id) : Guid.empty, '', true) + .then((loadedTerms) => { + const grps: IGroup[] = loadedTerms.value.map(term => { + let termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.languageTag && termLabel.isDefault === true)); + if (termNames.length === 0) { + termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.termStoreInfo.defaultLanguageTag && termLabel.isDefault === true)); + } + const g: IGroup = { + name: termNames[0]?.name, + key: term.id, + startIndex: -1, + count: 50, + level: 1, + isCollapsed: true, + data: { skiptoken: '', term: term }, + hasMoreData: term.childrenCount > 0, + }; + if (g.hasMoreData) { + g.children = []; + } + return g; + }); + props.setTerms((prevTerms) => { + const nonExistingTerms = loadedTerms.value.filter((term) => prevTerms.every((prevTerm) => prevTerm.id !== term.id)); + return [...prevTerms, ...nonExistingTerms]; + }); + rootGroup.children = grps; + rootGroup.data.skiptoken = loadedTerms.skiptoken; + rootGroup.hasMoreData = loadedTerms.skiptoken !== ''; + setGroupsLoading((prevGroupsLoading) => prevGroupsLoading.filter((value) => value !== props.termSetInfo.id)); + setGroups([rootGroup]); + }); + } + }, []); + + const onToggleCollapse = (group: IGroup): void => { + if (group.isCollapsed === true) { + setGroups((prevGroups) => { + const recurseGroups = (currentGroup: IGroup) => { + if (currentGroup.key === group.key) { + currentGroup.isCollapsed = false; + } + if (currentGroup.children?.length > 0) { + for (const child of currentGroup.children) { + recurseGroups(child); + } + } + }; + let newGroupsState: IGroup[] = []; + for (const prevGroup of prevGroups) { + recurseGroups(prevGroup); + newGroupsState.push(prevGroup); + } + + return newGroupsState; + }); + + if (group.children && group.children.length === 0) { + setGroupsLoading((prevGroupsLoading) => [...prevGroupsLoading, group.key]); + group.data.isLoading = true; + + props.onLoadMoreData(Guid.parse(props.termSetInfo.id), Guid.parse(group.key), '', true) + .then((loadedTerms) => { + const grps: IGroup[] = loadedTerms.value.map(term => { + let termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.languageTag && termLabel.isDefault === true)); + if (termNames.length === 0) { + termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.termStoreInfo.defaultLanguageTag && termLabel.isDefault === true)); + } + const g: IGroup = { + name: termNames[0]?.name, + key: term.id, + startIndex: -1, + count: 50, + level: group.level + 1, + isCollapsed: true, + data: { skiptoken: '', term: term }, + hasMoreData: term.childrenCount > 0, + }; + if (g.hasMoreData) { + g.children = []; + } + return g; + }); + + props.setTerms((prevTerms) => { + const nonExistingTerms = loadedTerms.value.filter((term) => prevTerms.every((prevTerm) => prevTerm.id !== term.id)); + return [...prevTerms, ...nonExistingTerms]; + }); + + group.children = grps; + group.data.skiptoken = loadedTerms.skiptoken; + group.hasMoreData = loadedTerms.skiptoken !== ''; + setGroupsLoading((prevGroupsLoading) => prevGroupsLoading.filter((value) => value !== group.key)); + }); + } + } + else { + setGroups((prevGroups) => { + const recurseGroups = (currentGroup: IGroup) => { + if (currentGroup.key === group.key) { + currentGroup.isCollapsed = true; + } + if (currentGroup.children?.length > 0) { + for (const child of currentGroup.children) { + recurseGroups(child); + } + } + }; + let newGroupsState: IGroup[] = []; + for (const prevGroup of prevGroups) { + recurseGroups(prevGroup); + newGroupsState.push(prevGroup); + } + + return newGroupsState; + }); + + } + }; + + const onRenderTitle = (groupHeaderProps: IGroupHeaderProps) => { + const isChildSelected = (children: IGroup[]): boolean => { + let aChildIsSelected = children && children.some((child) => props.selection.isKeySelected(child.key) || isChildSelected(child.children)); + return aChildIsSelected; + }; + + const childIsSelected = props.selection && isChildSelected(groupHeaderProps.group.children); + + if (groupHeaderProps.group.level === 0) { + const labelStyles: IStyleFunctionOrObject = {root: {width: "100%", fontWeight: childIsSelected ? "bold" : "normal"}}; + return ( + + +
+ {props.onRenderActionButton && props.onRenderActionButton(props.termStoreInfo, props.termSetInfo, props.anchorTermInfo)} +
+
+ ); + } + + if (!props.selection) { + const labelStyles: IStyleFunctionOrObject = {root: {width: "100%", fontWeight: childIsSelected ? "bold" : "normal"}}; + return ( + + +
+ {props.onRenderActionButton && props.onRenderActionButton(props.termStoreInfo, props.termSetInfo, groupHeaderProps.group.data.term)} +
+
+ ); + } + + const isDisabled = groupHeaderProps.group.data.term.isAvailableForTagging.filter((t) => t.setId === props.termSetInfo.id)[0].isAvailable === false; + const isSelected = props.selection.isKeySelected(groupHeaderProps.group.key); + + if (props.allowMultipleSelections) { + const checkBoxStyles: IStyleFunctionOrObject = {root: { flex: "1" } }; + if (isSelected || childIsSelected) { + checkBoxStyles.label = { fontWeight: 'bold' }; + } + else { + checkBoxStyles.label = { fontWeight: 'normal' }; + } + + return ( + + + {p.label} + } + onChange={(ev?: React.FormEvent, checked?: boolean) => { + props.selection.setKeySelected(groupHeaderProps.group.key, checked, false); + }} + /> +
+ {props.onRenderActionButton && props.onRenderActionButton(props.termStoreInfo, props.termSetInfo, groupHeaderProps.group.data.term)} +
+
+ ); + } + else { + const choiceGroupOptionStyles: IStyleFunctionOrObject = isSelected || childIsSelected ? { root: {marginTop: 0}, choiceFieldWrapper: { fontWeight: 'bold', flex: '1' }, field: { width: '100%'} } : { root: {marginTop: 0}, choiceFieldWrapper: { fontWeight: 'normal', flex: '1' }, field: { width: '100%'} }; + const options: IChoiceGroupOption[] = [{ + key: groupHeaderProps.group.key, + text: groupHeaderProps.group.name, + styles: choiceGroupOptionStyles, + onRenderLabel: (p) => + + {p.text} + , + onClick: () => { + props.selection.setAllSelected(false); + props.selection.setKeySelected(groupHeaderProps.group.key, true, false); + } + }]; + + const choiceGroupStyles: IStyleFunctionOrObject = { root: { flex: "1" }, applicationRole: { width: "100%" } }; + + return ( + + +
+ {props.onRenderActionButton && props.onRenderActionButton(props.termStoreInfo, props.termSetInfo, groupHeaderProps.group.data.term)} +
+
+ ); + } + }; + + const onRenderHeader = (headerProps: IGroupHeaderProps): JSX.Element => { + const groupHeaderStyles: IStyleFunctionOrObject = { + expand: { height: 42, visibility: !headerProps.group.children || headerProps.group.level === 0 ? "hidden" : "visible", fontSize: 14 }, + expandIsCollapsed: { visibility: !headerProps.group.children || headerProps.group.level === 0 ? "hidden" : "visible", fontSize: 14 }, + check: { display: 'none' }, + headerCount: { display: 'none' }, + groupHeaderContainer: { height: 36, paddingTop: 3, paddingBottom: 3, paddingLeft: 3, paddingRight: 3, alignItems: 'center', }, + root: { height: 42 }, + }; + + const isDisabled = headerProps.group.data.term && headerProps.group.data.term.isAvailableForTagging.filter((t) => t.setId === props.termSetInfo.id)[0].isAvailable === false; + + return ( + , group: IGroup) => { + if ((ev.key == " " || ev.key == "Enter" ) && !isDisabled) { + if (props.allowMultipleSelections) { + props.selection.toggleKeySelected(headerProps.group.key); + } + else { + props.selection.setAllSelected(false); + props.selection.setKeySelected(headerProps.group.key, true, false); + } + } + }} + /> + ); + }; + + const onRenderFooter = (footerProps: IGroupFooterProps): JSX.Element => { + if ((footerProps.group.hasMoreData || footerProps.group.children && footerProps.group.children.length === 0) && !footerProps.group.isCollapsed) { + + if (groupsLoading.some(value => value === footerProps.group.key)) { + const spinnerStyles: IStyleFunctionOrObject = { circle: { verticalAlign: 'middle' } }; + return ( +
+ +
+ ); + } + const linkStyles: IStyleFunctionOrObject = { root: { fontSize: '14px', paddingLeft: (footerProps.groupLevel + 1) * 20 + 62 } }; + return ( +
+ { + setGroupsLoading((prevGroupsLoading) => [...prevGroupsLoading, footerProps.group.key]); + props.onLoadMoreData(Guid.parse(props.termSetInfo.id), footerProps.group.key === props.termSetInfo.id ? Guid.empty : Guid.parse(footerProps.group.key), footerProps.group.data.skiptoken, true) + .then((loadedTerms) => { + const grps: IGroup[] = loadedTerms.value.map(term => { + let termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.languageTag && termLabel.isDefault === true)); + if (termNames.length === 0) { + termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.termStoreInfo.defaultLanguageTag && termLabel.isDefault === true)); + } + const g: IGroup = { + name: termNames[0]?.name, + key: term.id, + startIndex: -1, + count: 50, + level: footerProps.group.level + 1, + isCollapsed: true, + data: { skiptoken: '', term: term }, + hasMoreData: term.childrenCount > 0, + }; + if (g.hasMoreData) { + g.children = []; + } + return g; + }); + props.setTerms((prevTerms) => { + const nonExistingTerms = loadedTerms.value.filter((term) => prevTerms.every((prevTerm) => prevTerm.id !== term.id)); + return [...prevTerms, ...nonExistingTerms]; + }); + footerProps.group.children = [...footerProps.group.children, ...grps]; + footerProps.group.data.skiptoken = loadedTerms.skiptoken; + footerProps.group.hasMoreData = loadedTerms.skiptoken !== ''; + setGroupsLoading((prevGroupsLoading) => prevGroupsLoading.filter((value) => value !== footerProps.group.key)); + }); + }} + styles={linkStyles}> + {strings.ModernTaxonomyPickerLoadMoreText} + +
+ ); + } + return null; + }; + + const onRenderShowAll: IRenderFunction = () => { + return null; + }; + + const groupProps: IGroupRenderProps = { + onRenderFooter: onRenderFooter, + onRenderHeader: onRenderHeader, + showEmptyGroups: true, + onRenderShowAll: onRenderShowAll, + }; + + const shouldEnterInnerZone = (ev: React.KeyboardEvent): boolean => { + return ev.which === getRTLSafeKeyCode(KeyCodes.right); + }; + + return ( +
+ ) => false} + data-is-focusable={true} + focusZoneProps={{direction: FocusZoneDirection.vertical, shouldEnterInnerZone: shouldEnterInnerZone}} + /> +
+ ); +} From dfe3c29c18169568da94cef5ff86788612e43f55 Mon Sep 17 00:00:00 2001 From: Patrik Hellgren Date: Sat, 13 Nov 2021 02:11:47 +0100 Subject: [PATCH 3/6] Added icons and exports --- .../docs/controls/ModernTaxonomyPicker.md | 27 +++++++++++-------- .../ModernTaxonomyPicker.tsx | 2 +- src/controls/modernTaxonomyPicker/index.ts | 6 ++++- .../ModernTermPicker.types.ts | 6 ++--- .../modernTermPicker/index.ts | 2 ++ .../TaxonomyPanelContents.tsx | 2 ++ .../taxonomyTree/TaxonomyTree.module.scss | 5 ++++ .../taxonomyTree/TaxonomyTree.tsx | 16 ++++++++--- .../taxonomyTree/index.ts | 1 + .../modernTaxonomyPicker/termItem/index.ts | 2 ++ 10 files changed, 50 insertions(+), 19 deletions(-) create mode 100644 src/controls/modernTaxonomyPicker/modernTermPicker/index.ts create mode 100644 src/controls/modernTaxonomyPicker/taxonomyTree/index.ts create mode 100644 src/controls/modernTaxonomyPicker/termItem/index.ts diff --git a/docs/documentation/docs/controls/ModernTaxonomyPicker.md b/docs/documentation/docs/controls/ModernTaxonomyPicker.md index 7fd9b34cb..d6e0c5ead 100644 --- a/docs/documentation/docs/controls/ModernTaxonomyPicker.md +++ b/docs/documentation/docs/controls/ModernTaxonomyPicker.md @@ -156,7 +156,7 @@ You can also use the `TaxonomyTree` control separately to just render a stand-al ```TypeScript const taxonomyService = new SPTaxonomyService(props.context); - const [terms, setTerms] = React.useState(); + const [terms, setTerms] = React.useState([]); const [currentTermStoreInfo, setCurrentTermStoreInfo] = React.useState(); const [currentTermSetInfo, setCurrentTermSetInfo] = React.useState(); const [currentLanguageTag, setCurrentLanguageTag] = React.useState(""); @@ -176,16 +176,21 @@ You can also use the `TaxonomyTree` control separately to just render a stand-al }); }, []); - + return ( + {currentTermSetInfo && ( + + )} ``` ![](https://telemetry.sharepointpnp.com/sp-dev-fx-controls-react/wiki/controls/ModernTaxonomyPicker) diff --git a/src/controls/modernTaxonomyPicker/ModernTaxonomyPicker.tsx b/src/controls/modernTaxonomyPicker/ModernTaxonomyPicker.tsx index e269259b2..f182afb9d 100644 --- a/src/controls/modernTaxonomyPicker/ModernTaxonomyPicker.tsx +++ b/src/controls/modernTaxonomyPicker/ModernTaxonomyPicker.tsx @@ -50,7 +50,7 @@ export interface IModernTaxonomyPickerProps { panelTitle: string; label: string; context: BaseComponentContext; - initialValues?: ITermInfo[]; + initialValues?: Optional[]; disabled?: boolean; required?: boolean; onChange?: (newValue?: ITermInfo[]) => void; diff --git a/src/controls/modernTaxonomyPicker/index.ts b/src/controls/modernTaxonomyPicker/index.ts index 4d3768ab4..1f687b64f 100644 --- a/src/controls/modernTaxonomyPicker/index.ts +++ b/src/controls/modernTaxonomyPicker/index.ts @@ -1,2 +1,6 @@ export * from './ModernTaxonomyPicker'; -export * from './termItem/TermItem'; +export * from './modernTermPicker/index'; +export * from './taxonomyPanelContents/index'; +export * from './taxonomyTree/index'; +export * from './termItem/index'; +export * from '../../services/SPTaxonomyService'; diff --git a/src/controls/modernTaxonomyPicker/modernTermPicker/ModernTermPicker.types.ts b/src/controls/modernTaxonomyPicker/modernTermPicker/ModernTermPicker.types.ts index 1d1699b60..060f718f0 100644 --- a/src/controls/modernTaxonomyPicker/modernTermPicker/ModernTermPicker.types.ts +++ b/src/controls/modernTaxonomyPicker/modernTermPicker/ModernTermPicker.types.ts @@ -38,7 +38,7 @@ export interface ITermItemStyles { close: IStyle; } -export interface ITermItemSuggestionProps extends React.AllHTMLAttributes { +export interface ITermItemSuggestionElementProps extends React.AllHTMLAttributes { /** Additional CSS class(es) to apply to the TermItemSuggestion div element */ className?: string; @@ -49,8 +49,8 @@ export interface ITermItemSuggestionProps extends React.AllHTMLAttributes> & - Pick & {}; +export type ITermItemSuggestionStyleProps = Required> & + Pick & {}; export interface ITermItemSuggestionStyles { /** Refers to the text element of the TermItemSuggestion */ diff --git a/src/controls/modernTaxonomyPicker/modernTermPicker/index.ts b/src/controls/modernTaxonomyPicker/modernTermPicker/index.ts new file mode 100644 index 000000000..59ad6edfc --- /dev/null +++ b/src/controls/modernTaxonomyPicker/modernTermPicker/index.ts @@ -0,0 +1,2 @@ +export * from './ModernTermPicker'; +export * from './ModernTermPicker.types'; diff --git a/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.tsx b/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.tsx index 3825c5516..df09ea5c3 100644 --- a/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.tsx +++ b/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.tsx @@ -112,6 +112,8 @@ export function TaxonomyPanelContents(props: ITaxonomyPanelContentsProps): React terms={terms} allowMultipleSelections={props.allowMultipleSelections} onRenderActionButton={props.onRenderActionButton} + hideDeprecatedTerms={true} + showIcons={false} /> ); diff --git a/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.module.scss b/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.module.scss index 8f075b064..5d946231c 100644 --- a/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.module.scss +++ b/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.module.scss @@ -60,3 +60,8 @@ .taxonomyItemHeader:hover .actionButtonContainer > *, .taxonomyItemHeader:focus .actionButtonContainer > *, .taxonomyItemHeader .actionButtonContainer:focus-within > * { opacity: 1; } + +.taxonomyItemIcon { + margin-inline-start: 8px; + margin-inline-end: 8px; +} diff --git a/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.tsx b/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.tsx index 1dd3ad6a8..06e136704 100644 --- a/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.tsx +++ b/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.tsx @@ -4,6 +4,7 @@ import { Checkbox, css, FocusZone, FocusZoneDirection, + FontIcon, getRTLSafeKeyCode, GroupedList, GroupHeader, @@ -55,6 +56,8 @@ export interface ITaxonomyTreeProps { terms: ITermInfo[]; setTerms: React.Dispatch>; selection?: Selection; + hideDeprecatedTerms?: boolean; + showIcons?: boolean; } export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement { @@ -90,7 +93,7 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement [...prevGroupsLoading, props.termSetInfo.id]); if (props.termSetInfo.childrenCount > 0) { - props.onLoadMoreData(Guid.parse(props.termSetInfo.id), props.anchorTermInfo ? Guid.parse(props.anchorTermInfo.id) : Guid.empty, '', true) + props.onLoadMoreData(Guid.parse(props.termSetInfo.id), props.anchorTermInfo ? Guid.parse(props.anchorTermInfo.id) : Guid.empty, '', props.hideDeprecatedTerms) .then((loadedTerms) => { const grps: IGroup[] = loadedTerms.value.map(term => { let termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.languageTag && termLabel.isDefault === true)); @@ -151,7 +154,7 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement [...prevGroupsLoading, group.key]); group.data.isLoading = true; - props.onLoadMoreData(Guid.parse(props.termSetInfo.id), Guid.parse(group.key), '', true) + props.onLoadMoreData(Guid.parse(props.termSetInfo.id), Guid.parse(group.key), '', props.hideDeprecatedTerms) .then((loadedTerms) => { const grps: IGroup[] = loadedTerms.value.map(term => { let termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.languageTag && termLabel.isDefault === true)); @@ -225,6 +228,9 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement + {props.showIcons && ( + + )}
{props.onRenderActionButton && props.onRenderActionButton(props.termStoreInfo, props.termSetInfo, props.anchorTermInfo)} @@ -235,11 +241,15 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement = {root: {width: "100%", fontWeight: childIsSelected ? "bold" : "normal"}}; + let taxonomyItemIconName: string = groupHeaderProps.group.data.term.isDeprecated ? "Blocked" : "Tag"; return ( + {props.showIcons && ( + + )}
{props.onRenderActionButton && props.onRenderActionButton(props.termStoreInfo, props.termSetInfo, groupHeaderProps.group.data.term)} @@ -373,7 +383,7 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement { setGroupsLoading((prevGroupsLoading) => [...prevGroupsLoading, footerProps.group.key]); - props.onLoadMoreData(Guid.parse(props.termSetInfo.id), footerProps.group.key === props.termSetInfo.id ? Guid.empty : Guid.parse(footerProps.group.key), footerProps.group.data.skiptoken, true) + props.onLoadMoreData(Guid.parse(props.termSetInfo.id), footerProps.group.key === props.termSetInfo.id ? Guid.empty : Guid.parse(footerProps.group.key), footerProps.group.data.skiptoken, props.hideDeprecatedTerms) .then((loadedTerms) => { const grps: IGroup[] = loadedTerms.value.map(term => { let termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.languageTag && termLabel.isDefault === true)); diff --git a/src/controls/modernTaxonomyPicker/taxonomyTree/index.ts b/src/controls/modernTaxonomyPicker/taxonomyTree/index.ts new file mode 100644 index 000000000..3e2ce9d23 --- /dev/null +++ b/src/controls/modernTaxonomyPicker/taxonomyTree/index.ts @@ -0,0 +1 @@ +export * from './TaxonomyTree'; diff --git a/src/controls/modernTaxonomyPicker/termItem/index.ts b/src/controls/modernTaxonomyPicker/termItem/index.ts new file mode 100644 index 000000000..ce4e03597 --- /dev/null +++ b/src/controls/modernTaxonomyPicker/termItem/index.ts @@ -0,0 +1,2 @@ +export * from './TermItem'; +export * from './TermItemSuggestion'; From d7d10d273a486f3b0727200321945b4480f53d5d Mon Sep 17 00:00:00 2001 From: Patrik Hellgren Date: Mon, 13 Dec 2021 14:53:44 +0100 Subject: [PATCH 4/6] Added callback to update taxonomy tree view with changed terms --- .../docs/controls/ModernTaxonomyPicker.md | 52 ++++-- .../TaxonomyPanelContents.tsx | 2 +- .../taxonomyTree/TaxonomyTree.tsx | 160 +++++++++++++++--- 3 files changed, 182 insertions(+), 32 deletions(-) diff --git a/docs/documentation/docs/controls/ModernTaxonomyPicker.md b/docs/documentation/docs/controls/ModernTaxonomyPicker.md index d6e0c5ead..4338f4092 100644 --- a/docs/documentation/docs/controls/ModernTaxonomyPicker.md +++ b/docs/documentation/docs/controls/ModernTaxonomyPicker.md @@ -52,23 +52,53 @@ private onTaxPickerChange(terms : ITermInfo[]) { ``` ## Advanced example -Custom rendering of a More actions button that displays a context menu for each term in the term set and the term set itself and with different options for the terms and the term set. This could for example be used to add terms to an open term set. It also shows how to set the initialsValues property when just knowing the name and the id of the term, the rest of the term properties must be provided but doesn't need to be the correct values. +Custom rendering of a More actions button that displays a context menu for each term in the term set and the term set itself and with different options for the terms and the term set. This could for example be used to add terms to an open term set. It also shows how to set the initialsValues property when just knowing the name and the id of the term. ```TypeScript +const termSetId = "36d21c3f-b83b-4acc-a223-4df6fa8e946d"; +const [clickedActionTerm, setClickedActionTerm] = React.useState(); + +const addChildTerm = (parentTermId, updateTaxonomyTreeViewCallback): void => { + spPost(sp.termStore.sets.getById(termSetId).terms.getById(parentTermId).children, { + body: JSON.stringify({ + "labels": [ + { + "languageTag": "en-US", + "name": "Test", + "isDefault": true + } + ] + }), + }) + .then(addedTerm => { + return sp.termStore.sets.getById(termSetId).terms.getById(addedTerm.id).expand("parent")(); + }) + .then(term => { + updateTaxonomyTreeViewCallback([term], null, null); + }); +} + +... + { + onRenderActionButton={( + termStoreInfo: ITermStoreInfo, + termSetInfo: ITermSetInfo, + termInfo: ITermInfo + updateTaxonomyTreeViewCallback?: (newTermItems?: ITermInfo[], updatedTermItems?: ITermInfo[], deletedTermItems?: ITermInfo[]) => void + ): JSX.Element => { const menuIcon: IIconProps = { iconName: 'MoreVertical', "aria-label": "More actions", style: { fontSize: "medium" } }; if (termInfo) { const menuProps: IContextualMenuProps = { @@ -77,13 +107,13 @@ Custom rendering of a More actions button that displays a context menu for each key: 'addTerm', text: 'Add Term', iconProps: { iconName: 'Tag' }, - onClick: () => onContextualMenuClick(termInfo.id) + onClick: () => addChildTerm(termInfo.id, updateTaxonomyTreeViewCallback) }, { key: 'deleteTerm', text: 'Delete term', iconProps: { iconName: 'Untag' }, - onClick: () => onContextualMenuClick(termInfo.id) + onClick: () => deleteTerm(termInfo.id, updateTaxonomyTreeViewCallback) }, ], }; @@ -92,11 +122,11 @@ Custom rendering of a More actions button that displays a context menu for each | React.KeyboardEvent, button?: IButtonProps) => { - this.setState({clickedActionTerm: termInfo}); + setClickedActionTerm(termInfo)); }} - onAfterMenuDismiss={() => this.setState({clickedActionTerm: null})} + onAfterMenuDismiss={() => setClickedActionTerm(null)} /> ); } @@ -107,7 +137,7 @@ Custom rendering of a More actions button that displays a context menu for each key: 'addTerm', text: 'Add term', iconProps: { iconName: 'Tag' }, - onClick: () => onContextualMenuClick(termSetInfo.id) + onClick: () => addTerm(termInfo.id, updateTaxonomyTreeViewCallback) }, ], }; @@ -177,7 +207,7 @@ You can also use the `TaxonomyTree` control separately to just render a stand-al }, []); return ( - {currentTermSetInfo && ( + {currentTermStoreInfo && currentTermSetInfo && currentLanguageTag && ( ; - onRenderActionButton?: (termStoreInfo: ITermStoreInfo, termSetInfo: ITermSetInfo, termInfo?: ITermInfo) => JSX.Element; + onRenderActionButton?: (termStoreInfo: ITermStoreInfo, termSetInfo: ITermSetInfo, termInfo: ITermInfo, updateTaxonomyTreeViewCallback?: (newTermItems?: ITermInfo[], updatedTermItems?: ITermInfo[], deletedTermItems?: ITermInfo[]) => void) => JSX.Element; } export function TaxonomyPanelContents(props: ITaxonomyPanelContentsProps): React.ReactElement { diff --git a/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.tsx b/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.tsx index 06e136704..ebdd7117b 100644 --- a/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.tsx +++ b/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.tsx @@ -52,7 +52,7 @@ export interface ITaxonomyTreeProps { termStoreInfo: ITermStoreInfo; languageTag: string; themeVariant?: IReadonlyTheme; - onRenderActionButton?: (termStoreInfo: ITermStoreInfo, termSetInfo: ITermSetInfo, termInfo?: ITermInfo) => JSX.Element; + onRenderActionButton?: (termStoreInfo: ITermStoreInfo, termSetInfo: ITermSetInfo, termInfo: ITermInfo, updateTaxonomyTreeViewCallback?: (newTermItems?: ITermInfo[], updatedTermItems?: ITermInfo[], deletedTermItems?: ITermInfo[]) => void) => JSX.Element; terms: ITermInfo[]; setTerms: React.Dispatch>; selection?: Selection; @@ -64,6 +64,126 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement([]); const [groups, setGroups] = React.useState([]); + const updateTaxonomyTreeViewWithNewTermItems = (newTermItems: ITermInfo[]): void => { + for (const term of newTermItems) { + + const findGroupContainingTerm = (currentGroup: IGroup): IGroup => { + if (!term.parent || currentGroup.key === term.parent.id) { + return currentGroup; + } + if (currentGroup.children?.length > 0) { + for (const child of currentGroup.children) { + const foundGroup = findGroupContainingTerm(child); + if (foundGroup) { + return foundGroup; + } + } + } + return null; + }; + + const groupToAddTermTo = findGroupContainingTerm(groups[0]); + let termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.languageTag && termLabel.isDefault === true)); + if (termNames.length === 0) { + termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.termStoreInfo.defaultLanguageTag && termLabel.isDefault === true)); + } + + const g: IGroup = { + name: termNames[0]?.name, + key: term.id, + startIndex: -1, + count: 50, + level: groupToAddTermTo.level + 1, + isCollapsed: true, + data: { skiptoken: '', term: term }, + hasMoreData: term.childrenCount > 0, + }; + if (g.hasMoreData) { + g.children = []; + } + groupToAddTermTo.children = [...groupToAddTermTo.children ?? [], g]; + props.setTerms((prevTerms) => { + const nonExistingTerms = newTermItems.filter((term) => prevTerms.every((prevTerm) => prevTerm.id !== term.id)); + return [...prevTerms, ...nonExistingTerms]; + }); + } + } + + const updateTaxonomyTreeViewWithUpdatedTermItems = (updatedTermItems: ITermInfo[]): void => { + for (const term of updatedTermItems) { + + const findGroupForTerm = (currentGroup: IGroup): IGroup => { + if (currentGroup.key === term.id) { + return currentGroup; + } + if (currentGroup.children?.length > 0) { + for (const child of currentGroup.children) { + const foundGroup = findGroupForTerm(child); + if (foundGroup) { + return foundGroup; + } + } + } + return null; + }; + + const groupForUpdatedTerm = findGroupForTerm(groups[0]); + let termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.languageTag && termLabel.isDefault === true)); + if (termNames.length === 0) { + termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.termStoreInfo.defaultLanguageTag && termLabel.isDefault === true)); + } + + groupForUpdatedTerm.name = termNames[0]?.name; + groupForUpdatedTerm.data.term = term; + if (term.childrenCount > 0 && !groupForUpdatedTerm.children) { + groupForUpdatedTerm.children = []; + } + groupForUpdatedTerm.hasMoreData = groupForUpdatedTerm.children && term.childrenCount > groupForUpdatedTerm.children.length; + props.setTerms((prevTerms) => { + return [...prevTerms.filter(t => t.id !== term.id), term]; + }); + } + } + + const updateTaxonomyTreeViewWithDeletedTermItems = (deletedTermItems: ITermInfo[]): void => { + for (const term of deletedTermItems) { + + const deleteGroupForTerm = (currentGroup: IGroup): void => { + if (currentGroup.children?.length > 0) { + for (const child of currentGroup.children) { + deleteGroupForTerm(child); + } + if (currentGroup.children.some(t => t.key === term.id)) { + currentGroup.children = currentGroup.children.filter(t => t.key !== term.id); + if (currentGroup.children?.length === 0) { + currentGroup.hasMoreData = false; + currentGroup.children = undefined; + } + } + } + }; + + deleteGroupForTerm(groups[0]); + props.setTerms((prevTerms) => { + return [...prevTerms.filter(t => t.id !== term.id)]; + }); + } + } + + const updateTaxonomyTreeView = (newTermItems?: ITermInfo[], updatedTermItems?: ITermInfo[], deletedTermItems?: ITermInfo[]): void => { + if (newTermItems) { + updateTaxonomyTreeViewWithNewTermItems(newTermItems); + } + + if (updatedTermItems) { + updateTaxonomyTreeViewWithUpdatedTermItems(updatedTermItems); + } + + if (deletedTermItems) { + updateTaxonomyTreeViewWithDeletedTermItems(deletedTermItems); + } + } + React.useEffect(() => { let termRootName = ""; if (props.anchorTermInfo) { @@ -95,7 +215,8 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement 0) { props.onLoadMoreData(Guid.parse(props.termSetInfo.id), props.anchorTermInfo ? Guid.parse(props.anchorTermInfo.id) : Guid.empty, '', props.hideDeprecatedTerms) .then((loadedTerms) => { - const grps: IGroup[] = loadedTerms.value.map(term => { + const nonExistingTerms = loadedTerms.value.filter((term) => props.terms.every((prevTerm) => prevTerm.id !== term.id)); + const grps: IGroup[] = nonExistingTerms.map(term => { let termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.languageTag && termLabel.isDefault === true)); if (termNames.length === 0) { termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.termStoreInfo.defaultLanguageTag && termLabel.isDefault === true)); @@ -116,7 +237,6 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement { - const nonExistingTerms = loadedTerms.value.filter((term) => prevTerms.every((prevTerm) => prevTerm.id !== term.id)); return [...prevTerms, ...nonExistingTerms]; }); rootGroup.children = grps; @@ -156,7 +276,8 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement { - const grps: IGroup[] = loadedTerms.value.map(term => { + const nonExistingTerms = loadedTerms.value.filter((term) => props.terms.every((prevTerm) => prevTerm.id !== term.id)); + const grps: IGroup[] = nonExistingTerms.map(term => { let termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.languageTag && termLabel.isDefault === true)); if (termNames.length === 0) { termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.termStoreInfo.defaultLanguageTag && termLabel.isDefault === true)); @@ -178,7 +299,6 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement { - const nonExistingTerms = loadedTerms.value.filter((term) => prevTerms.every((prevTerm) => prevTerm.id !== term.id)); return [...prevTerms, ...nonExistingTerms]; }); @@ -215,7 +335,7 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement { const isChildSelected = (children: IGroup[]): boolean => { - let aChildIsSelected = children && children.some((child) => props.selection.isKeySelected(child.key) || isChildSelected(child.children)); + let aChildIsSelected = children && children.some((child) => props.selection && props.selection.isKeySelected(child.key) || isChildSelected(child.children)); return aChildIsSelected; }; @@ -233,7 +353,7 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement{groupHeaderProps.group.name}
- {props.onRenderActionButton && props.onRenderActionButton(props.termStoreInfo, props.termSetInfo, props.anchorTermInfo)} + {props.onRenderActionButton && props.onRenderActionButton(props.termStoreInfo, props.termSetInfo, props.anchorTermInfo, updateTaxonomyTreeView)}
); @@ -252,14 +372,14 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement{groupHeaderProps.group.name}
- {props.onRenderActionButton && props.onRenderActionButton(props.termStoreInfo, props.termSetInfo, groupHeaderProps.group.data.term)} + {props.onRenderActionButton && props.onRenderActionButton(props.termStoreInfo, props.termSetInfo, groupHeaderProps.group.data.term, updateTaxonomyTreeView)}
); } const isDisabled = groupHeaderProps.group.data.term.isAvailableForTagging.filter((t) => t.setId === props.termSetInfo.id)[0].isAvailable === false; - const isSelected = props.selection.isKeySelected(groupHeaderProps.group.key); + const isSelected = props.selection && props.selection.isKeySelected(groupHeaderProps.group.key); if (props.allowMultipleSelections) { const checkBoxStyles: IStyleFunctionOrObject = {root: { flex: "1" } }; @@ -285,11 +405,11 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement} onChange={(ev?: React.FormEvent, checked?: boolean) => { - props.selection.setKeySelected(groupHeaderProps.group.key, checked, false); + props.selection && props.selection.setKeySelected(groupHeaderProps.group.key, checked, false); }} />
- {props.onRenderActionButton && props.onRenderActionButton(props.termStoreInfo, props.termSetInfo, groupHeaderProps.group.data.term)} + {props.onRenderActionButton && props.onRenderActionButton(props.termStoreInfo, props.termSetInfo, groupHeaderProps.group.data.term, updateTaxonomyTreeView)}
); @@ -305,8 +425,8 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement, onClick: () => { - props.selection.setAllSelected(false); - props.selection.setKeySelected(groupHeaderProps.group.key, true, false); + props.selection && props.selection.setAllSelected(false); + props.selection && props.selection.setKeySelected(groupHeaderProps.group.key, true, false); } }]; @@ -319,12 +439,12 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement
- {props.onRenderActionButton && props.onRenderActionButton(props.termStoreInfo, props.termSetInfo, groupHeaderProps.group.data.term)} + {props.onRenderActionButton && props.onRenderActionButton(props.termStoreInfo, props.termSetInfo, groupHeaderProps.group.data.term, updateTaxonomyTreeView)}
); @@ -355,11 +475,11 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement, group: IGroup) => { if ((ev.key == " " || ev.key == "Enter" ) && !isDisabled) { if (props.allowMultipleSelections) { - props.selection.toggleKeySelected(headerProps.group.key); + props.selection && props.selection.toggleKeySelected(headerProps.group.key); } else { - props.selection.setAllSelected(false); - props.selection.setKeySelected(headerProps.group.key, true, false); + props.selection && props.selection.setAllSelected(false); + props.selection && props.selection.setKeySelected(headerProps.group.key, true, false); } } }} @@ -385,7 +505,8 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement [...prevGroupsLoading, footerProps.group.key]); props.onLoadMoreData(Guid.parse(props.termSetInfo.id), footerProps.group.key === props.termSetInfo.id ? Guid.empty : Guid.parse(footerProps.group.key), footerProps.group.data.skiptoken, props.hideDeprecatedTerms) .then((loadedTerms) => { - const grps: IGroup[] = loadedTerms.value.map(term => { + const nonExistingTerms = loadedTerms.value.filter((term) => props.terms.every((prevTerm) => prevTerm.id !== term.id)); + const grps: IGroup[] = nonExistingTerms.map(term => { let termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.languageTag && termLabel.isDefault === true)); if (termNames.length === 0) { termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.termStoreInfo.defaultLanguageTag && termLabel.isDefault === true)); @@ -406,7 +527,6 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement { - const nonExistingTerms = loadedTerms.value.filter((term) => prevTerms.every((prevTerm) => prevTerm.id !== term.id)); return [...prevTerms, ...nonExistingTerms]; }); footerProps.group.children = [...footerProps.group.children, ...grps]; From 579f51727b955afb16a9874549fd657dc9a0a5be Mon Sep 17 00:00:00 2001 From: Patrik Hellgren Date: Mon, 13 Dec 2021 15:12:35 +0100 Subject: [PATCH 5/6] Fixed build warnings --- .../taxonomyTree/TaxonomyTree.tsx | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.tsx b/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.tsx index ebdd7117b..557712bae 100644 --- a/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.tsx +++ b/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.tsx @@ -103,11 +103,11 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement { - const nonExistingTerms = newTermItems.filter((term) => prevTerms.every((prevTerm) => prevTerm.id !== term.id)); + const nonExistingTerms = newTermItems.filter((newTerm) => prevTerms.every((prevTerm) => prevTerm.id !== newTerm.id)); return [...prevTerms, ...nonExistingTerms]; }); } - } + }; const updateTaxonomyTreeViewWithUpdatedTermItems = (updatedTermItems: ITermInfo[]): void => { for (const term of updatedTermItems) { @@ -143,7 +143,7 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement t.id !== term.id), term]; }); } - } + }; const updateTaxonomyTreeViewWithDeletedTermItems = (deletedTermItems: ITermInfo[]): void => { for (const term of deletedTermItems) { @@ -168,7 +168,7 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement t.id !== term.id)]; }); } - } + }; const updateTaxonomyTreeView = (newTermItems?: ITermInfo[], updatedTermItems?: ITermInfo[], deletedTermItems?: ITermInfo[]): void => { if (newTermItems) { @@ -182,7 +182,7 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement { let termRootName = ""; @@ -405,7 +405,9 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement} onChange={(ev?: React.FormEvent, checked?: boolean) => { - props.selection && props.selection.setKeySelected(groupHeaderProps.group.key, checked, false); + if (props.selection) { + props.selection.setKeySelected(groupHeaderProps.group.key, checked, false); + } }} />
@@ -425,8 +427,10 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement, onClick: () => { - props.selection && props.selection.setAllSelected(false); - props.selection && props.selection.setKeySelected(groupHeaderProps.group.key, true, false); + if (props.selection) { + props.selection.setAllSelected(false); + props.selection.setKeySelected(groupHeaderProps.group.key, true, false); + } } }]; @@ -475,11 +479,15 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement, group: IGroup) => { if ((ev.key == " " || ev.key == "Enter" ) && !isDisabled) { if (props.allowMultipleSelections) { - props.selection && props.selection.toggleKeySelected(headerProps.group.key); + if (props.selection) { + props.selection.toggleKeySelected(headerProps.group.key); + } } else { - props.selection && props.selection.setAllSelected(false); - props.selection && props.selection.setKeySelected(headerProps.group.key, true, false); + if (props.selection) { + props.selection.setAllSelected(false); + props.selection.setKeySelected(headerProps.group.key, true, false); + } } } }} From e722a0bb1c33cf0d17f1ffc3163171e32e531a1e Mon Sep 17 00:00:00 2001 From: Patrik Hellgren Date: Mon, 13 Dec 2021 18:39:51 +0100 Subject: [PATCH 6/6] Fix for selection and duplicate terms --- .../taxonomyTree/TaxonomyTree.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.tsx b/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.tsx index 557712bae..cb99bf067 100644 --- a/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.tsx +++ b/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.tsx @@ -215,8 +215,7 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement 0) { props.onLoadMoreData(Guid.parse(props.termSetInfo.id), props.anchorTermInfo ? Guid.parse(props.anchorTermInfo.id) : Guid.empty, '', props.hideDeprecatedTerms) .then((loadedTerms) => { - const nonExistingTerms = loadedTerms.value.filter((term) => props.terms.every((prevTerm) => prevTerm.id !== term.id)); - const grps: IGroup[] = nonExistingTerms.map(term => { + const grps: IGroup[] = loadedTerms.value.map(term => { let termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.languageTag && termLabel.isDefault === true)); if (termNames.length === 0) { termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.termStoreInfo.defaultLanguageTag && termLabel.isDefault === true)); @@ -237,6 +236,7 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement { + const nonExistingTerms = loadedTerms.value.filter((newTerm) => prevTerms.every((prevTerm) => prevTerm.id !== newTerm.id)); return [...prevTerms, ...nonExistingTerms]; }); rootGroup.children = grps; @@ -276,8 +276,7 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement { - const nonExistingTerms = loadedTerms.value.filter((term) => props.terms.every((prevTerm) => prevTerm.id !== term.id)); - const grps: IGroup[] = nonExistingTerms.map(term => { + const grps: IGroup[] = loadedTerms.value.map(term => { let termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.languageTag && termLabel.isDefault === true)); if (termNames.length === 0) { termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.termStoreInfo.defaultLanguageTag && termLabel.isDefault === true)); @@ -299,10 +298,12 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement { + const nonExistingTerms = loadedTerms.value.filter((newTerm) => prevTerms.every((prevTerm) => prevTerm.id !== newTerm.id)); return [...prevTerms, ...nonExistingTerms]; }); - group.children = grps; + const nonExistingChildren = grps.filter((grp) => group.children?.every((child) => child.key !== grp.key)); + group.children = nonExistingChildren; group.data.skiptoken = loadedTerms.skiptoken; group.hasMoreData = loadedTerms.skiptoken !== ''; setGroupsLoading((prevGroupsLoading) => prevGroupsLoading.filter((value) => value !== group.key)); @@ -513,8 +514,7 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement [...prevGroupsLoading, footerProps.group.key]); props.onLoadMoreData(Guid.parse(props.termSetInfo.id), footerProps.group.key === props.termSetInfo.id ? Guid.empty : Guid.parse(footerProps.group.key), footerProps.group.data.skiptoken, props.hideDeprecatedTerms) .then((loadedTerms) => { - const nonExistingTerms = loadedTerms.value.filter((term) => props.terms.every((prevTerm) => prevTerm.id !== term.id)); - const grps: IGroup[] = nonExistingTerms.map(term => { + const grps: IGroup[] = loadedTerms.value.map(term => { let termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.languageTag && termLabel.isDefault === true)); if (termNames.length === 0) { termNames = term.labels.filter((termLabel) => (termLabel.languageTag === props.termStoreInfo.defaultLanguageTag && termLabel.isDefault === true)); @@ -535,9 +535,11 @@ export function TaxonomyTree(props: ITaxonomyTreeProps): React.ReactElement { + const nonExistingTerms = loadedTerms.value.filter((newTerm) => prevTerms.every((prevTerm) => prevTerm.id !== newTerm.id)); return [...prevTerms, ...nonExistingTerms]; }); - footerProps.group.children = [...footerProps.group.children, ...grps]; + const nonExistingChildren = grps.filter((grp) => footerProps.group.children?.every((child) => child.key !== grp.key)); + footerProps.group.children = [...footerProps.group.children, ...nonExistingChildren]; footerProps.group.data.skiptoken = loadedTerms.skiptoken; footerProps.group.hasMoreData = loadedTerms.skiptoken !== ''; setGroupsLoading((prevGroupsLoading) => prevGroupsLoading.filter((value) => value !== footerProps.group.key));