Add variable path (#10720)

<img width="537" alt="Capture d’écran 2025-03-07 à 09 44 21"
src="https://github.com/user-attachments/assets/52c4d292-01af-4389-aa66-551be2358dd7"
/>

- search through step output schema the variable
- build the variable path
- returns the variable label 
- display both
This commit is contained in:
Thomas Trompette
2025-03-10 13:44:58 +01:00
committed by GitHub
parent c067044e01
commit c981ae329e
29 changed files with 620 additions and 195 deletions

View File

@ -1,4 +1,6 @@
import { extractVariableLabel } from '@/workflow/workflow-variables/utils/extractVariableLabel'; import { useStepsOutputSchema } from '@/workflow/hooks/useStepsOutputSchema';
import { extractRawVariableNamePart } from '@/workflow/workflow-variables/utils/extractRawVariableNamePart';
import { searchVariableThroughOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughOutputSchema';
import { css, useTheme } from '@emotion/react'; import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { isDefined } from 'twenty-shared'; import { isDefined } from 'twenty-shared';
@ -60,17 +62,48 @@ const StyledDelete = styled.button`
type VariableChipProps = { type VariableChipProps = {
rawVariableName: string; rawVariableName: string;
onRemove?: () => void; onRemove?: () => void;
isFullRecord?: boolean;
}; };
export const VariableChip = ({ export const VariableChip = ({
rawVariableName, rawVariableName,
onRemove, onRemove,
isFullRecord = false,
}: VariableChipProps) => { }: VariableChipProps) => {
const theme = useTheme(); const theme = useTheme();
const { getStepsOutputSchema } = useStepsOutputSchema({});
const stepId = extractRawVariableNamePart({
rawVariableName,
part: 'stepId',
});
if (!isDefined(stepId)) {
return null;
}
const stepOutputSchema = getStepsOutputSchema([stepId])?.[0];
if (!isDefined(stepOutputSchema)) {
return null;
}
const { variableLabel, variablePathLabel } =
searchVariableThroughOutputSchema({
stepOutputSchema,
rawVariableName,
isFullRecord,
});
const label = isDefined(variableLabel)
? variableLabel
: extractRawVariableNamePart({
rawVariableName,
part: 'selectedField',
});
return ( return (
<StyledChip deletable={isDefined(onRemove)}> <StyledChip deletable={isDefined(onRemove)}>
<StyledLabel>{extractVariableLabel(rawVariableName)}</StyledLabel> <StyledLabel title={variablePathLabel}>{label}</StyledLabel>
{onRemove ? ( {onRemove ? (
<StyledDelete onClick={onRemove} aria-label="Remove variable"> <StyledDelete onClick={onRemove} aria-label="Remove variable">

View File

@ -10,15 +10,21 @@ const StyledContainer = styled.div`
type VariableChipStandaloneProps = { type VariableChipStandaloneProps = {
rawVariableName: string; rawVariableName: string;
onRemove?: () => void; onRemove?: () => void;
isFullRecord?: boolean;
}; };
export const VariableChipStandalone = ({ export const VariableChipStandalone = ({
rawVariableName, rawVariableName,
onRemove, onRemove,
isFullRecord,
}: VariableChipStandaloneProps) => { }: VariableChipStandaloneProps) => {
return ( return (
<StyledContainer> <StyledContainer>
<VariableChip rawVariableName={rawVariableName} onRemove={onRemove} /> <VariableChip
rawVariableName={rawVariableName}
onRemove={onRemove}
isFullRecord={isFullRecord}
/>
</StyledContainer> </StyledContainer>
); );
}; };

View File

@ -1,5 +1,7 @@
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test'; import { expect, fn, userEvent, within } from '@storybook/test';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { MOCKED_STEP_ID } from '~/testing/mock-data/workflow';
import { FormAddressFieldInput } from '../FormAddressFieldInput'; import { FormAddressFieldInput } from '../FormAddressFieldInput';
const meta: Meta<typeof FormAddressFieldInput> = { const meta: Meta<typeof FormAddressFieldInput> = {
@ -7,6 +9,7 @@ const meta: Meta<typeof FormAddressFieldInput> = {
component: FormAddressFieldInput, component: FormAddressFieldInput,
args: {}, args: {},
argTypes: {}, argTypes: {},
decorators: [WorkflowStepDecorator],
}; };
export default meta; export default meta;
@ -40,12 +43,12 @@ export const WithVariables: Story = {
args: { args: {
label: 'Address', label: 'Address',
defaultValue: { defaultValue: {
addressStreet1: '{{a.street1}}', addressStreet1: `{{${MOCKED_STEP_ID}.address.street1}}`,
addressStreet2: '{{a.street2}}', addressStreet2: `{{${MOCKED_STEP_ID}.address.street2}}`,
addressCity: '{{a.city}}', addressCity: `{{${MOCKED_STEP_ID}.address.city}}`,
addressState: '{{a.state}}', addressState: `{{${MOCKED_STEP_ID}.address.state}}`,
addressCountry: '{{a.country}}', addressCountry: `{{${MOCKED_STEP_ID}.address.country}}`,
addressPostcode: '{{a.postcode}}', addressPostcode: `{{${MOCKED_STEP_ID}.address.postcode}}`,
addressLat: 39.781721, addressLat: 39.781721,
addressLng: -89.650148, addressLng: -89.650148,
}, },
@ -58,14 +61,12 @@ export const WithVariables: Story = {
const street2Variable = await canvas.findByText('street2'); const street2Variable = await canvas.findByText('street2');
const cityVariable = await canvas.findByText('city'); const cityVariable = await canvas.findByText('city');
const stateVariable = await canvas.findByText('state'); const stateVariable = await canvas.findByText('state');
const countryVariable = await canvas.findByText('country');
const postcodeVariable = await canvas.findByText('postcode'); const postcodeVariable = await canvas.findByText('postcode');
expect(street1Variable).toBeVisible(); expect(street1Variable).toBeVisible();
expect(street2Variable).toBeVisible(); expect(street2Variable).toBeVisible();
expect(cityVariable).toBeVisible(); expect(cityVariable).toBeVisible();
expect(stateVariable).toBeVisible(); expect(stateVariable).toBeVisible();
expect(countryVariable).toBeVisible();
expect(postcodeVariable).toBeVisible(); expect(postcodeVariable).toBeVisible();
const variablePickers = await canvas.findAllByText('VariablePicker'); const variablePickers = await canvas.findAllByText('VariablePicker');

View File

@ -2,6 +2,8 @@ import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode';
import { FieldCurrencyValue } from '@/object-record/record-field/types/FieldMetadata'; import { FieldCurrencyValue } from '@/object-record/record-field/types/FieldMetadata';
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 { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { MOCKED_STEP_ID } from '~/testing/mock-data/workflow';
import { FormCurrencyFieldInput } from '../FormCurrencyFieldInput'; import { FormCurrencyFieldInput } from '../FormCurrencyFieldInput';
const meta: Meta<typeof FormCurrencyFieldInput> = { const meta: Meta<typeof FormCurrencyFieldInput> = {
@ -9,6 +11,7 @@ const meta: Meta<typeof FormCurrencyFieldInput> = {
component: FormCurrencyFieldInput, component: FormCurrencyFieldInput,
args: {}, args: {},
argTypes: {}, argTypes: {},
decorators: [WorkflowStepDecorator],
}; };
export default meta; export default meta;
@ -36,18 +39,18 @@ export const WithVariable: Story = {
args: { args: {
label: 'Salary', label: 'Salary',
defaultValue: { defaultValue: {
currencyCode: CurrencyCode.USD, currencyCode: `{{${MOCKED_STEP_ID}.amount.currencyCode}}` as CurrencyCode,
amountMicros: '{{a.b.c}}', amountMicros: `{{${MOCKED_STEP_ID}.amount.amountMicros}}`,
}, },
}, },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
const currency = await canvas.findByText(/USD/); const amountMicros = await canvas.findByText('My Amount Micros');
expect(currency).toBeVisible(); const currencyCode = await canvas.findByText('My Currency Code');
const amountVariable = await canvas.findByText('c'); expect(amountMicros).toBeVisible();
expect(amountVariable).toBeVisible(); expect(currencyCode).toBeVisible();
}, },
}; };

View File

@ -12,6 +12,8 @@ import {
} from '@storybook/test'; } from '@storybook/test';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { MOCKED_STEP_ID } from '~/testing/mock-data/workflow';
import { FormDateFieldInput } from '../FormDateFieldInput'; import { FormDateFieldInput } from '../FormDateFieldInput';
const meta: Meta<typeof FormDateFieldInput> = { const meta: Meta<typeof FormDateFieldInput> = {
@ -19,7 +21,7 @@ const meta: Meta<typeof FormDateFieldInput> = {
component: FormDateFieldInput, component: FormDateFieldInput,
args: {}, args: {},
argTypes: {}, argTypes: {},
decorators: [I18nFrontDecorator], decorators: [I18nFrontDecorator, WorkflowStepDecorator],
}; };
export default meta; export default meta;
@ -308,7 +310,7 @@ export const SwitchesToStandaloneVariable: Story = {
return ( return (
<button <button
onClick={() => { onClick={() => {
onVariableSelect('{{test}}'); onVariableSelect(`{{${MOCKED_STEP_ID}.createdAt}}`);
}} }}
> >
Add variable Add variable
@ -322,7 +324,7 @@ export const SwitchesToStandaloneVariable: Story = {
const addVariableButton = await canvas.findByText('Add variable'); const addVariableButton = await canvas.findByText('Add variable');
await userEvent.click(addVariableButton); await userEvent.click(addVariableButton);
const variableTag = await canvas.findByText('test'); const variableTag = await canvas.findByText('Creation date');
expect(variableTag).toBeVisible(); expect(variableTag).toBeVisible();
const removeVariableButton = canvasElement.querySelector( const removeVariableButton = canvasElement.querySelector(
@ -395,14 +397,14 @@ export const Disabled: Story = {
export const DisabledWithVariable: Story = { export const DisabledWithVariable: Story = {
args: { args: {
label: 'Created At', label: 'Created At',
defaultValue: `{{a.b.c}}`, defaultValue: `{{${MOCKED_STEP_ID}.createdAt}}`,
onPersist: fn(), onPersist: fn(),
readonly: true, readonly: true,
VariablePicker: ({ onVariableSelect }) => { VariablePicker: ({ onVariableSelect }) => {
return ( return (
<button <button
onClick={() => { onClick={() => {
onVariableSelect('{{test}}'); onVariableSelect(`{{${MOCKED_STEP_ID}.createdAt}}`);
}} }}
> >
Add variable Add variable
@ -410,10 +412,11 @@ export const DisabledWithVariable: Story = {
); );
}, },
}, },
decorators: [WorkflowStepDecorator],
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
const variableChip = await canvas.findByText('c'); const variableChip = await canvas.findByText('Creation date');
expect(variableChip).toBeVisible(); expect(variableChip).toBeVisible();
}, },
}; };

View File

@ -13,13 +13,15 @@ import {
} from '@storybook/test'; } from '@storybook/test';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { MOCKED_STEP_ID } from '~/testing/mock-data/workflow';
const meta: Meta<typeof FormDateTimeFieldInput> = { const meta: Meta<typeof FormDateTimeFieldInput> = {
title: 'UI/Data/Field/Form/Input/FormDateTimeFieldInput', title: 'UI/Data/Field/Form/Input/FormDateTimeFieldInput',
component: FormDateTimeFieldInput, component: FormDateTimeFieldInput,
args: {}, args: {},
argTypes: {}, argTypes: {},
decorators: [I18nFrontDecorator], decorators: [I18nFrontDecorator, WorkflowStepDecorator],
}; };
export default meta; export default meta;
@ -337,7 +339,7 @@ export const SwitchesToStandaloneVariable: Story = {
return ( return (
<button <button
onClick={() => { onClick={() => {
onVariableSelect('{{test}}'); onVariableSelect(`{{${MOCKED_STEP_ID}.createdAt}}`);
}} }}
> >
Add variable Add variable
@ -351,7 +353,7 @@ export const SwitchesToStandaloneVariable: Story = {
const addVariableButton = await canvas.findByText('Add variable'); const addVariableButton = await canvas.findByText('Add variable');
await userEvent.click(addVariableButton); await userEvent.click(addVariableButton);
const variableTag = await canvas.findByText('test'); const variableTag = await canvas.findByText('Creation date');
expect(variableTag).toBeVisible(); expect(variableTag).toBeVisible();
const removeVariableButton = canvasElement.querySelector( const removeVariableButton = canvasElement.querySelector(
@ -426,14 +428,14 @@ export const Disabled: Story = {
export const DisabledWithVariable: Story = { export const DisabledWithVariable: Story = {
args: { args: {
label: 'Created At', label: 'Created At',
defaultValue: `{{a.b.c}}`, defaultValue: `{{${MOCKED_STEP_ID}.createdAt}}`,
onPersist: fn(), onPersist: fn(),
readonly: true, readonly: true,
VariablePicker: ({ onVariableSelect }) => { VariablePicker: ({ onVariableSelect }) => {
return ( return (
<button <button
onClick={() => { onClick={() => {
onVariableSelect('{{test}}'); onVariableSelect(`{{${MOCKED_STEP_ID}.createdAt}}`);
}} }}
> >
Add variable Add variable
@ -444,7 +446,7 @@ export const DisabledWithVariable: Story = {
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
const variableChip = await canvas.findByText('c'); const variableChip = await canvas.findByText('Creation date');
expect(variableChip).toBeVisible(); expect(variableChip).toBeVisible();
}, },
}; };

View File

@ -1,5 +1,7 @@
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test'; import { expect, fn, userEvent, within } from '@storybook/test';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { MOCKED_STEP_ID } from '~/testing/mock-data/workflow';
import { FormEmailsFieldInput } from '../FormEmailsFieldInput'; import { FormEmailsFieldInput } from '../FormEmailsFieldInput';
const meta: Meta<typeof FormEmailsFieldInput> = { const meta: Meta<typeof FormEmailsFieldInput> = {
@ -7,6 +9,7 @@ const meta: Meta<typeof FormEmailsFieldInput> = {
component: FormEmailsFieldInput, component: FormEmailsFieldInput,
args: {}, args: {},
argTypes: {}, argTypes: {},
decorators: [WorkflowStepDecorator],
}; };
export default meta; export default meta;
@ -34,7 +37,7 @@ export const WithVariable: Story = {
args: { args: {
label: 'Emails', label: 'Emails',
defaultValue: { defaultValue: {
primaryEmail: '{{a.b.c}}', primaryEmail: `{{${MOCKED_STEP_ID}.name}}`,
additionalEmails: [], additionalEmails: [],
}, },
VariablePicker: () => <div>VariablePicker</div>, VariablePicker: () => <div>VariablePicker</div>,
@ -42,7 +45,7 @@ export const WithVariable: Story = {
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
const primaryEmailVariable = await canvas.findByText('c'); const primaryEmailVariable = await canvas.findByText('Name');
expect(primaryEmailVariable).toBeVisible(); expect(primaryEmailVariable).toBeVisible();
const variablePicker = await canvas.findByText('VariablePicker'); const variablePicker = await canvas.findByText('VariablePicker');

View File

@ -1,5 +1,7 @@
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test'; import { expect, fn, userEvent, within } from '@storybook/test';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { MOCKED_STEP_ID } from '~/testing/mock-data/workflow';
import { FormFullNameFieldInput } from '../FormFullNameFieldInput'; import { FormFullNameFieldInput } from '../FormFullNameFieldInput';
const meta: Meta<typeof FormFullNameFieldInput> = { const meta: Meta<typeof FormFullNameFieldInput> = {
@ -7,6 +9,7 @@ const meta: Meta<typeof FormFullNameFieldInput> = {
component: FormFullNameFieldInput, component: FormFullNameFieldInput,
args: {}, args: {},
argTypes: {}, argTypes: {},
decorators: [WorkflowStepDecorator],
}; };
export default meta; export default meta;
@ -34,18 +37,18 @@ export const WithVariable: Story = {
args: { args: {
label: 'Name', label: 'Name',
defaultValue: { defaultValue: {
firstName: '{{a.firstName}}', firstName: `{{${MOCKED_STEP_ID}.fullName.firstName}}`,
lastName: '{{a.lastName}}', lastName: `{{${MOCKED_STEP_ID}.fullName.lastName}}`,
}, },
VariablePicker: () => <div>VariablePicker</div>, VariablePicker: () => <div>VariablePicker</div>,
}, },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
const firstNameVariable = await canvas.findByText('firstName'); const firstNameVariable = await canvas.findByText('Full Name First Name');
expect(firstNameVariable).toBeVisible(); expect(firstNameVariable).toBeVisible();
const lastNameVariable = await canvas.findByText('lastName'); const lastNameVariable = await canvas.findByText('Full Name Last Name');
expect(lastNameVariable).toBeVisible(); expect(lastNameVariable).toBeVisible();
const variablePickers = await canvas.findAllByText('VariablePicker'); const variablePickers = await canvas.findAllByText('VariablePicker');

View File

@ -1,6 +1,7 @@
import { expect } from '@storybook/jest'; import { expect } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { fn, userEvent, within } from '@storybook/test'; import { fn, userEvent, within } from '@storybook/test';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { FormLinksFieldInput } from '../FormLinksFieldInput'; import { FormLinksFieldInput } from '../FormLinksFieldInput';
const meta: Meta<typeof FormLinksFieldInput> = { const meta: Meta<typeof FormLinksFieldInput> = {
@ -8,6 +9,7 @@ const meta: Meta<typeof FormLinksFieldInput> = {
component: FormLinksFieldInput, component: FormLinksFieldInput,
args: {}, args: {},
argTypes: {}, argTypes: {},
decorators: [WorkflowStepDecorator],
}; };
export default meta; export default meta;
@ -35,18 +37,18 @@ export const WithVariables: Story = {
args: { args: {
label: 'Domain Name', label: 'Domain Name',
defaultValue: { defaultValue: {
primaryLinkLabel: '{{a.label}}', primaryLinkLabel: '{{04d5f3bf-9714-400d-ba27-644006a5fb1b.name}}',
primaryLinkUrl: '{{a.url}}', primaryLinkUrl: '{{04d5f3bf-9714-400d-ba27-644006a5fb1b.stage}}',
}, },
VariablePicker: () => <div>VariablePicker</div>, VariablePicker: () => <div>VariablePicker</div>,
}, },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
const primaryLinkLabelVariable = await canvas.findByText('label'); const primaryLinkLabelVariable = await canvas.findByText('Name');
expect(primaryLinkLabelVariable).toBeVisible(); expect(primaryLinkLabelVariable).toBeVisible();
const primaryLinkUrlVariable = await canvas.findByText('url'); const primaryLinkUrlVariable = await canvas.findByText('Stage');
expect(primaryLinkUrlVariable).toBeVisible(); expect(primaryLinkUrlVariable).toBeVisible();
const variablePickers = await canvas.findAllByText('VariablePicker'); const variablePickers = await canvas.findAllByText('VariablePicker');

View File

@ -1,6 +1,8 @@
import { expect } from '@storybook/jest'; import { expect } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { fn, userEvent, within } from '@storybook/test'; import { fn, userEvent, within } from '@storybook/test';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { MOCKED_STEP_ID } from '~/testing/mock-data/workflow';
import { FormMultiSelectFieldInput } from '../FormMultiSelectFieldInput'; import { FormMultiSelectFieldInput } from '../FormMultiSelectFieldInput';
const meta: Meta<typeof FormMultiSelectFieldInput> = { const meta: Meta<typeof FormMultiSelectFieldInput> = {
@ -8,6 +10,7 @@ const meta: Meta<typeof FormMultiSelectFieldInput> = {
component: FormMultiSelectFieldInput, component: FormMultiSelectFieldInput,
args: {}, args: {},
argTypes: {}, argTypes: {},
decorators: [WorkflowStepDecorator],
}; };
export default meta; export default meta;
@ -117,14 +120,14 @@ export const Disabled: Story = {
export const DisabledWithVariable: Story = { export const DisabledWithVariable: Story = {
args: { args: {
label: 'Created At', label: 'Created At',
defaultValue: `{{a.b.c}}`, defaultValue: `{{${MOCKED_STEP_ID}.stage}}`,
onPersist: fn(), onPersist: fn(),
readonly: true, readonly: true,
}, },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
const variableChip = await canvas.findByText('c'); const variableChip = await canvas.findByText('Stage');
expect(variableChip).toBeVisible(); expect(variableChip).toBeVisible();
await userEvent.click(variableChip); await userEvent.click(variableChip);

View File

@ -2,6 +2,8 @@ import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, waitFor, within } from '@storybook/test'; import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
import { FieldPhonesValue } from '@/object-record/record-field/types/FieldMetadata'; import { FieldPhonesValue } from '@/object-record/record-field/types/FieldMetadata';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { MOCKED_STEP_ID } from '~/testing/mock-data/workflow';
import { FormPhoneFieldInput } from '../FormPhoneFieldInput'; import { FormPhoneFieldInput } from '../FormPhoneFieldInput';
const meta: Meta<typeof FormPhoneFieldInput> = { const meta: Meta<typeof FormPhoneFieldInput> = {
@ -9,6 +11,7 @@ const meta: Meta<typeof FormPhoneFieldInput> = {
component: FormPhoneFieldInput, component: FormPhoneFieldInput,
args: {}, args: {},
argTypes: {}, argTypes: {},
decorators: [WorkflowStepDecorator],
}; };
export default meta; export default meta;
@ -108,20 +111,17 @@ export const WithVariablesAsDefaultValues: Story = {
args: { args: {
label: 'Phone', label: 'Phone',
defaultValue: { defaultValue: {
primaryPhoneCountryCode: '{{a.countryCode}}', primaryPhoneCountryCode: `{{${MOCKED_STEP_ID}.name}}`,
primaryPhoneNumber: '{{a.phoneNumber}}', primaryPhoneNumber: `{{${MOCKED_STEP_ID}.amount.amountMicros}}`,
}, },
VariablePicker: () => <div>VariablePicker</div>, VariablePicker: () => <div>VariablePicker</div>,
}, },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
const countryCodeVariable = await canvas.findByText('countryCode'); const countryCodeVariable = await canvas.findByText('Name');
expect(countryCodeVariable).toBeVisible(); expect(countryCodeVariable).toBeVisible();
const phoneNumberVariable = await canvas.findByText('phoneNumber');
expect(phoneNumberVariable).toBeVisible();
const variablePickers = await canvas.findAllByText('VariablePicker'); const variablePickers = await canvas.findAllByText('VariablePicker');
expect(variablePickers).toHaveLength(1); expect(variablePickers).toHaveLength(1);
@ -139,7 +139,7 @@ export const SelectingVariables: Story = {
return ( return (
<button <button
onClick={() => { onClick={() => {
onVariableSelect('{{test}}'); onVariableSelect(`{{${MOCKED_STEP_ID}.phoneNumber}}`);
}} }}
> >
Add variable Add variable
@ -162,12 +162,12 @@ export const SelectingVariables: Story = {
await userEvent.click(phoneNumberVariablePicker); await userEvent.click(phoneNumberVariablePicker);
const phoneNumberVariable = await canvas.findByText('test'); const phoneNumberVariable = await canvas.findByText('phoneNumber');
expect(phoneNumberVariable).toBeVisible(); expect(phoneNumberVariable).toBeVisible();
await waitFor(() => { await waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith({ expect(args.onPersist).toHaveBeenCalledWith({
primaryPhoneNumber: '{{test}}', primaryPhoneNumber: `{{${MOCKED_STEP_ID}.phoneNumber}}`,
primaryPhoneCountryCode: '', primaryPhoneCountryCode: '',
primaryPhoneCallingCode: '', primaryPhoneCallingCode: '',
}); });

View File

@ -2,6 +2,8 @@ import { expect } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { fn, userEvent, waitFor, within } from '@storybook/test'; import { fn, userEvent, waitFor, within } from '@storybook/test';
import { getUserDevice } from 'twenty-ui'; import { getUserDevice } from 'twenty-ui';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { MOCKED_STEP_ID } from '~/testing/mock-data/workflow';
import { FormRawJsonFieldInput } from '../FormRawJsonFieldInput'; import { FormRawJsonFieldInput } from '../FormRawJsonFieldInput';
const meta: Meta<typeof FormRawJsonFieldInput> = { const meta: Meta<typeof FormRawJsonFieldInput> = {
@ -9,6 +11,7 @@ const meta: Meta<typeof FormRawJsonFieldInput> = {
component: FormRawJsonFieldInput, component: FormRawJsonFieldInput,
args: {}, args: {},
argTypes: {}, argTypes: {},
decorators: [WorkflowStepDecorator],
}; };
export default meta; export default meta;
@ -37,7 +40,7 @@ export const Readonly: Story = {
return ( return (
<button <button
onClick={() => { onClick={() => {
onVariableSelect('{{test}}'); onVariableSelect(`{{${MOCKED_STEP_ID}.name}}`);
}} }}
> >
Add variable Add variable
@ -142,7 +145,7 @@ export const DoesNotIgnoreInvalidJson: Story = {
export const DisplayDefaultValueWithVariablesProperly: Story = { export const DisplayDefaultValueWithVariablesProperly: Story = {
args: { args: {
placeholder: 'Enter valid json', placeholder: 'Enter valid json',
defaultValue: '{ "a": { "b" : {{var.test}} } }', defaultValue: `{ "a": { "b" : {{${MOCKED_STEP_ID}.name}} } }`,
onPersist: fn(), onPersist: fn(),
}, },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
@ -150,7 +153,7 @@ export const DisplayDefaultValueWithVariablesProperly: Story = {
await canvas.findByText(/{ "a": { "b" : /); await canvas.findByText(/{ "a": { "b" : /);
const variableTag = await canvas.findByText('test'); const variableTag = await canvas.findByText('Name');
await expect(variableTag).toBeVisible(); await expect(variableTag).toBeVisible();
await canvas.findByText(/ } }/); await canvas.findByText(/ } }/);
@ -304,7 +307,7 @@ export const HasHistory: Story = {
return ( return (
<button <button
onClick={() => { onClick={() => {
onVariableSelect('{{test}}'); onVariableSelect(`{{${MOCKED_STEP_ID}.name}}`);
}} }}
> >
Add variable Add variable
@ -331,7 +334,9 @@ export const HasHistory: Story = {
await userEvent.type(editor, ' }'); await userEvent.type(editor, ' }');
expect(args.onPersist).toHaveBeenLastCalledWith('{ "a": {{test}} }'); expect(args.onPersist).toHaveBeenLastCalledWith(
`{ "a": {{${MOCKED_STEP_ID}.name}} }`,
);
await userEvent.type(editor, `{${controlKey}>}z{/${controlKey}}`); await userEvent.type(editor, `{${controlKey}>}z{/${controlKey}}`);
@ -343,7 +348,9 @@ export const HasHistory: Story = {
`{Shift>}{${controlKey}>}z{/${controlKey}}{/Shift}`, `{Shift>}{${controlKey}>}z{/${controlKey}}{/Shift}`,
); );
expect(editor).toHaveTextContent('{ "a": test }'); expect(editor).toHaveTextContent('{ "a": Name }');
expect(args.onPersist).toHaveBeenLastCalledWith('{ "a": {{test}} }'); expect(args.onPersist).toHaveBeenLastCalledWith(
`{ "a": {{${MOCKED_STEP_ID}.name}} }`,
);
}, },
}; };

View File

@ -1,6 +1,8 @@
import { expect } from '@storybook/jest'; import { expect } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { fn, userEvent, within } from '@storybook/test'; import { fn, userEvent, within } from '@storybook/test';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { MOCKED_STEP_ID } from '~/testing/mock-data/workflow';
import { FormSelectFieldInput } from '../FormSelectFieldInput'; import { FormSelectFieldInput } from '../FormSelectFieldInput';
const meta: Meta<typeof FormSelectFieldInput> = { const meta: Meta<typeof FormSelectFieldInput> = {
@ -8,6 +10,7 @@ const meta: Meta<typeof FormSelectFieldInput> = {
component: FormSelectFieldInput, component: FormSelectFieldInput,
args: {}, args: {},
argTypes: {}, argTypes: {},
decorators: [WorkflowStepDecorator],
}; };
export default meta; export default meta;
@ -117,7 +120,7 @@ export const Disabled: Story = {
export const DisabledWithVariable: Story = { export const DisabledWithVariable: Story = {
args: { args: {
label: 'Created At', label: 'Created At',
defaultValue: `{{a.b.c}}`, defaultValue: `{{${MOCKED_STEP_ID}.createdAt}}`,
options: [ options: [
{ {
label: 'Work Policy 1', label: 'Work Policy 1',
@ -146,7 +149,7 @@ export const DisabledWithVariable: Story = {
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
const variableChip = await canvas.findByText('c'); const variableChip = await canvas.findByText('Creation date');
expect(variableChip).toBeVisible(); expect(variableChip).toBeVisible();
await userEvent.click(variableChip); await userEvent.click(variableChip);

View File

@ -8,6 +8,8 @@ import {
within, within,
} from '@storybook/test'; } from '@storybook/test';
import { getUserDevice } from 'twenty-ui'; import { getUserDevice } from 'twenty-ui';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { MOCKED_STEP_ID } from '~/testing/mock-data/workflow';
import { FormTextFieldInput } from '../FormTextFieldInput'; import { FormTextFieldInput } from '../FormTextFieldInput';
const meta: Meta<typeof FormTextFieldInput> = { const meta: Meta<typeof FormTextFieldInput> = {
@ -15,6 +17,7 @@ const meta: Meta<typeof FormTextFieldInput> = {
component: FormTextFieldInput, component: FormTextFieldInput,
args: {}, args: {},
argTypes: {}, argTypes: {},
decorators: [WorkflowStepDecorator],
}; };
export default meta; export default meta;
@ -78,7 +81,7 @@ export const WithVariable: Story = {
return ( return (
<button <button
onClick={() => { onClick={() => {
onVariableSelect('{{test}}'); onVariableSelect(`{{${MOCKED_STEP_ID}.name}}`);
}} }}
> >
Add variable Add variable
@ -96,11 +99,11 @@ export const WithVariable: Story = {
await userEvent.click(addVariableButton); await userEvent.click(addVariableButton);
const variable = await canvas.findByText('test'); const variable = await canvas.findByText('Name');
expect(variable).toBeVisible(); expect(variable).toBeVisible();
await waitFor(() => { await waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith('{{test}}'); expect(args.onPersist).toHaveBeenCalledWith(`{{${MOCKED_STEP_ID}.name}}`);
}); });
expect(args.onPersist).toHaveBeenCalledTimes(1); expect(args.onPersist).toHaveBeenCalledTimes(1);
}, },
@ -110,7 +113,7 @@ export const WithDeletableVariable: Story = {
args: { args: {
label: 'Text', label: 'Text',
placeholder: 'Text field...', placeholder: 'Text field...',
defaultValue: 'test {{a.b.variable}} test', defaultValue: `test {{${MOCKED_STEP_ID}.name}} test`,
onPersist: fn(), onPersist: fn(),
}, },
play: async ({ canvasElement, args }) => { play: async ({ canvasElement, args }) => {
@ -119,7 +122,7 @@ export const WithDeletableVariable: Story = {
const editor = canvasElement.querySelector('.ProseMirror > p'); const editor = canvasElement.querySelector('.ProseMirror > p');
expect(editor).toBeVisible(); expect(editor).toBeVisible();
const variable = await canvas.findByText('variable'); const variable = await canvas.findByText('Name');
expect(variable).toBeVisible(); expect(variable).toBeVisible();
const deleteVariableButton = await canvas.findByRole('button', { const deleteVariableButton = await canvas.findByRole('button', {
@ -173,7 +176,7 @@ export const Disabled: Story = {
export const DisabledWithVariable: Story = { export const DisabledWithVariable: Story = {
args: { args: {
label: 'Text', label: 'Text',
defaultValue: 'test {{a.b.variable}} test', defaultValue: `test {{${MOCKED_STEP_ID}.name}} test`,
readonly: true, readonly: true,
}, },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
@ -182,7 +185,7 @@ export const DisabledWithVariable: Story = {
expect(editor).toBeVisible(); expect(editor).toBeVisible();
await waitFor(() => { await waitFor(() => {
expect(editor).toHaveTextContent('test variable test'); expect(editor).toHaveTextContent('test Name test');
}); });
const deleteVariableButton = within(editor as HTMLElement).queryByRole( const deleteVariableButton = within(editor as HTMLElement).queryByRole(
@ -200,7 +203,7 @@ export const HasHistory: Story = {
return ( return (
<button <button
onClick={() => { onClick={() => {
onVariableSelect('{{test}}'); onVariableSelect(`{{${MOCKED_STEP_ID}.name}}`);
}} }}
> >
Add variable Add variable
@ -225,7 +228,9 @@ export const HasHistory: Story = {
await userEvent.click(addVariableButton); await userEvent.click(addVariableButton);
expect(args.onPersist).toHaveBeenLastCalledWith('Hello World {{test}}'); expect(args.onPersist).toHaveBeenLastCalledWith(
`Hello World {{${MOCKED_STEP_ID}.name}}`,
);
await userEvent.type(editor, `{${controlKey}>}z{/${controlKey}}`); await userEvent.type(editor, `{${controlKey}>}z{/${controlKey}}`);
@ -237,7 +242,9 @@ export const HasHistory: Story = {
`{Shift>}{${controlKey}>}z{/${controlKey}}{/Shift}`, `{Shift>}{${controlKey}>}z{/${controlKey}}{/Shift}`,
); );
expect(editor).toHaveTextContent('Hello World test'); expect(editor).toHaveTextContent(`Hello World Name`);
expect(args.onPersist).toHaveBeenLastCalledWith('Hello World {{test}}'); expect(args.onPersist).toHaveBeenLastCalledWith(
`Hello World {{${MOCKED_STEP_ID}.name}}`,
);
}, },
}; };

View File

@ -1,12 +1,8 @@
import { WorkflowVersionComponentInstanceContext } from '@/workflow/states/context/WorkflowVersionComponentInstanceContext';
import { expect } from '@storybook/jest'; import { expect } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { import { fn, userEvent, waitFor, within } from '@storybook/test';
fn, import { MOCKED_STEP_ID } from '~/testing/mock-data/workflow';
userEvent,
waitFor,
waitForElementToBeRemoved,
within,
} from '@storybook/test';
import { FormUuidFieldInput } from '../FormUuidFieldInput'; import { FormUuidFieldInput } from '../FormUuidFieldInput';
const meta: Meta<typeof FormUuidFieldInput> = { const meta: Meta<typeof FormUuidFieldInput> = {
@ -14,8 +10,16 @@ const meta: Meta<typeof FormUuidFieldInput> = {
component: FormUuidFieldInput, component: FormUuidFieldInput,
args: {}, args: {},
argTypes: {}, argTypes: {},
decorators: [
(Story) => (
<WorkflowVersionComponentInstanceContext.Provider
value={{ instanceId: 'workflow-version-id' }}
>
<Story />
</WorkflowVersionComponentInstanceContext.Provider>
),
],
}; };
export default meta; export default meta;
type Story = StoryObj<typeof FormUuidFieldInput>; type Story = StoryObj<typeof FormUuidFieldInput>;
@ -150,69 +154,6 @@ export const ClearField: Story = {
}, },
}; };
export const ReplaceStaticValueWithVariable: Story = {
args: {
label: 'UUID field',
placeholder: 'Enter UUID',
onPersist: fn(),
VariablePicker: ({ onVariableSelect }) => {
return (
<button
onClick={() => {
onVariableSelect('{{test}}');
}}
>
Add variable
</button>
);
},
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = await canvas.findByPlaceholderText('Enter UUID');
expect(input).toBeVisible();
expect(input).toHaveDisplayValue('');
const addVariableButton = await canvas.findByRole('button', {
name: 'Add variable',
});
const [, , variableTag] = await Promise.all([
userEvent.click(addVariableButton),
waitForElementToBeRemoved(input),
waitFor(() => {
const variableTag = canvas.getByText('test');
expect(variableTag).toBeVisible();
return variableTag;
}),
waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith('{{test}}');
}),
]);
const removeVariableButton = canvasElement.querySelector(
'button .tabler-icon-x',
);
await Promise.all([
userEvent.click(removeVariableButton),
waitForElementToBeRemoved(variableTag),
waitFor(() => {
const input = canvas.getByPlaceholderText('Enter UUID');
expect(input).toBeVisible();
}),
waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith(null);
}),
]);
},
};
export const Disabled: Story = { export const Disabled: Story = {
args: { args: {
label: 'UUID field', label: 'UUID field',
@ -222,7 +163,7 @@ export const Disabled: Story = {
return ( return (
<button <button
onClick={() => { onClick={() => {
onVariableSelect('{{test}}'); onVariableSelect(`{{${MOCKED_STEP_ID}.name}}`);
}} }}
> >
Add variable Add variable

View File

@ -10,6 +10,7 @@ import { useSetRecoilState } from 'recoil';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui'; import { ComponentDecorator, RouterDecorator } from 'twenty-ui';
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 { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator'; import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks'; import { graphqlMocks } from '~/testing/graphqlMocks';
import { oneFailedWorkflowRunQueryResult } from '~/testing/mock-data/workflow-run'; import { oneFailedWorkflowRunQueryResult } from '~/testing/mock-data/workflow-run';
@ -50,6 +51,7 @@ const meta: Meta<typeof RightDrawerWorkflowRunViewStep> = {
RouterDecorator, RouterDecorator,
ObjectMetadataItemsDecorator, ObjectMetadataItemsDecorator,
WorkspaceDecorator, WorkspaceDecorator,
WorkflowStepDecorator,
], ],
parameters: { parameters: {
msw: { msw: {

View File

@ -1,4 +1,3 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { RecordChip } from '@/object-record/components/RecordChip'; import { RecordChip } from '@/object-record/components/RecordChip';
import { VariableChipStandalone } from '@/object-record/record-field/form-types/components/VariableChipStandalone'; import { VariableChipStandalone } from '@/object-record/record-field/form-types/components/VariableChipStandalone';
import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecord } from '@/object-record/types/ObjectRecord';
@ -42,8 +41,6 @@ export const WorkflowSingleRecordFieldChip = ({
onRemove, onRemove,
disabled, disabled,
}: WorkflowSingleRecordFieldChipProps) => { }: WorkflowSingleRecordFieldChipProps) => {
const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular });
if ( if (
!!draftValue && !!draftValue &&
draftValue.type === 'variable' && draftValue.type === 'variable' &&
@ -51,8 +48,9 @@ export const WorkflowSingleRecordFieldChip = ({
) { ) {
return ( return (
<VariableChipStandalone <VariableChipStandalone
rawVariableName={objectMetadataItem.labelSingular} rawVariableName={draftValue.value}
onRemove={disabled ? undefined : onRemove} onRemove={disabled ? undefined : onRemove}
isFullRecord
/> />
); );
} }

View File

@ -193,11 +193,5 @@ export const DisabledWithDefaultVariableValues: Story = {
).queryByRole('button'); ).queryByRole('button');
expect(openRecordSelectButton).not.toBeInTheDocument(); expect(openRecordSelectButton).not.toBeInTheDocument();
const recordVariableToDelete = await within(
canvas.getByTestId('workflow-edit-action-record-delete-object-record-id'),
).findByText('Person');
expect(recordVariableToDelete).toBeVisible();
}, },
}; };

View File

@ -0,0 +1 @@
export const CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX = /{{([^{}]+)}}/g;

View File

@ -33,9 +33,9 @@ export const useAvailableVariablesInWorkflowStep = ({
const availableStepsOutputSchema: StepOutputSchema[] = const availableStepsOutputSchema: StepOutputSchema[] =
getStepsOutputSchema(previousStepIds).filter(isDefined); getStepsOutputSchema(previousStepIds).filter(isDefined);
const triggersOutputSchema: StepOutputSchema[] = getStepsOutputSchema([ const triggersOutputSchema: StepOutputSchema[] = isDefined(flow.trigger)
TRIGGER_STEP_ID, ? getStepsOutputSchema([TRIGGER_STEP_ID]).filter(isDefined)
]).filter(isDefined); : [];
const availableVariablesInWorkflowStep = [ const availableVariablesInWorkflowStep = [
...availableStepsOutputSchema, ...availableStepsOutputSchema,

View File

@ -1,13 +1,36 @@
import { extractVariableLabel } from '../extractVariableLabel'; import { extractRawVariableNamePart } from '@/workflow/workflow-variables/utils/extractRawVariableNamePart';
it('returns the last part of a properly formatted variable', () => { describe('extractRawVariableNamePart', () => {
const rawVariable = '{{a.b.c}}'; it('returns the last part of a properly formatted variable', () => {
const rawVariable = '{{a.b.c}}';
expect(extractVariableLabel(rawVariable)).toBe('c'); expect(
}); extractRawVariableNamePart({
rawVariableName: rawVariable,
it('stops on unclosed variables', () => { part: 'selectedField',
const rawVariable = '{{ test {{a.b.c}}'; }),
).toBe('c');
expect(extractVariableLabel(rawVariable)).toBe('c'); });
it('returns the first part of a properly formatted variable', () => {
const rawVariable = '{{a.b.c}}';
expect(
extractRawVariableNamePart({
rawVariableName: rawVariable,
part: 'stepId',
}),
).toBe('a');
});
it('stops on unclosed variables', () => {
const rawVariable = '{{ test {{a.b.c}}';
expect(
extractRawVariableNamePart({
rawVariableName: rawVariable,
part: 'selectedField',
}),
).toBe('c');
});
}); });

View File

@ -0,0 +1,195 @@
import { StepOutputSchema } from '@/workflow/workflow-variables/types/StepOutputSchema';
import { searchVariableThroughOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughOutputSchema';
const mockStep = {
id: 'step-1',
name: 'Step 1',
outputSchema: {
company: {
isLeaf: false,
icon: 'company',
label: 'Company',
value: {
object: {
nameSingular: 'company',
fieldIdName: 'id',
label: 'Company',
value: 'John',
isLeaf: true,
},
fields: {
name: { label: 'Name', value: 'Twenty', isLeaf: true },
address: { label: 'Address', value: '123 Main St', isLeaf: true },
},
_outputSchemaType: 'RECORD',
},
},
person: {
isLeaf: false,
icon: 'person',
label: 'Person',
value: {
object: {
nameSingular: 'person',
fieldIdName: 'id',
label: 'Person',
value: 'Jane',
isLeaf: true,
},
fields: {
firstName: { label: 'First Name', value: 'Jane', isLeaf: true },
lastName: { label: 'Last Name', value: 'Doe', isLeaf: true },
email: { label: 'Email', value: 'jane@example.com', isLeaf: true },
},
_outputSchemaType: 'RECORD',
},
},
simpleData: {
isLeaf: true,
label: 'Simple Data',
value: 'Simple value',
},
nestedData: {
isLeaf: false,
label: 'Nested Data',
value: {
field1: { label: 'Field 1', value: 'Value 1', isLeaf: true },
field2: { label: 'Field 2', value: 'Value 2', isLeaf: true },
},
},
},
} satisfies StepOutputSchema;
describe('searchVariableThroughOutputSchema', () => {
it('should find a company field variable', () => {
const result = searchVariableThroughOutputSchema({
stepOutputSchema: mockStep,
rawVariableName: '{{step-1.company.name}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Name',
variablePathLabel: 'Step 1 > Company > Name',
});
});
it('should find a person field variable', () => {
const result = searchVariableThroughOutputSchema({
stepOutputSchema: mockStep,
rawVariableName: '{{step-1.person.email}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Email',
variablePathLabel: 'Step 1 > Person > Email',
});
});
it('should find a company object variable', () => {
const result = searchVariableThroughOutputSchema({
stepOutputSchema: mockStep,
rawVariableName: '{{step-1.company.id}}',
isFullRecord: true,
});
expect(result).toEqual({
variableLabel: 'Company',
variablePathLabel: 'Step 1 > Company > Company',
});
});
it('should find a person object variable', () => {
const result = searchVariableThroughOutputSchema({
stepOutputSchema: mockStep,
rawVariableName: '{{step-1.person.id}}',
isFullRecord: true,
});
expect(result).toEqual({
variableLabel: 'Person',
variablePathLabel: 'Step 1 > Person > Person',
});
});
it('should handle simple data fields', () => {
const result = searchVariableThroughOutputSchema({
stepOutputSchema: mockStep,
rawVariableName: '{{step-1.simpleData}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Simple Data',
variablePathLabel: 'Step 1 > Simple Data',
});
});
it('should handle nested data fields', () => {
const result = searchVariableThroughOutputSchema({
stepOutputSchema: mockStep,
rawVariableName: '{{step-1.nestedData.field1}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Field 1',
variablePathLabel: 'Step 1 > Nested Data > Field 1',
});
});
it('should handle invalid variable names', () => {
const result = searchVariableThroughOutputSchema({
stepOutputSchema: mockStep,
rawVariableName: '{{invalid}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: 'Step 1 > undefined',
});
});
it('should handle non-existent paths', () => {
const result = searchVariableThroughOutputSchema({
stepOutputSchema: mockStep,
rawVariableName: '{{step-1.nonExistent.field}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: undefined,
variablePathLabel: 'Step 1 > undefined',
});
});
it('should handle the case where the path has dots in field names', () => {
const mockStepWithDotInField = {
id: 'step-1',
name: 'Step 1',
outputSchema: {
'complex.field': {
isLeaf: false,
label: 'Complex Field',
value: {
field1: { label: 'Field 1', value: 'Value 1', isLeaf: true },
field2: { label: 'Field 2', value: 'Value 2', isLeaf: true },
},
},
},
} satisfies StepOutputSchema;
const result = searchVariableThroughOutputSchema({
stepOutputSchema: mockStepWithDotInField,
rawVariableName: '{{step-1.complex.field.field1}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Field 1',
variablePathLabel: 'Step 1 > Complex Field > Field 1',
});
});
});

View File

@ -0,0 +1,32 @@
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from '@/workflow/workflow-variables/constants/CaptureAllVariableTagInnerRegex';
import { isDefined } from 'twenty-shared';
export const extractRawVariableNamePart = ({
rawVariableName,
part,
}: {
rawVariableName: string;
part: 'stepId' | 'selectedField';
}) => {
const variableWithoutBrackets = rawVariableName.replace(
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
(_, variableName) => {
return variableName;
},
);
const parts = variableWithoutBrackets.split('.');
const extractedPart =
part === 'stepId'
? parts.at(0)
: part === 'selectedField'
? parts.at(-1)
: null;
if (!isDefined(extractedPart)) {
throw new Error('Expected to find at least one splitted chunk.');
}
return extractedPart;
};

View File

@ -1,21 +0,0 @@
import { isDefined } from 'twenty-shared';
const CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX = /{{([^{}]+)}}/g;
export const extractVariableLabel = (rawVariableName: string) => {
const variableWithoutBrackets = rawVariableName.replace(
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
(_, variableName) => {
return variableName;
},
);
const parts = variableWithoutBrackets.split('.');
const displayText = parts.at(-1);
if (!isDefined(displayText)) {
throw new Error('Expected to find at least one splitted chunk.');
}
return displayText;
};

View File

@ -0,0 +1,128 @@
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from '@/workflow/workflow-variables/constants/CaptureAllVariableTagInnerRegex';
import {
OutputSchema,
StepOutputSchema,
} from '@/workflow/workflow-variables/types/StepOutputSchema';
import { isBaseOutputSchema } from '@/workflow/workflow-variables/utils/isBaseOutputSchema';
import { isRecordOutputSchema } from '@/workflow/workflow-variables/utils/isRecordOutputSchema';
import { isDefined } from 'twenty-shared';
const getDisplayedSubStepObjectLabel = (outputSchema: OutputSchema) => {
if (!isRecordOutputSchema(outputSchema)) {
return;
}
return outputSchema.object.label;
};
const getDisplayedSubStepFieldLabel = (
key: string,
outputSchema: OutputSchema,
) => {
if (isBaseOutputSchema(outputSchema)) {
return outputSchema[key]?.label;
}
if (isRecordOutputSchema(outputSchema)) {
return outputSchema.fields[key]?.label;
}
return;
};
const searchCurrentStepOutputSchema = ({
stepOutputSchema,
path,
isFullRecord,
selectedField,
}: {
stepOutputSchema: StepOutputSchema;
path: string[];
isFullRecord: boolean;
selectedField: string;
}) => {
let currentSubStep = stepOutputSchema.outputSchema;
let nextKeyIndex = 0;
let nextKey = path[nextKeyIndex];
let variablePathLabel = stepOutputSchema.name;
while (nextKeyIndex < path.length) {
if (isRecordOutputSchema(currentSubStep)) {
const currentField = currentSubStep.fields[nextKey];
currentSubStep = currentField?.value;
nextKey = path[nextKeyIndex + 1];
variablePathLabel = `${variablePathLabel} > ${currentField?.label}`;
} else if (isBaseOutputSchema(currentSubStep)) {
if (isDefined(currentSubStep[nextKey])) {
const currentField = currentSubStep[nextKey];
currentSubStep = currentField?.value;
nextKey = path[nextKeyIndex + 1];
variablePathLabel = `${variablePathLabel} > ${currentField?.label}`;
} else {
// If the key is not found in the step, we handle the case where the path has been wrongly split
// For example, if there is a dot in the field name
if (nextKeyIndex + 1 < path.length) {
nextKey = `${nextKey}.${path[nextKeyIndex + 1]}`;
}
}
}
nextKeyIndex++;
}
if (!isDefined(currentSubStep)) {
return {
variableLabel: undefined,
variablePathLabel: undefined,
};
}
return {
variableLabel: isFullRecord
? getDisplayedSubStepObjectLabel(currentSubStep)
: getDisplayedSubStepFieldLabel(selectedField, currentSubStep),
variablePathLabel,
};
};
export const searchVariableThroughOutputSchema = ({
stepOutputSchema,
rawVariableName,
isFullRecord = false,
}: {
stepOutputSchema: StepOutputSchema;
rawVariableName: string;
isFullRecord?: boolean;
}) => {
const variableWithoutBrackets = rawVariableName.replace(
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
(_, variableName) => {
return variableName;
},
);
const parts = variableWithoutBrackets.split('.');
const stepId = parts.at(0);
const selectedField = parts.at(-1);
// path is the remaining parts of the variable name
const path = parts.slice(1, -1);
if (!isDefined(stepId) || !isDefined(selectedField)) {
return {
variableLabel: undefined,
variablePathLabel: undefined,
};
}
const { variableLabel, variablePathLabel } = searchCurrentStepOutputSchema({
stepOutputSchema,
path,
isFullRecord,
selectedField,
});
return {
variableLabel,
variablePathLabel: `${variablePathLabel} > ${variableLabel}`,
};
};

View File

@ -1,5 +1,5 @@
import { WorkflowTextEditorVariableChip } from '@/workflow/workflow-variables/components/WorkflowTextEditorVariableChip'; import { WorkflowTextEditorVariableChip } from '@/workflow/workflow-variables/components/WorkflowTextEditorVariableChip';
import { extractVariableLabel } from '@/workflow/workflow-variables/utils/extractVariableLabel'; import { extractRawVariableNamePart } from '@/workflow/workflow-variables/utils/extractRawVariableNamePart';
import { Node } from '@tiptap/core'; import { Node } from '@tiptap/core';
import { mergeAttributes, ReactNodeViewRenderer } from '@tiptap/react'; import { mergeAttributes, ReactNodeViewRenderer } from '@tiptap/react';
@ -38,7 +38,10 @@ export const VariableTag = Node.create({
'data-type': 'variableTag', 'data-type': 'variableTag',
class: 'variable-tag', class: 'variable-tag',
}), }),
extractVariableLabel(variable), extractRawVariableNamePart({
rawVariableName: variable,
part: 'selectedField',
}),
]; ];
}, },

View File

@ -63,7 +63,7 @@ export const DateTimeSettingsTimezone: Story = {
await canvas.findByText('Date and time'); await canvas.findByText('Date and time');
const timezoneSelect = await canvas.findByText( const timezoneSelect = await canvas.findByText(
'(GMT-05:00) Eastern Standard Time - New York', '(GMT-04:00) Eastern Daylight Time - New York',
); );
userEvent.click(timezoneSelect); userEvent.click(timezoneSelect);

View File

@ -1,7 +1,10 @@
import { usePopulateStepsOutputSchema } from '@/workflow/hooks/usePopulateStepsOutputSchema';
import { WorkflowVersionComponentInstanceContext } from '@/workflow/states/context/WorkflowVersionComponentInstanceContext';
import { workflowIdState } from '@/workflow/states/workflowIdState'; import { workflowIdState } from '@/workflow/states/workflowIdState';
import { WorkflowVersion } from '@/workflow/types/Workflow';
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState'; import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
import { Decorator } from '@storybook/react'; import { Decorator } from '@storybook/react';
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useSetRecoilState } from 'recoil'; import { useSetRecoilState } from 'recoil';
import { import {
getWorkflowMock, getWorkflowMock,
@ -11,11 +14,30 @@ import {
export const WorkflowStepDecorator: Decorator = (Story) => { export const WorkflowStepDecorator: Decorator = (Story) => {
const setWorkflowId = useSetRecoilState(workflowIdState); const setWorkflowId = useSetRecoilState(workflowIdState);
const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState); const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState);
const workflowVersion = getWorkflowMock().versions.edges[0]
.node as WorkflowVersion;
const { populateStepsOutputSchema } = usePopulateStepsOutputSchema({
workflowVersionId: workflowVersion.id,
});
const [ready, setReady] = useState(false);
useEffect(() => { useEffect(() => {
setWorkflowId(getWorkflowMock().id); setWorkflowId(getWorkflowMock().id);
setWorkflowSelectedNode(getWorkflowNodeIdMock()); setWorkflowSelectedNode(getWorkflowNodeIdMock());
}, [setWorkflowId, setWorkflowSelectedNode]); populateStepsOutputSchema(workflowVersion);
setReady(true);
}, [
setWorkflowId,
setWorkflowSelectedNode,
populateStepsOutputSchema,
workflowVersion,
]);
return <Story />; return (
<WorkflowVersionComponentInstanceContext.Provider
value={{ instanceId: workflowVersion.id }}
>
{ready && <Story />}
</WorkflowVersionComponentInstanceContext.Provider>
);
}; };

View File

@ -25,6 +25,8 @@ export const getWorkflowNodeIdMock = () => {
return getWorkflowMock().versions.edges[0].node.steps[0].id; return getWorkflowMock().versions.edges[0].node.steps[0].id;
}; };
export const MOCKED_STEP_ID = '04d5f3bf-9714-400d-ba27-644006a5fb1b';
export const workflowQueryResult = { export const workflowQueryResult = {
workflows: { workflows: {
__typename: 'WorkflowConnection', __typename: 'WorkflowConnection',
@ -76,7 +78,7 @@ export const workflowQueryResult = {
status: 'DRAFT', status: 'DRAFT',
steps: [ steps: [
{ {
id: '04d5f3bf-9714-400d-ba27-644006a5fb1b', id: MOCKED_STEP_ID,
name: 'Create Record', name: 'Create Record',
type: 'CREATE_RECORD', type: 'CREATE_RECORD',
valid: false, valid: false,
@ -107,6 +109,28 @@ export const workflowQueryResult = {
value: 'My text', value: 'My text',
isLeaf: true, isLeaf: true,
}, },
fullName: {
icon: 'IconTargetArrow',
type: 'TEXT',
label: 'Full Name',
isLeaf: false,
value: {
firstName: {
icon: 'IconTargetArrow',
type: 'TEXT',
label: 'Full Name First Name',
value: 'John',
isLeaf: true,
},
lastName: {
icon: 'IconTargetArrow',
type: 'TEXT',
label: 'Full Name Last Name',
value: 'Doe',
isLeaf: true,
},
},
},
stage: { stage: {
icon: 'IconProgressCheck', icon: 'IconProgressCheck',
type: 'SELECT', type: 'SELECT',
@ -120,13 +144,13 @@ export const workflowQueryResult = {
value: { value: {
amountMicros: { amountMicros: {
type: 'NUMERIC', type: 'NUMERIC',
label: ' Amount Micros', label: 'My Amount Micros',
value: null, value: null,
isLeaf: true, isLeaf: true,
}, },
currencyCode: { currencyCode: {
type: 'TEXT', type: 'TEXT',
label: ' Currency Code', label: 'My Currency Code',
value: 'My text', value: 'My text',
isLeaf: true, isLeaf: true,
}, },
@ -186,6 +210,13 @@ export const workflowQueryResult = {
value: '01/23/2025 15:16', value: '01/23/2025 15:16',
isLeaf: true, isLeaf: true,
}, },
salary: {
icon: 'IconMoneybag',
type: 'NUMBER',
label: 'Salary',
value: 1000000000,
isLeaf: true,
},
}, },
object: { object: {
icon: 'IconTargetArrow', icon: 'IconTargetArrow',