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:
+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: [] };
|
||||
};
|
||||
Reference in New Issue
Block a user