Infer record pickers for record-typed logic function workflow inputs (#21494)
## Context Logic functions can declare workflow inputs typed as records or arrays of records (e.g. the People Data Labs enrichment functions), but the workflow builder rendered those as a plain text input with a variable picker, which is not usable. ## What this does - Adds an `objectUniversalIdentifier` link on input schema properties, so a record-typed input is tied to a workspace object. - The SDK build infers it from a `TwentyRecord<'objectUniversalIdentifier'>` marker type in the handler signature, reading the object's universal identifier straight from the source; explicit input schemas can still set the field directly. - The workflow builder renders these inputs as a single record picker or a record multi-select with the variable picker on the right. Selected records are stored as record ids; `TwentyRecord<UID>` is a branded `string`, so the handler signature reflects that it receives ids (a bound variable resolves to whatever the referenced step produced). - The multi-select collapses overflowing chips into a `+N` badge (reusing `ExpandableList`) and its variable picker offers both record objects and fields. - Updates the People Data Labs enrichment inputs as the reference implementation. <img width="802" height="824" alt="CleanShot 2026-06-12 at 16 54 10@2x" src="https://github.com/user-attachments/assets/a0896d74-0aab-49bd-a173-14c578a2e533" /> <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21494?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+23
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
+2
-2
@@ -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';
|
||||
|
||||
+87
@@ -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 (
|
||||
<StyledChipsContainer>
|
||||
<VariableChipStandalone
|
||||
rawVariableName={draftValue.value}
|
||||
onRemove={readonly ? undefined : onUnlinkVariable}
|
||||
isFullRecord
|
||||
/>
|
||||
</StyledChipsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const staticVariables = draftValue.value.filter((entry) =>
|
||||
isStandaloneVariableString(entry),
|
||||
);
|
||||
|
||||
if (!isNonEmptyArray(selectedRecords) && !isNonEmptyArray(staticVariables)) {
|
||||
return (
|
||||
<StyledChipsContainer>
|
||||
<FormFieldPlaceholder>{t`Select`}</FormFieldPlaceholder>
|
||||
</StyledChipsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const chips = [
|
||||
...selectedRecords.map((record) => (
|
||||
<RecordChip
|
||||
key={record.id}
|
||||
record={record}
|
||||
objectNameSingular={objectNameSingular}
|
||||
/>
|
||||
)),
|
||||
...staticVariables.map((variable) => (
|
||||
<VariableChipStandalone
|
||||
key={variable}
|
||||
rawVariableName={variable}
|
||||
onRemove={readonly ? undefined : () => onRemoveStaticVariable(variable)}
|
||||
isFullRecord
|
||||
/>
|
||||
)),
|
||||
];
|
||||
|
||||
return (
|
||||
<StyledChipsContainer>
|
||||
<ExpandableList isChipCountDisplayed={true}>{chips}</ExpandableList>
|
||||
</StyledChipsContainer>
|
||||
);
|
||||
};
|
||||
+258
@@ -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<RecordId | Variable> | Variable | string | null;
|
||||
onChange: (value: Array<RecordId | Variable> | 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<FormMultiRecordPickerDraftValue>(
|
||||
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 = (
|
||||
<FormMultiRecordFieldChips
|
||||
draftValue={draftValue}
|
||||
selectedRecords={orderedSelectedRecords}
|
||||
objectNameSingular={objectNameSingular}
|
||||
readonly={readonly}
|
||||
onUnlinkVariable={handleUnlinkVariable}
|
||||
onRemoveStaticVariable={handleRemoveStaticVariable}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<FormFieldInputContainer data-testid={testId}>
|
||||
{label ? <InputLabel>{label}</InputLabel> : null}
|
||||
<FormFieldInputRowContainer>
|
||||
{readonly || draftValue.type === 'variable' ? (
|
||||
<StyledFormSelectContainerWrapper readonly={readonly}>
|
||||
<FormFieldInputInnerContainer
|
||||
formFieldInputInstanceId={componentId}
|
||||
hasRightElement={false}
|
||||
>
|
||||
{chips}
|
||||
</FormFieldInputInnerContainer>
|
||||
</StyledFormSelectContainerWrapper>
|
||||
) : (
|
||||
<StyledDropdownContainer>
|
||||
<Dropdown
|
||||
dropdownId={dropdownId}
|
||||
dropdownPlacement="bottom-start"
|
||||
clickableComponentWidth="100%"
|
||||
onOpen={handleOpenDropdown}
|
||||
dropdownOffset={{
|
||||
y: parseInt(theme.spacing[1], 10),
|
||||
}}
|
||||
clickableComponent={
|
||||
<StyledFormSelectContainerWrapper>
|
||||
<FormFieldInputInnerContainer
|
||||
formFieldInputInstanceId={componentId}
|
||||
hasRightElement={isDefined(VariablePicker) && !readonly}
|
||||
hoverable
|
||||
preventFocusStackUpdate={true}
|
||||
>
|
||||
{chips}
|
||||
<StyledIconButton>
|
||||
<IconChevronDown
|
||||
size={theme.icon.size.md}
|
||||
color={theme.font.color.light}
|
||||
/>
|
||||
</StyledIconButton>
|
||||
</FormFieldInputInnerContainer>
|
||||
</StyledFormSelectContainerWrapper>
|
||||
}
|
||||
dropdownComponents={
|
||||
<MultipleRecordPicker
|
||||
componentInstanceId={dropdownId}
|
||||
focusId={dropdownId}
|
||||
onChange={handleMorphItemChange}
|
||||
onSubmit={() => closeDropdown(dropdownId)}
|
||||
onClickOutside={() => closeDropdown(dropdownId)}
|
||||
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</StyledDropdownContainer>
|
||||
)}
|
||||
{isDefined(VariablePicker) && !readonly && (
|
||||
<StyledVariablePickerContainer>
|
||||
<VariablePicker
|
||||
instanceId={variablesDropdownId}
|
||||
disabled={readonly}
|
||||
onVariableSelect={handleVariableTagInsert}
|
||||
shouldDisplayRecordObjects={true}
|
||||
shouldDisplayRecordFields={true}
|
||||
objectNameSingularsToSelect={[objectNameSingular]}
|
||||
/>
|
||||
</StyledVariablePickerContainer>
|
||||
)}
|
||||
</FormFieldInputRowContainer>
|
||||
</FormFieldInputContainer>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+80
-57
@@ -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 = ({
|
||||
</FormFieldInputInnerContainer>
|
||||
</StyledFormSelectContainerWrapper>
|
||||
) : (
|
||||
<Dropdown
|
||||
dropdownId={dropdownId}
|
||||
dropdownPlacement="bottom-start"
|
||||
clickableComponentWidth="100%"
|
||||
onClose={handleCloseRelationPickerDropdown}
|
||||
onOpen={handleOpenDropdown}
|
||||
dropdownOffset={{
|
||||
y: parseInt(theme.spacing[1], 10),
|
||||
}}
|
||||
clickableComponent={
|
||||
<StyledFormSelectContainerWrapper>
|
||||
<FormFieldInputInnerContainer
|
||||
formFieldInputInstanceId={componentId}
|
||||
hasRightElement={isDefined(VariablePicker) && !disabled}
|
||||
hoverable
|
||||
preventFocusStackUpdate={true}
|
||||
>
|
||||
<FormSingleRecordFieldChip
|
||||
draftValue={draftValue}
|
||||
selectedRecord={selectedRecord}
|
||||
objectNameSingular={resolvedObjectNameSingular}
|
||||
onRemove={handleUnlinkVariable}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<StyledIconButton>
|
||||
<IconChevronDown
|
||||
size={theme.icon.size.md}
|
||||
color={theme.font.color.light}
|
||||
<StyledDropdownContainer>
|
||||
<Dropdown
|
||||
dropdownId={dropdownId}
|
||||
dropdownPlacement="bottom-start"
|
||||
clickableComponentWidth="100%"
|
||||
onClose={handleCloseRelationPickerDropdown}
|
||||
onOpen={handleOpenDropdown}
|
||||
dropdownOffset={{
|
||||
y: parseInt(theme.spacing[1], 10),
|
||||
}}
|
||||
clickableComponent={
|
||||
<StyledFormSelectContainerWrapper>
|
||||
<FormFieldInputInnerContainer
|
||||
formFieldInputInstanceId={componentId}
|
||||
hasRightElement={isDefined(VariablePicker) && !disabled}
|
||||
hoverable
|
||||
preventFocusStackUpdate={true}
|
||||
>
|
||||
<FormSingleRecordFieldChip
|
||||
draftValue={draftValue}
|
||||
selectedRecord={selectedRecord}
|
||||
objectNameSingular={resolvedObjectNameSingular}
|
||||
onRemove={handleUnlinkVariable}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</StyledIconButton>
|
||||
</FormFieldInputInnerContainer>
|
||||
</StyledFormSelectContainerWrapper>
|
||||
}
|
||||
dropdownComponents={
|
||||
<SingleRecordPicker
|
||||
focusId={dropdownId}
|
||||
componentInstanceId={dropdownId}
|
||||
EmptyIcon={IconForbid}
|
||||
emptyLabel={t`No record`}
|
||||
onCancel={() => closeDropdown(dropdownId)}
|
||||
onCreate={isDefined(onCreate) ? handleCreateRecord : undefined}
|
||||
onMorphItemSelected={handleMorphItemSelected}
|
||||
objectNameSingulars={objectNameSingulars}
|
||||
recordPickerInstanceId={dropdownId}
|
||||
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<StyledIconButton>
|
||||
<IconChevronDown
|
||||
size={theme.icon.size.md}
|
||||
color={theme.font.color.light}
|
||||
/>
|
||||
</StyledIconButton>
|
||||
</FormFieldInputInnerContainer>
|
||||
</StyledFormSelectContainerWrapper>
|
||||
}
|
||||
dropdownComponents={
|
||||
<SingleRecordPicker
|
||||
focusId={dropdownId}
|
||||
componentInstanceId={dropdownId}
|
||||
EmptyIcon={IconForbid}
|
||||
emptyLabel={t`No record`}
|
||||
onCancel={() => closeDropdown(dropdownId)}
|
||||
onCreate={
|
||||
isDefined(onCreate) ? handleCreateRecord : undefined
|
||||
}
|
||||
onMorphItemSelected={handleMorphItemSelected}
|
||||
objectNameSingulars={objectNameSingulars}
|
||||
recordPickerInstanceId={dropdownId}
|
||||
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</StyledDropdownContainer>
|
||||
)}
|
||||
{isDefined(VariablePicker) && !disabled && (
|
||||
<VariablePicker
|
||||
instanceId={variablesDropdownId}
|
||||
disabled={disabled}
|
||||
onVariableSelect={handleVariableTagInsert}
|
||||
shouldDisplayRecordObjects={true}
|
||||
shouldDisplayRecordFields={false}
|
||||
objectNameSingularsToSelect={objectNameSingulars}
|
||||
/>
|
||||
<StyledVariablePickerContainer>
|
||||
<VariablePicker
|
||||
instanceId={variablesDropdownId}
|
||||
disabled={disabled}
|
||||
onVariableSelect={handleVariableTagInsert}
|
||||
shouldDisplayRecordObjects={true}
|
||||
shouldDisplayRecordFields={
|
||||
shouldDisplayRecordFieldsInVariablePicker
|
||||
}
|
||||
objectNameSingularsToSelect={objectNameSingulars}
|
||||
/>
|
||||
</StyledVariablePickerContainer>
|
||||
)}
|
||||
</FormFieldInputRowContainer>
|
||||
</FormFieldInputContainer>
|
||||
|
||||
+136
@@ -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<typeof FormMultiRecordPicker> = {
|
||||
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<typeof FormMultiRecordPicker>;
|
||||
|
||||
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: () => <div>VariablePicker</div>,
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<StyledNarrowContainer>
|
||||
<Story />
|
||||
</StyledNarrowContainer>
|
||||
),
|
||||
],
|
||||
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: () => <div>VariablePicker</div>,
|
||||
},
|
||||
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: () => <div>VariablePicker</div>,
|
||||
},
|
||||
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();
|
||||
},
|
||||
};
|
||||
+67
@@ -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 };
|
||||
};
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export type RecordId = string;
|
||||
export type Variable = string;
|
||||
+80
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
+65
@@ -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<RecordId | Variable>;
|
||||
}
|
||||
| {
|
||||
type: 'variable';
|
||||
value: Variable;
|
||||
};
|
||||
|
||||
const keepRecordIdsAndVariables = (
|
||||
entries: unknown[],
|
||||
): Array<RecordId | Variable> =>
|
||||
entries.filter(
|
||||
(entry): entry is string =>
|
||||
isString(entry) &&
|
||||
(isValidUuid(entry) || isStandaloneVariableString(entry)),
|
||||
);
|
||||
|
||||
export const getFormMultiRecordPickerDraftValue = (
|
||||
defaultValue:
|
||||
| Array<RecordId | Variable>
|
||||
| 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: [] };
|
||||
};
|
||||
+156
@@ -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 (
|
||||
<FormSingleRecordPicker
|
||||
label={label}
|
||||
defaultValue={isNonEmptyString(inputValue) ? inputValue : undefined}
|
||||
onChange={onChange}
|
||||
objectNameSingulars={[recordObjectMetadataItem.nameSingular]}
|
||||
disabled={readonly}
|
||||
VariablePicker={VariablePicker}
|
||||
shouldDisplayRecordFieldsInVariablePicker={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormMultiRecordPicker
|
||||
label={label}
|
||||
defaultValue={
|
||||
Array.isArray(inputValue) ||
|
||||
isString(inputValue) ||
|
||||
isNull(inputValue)
|
||||
? inputValue
|
||||
: undefined
|
||||
}
|
||||
onChange={onChange}
|
||||
objectNameSingular={recordObjectMetadataItem.nameSingular}
|
||||
readonly={readonly}
|
||||
VariablePicker={VariablePicker}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (leafKind === 'boolean') {
|
||||
return (
|
||||
<FormBooleanFieldInput
|
||||
label={label}
|
||||
defaultValue={
|
||||
isBoolean(inputValue) || isStandaloneVariableString(inputValue)
|
||||
? inputValue
|
||||
: undefined
|
||||
}
|
||||
readonly={readonly}
|
||||
onChange={onChange}
|
||||
VariablePicker={VariablePicker}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (leafKind === 'number') {
|
||||
return (
|
||||
<FormNumberFieldInput
|
||||
label={label}
|
||||
defaultValue={
|
||||
isNumber(inputValue) || isString(inputValue) ? inputValue : undefined
|
||||
}
|
||||
readonly={readonly}
|
||||
onChange={onChange}
|
||||
VariablePicker={VariablePicker}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (leafKind === 'enum' && isDefined(schemaProperty)) {
|
||||
const enumOptions = getWorkflowCodeFieldsEnumSelectOptions(schemaProperty);
|
||||
|
||||
if (isNonEmptyArray(enumOptions)) {
|
||||
return (
|
||||
<FormSelectFieldInput
|
||||
label={label}
|
||||
defaultValue={
|
||||
!isDefined(inputValue)
|
||||
? undefined
|
||||
: isString(inputValue)
|
||||
? inputValue
|
||||
: String(inputValue)
|
||||
}
|
||||
readonly={readonly}
|
||||
onChange={onChange}
|
||||
VariablePicker={VariablePicker}
|
||||
options={enumOptions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormTextFieldInput
|
||||
label={label}
|
||||
placeholder={t`Enter value`}
|
||||
defaultValue={isDefined(inputValue) ? `${inputValue}` : ''}
|
||||
readonly={readonly}
|
||||
onChange={onChange}
|
||||
VariablePicker={VariablePicker}
|
||||
multiline={schemaProperty?.multiline === true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+13
-80
@@ -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 (
|
||||
<div key={pathKey}>
|
||||
<InputLabel>{displayLabel}</InputLabel>
|
||||
@@ -78,83 +79,15 @@ export const WorkflowEditActionCodeFields = ({
|
||||
);
|
||||
}
|
||||
|
||||
const leafKind = getWorkflowCodeFieldsLeafKind(schemaProperty);
|
||||
|
||||
if (leafKind === 'boolean') {
|
||||
return (
|
||||
<FormBooleanFieldInput
|
||||
key={pathKey}
|
||||
label={displayLabel}
|
||||
defaultValue={
|
||||
!isDefined(inputValue)
|
||||
? undefined
|
||||
: typeof inputValue === 'boolean' ||
|
||||
typeof inputValue === 'string'
|
||||
? inputValue
|
||||
: undefined
|
||||
}
|
||||
readonly={readonly}
|
||||
onChange={(value) => onInputChange?.(value, currentPath)}
|
||||
VariablePicker={VariablePicker}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (leafKind === 'number') {
|
||||
return (
|
||||
<FormNumberFieldInput
|
||||
key={pathKey}
|
||||
label={displayLabel}
|
||||
defaultValue={
|
||||
!isDefined(inputValue)
|
||||
? undefined
|
||||
: typeof inputValue === 'number' ||
|
||||
typeof inputValue === 'string'
|
||||
? inputValue
|
||||
: undefined
|
||||
}
|
||||
readonly={readonly}
|
||||
onChange={(value) => onInputChange?.(value, currentPath)}
|
||||
VariablePicker={VariablePicker}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (leafKind === 'enum' && isDefined(schemaProperty)) {
|
||||
const enumOptions =
|
||||
getWorkflowCodeFieldsEnumSelectOptions(schemaProperty);
|
||||
|
||||
if (isNonEmptyArray(enumOptions)) {
|
||||
return (
|
||||
<FormSelectFieldInput
|
||||
key={pathKey}
|
||||
label={displayLabel}
|
||||
defaultValue={
|
||||
!isDefined(inputValue)
|
||||
? undefined
|
||||
: typeof inputValue === 'string'
|
||||
? inputValue
|
||||
: String(inputValue)
|
||||
}
|
||||
readonly={readonly}
|
||||
onChange={(value) => onInputChange?.(value, currentPath)}
|
||||
VariablePicker={VariablePicker}
|
||||
options={enumOptions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormTextFieldInput
|
||||
<WorkflowEditActionCodeFieldLeaf
|
||||
key={pathKey}
|
||||
label={displayLabel}
|
||||
placeholder={t`Enter value`}
|
||||
defaultValue={inputValue ? `${inputValue}` : ''}
|
||||
inputValue={inputValue}
|
||||
schemaProperty={schemaProperty}
|
||||
readonly={readonly}
|
||||
onChange={(value) => onInputChange?.(value, currentPath)}
|
||||
VariablePicker={VariablePicker}
|
||||
multiline={schemaProperty?.multiline === true}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
+40
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+27
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
+19
-1
@@ -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)
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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<T>` 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<TUid>` 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.
|
||||
+31
@@ -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',
|
||||
|
||||
+2
@@ -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',
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+41
@@ -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);
|
||||
});
|
||||
@@ -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]',
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export type TwentyRecord<TObjectUniversalIdentifier extends string = string> =
|
||||
string & { readonly __object?: TObjectUniversalIdentifier };
|
||||
+16
-6
@@ -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);
|
||||
|
||||
+3
@@ -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 = {
|
||||
|
||||
+154
@@ -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 },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<TwentyRecord<'company-universal-identifier'>>;
|
||||
}): 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(
|
||||
|
||||
+23
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+56
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
@@ -0,0 +1,12 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
type RecordObjectSchema = {
|
||||
type?: string;
|
||||
objectUniversalIdentifier?: string;
|
||||
};
|
||||
|
||||
export const isRecordObjectSchema = <TSchema extends RecordObjectSchema>(
|
||||
schema: TSchema | null | undefined,
|
||||
): schema is TSchema & { objectUniversalIdentifier: string } =>
|
||||
(schema?.type === 'record' || schema?.type === 'object') &&
|
||||
isNonEmptyString(schema.objectUniversalIdentifier);
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+52
@@ -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: [] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user