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 { ReactNode } from 'react';
|
||||
|
||||
import { identifierString } from '../../functions/conversion';
|
||||
import { notYetImplemented } from '../../functions/notifications';
|
||||
|
||||
export type ActionButtonProps = {
|
||||
@ -26,18 +27,21 @@ export function ActionButton(props: ActionButtonProps) {
|
||||
return (
|
||||
!hidden && (
|
||||
<Tooltip
|
||||
key={`tooltip-${props.text}`}
|
||||
key={`tooltip-${props.tooltip ?? props.text}`}
|
||||
disabled={!props.tooltip && !props.text}
|
||||
label={props.tooltip ?? props.text}
|
||||
position={props.tooltipAlignment ?? 'left'}
|
||||
>
|
||||
<ActionIcon
|
||||
key={`action-icon-${props.text}`}
|
||||
key={`action-icon-${props.tooltip ?? props.text}`}
|
||||
disabled={props.disabled}
|
||||
p={17}
|
||||
radius={props.radius ?? 'xs'}
|
||||
color={props.color}
|
||||
size={props.size}
|
||||
aria-label={`action-button-${identifierString(
|
||||
props.tooltip ?? props.text ?? ''
|
||||
)}`}
|
||||
onClick={props.onClick ?? notYetImplemented}
|
||||
variant={props.variant ?? 'light'}
|
||||
>
|
||||
|
@ -277,6 +277,10 @@ export function ApiForm({
|
||||
res[k] = processFields(field.children, dataValue);
|
||||
} else {
|
||||
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 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 adjustValue : Callback function to adjust the value of the field before it is sent to the API
|
||||
*/
|
||||
export type ApiFormFieldType = {
|
||||
label?: string;
|
||||
@ -89,6 +90,7 @@ export type ApiFormFieldType = {
|
||||
description?: string;
|
||||
preFieldContent?: JSX.Element;
|
||||
postFieldContent?: JSX.Element;
|
||||
adjustValue?: (value: any) => any;
|
||||
onValueChange?: (value: any, record?: any) => void;
|
||||
adjustFilters?: (value: ApiFormAdjustFilterType) => any;
|
||||
headers?: string[];
|
||||
@ -133,6 +135,7 @@ export function ApiFormField({
|
||||
...definition,
|
||||
onValueChange: undefined,
|
||||
adjustFilters: undefined,
|
||||
adjustValue: undefined,
|
||||
read_only: undefined,
|
||||
children: undefined
|
||||
};
|
||||
@ -141,6 +144,11 @@ export function ApiFormField({
|
||||
// Callback helper when form value changes
|
||||
const onChange = useCallback(
|
||||
(value: any) => {
|
||||
// Allow for custom value adjustments (per field)
|
||||
if (definition.adjustValue) {
|
||||
value = definition.adjustValue(value);
|
||||
}
|
||||
|
||||
field.onChange(value);
|
||||
|
||||
// Run custom callback for this field
|
||||
@ -173,6 +181,11 @@ export function ApiFormField({
|
||||
return val;
|
||||
}, [value]);
|
||||
|
||||
// Coerce the value to a (stringified) boolean value
|
||||
const booleanValue: string = useMemo(() => {
|
||||
return isTrue(value).toString();
|
||||
}, [value]);
|
||||
|
||||
// Construct the individual field
|
||||
function buildField() {
|
||||
switch (definition.field_type) {
|
||||
@ -209,6 +222,7 @@ export function ApiFormField({
|
||||
return (
|
||||
<Switch
|
||||
{...reducedDefinition}
|
||||
value={booleanValue}
|
||||
ref={ref}
|
||||
id={fieldId}
|
||||
aria-label={`boolean-field-${field.name}`}
|
||||
|
@ -73,6 +73,11 @@ export function RelatedModelField({
|
||||
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]);
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { IconPackages } from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
|
||||
|
||||
@ -114,3 +114,59 @@ export function partCategoryFields({}: {}): ApiFormFieldSet {
|
||||
|
||||
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,
|
||||
switchable: false,
|
||||
width: 50,
|
||||
render: (record: any) => (
|
||||
render: (record: any, index?: number | undefined) => (
|
||||
<RowActions
|
||||
actions={tableProps.rowActions?.(record) ?? []}
|
||||
disabled={tableState.selectedRecords.length > 0}
|
||||
index={index}
|
||||
/>
|
||||
)
|
||||
});
|
||||
|
@ -84,11 +84,13 @@ export function RowDeleteAction({
|
||||
export function RowActions({
|
||||
title,
|
||||
actions,
|
||||
disabled = false
|
||||
disabled = false,
|
||||
index
|
||||
}: {
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
actions: RowAction[];
|
||||
index?: number;
|
||||
}): ReactNode {
|
||||
// Prevent default event handling
|
||||
// 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>
|
||||
<Tooltip withinPortal={true} label={title || t`Actions`}>
|
||||
<ActionIcon
|
||||
key={`row-action-menu-${index ?? ''}`}
|
||||
aria-label={`row-action-menu-${index ?? ''}`}
|
||||
onClick={openMenu}
|
||||
disabled={disabled}
|
||||
variant="subtle"
|
||||
|
@ -1,14 +1,5 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
rem
|
||||
} from '@mantine/core';
|
||||
import { Badge, Group, Paper, Stack, Text } from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
@ -20,6 +11,7 @@ import {
|
||||
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '../../App';
|
||||
import { ActionButton } from '../../components/buttons/ActionButton';
|
||||
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
|
||||
import { AttachmentLink } from '../../components/items/AttachmentLink';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
@ -223,43 +215,30 @@ export function AttachmentTable({
|
||||
});
|
||||
|
||||
const tableActions: ReactNode[] = useMemo(() => {
|
||||
let actions = [];
|
||||
|
||||
if (allowEdit) {
|
||||
actions.push(
|
||||
<Tooltip label={t`Add attachment`} key="attachment-add">
|
||||
<ActionIcon
|
||||
radius="sm"
|
||||
onClick={() => {
|
||||
setAttachmentType('attachment');
|
||||
setSelectedAttachment(undefined);
|
||||
uploadAttachment.open();
|
||||
}}
|
||||
variant="transparent"
|
||||
>
|
||||
<IconFileUpload />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
actions.push(
|
||||
<Tooltip label={t`Add external link`} key="link-add">
|
||||
<ActionIcon
|
||||
radius="sm"
|
||||
onClick={() => {
|
||||
setAttachmentType('link');
|
||||
setSelectedAttachment(undefined);
|
||||
uploadAttachment.open();
|
||||
}}
|
||||
variant="transparent"
|
||||
>
|
||||
<IconExternalLink />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return actions;
|
||||
return [
|
||||
<ActionButton
|
||||
key="add-attachment"
|
||||
tooltip={t`Add attachment`}
|
||||
hidden={!allowEdit}
|
||||
icon={<IconFileUpload />}
|
||||
onClick={() => {
|
||||
setAttachmentType('attachment');
|
||||
setSelectedAttachment(undefined);
|
||||
uploadAttachment.open();
|
||||
}}
|
||||
/>,
|
||||
<ActionButton
|
||||
key="add-external-link"
|
||||
tooltip={t`Add external link`}
|
||||
hidden={!allowEdit}
|
||||
icon={<IconExternalLink />}
|
||||
onClick={() => {
|
||||
setAttachmentType('link');
|
||||
setSelectedAttachment(undefined);
|
||||
uploadAttachment.open();
|
||||
}}
|
||||
/>
|
||||
];
|
||||
}, [allowEdit]);
|
||||
|
||||
// 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 { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import { usePartParameterFields } from '../../forms/PartForms';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal,
|
||||
@ -97,15 +98,7 @@ export function PartParameterTable({ partId }: { partId: any }) {
|
||||
];
|
||||
}, [partId]);
|
||||
|
||||
const partParameterFields: ApiFormFieldSet = useMemo(() => {
|
||||
return {
|
||||
part: {
|
||||
disabled: true
|
||||
},
|
||||
template: {},
|
||||
data: {}
|
||||
};
|
||||
}, []);
|
||||
const partParameterFields: ApiFormFieldSet = usePartParameterFields();
|
||||
|
||||
const newParameter = useCreateApiFormModal({
|
||||
url: ApiEndpoints.part_parameter_list,
|
||||
@ -126,6 +119,7 @@ export function PartParameterTable({ partId }: { partId: any }) {
|
||||
url: ApiEndpoints.part_parameter_list,
|
||||
pk: selectedParameter,
|
||||
title: t`Edit Part Parameter`,
|
||||
focus: 'data',
|
||||
fields: useMemo(() => ({ ...partParameterFields }), [partParameterFields]),
|
||||
table: table
|
||||
});
|
||||
|
@ -157,5 +157,42 @@ test('PUI - Pages - Part - Attachments', async ({ page }) => {
|
||||
|
||||
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