mirror of
https://github.com/inventree/InvenTree.git
synced 2026-02-11 01:19:15 -06:00
* Add 'adjustValue' callback for form field * Cast checkbox values to boolean * Call "onChange" callbacks * Implement dynamic "data" field for PartParameter dialog - Type of field changes based on selected template * Add playwright unit tests * Add labels to table row actions * linting fixes * Adjust playwright tests
335 lines
9.2 KiB
TypeScript
335 lines
9.2 KiB
TypeScript
import { t } from '@lingui/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 {
|
|
FieldValues,
|
|
UseControllerReturn,
|
|
useFormContext
|
|
} from 'react-hook-form';
|
|
import Select from 'react-select';
|
|
|
|
import { api } from '../../../App';
|
|
import { vars } from '../../../theme';
|
|
import { RenderInstance } from '../../render/Instance';
|
|
import { ApiFormFieldType } from './ApiFormField';
|
|
|
|
/**
|
|
* Render a 'select' field for searching the database against a particular model type
|
|
*/
|
|
export function RelatedModelField({
|
|
controller,
|
|
fieldName,
|
|
definition,
|
|
limit = 10
|
|
}: {
|
|
controller: UseControllerReturn<FieldValues, any>;
|
|
definition: ApiFormFieldType;
|
|
fieldName: string;
|
|
limit?: number;
|
|
}) {
|
|
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);
|
|
|
|
// 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}/`;
|
|
|
|
api.get(url).then((response) => {
|
|
let pk_field = definition.pk_field ?? 'pk';
|
|
if (response.data && 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.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);
|
|
}
|
|
|
|
let 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) => {
|
|
let pk_field = definition.pk_field ?? 'pk';
|
|
let pk = item[pk_field];
|
|
|
|
if (pk && !alreadyPresentPks.includes(pk)) {
|
|
values.push({
|
|
value: pk,
|
|
data: item
|
|
});
|
|
}
|
|
});
|
|
|
|
setData(values);
|
|
dataRef.current = values;
|
|
return response;
|
|
})
|
|
.catch((error) => {
|
|
setData([]);
|
|
return error;
|
|
});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 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) => {
|
|
let _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,
|
|
onValueChange: undefined,
|
|
adjustFilters: undefined,
|
|
read_only: undefined
|
|
};
|
|
}, [definition]);
|
|
|
|
const currentValue = useMemo(() => {
|
|
if (!pk) {
|
|
return null;
|
|
}
|
|
|
|
let _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 colorschema = vars.colors.primaryColors;
|
|
const { colorScheme } = useMantineColorScheme();
|
|
|
|
const colors = useMemo(() => {
|
|
let colors: any;
|
|
if (colorScheme === 'dark') {
|
|
colors = {
|
|
neutral0: colorschema[6],
|
|
neutral5: colorschema[4],
|
|
neutral10: colorschema[4],
|
|
neutral20: colorschema[4],
|
|
neutral30: colorschema[3],
|
|
neutral40: colorschema[2],
|
|
neutral50: colorschema[1],
|
|
neutral60: colorschema[0],
|
|
neutral70: colorschema[0],
|
|
neutral80: colorschema[0],
|
|
neutral90: colorschema[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={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>
|
|
);
|
|
}
|