2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-08-01 09:31:34 +00:00
Files
InvenTree/src/frontend/src/components/forms/fields/RelatedModelField.tsx
Oliver 73231ce921 [ui] Enable form auto-fill (#10061)
* Enable form auto-fill

- If a single value is available, pre-fill
- Must be enabled per-field

* Tweak playwright tests for increased reliability

* Fix deps
2025-07-23 18:32:04 +10:00

403 lines
11 KiB
TypeScript

import { t } from '@lingui/core/macro';
import {
Input,
darken,
useMantineColorScheme,
useMantineTheme
} from '@mantine/core';
import { useDebouncedValue, useId } from '@mantine/hooks';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
type FieldValues,
type UseControllerReturn,
useFormContext
} from 'react-hook-form';
import Select from 'react-select';
import type { ApiFormFieldType } from '@lib/types/Forms';
import { useApi } from '../../../contexts/ApiContext';
import { vars } from '../../../theme';
import { RenderInstance } from '../../render/Instance';
/**
* Render a 'select' field for searching the database against a particular model type
*/
export function RelatedModelField({
controller,
fieldName,
definition,
limit = 10
}: Readonly<{
controller: UseControllerReturn<FieldValues, any>;
definition: ApiFormFieldType;
fieldName: string;
limit?: number;
}>) {
const api = useApi();
const fieldId = useId();
const {
field,
fieldState: { error }
} = controller;
const form = useFormContext();
// Keep track of the primary key value for this field
const [pk, setPk] = useState<number | null>(null);
const [offset, setOffset] = useState<number>(0);
const [initialData, setInitialData] = useState<{}>({});
const [data, setData] = useState<any[]>([]);
const dataRef = useRef<any[]>([]);
const [isOpen, setIsOpen] = useState<boolean>(false);
// Auto-fill the field with data from the API
useEffect(() => {
// If there is *no value defined*, and autoFill is enabled, then fetch data from the API
if (!definition.autoFill || !definition.api_url) {
return;
}
if (field.value != undefined) {
return;
}
const params = definition?.filters ?? {};
api
.get(definition.api_url, {
params: {
...params,
limit: 1,
offset: 0
}
})
.then((response) => {
const data: any = response?.data ?? {};
if (data.count === 1 && data.results?.length === 1) {
// If there is only a single result, set the field value to that result
const pk_field = definition.pk_field ?? 'pk';
if (data.results[0][pk_field]) {
const value = {
value: data.results[0][pk_field],
data: data.results[0]
};
// Run custom callback for this field (if provided)
if (definition.onValueChange) {
definition.onValueChange(
data.results[0][pk_field],
data.results[0]
);
}
setInitialData(value);
dataRef.current = [value];
setPk(data.results[0][pk_field]);
}
}
});
}, [
definition.autoFill,
definition.api_url,
definition.filters,
definition.pk_field,
field.value
]);
// If an initial value is provided, load from the API
useEffect(() => {
// If the value is unchanged, do nothing
if (field.value === pk) return;
if (
field?.value !== null &&
field?.value !== undefined &&
field?.value !== ''
) {
const url = `${definition.api_url}${field.value}/`;
if (!url) {
setPk(null);
return;
}
const params = definition?.filters ?? {};
api
.get(url, {
params: params
})
.then((response) => {
const pk_field = definition.pk_field ?? 'pk';
if (response.data?.[pk_field]) {
const value = {
value: response.data[pk_field],
data: response.data
};
// Run custom callback for this field (if provided)
if (definition.onValueChange) {
definition.onValueChange(response.data[pk_field], response.data);
}
setInitialData(value);
dataRef.current = [value];
setPk(response.data[pk_field]);
}
});
} else {
setPk(null);
}
}, [
definition.api_url,
definition.filters,
definition.pk_field,
field.value
]);
// Search input query
const [value, setValue] = useState<string>('');
const [searchText] = useDebouncedValue(value, 250);
const [filters, setFilters] = useState<any>({});
const resetSearch = useCallback(() => {
setOffset(0);
setData([]);
dataRef.current = [];
}, []);
// reset current data on search value change
useEffect(() => {
resetSearch();
}, [searchText, filters]);
const selectQuery = useQuery({
enabled:
isOpen &&
!definition.disabled &&
!!definition.api_url &&
!definition.hidden,
queryKey: [`related-field-${fieldName}`, fieldId, offset, searchText],
queryFn: async () => {
if (!definition.api_url) {
return null;
}
let _filters = definition.filters ?? {};
if (definition.adjustFilters) {
_filters =
definition.adjustFilters({
filters: _filters,
data: form.getValues()
}) ?? _filters;
}
// If the filters have changed, clear the data
if (JSON.stringify(_filters) !== JSON.stringify(filters)) {
resetSearch();
setFilters(_filters);
}
const params = {
..._filters,
search: searchText,
offset: offset,
limit: limit
};
return api
.get(definition.api_url, {
params: params
})
.then((response) => {
// current values need to be accessed via a ref, otherwise "data" has old values here
// and this results in no overriding the data which means the current value cannot be displayed
const values: any[] = [...dataRef.current];
const alreadyPresentPks = values.map((x) => x.value);
const results = response.data?.results ?? response.data ?? [];
results.forEach((item: any) => {
const pk_field = definition.pk_field ?? 'pk';
const pk = item[pk_field];
if (pk && !alreadyPresentPks.includes(pk)) {
values.push({
value: pk,
data: item
});
}
});
setData(values);
dataRef.current = values;
return response;
});
}
});
/**
* Format an option for display in the select field
*/
const formatOption = useCallback(
(option: any) => {
const data = option.data ?? option;
if (definition.modelRenderer) {
return <definition.modelRenderer instance={data} />;
}
return (
<RenderInstance instance={data} model={definition.model ?? undefined} />
);
},
[definition.model, definition.modelRenderer]
);
// Update form values when the selected value changes
const onChange = useCallback(
(value: any) => {
const _pk = value?.value ?? null;
field.onChange(_pk);
setPk(_pk);
// Run custom callback for this field (if provided)
definition.onValueChange?.(_pk, value?.data ?? {});
},
[field.onChange, definition]
);
/* Construct a "cut-down" version of the definition,
* which does not include any attributes that the lower components do not recognize
*/
const fieldDefinition = useMemo(() => {
return {
...definition,
autoFill: undefined,
onValueChange: undefined,
adjustFilters: undefined,
exclude: undefined,
read_only: undefined
};
}, [definition]);
const currentValue = useMemo(() => {
if (!pk) {
return null;
}
const _data = [...data, initialData];
return _data.find((item) => item.value === pk);
}, [pk, data]);
// Field doesn't follow Mantine theming
// Define color theme to pass to field based on Mantine theme
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const colors = useMemo(() => {
let colors: any;
if (colorScheme === 'dark') {
colors = {
neutral0: vars.colors.dark[6],
neutral5: vars.colors.dark[4],
neutral10: vars.colors.dark[4],
neutral20: vars.colors.dark[4],
neutral30: vars.colors.dark[3],
neutral40: vars.colors.dark[2],
neutral50: vars.colors.dark[1],
neutral60: vars.colors.dark[0],
neutral70: vars.colors.dark[0],
neutral80: vars.colors.dark[0],
neutral90: vars.colors.dark[0],
primary: vars.colors.primaryColors[7],
primary25: vars.colors.primaryColors[6],
primary50: vars.colors.primaryColors[5],
primary75: vars.colors.primaryColors[4]
};
} else {
colors = {
neutral0: vars.colors.white,
neutral5: darken(vars.colors.white, 0.05),
neutral10: darken(vars.colors.white, 0.1),
neutral20: darken(vars.colors.white, 0.2),
neutral30: darken(vars.colors.white, 0.3),
neutral40: darken(vars.colors.white, 0.4),
neutral50: darken(vars.colors.white, 0.5),
neutral60: darken(vars.colors.white, 0.6),
neutral70: darken(vars.colors.white, 0.7),
neutral80: darken(vars.colors.white, 0.8),
neutral90: darken(vars.colors.white, 0.9),
primary: vars.colors.primaryColors[7],
primary25: vars.colors.primaryColors[4],
primary50: vars.colors.primaryColors[5],
primary75: vars.colors.primaryColors[6]
};
}
return colors;
}, [theme]);
return (
<Input.Wrapper
{...fieldDefinition}
error={definition.error ?? error?.message}
styles={{ description: { paddingBottom: '5px' } }}
>
<Select
id={fieldId}
aria-label={`related-field-${field.name}`}
value={currentValue}
ref={field.ref}
options={data}
filterOption={null}
onInputChange={(value: any) => {
setValue(value);
resetSearch();
}}
onChange={onChange}
onMenuScrollToBottom={() => setOffset(offset + limit)}
onMenuOpen={() => {
setIsOpen(true);
resetSearch();
selectQuery.refetch();
}}
onMenuClose={() => {
setIsOpen(false);
}}
isLoading={
selectQuery.isFetching ||
selectQuery.isLoading ||
selectQuery.isRefetching
}
isClearable={!definition.required}
isDisabled={definition.disabled}
isSearchable={true}
placeholder={definition.placeholder || `${t`Search`}...`}
loadingMessage={() => `${t`Loading`}...`}
menuPortalTarget={document.body}
noOptionsMessage={() => t`No results found`}
menuPosition='fixed'
styles={{ menuPortal: (base: any) => ({ ...base, zIndex: 9999 }) }}
formatOptionLabel={(option: any) => formatOption(option)}
theme={(theme) => {
return {
...theme,
colors: {
...theme.colors,
...colors
}
};
}}
/>
</Input.Wrapper>
);
}