[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:
Oliver 2024-05-22 15:24:35 +10:00 committed by GitHub
parent 190c100fcb
commit afa4bb5b96
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 160 additions and 62 deletions

View File

@ -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'}
>

View File

@ -277,6 +277,10 @@ export function ApiForm({
res[k] = processFields(field.children, dataValue);
} else {
res[k] = dataValue;
if (field.onValueChange) {
field.onValueChange(dataValue, data);
}
}
}

View File

@ -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}`}

View File

@ -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]);

View File

@ -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]);
}

View File

@ -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}
/>
)
});

View File

@ -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"

View File

@ -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

View File

@ -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
});

View File

@ -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();
});