diff --git a/component-library/developer-guide.md b/component-library/developer-guide.md index 431a461ef..723bd3e40 100644 --- a/component-library/developer-guide.md +++ b/component-library/developer-guide.md @@ -43,7 +43,7 @@ Input components (ValidationField, TargetPanel, etc.) stay in the core package b Every app using the library must call `initFirefly()` once before rendering any store-connected component. It sets the Firefly server URL and bootstraps the Redux store. In Storybook it is called in `beforeAll` inside `.storybook/preview.jsx`. -Store-connected components (`ValidationField`, `TargetPanel`, `ListBoxInputField`, `SuggestBoxInputField`, etc.) use `useFieldGroupConnector` and require both `initFirefly()` to have run and a parent `FieldGroup` component to provide the group context. +Store-connected components (`ValidationField`, `TargetPanel`, `ListBoxInputField`, `AutoCompleteInput`, etc.) use `useFieldGroupConnector` and require both `initFirefly()` to have run and a parent `FieldGroup` component to provide the group context. --- diff --git a/component-library/index.js b/component-library/index.js index 7a2149a9b..941d6fdc4 100644 --- a/component-library/index.js +++ b/component-library/index.js @@ -30,7 +30,9 @@ export { SwitchInputFieldView } from '../src/fir // ─── Suggest / autocomplete ────────────────────────────────────────────────── -export { SuggestBoxInputField } from '../src/firefly/js/ui/SuggestBoxInputField.jsx'; +export { AutoCompleteInput, + AutoCompleteInputView, + useAsyncOptions } from '../src/firefly/js/ui/AutoCompleteInput.jsx'; // ─── Target ────────────────────────────────────────────────────────────────── diff --git a/component-library/stories/inputs/SuggestBoxInputField.stories.jsx b/component-library/stories/inputs/SuggestBoxInputField.stories.jsx deleted file mode 100644 index d5a0d729e..000000000 --- a/component-library/stories/inputs/SuggestBoxInputField.stories.jsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from 'react'; -import { SuggestBoxInputField, FieldGroup } from '../../index.js'; - -export default { - title: 'Inputs/SuggestBoxInputField', - component: SuggestBoxInputField, - tags: ['autodocs'], - parameters: { - controls: { disable: true }, actions: { disable: true }, - }, - decorators: [ - (Story, ctx) => ( - - - - ), - ], -}; - -export const Basic = () => ( - { - const catalogs = [ - 'AllWISE', 'CatWISE2020', '2MASS Point Source', '2MASS Extended Source', - 'Gaia DR3', 'Gaia DR2', 'SDSS DR17', 'SDSS DR16', - 'GALEX AIS', 'GALEX MIS', 'Spitzer IRAC', 'Herschel PACS', - 'AKARI FIS', 'IRAS PSC', 'MSX Galactic Plane', - ]; - return val ? catalogs.filter((s) => s.toLowerCase().startsWith(val.toLowerCase())) : catalogs; - }} - initialState={{ value: '', validator: () => ({ valid: true, message: '' }) }} - /> -); -Basic.parameters = { storyDescription: 'Suggests catalog names that start with the typed prefix.' }; - -export const AsyncSuggestions = () => ( - { - const catalogs = [ - 'AllWISE', 'CatWISE2020', '2MASS Point Source', '2MASS Extended Source', - 'Gaia DR3', 'Gaia DR2', 'SDSS DR17', 'SDSS DR16', - 'GALEX AIS', 'GALEX MIS', 'Spitzer IRAC', 'Herschel PACS', - 'AKARI FIS', 'IRAS PSC', 'MSX Galactic Plane', - ]; - const matches = val ? catalogs.filter((s) => s.toLowerCase().startsWith(val.toLowerCase())) : catalogs; - return new Promise((resolve) => setTimeout(() => resolve(matches), 300)); - }} - initialState={{ value: '', validator: () => ({ valid: true, message: '' }) }} - /> -); -AsyncSuggestions.storyName = 'Async Suggestions'; -AsyncSuggestions.parameters = { storyDescription: 'getSuggestions returns a Promise, simulating a server-side lookup.' }; - -export const ColumnPicker = () => ( - { - const columns = ['ra', 'dec', 'designation', 'w1mpro', 'w2mpro', 'w3mpro', 'w4mpro', - 'j_m', 'h_m', 'k_m', 'ph_qual', 'cc_flags', 'ext_key']; - return val ? columns.filter((s) => s.toLowerCase().startsWith(val.toLowerCase())) : columns; - }} - initialState={{ value: '', validator: (v) => v - ? { valid: true, message: '', value: v } - : { valid: false, message: 'Required', value: v } - }} - /> -); -ColumnPicker.storyName = 'Column Picker'; -ColumnPicker.parameters = { storyDescription: 'Autocomplete for table column names with a required validator.' }; diff --git a/src/firefly/js/tables/ui/AddOrUpdateColumn.jsx b/src/firefly/js/tables/ui/AddOrUpdateColumn.jsx index 39a3bedd2..5b2eea443 100644 --- a/src/firefly/js/tables/ui/AddOrUpdateColumn.jsx +++ b/src/firefly/js/tables/ui/AddOrUpdateColumn.jsx @@ -25,7 +25,7 @@ import {dispatchTableFetch, dispatchTableUiUpdate} from '../TablesCntlr.js'; import {textValidator} from '../../util/Validate.js'; import {formatColExpr} from '../../charts/ChartUtil.js'; import {useStoreConnector} from '../../ui/SimpleComponent.jsx'; -import {SuggestBoxInputField} from '../../ui/SuggestBoxInputField.jsx'; +import {AutoCompleteInput} from '../../ui/AutoCompleteInput.jsx'; import MAGNIFYING_GLASS from 'images/icons-2014/magnifyingGlass.png'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; @@ -99,7 +99,6 @@ export const AddOrUpdateColumn = React.memo(({tbl_ui_id, tbl_id, hidePopup, edit a':{width:12}, label:{width:80}, input:{width:265} }}> @@ -278,15 +277,16 @@ function CustomFields({tbl_ui_id, tbl_id, groupKey, editColName}) { initialState={{value: col.units}} tooltip='Units of measurement, IVOA VOUnits preferred' /> - } }} + endDecorator={} initialState={{value: col.UCD}} tooltip='IVOA Unified Content Descriptor, UCD1+ style' - getSuggestions={getSuggestions} valueOnSuggestion={valueOnSuggestion} + options={UCDList} + filterOptions={ucdFilterOptions} onChange={onUcdSelect} /> @@ -344,10 +344,22 @@ const Samples = () => { ); }; -function getSuggestions(val) { - if (!val) return []; - const cvals = val.toLowerCase().split(';').map((v) => v.trim()); - return UCDList.filter((ucd) => cvals.some((v) => ucd.includes(v))); +// a UCD1+ value is a ';'-delimited list, so this stays one text field rather than joy's `multiple` +// chips, which split on ',' and would drop any atom not in UCDList. Matching is unchanged from the +// pre-joy version: it tests every ';' token, not just the last one being typed. +function ucdFilterOptions(options, {inputValue}) { + // joy passes an empty inputValue on the render that opens the popup, so returning [] here would + // blank the listbox on the first keystroke + if (!inputValue) return options; + const cvals = inputValue.toLowerCase().split(';').map((v) => v.trim()); + return options.filter(({value}) => cvals.some((v) => value.includes(v))); +} + +// picking a suggestion replaces only the last ';'-segment; free-typed text is taken as-is +function onUcdSelect(ev, selected, reason, {value:cval, fireValueChange}) { + if (!selected) return fireValueChange({value: ''}); + if (typeof selected === 'string') return fireValueChange({value: selected}); + fireValueChange({value: valueOnSuggestion(cval, selected.value)}); } function valueOnSuggestion(cval='', suggestion) { @@ -358,6 +370,12 @@ function valueOnSuggestion(cval='', suggestion) { return suggestion; } +// width:12 is carried over from the panel's sx rule '.MuiInput-endDecorator > a', which no longer +// reaches this icon: on an Autocomplete joy names the slot .MuiAutocomplete-endDecorator, and +// AutoCompleteInput wraps decorators in a Stack. Setting it here covers both the Units and UCD icons. function Info({url, target='info'}) { - return }/>; + return ( + }/> + ); } \ No newline at end of file diff --git a/src/firefly/js/templates/lightcurve/LcUtil.jsx b/src/firefly/js/templates/lightcurve/LcUtil.jsx index 6a43191a0..82ca21790 100644 --- a/src/firefly/js/templates/lightcurve/LcUtil.jsx +++ b/src/firefly/js/templates/lightcurve/LcUtil.jsx @@ -7,7 +7,7 @@ import {getConverter} from './LcConverterFactory.js'; import {getCellValue, getTblById, findIndex, getColsByType, getColumnIdx, COL_TYPE} from '../../tables/TableUtil.js'; import {dispatchTableHighlight} from '../../tables/TablesCntlr.js'; import {ValidationField} from '../../ui/ValidationField.jsx'; -import {SuggestBoxInputField} from '../../ui/SuggestBoxInputField.jsx'; +import {AutoCompleteInput} from '../../ui/AutoCompleteInput.jsx'; import {getMissionName} from './LcConverterFactory.js'; import {getLayouInfo} from '../../core/LayoutCntlr.js'; import {getViewerGroupKey, onTimeColumnChange} from './LcManager.js'; @@ -87,21 +87,6 @@ export function keepHighlightedRowSynced(tbl_id, highlightedRow=0) { }); } } -/** - * @desc This method returns a suggested list of the column names based on the val entered and the defaultVal if provided. - * @param {string} val - the input in the text field - * @param {array} columnNames - the array of string - * @param {string} defaultVal - the default value, it can be null. - * @returns {*} - */ -export function getSuggestedList (val, columnNames, defaultVal) { - return columnNames.reduce((prev, name) => { - if ( name.startsWith(val) || (defaultVal && val===defaultVal)) { - prev.push(name); - } - return prev; - }, []); -} export function getInitialDefaultValues(labelWidth, missionName) { const commonDefault = { @@ -173,13 +158,11 @@ export function getInitialDefaultValues(labelWidth, missionName) { * @returns {Array} */ export function getMissionInput (numColumns){ - const topZ = 3; return ( { [LC.META_TIME_CNAME, LC.META_FLUX_CNAME].map((key) => - getSuggestedList(val,numColumns)} />) + ) } diff --git a/src/firefly/js/templates/lightcurve/generic/DefaultMissionOptions.js b/src/firefly/js/templates/lightcurve/generic/DefaultMissionOptions.js index 2ce80b10f..8c14553ca 100644 --- a/src/firefly/js/templates/lightcurve/generic/DefaultMissionOptions.js +++ b/src/firefly/js/templates/lightcurve/generic/DefaultMissionOptions.js @@ -4,16 +4,18 @@ import PropTypes from 'prop-types'; import {get, isEmpty} from 'lodash'; import {FieldGroup} from '../../../ui/FieldGroup.jsx'; import {ValidationField} from '../../../ui/ValidationField.jsx'; -import {SuggestBoxInputField} from '../../../ui/SuggestBoxInputField.jsx'; +import {AutoCompleteInput} from '../../../ui/AutoCompleteInput.jsx'; import {smartMerge} from '../../../tables/TableUtil.js'; import {makeFileRequest} from '../../../tables/TableRequestUtil.js'; -import {ReadOnlyText,getSuggestedList,getInitialDefaultValues,getMissionInput,validate,fileUpdateOnTimeColumn,setValueAndValidator} from '../LcUtil.jsx'; +import {ReadOnlyText,getInitialDefaultValues,getMissionInput,validate,fileUpdateOnTimeColumn,setValueAndValidator} from '../LcUtil.jsx'; import {LC, getViewerGroupKey} from '../LcManager.js'; import {getMissionName, coordSysOptions} from '../LcConverterFactory.js'; import {SettingBox} from '../SettingBox.jsx'; const labelWidth = 90; +const showAllOptions = (options) => options; + export class DefaultSettingBox extends SettingBox { constructor(props) { super(props); @@ -30,12 +32,10 @@ export class DefaultSettingBox extends SettingBox { const missionUrl = [LC.META_URL_CNAME]; const missionOtherKeys = [ LC.META_ERR_CNAME]; - const topZ = 3; const missionInputs=getMissionInput (numColumns, wrapperStyle); const missionData = missionUrl.map((key) => - ( getSuggestedList(val, charColumns)} />) + () ); const missionOthers = missionOtherKeys.map((key) => @@ -46,12 +46,12 @@ export class DefaultSettingBox extends SettingBox { const sKey = LC.META_COORD_SYS; const sysCol = ( - get(missionEntries, coordSysOptions, [])} /> + ); const xyCols = [LC.META_COORD_XNAME, LC.META_COORD_YNAME].map((key) => - ( getSuggestedList(val, charColumns)} />) + () ); return [sysCol, xyCols]; diff --git a/src/firefly/js/templates/lightcurve/lsst_sdss/LsstSdssMissionOptions.js b/src/firefly/js/templates/lightcurve/lsst_sdss/LsstSdssMissionOptions.js index dd882af0a..1e12528f2 100644 --- a/src/firefly/js/templates/lightcurve/lsst_sdss/LsstSdssMissionOptions.js +++ b/src/firefly/js/templates/lightcurve/lsst_sdss/LsstSdssMissionOptions.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import {get, isEmpty, set, pick, cloneDeep, defer} from 'lodash'; import {getLayouInfo} from '../../../core/LayoutCntlr.js'; import {ValidationField} from '../../../ui/ValidationField.jsx'; -import {SuggestBoxInputField} from '../../../ui/SuggestBoxInputField.jsx'; +import {AutoCompleteInput} from '../../../ui/AutoCompleteInput.jsx'; import {RadioGroupInputField} from '../../../ui/RadioGroupInputField.jsx'; import {FieldGroup} from '../../../ui/FieldGroup.jsx'; import {smartMerge} from '../../../tables/TableUtil.js'; @@ -42,15 +42,8 @@ export class LsstSdssSettingBox extends PureComponent { const validFluxVals = get(missionEntries, LC.META_FLUX_NAMES, []); const validTimeVals = get(missionEntries, LC.META_TIME_NAMES, []); - const suggestInput = (key, sugAry) => { - return ( { - const suggestions = sugAry && sugAry.filter((el) => {return el.startsWith(val);}); - return suggestions.length > 0 ? suggestions : sugAry; - }} - />); - }; + const suggestInput = (key, sugAry) => + ; var leftEntries = [ +const VIEW_OMIT_LIST= ['initialState', 'fieldKey', 'groupKey', 'validator', 'fireValueChange', + 'displayValue', 'visible', 'tooltip', 'showWarning', 'nullAllowed', 'labelWidth', + 'confirmValue', 'confirmValueOnInit', 'forceReinit']; + +// options may be bare strings; spreading 'raw' would yield {0:'r',1:'a',2:'w',label:undefined} +const toOption= (v) => typeof v === 'string' ? {value:v, label:v} : {...v, label: v.label || v.value}; + +const defIsOptionEqualToValue= (option, value) => (option?.value ?? option) === value; + +const valStrToArr= (val) => val ? val.split(',') : []; + + +export function AutoCompleteInputView({slotProps, orientation='horizontal', label, required, freeSolo=true, + startDecorator, endDecorator, multiple, + value:fieldValue='', valid=true, message='', options, validator, + fireValueChange, isOptionEqualToValue= defIsOptionEqualToValue, + disableClearable= !multiple, + onChange:onChangeProp, ...props}) { + const [open, setOpen] = useState(false); const [tooltipOpen, setTooltipOpen] = useState(false); - const {value: fieldValue, valid} = viewProps; - const fixedOptions = viewProps.options?.map((v) => ({...v, label: v.label || v.value})); + // control/label/tooltip are consumed here, anything else (listbox, option, input, ...) is Joy's + const {control:controlSlot, label:labelSlot, tooltip:tooltipSlot, ...acSlotProps}= slotProps ?? {}; + const fixedOptions = options?.map(toOption); - const inputProps= omit(props, 'initialState', 'fieldKey', 'groupKey'); - const {title, enterDelay} = inputFieldTooltipProps(viewProps); + const inputProps= omit(props, VIEW_OMIT_LIST); + const {title, enterDelay} = inputFieldTooltipProps({valid, message, tooltip:props.tooltip, showWarning:props.showWarning}); + + // like the other field views, this one owns calling the validator - the store does not run it on a value change + const fireValidatedChange = (value) => + fireValueChange(validator ? {value, ...validator(value)} : {value}); //set in the FieldGroup store + + // handles an edge case where Joy overwrites the textbox with the picked option's label and only + // repairs it when `value` changed - so an unchanged value leaves the field showing the bare label + const syncInputToValue = Boolean(onChangeProp) && !multiple; // inputValue is always a string, it's the value displayed in the textbox of autocomplete - const onInputChange = (e, inputValue) => { + const onInputChange = (e, inputValue, reason) => { if (multiple) return; - fireValueChange({value: inputValue}); //set in the FieldGroup store + // only 'input' is a user edit; every other reason is joy resetting its own textbox after a + // pick/blur, and writing those back would store the option's bare label (and re-validate it) + if (reason!=='input') return; + // like the other field views, keystrokes carry validity - a field goes red mid-word + fireValidatedChange(inputValue); }; // selectedValue is any object or list of objects (in multiple mode); // it's the value(s) selected by the user from options listbox or by pressing enter - const onChange = (e, selectedValue) => { - const fieldValue = isArray(selectedValue) //in case of multiple + const onChange = (e, selectedValue, reason, details) => { + // caller owns the selection -> stored-value mapping: supplying onChange replaces both the + // default write below and the validator for picks. Typing still writes via onInputChange. + if (onChangeProp) { + onChangeProp(e, selectedValue, reason, {...details, value:fieldValue, fireValueChange}); + return; + } + const value = isArray(selectedValue) //in case of multiple ? selectedValue.map((v) => v?.value ?? v).toString() : selectedValue?.value ?? selectedValue; - fireValueChange({value: fieldValue}); //set in the FieldGroup store + fireValidatedChange(value); }; - const valStrToArr = (val) => val ? val.split(',') : []; - // state logic becomes complicated when using freeSolo (custom option) with multiple option mode // also UX is not intuitive: user has to press enter to create a new chip for custom option const allowFreeSolo = multiple ? false : freeSolo; @@ -46,18 +82,20 @@ export function AutoCompleteInput({slotProps, orientation='horizontal', label, r !open && setTooltipOpen(true); //don't show tooltip as long as popup is open }} onClose={()=> setTooltipOpen(false)} - {...slotProps?.tooltip}> - - {label && {label}} + {...tooltipSlot}> + + {label && {label}} (option?.value ?? option) === value} + isOptionEqualToValue={isOptionEqualToValue} + disableClearable={disableClearable} value={multiple ? valStrToArr(fieldValue) : fieldValue} + inputValue={syncInputToValue ? (fieldValue ?? '') : undefined} onChange={onChange} onInputChange={onInputChange} {...inputProps} + slotProps={acSlotProps} title='' startDecorator={startDecorator && {startDecorator}} endDecorator={endDecorator && {endDecorator}} @@ -71,20 +109,18 @@ export function AutoCompleteInput({slotProps, orientation='horizontal', label, r ); - } -AutoCompleteInput.propTypes= { - fieldKey: string.isRequired, - fieldGroup: string, - initialState: shape({ - value: string, - valid: bool, - message: string, - validator: func, - nullAllowed: bool, - }), - options: arrayOf(object), // {value,label,...anything-else} + +AutoCompleteInputView.propTypes= { + value: any, + valid: bool, + message: string, + tooltip: string, + showWarning: bool, + validator: func, // (value) => ({valid, message}), run on every keystroke and on a selection + fireValueChange: func.isRequired, + options: arrayOf(oneOfType([string,object])), // string, or {value,label,...anything-else} label: node, title: string, orientation: oneOf(['horizontal', 'vertical']), @@ -93,25 +129,107 @@ AutoCompleteInput.propTypes= { endDecorator: element, startDecorator: element, multiple : bool, - slotProps: shape({ + loading: bool, + // a single-value field reads as a text input, where the clear 'x' is redundant and was never there + // pre-joy; in multiple mode it is the only one-gesture way to drop all the chips, so it stays on + disableClearable: bool, // default: !multiple + isOptionEqualToValue: func, // (option,value) => boolean + onChange: func, // (ev, selectedValue, reason, {value, fireValueChange, option}); replaces the + // default store write and the validator for selections (not for typing) + slotProps: shape({ // control/label/tooltip are used here, the rest is passed to control: object, label: object, - tooltip: object + tooltip: object, + listbox: object, + }), +}; + + +export const AutoCompleteInput= memo( ({freeSolo=true, multiple, ...props}) => { + const {viewProps, fireValueChange}= + useFieldGroupConnector({...props, freeSolo, multiple, + confirmValue: freeSolo ? confirmFreeSoloValue : confirmListedValue}); + return ; +}); + + +AutoCompleteInput.propTypes= { + fieldKey: string.isRequired, + fieldGroup: string, + initialState: shape({ + value: string, + valid: bool, + message: string, + validator: func, + nullAllowed: bool, }), + ...omit(AutoCompleteInputView.propTypes, 'value', 'valid', 'message', 'fireValueChange'), + tooltip: string, }; // may not be needed -function confirmValue(freeSolo) { - return (v,props) => { - const {options=[], defaultValue} = props; - const optionContain = (v) => Boolean(v && options.find((op) => op.value === v)); - if (freeSolo || isEmpty(options) || optionContain(v)) { - return v; - } else { - return defaultValue ?? options[0].value; +const confirmFreeSoloValue= (v) => v; + +const confirmListedValue= (v,props) => { + const {options=[], defaultValue} = props; + const optionContain= Boolean(v && options.find((op) => (op?.value ?? op) === v)); + if (isEmpty(options) || optionContain) return v; + return defaultValue ?? (options[0]?.value ?? options[0]); +}; + + +/** + * Coalesce rapid changes: returns value only after it has stayed unchanged for `wait` ms. + * @param {*} value - the value to debounce + * @param {number} wait - quiet period in ms + * @returns {*} the latest value that has been stable for `wait` ms + */ +export function useDebounced(value, wait) { + const [debounced, setDebounced]= useState(value); + useEffect(() => { + const id= setTimeout(() => setDebounced(value), wait); + return () => clearTimeout(id); + }, [value, wait]); + return debounced; +} + + +/** + * Resolve a list of options that the caller may produce either synchronously or asynchronously. + * Options from a superseded request are dropped; side effects inside getOptions are not. + * @param {function} getOptions - (value) => Array|Promise, called whenever value changes + * @param {string} value - the current input value to look options up for + * @returns {{options:Array, loading:boolean}} + */ +export function useAsyncOptions(getOptions, value) { + const [result, setResult]= useState({options:[], loading:false}); + const getOptionsRef= useRef(getOptions); + const latestRef= useRef(undefined); + + useEffect(() => { getOptionsRef.current= getOptions; }); // keep the callback fresh without re-querying + + useEffect(() => { + const arrayOrPromise= getOptionsRef.current?.(value); + latestRef.current= arrayOrPromise; + if (!arrayOrPromise || isArray(arrayOrPromise)) { + setResult({options: arrayOrPromise || [], loading:false}); + return; } - }; + setResult((r) => ({options:r.options, loading:true})); // keep showing the old list until the new one lands + Promise.resolve(arrayOrPromise) + .then((options) => { + if (arrayOrPromise!==latestRef.current) return; // a newer request was made, this one is stale + setResult({options: isArray(options) ? options : [], loading:false}); + }) + .catch((err) => { + if (arrayOrPromise!==latestRef.current) return; + logger.error(err); + setResult({options:[], loading:false}); + }); + }, [value]); + + return result; } @@ -122,4 +240,4 @@ function Decorator({children, setOpen}) { ); -} \ No newline at end of file +} diff --git a/src/firefly/js/ui/ExampleDialog.jsx b/src/firefly/js/ui/ExampleDialog.jsx index c579c8147..12ad76fd1 100644 --- a/src/firefly/js/ui/ExampleDialog.jsx +++ b/src/firefly/js/ui/ExampleDialog.jsx @@ -11,7 +11,7 @@ import {ValidationField} from './ValidationField.jsx'; import {CheckboxGroupInputField} from './CheckboxGroupInputField.jsx'; import {RadioGroupInputField} from './RadioGroupInputField.jsx'; import {ListBoxInputField} from './ListBoxInputField.jsx'; -import {SuggestBoxInputField} from './SuggestBoxInputField.jsx'; +import {AutoCompleteInput} from './AutoCompleteInput.jsx'; import {PlotlyWrapper} from '../charts/ui/PlotlyWrapper.jsx'; import CompleteButton from './CompleteButton.jsx'; import {FieldGroup, FieldGroupCtx} from './FieldGroup.jsx'; @@ -337,8 +337,9 @@ function FieldGroupTestView ({fields={}}) { - { @@ -352,10 +353,7 @@ function FieldGroupTestView ({fields={}}) { label : 'Suggestion Field:', labelWidth : 100 }} - getSuggestions = {(val)=>{ - const suggestions = validSuggestions.filter((el)=>{return el.startsWith(val);}); - return suggestions.length > 0 ? suggestions : validSuggestions; - }} + options={validSuggestions} /> {field1} diff --git a/src/firefly/js/ui/NaifidPanel.jsx b/src/firefly/js/ui/NaifidPanel.jsx index 7c7360033..e9fd9b66f 100644 --- a/src/firefly/js/ui/NaifidPanel.jsx +++ b/src/firefly/js/ui/NaifidPanel.jsx @@ -5,8 +5,9 @@ import React, {memo, useRef} from 'react'; import PropTypes from 'prop-types'; import {sortBy} from 'lodash'; +import {AutocompleteOption} from '@mui/joy'; import {resolveNaifidObj} from './NaifidPanelWorker.js'; -import {SuggestBoxInputFieldView} from './SuggestBoxInputField'; +import {AutoCompleteInputView, useAsyncOptions, useDebounced} from './AutoCompleteInput.jsx'; import {useFieldGroupConnector} from './FieldGroupConnector.jsx'; import {TargetFeedback} from './TargetFeedback'; @@ -15,26 +16,47 @@ const LABEL_DEFAULT = 'Moving Target Name:'; const DEFAULT_FORMAT = 'default'; const searchHistory = {[DEFAULT_FORMAT]: []}; // defining as global to persist it throughout the lifetime of app +// the naif lookup is done on the server, joy must not filter the result set on top of it +const noClientFilter= (options) => options; -function NaifidPanelView({showHelp, valid, message, examples, feedback, value, labelWidth, feedbackStyle, popStyle, - label= LABEL_DEFAULT, fireValueChange, updateNaifNameValue, +const SUGGEST_DEBOUNCE_MS= 200; + + +function renderNaifOption(optProps, suggestion) { + const {key, ...rest}= optProps ?? {}; + return ( + + Name: {suggestion.name}, NAIF ID: {suggestion.naifid} + + ); +} + + +function NaifidPanelView({showHelp, valid, message, examples, feedback, value, feedbackStyle, popStyle, + label= LABEL_DEFAULT, fireValueChange, naifNameRef, naifIdFormat=DEFAULT_FORMAT}){ + // the name just picked, so the value change it causes does not trigger an unnecessary lookup + const selectedNameRef= useRef(value); + const getSuggestions = (val= '') => { if (!val) return []; + if (val===selectedNameRef.current) return []; // just picked, already resolved if (naifIdFormat && !searchHistory[naifIdFormat]) searchHistory[naifIdFormat] = []; const getResSuggestionsList = (suggestionsList) => { const resSuggestionsList = Object.values(suggestionsList).map((v) => ({name: v.naifName, naifid: v.naifId})); - return sortBy(resSuggestionsList, 'naifid').reverse(); + // joy uses value/label; naifid rides along for renderOption and onChange + return sortBy(resSuggestionsList, 'naifid').reverse() + .map((s) => ({...s, value: s.name, label: s.name})); }; - //if value has been searched previously, no need to request from server + // if value has been searched previously, no need to request from server if (searchHistory[naifIdFormat].length > 0){ const cachedSuggList = Object.values(searchHistory[naifIdFormat]).find((v) => (v.searchVal === val)); if (cachedSuggList?.searchRes) return getResSuggestionsList(cachedSuggList.searchRes); } - //else request naif IDs from the server + // else request naif IDs from the server const rval = resolveNaifidObj(val, naifIdFormat); if (!rval.p) return []; return rval.p.then((response)=>{ @@ -44,46 +66,46 @@ function NaifidPanelView({showHelp, valid, message, examples, feedback, value, l return getResSuggestionsList(suggestionsList); } else { - //console.error(response); + // console.error(response); fireValueChange({valid: false, message: response.feedback}); } }); }; + const {options, loading}= useAsyncOptions(getSuggestions, useDebounced(value, SUGGEST_DEBOUNCE_MS)); - const getValueOnSuggestion = (val, selectedSugg) => { - if (!selectedSugg) return; - - updateNaifNameValue(selectedSugg.name); + // only called on an actual pick from the list; free typing goes through fireValueChange below + const onOptionSelect = (ev, selectedSugg) => { + if (!selectedSugg || typeof selectedSugg === 'string') return; // cleared, or free-solo text + naifNameRef.current = selectedSugg.name; // NaifidPanel treats text matching this as valid + selectedNameRef.current = selectedSugg.name; fireValueChange({ feedback: `Object Name: ${selectedSugg.name}, NAIF ID: ${selectedSugg.naifid}`, valid : true, + message: '', displayValue: selectedSugg.name, value: selectedSugg.name + ';' + selectedSugg.naifid, //this is the returned value from the component. }); - - return selectedSugg.name; }; - return (
- ({valid: true, message: ''})} - getSuggestions={getSuggestions} - renderSuggestion={(suggestion) => - (Name: {suggestion.name}, NAIF ID: {suggestion.naifid})} - fireValueChange={({message, valid, value}) => - fireValueChange({message, valid, displayValue: value, showHelp: value === ''})}/> + options={options} + loading={loading} + filterOptions={noClientFilter} + onChange={onOptionSelect} + renderOption={renderNaifOption} + fireValueChange={({message='', valid=true, value}) => { + selectedNameRef.current= undefined; // typed or cleared, so re-typing a picked name looks up again + fireValueChange({message, valid, displayValue: value, showHelp: value === ''}); + }}/>
); } @@ -102,7 +124,7 @@ NaifidPanelView.propTypes = { onUnmountCB : PropTypes.func, feedbackStyle: PropTypes.object, fireValueChange: PropTypes.func, - updateNaifNameValue: PropTypes.func, + naifNameRef: PropTypes.object, naifIdFormat: PropTypes.string }; @@ -111,17 +133,17 @@ export const NaifidPanel= memo( (props) => { const {fieldKey='NaifId'} = props; const {viewProps, fireValueChange} = useFieldGroupConnector({fieldKey, ...props}); - const naifNameValue = useRef(props.value ?? ''); // ref instead of state so that we can mutate it without causing re-renders - const updateNaifNameValue = (newValue) => {naifNameValue.current = newValue;}; // for mutation by child component + // ref instead of state so that we can mutate it without causing re-renders + const naifNameRef = useRef(props.value ?? ''); const handleValueChange = (payload, fireValueChange) => { const newPayload = {...payload}; - newPayload.valid = Boolean(newPayload.value) || (newPayload.displayValue === '' || newPayload.displayValue === naifNameValue.current); + newPayload.valid = Boolean(newPayload.value) || (newPayload.displayValue === '' || newPayload.displayValue === naifNameRef.current); if (!newPayload.valid) { if (!newPayload.message) { newPayload.message = 'Please use name from the list'; } - if (naifNameValue.current) { + if (naifNameRef.current) { newPayload.value = ''; newPayload.feedback = ''; } @@ -139,7 +161,7 @@ export const NaifidPanel= memo( (props) => { feedback: viewProps.feedback || '', showHelp: viewProps?.showHelp ?? true, fireValueChange: (payload) => handleValueChange(payload, fireValueChange), - updateNaifNameValue + naifNameRef }; return ; }); diff --git a/src/firefly/js/ui/NaifidPanelWorker.js b/src/firefly/js/ui/NaifidPanelWorker.js index 4b801fa73..57801e798 100644 --- a/src/firefly/js/ui/NaifidPanelWorker.js +++ b/src/firefly/js/ui/NaifidPanelWorker.js @@ -8,40 +8,10 @@ import {getCmdSrvSyncURL, toBoolean} from '../util/WebUtil'; import {fetchUrl} from '../util/fetch'; -function makeResolverPromise(objName, naifIdFormat) { - let ignoreSearchResults= null; - let aborted= false; - - const aborter= function() { - aborted= true; - if (ignoreSearchResults!==null) ignoreSearchResults(); - }; - - const workerPromise= new Promise( - function(resolve, reject) { - setTimeout( ()=> { - if (aborted) { - reject(); - } - else { - const {p, rejectFunc}= makeSearchPromise(objName, naifIdFormat); - ignoreSearchResults= rejectFunc; - resolve(p); - } - }, 200); - } - ); - - return {p:workerPromise, aborter}; -} - - - function makeSearchPromise(objName, naifIdFormat) { - let rejectFunc= null; let url= `${getCmdSrvSyncURL()}?objName=${objName}&cmd=${ServerParams.RESOLVE_NAIFID}`; if (naifIdFormat) url += `&naifIdFormat=${naifIdFormat}`; - const searchPromise= new Promise( + return new Promise( function(resolve, reject) { let fetchOptions = {}; // AbortController might not be available in older browsers @@ -57,18 +27,13 @@ function makeSearchPromise(objName, naifIdFormat) { } fetchUrl(url, fetchOptions).then( (response) => { - response.json().then((value) => { + return response.json().then((value) => { resolve(value); }); }).catch( (error) => { return reject(error); }); }); - - const abortPromise= new Promise(function(resolve,reject) { - rejectFunc= reject; - }); - return {p:Promise.race([searchPromise,abortPromise]), rejectFunc}; } @@ -89,7 +54,7 @@ function resolveObject(objName, naifIdFormat) { }; } - let {p}= makeResolverPromise(objName, naifIdFormat); + let p= makeSearchPromise(objName, naifIdFormat); p= p.then( (results) => { if (results) { diff --git a/src/firefly/js/ui/SuggestBoxInputField.css b/src/firefly/js/ui/SuggestBoxInputField.css deleted file mode 100644 index 7fd8fd343..000000000 --- a/src/firefly/js/ui/SuggestBoxInputField.css +++ /dev/null @@ -1,31 +0,0 @@ -.SuggestBoxInputField { - position: relative; -} - -.SuggestBoxPopup { - position: absolute; - overflow: auto; - top: 100%; - background: #fff; - z-index: 1; - max-height: 200px; - border: 2px solid #7eadd9; -} - -.SuggestBox { - list-style-type: none; - padding: 0; - margin: 0; -} - -.SuggestBox__Suggestion { - display: block; - padding: 2px 20px 2px 10px; - cursor: default; -} - -.SuggestBox__Suggestion--highlighted { - outline: 0; - color: white; - background: #7eadd9; -} \ No newline at end of file diff --git a/src/firefly/js/ui/SuggestBoxInputField.jsx b/src/firefly/js/ui/SuggestBoxInputField.jsx deleted file mode 100644 index 3afeca757..000000000 --- a/src/firefly/js/ui/SuggestBoxInputField.jsx +++ /dev/null @@ -1,372 +0,0 @@ -import React, {memo, PureComponent} from 'react'; -import PropTypes, {object, shape} from 'prop-types'; -import ReactDOM from 'react-dom'; -import {get, isArray, isUndefined, debounce} from 'lodash'; -import {dispatchHideDialog, dispatchShowDialog, isDialogVisible} from '../core/ComponentCntlr.js'; -import DialogRootContainer from './DialogRootContainer.jsx'; -import {DropDownMenuWrapper} from './DropDownMenu.jsx'; -import {useFieldGroupConnector} from './FieldGroupConnector.jsx'; -import {logger} from '../util/Logger.js'; - - -import {InputFieldView} from './InputFieldView.jsx'; -import './SuggestBoxInputField.css'; - - -/** - * Make sure a component (like highlighted suggestion) is visible - * @param el - * @param {Number} highlightedIdx - */ -function ensureVisible(el, highlightedIdx) { - if (el && highlightedIdx) { - const nSuggestions = el.children.length; - if (nSuggestions>1) { - const highlightedTop = el.childNodes[highlightedIdx].offsetTop; - const scrollTop = el.parentNode.scrollTop; - if (highlightedTopscrollTop+180) { - el.parentNode.scrollTop = scrollTop+180; - //console.log('highlightedTop: '+highlightedTop+' scrollTop: '+scrollTop+' '+el.parentNode.scrollTop ); - } - } - } -} - - -const Suggestion = (props) => { - const {suggestion, renderSuggestion, ...otherProps} = props; - return (
  • {renderSuggestion(suggestion)}
  • ); -}; - -Suggestion.propTypes = { - suggestion : PropTypes.any, - renderSuggestion: PropTypes.func -}; - -const SuggestBox = (props) => { - var {suggestions, highlightedIdx, renderSuggestion, onChange, onComplete, mouseTrigger} = props; - suggestions = suggestions || []; - - var mouseEnterIdx; // to keep track where the mouse is - - const handleOptionMouseEnter = (idx, event) => { - if (mouseTrigger) { - //console.log('handle mouse enter ' + idx + ' ' + event.target); - mouseEnterIdx = undefined; - onChange(idx); - } else { - mouseEnterIdx = idx; - event.stopPropagation(); - } - }; - - const handleMouseMove = (event) => { - if (!mouseTrigger) { - mouseTrigger = true; - event.stopPropagation(); - } else { - if (!isUndefined(mouseEnterIdx)) { - onChange(mouseEnterIdx); - } - } - }; - - const handleOptionClick = (idx, event) => { - onComplete(idx); - }; - - return ( - -
      {ensureVisible(c, highlightedIdx);}} - onMouseDown={handleOptionClick.bind(this, highlightedIdx)} //onClick competes with onBlur - onMouseMove={handleMouseMove.bind(this)}> - {suggestions.map((suggestion, idx) => { - const highlighted = idx === highlightedIdx; - return ( - - ); - })} -
    - ); -}; - -function computeDropdownXY(element) { - const bodyRect = document.body.parentElement.getBoundingClientRect(); - const elemRect = element.getBoundingClientRect(); - const x = elemRect.left - bodyRect.left; - const y = elemRect.bottom -10; - return {x,y}; -} - -const dropKey= 'suggestion-box-drop'; - -function showDrop(entryElement,dropDown, offComponentCB) { - - const beforeVisible= (e) =>{ - if (!e) return; - const {x,y}= computeDropdownXY(entryElement); - e.style.left= x+'px'; - e.style.top= y+'px'; - }; - const dd= ; - DialogRootContainer.defineDialog(dropKey,dd); - dispatchShowDialog(dropKey); - document.removeEventListener('mousedown', offComponentCB); - setTimeout(() => { - document.addEventListener('mousedown', offComponentCB); - },10); -} - -export class SuggestBoxInputFieldView extends PureComponent { - constructor(props) { - super(props); - this.state = { - isOpen: false, - displayValue: props.value, - validator: props.validator, - valid: get(props, 'valid', true), - message: get(props.message, ''), - inputWidth: undefined, - suggestions: [], - mouseTrigger: false - }; - - this.onValueChange = this.onValueChange.bind(this); - this.changeValue = this.changeValue.bind(this); - this.changeHighlighted = this.changeHighlighted.bind(this); - this.handleKeyPress = this.handleKeyPress.bind(this); - this.updateSuggestions = debounce(this.updateSuggestions.bind(this), 200); - this.offComponentCallback= this.offComponentCallback.bind(this); - } - - - - static getDerivedStateFromProps(props,state) { - const {valid, message, value, validator} = props; - if (valid !== state.valid || message !== state.message || value !== state.displayValue || validator !== state.validator) { - return {valid, message, displayValue: value, validator}; - } - return null; - } - - onValueChange(ev) { - var displayValue = get(ev, 'target.value'); - var {valid,message} = this.props.validator(displayValue); - const inputWidth = ev.target.offsetWidth ? ev.target.offsetWidth : this.state.inputWidth; - this.setState({displayValue, valid, message, inputWidth}); - this.updateSuggestions(displayValue); - this.props.fireValueChange({ value : displayValue, message, valid}); - } - - componentWillUnmount() { - document.removeEventListener('mousedown', this.offComponentCallback);// just in case - setTimeout(() => { - if (isDialogVisible(dropKey)) dispatchHideDialog(dropKey); - },5); - } - - offComponentCallback(ev) { - this.setState({isOpen: false, highlightedIdx: undefined}); - document.removeEventListener('mousedown', this.offComponentCallback);// just in case - } - - updateSuggestions(displayValue) { - const arrayOrPromise = this.props.getSuggestions(displayValue); - this.suggestionsPromise = arrayOrPromise; - Promise.resolve(arrayOrPromise).then((suggestions) => { - // make sure the suggestions are still relevant when promise returns - if (arrayOrPromise === this.suggestionsPromise && isArray(suggestions) && suggestions.length > 0) { - this.setState({isOpen: true, suggestions}); - } else { - if (this.state.isOpen) this.setState({isOpen: false, suggestions: []}); - } - }).catch((err) => logger.error(err)); - } - - - changeHighlighted(mouseTrigger, newHighlightedIdx) { - - if (newHighlightedIdx !== this.state.highlightedIdx || mouseTrigger !== this.state.mouseTrigger) { - //console.log('setting mouse trigger: '+mouseTrigger ); - this.setState({highlightedIdx: newHighlightedIdx, mouseTrigger}); - } - } - - - /* - Change value based on a suggest box action - @param {Array} current suggestions - @param {Number} index of the highlighted suggestion - */ - changeValue(highlightedIdx) { - const {validator, valueOnSuggestion, fireValueChange} = this.props; - const {displayValue, suggestions} = this.state; - - if (!isUndefined(highlightedIdx)) { - const currentSuggestion = suggestions[highlightedIdx]; - const value = valueOnSuggestion ? valueOnSuggestion(displayValue, currentSuggestion) : currentSuggestion; - if (value !== displayValue) { - var {valid,message} = validator? validator(value) : {valid: true, message:''}; - this.setState({ - isOpen: false, - highlightedIdx: undefined, - displayValue: value, - valid, - message - }); - fireValueChange({value, valid, message}); - } else { - this.setState({isOpen: false, highlightedIdx: undefined}); - } - } else { - this.setState({isOpen: false}); - } - } - - handleKeyPress(ev) { - const {isOpen, highlightedIdx, suggestions} = this.state; - this.divElement= ev.target; - - switch (ev.keyCode) { - case 13: // enter - case 9: // tab - isOpen && this.changeValue(highlightedIdx); - break; - case 27: // escape - this.setState({highlightedIdx : undefined, isOpen: false}); - break; - case 38: // arrow up - isOpen && this.changeHighlighted(false, isUndefined(highlightedIdx) ? suggestions.length - 1 : Math.max(0, highlightedIdx - 1)); - break; - case 40: // arrow down - isOpen && this.changeHighlighted(false, isUndefined(highlightedIdx) ? 0 : Math.min(highlightedIdx + 1, suggestions.length - 1)); - break; - default: - break; - } - }; - - - render() { - - const {displayValue, valid, message, highlightedIdx, isOpen, inputWidth, suggestions, mouseTrigger } = this.state; - const {label, tooltip, renderSuggestion, wrapperStyle, placeholder, slotProps={}, sx, - popStyle, popupIndex, readonly=false, required=false} = this.props; - - const leftOffset = 0; - const minWidth = (inputWidth?inputWidth-4:50); - const style = Object.assign({display: 'flex'}, wrapperStyle); - const pStyle = Object.assign({left: leftOffset, minWidth, zIndex: popupIndex}, popStyle); - - if (isOpen) { - if ((!isDialogVisible(dropKey) || this.lastHightlighedIdx!==highlightedIdx || this.lastSuggestions!==suggestions)) { - this.lastHightlighedIdx= highlightedIdx; - this.lastSuggestions= suggestions; - const box= ( -
    this.setState({highlightedIdx : undefined})}> - {suggestion})} - onChange={this.changeHighlighted.bind(this, true)} - onComplete={this.changeValue} - mouseTrigger={mouseTrigger} - /> -
    ); - setTimeout(() => { - showDrop(this.divElement,box,this.offComponentCallback); - },5); - } - } - else { - setTimeout(() => dispatchHideDialog(dropKey),5); - } - - return ( -
    - isOpen && this.changeValue(undefined), - endDecorator: this.props.endDecorator, - slotProps: { tooltip: {placement: 'right'}, ...slotProps }, - sx - }} /> -
    - ); - } -} - - -SuggestBoxInputFieldView.propTypes = { - value: PropTypes.string, - fieldKey : PropTypes.string, - inline : PropTypes.bool, - label: PropTypes.string, - sx: PropTypes.object, - placeholder: PropTypes.string, - tooltip: PropTypes.string, - popStyle : PropTypes.object, //style for the popup list - wrapperStyle: PropTypes.object, //style to merge into the container div - getSuggestions : PropTypes.func, //suggestionsArr = getSuggestions(displayValue) - valueOnSuggestion : PropTypes.func, //newDisplayValue = valueOnSuggestion(prevValue, suggestion), - renderSuggestion : PropTypes.func, // ReactElem = renderSuggestion(suggestion) - popupIndex: PropTypes.number, - endDecorator : PropTypes.object, - valid: PropTypes.bool, - message: PropTypes.string, - readonly: PropTypes.bool, - validator: PropTypes.func, - required: PropTypes.bool, - slotProps: shape({ - input: object, - control: object, - label: object, - tooltip: object - }), -}; - - -export const SuggestBoxInputField= memo( (props) => { - const {viewProps, fireValueChange}= useFieldGroupConnector(props); - return ; - -}); - - -SuggestBoxInputField.propTypes = { - fieldKey : PropTypes.string.isRequired, - groupKey : PropTypes.string, - inline : PropTypes.bool, - label: PropTypes.string, - placeholder: PropTypes.string, - sx: PropTypes.object, - tooltip: PropTypes.string, - popStyle : PropTypes.object, //style for the popup list - wrapperStyle: PropTypes.object, //style to merge into the container div - getSuggestions : PropTypes.func, //suggestionsArr = getSuggestions(displayValue) - valueOnSuggestion : PropTypes.func, //newDisplayValue = valueOnSuggestion(prevValue, suggestion), - renderSuggestion : PropTypes.func, // ReactElem = renderSuggestion(suggestion) - endDecorator : PropTypes.object, - popupIndex: PropTypes.number, - required: PropTypes.bool, - initialState: PropTypes.shape({ - value: PropTypes.string, - tooltip: PropTypes.string, - label: PropTypes.string, - validator: PropTypes.func - }), -}; -