Compare commits

...

16 Commits

Author SHA1 Message Date
Tiago Farto
3d4a276558 chore: lint fix 2026-03-13 17:22:14 +00:00
Tiago Farto
777f748bde chore: simplified function 2026-03-13 17:20:01 +00:00
Tiago Farto
a48db4d127 chore: lint fix 2026-03-13 16:52:34 +00:00
Tiago Farto
7ffcfa5b36 chore: assorted fixes 2026-03-13 16:45:34 +00:00
Tiago Farto
1c527e92a1 chore: refactor 2026-03-13 16:22:59 +00:00
Tiago Farto
1ed6e24a90 chore: change export 2026-03-13 15:21:18 +00:00
Tiago Farto
17bf8a96f7 chore: revert 2026-03-13 15:05:33 +00:00
Tiago Farto
02f4912c30 chore: fix 2026-03-13 15:01:51 +00:00
Tiago Farto
16fe95ee95 chore: fix build 2026-03-13 14:53:29 +00:00
Tiago Farto
e91d6dc048 chore: fix 2026-03-13 14:41:22 +00:00
Tiago Farto
f403160563 chore: refactor 2026-03-13 14:34:24 +00:00
Tiago Farto
a28c9e74af chore: more tests 2026-03-13 12:22:07 +00:00
Tiago Farto
40a1bfb372 chore: additional tests 2026-03-13 12:00:21 +00:00
Tiago Farto
24b814d578 chore: additional tests 2026-03-13 11:48:25 +00:00
Tiago Farto
7121063ee9 chore: fix sonarcube 2026-03-13 11:36:29 +00:00
Tiago Farto
66a9f06796 chore: fix date format 2026-03-13 11:25:16 +00:00
24 changed files with 743 additions and 97 deletions

View File

@@ -3,11 +3,12 @@
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; 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 { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time"; import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact"; import { getContactIdentifier } from "@/lib/utils/contact";
import { formatDateWithOrdinal } from "@/lib/utils/datetime"; import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
import { PersonAvatar } from "@/modules/ui/components/avatars"; import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state"; import { EmptyState } from "@/modules/ui/components/empty-state";
@@ -32,13 +33,8 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
}; };
const renderResponseValue = (value: string) => { const renderResponseValue = (value: string) => {
const parsedDate = new Date(value); const format = elementSummary.element?.format ?? DEFAULT_DATE_STORAGE_FORMAT;
return formatStoredDateForDisplay(value, format, `${t("common.invalid_date")}(${value})`);
const formattedDate = isNaN(parsedDate.getTime())
? `${t("common.invalid_date")}(${value})`
: formatDateWithOrdinal(parsedDate);
return formattedDate;
}; };
return ( return (

View File

@@ -0,0 +1,43 @@
import { describe, expect, test } from "vitest";
import { formatStoredDateForDisplay } from "./date-display";
describe("formatStoredDateForDisplay", () => {
test("returns formatted date for valid ISO string (y-M-d)", () => {
const result = formatStoredDateForDisplay("2024-03-13", "y-M-d", "fallback");
expect(result).toContain("2024");
expect(result).toContain("13");
expect(result).not.toBe("fallback");
});
test("returns formatted date for valid d-M-y string", () => {
const result = formatStoredDateForDisplay("20-03-2026", "d-M-y", "fallback");
expect(result).toContain("2026");
expect(result).toContain("20");
expect(result).not.toBe("fallback");
});
test("returns formatted date for valid M-d-y string", () => {
const result = formatStoredDateForDisplay("03-20-2026", "M-d-y", "fallback");
expect(result).toContain("2026");
expect(result).toContain("20");
expect(result).not.toBe("fallback");
});
test("returns fallback when value is unparseable", () => {
const fallback = "Invalid date(bad)";
const result = formatStoredDateForDisplay("bad", "y-M-d", fallback);
expect(result).toBe(fallback);
});
test("returns fallback when value is empty", () => {
const fallback = "—";
const result = formatStoredDateForDisplay("", "y-M-d", fallback);
expect(result).toBe(fallback);
});
test("returns fallback when value is malformed (wrong format)", () => {
const fallback = "Invalid date(13-03-2024)";
const result = formatStoredDateForDisplay("13-03-2024", "y-M-d", fallback);
expect(result).toBe(fallback);
});
});

View File

@@ -0,0 +1,17 @@
import type { TSurveyDateStorageFormat } from "@formbricks/surveys/date-format";
import { parseDateByFormat } from "@formbricks/surveys/date-format";
import { formatDateWithOrdinal } from "./datetime";
/**
* Parses a stored date string with the given format and returns a display string.
* If parsing fails, returns the provided fallback (e.g. raw value or "Invalid date(value)").
*/
export function formatStoredDateForDisplay(
value: string,
format: TSurveyDateStorageFormat,
fallback: string
): string {
const parsed = parseDateByFormat(value, format);
if (parsed === null) return fallback;
return formatDateWithOrdinal(parsed);
}

View File

@@ -480,6 +480,36 @@ describe("recall utility functions", () => {
expect(result).toBe("You joined on January 1st, 2023"); expect(result).toBe("You joined on January 1st, 2023");
}); });
test("formats d-M-y date values in recall", () => {
const text = "Event on #recall:eventDate/fallback:none#";
const responseData: TResponseData = {
eventDate: "20-03-2026",
};
const result = parseRecallInfo(text, responseData);
expect(result).toBe("Event on January 1st, 2023");
});
test("formats M-d-y date values in recall", () => {
const text = "Due #recall:dueDate/fallback:none#";
const responseData: TResponseData = {
dueDate: "03-20-2026",
};
const result = parseRecallInfo(text, responseData);
expect(result).toBe("Due January 1st, 2023");
});
test("leaves non-date string unchanged in recall", () => {
const text = "Note: #recall:note/fallback:none#";
const responseData: TResponseData = {
note: "some text",
};
const result = parseRecallInfo(text, responseData);
expect(result).toBe("Note: some text");
});
test("formats array values as comma-separated list", () => { test("formats array values as comma-separated list", () => {
const text = "Your selections: #recall:preferences/fallback:none#"; const text = "Your selections: #recall:preferences/fallback:none#";
const responseData: TResponseData = { const responseData: TResponseData = {

View File

@@ -1,3 +1,4 @@
import { parseDateWithFormats } from "@formbricks/surveys/date-format";
import { type TI18nString } from "@formbricks/types/i18n"; import { type TI18nString } from "@formbricks/types/i18n";
import { TResponseData, TResponseDataValue, TResponseVariables } from "@formbricks/types/responses"; import { TResponseData, TResponseDataValue, TResponseVariables } from "@formbricks/types/responses";
import { TSurveyElement } from "@formbricks/types/surveys/elements"; import { TSurveyElement } from "@formbricks/types/surveys/elements";
@@ -6,7 +7,7 @@ import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone"; import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils"; import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { formatDateWithOrdinal, isValidDateString } from "./datetime"; import { formatDateWithOrdinal } from "./datetime";
export interface fallbacks { export interface fallbacks {
[id: string]: string; [id: string]: string;
@@ -255,8 +256,11 @@ export const parseRecallInfo = (
// Apply formatting for special value types // Apply formatting for special value types
if (value) { if (value) {
if (isValidDateString(value as string)) { if (typeof value === "string") {
value = formatDateWithOrdinal(new Date(value as string)); const parsedDate = parseDateWithFormats(value);
if (parsedDate !== null) {
value = formatDateWithOrdinal(parsedDate);
}
} else if (Array.isArray(value)) { } else if (Array.isArray(value)) {
value = value.filter((item) => item).join(", "); value = value.filter((item) => item).join(", ");
} }

View File

@@ -1,13 +1,15 @@
import { CheckCheckIcon, MousePointerClickIcon, PhoneIcon } from "lucide-react"; import { CheckCheckIcon, MousePointerClickIcon, PhoneIcon } from "lucide-react";
import React from "react"; import React from "react";
import { logger } from "@formbricks/logger";
import { TResponseDataValue } from "@formbricks/types/responses"; import { TResponseDataValue } from "@formbricks/types/responses";
import { DEFAULT_DATE_STORAGE_FORMAT } from "@formbricks/types/surveys/date-formats";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { getLanguageCode, getLocalizedValue } from "@/lib/i18n/utils"; import { getLanguageCode, getLocalizedValue } from "@/lib/i18n/utils";
import { getChoiceIdByValue } from "@/lib/response/utils"; import { getChoiceIdByValue } from "@/lib/response/utils";
import { processResponseData } from "@/lib/responses"; import { processResponseData } from "@/lib/responses";
import { formatDateWithOrdinal } from "@/lib/utils/datetime"; import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
import { renderHyperlinkedContent } from "@/modules/analysis/utils"; import { renderHyperlinkedContent } from "@/modules/analysis/utils";
import { ArrayResponse } from "@/modules/ui/components/array-response"; import { ArrayResponse } from "@/modules/ui/components/array-response";
import { FileUploadResponse } from "@/modules/ui/components/file-upload-response"; import { FileUploadResponse } from "@/modules/ui/components/file-upload-response";
@@ -63,11 +65,15 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
break; break;
case TSurveyElementTypeEnum.Date: case TSurveyElementTypeEnum.Date:
if (typeof responseData === "string") { if (typeof responseData === "string") {
const parsedDate = new Date(responseData); const format = element.format ?? DEFAULT_DATE_STORAGE_FORMAT;
const formatted = formatStoredDateForDisplay(responseData, format, responseData);
const formattedDate = isNaN(parsedDate.getTime()) ? responseData : formatDateWithOrdinal(parsedDate); if (formatted === responseData) {
logger.warn(
return <p className="ph-no-capture my-1 truncate font-normal text-slate-700">{formattedDate}</p>; { elementId: element.id, format, value: responseData },
"[RenderResponse] could not parse date response value"
);
}
return <p className="ph-no-capture my-1 truncate font-normal text-slate-700">{formatted}</p>;
} }
break; break;
case TSurveyElementTypeEnum.PictureSelection: case TSurveyElementTypeEnum.PictureSelection:

View File

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

View File

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

View File

@@ -1,9 +1,45 @@
import * as React from "react"; import * as React from "react";
import {
DATE_STORAGE_FORMATS,
DEFAULT_DATE_STORAGE_FORMAT,
type TSurveyDateStorageFormat,
getOutputOrder,
} from "@formbricks/types/surveys/date-formats";
import { Calendar } from "@/components/general/calendar"; import { Calendar } from "@/components/general/calendar";
import { ElementError } from "@/components/general/element-error"; import { ElementError } from "@/components/general/element-error";
import { ElementHeader } from "@/components/general/element-header"; import { ElementHeader } from "@/components/general/element-header";
import { getDateFnsLocale } from "@/lib/locale"; import { getDateFnsLocale } from "@/lib/locale";
export type DateStorageFormat = TSurveyDateStorageFormat;
/** Optional whitespace + three hyphen-separated digit segments (validates shape and digits in one pass). */
const DATE_PARTS_REGEX = /^\s*(?<part1>\d+)-(?<part2>\d+)-(?<part3>\d+)\s*$/;
function parseValueToDate(value: string, format: DateStorageFormat): Date | undefined {
const match = DATE_PARTS_REGEX.exec(value);
if (!match?.groups) return undefined;
const { part1, part2, part3 } = match.groups;
const nums = [Number.parseInt(part1, 10), Number.parseInt(part2, 10), Number.parseInt(part3, 10)];
const effectiveFormat = part1.length === 4 ? DEFAULT_DATE_STORAGE_FORMAT : format;
const order = DATE_STORAGE_FORMATS[effectiveFormat].parseOrder;
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)
return undefined;
return date;
}
function formatDateForStorage(year: string, month: string, day: string, format: DateStorageFormat): string {
const comps = [year, month, day];
const [i, j, k] = getOutputOrder(DATE_STORAGE_FORMATS[format].parseOrder);
return `${comps[i]}-${comps[j]}-${comps[k]}`;
}
interface DateElementProps { interface DateElementProps {
/** Unique identifier for the element container */ /** Unique identifier for the element container */
elementId: string; elementId: string;
@@ -13,10 +49,12 @@ interface DateElementProps {
description?: string; description?: string;
/** Unique identifier for the date input */ /** Unique identifier for the date input */
inputId: string; inputId: string;
/** Current date value in ISO format (YYYY-MM-DD) */ /** Current date value (format depends on outputFormat; legacy is often YYYY-MM-DD) */
value?: string; value?: string;
/** Callback function called when the date value changes */ /** Callback function called when the date value changes */
onChange: (value: string) => void; onChange: (value: string) => void;
/** Format for the value passed to onChange (default y-M-d = ISO) */
outputFormat?: DateStorageFormat;
/** Whether the field is required (shows asterisk indicator) */ /** Whether the field is required (shows asterisk indicator) */
required?: boolean; required?: boolean;
/** Custom label for the required indicator */ /** Custom label for the required indicator */
@@ -46,6 +84,7 @@ function DateElement({
inputId, inputId,
value, value,
onChange, onChange,
outputFormat = DEFAULT_DATE_STORAGE_FORMAT,
required = false, required = false,
requiredLabel, requiredLabel,
minDate, minDate,
@@ -57,42 +96,31 @@ function DateElement({
imageUrl, imageUrl,
videoUrl, videoUrl,
}: Readonly<DateElementProps>): React.JSX.Element { }: Readonly<DateElementProps>): React.JSX.Element {
// Initialize date from value string, parsing as local time to avoid timezone issues
const [date, setDate] = React.useState<Date | undefined>(() => { const [date, setDate] = React.useState<Date | undefined>(() => {
if (!value) return undefined; if (!value) return undefined;
// Parse YYYY-MM-DD format as local date (not UTC) return parseValueToDate(value, outputFormat);
const [year, month, day] = value.split("-").map(Number);
return new Date(year, month - 1, day);
}); });
// Sync date state when value prop changes
React.useEffect(() => { React.useEffect(() => {
if (value) { if (!value) {
// Parse YYYY-MM-DD format as local date (not UTC)
const [year, month, day] = value.split("-").map(Number);
const newDate = new Date(year, month - 1, day);
setDate((prevDate) => {
// Only update if the date actually changed to avoid unnecessary re-renders
if (!prevDate || newDate.getTime() !== prevDate.getTime()) {
return newDate;
}
return prevDate;
});
} else {
setDate(undefined); setDate(undefined);
return;
} }
}, [value]); const newDate = parseValueToDate(value, outputFormat);
setDate((prevDate) => {
if (!newDate) return undefined;
if (prevDate?.getTime() !== newDate.getTime()) return newDate;
return prevDate;
});
}, [value, outputFormat]);
// Convert Date to ISO string (YYYY-MM-DD) when date changes
const handleDateSelect = (selectedDate: Date | undefined): void => { const handleDateSelect = (selectedDate: Date | undefined): void => {
setDate(selectedDate); setDate(selectedDate);
if (selectedDate) { if (selectedDate) {
// Convert to ISO format (YYYY-MM-DD) using local time to avoid timezone issues
const year = String(selectedDate.getFullYear()); const year = String(selectedDate.getFullYear());
const month = String(selectedDate.getMonth() + 1).padStart(2, "0"); const month = String(selectedDate.getMonth() + 1).padStart(2, "0");
const day = String(selectedDate.getDate()).padStart(2, "0"); const day = String(selectedDate.getDate()).padStart(2, "0");
const isoString = `${year}-${month}-${day}`; onChange(formatDateForStorage(year, month, day, outputFormat));
onChange(isoString);
} else { } else {
onChange(""); onChange("");
} }

View File

@@ -27,6 +27,10 @@
"./validation": { "./validation": {
"types": "./dist/validation.d.ts", "types": "./dist/validation.d.ts",
"import": "./dist/validation.js" "import": "./dist/validation.js"
},
"./date-format": {
"types": "./dist/date-format.d.ts",
"import": "./dist/date-format.js"
} }
}, },
"scripts": { "scripts": {

View File

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

View File

@@ -0,0 +1 @@
export { parseDateByFormat, parseDateWithFormats, type TSurveyDateStorageFormat } from "./lib/date-format";

View File

@@ -0,0 +1,131 @@
import { describe, expect, test } from "vitest";
import { parseDateByFormat, parseDateWithFormats } from "./date-format";
describe("parseDateByFormat", () => {
test("parses ISO (y-M-d) string with default format", () => {
const result = parseDateByFormat("2024-03-13");
expect(result).not.toBeNull();
expect(result!.getFullYear()).toBe(2024);
expect(result!.getMonth()).toBe(2);
expect(result!.getDate()).toBe(13);
});
test("parses ISO string with explicit y-M-d format", () => {
const result = parseDateByFormat("2024-01-05", "y-M-d");
expect(result).not.toBeNull();
expect(result!.getFullYear()).toBe(2024);
expect(result!.getMonth()).toBe(0);
expect(result!.getDate()).toBe(5);
});
test("parses d-M-y string (DD-MM-YYYY)", () => {
const result = parseDateByFormat("13-03-2024", "d-M-y");
expect(result).not.toBeNull();
expect(result!.getFullYear()).toBe(2024);
expect(result!.getMonth()).toBe(2);
expect(result!.getDate()).toBe(13);
});
test("parses M-d-y string (MM-DD-YYYY)", () => {
const result = parseDateByFormat("03-13-2024", "M-d-y");
expect(result).not.toBeNull();
expect(result!.getFullYear()).toBe(2024);
expect(result!.getMonth()).toBe(2);
expect(result!.getDate()).toBe(13);
});
test("backward compat: value starting with 4 digits (YYYY) parses as ISO regardless of format", () => {
const value = "2024-03-13";
expect(parseDateByFormat(value, "d-M-y")).not.toBeNull();
expect(parseDateByFormat(value, "d-M-y")!.getDate()).toBe(13);
expect(parseDateByFormat(value, "M-d-y")).not.toBeNull();
expect(parseDateByFormat(value, "M-d-y")!.getDate()).toBe(13);
});
test("backward compat: single-digit month/day in ISO-style parses", () => {
const result = parseDateByFormat("2024-3-5");
expect(result).not.toBeNull();
expect(result!.getFullYear()).toBe(2024);
expect(result!.getMonth()).toBe(2);
expect(result!.getDate()).toBe(5);
});
test("returns null for empty string", () => {
expect(parseDateByFormat("")).toBeNull();
});
test("returns null for malformed string (not three parts)", () => {
expect(parseDateByFormat("2024-03")).toBeNull();
expect(parseDateByFormat("2024")).toBeNull();
expect(parseDateByFormat("a-b-c")).toBeNull();
});
test("returns null for invalid numbers (e.g. month 13)", () => {
expect(parseDateByFormat("2024-13-01")).toBeNull();
expect(parseDateByFormat("2024-00-01")).toBeNull();
expect(parseDateByFormat("32-03-2024", "d-M-y")).toBeNull();
});
test("returns null for invalid date (e.g. Feb 30)", () => {
expect(parseDateByFormat("2024-02-30")).toBeNull();
});
test("trims whitespace", () => {
const result = parseDateByFormat(" 2024-03-13 ");
expect(result).not.toBeNull();
expect(result!.getDate()).toBe(13);
});
test("leap year parses correctly", () => {
const result = parseDateByFormat("2024-02-29");
expect(result).not.toBeNull();
expect(result!.getMonth()).toBe(1);
expect(result!.getDate()).toBe(29);
});
test("single-digit month/day in d-M-y format", () => {
const result = parseDateByFormat("5-3-2024", "d-M-y");
expect(result).not.toBeNull();
expect(result!.getFullYear()).toBe(2024);
expect(result!.getMonth()).toBe(2);
expect(result!.getDate()).toBe(5);
});
test("single-digit month/day in M-d-y format", () => {
const result = parseDateByFormat("3-5-2024", "M-d-y");
expect(result).not.toBeNull();
expect(result!.getFullYear()).toBe(2024);
expect(result!.getMonth()).toBe(2);
expect(result!.getDate()).toBe(5);
});
});
describe("parseDateWithFormats", () => {
test("parses ISO string (y-M-d)", () => {
const result = parseDateWithFormats("2024-03-13");
expect(result).not.toBeNull();
expect(result!.getFullYear()).toBe(2024);
expect(result!.getDate()).toBe(13);
});
test("parses d-M-y string when format unknown", () => {
const result = parseDateWithFormats("20-03-2026");
expect(result).not.toBeNull();
expect(result!.getFullYear()).toBe(2026);
expect(result!.getMonth()).toBe(2);
expect(result!.getDate()).toBe(20);
});
test("parses M-d-y string when format unknown", () => {
const result = parseDateWithFormats("03-20-2026");
expect(result).not.toBeNull();
expect(result!.getFullYear()).toBe(2026);
expect(result!.getMonth()).toBe(2);
expect(result!.getDate()).toBe(20);
});
test("returns null for unparseable string", () => {
expect(parseDateWithFormats("not-a-date")).toBeNull();
expect(parseDateWithFormats("")).toBeNull();
});
});

View File

@@ -0,0 +1,77 @@
import {
DATE_STORAGE_FORMATS,
DATE_STORAGE_FORMATS_LIST,
DEFAULT_DATE_STORAGE_FORMAT,
type TSurveyDateStorageFormat,
} from "@formbricks/types/surveys/date-formats";
export type { TSurveyDateStorageFormat } from "@formbricks/types/surveys/date-formats";
const ISO_FIRST_CHARS = /^\d{4}/;
/**
* Parse a date string stored in response data using the element's storage format.
* 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.
*
* @param value - The stored date string (e.g. "2024-03-13", "13-03-2024", "03-13-2024")
* @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 = DEFAULT_DATE_STORAGE_FORMAT
): Date | null {
const trimmed = value?.trim();
if (!trimmed || typeof trimmed !== "string") {
return null;
}
const parts = trimmed.split("-");
if (parts.length !== 3) {
return null;
}
const useIso = ISO_FIRST_CHARS.test(trimmed);
const effectiveFormat = useIso ? DEFAULT_DATE_STORAGE_FORMAT : format;
const order = DATE_STORAGE_FORMATS[effectiveFormat].parseOrder;
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];
if (month < 1 || month > 12 || day < 1 || day > 31) {
return null;
}
const date = new Date(year, month - 1, day);
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
return null;
}
return date;
}
/**
* Try to parse a date string using each known storage format in order.
* Use when the storage format is unknown (e.g. recall placeholders).
*
* Ambiguity: Values like "03-05-2024" can be March 5th (M-d-y) or May 3rd (d-M-y).
* Formats are tried in DATE_STORAGE_FORMATS_LIST order (M-d-y, then d-M-y, then y-M-d);
* the first successful parse is returned. M-d-y is preferred when format metadata is
* unavailable so that common US-style input is accepted first.
*
* @param value - Stored date string
* @returns Parsed Date or null if no format matched
*/
export function parseDateWithFormats(value: string): Date | null {
for (const format of DATE_STORAGE_FORMATS_LIST) {
const parsed = parseDateByFormat(value, format);
if (parsed !== null) return parsed;
}
return null;
}

View File

@@ -807,6 +807,57 @@ describe("Survey Logic", () => {
); );
}); });
test("date equals with d-M-y stored value", () => {
const dataWithDMy: TResponseData = { ...mockData, q5: "01-01-2023" };
const condition: TConditionGroup = {
id: "g1",
connector: "and",
conditions: [
{
id: "c1",
operator: "equals",
leftOperand: { type: "element", value: "q5" },
rightOperand: { type: "static", value: "2023-01-01" },
},
],
};
expect(evaluateLogic(mockSurvey, dataWithDMy, mockVariablesData, condition, "default")).toBe(true);
});
test("date comparison returns false when response date is unparseable", () => {
const dataInvalidDate: TResponseData = { ...mockData, q5: "not-a-date" };
const condition: TConditionGroup = {
id: "g1",
connector: "and",
conditions: [
{
id: "c1",
operator: "equals",
leftOperand: { type: "element", value: "q5" },
rightOperand: { type: "static", value: "2023-01-01" },
},
],
};
expect(evaluateLogic(mockSurvey, dataInvalidDate, mockVariablesData, condition, "default")).toBe(false);
});
test("isAfter returns false when left date is unparseable", () => {
const dataInvalidDate: TResponseData = { ...mockData, q5: "invalid" };
const condition: TConditionGroup = {
id: "g1",
connector: "and",
conditions: [
{
id: "c1",
operator: "isAfter",
leftOperand: { type: "element", value: "q5" },
rightOperand: { type: "static", value: "2022-01-01" },
},
],
};
expect(evaluateLogic(mockSurvey, dataInvalidDate, mockVariablesData, condition, "default")).toBe(false);
});
test("evaluates array inclusion operators", () => { test("evaluates array inclusion operators", () => {
// Tests for includesAllOf, includesOneOf, etc. // Tests for includesAllOf, includesOneOf, etc.
const includesAllOfCondition: TConditionGroup = { const includesAllOfCondition: TConditionGroup = {

View File

@@ -2,12 +2,56 @@ import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { type TResponseData, type TResponseVariables } from "@formbricks/types/responses"; import { type TResponseData, type TResponseVariables } from "@formbricks/types/responses";
import { type TActionCalculate, type TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks"; import { type TActionCalculate, type TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants"; import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { DEFAULT_DATE_STORAGE_FORMAT } from "@formbricks/types/surveys/date-formats";
import type { TSurveyElement } from "@formbricks/types/surveys/elements"; import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import { type TConditionGroup, type TSingleCondition } from "@formbricks/types/surveys/logic"; import { type TConditionGroup, type TSingleCondition } from "@formbricks/types/surveys/logic";
import { type TSurveyVariable } from "@formbricks/types/surveys/types"; import { type TSurveyVariable } from "@formbricks/types/surveys/types";
import { parseDateByFormat } from "@/lib/date-format";
import { getLocalizedValue } from "@/lib/i18n"; import { getLocalizedValue } from "@/lib/i18n";
import { getElementsFromSurveyBlocks } from "./utils"; 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.format ?? DEFAULT_DATE_STORAGE_FORMAT)
: DEFAULT_DATE_STORAGE_FORMAT;
return parseDateByFormat(value, format);
}
function compareDateOperands(
leftValue: string,
rightValue: string,
leftField: TSurveyElement,
rightField: TSurveyElement,
operator: string,
compare: (left: Date, right: Date) => boolean
): boolean {
const leftDate = parseDateOperand(leftValue, leftField);
const rightDate = parseDateOperand(rightValue, rightField);
if (leftDate === null) {
console.warn(`[logic] ${operator}: could not parse left date`, {
elementId: leftField.id,
value: leftValue,
});
return false;
}
if (rightDate === null) {
console.warn(`[logic] ${operator}: could not parse right date`, {
elementId: rightField.id,
value: rightValue,
});
return false;
}
return compare(leftDate, rightDate);
}
const getVariableValue = ( const getVariableValue = (
variables: TSurveyVariable[], variables: TSurveyVariable[],
variableId: string, variableId: string,
@@ -265,12 +309,17 @@ const evaluateSingleCondition = (
typeof leftValue === "string" && typeof leftValue === "string" &&
typeof rightValue === "string" typeof rightValue === "string"
) { ) {
// when left value is of date question and right value is string return compareDateOperands(
return new Date(leftValue).getTime() === new Date(rightValue).getTime(); leftValue,
String(rightValue),
leftField as TSurveyElement,
rightField as TSurveyElement,
"equals",
(l, r) => l.getTime() === r.getTime()
);
} }
} }
// when left value is of openText, hiddenField, variable and right value is of multichoice
if (condition.rightOperand?.type === "element") { if (condition.rightOperand?.type === "element") {
if ((rightField as TSurveyElement).type === TSurveyElementTypeEnum.MultipleChoiceMulti) { if ((rightField as TSurveyElement).type === TSurveyElementTypeEnum.MultipleChoiceMulti) {
if (Array.isArray(rightValue) && typeof leftValue === "string" && rightValue.length === 1) { if (Array.isArray(rightValue) && typeof leftValue === "string" && rightValue.length === 1) {
@@ -281,7 +330,14 @@ const evaluateSingleCondition = (
typeof leftValue === "string" && typeof leftValue === "string" &&
typeof rightValue === "string" typeof rightValue === "string"
) { ) {
return new Date(leftValue).getTime() === new Date(rightValue).getTime(); return compareDateOperands(
leftValue,
String(rightValue),
leftField as TSurveyElement,
rightField as TSurveyElement,
"equals",
(l, r) => l.getTime() === r.getTime()
);
} }
} }
@@ -304,17 +360,22 @@ const evaluateSingleCondition = (
return !leftValue.includes(rightValue); return !leftValue.includes(rightValue);
} }
// when left value is of date question and right value is string
if ( if (
condition.leftOperand.type === "element" && condition.leftOperand.type === "element" &&
(leftField as TSurveyElement).type === TSurveyElementTypeEnum.Date && (leftField as TSurveyElement).type === TSurveyElementTypeEnum.Date &&
typeof leftValue === "string" && typeof leftValue === "string" &&
typeof rightValue === "string" typeof rightValue === "string"
) { ) {
return new Date(leftValue).getTime() !== new Date(rightValue).getTime(); return compareDateOperands(
leftValue,
String(rightValue),
leftField as TSurveyElement,
rightField as TSurveyElement,
"doesNotEqual",
(l, r) => l.getTime() !== r.getTime()
);
} }
// when left value is of openText, hiddenField, variable and right value is of multichoice
if (condition.rightOperand?.type === "element") { if (condition.rightOperand?.type === "element") {
if ((rightField as TSurveyElement).type === TSurveyElementTypeEnum.MultipleChoiceMulti) { if ((rightField as TSurveyElement).type === TSurveyElementTypeEnum.MultipleChoiceMulti) {
if (Array.isArray(rightValue) && typeof leftValue === "string" && rightValue.length === 1) { if (Array.isArray(rightValue) && typeof leftValue === "string" && rightValue.length === 1) {
@@ -325,7 +386,14 @@ const evaluateSingleCondition = (
typeof leftValue === "string" && typeof leftValue === "string" &&
typeof rightValue === "string" typeof rightValue === "string"
) { ) {
return new Date(leftValue).getTime() !== new Date(rightValue).getTime(); return compareDateOperands(
leftValue,
String(rightValue),
leftField as TSurveyElement,
rightField as TSurveyElement,
"doesNotEqual",
(l, r) => l.getTime() !== r.getTime()
);
} }
} }
@@ -413,9 +481,23 @@ const evaluateSingleCondition = (
case "isNotClicked": case "isNotClicked":
return leftValue !== "clicked"; return leftValue !== "clicked";
case "isAfter": case "isAfter":
return new Date(String(leftValue)) > new Date(String(rightValue)); return compareDateOperands(
toDateOperandString(leftValue),
toDateOperandString(rightValue),
leftField as TSurveyElement,
rightField as TSurveyElement,
"isAfter",
(l, r) => l.getTime() > r.getTime()
);
case "isBefore": case "isBefore":
return new Date(String(leftValue)) < new Date(String(rightValue)); return compareDateOperands(
toDateOperandString(leftValue),
toDateOperandString(rightValue),
leftField as TSurveyElement,
rightField as TSurveyElement,
"isBefore",
(l, r) => l.getTime() < r.getTime()
);
case "isBooked": case "isBooked":
return leftValue === "booked" || !!(leftValue && leftValue !== ""); return leftValue === "booked" || !!(leftValue && leftValue !== "");
case "isPartiallySubmitted": case "isPartiallySubmitted":

View File

@@ -585,6 +585,58 @@ describe("validators", () => {
}); });
}); });
describe("date validators with element format and parse failure", () => {
const dateElementDMy: TSurveyElement = {
id: "date1",
type: TSurveyElementTypeEnum.Date,
headline: { default: "Date" },
required: false,
format: "d-M-y",
} as TSurveyElement;
test("isLaterThan uses element format d-M-y", () => {
const result = validators.isLaterThan.check("20-03-2026", { date: "2024-01-01" }, dateElementDMy);
expect(result.valid).toBe(true);
});
test("isEarlierThan uses element format d-M-y", () => {
const result = validators.isEarlierThan.check("01-01-2024", { date: "2024-12-31" }, dateElementDMy);
expect(result.valid).toBe(true);
});
test("isBetween with d-M-y value", () => {
const result = validators.isBetween.check(
"15-06-2024",
{ startDate: "2024-01-01", endDate: "2024-12-31" },
dateElementDMy
);
expect(result.valid).toBe(true);
});
test("isLaterThan returns valid false when response date is unparseable", () => {
const result = validators.isLaterThan.check("not-a-date", { date: "2024-01-01" }, dateElementDMy);
expect(result.valid).toBe(false);
});
test("isBetween returns valid false when response date is unparseable", () => {
const result = validators.isBetween.check(
"invalid",
{ startDate: "2024-01-01", endDate: "2024-12-31" },
dateElementDMy
);
expect(result.valid).toBe(false);
});
test("isBetween returns valid false when param date is unparseable", () => {
const result = validators.isBetween.check(
"15-06-2024",
{ startDate: "bad-start", endDate: "2024-12-31" },
dateElementDMy
);
expect(result.valid).toBe(false);
});
});
describe("minRanked", () => { describe("minRanked", () => {
const rankingElement: TSurveyElement = { const rankingElement: TSurveyElement = {
id: "rank1", id: "rank1",

View File

@@ -1,5 +1,9 @@
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import type { TResponseDataValue } from "@formbricks/types/responses"; import type { TResponseDataValue } from "@formbricks/types/responses";
import {
DEFAULT_DATE_STORAGE_FORMAT,
type TSurveyDateStorageFormat,
} from "@formbricks/types/surveys/date-formats";
import type { TSurveyElement } from "@formbricks/types/surveys/elements"; import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { import type {
TValidationRuleParams, TValidationRuleParams,
@@ -27,9 +31,38 @@ import type {
TValidationRuleType, TValidationRuleType,
TValidatorCheckResult, TValidatorCheckResult,
} from "@formbricks/types/surveys/validation-rules"; } from "@formbricks/types/surveys/validation-rules";
import { parseDateByFormat } from "@/lib/date-format";
import { countSelections } from "./validators/selection-utils"; import { countSelections } from "./validators/selection-utils";
import { validateEmail, validatePhone, validateUrl } from "./validators/validation-utils"; import { validateEmail, validatePhone, validateUrl } from "./validators/validation-utils";
function getDateElementFormat(element: TSurveyElement): TSurveyDateStorageFormat {
if (element.type === "date" && "format" in element) {
return element.format ?? DEFAULT_DATE_STORAGE_FORMAT;
}
return DEFAULT_DATE_STORAGE_FORMAT;
}
function parseForDateComparison(
value: string,
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, DEFAULT_DATE_STORAGE_FORMAT));
if (valueDate === null) {
console.warn(`[date validation] ${ruleName}: could not parse response date`, {
elementId: element.id,
format,
value,
});
return null;
}
if (parsedParams.includes(null)) return null;
return { valueDate, paramDates: parsedParams as Date[] };
}
/** /**
* Generic validator interface * Generic validator interface
* Uses type assertions internally to handle the discriminated union params * Uses type assertions internally to handle the discriminated union params
@@ -349,14 +382,18 @@ export const validators: Record<TValidationRuleType, TValidator> = {
}, },
}, },
isLaterThan: { isLaterThan: {
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => { check: (
value: TResponseDataValue,
params: TValidationRuleParams,
element: TSurveyElement
): TValidatorCheckResult => {
const typedParams = params as TValidationRuleParamsIsLaterThan; const typedParams = params as TValidationRuleParamsIsLaterThan;
// Skip validation if value is empty if (!value || typeof value !== "string" || value === "") return { valid: true };
if (!value || typeof value !== "string" || value === "") { const parsed = parseForDateComparison(value, getDateElementFormat(element), element, "isLaterThan", [
return { valid: true }; typedParams.date,
} ]);
// Compare dates as strings (YYYY-MM-DD format) if (!parsed) return { valid: false };
return { valid: value > typedParams.date }; return { valid: parsed.valueDate.getTime() > parsed.paramDates[0].getTime() };
}, },
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => { getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
const typedParams = params as TValidationRuleParamsIsLaterThan; const typedParams = params as TValidationRuleParamsIsLaterThan;
@@ -364,14 +401,18 @@ export const validators: Record<TValidationRuleType, TValidator> = {
}, },
}, },
isEarlierThan: { isEarlierThan: {
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => { check: (
value: TResponseDataValue,
params: TValidationRuleParams,
element: TSurveyElement
): TValidatorCheckResult => {
const typedParams = params as TValidationRuleParamsIsEarlierThan; const typedParams = params as TValidationRuleParamsIsEarlierThan;
// Skip validation if value is empty if (!value || typeof value !== "string" || value === "") return { valid: true };
if (!value || typeof value !== "string" || value === "") { const parsed = parseForDateComparison(value, getDateElementFormat(element), element, "isEarlierThan", [
return { valid: true }; typedParams.date,
} ]);
// Compare dates as strings (YYYY-MM-DD format) if (!parsed) return { valid: false };
return { valid: value < typedParams.date }; return { valid: parsed.valueDate.getTime() < parsed.paramDates[0].getTime() };
}, },
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => { getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
const typedParams = params as TValidationRuleParamsIsEarlierThan; const typedParams = params as TValidationRuleParamsIsEarlierThan;
@@ -379,14 +420,22 @@ export const validators: Record<TValidationRuleType, TValidator> = {
}, },
}, },
isBetween: { isBetween: {
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => { check: (
value: TResponseDataValue,
params: TValidationRuleParams,
element: TSurveyElement
): TValidatorCheckResult => {
const typedParams = params as TValidationRuleParamsIsBetween; const typedParams = params as TValidationRuleParamsIsBetween;
// Skip validation if value is empty if (!value || typeof value !== "string" || value === "") return { valid: true };
if (!value || typeof value !== "string" || value === "") { const parsed = parseForDateComparison(value, getDateElementFormat(element), element, "isBetween", [
return { valid: true }; typedParams.startDate,
} typedParams.endDate,
// Compare dates as strings (YYYY-MM-DD format) ]);
return { valid: value > typedParams.startDate && value < typedParams.endDate }; if (!parsed) return { valid: false };
const t = parsed.valueDate.getTime();
return {
valid: t > parsed.paramDates[0].getTime() && t < parsed.paramDates[1].getTime(),
};
}, },
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => { getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
const typedParams = params as TValidationRuleParamsIsBetween; const typedParams = params as TValidationRuleParamsIsBetween;
@@ -394,14 +443,22 @@ export const validators: Record<TValidationRuleType, TValidator> = {
}, },
}, },
isNotBetween: { isNotBetween: {
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => { check: (
value: TResponseDataValue,
params: TValidationRuleParams,
element: TSurveyElement
): TValidatorCheckResult => {
const typedParams = params as TValidationRuleParamsIsNotBetween; const typedParams = params as TValidationRuleParamsIsNotBetween;
// Skip validation if value is empty if (!value || typeof value !== "string" || value === "") return { valid: true };
if (!value || typeof value !== "string" || value === "") { const parsed = parseForDateComparison(value, getDateElementFormat(element), element, "isNotBetween", [
return { valid: true }; typedParams.startDate,
} typedParams.endDate,
// Compare dates as strings (YYYY-MM-DD format) ]);
return { valid: value < typedParams.startDate || value > typedParams.endDate }; if (!parsed) return { valid: false };
const t = parsed.valueDate.getTime();
return {
valid: t < parsed.paramDates[0].getTime() || t > parsed.paramDates[1].getTime(),
};
}, },
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => { getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
const typedParams = params as TValidationRuleParamsIsNotBetween; const typedParams = params as TValidationRuleParamsIsNotBetween;

View File

@@ -87,6 +87,7 @@ const config = ({ mode }) => {
entry: { entry: {
index: resolve(__dirname, "src/index.ts"), index: resolve(__dirname, "src/index.ts"),
validation: resolve(__dirname, "src/validation.ts"), validation: resolve(__dirname, "src/validation.ts"),
"date-format": resolve(__dirname, "src/date-format.ts"),
}, },
formats: ["es"], formats: ["es"],
}, },

View File

@@ -0,0 +1,61 @@
/**
* 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 and one descriptor (parseOrder + label) to DATE_STORAGE_FORMATS.
*/
/** 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";
/** Indices into the split("-") array for [year, month, day]. parts[yearIdx]=year, etc. */
export interface DateParseOrder {
yearIdx: number;
monthIdx: number;
dayIdx: number;
}
/** Descriptor for one date storage format. outputOrder is derived from parseOrder. */
export interface DateFormatDescriptor {
parseOrder: DateParseOrder;
/** Default display label (e.g. "YYYY-MM-DD"). Apps can override with i18n. */
label: string;
}
/** Output order derived from parse order: which of [year, month, day] goes in each string position. */
export function getOutputOrder(parseOrder: DateParseOrder): [number, number, number] {
const out: [number, number, number] = [0, 0, 0];
out[parseOrder.yearIdx] = 0;
out[parseOrder.monthIdx] = 1;
out[parseOrder.dayIdx] = 2;
return out;
}
/** One struct per format; single place to add or change a format. */
export const DATE_STORAGE_FORMATS: Record<TSurveyDateStorageFormat, DateFormatDescriptor> = {
"y-M-d": {
parseOrder: { yearIdx: 0, monthIdx: 1, dayIdx: 2 },
label: "YYYY-MM-DD",
},
"d-M-y": {
parseOrder: { yearIdx: 2, monthIdx: 1, dayIdx: 0 },
label: "DD-MM-YYYY",
},
"M-d-y": {
parseOrder: { yearIdx: 2, monthIdx: 0, dayIdx: 1 },
label: "MM-DD-YYYY",
},
};
/**
* All format ids as an array (for iteration, e.g. dropdowns or try-parse).
* When used for try-parse without format metadata, the first matching format wins;
* order is M-d-y, d-M-y, y-M-d (see parseDateWithFormats in \@formbricks/surveys).
*/
export const DATE_STORAGE_FORMATS_LIST: TSurveyDateStorageFormat[] = [...DATE_STORAGE_FORMAT_IDS];

View File

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

View File

@@ -10,6 +10,7 @@ import { ZAllowedFileExtension } from "../storage";
import { ZBaseStyling } from "../styling"; import { ZBaseStyling } from "../styling";
import { type TSurveyBlock, type TSurveyBlockLogicAction, ZSurveyBlocks } from "./blocks"; import { type TSurveyBlock, type TSurveyBlockLogicAction, ZSurveyBlocks } from "./blocks";
import { findBlocksWithCyclicLogic } from "./blocks-validation"; import { findBlocksWithCyclicLogic } from "./blocks-validation";
import { DATE_STORAGE_FORMAT_IDS } from "./date-formats";
import { import {
type TSurveyElement, type TSurveyElement,
TSurveyElementTypeEnum, TSurveyElementTypeEnum,
@@ -560,7 +561,7 @@ export const ZSurveyRatingQuestion = ZSurveyQuestionBase.extend({
export const ZSurveyDateQuestion = ZSurveyQuestionBase.extend({ export const ZSurveyDateQuestion = ZSurveyQuestionBase.extend({
type: z.literal(TSurveyQuestionTypeEnum.Date), type: z.literal(TSurveyQuestionTypeEnum.Date),
html: ZI18nString.optional(), 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: packages/survey-ui:
dependencies: dependencies:
'@formbricks/types':
specifier: workspace:*
version: link:../types
'@formkit/auto-animate': '@formkit/auto-animate':
specifier: 0.9.0 specifier: 0.9.0
version: 0.9.0 version: 0.9.0

View File

@@ -114,7 +114,8 @@
"@formbricks/logger#build", "@formbricks/logger#build",
"@formbricks/database#build", "@formbricks/database#build",
"@formbricks/storage#build", "@formbricks/storage#build",
"@formbricks/cache#build" "@formbricks/cache#build",
"@formbricks/surveys#build"
] ]
}, },
"@formbricks/web#test:coverage": { "@formbricks/web#test:coverage": {
@@ -122,7 +123,8 @@
"@formbricks/logger#build", "@formbricks/logger#build",
"@formbricks/database#build", "@formbricks/database#build",
"@formbricks/storage#build", "@formbricks/storage#build",
"@formbricks/cache#build" "@formbricks/cache#build",
"@formbricks/surveys#build"
] ]
}, },
"build": { "build": {