diff --git a/docs/pages/01_ui_react.adoc b/docs/pages/01_ui_react.adoc index c5ebcbad..2ec70f43 100644 --- a/docs/pages/01_ui_react.adoc +++ b/docs/pages/01_ui_react.adoc @@ -593,6 +593,32 @@ export class DefaultApplicationCustomizer implements ApplicationCustomizer { } ---- +=== Implementing typeahead queries for text inputs + +If a text input is modeled with typeahead turned on, an API is generated which we can inplement to return results. + +*src/custom/application-customizer.tsx:* +[source,typescriptjsx] +---- +import type { BundleContext } from '@pandino/pandino-api'; +import type { ApplicationCustomizer } from './interfaces'; +import { GOD_GOD_GALAXIES_ACCESS_VIEW_PAGE_ACTIONS_HOOK_INTERFACE_KEY, ViewGalaxyViewActionsHook } from '~/pages/God/God/Galaxies/AccessViewPage/customization'; + +export class DefaultApplicationCustomizer implements ApplicationCustomizer { + async customize(context: BundleContext): Promise { + context.registerService(GOD_GOD_GALAXIES_ACCESS_VIEW_PAGE_ACTIONS_HOOK_INTERFACE_KEY, () => { + return { + getNameOptions: async (text) => { + console.log(text); + return [text + '__a', text + '__b']; + }, + }; + }); + } +} +---- + + === Implementing custom columns When the "Custom Implementation" option is checked in the Designer for a column in a table, we get access to an API where diff --git a/judo-ui-react/src/main/java/hu/blackbelt/judo/ui/generator/react/UiPageContainerHelper.java b/judo-ui-react/src/main/java/hu/blackbelt/judo/ui/generator/react/UiPageContainerHelper.java index 937c7719..47b97d4f 100644 --- a/judo-ui-react/src/main/java/hu/blackbelt/judo/ui/generator/react/UiPageContainerHelper.java +++ b/judo-ui-react/src/main/java/hu/blackbelt/judo/ui/generator/react/UiPageContainerHelper.java @@ -238,6 +238,11 @@ public static boolean containerHasTrinaryLogicCombo(PageContainer container) { return !collectElementsOfType(container, new ArrayList<>(), TrinaryLogicCombo.class).isEmpty(); } + public static boolean containerHasTypeAhead(PageContainer container) { + var elements = collectElementsOfType(container, new ArrayList<>(), TextInput.class); + return elements.stream().anyMatch(TextInput::isIsTypeAheadField); + } + public static boolean containerHasDivider(PageContainer container) { return !collectElementsOfType(container, new ArrayList<>(), Divider.class).isEmpty(); } @@ -288,6 +293,16 @@ public static List getEnumsForContainer(PageContainer container) { .collect(Collectors.toList()); } + public static List getTextInputsWithTypeAhead(PageContainer container) { + Set elements = new LinkedHashSet<>(); + collectVisualElementsMatchingCondition(container, e -> e instanceof TextInput, elements); + return elements.stream() + .filter(e -> ((TextInput) e).isIsTypeAheadField()) + .map(e -> ((Input) e)) + .sorted(Comparator.comparing(NamedElement::getFQName)) + .collect(Collectors.toList()); + } + public static List getEnumDataTypesForContainer(PageContainer container) { return getEnumsForContainer(container).stream() .map(e -> (EnumerationType) e.getAttributeType().getDataType()) diff --git a/judo-ui-react/src/main/resources/actor/src/components/widgets/TextWithTypeAhead.tsx.hbs b/judo-ui-react/src/main/resources/actor/src/components/widgets/TextWithTypeAhead.tsx.hbs new file mode 100644 index 00000000..77fa420a --- /dev/null +++ b/judo-ui-react/src/main/resources/actor/src/components/widgets/TextWithTypeAhead.tsx.hbs @@ -0,0 +1,146 @@ +{{> fragment.header.hbs }} + +import Autocomplete from '@mui/material/Autocomplete'; +import InputAdornment from '@mui/material/InputAdornment'; +import TextField from '@mui/material/TextField'; +import { debounce } from '@mui/material/utils'; +import { clsx } from 'clsx'; +import { useEffect, useMemo, useState } from 'react'; +import type { ReactNode } from 'react'; +import { debounceInputs } from '~/config'; + +interface TextWithTypeAheadProps { + name: string; + id: string; + required?: boolean; + label?: string; + ownerData: any; + error?: boolean; + helperText?: string; + readOnly?: boolean; + disabled?: boolean; + editMode?: boolean; + icon?: ReactNode; + onAutoCompleteSearch?: (searchText: string) => Promise; + onChange: (target?: string | null) => void; + onBlur?: (event: any) => void; + maxLength?: number; +} + +export const TextWithTypeAhead = ({ + name, + id, + required, + label, + ownerData, + error = false, + helperText, + readOnly = false, + disabled = false, + editMode = true, + icon, + onAutoCompleteSearch, + onChange, + onBlur, + maxLength, +}: TextWithTypeAheadProps) => { + const [options, setOptions] = useState([]); + const [loading, setLoading] = useState(false); + const [value, setValue] = useState(ownerData[name] || null); + + useEffect(() => { + setValue(ownerData[name] || null); + }, [ownerData[name]]); + + const handleSearch = async (searchText: string) => { + try { + if (onAutoCompleteSearch) { + setLoading(true); + const data = await onAutoCompleteSearch(searchText); + setOptions(data); + } + } catch (error) { + // Handle error + } finally { + setLoading(false); + } + }; + + const propagateChange = useMemo( + () => + debounce((val: string) => { + handleSearch(val); + }, debounceInputs), + [], + ); + + const onInputChange = useMemo( + () => (event: any, val: string, reason: string) => { + if (value !== val && reason !== 'reset') { + onChange(val); + propagateChange(val); + } + }, + [ownerData], + ); + + const effectiveReadOnly = readOnly || !onAutoCompleteSearch; + + return ( + { + if (!readOnly) { + setOptions([]); // always start with a clean slate + handleSearch(ownerData[name] || ''); + } + } } + isOptionEqualToValue={(option: any, value: any) => option === value} + getOptionLabel={(option) => option || ''} + options={options} + value={ownerData[name] || null} + clearOnBlur={false} + disableClearable={true} + renderInput={(params) => ( + { + event.target.select(); + } } + onBlur={onBlur} + InputProps={ { + ...params.InputProps, + readOnly: readOnly, + startAdornment: icon && ( + + {icon} + + ), + } } + inputProps={ { + ...params.inputProps, + maxLength: maxLength, + } } + /> + )} + onInputChange={onInputChange} + onChange={ (event, target) => { + setValue(target); + handleSearch(target); + } } + /> + ); +}; diff --git a/judo-ui-react/src/main/resources/actor/src/components/widgets/index.tsx.hbs b/judo-ui-react/src/main/resources/actor/src/components/widgets/index.tsx.hbs index eaf4142d..c9b6cc53 100644 --- a/judo-ui-react/src/main/resources/actor/src/components/widgets/index.tsx.hbs +++ b/judo-ui-react/src/main/resources/actor/src/components/widgets/index.tsx.hbs @@ -4,4 +4,5 @@ export * from './AggregationInput'; export * from './AssociationButton'; export * from './BinaryInput'; export * from './NumericInput'; +export * from './TextWithTypeAhead'; export * from './TrinaryLogicCombobox'; diff --git a/judo-ui-react/src/main/resources/actor/src/containers/types.ts.hbs b/judo-ui-react/src/main/resources/actor/src/containers/types.ts.hbs index 94748faa..9d16e163 100644 --- a/judo-ui-react/src/main/resources/actor/src/containers/types.ts.hbs +++ b/judo-ui-react/src/main/resources/actor/src/containers/types.ts.hbs @@ -40,6 +40,9 @@ {{# each (getEnumsForContainer container) as |ve| }} filter{{ firstToUpper ve.attributeType.name }}Options?: (data: {{ classDataName container.dataElement '' }}, options: EnumOption[]) => EnumOption[]; {{/ each }} + {{# each (getTextInputsWithTypeAhead container) as |ve| }} + get{{ firstToUpper ve.attributeType.name }}Options?: (searchText: string, data: {{ classDataName container.dataElement '' }}, editMode: boolean, validation: Map) => Promise; + {{/ each }} {{# if container.view }} getMask?: () => string; {{/ if }} diff --git a/judo-ui-react/src/main/resources/actor/src/containers/widget-fragments/textinput.hbs b/judo-ui-react/src/main/resources/actor/src/containers/widget-fragments/textinput.hbs index 1e9faf8e..1a15d361 100644 --- a/judo-ui-react/src/main/resources/actor/src/containers/widget-fragments/textinput.hbs +++ b/judo-ui-react/src/main/resources/actor/src/containers/widget-fragments/textinput.hbs @@ -11,6 +11,43 @@ actions={actions} > {{/ if }} + {{# if child.isTypeAheadField }} + } + {{/ if }} + readOnly={ {{ boolValue child.attributeType.isReadOnly }} || !isFormUpdateable() } + editMode={editMode} + {{# if child.onBlur }} + onBlur={ () => { + if (actions?.on{{ firstToUpper child.attributeType.name }}BlurAction) { + actions.on{{ firstToUpper child.attributeType.name }}BlurAction(data, storeDiff, editMode, submit); + } + } } + {{/ if }} + onChange={ (value: any) => { + const realValue = value?.length === 0 ? null : value; + storeDiff('{{ child.attributeType.name }}', realValue); + } } + {{# if child.attributeType.dataType.maxLength }} + maxLength={ {{ child.attributeType.dataType.maxLength }} } + {{/ if }} + onAutoCompleteSearch={ async (searchText: string) => { + if (actions?.get{{ firstToUpper child.attributeType.name }}Options) { + return await actions.get{{ firstToUpper child.attributeType.name }}Options(searchText, data, editMode, validation); + } + return Promise.resolve([]); + } } + /> + {{ else }} + {{/ if }} {{# if child.customImplementation }} {{/ if }} diff --git a/judo-ui-react/src/main/resources/actor/src/fragments/container/view-imports.fragment.hbs b/judo-ui-react/src/main/resources/actor/src/fragments/container/view-imports.fragment.hbs index ec85c365..bcb4ab1e 100644 --- a/judo-ui-react/src/main/resources/actor/src/fragments/container/view-imports.fragment.hbs +++ b/judo-ui-react/src/main/resources/actor/src/fragments/container/view-imports.fragment.hbs @@ -33,6 +33,9 @@ import { {{# if (containerHasTrinaryLogicCombo container) }} TrinaryLogicCombobox, {{/ if }} + {{# if (containerHasTypeAhead container) }} + TextWithTypeAhead, + {{/ if }} } from '~/components/widgets'; import { useConfirmationBeforeChange } from '~/hooks'; {{# if container.form }} diff --git a/judo-ui-react/src/main/resources/ui-react.yaml b/judo-ui-react/src/main/resources/ui-react.yaml index 798dc827..ad9ba436 100644 --- a/judo-ui-react/src/main/resources/ui-react.yaml +++ b/judo-ui-react/src/main/resources/ui-react.yaml @@ -437,6 +437,10 @@ templates: pathExpression: "'src/components/widgets/TrinaryLogicCombobox.tsx'" templateName: actor/src/components/widgets/TrinaryLogicCombobox.tsx.hbs + - name: actor/src/components/widgets/TextWithTypeAhead.tsx + pathExpression: "'src/components/widgets/TextWithTypeAhead.tsx'" + templateName: actor/src/components/widgets/TextWithTypeAhead.tsx.hbs + # Actor - src - components-api - name: actor/src/components-api/components/Action.ts