mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-06 00:49:42 -06:00
chore: Response page data handling optimization + UI tweaks (#6716)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
@@ -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<number | null>(null);
|
||||
const [isNavigating, setIsNavigating] = useState(false);
|
||||
|
||||
const idToIndexMap = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
@@ -81,6 +100,11 @@ export const ResponseCardModal = ({
|
||||
<VisuallyHidden asChild>
|
||||
<DialogTitle>Survey Response Details</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
<VisuallyHidden asChild>
|
||||
<DialogDescription>
|
||||
Response {currentIndex + 1} of {responses.length}
|
||||
</DialogDescription>
|
||||
</VisuallyHidden>
|
||||
<DialogBody>
|
||||
<SingleResponseCard
|
||||
survey={survey}
|
||||
@@ -96,12 +120,16 @@ export const ResponseCardModal = ({
|
||||
/>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button onClick={handleBack} disabled={currentIndex === 0} variant="outline" size="icon">
|
||||
<Button
|
||||
onClick={handleBack}
|
||||
disabled={currentIndex === 0 || isNavigating}
|
||||
variant="outline"
|
||||
size="icon">
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={currentIndex === responses.length - 1}
|
||||
disabled={currentIndex === responses.length - 1 || isNavigating}
|
||||
variant="outline"
|
||||
size="icon">
|
||||
<ChevronRight />
|
||||
|
||||
@@ -28,60 +28,63 @@ interface ResponseDataViewProps {
|
||||
quotas: TSurveyQuota[];
|
||||
}
|
||||
|
||||
// Helper function to format array values to record with specified keys
|
||||
const formatArrayToRecord = (responseValue: TResponseDataValue, keys: string[]): Record<string, string> => {
|
||||
if (!Array.isArray(responseValue)) return {};
|
||||
const result: Record<string, string> = {};
|
||||
for (let index = 0; index < responseValue.length; index++) {
|
||||
const curr = responseValue[index];
|
||||
result[keys[index]] = curr || "";
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Export for testing
|
||||
export const formatAddressData = (responseValue: TResponseDataValue): Record<string, string> => {
|
||||
const addressKeys = ["addressLine1", "addressLine2", "city", "state", "zip", "country"];
|
||||
return Array.isArray(responseValue)
|
||||
? responseValue.reduce((acc, curr, index) => {
|
||||
acc[addressKeys[index]] = curr || ""; // Fallback to empty string if undefined
|
||||
return acc;
|
||||
}, {})
|
||||
: {};
|
||||
return formatArrayToRecord(responseValue, addressKeys);
|
||||
};
|
||||
|
||||
// Export for testing
|
||||
export const formatContactInfoData = (responseValue: TResponseDataValue): Record<string, string> => {
|
||||
const addressKeys = ["firstName", "lastName", "email", "phone", "company"];
|
||||
return Array.isArray(responseValue)
|
||||
? responseValue.reduce((acc, curr, index) => {
|
||||
acc[addressKeys[index]] = curr || ""; // Fallback to empty string if undefined
|
||||
return acc;
|
||||
}, {})
|
||||
: {};
|
||||
const contactInfoKeys = ["firstName", "lastName", "email", "phone", "company"];
|
||||
return formatArrayToRecord(responseValue, contactInfoKeys);
|
||||
};
|
||||
|
||||
// Export for testing
|
||||
export const extractResponseData = (response: TResponseWithQuotas, survey: TSurvey): Record<string, any> => {
|
||||
let responseData: Record<string, any> = {};
|
||||
const responseData: Record<string, any> = {};
|
||||
|
||||
survey.questions.forEach((question) => {
|
||||
for (const question of survey.questions) {
|
||||
const responseValue = response.data[question.id];
|
||||
switch (question.type) {
|
||||
case "matrix":
|
||||
if (typeof responseValue === "object") {
|
||||
responseData = { ...responseData, ...responseValue };
|
||||
Object.assign(responseData, responseValue);
|
||||
}
|
||||
break;
|
||||
case "address":
|
||||
responseData = { ...responseData, ...formatAddressData(responseValue) };
|
||||
Object.assign(responseData, formatAddressData(responseValue));
|
||||
break;
|
||||
case "contactInfo":
|
||||
responseData = { ...responseData, ...formatContactInfoData(responseValue) };
|
||||
Object.assign(responseData, formatContactInfoData(responseValue));
|
||||
break;
|
||||
default:
|
||||
responseData[question.id] = responseValue;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
survey.hiddenFields.fieldIds?.forEach((fieldId) => {
|
||||
responseData[fieldId] = response.data[fieldId];
|
||||
});
|
||||
if (survey.hiddenFields.fieldIds) {
|
||||
for (const fieldId of survey.hiddenFields.fieldIds) {
|
||||
responseData[fieldId] = response.data[fieldId];
|
||||
}
|
||||
}
|
||||
|
||||
return responseData;
|
||||
};
|
||||
|
||||
// Export for testing
|
||||
export const mapResponsesToTableData = (
|
||||
const mapResponsesToTableData = (
|
||||
responses: TResponseWithQuotas[],
|
||||
survey: TSurvey,
|
||||
t: TFunction
|
||||
@@ -126,6 +129,10 @@ export const ResponseDataView: React.FC<ResponseDataViewProps> = ({
|
||||
quotas,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedResponseId, setSelectedResponseId] = React.useState<string | null>(null);
|
||||
const setSelectedResponseIdTransition = React.useCallback((id: string | null) => {
|
||||
React.startTransition(() => setSelectedResponseId(id));
|
||||
}, []);
|
||||
const data = mapResponsesToTableData(responses, survey, t);
|
||||
|
||||
return (
|
||||
@@ -146,6 +153,8 @@ export const ResponseDataView: React.FC<ResponseDataViewProps> = ({
|
||||
locale={locale}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
quotas={quotas}
|
||||
selectedResponseId={selectedResponseId}
|
||||
setSelectedResponseId={setSelectedResponseIdTransition}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -122,12 +122,11 @@ export const ResponsePage = ({
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
setResponses([]);
|
||||
}, [filters]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-1.5">
|
||||
<div className="flex h-9 gap-1.5">
|
||||
<CustomFilter survey={surveyMemoized} />
|
||||
</div>
|
||||
<ResponseDataView
|
||||
|
||||
@@ -39,6 +39,12 @@ import {
|
||||
import { Skeleton } from "@/modules/ui/components/skeleton";
|
||||
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
|
||||
const SkeletonCell = () => (
|
||||
<Skeleton className="w-full">
|
||||
<div className="h-6"></div>
|
||||
</Skeleton>
|
||||
);
|
||||
|
||||
interface ResponseTableProps {
|
||||
data: TResponseTableData[];
|
||||
survey: TSurvey;
|
||||
@@ -55,6 +61,8 @@ interface ResponseTableProps {
|
||||
locale: TUserLocale;
|
||||
isQuotasAllowed: boolean;
|
||||
quotas: TSurveyQuota[];
|
||||
selectedResponseId: string | null;
|
||||
setSelectedResponseId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const ResponseTable = ({
|
||||
@@ -73,12 +81,13 @@ export const ResponseTable = ({
|
||||
locale,
|
||||
isQuotasAllowed,
|
||||
quotas,
|
||||
selectedResponseId,
|
||||
setSelectedResponseId,
|
||||
}: ResponseTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [isTableSettingsModalOpen, setIsTableSettingsModalOpen] = useState(false);
|
||||
const [selectedResponseId, setSelectedResponseId] = useState<string | null>(null);
|
||||
const selectedResponse = responses?.find((response) => response.id === selectedResponseId) ?? null;
|
||||
const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||||
@@ -86,7 +95,10 @@ export const ResponseTable = ({
|
||||
|
||||
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
|
||||
// Generate columns
|
||||
const columns = generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn);
|
||||
const columns = useMemo(
|
||||
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
|
||||
[survey, isExpanded, isReadOnly, t, showQuotasColumn]
|
||||
);
|
||||
|
||||
// Save settings to localStorage when they change
|
||||
useEffect(() => {
|
||||
@@ -110,7 +122,13 @@ export const ResponseTable = ({
|
||||
|
||||
// Memoize table data and columns
|
||||
const tableData: TResponseTableData[] = useMemo(
|
||||
() => (isFetchingFirstPage ? Array(10).fill({}) : data),
|
||||
() =>
|
||||
isFetchingFirstPage
|
||||
? Array.from(
|
||||
{ length: 10 },
|
||||
(_, index) => ({ responseId: `skeleton-${index}` }) as TResponseTableData
|
||||
)
|
||||
: data,
|
||||
[data, isFetchingFirstPage]
|
||||
);
|
||||
|
||||
@@ -119,11 +137,7 @@ export const ResponseTable = ({
|
||||
isFetchingFirstPage
|
||||
? columns.map((column) => ({
|
||||
...column,
|
||||
cell: () => (
|
||||
<Skeleton className="w-full">
|
||||
<div className="h-6"></div>
|
||||
</Skeleton>
|
||||
),
|
||||
cell: SkeletonCell,
|
||||
}))
|
||||
: columns,
|
||||
[columns, isFetchingFirstPage]
|
||||
@@ -247,8 +261,8 @@ export const ResponseTable = ({
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody ref={parent}>
|
||||
{/* disable auto animation if there are more than 200 responses for performance optimizations */}
|
||||
<TableBody ref={responses && responses.length > 200 ? undefined : parent}>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
@@ -261,7 +275,6 @@ export const ResponseTable = ({
|
||||
row={row}
|
||||
isExpanded={isExpanded ?? false}
|
||||
setSelectedResponseId={setSelectedResponseId}
|
||||
responses={responses}
|
||||
/>
|
||||
))}
|
||||
</TableRow>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Cell, Row, flexRender } from "@tanstack/react-table";
|
||||
import { Maximize2Icon } from "lucide-react";
|
||||
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
|
||||
import React from "react";
|
||||
import { TResponseTableData } from "@formbricks/types/responses";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getCommonPinningStyles } from "@/modules/ui/components/data-table/lib/utils";
|
||||
import { TableCell } from "@/modules/ui/components/table";
|
||||
@@ -10,21 +11,18 @@ interface ResponseTableCellProps {
|
||||
row: Row<TResponseTableData>;
|
||||
isExpanded: boolean;
|
||||
setSelectedResponseId: (responseId: string | null) => void;
|
||||
responses: TResponse[] | null;
|
||||
}
|
||||
|
||||
export const ResponseTableCell = ({
|
||||
const ResponseTableCellComponent = ({
|
||||
cell,
|
||||
row,
|
||||
isExpanded,
|
||||
setSelectedResponseId,
|
||||
responses,
|
||||
}: ResponseTableCellProps) => {
|
||||
// Function to handle cell click
|
||||
const handleCellClick = () => {
|
||||
if (cell.column.id !== "select") {
|
||||
const response = responses?.find((response) => response.id === row.id);
|
||||
if (response) setSelectedResponseId(response.id);
|
||||
setSelectedResponseId(row.id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -66,3 +64,5 @@ export const ResponseTableCell = ({
|
||||
</TableCell>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResponseTableCell = React.memo(ResponseTableCellComponent);
|
||||
|
||||
@@ -86,7 +86,7 @@ export const MultipleChoiceSummary = ({
|
||||
}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{results.map((result, resultsIdx) => {
|
||||
{results.map((result) => {
|
||||
const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
|
||||
return (
|
||||
<Fragment key={result.value}>
|
||||
@@ -107,7 +107,7 @@ export const MultipleChoiceSummary = ({
|
||||
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
||||
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
|
||||
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
|
||||
{results.length - resultsIdx} - {result.value}
|
||||
{result.value}
|
||||
</p>
|
||||
{choiceId && <IdBadge id={choiceId} />}
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
subYears,
|
||||
} from "date-fns";
|
||||
import { TFunction } from "i18next";
|
||||
import { ArrowDownToLineIcon, ChevronDown, ChevronUp, DownloadIcon, Loader2Icon } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -37,8 +37,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { cn } from "@/modules/ui/lib/utils";
|
||||
import { ResponseFilter } from "./ResponseFilter";
|
||||
import { PopoverTriggerButton, ResponseFilter } from "./ResponseFilter";
|
||||
|
||||
enum DateSelected {
|
||||
FROM = "common.from",
|
||||
@@ -137,6 +136,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
const [selectingDate, setSelectingDate] = useState<DateSelected>(DateSelected.FROM);
|
||||
const [isDatePickerOpen, setIsDatePickerOpen] = useState<boolean>(false);
|
||||
const [isFilterDropDownOpen, setIsFilterDropDownOpen] = useState<boolean>(false);
|
||||
const [isDownloadDropDownOpen, setIsDownloadDropDownOpen] = useState<boolean>(false);
|
||||
const [hoveredRange, setHoveredRange] = useState<DateRange | null>(null);
|
||||
const [isDownloading, setIsDownloading] = useState<boolean>(false);
|
||||
|
||||
@@ -270,201 +270,179 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
|
||||
useClickOutside(datePickerRef, () => handleDatePickerClose());
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex justify-between">
|
||||
<div className="flex justify-stretch gap-x-1.5">
|
||||
<ResponseFilter survey={survey} />
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
value && handleDatePickerClose();
|
||||
setIsFilterDropDownOpen(value);
|
||||
}}>
|
||||
<DropdownMenuTrigger>
|
||||
<div className="flex min-w-[8rem] items-center justify-between rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
|
||||
<span className="text-sm text-slate-700">
|
||||
{filterRange === getFilterDropDownLabels(t).CUSTOM_RANGE
|
||||
? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${
|
||||
dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date"
|
||||
}`
|
||||
: filterRange}
|
||||
</span>
|
||||
{isFilterDropDownOpen ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).ALL_TIME);
|
||||
setDateRange({ from: undefined, to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).ALL_TIME}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_7_DAYS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_30_DAYS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_MONTH);
|
||||
setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_MONTH}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_MONTH);
|
||||
setDateRange({
|
||||
from: startOfMonth(subMonths(new Date(), 1)),
|
||||
to: endOfMonth(subMonths(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_MONTH}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER);
|
||||
setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_QUARTER}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_QUARTER);
|
||||
setDateRange({
|
||||
from: startOfQuarter(subQuarters(new Date(), 1)),
|
||||
to: endOfQuarter(subQuarters(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_QUARTER}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_6_MONTHS);
|
||||
setDateRange({
|
||||
from: startOfMonth(subMonths(new Date(), 6)),
|
||||
to: endOfMonth(getTodayDate()),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_6_MONTHS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_YEAR);
|
||||
setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_YEAR}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_YEAR);
|
||||
setDateRange({
|
||||
from: startOfYear(subYears(new Date(), 1)),
|
||||
to: endOfYear(subYears(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_YEAR}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setIsDatePickerOpen(true);
|
||||
setFilterRange(getFilterDropDownLabels(t).CUSTOM_RANGE);
|
||||
setSelectingDate(DateSelected.FROM);
|
||||
}}>
|
||||
<p className="text-sm text-slate-700 hover:ring-0">
|
||||
{getFilterDropDownLabels(t).CUSTOM_RANGE}
|
||||
</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
value && handleDatePickerClose();
|
||||
}}>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
className={cn(
|
||||
"focus:bg-muted cursor-pointer outline-none",
|
||||
isDownloading && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
disabled={isDownloading}
|
||||
data-testid="fb__custom-filter-download-responses-button">
|
||||
<div className="min-w-auto h-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
|
||||
<div className="hidden w-full items-center justify-between sm:flex">
|
||||
<span className="text-sm text-slate-700">{t("common.download")}</span>
|
||||
{isDownloading ? (
|
||||
<Loader2Icon className="ml-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<DownloadIcon className="block h-4 sm:hidden" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<div className="relative flex justify-between">
|
||||
<div className="flex justify-stretch gap-x-1.5">
|
||||
<ResponseFilter survey={survey} />
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
value && handleDatePickerClose();
|
||||
setIsFilterDropDownOpen(value);
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<PopoverTriggerButton isOpen={isFilterDropDownOpen}>
|
||||
{filterRange === getFilterDropDownLabels(t).CUSTOM_RANGE
|
||||
? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${
|
||||
dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date"
|
||||
}`
|
||||
: filterRange}
|
||||
</PopoverTriggerButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).ALL_TIME);
|
||||
setDateRange({ from: undefined, to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).ALL_TIME}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_7_DAYS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_30_DAYS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_MONTH);
|
||||
setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_MONTH}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_MONTH);
|
||||
setDateRange({
|
||||
from: startOfMonth(subMonths(new Date(), 1)),
|
||||
to: endOfMonth(subMonths(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_MONTH}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER);
|
||||
setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_QUARTER}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_QUARTER);
|
||||
setDateRange({
|
||||
from: startOfQuarter(subQuarters(new Date(), 1)),
|
||||
to: endOfQuarter(subQuarters(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_QUARTER}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_6_MONTHS);
|
||||
setDateRange({
|
||||
from: startOfMonth(subMonths(new Date(), 6)),
|
||||
to: endOfMonth(getTodayDate()),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_6_MONTHS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_YEAR);
|
||||
setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_YEAR}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_YEAR);
|
||||
setDateRange({
|
||||
from: startOfYear(subYears(new Date(), 1)),
|
||||
to: endOfYear(subYears(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_YEAR}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setIsDatePickerOpen(true);
|
||||
setFilterRange(getFilterDropDownLabels(t).CUSTOM_RANGE);
|
||||
setSelectingDate(DateSelected.FROM);
|
||||
}}>
|
||||
<p className="text-sm text-slate-700 hover:ring-0">{getFilterDropDownLabels(t).CUSTOM_RANGE}</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
value && handleDatePickerClose();
|
||||
setIsDownloadDropDownOpen(value);
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<PopoverTriggerButton isOpen={isDownloadDropDownOpen} disabled={isDownloading}>
|
||||
<span className="flex items-center gap-2">
|
||||
{t("common.download")}
|
||||
{isDownloading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
</span>
|
||||
</PopoverTriggerButton>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-all-csv"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.ALL, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-all-xlsx"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.ALL, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-filtered-csv"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.FILTER, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-filtered-xlsx"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.FILTER, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_excel")}</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{isDatePickerOpen && (
|
||||
<div ref={datePickerRef} className="absolute top-full z-50 my-2 rounded-md border bg-white">
|
||||
<Calendar
|
||||
autoFocus
|
||||
mode="range"
|
||||
defaultMonth={dateRange?.from}
|
||||
selected={hoveredRange ? hoveredRange : dateRange}
|
||||
numberOfMonths={2}
|
||||
onDayClick={(date) => handleDateChange(date)}
|
||||
onDayMouseEnter={handleDateHoveredChange}
|
||||
onDayMouseLeave={() => setHoveredRange(null)}
|
||||
classNames={{
|
||||
day_today: "hover:bg-slate-200 bg-white",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-all-csv"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.ALL, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-all-xlsx"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.ALL, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-filtered-csv"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.FILTER, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-filtered-xlsx"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.FILTER, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_excel")}</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
{isDatePickerOpen && (
|
||||
<div ref={datePickerRef} className="absolute top-full z-50 my-2 rounded-md border bg-white">
|
||||
<Calendar
|
||||
autoFocus
|
||||
mode="range"
|
||||
defaultMonth={dateRange?.from}
|
||||
selected={hoveredRange || dateRange}
|
||||
numberOfMonths={2}
|
||||
onDayClick={(date) => handleDateChange(date)}
|
||||
onDayMouseEnter={handleDateHoveredChange}
|
||||
onDayMouseLeave={() => setHoveredRange(null)}
|
||||
classNames={{
|
||||
day_today: "hover:bg-slate-200 bg-white",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
|
||||
import clsx from "clsx";
|
||||
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/modules/ui/components/command";
|
||||
@@ -48,117 +50,160 @@ export const QuestionFilterComboBox = ({
|
||||
disabled = false,
|
||||
fieldId,
|
||||
}: QuestionFilterComboBoxProps) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [openFilterValue, setOpenFilterValue] = React.useState<boolean>(false);
|
||||
const commandRef = React.useRef(null);
|
||||
const [searchQuery, setSearchQuery] = React.useState<string>("");
|
||||
const defaultLanguageCode = "default";
|
||||
useClickOutside(commandRef, () => setOpen(false));
|
||||
const [open, setOpen] = useState(false);
|
||||
const commandRef = useRef(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const { t } = useTranslation();
|
||||
// multiple when question type is multi selection
|
||||
const isMultiple =
|
||||
type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ||
|
||||
type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
|
||||
type === TSurveyQuestionTypeEnum.PictureSelection ||
|
||||
(type === TSurveyQuestionTypeEnum.NPS && filterValue === "Includes either");
|
||||
|
||||
// when question type is multi selection so we remove the option from the options which has been already selected
|
||||
const options = isMultiple
|
||||
? filterComboBoxOptions?.filter(
|
||||
(o) =>
|
||||
!filterComboBoxValue?.includes(
|
||||
typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o
|
||||
)
|
||||
)
|
||||
: filterComboBoxOptions;
|
||||
useClickOutside(commandRef, () => setOpen(false));
|
||||
|
||||
// disable the combo box for selection of value when question type is nps or rating and selected value is submitted or skipped
|
||||
const defaultLanguageCode = "default";
|
||||
|
||||
// Check if multiple selection is allowed
|
||||
const isMultiple = useMemo(
|
||||
() =>
|
||||
type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ||
|
||||
type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
|
||||
type === TSurveyQuestionTypeEnum.PictureSelection ||
|
||||
(type === TSurveyQuestionTypeEnum.NPS && filterValue === "Includes either"),
|
||||
[type, filterValue]
|
||||
);
|
||||
|
||||
// Filter out already selected options for multi-select
|
||||
const options = useMemo(() => {
|
||||
if (!isMultiple) return filterComboBoxOptions;
|
||||
|
||||
return filterComboBoxOptions?.filter((o) => {
|
||||
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
return !filterComboBoxValue?.includes(optionValue);
|
||||
});
|
||||
}, [isMultiple, filterComboBoxOptions, filterComboBoxValue, defaultLanguageCode]);
|
||||
|
||||
// Disable combo box for NPS/Rating when Submitted/Skipped
|
||||
const isDisabledComboBox =
|
||||
(type === TSurveyQuestionTypeEnum.NPS || type === TSurveyQuestionTypeEnum.Rating) &&
|
||||
(filterValue === "Submitted" || filterValue === "Skipped");
|
||||
|
||||
// Check if this is a URL field with string comparison operations that require text input
|
||||
// Check if this is a text input field (URL meta field)
|
||||
const isTextInputField = type === OptionsType.META && fieldId === "url";
|
||||
|
||||
const filteredOptions = options?.filter((o) =>
|
||||
(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o)
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase())
|
||||
// Filter options based on search query
|
||||
const filteredOptions = useMemo(
|
||||
() =>
|
||||
options?.filter((o) => {
|
||||
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
return optionValue.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
}),
|
||||
[options, searchQuery, defaultLanguageCode]
|
||||
);
|
||||
|
||||
const filterComboBoxItem = !Array.isArray(filterComboBoxValue) ? (
|
||||
<p className="text-slate-600">{filterComboBoxValue}</p>
|
||||
) : (
|
||||
<div className="no-scrollbar flex w-[7rem] gap-3 overflow-auto md:w-[10rem] lg:w-[18rem]">
|
||||
{typeof filterComboBoxValue !== "string" &&
|
||||
filterComboBoxValue?.map((o, index) => (
|
||||
<button
|
||||
key={`${o}-${index}`}
|
||||
type="button"
|
||||
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
|
||||
className="w-30 flex items-center whitespace-nowrap bg-slate-100 px-2 text-slate-600">
|
||||
{o}
|
||||
<X width={14} height={14} className="ml-2" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
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) => (
|
||||
<button
|
||||
key={`${value}-${index}`}
|
||||
type="button"
|
||||
onClick={(e) => handleRemoveTag(e, value)}
|
||||
className="flex items-center gap-1 whitespace-nowrap rounded bg-slate-100 px-2 py-1 text-sm text-slate-600 hover:bg-slate-200">
|
||||
{value}
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="no-scrollbar flex grow gap-2 overflow-auto">
|
||||
{filterComboBoxValue.map((value, index) => renderTag(value, index))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render the appropriate content based on filterComboBoxValue state
|
||||
const renderComboBoxContent = () => {
|
||||
if (!filterComboBoxValue || filterComboBoxValue.length === 0) {
|
||||
return (
|
||||
<p className={clsx("text-sm", isComboBoxDisabled ? "text-slate-300" : "text-slate-400")}>
|
||||
{t("common.select")}...
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (!isMultiple) {
|
||||
setOpen(false);
|
||||
|
||||
if (Array.isArray(filterComboBoxValue)) {
|
||||
return renderMultiSelectTags();
|
||||
}
|
||||
|
||||
return <p className="truncate text-sm text-slate-600">{filterComboBoxValue}</p>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inline-flex w-full flex-row">
|
||||
{filterOptions && filterOptions?.length <= 1 ? (
|
||||
<div className="h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600">
|
||||
<p className="mr-1 max-w-[50px] truncate text-slate-600 sm:max-w-[100px]">{filterValue}</p>
|
||||
<div className="inline-flex h-fit w-full flex-row rounded-md border border-slate-300 hover:border-slate-400">
|
||||
{filterOptions && filterOptions.length <= 1 ? (
|
||||
<div className="flex h-9 max-w-fit items-center rounded-md rounded-r-none border-r border-slate-300 bg-white px-2 text-sm text-slate-600">
|
||||
<p className="mr-1 max-w-[50px] truncate sm:max-w-[100px]">{filterValue}</p>
|
||||
</div>
|
||||
) : (
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
value && setOpen(false);
|
||||
setOpenFilterValue(value);
|
||||
if (value) setOpen(false);
|
||||
}}>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
className={clsx(
|
||||
"h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600 focus:outline-transparent focus:ring-0",
|
||||
!disabled ? "cursor-pointer" : "opacity-50"
|
||||
"flex h-9 max-w-fit items-center justify-between gap-2 rounded-md rounded-r-none border-r border-slate-300 bg-white px-2 text-sm text-slate-600 focus:outline-transparent focus:ring-0",
|
||||
disabled ? "opacity-50" : "cursor-pointer hover:bg-slate-50"
|
||||
)}>
|
||||
<div className="flex items-center justify-between">
|
||||
{!filterValue ? (
|
||||
<p className="text-slate-400">{t("common.select")}...</p>
|
||||
) : (
|
||||
<p className="mr-1 max-w-[50px] truncate text-slate-600 sm:max-w-[80px]">{filterValue}</p>
|
||||
)}
|
||||
{filterOptions && filterOptions.length > 1 && (
|
||||
<>
|
||||
{openFilterValue ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{filterValue ? (
|
||||
<p className="max-w-[50px] truncate sm:max-w-[80px]">{filterValue}</p>
|
||||
) : (
|
||||
<p className="text-slate-400">{t("common.select")}...</p>
|
||||
)}
|
||||
{filterOptions && filterOptions.length > 1 && (
|
||||
<ChevronIcon className="h-4 w-4 flex-shrink-0 opacity-50" />
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="bg-white p-2">
|
||||
<DropdownMenuContent className="bg-white">
|
||||
{filterOptions?.map((o, index) => (
|
||||
<DropdownMenuItem
|
||||
key={`${o}-${index}`}
|
||||
className="px-0.5 py-1 dark:bg-slate-700 dark:text-slate-300 dark:ring-slate-700"
|
||||
className="cursor-pointer"
|
||||
onClick={() => onChangeFilterValue(o)}>
|
||||
{o}
|
||||
</DropdownMenuItem>
|
||||
@@ -166,78 +211,78 @@ export const QuestionFilterComboBox = ({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{isTextInputField ? (
|
||||
<Input
|
||||
type="text"
|
||||
value={typeof filterComboBoxValue === "string" ? filterComboBoxValue : ""}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
) : (
|
||||
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent">
|
||||
<Command ref={commandRef} className="relative h-fit w-full min-w-0 overflow-visible bg-transparent">
|
||||
{/* eslint-disable-next-line jsx-a11y/prefer-tag-over-role */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={isComboBoxDisabled ? -1 : 0}
|
||||
className={clsx(
|
||||
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm"
|
||||
)}>
|
||||
{filterComboBoxValue && filterComboBoxValue.length > 0 ? (
|
||||
filterComboBoxItem
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||
disabled={disabled || isDisabledComboBox || !filterValue}
|
||||
className={clsx(
|
||||
"flex-1 text-left text-slate-400",
|
||||
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||
)}>
|
||||
{t("common.select")}...
|
||||
</button>
|
||||
"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"
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||
disabled={disabled || isDisabledComboBox || !filterValue}
|
||||
className={clsx(
|
||||
"ml-2 flex items-center justify-center",
|
||||
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||
)}>
|
||||
{open ? (
|
||||
<ChevronUp className="h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</button>
|
||||
onClick={handleOpenDropdown}
|
||||
onKeyDown={(e) => {
|
||||
const isActivationKey = e.key === "Enter" || e.key === " ";
|
||||
if (isActivationKey && !isComboBoxDisabled) {
|
||||
e.preventDefault();
|
||||
handleOpenDropdown();
|
||||
}
|
||||
}}>
|
||||
<div className="min-w-0 flex-1">{renderComboBoxContent()}</div>
|
||||
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isComboBoxDisabled) return;
|
||||
setOpen(!open);
|
||||
}}
|
||||
disabled={isComboBoxDisabled}
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="flex-shrink-0"
|
||||
aria-expanded={open}
|
||||
aria-label={t("common.select")}>
|
||||
<ChevronIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative mt-2 h-full">
|
||||
{open && (
|
||||
<div className="animate-in absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
|
||||
<CommandList>
|
||||
<div className="p-2">
|
||||
<Input
|
||||
type="text"
|
||||
autoFocus
|
||||
placeholder={t("common.search") + "..."}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-md border border-slate-300 p-2 text-sm focus:border-slate-300"
|
||||
/>
|
||||
</div>
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredOptions?.map((o, index) => (
|
||||
|
||||
{open && (
|
||||
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md bg-white shadow-md outline-none">
|
||||
<CommandList className="max-h-52">
|
||||
<CommandInput
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
placeholder={`${t("common.search")}...`}
|
||||
className="border-none"
|
||||
/>
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredOptions?.map((o) => {
|
||||
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
|
||||
onSelect={() => commandItemOnSelect(o)}
|
||||
key={optionValue}
|
||||
onSelect={() => handleCommandItemSelect(o)}
|
||||
className="cursor-pointer">
|
||||
{typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
|
||||
{optionValue}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</div>
|
||||
)}
|
||||
</Command>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 ? <IconComponent width={18} height={18} className="text-white" /> : null;
|
||||
return IconComponent ? <IconComponent className="h-5 w-5" strokeWidth={1.5} /> : null;
|
||||
};
|
||||
|
||||
const getIconBackground = (type: OptionsType | string): string => {
|
||||
const backgroundMap: Record<string, string> = {
|
||||
[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<QuestionOption>) => {
|
||||
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 (
|
||||
<div className="flex h-5 w-[12rem] items-center sm:w-4/5">
|
||||
<span className={clsx("rounded-md p-1", getColor())}>{getIconType()}</span>
|
||||
<p className={clsx("ml-3 truncate text-sm text-slate-600", getLabelStyle())}>
|
||||
<div className="flex h-full min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={clsx(
|
||||
"flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md text-white",
|
||||
getIconBackground(type ?? "")
|
||||
)}>
|
||||
{getDisplayIcon()}
|
||||
</span>
|
||||
<p className={clsx("truncate text-sm text-slate-600", getLabelClassName(type ?? "", label))}>
|
||||
{typeof label === "string" ? label : getLocalizedValue(label, "default")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent hover:bg-slate-50">
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="group flex cursor-pointer items-center justify-between rounded-md bg-white px-3 py-2 text-sm">
|
||||
{!open && selected.hasOwnProperty("label") && (
|
||||
<SelectedCommandItem
|
||||
label={selected?.label}
|
||||
type={selected?.type}
|
||||
questionType={selected?.questionType}
|
||||
/>
|
||||
)}
|
||||
{(open || !selected.hasOwnProperty("label")) && (
|
||||
<Command
|
||||
ref={commandRef}
|
||||
className="relative h-fit w-full overflow-visible rounded-md border border-slate-300 bg-white hover:border-slate-400">
|
||||
{/* eslint-disable-next-line jsx-a11y/prefer-tag-over-role */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex cursor-pointer items-center justify-between"
|
||||
onClick={() => !open && setOpen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
!open && setOpen(true);
|
||||
}
|
||||
}}>
|
||||
{!open && hasSelection && <SelectedCommandItem {...selected} />}
|
||||
{(open || !hasSelection) && (
|
||||
<CommandInput
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
placeholder={t("common.search") + "..."}
|
||||
className="h-5 border-none border-transparent p-0 shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
|
||||
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
|
||||
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none outline-none ring-offset-transparent focus:border-none focus:shadow-none focus:outline-none focus:ring-offset-0"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
{open ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<div className="relative mt-2 h-full">
|
||||
{open && (
|
||||
<div className="animate-in absolute top-0 z-50 w-full overflow-auto rounded-md bg-white outline-none">
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
{options?.map((data) => (
|
||||
<Fragment key={data.header}>
|
||||
{data?.option.length > 0 && (
|
||||
<CommandGroup
|
||||
heading={<p className="text-sm font-normal text-slate-600">{data.header}</p>}>
|
||||
{data?.option?.map((o, i) => (
|
||||
<CommandItem
|
||||
key={`${o.label}-${i}`}
|
||||
onSelect={() => {
|
||||
setInputValue("");
|
||||
onChangeValue(o);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="cursor-pointer">
|
||||
<SelectedCommandItem label={o.label} type={o.type} questionType={o.questionType} />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</CommandList>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(!open);
|
||||
}}
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="flex-shrink-0"
|
||||
aria-expanded={open}
|
||||
aria-label={t("common.select")}>
|
||||
<ChevronIcon className="h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md shadow-md outline-none">
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
{options?.map((data) => (
|
||||
<Fragment key={data.header}>
|
||||
{data?.option.length > 0 && (
|
||||
<CommandGroup heading={<p className="text-sm font-medium text-slate-600">{data.header}</p>}>
|
||||
{data?.option?.map((o) => (
|
||||
<CommandItem
|
||||
key={o.id}
|
||||
onSelect={() => {
|
||||
setInputValue("");
|
||||
onChangeValue(o);
|
||||
setOpen(false);
|
||||
}}>
|
||||
<SelectedCommandItem {...o} />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</CommandList>
|
||||
</div>
|
||||
)}
|
||||
</Command>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -31,6 +31,32 @@ export type QuestionFilterOptions = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
interface PopoverTriggerButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
isOpen: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PopoverTriggerButton = React.forwardRef<HTMLButtonElement, PopoverTriggerButtonProps>(
|
||||
({ isOpen, children, ...props }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
{...props}
|
||||
className="flex min-w-[8rem] cursor-pointer items-center justify-between rounded-md border border-slate-300 bg-white p-2 hover:border-slate-400">
|
||||
<span className="text-sm text-slate-700">{children}</span>
|
||||
<div className="ml-3">
|
||||
{isOpen ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
|
||||
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 (
|
||||
<Popover open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger className="flex min-w-[8rem] items-center justify-between rounded border border-slate-200 bg-white p-3 text-sm text-slate-600 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
|
||||
<span>
|
||||
<PopoverTrigger asChild>
|
||||
<PopoverTriggerButton isOpen={isOpen}>
|
||||
Filter <b>{filterValue.filter.length > 0 && `(${filterValue.filter.length})`}</b>
|
||||
</span>
|
||||
<div className="ml-3">
|
||||
{isOpen ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
</PopoverTriggerButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[300px] border-slate-200 bg-slate-100 p-6 sm:w-[400px] md:w-[750px] lg:w-[1000px]"
|
||||
className="w-[300px] rounded-lg border-slate-200 p-6 sm:w-[400px] md:w-[750px] lg:w-[1000px]"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}>
|
||||
<div className="mb-8 flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="text-slate800 hidden text-lg font-semibold sm:block">
|
||||
<div className="mb-6 flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="font-semibold text-slate-800">
|
||||
{t("environments.surveys.summary.show_all_responses_that_match")}
|
||||
</p>
|
||||
<p className="block text-base text-slate-500 sm:hidden">
|
||||
{t("environments.surveys.summary.show_all_responses_where")}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select
|
||||
value={filterValue.responseStatus ?? "all"}
|
||||
onValueChange={(val) => {
|
||||
handleResponseStatusChange(val as TResponseStatus);
|
||||
}}
|
||||
defaultValue={filterValue.responseStatus}>
|
||||
<SelectTrigger className="w-full bg-white">
|
||||
}}>
|
||||
<SelectTrigger className="w-full bg-white text-slate-700">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
@@ -285,35 +298,38 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-end gap-1 md:w-auto">
|
||||
<p className="block font-light text-slate-500 md:hidden">Delete</p>
|
||||
<TrashIcon
|
||||
className="w-4 cursor-pointer text-slate-500 md:text-black"
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteFilter(i)}
|
||||
/>
|
||||
aria-label={t("common.delete")}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{i !== filterValue.filter.length - 1 && (
|
||||
<div className="my-6 flex items-center">
|
||||
<p className="mr-6 text-base text-slate-600">And</p>
|
||||
<div className="my-4 flex items-center">
|
||||
<p className="mr-4 font-semibold text-slate-800">and</p>
|
||||
<hr className="w-full text-slate-600" />
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<Button size="sm" variant="secondary" onClick={handleAddNewFilter}>
|
||||
{t("common.add_filter")}
|
||||
<Plus width={18} height={18} className="ml-2" />
|
||||
</Button>
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={handleAddNewFilter}>
|
||||
{t("common.add_filter")}
|
||||
<Plus />
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleApplyFilters}>
|
||||
{t("common.apply_filters")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleClearAllFilters}>
|
||||
{t("common.clear_all")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="sm" variant="destructive" onClick={handleClearAllFilters}>
|
||||
{t("common.clear_all")}
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -173,6 +173,7 @@ checksums:
|
||||
common/edit: eee7f39ff90b18852afc1671f21fbaa9
|
||||
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
|
||||
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
|
||||
common/enterprise_license: e81bf506f47968870c7bd07245648a0d
|
||||
common/environment_not_found: 4d7610bdb55a8b5e6131bb5b08ce04c5
|
||||
common/environment_notice: 0a860e3fa89407726dd8a2083a6b7fd5
|
||||
@@ -330,6 +331,7 @@ checksums:
|
||||
common/segments: 271db72d5b973fbc5fadab216177eaae
|
||||
common/select: 5ac04c47a98deb85906bc02e0de91ab0
|
||||
common/select_all: eedc7cdb02de467c15dc418a066a77f2
|
||||
common/select_filter: c50082c3981f1161022f9787a19aed71
|
||||
common/select_survey: bac52e59c7847417bef6fe7b7096b475
|
||||
common/select_teams: ae5d451929846ae6367562bc671a1af9
|
||||
common/selected: 9f09e059ba20c88ed34e2b4e8e032d56
|
||||
@@ -1574,6 +1576,8 @@ checksums:
|
||||
environments/surveys/relevance: 9a5655d1d14efdd35052a8ed09bed127
|
||||
environments/surveys/responses/address_line_1: 44788358e7a7c25b0b79bc3090ed15f5
|
||||
environments/surveys/responses/address_line_2: fc4b5a87de46ac4a28a6616f47a34135
|
||||
environments/surveys/responses/an_error_occurred_adding_the_tag: f211ea1ceb8a93b415d88a8deed874ef
|
||||
environments/surveys/responses/an_error_occurred_creating_the_tag: 89689815f8aff6ff3ba821ab599c540c
|
||||
environments/surveys/responses/an_error_occurred_deleting_the_tag: c63f28ac2a4cda558423ea7f975d5b8b
|
||||
environments/surveys/responses/browser: e58e554eb7b0761ede25f2425173d31f
|
||||
environments/surveys/responses/bulk_delete_response_quotas: ae1b3a7684c53ea681a3de6c7f911e70
|
||||
@@ -1770,7 +1774,6 @@ checksums:
|
||||
environments/surveys/summary/setup_integrations: 70de06d73be671a0cd58a3fd4fa62e53
|
||||
environments/surveys/summary/share_survey: b77bc25bae24b97f39e95dd2a6d74515
|
||||
environments/surveys/summary/show_all_responses_that_match: c199f03983d7fcdd5972cc2759558c68
|
||||
environments/surveys/summary/show_all_responses_where: 370a56de4692a588f7ebdbf7f1e28f6f
|
||||
environments/surveys/summary/starts: 3153990a4ade414f501a7e63ab771362
|
||||
environments/surveys/summary/starts_tooltip: 0a7dd01320490dbbea923053fa1ccad6
|
||||
environments/surveys/summary/survey_reset_successfully: bd50acaafccb709527072ac0da6c8bfd
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
"edit": "Bearbeiten",
|
||||
"email": "E-Mail",
|
||||
"ending_card": "Abschluss-Karte",
|
||||
"enter_url": "URL eingeben",
|
||||
"enterprise_license": "Enterprise Lizenz",
|
||||
"environment_not_found": "Umgebung nicht gefunden",
|
||||
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
|
||||
@@ -357,6 +358,7 @@
|
||||
"segments": "Segmente",
|
||||
"select": "Auswählen",
|
||||
"select_all": "Alles auswählen",
|
||||
"select_filter": "Filter auswählen",
|
||||
"select_survey": "Umfrage auswählen",
|
||||
"select_teams": "Teams auswählen",
|
||||
"selected": "Ausgewählt",
|
||||
@@ -1665,6 +1667,8 @@
|
||||
"responses": {
|
||||
"address_line_1": "Adresszeile 1",
|
||||
"address_line_2": "Adresszeile 2",
|
||||
"an_error_occurred_adding_the_tag": "Beim Hinzufügen des Tags ist ein Fehler aufgetreten",
|
||||
"an_error_occurred_creating_the_tag": "Beim Erstellen des Tags ist ein Fehler aufgetreten",
|
||||
"an_error_occurred_deleting_the_tag": "Beim Löschen des Tags ist ein Fehler aufgetreten",
|
||||
"browser": "Browser",
|
||||
"bulk_delete_response_quotas": "Die Antworten sind Teil der Quoten für diese Umfrage. Wie möchten Sie die Quoten verwalten?",
|
||||
@@ -1881,7 +1885,6 @@
|
||||
"setup_integrations": "Integrationen einrichten",
|
||||
"share_survey": "Umfrage teilen",
|
||||
"show_all_responses_that_match": "Zeige alle Antworten, die übereinstimmen",
|
||||
"show_all_responses_where": "Zeige alle Antworten, bei denen...",
|
||||
"starts": "Startet",
|
||||
"starts_tooltip": "So oft wurde die Umfrage gestartet.",
|
||||
"survey_reset_successfully": "Umfrage erfolgreich zurückgesetzt! {responseCount} Antworten und {displayCount} Anzeigen wurden gelöscht.",
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
"edit": "Edit",
|
||||
"email": "Email",
|
||||
"ending_card": "Ending card",
|
||||
"enter_url": "Enter URL",
|
||||
"enterprise_license": "Enterprise License",
|
||||
"environment_not_found": "Environment not found",
|
||||
"environment_notice": "You're currently in the {environment} environment.",
|
||||
@@ -357,6 +358,7 @@
|
||||
"segments": "Segments",
|
||||
"select": "Select",
|
||||
"select_all": "Select all",
|
||||
"select_filter": "Select filter",
|
||||
"select_survey": "Select Survey",
|
||||
"select_teams": "Select teams",
|
||||
"selected": "Selected",
|
||||
@@ -1665,6 +1667,8 @@
|
||||
"responses": {
|
||||
"address_line_1": "Address Line 1",
|
||||
"address_line_2": "Address Line 2",
|
||||
"an_error_occurred_adding_the_tag": "An error occurred adding the tag",
|
||||
"an_error_occurred_creating_the_tag": "An error occurred creating the tag",
|
||||
"an_error_occurred_deleting_the_tag": "An error occurred deleting the tag",
|
||||
"browser": "Browser",
|
||||
"bulk_delete_response_quotas": "The responses are part of quotas for this survey. How do you want to handle the quotas?",
|
||||
@@ -1881,7 +1885,6 @@
|
||||
"setup_integrations": "Setup integrations",
|
||||
"share_survey": "Share survey",
|
||||
"show_all_responses_that_match": "Show all responses that match",
|
||||
"show_all_responses_where": "Show all responses where...",
|
||||
"starts": "Starts",
|
||||
"starts_tooltip": "Number of times the survey has been started.",
|
||||
"survey_reset_successfully": "Survey reset successfully! {responseCount} responses and {displayCount} displays were deleted.",
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
"edit": "Modifier",
|
||||
"email": "Email",
|
||||
"ending_card": "Carte de fin",
|
||||
"enter_url": "Saisir l'URL",
|
||||
"enterprise_license": "Licence d'entreprise",
|
||||
"environment_not_found": "Environnement non trouvé",
|
||||
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
|
||||
@@ -357,6 +358,7 @@
|
||||
"segments": "Segments",
|
||||
"select": "Sélectionner",
|
||||
"select_all": "Sélectionner tout",
|
||||
"select_filter": "Sélectionner un filtre",
|
||||
"select_survey": "Sélectionner l'enquête",
|
||||
"select_teams": "Sélectionner les équipes",
|
||||
"selected": "Sélectionné",
|
||||
@@ -1665,6 +1667,8 @@
|
||||
"responses": {
|
||||
"address_line_1": "Ligne d'adresse 1",
|
||||
"address_line_2": "Ligne d'adresse 2",
|
||||
"an_error_occurred_adding_the_tag": "Une erreur est survenue lors de l'ajout de l'étiquette",
|
||||
"an_error_occurred_creating_the_tag": "Une erreur est survenue lors de la création de l'étiquette",
|
||||
"an_error_occurred_deleting_the_tag": "Une erreur est survenue lors de la suppression de l'étiquette.",
|
||||
"browser": "Navigateur",
|
||||
"bulk_delete_response_quotas": "Les réponses font partie des quotas pour ce sondage. Comment voulez-vous gérer les quotas ?",
|
||||
@@ -1881,7 +1885,6 @@
|
||||
"setup_integrations": "Configurer les intégrations",
|
||||
"share_survey": "Partager l'enquête",
|
||||
"show_all_responses_that_match": "Afficher toutes les réponses correspondantes",
|
||||
"show_all_responses_where": "Afficher toutes les réponses où...",
|
||||
"starts": "Commence",
|
||||
"starts_tooltip": "Nombre de fois que l'enquête a été commencée.",
|
||||
"survey_reset_successfully": "Réinitialisation du sondage réussie ! {responseCount} réponses et {displayCount} affichages ont été supprimés.",
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
"edit": "編集",
|
||||
"email": "メールアドレス",
|
||||
"ending_card": "終了カード",
|
||||
"enter_url": "URLを入力",
|
||||
"enterprise_license": "エンタープライズライセンス",
|
||||
"environment_not_found": "環境が見つかりません",
|
||||
"environment_notice": "現在、{environment} 環境にいます。",
|
||||
@@ -357,6 +358,7 @@
|
||||
"segments": "セグメント",
|
||||
"select": "選択",
|
||||
"select_all": "すべて選択",
|
||||
"select_filter": "フィルターを選択",
|
||||
"select_survey": "フォームを選択",
|
||||
"select_teams": "チームを選択",
|
||||
"selected": "選択済み",
|
||||
@@ -1665,6 +1667,8 @@
|
||||
"responses": {
|
||||
"address_line_1": "住所1",
|
||||
"address_line_2": "住所2",
|
||||
"an_error_occurred_adding_the_tag": "タグの追加中にエラーが発生しました",
|
||||
"an_error_occurred_creating_the_tag": "タグの作成中にエラーが発生しました",
|
||||
"an_error_occurred_deleting_the_tag": "タグの削除中にエラーが発生しました",
|
||||
"browser": "ブラウザ",
|
||||
"bulk_delete_response_quotas": "この回答は、このアンケートの割り当ての一部です。 割り当てをどのように処理しますか?",
|
||||
@@ -1881,7 +1885,6 @@
|
||||
"setup_integrations": "連携を設定",
|
||||
"share_survey": "フォームを共有",
|
||||
"show_all_responses_that_match": "一致するすべての回答を表示",
|
||||
"show_all_responses_where": "以下のすべての回答を表示...",
|
||||
"starts": "開始",
|
||||
"starts_tooltip": "フォームが開始された回数。",
|
||||
"survey_reset_successfully": "フォームを正常にリセットしました!{responseCount} 件の回答と {displayCount} 件の表示が削除されました。",
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
"edit": "Editar",
|
||||
"email": "Email",
|
||||
"ending_card": "Cartão de encerramento",
|
||||
"enter_url": "Inserir URL",
|
||||
"enterprise_license": "Licença Empresarial",
|
||||
"environment_not_found": "Ambiente não encontrado",
|
||||
"environment_notice": "Você está atualmente no ambiente {environment}.",
|
||||
@@ -357,6 +358,7 @@
|
||||
"segments": "Segmentos",
|
||||
"select": "Selecionar",
|
||||
"select_all": "Selecionar tudo",
|
||||
"select_filter": "Selecionar filtro",
|
||||
"select_survey": "Selecionar Pesquisa",
|
||||
"select_teams": "Selecionar times",
|
||||
"selected": "Selecionado",
|
||||
@@ -1665,6 +1667,8 @@
|
||||
"responses": {
|
||||
"address_line_1": "Endereço Linha 1",
|
||||
"address_line_2": "Complemento",
|
||||
"an_error_occurred_adding_the_tag": "Ocorreu um erro ao adicionar a tag",
|
||||
"an_error_occurred_creating_the_tag": "Ocorreu um erro ao criar a tag",
|
||||
"an_error_occurred_deleting_the_tag": "Ocorreu um erro ao deletar a tag",
|
||||
"browser": "navegador",
|
||||
"bulk_delete_response_quotas": "As respostas fazem parte das cotas desta pesquisa. Como você quer gerenciar as cotas?",
|
||||
@@ -1881,7 +1885,6 @@
|
||||
"setup_integrations": "Configurar integrações",
|
||||
"share_survey": "Compartilhar pesquisa",
|
||||
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
|
||||
"show_all_responses_where": "Mostre todas as respostas onde...",
|
||||
"starts": "começa",
|
||||
"starts_tooltip": "Número de vezes que a pesquisa foi iniciada.",
|
||||
"survey_reset_successfully": "Pesquisa redefinida com sucesso! {responseCount} respostas e {displayCount} exibições foram deletadas.",
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
"edit": "Editar",
|
||||
"email": "Email",
|
||||
"ending_card": "Cartão de encerramento",
|
||||
"enter_url": "Introduzir URL",
|
||||
"enterprise_license": "Licença Enterprise",
|
||||
"environment_not_found": "Ambiente não encontrado",
|
||||
"environment_notice": "Está atualmente no ambiente {environment}.",
|
||||
@@ -357,6 +358,7 @@
|
||||
"segments": "Segmentos",
|
||||
"select": "Selecionar",
|
||||
"select_all": "Selecionar tudo",
|
||||
"select_filter": "Selecionar filtro",
|
||||
"select_survey": "Selecionar Inquérito",
|
||||
"select_teams": "Selecionar equipas",
|
||||
"selected": "Selecionado",
|
||||
@@ -1665,6 +1667,8 @@
|
||||
"responses": {
|
||||
"address_line_1": "Endereço Linha 1",
|
||||
"address_line_2": "Endereço Linha 2",
|
||||
"an_error_occurred_adding_the_tag": "Ocorreu um erro ao adicionar a etiqueta",
|
||||
"an_error_occurred_creating_the_tag": "Ocorreu um erro ao criar a etiqueta",
|
||||
"an_error_occurred_deleting_the_tag": "Ocorreu um erro ao eliminar a etiqueta",
|
||||
"browser": "Navegador",
|
||||
"bulk_delete_response_quotas": "As respostas são parte das quotas deste inquérito. Como deseja gerir as quotas?",
|
||||
@@ -1881,7 +1885,6 @@
|
||||
"setup_integrations": "Configurar integrações",
|
||||
"share_survey": "Partilhar inquérito",
|
||||
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
|
||||
"show_all_responses_where": "Mostrar todas as respostas onde...",
|
||||
"starts": "Começa",
|
||||
"starts_tooltip": "Número de vezes que o inquérito foi iniciado.",
|
||||
"survey_reset_successfully": "Inquérito reiniciado com sucesso! {responseCount} respostas e {displayCount} exibições foram eliminadas.",
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
"edit": "Editare",
|
||||
"email": "Email",
|
||||
"ending_card": "Cardul de finalizare",
|
||||
"enter_url": "Introduceți URL-ul",
|
||||
"enterprise_license": "Licență Întreprindere",
|
||||
"environment_not_found": "Mediul nu a fost găsit",
|
||||
"environment_notice": "Te afli în prezent în mediul {environment}",
|
||||
@@ -357,6 +358,7 @@
|
||||
"segments": "Segment",
|
||||
"select": "Selectați",
|
||||
"select_all": "Selectați toate",
|
||||
"select_filter": "Selectați filtrul",
|
||||
"select_survey": "Selectați chestionar",
|
||||
"select_teams": "Selectați echipele",
|
||||
"selected": "Selectat",
|
||||
@@ -1665,6 +1667,8 @@
|
||||
"responses": {
|
||||
"address_line_1": "Adresă Linie 1",
|
||||
"address_line_2": "Adresă Linie 2",
|
||||
"an_error_occurred_adding_the_tag": "A apărut o eroare la adăugarea etichetei",
|
||||
"an_error_occurred_creating_the_tag": "A apărut o eroare la crearea etichetei",
|
||||
"an_error_occurred_deleting_the_tag": "A apărut o eroare la ștergerea etichetei",
|
||||
"browser": "Browser",
|
||||
"bulk_delete_response_quotas": "Răspunsurile fac parte din cotele pentru acest sondaj. Cum doriți să gestionați cotele?",
|
||||
@@ -1881,7 +1885,6 @@
|
||||
"setup_integrations": "Configurare integrare",
|
||||
"share_survey": "Distribuie chestionarul",
|
||||
"show_all_responses_that_match": "Afișează toate răspunsurile care corespund",
|
||||
"show_all_responses_where": "Afișează toate răspunsurile unde...",
|
||||
"starts": "Începuturi",
|
||||
"starts_tooltip": "Număr de ori când sondajul a fost început.",
|
||||
"survey_reset_successfully": "Resetarea chestionarului realizată cu succes! Au fost șterse {responseCount} răspunsuri și {displayCount} afișări.",
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
"edit": "编辑",
|
||||
"email": "邮箱",
|
||||
"ending_card": "结尾卡片",
|
||||
"enter_url": "输入 URL",
|
||||
"enterprise_license": "企业 许可证",
|
||||
"environment_not_found": "环境 未找到",
|
||||
"environment_notice": "你 目前 位于 {environment} 环境。",
|
||||
@@ -357,6 +358,7 @@
|
||||
"segments": "细分",
|
||||
"select": "选择",
|
||||
"select_all": "选择 全部",
|
||||
"select_filter": "选择过滤器",
|
||||
"select_survey": "选择 调查",
|
||||
"select_teams": "选择 团队",
|
||||
"selected": "已选择",
|
||||
@@ -1665,6 +1667,8 @@
|
||||
"responses": {
|
||||
"address_line_1": "地址 第1行",
|
||||
"address_line_2": "地址 第2行",
|
||||
"an_error_occurred_adding_the_tag": "添加标签时发生错误",
|
||||
"an_error_occurred_creating_the_tag": "创建标签时发生错误",
|
||||
"an_error_occurred_deleting_the_tag": "删除 标签 时发生错误",
|
||||
"browser": "浏览器",
|
||||
"bulk_delete_response_quotas": "这些 响应是 此 调查配额 的一部分。 您 希望 如何 处理 这些 配额?",
|
||||
@@ -1881,7 +1885,6 @@
|
||||
"setup_integrations": "设置 集成",
|
||||
"share_survey": "分享 问卷调查",
|
||||
"show_all_responses_that_match": "显示所有匹配的响应",
|
||||
"show_all_responses_where": "显示所有的响应,条件为...",
|
||||
"starts": "开始",
|
||||
"starts_tooltip": "调查 被 开始 的 次数",
|
||||
"survey_reset_successfully": "调查已重置成功!{responseCount} 个 反馈 和 {displayCount} 个 显示 已删除。",
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
"edit": "編輯",
|
||||
"email": "電子郵件",
|
||||
"ending_card": "結尾卡片",
|
||||
"enter_url": "輸入 URL",
|
||||
"enterprise_license": "企業授權",
|
||||
"environment_not_found": "找不到環境",
|
||||
"environment_notice": "您目前在 '{'environment'}' 環境中。",
|
||||
@@ -357,6 +358,7 @@
|
||||
"segments": "區隔",
|
||||
"select": "選擇",
|
||||
"select_all": "全選",
|
||||
"select_filter": "選擇篩選器",
|
||||
"select_survey": "選擇問卷",
|
||||
"select_teams": "選擇 團隊",
|
||||
"selected": "已選取",
|
||||
@@ -1665,6 +1667,8 @@
|
||||
"responses": {
|
||||
"address_line_1": "地址 1",
|
||||
"address_line_2": "地址 2",
|
||||
"an_error_occurred_adding_the_tag": "新增標籤時發生錯誤",
|
||||
"an_error_occurred_creating_the_tag": "建立標籤時發生錯誤",
|
||||
"an_error_occurred_deleting_the_tag": "刪除標籤時發生錯誤",
|
||||
"browser": "瀏覽器",
|
||||
"bulk_delete_response_quotas": "回應 屬於 此 調查 的 配額 一部分 . 你 想 如何 處理 配額?",
|
||||
@@ -1881,7 +1885,6 @@
|
||||
"setup_integrations": "設定整合",
|
||||
"share_survey": "分享問卷",
|
||||
"show_all_responses_that_match": "顯示所有相符的回應",
|
||||
"show_all_responses_where": "顯示所有回應,其中...",
|
||||
"starts": "開始次數",
|
||||
"starts_tooltip": "問卷已開始的次數。",
|
||||
"survey_reset_successfully": "調查 重置 成功!{responseCount} 條回應和 {displayCount} 個顯示被刪除。",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircleIcon, SettingsIcon } from "lucide-react";
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { TagError } from "@/modules/projects/settings/types/tag";
|
||||
@@ -39,14 +40,19 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [tagsState, setTagsState] = useState(tags);
|
||||
const [tagIdToHighlight, setTagIdToHighlight] = useState("");
|
||||
const [isLoadingTagOperation, setIsLoadingTagOperation] = useState(false);
|
||||
|
||||
const onDelete = async (tagId: string) => {
|
||||
try {
|
||||
await deleteTagOnResponseAction({ responseId, tagId });
|
||||
setIsLoadingTagOperation(true);
|
||||
const deleteTagResponse = await deleteTagOnResponseAction({ responseId, tagId });
|
||||
if (deleteTagResponse?.data) {
|
||||
updateFetchedResponses();
|
||||
} catch (e) {
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(deleteTagResponse);
|
||||
logger.error({ errorMessage }, "Error deleting tag");
|
||||
toast.error(t("environments.surveys.responses.an_error_occurred_deleting_the_tag"));
|
||||
}
|
||||
setIsLoadingTagOperation(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -60,72 +66,70 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
}, [tagIdToHighlight]);
|
||||
|
||||
const handleCreateTag = async (tagName: string) => {
|
||||
setOpen(false);
|
||||
setIsLoadingTagOperation(true);
|
||||
const newTagResponse = await createTagAction({ environmentId, tagName });
|
||||
|
||||
const createTagResponse = await createTagAction({
|
||||
environmentId,
|
||||
tagName: tagName?.trim() ?? "",
|
||||
});
|
||||
if (!newTagResponse?.data) {
|
||||
toast.error(t("environments.surveys.responses.an_error_occurred_creating_the_tag"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (createTagResponse?.data?.ok) {
|
||||
const tag = createTagResponse.data.data;
|
||||
setTagsState((prevTags) => [
|
||||
...prevTags,
|
||||
{
|
||||
tagId: tag.id,
|
||||
tagName: tag.name,
|
||||
},
|
||||
]);
|
||||
|
||||
const createTagToResponseActionResponse = await createTagToResponseAction({
|
||||
responseId,
|
||||
tagId: tag.id,
|
||||
});
|
||||
|
||||
if (createTagToResponseActionResponse?.data) {
|
||||
updateFetchedResponses();
|
||||
setSearchValue("");
|
||||
if (!newTagResponse.data.ok) {
|
||||
const errorMessage = newTagResponse.data.error;
|
||||
if (errorMessage?.code === TagError.TAG_NAME_ALREADY_EXISTS) {
|
||||
toast.error(t("environments.surveys.responses.tag_already_exists"), {
|
||||
duration: 2000,
|
||||
icon: <SettingsIcon className="h-5 w-5 text-orange-500" />,
|
||||
});
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createTagToResponseActionResponse);
|
||||
toast.error(errorMessage);
|
||||
toast.error(t("environments.surveys.responses.an_error_occurred_creating_the_tag"));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
createTagResponse?.data?.ok === false &&
|
||||
createTagResponse?.data?.error?.code === TagError.TAG_NAME_ALREADY_EXISTS
|
||||
) {
|
||||
toast.error(t("environments.surveys.responses.tag_already_exists"), {
|
||||
duration: 2000,
|
||||
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
|
||||
});
|
||||
|
||||
const newTag = newTagResponse.data.data;
|
||||
const createTagToResponseResponse = await createTagToResponseAction({ responseId, tagId: newTag.id });
|
||||
if (createTagToResponseResponse?.data) {
|
||||
setTagsState((prevTags) => [...prevTags, { tagId: newTag.id, tagName: newTag.name }]);
|
||||
setTagIdToHighlight(newTag.id);
|
||||
updateFetchedResponses();
|
||||
setSearchValue("");
|
||||
return;
|
||||
setOpen(false);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createTagToResponseResponse);
|
||||
logger.error({ errorMessage });
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
setIsLoadingTagOperation(false);
|
||||
};
|
||||
|
||||
const errorMessage = getFormattedErrorMessage(createTagResponse);
|
||||
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"), {
|
||||
duration: 2000,
|
||||
});
|
||||
setSearchValue("");
|
||||
const handleAddTag = async (tagId: string) => {
|
||||
setIsLoadingTagOperation(true);
|
||||
setTagsState((prevTags) => [
|
||||
...prevTags,
|
||||
{
|
||||
tagId,
|
||||
tagName: environmentTags?.find((tag) => tag.id === tagId)?.name ?? "",
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
await createTagToResponseAction({ responseId, tagId });
|
||||
updateFetchedResponses();
|
||||
setSearchValue("");
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(t("environments.surveys.responses.an_error_occurred_adding_the_tag"));
|
||||
console.error("Error adding tag:", error);
|
||||
// Revert the tag if the action failed
|
||||
setTagsState((prevTags) => prevTags.filter((tag) => tag.tagId !== tagId));
|
||||
} finally {
|
||||
setIsLoadingTagOperation(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-t border-slate-200 px-6 py-4">
|
||||
{!isReadOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="cursor-pointer p-0"
|
||||
onClick={() => {
|
||||
router.push(`/environments/${environmentId}/project/tags`);
|
||||
}}>
|
||||
<SettingsIcon className="h-5 w-5 text-slate-500 hover:text-slate-600" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-4 border-t border-slate-200 px-6 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{tagsState?.map((tag) => (
|
||||
<Tag
|
||||
@@ -136,37 +140,35 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
tags={tagsState}
|
||||
setTagsState={setTagsState}
|
||||
highlight={tagIdToHighlight === tag.tagId}
|
||||
allowDelete={!isReadOnly}
|
||||
allowDelete={!isReadOnly && !isLoadingTagOperation}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!isReadOnly && (
|
||||
<TagsCombobox
|
||||
open={open}
|
||||
open={open && !isLoadingTagOperation}
|
||||
setOpen={setOpen}
|
||||
searchValue={searchValue}
|
||||
setSearchValue={setSearchValue}
|
||||
tags={environmentTags?.map((tag) => ({ value: tag.id, label: tag.name })) ?? []}
|
||||
currentTags={tagsState.map((tag) => ({ value: tag.tagId, label: tag.tagName }))}
|
||||
createTag={handleCreateTag}
|
||||
addTag={(tagId) => {
|
||||
setTagsState((prevTags) => [
|
||||
...prevTags,
|
||||
{
|
||||
tagId,
|
||||
tagName: environmentTags?.find((tag) => tag.id === tagId)?.name ?? "",
|
||||
},
|
||||
]);
|
||||
|
||||
createTagToResponseAction({ responseId, tagId }).then(() => {
|
||||
updateFetchedResponses();
|
||||
setSearchValue("");
|
||||
setOpen(false);
|
||||
});
|
||||
}}
|
||||
addTag={handleAddTag}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isReadOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
onClick={() => {
|
||||
router.push(`/environments/${environmentId}/project/tags`);
|
||||
}}>
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
@@ -42,46 +41,58 @@ export const SingleResponseCard = ({
|
||||
setSelectedResponseId,
|
||||
locale,
|
||||
}: SingleResponseCardProps) => {
|
||||
const hasQuotas = (response.quotas && response.quotas.length > 0) ?? false;
|
||||
const hasQuotas = (response?.quotas && response.quotas.length > 0) ?? false;
|
||||
const [decrementQuotas, setDecrementQuotas] = useState(hasQuotas);
|
||||
const { t } = useTranslation();
|
||||
const environmentId = survey.environmentId;
|
||||
const router = useRouter();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
let skippedQuestions: string[][] = [];
|
||||
let temp: string[] = [];
|
||||
const skippedQuestions: string[][] = useMemo(() => {
|
||||
const flushTemp = (temp: string[], result: string[][], shouldReverse = false) => {
|
||||
if (temp.length > 0) {
|
||||
if (shouldReverse) temp.reverse();
|
||||
result.push([...temp]);
|
||||
temp.length = 0;
|
||||
}
|
||||
};
|
||||
|
||||
if (response.finished) {
|
||||
survey.questions.forEach((question) => {
|
||||
if (!isValidValue(response.data[question.id])) {
|
||||
temp.push(question.id);
|
||||
} else if (temp.length > 0) {
|
||||
skippedQuestions.push([...temp]);
|
||||
temp = [];
|
||||
const processFinishedResponse = () => {
|
||||
const result: string[][] = [];
|
||||
let temp: string[] = [];
|
||||
|
||||
for (const question of survey.questions) {
|
||||
if (isValidValue(response.data[question.id])) {
|
||||
flushTemp(temp, result);
|
||||
} else {
|
||||
temp.push(question.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
for (let index = survey.questions.length - 1; index >= 0; index--) {
|
||||
const question = survey.questions[index];
|
||||
if (
|
||||
!response.data[question.id] &&
|
||||
(skippedQuestions.length === 0 ||
|
||||
(skippedQuestions.length > 0 && !isValidValue(response.data[question.id])))
|
||||
) {
|
||||
temp.push(question.id);
|
||||
} else if (temp.length > 0) {
|
||||
temp.reverse();
|
||||
skippedQuestions.push([...temp]);
|
||||
temp = [];
|
||||
flushTemp(temp, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
const processUnfinishedResponse = () => {
|
||||
const result: string[][] = [];
|
||||
let temp: string[] = [];
|
||||
|
||||
for (let index = survey.questions.length - 1; index >= 0; index--) {
|
||||
const question = survey.questions[index];
|
||||
const hasNoData = !response.data[question.id];
|
||||
const shouldSkip = hasNoData && (result.length === 0 || !isValidValue(response.data[question.id]));
|
||||
|
||||
if (shouldSkip) {
|
||||
temp.push(question.id);
|
||||
} else {
|
||||
flushTemp(temp, result, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle the case where the last entries are empty
|
||||
if (temp.length > 0) {
|
||||
skippedQuestions.push(temp);
|
||||
}
|
||||
flushTemp(temp, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
return response.finished ? processFinishedResponse() : processUnfinishedResponse();
|
||||
}, [response.id, response.finished, response.data, survey.questions]);
|
||||
|
||||
const handleDeleteResponse = async () => {
|
||||
setIsDeleting(true);
|
||||
@@ -91,7 +102,6 @@ export const SingleResponseCard = ({
|
||||
}
|
||||
await deleteResponseAction({ responseId: response.id, decrementQuotas });
|
||||
updateResponseList?.([response.id]);
|
||||
router.refresh();
|
||||
if (setSelectedResponseId) setSelectedResponseId(null);
|
||||
toast.success(t("environments.surveys.responses.response_deleted_successfully"));
|
||||
setDeleteDialogOpen(false);
|
||||
|
||||
@@ -92,7 +92,7 @@ export const UploadContactsAttributeCombobox = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<CommandList>
|
||||
<CommandList className="border-0">
|
||||
<CommandGroup>
|
||||
{keys.map((tag) => {
|
||||
return (
|
||||
|
||||
@@ -46,7 +46,7 @@ export const MergeTagsCombobox = ({ tags, onSelect }: MergeTagsComboboxProps) =>
|
||||
className="border-b border-none border-transparent shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
|
||||
/>
|
||||
</div>
|
||||
<CommandList>
|
||||
<CommandList className="border-0">
|
||||
<CommandEmpty>
|
||||
<div className="p-2 text-sm text-slate-500">{t("environments.project.tags.no_tag_found")}</div>
|
||||
</CommandEmpty>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -60,17 +59,14 @@ function CommandInput({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input> & { hidden?: boolean }) {
|
||||
return (
|
||||
<div data-slot="command-input-wrapper" className={cn("flex items-center")}>
|
||||
<SearchIcon className="h-4 w-4 shrink-0 text-slate-500" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"outline-hidden flex h-10 w-full rounded-md bg-transparent py-3 text-sm placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"outline-hidden flex h-9 w-full rounded-md bg-transparent py-3 text-sm placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,7 +74,10 @@ function CommandList({ className, ...props }: React.ComponentProps<typeof Comman
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn("max-h-[300px] scroll-py-1 overflow-y-auto overflow-x-hidden", className)}
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-y-auto overflow-x-hidden rounded-md border border-slate-300 bg-white",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -116,7 +115,7 @@ function CommandItem({ className, ...props }: React.ComponentProps<typeof Comman
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
"data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm data-[disabled=true]:pointer-events-none data-[selected=true]:cursor-pointer data-[selected=true]:bg-slate-100 data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -137,11 +136,11 @@ function CommandShortcut({ className, ...props }: React.ComponentProps<"span">)
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ export const DataTableToolbar = <T,>({
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-30 my-2 flex w-full items-center justify-between bg-slate-50 py-2">
|
||||
<div className="sticky top-0 z-30 flex w-full items-center justify-between bg-slate-50 py-2">
|
||||
{table.getFilteredSelectedRowModel().rows.length > 0 ? (
|
||||
<SelectedRowSettings
|
||||
table={table}
|
||||
|
||||
@@ -265,7 +265,7 @@ export const InputCombobox: React.FC<InputComboboxProps> = ({
|
||||
<button autoFocus className="sr-only" aria-hidden type="button" />
|
||||
)}
|
||||
|
||||
<CommandList className="p-1">
|
||||
<CommandList className="border-0 p-1">
|
||||
<CommandEmpty className="mx-2 my-0 text-xs font-semibold text-slate-500">
|
||||
{emptyDropdownText ?? t("environments.surveys.edit.no_option_found")}
|
||||
</CommandEmpty>
|
||||
|
||||
@@ -128,8 +128,8 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
|
||||
</div>
|
||||
{open && selectableOptions.length > 0 && !disabled && (
|
||||
<div className="relative mt-2">
|
||||
<CommandList>
|
||||
<div className="text-popover-foreground animate-in absolute top-0 z-10 max-h-32 w-full overflow-auto rounded-md border bg-white shadow-md outline-none">
|
||||
<CommandList className="border-0">
|
||||
<div className="text-popover-foreground animate-in absolute top-0 z-10 max-h-32 w-full overflow-auto rounded-md bg-white shadow-md outline-none">
|
||||
<CommandGroup className="h-full overflow-auto">
|
||||
{selectableOptions.map((option) => (
|
||||
<CommandItem
|
||||
|
||||
@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
|
||||
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm hover:border-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
|
||||
@@ -104,7 +104,7 @@ export const TagsCombobox = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<CommandList>
|
||||
<CommandList className="border-0">
|
||||
<CommandGroup>
|
||||
{tagsToSearch?.map((tag) => {
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user