Add record picker in form action (#11331)

Record picker becomes a form field that could be used in another context
than workflows.

Settings
<img width="488" alt="Capture d’écran 2025-04-02 à 10 55 53"
src="https://github.com/user-attachments/assets/a9fc09ff-28cd-4ede-8aaa-af1e986cda8e"
/>

Execution
<img width="936" alt="Capture d’écran 2025-04-02 à 10 57 36"
src="https://github.com/user-attachments/assets/d796aeeb-cae1-4e59-b388-5b8d08739ea8"
/>
This commit is contained in:
Thomas Trompette
2025-04-02 17:08:33 +02:00
committed by GitHub
parent 2bc9691021
commit 7488c6727a
21 changed files with 400 additions and 154 deletions

View File

@ -0,0 +1,68 @@
import { RecordChip } from '@/object-record/components/RecordChip';
import {
RecordId,
Variable,
} from '@/object-record/record-field/form-types/components/FormSingleRecordPicker';
import { VariableChipStandalone } from '@/object-record/record-field/form-types/components/VariableChipStandalone';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import styled from '@emotion/styled';
const StyledRecordChip = styled(RecordChip)`
margin: ${({ theme }) => theme.spacing(2)};
`;
const StyledPlaceholder = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.md};
margin: ${({ theme }) => theme.spacing(2)};
`;
type FormSingleRecordFieldChipProps = {
draftValue:
| {
type: 'static';
value: RecordId;
}
| {
type: 'variable';
value: Variable;
};
selectedRecord?: ObjectRecord;
objectNameSingular: string;
onRemove: () => void;
disabled?: boolean;
};
export const FormSingleRecordFieldChip = ({
draftValue,
selectedRecord,
objectNameSingular,
onRemove,
disabled,
}: FormSingleRecordFieldChipProps) => {
if (
!!draftValue &&
draftValue.type === 'variable' &&
isStandaloneVariableString(draftValue.value)
) {
return (
<VariableChipStandalone
rawVariableName={draftValue.value}
onRemove={disabled ? undefined : onRemove}
isFullRecord
/>
);
}
if (!!draftValue && draftValue.type === 'static' && !!selectedRecord) {
return (
<StyledRecordChip
record={selectedRecord}
objectNameSingular={objectNameSingular}
/>
);
}
return <StyledPlaceholder>Select a {objectNameSingular}</StyledPlaceholder>;
};

View File

@ -0,0 +1,183 @@
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { FormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputInputContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
import { FormSingleRecordFieldChip } from '@/object-record/record-field/form-types/components/FormSingleRecordFieldChip';
import { VariablePickerComponent } from '@/object-record/record-field/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';
import { singleRecordPickerSelectedIdComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSelectedIdComponentState';
import { SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import styled from '@emotion/styled';
import { useCallback } from 'react';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { IconChevronDown, IconForbid, LightIconButton } from 'twenty-ui';
const StyledFormSelectContainer = styled(FormFieldInputInputContainer)`
justify-content: space-between;
align-items: center;
padding-right: ${({ theme }) => theme.spacing(1)};
`;
export type RecordId = string;
export type Variable = string;
type FormSingleRecordPickerValue =
| {
type: 'static';
value: RecordId;
}
| {
type: 'variable';
value: Variable;
};
export type FormSingleRecordPickerProps = {
label?: string;
defaultValue: RecordId | Variable;
onChange: (value: RecordId | Variable) => void;
objectNameSingular: string;
disabled?: boolean;
testId?: string;
VariablePicker?: VariablePickerComponent;
};
export const FormSingleRecordPicker = ({
label,
defaultValue,
objectNameSingular,
onChange,
disabled,
testId,
VariablePicker,
}: FormSingleRecordPickerProps) => {
const draftValue: FormSingleRecordPickerValue = isStandaloneVariableString(
defaultValue,
)
? {
type: 'variable',
value: defaultValue,
}
: {
type: 'static',
value: defaultValue || '',
};
const { record: selectedRecord } = useFindOneRecord({
objectRecordId:
isDefined(defaultValue) && !isStandaloneVariableString(defaultValue)
? defaultValue
: '',
objectNameSingular,
withSoftDeleted: true,
skip: !isValidUuid(defaultValue),
});
const dropdownId = `form-record-picker-${objectNameSingular}`;
const variablesDropdownId = `form-record-picker-${objectNameSingular}-variables`;
const { closeDropdown } = useDropdown(dropdownId);
const setRecordPickerSearchFilter = useSetRecoilComponentStateV2(
singleRecordPickerSearchFilterComponentState,
dropdownId,
);
const handleCloseRelationPickerDropdown = useCallback(() => {
setRecordPickerSearchFilter('');
}, [setRecordPickerSearchFilter]);
const handleRecordSelected = (
selectedEntity: SingleRecordPickerRecord | null | undefined,
) => {
onChange?.(selectedEntity?.record?.id ?? '');
closeDropdown();
};
const handleVariableTagInsert = (variable: string) => {
onChange?.(variable);
closeDropdown();
};
const handleUnlinkVariable = () => {
closeDropdown();
onChange('');
};
const setRecordPickerSelectedId = useSetRecoilComponentStateV2(
singleRecordPickerSelectedIdComponentState,
dropdownId,
);
const handleOpenDropdown = () => {
if (
isDefined(draftValue?.value) &&
!isStandaloneVariableString(draftValue.value)
) {
setRecordPickerSelectedId(draftValue.value);
}
};
return (
<FormFieldInputContainer testId={testId}>
{label ? <InputLabel>{label}</InputLabel> : null}
<FormFieldInputRowContainer>
<StyledFormSelectContainer
hasRightElement={isDefined(VariablePicker) && !disabled}
>
<FormSingleRecordFieldChip
draftValue={draftValue}
selectedRecord={selectedRecord}
objectNameSingular={objectNameSingular}
onRemove={handleUnlinkVariable}
disabled={disabled}
/>
{!disabled && (
<DropdownScope dropdownScopeId={dropdownId}>
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="left-start"
onClose={handleCloseRelationPickerDropdown}
onOpen={handleOpenDropdown}
clickableComponent={
<LightIconButton
className="displayOnHover"
Icon={IconChevronDown}
accent="tertiary"
/>
}
dropdownComponents={
<SingleRecordPicker
componentInstanceId={dropdownId}
EmptyIcon={IconForbid}
emptyLabel={'No ' + objectNameSingular}
onCancel={() => closeDropdown()}
onRecordSelected={handleRecordSelected}
objectNameSingular={objectNameSingular}
recordPickerInstanceId={dropdownId}
/>
}
dropdownHotkeyScope={{ scope: dropdownId }}
/>
</DropdownScope>
)}
</StyledFormSelectContainer>
{isDefined(VariablePicker) && !disabled && (
<VariablePicker
inputId={variablesDropdownId}
disabled={disabled}
onVariableSelect={handleVariableTagInsert}
objectNameSingularToSelect={objectNameSingular}
/>
)}
</FormFieldInputRowContainer>
</FormFieldInputContainer>
);
};

View File

@ -3,4 +3,5 @@ export type VariablePickerComponent = React.FC<{
disabled?: boolean;
multiline?: boolean;
onVariableSelect: (variableName: string) => void;
objectNameSingularToSelect?: string;
}>;