admin管理员组

文章数量:1297068

The problem is when sending a date to the backend, it gets shifted by a day. For example, the startDate value is being converted to "2025-01-19T22:00:00.000Z", which corresponds to the local timezone offset. The backend expects the date to be in UTC, but it's still affected by local timezone conversion.

How do I fix this? I expect the output to be in UTC, not my local time.

const onSave = async (data: CreateFinanceAgreementForm | FieldValues) => {
    try {
        const { transactions, ...financeAgreement } = data;

        const transformDateToUTC = (date: Date | string) => {
            if (!date) return null;
            const dateObj = new Date(date);
            return dateObj.toISOString(); // Convert to UTC ISO string
        };

        const transformedData = {
            id,
            financeAgreement: { ...financeAgreement },
            startDate: moment(financeAgreement.startDate).utc().toISOString(),
            maturityDate: moment(financeAgreement.maturityDate).utc().toISOString(),
            expectedRepaymentDate: moment(financeAgreement.expectedRepaymentDate).utc().toISOString()
        };
        const response = await fetch(`/api/finance-agreement/${id}`, {
            method: 'PUT',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(transformedData)
        });
        if (!response.ok) {
            throw new Error('Failed to edit Finance Agreement');
        }
    } catch {
        showError('Failed to update the Finance Agreement. Please try again.');
    }
};
export const baseSchema: yup.ObjectSchema<DealFABase> = yup.object().shape({
    id: yup.string().optional(),
    currency: yup
        .string()
        .required('Currency is required')
        .matches(/^[A-Za-z]{1,3}$/, 'Currency must be alphabetic and not more than 3 letters'),
    startDate: yup
        .date()
        .transform((currentValue, originalValue) => (originalValue === '' ? null : currentValue))
        .required('Start Date is required'),
    maturityDate: yup
        .date()
        .transform((currentValue, originalValue) => (originalValue === '' ? null : currentValue))
        .required('Maturity Date is required')
        .test('is-after-start-date', 'Maturity Date must be after Start Date', function isAfterStartDate(value) {
            const { startDate } = this.parent;
            return value && startDate ? value > startDate : true;
        }),
import {
    FormControl,
    FormErrorMessage,
    FormLabel,
    Input,
    InputGroup,
    InputRightAddon,
    Select,
    Switch,
    Text,
    Textarea
} from '@chakra-ui/react';
import React, { useEffect } from 'react';
import type { Control, FieldErrors, FieldValues, Path, PathValue } from 'react-hook-form';
import { Controller } from 'react-hook-form';

export type ControlledFieldProps<T extends FieldValues, TContext> = {
    name: Path<T>;
    label?: string;
    control?: Control<T, TContext>;
    errors?: FieldErrors<T>;
    inputType?: 'text' | 'select' | 'number' | 'date' | 'textarea' | 'switch' | 'file';
    placeholder?: string;
    options?: { label: string; value: unknown }[];
    rows?: number;
    isReadOnly?: boolean;
    isRequired?: boolean;
    defaultValue?: PathValue<T, Path<T>> | undefined;
    id?: string;
    withAddon?: boolean;
    step?: string;
    rightAddon?: string | React.ReactNode;
    customComponent?: React.ReactNode;
    onCustomChange?: (value: unknown) => void;
    isHidden?: boolean;
};

const formatDateString = (dateString: string) => {
    try {
        return new Date(dateString).toISOString().split('T')[0];
    } catch {
        return '';
    }
};

const getNestedValue = (obj, path) => {
    const keys = path.split('.');
    let result = obj;

    result = keys.reduce((acc, key) => {
        if (acc && typeof acc === 'object' && key in acc) {
            return acc[key];
        }
        return null; // Return null if the path does not exist
    }, obj);
    return result;
};

const renderCustomComponent = (field, customComponent, placeholder, onCustomChange) => {
    return React.cloneElement(customComponent as React.ReactElement<unknown>, {
        ...field,
        placeholder,
        value: field.value ? field.value.trim() : '',
        onChange: e => {
            const newValue = e?.name ? e.name.trim() : e;
            field.onChange(newValue);
            if (onCustomChange) {
                onCustomChange(e);
            }
        },
        defaultValue: field.value ? { name: field.value.trim() } : null,
        autoComplete: 'off',
        'data-lpignore': 'true',
        'data-form-type': 'other'
    });
};

const renderSelect = (field, options, placeholder, id) => (
    <Select
        id={id}
        {...field}
        placeholder={placeholder}
        onChange={e => {
            field.onChange(e.target.value || null);
        }}
    >
        {options.map(option => (
            <option key={option.value} value={option.value}>
                {option.label}
            </option>
        ))}
    </Select>
);

interface RenderTextareaProps {
    field: {
        value: string;
        onChange: (value: string) => void;
    };
    placeholder: string;
    rows: number;
    id: string;
}

const RenderTextarea: React.FC<RenderTextareaProps> = ({ field, placeholder, rows, id }) => {
    const textareaRef = React.useRef<HTMLTextAreaElement>(null);

    const adjustHeight = () => {
        const textarea = textareaRef.current;
        if (textarea) {
            textarea.style.height = 'auto';
            textarea.style.height = `${textarea.scrollHeight}px`;
        }
    };

    useEffect(() => {
        adjustHeight();
    }, [field.value]);

    const handleChange = e => {
        const { value } = e.target;
        field.onChange(value);
        adjustHeight();
    };

    return (
        <Textarea
            id={id}
            {...field}
            ref={textareaRef}
            placeholder={placeholder}
            rows={rows}
            onChange={handleChange}
            style={{ resize: 'none' }}
        />
    );
};

const renderTextarea = (field, placeholder, rows, id) => (
    <RenderTextarea field={field} placeholder={placeholder} rows={rows} id={id} />
);

const renderSwitch = (field, id) => <Switch id={id} {...field} isChecked={field.value} />;

const renderFileInput = (field, id, placeholder, onCustomChange) => (
    <Input
        id={id}
        type="file"
        placeholder={placeholder}
        onChange={e => {
            if (onCustomChange) {
                onCustomChange(e.target.files?.[0]);
            }
            field.onChange(e.target.files?.[0]);
        }}
    />
);

const renderNumberInputWithAddon = (field, id, step, placeholder, rightAddon) => (
    <InputGroup>
        <Input id={id} {...field} type="number" step={step || '1'} placeholder={placeholder} />
        <InputRightAddon>{rightAddon}</InputRightAddon>
    </InputGroup>
);

const formatNumberWithCommas = (value: string | number | null | undefined, maxDecimalPlaces: number = 2): string => {
    if (value === null || value === undefined || value === '') return '';
    const stringValue = String(value);
    if (!stringValue.includes('.')) {
        return new Intl.NumberFormat('en-US').format(Number(stringValue));
    }
    const [integerPart, decimalPart] = stringValue.split('.');
    const formattedIntegerPart = new Intl.NumberFormat('en-US').format(Number(integerPart));
    const truncatedDecimalPart = decimalPart.slice(0, maxDecimalPlaces);
    return `${formattedIntegerPart}.${truncatedDecimalPart}`;
};

const renderDefaultInput = (field, id, inputType, placeholder, isReadOnly, value) => {
    let initialValue = value;

    if (inputType === 'number') {
        initialValue = formatNumberWithCommas(value);
    }

    if (inputType === 'date' && value) {
        initialValue = formatDateString(value);
    }

    return (
        <Input
            id={id}
            {...field}
            type={inputType === 'number' ? 'text' : inputType}
            placeholder={placeholder}
            sx={{
                backgroundColor: isReadOnly ? 'gray.100' : undefined
            }}
            isReadOnly={isReadOnly}
            value={initialValue}
            onChange={e => {
                if (inputType === 'number') {
                    const rawValue = e.target.value.replace(/,/g, '');
                    if (/^\d*\.?\d*$/.test(rawValue)) {
                        field.onChange(rawValue);
                    }
                } else {
                    field.onChange(e.target.value);
                }
            }}
            onBlur={e => {
                if (inputType === 'number') {
                    const rawValue = e.target.value.replace(/,/g, '');
                    field.onChange(rawValue);
                }
            }}
        />
    );
};

const ControlledField = <T extends FieldValues, TContext>({
    name,
    control,
    errors,
    label,
    inputType = 'text',
    placeholder = '',
    options = [],
    rows = 3,
    isReadOnly = false,
    isRequired = false,
    defaultValue,
    id,
    withAddon,
    step,
    rightAddon,
    customComponent,
    onCustomChange,
    isHidden = false
}: ControlledFieldProps<T, TContext>) => {
    const errorMessage = getNestedValue(errors, name)?.message;
    const renderInput = field => {
        if (customComponent) {
            return renderCustomComponent(field, customComponent, placeholder, onCustomChange);
        }

        if (inputType === 'select' && !isReadOnly) {
            return renderSelect(field, options, placeholder, id || name);
        }

        if (inputType === 'textarea') {
            return renderTextarea(field, placeholder, rows, id || name);
        }

        if (inputType === 'switch') {
            return renderSwitch(field, id || name);
        }

        if (inputType === 'file' && !isReadOnly) {
            return renderFileInput(field, id || name, placeholder, onCustomChange);
        }

        if (withAddon && inputType === 'number' && rightAddon) {
            return renderNumberInputWithAddon(field, id || name, step, placeholder, rightAddon);
        }

        return renderDefaultInput(field, id || name, inputType, placeholder, isReadOnly, field.value);
    };

    return (
        <Controller
            name={name}
            control={control}
            defaultValue={defaultValue}
            render={({ field }) =>
                isHidden ? (
                    <input type="hidden" {...field} />
                ) : (
                    <FormControl
                        isInvalid={!!errorMessage}
                        display={inputType === 'switch' ? 'flex' : 'block'}
                        alignItems={inputType === 'switch' ? 'center' : 'initial'}
                    >
                        <FormLabel htmlFor={id || name}>
                            {label}{' '}
                            {isRequired && (
                                <Text as="span" color="red.500">
                                    *
                                </Text>
                            )}
                        </FormLabel>
                        {renderInput(field)}
                        <FormErrorMessage>{errorMessage?.toString()}</FormErrorMessage>
                    </FormControl>
                )
            }
        />
    );
};

export default ControlledField;

The problem is when sending a date to the backend, it gets shifted by a day. For example, the startDate value is being converted to "2025-01-19T22:00:00.000Z", which corresponds to the local timezone offset. The backend expects the date to be in UTC, but it's still affected by local timezone conversion.

How do I fix this? I expect the output to be in UTC, not my local time.

const onSave = async (data: CreateFinanceAgreementForm | FieldValues) => {
    try {
        const { transactions, ...financeAgreement } = data;

        const transformDateToUTC = (date: Date | string) => {
            if (!date) return null;
            const dateObj = new Date(date);
            return dateObj.toISOString(); // Convert to UTC ISO string
        };

        const transformedData = {
            id,
            financeAgreement: { ...financeAgreement },
            startDate: moment(financeAgreement.startDate).utc().toISOString(),
            maturityDate: moment(financeAgreement.maturityDate).utc().toISOString(),
            expectedRepaymentDate: moment(financeAgreement.expectedRepaymentDate).utc().toISOString()
        };
        const response = await fetch(`/api/finance-agreement/${id}`, {
            method: 'PUT',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(transformedData)
        });
        if (!response.ok) {
            throw new Error('Failed to edit Finance Agreement');
        }
    } catch {
        showError('Failed to update the Finance Agreement. Please try again.');
    }
};
export const baseSchema: yup.ObjectSchema<DealFABase> = yup.object().shape({
    id: yup.string().optional(),
    currency: yup
        .string()
        .required('Currency is required')
        .matches(/^[A-Za-z]{1,3}$/, 'Currency must be alphabetic and not more than 3 letters'),
    startDate: yup
        .date()
        .transform((currentValue, originalValue) => (originalValue === '' ? null : currentValue))
        .required('Start Date is required'),
    maturityDate: yup
        .date()
        .transform((currentValue, originalValue) => (originalValue === '' ? null : currentValue))
        .required('Maturity Date is required')
        .test('is-after-start-date', 'Maturity Date must be after Start Date', function isAfterStartDate(value) {
            const { startDate } = this.parent;
            return value && startDate ? value > startDate : true;
        }),
import {
    FormControl,
    FormErrorMessage,
    FormLabel,
    Input,
    InputGroup,
    InputRightAddon,
    Select,
    Switch,
    Text,
    Textarea
} from '@chakra-ui/react';
import React, { useEffect } from 'react';
import type { Control, FieldErrors, FieldValues, Path, PathValue } from 'react-hook-form';
import { Controller } from 'react-hook-form';

export type ControlledFieldProps<T extends FieldValues, TContext> = {
    name: Path<T>;
    label?: string;
    control?: Control<T, TContext>;
    errors?: FieldErrors<T>;
    inputType?: 'text' | 'select' | 'number' | 'date' | 'textarea' | 'switch' | 'file';
    placeholder?: string;
    options?: { label: string; value: unknown }[];
    rows?: number;
    isReadOnly?: boolean;
    isRequired?: boolean;
    defaultValue?: PathValue<T, Path<T>> | undefined;
    id?: string;
    withAddon?: boolean;
    step?: string;
    rightAddon?: string | React.ReactNode;
    customComponent?: React.ReactNode;
    onCustomChange?: (value: unknown) => void;
    isHidden?: boolean;
};

const formatDateString = (dateString: string) => {
    try {
        return new Date(dateString).toISOString().split('T')[0];
    } catch {
        return '';
    }
};

const getNestedValue = (obj, path) => {
    const keys = path.split('.');
    let result = obj;

    result = keys.reduce((acc, key) => {
        if (acc && typeof acc === 'object' && key in acc) {
            return acc[key];
        }
        return null; // Return null if the path does not exist
    }, obj);
    return result;
};

const renderCustomComponent = (field, customComponent, placeholder, onCustomChange) => {
    return React.cloneElement(customComponent as React.ReactElement<unknown>, {
        ...field,
        placeholder,
        value: field.value ? field.value.trim() : '',
        onChange: e => {
            const newValue = e?.name ? e.name.trim() : e;
            field.onChange(newValue);
            if (onCustomChange) {
                onCustomChange(e);
            }
        },
        defaultValue: field.value ? { name: field.value.trim() } : null,
        autoComplete: 'off',
        'data-lpignore': 'true',
        'data-form-type': 'other'
    });
};

const renderSelect = (field, options, placeholder, id) => (
    <Select
        id={id}
        {...field}
        placeholder={placeholder}
        onChange={e => {
            field.onChange(e.target.value || null);
        }}
    >
        {options.map(option => (
            <option key={option.value} value={option.value}>
                {option.label}
            </option>
        ))}
    </Select>
);

interface RenderTextareaProps {
    field: {
        value: string;
        onChange: (value: string) => void;
    };
    placeholder: string;
    rows: number;
    id: string;
}

const RenderTextarea: React.FC<RenderTextareaProps> = ({ field, placeholder, rows, id }) => {
    const textareaRef = React.useRef<HTMLTextAreaElement>(null);

    const adjustHeight = () => {
        const textarea = textareaRef.current;
        if (textarea) {
            textarea.style.height = 'auto';
            textarea.style.height = `${textarea.scrollHeight}px`;
        }
    };

    useEffect(() => {
        adjustHeight();
    }, [field.value]);

    const handleChange = e => {
        const { value } = e.target;
        field.onChange(value);
        adjustHeight();
    };

    return (
        <Textarea
            id={id}
            {...field}
            ref={textareaRef}
            placeholder={placeholder}
            rows={rows}
            onChange={handleChange}
            style={{ resize: 'none' }}
        />
    );
};

const renderTextarea = (field, placeholder, rows, id) => (
    <RenderTextarea field={field} placeholder={placeholder} rows={rows} id={id} />
);

const renderSwitch = (field, id) => <Switch id={id} {...field} isChecked={field.value} />;

const renderFileInput = (field, id, placeholder, onCustomChange) => (
    <Input
        id={id}
        type="file"
        placeholder={placeholder}
        onChange={e => {
            if (onCustomChange) {
                onCustomChange(e.target.files?.[0]);
            }
            field.onChange(e.target.files?.[0]);
        }}
    />
);

const renderNumberInputWithAddon = (field, id, step, placeholder, rightAddon) => (
    <InputGroup>
        <Input id={id} {...field} type="number" step={step || '1'} placeholder={placeholder} />
        <InputRightAddon>{rightAddon}</InputRightAddon>
    </InputGroup>
);

const formatNumberWithCommas = (value: string | number | null | undefined, maxDecimalPlaces: number = 2): string => {
    if (value === null || value === undefined || value === '') return '';
    const stringValue = String(value);
    if (!stringValue.includes('.')) {
        return new Intl.NumberFormat('en-US').format(Number(stringValue));
    }
    const [integerPart, decimalPart] = stringValue.split('.');
    const formattedIntegerPart = new Intl.NumberFormat('en-US').format(Number(integerPart));
    const truncatedDecimalPart = decimalPart.slice(0, maxDecimalPlaces);
    return `${formattedIntegerPart}.${truncatedDecimalPart}`;
};

const renderDefaultInput = (field, id, inputType, placeholder, isReadOnly, value) => {
    let initialValue = value;

    if (inputType === 'number') {
        initialValue = formatNumberWithCommas(value);
    }

    if (inputType === 'date' && value) {
        initialValue = formatDateString(value);
    }

    return (
        <Input
            id={id}
            {...field}
            type={inputType === 'number' ? 'text' : inputType}
            placeholder={placeholder}
            sx={{
                backgroundColor: isReadOnly ? 'gray.100' : undefined
            }}
            isReadOnly={isReadOnly}
            value={initialValue}
            onChange={e => {
                if (inputType === 'number') {
                    const rawValue = e.target.value.replace(/,/g, '');
                    if (/^\d*\.?\d*$/.test(rawValue)) {
                        field.onChange(rawValue);
                    }
                } else {
                    field.onChange(e.target.value);
                }
            }}
            onBlur={e => {
                if (inputType === 'number') {
                    const rawValue = e.target.value.replace(/,/g, '');
                    field.onChange(rawValue);
                }
            }}
        />
    );
};

const ControlledField = <T extends FieldValues, TContext>({
    name,
    control,
    errors,
    label,
    inputType = 'text',
    placeholder = '',
    options = [],
    rows = 3,
    isReadOnly = false,
    isRequired = false,
    defaultValue,
    id,
    withAddon,
    step,
    rightAddon,
    customComponent,
    onCustomChange,
    isHidden = false
}: ControlledFieldProps<T, TContext>) => {
    const errorMessage = getNestedValue(errors, name)?.message;
    const renderInput = field => {
        if (customComponent) {
            return renderCustomComponent(field, customComponent, placeholder, onCustomChange);
        }

        if (inputType === 'select' && !isReadOnly) {
            return renderSelect(field, options, placeholder, id || name);
        }

        if (inputType === 'textarea') {
            return renderTextarea(field, placeholder, rows, id || name);
        }

        if (inputType === 'switch') {
            return renderSwitch(field, id || name);
        }

        if (inputType === 'file' && !isReadOnly) {
            return renderFileInput(field, id || name, placeholder, onCustomChange);
        }

        if (withAddon && inputType === 'number' && rightAddon) {
            return renderNumberInputWithAddon(field, id || name, step, placeholder, rightAddon);
        }

        return renderDefaultInput(field, id || name, inputType, placeholder, isReadOnly, field.value);
    };

    return (
        <Controller
            name={name}
            control={control}
            defaultValue={defaultValue}
            render={({ field }) =>
                isHidden ? (
                    <input type="hidden" {...field} />
                ) : (
                    <FormControl
                        isInvalid={!!errorMessage}
                        display={inputType === 'switch' ? 'flex' : 'block'}
                        alignItems={inputType === 'switch' ? 'center' : 'initial'}
                    >
                        <FormLabel htmlFor={id || name}>
                            {label}{' '}
                            {isRequired && (
                                <Text as="span" color="red.500">
                                    *
                                </Text>
                            )}
                        </FormLabel>
                        {renderInput(field)}
                        <FormErrorMessage>{errorMessage?.toString()}</FormErrorMessage>
                    </FormControl>
                )
            }
        />
    );
};

export default ControlledField;
Share Improve this question edited Feb 11 at 18:45 nop asked Feb 11 at 18:26 nopnop 6,36910 gold badges53 silver badges163 bronze badges 9
  • Shouldn't .UTC() be used like this? startDate: moment.utc(financeAgreement.startDate).toISOString() – Matt Smith Commented Feb 11 at 18:38
  • Do you need a datetime or actually just a date? Because dates solve a lot of these issues, since they don't have a timezone. The 20th is the 20th. – VLAZ Commented Feb 11 at 18:38
  • @VLAZ, it is just a Date, e.g. startDate: Date; – nop Commented Feb 11 at 18:44
  • @MattSmith, it doesn't work either. expectedRepaymentDate: moment.utc(financeAgreement.expectedRepaymentDate).toISOString() i.imgur/PH4QWAO.png (output). i.imgur/v777SW1.png (original data before the transformation). It picks my local timezone instead of UTC – nop Commented Feb 11 at 18:46
  • 1 @nop the transformDateToUTC() is unused, I assume it's a remnant and has no bearing on this? – Matt Smith Commented Feb 11 at 19:03
 |  Show 4 more comments

1 Answer 1

Reset to default 0

To fix this, you need to tell Moment to parse the date as UTC from the start rather than converting it from local time. Instead of using:

moment(financeAgreement.startDate).utc().toISOString()

you should use

moment.utc(financeAgreement.startDate).toISOString()

本文标签: reactjsHow to correctly convert and send dates in UTC format to the backend in TypeScriptStack Overflow