mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-13 11:09:29 -05:00
Compare commits
11 Commits
fix-manage
...
chore/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ed6e24a90 | ||
|
|
17bf8a96f7 | ||
|
|
02f4912c30 | ||
|
|
16fe95ee95 | ||
|
|
e91d6dc048 | ||
|
|
f403160563 | ||
|
|
a28c9e74af | ||
|
|
40a1bfb372 | ||
|
|
24b814d578 | ||
|
|
7121063ee9 | ||
|
|
66a9f06796 |
@@ -3,11 +3,12 @@
|
||||
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";
|
||||
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 { Button } from "@/modules/ui/components/button";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
@@ -32,13 +33,8 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
|
||||
};
|
||||
|
||||
const renderResponseValue = (value: string) => {
|
||||
const parsedDate = new Date(value);
|
||||
|
||||
const formattedDate = isNaN(parsedDate.getTime())
|
||||
? `${t("common.invalid_date")}(${value})`
|
||||
: formatDateWithOrdinal(parsedDate);
|
||||
|
||||
return formattedDate;
|
||||
const format = elementSummary.element?.format ?? DEFAULT_DATE_STORAGE_FORMAT;
|
||||
return formatStoredDateForDisplay(value, format, `${t("common.invalid_date")}(${value})`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
43
apps/web/lib/utils/date-display.test.ts
Normal file
43
apps/web/lib/utils/date-display.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
17
apps/web/lib/utils/date-display.ts
Normal file
17
apps/web/lib/utils/date-display.ts
Normal 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);
|
||||
}
|
||||
@@ -480,6 +480,36 @@ describe("recall utility functions", () => {
|
||||
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", () => {
|
||||
const text = "Your selections: #recall:preferences/fallback:none#";
|
||||
const responseData: TResponseData = {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseDateWithFormats } from "@formbricks/surveys/date-format";
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TResponseData, TResponseDataValue, TResponseVariables } from "@formbricks/types/responses";
|
||||
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 { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { formatDateWithOrdinal, isValidDateString } from "./datetime";
|
||||
import { formatDateWithOrdinal } from "./datetime";
|
||||
|
||||
export interface fallbacks {
|
||||
[id: string]: string;
|
||||
@@ -255,8 +256,11 @@ export const parseRecallInfo = (
|
||||
|
||||
// Apply formatting for special value types
|
||||
if (value) {
|
||||
if (isValidDateString(value as string)) {
|
||||
value = formatDateWithOrdinal(new Date(value as string));
|
||||
if (typeof value === "string") {
|
||||
const parsedDate = parseDateWithFormats(value);
|
||||
if (parsedDate !== null) {
|
||||
value = formatDateWithOrdinal(parsedDate);
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
value = value.filter((item) => item).join(", ");
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
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 { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getLanguageCode, getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getChoiceIdByValue } from "@/lib/response/utils";
|
||||
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 { ArrayResponse } from "@/modules/ui/components/array-response";
|
||||
import { FileUploadResponse } from "@/modules/ui/components/file-upload-response";
|
||||
@@ -63,11 +65,15 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
break;
|
||||
case TSurveyElementTypeEnum.Date:
|
||||
if (typeof responseData === "string") {
|
||||
const parsedDate = new Date(responseData);
|
||||
|
||||
const formattedDate = isNaN(parsedDate.getTime()) ? responseData : formatDateWithOrdinal(parsedDate);
|
||||
|
||||
return <p className="ph-no-capture my-1 truncate font-normal text-slate-700">{formattedDate}</p>;
|
||||
const format = element.format ?? DEFAULT_DATE_STORAGE_FORMAT;
|
||||
const formatted = formatStoredDateForDisplay(responseData, format, responseData);
|
||||
if (formatted === responseData) {
|
||||
logger.warn(
|
||||
{ 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;
|
||||
case TSurveyElementTypeEnum.PictureSelection:
|
||||
|
||||
@@ -4,6 +4,11 @@ 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,
|
||||
type TSurveyDateStorageFormat,
|
||||
} 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 +32,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,
|
||||
@@ -122,7 +117,7 @@ export const DateElementForm = ({
|
||||
options={dateOptions}
|
||||
currentOption={element.format}
|
||||
handleOptionChange={(value: string) =>
|
||||
updateElement(elementIdx, { format: value as "M-d-y" | "d-M-y" | "y-M-d" })
|
||||
updateElement(elementIdx, { format: value as TSurveyDateStorageFormat })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,9 +1,45 @@
|
||||
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";
|
||||
|
||||
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 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)
|
||||
return undefined;
|
||||
return date;
|
||||
}
|
||||
|
||||
function formatDateForStorage(year: string, month: string, day: string, format: DateStorageFormat): string {
|
||||
const comps = [year, month, day];
|
||||
const [i, j, k] = DATE_FORMAT_OUTPUT_ORDER[format];
|
||||
return `${comps[i]}-${comps[j]}-${comps[k]}`;
|
||||
}
|
||||
|
||||
interface DateElementProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
@@ -13,10 +49,12 @@ interface DateElementProps {
|
||||
description?: string;
|
||||
/** Unique identifier for the date input */
|
||||
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;
|
||||
/** Callback function called when the date value changes */
|
||||
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) */
|
||||
required?: boolean;
|
||||
/** Custom label for the required indicator */
|
||||
@@ -46,6 +84,7 @@ function DateElement({
|
||||
inputId,
|
||||
value,
|
||||
onChange,
|
||||
outputFormat = DEFAULT_DATE_STORAGE_FORMAT,
|
||||
required = false,
|
||||
requiredLabel,
|
||||
minDate,
|
||||
@@ -57,42 +96,31 @@ function DateElement({
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
}: 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>(() => {
|
||||
if (!value) return undefined;
|
||||
// Parse YYYY-MM-DD format as local date (not UTC)
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
return parseValueToDate(value, outputFormat);
|
||||
});
|
||||
|
||||
// Sync date state when value prop changes
|
||||
React.useEffect(() => {
|
||||
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 {
|
||||
if (!value) {
|
||||
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 => {
|
||||
setDate(selectedDate);
|
||||
if (selectedDate) {
|
||||
// Convert to ISO format (YYYY-MM-DD) using local time to avoid timezone issues
|
||||
const year = String(selectedDate.getFullYear());
|
||||
const month = String(selectedDate.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(selectedDate.getDate()).padStart(2, "0");
|
||||
const isoString = `${year}-${month}-${day}`;
|
||||
onChange(isoString);
|
||||
onChange(formatDateForStorage(year, month, day, outputFormat));
|
||||
} else {
|
||||
onChange("");
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
"./validation": {
|
||||
"types": "./dist/validation.d.ts",
|
||||
"import": "./dist/validation.js"
|
||||
},
|
||||
"./date-format": {
|
||||
"types": "./dist/date-format.d.ts",
|
||||
"import": "./dist/date-format.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -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,6 +69,7 @@ export function DateElement({
|
||||
description={element.subheader ? getLocalizedValue(element.subheader, languageCode) : undefined}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
outputFormat={element.format ?? DEFAULT_DATE_STORAGE_FORMAT}
|
||||
minDate={getMinDate()}
|
||||
maxDate={getMaxDate()}
|
||||
required={isRequired}
|
||||
|
||||
1
packages/surveys/src/date-format.ts
Normal file
1
packages/surveys/src/date-format.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { parseDateByFormat, parseDateWithFormats, type TSurveyDateStorageFormat } from "./lib/date-format";
|
||||
131
packages/surveys/src/lib/date-format.test.ts
Normal file
131
packages/surveys/src/lib/date-format.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
72
packages/surveys/src/lib/date-format.ts
Normal file
72
packages/surveys/src/lib/date-format.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
DATE_FORMAT_PARSE_ORDER,
|
||||
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_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];
|
||||
|
||||
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).
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
@@ -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", () => {
|
||||
// Tests for includesAllOf, includesOneOf, etc.
|
||||
const includesAllOfCondition: TConditionGroup = {
|
||||
|
||||
@@ -2,12 +2,50 @@ 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 { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { type TConditionGroup, type TSingleCondition } from "@formbricks/types/surveys/logic";
|
||||
import { type TSurveyVariable } from "@formbricks/types/surveys/types";
|
||||
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.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) return false;
|
||||
return compare(leftDate, rightDate);
|
||||
}
|
||||
|
||||
const getVariableValue = (
|
||||
variables: TSurveyVariable[],
|
||||
variableId: string,
|
||||
@@ -265,12 +303,17 @@ const evaluateSingleCondition = (
|
||||
typeof leftValue === "string" &&
|
||||
typeof rightValue === "string"
|
||||
) {
|
||||
// when left value is of date question and right value is 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// when left value is of openText, hiddenField, variable and right value is of multichoice
|
||||
if (condition.rightOperand?.type === "element") {
|
||||
if ((rightField as TSurveyElement).type === TSurveyElementTypeEnum.MultipleChoiceMulti) {
|
||||
if (Array.isArray(rightValue) && typeof leftValue === "string" && rightValue.length === 1) {
|
||||
@@ -281,7 +324,14 @@ const evaluateSingleCondition = (
|
||||
typeof leftValue === "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 +354,22 @@ const evaluateSingleCondition = (
|
||||
return !leftValue.includes(rightValue);
|
||||
}
|
||||
|
||||
// when left value is of date question and right value is string
|
||||
if (
|
||||
condition.leftOperand.type === "element" &&
|
||||
(leftField as TSurveyElement).type === TSurveyElementTypeEnum.Date &&
|
||||
typeof leftValue === "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 ((rightField as TSurveyElement).type === TSurveyElementTypeEnum.MultipleChoiceMulti) {
|
||||
if (Array.isArray(rightValue) && typeof leftValue === "string" && rightValue.length === 1) {
|
||||
@@ -325,7 +380,14 @@ const evaluateSingleCondition = (
|
||||
typeof leftValue === "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 +475,23 @@ const evaluateSingleCondition = (
|
||||
case "isNotClicked":
|
||||
return leftValue !== "clicked";
|
||||
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":
|
||||
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":
|
||||
return leftValue === "booked" || !!(leftValue && leftValue !== "");
|
||||
case "isPartiallySubmitted":
|
||||
|
||||
@@ -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", () => {
|
||||
const rankingElement: TSurveyElement = {
|
||||
id: "rank1",
|
||||
|
||||
@@ -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 { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import type {
|
||||
TValidationRuleParams,
|
||||
@@ -27,9 +31,38 @@ import type {
|
||||
TValidationRuleType,
|
||||
TValidatorCheckResult,
|
||||
} from "@formbricks/types/surveys/validation-rules";
|
||||
import { parseDateByFormat } from "@/lib/date-format";
|
||||
import { countSelections } from "./validators/selection-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
|
||||
* Uses type assertions internally to handle the discriminated union params
|
||||
@@ -349,14 +382,18 @@ export const validators: Record<TValidationRuleType, TValidator> = {
|
||||
},
|
||||
},
|
||||
isLaterThan: {
|
||||
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => {
|
||||
check: (
|
||||
value: TResponseDataValue,
|
||||
params: TValidationRuleParams,
|
||||
element: TSurveyElement
|
||||
): TValidatorCheckResult => {
|
||||
const typedParams = params as TValidationRuleParamsIsLaterThan;
|
||||
// Skip validation if value is empty
|
||||
if (!value || typeof value !== "string" || value === "") {
|
||||
return { valid: true };
|
||||
}
|
||||
// Compare dates as strings (YYYY-MM-DD format)
|
||||
return { valid: value > typedParams.date };
|
||||
if (!value || typeof value !== "string" || value === "") return { valid: true };
|
||||
const parsed = parseForDateComparison(value, getDateElementFormat(element), element, "isLaterThan", [
|
||||
typedParams.date,
|
||||
]);
|
||||
if (!parsed) return { valid: false };
|
||||
return { valid: parsed.valueDate.getTime() > parsed.paramDates[0].getTime() };
|
||||
},
|
||||
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
|
||||
const typedParams = params as TValidationRuleParamsIsLaterThan;
|
||||
@@ -364,14 +401,18 @@ export const validators: Record<TValidationRuleType, TValidator> = {
|
||||
},
|
||||
},
|
||||
isEarlierThan: {
|
||||
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => {
|
||||
check: (
|
||||
value: TResponseDataValue,
|
||||
params: TValidationRuleParams,
|
||||
element: TSurveyElement
|
||||
): TValidatorCheckResult => {
|
||||
const typedParams = params as TValidationRuleParamsIsEarlierThan;
|
||||
// Skip validation if value is empty
|
||||
if (!value || typeof value !== "string" || value === "") {
|
||||
return { valid: true };
|
||||
}
|
||||
// Compare dates as strings (YYYY-MM-DD format)
|
||||
return { valid: value < typedParams.date };
|
||||
if (!value || typeof value !== "string" || value === "") return { valid: true };
|
||||
const parsed = parseForDateComparison(value, getDateElementFormat(element), element, "isEarlierThan", [
|
||||
typedParams.date,
|
||||
]);
|
||||
if (!parsed) return { valid: false };
|
||||
return { valid: parsed.valueDate.getTime() < parsed.paramDates[0].getTime() };
|
||||
},
|
||||
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
|
||||
const typedParams = params as TValidationRuleParamsIsEarlierThan;
|
||||
@@ -379,14 +420,22 @@ export const validators: Record<TValidationRuleType, TValidator> = {
|
||||
},
|
||||
},
|
||||
isBetween: {
|
||||
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => {
|
||||
check: (
|
||||
value: TResponseDataValue,
|
||||
params: TValidationRuleParams,
|
||||
element: TSurveyElement
|
||||
): TValidatorCheckResult => {
|
||||
const typedParams = params as TValidationRuleParamsIsBetween;
|
||||
// Skip validation if value is empty
|
||||
if (!value || typeof value !== "string" || value === "") {
|
||||
return { valid: true };
|
||||
}
|
||||
// Compare dates as strings (YYYY-MM-DD format)
|
||||
return { valid: value > typedParams.startDate && value < typedParams.endDate };
|
||||
if (!value || typeof value !== "string" || value === "") return { valid: true };
|
||||
const parsed = parseForDateComparison(value, getDateElementFormat(element), element, "isBetween", [
|
||||
typedParams.startDate,
|
||||
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 => {
|
||||
const typedParams = params as TValidationRuleParamsIsBetween;
|
||||
@@ -394,14 +443,22 @@ export const validators: Record<TValidationRuleType, TValidator> = {
|
||||
},
|
||||
},
|
||||
isNotBetween: {
|
||||
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => {
|
||||
check: (
|
||||
value: TResponseDataValue,
|
||||
params: TValidationRuleParams,
|
||||
element: TSurveyElement
|
||||
): TValidatorCheckResult => {
|
||||
const typedParams = params as TValidationRuleParamsIsNotBetween;
|
||||
// Skip validation if value is empty
|
||||
if (!value || typeof value !== "string" || value === "") {
|
||||
return { valid: true };
|
||||
}
|
||||
// Compare dates as strings (YYYY-MM-DD format)
|
||||
return { valid: value < typedParams.startDate || value > typedParams.endDate };
|
||||
if (!value || typeof value !== "string" || value === "") return { valid: true };
|
||||
const parsed = parseForDateComparison(value, getDateElementFormat(element), element, "isNotBetween", [
|
||||
typedParams.startDate,
|
||||
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 => {
|
||||
const typedParams = params as TValidationRuleParamsIsNotBetween;
|
||||
|
||||
@@ -87,6 +87,7 @@ const config = ({ mode }) => {
|
||||
entry: {
|
||||
index: resolve(__dirname, "src/index.ts"),
|
||||
validation: resolve(__dirname, "src/validation.ts"),
|
||||
"date-format": resolve(__dirname, "src/date-format.ts"),
|
||||
},
|
||||
formats: ["es"],
|
||||
},
|
||||
|
||||
49
packages/types/surveys/date-formats.ts
Normal file
49
packages/types/surveys/date-formats.ts
Normal 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",
|
||||
};
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -114,7 +114,8 @@
|
||||
"@formbricks/logger#build",
|
||||
"@formbricks/database#build",
|
||||
"@formbricks/storage#build",
|
||||
"@formbricks/cache#build"
|
||||
"@formbricks/cache#build",
|
||||
"@formbricks/surveys#build"
|
||||
]
|
||||
},
|
||||
"@formbricks/web#test:coverage": {
|
||||
@@ -122,7 +123,8 @@
|
||||
"@formbricks/logger#build",
|
||||
"@formbricks/database#build",
|
||||
"@formbricks/storage#build",
|
||||
"@formbricks/cache#build"
|
||||
"@formbricks/cache#build",
|
||||
"@formbricks/surveys#build"
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
|
||||
Reference in New Issue
Block a user