chore: refactor

This commit is contained in:
Tiago Farto
2026-03-13 14:34:24 +00:00
parent a28c9e74af
commit f403160563
13 changed files with 142 additions and 119 deletions

View File

@@ -3,6 +3,7 @@
import Link from "next/link";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { DEFAULT_DATE_STORAGE_FORMAT } from "@formbricks/types/surveys/date-formats";
import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
@@ -32,7 +33,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
};
const renderResponseValue = (value: string) => {
const format = elementSummary.element?.format ?? "y-M-d";
const format = elementSummary.element?.format ?? DEFAULT_DATE_STORAGE_FORMAT;
return formatStoredDateForDisplay(value, format, `${t("common.invalid_date")}(${value})`);
};

View File

@@ -2,6 +2,7 @@ import { CheckCheckIcon, MousePointerClickIcon, PhoneIcon } from "lucide-react";
import React from "react";
import { logger } from "@formbricks/logger";
import { TResponseDataValue } from "@formbricks/types/responses";
import { DEFAULT_DATE_STORAGE_FORMAT } from "@formbricks/types/surveys/date-formats";
import {
TSurveyDateElement,
TSurveyElement,
@@ -68,7 +69,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
break;
case TSurveyElementTypeEnum.Date:
if (typeof responseData === "string") {
const format = (element as TSurveyDateElement).format ?? "y-M-d";
const format = element.format ?? DEFAULT_DATE_STORAGE_FORMAT;
const formatted = formatStoredDateForDisplay(responseData, format, responseData);
if (formatted === responseData) {
logger.warn(

View File

@@ -4,6 +4,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { PlusIcon } from "lucide-react";
import { type JSX } from "react";
import { useTranslation } from "react-i18next";
import { DATE_STORAGE_FORMAT_IDS, DATE_STORAGE_FORMAT_LABELS } from "@formbricks/types/surveys/date-formats";
import type { TSurveyDateElement, TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
@@ -27,20 +28,10 @@ interface IDateElementFormProps {
isExternalUrlsAllowed?: boolean;
}
const dateOptions = [
{
value: "M-d-y",
label: "MM-DD-YYYY",
},
{
value: "d-M-y",
label: "DD-MM-YYYY",
},
{
value: "y-M-d",
label: "YYYY-MM-DD",
},
];
const dateOptions = DATE_STORAGE_FORMAT_IDS.map((value) => ({
value,
label: DATE_STORAGE_FORMAT_LABELS[value],
}));
export const DateElementForm = ({
element,
@@ -121,9 +112,7 @@ export const DateElementForm = ({
<OptionsSwitch
options={dateOptions}
currentOption={element.format}
handleOptionChange={(value: string) =>
updateElement(elementIdx, { format: value as "M-d-y" | "d-M-y" | "y-M-d" })
}
handleOptionChange={(value: string) => updateElement(elementIdx, { format: value })}
/>
</div>
</div>

View File

@@ -67,6 +67,7 @@
"react-dom": "^19.0.0"
},
"dependencies": {
"@formbricks/types": "workspace:*",
"@formkit/auto-animate": "0.9.0",
"@radix-ui/react-checkbox": "1.3.3",
"@radix-ui/react-dropdown-menu": "2.1.16",

View File

@@ -1,37 +1,32 @@
import * as React from "react";
import {
DATE_FORMAT_OUTPUT_ORDER,
DATE_FORMAT_PARSE_ORDER,
DEFAULT_DATE_STORAGE_FORMAT,
type TSurveyDateStorageFormat,
} from "@formbricks/types/surveys/date-formats";
import { Calendar } from "@/components/general/calendar";
import { ElementError } from "@/components/general/element-error";
import { ElementHeader } from "@/components/general/element-header";
import { getDateFnsLocale } from "@/lib/locale";
/** Storage format for date response values: M=month, d=day, y=year */
export type DateStorageFormat = "M-d-y" | "d-M-y" | "y-M-d";
export type DateStorageFormat = TSurveyDateStorageFormat;
const ISO_FIRST_CHARS = /^\d{4}/;
function parseValueToDate(value: string, format: DateStorageFormat): Date | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;
const parts = trimmed.split("-");
if (parts.length !== 3) return undefined;
const [a, b, c] = parts.map((p) => parseInt(p, 10));
if (Number.isNaN(a) || Number.isNaN(b) || Number.isNaN(c)) return undefined;
const useIso = /^\d{4}/.test(trimmed);
const effective = useIso ? "y-M-d" : format;
let year: number;
let month: number;
let day: number;
if (effective === "y-M-d") {
year = a;
month = b;
day = c;
} else if (effective === "d-M-y") {
day = a;
month = b;
year = c;
} else {
month = a;
day = b;
year = c;
}
const nums = parts.map((p) => Number.parseInt(p, 10));
if (nums.some(Number.isNaN)) return undefined;
const useIso = ISO_FIRST_CHARS.test(trimmed);
const effective = useIso ? DEFAULT_DATE_STORAGE_FORMAT : format;
const order = DATE_FORMAT_PARSE_ORDER[effective];
const year = nums[order.yearIdx];
const month = nums[order.monthIdx];
const day = nums[order.dayIdx];
if (month < 1 || month > 12 || day < 1 || day > 31) return undefined;
const date = new Date(year, month - 1, day);
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day)
@@ -40,16 +35,9 @@ function parseValueToDate(value: string, format: DateStorageFormat): Date | unde
}
function formatDateForStorage(year: string, month: string, day: string, format: DateStorageFormat): string {
switch (format) {
case "y-M-d":
return `${year}-${month}-${day}`;
case "M-d-y":
return `${month}-${day}-${year}`;
case "d-M-y":
return `${day}-${month}-${year}`;
default:
return `${year}-${month}-${day}`;
}
const comps = [year, month, day];
const [i, j, k] = DATE_FORMAT_OUTPUT_ORDER[format];
return `${comps[i]}-${comps[j]}-${comps[k]}`;
}
interface DateElementProps {
@@ -96,7 +84,7 @@ function DateElement({
inputId,
value,
onChange,
outputFormat = "y-M-d",
outputFormat = DEFAULT_DATE_STORAGE_FORMAT,
required = false,
requiredLabel,
minDate,
@@ -121,8 +109,8 @@ function DateElement({
const newDate = parseValueToDate(value, outputFormat);
setDate((prevDate) => {
if (!newDate) return undefined;
if (!prevDate || newDate.getTime() !== prevDate.getTime()) return newDate;
return prevDate;
if (prevDate?.getTime() !== newDate.getTime()) return newDate;
return prevDate ?? undefined;
});
}, [value, outputFormat]);

View File

@@ -2,6 +2,7 @@ import { useState } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { DateElement as SurveyUIDateElement } from "@formbricks/survey-ui";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import { DEFAULT_DATE_STORAGE_FORMAT } from "@formbricks/types/surveys/date-formats";
import type { TSurveyDateElement } from "@formbricks/types/surveys/elements";
import { TSurveyLanguage } from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "@/lib/i18n";
@@ -68,7 +69,7 @@ export function DateElement({
description={element.subheader ? getLocalizedValue(element.subheader, languageCode) : undefined}
value={value}
onChange={handleChange}
outputFormat={element.format ?? "y-M-d"}
outputFormat={element.format ?? DEFAULT_DATE_STORAGE_FORMAT}
minDate={getMinDate()}
maxDate={getMaxDate()}
required={isRequired}

View File

@@ -1,14 +1,17 @@
/**
* Date storage format for survey date elements.
* Values are stored in response data in this order: y = year, M = month, d = day.
*/
export type TSurveyDateStorageFormat = "M-d-y" | "d-M-y" | "y-M-d";
import {
DATE_FORMAT_PARSE_ORDER,
DATE_STORAGE_FORMATS_LIST,
DEFAULT_DATE_STORAGE_FORMAT,
type TSurveyDateStorageFormat,
} from "@formbricks/types/surveys/date-formats";
export type { TSurveyDateStorageFormat };
const ISO_FIRST_CHARS = /^\d{4}/;
/**
* Parse a date string stored in response data using the element's storage format.
* Pure function, synchronous, no I/O.
* Uses the format registry from @formbricks/types for data-driven parsing.
*
* Backward compatibility: if the value starts with 4 digits (YYYY), it is treated
* as ISO (y-M-d) regardless of format, so legacy YYYY-MM-DD values parse correctly.
@@ -17,7 +20,10 @@ const ISO_FIRST_CHARS = /^\d{4}/;
* @param format - The format used when the value was stored; defaults to "y-M-d" (ISO)
* @returns Parsed Date in local time, or null if invalid
*/
export function parseDateByFormat(value: string, format: TSurveyDateStorageFormat = "y-M-d"): Date | null {
export function parseDateByFormat(
value: string,
format: TSurveyDateStorageFormat = DEFAULT_DATE_STORAGE_FORMAT
): Date | null {
const trimmed = value?.trim();
if (!trimmed || typeof trimmed !== "string") {
return null;
@@ -28,44 +34,16 @@ export function parseDateByFormat(value: string, format: TSurveyDateStorageForma
return null;
}
// Backward compat: value starts with 4 digits (year) => treat as ISO
const useIso = ISO_FIRST_CHARS.test(trimmed);
const effectiveFormat = useIso ? "y-M-d" : format;
const effectiveFormat = useIso ? DEFAULT_DATE_STORAGE_FORMAT : format;
let year: number;
let month: number;
let day: number;
const order = DATE_FORMAT_PARSE_ORDER[effectiveFormat];
const nums = parts.map((p) => Number.parseInt(p, 10));
if (nums.some(Number.isNaN)) return null;
const year = nums[order.yearIdx];
const month = nums[order.monthIdx];
const day = nums[order.dayIdx];
switch (effectiveFormat) {
case "y-M-d": {
const [y, m, d] = parts.map((p) => parseInt(p, 10));
if (Number.isNaN(y) || Number.isNaN(m) || Number.isNaN(d)) return null;
year = y;
month = m;
day = d;
break;
}
case "d-M-y": {
const [d, m, y] = parts.map((p) => parseInt(p, 10));
if (Number.isNaN(y) || Number.isNaN(m) || Number.isNaN(d)) return null;
year = y;
month = m;
day = d;
break;
}
case "M-d-y": {
const [m, d, y] = parts.map((p) => parseInt(p, 10));
if (Number.isNaN(y) || Number.isNaN(m) || Number.isNaN(d)) return null;
year = y;
month = m;
day = d;
break;
}
default:
return null;
}
// Month 1-12, day 1-31; Date constructor uses 0-indexed month
if (month < 1 || month > 12 || day < 1 || day > 31) {
return null;
}
@@ -78,8 +56,6 @@ export function parseDateByFormat(value: string, format: TSurveyDateStorageForma
return date;
}
const STORAGE_FORMATS: TSurveyDateStorageFormat[] = ["y-M-d", "d-M-y", "M-d-y"];
/**
* Try to parse a date string using each known storage format in order.
* Use when the storage format is unknown (e.g. recall placeholders).
@@ -88,7 +64,7 @@ const STORAGE_FORMATS: TSurveyDateStorageFormat[] = ["y-M-d", "d-M-y", "M-d-y"];
* @returns Parsed Date or null if no format matched
*/
export function parseDateWithFormats(value: string): Date | null {
for (const format of STORAGE_FORMATS) {
for (const format of DATE_STORAGE_FORMATS_LIST) {
const parsed = parseDateByFormat(value, format);
if (parsed !== null) return parsed;
}

View File

@@ -2,6 +2,7 @@ import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { type TResponseData, type TResponseVariables } from "@formbricks/types/responses";
import { type TActionCalculate, type TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { DEFAULT_DATE_STORAGE_FORMAT } from "@formbricks/types/surveys/date-formats";
import type { TSurveyDateElement, TSurveyElement } from "@formbricks/types/surveys/elements";
import { type TConditionGroup, type TSingleCondition } from "@formbricks/types/surveys/logic";
import { type TSurveyVariable } from "@formbricks/types/surveys/types";
@@ -9,11 +10,18 @@ import { parseDateByFormat } from "@/lib/date-format";
import { getLocalizedValue } from "@/lib/i18n";
import { getElementsFromSurveyBlocks } from "./utils";
/** Coerce to string for date comparison; avoids Object's default stringification. */
function toDateOperandString(value: unknown): string {
if (typeof value === "string") return value;
if (typeof value === "number") return String(value);
return "";
}
function parseDateOperand(value: string, field: TSurveyElement | ""): Date | null {
const format =
field && typeof field === "object" && field.type === TSurveyElementTypeEnum.Date && "format" in field
? ((field as TSurveyDateElement).format ?? "y-M-d")
: "y-M-d";
? (field.format ?? DEFAULT_DATE_STORAGE_FORMAT)
: DEFAULT_DATE_STORAGE_FORMAT;
return parseDateByFormat(value, format);
}
@@ -26,7 +34,7 @@ function compareDateOperands(
compare: (left: Date, right: Date) => boolean
): boolean {
const leftDate = parseDateOperand(leftValue, leftField);
const rightDate = parseDateOperand(String(rightValue), rightField);
const rightDate = parseDateOperand(rightValue, rightField);
if (leftDate === null) {
console.warn(`[logic] ${operator}: could not parse left date`, {
elementId: leftField.id,
@@ -468,19 +476,19 @@ const evaluateSingleCondition = (
return leftValue !== "clicked";
case "isAfter":
return compareDateOperands(
String(leftValue),
String(rightValue),
leftField as TSurveyElement,
rightField as TSurveyElement,
toDateOperandString(leftValue),
toDateOperandString(rightValue),
leftField,
rightField,
"isAfter",
(l, r) => l.getTime() > r.getTime()
);
case "isBefore":
return compareDateOperands(
String(leftValue),
String(rightValue),
leftField as TSurveyElement,
rightField as TSurveyElement,
toDateOperandString(leftValue),
toDateOperandString(rightValue),
leftField,
rightField,
"isBefore",
(l, r) => l.getTime() < r.getTime()
);

View File

@@ -1,5 +1,9 @@
import type { TFunction } from "i18next";
import type { TResponseDataValue } from "@formbricks/types/responses";
import {
DEFAULT_DATE_STORAGE_FORMAT,
type TSurveyDateStorageFormat,
} from "@formbricks/types/surveys/date-formats";
import type { TSurveyDateElement, TSurveyElement } from "@formbricks/types/surveys/elements";
import type {
TValidationRuleParams,
@@ -31,22 +35,22 @@ import { parseDateByFormat } from "@/lib/date-format";
import { countSelections } from "./validators/selection-utils";
import { validateEmail, validatePhone, validateUrl } from "./validators/validation-utils";
function getDateElementFormat(element: TSurveyElement): "M-d-y" | "d-M-y" | "y-M-d" {
function getDateElementFormat(element: TSurveyElement): TSurveyDateStorageFormat {
if (element.type === "date" && "format" in element) {
return (element as TSurveyDateElement).format ?? "y-M-d";
return element.format ?? DEFAULT_DATE_STORAGE_FORMAT;
}
return "y-M-d";
return DEFAULT_DATE_STORAGE_FORMAT;
}
function parseForDateComparison(
value: string,
format: "M-d-y" | "d-M-y" | "y-M-d",
format: TSurveyDateStorageFormat,
element: TSurveyElement,
ruleName: string,
paramDates: [string] | [string, string]
): { valueDate: Date; paramDates: Date[] } | null {
const valueDate = parseDateByFormat(value, format);
const parsedParams = paramDates.map((d) => parseDateByFormat(d, "y-M-d"));
const parsedParams = paramDates.map((d) => parseDateByFormat(d, DEFAULT_DATE_STORAGE_FORMAT));
if (valueDate === null) {
console.warn(`[date validation] ${ruleName}: could not parse response date`, {
elementId: element.id,
@@ -55,7 +59,7 @@ function parseForDateComparison(
});
return null;
}
if (parsedParams.some((d) => d === null)) return null;
if (parsedParams.includes(null)) return null;
return { valueDate, paramDates: parsedParams as Date[] };
}

View File

@@ -0,0 +1,49 @@
/**
* Single source of truth for survey date storage formats.
* Values are stored in response data as hyphen-separated strings: year, month, day
* in the order defined by each format (e.g. y-M-d = YYYY-MM-DD).
*
* To add a new format: add the id to the tuple, add parse order and output order
* entries, then implement parsing/formatting in packages that consume this (surveys, survey-ui).
*/
/** Supported date storage format ids. Extend this tuple to add new formats. */
export const DATE_STORAGE_FORMAT_IDS = ["M-d-y", "d-M-y", "y-M-d"] as const;
export type TSurveyDateStorageFormat = (typeof DATE_STORAGE_FORMAT_IDS)[number];
/** Default format (ISO-style). Used when element has no format set. */
export const DEFAULT_DATE_STORAGE_FORMAT: TSurveyDateStorageFormat = "y-M-d";
/**
* For each format, indices into the split("-") array for [year, month, day].
* parts[yearIdx]=year, parts[monthIdx]=month, parts[dayIdx]=day.
*/
export const DATE_FORMAT_PARSE_ORDER: Record<
TSurveyDateStorageFormat,
{ yearIdx: number; monthIdx: number; dayIdx: number }
> = {
"y-M-d": { yearIdx: 0, monthIdx: 1, dayIdx: 2 },
"d-M-y": { yearIdx: 2, monthIdx: 1, dayIdx: 0 },
"M-d-y": { yearIdx: 2, monthIdx: 0, dayIdx: 1 },
};
/**
* For each format, indices into [year, month, day] for output order.
* Output = [year, month, day][out[0]] + "-" + [year, month, day][out[1]] + "-" + [year, month, day][out[2]]
*/
export const DATE_FORMAT_OUTPUT_ORDER: Record<TSurveyDateStorageFormat, [number, number, number]> = {
"y-M-d": [0, 1, 2],
"d-M-y": [2, 1, 0],
"M-d-y": [1, 2, 0],
};
/** All format ids as an array (for iteration, e.g. try-parse or dropdowns). */
export const DATE_STORAGE_FORMATS_LIST: TSurveyDateStorageFormat[] = [...DATE_STORAGE_FORMAT_IDS];
/** Default display labels for UI (e.g. editor dropdown). Apps can override with i18n. */
export const DATE_STORAGE_FORMAT_LABELS: Record<TSurveyDateStorageFormat, string> = {
"M-d-y": "MM-DD-YYYY",
"d-M-y": "DD-MM-YYYY",
"y-M-d": "YYYY-MM-DD",
};

View File

@@ -3,6 +3,7 @@ import { ZStorageUrl, ZUrl } from "../common";
import { ZI18nString } from "../i18n";
import { ZAllowedFileExtension } from "../storage";
import { TSurveyElementTypeEnum } from "./constants";
import { DATE_STORAGE_FORMAT_IDS } from "./date-formats";
import { FORBIDDEN_IDS } from "./validation";
import { ZValidationRules } from "./validation-rules";
@@ -257,7 +258,7 @@ export type TSurveyPictureSelectionElement = z.infer<typeof ZSurveyPictureSelect
export const ZSurveyDateElement = ZSurveyElementBase.extend({
type: z.literal(TSurveyElementTypeEnum.Date),
html: ZI18nString.optional(),
format: z.enum(["M-d-y", "d-M-y", "y-M-d"]),
format: z.enum(DATE_STORAGE_FORMAT_IDS),
validation: ZValidation.optional(),
});

View File

@@ -10,6 +10,7 @@ import { ZAllowedFileExtension } from "../storage";
import { ZBaseStyling } from "../styling";
import { type TSurveyBlock, type TSurveyBlockLogicAction, ZSurveyBlocks } from "./blocks";
import { findBlocksWithCyclicLogic } from "./blocks-validation";
import { DATE_STORAGE_FORMAT_IDS } from "./date-formats";
import {
type TSurveyElement,
TSurveyElementTypeEnum,
@@ -560,7 +561,7 @@ export const ZSurveyRatingQuestion = ZSurveyQuestionBase.extend({
export const ZSurveyDateQuestion = ZSurveyQuestionBase.extend({
type: z.literal(TSurveyQuestionTypeEnum.Date),
html: ZI18nString.optional(),
format: z.enum(["M-d-y", "d-M-y", "y-M-d"]),
format: z.enum(DATE_STORAGE_FORMAT_IDS),
});
/**

3
pnpm-lock.yaml generated
View File

@@ -806,6 +806,9 @@ importers:
packages/survey-ui:
dependencies:
'@formbricks/types':
specifier: workspace:*
version: link:../types
'@formkit/auto-animate':
specifier: 0.9.0
version: 0.9.0