mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
[PUI] Dynamic PartParameter field (#7298)
* 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
This commit is contained in:
parent
190c100fcb
commit
afa4bb5b96
@ -1,6 +1,7 @@
|
|||||||
import { ActionIcon, FloatingPosition, Group, Tooltip } from '@mantine/core';
|
import { ActionIcon, FloatingPosition, Group, Tooltip } from '@mantine/core';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { identifierString } from '../../functions/conversion';
|
||||||
import { notYetImplemented } from '../../functions/notifications';
|
import { notYetImplemented } from '../../functions/notifications';
|
||||||
|
|
||||||
export type ActionButtonProps = {
|
export type ActionButtonProps = {
|
||||||
@ -26,18 +27,21 @@ export function ActionButton(props: ActionButtonProps) {
|
|||||||
return (
|
return (
|
||||||
!hidden && (
|
!hidden && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
key={`tooltip-${props.text}`}
|
key={`tooltip-${props.tooltip ?? props.text}`}
|
||||||
disabled={!props.tooltip && !props.text}
|
disabled={!props.tooltip && !props.text}
|
||||||
label={props.tooltip ?? props.text}
|
label={props.tooltip ?? props.text}
|
||||||
position={props.tooltipAlignment ?? 'left'}
|
position={props.tooltipAlignment ?? 'left'}
|
||||||
>
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
key={`action-icon-${props.text}`}
|
key={`action-icon-${props.tooltip ?? props.text}`}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
p={17}
|
p={17}
|
||||||
radius={props.radius ?? 'xs'}
|
radius={props.radius ?? 'xs'}
|
||||||
color={props.color}
|
color={props.color}
|
||||||
size={props.size}
|
size={props.size}
|
||||||
|
aria-label={`action-button-${identifierString(
|
||||||
|
props.tooltip ?? props.text ?? ''
|
||||||
|
)}`}
|
||||||
onClick={props.onClick ?? notYetImplemented}
|
onClick={props.onClick ?? notYetImplemented}
|
||||||
variant={props.variant ?? 'light'}
|
variant={props.variant ?? 'light'}
|
||||||
>
|
>
|
||||||
|
@ -277,6 +277,10 @@ export function ApiForm({
|
|||||||
res[k] = processFields(field.children, dataValue);
|
res[k] = processFields(field.children, dataValue);
|
||||||
} else {
|
} else {
|
||||||
res[k] = dataValue;
|
res[k] = dataValue;
|
||||||
|
|
||||||
|
if (field.onValueChange) {
|
||||||
|
field.onValueChange(dataValue, data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,6 +52,7 @@ export type ApiFormAdjustFilterType = {
|
|||||||
* @param postFieldContent : Content to render after the field
|
* @param postFieldContent : Content to render after the field
|
||||||
* @param onValueChange : Callback function to call when the field value changes
|
* @param onValueChange : Callback function to call when the field value changes
|
||||||
* @param adjustFilters : Callback function to adjust the filters for a related field before a query is made
|
* @param adjustFilters : Callback function to adjust the filters for a related field before a query is made
|
||||||
|
* @param adjustValue : Callback function to adjust the value of the field before it is sent to the API
|
||||||
*/
|
*/
|
||||||
export type ApiFormFieldType = {
|
export type ApiFormFieldType = {
|
||||||
label?: string;
|
label?: string;
|
||||||
@ -89,6 +90,7 @@ export type ApiFormFieldType = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
preFieldContent?: JSX.Element;
|
preFieldContent?: JSX.Element;
|
||||||
postFieldContent?: JSX.Element;
|
postFieldContent?: JSX.Element;
|
||||||
|
adjustValue?: (value: any) => any;
|
||||||
onValueChange?: (value: any, record?: any) => void;
|
onValueChange?: (value: any, record?: any) => void;
|
||||||
adjustFilters?: (value: ApiFormAdjustFilterType) => any;
|
adjustFilters?: (value: ApiFormAdjustFilterType) => any;
|
||||||
headers?: string[];
|
headers?: string[];
|
||||||
@ -133,6 +135,7 @@ export function ApiFormField({
|
|||||||
...definition,
|
...definition,
|
||||||
onValueChange: undefined,
|
onValueChange: undefined,
|
||||||
adjustFilters: undefined,
|
adjustFilters: undefined,
|
||||||
|
adjustValue: undefined,
|
||||||
read_only: undefined,
|
read_only: undefined,
|
||||||
children: undefined
|
children: undefined
|
||||||
};
|
};
|
||||||
@ -141,6 +144,11 @@ export function ApiFormField({
|
|||||||
// Callback helper when form value changes
|
// Callback helper when form value changes
|
||||||
const onChange = useCallback(
|
const onChange = useCallback(
|
||||||
(value: any) => {
|
(value: any) => {
|
||||||
|
// Allow for custom value adjustments (per field)
|
||||||
|
if (definition.adjustValue) {
|
||||||
|
value = definition.adjustValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
|
|
||||||
// Run custom callback for this field
|
// Run custom callback for this field
|
||||||
@ -173,6 +181,11 @@ export function ApiFormField({
|
|||||||
return val;
|
return val;
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
|
// Coerce the value to a (stringified) boolean value
|
||||||
|
const booleanValue: string = useMemo(() => {
|
||||||
|
return isTrue(value).toString();
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
// Construct the individual field
|
// Construct the individual field
|
||||||
function buildField() {
|
function buildField() {
|
||||||
switch (definition.field_type) {
|
switch (definition.field_type) {
|
||||||
@ -209,6 +222,7 @@ export function ApiFormField({
|
|||||||
return (
|
return (
|
||||||
<Switch
|
<Switch
|
||||||
{...reducedDefinition}
|
{...reducedDefinition}
|
||||||
|
value={booleanValue}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
id={fieldId}
|
id={fieldId}
|
||||||
aria-label={`boolean-field-${field.name}`}
|
aria-label={`boolean-field-${field.name}`}
|
||||||
|
@ -73,6 +73,11 @@ export function RelatedModelField({
|
|||||||
data: response.data
|
data: response.data
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Run custom callback for this field (if provided)
|
||||||
|
if (definition.onValueChange) {
|
||||||
|
definition.onValueChange(response.data[pk_field], response.data);
|
||||||
|
}
|
||||||
|
|
||||||
setInitialData(value);
|
setInitialData(value);
|
||||||
dataRef.current = [value];
|
dataRef.current = [value];
|
||||||
setPk(response.data[pk_field]);
|
setPk(response.data[pk_field]);
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { IconPackages } from '@tabler/icons-react';
|
import { IconPackages } from '@tabler/icons-react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
|
import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
|
||||||
|
|
||||||
@ -114,3 +114,59 @@ export function partCategoryFields({}: {}): ApiFormFieldSet {
|
|||||||
|
|
||||||
return fields;
|
return fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function usePartParameterFields(): ApiFormFieldSet {
|
||||||
|
// Valid field choices
|
||||||
|
const [choices, setChoices] = useState<any[]>([]);
|
||||||
|
|
||||||
|
// Field type for "data" input
|
||||||
|
const [fieldType, setFieldType] = useState<'string' | 'boolean' | 'choice'>(
|
||||||
|
'string'
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
return {
|
||||||
|
part: {
|
||||||
|
disabled: true
|
||||||
|
},
|
||||||
|
template: {
|
||||||
|
onValueChange: (value: any, record: any) => {
|
||||||
|
// Adjust the type of the "data" field based on the selected template
|
||||||
|
if (record?.checkbox) {
|
||||||
|
// This is a "checkbox" field
|
||||||
|
setChoices([]);
|
||||||
|
setFieldType('boolean');
|
||||||
|
} else if (record?.choices) {
|
||||||
|
let _choices: string[] = record.choices.split(',');
|
||||||
|
|
||||||
|
if (_choices.length > 0) {
|
||||||
|
setChoices(
|
||||||
|
_choices.map((choice) => {
|
||||||
|
return {
|
||||||
|
label: choice.trim(),
|
||||||
|
value: choice.trim()
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setFieldType('choice');
|
||||||
|
} else {
|
||||||
|
setChoices([]);
|
||||||
|
setFieldType('string');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setChoices([]);
|
||||||
|
setFieldType('string');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
field_type: fieldType,
|
||||||
|
choices: fieldType === 'choice' ? choices : undefined,
|
||||||
|
adjustValue: (value: any) => {
|
||||||
|
// Coerce boolean value into a string (required by backend)
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [fieldType, choices]);
|
||||||
|
}
|
||||||
|
@ -263,10 +263,11 @@ export function InvenTreeTable<T = any>({
|
|||||||
hidden: false,
|
hidden: false,
|
||||||
switchable: false,
|
switchable: false,
|
||||||
width: 50,
|
width: 50,
|
||||||
render: (record: any) => (
|
render: (record: any, index?: number | undefined) => (
|
||||||
<RowActions
|
<RowActions
|
||||||
actions={tableProps.rowActions?.(record) ?? []}
|
actions={tableProps.rowActions?.(record) ?? []}
|
||||||
disabled={tableState.selectedRecords.length > 0}
|
disabled={tableState.selectedRecords.length > 0}
|
||||||
|
index={index}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
@ -84,11 +84,13 @@ export function RowDeleteAction({
|
|||||||
export function RowActions({
|
export function RowActions({
|
||||||
title,
|
title,
|
||||||
actions,
|
actions,
|
||||||
disabled = false
|
disabled = false,
|
||||||
|
index
|
||||||
}: {
|
}: {
|
||||||
title?: string;
|
title?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
actions: RowAction[];
|
actions: RowAction[];
|
||||||
|
index?: number;
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
// Prevent default event handling
|
// Prevent default event handling
|
||||||
// Ref: https://icflorescu.github.io/mantine-datatable/examples/links-or-buttons-inside-clickable-rows-or-cells
|
// Ref: https://icflorescu.github.io/mantine-datatable/examples/links-or-buttons-inside-clickable-rows-or-cells
|
||||||
@ -146,6 +148,8 @@ export function RowActions({
|
|||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<Tooltip withinPortal={true} label={title || t`Actions`}>
|
<Tooltip withinPortal={true} label={title || t`Actions`}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
key={`row-action-menu-${index ?? ''}`}
|
||||||
|
aria-label={`row-action-menu-${index ?? ''}`}
|
||||||
onClick={openMenu}
|
onClick={openMenu}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
|
@ -1,14 +1,5 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import {
|
import { Badge, Group, Paper, Stack, Text } from '@mantine/core';
|
||||||
ActionIcon,
|
|
||||||
Badge,
|
|
||||||
Group,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
rem
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { Dropzone } from '@mantine/dropzone';
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import {
|
import {
|
||||||
@ -20,6 +11,7 @@ import {
|
|||||||
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '../../App';
|
import { api } from '../../App';
|
||||||
|
import { ActionButton } from '../../components/buttons/ActionButton';
|
||||||
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
|
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
|
||||||
import { AttachmentLink } from '../../components/items/AttachmentLink';
|
import { AttachmentLink } from '../../components/items/AttachmentLink';
|
||||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
@ -223,43 +215,30 @@ export function AttachmentTable({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const tableActions: ReactNode[] = useMemo(() => {
|
const tableActions: ReactNode[] = useMemo(() => {
|
||||||
let actions = [];
|
return [
|
||||||
|
<ActionButton
|
||||||
if (allowEdit) {
|
key="add-attachment"
|
||||||
actions.push(
|
tooltip={t`Add attachment`}
|
||||||
<Tooltip label={t`Add attachment`} key="attachment-add">
|
hidden={!allowEdit}
|
||||||
<ActionIcon
|
icon={<IconFileUpload />}
|
||||||
radius="sm"
|
onClick={() => {
|
||||||
onClick={() => {
|
setAttachmentType('attachment');
|
||||||
setAttachmentType('attachment');
|
setSelectedAttachment(undefined);
|
||||||
setSelectedAttachment(undefined);
|
uploadAttachment.open();
|
||||||
uploadAttachment.open();
|
}}
|
||||||
}}
|
/>,
|
||||||
variant="transparent"
|
<ActionButton
|
||||||
>
|
key="add-external-link"
|
||||||
<IconFileUpload />
|
tooltip={t`Add external link`}
|
||||||
</ActionIcon>
|
hidden={!allowEdit}
|
||||||
</Tooltip>
|
icon={<IconExternalLink />}
|
||||||
);
|
onClick={() => {
|
||||||
|
setAttachmentType('link');
|
||||||
actions.push(
|
setSelectedAttachment(undefined);
|
||||||
<Tooltip label={t`Add external link`} key="link-add">
|
uploadAttachment.open();
|
||||||
<ActionIcon
|
}}
|
||||||
radius="sm"
|
/>
|
||||||
onClick={() => {
|
];
|
||||||
setAttachmentType('link');
|
|
||||||
setSelectedAttachment(undefined);
|
|
||||||
uploadAttachment.open();
|
|
||||||
}}
|
|
||||||
variant="transparent"
|
|
||||||
>
|
|
||||||
<IconExternalLink />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return actions;
|
|
||||||
}, [allowEdit]);
|
}, [allowEdit]);
|
||||||
|
|
||||||
// Construct row actions for the attachment table
|
// Construct row actions for the attachment table
|
||||||
|
@ -7,6 +7,7 @@ import { YesNoButton } from '../../components/buttons/YesNoButton';
|
|||||||
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
|
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
|
||||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
import { UserRoles } from '../../enums/Roles';
|
import { UserRoles } from '../../enums/Roles';
|
||||||
|
import { usePartParameterFields } from '../../forms/PartForms';
|
||||||
import {
|
import {
|
||||||
useCreateApiFormModal,
|
useCreateApiFormModal,
|
||||||
useDeleteApiFormModal,
|
useDeleteApiFormModal,
|
||||||
@ -97,15 +98,7 @@ export function PartParameterTable({ partId }: { partId: any }) {
|
|||||||
];
|
];
|
||||||
}, [partId]);
|
}, [partId]);
|
||||||
|
|
||||||
const partParameterFields: ApiFormFieldSet = useMemo(() => {
|
const partParameterFields: ApiFormFieldSet = usePartParameterFields();
|
||||||
return {
|
|
||||||
part: {
|
|
||||||
disabled: true
|
|
||||||
},
|
|
||||||
template: {},
|
|
||||||
data: {}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const newParameter = useCreateApiFormModal({
|
const newParameter = useCreateApiFormModal({
|
||||||
url: ApiEndpoints.part_parameter_list,
|
url: ApiEndpoints.part_parameter_list,
|
||||||
@ -126,6 +119,7 @@ export function PartParameterTable({ partId }: { partId: any }) {
|
|||||||
url: ApiEndpoints.part_parameter_list,
|
url: ApiEndpoints.part_parameter_list,
|
||||||
pk: selectedParameter,
|
pk: selectedParameter,
|
||||||
title: t`Edit Part Parameter`,
|
title: t`Edit Part Parameter`,
|
||||||
|
focus: 'data',
|
||||||
fields: useMemo(() => ({ ...partParameterFields }), [partParameterFields]),
|
fields: useMemo(() => ({ ...partParameterFields }), [partParameterFields]),
|
||||||
table: table
|
table: table
|
||||||
});
|
});
|
||||||
|
@ -157,5 +157,42 @@ test('PUI - Pages - Part - Attachments', async ({ page }) => {
|
|||||||
|
|
||||||
await page.goto(`${baseUrl}/part/69/attachments`);
|
await page.goto(`${baseUrl}/part/69/attachments`);
|
||||||
|
|
||||||
await page.waitForTimeout(5000);
|
// Submit a new external link
|
||||||
|
await page.getByLabel('action-button-add-external-').click();
|
||||||
|
await page.getByLabel('text-field-link').fill('https://www.google.com');
|
||||||
|
await page.getByLabel('text-field-comment').fill('a sample comment');
|
||||||
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
await page.getByRole('cell', { name: 'a sample comment' }).first().waitFor();
|
||||||
|
|
||||||
|
// Launch dialog to upload a file
|
||||||
|
await page.getByLabel('action-button-add-attachment').click();
|
||||||
|
await page.getByLabel('text-field-comment').fill('some comment');
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PUI - Pages - Part - Parameters', async ({ page }) => {
|
||||||
|
await doQuickLogin(page);
|
||||||
|
|
||||||
|
await page.goto(`${baseUrl}/part/69/parameters`);
|
||||||
|
|
||||||
|
// Create a new template
|
||||||
|
await page.getByLabel('action-button-add-parameter').click();
|
||||||
|
|
||||||
|
// Select the "Color" parameter template (should create a "choice" field)
|
||||||
|
await page.getByLabel('related-field-template').fill('Color');
|
||||||
|
await page.getByText('Part color').click();
|
||||||
|
await page.getByLabel('choice-field-data').click();
|
||||||
|
await page.getByRole('option', { name: 'Green' }).click();
|
||||||
|
|
||||||
|
// Select the "polarized" parameter template (should create a "checkbox" field)
|
||||||
|
await page.getByLabel('related-field-template').fill('Polarized');
|
||||||
|
await page.getByText('Is this part polarized?').click();
|
||||||
|
await page
|
||||||
|
.locator('label')
|
||||||
|
.filter({ hasText: 'DataParameter Value' })
|
||||||
|
.locator('div')
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user