[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 { 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'}
> >

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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