Add readonly mode to form fields - 2nd part (#9582)

In this PR, I implemented or confirmed that the read-only mode works for
the following fields:

- [x] FormUuidFieldInput
- [x] FormRawJsonFieldInput
- [x] FormPhoneFieldInput
- [x] FormEmailsFieldInput
- [x] FormLinksFieldInput
- [x] FormAddressFieldInput
- [x] FormFullNameFieldInput
This commit is contained in:
Baptiste Devessier
2025-01-14 11:34:26 +01:00
committed by GitHub
parent 21a6dff2c9
commit 5eeee6a7ed
15 changed files with 379 additions and 15 deletions

View File

@ -57,7 +57,9 @@ export const FormCountryCodeSelectInput = ({
onPersist={onChange} onPersist={onChange}
options={options} options={options}
defaultValue={selectedCountryCode} defaultValue={selectedCountryCode}
readonly={readonly}
VariablePicker={VariablePicker} VariablePicker={VariablePicker}
preventDisplayPadding
/> />
); );
}; };

View File

@ -57,7 +57,9 @@ export const FormCountrySelectInput = ({
onPersist={onChange} onPersist={onChange}
options={options} options={options}
defaultValue={selectedCountryName} defaultValue={selectedCountryName}
readonly={readonly}
VariablePicker={VariablePicker} VariablePicker={VariablePicker}
preventDisplayPadding
/> />
); );
}; };

View File

@ -62,6 +62,7 @@ export const FormCurrencyFieldInput = ({
clearLabel={'Currency Code'} clearLabel={'Currency Code'}
VariablePicker={VariablePicker} VariablePicker={VariablePicker}
readonly={readonly} readonly={readonly}
preventDisplayPadding
/> />
<FormNumberFieldInput <FormNumberFieldInput
label="Amount Micros" label="Amount Micros"

View File

@ -57,6 +57,7 @@ export const FormPhoneFieldInput = ({
VariablePicker={VariablePicker} VariablePicker={VariablePicker}
placeholder="Enter phone number" placeholder="Enter phone number"
hint="Without calling code" hint="Without calling code"
readonly={readonly}
/> />
</FormNestedFieldInputContainer> </FormNestedFieldInputContainer>
</FormFieldInputContainer> </FormFieldInputContainer>

View File

@ -66,19 +66,19 @@ export const FormRawJsonFieldInput = ({
<FormFieldInputRowContainer multiline> <FormFieldInputRowContainer multiline>
<FormFieldInputInputContainer <FormFieldInputInputContainer
hasRightElement={isDefined(VariablePicker)} hasRightElement={isDefined(VariablePicker) && !readonly}
multiline multiline
> >
<TextVariableEditor editor={editor} multiline readonly={readonly} /> <TextVariableEditor editor={editor} multiline readonly={readonly} />
</FormFieldInputInputContainer> </FormFieldInputInputContainer>
{VariablePicker ? ( {VariablePicker && !readonly && (
<VariablePicker <VariablePicker
inputId={inputId} inputId={inputId}
multiline multiline
onVariableSelect={handleVariableTagInsert} onVariableSelect={handleVariableTagInsert}
/> />
) : null} )}
</FormFieldInputRowContainer> </FormFieldInputRowContainer>
</FormFieldInputContainer> </FormFieldInputContainer>
); );

View File

@ -28,6 +28,7 @@ type FormSelectFieldInputProps = {
options: SelectOption[]; options: SelectOption[];
clearLabel?: string; clearLabel?: string;
readonly?: boolean; readonly?: boolean;
preventDisplayPadding?: boolean;
}; };
const StyledDisplayModeReadonlyContainer = styled.div` const StyledDisplayModeReadonlyContainer = styled.div`
@ -65,6 +66,7 @@ export const FormSelectFieldInput = ({
options, options,
clearLabel, clearLabel,
readonly, readonly,
preventDisplayPadding,
}: FormSelectFieldInputProps) => { }: FormSelectFieldInputProps) => {
const inputId = useId(); const inputId = useId();
@ -219,7 +221,7 @@ export const FormSelectFieldInput = ({
<FormFieldInputRowContainer> <FormFieldInputRowContainer>
<FormFieldInputInputContainer <FormFieldInputInputContainer
hasRightElement={isDefined(VariablePicker)} hasRightElement={isDefined(VariablePicker) && !readonly}
> >
{draftValue.type === 'static' ? ( {draftValue.type === 'static' ? (
readonly ? ( readonly ? (
@ -229,6 +231,7 @@ export const FormSelectFieldInput = ({
color={selectedOption.color ?? 'transparent'} color={selectedOption.color ?? 'transparent'}
label={selectedOption.label} label={selectedOption.label}
Icon={selectedOption.icon ?? undefined} Icon={selectedOption.icon ?? undefined}
preventPadding={preventDisplayPadding}
/> />
)} )}
<IconChevronDown <IconChevronDown
@ -248,6 +251,7 @@ export const FormSelectFieldInput = ({
color={selectedOption.color ?? 'transparent'} color={selectedOption.color ?? 'transparent'}
label={selectedOption.label} label={selectedOption.label}
Icon={selectedOption.icon ?? undefined} Icon={selectedOption.icon ?? undefined}
preventPadding={preventDisplayPadding}
/> />
)} )}
<IconChevronDown <IconChevronDown

View File

@ -28,6 +28,7 @@ export const FormUuidFieldInput = ({
defaultValue, defaultValue,
placeholder, placeholder,
onPersist, onPersist,
readonly,
VariablePicker, VariablePicker,
}: FormUuidFieldInputProps) => { }: FormUuidFieldInputProps) => {
const inputId = useId(); const inputId = useId();
@ -94,7 +95,7 @@ export const FormUuidFieldInput = ({
<FormFieldInputRowContainer> <FormFieldInputRowContainer>
<FormFieldInputInputContainer <FormFieldInputInputContainer
hasRightElement={isDefined(VariablePicker)} hasRightElement={isDefined(VariablePicker) && !readonly}
> >
{draftValue.type === 'static' ? ( {draftValue.type === 'static' ? (
<StyledInput <StyledInput
@ -103,17 +104,18 @@ export const FormUuidFieldInput = ({
value={draftValue.value} value={draftValue.value}
copyButton={false} copyButton={false}
hotkeyScope="record-create" hotkeyScope="record-create"
disabled={readonly}
onChange={handleChange} onChange={handleChange}
/> />
) : ( ) : (
<VariableChip <VariableChip
rawVariableName={draftValue.value} rawVariableName={draftValue.value}
onRemove={handleUnlinkVariable} onRemove={readonly ? undefined : handleUnlinkVariable}
/> />
)} )}
</FormFieldInputInputContainer> </FormFieldInputInputContainer>
{VariablePicker ? ( {VariablePicker && !readonly ? (
<VariablePicker <VariablePicker
inputId={inputId} inputId={inputId}
onVariableSelect={handleVariableTagInsert} onVariableSelect={handleVariableTagInsert}

View File

@ -1,5 +1,5 @@
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test'; import { expect, fn, userEvent, within } from '@storybook/test';
import { FormAddressFieldInput } from '../FormAddressFieldInput'; import { FormAddressFieldInput } from '../FormAddressFieldInput';
const meta: Meta<typeof FormAddressFieldInput> = { const meta: Meta<typeof FormAddressFieldInput> = {
@ -21,7 +21,7 @@ export const Default: Story = {
addressStreet2: 'Apt 123', addressStreet2: 'Apt 123',
addressCity: 'Springfield', addressCity: 'Springfield',
addressState: 'IL', addressState: 'IL',
addressCountry: 'US', addressCountry: 'United States',
addressPostcode: '12345', addressPostcode: '12345',
addressLat: 39.781721, addressLat: 39.781721,
addressLng: -89.650148, addressLng: -89.650148,
@ -35,3 +35,85 @@ export const Default: Story = {
await canvas.findByText('Post Code'); await canvas.findByText('Post Code');
}, },
}; };
export const WithVariables: Story = {
args: {
label: 'Address',
defaultValue: {
addressStreet1: '{{a.street1}}',
addressStreet2: '{{a.street2}}',
addressCity: '{{a.city}}',
addressState: '{{a.state}}',
addressCountry: '{{a.country}}',
addressPostcode: '{{a.postcode}}',
addressLat: 39.781721,
addressLng: -89.650148,
},
VariablePicker: () => <div>VariablePicker</div>,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const street1Variable = await canvas.findByText('street1');
const street2Variable = await canvas.findByText('street2');
const cityVariable = await canvas.findByText('city');
const stateVariable = await canvas.findByText('state');
const countryVariable = await canvas.findByText('country');
const postcodeVariable = await canvas.findByText('postcode');
expect(street1Variable).toBeVisible();
expect(street2Variable).toBeVisible();
expect(cityVariable).toBeVisible();
expect(stateVariable).toBeVisible();
expect(countryVariable).toBeVisible();
expect(postcodeVariable).toBeVisible();
const variablePickers = await canvas.findAllByText('VariablePicker');
expect(variablePickers).toHaveLength(6);
},
};
export const Disabled: Story = {
args: {
label: 'Address',
readonly: true,
defaultValue: {
addressStreet1: '123 Main St',
addressStreet2: 'Apt 123',
addressCity: 'Springfield',
addressState: 'IL',
addressCountry: 'United States',
addressPostcode: '12345',
addressLat: 39.781721,
addressLng: -89.650148,
},
onPersist: fn(),
VariablePicker: () => <div>VariablePicker</div>,
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const street1Input = await canvas.findByText('123 Main St');
const street2Input = await canvas.findByText('Apt 123');
const cityInput = await canvas.findByText('Springfield');
const stateInput = await canvas.findByText('IL');
const postcodeInput = await canvas.findByText('12345');
const countrySelect = await canvas.findByText('United States');
await userEvent.type(street1Input, 'XXX');
await userEvent.type(street2Input, 'YYY');
await userEvent.type(cityInput, 'ZZZ');
await userEvent.type(stateInput, 'ZZ');
await userEvent.type(postcodeInput, '1234');
await userEvent.click(countrySelect);
const searchInputInModal = canvas.queryByPlaceholderText('Search');
expect(searchInputInModal).not.toBeInTheDocument();
expect(args.onPersist).not.toHaveBeenCalled();
const variablePickers = canvas.queryAllByText('VariablePicker');
expect(variablePickers).toHaveLength(0);
},
};

View File

@ -1,5 +1,5 @@
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test'; import { expect, fn, userEvent, within } from '@storybook/test';
import { FormEmailsFieldInput } from '../FormEmailsFieldInput'; import { FormEmailsFieldInput } from '../FormEmailsFieldInput';
const meta: Meta<typeof FormEmailsFieldInput> = { const meta: Meta<typeof FormEmailsFieldInput> = {
@ -29,3 +29,54 @@ export const Default: Story = {
await canvas.findByText('tim@twenty.com'); await canvas.findByText('tim@twenty.com');
}, },
}; };
export const WithVariable: Story = {
args: {
label: 'Emails',
defaultValue: {
primaryEmail: '{{a.b.c}}',
additionalEmails: [],
},
VariablePicker: () => <div>VariablePicker</div>,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const primaryEmailVariable = await canvas.findByText('c');
expect(primaryEmailVariable).toBeVisible();
const variablePicker = await canvas.findByText('VariablePicker');
expect(variablePicker).toBeVisible();
},
};
export const Disabled: Story = {
args: {
label: 'Emails',
defaultValue: {
primaryEmail: 'tim@twenty.com',
additionalEmails: [],
},
onPersist: fn(),
VariablePicker: () => <div>VariablePicker</div>,
readonly: true,
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const editor = canvasElement.querySelector('.ProseMirror > p');
expect(editor).toBeVisible();
const defaultValue = await canvas.findByText('tim@twenty.com');
expect(defaultValue).toBeVisible();
await userEvent.type(editor, 'hello@gmail.com');
expect(args.onPersist).not.toHaveBeenCalled();
expect(canvas.queryByText('hello@gmail.com')).not.toBeInTheDocument();
expect(defaultValue).toBeVisible();
const variablePicker = canvas.queryByText('VariablePicker');
expect(variablePicker).not.toBeInTheDocument();
},
};

View File

@ -1,5 +1,5 @@
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test'; import { expect, fn, userEvent, within } from '@storybook/test';
import { FormFullNameFieldInput } from '../FormFullNameFieldInput'; import { FormFullNameFieldInput } from '../FormFullNameFieldInput';
const meta: Meta<typeof FormFullNameFieldInput> = { const meta: Meta<typeof FormFullNameFieldInput> = {
@ -29,3 +29,53 @@ export const Default: Story = {
await canvas.findByText('Last Name'); await canvas.findByText('Last Name');
}, },
}; };
export const WithVariable: Story = {
args: {
label: 'Name',
defaultValue: {
firstName: '{{a.firstName}}',
lastName: '{{a.lastName}}',
},
VariablePicker: () => <div>VariablePicker</div>,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const firstNameVariable = await canvas.findByText('firstName');
expect(firstNameVariable).toBeVisible();
const lastNameVariable = await canvas.findByText('lastName');
expect(lastNameVariable).toBeVisible();
const variablePickers = await canvas.findAllByText('VariablePicker');
expect(variablePickers).toHaveLength(2);
},
};
export const Disabled: Story = {
args: {
label: 'Name',
readonly: true,
defaultValue: {
firstName: 'John',
lastName: 'Doe',
},
VariablePicker: () => <div>VariablePicker</div>,
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const firstNameVariable = await canvas.findByText('John');
const lastNameVariable = await canvas.findByText('Doe');
await userEvent.type(firstNameVariable, 'Jane');
await userEvent.type(lastNameVariable, 'Smith');
expect(args.onPersist).not.toHaveBeenCalled();
const variablePickers = canvas.queryAllByText('VariablePicker');
expect(variablePickers).toHaveLength(0);
},
};

View File

@ -1,5 +1,6 @@
import { expect } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test'; import { fn, userEvent, within } from '@storybook/test';
import { FormLinksFieldInput } from '../FormLinksFieldInput'; import { FormLinksFieldInput } from '../FormLinksFieldInput';
const meta: Meta<typeof FormLinksFieldInput> = { const meta: Meta<typeof FormLinksFieldInput> = {
@ -29,3 +30,57 @@ export const Default: Story = {
await canvas.findByText('Google'); await canvas.findByText('Google');
}, },
}; };
export const WithVariables: Story = {
args: {
label: 'Domain Name',
defaultValue: {
primaryLinkLabel: '{{a.label}}',
primaryLinkUrl: '{{a.url}}',
},
VariablePicker: () => <div>VariablePicker</div>,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const primaryLinkLabelVariable = await canvas.findByText('label');
expect(primaryLinkLabelVariable).toBeVisible();
const primaryLinkUrlVariable = await canvas.findByText('url');
expect(primaryLinkUrlVariable).toBeVisible();
const variablePickers = await canvas.findAllByText('VariablePicker');
expect(variablePickers).toHaveLength(2);
for (const variablePicker of variablePickers) {
expect(variablePicker).toBeVisible();
}
},
};
export const Disabled: Story = {
args: {
label: 'Number field...',
readonly: true,
onPersist: fn(),
VariablePicker: () => <div>VariablePicker</div>,
defaultValue: {
primaryLinkLabel: 'Google',
primaryLinkUrl: 'https://www.google.com',
},
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const labelInput = await canvas.findByText('Google');
const linkInput = await canvas.findByText('https://www.google.com');
await userEvent.type(labelInput, 'Yahoo');
await userEvent.type(linkInput, 'https://www.yahoo.com');
expect(args.onPersist).not.toHaveBeenCalled();
const variablePickers = canvas.queryAllByText('VariablePicker');
expect(variablePickers).toHaveLength(0);
},
};

View File

@ -1,5 +1,5 @@
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test'; import { expect, userEvent, within } from '@storybook/test';
import { FieldPhonesValue } from '@/object-record/record-field/types/FieldMetadata'; import { FieldPhonesValue } from '@/object-record/record-field/types/FieldMetadata';
import { FormPhoneFieldInput } from '../FormPhoneFieldInput'; import { FormPhoneFieldInput } from '../FormPhoneFieldInput';
@ -32,3 +32,57 @@ export const Default: Story = {
await canvas.findByText('Phone'); await canvas.findByText('Phone');
}, },
}; };
export const WithVariables: Story = {
args: {
label: 'Enter phone...',
defaultValue: {
primaryPhoneCountryCode: '{{a.countryCode}}',
primaryPhoneNumber: '{{a.phoneNumber}}',
},
VariablePicker: () => <div>VariablePicker</div>,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const countryCodeVariable = await canvas.findByText('countryCode');
expect(countryCodeVariable).toBeVisible();
const phoneNumberVariable = await canvas.findByText('phoneNumber');
expect(phoneNumberVariable).toBeVisible();
const variablePickers = await canvas.findAllByText('VariablePicker');
expect(variablePickers).toHaveLength(2);
for (const variablePicker of variablePickers) {
expect(variablePicker).toBeVisible();
}
},
};
export const Disabled: Story = {
args: {
label: 'Enter phone...',
readonly: true,
VariablePicker: () => <div>VariablePicker</div>,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const countryInput = await canvas.findByText('No country');
expect(countryInput).toBeVisible();
await userEvent.click(countryInput);
const searchInputInModal = canvas.queryByPlaceholderText('Search');
expect(searchInputInModal).not.toBeInTheDocument();
const phoneNumberInput =
await canvas.findByPlaceholderText('Enter phone number');
expect(phoneNumberInput).toBeDisabled();
const variablePickers = canvas.queryAllByText('VariablePicker');
expect(variablePickers).toHaveLength(0);
},
};

View File

@ -32,8 +32,21 @@ export const Readonly: Story = {
placeholder: 'Enter valid json', placeholder: 'Enter valid json',
readonly: true, readonly: true,
onPersist: fn(), onPersist: fn(),
VariablePicker: ({ onVariableSelect }) => {
return (
<button
onClick={() => {
onVariableSelect('{{test}}');
}}
>
Add variable
</button>
);
},
}, },
play: async ({ canvasElement, args }) => { play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const editor = canvasElement.querySelector('.ProseMirror > p'); const editor = canvasElement.querySelector('.ProseMirror > p');
expect(editor).toBeVisible(); expect(editor).toBeVisible();
@ -46,6 +59,9 @@ export const Readonly: Story = {
}); });
expect(args.onPersist).not.toHaveBeenCalled(); expect(args.onPersist).not.toHaveBeenCalled();
const addVariableButton = canvas.queryByText('Add variable');
expect(addVariableButton).not.toBeInTheDocument();
}, },
}; };

View File

@ -212,3 +212,33 @@ export const ReplaceStaticValueWithVariable: Story = {
]); ]);
}, },
}; };
export const Disabled: Story = {
args: {
label: 'UUID field',
placeholder: 'Enter UUID',
readonly: true,
VariablePicker: ({ onVariableSelect }) => {
return (
<button
onClick={() => {
onVariableSelect('{{test}}');
}}
>
Add variable
</button>
);
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('Enter UUID');
expect(input).toBeDisabled();
const variablePicker = canvas.queryByText('Add variable');
expect(variablePicker).not.toBeInTheDocument();
},
};

View File

@ -4,8 +4,22 @@ type SelectDisplayProps = {
color: ThemeColor | 'transparent'; color: ThemeColor | 'transparent';
label: string; label: string;
Icon?: IconComponent; Icon?: IconComponent;
preventPadding?: boolean;
}; };
export const SelectDisplay = ({ color, label, Icon }: SelectDisplayProps) => { export const SelectDisplay = ({
return <Tag preventShrink color={color} text={label} Icon={Icon} />; color,
label,
Icon,
preventPadding,
}: SelectDisplayProps) => {
return (
<Tag
preventShrink
color={color}
text={label}
Icon={Icon}
preventPadding={preventPadding}
/>
);
}; };