Set error on number field and prevent form from being submitted (#11637)

<img width="943" alt="Capture d’écran 2025-04-17 à 18 14 46"
src="https://github.com/user-attachments/assets/1208075e-937f-4224-886c-be578264d448"
/>
This commit is contained in:
Thomas Trompette
2025-04-17 18:39:18 +02:00
committed by GitHub
parent 19da80d2e4
commit 18a89b5152
6 changed files with 47 additions and 8 deletions

View File

@ -23,7 +23,7 @@ export const CmdEnterActionButton = ({
return ( return (
<Button <Button
title={title} title={title}
variant="primary" variant={disabled ? 'secondary' : 'primary'}
accent="blue" accent="blue"
size="medium" size="medium"
onClick={onClick} onClick={onClick}

View File

@ -52,6 +52,8 @@ type FormFieldInputProps = {
VariablePicker?: VariablePickerComponent; VariablePicker?: VariablePickerComponent;
readonly?: boolean; readonly?: boolean;
placeholder?: string; placeholder?: string;
error?: string;
onError?: (error: string | undefined) => void;
}; };
export const FormFieldInput = ({ export const FormFieldInput = ({
@ -61,6 +63,8 @@ export const FormFieldInput = ({
VariablePicker, VariablePicker,
readonly, readonly,
placeholder, placeholder,
error,
onError,
}: FormFieldInputProps) => { }: FormFieldInputProps) => {
return isFieldNumber(field) ? ( return isFieldNumber(field) ? (
<FormNumberFieldInput <FormNumberFieldInput
@ -70,6 +74,8 @@ export const FormFieldInput = ({
VariablePicker={VariablePicker} VariablePicker={VariablePicker}
readonly={readonly} readonly={readonly}
placeholder={placeholder} placeholder={placeholder}
error={error}
onError={onError}
/> />
) : isFieldBoolean(field) ? ( ) : isFieldBoolean(field) ? (
<FormBooleanFieldInput <FormBooleanFieldInput

View File

@ -9,6 +9,7 @@ import { InputHint } from '@/ui/input/components/InputHint';
import { InputLabel } from '@/ui/input/components/InputLabel'; import { InputLabel } from '@/ui/input/components/InputLabel';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString'; import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import isEmpty from 'lodash.isempty';
import { useId, useState } from 'react'; import { useId, useState } from 'react';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { import {
@ -22,7 +23,6 @@ const StyledInput = styled(TextInput)`
type FormNumberFieldInputProps = { type FormNumberFieldInputProps = {
label?: string; label?: string;
error?: string;
defaultValue: number | string | undefined; defaultValue: number | string | undefined;
onChange: (value: number | null | string) => void; onChange: (value: number | null | string) => void;
onBlur?: () => void; onBlur?: () => void;
@ -30,20 +30,26 @@ type FormNumberFieldInputProps = {
hint?: string; hint?: string;
readonly?: boolean; readonly?: boolean;
placeholder?: string; placeholder?: string;
error?: string;
onError?: (error: string | undefined) => void;
}; };
export const FormNumberFieldInput = ({ export const FormNumberFieldInput = ({
label, label,
error,
placeholder, placeholder,
defaultValue, defaultValue,
onChange, onChange,
onError,
onBlur, onBlur,
VariablePicker, VariablePicker,
hint, hint,
readonly, readonly,
error: errorFromProps,
}: FormNumberFieldInputProps) => { }: FormNumberFieldInputProps) => {
const inputId = useId(); const inputId = useId();
const [errorMessage, setErrorMessage] = useState<string | undefined>(
undefined,
);
const [draftValue, setDraftValue] = useState< const [draftValue, setDraftValue] = useState<
| { | {
@ -68,9 +74,14 @@ export const FormNumberFieldInput = ({
const persistNumber = (newValue: string) => { const persistNumber = (newValue: string) => {
if (!canBeCastAsNumberOrNull(newValue)) { if (!canBeCastAsNumberOrNull(newValue)) {
setErrorMessage('Invalid number');
onError?.('Invalid number');
return; return;
} }
setErrorMessage(undefined);
onError?.(undefined);
const castedValue = castAsNumberOrNull(newValue); const castedValue = castAsNumberOrNull(newValue);
onChange(castedValue); onChange(castedValue);
@ -115,7 +126,11 @@ export const FormNumberFieldInput = ({
{draftValue.type === 'static' ? ( {draftValue.type === 'static' ? (
<StyledInput <StyledInput
inputId={inputId} inputId={inputId}
placeholder={placeholder ?? 'Enter a number'} placeholder={
isDefined(placeholder) && !isEmpty(placeholder)
? placeholder
: 'Enter a number'
}
value={draftValue.value} value={draftValue.value}
copyButton={false} copyButton={false}
hotkeyScope="record-create" hotkeyScope="record-create"
@ -139,7 +154,7 @@ export const FormNumberFieldInput = ({
</FormFieldInputRowContainer> </FormFieldInputRowContainer>
{hint ? <InputHint>{hint}</InputHint> : null} {hint ? <InputHint>{hint}</InputHint> : null}
<InputErrorHelper>{error}</InputErrorHelper> <InputErrorHelper>{errorMessage ?? errorFromProps}</InputErrorHelper>
</FormFieldInputContainer> </FormFieldInputContainer>
); );
}; };

View File

@ -70,3 +70,16 @@ export const Disabled: Story = {
expect(variablePicker).not.toBeInTheDocument(); expect(variablePicker).not.toBeInTheDocument();
}, },
}; };
export const WithError: Story = {
args: {
error: 'Invalid number',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const error = await canvas.findByText('Invalid number');
expect(error).toBeVisible();
},
};

View File

@ -16,8 +16,8 @@ import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { useDebouncedCallback } from 'use-debounce';
import { useIcons } from 'twenty-ui/display'; import { useIcons } from 'twenty-ui/display';
import { useDebouncedCallback } from 'use-debounce';
export type WorkflowEditActionFormFillerProps = { export type WorkflowEditActionFormFillerProps = {
action: WorkflowFormAction; action: WorkflowFormAction;
@ -39,6 +39,8 @@ export const WorkflowEditActionFormFiller = ({
const { workflowRunId } = useWorkflowStepContextOrThrow(); const { workflowRunId } = useWorkflowStepContextOrThrow();
const { closeCommandMenu } = useCommandMenu(); const { closeCommandMenu } = useCommandMenu();
const { updateWorkflowRunStep } = useUpdateWorkflowRunStep(); const { updateWorkflowRunStep } = useUpdateWorkflowRunStep();
const [error, setError] = useState<string | undefined>(undefined);
const canSubmit = !actionOptions.readonly && !isDefined(error);
if (!isDefined(workflowRunId)) { if (!isDefined(workflowRunId)) {
throw new Error('Form filler action must be used in a workflow run'); throw new Error('Form filler action must be used in a workflow run');
@ -164,6 +166,9 @@ export const WorkflowEditActionFormFiller = ({
field.placeholder ?? field.placeholder ??
getDefaultFormFieldSettings(field.type).placeholder getDefaultFormFieldSettings(field.type).placeholder
} }
onError={(error) => {
setError(error);
}}
/> />
); );
})} })}
@ -174,7 +179,7 @@ export const WorkflowEditActionFormFiller = ({
<CmdEnterActionButton <CmdEnterActionButton
title="Submit" title="Submit"
onClick={onSubmit} onClick={onSubmit}
disabled={actionOptions.readonly} disabled={!canSubmit}
/>, />,
]} ]}
/> />

View File

@ -2,6 +2,7 @@ import { WorkflowFormAction } from '@/workflow/types/Workflow';
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { expect, within } from '@storybook/test'; import { expect, within } from '@storybook/test';
import { FieldMetadataType } from 'twenty-shared/types'; import { FieldMetadataType } from 'twenty-shared/types';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator'; import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
@ -9,7 +10,6 @@ import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorato
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator'; import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks'; import { graphqlMocks } from '~/testing/graphqlMocks';
import { WorkflowEditActionFormFiller } from '../WorkflowEditActionFormFiller'; import { WorkflowEditActionFormFiller } from '../WorkflowEditActionFormFiller';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
const meta: Meta<typeof WorkflowEditActionFormFiller> = { const meta: Meta<typeof WorkflowEditActionFormFiller> = {
title: 'Modules/Workflow/Actions/Form/WorkflowEditActionFormFiller', title: 'Modules/Workflow/Actions/Form/WorkflowEditActionFormFiller',