Skip to content

Commit

Permalink
Add record picker with variables (#8813)
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
thomtrp authored Nov 29, 2024
1 parent 29eb9fe commit b542b43
Show file tree
Hide file tree
Showing 12 changed files with 561 additions and 47 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export const WorkflowDiagramStepNodeBase = ({
</StyledStepNodeLabelIconContainer>
);
}
case 'RECORD_CRUD.UPDATE':
case 'RECORD_CRUD.CREATE': {
return (
<StyledStepNodeLabelIconContainer>
Expand All @@ -87,8 +88,8 @@ export const WorkflowDiagramStepNodeBase = ({
</StyledStepNodeLabelIconContainer>
);
}
case 'RECORD_CRUD.DELETE':
case 'RECORD_CRUD.UPDATE': {

case 'RECORD_CRUD.DELETE': {
return null;
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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>;
};
Original file line number Diff line number Diff line change
@@ -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>
);
};
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 =
Expand Down Expand Up @@ -102,6 +104,15 @@ export const WorkflowStepDetail = ({
);
}

if (isWorkflowRecordUpdateAction(stepDefinition.definition)) {
return (
<WorkflowEditActionFormRecordUpdate
action={stepDefinition.definition}
actionOptions={props}
/>
);
}

return null;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,9 @@ export const ACTIONS: Array<{
type: 'RECORD_CRUD.CREATE',
icon: IconAddressBook,
},
{
label: 'Update Record',
type: 'RECORD_CRUD.UPDATE',
icon: IconAddressBook,
},
];
Original file line number Diff line number Diff line change
Expand Up @@ -92,19 +92,41 @@ it('returns a valid definition for RECORD_CRUD.CREATE actions', () => {
});
});

it("throws for RECORD_CRUD.DELETE actions as it's not implemented yet", () => {
expect(() => {
it('returns a valid definition for RECORD_CRUD.UPDATE actions', () => {
expect(
getStepDefaultDefinition({
type: 'RECORD_CRUD.DELETE',
type: 'RECORD_CRUD.UPDATE',
activeObjectMetadataItems: generatedMockObjectMetadataItems,
});
}).toThrow('Not implemented yet');
}),
).toStrictEqual({
id: expect.any(String),
name: 'Update Record',
type: 'RECORD_CRUD',
valid: false,
settings: {
input: {
type: 'UPDATE',
objectName: generatedMockObjectMetadataItems[0].nameSingular,
objectRecord: {},
objectRecordId: '',
},
outputSchema: {},
errorHandlingOptions: {
continueOnFailure: {
value: false,
},
retryOnFailure: {
value: false,
},
},
},
});
});

it("throws for RECORD_CRUD.UPDATE actions as it's not implemented yet", () => {
it("throws for RECORD_CRUD.DELETE actions as it's not implemented yet", () => {
expect(() => {
getStepDefaultDefinition({
type: 'RECORD_CRUD.UPDATE',
type: 'RECORD_CRUD.DELETE',
activeObjectMetadataItems: generatedMockObjectMetadataItems,
});
}).toThrow('Not implemented yet');
Expand Down
Loading

0 comments on commit b542b43

Please sign in to comment.