diff --git a/package.json b/package.json index cebb2a269..63a28b895 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "bytes": "^3.1.2", "class-transformer": "^0.5.1", "clsx": "^2.1.1", + "cron-validate": "^1.4.5", "cross-env": "^7.0.3", "css-loader": "^7.1.2", "danger-plugin-todos": "^1.3.1", diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormFieldInputContainer.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormFieldInputContainer.tsx index 6de0d78b3..3002a6134 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormFieldInputContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormFieldInputContainer.tsx @@ -1,4 +1,7 @@ import styled from '@emotion/styled'; +import { ReactNode } from 'react'; +import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; +import { FormFieldInputHotKeyScope } from '@/object-record/record-field/form-types/constants/FormFieldInputHotKeyScope'; const StyledFormFieldInputContainer = styled.div` display: flex; @@ -6,4 +9,35 @@ const StyledFormFieldInputContainer = styled.div` width: 100%; `; -export const FormFieldInputContainer = StyledFormFieldInputContainer; +export const FormFieldInputContainer = ({ + children, + testId, +}: { + children: ReactNode; + testId?: string; +}) => { + const { + goBackToPreviousHotkeyScope, + setHotkeyScopeAndMemorizePreviousScope, + } = usePreviousHotkeyScope(); + + const onFocus = () => { + setHotkeyScopeAndMemorizePreviousScope( + FormFieldInputHotKeyScope.FormFieldInput, + ); + }; + + const onBlur = () => { + goBackToPreviousHotkeyScope(); + }; + + return ( + + {children} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormMultiSelectFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormMultiSelectFieldInput.tsx index c97d3eee9..a50f4d147 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormMultiSelectFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormMultiSelectFieldInput.tsx @@ -178,7 +178,7 @@ export const FormMultiSelectFieldInput = ({ const placeholderText = placeholder ?? label; return ( - + {label ? {label} : null} diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormNumberFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormNumberFieldInput.tsx index 142cc82e7..f19b5ac22 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormNumberFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormNumberFieldInput.tsx @@ -1,4 +1,3 @@ -import { FormFieldHint } from '@/object-record/record-field/form-types/components/FormFieldHint'; 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'; @@ -14,6 +13,8 @@ import { canBeCastAsNumberOrNull, castAsNumberOrNull, } from '~/utils/cast-as-number-or-null'; +import { InputErrorHelper } from '@/ui/input/components/InputErrorHelper'; +import { InputHint } from '@/ui/input/components/InputHint'; const StyledInput = styled(TextInput)` padding: ${({ theme }) => `${theme.spacing(1)} ${theme.spacing(2)}`}; @@ -21,9 +22,11 @@ const StyledInput = styled(TextInput)` type FormNumberFieldInputProps = { label?: string; + error?: string; placeholder: string; defaultValue: number | string | undefined; onPersist: (value: number | null | string) => void; + onBlur?: () => void; VariablePicker?: VariablePickerComponent; hint?: string; readonly?: boolean; @@ -31,9 +34,11 @@ type FormNumberFieldInputProps = { export const FormNumberFieldInput = ({ label, + error, placeholder, defaultValue, onPersist, + onBlur, VariablePicker, hint, readonly, @@ -105,6 +110,7 @@ export const FormNumberFieldInput = ({ {draftValue.type === 'static' ? ( - {hint ? {hint} : null} + {hint ? {hint} : null} + {error && {error}} ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormTextFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormTextFieldInput.tsx index 3f4e998f7..35d531086 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormTextFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormTextFieldInput.tsx @@ -8,12 +8,17 @@ import { InputLabel } from '@/ui/input/components/InputLabel'; import { parseEditorContent } from '@/workflow/workflow-variables/utils/parseEditorContent'; import { useId } from 'react'; import { isDefined } from 'twenty-shared'; +import { InputErrorHelper } from '@/ui/input/components/InputErrorHelper'; +import { InputHint } from '@/ui/input/components/InputHint'; type FormTextFieldInputProps = { label?: string; + error?: string; + hint?: string; defaultValue: string | undefined; placeholder: string; onPersist: (value: string) => void; + onBlur?: () => void; multiline?: boolean; readonly?: boolean; VariablePicker?: VariablePickerComponent; @@ -21,9 +26,12 @@ type FormTextFieldInputProps = { export const FormTextFieldInput = ({ label, + error, + hint, defaultValue, placeholder, onPersist, + onBlur, multiline, readonly, VariablePicker, @@ -65,6 +73,7 @@ export const FormTextFieldInput = ({ ) : null} + {hint && {hint}} + {error && {error}} ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/constants/FormFieldInputHotKeyScope.ts b/packages/twenty-front/src/modules/object-record/record-field/form-types/constants/FormFieldInputHotKeyScope.ts new file mode 100644 index 000000000..38c1c948c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/constants/FormFieldInputHotKeyScope.ts @@ -0,0 +1,3 @@ +export enum FormFieldInputHotKeyScope { + FormFieldInput = 'form-field-input', +} diff --git a/packages/twenty-front/src/modules/ui/input/components/InputErrorHelper.tsx b/packages/twenty-front/src/modules/ui/input/components/InputErrorHelper.tsx new file mode 100644 index 000000000..5c0b1deb5 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/InputErrorHelper.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import styled from '@emotion/styled'; + +const StyledInputErrorHelper = styled.div` + color: ${({ theme }) => theme.color.red}; + font-size: ${({ theme }) => theme.font.size.xs}; +`; + +export const InputErrorHelper = ({ + children, +}: { + children: React.ReactNode; +}) => ( + {children} +); diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormFieldHint.tsx b/packages/twenty-front/src/modules/ui/input/components/InputHint.tsx similarity index 59% rename from packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormFieldHint.tsx rename to packages/twenty-front/src/modules/ui/input/components/InputHint.tsx index b172a457b..85633dc95 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormFieldHint.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/InputHint.tsx @@ -1,10 +1,10 @@ import styled from '@emotion/styled'; -const StyledFormFieldHint = styled.div` +const StyledInputHint = styled.div` color: ${({ theme }) => theme.font.color.light}; font-size: ${({ theme }) => theme.font.size.xs}; font-weight: ${({ theme }) => theme.font.weight.regular}; - margin-top: ${({ theme }) => theme.spacing(1)}; + margin-top: ${({ theme }) => theme.spacing(0.5)}; `; -export const FormFieldHint = StyledFormFieldHint; +export { StyledInputHint as InputHint }; diff --git a/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx b/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx index 2f0150589..b4bb09178 100644 --- a/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx @@ -19,6 +19,7 @@ import { } from 'twenty-ui'; import { useCombinedRefs } from '~/hooks/useCombinedRefs'; import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly'; +import { InputErrorHelper } from '@/ui/input/components/InputErrorHelper'; const StyledContainer = styled.div< Pick @@ -83,9 +84,7 @@ const StyledInput = styled.input< } `; -const StyledErrorHelper = styled.div` - color: ${({ theme }) => theme.color.red}; - font-size: ${({ theme }) => theme.font.size.xs}; +const StyledErrorHelper = styled(InputErrorHelper)` padding: ${({ theme }) => theme.spacing(1)}; `; diff --git a/packages/twenty-front/src/modules/workflow/hooks/useAllActiveWorkflowVersions.ts b/packages/twenty-front/src/modules/workflow/hooks/useAllActiveWorkflowVersions.ts index 37425e232..b3d6586c5 100644 --- a/packages/twenty-front/src/modules/workflow/hooks/useAllActiveWorkflowVersions.ts +++ b/packages/twenty-front/src/modules/workflow/hooks/useAllActiveWorkflowVersions.ts @@ -62,7 +62,9 @@ export const useAllActiveWorkflowVersions = ({ if (!isDefined(objectMetadataItem)) { return { records: records.filter( - (record) => !isDefined(record.trigger?.settings.objectType), + (record) => + record.trigger?.type !== 'CRON' && + !isDefined(record.trigger?.settings.objectType), ), }; } diff --git a/packages/twenty-front/src/modules/workflow/types/Workflow.ts b/packages/twenty-front/src/modules/workflow/types/Workflow.ts index e7c2dfa15..691032a70 100644 --- a/packages/twenty-front/src/modules/workflow/types/Workflow.ts +++ b/packages/twenty-front/src/modules/workflow/types/Workflow.ts @@ -122,6 +122,24 @@ export type WorkflowManualTrigger = BaseTrigger & { }; }; +export type WorkflowCronTrigger = BaseTrigger & { + type: 'CRON'; + settings: ( + | { + type: 'HOURS'; + schedule: { hour: number; minute: number }; + } + | { + type: 'MINUTES'; + schedule: { minute: number }; + } + | { + type: 'CUSTOM'; + pattern: string; + } + ) & { outputSchema: object }; +}; + export type WorkflowManualTriggerSettings = WorkflowManualTrigger['settings']; export type WorkflowManualTriggerAvailability = @@ -130,7 +148,8 @@ export type WorkflowManualTriggerAvailability = export type WorkflowTrigger = | WorkflowDatabaseEventTrigger - | WorkflowManualTrigger; + | WorkflowManualTrigger + | WorkflowCronTrigger; export type WorkflowTriggerType = WorkflowTrigger['type']; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeIcon.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeIcon.tsx index e355bd647..bbdd9313b 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeIcon.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramStepNodeIcon.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { assertUnreachable } from '@/workflow/utils/assertUnreachable'; import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey'; @@ -46,6 +47,16 @@ export const WorkflowDiagramStepNodeIcon = ({ ); } + case 'CRON': { + return ( + + + + ); + } } return assertUnreachable(data.triggerType); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateWorkflowDiagram.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateWorkflowDiagram.ts index 906d8b0b9..450249786 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateWorkflowDiagram.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateWorkflowDiagram.ts @@ -73,6 +73,14 @@ export const generateWorkflowDiagram = ({ break; } + case 'CRON': { + triggerDefaultLabel = 'On a Schedule'; + triggerIcon = getTriggerIcon({ + type: 'CRON', + }); + + break; + } case 'DATABASE_EVENT': { const triggerEvent = splitWorkflowTriggerEventName( trigger.settings.eventName, diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowStepDetail.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowStepDetail.tsx index eee236a82..985b05638 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowStepDetail.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowStepDetail.tsx @@ -11,6 +11,7 @@ import { WorkflowEditActionFormSendEmail } from '@/workflow/workflow-steps/workf import { WorkflowEditActionFormUpdateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormUpdateRecord'; import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm'; import { WorkflowEditTriggerManualForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerManualForm'; +import { WorkflowEditTriggerCronForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm'; import { Suspense, lazy } from 'react'; import { isDefined } from 'twenty-shared'; import { RightDrawerSkeletonLoader } from '~/loading/components/RightDrawerSkeletonLoader'; @@ -71,6 +72,14 @@ export const WorkflowStepDetail = ({ /> ); } + case 'CRON': { + return ( + + ); + } } return assertUnreachable( diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowSingleRecordPicker.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowSingleRecordPicker.tsx index fb37b76e9..9985c57d0 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowSingleRecordPicker.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowSingleRecordPicker.tsx @@ -125,7 +125,7 @@ export const WorkflowSingleRecordPicker = ({ }; return ( - + {label ? {label} : null} diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionFormCreateRecord.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionFormCreateRecord.stories.tsx index ba512c0b9..15667a794 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionFormCreateRecord.stories.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionFormCreateRecord.stories.tsx @@ -9,6 +9,7 @@ import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorato import { graphqlMocks } from '~/testing/graphqlMocks'; import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow'; import { WorkflowEditActionFormCreateRecord } from '../WorkflowEditActionFormCreateRecord'; +import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator'; const meta: Meta = { title: 'Modules/Workflow/WorkflowEditActionFormCreateRecord', @@ -45,6 +46,7 @@ const meta: Meta = { ComponentDecorator, ObjectMetadataItemsDecorator, SnackBarDecorator, + WorkspaceDecorator, ], }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionFormDeleteRecord.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionFormDeleteRecord.stories.tsx index 74f435a6d..436bb07df 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionFormDeleteRecord.stories.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionFormDeleteRecord.stories.tsx @@ -10,6 +10,7 @@ import { graphqlMocks } from '~/testing/graphqlMocks'; import { getPeopleMock } from '~/testing/mock-data/people'; import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow'; import { WorkflowEditActionFormDeleteRecord } from '../WorkflowEditActionFormDeleteRecord'; +import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator'; const DEFAULT_ACTION = { id: getWorkflowNodeIdMock(), @@ -49,6 +50,7 @@ const meta: Meta = { ObjectMetadataItemsDecorator, SnackBarDecorator, RouterDecorator, + WorkspaceDecorator, ], }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionFormUpdateRecord.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionFormUpdateRecord.stories.tsx index 7a0c9af2a..c422fd47e 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionFormUpdateRecord.stories.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/__stories__/WorkflowEditActionFormUpdateRecord.stories.tsx @@ -10,6 +10,7 @@ import { graphqlMocks } from '~/testing/graphqlMocks'; import { getPeopleMock } from '~/testing/mock-data/people'; import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow'; import { WorkflowEditActionFormUpdateRecord } from '../WorkflowEditActionFormUpdateRecord'; +import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator'; const DEFAULT_ACTION = { id: getWorkflowNodeIdMock(), @@ -62,6 +63,7 @@ const meta: Meta = { ObjectMetadataItemsDecorator, SnackBarDecorator, RouterDecorator, + WorkspaceDecorator, ], }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm.tsx b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm.tsx new file mode 100644 index 000000000..049a49ac9 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm.tsx @@ -0,0 +1,291 @@ +import { WorkflowCronTrigger } from '@/workflow/types/Workflow'; +import { useIcons } from 'twenty-ui'; +import { useTheme } from '@emotion/react'; +import { Select } from '@/ui/input/components/Select'; +import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader'; +import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; +import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon'; +import { isDefined } from 'twenty-shared'; +import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerLabel'; +import { CRON_TRIGGER_INTERVAL_OPTIONS } from '@/workflow/workflow-trigger/constants/CronTriggerIntervalOptions'; +import cron from 'cron-validate'; +import { useState } from 'react'; +import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput'; +import { FormNumberFieldInput } from '@/object-record/record-field/form-types/components/FormNumberFieldInput'; +import { isNumber } from '@sniptt/guards'; +import { getCronTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getCronTriggerDefaultSettings'; + +type WorkflowEditTriggerCronFormProps = { + trigger: WorkflowCronTrigger; + triggerOptions: + | { + readonly: true; + onTriggerUpdate?: undefined; + } + | { + readonly?: false; + onTriggerUpdate: (trigger: WorkflowCronTrigger) => void; + }; +}; + +type FormErrorMessages = { + CUSTOM?: string | undefined; + HOURS_hour?: string | undefined; + HOURS_minute?: string | undefined; + MINUTES?: string | undefined; +}; + +export const WorkflowEditTriggerCronForm = ({ + trigger, + triggerOptions, +}: WorkflowEditTriggerCronFormProps) => { + const theme = useTheme(); + const [errorMessages, setErrorMessages] = useState({}); + const [errorMessagesVisible, setErrorMessagesVisible] = useState(false); + + const { getIcon } = useIcons(); + + const headerIcon = getTriggerIcon({ + type: 'CRON', + }); + + const defaultLabel = + getTriggerDefaultLabel({ + type: 'CRON', + }) ?? ''; + + const headerTitle = isDefined(trigger.name) ? trigger.name : defaultLabel; + + const headerType = 'Trigger'; + + const onBlur = () => { + setErrorMessagesVisible(true); + }; + + return ( + <> + { + if (triggerOptions.readonly === true) { + return; + } + + triggerOptions.onTriggerUpdate({ + ...trigger, + name: newName, + }); + }} + Icon={getIcon(headerIcon)} + iconColor={theme.font.color.tertiary} + initialTitle={headerTitle} + headerType={headerType} + /> + +