diff --git a/src/controls/modernTaxonomyPicker/ModernTaxonomyPicker.tsx b/src/controls/modernTaxonomyPicker/ModernTaxonomyPicker.tsx index d2ea1da14..e477444b7 100644 --- a/src/controls/modernTaxonomyPicker/ModernTaxonomyPicker.tsx +++ b/src/controls/modernTaxonomyPicker/ModernTaxonomyPicker.tsx @@ -2,11 +2,22 @@ import * as React from 'react'; import { BaseComponentContext } from '@microsoft/sp-component-base'; import { Guid } from '@microsoft/sp-core-library'; import { IIconProps } from 'office-ui-fabric-react/lib/components/Icon'; -import { PrimaryButton, DefaultButton, IconButton, IButtonStyles } from 'office-ui-fabric-react/lib/Button'; +import { PrimaryButton, + DefaultButton, + IconButton, + IButtonStyles + } from 'office-ui-fabric-react/lib/Button'; import { Label } from 'office-ui-fabric-react/lib/Label'; -import { Panel, PanelType } from 'office-ui-fabric-react/lib/Panel'; -import { IBasePickerStyleProps, IBasePickerStyles, ITag, TagPicker } from 'office-ui-fabric-react/lib/Pickers'; -import { IStackTokens, Stack } from 'office-ui-fabric-react/lib/Stack'; +import { Panel, + PanelType + } from 'office-ui-fabric-react/lib/Panel'; +import { IBasePickerStyleProps, + IBasePickerStyles, + ISuggestionItemProps + } from 'office-ui-fabric-react/lib/Pickers'; +import { IStackTokens, + Stack + } from 'office-ui-fabric-react/lib/Stack'; import { IStyleFunctionOrObject } from 'office-ui-fabric-react/lib/Utilities'; import { sp } from '@pnp/sp'; import { SPTaxonomyService } from '../../services/SPTaxonomyService'; @@ -16,52 +27,74 @@ import * as strings from 'ControlStrings'; import { TooltipHost } from '@microsoft/office-ui-fabric-react-bundle'; import { useId } from '@uifabric/react-hooks'; import { ITooltipHostStyles } from 'office-ui-fabric-react'; -import { ITermInfo, ITermSetInfo, ITermStoreInfo } from '@pnp/sp/taxonomy'; +import { ITermInfo, + ITermSetInfo, + ITermStoreInfo + } from '@pnp/sp/taxonomy'; +import { TermItemSuggestion } from './termItem/TermItemSuggestion'; +import { ModernTermPicker } from './modernTermPicker/ModernTermPicker'; +import { ITermInfoExt, + ITermItemProps + } from './modernTermPicker/ModernTermPicker.types'; +import { TermItem } from './termItem/TermItem'; export interface IModernTaxonomyPickerProps { - allowMultipleSelections: boolean; + allowMultipleSelections?: boolean; termSetId: string; anchorTermId?: string; panelTitle: string; label: string; context: BaseComponentContext; - initialValues?: ITag[]; + initialValues?: ITermInfo[]; disabled?: boolean; required?: boolean; - onChange?: (newValue?: ITag[]) => void; + onChange?: (newValue?: ITermInfo[]) => void; + onRenderItem?: (itemProps: ITermItemProps) => JSX.Element; + onRenderSuggestionsItem?: (term: ITermInfoExt, itemProps: ISuggestionItemProps) => JSX.Element; placeHolder?: string; + customPanelWidth?: number; } export function ModernTaxonomyPicker(props: IModernTaxonomyPickerProps) { const [taxonomyService] = React.useState(() => new SPTaxonomyService(props.context)); const [panelIsOpen, setPanelIsOpen] = React.useState(false); - const [selectedOptions, setSelectedOptions] = React.useState(Object.prototype.toString.call(props.initialValues) === '[object Array]' ? props.initialValues : []); - const [selectedPanelOptions, setSelectedPanelOptions] = React.useState([]); - const [termStoreInfo, setTermStoreInfo] = React.useState(); - const [termSetInfo, setTermSetInfo] = React.useState(); - const [anchorTermInfo, setAnchorTermInfo] = React.useState(); + const [selectedOptions, setSelectedOptions] = React.useState([]); + const [selectedPanelOptions, setSelectedPanelOptions] = React.useState([]); + const [currentTermStoreInfo, setCurrentTermStoreInfo] = React.useState(); + const [currentTermSetInfo, setCurrentTermSetInfo] = React.useState(); + const [currentAnchorTermInfo, setCurrentAnchorTermInfo] = React.useState(); + const [currentLanguageTag, setCurrentLanguageTag] = React.useState(""); React.useEffect(() => { sp.setup(props.context); taxonomyService.getTermStoreInfo() - .then((localTermStoreInfo) => { - setTermStoreInfo(localTermStoreInfo); + .then((termStoreInfo) => { + setCurrentTermStoreInfo(termStoreInfo); + setCurrentLanguageTag(props.context.pageContext.cultureInfo.currentUICultureName !== '' ? + props.context.pageContext.cultureInfo.currentUICultureName : + currentTermStoreInfo.defaultLanguageTag); + setSelectedOptions(Object.prototype.toString.call(props.initialValues) === '[object Array]' ? + props.initialValues.map(term => { return { ...term, languageTag: currentLanguageTag, termStoreInfo: currentTermStoreInfo } as ITermInfoExt;}) : + []); }); taxonomyService.getTermSetInfo(Guid.parse(props.termSetId)) - .then((localTermSetInfo) => { - setTermSetInfo(localTermSetInfo); + .then((termSetInfo) => { + setCurrentTermSetInfo(termSetInfo); }); if (props.anchorTermId && props.anchorTermId !== Guid.empty.toString()) { taxonomyService.getTermById(Guid.parse(props.termSetId), props.anchorTermId ? Guid.parse(props.anchorTermId) : Guid.empty) - .then((localAnchorTermInfo) => { - setAnchorTermInfo(localAnchorTermInfo); + .then((anchorTermInfo) => { + setCurrentAnchorTermInfo(anchorTermInfo); }); } }, []); React.useEffect(() => { if (props.onChange) { - props.onChange(selectedOptions); + props.onChange(selectedOptions.map(termInfoExt => { + const {languageTag, termStoreInfo, ...termInfo} = termInfoExt; + return termInfo; + })); } }, [selectedOptions]); @@ -83,29 +116,69 @@ export function ModernTaxonomyPicker(props: IModernTaxonomyPickerProps) { onClosePanel(); } - async function onResolveSuggestions(filter: string, selectedItems?: ITag[]): Promise { - const languageTag = props.context.pageContext.cultureInfo.currentUICultureName !== '' ? props.context.pageContext.cultureInfo.currentUICultureName : termStoreInfo.defaultLanguageTag; + async function onResolveSuggestions(filter: string, selectedItems?: ITermInfoExt[]): Promise { if (filter === '') { return []; } - const filteredTerms = await taxonomyService.searchTerm(Guid.parse(props.termSetId), filter, languageTag, props.anchorTermId ? Guid.parse(props.anchorTermId) : Guid.empty); + const filteredTerms = await taxonomyService.searchTerm(Guid.parse(props.termSetId), filter, currentLanguageTag, props.anchorTermId ? Guid.parse(props.anchorTermId) : Guid.empty); const filteredTermsWithoutSelectedItems = filteredTerms.filter((term) => { if (!selectedItems || selectedItems.length === 0) { return true; } - return selectedItems.every((item) => item.key !== term.id); + return selectedItems.every((item) => item.id !== term.id); }); const filteredTermsAndAvailable = filteredTermsWithoutSelectedItems.filter((term) => term.isAvailableForTagging.filter((t) => t.setId === props.termSetId)[0].isAvailable); - const filteredTags = filteredTermsAndAvailable.map((term) => { - const key = term.id; - let labelsWithMatchingLanguageTag = term.labels.filter((termLabel) => (termLabel.languageTag === languageTag)); + const filteredTermsAndAvailableAsExt = filteredTermsAndAvailable.map(term => { return { ...term, languageTag: currentLanguageTag, termStoreInfo: currentTermStoreInfo } as ITermInfoExt;}); + return filteredTermsAndAvailableAsExt; + } + + async function onLoadParentLabel(termId: Guid): Promise { + const termInfo = await taxonomyService.getTermById(Guid.parse(props.termSetId), termId); + if (termInfo.parent) { + let labelsWithMatchingLanguageTag = termInfo.parent.labels.filter((termLabel) => (termLabel.languageTag === currentLanguageTag)); if (labelsWithMatchingLanguageTag.length === 0) { - labelsWithMatchingLanguageTag = term.labels.filter((termLabel) => (termLabel.languageTag === termStoreInfo.defaultLanguageTag)); + labelsWithMatchingLanguageTag = termInfo.parent.labels.filter((termLabel) => (termLabel.languageTag === currentTermStoreInfo.defaultLanguageTag)); } - const name = labelsWithMatchingLanguageTag.filter((termLabel) => termLabel.name.toLowerCase().indexOf(filter.toLowerCase()) === 0)[0]?.name; - return { key: key, name: name }; - }); - return filteredTags; + return labelsWithMatchingLanguageTag[0]?.name; + } + else { + let termSetNames = currentTermSetInfo.localizedNames.filter((name) => name.languageTag === currentLanguageTag); + if (termSetNames.length === 0) { + termSetNames = currentTermSetInfo.localizedNames.filter((name) => name.languageTag === currentTermStoreInfo.defaultLanguageTag); + } + return termSetNames[0].name; + } + } + + function onRenderSuggestionsItem(term: ITermInfoExt, itemProps: ISuggestionItemProps): JSX.Element { + return ( + + ); + } + + function onRenderItem(itemProps: ITermItemProps): JSX.Element { + let labels = itemProps.item.labels.filter((name) => name.languageTag === currentLanguageTag && name.isDefault); + if (labels.length === 0) { + labels = itemProps.item.labels.filter((name) => name.languageTag === currentTermStoreInfo.defaultLanguageTag && name.isDefault); + } + + return labels.length > 0 ? ( + {labels[0].name} + ) : null; + } + + function getTextFromItem(termInfo: ITermInfoExt): string { + let labelsWithMatchingLanguageTag = termInfo.labels.filter((termLabel) => (termLabel.languageTag === currentLanguageTag)); + if (labelsWithMatchingLanguageTag.length === 0) { + labelsWithMatchingLanguageTag = termInfo.labels.filter((termLabel) => (termLabel.languageTag === currentTermStoreInfo.defaultLanguageTag)); + } + return labelsWithMatchingLanguageTag[0]?.name; } const calloutProps = { gapSpace: 0 }; @@ -119,22 +192,25 @@ export function ModernTaxonomyPicker(props: IModernTaxonomyPickerProps) { {props.label && }
- { + onChange={(itms?: ITermInfoExt[]) => { setSelectedOptions(itms || []); setSelectedPanelOptions(itms || []); }} - getTextFromItem={(tag: ITag) => tag.name} + getTextFromItem={getTextFromItem} + pickerSuggestionsProps={{noResultsFoundText: strings.ModernTaxonomyPickerNoResultsFound}} inputProps={{ 'aria-label': props.placeHolder || strings.ModernTaxonomyPickerDefaultPlaceHolder, placeholder: props.placeHolder || strings.ModernTaxonomyPickerDefaultPlaceHolder }} + onRenderSuggestionsItem={props.onRenderSuggestionsItem ?? onRenderSuggestionsItem} + onRenderItem={props.onRenderItem ?? onRenderItem} />
@@ -155,7 +231,8 @@ export function ModernTaxonomyPicker(props: IModernTaxonomyPickerProps) { closeButtonAriaLabel={strings.ModernTaxonomyPickerPanelCloseButtonText} onDismiss={onClosePanel} isLightDismiss={true} - type={PanelType.medium} + type={props.customPanelWidth ? PanelType.custom : PanelType.medium} + customWidth={props.customPanelWidth ? `${props.customPanelWidth}px` : undefined} headerText={props.panelTitle} onRenderFooterContent={() => { const horizontalGapStackTokens: IStackTokens = { @@ -176,15 +253,19 @@ export function ModernTaxonomyPicker(props: IModernTaxonomyPickerProps) { allowMultipleSelections={props.allowMultipleSelections} onResolveSuggestions={onResolveSuggestions} onLoadMoreData={taxonomyService.getTerms} - anchorTermInfo={anchorTermInfo} - termSetInfo={termSetInfo} - termStoreInfo={termStoreInfo} + anchorTermInfo={currentAnchorTermInfo} + termSetInfo={currentTermSetInfo} + termStoreInfo={currentTermStoreInfo} context={props.context} termSetId={Guid.parse(props.termSetId)} pageSize={50} selectedPanelOptions={selectedPanelOptions} setSelectedPanelOptions={setSelectedPanelOptions} placeHolder={props.placeHolder || strings.ModernTaxonomyPickerDefaultPlaceHolder} + onRenderSuggestionsItem={props.onRenderSuggestionsItem ?? onRenderSuggestionsItem} + onRenderItem={props.onRenderItem ?? onRenderItem} + getTextFromItem={getTextFromItem} + languageTag={currentLanguageTag} />
) diff --git a/src/controls/modernTaxonomyPicker/index.ts b/src/controls/modernTaxonomyPicker/index.ts index bfa130c40..4d3768ab4 100644 --- a/src/controls/modernTaxonomyPicker/index.ts +++ b/src/controls/modernTaxonomyPicker/index.ts @@ -1 +1,2 @@ export * from './ModernTaxonomyPicker'; +export * from './termItem/TermItem'; diff --git a/src/controls/modernTaxonomyPicker/modernTermPicker/ModernTermPicker.tsx b/src/controls/modernTaxonomyPicker/modernTermPicker/ModernTermPicker.tsx new file mode 100644 index 000000000..34cf8a0f9 --- /dev/null +++ b/src/controls/modernTaxonomyPicker/modernTermPicker/ModernTermPicker.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { BasePicker } from "office-ui-fabric-react/lib/components/pickers/BasePicker"; +import { IModernTermPickerProps, + ITermInfoExt, + ITermItemProps + } from "./ModernTermPicker.types"; +import { TermItem } from "../termItem/TermItem"; +import { TermItemSuggestion } from "../termItem/TermItemSuggestion"; +import { IBasePickerStyleProps, + IBasePickerStyles + } from "office-ui-fabric-react/lib/components/pickers/BasePicker.types"; +import { getStyles } from "office-ui-fabric-react/lib/components/pickers/BasePicker.styles"; +import { initializeComponentRef, + styled + } from "office-ui-fabric-react/lib/Utilities"; +import { ISuggestionItemProps } from "office-ui-fabric-react/lib/components/pickers/Suggestions/SuggestionsItem.types"; +import { Guid } from "@microsoft/sp-core-library"; + +export class ModernTermPickerBase extends BasePicker { + public static defaultProps = { + onRenderItem: (props: ITermItemProps) => { + let labels = props.item.labels.filter((name) => name.languageTag === props.languageTag && name.isDefault); + if (labels.length === 0) { + labels = props.item.labels.filter((name) => name.languageTag === props.termStoreInfo?.defaultLanguageTag && name.isDefault); + } + + return labels.length > 0 ? ( + {labels[0].name} + ) : null; + }, + onRenderSuggestionsItem: (props: ITermInfoExt, itemProps: ISuggestionItemProps) => { + const onLoadParentLabel = async (termId: Guid): Promise => { + return Promise.resolve(""); + }; + return ; + }, + }; + + constructor(props: IModernTermPickerProps) { + super(props); + initializeComponentRef(this); + } +} + +export const ModernTermPicker = styled( + ModernTermPickerBase, + getStyles, + undefined, + { + scope: 'ModernTermPicker', + }, +); diff --git a/src/controls/modernTaxonomyPicker/modernTermPicker/ModernTermPicker.types.ts b/src/controls/modernTaxonomyPicker/modernTermPicker/ModernTermPicker.types.ts new file mode 100644 index 000000000..679b597fa --- /dev/null +++ b/src/controls/modernTaxonomyPicker/modernTermPicker/ModernTermPicker.types.ts @@ -0,0 +1,60 @@ +import { ITermInfo, ITermStoreInfo } from "@pnp/sp/taxonomy"; +import { IBasePickerProps } from "office-ui-fabric-react/lib/components/pickers/BasePicker.types"; +import { IPickerItemProps } from "office-ui-fabric-react/lib/components/pickers/PickerItem.types"; +import { IStyle, ITheme } from "office-ui-fabric-react/lib/Styling"; +import { IStyleFunctionOrObject } from "office-ui-fabric-react/lib/Utilities"; + +export interface ITermInfoExt extends ITermInfo { + termStoreInfo: ITermStoreInfo; + languageTag: string; + key: string; +} +export interface IModernTermPickerProps extends IBasePickerProps {} + +export interface ITermItemProps extends IPickerItemProps { + /** Additional CSS class(es) to apply to the TermItem root element. */ + className?: string; + + enableTermFocusInDisabledPicker?: boolean; + + /** Call to provide customized styling that will layer on top of the variant rules. */ + styles?: IStyleFunctionOrObject; + + /** Theme provided by High-Order Component. */ + theme?: ITheme; + termStoreInfo: ITermStoreInfo; + languageTag: string; +} + +export type ITermItemStyleProps = Required> & + Pick & {}; + +export interface ITermItemStyles { + /** Root element of picked TermItem */ + root: IStyle; + + /** Refers to the text element of the TermItem already picked. */ + text: IStyle; + + /** Refers to the cancel action button on a picked TermItem. */ + close: IStyle; +} + +export interface ITermItemSuggestionProps extends React.AllHTMLAttributes { + /** Additional CSS class(es) to apply to the TermItemSuggestion div element */ + className?: string; + + /** Call to provide customized styling that will layer on top of the variant rules. */ + styles?: IStyleFunctionOrObject; + + /** Theme provided by High-Order Component. */ + theme?: ITheme; +} + +export type ITermItemSuggestionStyleProps = Required> & + Pick & {}; + +export interface ITermItemSuggestionStyles { + /** Refers to the text element of the TermItemSuggestion */ + suggestionTextOverflow?: IStyle; +} diff --git a/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.module.scss b/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.module.scss index 5fcd78476..3be7fc38c 100644 --- a/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.module.scss +++ b/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.module.scss @@ -30,4 +30,17 @@ 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; + } } diff --git a/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.tsx b/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.tsx index 380065bb2..2b9aa6a0d 100644 --- a/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.tsx +++ b/src/controls/modernTaxonomyPicker/taxonomyPanelContents/TaxonomyPanelContents.tsx @@ -1,61 +1,104 @@ import * as React from 'react'; import styles from './TaxonomyPanelContents.module.scss'; -import { Checkbox, ChoiceGroup, GroupedList, GroupHeader, IBasePickerStyleProps, IBasePickerStyles, ICheckboxStyleProps, ICheckboxStyles, IChoiceGroupOption, IChoiceGroupOptionStyleProps, IChoiceGroupOptionStyles, IGroup, IGroupFooterProps, IGroupHeaderProps, IGroupHeaderStyleProps, IGroupHeaderStyles, IGroupRenderProps, IGroupShowAllProps, ILabelStyleProps, ILabelStyles, ILinkStyleProps, ILinkStyles, IListProps, IRenderFunction, ISpinnerStyleProps, ISpinnerStyles, IStyleFunctionOrObject, ITag, Label, Link, Selection, SelectionMode, SelectionZone, Spinner, TagPicker } from 'office-ui-fabric-react'; -import { ITermInfo, ITermSetInfo, ITermStoreInfo } from '@pnp/sp/taxonomy'; +import { Checkbox, + ChoiceGroup, + GroupedList, + GroupHeader, + IBasePickerStyleProps, + IBasePickerStyles, + ICheckboxStyleProps, + ICheckboxStyles, + IChoiceGroupOption, + IChoiceGroupOptionStyleProps, + IChoiceGroupOptionStyles, + IGroup, + IGroupFooterProps, + IGroupHeaderProps, + IGroupHeaderStyleProps, + IGroupHeaderStyles, + IGroupRenderProps, + IGroupShowAllProps, + ILabelStyleProps, + ILabelStyles, + ILinkStyleProps, + ILinkStyles, + IListProps, + IPickerItemProps, + IRenderFunction, + ISpinnerStyleProps, + ISpinnerStyles, + IStyleFunctionOrObject, + ISuggestionItemProps, + 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 { ITermInfoExt } from '../modernTermPicker/ModernTermPicker.types'; export interface ITaxonomyFormProps { context: BaseComponentContext; - allowMultipleSelections: boolean; + allowMultipleSelections?: boolean; termSetId: Guid; pageSize: number; - selectedPanelOptions: ITag[]; - setSelectedPanelOptions: React.Dispatch>; - onResolveSuggestions: (filter: string, selectedItems?: ITag[]) => ITag[] | PromiseLike; + selectedPanelOptions: ITermInfoExt[]; + setSelectedPanelOptions: React.Dispatch>; + onResolveSuggestions: (filter: string, selectedItems?: ITermInfoExt[]) => ITermInfoExt[] | PromiseLike; onLoadMoreData: (termSetId: Guid, parentTermId?: Guid, skiptoken?: string, hideDeprecatedTerms?: boolean, pageSize?: number) => Promise<{ value: ITermInfo[], skiptoken: string }>; anchorTermInfo: ITermInfo; termSetInfo: ITermSetInfo; termStoreInfo: ITermStoreInfo; placeHolder: string; + onRenderSuggestionsItem?: (props: ITermInfoExt, itemProps: ISuggestionItemProps) => JSX.Element; + onRenderItem?: (props: IPickerItemProps) => JSX.Element; + getTextFromItem: (item: ITermInfoExt, currentValue?: string) => string; + languageTag: string; } export function TaxonomyPanelContents(props: ITaxonomyFormProps): React.ReactElement { const [groupsLoading, setGroupsLoading] = React.useState([]); const [groups, setGroups] = React.useState([]); - const [terms, setTerms] = React.useState(props.selectedPanelOptions?.length > 0 ? [...props.selectedPanelOptions] : []); + const [terms, setTerms] = React.useState(props.selectedPanelOptions?.length > 0 ? [...props.selectedPanelOptions] : []); const forceUpdate = useForceUpdate(); const selection = React.useMemo(() => { - const s = new Selection({onSelectionChanged: () => { + const s = new Selection({onSelectionChanged: () => { props.setSelectedPanelOptions((prevOptions) => [...selection.getSelection()]); forceUpdate(); - }}); + }, getKey: (term: ITermInfoExt) => term.id}); s.setItems(terms); for (const selectedOption of props.selectedPanelOptions) { if (s.canSelectItem) { - s.setKeySelected(selectedOption.key.toString(), true, true); + s.setKeySelected(selectedOption.id.toString(), true, true); } } return s; }, [terms]); React.useEffect(() => { - const languageTag = props.context.pageContext.cultureInfo.currentUICultureName !== '' ? props.context.pageContext.cultureInfo.currentUICultureName : props.termStoreInfo.defaultLanguageTag; let termRootName = ""; if (props.anchorTermInfo) { - let anchorTermNames = props.anchorTermInfo.labels.filter((name) => name.languageTag === languageTag && name.isDefault); + 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 === languageTag); + 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); } @@ -77,7 +120,7 @@ export function TaxonomyPanelContents(props: ITaxonomyFormProps): React.ReactEle 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 === languageTag && termLabel.isDefault === true)); + 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)); } @@ -97,8 +140,9 @@ export function TaxonomyPanelContents(props: ITaxonomyFormProps): React.ReactEle return g; }); setTerms((prevTerms) => { - const nonExistingTerms = grps.filter((grp) => prevTerms.every((prevTerm) => prevTerm.key !== grp.key)); - return [...prevTerms, ...nonExistingTerms]; + const nonExistingTerms = loadedTerms.value.filter((term) => prevTerms.every((prevTerm) => prevTerm.id !== term.id)); + const nonExistingTermsAsExt = nonExistingTerms.map(term => { return { ...term, languageTag: props.languageTag, termStoreInfo: props.termStoreInfo } as ITermInfoExt;}); + return [...prevTerms, ...nonExistingTermsAsExt]; }); rootGroup.children = grps; rootGroup.data.skiptoken = loadedTerms.skiptoken; @@ -110,11 +154,9 @@ export function TaxonomyPanelContents(props: ITaxonomyFormProps): React.ReactEle }, []); const onToggleCollapse = (group: IGroup): void => { - const languageTag = props.context.pageContext.cultureInfo.currentUICultureName !== '' ? props.context.pageContext.cultureInfo.currentUICultureName : props.termStoreInfo.defaultLanguageTag; - if (group.isCollapsed === true) { setGroups((prevGroups) => { - const recurseGroups = (currentGroup) => { + const recurseGroups = (currentGroup: IGroup) => { if (currentGroup.key === group.key) { currentGroup.isCollapsed = false; } @@ -140,7 +182,7 @@ export function TaxonomyPanelContents(props: ITaxonomyFormProps): React.ReactEle 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 === languageTag && termLabel.isDefault === true)); + 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)); } @@ -159,10 +201,13 @@ export function TaxonomyPanelContents(props: ITaxonomyFormProps): React.ReactEle } return g; }); + setTerms((prevTerms) => { - const nonExistingTerms = grps.filter((grp) => prevTerms.every((prevTerm) => prevTerm.key !== grp.key)); - return [...prevTerms, ...nonExistingTerms]; + const nonExistingTerms = loadedTerms.value.filter((term) => prevTerms.every((prevTerm) => prevTerm.id !== term.id)); + const nonExistingTermsAsExt = nonExistingTerms.map(term => { return { ...term, languageTag: props.languageTag, termStoreInfo: props.termStoreInfo } as ITermInfoExt;}); + return [...prevTerms, ...nonExistingTermsAsExt]; }); + group.children = grps; group.data.skiptoken = loadedTerms.skiptoken; group.hasMoreData = loadedTerms.skiptoken !== ''; @@ -172,7 +217,7 @@ export function TaxonomyPanelContents(props: ITaxonomyFormProps): React.ReactEle } else { setGroups((prevGroups) => { - const recurseGroups = (currentGroup) => { + const recurseGroups = (currentGroup: IGroup) => { if (currentGroup.key === group.key) { currentGroup.isCollapsed = true; } @@ -213,7 +258,7 @@ export function TaxonomyPanelContents(props: ITaxonomyFormProps): React.ReactEle const isSelected = selection.isKeySelected(groupHeaderProps.group.key); const selectionProps = { - "data-selection-index": selection.getItems().findIndex((term) => term.key === groupHeaderProps.group.key) + "data-selection-index": selection.getItems().findIndex((term) => term.id === groupHeaderProps.group.key) }; if (props.allowMultipleSelections) { @@ -270,7 +315,7 @@ export function TaxonomyPanelContents(props: ITaxonomyFormProps): React.ReactEle
@@ -301,25 +346,24 @@ export function TaxonomyPanelContents(props: ITaxonomyFormProps): React.ReactEle const onRenderFooter = (footerProps: IGroupFooterProps): JSX.Element => { if ((footerProps.group.hasMoreData || footerProps.group.children && footerProps.group.children.length === 0) && !footerProps.group.isCollapsed) { - const languageTag = props.context.pageContext.cultureInfo.currentUICultureName !== '' ? props.context.pageContext.cultureInfo.currentUICultureName : props.termStoreInfo.defaultLanguageTag; 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 === languageTag && termLabel.isDefault === true)); + 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)); } @@ -339,8 +383,9 @@ export function TaxonomyPanelContents(props: ITaxonomyFormProps): React.ReactEle return g; }); setTerms((prevTerms) => { - const nonExistingTerms = grps.filter((grp) => prevTerms.every((prevTerm) => prevTerm.key !== grp.key)); - return [...prevTerms, ...nonExistingTerms]; + const nonExistingTerms = loadedTerms.value.filter((term) => prevTerms.every((prevTerm) => prevTerm.id !== term.id)); + const nonExistingTermsAsExt = nonExistingTerms.map(term => { return { ...term, languageTag: props.languageTag, termStoreInfo: props.termStoreInfo } as ITermInfoExt;}); + return [...prevTerms, ...nonExistingTermsAsExt]; }); footerProps.group.children = [...footerProps.group.children, ...grps]; footerProps.group.data.skiptoken = loadedTerms.skiptoken; @@ -368,19 +413,14 @@ export function TaxonomyPanelContents(props: ITaxonomyFormProps): React.ReactEle onRenderShowAll: onRenderShowAll, }; - function getTagText(tag: ITag, currentValue?: string) { - return tag.name; - } - - const onPickerChange = (items?: ITag[]): void => { - selection.setAllSelected(false); - const itemsToAdd = items.filter((item) => terms.every((term) => term.key !== item.key)); - if (itemsToAdd.length > 0) { - selection.setItems([...terms, ...itemsToAdd]); - setTerms((prevTerms) => [...prevTerms, ...itemsToAdd]); - } + const onPickerChange = (items?: ITermInfoExt[]): void => { + const itemsToAdd = items.filter((item) => terms.every((term) => term.id !== item.id)); + setTerms((prevTerms) => [...prevTerms, ...itemsToAdd]); + selection.setItems([...selection.getItems(), ...itemsToAdd], true); for (const item of items) { - selection.setKeySelected(item.key.toString(), true, true); + if (selection.canSelectItem(item)) { + selection.setKeySelected(item.id.toString(), true, false); + } } }; @@ -390,18 +430,21 @@ export function TaxonomyPanelContents(props: ITaxonomyFormProps): React.ReactEle
-
diff --git a/src/controls/modernTaxonomyPicker/termItem/TermItem.styles.ts b/src/controls/modernTaxonomyPicker/termItem/TermItem.styles.ts new file mode 100644 index 000000000..e9f13078d --- /dev/null +++ b/src/controls/modernTaxonomyPicker/termItem/TermItem.styles.ts @@ -0,0 +1,134 @@ +import { ButtonGlobalClassNames } from "office-ui-fabric-react/lib/components/Button/BaseButton.classNames"; +import { getFocusStyle, getGlobalClassNames, HighContrastSelector } from "office-ui-fabric-react/lib/Styling"; +import { getRTL } from "office-ui-fabric-react/lib/Utilities"; +import { ITermItemStyleProps, ITermItemStyles } from "../modernTermPicker/ModernTermPicker.types"; + +const GlobalClassNames = { + root: 'ms-TagItem', + text: 'ms-TagItem-text', + close: 'ms-TagItem-close', + isSelected: 'is-selected', +}; + +const TAG_HEIGHT = 26; + +export function getStyles(props: ITermItemStyleProps): ITermItemStyles { + const { className, theme, selected, disabled } = props; + + const { palette, effects, fonts, semanticColors } = theme; + + const classNames = getGlobalClassNames(GlobalClassNames, theme); + + return { + root: [ + classNames.root, + fonts.medium, + getFocusStyle(theme), + { + boxSizing: 'content-box', + flexShrink: '1', + margin: 2, + height: TAG_HEIGHT, + lineHeight: TAG_HEIGHT, + cursor: 'default', + userSelect: 'none', + display: 'flex', + flexWrap: 'nowrap', + maxWidth: 300, + minWidth: 0, // needed to prevent long tags from overflowing container + borderRadius: effects.roundedCorner2, + color: semanticColors.inputText, + background: !selected || disabled ? palette.neutralLighter : palette.themePrimary, + selectors: { + ':hover': [ + !disabled && + !selected && { + color: palette.neutralDark, + background: palette.neutralLight, + selectors: { + '.ms-TagItem-close': { + color: palette.neutralPrimary, + }, + }, + }, + disabled && { background: palette.neutralLighter }, + selected && !disabled && { background: palette.themePrimary }, + ], + [HighContrastSelector]: { + border: `1px solid ${!selected ? 'WindowText' : 'WindowFrame'}`, + }, + }, + }, + disabled && { + selectors: { + [HighContrastSelector]: { + borderColor: 'GrayText', + }, + }, + }, + selected && + !disabled && [ + classNames.isSelected, + { + color: palette.white, + }, + ], + className, + ], + text: [ + classNames.text, + { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + minWidth: 30, + margin: '0 8px', + }, + disabled && { + selectors: { + [HighContrastSelector]: { + color: 'GrayText', + }, + }, + }, + ], + close: [ + classNames.close, + { + color: palette.neutralSecondary, + width: 30, + height: '100%', + flex: '0 0 auto', + borderRadius: getRTL(theme) + ? `${effects.roundedCorner2} 0 0 ${effects.roundedCorner2}` + : `0 ${effects.roundedCorner2} ${effects.roundedCorner2} 0`, + selectors: { + ':hover': { + background: palette.neutralQuaternaryAlt, + color: palette.neutralPrimary, + }, + ':active': { + color: palette.white, + backgroundColor: palette.themeDark, + }, + }, + }, + selected && { + color: palette.white, + selectors: { + ':hover': { + color: palette.white, + background: palette.themeDark, + }, + }, + }, + disabled && { + selectors: { + [`.${ButtonGlobalClassNames.msButtonIcon}`]: { + color: palette.neutralSecondary, + }, + }, + }, + ], + }; +} diff --git a/src/controls/modernTaxonomyPicker/termItem/TermItem.tsx b/src/controls/modernTaxonomyPicker/termItem/TermItem.tsx new file mode 100644 index 000000000..18a465d0a --- /dev/null +++ b/src/controls/modernTaxonomyPicker/termItem/TermItem.tsx @@ -0,0 +1,64 @@ +import { IconButton } from 'office-ui-fabric-react/lib/components/Button/IconButton/IconButton'; +import { classNamesFunction, styled } from 'office-ui-fabric-react/lib/Utilities'; +import * as React from 'react'; +import { ITermItemProps, ITermItemStyleProps, ITermItemStyles } from '../modernTermPicker/ModernTermPicker.types'; +import { getStyles } from './TermItem.styles'; + +const getClassNames = classNamesFunction(); + +/** + * {@docCategory TagPicker} + */ +export const TermItemBase = (props: ITermItemProps) => { + const { + theme, + styles, + selected, + disabled, + enableTermFocusInDisabledPicker, + children, + className, + index, + onRemoveItem, + removeButtonAriaLabel, + termStoreInfo, + languageTag, + } = props; + + const classNames = getClassNames(styles, { + theme: theme!, + className, + selected, + disabled, + }); + + let labels = props.item.labels.filter((name) => name.languageTag === languageTag && name.isDefault); + if (labels.length === 0) { + labels = props.item.labels.filter((name) => name.languageTag === props.termStoreInfo.defaultLanguageTag && name.isDefault); + } + + return ( +
+ + {children} + + +
+ ); +}; + +export const TermItem = styled(TermItemBase, getStyles, undefined, { + scope: 'TermItem', +}); diff --git a/src/controls/modernTaxonomyPicker/termItem/TermItemSuggestion.styles.ts b/src/controls/modernTaxonomyPicker/termItem/TermItemSuggestion.styles.ts new file mode 100644 index 000000000..16a07d981 --- /dev/null +++ b/src/controls/modernTaxonomyPicker/termItem/TermItemSuggestion.styles.ts @@ -0,0 +1,26 @@ +import { getGlobalClassNames } from "office-ui-fabric-react/lib/Styling"; +import { ITermItemSuggestionStyleProps, ITermItemSuggestionStyles } from "../modernTermPicker/ModernTermPicker.types"; + +const GlobalClassNames = { + suggestionTextOverflow: 'ms-TagItem-TextOverflow', +}; + +export function getStyles(props: ITermItemSuggestionStyleProps): ITermItemSuggestionStyles { + const { className, theme } = props; + + const classNames = getGlobalClassNames(GlobalClassNames, theme); + + return { + suggestionTextOverflow: [ + classNames.suggestionTextOverflow, + { + overflow: 'hidden', + textOverflow: 'ellipsis', + maxWidth: '60vw', + padding: '6px 12px 7px', + whiteSpace: 'nowrap', + }, + className, + ], + }; +} diff --git a/src/controls/modernTaxonomyPicker/termItem/TermItemSuggestion.tsx b/src/controls/modernTaxonomyPicker/termItem/TermItemSuggestion.tsx new file mode 100644 index 000000000..75e585998 --- /dev/null +++ b/src/controls/modernTaxonomyPicker/termItem/TermItemSuggestion.tsx @@ -0,0 +1,39 @@ +import React, { useEffect } from "react"; +import { ISuggestionItemProps } from "office-ui-fabric-react"; +import styles from './TermItemSuggestions.module.scss'; +import * as strings from 'ControlStrings'; +import { Guid } from "@microsoft/sp-core-library"; +import { ITermStoreInfo } from "@pnp/sp/taxonomy"; +import { ITermInfoExt } from "../modernTermPicker/ModernTermPicker.types"; + +export interface ITermItemSuggestionProps extends ISuggestionItemProps { + term: ITermInfoExt; + languageTag: string; + termStoreInfo: ITermStoreInfo; + onLoadParentLabel: (termId: Guid) => Promise; +} + +export function TermItemSuggestion(props: ITermItemSuggestionProps): JSX.Element { + const [parentLabel, setParentLabel] = React.useState(""); + + useEffect(() => { + props.onLoadParentLabel(Guid.parse(props.term.id.toString())) + .then((localParentInfo) => { + setParentLabel(localParentInfo); + }); + }, []); + + let labels = props.term.labels.filter((name) => name.languageTag === props.languageTag && name.isDefault); + if (labels.length === 0) { + labels = props.term.labels.filter((name) => name.languageTag === props.termStoreInfo.defaultLanguageTag && name.isDefault); + } + + return ( +
+ {labels[0].name} + {parentLabel !== "" &&
+ {`${strings.ModernTaxonomyPickerSuggestionInLabel} ${parentLabel}`} +
} +
+ ); +} diff --git a/src/controls/modernTaxonomyPicker/termItem/TermItemSuggestions.module.scss b/src/controls/modernTaxonomyPicker/termItem/TermItemSuggestions.module.scss new file mode 100644 index 000000000..b94f5cefc --- /dev/null +++ b/src/controls/modernTaxonomyPicker/termItem/TermItemSuggestions.module.scss @@ -0,0 +1,26 @@ +@import '~office-ui-fabric-react/dist/sass/References.scss'; + +html[dir='ltr'] .termSuggestionContainer +{ + text-align: left; +} + +html[dir='rtl'] .termSuggestionContainer +{ + text-align: right; +} + +.termSuggestionContainer +{ + padding-top: 7px; + padding-left: 12px; + padding-right: 12px; + padding-bottom: 7px; + + .termSuggestionPath + { + font-size: 12px; + color: #666666; + } + +} diff --git a/src/services/SPTaxonomyService.ts b/src/services/SPTaxonomyService.ts index d6d03e016..5e9a6dd57 100644 --- a/src/services/SPTaxonomyService.ts +++ b/src/services/SPTaxonomyService.ts @@ -7,9 +7,6 @@ import { ITermInfo, ITermSetInfo, ITermStoreInfo } from '@pnp/sp/taxonomy'; export class SPTaxonomyService { - /** - * Service constructor - */ constructor(private context: BaseComponentContext) { } @@ -54,7 +51,7 @@ export class SPTaxonomyService { return undefined; } try { - const termInfo = await sp.termStore.sets.getById(termSetId.toString()).terms.getById(termId.toString())(); + const termInfo = await sp.termStore.sets.getById(termSetId.toString()).terms.getById(termId.toString()).expand("parent")(); return termInfo; } catch (error) { return undefined;