/**
 * Copyright (c) Microsoft Corporation. All rights reserved.
 * Licensed under the MIT License.
 */ import * as React from 'react' ; import * as CLM from '@conversationlearner/models' ; import Plain from 'slate-plain-serializer' ; import CustomEntityNode from './CustomEntityNode' ; import PreBuiltEntityNode from './PreBuiltEntityNode' ; import EntityPicker from './EntityPicker' ; import TokenNode from './TokenNode' ; import { Editor } from 'slate-react' ; import { Expando } from '../modals' ; import { IOption , IPosition , IEntityPickerProps , IGenericEntity , NodeType , IGenericEntityData , ExtractorStatus } from './models' ; import { convertEntitiesAndTextToTokenizedEditorValue , convertEntitiesAndTextToEditorValue , getRelativeParent , getEntitiesFromValueUsingTokenData , getSelectedText } from './utilities' ; import './ExtractorResponseEditor.css' ; import { autobind } from 'core-decorators' ; export type SlateValue = any ; interface Props { readOnly : boolean ; status : ExtractorStatus ; options : IOption [ ] ; text : string ; entities : CLM . EntityBase [ ] ; customEntities : IGenericEntity < any > [ ] ; onChangeCustomEntities : ( customEntities : IGenericEntity < any > [ ] , entities : CLM . EntityBase [ ] ) => void ; preBuiltEntities : IGenericEntity < any > [ ] ; onClickNewEntity : ( entityTypeFilter : string ) => void ; isPickerVisible : boolean ; onOpenPicker : ( ) => void ; onClosePicker : ( onlyCloseOthers ? : boolean ) => void ; } interface State { isSelectionOverlappingOtherEntities : boolean ; isDeleteButtonVisible : boolean ; isPreBuiltExpandoOpen : boolean ; menuPosition : IPosition | null ; value : SlateValue ; preBuiltEditorValues : SlateValue [ ] ; builtInTypeFilter : string | null ; } const initialState = { isSelectionOverlappingOtherEntities : false , isDeleteButtonVisible : false , isPreBuiltExpandoOpen : true , menuPosition : { top : 0 , left : 0 , bottom : 0 } , value : Plain . deserialize ( '' ) , preBuiltEditorValues : [ { } ] , builtInTypeFilter : null } ; const disallowedOperations = [ 'insert_text' , 'remove_text' ] ; const externalChangeOperations = [ 'insert_node' , 'remove_node' ] ; /**
 * The higher level goal behind this component is for consumers to think of it like a normal controlled <input value={value} onChange={this.onChangeValue} />
 * However, instead of editing a simple string of text we are editing an extractorResponse object. Then it becomes easy to understand the
 * abstractions and encapsulation.  Example: for normal <input /> the user can change cursor position and selection, but only when characters are changed,
 * does the onChange get called.  For the ExtractorResponse allows certain operations to change and only exposes the changes externally
 *
 * The other important concept is the translation of state from the external domain to internal domain.  Externally the consumers know extractorResponses / entities
 * however internally it stores as generic options list and a Slate.js value object.
 */ class ExtractorResponseEditor extends React . Component < Props , State > { menuRef = React . createRef < HTMLDivElement > ( ) ; editor = React . createRef < any > ( ) ; constructor ( props ) { super ( props ) ; const preBuiltEditorValues = props . preBuiltEntities . map < any [ ] > ( preBuiltEntity => convertEntitiesAndTextToEditorValue ( props . text , [ preBuiltEntity ] , NodeType . PreBuiltEntityNodeType ) ) ; this . state = { ... initialState , value : convertEntitiesAndTextToTokenizedEditorValue ( props . text , props . customEntities , NodeType . CustomEntityNodeType ) , preBuiltEditorValues , isPreBuiltExpandoOpen : preBuiltEditorValues . length <= 4 } ; } UNSAFE_componentWillReceiveProps ( nextProps ) { /**
         * This makes assumption that options that are added during the life-cycle of this component are likely
         * added via users clicking the New Entity item in the menu.  We can then simulate a change of custom entities
         * effectively auto-labeling the currently selected text with this new entity.
         */ if ( nextProps . options . length !== this . props . options . length ) { const newOptions = nextProps . options . filter ( newOption => this . props . options . every ( oldOption => oldOption . id !== newOption . id ) ) ; if ( newOptions . length === 1 ) { const newOption = newOptions [ 0 ] ; /**
                 * This is hack to prevent bug with negative entities which as assume have '-' prefix.
                 * When user creates a new Entity and checks negatable the options are updated twice.
                 * Once with the positive which correctly tags the text and another time with the negative which
                 */ const negativeEntityPrefix = '-' ; if ( ! newOption . name . startsWith ( negativeEntityPrefix ) ) { const value = this . state . value ; const selectedNodes = value . inlines . toJS ( ) ; if ( selectedNodes . length > 0 && selectedNodes . every ( n => n . type === NodeType . TokenNodeType ) ) { const startIndex = selectedNodes [ 0 ] . data . startIndex ; const endIndex = selectedNodes [ selectedNodes . length - 1 ] . data . endIndex ; const selectedText = getSelectedText ( value ) ; if ( selectedText . length === 0 ) { console . warn ( `Attempting to label the selected text as entity: ${ newOption . name }; however, the selected text was empty. This should not be possible. Likely the first token is defaulted as selected.` ) ; } else { const newCustomEntity = { startIndex , endIndex , data : { text : selectedText , displayName : newOption . name , option : newOption , original : null } } ; this . props . onChangeCustomEntities ( [ ... nextProps . customEntities , newCustomEntity ] , this . props . entities ) ; return ; } } } } } if ( nextProps . text !== this . props . text || nextProps . customEntities . length !== this . props . customEntities . length || nextProps . preBuiltEntities . length !== this . props . preBuiltEntities . length ) { this . setState ( { value : convertEntitiesAndTextToTokenizedEditorValue ( nextProps . text , nextProps . customEntities , NodeType . CustomEntityNodeType ) , preBuiltEditorValues : nextProps . preBuiltEntities . map < any [ ] > ( preBuiltEntity => convertEntitiesAndTextToEditorValue ( nextProps . text , [ preBuiltEntity ] , NodeType . PreBuiltEntityNodeType ) ) , isSelectionOverlappingOtherEntities : false , isDeleteButtonVisible : false , } ) ; this . props . onClosePicker ( ) ; } } getNextPickerProps = ( value , menu ) : IEntityPickerProps | void => { const hideMenu = { isOverlappingOtherEntities : false , isVisible : false , position : null , builtInTypeFilter : null } ; const selectionDoesNotContainTokens = value . isEmpty || value . selection . isCollapsed || ( value . inlines . size === 0 ) ; if ( ! menu || selectionDoesNotContainTokens ) { return hideMenu ; } const relativeParent = getRelativeParent ( menu . parentElement ) ; const relativeRect = relativeParent . getBoundingClientRect ( ) ; const selection = window . getSelection ( ) ; if ( ! selection || selection . rangeCount === 0 ) { return hideMenu ; } const range = selection . getRangeAt ( 0 ) ; const selectionBoundingRect = range . getBoundingClientRect ( ) ; const left = ( selectionBoundingRect . left - relativeRect . left ) + window . scrollX - menu . offsetWidth / 2 + selectionBoundingRect . width / 2 ; const menuPosition = { top : ( ( selectionBoundingRect . top - relativeRect . top ) + selectionBoundingRect . height ) + window . scrollY + 10 , left : Math . max ( 0 , left ) , bottom : relativeRect . height - ( selectionBoundingRect . top - relativeRect . top ) + 10 } ; /**
         * If selection overlaps with existing custom entity nodes, or if it's has parent of custom entity node
         * then set special flag isOverlappingOtherEntities which prevents adding entities
         */ let isOverlappingOtherEntities = false ; if ( value . inlines . size > 0 ) { const customEntityNodesInSelection = value . inlines . filter ( ( n ) => n . type === NodeType . CustomEntityNodeType ) ; if ( customEntityNodesInSelection . size > 0 ) { isOverlappingOtherEntities = true ; } else { const parentOfFirstInline = value . document . getParent ( value . inlines . first ( ) . key ) ; if ( parentOfFirstInline . type === NodeType . CustomEntityNodeType ) { isOverlappingOtherEntities = true ; } } } /**
         * If the text you select has a prebuilt entity detected within same region
         * it opens the entity creator modal with that prebuilt type pre-selected to help prevent mismatches.
         * This means `entityTypeFilter` and `builtInTypeFilter` do need to remain a string since the pre-built types/names are not EntityTypes
         *
         * TODO: In future look at adding explicit resolver preset parameter and remove casting
         */ let builtInTypeFilter = null ; const selectedNodes = value . inlines . toJS ( ) ; if ( selectedNodes . length > 0 && selectedNodes . every ( n => n . type === NodeType . TokenNodeType ) ) { const startIndex = selectedNodes [ 0 ] . data . startIndex ; const endIndex = selectedNodes [ selectedNodes . length - 1 ] . data . endIndex ; const builtInEntity = this . props . preBuiltEntities . find ( entity => entity . startIndex >= startIndex && entity . endIndex <= endIndex ) ; if ( builtInEntity ) { const builtInEntityId = builtInEntity . data . option . id ; const builtInEntityDef = this . props . entities . find ( e => e . entityId === builtInEntityId ) ; if ( builtInEntityDef ) { builtInTypeFilter = builtInEntityDef . entityType ; } } } return { isOverlappingOtherEntities , isVisible : true , position : menuPosition , builtInTypeFilter : builtInTypeFilter } ; } ; onSelectChar = ( ) => { const selection = window . getSelection ( ) ; const parentNode = selection ?. anchorNode ?. parentElement ?. parentNode ; if ( parentNode && selection ) { const sibling = parentNode . nextSibling ? parentNode . nextSibling : parentNode . parentNode && parentNode . parentNode . nextSibling ; if ( sibling ?. firstChild ?. firstChild ?. firstChild ) { const newSelection = sibling . firstChild . firstChild . firstChild ; const range = document . createRange ( ) ; range . selectNode ( newSelection ) ; selection . removeAllRanges ( ) ; selection . addRange ( range ) ; } } } ; @ autobind onChange ( change ) { const { value , operations } = change ; const operationsJs = operations . toJS ( ) ; const ignoreOperations = [ ... disallowedOperations ] ; if ( this . state . isDeleteButtonVisible ) { ignoreOperations . push ( "set_selection" ) ; } if ( operationsJs . some ( ( o ) => ignoreOperations . includes ( o . type ) ) ) { return ; } const tokenNodes = value . inlines . filter ( ( n ) => n . type === NodeType . TokenNodeType ) ; if ( tokenNodes . size === 0 && operationsJs . length === 1 ) { this . onSelectChar ( ) ; this . props . onClosePicker ( true ) ; return ; } if ( tokenNodes . size > 0 ) { let shouldExpandSelection = true ; const parentNodes = tokenNodes . map ( ( n ) => value . document . getParent ( n . key ) ) ; const firstParent = parentNodes . first ( ) ; if ( firstParent && firstParent . type === NodeType . CustomEntityNodeType && parentNodes . every ( ( x ) => x === firstParent ) ) { shouldExpandSelection = false ; } const selection = window . getSelection ( ) ; const selectionParentElement = selection && selection . anchorNode && selection . anchorNode . parentElement ; if ( selectionParentElement == null ) { console . warn ( `selectionParentElement is null or undefined. Value: ${ value . document . text }` ) ; } else if ( [ "BUTTON" , "I" ] . includes ( selectionParentElement . tagName ) ) { shouldExpandSelection = false ; } if ( shouldExpandSelection ) { const firstInline = value . inlines . first ( ) ; const lastInline = value . inlines . last ( ) ; change . collapseToStartOf ( firstInline ) . extendToEndOf ( lastInline ) ; } } this . setState ( { value : change . value } ) ; const containsExternalChangeOperation = operationsJs . some ( ( o ) => externalChangeOperations . includes ( o . type ) ) ; if ( containsExternalChangeOperation ) { const customEntities = getEntitiesFromValueUsingTokenData ( change ) ; this . props . onChangeCustomEntities ( customEntities , this . props . entities ) ; } const pickerProps = this . getNextPickerProps ( change . value , this . menuRef . current ) ; if ( pickerProps ) { this . setState ( { isSelectionOverlappingOtherEntities : pickerProps . isOverlappingOtherEntities , menuPosition : pickerProps . position , builtInTypeFilter : pickerProps . builtInTypeFilter } ) ; if ( this . props . isPickerVisible && ! pickerProps . isVisible ) { this . props . onClosePicker ( ) ; } else if ( ! this . props . isPickerVisible && pickerProps . isVisible ) { this . props . onOpenPicker ( ) ; } } } /**
     * Note: This is only here to prevent an edge case bug but doesn't affect normal behavior.
     * Bug: The user has labeled the last word in the phrase as an entity, has the cursor at the
     * right most position of the input and presses the left arrow.
     */ onKeyDown = ( event , change ) => { if ( [ 'Enter' , 'Backspace' ] . includes ( event . key ) ) { event . preventDefault ( ) ; return true ; } const blockNode = change . value . document . getBlocks ( ) . last ( ) ; const lastTextNode = blockNode . getLastText ( ) ; const isAtEnd = change . value . selection . isAtEndOf ( lastTextNode ) ; if ( isAtEnd ) { event . preventDefault ( ) ; change . collapseToEndOfPreviousText ( ) ; return true ; } return undefined ; } ; @ autobind onDeleteButtonVisible ( isDeleteButtonVisible ) { this . setState ( { isDeleteButtonVisible } ) ; } renderNode = ( props ) : React . ReactNode | void => { switch ( props . node . type ) { case NodeType . TokenNodeType : return < TokenNode { ... props } / > ; case NodeType . CustomEntityNodeType : const cenProps = { ... props , onDeleteButtonVisible : this . onDeleteButtonVisible } ; return < CustomEntityNode { ... cenProps } / > ; case NodeType . PreBuiltEntityNodeType : return < PreBuiltEntityNode { ... props } / > ; default : return ; } } ; onSelectOption = ( option , value , onChange ) => { const selectedText = getSelectedText ( value ) ; if ( selectedText . length === 0 ) { console . warn ( `onSelectOption was called but the value has an empty selection` ) ; return ; } const change = value . change ( ) . wrapInline ( { type : NodeType . CustomEntityNodeType , data : { option , text : selectedText } } ) . collapseToEnd ( ) ; onChange ( change ) ; } ; @ autobind onClickNewEntity ( entityTypeFilter ) { if ( this . props . isPickerVisible ) { this . props . onClosePicker ( ) ; } this . props . onClickNewEntity ( entityTypeFilter ) ; } componentDidMount ( ) { document . addEventListener ( "Test_SelectWord" , this . onTestSelectWord as any ) ; } componentWillUnmount ( ) { document . removeEventListener ( "Test_SelectWord" , this . onTestSelectWord as any ) ; } /**
     * For cypress end to end unit testing.  Event selects phrase in entity labeller
     * The following will select the word "hello" in the second row (index is 0 based)
     *   var event = new CustomEvent("Test_SelectWord", { detail: { phrase: "hello", index: 1 }})
     *   event.initEvent("Test_SelectWord", true, true)}
     *   element.dispatchEvent(event)
     *
     * Or default to the first row:
     *   var event = new CustomEvent("Test_SelectWord", { detail: "hello"})
     *   event.initEvent("Test_SelectWord", true, true)}
     *   element.dispatchEvent(event)
     */ @ autobind onTestSelectWord ( val ) { if ( ! val . detail ) { throw new Error ( "Test_SelectWord expecting detail phrase" ) ; } const phrase = ( typeof val . detail !== "string" ) ? val . detail . phrase : val . detail ; const index = ( typeof val . detail !== "string" ) ? val . detail . index : 0 ; const words = phrase . split ( " " ) ; const firstWord = words [ 0 ] ; const lastWord = words [ words . length - 1 ] ; const selectors = { extractionEditor : '[data-testid="extractor-response-editor-entity-labeler"]' , tokenNode : ".cl-token-node" , slateEditor : '[data-slate-editor="true"]' , } ; const rows = Array . from ( document . querySelectorAll ( selectors . extractionEditor ) ) ; if ( index >= rows . length || index < 0 ) { throw new Error ( "Row index does not exist" ) ; } const selectedRow = rows [ index ] ; const tokens = selectedRow ? Array . from ( selectedRow . querySelectorAll ( selectors . tokenNode ) ) : [ ] ; const firstWordToken = tokens . find ( element => element . children [ 0 ] . textContent === firstWord ) ; const lastWordToken = tokens . find ( element => element . children [ 0 ] . textContent === lastWord ) ; if ( ! firstWordToken || ! lastWordToken ) { const extractionTextElement = document . querySelector ( selectors . slateEditor ) ; const input = extractionTextElement ?. textContent ?? '' ; throw new Error ( `You attempted to select the phrase: '${ phrase }', but it was not found in the input: '${ input }'` ) ; } const firstDiv = firstWordToken . children [ 0 ] . children [ 0 ] ; const lastDiv = lastWordToken . children [ 0 ] . children [ 0 ] ; const slateEditor = firstDiv ! . parentElement ! . parentElement ! . parentElement ! . parentElement ; const currentTarget = 'currentTarget' ; const typeKey = 'type' ; const selectEvent = { } ; for ( const key in val ) { if ( key === currentTarget ) { selectEvent [ currentTarget ] = slateEditor ; } else if ( key === typeKey ) { selectEvent [ typeKey ] = "select" ; } else { selectEvent [ key ] = val [ key ] ; } } const selection = window . getSelection ( ) ; const range = document . createRange ( ) ; if ( selection ) { range . setStartBefore ( firstDiv ) ; range . setEndAfter ( lastDiv ) ; if ( selection ) { selection . removeAllRanges ( ) ; selection . addRange ( range ) ; } this . editor . current . onEvent ( "onSelect" , selectEvent ) ; } } borderStyle ( ) { switch ( this . props . status ) { case ExtractorStatus . ERROR : return 'entity-labeler__custom-editor--error' ; case ExtractorStatus . WARNING : return 'entity-labeler__custom-editor--warning' ; default : return '' ; } } render ( ) { const filteredOptions = this . props . options . filter ( option => option . type === CLM . EntityType . LUIS ) . sort ( ( a , b ) => { const nameCompare = ( x , y ) => x . name > y . name ? 1 : ( x . name < y . name ? - 1 : 0 ) ; if ( a . resolverType === this . state . builtInTypeFilter ) { if ( b . resolverType === this . state . builtInTypeFilter ) { return nameCompare ( a , b ) ; } return - 1 ; } else if ( b . resolverType === this . state . builtInTypeFilter ) { return - 1 ; } else { return nameCompare ( a , b ) ; } } ) ; return ( < div className = "entity-labeler" data - testid = "extractor-response-editor-entity-labeler" > { ( this . props . isPickerVisible || this . state . isDeleteButtonVisible ) && < div className = "entity-labeler-overlay" onClick = { ( ) => this . props . onClosePicker ( ) } role = "button" / > } < div className = { `entity-labeler__custom-editor ${ this . props . readOnly ? 'entity-labeler__custom-editor--read-only' : '' } ${ this . borderStyle ( ) }` } > < div className = "entity-labeler__editor" > < Editor data - testid = "extractorresponseeditor-editor-text" className = "slate-editor" placeholder = "Enter some text..." value = { this . state . value } onChange = { this . onChange } onKeyDown = { this . onKeyDown } renderNode = { this . renderNode } readOnly = { this . props . readOnly } ref = { this . editor } / > < EntityPicker data - testid = "extractorresponseeditor-entitypicker" isOverlappingOtherEntities = { this . state . isSelectionOverlappingOtherEntities } isVisible = { this . props . isPickerVisible } options = { filteredOptions } maxDisplayedOptions = { 500 } ref = { this . menuRef } position = { this . state . menuPosition } value = { this . state . value } onClickNewEntity = { this . onClickNewEntity } onSelectOption = { o => this . onSelectOption ( o , this . state . value , this . onChange ) } entityTypeFilter = { this . state . builtInTypeFilter !== null ? this . state . builtInTypeFilter as any : CLM . EntityType . LUIS } / > < /div> < /div> { this . state . preBuiltEditorValues . length > 0 && < div className = "entity-labeler__prebuilt-editors" > < Expando className = { 'entity-labeler__title' } isOpen = { this . state . isPreBuiltExpandoOpen } text = "Extracted Pre-Trained Entities:" onToggle = { ( ) => this . setState ( { isPreBuiltExpandoOpen : ! this . state . isPreBuiltExpandoOpen } ) } /> { this . state . isPreBuiltExpandoOpen && < div className = "entity-labeler__editor entity-labeler__editor--prebuilt" > { this . state . preBuiltEditorValues . map ( ( preBuiltEditorValue , i ) => < Editor data - testid = "extractorresponseeditor-editor-prebuilt-text" key = { i } className = "slate-editor" placeholder = "Enter some text..." value = { preBuiltEditorValue } renderNode = { this . renderNode } readOnly = { true } / > ) } < /div>} < /div>} < /div>); } } export default ExtractorResponseEditor ;