diff --git a/packages/twenty-apps/fixtures/rich-app/src/logic-functions/enrich-post-cards.function.ts b/packages/twenty-apps/fixtures/rich-app/src/logic-functions/enrich-post-cards.function.ts new file mode 100644 index 0000000000..3e51d8155c --- /dev/null +++ b/packages/twenty-apps/fixtures/rich-app/src/logic-functions/enrich-post-cards.function.ts @@ -0,0 +1,23 @@ +import { defineLogicFunction, type TwentyRecord } from 'twenty-sdk/define'; + +const handler = async (params: { + companyId: TwentyRecord<'20202020-b374-4779-a561-80086cb2e17f'>; + postCardIds: TwentyRecord<'54b589ca-eeed-4950-a176-358418b85c05'>[]; +}) => { + return { + companyId: params.companyId, + postCardCount: params.postCardIds.length, + }; +}; + +export default defineLogicFunction({ + universalIdentifier: 'a1b2c3d4-ac10-4a7b-8c9d-0e1f2a3b4c5d', + name: 'enrich-post-cards', + description: 'Enrich post cards of a company', + timeoutSeconds: 5, + handler, + workflowActionTriggerSettings: { + label: 'Enrich Post Cards', + icon: 'IconMail', + }, +}); diff --git a/packages/twenty-front/src/modules/logic-functions/hooks/useLogicFunctionForm.ts b/packages/twenty-front/src/modules/logic-functions/hooks/useLogicFunctionForm.ts index dccc899b76..594836901b 100644 --- a/packages/twenty-front/src/modules/logic-functions/hooks/useLogicFunctionForm.ts +++ b/packages/twenty-front/src/modules/logic-functions/hooks/useLogicFunctionForm.ts @@ -41,8 +41,6 @@ export const useLogicFunctionForm = ({ setFormValues((prevState: LogicFunctionFormValues) => ({ ...prevState, sourceHandlerCode: value as string, - // Re-infer schemas for any active surface so they stay in sync - // with the source code. toolTriggerSettings: prevState.toolTriggerSettings ? { ...prevState.toolTriggerSettings, diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormMorphRelationToOneFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormMorphRelationToOneFieldInput.tsx index 37d2dd5979..96042b9b79 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormMorphRelationToOneFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormMorphRelationToOneFieldInput.tsx @@ -5,11 +5,11 @@ import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-ty import { FormFieldInputInnerContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer'; import { FormFieldInputRowContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputRowContainer'; import { FormFieldPlaceholder } from '@/object-record/record-field/ui/form-types/components/FormFieldPlaceholder'; +import { FormSingleRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormSingleRecordPicker'; import { - FormSingleRecordPicker, type RecordId, type Variable, -} from '@/object-record/record-field/ui/form-types/components/FormSingleRecordPicker'; +} from '@/object-record/record-field/ui/form-types/types/RecordPickerValue'; import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent'; import { ForbiddenFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/ForbiddenFieldDisplay'; import { InputLabel } from '@/ui/input/components/InputLabel'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormMultiRecordFieldChips.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormMultiRecordFieldChips.tsx new file mode 100644 index 0000000000..8da9f570d6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormMultiRecordFieldChips.tsx @@ -0,0 +1,87 @@ +import { RecordChip } from '@/object-record/components/RecordChip'; +import { FormFieldPlaceholder } from '@/object-record/record-field/ui/form-types/components/FormFieldPlaceholder'; +import { VariableChipStandalone } from '@/object-record/record-field/ui/form-types/components/VariableChipStandalone'; +import { type FormMultiRecordPickerDraftValue } from '@/object-record/record-field/ui/form-types/utils/getFormMultiRecordPickerDraftValue'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList'; +import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString'; +import { styled } from '@linaria/react'; +import { t } from '@lingui/core/macro'; +import { isNonEmptyArray } from '@sniptt/guards'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +const StyledChipsContainer = styled.div` + align-items: center; + display: flex; + flex: 1; + gap: ${themeCssVariables.spacing[1]}; + margin-inline: ${themeCssVariables.spacing[2]}; + min-width: 0; + overflow: hidden; +`; + +type FormMultiRecordFieldChipsProps = { + draftValue: FormMultiRecordPickerDraftValue; + selectedRecords: ObjectRecord[]; + objectNameSingular: string; + readonly?: boolean; + onUnlinkVariable: () => void; + onRemoveStaticVariable: (variable: string) => void; +}; + +export const FormMultiRecordFieldChips = ({ + draftValue, + selectedRecords, + objectNameSingular, + readonly, + onUnlinkVariable, + onRemoveStaticVariable, +}: FormMultiRecordFieldChipsProps) => { + if (draftValue.type === 'variable') { + return ( + + + + ); + } + + const staticVariables = draftValue.value.filter((entry) => + isStandaloneVariableString(entry), + ); + + if (!isNonEmptyArray(selectedRecords) && !isNonEmptyArray(staticVariables)) { + return ( + + {t`Select`} + + ); + } + + const chips = [ + ...selectedRecords.map((record) => ( + + )), + ...staticVariables.map((variable) => ( + onRemoveStaticVariable(variable)} + isFullRecord + /> + )), + ]; + + return ( + + {chips} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormMultiRecordPicker.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormMultiRecordPicker.tsx new file mode 100644 index 0000000000..ae5d2843b7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormMultiRecordPicker.tsx @@ -0,0 +1,258 @@ +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer'; +import { FormFieldInputInnerContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer'; +import { FormFieldInputRowContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputRowContainer'; +import { FormMultiRecordFieldChips } from '@/object-record/record-field/ui/form-types/components/FormMultiRecordFieldChips'; +import { useOpenFormMultiRecordPicker } from '@/object-record/record-field/ui/form-types/hooks/useOpenFormMultiRecordPicker'; +import { + type RecordId, + type Variable, +} from '@/object-record/record-field/ui/form-types/types/RecordPickerValue'; +import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent'; +import { + type FormMultiRecordPickerDraftValue, + getFormMultiRecordPickerDraftValue, +} from '@/object-record/record-field/ui/form-types/utils/getFormMultiRecordPickerDraftValue'; +import { MultipleRecordPicker } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPicker'; +import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { InputLabel } from '@/ui/input/components/InputLabel'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; +import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; +import { styled } from '@linaria/react'; +import { t } from '@lingui/core/macro'; +import { useContext, useId, useState } from 'react'; +import { isNonEmptyArray } from '@sniptt/guards'; +import { QUERY_MAX_RECORDS } from 'twenty-shared/constants'; +import { isDefined, isValidUuid } from 'twenty-shared/utils'; +import { mapArrayToObject } from '~/utils/array/mapArrayToObject'; +import { IconChevronDown } from 'twenty-ui/icon'; +import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; + +const StyledFormSelectContainerWrapper = styled.div<{ readonly?: boolean }>` + cursor: ${({ readonly }) => (readonly ? 'default' : 'pointer')}; + display: flex; + height: 32px; + min-width: 0; + width: 100%; +`; + +const StyledIconButton = styled.div` + display: flex; + padding-right: ${themeCssVariables.spacing[2]}; +`; + +const StyledDropdownContainer = styled.div` + display: flex; + flex: 1; + min-width: 0; +`; + +const StyledVariablePickerContainer = styled.div` + display: flex; + flex-shrink: 0; +`; + +export type FormMultiRecordPickerProps = { + label?: string; + defaultValue?: Array | Variable | string | null; + onChange: (value: Array | Variable) => void; + objectNameSingular: string; + readonly?: boolean; + testId?: string; + VariablePicker?: VariablePickerComponent; +}; + +export const FormMultiRecordPicker = ({ + label, + defaultValue, + onChange, + objectNameSingular, + readonly, + testId, + VariablePicker, +}: FormMultiRecordPickerProps) => { + const { theme } = useContext(ThemeContext); + + const [draftValue, setDraftValue] = useState( + getFormMultiRecordPickerDraftValue(defaultValue), + ); + + const componentId = useId(); + const dropdownId = `form-multi-record-picker-${componentId}`; + const variablesDropdownId = `form-multi-record-picker-${componentId}-variables`; + + const { closeDropdown } = useCloseDropdown(); + const { enqueueWarningSnackBar } = useSnackBar(); + const { openFormMultiRecordPicker } = useOpenFormMultiRecordPicker({ + objectNameSingular, + }); + + const staticRecordIds = + draftValue.type === 'static' + ? draftValue.value.filter((entry) => isValidUuid(entry)) + : []; + + const { records: selectedRecords } = useFindManyRecords({ + objectNameSingular, + filter: { id: { in: staticRecordIds } }, + limit: Math.min(staticRecordIds.length, QUERY_MAX_RECORDS), + skip: !isNonEmptyArray(staticRecordIds), + withSoftDeleted: true, + }); + + const selectedRecordsById = mapArrayToObject( + selectedRecords, + (record) => record.id, + ); + + const orderedSelectedRecords = staticRecordIds + .map((recordId) => selectedRecordsById[recordId]) + .filter(isDefined); + + const handleOpenDropdown = () => { + if (draftValue.type !== 'static') { + return; + } + + openFormMultiRecordPicker({ + pickerInstanceId: dropdownId, + selectedRecordIds: staticRecordIds, + selectedRecords: orderedSelectedRecords, + }); + }; + + const handleMorphItemChange = (morphItem: RecordPickerPickableMorphItem) => { + if (draftValue.type !== 'static') { + return; + } + + const valueWithoutRecord = draftValue.value.filter( + (entry) => entry !== morphItem.recordId, + ); + + if (morphItem.isSelected) { + const selectedRecordCount = valueWithoutRecord.filter((entry) => + isValidUuid(entry), + ).length; + + if (selectedRecordCount >= QUERY_MAX_RECORDS) { + enqueueWarningSnackBar({ + message: t`You can select at most ${QUERY_MAX_RECORDS} records.`, + }); + + return; + } + } + + const updatedValue = morphItem.isSelected + ? [...valueWithoutRecord, morphItem.recordId] + : valueWithoutRecord; + + setDraftValue({ type: 'static', value: updatedValue }); + onChange(updatedValue); + }; + + const handleVariableTagInsert = (variableName: string) => { + setDraftValue({ type: 'variable', value: variableName }); + onChange(variableName); + }; + + const handleUnlinkVariable = () => { + setDraftValue({ type: 'static', value: [] }); + onChange([]); + }; + + const handleRemoveStaticVariable = (variable: string) => { + if (draftValue.type !== 'static') { + return; + } + + const updatedValue = draftValue.value.filter((entry) => entry !== variable); + + setDraftValue({ type: 'static', value: updatedValue }); + onChange(updatedValue); + }; + + const chips = ( + + ); + + return ( + + {label ? {label} : null} + + {readonly || draftValue.type === 'variable' ? ( + + + {chips} + + + ) : ( + + + + {chips} + + + + + + } + dropdownComponents={ + closeDropdown(dropdownId)} + onClickOutside={() => closeDropdown(dropdownId)} + dropdownWidth={GenericDropdownContentWidth.ExtraLarge} + /> + } + /> + + )} + {isDefined(VariablePicker) && !readonly && ( + + + + )} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormSingleRecordFieldChip.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormSingleRecordFieldChip.tsx index c2e1c0f3d1..92b74e28f0 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormSingleRecordFieldChip.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormSingleRecordFieldChip.tsx @@ -3,7 +3,7 @@ import { FormFieldPlaceholder } from '@/object-record/record-field/ui/form-types import { type RecordId, type Variable, -} from '@/object-record/record-field/ui/form-types/components/FormSingleRecordPicker'; +} from '@/object-record/record-field/ui/form-types/types/RecordPickerValue'; import { VariableChipStandalone } from '@/object-record/record-field/ui/form-types/components/VariableChipStandalone'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormSingleRecordPicker.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormSingleRecordPicker.tsx index af2c082b32..82ae237928 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormSingleRecordPicker.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormSingleRecordPicker.tsx @@ -3,6 +3,10 @@ import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-ty import { FormFieldInputInnerContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer'; import { FormFieldInputRowContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputRowContainer'; import { FormSingleRecordFieldChip } from '@/object-record/record-field/ui/form-types/components/FormSingleRecordFieldChip'; +import { + type RecordId, + type Variable, +} from '@/object-record/record-field/ui/form-types/types/RecordPickerValue'; import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent'; import { SingleRecordPicker } from '@/object-record/record-picker/single-record-picker/components/SingleRecordPicker'; import { singleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSearchFilterComponentState'; @@ -25,6 +29,7 @@ const StyledFormSelectContainerWrapper = styled.div<{ readonly?: boolean }>` cursor: ${({ readonly }) => (readonly ? 'default' : 'pointer')}; display: flex; height: 32px; + min-width: 0; width: 100%; `; @@ -33,8 +38,16 @@ const StyledIconButton = styled.div` padding-right: ${themeCssVariables.spacing[2]}; `; -export type RecordId = string; -export type Variable = string; +const StyledDropdownContainer = styled.div` + display: flex; + flex: 1; + min-width: 0; +`; + +const StyledVariablePickerContainer = styled.div` + display: flex; + flex-shrink: 0; +`; type FormSingleRecordPickerValue = | { @@ -64,6 +77,7 @@ export type FormSingleRecordPickerProps = { disabled?: boolean; testId?: string; VariablePicker?: VariablePickerComponent; + shouldDisplayRecordFieldsInVariablePicker?: boolean; }; export const FormSingleRecordPicker = ({ @@ -78,6 +92,7 @@ export const FormSingleRecordPicker = ({ disabled, testId, VariablePicker, + shouldDisplayRecordFieldsInVariablePicker = false, }: FormSingleRecordPickerProps) => { const { theme } = useContext(ThemeContext); @@ -200,64 +215,72 @@ export const FormSingleRecordPicker = ({ ) : ( - - - - - + + + - - - - } - dropdownComponents={ - closeDropdown(dropdownId)} - onCreate={isDefined(onCreate) ? handleCreateRecord : undefined} - onMorphItemSelected={handleMorphItemSelected} - objectNameSingulars={objectNameSingulars} - recordPickerInstanceId={dropdownId} - dropdownWidth={GenericDropdownContentWidth.ExtraLarge} - /> - } - /> + + + + + + } + dropdownComponents={ + closeDropdown(dropdownId)} + onCreate={ + isDefined(onCreate) ? handleCreateRecord : undefined + } + onMorphItemSelected={handleMorphItemSelected} + objectNameSingulars={objectNameSingulars} + recordPickerInstanceId={dropdownId} + dropdownWidth={GenericDropdownContentWidth.ExtraLarge} + /> + } + /> + )} {isDefined(VariablePicker) && !disabled && ( - + + + )} diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormMultiRecordPicker.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormMultiRecordPicker.stories.tsx new file mode 100644 index 0000000000..33bbd2ee26 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormMultiRecordPicker.stories.tsx @@ -0,0 +1,136 @@ +import { FormMultiRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormMultiRecordPicker'; +import { styled } from '@linaria/react'; +import { type Meta, type StoryObj } from '@storybook/react-vite'; +import { expect, fn, userEvent, within } from 'storybook/test'; +import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing'; +import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; +import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; +import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator'; +import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator'; +import { graphqlMocks } from '~/testing/graphqlMocks'; +import { MOCKED_STEP_ID } from '~/testing/mock-data/workflow'; + +const meta: Meta = { + title: 'UI/Data/Field/Form/Input/FormMultiRecordPicker', + component: FormMultiRecordPicker, + parameters: { + msw: graphqlMocks, + }, + args: {}, + argTypes: {}, + decorators: [ + ObjectMetadataItemsDecorator, + ComponentDecorator, + WorkspaceDecorator, + SnackBarDecorator, + RouterDecorator, + ], +}; + +export default meta; + +type Story = StoryObj; + +const StyledNarrowContainer = styled.div` + width: 480px; +`; + +export const Default: Story = { + args: { + label: 'Companies', + defaultValue: ['123e4567-e89b-12d3-a456-426614174000'], + objectNameSingular: 'company', + onChange: fn(), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const label = await canvas.findByText('Companies'); + expect(label).toBeVisible(); + + const dropdown = await canvas.findByRole('button'); + expect(dropdown).toBeVisible(); + + await userEvent.click(dropdown); + }, +}; + +export const WithManyRecords: Story = { + args: { + label: 'Companies', + defaultValue: [ + '20202020-a000-4485-94de-70c2a98daef2', + '20202020-a018-492d-89de-f9cd4ee80437', + '20202020-a023-4180-9da1-6b417beacf0e', + '20202020-a026-43c0-b042-0123f72f6cf9', + '20202020-a026-47d2-9474-75fb625f5eb1', + '20202020-a02e-4e28-b4a9-6096b36e26df', + '20202020-a043-441a-b269-a2378afed31c', + '20202020-a045-4266-b9e4-0e7a0697322b', + '20202020-a045-4b32-8484-a6807e9e0d22', + '20202020-a048-4007-9024-3ac47b8484d5', + ], + objectNameSingular: 'company', + onChange: fn(), + VariablePicker: () =>
VariablePicker
, + }, + decorators: [ + (Story) => ( + + + + ), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText('Companies'); + + const hiddenChipCount = await canvas.findByText(/^\+\d+$/); + expect(hiddenChipCount).toBeVisible(); + + const variablePicker = await canvas.findByText('VariablePicker'); + expect(variablePicker).toBeVisible(); + }, +}; + +export const WithVariable: Story = { + args: { + label: 'Companies', + defaultValue: `{{${MOCKED_STEP_ID}.companies}}`, + objectNameSingular: 'company', + onChange: fn(), + VariablePicker: () =>
VariablePicker
, + }, + decorators: [WorkflowStepDecorator], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText('Companies'); + const variablePicker = await canvas.findByText('VariablePicker'); + expect(variablePicker).toBeVisible(); + }, +}; + +export const Readonly: Story = { + args: { + label: 'Companies', + defaultValue: ['123e4567-e89b-12d3-a456-426614174000'], + objectNameSingular: 'company', + onChange: fn(), + readonly: true, + VariablePicker: () =>
VariablePicker
, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + await canvas.findByText('Companies'); + const dropdown = canvas.queryByRole('button'); + expect(dropdown).not.toBeInTheDocument(); + + const variablePicker = canvas.queryByText('VariablePicker'); + expect(variablePicker).not.toBeInTheDocument(); + + expect(args.onChange).not.toHaveBeenCalled(); + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/hooks/useOpenFormMultiRecordPicker.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/hooks/useOpenFormMultiRecordPicker.ts new file mode 100644 index 0000000000..233601f1fa --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/hooks/useOpenFormMultiRecordPicker.ts @@ -0,0 +1,67 @@ +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useMultipleRecordPickerOpen } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerOpen'; +import { useMultipleRecordPickerPerformSearch } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch'; +import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState'; +import { multipleRecordPickerSearchableObjectMetadataItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchableObjectMetadataItemsComponentState'; +import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { useStore } from 'jotai'; + +export const useOpenFormMultiRecordPicker = ({ + objectNameSingular, +}: { + objectNameSingular: string; +}) => { + const store = useStore(); + const { openMultipleRecordPicker } = useMultipleRecordPickerOpen(); + const { performSearch } = useMultipleRecordPickerPerformSearch(); + const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular }); + + const openFormMultiRecordPicker = ({ + pickerInstanceId, + selectedRecordIds, + selectedRecords, + }: { + pickerInstanceId: string; + selectedRecordIds: string[]; + selectedRecords: ObjectRecord[]; + }) => { + openMultipleRecordPicker(pickerInstanceId); + + const pickableMorphItems: RecordPickerPickableMorphItem[] = + selectedRecordIds.map((recordId) => ({ + objectMetadataId: objectMetadataItem.id, + recordId, + isSelected: true, + isMatchingSearchFilter: true, + })); + + for (const record of selectedRecords) { + store.set(recordStoreFamilyState.atomFamily(record.id), record); + } + + store.set( + multipleRecordPickerPickableMorphItemsComponentState.atomFamily({ + instanceId: pickerInstanceId, + }), + pickableMorphItems, + ); + + store.set( + multipleRecordPickerSearchableObjectMetadataItemsComponentState.atomFamily( + { instanceId: pickerInstanceId }, + ), + [objectMetadataItem], + ); + + performSearch({ + multipleRecordPickerInstanceId: pickerInstanceId, + forceSearchFilter: '', + forceSearchableObjectMetadataItems: [objectMetadataItem], + forcePickableMorphItems: pickableMorphItems, + }); + }; + + return { openFormMultiRecordPicker }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/types/RecordPickerValue.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/types/RecordPickerValue.ts new file mode 100644 index 0000000000..da730069f4 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/types/RecordPickerValue.ts @@ -0,0 +1,2 @@ +export type RecordId = string; +export type Variable = string; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/utils/__tests__/getFormMultiRecordPickerDraftValue.test.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/utils/__tests__/getFormMultiRecordPickerDraftValue.test.ts new file mode 100644 index 0000000000..2a86a5f0ec --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/utils/__tests__/getFormMultiRecordPickerDraftValue.test.ts @@ -0,0 +1,80 @@ +import { getFormMultiRecordPickerDraftValue } from '@/object-record/record-field/ui/form-types/utils/getFormMultiRecordPickerDraftValue'; + +describe('getFormMultiRecordPickerDraftValue', () => { + it('should keep arrays of record ids and variables as static values', () => { + expect( + getFormMultiRecordPickerDraftValue([ + '20202020-aaaa-4bbb-8ccc-111111111111', + '{{trigger.record.id}}', + ]), + ).toEqual({ + type: 'static', + value: ['20202020-aaaa-4bbb-8ccc-111111111111', '{{trigger.record.id}}'], + }); + }); + + it('should map a standalone variable string to a variable value', () => { + expect(getFormMultiRecordPickerDraftValue('{{step1.companies}}')).toEqual({ + type: 'variable', + value: '{{step1.companies}}', + }); + }); + + it('should wrap a single record id string into an array', () => { + expect( + getFormMultiRecordPickerDraftValue( + '20202020-aaaa-4bbb-8ccc-111111111111', + ), + ).toEqual({ + type: 'static', + value: ['20202020-aaaa-4bbb-8ccc-111111111111'], + }); + }); + + it('should drop array entries that are neither record ids nor variables', () => { + expect( + getFormMultiRecordPickerDraftValue([ + '20202020-aaaa-4bbb-8ccc-111111111111', + 'not-a-uuid', + '{{step1.companies}}', + 123 as unknown as string, + ]), + ).toEqual({ + type: 'static', + value: ['20202020-aaaa-4bbb-8ccc-111111111111', '{{step1.companies}}'], + }); + }); + + it('should keep only record ids and variables from a JSON array string', () => { + expect( + getFormMultiRecordPickerDraftValue( + '["20202020-aaaa-4bbb-8ccc-111111111111", "{{step1.companies}}", "junk"]', + ), + ).toEqual({ + type: 'static', + value: ['20202020-aaaa-4bbb-8ccc-111111111111', '{{step1.companies}}'], + }); + }); + + it('should degrade a JSON array of plain strings to an empty selection', () => { + expect(getFormMultiRecordPickerDraftValue('["a", "b"]')).toEqual({ + type: 'static', + value: [], + }); + }); + + it('should degrade legacy free text, null and undefined to an empty selection', () => { + expect(getFormMultiRecordPickerDraftValue('some free text')).toEqual({ + type: 'static', + value: [], + }); + expect(getFormMultiRecordPickerDraftValue(null)).toEqual({ + type: 'static', + value: [], + }); + expect(getFormMultiRecordPickerDraftValue(undefined)).toEqual({ + type: 'static', + value: [], + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/utils/getFormMultiRecordPickerDraftValue.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/utils/getFormMultiRecordPickerDraftValue.ts new file mode 100644 index 0000000000..ce075445c7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/utils/getFormMultiRecordPickerDraftValue.ts @@ -0,0 +1,65 @@ +import { + type RecordId, + type Variable, +} from '@/object-record/record-field/ui/form-types/types/RecordPickerValue'; +import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString'; +import { isArray, isNonEmptyString, isString } from '@sniptt/guards'; +import { isValidUuid } from 'twenty-shared/utils'; + +export type FormMultiRecordPickerDraftValue = + | { + type: 'static'; + value: Array; + } + | { + type: 'variable'; + value: Variable; + }; + +const keepRecordIdsAndVariables = ( + entries: unknown[], +): Array => + entries.filter( + (entry): entry is string => + isString(entry) && + (isValidUuid(entry) || isStandaloneVariableString(entry)), + ); + +export const getFormMultiRecordPickerDraftValue = ( + defaultValue: + | Array + | Variable + | string + | null + | undefined, +): FormMultiRecordPickerDraftValue => { + if (isArray(defaultValue)) { + return { + type: 'static', + value: keepRecordIdsAndVariables(defaultValue), + }; + } + + if (isNonEmptyString(defaultValue)) { + if (isStandaloneVariableString(defaultValue)) { + return { type: 'variable', value: defaultValue }; + } + + if (isValidUuid(defaultValue)) { + return { type: 'static', value: [defaultValue] }; + } + + try { + const parsedValue = JSON.parse(defaultValue); + + if (isArray(parsedValue)) { + return { + type: 'static', + value: keepRecordIdsAndVariables(parsedValue), + }; + } + } catch {} + } + + return { type: 'static', value: [] }; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFieldLeaf.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFieldLeaf.tsx new file mode 100644 index 0000000000..7ba327756a --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFieldLeaf.tsx @@ -0,0 +1,156 @@ +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { FormBooleanFieldInput } from '@/object-record/record-field/ui/form-types/components/FormBooleanFieldInput'; +import { FormMultiRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormMultiRecordPicker'; +import { FormNumberFieldInput } from '@/object-record/record-field/ui/form-types/components/FormNumberFieldInput'; +import { FormSelectFieldInput } from '@/object-record/record-field/ui/form-types/components/FormSelectFieldInput'; +import { FormSingleRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormSingleRecordPicker'; +import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput'; +import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent'; +import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString'; +import { getWorkflowCodeFieldsEnumSelectOptions } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/getWorkflowCodeFieldsEnumSelectOptions'; +import { getWorkflowCodeFieldsLeafKind } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/getWorkflowCodeFieldsLeafKind'; +import { t } from '@lingui/core/macro'; +import { + isBoolean, + isNonEmptyArray, + isNonEmptyString, + isNull, + isNumber, + isString, +} from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; +import { type InputSchemaProperty } from 'twenty-shared/workflow'; + +type WorkflowEditActionCodeFieldLeafProps = { + label: string; + inputValue: unknown; + schemaProperty?: InputSchemaProperty; + readonly?: boolean; + onChange: (value: unknown) => void; + VariablePicker?: VariablePickerComponent; +}; + +export const WorkflowEditActionCodeFieldLeaf = ({ + label, + inputValue, + schemaProperty, + readonly, + onChange, + VariablePicker, +}: WorkflowEditActionCodeFieldLeafProps) => { + const { objectMetadataItems } = useObjectMetadataItems(); + + const leafKind = getWorkflowCodeFieldsLeafKind(schemaProperty); + + if (leafKind === 'record' || leafKind === 'record-array') { + const objectUniversalIdentifier = + schemaProperty?.objectUniversalIdentifier ?? + schemaProperty?.items?.objectUniversalIdentifier; + + const recordObjectMetadataItem = isNonEmptyString(objectUniversalIdentifier) + ? objectMetadataItems.find( + (objectMetadataItem) => + objectMetadataItem.universalIdentifier === + objectUniversalIdentifier, + ) + : undefined; + + if (isDefined(recordObjectMetadataItem)) { + if (leafKind === 'record') { + return ( + + ); + } + + return ( + + ); + } + } + + if (leafKind === 'boolean') { + return ( + + ); + } + + if (leafKind === 'number') { + return ( + + ); + } + + if (leafKind === 'enum' && isDefined(schemaProperty)) { + const enumOptions = getWorkflowCodeFieldsEnumSelectOptions(schemaProperty); + + if (isNonEmptyArray(enumOptions)) { + return ( + + ); + } + } + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFields.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFields.tsx index 323675355f..9cab4d97b2 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFields.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFields.tsx @@ -1,17 +1,12 @@ -import { FormBooleanFieldInput } from '@/object-record/record-field/ui/form-types/components/FormBooleanFieldInput'; import { FormNestedFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormNestedFieldInputContainer'; -import { FormNumberFieldInput } from '@/object-record/record-field/ui/form-types/components/FormNumberFieldInput'; -import { FormSelectFieldInput } from '@/object-record/record-field/ui/form-types/components/FormSelectFieldInput'; -import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput'; import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent'; import { InputLabel } from '@/ui/input/components/InputLabel'; +import { WorkflowEditActionCodeFieldLeaf } from '@/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFieldLeaf'; import { getInputSchemaPropertyAtPath } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/getInputSchemaPropertyAtPath'; -import { getWorkflowCodeFieldsEnumSelectOptions } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/getWorkflowCodeFieldsEnumSelectOptions'; import { getWorkflowCodeFieldsLeafKind } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/getWorkflowCodeFieldsLeafKind'; import { styled } from '@linaria/react'; -import { t } from '@lingui/core/macro'; -import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards'; -import { isDefined, isPlainObject } from 'twenty-shared/utils'; +import { isNonEmptyString } from '@sniptt/guards'; +import { isPlainObject } from 'twenty-shared/utils'; import { type FunctionInput, type InputSchema } from 'twenty-shared/workflow'; import { themeCssVariables } from 'twenty-ui/theme-constants'; @@ -59,7 +54,13 @@ export const WorkflowEditActionCodeFields = ({ ? schemaProperty.label : inputKey; - if (isPlainObject(inputValue)) { + const leafKind = getWorkflowCodeFieldsLeafKind(schemaProperty); + const isNestedObject = + isPlainObject(inputValue) && + leafKind !== 'record' && + leafKind !== 'record-array'; + + if (isNestedObject) { return (
{displayLabel} @@ -78,83 +79,15 @@ export const WorkflowEditActionCodeFields = ({ ); } - const leafKind = getWorkflowCodeFieldsLeafKind(schemaProperty); - - if (leafKind === 'boolean') { - return ( - onInputChange?.(value, currentPath)} - VariablePicker={VariablePicker} - /> - ); - } - - if (leafKind === 'number') { - return ( - onInputChange?.(value, currentPath)} - VariablePicker={VariablePicker} - /> - ); - } - - if (leafKind === 'enum' && isDefined(schemaProperty)) { - const enumOptions = - getWorkflowCodeFieldsEnumSelectOptions(schemaProperty); - - if (isNonEmptyArray(enumOptions)) { - return ( - onInputChange?.(value, currentPath)} - VariablePicker={VariablePicker} - options={enumOptions} - /> - ); - } - } - return ( - onInputChange?.(value, currentPath)} VariablePicker={VariablePicker} - multiline={schemaProperty?.multiline === true} /> ); })} diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/__tests__/getWorkflowCodeFieldsLeafKind.test.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/__tests__/getWorkflowCodeFieldsLeafKind.test.ts index 4c68d33d86..b4280e827a 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/__tests__/getWorkflowCodeFieldsLeafKind.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/__tests__/getWorkflowCodeFieldsLeafKind.test.ts @@ -33,4 +33,44 @@ describe('getWorkflowCodeFieldsLeafKind', () => { ); expect(getWorkflowCodeFieldsLeafKind(undefined)).toBe('text'); }); + + it('should map record/records types to record kinds', () => { + expect( + getWorkflowCodeFieldsLeafKind({ + type: 'record', + objectUniversalIdentifier: 'company-universal-identifier', + }), + ).toBe('record'); + expect( + getWorkflowCodeFieldsLeafKind({ + type: 'records', + objectUniversalIdentifier: 'person-universal-identifier', + }), + ).toBe('record-array'); + }); + + it('should map the legacy object/array+marker form to record kinds', () => { + expect( + getWorkflowCodeFieldsLeafKind({ + type: 'object', + objectUniversalIdentifier: 'company-universal-identifier', + }), + ).toBe('record'); + expect( + getWorkflowCodeFieldsLeafKind({ + type: 'array', + items: { + type: 'object', + objectUniversalIdentifier: 'person-universal-identifier', + }, + }), + ).toBe('record-array'); + expect(getWorkflowCodeFieldsLeafKind({ type: 'object' })).toBe('text'); + expect( + getWorkflowCodeFieldsLeafKind({ + type: 'array', + items: { type: 'object' }, + }), + ).toBe('text'); + }); }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/__tests__/mergeDefaultFunctionInputAndFunctionInput.test.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/__tests__/mergeDefaultFunctionInputAndFunctionInput.test.ts index 233315172d..d560555dbc 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/__tests__/mergeDefaultFunctionInputAndFunctionInput.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/__tests__/mergeDefaultFunctionInputAndFunctionInput.test.ts @@ -48,4 +48,31 @@ describe('mergeDefaultFunctionInputAndFunctionInput', () => { }), ).toEqual({ briefs: '["a", "b"]', b: null }); }); + + it('should preserve stored record values for record-typed inputs', () => { + const newInput = { company: null, people: [] }; + const oldInput = { + company: '20202020-aaaa-4bbb-8ccc-111111111111', + people: ['20202020-aaaa-4bbb-8ccc-222222222222', '{{trigger.record.id}}'], + }; + + expect( + mergeDefaultFunctionInputAndFunctionInput({ + newInput, + oldInput, + }), + ).toEqual(oldInput); + }); + + it('should reset stale empty objects to null for record-typed inputs', () => { + const newInput = { company: null }; + const oldInput = { company: {} }; + + expect( + mergeDefaultFunctionInputAndFunctionInput({ + newInput, + oldInput, + }), + ).toEqual({ company: null }); + }); }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/getWorkflowCodeFieldsLeafKind.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/getWorkflowCodeFieldsLeafKind.ts index 247455ee2d..873d8aad8b 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/getWorkflowCodeFieldsLeafKind.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/getWorkflowCodeFieldsLeafKind.ts @@ -1,9 +1,19 @@ import { isNonEmptyArray } from '@sniptt/guards'; +import { + isRecordArraySchema, + isRecordObjectSchema, +} from 'twenty-shared/logic-function'; import { FieldMetadataType } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { type InputSchemaProperty } from 'twenty-shared/workflow'; -type WorkflowCodeFieldsLeafKind = 'boolean' | 'enum' | 'number' | 'text'; +type WorkflowCodeFieldsLeafKind = + | 'boolean' + | 'enum' + | 'number' + | 'record' + | 'record-array' + | 'text'; export const getWorkflowCodeFieldsLeafKind = ( property: InputSchemaProperty | undefined, @@ -12,6 +22,14 @@ export const getWorkflowCodeFieldsLeafKind = ( return 'text'; } + if (isRecordObjectSchema(property)) { + return 'record'; + } + + if (isRecordArraySchema(property)) { + return 'record-array'; + } + if ( (property.type === 'string' || property.type === FieldMetadataType.TEXT) && isNonEmptyArray(property.enum) diff --git a/packages/twenty-sdk/README.md b/packages/twenty-sdk/README.md index 436d09ac62..a0651b8d29 100644 --- a/packages/twenty-sdk/README.md +++ b/packages/twenty-sdk/README.md @@ -32,6 +32,10 @@ Full documentation is available at **[docs.twenty.com/developers/extend/apps](ht - [Building Apps](https://docs.twenty.com/developers/extend/apps/building) — entity definitions, API clients, testing, CLI reference - [Publishing](https://docs.twenty.com/developers/extend/apps/publishing) — deploy, npm publish, marketplace +Guides in this repository: + +- [Logic function inputs](./docs/logic-function-inputs.md) — input schema inference, record-typed inputs, and the id contract + ## Manual installation If you are adding `twenty-sdk` to an existing project instead of using `create-twenty-app`: diff --git a/packages/twenty-sdk/docs/logic-function-inputs.md b/packages/twenty-sdk/docs/logic-function-inputs.md new file mode 100644 index 0000000000..cb3585ae57 --- /dev/null +++ b/packages/twenty-sdk/docs/logic-function-inputs.md @@ -0,0 +1,66 @@ +# Logic function inputs + +When a logic function opts into the workflow action or AI tool surface but does +not declare an explicit `inputSchema`, the SDK infers one from the handler's +parameter type during the manifest build. The workflow builder uses that schema +to render an input form, and record-typed inputs render as record pickers. + +## How inference works + +Inference reads the handler's single `params` object type and maps each property: + +- `string` / `number` / `boolean` map to the matching scalar input. +- String literal unions (`'a' | 'b'`) map to a select input. +- `T[]` / `Array` map to array inputs. +- `TwentyRecord<'objectUniversalIdentifier'>` maps to a record input (see below). + +Inference runs only when the trigger settings omit `inputSchema`. Providing an +explicit `inputSchema` disables inference for that surface entirely — this is the +escape hatch when a handler type cannot be expressed inline. + +## Record-typed inputs + +To bind an input to a workspace object, type it with `TwentyRecord`, passing the +object's universal identifier as a string literal: + +```ts +import { defineLogicFunction, type TwentyRecord } from 'twenty-sdk/define'; + +const handler = async (params: { + companyId: TwentyRecord<'20202020-b374-4779-a561-80086cb2e17f'>; + postCardIds: TwentyRecord<'54b589ca-eeed-4950-a176-358418b85c05'>[]; +}) => { + return { + companyId: params.companyId, + postCardCount: params.postCardIds.length, + }; +}; +``` + +The universal identifier is the source of truth and is read directly from the +literal — there is no name matching, so an unrelated type can never be mistaken +for a record. + +- **Standard objects**: get the identifier from `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS` + (exported from `twenty-sdk/define`), e.g. + `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier`. +- **App objects**: use the `universalIdentifier` you set on the object's + `defineObject(...)`. + +Only a string-literal argument resolves. `TwentyRecord` with no argument, or with +a non-literal argument, is treated as an unknown input. + +## What the handler receives + +`TwentyRecord` is a branded `string`: `companyId` is a record id, and +`postCardIds` is an array of record ids. This matches what the runtime delivers — +the workflow action passes the selected record ids (or the value a bound +`{{variable}}` resolves to) straight to the handler. Handlers must therefore +accept ids. The People Data Labs functions model this: + +```ts +export type RecordInput = string | { id?: string | null }; +``` + +and normalize the input with an `extractRecordIds` helper before use. If a +handler needs full records, it fetches them by id with the Core API client. diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts index 5c55554f7a..26332a2579 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts @@ -1659,6 +1659,37 @@ export const EXPECTED_MANIFEST: Manifest = { }, universalIdentifier: 'a1b2c3d4-1001-4a7b-8c9d-0e1f2a3b4c5d', }, + { + builtHandlerChecksum: '[checksum]', + builtHandlerPath: 'src/logic-functions/enrich-post-cards.function.mjs', + description: 'Enrich post cards of a company', + handlerName: 'default.config.handler', + name: 'enrich-post-cards', + sourceHandlerPath: 'src/logic-functions/enrich-post-cards.function.ts', + timeoutSeconds: 5, + workflowActionTriggerSettings: { + label: 'Enrich Post Cards', + icon: 'IconMail', + inputSchema: [ + { + type: 'object', + properties: { + companyId: { + type: 'record', + objectUniversalIdentifier: + '20202020-b374-4779-a561-80086cb2e17f', + }, + postCardIds: { + type: 'records', + objectUniversalIdentifier: + '54b589ca-eeed-4950-a176-358418b85c05', + }, + }, + }, + ], + }, + universalIdentifier: 'a1b2c3d4-ac10-4a7b-8c9d-0e1f2a3b4c5d', + }, { builtHandlerChecksum: '[checksum]', builtHandlerPath: 'src/logic-functions/on-post-card-created.function.mjs', diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/entities.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/entities.tests.ts index ae2d7be194..2f0e7f4823 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/entities.tests.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/entities.tests.ts @@ -27,6 +27,8 @@ export const defineEntitiesTests = (appPath: string): void => { 'src/components/test.front-component.mjs', 'src/components/test.front-component.mjs.map', 'src/logic-functions', + 'src/logic-functions/enrich-post-cards.function.mjs', + 'src/logic-functions/enrich-post-cards.function.mjs.map', 'src/logic-functions/greeting.function.mjs', 'src/logic-functions/greeting.function.mjs.map', 'src/logic-functions/lookup-recipient.function.mjs', diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/manifest.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/manifest.tests.ts index 3d4b4f6c25..e928b6ce5d 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/manifest.tests.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/manifest.tests.ts @@ -20,7 +20,7 @@ export const defineManifestTests = (appPath: string): void => { expect(manifest).not.toBeNull(); expect(manifest.objects).toHaveLength(4); - expect(manifest.logicFunctions).toHaveLength(6); + expect(manifest.logicFunctions).toHaveLength(7); expect(manifest.frontComponents).toHaveLength(4); expect(manifest.roles).toHaveLength(2); expect(manifest.fields).toHaveLength(23); diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-build-logic-function-input-schema.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-build-logic-function-input-schema.spec.ts new file mode 100644 index 0000000000..6fd419b0fa --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-build-logic-function-input-schema.spec.ts @@ -0,0 +1,41 @@ +import { RICH_APP_PATH } from '@/cli/__tests__/apps/fixture-paths'; +import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build'; +import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; + +const ENRICH_POST_CARDS_FUNCTION_UNIVERSAL_IDENTIFIER = + 'a1b2c3d4-ac10-4a7b-8c9d-0e1f2a3b4c5d'; +const POST_CARD_OBJECT_UNIVERSAL_IDENTIFIER = + '54b589ca-eeed-4950-a176-358418b85c05'; + +describe('buildManifest logic function input schema inference', () => { + it('resolves record-typed inputs to standard and app object universal identifiers', async () => { + const { manifest, errors } = await buildManifest(RICH_APP_PATH); + + expect(errors).toEqual([]); + expect(manifest).not.toBeNull(); + + const logicFunction = manifest?.logicFunctions.find( + (entry) => + entry.universalIdentifier === + ENRICH_POST_CARDS_FUNCTION_UNIVERSAL_IDENTIFIER, + ); + + expect(logicFunction).toBeDefined(); + expect(logicFunction?.workflowActionTriggerSettings?.inputSchema).toEqual([ + { + type: 'object', + properties: { + companyId: { + type: 'record', + objectUniversalIdentifier: + STANDARD_OBJECTS.company.universalIdentifier, + }, + postCardIds: { + type: 'records', + objectUniversalIdentifier: POST_CARD_OBJECT_UNIVERSAL_IDENTIFIER, + }, + }, + }, + ]); + }, 60000); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts index b1a7418e58..b73b157b3d 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts @@ -279,11 +279,6 @@ export const buildManifest = async ( const { handler: _, ...rest } = extract.config; - const relativeFilePath = relative(appPath, filePath); - - // Auto-infer inputSchema for any trigger that opts in but omits one. - // For the AI tool surface we use the JSON schema directly; for the - // workflow action surface we convert to Twenty's InputSchema. const inferredJsonSchema = (rest.toolTriggerSettings && !rest.toolTriggerSettings.inputSchema) || (rest.workflowActionTriggerSettings && @@ -319,8 +314,8 @@ export const buildManifest = async ( ? { workflowActionTriggerSettings } : {}), handlerName: 'default.config.handler', - sourceHandlerPath: relativeFilePath, - builtHandlerPath: relativeFilePath.replace(/\.tsx?$/, '.mjs'), + sourceHandlerPath: relativePath, + builtHandlerPath: relativePath.replace(/\.tsx?$/, '.mjs'), builtHandlerChecksum: '[default-checksum]', }; diff --git a/packages/twenty-sdk/src/sdk/define/index.ts b/packages/twenty-sdk/src/sdk/define/index.ts index d09db7a5f3..7f6b6d8cc9 100644 --- a/packages/twenty-sdk/src/sdk/define/index.ts +++ b/packages/twenty-sdk/src/sdk/define/index.ts @@ -114,6 +114,7 @@ export { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS as STANDARD_OBJECT, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, } from '@/sdk/define/objects/standard-object-ids'; +export type { TwentyRecord } from '@/sdk/define/objects/twenty-record.type'; export { definePageLayout } from '@/sdk/define/page-layouts/define-page-layout'; export { definePageLayoutTab } from '@/sdk/define/page-layouts/define-page-layout-tab'; diff --git a/packages/twenty-sdk/src/sdk/define/objects/twenty-record.type.ts b/packages/twenty-sdk/src/sdk/define/objects/twenty-record.type.ts new file mode 100644 index 0000000000..7a65fdd388 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/define/objects/twenty-record.type.ts @@ -0,0 +1,2 @@ +export type TwentyRecord = + string & { readonly __object?: TObjectUniversalIdentifier }; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts index b06fed4bd1..5ebbaeca4f 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts @@ -1,7 +1,10 @@ import { Injectable } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; -import { DEFAULT_TOOL_INPUT_SCHEMA } from 'twenty-shared/logic-function'; +import { + buildToolInputJsonSchema, + DEFAULT_TOOL_INPUT_SCHEMA, +} from 'twenty-shared/logic-function'; import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-provider/interfaces/generate-descriptor-options.type'; import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface'; @@ -46,14 +49,18 @@ export class LogicFunctionToolProvider implements ToolProvider { ): Promise<(ToolIndexEntry | ToolDescriptor)[]> { const includeSchemas = options?.includeSchemas ?? true; - const { flatLogicFunctionMaps } = + const { flatLogicFunctionMaps, flatObjectMetadataMaps } = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( { workspaceId: context.workspaceId, - flatMapsKeys: ['flatLogicFunctionMaps'], + flatMapsKeys: ['flatLogicFunctionMaps', 'flatObjectMetadataMaps'], }, ); + const resolveObjectLabel = (objectUniversalIdentifier: string) => + flatObjectMetadataMaps.byUniversalIdentifier[objectUniversalIdentifier] + ?.labelSingular; + const logicFunctionsWithSchema = Object.values( flatLogicFunctionMaps.byUniversalIdentifier, ).filter( @@ -83,9 +90,12 @@ export class LogicFunctionToolProvider implements ToolProvider { if (includeSchemas) { descriptors.push({ ...base, - inputSchema: - (logicFunction.toolTriggerSettings?.inputSchema as object) ?? - DEFAULT_TOOL_INPUT_SCHEMA, + inputSchema: isDefined(logicFunction.toolTriggerSettings?.inputSchema) + ? (buildToolInputJsonSchema( + logicFunction.toolTriggerSettings.inputSchema, + resolveObjectLabel, + ) as object) + : DEFAULT_TOOL_INPUT_SCHEMA, }); } else { descriptors.push(base); diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/types/input-schema.type.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/types/input-schema.type.ts index 70444aeb36..84e90dab77 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/types/input-schema.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/types/input-schema.type.ts @@ -5,6 +5,8 @@ export type InputSchemaPropertyType = | 'boolean' | 'object' | 'array' + | 'record' + | 'records' | 'unknown' | FieldMetadataType; @@ -13,6 +15,7 @@ export type InputSchemaProperty = { enum?: string[]; items?: InputSchemaProperty; // used to describe array type elements properties?: Properties; // used to describe object type elements + objectUniversalIdentifier?: string; }; type Properties = { diff --git a/packages/twenty-shared/src/logic-function/__tests__/build-tool-input-json-schema.test.ts b/packages/twenty-shared/src/logic-function/__tests__/build-tool-input-json-schema.test.ts new file mode 100644 index 0000000000..da645a5f6a --- /dev/null +++ b/packages/twenty-shared/src/logic-function/__tests__/build-tool-input-json-schema.test.ts @@ -0,0 +1,154 @@ +import { buildToolInputJsonSchema } from '@/logic-function/build-tool-input-json-schema'; + +describe('buildToolInputJsonSchema', () => { + it('should collapse a record-typed object to an id string with a resolved label', () => { + const result = buildToolInputJsonSchema( + { + type: 'object', + objectUniversalIdentifier: 'company-universal-identifier', + }, + (universalIdentifier) => + universalIdentifier === 'company-universal-identifier' + ? 'Company' + : undefined, + ); + + expect(result).toEqual({ + type: 'string', + description: 'Id of the Company record', + }); + }); + + it('should fall back to a generic label when none resolves', () => { + expect( + buildToolInputJsonSchema({ + type: 'object', + objectUniversalIdentifier: 'company-universal-identifier', + }), + ).toEqual({ + type: 'string', + description: 'Id of the linked record', + }); + }); + + it('should collapse arrays of records to arrays of id strings', () => { + const result = buildToolInputJsonSchema( + { + type: 'array', + items: { + type: 'object', + objectUniversalIdentifier: 'person-universal-identifier', + }, + }, + () => 'Person', + ); + + expect(result).toEqual({ + type: 'array', + items: { type: 'string', description: 'Id of the Person record' }, + }); + }); + + it('should collapse a record type to an id string', () => { + expect( + buildToolInputJsonSchema( + { + type: 'record', + objectUniversalIdentifier: 'company-universal-identifier', + }, + () => 'Company', + ), + ).toEqual({ type: 'string', description: 'Id of the Company record' }); + }); + + it('should collapse a records type to an array of id strings', () => { + expect( + buildToolInputJsonSchema( + { + type: 'records', + objectUniversalIdentifier: 'person-universal-identifier', + }, + () => 'Person', + ), + ).toEqual({ + type: 'array', + items: { type: 'string', description: 'Id of the Person record' }, + }); + }); + + it('should strip custom keywords recursively while keeping standard ones', () => { + const result = buildToolInputJsonSchema( + { + type: 'object', + label: 'Params', + properties: { + company: { + type: 'object', + objectUniversalIdentifier: 'company-universal-identifier', + description: 'A company', + }, + people: { + type: 'array', + label: 'People', + items: { + type: 'object', + objectUniversalIdentifier: 'person-universal-identifier', + }, + }, + note: { type: 'string', multiline: true }, + kind: { type: 'string', enum: ['a', 'b'] }, + }, + required: ['company'], + }, + () => 'Company', + ); + + expect(result).toEqual({ + type: 'object', + properties: { + company: { type: 'string', description: 'Id of the Company record' }, + people: { + type: 'array', + items: { type: 'string', description: 'Id of the Company record' }, + }, + note: { type: 'string' }, + kind: { type: 'string', enum: ['a', 'b'] }, + }, + required: ['company'], + }); + }); + + it('should keep boolean additionalProperties and sanitize object ones', () => { + expect( + buildToolInputJsonSchema({ + type: 'object', + additionalProperties: false, + }), + ).toEqual({ type: 'object', additionalProperties: false }); + + expect( + buildToolInputJsonSchema({ + type: 'object', + additionalProperties: { type: 'string', multiline: true }, + }), + ).toEqual({ type: 'object', additionalProperties: { type: 'string' } }); + }); + + it('should leave non-record nodes untouched aside from custom keywords', () => { + expect( + buildToolInputJsonSchema({ + type: 'object', + properties: { + plainObject: { type: 'object' }, + count: { type: 'number', minimum: 0, maximum: 10 }, + }, + }), + ).toEqual({ + type: 'object', + properties: { + plainObject: { type: 'object' }, + count: { type: 'number', minimum: 0, maximum: 10 }, + }, + }); + }); +}); diff --git a/packages/twenty-shared/src/logic-function/__tests__/get-function-input-schema.test.ts b/packages/twenty-shared/src/logic-function/__tests__/get-function-input-schema.test.ts index 8a62274886..c356c3b683 100644 --- a/packages/twenty-shared/src/logic-function/__tests__/get-function-input-schema.test.ts +++ b/packages/twenty-shared/src/logic-function/__tests__/get-function-input-schema.test.ts @@ -86,6 +86,79 @@ describe('getFunctionInputSchema', () => { ]); }); + it('should resolve TwentyRecord markers to record schemas', () => { + const fileContent = ` + export const main = (params: { + company: TwentyRecord<'company-universal-identifier'>; + companies: TwentyRecord<'company-universal-identifier'>[]; + otherCompanies: Array>; + }): void => { + return; + }; + `; + const result = getFunctionInputSchema(fileContent); + + expect(result).toEqual([ + { + type: 'object', + properties: { + company: { + type: 'record', + objectUniversalIdentifier: 'company-universal-identifier', + }, + companies: { + type: 'records', + objectUniversalIdentifier: 'company-universal-identifier', + }, + otherCompanies: { + type: 'records', + objectUniversalIdentifier: 'company-universal-identifier', + }, + }, + }, + ]); + }); + + it('should leave a bare TwentyRecord without a literal argument unresolved', () => { + const fileContent = ` + export const main = (params: { record: TwentyRecord }): void => { + return; + }; + `; + const result = getFunctionInputSchema(fileContent); + + expect(result).toEqual([ + { + type: 'object', + properties: { + record: {}, + }, + }, + ]); + }); + + it('should not resolve plain object type references to records', () => { + const fileContent = ` + export const main = (params: { + company: Company; + companies: Company[]; + }): void => { + return; + }; + `; + const result = getFunctionInputSchema(fileContent); + + expect(result).toEqual([ + { + type: 'object', + properties: { + company: {}, + companies: { type: 'array', items: {} }, + }, + }, + ]); + }); + it('should analyze a complex function correctly', () => { const fileContent = ` function testFunction( diff --git a/packages/twenty-shared/src/logic-function/__tests__/get-input-schema-from-source-code.test.ts b/packages/twenty-shared/src/logic-function/__tests__/get-input-schema-from-source-code.test.ts index 6d7b4a6582..7ce808159e 100644 --- a/packages/twenty-shared/src/logic-function/__tests__/get-input-schema-from-source-code.test.ts +++ b/packages/twenty-shared/src/logic-function/__tests__/get-input-schema-from-source-code.test.ts @@ -18,6 +18,29 @@ describe('getInputSchemaFromSourceCode', () => { const result = await getInputSchemaFromSourceCode(fileContent); expect(result).toEqual(DEFAULT_TOOL_INPUT_SCHEMA); }); + it('should fall back to empty when the params type cannot be inferred', async () => { + const fileContent = + 'function testFunction(params: ImportedAlias) { return }'; + const result = await getInputSchemaFromSourceCode(fileContent); + expect(result).toEqual(DEFAULT_TOOL_INPUT_SCHEMA); + }); + it('should infer record schemas from TwentyRecord markers', async () => { + const fileContent = ` + function testFunction(params: { + companies: TwentyRecord<'company-universal-identifier'>[]; + }) { return } + `; + const result = await getInputSchemaFromSourceCode(fileContent); + expect(result).toEqual({ + type: 'object', + properties: { + companies: { + type: 'records', + objectUniversalIdentifier: 'company-universal-identifier', + }, + }, + }); + }); it('should return input from source code', async () => { const fileContent = ` function testFunction( diff --git a/packages/twenty-shared/src/logic-function/__tests__/is-record-array-schema.test.ts b/packages/twenty-shared/src/logic-function/__tests__/is-record-array-schema.test.ts new file mode 100644 index 0000000000..e40babbcb7 --- /dev/null +++ b/packages/twenty-shared/src/logic-function/__tests__/is-record-array-schema.test.ts @@ -0,0 +1,39 @@ +import { isRecordArraySchema } from '@/logic-function/is-record-array-schema'; + +describe('isRecordArraySchema', () => { + it('returns true for a records schema', () => { + expect( + isRecordArraySchema({ + type: 'records', + objectUniversalIdentifier: 'person-universal-identifier', + }), + ).toBe(true); + }); + + it('returns false for a records schema without an objectUniversalIdentifier', () => { + expect(isRecordArraySchema({ type: 'records' })).toBe(false); + }); + + it('returns true for the legacy array of record objects', () => { + expect( + isRecordArraySchema({ + type: 'array', + items: { + type: 'object', + objectUniversalIdentifier: 'person-universal-identifier', + }, + }), + ).toBe(true); + }); + + it('returns false for an array of plain objects', () => { + expect( + isRecordArraySchema({ type: 'array', items: { type: 'object' } }), + ).toBe(false); + }); + + it('returns false for non-array schemas and nullish input', () => { + expect(isRecordArraySchema({ type: 'object' })).toBe(false); + expect(isRecordArraySchema(undefined)).toBe(false); + }); +}); diff --git a/packages/twenty-shared/src/logic-function/__tests__/is-record-object-schema.test.ts b/packages/twenty-shared/src/logic-function/__tests__/is-record-object-schema.test.ts new file mode 100644 index 0000000000..cc10c460ae --- /dev/null +++ b/packages/twenty-shared/src/logic-function/__tests__/is-record-object-schema.test.ts @@ -0,0 +1,43 @@ +import { isRecordObjectSchema } from '@/logic-function/is-record-object-schema'; + +describe('isRecordObjectSchema', () => { + it('returns true for a record schema', () => { + expect( + isRecordObjectSchema({ + type: 'record', + objectUniversalIdentifier: 'company-universal-identifier', + }), + ).toBe(true); + }); + + it('returns true for the legacy object+marker schema', () => { + expect( + isRecordObjectSchema({ + type: 'object', + objectUniversalIdentifier: 'company-universal-identifier', + }), + ).toBe(true); + }); + + it('returns false for a record schema without an objectUniversalIdentifier', () => { + expect(isRecordObjectSchema({ type: 'record' })).toBe(false); + }); + + it('returns false for a plain object schema', () => { + expect(isRecordObjectSchema({ type: 'object' })).toBe(false); + expect( + isRecordObjectSchema({ type: 'object', objectUniversalIdentifier: '' }), + ).toBe(false); + }); + + it('returns false for non-object schemas and nullish input', () => { + expect( + isRecordObjectSchema({ + type: 'string', + objectUniversalIdentifier: 'company-universal-identifier', + }), + ).toBe(false); + expect(isRecordObjectSchema(undefined)).toBe(false); + expect(isRecordObjectSchema(null)).toBe(false); + }); +}); diff --git a/packages/twenty-shared/src/logic-function/__tests__/json-schema-to-input-schema.test.ts b/packages/twenty-shared/src/logic-function/__tests__/json-schema-to-input-schema.test.ts index 5ab321f62c..6c520e121b 100644 --- a/packages/twenty-shared/src/logic-function/__tests__/json-schema-to-input-schema.test.ts +++ b/packages/twenty-shared/src/logic-function/__tests__/json-schema-to-input-schema.test.ts @@ -140,6 +140,62 @@ describe('jsonSchemaToInputSchema', () => { }); }); + it('preserves objectUniversalIdentifier on object properties and array items', () => { + const result = jsonSchemaToInputSchema({ + type: 'object', + properties: { + company: { + type: 'object', + objectUniversalIdentifier: 'company-universal-identifier', + }, + people: { + type: 'array', + items: { + type: 'object', + objectUniversalIdentifier: 'person-universal-identifier', + }, + }, + }, + }); + + expect(result[0].properties?.company).toEqual({ + type: 'object', + objectUniversalIdentifier: 'company-universal-identifier', + }); + expect(result[0].properties?.people).toEqual({ + type: 'array', + items: { + type: 'object', + objectUniversalIdentifier: 'person-universal-identifier', + }, + }); + }); + + it('passes record/records types through with objectUniversalIdentifier', () => { + const result = jsonSchemaToInputSchema({ + type: 'object', + properties: { + company: { + type: 'record', + objectUniversalIdentifier: 'company-universal-identifier', + }, + people: { + type: 'records', + objectUniversalIdentifier: 'person-universal-identifier', + }, + }, + }); + + expect(result[0].properties?.company).toEqual({ + type: 'record', + objectUniversalIdentifier: 'company-universal-identifier', + }); + expect(result[0].properties?.people).toEqual({ + type: 'records', + objectUniversalIdentifier: 'person-universal-identifier', + }); + }); + it('does not set label when empty or omitted', () => { const result = jsonSchemaToInputSchema({ type: 'object', diff --git a/packages/twenty-shared/src/logic-function/build-tool-input-json-schema.ts b/packages/twenty-shared/src/logic-function/build-tool-input-json-schema.ts new file mode 100644 index 0000000000..26a426445a --- /dev/null +++ b/packages/twenty-shared/src/logic-function/build-tool-input-json-schema.ts @@ -0,0 +1,81 @@ +import { isNonEmptyString, isObject } from '@sniptt/guards'; + +import { type InputJsonSchema } from '@/logic-function/input-json-schema.type'; +import { isRecordObjectSchema } from '@/logic-function/is-record-object-schema'; +import { isDefined } from '@/utils/validation/isDefined'; + +type ResolveObjectLabel = ( + objectUniversalIdentifier: string, +) => string | undefined; + +const buildRecordIdDescription = ( + objectUniversalIdentifier: string | undefined, + resolveObjectLabel?: ResolveObjectLabel, +): string => { + const objectLabel = isNonEmptyString(objectUniversalIdentifier) + ? resolveObjectLabel?.(objectUniversalIdentifier) + : undefined; + + return `Id of the ${isNonEmptyString(objectLabel) ? objectLabel : 'linked'} record`; +}; + +export const buildToolInputJsonSchema = ( + jsonSchema: InputJsonSchema, + resolveObjectLabel?: ResolveObjectLabel, +): InputJsonSchema => { + if (isRecordObjectSchema(jsonSchema)) { + return { + type: 'string', + description: buildRecordIdDescription( + jsonSchema.objectUniversalIdentifier, + resolveObjectLabel, + ), + }; + } + + if (jsonSchema.type === 'records') { + return { + type: 'array', + items: { + type: 'string', + description: buildRecordIdDescription( + jsonSchema.objectUniversalIdentifier, + resolveObjectLabel, + ), + }, + }; + } + + const { + objectUniversalIdentifier: _objectUniversalIdentifier, + multiline: _multiline, + label: _label, + items, + properties, + additionalProperties, + ...standardKeywords + } = jsonSchema; + + const toolSchema: InputJsonSchema = { ...standardKeywords }; + + if (isDefined(items)) { + toolSchema.items = buildToolInputJsonSchema(items, resolveObjectLabel); + } + + if (isDefined(properties)) { + toolSchema.properties = Object.fromEntries( + Object.entries(properties).map(([key, value]) => [ + key, + buildToolInputJsonSchema(value, resolveObjectLabel), + ]), + ); + } + + if (isDefined(additionalProperties)) { + toolSchema.additionalProperties = isObject(additionalProperties) + ? buildToolInputJsonSchema(additionalProperties, resolveObjectLabel) + : additionalProperties; + } + + return toolSchema; +}; diff --git a/packages/twenty-shared/src/logic-function/get-function-input-schema.ts b/packages/twenty-shared/src/logic-function/get-function-input-schema.ts index 4da182aaf8..b4129817c3 100644 --- a/packages/twenty-shared/src/logic-function/get-function-input-schema.ts +++ b/packages/twenty-shared/src/logic-function/get-function-input-schema.ts @@ -20,6 +20,32 @@ import { import { type InputJsonSchema } from '@/logic-function'; import { isDefined } from '@/utils/validation/isDefined'; +const TWENTY_RECORD_TYPE_NAME = 'TwentyRecord'; + +const getObjectUniversalIdentifierFromTypeArgument = ( + typeReferenceNode: TypeReferenceNode, +): string | undefined => { + const typeArgument = typeReferenceNode.typeArguments?.[0]; + + if ( + isDefined(typeArgument) && + typeArgument.kind === SyntaxKind.LiteralType && + (typeArgument as LiteralTypeNode).literal.kind === SyntaxKind.StringLiteral + ) { + return ((typeArgument as LiteralTypeNode).literal as StringLiteral).text; + } + + return undefined; +}; + +const buildArraySchemaFromItems = (items: InputJsonSchema): InputJsonSchema => + items.type === 'record' + ? { + type: 'records', + objectUniversalIdentifier: items.objectUniversalIdentifier, + } + : { type: 'array', items }; + const getTypeString = (typeNode: TypeNode): InputJsonSchema => { switch (typeNode.kind) { case SyntaxKind.NumberKeyword: @@ -29,10 +55,9 @@ const getTypeString = (typeNode: TypeNode): InputJsonSchema => { case SyntaxKind.BooleanKeyword: return { type: 'boolean' }; case SyntaxKind.ArrayType: - return { - type: 'array', - items: getTypeString((typeNode as ArrayTypeNode).elementType), - }; + return buildArraySchemaFromItems( + getTypeString((typeNode as ArrayTypeNode).elementType), + ); case SyntaxKind.TypeReference: { const typeReferenceNode = typeNode as TypeReferenceNode; const typeName = @@ -43,10 +68,18 @@ const getTypeString = (typeNode: TypeNode): InputJsonSchema => { if (typeName === 'Array' || typeName === 'ReadonlyArray') { const elementType = typeReferenceNode.typeArguments?.[0]; - return { - type: 'array', - items: isDefined(elementType) ? getTypeString(elementType) : {}, - }; + return buildArraySchemaFromItems( + isDefined(elementType) ? getTypeString(elementType) : {}, + ); + } + + if (typeName === TWENTY_RECORD_TYPE_NAME) { + const objectUniversalIdentifier = + getObjectUniversalIdentifierFromTypeArgument(typeReferenceNode); + + if (isDefined(objectUniversalIdentifier)) { + return { type: 'record', objectUniversalIdentifier }; + } } return {}; diff --git a/packages/twenty-shared/src/logic-function/get-input-schema-from-source-code.ts b/packages/twenty-shared/src/logic-function/get-input-schema-from-source-code.ts index 19e6a2af9f..af8dcb3aec 100644 --- a/packages/twenty-shared/src/logic-function/get-input-schema-from-source-code.ts +++ b/packages/twenty-shared/src/logic-function/get-input-schema-from-source-code.ts @@ -11,7 +11,6 @@ export const getInputSchemaFromSourceCode = async ( await import('./get-function-input-schema'); const inputSchema = getFunctionInputSchema(sourceCode); - // Logic functions take a single params object const firstParam = inputSchema[0]; if (firstParam?.type === 'object' && isDefined(firstParam.properties)) { diff --git a/packages/twenty-shared/src/logic-function/index.ts b/packages/twenty-shared/src/logic-function/index.ts index 96850578cd..0f2ab6226c 100644 --- a/packages/twenty-shared/src/logic-function/index.ts +++ b/packages/twenty-shared/src/logic-function/index.ts @@ -7,6 +7,7 @@ * |___/ */ +export { buildToolInputJsonSchema } from './build-tool-input-json-schema'; export { DEFAULT_TOOL_INPUT_SCHEMA } from './constants/DefaultToolInputSchema'; export { SEED_WORKFLOW_ACTION_TRIGGER_SETTINGS } from './constants/SeedWorkflowActionTriggerSettings'; export { getInputSchemaFromSourceCode } from './get-input-schema-from-source-code'; @@ -14,4 +15,6 @@ export { getOutputSchemaFromValue } from './get-output-schema-from-value'; export { getOutputSchemaMismatchIssues } from './get-output-schema-mismatch-issues'; export type { InputJsonSchema } from './input-json-schema.type'; export { inputSchemaToOutputSchema } from './input-schema-to-output-schema'; +export { isRecordArraySchema } from './is-record-array-schema'; +export { isRecordObjectSchema } from './is-record-object-schema'; export { jsonSchemaToInputSchema } from './json-schema-to-input-schema'; diff --git a/packages/twenty-shared/src/logic-function/input-json-schema.type.ts b/packages/twenty-shared/src/logic-function/input-json-schema.type.ts index 782764eede..c9ad5ce2fd 100644 --- a/packages/twenty-shared/src/logic-function/input-json-schema.type.ts +++ b/packages/twenty-shared/src/logic-function/input-json-schema.type.ts @@ -6,7 +6,9 @@ export type InputJsonSchema = { | 'object' | 'array' | 'integer' - | 'null'; + | 'null' + | 'record' + | 'records'; description?: string; enum?: unknown[]; items?: InputJsonSchema; @@ -17,4 +19,5 @@ export type InputJsonSchema = { maximum?: number; multiline?: boolean; label?: string; + objectUniversalIdentifier?: string; }; diff --git a/packages/twenty-shared/src/logic-function/input-schema-to-output-schema.ts b/packages/twenty-shared/src/logic-function/input-schema-to-output-schema.ts index f350343016..c36df1ae4e 100644 --- a/packages/twenty-shared/src/logic-function/input-schema-to-output-schema.ts +++ b/packages/twenty-shared/src/logic-function/input-schema-to-output-schema.ts @@ -28,6 +28,14 @@ const convertProperty = ( ): Leaf | Node => { const label = property.label ?? key; + if (property.type === 'record') { + return { isLeaf: true, type: 'string', label, value: null }; + } + + if (property.type === 'records') { + return { isLeaf: true, type: 'array', label, value: null }; + } + if (property.type === 'object') { return { isLeaf: false, diff --git a/packages/twenty-shared/src/logic-function/is-record-array-schema.ts b/packages/twenty-shared/src/logic-function/is-record-array-schema.ts new file mode 100644 index 0000000000..dd0d3ca8ff --- /dev/null +++ b/packages/twenty-shared/src/logic-function/is-record-array-schema.ts @@ -0,0 +1,19 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +import { isRecordObjectSchema } from '@/logic-function/is-record-object-schema'; + +type RecordArraySchema = { + type?: string; + objectUniversalIdentifier?: string; + items?: { + type?: string; + objectUniversalIdentifier?: string; + } | null; +}; + +export const isRecordArraySchema = ( + schema: RecordArraySchema | null | undefined, +): boolean => + (schema?.type === 'records' && + isNonEmptyString(schema.objectUniversalIdentifier)) || + (schema?.type === 'array' && isRecordObjectSchema(schema?.items)); diff --git a/packages/twenty-shared/src/logic-function/is-record-object-schema.ts b/packages/twenty-shared/src/logic-function/is-record-object-schema.ts new file mode 100644 index 0000000000..892e056b12 --- /dev/null +++ b/packages/twenty-shared/src/logic-function/is-record-object-schema.ts @@ -0,0 +1,12 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +type RecordObjectSchema = { + type?: string; + objectUniversalIdentifier?: string; +}; + +export const isRecordObjectSchema = ( + schema: TSchema | null | undefined, +): schema is TSchema & { objectUniversalIdentifier: string } => + (schema?.type === 'record' || schema?.type === 'object') && + isNonEmptyString(schema.objectUniversalIdentifier); diff --git a/packages/twenty-shared/src/logic-function/json-schema-to-input-schema.ts b/packages/twenty-shared/src/logic-function/json-schema-to-input-schema.ts index 2c0a17633a..e72e6fd56d 100644 --- a/packages/twenty-shared/src/logic-function/json-schema-to-input-schema.ts +++ b/packages/twenty-shared/src/logic-function/json-schema-to-input-schema.ts @@ -36,6 +36,12 @@ const convertProperty = (jsonSchema: InputJsonSchema): InputSchemaProperty => { ); } break; + case 'record': + property.type = 'record'; + break; + case 'records': + property.type = 'records'; + break; case 'null': default: property.type = 'unknown'; @@ -55,6 +61,10 @@ const convertProperty = (jsonSchema: InputJsonSchema): InputSchemaProperty => { property.label = jsonSchema.label; } + if (isNonEmptyString(jsonSchema.objectUniversalIdentifier)) { + property.objectUniversalIdentifier = jsonSchema.objectUniversalIdentifier; + } + return property; }; diff --git a/packages/twenty-shared/src/workflow/index.ts b/packages/twenty-shared/src/workflow/index.ts index 745381058c..c905a25379 100644 --- a/packages/twenty-shared/src/workflow/index.ts +++ b/packages/twenty-shared/src/workflow/index.ts @@ -94,6 +94,7 @@ export { workflowTriggerSchema } from './schemas/workflow-trigger-schema'; export type { EmailRecipients } from './types/EmailRecipients'; export type { FunctionInput } from './types/FunctionInput'; export type { + RecordSchemaType, InputSchemaPropertyType, InputSchemaProperty, InputSchema, diff --git a/packages/twenty-shared/src/workflow/types/InputSchema.ts b/packages/twenty-shared/src/workflow/types/InputSchema.ts index 1032899323..89f6dce4a2 100644 --- a/packages/twenty-shared/src/workflow/types/InputSchema.ts +++ b/packages/twenty-shared/src/workflow/types/InputSchema.ts @@ -1,7 +1,13 @@ import { type FieldMetadataType } from '@/types'; import { type LeafType, type NodeType } from '@/workflow'; -export type InputSchemaPropertyType = LeafType | NodeType | FieldMetadataType; +export type RecordSchemaType = 'record' | 'records'; + +export type InputSchemaPropertyType = + | LeafType + | NodeType + | RecordSchemaType + | FieldMetadataType; export type InputSchemaProperty = { type: InputSchemaPropertyType; @@ -10,6 +16,7 @@ export type InputSchemaProperty = { properties?: Properties; multiline?: boolean; label?: string; + objectUniversalIdentifier?: string; }; type Properties = { diff --git a/packages/twenty-shared/src/workflow/utils/__tests__/getDefaultFunctionInputFromInputSchema.test.ts b/packages/twenty-shared/src/workflow/utils/__tests__/getDefaultFunctionInputFromInputSchema.test.ts index 51931b74c2..63a5ab56f7 100644 --- a/packages/twenty-shared/src/workflow/utils/__tests__/getDefaultFunctionInputFromInputSchema.test.ts +++ b/packages/twenty-shared/src/workflow/utils/__tests__/getDefaultFunctionInputFromInputSchema.test.ts @@ -56,4 +56,56 @@ describe('getDefaultFunctionInputFromInputSchema', () => { { briefs: [] }, ]); }); + + it('should init record-typed inputs with null and record arrays with empty arrays', () => { + const inputSchema = [ + { + type: 'object', + properties: { + company: { + type: 'object', + objectUniversalIdentifier: 'company-universal-identifier', + }, + people: { + type: 'array', + items: { + type: 'object', + objectUniversalIdentifier: 'person-universal-identifier', + }, + }, + plainObject: { type: 'object' }, + }, + }, + ] as InputSchema; + + expect(getFunctionInputFromInputSchema(inputSchema)).toEqual([ + { + company: null, + people: [], + plainObject: {}, + }, + ]); + }); + + it('should init record/records types with null and empty array', () => { + const inputSchema = [ + { + type: 'object', + properties: { + company: { + type: 'record', + objectUniversalIdentifier: 'company-universal-identifier', + }, + people: { + type: 'records', + objectUniversalIdentifier: 'person-universal-identifier', + }, + }, + }, + ] as InputSchema; + + expect(getFunctionInputFromInputSchema(inputSchema)).toEqual([ + { company: null, people: [] }, + ]); + }); }); diff --git a/packages/twenty-shared/src/workflow/utils/getFunctionInputFromInputSchema.ts b/packages/twenty-shared/src/workflow/utils/getFunctionInputFromInputSchema.ts index c2bdb3555e..018ffd0f6a 100644 --- a/packages/twenty-shared/src/workflow/utils/getFunctionInputFromInputSchema.ts +++ b/packages/twenty-shared/src/workflow/utils/getFunctionInputFromInputSchema.ts @@ -1,11 +1,20 @@ import { type InputSchema, type FunctionInput } from '@/workflow'; import { type InputJsonSchema } from '@/logic-function'; +import { isRecordObjectSchema } from '@/logic-function/is-record-object-schema'; import { isDefined } from '@/utils'; export const getFunctionInputFromInputSchema = ( inputSchema: InputSchema | InputJsonSchema[], ): FunctionInput => { return inputSchema.map((param) => { + if (isRecordObjectSchema(param)) { + return null; + } + + if (param.type === 'records') { + return []; + } + if ( isDefined(param.type) && ['string', 'number', 'boolean'].includes(param.type)