From 2a728a60a7c42b76db764de6d44ca6b5e2e92c74 Mon Sep 17 00:00:00 2001 From: Alejandro Ventura De Moya Date: Wed, 2 Sep 2026 15:10:59 -0700 Subject: [PATCH 1/9] FIREFLY-2087: refactor remaining dropdowns to use joy autocomplete --- component-library/developer-guide.md | 2 +- component-library/index.js | 4 +- .../inputs/AutoCompleteInput.stories.jsx | 139 +++++++ .../inputs/SuggestBoxInputField.stories.jsx | 80 ---- .../js/tables/ui/AddOrUpdateColumn.jsx | 43 +- .../js/templates/lightcurve/LcUtil.jsx | 6 +- .../generic/DefaultMissionOptions.js | 19 +- .../lsst_sdss/LsstSdssMissionOptions.js | 13 +- src/firefly/js/ui/AutoCompleteInput.jsx | 190 +++++++-- src/firefly/js/ui/ExampleDialog.jsx | 10 +- src/firefly/js/ui/NaifidPanel.jsx | 101 +++-- src/firefly/js/ui/SuggestBoxInputField.css | 31 -- src/firefly/js/ui/SuggestBoxInputField.jsx | 372 ------------------ 13 files changed, 413 insertions(+), 597 deletions(-) create mode 100644 component-library/stories/inputs/AutoCompleteInput.stories.jsx delete mode 100644 component-library/stories/inputs/SuggestBoxInputField.stories.jsx delete mode 100644 src/firefly/js/ui/SuggestBoxInputField.css delete mode 100644 src/firefly/js/ui/SuggestBoxInputField.jsx 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/AutoCompleteInput.stories.jsx b/component-library/stories/inputs/AutoCompleteInput.stories.jsx new file mode 100644 index 000000000..50e2bb0e9 --- /dev/null +++ b/component-library/stories/inputs/AutoCompleteInput.stories.jsx @@ -0,0 +1,139 @@ +import React, { useState } from 'react'; +import { AutoCompleteInput, AutoCompleteInputView, useAsyncOptions, FieldGroup } from '../../index.js'; + +export default { + title: 'Inputs/AutoCompleteInput', + component: AutoCompleteInput, + tags: ['autodocs'], + parameters: { + controls: { disable: true }, actions: { disable: true }, + }, + decorators: [ + (Story, ctx) => ( + + + + ), + ], +}; + +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 COLUMNS = ['ra', 'dec', 'designation', 'w1mpro', 'w2mpro', 'w3mpro', 'w4mpro', + 'j_m', 'h_m', 'k_m', 'ph_qual', 'cc_flags', 'ext_key']; + +export const Basic = () => ( + +); +Basic.parameters = { storyDescription: + 'Options as bare strings with no initialState - the shape the lightcurve column pickers use. ' + + 'Filtering is joy\'s default case-insensitive "contains".' }; + +export const ColumnPicker = () => ( + v + ? { valid: true, message: '' } + : { valid: false, message: 'Required' } + }} + /> +); +ColumnPicker.storyName = 'Column Picker'; +ColumnPicker.parameters = { storyDescription: + 'Table column names with a required validator. The view runs it on every keystroke and on a selection, ' + + 'so the field goes red mid-word - the same timing as ValidationField.' }; + + +// a server-ish lookup: the results deliberately do NOT all contain the typed substring, so joy's +// default client filter would silently drop rows - filterOptions={(x) => x} is what keeps them. +const searchCatalogs = (val) => { + if (!val) return []; + const direct = CATALOGS.filter((s) => s.toLowerCase().includes(val.toLowerCase())); + const aliased = /^(ir|infrared)/i.test(val) ? ['AllWISE', 'Spitzer IRAC', 'AKARI FIS'] : []; + const hits = [...new Set([...direct, ...aliased])]; + return new Promise((resolve) => setTimeout(() => resolve(hits), 600)); +}; + +export const AsyncSuggestions = () => { + const [value, setValue] = useState(''); + const { options, loading } = useAsyncOptions(searchCatalogs, value); + return ( + x} + fireValueChange={({ value: v }) => setValue(v ?? '')} + /> + ); +}; +AsyncSuggestions.storyName = 'Async Suggestions'; +AsyncSuggestions.parameters = { storyDescription: + 'useAsyncOptions resolves a promise and drops responses that are no longer the latest request. ' + + 'Typing "ir" returns rows that do not contain "ir" - they stay visible only because the caller ' + + 'turned joy\'s client-side filter off.' }; + + +const NAIF_OBJECTS = [ + { name: 'Neptune', naifid: 899 }, + { name: 'Neptune Barycenter', naifid: 8 }, + { name: '951 Gaspra', naifid: 20000951 }, + { name: '2 Pallas', naifid: 20000002 }, +]; + +// stand-in for NaifidPanel (which is not exported from the barrel): what is displayed is the object +// name, what is stored is "name;naifid". +export const CompositeValue = () => { + const [stored, setStored] = useState(''); + const [display, setDisplay] = useState(''); + const options = NAIF_OBJECTS.map((o) => ({ ...o, value: o.name, label: o.name })); + return ( +
+ x} + renderOption={(optProps, opt) => { + const { key, ...rest } = optProps ?? {}; + return ( +
  • + Name: {opt.name}, NAIF ID: {opt.naifid} +
  • + ); + }} + onChange={(ev, selected) => { + if (!selected || typeof selected === 'string') return; + setDisplay(selected.name); + setStored(`${selected.name};${selected.naifid}`); + }} + fireValueChange={({ value: v }) => { setDisplay(v ?? ''); setStored(''); }} + /> +
    stored value: {stored || '(none)'}
    +
    + ); +}; +CompositeValue.storyName = 'Composite Value (display ≠ stored)'; +CompositeValue.parameters = { storyDescription: + 'Custom renderOption plus a caller-owned onChange that stores something other than what is shown. ' + + 'The reset-reason onInputChange that fires right after a selection must not clobber it. ' + + 'UCD token completion is the degenerate case of the same mechanism.' }; 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..4a4844f52 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,26 @@ 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))); +/* + * UCD1+ is a ';'-delimited string, so this stays one text field rather than joy's `multiple` chips + * (which hardcode ',' and would drop any atom missing from the UCDList snapshot). + * The matching below is the pre-joy behavior, warts included: it matches against *every* ';' token + * rather than only the last, and it lowercases the input but not UCDList (so `EM.IR` misses `em.IR.J`). + */ +function ucdFilterOptions(options, {inputValue}) { + // joy hands us an empty inputValue on the render that opens the popup (useAutocomplete's + // `inputPristine`), even though the user has typed. Returning [] there would blank the listbox + // on the first keystroke; dev called this with the typed text and never hit the empty case. + 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 +374,13 @@ function valueOnSuggestion(cval='', suggestion) { return suggestion; } +// the 16px icon is wider than its box on purpose - the width keeps the decorator from stretching the +// field. It lives here rather than in the panel's sx because joy names the slot .MuiInput-endDecorator +// on an Input but .MuiAutocomplete-endDecorator on an Autocomplete (and AutoCompleteInput wraps +// decorators one level deeper), so no single container selector reaches 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..88fab0366 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'; @@ -173,13 +173,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..913d3f24a 100644 --- a/src/firefly/js/templates/lightcurve/generic/DefaultMissionOptions.js +++ b/src/firefly/js/templates/lightcurve/generic/DefaultMissionOptions.js @@ -4,16 +4,19 @@ 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; +// the coord system list is short and fixed - getSuggestions used to ignore the typed value and return all of it +const showAllOptions = (options) => options; + export class DefaultSettingBox extends SettingBox { constructor(props) { super(props); @@ -30,12 +33,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 +47,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..144e14354 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,21 @@ 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 +110,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 +130,92 @@ 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]); +}; + + +/** + * 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. + * Note: no debounce is added here - if the source needs one it should own it. + * @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 +226,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..af44505a7 100644 --- a/src/firefly/js/ui/NaifidPanel.jsx +++ b/src/firefly/js/ui/NaifidPanel.jsx @@ -2,11 +2,12 @@ * License information at https://github.com/Caltech-IPAC/firefly/blob/master/License.txt */ -import React, {memo, useRef} from 'react'; +import React, {memo, useEffect, useRef, useState} 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} from './AutoCompleteInput.jsx'; import {useFieldGroupConnector} from './FieldGroupConnector.jsx'; import {TargetFeedback} from './TargetFeedback'; @@ -15,17 +16,57 @@ 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; + +/** + * Trailing-edge debounce of a value. The naif lookup is one server round trip per distinct value, + * and nothing downstream coalesces: NaifidPanelWorker's 200ms is a per-invocation setTimeout, not a + * debounce, and the aborter it builds is dropped by resolveObject (TargetPanelWorker keeps its and + * cancels the superseded request; this one never has). So without this, every keystroke is a fetch. + * @param {*} value + * @param {number} [wait] - milliseconds of quiet before the value is passed along + * @returns {*} the value as of `wait` ms ago + */ +function useDebounced(value, wait=SUGGEST_DEBOUNCE_MS) { + const [debounced, setDebounced]= useState(value); + useEffect(() => { + const id= setTimeout(() => setDebounced(value), wait); + return () => clearTimeout(id); + }, [value, wait]); + return debounced; +} + + +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 the value came from, if the value was set by picking an option; any typing clears it. + // separate from naifNameRef, which must keep the last resolved name for the validity check. + const selectedNameRef= useRef(undefined); + const getSuggestions = (val= '') => { if (!val) return []; + if (val===selectedNameRef.current) return []; // just selected, the lookup would be redundant 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(); + // the option's value/label is the name; the naifid rides along for onChange and renderOption + 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 @@ -50,40 +91,40 @@ function NaifidPanelView({showHelp, valid, message, examples, feedback, value, l }); }; + const {options, loading}= useAsyncOptions(getSuggestions, useDebounced(value)); - 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; + 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 +143,7 @@ NaifidPanelView.propTypes = { onUnmountCB : PropTypes.func, feedbackStyle: PropTypes.object, fireValueChange: PropTypes.func, - updateNaifNameValue: PropTypes.func, + naifNameRef: PropTypes.object, naifIdFormat: PropTypes.string }; @@ -111,17 +152,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 +180,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/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 - }), -}; - From 85e3d05334ad35b0f44be79d35d4776ecae9a93e Mon Sep 17 00:00:00 2001 From: Alejandro Ventura De Moya Date: Wed, 2 Sep 2026 22:09:56 -0700 Subject: [PATCH 2/9] remove autoselect and fix column widths --- src/firefly/js/templates/lightcurve/LcUtil.jsx | 2 +- .../templates/lightcurve/generic/DefaultMissionOptions.js | 6 +++--- .../lightcurve/lsst_sdss/LsstSdssMissionOptions.js | 2 +- src/firefly/js/ui/AutoCompleteInput.jsx | 1 - 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/firefly/js/templates/lightcurve/LcUtil.jsx b/src/firefly/js/templates/lightcurve/LcUtil.jsx index 88fab0366..cb554da3d 100644 --- a/src/firefly/js/templates/lightcurve/LcUtil.jsx +++ b/src/firefly/js/templates/lightcurve/LcUtil.jsx @@ -177,7 +177,7 @@ export function getMissionInput (numColumns){ { [LC.META_TIME_CNAME, LC.META_FLUX_CNAME].map((key) => - ) + ) } diff --git a/src/firefly/js/templates/lightcurve/generic/DefaultMissionOptions.js b/src/firefly/js/templates/lightcurve/generic/DefaultMissionOptions.js index 913d3f24a..755dc642f 100644 --- a/src/firefly/js/templates/lightcurve/generic/DefaultMissionOptions.js +++ b/src/firefly/js/templates/lightcurve/generic/DefaultMissionOptions.js @@ -36,7 +36,7 @@ export class DefaultSettingBox extends SettingBox { const missionInputs=getMissionInput (numColumns, wrapperStyle); const missionData = missionUrl.map((key) => - () + () ); const missionOthers = missionOtherKeys.map((key) => @@ -49,10 +49,10 @@ export class DefaultSettingBox extends SettingBox { const sysCol = ( + filterOptions={showAllOptions} orientation='vertical' /> ); const xyCols = [LC.META_COORD_XNAME, LC.META_COORD_YNAME].map((key) => - () + () ); 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 144e14354..1e12528f2 100644 --- a/src/firefly/js/templates/lightcurve/lsst_sdss/LsstSdssMissionOptions.js +++ b/src/firefly/js/templates/lightcurve/lsst_sdss/LsstSdssMissionOptions.js @@ -43,7 +43,7 @@ export class LsstSdssSettingBox extends PureComponent { const validTimeVals = get(missionEntries, LC.META_TIME_NAMES, []); const suggestInput = (key, sugAry) => - ; + ; var leftEntries = [ {label && {label}} Date: Thu, 3 Sep 2026 11:01:00 -0700 Subject: [PATCH 3/9] copy edit comments, remove unused function --- .../js/tables/ui/AddOrUpdateColumn.jsx | 21 +++++++------------ .../js/templates/lightcurve/LcUtil.jsx | 15 ------------- 2 files changed, 8 insertions(+), 28 deletions(-) diff --git a/src/firefly/js/tables/ui/AddOrUpdateColumn.jsx b/src/firefly/js/tables/ui/AddOrUpdateColumn.jsx index 4a4844f52..5b2eea443 100644 --- a/src/firefly/js/tables/ui/AddOrUpdateColumn.jsx +++ b/src/firefly/js/tables/ui/AddOrUpdateColumn.jsx @@ -344,16 +344,12 @@ const Samples = () => { ); }; -/* - * UCD1+ is a ';'-delimited string, so this stays one text field rather than joy's `multiple` chips - * (which hardcode ',' and would drop any atom missing from the UCDList snapshot). - * The matching below is the pre-joy behavior, warts included: it matches against *every* ';' token - * rather than only the last, and it lowercases the input but not UCDList (so `EM.IR` misses `em.IR.J`). - */ +// 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 hands us an empty inputValue on the render that opens the popup (useAutocomplete's - // `inputPristine`), even though the user has typed. Returning [] there would blank the listbox - // on the first keystroke; dev called this with the typed text and never hit the empty case. + // 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))); @@ -374,10 +370,9 @@ function valueOnSuggestion(cval='', suggestion) { return suggestion; } -// the 16px icon is wider than its box on purpose - the width keeps the decorator from stretching the -// field. It lives here rather than in the panel's sx because joy names the slot .MuiInput-endDecorator -// on an Input but .MuiAutocomplete-endDecorator on an Autocomplete (and AutoCompleteInput wraps -// decorators one level deeper), so no single container selector reaches both the Units and UCD icons. +// 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 ( { - if ( name.startsWith(val) || (defaultVal && val===defaultVal)) { - prev.push(name); - } - return prev; - }, []); -} export function getInitialDefaultValues(labelWidth, missionName) { const commonDefault = { From b8b708d1c577bae1548f44bf5eec3426400a01c7 Mon Sep 17 00:00:00 2001 From: Alejandro Ventura De Moya Date: Thu, 3 Sep 2026 14:14:59 -0700 Subject: [PATCH 4/9] FIREFLY-2087: remove AutoCompleteInput story until storybook builds The component-library storybook build currently fails on an unrelated circular-import fault in ChartPanel, so this story cannot be rendered or verified. Re-add once that is fixed. The index.js exports stay, since they are the published package surface, not storybook. --- .../inputs/AutoCompleteInput.stories.jsx | 139 ------------------ 1 file changed, 139 deletions(-) delete mode 100644 component-library/stories/inputs/AutoCompleteInput.stories.jsx diff --git a/component-library/stories/inputs/AutoCompleteInput.stories.jsx b/component-library/stories/inputs/AutoCompleteInput.stories.jsx deleted file mode 100644 index 50e2bb0e9..000000000 --- a/component-library/stories/inputs/AutoCompleteInput.stories.jsx +++ /dev/null @@ -1,139 +0,0 @@ -import React, { useState } from 'react'; -import { AutoCompleteInput, AutoCompleteInputView, useAsyncOptions, FieldGroup } from '../../index.js'; - -export default { - title: 'Inputs/AutoCompleteInput', - component: AutoCompleteInput, - tags: ['autodocs'], - parameters: { - controls: { disable: true }, actions: { disable: true }, - }, - decorators: [ - (Story, ctx) => ( - - - - ), - ], -}; - -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 COLUMNS = ['ra', 'dec', 'designation', 'w1mpro', 'w2mpro', 'w3mpro', 'w4mpro', - 'j_m', 'h_m', 'k_m', 'ph_qual', 'cc_flags', 'ext_key']; - -export const Basic = () => ( - -); -Basic.parameters = { storyDescription: - 'Options as bare strings with no initialState - the shape the lightcurve column pickers use. ' + - 'Filtering is joy\'s default case-insensitive "contains".' }; - -export const ColumnPicker = () => ( - v - ? { valid: true, message: '' } - : { valid: false, message: 'Required' } - }} - /> -); -ColumnPicker.storyName = 'Column Picker'; -ColumnPicker.parameters = { storyDescription: - 'Table column names with a required validator. The view runs it on every keystroke and on a selection, ' + - 'so the field goes red mid-word - the same timing as ValidationField.' }; - - -// a server-ish lookup: the results deliberately do NOT all contain the typed substring, so joy's -// default client filter would silently drop rows - filterOptions={(x) => x} is what keeps them. -const searchCatalogs = (val) => { - if (!val) return []; - const direct = CATALOGS.filter((s) => s.toLowerCase().includes(val.toLowerCase())); - const aliased = /^(ir|infrared)/i.test(val) ? ['AllWISE', 'Spitzer IRAC', 'AKARI FIS'] : []; - const hits = [...new Set([...direct, ...aliased])]; - return new Promise((resolve) => setTimeout(() => resolve(hits), 600)); -}; - -export const AsyncSuggestions = () => { - const [value, setValue] = useState(''); - const { options, loading } = useAsyncOptions(searchCatalogs, value); - return ( - x} - fireValueChange={({ value: v }) => setValue(v ?? '')} - /> - ); -}; -AsyncSuggestions.storyName = 'Async Suggestions'; -AsyncSuggestions.parameters = { storyDescription: - 'useAsyncOptions resolves a promise and drops responses that are no longer the latest request. ' + - 'Typing "ir" returns rows that do not contain "ir" - they stay visible only because the caller ' + - 'turned joy\'s client-side filter off.' }; - - -const NAIF_OBJECTS = [ - { name: 'Neptune', naifid: 899 }, - { name: 'Neptune Barycenter', naifid: 8 }, - { name: '951 Gaspra', naifid: 20000951 }, - { name: '2 Pallas', naifid: 20000002 }, -]; - -// stand-in for NaifidPanel (which is not exported from the barrel): what is displayed is the object -// name, what is stored is "name;naifid". -export const CompositeValue = () => { - const [stored, setStored] = useState(''); - const [display, setDisplay] = useState(''); - const options = NAIF_OBJECTS.map((o) => ({ ...o, value: o.name, label: o.name })); - return ( -
    - x} - renderOption={(optProps, opt) => { - const { key, ...rest } = optProps ?? {}; - return ( -
  • - Name: {opt.name}, NAIF ID: {opt.naifid} -
  • - ); - }} - onChange={(ev, selected) => { - if (!selected || typeof selected === 'string') return; - setDisplay(selected.name); - setStored(`${selected.name};${selected.naifid}`); - }} - fireValueChange={({ value: v }) => { setDisplay(v ?? ''); setStored(''); }} - /> -
    stored value: {stored || '(none)'}
    -
    - ); -}; -CompositeValue.storyName = 'Composite Value (display ≠ stored)'; -CompositeValue.parameters = { storyDescription: - 'Custom renderOption plus a caller-owned onChange that stores something other than what is shown. ' + - 'The reset-reason onInputChange that fires right after a selection must not clobber it. ' + - 'UCD token completion is the degenerate case of the same mechanism.' }; From e37d0544f835a871ad99c9c9104166172dcb68ec Mon Sep 17 00:00:00 2001 From: Alejandro Ventura De Moya Date: Thu, 3 Sep 2026 18:10:31 -0700 Subject: [PATCH 5/9] clarify comments in NaifidPanel --- .../generic/DefaultMissionOptions.js | 1 - src/firefly/js/ui/NaifidPanel.jsx | 25 ++++++------------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/src/firefly/js/templates/lightcurve/generic/DefaultMissionOptions.js b/src/firefly/js/templates/lightcurve/generic/DefaultMissionOptions.js index 755dc642f..8c14553ca 100644 --- a/src/firefly/js/templates/lightcurve/generic/DefaultMissionOptions.js +++ b/src/firefly/js/templates/lightcurve/generic/DefaultMissionOptions.js @@ -14,7 +14,6 @@ import {SettingBox} from '../SettingBox.jsx'; const labelWidth = 90; -// the coord system list is short and fixed - getSuggestions used to ignore the typed value and return all of it const showAllOptions = (options) => options; export class DefaultSettingBox extends SettingBox { diff --git a/src/firefly/js/ui/NaifidPanel.jsx b/src/firefly/js/ui/NaifidPanel.jsx index af44505a7..c3e728a4c 100644 --- a/src/firefly/js/ui/NaifidPanel.jsx +++ b/src/firefly/js/ui/NaifidPanel.jsx @@ -21,15 +21,7 @@ const noClientFilter= (options) => options; const SUGGEST_DEBOUNCE_MS= 200; -/** - * Trailing-edge debounce of a value. The naif lookup is one server round trip per distinct value, - * and nothing downstream coalesces: NaifidPanelWorker's 200ms is a per-invocation setTimeout, not a - * debounce, and the aborter it builds is dropped by resolveObject (TargetPanelWorker keeps its and - * cancels the superseded request; this one never has). So without this, every keystroke is a fetch. - * @param {*} value - * @param {number} [wait] - milliseconds of quiet before the value is passed along - * @returns {*} the value as of `wait` ms ago - */ +// nothing downstream coalesces the naif lookup, so without this every keystroke is a server request function useDebounced(value, wait=SUGGEST_DEBOUNCE_MS) { const [debounced, setDebounced]= useState(value); useEffect(() => { @@ -53,29 +45,28 @@ function renderNaifOption(optProps, suggestion) { function NaifidPanelView({showHelp, valid, message, examples, feedback, value, feedbackStyle, popStyle, label= LABEL_DEFAULT, fireValueChange, naifNameRef, naifIdFormat=DEFAULT_FORMAT}){ - // the name the value came from, if the value was set by picking an option; any typing clears it. - // separate from naifNameRef, which must keep the last resolved name for the validity check. + // the name just picked, so the value change it causes does not trigger an unnecessary lookup const selectedNameRef= useRef(undefined); const getSuggestions = (val= '') => { if (!val) return []; - if (val===selectedNameRef.current) return []; // just selected, the lookup would be redundant + 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})); - // the option's value/label is the name; the naifid rides along for onChange and renderOption + // 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)=>{ @@ -85,7 +76,7 @@ function NaifidPanelView({showHelp, valid, message, examples, feedback, value, f return getResSuggestionsList(suggestionsList); } else { - //console.error(response); + // console.error(response); fireValueChange({valid: false, message: response.feedback}); } }); @@ -96,7 +87,7 @@ function NaifidPanelView({showHelp, valid, message, examples, feedback, value, f // 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; + 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}`, From 05e825e2d8004c331b1c3c2db04fcae41e9bb6e3 Mon Sep 17 00:00:00 2001 From: Alejandro Ventura De Moya Date: Fri, 4 Sep 2026 06:58:28 -0700 Subject: [PATCH 6/9] no wasted lookup until user types in naifidpanel --- src/firefly/js/ui/NaifidPanel.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/firefly/js/ui/NaifidPanel.jsx b/src/firefly/js/ui/NaifidPanel.jsx index c3e728a4c..41b271e7c 100644 --- a/src/firefly/js/ui/NaifidPanel.jsx +++ b/src/firefly/js/ui/NaifidPanel.jsx @@ -46,7 +46,7 @@ function NaifidPanelView({showHelp, valid, message, examples, feedback, value, f 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(undefined); + const selectedNameRef= useRef(value); const getSuggestions = (val= '') => { if (!val) return []; From 96d9d5b351d1a57a178a4259169ca71bcb4c328b Mon Sep 17 00:00:00 2001 From: Alejandro Ventura De Moya Date: Fri, 4 Sep 2026 08:38:06 -0700 Subject: [PATCH 7/9] remove stacked delay and unaccessed aborter logic --- src/firefly/js/ui/NaifidPanel.jsx | 2 +- src/firefly/js/ui/NaifidPanelWorker.js | 41 +++----------------------- 2 files changed, 5 insertions(+), 38 deletions(-) diff --git a/src/firefly/js/ui/NaifidPanel.jsx b/src/firefly/js/ui/NaifidPanel.jsx index 41b271e7c..26efc258e 100644 --- a/src/firefly/js/ui/NaifidPanel.jsx +++ b/src/firefly/js/ui/NaifidPanel.jsx @@ -21,7 +21,7 @@ const noClientFilter= (options) => options; const SUGGEST_DEBOUNCE_MS= 200; -// nothing downstream coalesces the naif lookup, so without this every keystroke is a server request +// coalesce kesystrokes function useDebounced(value, wait=SUGGEST_DEBOUNCE_MS) { const [debounced, setDebounced]= useState(value); useEffect(() => { diff --git a/src/firefly/js/ui/NaifidPanelWorker.js b/src/firefly/js/ui/NaifidPanelWorker.js index 4b801fa73..d204798bc 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 @@ -64,11 +34,6 @@ function makeSearchPromise(objName, naifIdFormat) { return reject(error); }); }); - - const abortPromise= new Promise(function(resolve,reject) { - rejectFunc= reject; - }); - return {p:Promise.race([searchPromise,abortPromise]), rejectFunc}; } @@ -89,7 +54,9 @@ function resolveObject(objName, naifIdFormat) { }; } - let {p}= makeResolverPromise(objName, naifIdFormat); + // the keystroke debounce lives in NaifidPanel (SUGGEST_DEBOUNCE_MS) and useAsyncOptions drops + // superseded results, so the lookup starts as soon as it is asked for + let p= makeSearchPromise(objName, naifIdFormat); p= p.then( (results) => { if (results) { From 73acf8660ba2c31419eeeafe0d995a1af1f624a3 Mon Sep 17 00:00:00 2001 From: Alejandro Ventura De Moya Date: Fri, 4 Sep 2026 09:07:01 -0700 Subject: [PATCH 8/9] fix typo --- src/firefly/js/ui/NaifidPanel.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/firefly/js/ui/NaifidPanel.jsx b/src/firefly/js/ui/NaifidPanel.jsx index 26efc258e..4f4c7e68e 100644 --- a/src/firefly/js/ui/NaifidPanel.jsx +++ b/src/firefly/js/ui/NaifidPanel.jsx @@ -21,7 +21,7 @@ const noClientFilter= (options) => options; const SUGGEST_DEBOUNCE_MS= 200; -// coalesce kesystrokes +// coalesce keystrokes function useDebounced(value, wait=SUGGEST_DEBOUNCE_MS) { const [debounced, setDebounced]= useState(value); useEffect(() => { From eb678074690ae31130efc97ebf741cb98bfe48ed Mon Sep 17 00:00:00 2001 From: Alejandro Ventura De Moya Date: Fri, 4 Sep 2026 11:57:05 -0700 Subject: [PATCH 9/9] move debouncer to common component --- src/firefly/js/ui/AutoCompleteInput.jsx | 17 ++++++++++++++++- src/firefly/js/ui/NaifidPanel.jsx | 16 +++------------- src/firefly/js/ui/NaifidPanelWorker.js | 4 +--- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/firefly/js/ui/AutoCompleteInput.jsx b/src/firefly/js/ui/AutoCompleteInput.jsx index 39346a4fb..f84d9060a 100644 --- a/src/firefly/js/ui/AutoCompleteInput.jsx +++ b/src/firefly/js/ui/AutoCompleteInput.jsx @@ -179,10 +179,25 @@ const confirmListedValue= (v,props) => { }; +/** + * 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. - * Note: no debounce is added here - if the source needs one it should own it. * @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}} diff --git a/src/firefly/js/ui/NaifidPanel.jsx b/src/firefly/js/ui/NaifidPanel.jsx index 4f4c7e68e..e9fd9b66f 100644 --- a/src/firefly/js/ui/NaifidPanel.jsx +++ b/src/firefly/js/ui/NaifidPanel.jsx @@ -2,12 +2,12 @@ * License information at https://github.com/Caltech-IPAC/firefly/blob/master/License.txt */ -import React, {memo, useEffect, useRef, useState} from 'react'; +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 {AutoCompleteInputView, useAsyncOptions} from './AutoCompleteInput.jsx'; +import {AutoCompleteInputView, useAsyncOptions, useDebounced} from './AutoCompleteInput.jsx'; import {useFieldGroupConnector} from './FieldGroupConnector.jsx'; import {TargetFeedback} from './TargetFeedback'; @@ -21,16 +21,6 @@ const noClientFilter= (options) => options; const SUGGEST_DEBOUNCE_MS= 200; -// coalesce keystrokes -function useDebounced(value, wait=SUGGEST_DEBOUNCE_MS) { - const [debounced, setDebounced]= useState(value); - useEffect(() => { - const id= setTimeout(() => setDebounced(value), wait); - return () => clearTimeout(id); - }, [value, wait]); - return debounced; -} - function renderNaifOption(optProps, suggestion) { const {key, ...rest}= optProps ?? {}; @@ -82,7 +72,7 @@ function NaifidPanelView({showHelp, valid, message, examples, feedback, value, f }); }; - const {options, loading}= useAsyncOptions(getSuggestions, useDebounced(value)); + const {options, loading}= useAsyncOptions(getSuggestions, useDebounced(value, SUGGEST_DEBOUNCE_MS)); // only called on an actual pick from the list; free typing goes through fireValueChange below const onOptionSelect = (ev, selectedSugg) => { diff --git a/src/firefly/js/ui/NaifidPanelWorker.js b/src/firefly/js/ui/NaifidPanelWorker.js index d204798bc..57801e798 100644 --- a/src/firefly/js/ui/NaifidPanelWorker.js +++ b/src/firefly/js/ui/NaifidPanelWorker.js @@ -27,7 +27,7 @@ function makeSearchPromise(objName, naifIdFormat) { } fetchUrl(url, fetchOptions).then( (response) => { - response.json().then((value) => { + return response.json().then((value) => { resolve(value); }); }).catch( (error) => { @@ -54,8 +54,6 @@ function resolveObject(objName, naifIdFormat) { }; } - // the keystroke debounce lives in NaifidPanel (SUGGEST_DEBOUNCE_MS) and useAsyncOptions drops - // superseded results, so the lookup starts as soon as it is asked for let p= makeSearchPromise(objName, naifIdFormat); p= p.then( (results) => {