diff --git a/web/app/components/base/date-and-time-picker/utils/dayjs.ts b/web/app/components/base/date-and-time-picker/utils/dayjs.ts index 808b50247a..671c2f2bde 100644 --- a/web/app/components/base/date-and-time-picker/utils/dayjs.ts +++ b/web/app/components/base/date-and-time-picker/utils/dayjs.ts @@ -107,7 +107,15 @@ export const convertTimezoneToOffsetStr = (timezone?: string) => { const tzItem = tz.find(item => item.value === timezone) if (!tzItem) return DEFAULT_OFFSET_STR - return `UTC${tzItem.name.charAt(0)}${tzItem.name.charAt(2)}` + // Extract offset from name format like "-11:00 Niue Time - Alofi" + // Name format is always "{offset}:00 {timezone name}" + const offsetMatch = tzItem.name.match(/^([+-]?\d{1,2}):00/) + if (!offsetMatch) + return DEFAULT_OFFSET_STR + // Parse offset as integer to remove leading zeros (e.g., "-05" → "-5") + const offsetNum = Number.parseInt(offsetMatch[1], 10) + const sign = offsetNum >= 0 ? '+' : '' + return `UTC${sign}${offsetNum}` } export const isDayjsObject = (value: unknown): value is Dayjs => dayjs.isDayjs(value) diff --git a/web/app/components/base/timezone-label/__tests__/index.test.tsx b/web/app/components/base/timezone-label/__tests__/index.test.tsx new file mode 100644 index 0000000000..1c36ac929a --- /dev/null +++ b/web/app/components/base/timezone-label/__tests__/index.test.tsx @@ -0,0 +1,145 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import TimezoneLabel from '../index' + +// Mock the convertTimezoneToOffsetStr function +jest.mock('@/app/components/base/date-and-time-picker/utils/dayjs', () => ({ + convertTimezoneToOffsetStr: (timezone?: string) => { + if (!timezone) return 'UTC+0' + + // Mock implementation matching the actual timezone conversions + const timezoneOffsets: Record = { + 'Asia/Shanghai': 'UTC+8', + 'America/New_York': 'UTC-5', + 'Europe/London': 'UTC+0', + 'Pacific/Auckland': 'UTC+13', + 'Pacific/Niue': 'UTC-11', + 'UTC': 'UTC+0', + } + + return timezoneOffsets[timezone] || 'UTC+0' + }, +})) + +describe('TimezoneLabel', () => { + describe('Basic Rendering', () => { + it('should render timezone offset correctly', () => { + render() + expect(screen.getByText('UTC+8')).toBeInTheDocument() + }) + + it('should display UTC+0 for invalid timezone', () => { + render() + expect(screen.getByText('UTC+0')).toBeInTheDocument() + }) + + it('should handle UTC timezone', () => { + render() + expect(screen.getByText('UTC+0')).toBeInTheDocument() + }) + }) + + describe('Styling', () => { + it('should apply default tertiary text color', () => { + const { container } = render() + const span = container.querySelector('span') + expect(span).toHaveClass('text-text-tertiary') + expect(span).not.toHaveClass('text-text-quaternary') + }) + + it('should apply quaternary text color in inline mode', () => { + const { container } = render() + const span = container.querySelector('span') + expect(span).toHaveClass('text-text-quaternary') + }) + + it('should apply custom className', () => { + const { container } = render( + , + ) + const span = container.querySelector('span') + expect(span).toHaveClass('custom-class') + }) + + it('should maintain default classes with custom className', () => { + const { container } = render( + , + ) + const span = container.querySelector('span') + expect(span).toHaveClass('system-sm-regular') + expect(span).toHaveClass('text-text-tertiary') + expect(span).toHaveClass('custom-class') + }) + }) + + describe('Tooltip', () => { + it('should include timezone information in title attribute', () => { + const { container } = render() + const span = container.querySelector('span') + expect(span).toHaveAttribute('title', 'Timezone: Asia/Shanghai (UTC+8)') + }) + + it('should update tooltip for different timezones', () => { + const { container } = render() + const span = container.querySelector('span') + expect(span).toHaveAttribute('title', 'Timezone: America/New_York (UTC-5)') + }) + }) + + describe('Edge Cases', () => { + it('should handle positive offset timezones', () => { + render() + expect(screen.getByText('UTC+13')).toBeInTheDocument() + }) + + it('should handle negative offset timezones', () => { + render() + expect(screen.getByText('UTC-11')).toBeInTheDocument() + }) + + it('should handle zero offset timezone', () => { + render() + expect(screen.getByText('UTC+0')).toBeInTheDocument() + }) + }) + + describe('Props Variations', () => { + it('should render with only required timezone prop', () => { + render() + expect(screen.getByText('UTC+8')).toBeInTheDocument() + }) + + it('should render with all props', () => { + const { container } = render( + , + ) + const span = container.querySelector('span') + expect(screen.getByText('UTC-5')).toBeInTheDocument() + expect(span).toHaveClass('text-xs') + expect(span).toHaveClass('text-text-quaternary') + }) + }) + + describe('Memoization', () => { + it('should memoize offset calculation', () => { + const { rerender } = render() + expect(screen.getByText('UTC+8')).toBeInTheDocument() + + // Rerender with same props should not trigger recalculation + rerender() + expect(screen.getByText('UTC+8')).toBeInTheDocument() + }) + + it('should recalculate when timezone changes', () => { + const { rerender } = render() + expect(screen.getByText('UTC+8')).toBeInTheDocument() + + rerender() + expect(screen.getByText('UTC-5')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/timezone-label/index.tsx b/web/app/components/base/timezone-label/index.tsx new file mode 100644 index 0000000000..b151ceb9b8 --- /dev/null +++ b/web/app/components/base/timezone-label/index.tsx @@ -0,0 +1,56 @@ +import React, { useMemo } from 'react' +import { convertTimezoneToOffsetStr } from '@/app/components/base/date-and-time-picker/utils/dayjs' +import cn from '@/utils/classnames' + +export type TimezoneLabelProps = { + /** IANA timezone identifier (e.g., 'Asia/Shanghai', 'America/New_York') */ + timezone: string + /** Additional CSS classes to apply */ + className?: string + /** Use inline mode with lighter text color for secondary display */ + inline?: boolean +} + +/** + * TimezoneLabel component displays timezone information in UTC offset format. + * + * @example + * // Standard display + * + * // Output: UTC+8 + * + * @example + * // Inline mode with lighter color + * + * // Output: UTC-5 + * + * @example + * // Custom styling + * + */ +const TimezoneLabel: React.FC = ({ + timezone, + className, + inline = false, +}) => { + // Memoize offset calculation to avoid redundant computations + const offsetStr = useMemo( + () => convertTimezoneToOffsetStr(timezone), + [timezone], + ) + + return ( + + {offsetStr} + + ) +} + +export default React.memo(TimezoneLabel) diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/next-execution-times.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/next-execution-times.tsx index 02e85e2724..f7a0c2e483 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/components/next-execution-times.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/components/next-execution-times.tsx @@ -2,6 +2,7 @@ import React from 'react' import { useTranslation } from 'react-i18next' import type { ScheduleTriggerNodeType } from '../types' import { getFormattedExecutionTimes } from '../utils/execution-time-calculator' +import TimezoneLabel from '@/app/components/base/timezone-label' type NextExecutionTimesProps = { data: ScheduleTriggerNodeType @@ -31,6 +32,8 @@ const NextExecutionTimes = ({ data }: NextExecutionTimesProps) => { {time} + {' '} + ))} diff --git a/web/app/components/workflow/nodes/trigger-schedule/node.tsx b/web/app/components/workflow/nodes/trigger-schedule/node.tsx index 9870ef211a..c20729488a 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/node.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/node.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next' import type { ScheduleTriggerNodeType } from './types' import type { NodeProps } from '@/app/components/workflow/types' import { getNextExecutionTime } from './utils/execution-time-calculator' +import TimezoneLabel from '@/app/components/base/timezone-label' const i18nPrefix = 'workflow.nodes.triggerSchedule' @@ -19,8 +20,10 @@ const Node: FC> = ({
-
+
{getNextExecutionTime(data)} + {' '} +
diff --git a/web/app/components/workflow/nodes/trigger-schedule/panel.tsx b/web/app/components/workflow/nodes/trigger-schedule/panel.tsx index a74b64b460..6cd4dbfcc6 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/panel.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/panel.tsx @@ -12,6 +12,7 @@ import NextExecutionTimes from './components/next-execution-times' import MonthlyDaysSelector from './components/monthly-days-selector' import OnMinuteSelector from './components/on-minute-selector' import Input from '@/app/components/base/input' +import TimezoneLabel from '@/app/components/base/timezone-label' import useConfig from './use-config' const i18nPrefix = 'workflow.nodes.triggerSchedule' @@ -69,21 +70,24 @@ const Panel: FC> = ({ - { - if (time) { - const timeString = time.format('h:mm A') - handleTimeChange(timeString) - } - }} - onClear={() => { - handleTimeChange('12:00 AM') - }} - placeholder={t('workflow.nodes.triggerSchedule.selectTime')} - /> +
+ { + if (time) { + const timeString = time.format('h:mm A') + handleTimeChange(timeString) + } + }} + onClear={() => { + handleTimeChange('12:00 AM') + }} + placeholder={t('workflow.nodes.triggerSchedule.selectTime')} + /> + +
)}