Skip to content

Commit

Permalink
[PUI] Dynamic PartParameter field (#7298)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
SchrodingersGat committed May 22, 2024
1 parent 190c100 commit afa4bb5
Show file tree
Hide file tree
Showing 10 changed files with 160 additions and 62 deletions.
8 changes: 6 additions & 2 deletions src/frontend/src/components/buttons/ActionButton.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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'}
>
Expand Down
4 changes: 4 additions & 0 deletions src/frontend/src/components/forms/ApiForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,10 @@ export function ApiForm({
res[k] = processFields(field.children, dataValue);
} else {
res[k] = dataValue;

if (field.onValueChange) {
field.onValueChange(dataValue, data);
}
}
}

Expand Down
14 changes: 14 additions & 0 deletions src/frontend/src/components/forms/fields/ApiFormField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -133,6 +135,7 @@ export function ApiFormField({
...definition,
onValueChange: undefined,
adjustFilters: undefined,
adjustValue: undefined,
read_only: undefined,
children: undefined
};
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -209,6 +222,7 @@ export function ApiFormField({
return (
<Switch
{...reducedDefinition}
value={booleanValue}
ref={ref}
id={fieldId}
aria-label={`boolean-field-${field.name}`}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
58 changes: 57 additions & 1 deletion src/frontend/src/forms/PartForms.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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]);
}
3 changes: 2 additions & 1 deletion src/frontend/src/tables/InvenTreeTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
)
});
Expand Down
6 changes: 5 additions & 1 deletion src/frontend/src/tables/RowActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
73 changes: 26 additions & 47 deletions src/frontend/src/tables/general/AttachmentTable.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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';
Expand Down Expand Up @@ -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
Expand Down
12 changes: 3 additions & 9 deletions src/frontend/src/tables/part/PartParameterTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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
});
Expand Down
39 changes: 38 additions & 1 deletion src/frontend/tests/pages/pui_part.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

0 comments on commit afa4bb5

Please sign in to comment.