//============================================================================== // Copyright 2010-2026 Moraware, Inc. // // Purpose: Supports a search popup window for assisted selection list // population. Allows you to type in the textbox and // (asynchronously) bring up a selection list of available options. // //============================================================================== /* eslint no-eval: "off" */ /*global cancelEvent checkResponseForError ConsoleLogging doPositionPicker getObj htmlEncode jsrsExecuteWithErrorP mjtElemData rsCallbackHandleStandardJSONResponse scrollElementIntoViewWithinContainerElement setFocus setFocusIfFocusable StringificationUtils stringStartsWith trim */ //------------------------------------------------------------------------------ //------------------------------------------------------------------------------ // eslint-disable-next-line no-unused-vars var TypeAheadPopup = (function() { var TYPEAHEAD_ITEM_TEXT_ALL = 'All', TYPEAHEAD_ITEM_ID_ALL = -1, // Whether a typeahead's text still names the value it is supposed to // hold, for a caller about to commit that value. // // A typeahead field may be nullable, so empty text is a legitimate "no // value". But where the value is id-backed, text that names nothing is // an invalid state: the id field is cleared on every keystroke (see // "pvClearForAndHandleTypeAheadChange()"), so text typed and never // picked from the dropdown leaves the two disagreeing. // // Mutually exclusive, so a caller branches exactly once. Note that // "ptrResolutionPending" is not a rejection: an answer is still coming, // and putting an error in front of the user over a lookup that may yet // succeed would be wrong. // // Returned today by "PagedPicker.resolvePendingTypeaheadText()", // which answers it for a Paged Picker's own field. It lives here // because the question is the typeahead's, not that control's: any // typeahead carrying a hidden id field can be left in the same state. PendingTypeaheadResolution_Enum = { ptrNothingPending: 1, ptrTextCleared: 2, ptrUnresolvedText: 3, ptrResolutionPending: 4 }, _test_suppressHideDropdown, _typeAheadTextChangeTimer = null; //-------------------------------------------------------------------------- // // When debugging typeahead styling, it is extremely useful to be able to // prevent the dropdown from hiding itself on blur, etc. This method allows // you to suppress that behaviour. // //-------------------------------------------------------------------------- function pvSetSuppressHideDropdown(suppressHideDropdown_) { _test_suppressHideDropdown = suppressHideDropdown_; } //-------------------------------------------------------------------------- // Reads the div's "isDropdownOpen" flag rather than its inline display: // headers may emit the div with no inline style, and the renderers hide it // for a tick before "doPositionPicker()" shows it. //-------------------------------------------------------------------------- function pvIsTypeaheadDivShowing(objTypeAheadDiv_) { return objTypeAheadDiv_.isDropdownOpen === true; } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pbIsTypeaheadDropdownShowing() { var objTypeAheadDiv = getObj('typeAheadDiv'), rcIsShowing = !!objTypeAheadDiv && pvIsTypeaheadDivShowing(objTypeAheadDiv); return rcIsShowing; } //-------------------------------------------------------------------------- // // TODO: This method definitely doesn't belong in the TypeAheadPopup module. // (DL: 2026-09-11) // //-------------------------------------------------------------------------- function pbSetInvalidTextClass(objTargetField_, isInvalid_) { var strClassName = objTargetField_.className, strInvalidClassName = 'invalidTextField'; if (isInvalid_) { if (strClassName.indexOf(strInvalidClassName) < 0) { strClassName = trim(strClassName + ' ' + strInvalidClassName); } } else { strClassName = trim(strClassName.replace(strInvalidClassName, '')); } objTargetField_.className = strClassName; } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pvUpdateTypeaheadTextboxValidation(params_) { var objTypeAheadDiv = params_.typeaheadDiv, objConfigParams = objTypeAheadDiv.typeAheadConfigParams, tmpDoForceMatchItem = objConfigParams && objConfigParams.forceMatchItem; if(tmpDoForceMatchItem) { // If the field doesn't match an item, mark it as invalid... var theForm = getObj(objConfigParams.formId) || 0, objTextField = theForm[objConfigParams.textFieldName] || 0, objIdField = theForm[objConfigParams.idFieldName] || 0, strTextFieldValue = objTextField.value || ''; if (objIdField.value || !strTextFieldValue.length) { // Either there is a matching selection or the field is empty. // Regardless, clear the invalid marker. pbSetInvalidTextClass( objTextField, 0); // isInvalid_ } else if (objTextField.form && objTextField.form.ignoreFieldValue && objTextField.form.ignoreFieldValue.value === strTextFieldValue) { pbSetInvalidTextClass( objTextField, 0); // isInvalid_ } else { pbSetInvalidTextClass( objTextField, 1); // isInvalid_ } } } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pbHideTypeAheadDiv() { var objTypeAheadDiv = getObj('typeAheadDiv'), rc = 0; if(objTypeAheadDiv) { // Before hiding the typeahead div update the validation status if // necessary. pvUpdateTypeaheadTextboxValidation({ typeaheadDiv: objTypeAheadDiv }); rc = pvIsTypeaheadDivShowing(objTypeAheadDiv); if(!_test_suppressHideDropdown) { objTypeAheadDiv.scrollTop = 0; objTypeAheadDiv.ignoreBlur = false; objTypeAheadDiv.typeAheadConfigParams = null; objTypeAheadDiv.isDropdownOpen = false; objTypeAheadDiv.innerHTML = ''; objTypeAheadDiv.style.display = 'none'; } } return rc; } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pbHideTypeAheadDivIfNotTypeAheadDiv(clickedElement_) { var objTypeAheadDiv = getObj('typeAheadDiv'), objTypeAheadConfigParams = (objTypeAheadDiv || {}).typeAheadConfigParams; if (objTypeAheadConfigParams) { var doHideTypeAhead = true, theForm = getObj(objTypeAheadConfigParams.formId), objTextField = (theForm || {})[objTypeAheadConfigParams.textFieldName]; if (objTextField) { doHideTypeAhead = clickedElement_ !== objTextField; } if (doHideTypeAhead) { pbHideTypeAheadDiv(); } } } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pvFireTypeAheadOnChangeIfNecessary(params_) { var objTextControl = params_.textField, tmpUseAsynchForOnChange = params_.useAsynchForOnChange, strOnChangeFunction = objTextControl.getAttribute('mjttypeaheadonchange'); if(strOnChangeFunction) { // TODO: Work out a way to obviate these "eval()" calls. if(tmpUseAsynchForOnChange) { setTimeout( function() { eval(unescape(strOnChangeFunction)); }, 0); } else { eval(unescape(strOnChangeFunction)); } } } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pvHandleExplicitTypeAheadSelection(params_) { params_ = params_ || {}; var objResultOptions = params_.resultOptions, rc = false, objTypeAheadDiv = getObj('typeAheadDiv'); if (objTypeAheadDiv) { var typeAheadConfigParams = objTypeAheadDiv.typeAheadConfigParams, fnHandleExplicitSelection = typeAheadConfigParams ? StringificationUtils.getOrResolveFunction({ functionNameOrReference: typeAheadConfigParams.fnHandleExplicitSelection, descriptionOfFunctionBeingResolved: 'typeAheadConfigParams.fnHandleExplicitSelection', suppressErrorsOnMissingNameOrReference: 1 }) : null, fnHandleExplicitSelectionP = typeAheadConfigParams ? StringificationUtils.getOrResolveFunction({ functionNameOrReference: typeAheadConfigParams.fnHandleExplicitSelectionP, descriptionOfFunctionBeingResolved: 'typeAheadConfigParams.fnHandleExplicitSelectionP', suppressErrorsOnMissingNameOrReference: 1 }) : null, objExplicitSelectionResultOptions = {}; if(fnHandleExplicitSelection || fnHandleExplicitSelectionP) { if(typeof objTypeAheadDiv.selectedRowIndex !== 'undefined' && objTypeAheadDiv.selectedRowIndex !== null && objTypeAheadDiv.selectedRowIndex > -1) { var objSelectedRow = getObj('typeAheadRow' + objTypeAheadDiv.selectedRowIndex); if(objSelectedRow) { if(fnHandleExplicitSelectionP) { fnHandleExplicitSelectionP({ idxSelectedRow: objTypeAheadDiv.selectedRowIndex, selectedRow: objSelectedRow, formId: typeAheadConfigParams.formId, typeAheadConfigParams: typeAheadConfigParams, explicitSelectionFnParameter: typeAheadConfigParams.explicitSelectionFnParameter, arrTypeaheadRowVals: mjtElemData(objTypeAheadDiv, 'arrTypeaheadRowVals') || null, explicitSelectionResultOptions: objExplicitSelectionResultOptions }); if(objResultOptions) { objResultOptions.suppressSetFocus = objExplicitSelectionResultOptions.suppressSetFocus; } } else { fnHandleExplicitSelection( objTypeAheadDiv.selectedRowIndex, objSelectedRow, typeAheadConfigParams.formId, typeAheadConfigParams, typeAheadConfigParams.explicitSelectionFnParameter, mjtElemData(objTypeAheadDiv, 'arrTypeaheadRowVals') || null); } rc = true; } else { if(typeAheadConfigParams.fnHandleExplicitEmptySelection) { if(typeAheadConfigParams.fnHandleExplicitEmptySelection()) { rc = true; } } } } else { if(typeAheadConfigParams.fnHandleExplicitEmptySelection) { if(typeAheadConfigParams.fnHandleExplicitEmptySelection()) { rc = true; } } } } } return rc; } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pbScrollTypeaheadSelectionIntoViewIfNecessary() { var objTypeAheadDiv = getObj('typeAheadDiv'); if (!objTypeAheadDiv.typeAheadConfigParams) { // The type-ahead has already been disimissed. return; } if (-1 === objTypeAheadDiv.selectedRowIndex) { // No selection...no need to scroll... return; } var objSelectedRow = getObj('typeAheadRow' + objTypeAheadDiv.selectedRowIndex); if (objSelectedRow) { scrollElementIntoViewWithinContainerElement(objSelectedRow, objTypeAheadDiv); } } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pvGetLastTypeaheadDisplayValue(objTextField_) { return objTextField_.getAttribute('data-mjtLastTypeAheadDisplayValue'); } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pvSetLastTypeaheadDisplayValue(params_) { var objTextField = params_.textField, strDisplayValue = params_.displayValue, tmpDontSetFieldValue = params_.dontSetFieldValue; objTextField.setAttribute( 'data-mjtLastTypeAheadDisplayValue', strDisplayValue); if (!tmpDontSetFieldValue) { objTextField.value = strDisplayValue; } } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pvCheckIfTypeaheadDisplayValueChangedAndSetLastValue(objTextField_) { var strLastValue = pvGetLastTypeaheadDisplayValue(objTextField_), strCurrValue = objTextField_.value, rc = strLastValue !== strCurrValue; if (rc) { pvSetLastTypeaheadDisplayValue({ textField: objTextField_, displayValue: strCurrValue, dontSetFieldValue: 1 }); } return rc; } //-------------------------------------------------------------------------- // // Records a field's own typeahead configuration on the field itself, so // that something holding nothing but the form and field name can find it // later. // // The global "typeAheadDiv" is not that place. It carries whichever field // was interacted with last, is cleared outright the moment the dropdown // hides (see "hideTypeAheadDiv()"), and after a server round trip may well // be describing an entirely different field by the time an answer comes // back. // //-------------------------------------------------------------------------- function pvRememberTypeAheadConfigParamsForField(typeAheadConfigParams_) { var theForm = getObj(typeAheadConfigParams_.formId), objTextField = theForm ? theForm[typeAheadConfigParams_.textFieldName] : null; if (objTextField) { mjtElemData( objTextField, 'typeAheadConfigParams', typeAheadConfigParams_); } } //-------------------------------------------------------------------------- // // The configuration last recorded against "objTextField_" by // "pvRememberTypeAheadConfigParamsForField()", or null if that field has // never been focused or typed into. Typing is what puts a field in the // state this exists to answer for, so in practice a field with text to // resolve has one. // //-------------------------------------------------------------------------- function pvGetTypeAheadConfigParamsForField(objTextField_) { return objTextField_ ? (mjtElemData(objTextField_, 'typeAheadConfigParams') || null) : null; } //-------------------------------------------------------------------------- // // Writes a chosen {id, displayValue} pair into the field pair a typeahead // is built on: the visible text field and the hidden id field that backs // it. // // Shared so that a value recovered by lookup lands in exactly the state a // dropdown pick leaves behind, rather than in an approximation of it that // drifts the first time either path changes. Deliberately takes the id and // the display value themselves rather than a dropdown row, since the // caller that needs this most has no row: see // "pbResolveUnmatchedTypeaheadText()". // // Does not touch the dropdown, dismiss it, or move focus. Those belong to // whoever is committing, and the two callers want opposite things of them. // //-------------------------------------------------------------------------- function pvCommitTypeAheadSelectionValue(params_) { var objTypeAheadConfigParams = params_.typeAheadConfigParams, strId = params_.id, strDisplayValue = params_.displayValue, objTypeAheadDiv = getObj('typeAheadDiv'), theForm = getObj(objTypeAheadConfigParams.formId), objTextField = theForm[objTypeAheadConfigParams.textFieldName], objIdField = theForm[objTypeAheadConfigParams.idFieldName]; // Got a valid selection, so clear the invalid indicator. pbSetInvalidTextClass( objTextField, 0); // isInvalid_ // Be sure to ignore changes here. // Don't wipe out the list simply because they've arrowed down... if (objTypeAheadDiv) { objTypeAheadDiv.ignoreTextChange = true; } if (_typeAheadTextChangeTimer) { clearTimeout(_typeAheadTextChangeTimer); } pvSetLastTypeaheadDisplayValue({ textField: objTextField, displayValue: strDisplayValue }); objIdField.value = strId; if (objTypeAheadDiv) { objTypeAheadDiv.ignoreTextChange = false; } pvFireTypeAheadOnChangeIfNecessary({ textField: objTextField, useAsynchForOnChange: false }); } //-------------------------------------------------------------------------- // // TODO: Ideally, we'd simply be using element.classList to add and remove // the style classes we need. However, to keep differences simpler // until such time as we at least remove v1 support, this function // will be used to set a class while exclusively preserving the new // "typeAheadPriorSelection" style class and ignoring any others (as // has always been done). // //-------------------------------------------------------------------------- function pvSetClassWhileRetainingPriorSelectionInd(params_) { var objElement = params_.targetElement, strNewClassName = params_.newClassName, tmpWasMarkedPriorSelection; if (objElement) { tmpWasMarkedPriorSelection = objElement.classList.contains('typeAheadPriorSelection'); objElement.className = strNewClassName + (tmpWasMarkedPriorSelection ? ' typeAheadPriorSelection' : ''); objElement.setAttribute('mjtclass', objElement.className); } } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function ptSelectTypeAheadItem(params_) { var tmpRowIndex = params_.rowIndex, tmpActiveSelection = params_.activeSelection, tmpSetFocus = params_.setFocus, objEvent = params_.event, rc = false, objTypeAheadDiv = getObj('typeAheadDiv'); if(objEvent && objEvent.button && objEvent.button !== 1) { return false; } if(!objTypeAheadDiv.typeAheadConfigParams) { // Already possible duplicate select call. Regardless, there's no // typeahead config, so there's nothing to do... return false; } objTypeAheadDiv.ignoreBlur = tmpSetFocus ? true : false; if(objTypeAheadDiv.selectedRowIndex !== tmpRowIndex) { pvSetClassWhileRetainingPriorSelectionInd({ targetElement: getObj( 'typeAheadRow' + objTypeAheadDiv.selectedRowIndex), newClassName: 'typeAheadRow' }); } objTypeAheadDiv.selectedRowIndex = tmpRowIndex; var objSelectedRow = getObj('typeAheadRow' + tmpRowIndex), objTypeaheadConfigParams = objTypeAheadDiv.typeAheadConfigParams, strFormId = objTypeaheadConfigParams.formId, strTextFieldName = objTypeaheadConfigParams.textFieldName, tmpAsyncFocusOnSelect = objTypeaheadConfigParams.asyncFocusOnSelect, theForm = getObj(strFormId), objCurrText = theForm[strTextFieldName], objExplicitSelectionResultOptions = {}; if(objSelectedRow) { rc = true; // Highlight the newly selected row... pvSetClassWhileRetainingPriorSelectionInd({ targetElement: objSelectedRow, newClassName: 'typeAheadSelectedRow' }); pbScrollTypeaheadSelectionIntoViewIfNecessary(); // Now copy the value of the newly selected row into the textbox. pvCommitTypeAheadSelectionValue({ typeAheadConfigParams: objTypeaheadConfigParams, id: unescape(objSelectedRow.getAttribute('mjtid')), displayValue: unescape(objSelectedRow.getAttribute('mjtvalue')) }); if(tmpActiveSelection) { pvHandleExplicitTypeAheadSelection({ resultOptions: objExplicitSelectionResultOptions }); } } if(tmpSetFocus) { if(!objExplicitSelectionResultOptions.suppressSetFocus) { if(tmpAsyncFocusOnSelect) { setTimeout( function() { setFocus(objCurrText); }, 0); } else { setFocus(objCurrText); } } if(tmpActiveSelection) { // Hide the type-ahead since we're actively selecting an item // and the focus event might otherwise force it to re-display. pbHideTypeAheadDiv(); } } return rc; } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function ptRemoveTypeAheadSelectionOrHoverHighlight(removeSelHightlight_) { var i, objRow, strTargetClass = removeSelHightlight_ ? 'typeAheadSelectedRow' : 'typeAheadHoverRow'; objRow = getObj('typeAheadRow0'); for(i = 0; objRow; ++i) { if(objRow.className === strTargetClass) { objRow.className = 'typeAheadRow'; if(!removeSelHightlight_) { var objTypeAheadDiv = getObj('typeAheadDiv'); objTypeAheadDiv.selectedRowIndex = i; } } objRow = getObj('typeAheadRow' + (i + 1)); } } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pvMousedownOnTypeahead() { var objTypeAheadDiv = getObj('typeAheadDiv'); objTypeAheadDiv.ignoreBlur = true; } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pvBlurTypeahead() { pbHideTypeAheadDiv(); } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function ptSetFocusToTextField(params_) { var strFormId = params_.formId, strFieldName = params_.fieldName, theForm = getObj(strFormId); if(theForm) { var objField = theForm[strFieldName]; if(objField) { setFocus(objField); } } } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pvClearSelection() { var objTypeAheadDiv = getObj('typeAheadDiv'); if(objTypeAheadDiv) { var objSelectedRow = getObj('typeAheadRow' + objTypeAheadDiv.selectedRowIndex); if(objSelectedRow) { objSelectedRow.className = 'typeAheadRow'; objTypeAheadDiv.selectedRowIndex = -1; } } } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pvGetNoMatchFoundHtml(params_) { var objTAConfig = params_.taConfig, strAdditionalLastRowHtml = params_.additionalLastRowHtml, objOriginalPayload = params_.originalPayload, fnNoMessagePostfix = params_.fnNoMessagePostfix, strNoDataMessageOverride = objTAConfig.noDataMessage, strNoMessagePostfix = fnNoMessagePostfix ? fnNoMessagePostfix( objTAConfig.showAdditionalLastRow, objOriginalPayload) : '', strNoDataMessageToUse = strNoDataMessageOverride ? strNoDataMessageOverride + strNoMessagePostfix : 'No matches found', strNoMatchFoundAdditionalClasses = objTAConfig.typeAheadTableAdditionalClasses, strClassAtr = strNoMatchFoundAdditionalClasses ? ' class="' + strNoMatchFoundAdditionalClasses + '" ' : '', strNoMatchFoundHTML = '' + '' + '' + ''; if (objTAConfig) { if(strAdditionalLastRowHtml && objTAConfig.showAdditionalLastRow) { strNoMatchFoundHTML += strAdditionalLastRowHtml; } } strNoMatchFoundHTML += '
' + strNoDataMessageToUse + '
'; return strNoMatchFoundHTML; } //-------------------------------------------------------------------------- // // Readies the dropdown element for a fresh render and gathers everything // both renderers need to know about the field being rendered for, or null // when there is nothing to render into. // // Answers null in the two cases neither renderer can proceed from: the // typeahead has already been dismissed (no config on the div), or the // config names a form that is not there. Note the div is wired and reset // before the form is looked up, so a missing form still leaves it in the // state below; that ordering is preserved from the two copies this was // lifted out of. // // The caller still owns the per-render state that changes as rows are // emitted ("tmpIsExactMatch" and friends). Only what is fixed for the whole // render belongs here. // //-------------------------------------------------------------------------- function pvPrepareTypeaheadRender() { var objTypeAheadDiv = getObj('typeAheadDiv'), objTAConfig = objTypeAheadDiv.typeAheadConfigParams, rcRenderContext = null; if (objTAConfig) { // Make sure that clicking on the typeahead scrollbar doesn't cause // the typeahead to be hidden and also make sure that once the // typeahead loses focus, blur handling is turned back on again. objTypeAheadDiv.onmousedown = pvMousedownOnTypeahead; objTypeAheadDiv.onblur = pvBlurTypeahead; objTypeAheadDiv.ignoreBlur = false; objTypeAheadDiv.selectedRowIndex = -1; var theForm = getObj(objTAConfig.formId); if (theForm) { var objCurrText = theForm[objTAConfig.textFieldName], objCurrId = theForm[objTAConfig.idFieldName]; rcRenderContext = { typeAheadDiv: objTypeAheadDiv, taConfig: objTAConfig, form: theForm, currText: objCurrText, currId: objCurrId, originalIdValue: objCurrId ? objCurrId.value : null, isText: objCurrText.value.length > 0, doAutoMatch: objTAConfig.autoMatchOnKeyInput }; } else { // Not sure what may have gone wrong here. Don't bother // continuing. ConsoleLogging.logBlockClose( 'No Form! looking for formId=' + objTAConfig.formId); } } return rcRenderContext; } //-------------------------------------------------------------------------- // // Puts a finished dropdown on screen: shows the built markup, highlights // the row an exact match picked out, and positions the whole thing. // // The counterpart to "pvPrepareTypeaheadRender()", and shared by both // renderers for the same reason. Assembling the markup is the caller's // job, because that is the half the two genuinely differ over; everything // from there on is the same either way. // // // (Named) Parameters: // // arrObjects: Only passed on to "fnPostProcessTypeaheadDisplay", and // so only meaningful alongside it. // // fnPostProcessTypeaheadDisplay: // Optional caller hook, invoked once the markup is in // place, with "typeaheadDiv" and "arrObjects". // // html: The finished dropdown markup, rendered as-is. // // isExactMatch: Set when a row was recognized as naming exactly what is // already typed. That row (named by the div's // "selectedRowIndex", which the caller set as it emitted // the row) is highlighted and the field's change hook // fires. Ignored where the field does not auto-match. // // renderContext: What "pvPrepareTypeaheadRender()" answered for this // render. // //-------------------------------------------------------------------------- function pvFinishTypeaheadRender(params_) { var objRenderContext = params_.renderContext, strHTML = params_.html, tmpIsExactMatch = params_.isExactMatch, fnPostProcessTypeaheadDisplay = params_.fnPostProcessTypeaheadDisplay, arrObjects = params_.arrObjects, objTypeAheadDiv = objRenderContext.typeAheadDiv; objTypeAheadDiv.style.display = 'none'; objTypeAheadDiv.isDropdownOpen = true; objTypeAheadDiv.style.zIndex = 501; objTypeAheadDiv.innerHTML = strHTML; if (tmpIsExactMatch && objRenderContext.doAutoMatch) { var objSelectedRow = getObj('typeAheadRow' + objTypeAheadDiv.selectedRowIndex); objSelectedRow.className = 'typeAheadSelectedRow'; objSelectedRow.setAttribute('mjtclass', 'typeAheadSelectedRow'); // We've found a match (and thus set the selected row and id value // (if there is one) so fire the onchange event (if there is one) pvFireTypeAheadOnChangeIfNecessary({ textField: objRenderContext.currText, useAsynchForOnChange: false }); } if (fnPostProcessTypeaheadDisplay) { fnPostProcessTypeaheadDisplay({ typeaheadDiv: objTypeAheadDiv, arrObjects: arrObjects }); } setTimeout(doPositionPicker, 0); } //-------------------------------------------------------------------------- // // Decides whether the row being emitted is the one the dropdown should open // with already selected, and records it as such if so. // // Called once per row by both renderers, threading its verdict through // "matchState" because the answer depends on the rows already seen: the // rule is to take the FIRST row whose name is exactly what has been typed, // unless a LATER row is an equally exact match and is also the value the // field was already holding. That is why an exact match does not end the // scan, and why "matchedOriginalIdValue" latches. // // Writes the matched row's id into the field's hidden id field as it goes, // so a field left showing an auto-matched row is id-backed the same way one // the user picked from is. // // // (Named) Parameters: // // currentText: What the field currently holds, raw. A row matches when // its name equals this case-insensitively. // // matchState: The scan's running verdict, carrying "isExactMatch" and // "matchedOriginalIdValue", both of which this updates. // The caller starts it off with both false and reads // "isExactMatch" once every row has been offered. // // name: The row's display name, already unescaped. // // renderContext: What "pvPrepareTypeaheadRender()" answered for this // render. // // rowId: Index of this row within the rendered dropdown, which // is what gets recorded as the selected row. Not the // index within the incoming data, which separator rows // make differ. // // unescapedId: The row's id, already unescaped, since the two // renderers get there differently. // //-------------------------------------------------------------------------- function pvApplyExactMatchIfRowMatches(params_) { var objRenderContext = params_.renderContext, objMatchState = params_.matchState, strName = params_.name, strUnescapedId = params_.unescapedId, strCurrentText = params_.currentText, tmpRowId = params_.rowId, objCurrId = objRenderContext.currId; if (!objMatchState.matchedOriginalIdValue && strName && strName.toLowerCase() === strCurrentText.toLowerCase()) { // If there is more than one product with the same name, we want to // select the first item with that name, unless the previously // selected item is also an identical match, but is NOT the FIRST // such match. var tmpThisRowMatchesTheOriginalId = objCurrId && (strUnescapedId === objRenderContext.originalIdValue); if (objRenderContext.doAutoMatch && (tmpThisRowMatchesTheOriginalId || !objMatchState.isExactMatch)) { // Either this row is an exact match and its id matches the // previously selected id, or it is simply the first exact // match. // // In the former case, we definitely want it to be the selected // row (and definitely won't want any other row to be selected). // // In the latter case, we want this to be the selected row // unless there is a later row that is also an exact match, and // that is the same row as was previously selected. if (objCurrId) { objCurrId.value = strUnescapedId; } objMatchState.matchedOriginalIdValue = objMatchState.matchedOriginalIdValue || tmpThisRowMatchesTheOriginalId; objMatchState.isExactMatch = true; objRenderContext.typeAheadDiv.selectedRowIndex = tmpRowId; } } } //-------------------------------------------------------------------------- // Whether one of a row's optional flag columns is set, which this row // format spells as the string "1". // // An absent, null or negative index all mean the caller never supplied that // column at all, which reads the same as the flag being off. //-------------------------------------------------------------------------- function pvRowFlagIsSet(arrRowColumns_, idxFlagColumn_) { return typeof idxFlagColumn_ !== 'undefined' && idxFlagColumn_ !== null && idxFlagColumn_ > -1 && '1' === arrRowColumns_[idxFlagColumn_]; } //-------------------------------------------------------------------------- // // Builds the dropdown's markup and puts it on screen. The one renderer // behind both varieties of typeahead, which differ only in how a row // arrives. // // The two reach a row's fields differently, by column index into a // colon-delimited string or by property name on an object, and // "fnDescribeRow" is the whole of that difference. Everything from the // markup outwards is the same either way, which is why this exists rather // than the two near-copies it replaced. // // // (Named) Parameters: // // arrRows: The rows to render, in whatever shape "fnDescribeRow" // understands. Empty renders the "no match found" row, or // dismisses the dropdown outright when the field is also // empty. // // fnDescribeRow: Called once per row, with that row and its index, to // answer what this needs to know about it. Either // {isSeparator: true} or an object carrying "rowVals" // (stashed for the host's explicit-selection handler), // "id"/"name" (unescaped), "escapedId"/"escapedName" (as // they are to appear in the row's "mjtid"/"mjtvalue" // attributes), "isInactive", // "inactiveMeansComplete", "extraHTML" and // "renderExtraOnTheLeft". // // hasExtraColumn: Whether rows carry a second column at all. Decides the // colspan of the rows that span both, and whether an // extra cell is emitted per row. Kept separate from any // one row's "extraHTML" because the cell is emitted even // for rows with nothing to put in it. // // showInactiveStateSuffix: // Whether an inactive row's name is followed by // "(Inactive)", or "(Complete)" where the row says its // inactive state means completion. Only the JSON variety // asks for it; the text variety's own entry point says // why it deliberately does not. // // additionalFirstRowHtml, // additionalLastRowHtml: // Optional caller-supplied rows placed before and after // the real ones. The last one is honoured only where the // field's config asks for it, and is also what the "no // match found" answer offers in place of any rows at all. // // areMoreItems: Set when the server truncated the result set, which // adds a trailing "There are more items" row. // // forceFireTypeAheadOnChange: // Fire the field's change hook even when the answer is // empty and the dropdown is simply dismissed. // // forceIgnoreCurrentTextForBold: // Render each name unemphasized rather than emphasizing // the leading characters the user typed. // // arrObjects, // fnPostProcessTypeaheadDisplay: // Optional caller hook invoked once the markup is in // place, and the rows handed to it. Passed straight // through to "pvFinishTypeaheadRender()". // // fnNoMessagePostfix, // originalPayload: Passed straight through to "pvGetNoMatchFoundHtml()", // and so only consulted when nothing matched. // //-------------------------------------------------------------------------- function pvRenderTypeaheadDropdown(params_) { var arrRows = params_.arrRows, fnDescribeRow = params_.fnDescribeRow, tmpHasExtraColumn = params_.hasExtraColumn, tmpShowInactiveStateSuffix = params_.showInactiveStateSuffix, tmpAreMoreItems = params_.areMoreItems, tmpForceFireTypeAheadOnChange = params_.forceFireTypeAheadOnChange, tmpForceIgnoreCurrentTextForBold = params_.forceIgnoreCurrentTextForBold, strAdditionalFirstRowHtml = params_.additionalFirstRowHtml, strAdditionalLastRowHtml = params_.additionalLastRowHtml, objRenderContext = pvPrepareTypeaheadRender(); if (!objRenderContext) { // The type-ahead has already been dismissed, or its form is gone. return; } var objTypeAheadDiv = objRenderContext.typeAheadDiv, objTAConfig = objRenderContext.taConfig, theForm = objRenderContext.form, objCurrText = objRenderContext.currText, objMatchState = { isExactMatch: false, matchedOriginalIdValue: false }; if (!(arrRows && arrRows.length)) { if (objRenderContext.isText) { var strNoMatchFoundHtml = pvGetNoMatchFoundHtml({ taConfig: objTAConfig, additionalLastRowHtml: strAdditionalLastRowHtml, originalPayload: params_.originalPayload, fnNoMessagePostfix: params_.fnNoMessagePostfix }); objTypeAheadDiv.style.display = 'none'; objTypeAheadDiv.isDropdownOpen = true; objTypeAheadDiv.style.zIndex = 501; objTypeAheadDiv.innerHTML = strNoMatchFoundHtml; setTimeout(doPositionPicker, 0); } else { mjtElemData(objTypeAheadDiv, 'arrTypeaheadRowVals', null); pbHideTypeAheadDiv(); if (tmpForceFireTypeAheadOnChange) { pvFireTypeAheadOnChangeIfNecessary({ textField: objCurrText, useAsynchForOnChange: false }); } } } else { pbSetInvalidTextClass( objCurrText, 0); // isInvalid_ var strColspanAttr = tmpHasExtraColumn ? ' colspan="2"' : '', strAdditionalClasses = objTAConfig ? (objTAConfig.typeAheadTableAdditionalClasses || '') : '', strHTML = '' + '', currTextRaw = objCurrText.value, currText = (tmpForceIgnoreCurrentTextForBold || objTAConfig.ignoreCurrentTextForBold) ? '' : currTextRaw, strExistingSelectionText = objTAConfig.existingSelectionText, rowId = 0, i, arrTypeaheadRowVals = []; objTypeAheadDiv.itemCount = arrRows.length; mjtElemData( objTypeAheadDiv, 'arrTypeaheadRowVals', arrTypeaheadRowVals); if (strAdditionalFirstRowHtml) { strHTML += strAdditionalFirstRowHtml; } for (i = 0; i < arrRows.length; ++i) { var objRowInfo = fnDescribeRow(arrRows[i], i), strOnMouseDown = 'onmousedown="' + 'TypeAheadPopup.PROTECTED.selectTypeAheadItem({' + 'rowIndex: ' + rowId + ',' + 'activeSelection: true,' + 'setFocus: true,' + 'event: event})" ', strOnMouseUp = 'onmouseup="' + 'TypeAheadPopup.hideTypeAheadDiv();' + 'TypeAheadPopup.PROTECTED.setFocusToTextField({' + 'formId: \'' + theForm.id + '\',' + 'fieldName: \'' + objCurrText.name + '\'});"'; if (objRowInfo.isSeparator) { // It's a separator! --objTypeAheadDiv.itemCount; strHTML += '' + '' + ''; } else { arrTypeaheadRowVals[rowId] = objRowInfo.rowVals; var strName = objRowInfo.name; pvApplyExactMatchIfRowMatches({ renderContext: objRenderContext, matchState: objMatchState, name: strName, unescapedId: objRowInfo.id, currentText: currTextRaw, rowId: rowId }); var tmpIsInactive = objRowInfo.isInactive, tmpRenderExtraOnTheLeft = objRowInfo.renderExtraOnTheLeft, strBolded = strName.substring(0, currText.length), strPostBolded = strName.substring(currText.length), tmpMarkAsPriorSelection = strExistingSelectionText && strName && (strName.toLowerCase() === strExistingSelectionText.toLowerCase()), strAdditionalRowClass = tmpMarkAsPriorSelection ? ' typeAheadPriorSelection' : '', // Emitted for every row once the field has a second // column at all, empty or not, so that the rows stay // aligned with one another. strExtraHTML = tmpHasExtraColumn ? '' : '', strRow = ''; if (strExtraHTML && tmpRenderExtraOnTheLeft) { strRow += strExtraHTML; } strRow += ''; if (strExtraHTML && !tmpRenderExtraOnTheLeft) { strRow += strExtraHTML; } strRow += ''; strHTML += strRow; ++rowId; } } if (tmpAreMoreItems) { var strMoreMsg = ' There are more items' + (currText ? ' starting with "' + htmlEncode(currText) + '"' : ''); strHTML += '' + '' + strMoreMsg + '' + ''; } if (objTAConfig) { if (strAdditionalLastRowHtml && objTAConfig.showAdditionalLastRow) { strHTML += strAdditionalLastRowHtml; } } strHTML += '' + '
' + (objRowInfo.extraHTML || '') + ''; if (tmpIsInactive) { strRow += ''; } strRow += '' + htmlEncode(strBolded) + '' + htmlEncode(strPostBolded); if (tmpIsInactive) { if (tmpShowInactiveStateSuffix) { strRow += ' (' + (objRowInfo.inactiveMeansComplete ? 'Complete' : 'Inactive') + ')'; } strRow += ''; } strRow += '
'; pvFinishTypeaheadRender({ renderContext: objRenderContext, html: strHTML, isExactMatch: objMatchState.isExactMatch, fnPostProcessTypeaheadDisplay: params_.fnPostProcessTypeaheadDisplay, arrObjects: params_.arrObjects }); } } //-------------------------------------------------------------------------- // // Renders the dropdown from rows delivered as escaped, colon-delimited // strings, which is what the older remote-scripting responses answer with // (see "pbRSCallbackGetTypeAheadSearchText()"). // // The sibling of "pbProcessTypeAheadSearchOfJSONArray()", which does the // same job for rows delivered as JSON objects. The two differ only in how a // row's fields are reached: by column index here, by property name there. // // // (Named) Parameters: // // arrEscapedValues: The rows to render, each one a single escaped, // colon-delimited string whose fields are addressed by // the column indexes below. An empty array renders the // "no match found" row, or dismisses the dropdown // outright when the field is also empty. // // idColumnIndex, // nameColumnIndex: // Which colon-delimited field of a row holds its id and // its display name. Both required; everything else about // a row is optional. // // extraHTMLColumnIndex: // Optional field holding supplemental HTML to render in // a second column beside the name. Absent, negative or // null all mean "no second column", and the rendered // rows carry no colspan. // // isInactiveColumnIndex: // Optional field holding "1" for a row that should // render as inactive. // // inactiveMeansCompleteColumnIndex: // Optional field holding "1" where a row's inactive // state means "complete" rather than plain inactive. // Read here but not yet acted on, so it currently // changes nothing about what is rendered. // // areMoreItems: Set when the server truncated the result set, which // adds a trailing "There are more items" row. It is a // message only; nothing fetches the rest. // // forceFireTypeAheadOnChange: // Fire the field's own change hook even when the answer // is empty and the dropdown is simply dismissed. Only // matters on that path, since a rendered exact match // fires it regardless. // // forceIgnoreCurrentTextForBold: // Render each name unemphasized rather than emphasizing // the leading characters the user typed. Also settable // for the field as a whole, as the typeahead config's // "ignoreCurrentTextForBold"; either one turns the // emphasis off. // //-------------------------------------------------------------------------- function pvProcessTypeAheadSearchTextArray(params_) { var idxIdColumn = params_.idColumnIndex, idxNameColumn = params_.nameColumnIndex, idxExtraHTMLColumn = params_.extraHTMLColumnIndex, idxIsInactiveColumn = params_.isInactiveColumnIndex, idxInactiveMeansCompleteColumn = params_.inactiveMeansCompleteColumnIndex; if (typeof idxExtraHTMLColumn === 'undefined' || null === idxExtraHTMLColumn || idxExtraHTMLColumn < 0) { idxExtraHTMLColumn = -1; } pvRenderTypeaheadDropdown({ arrRows: params_.arrEscapedValues, hasExtraColumn: idxExtraHTMLColumn > -1, areMoreItems: params_.areMoreItems, forceFireTypeAheadOnChange: params_.forceFireTypeAheadOnChange, forceIgnoreCurrentTextForBold: params_.forceIgnoreCurrentTextForBold, // Deliberately off, and not an oversight waiting to be corrected. // Callers of this variety commonly already say that a row is // inactive in their own way, through the extra HTML column or by // styling the whole row, so a suffix added here would say it a // second time and could contradict what the caller is already // showing. // // Should a caller ever want it, this is where it becomes that // caller's own option rather than a blanket switch for the whole // variety. showInactiveStateSuffix: false, fnDescribeRow: function (strRowValue_) { var rcRowInfo; if (strRowValue_ === '-') { rcRowInfo = { isSeparator: true }; } else { var arrRowColumns = strRowValue_.split(':'), strEscapedId = arrRowColumns[idxIdColumn], strEscapedName = arrRowColumns[idxNameColumn]; rcRowInfo = { // Handed to the host's explicit-selection handler as // "arrTypeaheadRowVals", still as the split row rather // than anything this reshaped. rowVals: arrRowColumns, id: unescape(strEscapedId), name: unescape(strEscapedName), // Already escaped by whoever built the row, so they go // into the attributes exactly as they arrived. escapedId: strEscapedId, escapedName: strEscapedName, isInactive: pvRowFlagIsSet( arrRowColumns, idxIsInactiveColumn), inactiveMeansComplete: pvRowFlagIsSet( arrRowColumns, idxInactiveMeansCompleteColumn), extraHTML: idxExtraHTMLColumn > -1 && idxExtraHTMLColumn < arrRowColumns.length ? unescape(arrRowColumns[idxExtraHTMLColumn]) : '' }; } return rcRowInfo; } }); } //-------------------------------------------------------------------------- // Processes Remote Scripting responses. //-------------------------------------------------------------------------- function pbRSCallbackGetTypeAheadSearchText(responseString_, contextId_, processResultParams_) { var objTypeAheadDiv = getObj('typeAheadDiv'), objTAConfig = objTypeAheadDiv.typeAheadConfigParams; if(!objTAConfig) { // The type-ahead has already been dismissed. return; } // At this point we know that a type-ahead is currently open, but we // can't be sure that: // // 1) This callback is for this type-ahead (i.e. it could be a // really slow response from a prior type-ahead) // 2) This callback is the latest request from this type-ahead // // To handle these cases: // // First check that the remote scripting context id stored on the // type-ahead element matches up with the context id of this callback. // // If it doesn't match, then this is at the very least a stale request. // ContextIds look like mjtrs#, ex: mjtrs1 if(objTAConfig.expectedContextId && contextId_ && objTAConfig.expectedContextId !== contextId_) { ConsoleLogging.logMessage( 'TypeAheadPopup: expected to receive callback from ContextId=' + objTAConfig.expectedContextId + ', but instead received callback from ContextId=' + contextId_); } else if(checkResponseForError(responseString_)) { var arrEscapedValues, tmpAreMoreItems; if (!trim(responseString_)) { // No items...process an empty array arrEscapedValues = []; } else { // The response should be prefixed by a "1;" or a "0;", // indicating whether or not there are more after what is // listed... tmpAreMoreItems = responseString_.substring(0, 1) === '1'; responseString_ = responseString_.substring(2); arrEscapedValues = responseString_.split(','); } var tmpHasExtraColumnIndex = typeof processResultParams_.extraHTMLColumnIndex !== 'undefined' && processResultParams_.extraHTMLColumnIndex !== null, idxExtraHTMLColumn = tmpHasExtraColumnIndex ? processResultParams_.extraHTMLColumnIndex : -1, tmpHasIsInactiveColumnIndex = typeof processResultParams_.isInactiveColumnIndex !== 'undefined' && processResultParams_.isInactiveColumnIndex !== null, tmpIsInactiveColumnIndex = tmpHasIsInactiveColumnIndex ? processResultParams_.isInactiveColumnIndex : -1, tmpHasInactiveMeansCompleteColumnIndex = typeof processResultParams_.inactiveMeansCompleteColumnIndex !== 'undefined' && processResultParams_.inactiveMeansCompleteColumnIndex !== null, tmpInactiveMeansCompleteColumnIndex = tmpHasInactiveMeansCompleteColumnIndex ? processResultParams_.inactiveMeansCompleteColumnIndex : -1; pvProcessTypeAheadSearchTextArray({ arrEscapedValues: arrEscapedValues, idColumnIndex: 0, nameColumnIndex: 1, extraHTMLColumnIndex: idxExtraHTMLColumn, isInactiveColumnIndex: tmpIsInactiveColumnIndex, inactiveMeansCompleteColumnIndex: tmpInactiveMeansCompleteColumnIndex, areMoreItems: tmpAreMoreItems, forceFireTypeAheadOnChange: false, forceIgnoreCurrentTextForBold: processResultParams_.forceIgnoreCurrentTextForBold ? 1 : 0 }); } } //-------------------------------------------------------------------------- // // params_: // { // extraHTMLPropertyName, // renderExtraOnTheLeftPropertyName, // arrObjects, // forceFireTypeAheadOnChange, // fnPostProcessTypeaheadDisplay // } // //-------------------------------------------------------------------------- function pbProcessTypeAheadSearchOfJSONArray(params_) { var strExtraHTMLPropertyName = params_.extraHTMLPropertyName, strRenderExtraOnTheLeftPropertyName = params_.renderExtraOnTheLeftPropertyName; pvRenderTypeaheadDropdown({ arrRows: params_.arrObjects, hasExtraColumn: !!strExtraHTMLPropertyName, areMoreItems: params_.areMoreItems, forceFireTypeAheadOnChange: params_.forceFireTypeAheadOnChange, forceIgnoreCurrentTextForBold: params_.forceIgnoreCurrentTextForBold, additionalFirstRowHtml: params_.additionalFirstRowHtml, additionalLastRowHtml: params_.additionalLastRowHtml, originalPayload: params_.originalPayload, fnNoMessagePostfix: params_.fnNoMessagePostfix, fnPostProcessTypeaheadDisplay: params_.fnPostProcessTypeaheadDisplay, arrObjects: params_.arrObjects, showInactiveStateSuffix: true, fnDescribeRow: function (objRow_) { var rcRowInfo; if (objRow_.isSeparator) { rcRowInfo = { isSeparator: true }; } else { var strUnescapedId = objRow_.id, strName = objRow_.name; if (objRow_ && !(strUnescapedId || strName) && strUnescapedId !== '' && strName !== '') { // Looks like the given rows are not objects, so just // use them directly as both the id and name... strUnescapedId = objRow_; strName = objRow_; } rcRowInfo = { // Handed to the host's explicit-selection handler as // "arrTypeaheadRowVals", still as the row object the // caller supplied. rowVals: objRow_, id: strUnescapedId, name: strName, // Unescaped as they arrive, so they are escaped here on // the way into the row's attributes. escapedId: escape(strUnescapedId), escapedName: escape(strName), isInactive: objRow_.isInactive, inactiveMeansComplete: objRow_.inactiveMeansComplete, extraHTML: strExtraHTMLPropertyName ? objRow_[strExtraHTMLPropertyName] || '' : '', renderExtraOnTheLeft: strRenderExtraOnTheLeftPropertyName ? objRow_[strRenderExtraOnTheLeftPropertyName] : false }; } return rcRowInfo; } }); } //-------------------------------------------------------------------------- // // Processes callback data that has a JSON form. // //-------------------------------------------------------------------------- function pbRSCallbackGetTypeAheadSearchTextJSON(json_, optionsHolder_) { var objOriginalOptions = optionsHolder_.originalOptions, objOriginalPayload = optionsHolder_.originalPayload, tmpContextId = optionsHolder_.contextId, objTypeAheadDiv = getObj('typeAheadDiv'), objTAConfig = (objTypeAheadDiv || {}).typeAheadConfigParams, strArrayPropertyName = objOriginalOptions.arrayPropertyName ? objOriginalOptions.arrayPropertyName : 'array', arrObjects = json_[strArrayPropertyName], fnPreprocessArray = objOriginalOptions.fnPreprocessArray, fnPreprocessObject = objOriginalOptions.fnPreprocessObject, strSearchTerm = (objOriginalPayload || {}).term || '', tmpAreMoreItems = json_.isMore; if(!objTAConfig) { // The type-ahead has already been dismissed. return; } // At this point we know that a type-ahead is currently open, but we // can't be sure that: // // 1) This callback is for this type-ahead (i.e. it could be a // really slow response from a prior type-ahead) // 2) This callback is the latest request from this type-ahead // // To handle these cases: // // First check that the remote scripting context id stored on the // type-ahead element matches up with the context id of this callback. // // If it doesn't match, then this is at the very least a stale request. // ContextIds look like mjtrs#, ex: mjtrs1 if(objTAConfig.expectedContextId && tmpContextId && objTAConfig.expectedContextId !== tmpContextId) { ConsoleLogging.logMessage( 'TypeAheadPopup: expected to receive json-callback from ContextId=' + objTAConfig.expectedContextId + ', but instead received callback from ContextId=' + tmpContextId); return; } if(fnPreprocessArray) { fnPreprocessArray({ arrItems: arrObjects, searchTerm: strSearchTerm }); } if(fnPreprocessObject && arrObjects) { arrObjects.forEach( function(object_) { fnPreprocessObject(object_); }); } pbProcessTypeAheadSearchOfJSONArray({ extraHTMLPropertyName: objOriginalOptions.extraHTMLPropertyName, renderExtraOnTheLeftPropertyName: objOriginalOptions.renderExtraOnTheLeftPropertyName, forceIgnoreCurrentTextForBold: objOriginalOptions.forceIgnoreCurrentTextForBold, arrObjects: arrObjects, areMoreItems: tmpAreMoreItems, fnPostProcessTypeaheadDisplay: objOriginalOptions.fnPostProcessTypeaheadDisplay, additionalLastRowHtml: objOriginalOptions.additionalLastRowHtml, additionalFirstRowHtml: objOriginalOptions.additionalFirstRowHtml, originalPayload: optionsHolder_.originalPayload, fnNoMessagePostfix: objOriginalOptions.fnNoMessagePostfix }); } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pbUpdateTypeAheadBoxTimerFired() { var objTypeAheadDiv = getObj('typeAheadDiv'), configParams = objTypeAheadDiv.typeAheadConfigParams; if(configParams) { var theForm = getObj(configParams.formId); // Do a lookup using the current text... if(!theForm[configParams.textFieldName]) { // Debugging info! ConsoleLogging.logMessage( '\n' + '\n' + '!!!!\n' + 'Can not find text field for type-ahead.\n' + ' configParams.textFieldName="' + configParams.textFieldName + '"\n' + '!!!!'); } var currText = theForm[configParams.textFieldName].value.toLowerCase(), fnGetTypeAheadData; if(currText.length === 0 && !configParams.showTypeAheadWhenUnmatched) { // No items, which on an empty field means "dismiss the // dropdown". The column indexes still have to be given, but // nothing reads them with no row to address. pvProcessTypeAheadSearchTextArray({ arrEscapedValues: [], idColumnIndex: 0, nameColumnIndex: 1, areMoreItems: false, forceFireTypeAheadOnChange: true }); } else { fnGetTypeAheadData = StringificationUtils.getOrResolveFunction({ functionNameOrReference: configParams.fnGetTypeAheadData, descriptionOfFunctionBeingResolved: 'configParams.fnGetTypeAheadData' }); if(fnGetTypeAheadData) { fnGetTypeAheadData( theForm, currText, configParams, configParams.getTypeAheadDataFnParameter); } } } } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pbHandleTypeAheadTextBoxBlur() { var objTypeAheadDiv = getObj('typeAheadDiv'); if(objTypeAheadDiv && !objTypeAheadDiv.ignoreBlur) { pbHideTypeAheadDiv(); } } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pvFireAsynchHandleTypeAheadTextChange() { if(_typeAheadTextChangeTimer) { clearTimeout(_typeAheadTextChangeTimer); } // By default, wait 1/3 of a second before running off for data... var timeout = 300; if(getObj('typeAheadDiv').typeAheadConfigParams.forceImmediatePopup) { timeout = 0; } _typeAheadTextChangeTimer = setTimeout(pbUpdateTypeAheadBoxTimerFired, timeout); } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pbHandleTypeAheadTextBoxFocus(e_, formId_, typeAheadConfigParams_) { var objTypeAheadDiv = getObj('typeAheadDiv'); objTypeAheadDiv.ignoreBlur = false; if(!objTypeAheadDiv.ignoreFocus) { objTypeAheadDiv.typeAheadConfigParams = typeAheadConfigParams_; pvRememberTypeAheadConfigParamsForField(typeAheadConfigParams_); var theForm = getObj(typeAheadConfigParams_.formId), objIdField = theForm[typeAheadConfigParams_.idFieldName]; if(typeAheadConfigParams_.forceTypeAheadOnFocus || (typeAheadConfigParams_.showTypeAheadWhenUnmatched && !objIdField.value.length)) { pvFireAsynchHandleTypeAheadTextChange(); } } } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pvIncrementTypeAheadSelectionIndex(moveDown_) { var objTypeAheadDiv = getObj('typeAheadDiv'), itemCount = objTypeAheadDiv.itemCount ? objTypeAheadDiv.itemCount : 0; ptRemoveTypeAheadSelectionOrHoverHighlight(false); if(typeof objTypeAheadDiv.selectedRowIndex === 'undefined') { // Nothing selected yet, just select the first one. ptSelectTypeAheadItem({ rowIndex: 0, activeSelection: false }); } else { var currSelIndex = parseInt(objTypeAheadDiv.selectedRowIndex, 10); if(moveDown_) { currSelIndex++; } else { --currSelIndex; } if(currSelIndex >= itemCount) { currSelIndex = itemCount - 1; } if(currSelIndex < 0) { currSelIndex = 0; } ptSelectTypeAheadItem({ rowIndex: currSelIndex, activeSelection: false }); } } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pvSelectFirstLastInTypeahead(params_) { var objTypeAheadDiv = params_.typeAheadDiv, tmpFirst = params_.first; if(pvIsTypeaheadDivShowing(objTypeAheadDiv)) { var itemCount = objTypeAheadDiv.itemCount ? objTypeAheadDiv.itemCount : 0; if(itemCount) { ptSelectTypeAheadItem({ rowIndex: tmpFirst ? 0 : itemCount - 1, activeSelection: false, setFocus: false }); } } } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pvPageTypeAheadIfNecessary(params_) { var objTypeAheadDiv = params_.typeAheadDiv, tmpUp = params_.up; if(pvIsTypeaheadDivShowing(objTypeAheadDiv)) { var tmpItemCount = objTypeAheadDiv.itemCount ? objTypeAheadDiv.itemCount : 0, tmpSelectedIndex = objTypeAheadDiv.selectedRowIndex ? objTypeAheadDiv.selectedRowIndex : 0, firstVisibleItem = 0, lastVisibleItem = tmpItemCount - 1, topOfViewport = objTypeAheadDiv.scrollTop, bottomOfViewport = topOfViewport + objTypeAheadDiv.clientHeight - 1, i; for(i = 0; i < tmpItemCount; ++i) { var objRow = getObj('typeAheadRow' + i); if(topOfViewport + objRow.offsetHeight / 2 >= objRow.offsetTop && topOfViewport + objRow.offsetHeight / 2 < objRow.offsetTop + objRow.offsetHeight) { // Found the first visible item... firstVisibleItem = i; } if(bottomOfViewport - objRow.offsetHeight / 2 >= objRow.offsetTop && bottomOfViewport - objRow.offsetHeight / 2 < objRow.offsetTop + objRow.offsetHeight) { // Found the last visible item... lastVisibleItem = i; } } var indexToSelect; if(tmpUp && tmpSelectedIndex > firstVisibleItem) { // Paging up, but we're not at the top of the viewport, // so simply select the top item in the viewport. indexToSelect = firstVisibleItem; } else if((!tmpUp) && tmpSelectedIndex < lastVisibleItem) { // Paging down, but we're not at the bottom of the viewport, // so simply select the bottom item in the viewport. indexToSelect = lastVisibleItem; } else { var itemsPerPage = lastVisibleItem - firstVisibleItem; if(itemsPerPage < 1) { itemsPerPage = 1; } if(tmpUp) { // Paging to the top of the previous page... indexToSelect = firstVisibleItem - itemsPerPage; } else { // Paging to the bottom of the next page... indexToSelect = lastVisibleItem + itemsPerPage; } } if(indexToSelect >= tmpItemCount) { indexToSelect = tmpItemCount - 1; } if(indexToSelect < 0) { indexToSelect = 0; } ptSelectTypeAheadItem({ rowIndex: indexToSelect, activeSelection: false, setFocus: false }); } } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pvClearForAndHandleTypeAheadChange(params_) { var objIdField = params_.idField, objTextField = params_.textField, tmpUseAsynchForOnChange = params_.useAsynchForOnChange; // Clear out the id pending selection... if(objIdField && objIdField.value.length) { objIdField.value = ''; pvFireTypeAheadOnChangeIfNecessary({ textField: objTextField, useAsynchForOnChange: tmpUseAsynchForOnChange }); } pvFireAsynchHandleTypeAheadTextChange(); } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pbHandleTypeAheadTextBoxOnInput(e_, formId_, typeAheadConfigParams_) { var objTypeAheadDiv = getObj('typeAheadDiv'); if(!objTypeAheadDiv.ignoreTextChange) { objTypeAheadDiv.typeAheadConfigParams = typeAheadConfigParams_; pvRememberTypeAheadConfigParamsForField(typeAheadConfigParams_); var theForm = getObj(typeAheadConfigParams_.formId), objTextField = theForm[typeAheadConfigParams_.textFieldName], objIdField = theForm[typeAheadConfigParams_.idFieldName]; if(pvCheckIfTypeaheadDisplayValueChangedAndSetLastValue(objTextField)) { pvClearForAndHandleTypeAheadChange({ idField: objIdField, textField: objTextField, useAsynchForOnChange: false }); } } } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pbHandleTypeAheadTextBoxKeyDown( e_, formId_, typeAheadConfigParams_, handlingEnter_, skipHideDialogOnEnter_) { var objTypeAheadDiv = getObj('typeAheadDiv'); if(!objTypeAheadDiv.ignoreTextChange) { objTypeAheadDiv.typeAheadConfigParams = typeAheadConfigParams_; pvRememberTypeAheadConfigParamsForField(typeAheadConfigParams_); var theForm = getObj(typeAheadConfigParams_.formId), objTextField = theForm[typeAheadConfigParams_.textFieldName], objIdField = theForm[typeAheadConfigParams_.idFieldName]; switch(e_.keyCode) { case 38: // up pvIncrementTypeAheadSelectionIndex(false); break; case 40: // down if(e_.altKey) { var strPickerActivation = objTextField.getAttribute('mjttypeaheadactivation'); if(strPickerActivation) { pbHideTypeAheadDiv(); // TODO: Work out a way to obviate these "eval()" // calls. eval(strPickerActivation); } } else { pvIncrementTypeAheadSelectionIndex(true); } break; case 33: // pageup pvPageTypeAheadIfNecessary({ typeAheadDiv: objTypeAheadDiv, up: true }); return false; case 34: // pagedown pvPageTypeAheadIfNecessary({ typeAheadDiv: objTypeAheadDiv, up: false }); return false; case 27: // Escape if(pbHideTypeAheadDiv()) { // The dropdown was showing, so prevent the event from // propagating so that the only impact will be dismissal // of the typeahead. (e.g. if this is in a dialog, the // dialog won't also be dismissed.) cancelEvent(e_); return false; } break; case 13: if(pvHandleExplicitTypeAheadSelection()) { return false; } else { if(!skipHideDialogOnEnter_) { pbHideTypeAheadDiv(); } if(!handlingEnter_) { // Not handling enter separately, so return false // to prevent the beep on IE. return false; } } break; case 9: // Tab break; case 116: // F5 break; case 37: // Left break; case 39: // Right break; case 16: // Shift break; case 36: // Home if(e_.ctrlKey) { pvSelectFirstLastInTypeahead({ typeAheadDiv: objTypeAheadDiv, first: true }); } break; case 35: // End if(e_.ctrlKey) { pvSelectFirstLastInTypeahead({ typeAheadDiv: objTypeAheadDiv, first: false }); } break; case 17: // Ctrl break; case 18: // Alt break; default: if(e_.altKey) { break; } if(e_.ctrlKey && (65 === e_.keyCode || 82 === e_.keyCode)) { // Ctrl-A/Ctrl-R ... ignore these... break; } // Whatever key the user hit may invalidate the current // selection so clear it now (before we fetch new data) so // that if the user hits 'enter' before the new data arrives // they aren't taken to any previously selected item which // may not match the last keys they entered. pvClearSelection(); pvClearForAndHandleTypeAheadChange({ idField: objIdField, textField: objTextField, // Trigger the onchange event async because it is coming // from a text change that has not yet completed. // If a synchronous event is fired, then the textbox // would still have stale text. useAsynchForOnChange: true }); break; } } } //-------------------------------------------------------------------------- // // Used as the "fnHandleExplicitSelection" parameter when you want to // support submitting a dialog on enter from a typeahead field, but you // don't need to take any sort of specific custom action when a typeahead // option is explicitly selected from the list by clicking or pressing // enter. // // This function will simply dismiss the typeahead and try to set focus back // to the underlying text field. // // This is used to support the behaviour of hiding the typeahead dropdown if // is pressed while the dropdown is showing and an item is selected, // and of allowing the dialog to submit if the dropdown is NOT showing with // an item selected. (In order to actually see this behaviour, you need to // also set the "handlingEnter_" flag of the // "pbHandleTypeAheadTextBoxKeyDown()" function.) // // Note: The typeahead framework will pass the following parameters beyond // the "typeAheadConfigParams_" parameter, but this helper doesn't // need them, so they aren't included in its declared signature: // explicitSelectionFnParameter_ // and arrTypeaheadRowVals_ // //-------------------------------------------------------------------------- function pbSimpleExplicitSelectionHandler( selectedRowIndex_, objSelectedRow_, formId_, typeAheadConfigParams_) { var theForm = getObj(formId_), objTextField = theForm ? theForm[typeAheadConfigParams_.textFieldName] : 0; pbHideTypeAheadDiv(); if(objTextField) { // Asynchronously set focus back to the text field... setTimeout( function() { setFocusIfFocusable(objTextField); }, 0); } } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pvSearchTermPermitsAllItem(searchTerm_) { var tmpSearchTermLen = searchTerm_.length, tmpAllLen = TYPEAHEAD_ITEM_TEXT_ALL.length, rc = 0; if (tmpSearchTermLen <= tmpAllLen) { // Don't even bother with a text comparison unless the length of the // search term is no longer than the text we're vetting. (If the // search term is longer than the value we're filtering, there's no // way it could be a match) if (!tmpSearchTermLen) { // Since there is no search term to filter values, the "All" // item is definitely not being filtered out. rc = 1; } else { rc = stringStartsWith( TYPEAHEAD_ITEM_TEXT_ALL, // string_ searchTerm_, // prefix_ 1); // ignoreCase_ } } return rc; } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pbPrependAllItemToArray(params_) { var arrItems = params_.arrItems, strSearchTerm = params_.searchTerm; if (arrItems && pvSearchTermPermitsAllItem(strSearchTerm)) { var objAllItem = { id: TYPEAHEAD_ITEM_ID_ALL, name: TYPEAHEAD_ITEM_TEXT_ALL }; arrItems.unshift(objAllItem); } return arrItems; } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pbBuildStringifiedTypeaheadInfo(params_) { var objStringifiableParams = params_.stringifiableParams, rcStringified = JSON.stringify(objStringifiableParams); return rcStringified; } //-------------------------------------------------------------------------- // // The one option out of "arrCandidates_" whose name is the text that was // typed, or null when none of them is. // // Compared trimmed and case-insensitively, the same way a typed value is // judged against an existing selection in the first place, so that the // answer to "does this text name a real option" does not depend on which // of the two asked. "Exact" is deliberately the client's definition and // not the server's: a lookup that decided for itself what counted as a // match could hand back something the user never typed. // //-------------------------------------------------------------------------- function pvFindExactMatch(arrCandidates_, searchText_) { var strWantedName = searchText_.trim().toLowerCase(), rcMatch = null; arrCandidates_.some(function(candidate_) { var strCandidateName = (candidate_.name || '').trim().toLowerCase(), tmpIsMatch = strCandidateName === strWantedName; if(tmpIsMatch) { rcMatch = candidate_; } return tmpIsMatch; }); return rcMatch; } //-------------------------------------------------------------------------- // // The extra payload a lookup will actually carry: whatever the caller // supplied at request time, or, where it supplied nothing, whatever the // field declared statically in its "exactMatchInfo". // // Answered in one place because two things need the same answer: the // request itself, and the loop-breaker's key (see // "pvBuildResolutionAttemptKey()"). // //-------------------------------------------------------------------------- function pvResolveExactMatchAdditionalData(params_) { var objExactMatchInfo = params_.exactMatchInfo, objSuppliedAdditionalData = params_.additionalData; return undefined === objSuppliedAdditionalData ? (objExactMatchInfo || {}).additionalData : objSuppliedAdditionalData; } //-------------------------------------------------------------------------- // // The question a lookup is about to be put, as one string: the text to // resolve together with whatever narrowing rides along with it. // // The narrowing belongs in here because it decides the answer. A field // whose lookup honours a control the user can still change (see // "PagedPicker.PickerRequestStage_Enum.prsExactMatchFetch") can be asked // about one piece of text twice and be told different things, so a key made // of the text alone would cache the first refusal and go on refusing a // value that has since become legitimate. // // Serialized rather than compared field by field, since the narrowing is // whatever shape its host chose. Two objects saying the same thing in a // different property order read as different questions, which costs a round // trip and never a wrong answer: that is the safe way round. // //-------------------------------------------------------------------------- function pvBuildResolutionAttemptKey(params_) { var strSearchText = params_.searchText, objAdditionalData = params_.additionalData; return JSON.stringify({ searchText: strSearchText, additionalData: objAdditionalData }); } //-------------------------------------------------------------------------- // // The loop breaker. Records against the field itself the question a lookup // has just answered, and says whether that same question has been answered // before. // // Built in here rather than left to each caller (which is what every // hand-rolled version of this has had to do) because without it a value // that genuinely matches nothing would re-enter the caller, fail again, // fire the same lookup again, and never terminate. // // Keyed on the whole question rather than being a plain "once" flag, so // editing the value, or changing whatever narrows the lookup, asks the // server again, while a question already answered is not asked twice. See // "pvBuildResolutionAttemptKey()" for what a question is made of, and the // caller for why the record waits on the answer. // //-------------------------------------------------------------------------- function pvResolutionAlreadyAttemptedFor(objTextField_, attemptKey_) { var strLastKey = mjtElemData(objTextField_, 'lastResolutionAttemptKey'); return strLastKey === attemptKey_; } //-------------------------------------------------------------------------- //-------------------------------------------------------------------------- function pvRememberResolutionAttempt(objTextField_, attemptKey_) { mjtElemData(objTextField_, 'lastResolutionAttemptKey', attemptKey_); } //-------------------------------------------------------------------------- // // Hands a recovered match to the field's own explicit-selection hook, so // that a host's reaction to a value arriving is the one it already has for // a value picked from the dropdown and it needs no second commit path. // // Invoked off the field's own "typeAheadConfigParams" rather than the // global "typeAheadDiv"'s, which by now describes whatever the user has // touched since the lookup was fired. // // Only "fnHandleExplicitSelectionP" is consulted here, never the older // "fnHandleExplicitSelection" that a field may declare in its place. The // two differ in how the handler is called: the "P" one is handed a single // "params_" object, whereas the older one is handed a fixed list of // positional arguments, among them the selected row's own DOM element. The // row synthesized below has no such element, which the "params_" contract // allows for and the positional one says nothing about. // // A field declaring only "fnHandleExplicitSelection" still gets the // recovered value: "pvCommitTypeAheadSelectionValue()" wrote its text and // id fields before this ran. Only this call is skipped for it. That handler // is still run by "pvHandleExplicitTypeAheadSelection()", which falls back // to it when a row is picked out of the dropdown; it just never runs for a // value recovered by lookup. // //-------------------------------------------------------------------------- function pvNotifyHostOfResolvedSelection(params_) { var objTypeAheadConfigParams = params_.typeAheadConfigParams, objMatch = params_.match, fnHandleExplicitSelectionP = StringificationUtils.getOrResolveFunction({ functionNameOrReference: objTypeAheadConfigParams.fnHandleExplicitSelectionP, descriptionOfFunctionBeingResolved: 'typeAheadConfigParams.fnHandleExplicitSelectionP', suppressErrorsOnMissingNameOrReference: 1 }); if(fnHandleExplicitSelectionP) { fnHandleExplicitSelectionP({ idxSelectedRow: 0, selectedRow: null, formId: objTypeAheadConfigParams.formId, typeAheadConfigParams: objTypeAheadConfigParams, explicitSelectionFnParameter: objTypeAheadConfigParams.explicitSelectionFnParameter, arrTypeaheadRowVals: [objMatch], explicitSelectionResultOptions: {} }); } } //-------------------------------------------------------------------------- // // Commits the lookup's answer, when it holds one, into exactly the state a // dropdown pick would have left behind: the display text and the hidden id // field both written (see "pvCommitTypeAheadSelectionValue()"), and the // host told. A response holding nothing that matches is left alone // entirely, which is what lets the caller's next pass refuse the value with // its own wording rather than a message owned by this framework. // //-------------------------------------------------------------------------- function pvCommitExactMatchIfFound(params_) { var objTypeAheadConfigParams = params_.typeAheadConfigParams, strSearchText = params_.searchText, objMatch = pvFindExactMatch( params_.arrCandidates, strSearchText); if(objMatch) { pvCommitTypeAheadSelectionValue({ typeAheadConfigParams: objTypeAheadConfigParams, id: objMatch.id, displayValue: objMatch.name }); pvNotifyHostOfResolvedSelection({ typeAheadConfigParams: objTypeAheadConfigParams, match: objMatch }); } } //-------------------------------------------------------------------------- // // Asks the field's dedicated exact-match command whether the typed text // names a real option. // // A dedicated command rather than a rescan of what the dropdown last // fetched: that feed is capped (see "MAX_TYPE_AHEAD_TO_RETRIEVE" and the // "isMore" flag it sets), so a common prefix can truncate the exact match // out of it and see a legitimate value refused. // // Buttons are deliberately left to the standard disable/enable handling // here, unlike the dropdown's own fetch: this runs while a submit is held // open on the answer, so a second submit landing in the middle is the // thing to prevent. // // "additionalData" arrives already settled between what the caller asked // for and what the field declared (see // "pvResolveExactMatchAdditionalData()"), and is sent as it stands. That // choice is not remade here, so that what rides on the request and what the // loop breaker recorded cannot say different things. // //-------------------------------------------------------------------------- function pvRequestExactMatch(params_) { var objTypeAheadConfigParams = params_.typeAheadConfigParams, strSearchText = params_.searchText, fnOnResolved = params_.fnOnResolved, objAdditionalData = params_.additionalData, objExactMatchInfo = objTypeAheadConfigParams.exactMatchInfo, strArrayPropertyName = objExactMatchInfo.arrayPropertyName, objIdHolder = {}; objIdHolder.expectedContextId = jsrsExecuteWithErrorP( objExactMatchInfo.commandString, // commandString_ rsCallbackHandleStandardJSONResponse, // fnCallback_ 'Validating', // doingWhat_ { term: strSearchText, additionalExactMatchData: objAdditionalData }, // payloadDocument_ { idHolder: objIdHolder, fnOnSuccess: function(json_) { pvCommitExactMatchIfFound({ typeAheadConfigParams: objTypeAheadConfigParams, searchText: strSearchText, arrCandidates: json_[strArrayPropertyName] || [] }); fnOnResolved(); } }); // options_ } //-------------------------------------------------------------------------- // // Asks the server whether text a user typed into an id-backed typeahead, // and never picked out of the dropdown, is nonetheless the name of a real // option; if it is, recovers that option's id so the field holds a // complete value instead of being refused. // // Answers "ptrResolutionPending" when a lookup has been fired, in which // case nothing is decided yet and the caller should commit nothing and say // nothing: "fnOnResolved" re-enters it once there is an answer. Answers // "ptrUnresolvedText" whenever no lookup will happen, which covers a field // that declares no exact-match command, a caller with no way to be resumed, // and a question a lookup has already answered (see // "pvBuildResolutionAttemptKey()"). That verdict is a refusal the caller // words itself; this deliberately shows no message of its own, so the // wording stays wherever each host already keeps it. // // The field declares the lookup through "exactMatchInfo" on its // "typeAheadConfigParams": a "commandString", the "arrayPropertyName" the // matching options come back under, and optional "additionalData" passed // along to the command. Declared there, rather than in any server-side // contract, because hosts that hand-write their typeaheads in JavaScript // would have no way to fill in anything only such a contract could // populate. // // // (Named) Parameters: // // additionalData: Optional extra payload for the lookup, replacing // (never merged with) whatever // "exactMatchInfo.additionalData" declared. For a caller // whose narrowing is only known at submit time, such as // the state of a control the user has since touched. // Undefined means "nothing to say", leaving the declared // value in place. Part of what the loop breaker keys on, // so a caller answering differently than it did last // time is asking a new question and gets a fresh lookup // rather than the previous verdict. // // fnOnResolved: Called once the lookup answers, whether or not it // matched anything, to re-enter whatever the caller was // doing when it asked. Required: firing a lookup with no // way to resume would abandon that work with the dialog // still open, so a caller that supplies none is simply // told the text is unresolved. // // formId: Id of the form the typeahead field was built onto. // // textFieldName: Name of the typeahead's visible text field, whose // current contents are the text to resolve. The field's // own configuration, and so the command string this // needs, is found from the field itself (see // "pvRememberTypeAheadConfigParamsForField()"), not // passed in. // //-------------------------------------------------------------------------- function pbResolveUnmatchedTypeaheadText(params_) { var strFormId = params_.formId, strTextFieldName = params_.textFieldName, fnOnResolved = params_.fnOnResolved, objForm = getObj(strFormId), objTextField = objForm ? objForm[strTextFieldName] : null, objTypeAheadConfigParams = pvGetTypeAheadConfigParamsForField(objTextField), objExactMatchInfo = (objTypeAheadConfigParams || {}).exactMatchInfo, strSearchText = objTextField ? trim(objTextField.value) : '', objAdditionalData = pvResolveExactMatchAdditionalData({ exactMatchInfo: objExactMatchInfo, additionalData: params_.additionalData }), strAttemptKey = pvBuildResolutionAttemptKey({ searchText: strSearchText, additionalData: objAdditionalData }), tmpCanAsk = !!objTextField && !!fnOnResolved && !!strSearchText.length && !!(objExactMatchInfo || {}).commandString && !pvResolutionAlreadyAttemptedFor(objTextField, strAttemptKey), rcResolution = PendingTypeaheadResolution_Enum.ptrUnresolvedText; if(tmpCanAsk) { pvRequestExactMatch({ typeAheadConfigParams: objTypeAheadConfigParams, searchText: strSearchText, additionalData: objAdditionalData, // Wrapped so the attempt is recorded by the answer rather than // by the request, immediately before the caller is re-entered. // Re-entry is the whole of what the loop breaker guards // against, and a request that never answers never re-enters // anybody, so recording one that did not arrive would refuse a // perfectly good value on the strength of a lookup that failed. // That matters because there is no one seam to clear it from: a // lookup can go unanswered through a transport error, a // response that fails "checkResponseForError()", or an // "rsErrorObject" in the response, and the three are reported // in three different ways. fnOnResolved: function() { pvRememberResolutionAttempt(objTextField, strAttemptKey); fnOnResolved(); } }); rcResolution = PendingTypeaheadResolution_Enum.ptrResolutionPending; } return rcResolution; } //-------------------------------------------------------------------------- // TypeAheadPopup //-------------------------------------------------------------------------- return { isTypeaheadDropdownShowing: pbIsTypeaheadDropdownShowing, prependAllItemToArray: pbPrependAllItemToArray, buildStringifiedTypeaheadInfo: pbBuildStringifiedTypeaheadInfo, resolveUnmatchedTypeaheadText: pbResolveUnmatchedTypeaheadText, PendingTypeaheadResolution_Enum: PendingTypeaheadResolution_Enum, hideTypeAheadDiv: pbHideTypeAheadDiv, hideTypeAheadDivIfNotTypeAheadDiv: pbHideTypeAheadDivIfNotTypeAheadDiv, rsCallbackGetTypeAheadSearchText: pbRSCallbackGetTypeAheadSearchText, rsCallbackGetTypeAheadSearchTextJSON: pbRSCallbackGetTypeAheadSearchTextJSON, processTypeAheadSearchOfJSONArray: pbProcessTypeAheadSearchOfJSONArray, scrollTypeaheadSelectionIntoViewIfNecessary: pbScrollTypeaheadSelectionIntoViewIfNecessary, updateTypeAheadBoxTimerFired: pbUpdateTypeAheadBoxTimerFired, handleTypeAheadTextBoxBlur: pbHandleTypeAheadTextBoxBlur, handleTypeAheadTextBoxFocus: pbHandleTypeAheadTextBoxFocus, handleTypeAheadTextBoxOnInput: pbHandleTypeAheadTextBoxOnInput, handleTypeAheadTextBoxKeyDown: pbHandleTypeAheadTextBoxKeyDown, simpleExplicitSelectionHandler: pbSimpleExplicitSelectionHandler, setInvalidTextClass: pbSetInvalidTextClass, PROTECTED: { // Named by the inline event handlers the dropdown's own rows are // built with, so these three are reached by name from the DOM (see // "pvRenderTypeaheadDropdown()"), not by any other file. selectTypeAheadItem: ptSelectTypeAheadItem, setFocusToTextField: ptSetFocusToTextField, removeTypeAheadSelectionOrHoverHighlight: ptRemoveTypeAheadSelectionOrHoverHighlight }, TEST: { setSuppressHideDropdown: pvSetSuppressHideDropdown, findExactMatch: pvFindExactMatch, // Seeds a field's configuration the way a focus or keystroke would, // so a spec can put a field into the state the resolver reads // without dispatching real events at it. rememberTypeAheadConfigParamsForField: pvRememberTypeAheadConfigParamsForField, // The auto-select rule, whose answer depends on the rows already // offered and so cannot be read off any single call. Exposed so a // spec can walk a whole row set past it. applyExactMatchIfRowMatches: pvApplyExactMatchIfRowMatches, // The render step that fills the dropdown and schedules its // positioning. Exposed so a spec can reach the gap between the two // without running a lookup. finishTypeaheadRender: pvFinishTypeaheadRender } }; }());