Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { FormFieldInputInnerContainer } from '@/object-record/record-field/ui/fo
import { FormFieldInputRowContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputRowContainer';
import { VariableChipStandalone } from '@/object-record/record-field/ui/form-types/components/VariableChipStandalone';
import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
import { InputHint } from '@/ui/input/components/InputHint';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { Select } from '@/ui/input/components/Select';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
Expand All @@ -18,6 +19,7 @@ import { type SelectOption } from 'twenty-ui/input';

type FormSelectFieldInputProps = {
label?: string;
hint?: string;
defaultValue: string | undefined;
onChange: (value: string | null) => void;
VariablePicker?: VariablePickerComponent;
Expand All @@ -27,6 +29,7 @@ type FormSelectFieldInputProps = {

export const FormSelectFieldInput = ({
label,
hint,
defaultValue,
onChange,
VariablePicker,
Expand Down Expand Up @@ -162,6 +165,7 @@ export const FormSelectFieldInput = ({
/>
)}
</FormFieldInputRowContainer>
{hint && <InputHint>{hint}</InputHint>}
</FormFieldInputContainer>
);
};
Original file line number Diff line number Diff line change
@@ -1,56 +1,15 @@
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
import { InputHint } from '@/ui/input/components/InputHint';
import { InputLabel } from '@/ui/input/components/InputLabel';
import type { WorkflowCronTrigger } from '@/workflow/types/Workflow';
import { describeCronExpression } from '@/workflow/workflow-trigger/utils/cron-to-human/describeCronExpression';
import { convertScheduleToCronExpression } from '@/workflow/workflow-trigger/utils/cron-to-human/utils/convertScheduleToCronExpression';
import { getTriggerScheduleDescription } from '@/workflow/workflow-trigger/utils/getTriggerScheduleDescription';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { CronExpressionParser } from 'cron-parser';
import { type Locale } from 'date-fns';
import { useRecoilValue } from 'recoil';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { formatDateTimeString } from '~/utils/string/formatDateTimeString';

const convertScheduleToCronExpression = (
trigger: WorkflowCronTrigger,
): string | null => {
switch (trigger.settings.type) {
case 'CUSTOM':
return trigger.settings.pattern;
case 'DAYS':
return `${trigger.settings.schedule.minute} ${trigger.settings.schedule.hour} */${trigger.settings.schedule.day} * *`;
case 'HOURS':
return `${trigger.settings.schedule.minute} */${trigger.settings.schedule.hour} * * *`;
case 'MINUTES':
return `*/${trigger.settings.schedule.minute} * * * *`;
default:
return null;
}
};

const getTriggerScheduleDescription = (
trigger: WorkflowCronTrigger,
localeCatalog?: Locale,
): string | null => {
const cronExpression = convertScheduleToCronExpression(trigger);

if (!cronExpression) {
return null;
}

try {
return describeCronExpression(
cronExpression,
{ use24HourTimeFormat: true },
localeCatalog,
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : t`Invalid cron expression`;
return errorMessage;
}
};

const getNextExecutions = (cronExpression: string): Date[] => {
try {
const interval = CronExpressionParser.parse(cronExpression, {
Expand All @@ -70,37 +29,48 @@ const StyledContainer = styled.div`
`;

const StyledSection = styled.div`
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(3)} ${({ theme }) => theme.spacing(4)};
`;

const StyledScheduleDescription = styled.div`
color: ${({ theme }) => theme.font.color.primary};
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
`;

const StyledScheduleSubtext = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.xs};
const StyledScheduleTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-bottom: ${({ theme }) => theme.spacing(1)};
`;

const StyledExecutionItem = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
font-family: monospace;
font-size: ${({ theme }) => theme.font.size.xs};
margin-bottom: ${({ theme }) => theme.spacing(0.5)};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-top: ${({ theme }) => theme.spacing(0.5)};
`;

type CronExpressionHelperProps = {
trigger: WorkflowCronTrigger;
isVisible?: boolean;
isScheduleVisible?: boolean;
isUpcomingExecutionVisible?: boolean;
};

export const CronExpressionHelper = ({
trigger,
isVisible = true,
isScheduleVisible = true,
isUpcomingExecutionVisible = true,
}: CronExpressionHelperProps) => {
const { timeZone, dateFormat, timeFormat } = useDateTimeFormat();
const dateLocale = useRecoilValue(dateLocaleState);
Expand Down Expand Up @@ -143,19 +113,17 @@ export const CronExpressionHelper = ({

return (
<StyledContainer>
<StyledSection>
<InputLabel>{t`Schedule`}</InputLabel>
<StyledScheduleDescription>
{customDescription}
</StyledScheduleDescription>
<StyledScheduleSubtext>
{t`Schedule runs in UTC timezone.`}
</StyledScheduleSubtext>
</StyledSection>

{nextExecutions.length > 0 && (
{isScheduleVisible && (
<StyledSection>
<StyledScheduleTitle>{t`Schedule`}</StyledScheduleTitle>
<StyledScheduleDescription>
{customDescription}
</StyledScheduleDescription>
</StyledSection>
)}
{nextExecutions.length > 0 && isUpcomingExecutionVisible && (
<StyledSection>
<InputLabel>{t`Upcoming execution times (${timeZone})`}</InputLabel>
<StyledScheduleTitle>{t`Upcoming execution time (${timeZone})`}</StyledScheduleTitle>
{nextExecutions.slice(0, 3).map((execution, index) => (
<StyledExecutionItem key={index}>
{formatDateTimeString({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,28 @@ const CronExpressionHelperComponent = lazy(() =>
type CronExpressionHelperLazyProps = {
trigger: WorkflowCronTrigger;
isVisible?: boolean;
isScheduleVisible?: boolean;
isUpcomingExecutionVisible?: boolean;
};

export const CronExpressionHelperLazy = ({
trigger,
isVisible = true,
isScheduleVisible = true,
isUpcomingExecutionVisible = true,
}: CronExpressionHelperLazyProps) => {
if (!isVisible) {
return null;
}

return (
<Suspense fallback={null}>
<CronExpressionHelperComponent trigger={trigger} isVisible={isVisible} />
<CronExpressionHelperComponent
trigger={trigger}
isVisible={isVisible}
isScheduleVisible={isScheduleVisible}
isUpcomingExecutionVisible={isUpcomingExecutionVisible}
/>
</Suspense>
);
};
Original file line number Diff line number Diff line change
@@ -1,25 +1,30 @@
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { FormNumberFieldInput } from '@/object-record/record-field/ui/form-types/components/FormNumberFieldInput';
import { FormSelectFieldInput } from '@/object-record/record-field/ui/form-types/components/FormSelectFieldInput';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { Select } from '@/ui/input/components/Select';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { type WorkflowCronTrigger } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { CronExpressionHelperLazy } from '@/workflow/workflow-trigger/components/CronExpressionHelperLazy';
import { CRON_TRIGGER_INTERVAL_OPTIONS } from '@/workflow/workflow-trigger/constants/CronTriggerIntervalOptions';
import {
CRON_TRIGGER_INTERVAL_OPTIONS,
type CronTriggerInterval,
} from '@/workflow/workflow-trigger/constants/CronTriggerIntervalOptions';
import { getCronTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getCronTriggerDefaultSettings';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
import { getTriggerHeaderType } from '@/workflow/workflow-trigger/utils/getTriggerHeaderType';
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
import { getTriggerIconColor } from '@/workflow/workflow-trigger/utils/getTriggerIconColor';
import { getTriggerScheduleDescription } from '@/workflow/workflow-trigger/utils/getTriggerScheduleDescription';
import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro';
import { isNumber } from '@sniptt/guards';
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { useIcons } from 'twenty-ui/display';
import { dateLocaleState } from '~/localization/states/dateLocaleState';

type WorkflowEditTriggerCronFormProps = {
trigger: WorkflowCronTrigger;
Expand Down Expand Up @@ -51,6 +56,7 @@ export const WorkflowEditTriggerCronForm = ({
const theme = useTheme();
const [errorMessages, setErrorMessages] = useState<FormErrorMessages>({});
const [errorMessagesVisible, setErrorMessagesVisible] = useState(false);
const dateLocale = useRecoilValue(dateLocaleState);

const { getIcon } = useIcons();

Expand All @@ -60,6 +66,11 @@ export const WorkflowEditTriggerCronForm = ({
const headerTitle = trigger.name ?? defaultLabel;
const headerType = getTriggerHeaderType(trigger);

const customDescription = getTriggerScheduleDescription(
trigger,
dateLocale.localeCatalog,
);

const onBlur = () => {
setErrorMessagesVisible(true);
};
Expand All @@ -85,30 +96,30 @@ export const WorkflowEditTriggerCronForm = ({
iconTooltip={getTriggerDefaultLabel(trigger)}
/>
<WorkflowStepBody>
<Select
dropdownId="workflow-edit-cron-trigger-interval"
<FormSelectFieldInput
label={t`Trigger interval`}
fullWidth
disabled={triggerOptions.readonly}
value={trigger.settings.type}
hint={t`Cron will be triggered at UTC time`}
defaultValue={trigger.settings.type}
options={CRON_TRIGGER_INTERVAL_OPTIONS}
readonly={triggerOptions.readonly}
onChange={(newTriggerType) => {
if (triggerOptions.readonly === true) {
return;
}

if (!newTriggerType) {
return;
}
setErrorMessages({});

setErrorMessagesVisible(false);

triggerOptions.onTriggerUpdate({
...trigger,
settings: getCronTriggerDefaultSettings(newTriggerType),
settings: getCronTriggerDefaultSettings(
newTriggerType as CronTriggerInterval,
),
});
}}
withSearchInput
dropdownOffset={{ y: parseInt(theme.spacing(1), 10) }}
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
/>
{trigger.settings.type === 'CUSTOM' && (
<>
Expand All @@ -117,7 +128,7 @@ export const WorkflowEditTriggerCronForm = ({
placeholder="0 */1 * * *"
error={errorMessagesVisible ? errorMessages.CUSTOM : undefined}
onBlur={onBlur}
hint={t`Format: [Minute] [Hour] [Day of Month] [Month] [Day of Week]`}
hint={customDescription ?? ''}
readonly={triggerOptions.readonly}
defaultValue={trigger.settings.pattern}
onChange={async (newPattern: string) => {
Expand Down Expand Up @@ -154,6 +165,7 @@ export const WorkflowEditTriggerCronForm = ({
<CronExpressionHelperLazy
trigger={trigger}
isVisible={!!trigger.settings.pattern && !errorMessages.CUSTOM}
isScheduleVisible={false}
/>
</>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { type WorkflowCronTrigger } from '@/workflow/types/Workflow';

export const convertScheduleToCronExpression = (
trigger: WorkflowCronTrigger,
): string | null => {
switch (trigger.settings.type) {
case 'CUSTOM':
return trigger.settings.pattern;
case 'DAYS':
return `${trigger.settings.schedule.minute} ${trigger.settings.schedule.hour} */${trigger.settings.schedule.day} * *`;
case 'HOURS':
return `${trigger.settings.schedule.minute} */${trigger.settings.schedule.hour} * * *`;
case 'MINUTES':
return `*/${trigger.settings.schedule.minute} * * * *`;
default:
return null;
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { type WorkflowCronTrigger } from '@/workflow/types/Workflow';
import { describeCronExpression } from '@/workflow/workflow-trigger/utils/cron-to-human/describeCronExpression';
import { convertScheduleToCronExpression } from '@/workflow/workflow-trigger/utils/cron-to-human/utils/convertScheduleToCronExpression';
import { t } from '@lingui/core/macro';

export const getTriggerScheduleDescription = (
trigger: WorkflowCronTrigger,
localeCatalog?: Locale,
): string | null => {
const cronExpression = convertScheduleToCronExpression(trigger);

if (!cronExpression) {
return null;
}

try {
return describeCronExpression(
cronExpression,
{ use24HourTimeFormat: true },
localeCatalog,
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : t`Invalid cron expression`;
return errorMessage;
}
};