diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.tsx index 16373ad08c..656bf10708 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.tsx @@ -1,6 +1,6 @@ import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { ChevronLeft, ChevronRight } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { TEnvironment } from "@formbricks/types/environment"; import { TResponse } from "@formbricks/types/responses"; import { TSurvey } from "@formbricks/types/surveys/types"; @@ -8,7 +8,14 @@ import { TTag } from "@formbricks/types/tags"; import { TUser, TUserLocale } from "@formbricks/types/user"; import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard"; import { Button } from "@/modules/ui/components/button"; -import { Dialog, DialogBody, DialogContent, DialogFooter, DialogTitle } from "@/modules/ui/components/dialog"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, +} from "@/modules/ui/components/dialog"; interface ResponseCardModalProps { responses: TResponse[]; @@ -42,25 +49,37 @@ export const ResponseCardModal = ({ locale, }: ResponseCardModalProps) => { const [currentIndex, setCurrentIndex] = useState(null); + const [isNavigating, setIsNavigating] = useState(false); + + const idToIndexMap = useMemo(() => { + const map = new Map(); + for (let i = 0; i < responses.length; i++) { + map.set(responses[i].id, i); + } + return map; + }, [responses]); useEffect(() => { if (selectedResponseId) { setOpen(true); - const index = responses.findIndex((response) => response.id === selectedResponseId); + const index = idToIndexMap.get(selectedResponseId) ?? -1; setCurrentIndex(index); + setIsNavigating(false); } else { setOpen(false); } - }, [selectedResponseId, responses, setOpen]); + }, [selectedResponseId, idToIndexMap, setOpen]); const handleNext = () => { if (currentIndex !== null && currentIndex < responses.length - 1) { + setIsNavigating(true); setSelectedResponseId(responses[currentIndex + 1].id); } }; const handleBack = () => { if (currentIndex !== null && currentIndex > 0) { + setIsNavigating(true); setSelectedResponseId(responses[currentIndex - 1].id); } }; @@ -72,8 +91,8 @@ export const ResponseCardModal = ({ } }; - // If no response is selected or currentIndex is null, do not render the modal - if (selectedResponseId === null || currentIndex === null) return null; + // If no response is selected or currentIndex is null or invalid, do not render the modal + if (selectedResponseId === null || currentIndex === null || currentIndex === -1) return null; return ( @@ -81,6 +100,11 @@ export const ResponseCardModal = ({ Survey Response Details + + + Response {currentIndex + 1} of {responses.length} + + - - ))} - + const handleCommandItemSelect = (o: string) => { + const value = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o; + + if (isMultiple) { + const newValue = Array.isArray(filterComboBoxValue) ? [...filterComboBoxValue, value] : [value]; + onChangeFilterComboBoxValue(newValue); + return; + } + + onChangeFilterComboBoxValue(value); + setOpen(false); + }; + + const isComboBoxDisabled = disabled || isDisabledComboBox || !filterValue; + + const handleOpenDropdown = () => { + if (isComboBoxDisabled) return; + setOpen(true); + }; + const ChevronIcon = open ? ChevronUp : ChevronDown; + + // Helper to filter out a specific value from the array + const getFilteredValues = (valueToRemove: string): string[] => { + if (!Array.isArray(filterComboBoxValue)) return []; + return filterComboBoxValue.filter((i) => i !== valueToRemove); + }; + + // Handle removal of a multi-select tag + const handleRemoveTag = (e: React.MouseEvent, valueToRemove: string) => { + e.stopPropagation(); + const filteredValues = getFilteredValues(valueToRemove); + handleRemoveMultiSelect(filteredValues); + }; + + // Render a single multi-select tag + const renderTag = (value: string, index: number) => ( + ); - const commandItemOnSelect = (o: string) => { - if (!isMultiple) { - onChangeFilterComboBoxValue(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o); - } else { - onChangeFilterComboBoxValue( - Array.isArray(filterComboBoxValue) - ? [...filterComboBoxValue, typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o] - : [typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o] + // Render multi-select tags + const renderMultiSelectTags = () => { + if (!Array.isArray(filterComboBoxValue) || filterComboBoxValue.length === 0) { + return null; + } + + return ( +
+ {filterComboBoxValue.map((value, index) => renderTag(value, index))} +
+ ); + }; + + // Render the appropriate content based on filterComboBoxValue state + const renderComboBoxContent = () => { + if (!filterComboBoxValue || filterComboBoxValue.length === 0) { + return ( +

+ {t("common.select")}... +

); } - if (!isMultiple) { - setOpen(false); + + if (Array.isArray(filterComboBoxValue)) { + return renderMultiSelectTags(); } + + return

{filterComboBoxValue}

; }; return ( -
- {filterOptions && filterOptions?.length <= 1 ? ( -
-

{filterValue}

+
+ {filterOptions && filterOptions.length <= 1 ? ( +
+

{filterValue}

) : ( { - value && setOpen(false); - setOpenFilterValue(value); + if (value) setOpen(false); }}> -
- {!filterValue ? ( -

{t("common.select")}...

- ) : ( -

{filterValue}

- )} - {filterOptions && filterOptions.length > 1 && ( - <> - {openFilterValue ? ( - - ) : ( - - )} - - )} -
+ {filterValue ? ( +

{filterValue}

+ ) : ( +

{t("common.select")}...

+ )} + {filterOptions && filterOptions.length > 1 && ( + + )}
- + {filterOptions?.map((o, index) => ( onChangeFilterValue(o)}> {o} @@ -166,78 +211,78 @@ export const QuestionFilterComboBox = ({
)} + {isTextInputField ? ( onChangeFilterComboBoxValue(e.target.value)} - disabled={disabled || !filterValue} + disabled={isComboBoxDisabled} + placeholder={t("common.enter_url")} className="h-9 rounded-l-none border-none bg-white text-sm focus:ring-offset-0" /> ) : ( - + + {/* eslint-disable-next-line jsx-a11y/prefer-tag-over-role */}
- {filterComboBoxValue && filterComboBoxValue.length > 0 ? ( - filterComboBoxItem - ) : ( - + "flex min-w-0 items-center gap-2 rounded-md rounded-l-none bg-white pl-2", + isComboBoxDisabled ? "opacity-50" : "cursor-pointer hover:bg-slate-50" )} - + onClick={handleOpenDropdown} + onKeyDown={(e) => { + const isActivationKey = e.key === "Enter" || e.key === " "; + if (isActivationKey && !isComboBoxDisabled) { + e.preventDefault(); + handleOpenDropdown(); + } + }}> +
{renderComboBoxContent()}
+ +
-
- {open && ( -
- -
- setSearchQuery(e.target.value)} - className="w-full rounded-md border border-slate-300 p-2 text-sm focus:border-slate-300" - /> -
- {t("common.no_result_found")} - - {filteredOptions?.map((o, index) => ( + + {open && ( +
+ + + {t("common.no_result_found")} + + {filteredOptions?.map((o) => { + const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o; + return ( commandItemOnSelect(o)} + key={optionValue} + onSelect={() => handleCommandItemSelect(o)} className="cursor-pointer"> - {typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o} + {optionValue} - ))} - - -
- )} -
+ ); + })} + + +
+ )}
)}
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx index ea96d06d63..edcfd09676 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx @@ -32,6 +32,7 @@ import { useTranslation } from "react-i18next"; import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; import { getLocalizedValue } from "@/lib/i18n/utils"; import { useClickOutside } from "@/lib/utils/hooks/useClickOutside"; +import { Button } from "@/modules/ui/components/button"; import { Command, CommandEmpty, @@ -111,51 +112,46 @@ const questionIcons = { const getIcon = (type: string) => { const IconComponent = questionIcons[type]; - return IconComponent ? : null; + return IconComponent ? : null; +}; + +const getIconBackground = (type: OptionsType | string): string => { + const backgroundMap: Record = { + [OptionsType.ATTRIBUTES]: "bg-indigo-500", + [OptionsType.QUESTIONS]: "bg-brand-dark", + [OptionsType.TAGS]: "bg-indigo-500", + [OptionsType.QUOTAS]: "bg-slate-500", + }; + return backgroundMap[type] ?? "bg-amber-500"; +}; + +const getLabelClassName = (type: OptionsType | string, label?: string): string => { + if (type !== OptionsType.META) return ""; + return label === "os" || label === "url" ? "uppercase" : "capitalize"; }; export const SelectedCommandItem = ({ label, questionType, type }: Partial) => { - const getIconType = () => { - if (type) { - if (type === OptionsType.QUESTIONS && questionType) { - return getIcon(questionType); - } else if (type === OptionsType.ATTRIBUTES) { - return getIcon(OptionsType.ATTRIBUTES); - } else if (type === OptionsType.HIDDEN_FIELDS) { - return getIcon(OptionsType.HIDDEN_FIELDS); - } else if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) { - return getIcon(label); - } else if (type === OptionsType.TAGS) { - return getIcon(OptionsType.TAGS); - } else if (type === OptionsType.QUOTAS) { - return getIcon(OptionsType.QUOTAS); - } - } - }; - - const getColor = () => { - if (type === OptionsType.ATTRIBUTES) { - return "bg-indigo-500"; - } else if (type === OptionsType.QUESTIONS) { - return "bg-brand-dark"; - } else if (type === OptionsType.TAGS) { - return "bg-indigo-500"; - } else if (type === OptionsType.QUOTAS) { - return "bg-slate-500"; - } else { - return "bg-amber-500"; - } - }; - - const getLabelStyle = (): string | undefined => { - if (type !== OptionsType.META) return undefined; - return label === "os" || label === "url" ? "uppercase" : "capitalize"; + const getDisplayIcon = () => { + if (!type) return null; + if (type === OptionsType.QUESTIONS && questionType) return getIcon(questionType); + if (type === OptionsType.ATTRIBUTES) return getIcon(OptionsType.ATTRIBUTES); + if (type === OptionsType.HIDDEN_FIELDS) return getIcon(OptionsType.HIDDEN_FIELDS); + if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) return getIcon(label); + if (type === OptionsType.TAGS) return getIcon(OptionsType.TAGS); + if (type === OptionsType.QUOTAS) return getIcon(OptionsType.QUOTAS); + return null; }; return ( -
- {getIconType()} -

+

+ + {getDisplayIcon()} + +

{typeof label === "string" ? label : getLocalizedValue(label, "default")}

@@ -169,64 +165,74 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question const [inputValue, setInputValue] = useState(""); useClickOutside(commandRef, () => setOpen(false)); + const hasSelection = selected.hasOwnProperty("label"); + const ChevronIcon = open ? ChevronUp : ChevronDown; + return ( - - -
- {open && ( -
- - {t("common.no_result_found")} - {options?.map((data) => ( - - {data?.option.length > 0 && ( - {data.header}

}> - {data?.option?.map((o, i) => ( - { - setInputValue(""); - onChangeValue(o); - setOpen(false); - }} - className="cursor-pointer"> - - - ))} -
- )} -
- ))} -
-
- )} +
+ + {open && ( +
+ + {t("common.no_result_found")} + {options?.map((data) => ( + + {data?.option.length > 0 && ( + {data.header}

}> + {data?.option?.map((o) => ( + { + setInputValue(""); + onChangeValue(o); + setOpen(false); + }}> + + + ))} +
+ )} +
+ ))} +
+
+ )}
); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter.tsx index 1a82db9ec3..a1d8c841b6 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter.tsx @@ -31,6 +31,32 @@ export type QuestionFilterOptions = { id: string; }; +interface PopoverTriggerButtonProps extends React.ButtonHTMLAttributes { + isOpen: boolean; + children: React.ReactNode; +} + +export const PopoverTriggerButton = React.forwardRef( + ({ isOpen, children, ...props }, ref) => ( + + ) +); + +PopoverTriggerButton.displayName = "PopoverTriggerButton"; + interface ResponseFilterProps { survey: TSurvey; } @@ -108,7 +134,6 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => { useEffect(() => { if (!isOpen) { clearItem(); - handleApplyFilters(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen]); @@ -127,8 +152,9 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => { }; const handleClearAllFilters = () => { - setFilterValue((filterValue) => ({ ...filterValue, filter: [], responseStatus: "all" })); - setSelectedFilter((selectedFilters) => ({ ...selectedFilters, filter: [], responseStatus: "all" })); + const clearedFilters = { filter: [], responseStatus: "all" as const }; + setFilterValue(clearedFilters); + setSelectedFilter(clearedFilters); setIsOpen(false); }; @@ -184,9 +210,6 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => { }; const handleOpenChange = (open: boolean) => { - if (!open) { - handleApplyFilters(); - } setIsOpen(open); }; @@ -196,36 +219,26 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => { return ( - - + + Filter {filterValue.filter.length > 0 && `(${filterValue.filter.length})`} - -
- {isOpen ? ( - - ) : ( - - )} -
+
event.preventDefault()}> -
-

+

+

{t("environments.surveys.summary.show_all_responses_that_match")}

-

- {t("environments.surveys.summary.show_all_responses_where")} -