fix: custom-cron-ui (#15786)

Fixes #14723 

### Summary
Fixes a UI while creating a custom CRON

### Changes Made
- updated `WorkflowEditTriggerCronForm.tsx` to use the predefined
`FormSelectFieldInput.tsx` instead of using `Select.tsx`
- added `hint` prop to `FormSelectFieldInput.tsx`
- updated styling for showing `Upcoming execution time`
- added conditional render for `Schedule` and `Upcoming Execution Time` 
- `Schedule`  section is not rendered in Custom Cron

### Steps to Reproduce (Before Fix)
1. Navigate to - /object/workflow/xxxx
2. Try adding a new Trigger
3. Set Trigger Interval to Cron (Custom)


### Before 

![Screenshot 2025-11-13 at 4 00
29 PM](https://github.com/user-attachments/assets/f85ab0fa-cb71-4257-9659-10e1404f17ed)


### After

![Screenshot 2025-11-13 at 4 00
55 PM](https://github.com/user-attachments/assets/db1d54ef-e5d5-42e2-9de5-c1f91f46088e)

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Aryan Bhati
2025-11-17 22:33:41 +05:30
committed by GitHub
parent 2a44bde848
commit 8d7ee47ec5
6 changed files with 115 additions and 77 deletions
@@ -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';
@@ -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;
@@ -27,6 +29,7 @@ type FormSelectFieldInputProps = {
export const FormSelectFieldInput = ({
label,
hint,
defaultValue,
onChange,
VariablePicker,
@@ -162,6 +165,7 @@ export const FormSelectFieldInput = ({
/>
)}
</FormFieldInputRowContainer>
{hint && <InputHint>{hint}</InputHint>}
</FormFieldInputContainer>
);
};
@@ -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, {
@@ -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);
@@ -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>
<InputLabel>{t`Upcoming execution times (${timeZone})`}</InputLabel>
<StyledScheduleTitle>{t`Schedule`}</StyledScheduleTitle>
<StyledScheduleDescription>
{customDescription}
</StyledScheduleDescription>
</StyledSection>
)}
{nextExecutions.length > 0 && isUpcomingExecutionVisible && (
<StyledSection>
<StyledScheduleTitle>{t`Upcoming execution time (${timeZone})`}</StyledScheduleTitle>
{nextExecutions.slice(0, 3).map((execution, index) => (
<StyledExecutionItem key={index}>
{formatDateTimeString({
@@ -10,11 +10,15 @@ 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;
@@ -22,7 +26,12 @@ export const CronExpressionHelperLazy = ({
return (
<Suspense fallback={null}>
<CronExpressionHelperComponent trigger={trigger} isVisible={isVisible} />
<CronExpressionHelperComponent
trigger={trigger}
isVisible={isVisible}
isScheduleVisible={isScheduleVisible}
isUpcomingExecutionVisible={isUpcomingExecutionVisible}
/>
</Suspense>
);
};
@@ -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;
@@ -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();
@@ -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);
};
@@ -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' && (
<>
@@ -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) => {
@@ -154,6 +165,7 @@ export const WorkflowEditTriggerCronForm = ({
<CronExpressionHelperLazy
trigger={trigger}
isVisible={!!trigger.settings.pattern && !errorMessages.CUSTOM}
isScheduleVisible={false}
/>
</>
)}
@@ -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;
}
};
@@ -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;
}
};