mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-29 18:00:26 -06:00
refactor: improved error ui
This commit is contained in:
@@ -69,7 +69,7 @@ function Consent({
|
||||
/>
|
||||
|
||||
{/* Consent Checkbox */}
|
||||
<div className="relative space-y-2">
|
||||
<div className="relative">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
<label
|
||||
@@ -87,7 +87,6 @@ function Consent({
|
||||
onCheckedChange={handleCheckboxChange}
|
||||
disabled={disabled}
|
||||
aria-invalid={Boolean(errorMessage)}
|
||||
required={required}
|
||||
/>
|
||||
{/* need to use style here because tailwind is not able to use css variables for font size and weight */}
|
||||
<span
|
||||
|
||||
@@ -138,7 +138,6 @@ interface UploadAreaProps {
|
||||
placeholderText: string;
|
||||
allowMultiple: boolean;
|
||||
acceptAttribute?: string;
|
||||
required: boolean;
|
||||
disabled: boolean;
|
||||
dir: "ltr" | "rtl" | "auto";
|
||||
onFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
@@ -153,7 +152,6 @@ function UploadArea({
|
||||
placeholderText,
|
||||
allowMultiple,
|
||||
acceptAttribute,
|
||||
required,
|
||||
disabled,
|
||||
dir,
|
||||
onFileChange,
|
||||
@@ -201,7 +199,6 @@ function UploadArea({
|
||||
accept={acceptAttribute}
|
||||
onChange={onFileChange}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
dir={dir}
|
||||
aria-label="File upload"
|
||||
aria-describedby={`${inputId}-label`}
|
||||
@@ -293,8 +290,8 @@ function FileUpload({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"w-input px-input-x py-input-y rounded-input relative flex flex-col items-center justify-center border-2 border-dashed transition-colors",
|
||||
errorMessage ? "border-destructive" : "border-input-border bg-accent",
|
||||
"w-input px-input-x py-input-y rounded-input bg-accent relative flex flex-col items-center justify-center border-2 border-dashed transition-colors",
|
||||
errorMessage ? "border-destructive" : "border-input-border",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}>
|
||||
<UploadedFilesList files={uploadedFiles} disabled={disabled} onDelete={handleDeleteFile} />
|
||||
@@ -316,7 +313,6 @@ function FileUpload({
|
||||
placeholderText={placeholderText}
|
||||
allowMultiple={allowMultiple}
|
||||
acceptAttribute={acceptAttribute}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
dir={dir}
|
||||
onFileChange={handleFileChange}
|
||||
|
||||
@@ -66,10 +66,6 @@ function Matrix({
|
||||
// Ensure value is always an object (value already has default of {})
|
||||
const selectedValues = value;
|
||||
|
||||
// Check which rows have errors (no selection when required)
|
||||
const hasError = Boolean(errorMessage);
|
||||
const rowsWithErrors = hasError && required ? rows.filter((row) => !selectedValues[row.id]) : [];
|
||||
|
||||
const handleRowChange = (rowId: string, columnId: string): void => {
|
||||
// Toggle: if same column is selected, deselect it
|
||||
if (selectedValues[rowId] === columnId) {
|
||||
@@ -116,7 +112,6 @@ function Matrix({
|
||||
{rows.map((row, index) => {
|
||||
const rowGroupId = `${inputId}-row-${row.id}`;
|
||||
const selectedColumnId = selectedValues[row.id];
|
||||
const rowHasError = rowsWithErrors.includes(row);
|
||||
const baseBgColor = index % 2 === 0 ? "bg-input-bg" : "bg-transparent";
|
||||
|
||||
return (
|
||||
@@ -131,14 +126,11 @@ function Matrix({
|
||||
disabled={disabled}
|
||||
aria-required={required}
|
||||
aria-invalid={Boolean(errorMessage)}>
|
||||
<tr className={cn("relative", baseBgColor, rowHasError ? "bg-destructive-muted" : "")}>
|
||||
<tr className={cn("relative", baseBgColor)}>
|
||||
{/* Row label */}
|
||||
<th scope="row" className={cn("p-2 align-middle", !rowHasError && "rounded-l-input")}>
|
||||
<th scope="row" className={cn("rounded-l-input p-2 align-middle")}>
|
||||
<div className="flex flex-col gap-0 leading-none">
|
||||
<Label>{row.label}</Label>
|
||||
{rowHasError ? (
|
||||
<span className="text-destructive text-xs font-normal">Select one option</span>
|
||||
) : null}
|
||||
</div>
|
||||
</th>
|
||||
{/* Column options for this row */}
|
||||
@@ -149,14 +141,10 @@ function Matrix({
|
||||
return (
|
||||
<td
|
||||
key={column.id}
|
||||
className={cn(
|
||||
"p-2 text-center align-middle",
|
||||
isLastColumn && !rowHasError && "rounded-r-input"
|
||||
)}>
|
||||
className={cn("p-2 text-center align-middle", isLastColumn && "rounded-r-input")}>
|
||||
<Label htmlFor={cellId} className="flex cursor-pointer justify-center">
|
||||
<RadioGroupItem
|
||||
value={column.id}
|
||||
required={required}
|
||||
id={cellId}
|
||||
disabled={disabled}
|
||||
aria-label={`${row.label}-${column.label}`}
|
||||
|
||||
@@ -153,7 +153,6 @@ function NPS({
|
||||
handleSelect(number);
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-required={required}
|
||||
className="sr-only"
|
||||
aria-label={`Rate ${String(number)} out of 10`}
|
||||
/>
|
||||
@@ -178,7 +177,7 @@ function NPS({
|
||||
/>
|
||||
|
||||
{/* NPS Options */}
|
||||
<div className="relative space-y-2">
|
||||
<div className="relative">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<fieldset className="w-full px-[2px]">
|
||||
<legend className="sr-only">NPS rating options</legend>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { Input } from "@/components/general/input";
|
||||
import { Textarea } from "@/components/general/textarea";
|
||||
@@ -75,39 +76,41 @@ function OpenText({
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
/>
|
||||
|
||||
{/* Input or Textarea */}
|
||||
<div className="space-y-1">
|
||||
{longAnswer ? (
|
||||
<Textarea
|
||||
id={inputId}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
aria-required={required}
|
||||
dir={dir}
|
||||
rows={rows}
|
||||
disabled={disabled}
|
||||
errorMessage={errorMessage}
|
||||
minLength={charLimit?.min}
|
||||
maxLength={charLimit?.max}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={inputId}
|
||||
type={inputType}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
aria-required={required}
|
||||
dir={dir}
|
||||
disabled={disabled}
|
||||
errorMessage={errorMessage}
|
||||
minLength={charLimit?.min}
|
||||
maxLength={charLimit?.max}
|
||||
/>
|
||||
)}
|
||||
{renderCharLimit()}
|
||||
<div className="relative">
|
||||
<ElementError errorMessage={errorMessage} />
|
||||
{/* Input or Textarea */}
|
||||
<div className="space-y-1">
|
||||
{longAnswer ? (
|
||||
<Textarea
|
||||
id={inputId}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
aria-required={required}
|
||||
dir={dir}
|
||||
rows={rows}
|
||||
disabled={disabled}
|
||||
errorMessage={errorMessage}
|
||||
minLength={charLimit?.min}
|
||||
maxLength={charLimit?.max}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={inputId}
|
||||
type={inputType}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
aria-required={required}
|
||||
dir={dir}
|
||||
disabled={disabled}
|
||||
errorMessage={errorMessage}
|
||||
minLength={charLimit?.min}
|
||||
maxLength={charLimit?.max}
|
||||
/>
|
||||
)}
|
||||
{renderCharLimit()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -278,7 +278,6 @@ function Rating({
|
||||
handleSelect(number);
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-required={required}
|
||||
className="sr-only"
|
||||
aria-label={`Rate ${String(number)} out of ${String(range)}`}
|
||||
/>
|
||||
@@ -329,7 +328,6 @@ function Rating({
|
||||
handleSelect(number);
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-required={required}
|
||||
className="sr-only"
|
||||
aria-label={`Rate ${String(number)} out of ${String(range)} stars`}
|
||||
/>
|
||||
@@ -388,7 +386,6 @@ function Rating({
|
||||
handleSelect(number);
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-required={required}
|
||||
className="sr-only"
|
||||
aria-label={`Rate ${String(number)} out of ${String(range)}`}
|
||||
/>
|
||||
@@ -415,7 +412,7 @@ function Rating({
|
||||
/>
|
||||
|
||||
{/* Rating Options */}
|
||||
<div className="relative space-y-2">
|
||||
<div className="relative">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<fieldset className="w-full">
|
||||
<legend className="sr-only">Rating options</legend>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface InputProps extends React.ComponentProps<"input"> {
|
||||
@@ -10,21 +9,17 @@ interface InputProps extends React.ComponentProps<"input"> {
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{ className, type, errorMessage, dir, ...props },
|
||||
{ className, type, dir, ...props },
|
||||
ref
|
||||
): React.JSX.Element {
|
||||
const hasError = Boolean(errorMessage);
|
||||
|
||||
return (
|
||||
<div className="relative space-y-1">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<input
|
||||
ref={ref}
|
||||
type={type}
|
||||
dir={dir}
|
||||
data-slot="input"
|
||||
style={{ fontSize: "var(--fb-input-font-size)" }}
|
||||
aria-invalid={hasError || undefined}
|
||||
className={cn(
|
||||
// Layout and behavior
|
||||
"flex min-w-0 border transition-[color,box-shadow] outline-none",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TextareaProps = React.ComponentProps<"textarea"> & {
|
||||
@@ -6,17 +5,13 @@ type TextareaProps = React.ComponentProps<"textarea"> & {
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
function Textarea({ className, errorMessage, dir = "auto", ...props }: TextareaProps): React.JSX.Element {
|
||||
const hasError = Boolean(errorMessage);
|
||||
|
||||
function Textarea({ className, dir = "auto", ...props }: TextareaProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="relative space-y-2">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
style={{ fontSize: "var(--fb-input-font-size)" }}
|
||||
dir={dir}
|
||||
aria-invalid={hasError || undefined}
|
||||
className={cn(
|
||||
"w-input bg-input-bg border-input-border rounded-input font-input font-input-weight px-input-x py-input-y shadow-input placeholder:text-input-placeholder placeholder:opacity-input-placeholder focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 text-input text-input-text flex field-sizing-content min-h-16 border transition-[color,box-shadow] outline-none placeholder:text-sm focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
|
||||
@@ -68,7 +68,6 @@ export function PictureSelectionElement({
|
||||
e.preventDefault();
|
||||
if (element.required) {
|
||||
if (element.allowMulti) {
|
||||
console.log("currentValue", currentValue);
|
||||
if (!currentValue || !Array.isArray(currentValue) || currentValue.length === 0) {
|
||||
setErrorMessage(t("errors.please_select_an_option"));
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Rating } from "@formbricks/survey-ui";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import type { TSurveyRatingElement } from "@formbricks/types/surveys/elements";
|
||||
@@ -12,7 +13,6 @@ interface RatingElementProps {
|
||||
languageCode: string;
|
||||
ttc: TResponseTtc;
|
||||
setTtc: (ttc: TResponseTtc) => void;
|
||||
autoFocusEnabled: boolean;
|
||||
currentElementId: string;
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
}
|
||||
@@ -29,16 +29,28 @@ export function RatingElement({
|
||||
}: RatingElementProps) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isCurrent = element.id === currentElementId;
|
||||
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
|
||||
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleChange = (ratingValue: number) => {
|
||||
setErrorMessage(undefined);
|
||||
onChange({ [element.id]: ratingValue });
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
};
|
||||
|
||||
const validateRequired = (): boolean => {
|
||||
if (element.required && !value) {
|
||||
setErrorMessage(t("errors.please_select_an_option"));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
if (!validateRequired()) return;
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
};
|
||||
@@ -61,6 +73,7 @@ export function RatingElement({
|
||||
dir={dir}
|
||||
imageUrl={element.imageUrl}
|
||||
videoUrl={element.videoUrl}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { type TJsFileUploadParams } from "@formbricks/types/js";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import { type TResponseData, TResponseDataValue, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import { type TUploadFileConfig } from "@formbricks/types/storage";
|
||||
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||
import {
|
||||
TSurveyElement,
|
||||
TSurveyElementTypeEnum,
|
||||
TSurveyMatrixElement,
|
||||
TSurveyRankingElement,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import { BackButton } from "@/components/buttons/back-button";
|
||||
@@ -157,6 +158,13 @@ export function BlockConditional({
|
||||
);
|
||||
};
|
||||
|
||||
const hasUnansweredRows = (responseData: TResponseDataValue, element: TSurveyMatrixElement): boolean => {
|
||||
return element.rows.some((row) => {
|
||||
const rowLabel = getLocalizedValue(row.label, languageCode);
|
||||
return !responseData?.[rowLabel as keyof typeof responseData];
|
||||
});
|
||||
};
|
||||
|
||||
// Validate a single element's form
|
||||
const validateElementForm = (element: TSurveyElement, form: HTMLFormElement): boolean => {
|
||||
const response = value[element.id];
|
||||
@@ -166,6 +174,16 @@ export function BlockConditional({
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
element.type === TSurveyElementTypeEnum.Matrix &&
|
||||
element.required &&
|
||||
response &&
|
||||
hasUnansweredRows(response, element)
|
||||
) {
|
||||
form.requestSubmit();
|
||||
return false;
|
||||
}
|
||||
|
||||
// For other element types, check if required fields are empty
|
||||
// CTA elements should not block navigation even if marked required (as they are informational)
|
||||
if (element.type !== TSurveyElementTypeEnum.CTA && element.required && isEmptyResponse(response)) {
|
||||
|
||||
@@ -196,7 +196,6 @@ export function ElementConditional({
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={wrappedSetTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentElementId={currentElementId}
|
||||
dir={dir}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user