mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-16 03:31:27 -05:00
Compare commits
16 Commits
cursor/cus
...
chore/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d4a276558 | ||
|
|
777f748bde | ||
|
|
a48db4d127 | ||
|
|
7ffcfa5b36 | ||
|
|
1c527e92a1 | ||
|
|
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_FORMATS,
|
||||
DATE_STORAGE_FORMAT_IDS,
|
||||
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_FORMATS[value].label,
|
||||
}));
|
||||
|
||||
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>
|
||||
|
||||
@@ -41,113 +41,41 @@ export const CustomScriptsInjector = ({
|
||||
|
||||
if (!scriptsToInject.trim()) return;
|
||||
|
||||
/**
|
||||
* Ensures document.body exists before executing the injection.
|
||||
* This prevents race conditions where custom scripts try to access document.body
|
||||
* before React hydration completes, which would cause:
|
||||
* - React error #454 (missing document.body)
|
||||
* - TypeError: can't access property "removeChild" of null
|
||||
*/
|
||||
const ensureBodyExists = (): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
// If body already exists, resolve immediately
|
||||
if (document.body) {
|
||||
resolve();
|
||||
return;
|
||||
try {
|
||||
// Create a temporary container to parse the HTML
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = scriptsToInject;
|
||||
|
||||
// Process and inject script elements
|
||||
const scripts = container.querySelectorAll("script");
|
||||
scripts.forEach((script) => {
|
||||
const newScript = document.createElement("script");
|
||||
|
||||
// Copy all attributes (src, async, defer, type, etc.)
|
||||
Array.from(script.attributes).forEach((attr) => {
|
||||
newScript.setAttribute(attr.name, attr.value);
|
||||
});
|
||||
|
||||
// Copy inline script content
|
||||
if (script.textContent) {
|
||||
newScript.textContent = script.textContent;
|
||||
}
|
||||
|
||||
// Wait for DOMContentLoaded
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => resolve(), { once: true });
|
||||
} else {
|
||||
// Document is already loaded but body doesn't exist yet (edge case)
|
||||
// Use setTimeout to defer until next tick
|
||||
setTimeout(() => resolve(), 0);
|
||||
}
|
||||
document.head.appendChild(newScript);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps inline script content to ensure safe execution after DOM is ready.
|
||||
* This prevents scripts from executing before document.body is available.
|
||||
*/
|
||||
const wrapScriptContent = (content: string): string => {
|
||||
// Don't wrap if the script already has DOM-ready checks
|
||||
if (
|
||||
content.includes("DOMContentLoaded") ||
|
||||
content.includes("document.readyState") ||
|
||||
content.includes("window.addEventListener('load'")
|
||||
) {
|
||||
return content;
|
||||
}
|
||||
// Process and inject non-script elements (noscript, meta, link, style, etc.)
|
||||
const nonScripts = container.querySelectorAll(":not(script)");
|
||||
nonScripts.forEach((el) => {
|
||||
const clonedEl = el.cloneNode(true) as Element;
|
||||
document.head.appendChild(clonedEl);
|
||||
});
|
||||
|
||||
// Wrap the script to ensure it runs after DOM is ready
|
||||
return `
|
||||
(function() {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
${content}
|
||||
});
|
||||
} else {
|
||||
${content}
|
||||
}
|
||||
})();
|
||||
`;
|
||||
};
|
||||
|
||||
const injectScripts = async () => {
|
||||
try {
|
||||
// Wait for document.body to exist before injecting any scripts
|
||||
await ensureBodyExists();
|
||||
|
||||
// Defensive check: ensure body still exists
|
||||
if (!document.body) {
|
||||
console.warn("[Formbricks] document.body is not available, skipping script injection");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a temporary container to parse the HTML
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = scriptsToInject;
|
||||
|
||||
// Process and inject script elements
|
||||
const scripts = container.querySelectorAll("script");
|
||||
scripts.forEach((script) => {
|
||||
const newScript = document.createElement("script");
|
||||
|
||||
// Copy all attributes (src, async, defer, type, etc.)
|
||||
Array.from(script.attributes).forEach((attr) => {
|
||||
newScript.setAttribute(attr.name, attr.value);
|
||||
});
|
||||
|
||||
// Copy inline script content with safety wrapper
|
||||
if (script.textContent) {
|
||||
// Only wrap inline scripts (not external scripts with src attribute)
|
||||
if (!script.hasAttribute("src")) {
|
||||
newScript.textContent = wrapScriptContent(script.textContent);
|
||||
} else {
|
||||
newScript.textContent = script.textContent;
|
||||
}
|
||||
}
|
||||
|
||||
document.head.appendChild(newScript);
|
||||
});
|
||||
|
||||
// Process and inject non-script elements (noscript, meta, link, style, etc.)
|
||||
const nonScripts = container.querySelectorAll(":not(script)");
|
||||
nonScripts.forEach((el) => {
|
||||
const clonedEl = el.cloneNode(true) as Element;
|
||||
document.head.appendChild(clonedEl);
|
||||
});
|
||||
|
||||
injectedRef.current = true;
|
||||
} catch (error) {
|
||||
// Log error but don't break the survey - self-hosted admins can check console
|
||||
console.warn("[Formbricks] Error injecting custom scripts:", error);
|
||||
}
|
||||
};
|
||||
|
||||
injectScripts();
|
||||
injectedRef.current = true;
|
||||
} catch (error) {
|
||||
// Log error but don't break the survey - self-hosted admins can check console
|
||||
console.warn("[Formbricks] Error injecting custom scripts:", error);
|
||||
}
|
||||
}, [projectScripts, surveyScripts, scriptsMode]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -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_STORAGE_FORMATS,
|
||||
DEFAULT_DATE_STORAGE_FORMAT,
|
||||
type TSurveyDateStorageFormat,
|
||||
getOutputOrder,
|
||||
} 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;
|
||||
|
||||
/** 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 {
|
||||
/** 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();
|
||||
});
|
||||
});
|
||||
77
packages/surveys/src/lib/date-format.ts
Normal file
77
packages/surveys/src/lib/date-format.ts
Normal 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;
|
||||
}
|
||||
@@ -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,56 @@ 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) {
|
||||
console.warn(`[logic] ${operator}: could not parse right date`, {
|
||||
elementId: rightField.id,
|
||||
value: rightValue,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return compare(leftDate, rightDate);
|
||||
}
|
||||
|
||||
const getVariableValue = (
|
||||
variables: TSurveyVariable[],
|
||||
variableId: string,
|
||||
@@ -265,12 +309,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 +330,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 +360,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 +386,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 +481,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"],
|
||||
},
|
||||
|
||||
61
packages/types/surveys/date-formats.ts
Normal file
61
packages/types/surveys/date-formats.ts
Normal 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];
|
||||
@@ -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