Add record picker with variables (#8813)

- Add update actions
- Create a folder for workflow actions
- Add a SingleRecordPicker with variables handler



https://github.com/user-attachments/assets/9fd57ce1-1b8d-424a-8aa1-69468d684fa1
This commit is contained in:
Thomas Trompette
2024-11-29 20:33:45 +01:00
committed by GitHub
parent 29eb9fe77b
commit b542b43878
12 changed files with 563 additions and 49 deletions

View File

@ -76,6 +76,7 @@ export const WorkflowDiagramStepNodeBase = ({
</StyledStepNodeLabelIconContainer>
);
}
case 'RECORD_CRUD.UPDATE':
case 'RECORD_CRUD.CREATE': {
return (
<StyledStepNodeLabelIconContainer>
@ -87,8 +88,8 @@ export const WorkflowDiagramStepNodeBase = ({
</StyledStepNodeLabelIconContainer>
);
}
case 'RECORD_CRUD.DELETE':
case 'RECORD_CRUD.UPDATE': {
case 'RECORD_CRUD.DELETE': {
return null;
}
}

View File

@ -1,185 +0,0 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { FormFieldInput } from '@/object-record/record-field/components/FormFieldInput';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker';
import { WorkflowRecordCreateAction } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react';
import { useEffect, useState } from 'react';
import {
HorizontalSeparator,
IconAddressBook,
isDefined,
useIcons,
} from 'twenty-ui';
import { JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { FieldMetadataType } from '~/generated/graphql';
type WorkflowEditActionFormRecordCreateProps = {
action: WorkflowRecordCreateAction;
actionOptions:
| {
readonly: true;
}
| {
readonly?: false;
onActionUpdate: (action: WorkflowRecordCreateAction) => void;
};
};
type SendEmailFormData = {
objectName: string;
[field: string]: unknown;
};
export const WorkflowEditActionFormRecordCreate = ({
action,
actionOptions,
}: WorkflowEditActionFormRecordCreateProps) => {
const theme = useTheme();
const { getIcon } = useIcons();
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const availableMetadata: Array<SelectOption<string>> =
activeObjectMetadataItems.map((item) => ({
Icon: getIcon(item.icon),
label: item.labelPlural,
value: item.nameSingular,
}));
const [formData, setFormData] = useState<SendEmailFormData>({
objectName: action.settings.input.objectName,
...action.settings.input.objectRecord,
});
const isFormDisabled = actionOptions.readonly;
const handleFieldChange = (
fieldName: keyof SendEmailFormData,
updatedValue: JsonValue,
) => {
const newFormData: SendEmailFormData = {
...formData,
[fieldName]: updatedValue,
};
setFormData(newFormData);
saveAction(newFormData);
};
useEffect(() => {
setFormData({
objectName: action.settings.input.objectName,
...action.settings.input.objectRecord,
});
}, [action.settings.input]);
const selectedObjectMetadataItemNameSingular = formData.objectName;
const selectedObjectMetadataItem = activeObjectMetadataItems.find(
(item) => item.nameSingular === selectedObjectMetadataItemNameSingular,
);
if (!isDefined(selectedObjectMetadataItem)) {
throw new Error('Should have found the metadata item');
}
const editableFields = selectedObjectMetadataItem.fields.filter(
(field) =>
field.type !== FieldMetadataType.Relation &&
!field.isSystem &&
field.isActive,
);
const saveAction = useDebouncedCallback(
async (formData: SendEmailFormData) => {
if (actionOptions.readonly === true) {
return;
}
const { objectName: updatedObjectName, ...updatedOtherFields } = formData;
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: {
type: 'CREATE',
objectName: updatedObjectName,
objectRecord: updatedOtherFields,
},
},
});
},
1_000,
);
useEffect(() => {
return () => {
saveAction.flush();
};
}, [saveAction]);
const headerTitle = isDefined(action.name) ? action.name : `Create Record`;
return (
<WorkflowEditGenericFormBase
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
HeaderIcon={
<IconAddressBook
color={theme.font.color.tertiary}
stroke={theme.icon.stroke.sm}
/>
}
headerTitle={headerTitle}
headerType="Action"
>
<Select
dropdownId="workflow-edit-action-record-create-object-name"
label="Object"
fullWidth
disabled={isFormDisabled}
value={formData.objectName}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(updatedObjectName) => {
const newFormData: SendEmailFormData = {
objectName: updatedObjectName,
};
setFormData(newFormData);
saveAction(newFormData);
}}
/>
<HorizontalSeparator noMargin />
{editableFields.map((field) => {
const currentValue = formData[field.name] as JsonValue;
return (
<FormFieldInput
key={field.id}
defaultValue={currentValue}
field={field}
onPersist={(value) => {
handleFieldChange(field.name, value);
}}
VariablePicker={WorkflowVariablePicker}
/>
);
})}
</WorkflowEditGenericFormBase>
);
};

View File

@ -1,268 +0,0 @@
import { GMAIL_SEND_SCOPE } from '@/accounts/constants/GmailSendScope';
import { ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { WorkflowSendEmailAction } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react';
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useRecoilValue } from 'recoil';
import { IconMail, IconPlus, isDefined } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce';
type WorkflowEditActionFormSendEmailProps = {
action: WorkflowSendEmailAction;
actionOptions:
| {
readonly: true;
}
| {
readonly?: false;
onActionUpdate: (action: WorkflowSendEmailAction) => void;
};
};
type SendEmailFormData = {
connectedAccountId: string;
email: string;
subject: string;
body: string;
};
export const WorkflowEditActionFormSendEmail = ({
action,
actionOptions,
}: WorkflowEditActionFormSendEmailProps) => {
const theme = useTheme();
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { triggerApisOAuth } = useTriggerApisOAuth();
const workflowId = useRecoilValue(workflowIdState);
const redirectUrl = `/object/workflow/${workflowId}`;
const form = useForm<SendEmailFormData>({
defaultValues: {
connectedAccountId: '',
email: '',
subject: '',
body: '',
},
disabled: actionOptions.readonly,
});
const checkConnectedAccountScopes = async (
connectedAccountId: string | null,
) => {
const connectedAccount = accounts.find(
(account) => account.id === connectedAccountId,
);
if (!isDefined(connectedAccount)) {
return;
}
const scopes = connectedAccount.scopes;
if (
!isDefined(scopes) ||
!isDefined(scopes.find((scope) => scope === GMAIL_SEND_SCOPE))
) {
await triggerApisOAuth('google', {
redirectLocation: redirectUrl,
loginHint: connectedAccount.handle,
});
}
};
useEffect(() => {
form.setValue(
'connectedAccountId',
action.settings.input.connectedAccountId ?? '',
);
form.setValue('email', action.settings.input.email ?? '');
form.setValue('subject', action.settings.input.subject ?? '');
form.setValue('body', action.settings.input.body ?? '');
}, [action.settings, form]);
const saveAction = useDebouncedCallback(
async (formData: SendEmailFormData, checkScopes = false) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: {
connectedAccountId: formData.connectedAccountId,
email: formData.email,
subject: formData.subject,
body: formData.body,
},
},
});
if (checkScopes === true) {
await checkConnectedAccountScopes(formData.connectedAccountId);
}
},
1_000,
);
useEffect(() => {
return () => {
saveAction.flush();
};
}, [saveAction]);
const handleSave = (checkScopes = false) =>
form.handleSubmit((formData: SendEmailFormData) =>
saveAction(formData, checkScopes),
)();
const filter: { or: object[] } = {
or: [
{
accountOwnerId: {
eq: currentWorkspaceMember?.id,
},
},
],
};
if (
isDefined(action.settings.input.connectedAccountId) &&
action.settings.input.connectedAccountId !== ''
) {
filter.or.push({
id: {
eq: action.settings.input.connectedAccountId,
},
});
}
const { records: accounts, loading } = useFindManyRecords<ConnectedAccount>({
objectNameSingular: 'connectedAccount',
filter,
});
let emptyOption: SelectOption<string | null> = { label: 'None', value: null };
const connectedAccountOptions: SelectOption<string | null>[] = [];
accounts.forEach((account) => {
const selectOption = {
label: account.handle,
value: account.id,
};
if (account.accountOwnerId === currentWorkspaceMember?.id) {
connectedAccountOptions.push(selectOption);
} else {
// This handle the case when the current connected account does not belong to the currentWorkspaceMember
// In that case, current connected account email is displayed, but cannot be selected
emptyOption = selectOption;
}
});
const headerTitle = isDefined(action.name) ? action.name : 'Send Email';
return (
!loading && (
<WorkflowEditGenericFormBase
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
HeaderIcon={<IconMail color={theme.color.blue} />}
headerTitle={headerTitle}
headerType="Email"
>
<Controller
name="connectedAccountId"
control={form.control}
render={({ field }) => (
<Select
dropdownId="select-connected-account-id"
label="Account"
fullWidth
emptyOption={emptyOption}
value={field.value}
options={connectedAccountOptions}
callToActionButton={{
onClick: () =>
triggerApisOAuth('google', { redirectLocation: redirectUrl }),
Icon: IconPlus,
text: 'Add account',
}}
onChange={(connectedAccountId) => {
field.onChange(connectedAccountId);
handleSave(true);
}}
disabled={actionOptions.readonly}
/>
)}
/>
<Controller
name="email"
control={form.control}
render={({ field }) => (
<FormTextFieldInput
label="Email"
placeholder="Enter receiver email"
readonly={actionOptions.readonly}
defaultValue={field.value}
onPersist={(value) => {
field.onChange(value);
handleSave();
}}
VariablePicker={WorkflowVariablePicker}
/>
)}
/>
<Controller
name="subject"
control={form.control}
render={({ field }) => (
<FormTextFieldInput
label="Subject"
placeholder="Enter email subject"
readonly={actionOptions.readonly}
defaultValue={field.value}
onPersist={(value) => {
field.onChange(value);
handleSave();
}}
VariablePicker={WorkflowVariablePicker}
/>
)}
/>
<Controller
name="body"
control={form.control}
render={({ field }) => (
<FormTextFieldInput
label="Body"
placeholder="Enter email body"
readonly={actionOptions.readonly}
defaultValue={field.value}
onPersist={(value) => {
field.onChange(value);
handleSave();
}}
VariablePicker={WorkflowVariablePicker}
/>
)}
/>
</WorkflowEditGenericFormBase>
)
);
};

View File

@ -1,34 +0,0 @@
import { useGetManyServerlessFunctions } from '@/settings/serverless-functions/hooks/useGetManyServerlessFunctions';
import { WorkflowEditActionFormServerlessFunctionInner } from '@/workflow/components/WorkflowEditActionFormServerlessFunctionInner';
import { WorkflowCodeAction } from '@/workflow/types/Workflow';
type WorkflowEditActionFormServerlessFunctionProps = {
action: WorkflowCodeAction;
actionOptions:
| {
readonly: true;
}
| {
readonly?: false;
onActionUpdate: (action: WorkflowCodeAction) => void;
};
};
export const WorkflowEditActionFormServerlessFunction = ({
action,
actionOptions,
}: WorkflowEditActionFormServerlessFunctionProps) => {
const { loading: isLoadingServerlessFunctions } =
useGetManyServerlessFunctions();
if (isLoadingServerlessFunctions) {
return null;
}
return (
<WorkflowEditActionFormServerlessFunctionInner
action={action}
actionOptions={actionOptions}
/>
);
};

View File

@ -0,0 +1,62 @@
import { RecordChip } from '@/object-record/components/RecordChip';
import { VariableChip } from '@/object-record/record-field/form-types/components/VariableChip';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import {
RecordId,
Variable,
} from '@/workflow/components/WorkflowSingleRecordPicker';
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 WorkflowSingleRecordFieldChipProps = {
draftValue:
| {
type: 'static';
value: RecordId;
}
| {
type: 'variable';
value: Variable;
};
selectedRecord?: ObjectRecord;
objectNameSingular: string;
onRemove: () => void;
};
export const WorkflowSingleRecordFieldChip = ({
draftValue,
selectedRecord,
objectNameSingular,
onRemove,
}: WorkflowSingleRecordFieldChipProps) => {
if (
!!draftValue &&
draftValue.type === 'variable' &&
isStandaloneVariableString(draftValue.value)
) {
return (
<VariableChip rawVariableName={draftValue.value} onRemove={onRemove} />
);
}
if (!!draftValue && draftValue.type === 'static' && !!selectedRecord) {
return (
<StyledRecordChip
record={selectedRecord}
objectNameSingular={objectNameSingular}
/>
);
}
return <StyledPlaceholder>Select a {objectNameSingular}</StyledPlaceholder>;
};

View File

@ -0,0 +1,219 @@
import {
IconChevronDown,
IconForbid,
isDefined,
LightIconButton,
} from 'twenty-ui';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { StyledFormFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputContainer';
import { StyledFormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputRowContainer';
import { SingleRecordSelect } from '@/object-record/relation-picker/components/SingleRecordSelect';
import { useRecordPicker } from '@/object-record/relation-picker/hooks/useRecordPicker';
import { RecordPickerComponentInstanceContext } from '@/object-record/relation-picker/states/contexts/RecordPickerComponentInstanceContext';
import { RecordForSelect } from '@/object-record/relation-picker/types/RecordForSelect';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
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 { WorkflowSingleRecordFieldChip } from '@/workflow/components/WorkflowSingleRecordFieldChip';
import SearchVariablesDropdown from '@/workflow/search-variables/components/SearchVariablesDropdown';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { useCallback, useState } from 'react';
import { isValidUuid } from '~/utils/isValidUuid';
const StyledFormSelectContainer = styled.div`
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-top-left-radius: ${({ theme }) => theme.border.radius.sm};
border-bottom-left-radius: ${({ theme }) => theme.border.radius.sm};
border-right: none;
border-bottom-right-radius: none;
border-top-right-radius: none;
box-sizing: border-box;
display: flex;
overflow: 'hidden';
width: 100%;
justify-content: space-between;
align-items: center;
padding-right: ${({ theme }) => theme.spacing(1)};
`;
const StyledSearchVariablesDropdownContainer = styled.div`
align-items: center;
display: flex;
justify-content: center;
${({ theme }) => css`
:hover {
background-color: ${theme.background.transparent.light};
}
`}
${({ theme }) => css`
background-color: ${theme.background.transparent.lighter};
border-top-right-radius: ${theme.border.radius.sm};
border-bottom-right-radius: ${theme.border.radius.sm};
border: 1px solid ${theme.border.color.medium};
`}
`;
export type RecordId = string;
export type Variable = string;
export type WorkflowSingleRecordPickerProps = {
label?: string;
defaultValue: RecordId | Variable;
onChange: (value: RecordId | Variable) => void;
objectNameSingular: string;
};
export const WorkflowSingleRecordPicker = ({
label,
defaultValue,
objectNameSingular,
onChange,
}: WorkflowSingleRecordPickerProps) => {
const [draftValue, setDraftValue] = useState<
| {
type: 'static';
value: RecordId;
}
| {
type: 'variable';
value: Variable;
}
>(
isStandaloneVariableString(defaultValue)
? {
type: 'variable',
value: defaultValue,
}
: {
type: 'static',
value: defaultValue || '',
},
);
const { record } = useFindOneRecord({
objectRecordId:
isDefined(defaultValue) && !isStandaloneVariableString(defaultValue)
? defaultValue
: '',
objectNameSingular,
withSoftDeleted: true,
skip: !isValidUuid(defaultValue),
});
const [selectedRecord, setSelectedRecord] = useState<
ObjectRecord | undefined
>(record);
const dropdownId = `workflow-record-picker-${objectNameSingular}`;
const variablesDropdownId = `workflow-record-picker-${objectNameSingular}-variables`;
const { closeDropdown } = useDropdown(dropdownId);
const { setRecordPickerSearchFilter } = useRecordPicker({
recordPickerInstanceId: dropdownId,
});
const handleCloseRelationPickerDropdown = useCallback(() => {
setRecordPickerSearchFilter('');
}, [setRecordPickerSearchFilter]);
const handleRecordSelected = (
selectedEntity: RecordForSelect | null | undefined,
) => {
setDraftValue({
type: 'static',
value: selectedEntity?.record?.id ?? '',
});
setSelectedRecord(selectedEntity?.record);
closeDropdown();
onChange?.(selectedEntity?.record?.id ?? '');
};
const handleVariableTagInsert = (variable: string) => {
setDraftValue({
type: 'variable',
value: variable,
});
setSelectedRecord(undefined);
closeDropdown();
onChange?.(variable);
};
const handleUnlinkVariable = () => {
setDraftValue({
type: 'static',
value: '',
});
closeDropdown();
onChange('');
};
return (
<StyledFormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<StyledFormFieldInputRowContainer>
<StyledFormSelectContainer>
<WorkflowSingleRecordFieldChip
draftValue={draftValue}
selectedRecord={selectedRecord}
objectNameSingular={objectNameSingular}
onRemove={handleUnlinkVariable}
/>
<DropdownScope dropdownScopeId={dropdownId}>
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="left-start"
onClose={handleCloseRelationPickerDropdown}
clickableComponent={
<LightIconButton
className="displayOnHover"
Icon={IconChevronDown}
accent="tertiary"
/>
}
dropdownComponents={
<RecordPickerComponentInstanceContext.Provider
value={{ instanceId: dropdownId }}
>
<SingleRecordSelect
EmptyIcon={IconForbid}
emptyLabel={'No ' + objectNameSingular}
onCancel={() => closeDropdown()}
onRecordSelected={handleRecordSelected}
objectNameSingular={objectNameSingular}
recordPickerInstanceId={dropdownId}
selectedRecordIds={
draftValue?.value &&
!isStandaloneVariableString(draftValue.value)
? [draftValue.value]
: []
}
/>
</RecordPickerComponentInstanceContext.Provider>
}
dropdownHotkeyScope={{
scope: dropdownId,
}}
/>
</DropdownScope>
</StyledFormSelectContainer>
<StyledSearchVariablesDropdownContainer>
<SearchVariablesDropdown
inputId={variablesDropdownId}
onVariableSelect={handleVariableTagInsert}
disabled={false}
/>
</StyledSearchVariablesDropdownContainer>
</StyledFormFieldInputRowContainer>
</StyledFormFieldInputContainer>
);
};

View File

@ -1,6 +1,3 @@
import { WorkflowEditActionFormRecordCreate } from '@/workflow/components/WorkflowEditActionFormRecordCreate';
import { WorkflowEditActionFormSendEmail } from '@/workflow/components/WorkflowEditActionFormSendEmail';
import { WorkflowEditActionFormServerlessFunction } from '@/workflow/components/WorkflowEditActionFormServerlessFunction';
import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/components/WorkflowEditTriggerDatabaseEventForm';
import { WorkflowEditTriggerManualForm } from '@/workflow/components/WorkflowEditTriggerManualForm';
import {
@ -11,6 +8,11 @@ import {
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
import { isWorkflowRecordCreateAction } from '@/workflow/utils/isWorkflowRecordCreateAction';
import { isWorkflowRecordUpdateAction } from '@/workflow/utils/isWorkflowRecordUpdateAction';
import { WorkflowEditActionFormRecordCreate } from '@/workflow/workflow-actions/components/WorkflowEditActionFormRecordCreate';
import { WorkflowEditActionFormRecordUpdate } from '@/workflow/workflow-actions/components/WorkflowEditActionFormRecordUpdate';
import { WorkflowEditActionFormSendEmail } from '@/workflow/workflow-actions/components/WorkflowEditActionFormSendEmail';
import { WorkflowEditActionFormServerlessFunction } from '@/workflow/workflow-actions/components/WorkflowEditActionFormServerlessFunction';
import { isDefined } from 'twenty-ui';
type WorkflowStepDetailProps =
@ -102,6 +104,15 @@ export const WorkflowStepDetail = ({
);
}
if (isWorkflowRecordUpdateAction(stepDefinition.definition)) {
return (
<WorkflowEditActionFormRecordUpdate
action={stepDefinition.definition}
actionOptions={props}
/>
);
}
return null;
}
}