mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-05 16:19:55 -06:00
feat: Data table for persons (#3154)
This commit is contained in:
committed by
GitHub
parent
fe9746ba67
commit
6b64367d99
@@ -1,11 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { getQuestionDefaults, questionTypes, universalQuestionPresets } from "@/app/lib/questions";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import {
|
||||
getQuestionDefaults,
|
||||
questionTypes,
|
||||
universalQuestionPresets,
|
||||
} from "@formbricks/lib/utils/questions";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
|
||||
interface AddQuestionButtonProps {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { QUESTIONS_ICON_MAP, QUESTIONS_NAME_MAP, getQuestionDefaults } from "@/app/lib/questions";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, EllipsisIcon, TrashIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { QUESTIONS_ICON_MAP, QUESTIONS_NAME_MAP, getQuestionDefaults } from "@formbricks/lib/utils/questions";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import {
|
||||
TSurvey,
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
import { RankingQuestionForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RankingQuestionForm";
|
||||
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util";
|
||||
import { QUESTIONS_ICON_MAP, getTSurveyQuestionTypeEnumName } from "@/app/lib/questions";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { QUESTIONS_ICON_MAP, getTSurveyQuestionTypeEnumName } from "@formbricks/lib/utils/questions";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
|
||||
@@ -86,6 +86,13 @@ export const SurveyMenuBar = ({
|
||||
};
|
||||
}, [localSurvey, survey]);
|
||||
|
||||
const clearSurveyLocalStorage = () => {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.removeItem(`${localSurvey.id}-columnOrder`);
|
||||
localStorage.removeItem(`${localSurvey.id}-columnVisibility`);
|
||||
}
|
||||
};
|
||||
|
||||
const containsEmptyTriggers = useMemo(() => {
|
||||
if (localSurvey.type === "link") return false;
|
||||
|
||||
@@ -233,6 +240,7 @@ export const SurveyMenuBar = ({
|
||||
}
|
||||
|
||||
const segment = await handleSegmentUpdate();
|
||||
clearSurveyLocalStorage();
|
||||
const updatedSurveyResponse = await updateSurveyAction({ ...localSurvey, segment });
|
||||
|
||||
setIsSurveySaving(false);
|
||||
@@ -278,6 +286,7 @@ export const SurveyMenuBar = ({
|
||||
}
|
||||
const status = localSurvey.runOnDate ? "scheduled" : "inProgress";
|
||||
const segment = await handleSegmentUpdate();
|
||||
clearSurveyLocalStorage();
|
||||
|
||||
await updateSurveyAction({
|
||||
...localSurvey,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
|
||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
||||
import { TagIcon } from "lucide-react";
|
||||
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
|
||||
import { PageHeader } from "@formbricks/ui/PageHeader";
|
||||
@@ -8,7 +8,7 @@ const Loading = () => {
|
||||
<>
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="People">
|
||||
<PeopleSecondaryNavigation activeId="attributes" loading />
|
||||
<PersonSecondaryNavigation activeId="attributes" loading />
|
||||
</PageHeader>
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-5 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
|
||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
||||
import { CircleHelpIcon } from "lucide-react";
|
||||
import { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
@@ -42,7 +42,7 @@ const Page = async ({ params }) => {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="People" cta={HowToAddAttributesButton}>
|
||||
<PeopleSecondaryNavigation activeId="attributes" environmentId={params.environmentId} />
|
||||
<PersonSecondaryNavigation activeId="attributes" environmentId={params.environmentId} />
|
||||
</PageHeader>
|
||||
<AttributeClassesTable attributeClasses={attributeClasses} />
|
||||
</PageContentWrapper>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { deletePersonAction } from "@/app/(app)/environments/[environmentId]/(people)/people/[personId]/actions";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||
import { deletePersonAction } from "@formbricks/ui/DataTable/actions";
|
||||
import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
|
||||
|
||||
interface DeletePersonButtonProps {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { getAttributes } from "@formbricks/lib/attribute/service";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils";
|
||||
import { getPeople } from "@formbricks/lib/person/service";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
|
||||
const ZGetPersonsAction = z.object({
|
||||
environmentId: ZId,
|
||||
page: z.number(),
|
||||
});
|
||||
|
||||
export const getPersonsAction = authenticatedActionClient
|
||||
.schema(ZGetPersonsAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
rules: ["environment", "read"],
|
||||
});
|
||||
|
||||
return getPeople(parsedInput.environmentId, parsedInput.page);
|
||||
});
|
||||
|
||||
const ZGetPersonAttributesAction = z.object({
|
||||
environmentId: ZId,
|
||||
personId: ZId,
|
||||
});
|
||||
|
||||
export const getPersonAttributesAction = authenticatedActionClient
|
||||
.schema(ZGetPersonAttributesAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
rules: ["environment", "read"],
|
||||
});
|
||||
|
||||
return getAttributes(parsedInput.personId);
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import { getAttributes } from "@formbricks/lib/attribute/service";
|
||||
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
|
||||
import { TPerson } from "@formbricks/types/people";
|
||||
import { PersonAvatar } from "@formbricks/ui/Avatars";
|
||||
|
||||
export const PersonCard = async ({ person }: { person: TPerson }) => {
|
||||
const attributes = await getAttributes(person.id);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/environments/${person.environmentId}/people/${person.id}`}
|
||||
key={person.id}
|
||||
className="w-full">
|
||||
<div className="m-2 grid h-16 grid-cols-7 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
|
||||
<div className="col-span-3 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="ph-no-capture h-10 w-10 flex-shrink-0">
|
||||
<PersonAvatar personId={person.id} />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="ph-no-capture font-medium text-slate-900">
|
||||
<span>{getPersonIdentifier({ id: person.id, userId: person.userId }, attributes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">{person.userId}</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">{attributes.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
getPersonAttributesAction,
|
||||
getPersonsAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/(people)/people/actions";
|
||||
import { PersonTable } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonTable";
|
||||
import { useEffect, useState } from "react";
|
||||
import React from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TPerson, TPersonTableData } from "@formbricks/types/people";
|
||||
|
||||
interface PersonDataViewProps {
|
||||
environment: TEnvironment;
|
||||
personCount: number;
|
||||
itemsPerPage: number;
|
||||
}
|
||||
|
||||
export const PersonDataView = ({ environment, personCount, itemsPerPage }: PersonDataViewProps) => {
|
||||
const [persons, setPersons] = useState<TPerson[]>([]);
|
||||
const [personTableData, setPersonTableData] = useState<TPersonTableData[]>([]);
|
||||
const [pageNumber, setPageNumber] = useState<number>(1);
|
||||
const [totalPersons, setTotalPersons] = useState<number>(0);
|
||||
const [isDataLoaded, setIsDataLoaded] = useState<boolean>(false);
|
||||
const [hasMore, setHasMore] = useState<boolean>(false);
|
||||
const [loadingNextPage, setLoadingNextPage] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTotalPersons(personCount);
|
||||
setHasMore(pageNumber < Math.ceil(personCount / itemsPerPage));
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const getPersonActionData = await getPersonsAction({
|
||||
environmentId: environment.id,
|
||||
page: pageNumber,
|
||||
});
|
||||
if (getPersonActionData?.data) {
|
||||
setPersons(getPersonActionData.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching people data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [pageNumber]);
|
||||
|
||||
// Fetch additional person attributes and update table data
|
||||
useEffect(() => {
|
||||
const fetchAttributes = async () => {
|
||||
const updatedPersonTableData = await Promise.all(
|
||||
persons.map(async (person) => {
|
||||
const attributes = await getPersonAttributesAction({
|
||||
environmentId: environment.id,
|
||||
personId: person.id,
|
||||
});
|
||||
return {
|
||||
createdAt: person.createdAt,
|
||||
personId: person.id,
|
||||
userId: person.userId,
|
||||
email: attributes?.data?.email ?? "",
|
||||
attributes: attributes?.data ?? {},
|
||||
};
|
||||
})
|
||||
);
|
||||
setPersonTableData(updatedPersonTableData);
|
||||
setIsDataLoaded(true);
|
||||
};
|
||||
|
||||
if (persons.length > 0) {
|
||||
fetchAttributes();
|
||||
}
|
||||
}, [persons]);
|
||||
|
||||
const fetchNextPage = async () => {
|
||||
if (hasMore && !loadingNextPage) {
|
||||
setLoadingNextPage(true);
|
||||
const getPersonsActionData = await getPersonsAction({
|
||||
environmentId: environment.id,
|
||||
page: pageNumber,
|
||||
});
|
||||
if (getPersonsActionData?.data) {
|
||||
const newData = getPersonsActionData.data;
|
||||
setPersons((prevPersonsData) => [...prevPersonsData, ...newData]);
|
||||
}
|
||||
setPageNumber((prevPage) => prevPage + 1);
|
||||
setHasMore(pageNumber + 1 < Math.ceil(totalPersons / itemsPerPage));
|
||||
setLoadingNextPage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deletePersons = (personIds: string[]) => {
|
||||
setPersons(persons.filter((p) => !personIds.includes(p.id)));
|
||||
};
|
||||
|
||||
return (
|
||||
<PersonTable
|
||||
data={personTableData}
|
||||
fetchNextPage={fetchNextPage}
|
||||
hasMore={hasMore}
|
||||
isDataLoaded={isDataLoaded}
|
||||
deletePersons={deletePersons}
|
||||
environmentId={environment.id}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -2,17 +2,17 @@ import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { TProductConfigChannel } from "@formbricks/types/product";
|
||||
import { SecondaryNavigation } from "@formbricks/ui/SecondaryNavigation";
|
||||
|
||||
interface PeopleSegmentsTabsProps {
|
||||
interface PersonSecondaryNavigationProps {
|
||||
activeId: string;
|
||||
environmentId?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const PeopleSecondaryNavigation = async ({
|
||||
export const PersonSecondaryNavigation = async ({
|
||||
activeId,
|
||||
environmentId,
|
||||
loading,
|
||||
}: PeopleSegmentsTabsProps) => {
|
||||
}: PersonSecondaryNavigationProps) => {
|
||||
let currentProductChannel: TProductConfigChannel = null;
|
||||
|
||||
if (!loading && environmentId) {
|
||||
@@ -0,0 +1,238 @@
|
||||
import { generatePersonTableColumns } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonTableColumn";
|
||||
import {
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
|
||||
import { SortableContext, arrayMove, horizontalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { VisibilityState, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TPersonTableData } from "@formbricks/types/people";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { DataTableHeader, DataTableSettingsModal, DataTableToolbar } from "@formbricks/ui/DataTable";
|
||||
import { Skeleton } from "@formbricks/ui/Skeleton";
|
||||
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@formbricks/ui/Table";
|
||||
|
||||
interface PersonTableProps {
|
||||
data: TPersonTableData[];
|
||||
fetchNextPage: () => void;
|
||||
hasMore: boolean;
|
||||
deletePersons: (personIds: string[]) => void;
|
||||
isDataLoaded: boolean;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const PersonTable = ({
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasMore,
|
||||
deletePersons,
|
||||
isDataLoaded,
|
||||
environmentId,
|
||||
}: PersonTableProps) => {
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||||
const [isTableSettingsModalOpen, setIsTableSettingsModalOpen] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const router = useRouter();
|
||||
// Generate columns
|
||||
const columns = useMemo(() => generatePersonTableColumns(isExpanded ?? false), [isExpanded]);
|
||||
|
||||
// Load saved settings from localStorage
|
||||
useEffect(() => {
|
||||
const savedColumnOrder = localStorage.getItem(`${environmentId}-columnOrder`);
|
||||
const savedColumnVisibility = localStorage.getItem(`${environmentId}-columnVisibility`);
|
||||
const savedExpandedSettings = localStorage.getItem(`${environmentId}-rowExpand`);
|
||||
if (savedColumnOrder && JSON.parse(savedColumnOrder).length > 0) {
|
||||
setColumnOrder(JSON.parse(savedColumnOrder));
|
||||
} else {
|
||||
setColumnOrder(table.getAllLeafColumns().map((d) => d.id));
|
||||
}
|
||||
|
||||
if (savedColumnVisibility) {
|
||||
setColumnVisibility(JSON.parse(savedColumnVisibility));
|
||||
}
|
||||
if (savedExpandedSettings !== null) {
|
||||
setIsExpanded(JSON.parse(savedExpandedSettings));
|
||||
}
|
||||
}, [environmentId]);
|
||||
|
||||
// Save settings to localStorage when they change
|
||||
useEffect(() => {
|
||||
if (columnOrder.length > 0) {
|
||||
localStorage.setItem(`${environmentId}-columnOrder`, JSON.stringify(columnOrder));
|
||||
}
|
||||
if (Object.keys(columnVisibility).length > 0) {
|
||||
localStorage.setItem(`${environmentId}-columnVisibility`, JSON.stringify(columnVisibility));
|
||||
}
|
||||
|
||||
if (isExpanded !== null) {
|
||||
localStorage.setItem(`${environmentId}-rowExpand`, JSON.stringify(isExpanded));
|
||||
}
|
||||
}, [columnOrder, columnVisibility, isExpanded, environmentId]);
|
||||
|
||||
// Initialize DnD sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
);
|
||||
|
||||
// Memoize table data and columns
|
||||
const tableData: TPersonTableData[] = useMemo(
|
||||
() => (!isDataLoaded ? Array(10).fill({}) : data),
|
||||
[data, isDataLoaded]
|
||||
);
|
||||
const tableColumns = useMemo(
|
||||
() =>
|
||||
!isDataLoaded
|
||||
? columns.map((column) => ({
|
||||
...column,
|
||||
cell: () => (
|
||||
<Skeleton className="w-full">
|
||||
<div className="h-6"></div>
|
||||
</Skeleton>
|
||||
),
|
||||
}))
|
||||
: columns,
|
||||
[columns, data]
|
||||
);
|
||||
|
||||
// React Table instance
|
||||
const table = useReactTable({
|
||||
data: tableData,
|
||||
columns: tableColumns,
|
||||
getRowId: (originalRow) => originalRow.personId,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onColumnOrderChange: setColumnOrder,
|
||||
columnResizeMode: "onChange",
|
||||
columnResizeDirection: "ltr",
|
||||
manualPagination: true,
|
||||
defaultColumn: { size: 300 },
|
||||
state: {
|
||||
columnOrder,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnPinning: {
|
||||
left: ["select", "createdAt"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Handle column drag end
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (active && over && active.id !== over.id) {
|
||||
setColumnOrder((prevOrder) => {
|
||||
const oldIndex = prevOrder.indexOf(active.id as string);
|
||||
const newIndex = prevOrder.indexOf(over.id as string);
|
||||
return arrayMove(prevOrder, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToHorizontalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
sensors={sensors}>
|
||||
<DataTableToolbar
|
||||
setIsExpanded={setIsExpanded}
|
||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
||||
isExpanded={isExpanded ?? false}
|
||||
table={table}
|
||||
deleteRows={deletePersons}
|
||||
type="person"
|
||||
/>
|
||||
<div className="w-fit max-w-full overflow-hidden overflow-x-auto rounded-xl border border-slate-300">
|
||||
<div className="w-full overflow-x-auto">
|
||||
<Table style={{ width: table.getCenterTotalSize(), tableLayout: "fixed" }}>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
<SortableContext items={columnOrder} strategy={horizontalListSortingStrategy}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<DataTableHeader
|
||||
key={header.id}
|
||||
header={header}
|
||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</tr>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={"group cursor-pointer"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
onClick={() => {
|
||||
if (cell.column.id === "select") return;
|
||||
router.push(`/environments/${environmentId}/people/${row.id}`);
|
||||
}}
|
||||
className={cn(
|
||||
"border-slate-300 bg-white shadow-none group-hover:bg-slate-100",
|
||||
row.getIsSelected() && "bg-slate-100",
|
||||
{
|
||||
"border-r": !cell.column.getIsLastColumn(),
|
||||
"border-l": !cell.column.getIsFirstColumn(),
|
||||
}
|
||||
)}>
|
||||
<div
|
||||
className={cn("flex flex-1 items-center truncate", isExpanded ? "h-full" : "h-10")}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
{table.getRowModel().rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data && hasMore && data.length > 0 && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button onClick={fetchNextPage} className="bg-blue-500 text-white">
|
||||
Load More
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTableSettingsModal
|
||||
open={isTableSettingsModalOpen}
|
||||
setOpen={setIsTableSettingsModalOpen}
|
||||
table={table}
|
||||
columnOrder={columnOrder}
|
||||
handleDragEnd={handleDragEnd}
|
||||
/>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TPersonTableData } from "@formbricks/types/people";
|
||||
import { getSelectionColumn } from "@formbricks/ui/DataTable";
|
||||
|
||||
export const generatePersonTableColumns = (isExpanded: boolean): ColumnDef<TPersonTableData>[] => {
|
||||
const dateColumn: ColumnDef<TPersonTableData> = {
|
||||
accessorKey: "createdAt",
|
||||
header: () => "Date",
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const isoDateString = row.original.createdAt;
|
||||
const date = new Date(isoDateString);
|
||||
|
||||
const formattedDate = date.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const formattedTime = date.toLocaleString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="truncate text-slate-900">{formattedDate}</p>
|
||||
<p className="truncate text-slate-900">{formattedTime}</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const userColumn: ColumnDef<TPersonTableData> = {
|
||||
accessorKey: "user",
|
||||
header: "User",
|
||||
cell: ({ row }) => {
|
||||
const personId = row.original.personId;
|
||||
return <p className="truncate text-slate-900">{personId}</p>;
|
||||
},
|
||||
};
|
||||
|
||||
const userIdColumn: ColumnDef<TPersonTableData> = {
|
||||
accessorKey: "userId",
|
||||
header: "User ID",
|
||||
cell: ({ row }) => {
|
||||
const userId = row.original.userId;
|
||||
return <p className="truncate text-slate-900">{userId}</p>;
|
||||
},
|
||||
};
|
||||
|
||||
const emailColumn: ColumnDef<TPersonTableData> = {
|
||||
accessorKey: "email",
|
||||
header: "Email",
|
||||
};
|
||||
|
||||
const attributesColumn: ColumnDef<TPersonTableData> = {
|
||||
accessorKey: "attributes",
|
||||
header: "Attributes",
|
||||
cell: ({ row }) => {
|
||||
const attributes = row.original.attributes;
|
||||
|
||||
// Handle cases where attributes are missing or empty
|
||||
if (!attributes || Object.keys(attributes).length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={cn(!isExpanded && "flex space-x-2")}>
|
||||
{Object.entries(attributes).map(([key, value]) => (
|
||||
<div key={key} className="flex space-x-2">
|
||||
<div className="font-semibold">{key}</div> : <div>{value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return [getSelectionColumn(), dateColumn, userColumn, userIdColumn, emailColumn, attributesColumn];
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
export const Pagination = ({ environmentId, currentPage, totalItems, itemsPerPage }) => {
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
|
||||
const previousPageLink =
|
||||
currentPage === 1 ? "#" : `/environments/${environmentId}/people?page=${currentPage - 1}`;
|
||||
const nextPageLink =
|
||||
currentPage === totalPages ? "#" : `/environments/${environmentId}/people?page=${currentPage + 1}`;
|
||||
|
||||
return (
|
||||
<nav aria-label="Page navigation" className="flex justify-center">
|
||||
<ul className="mt-4 inline-flex -space-x-px text-sm">
|
||||
<li>
|
||||
<a
|
||||
href={previousPageLink}
|
||||
className={`ml-0 flex h-8 items-center justify-center rounded-l-lg border border-slate-300 bg-white px-3 text-slate-500 ${
|
||||
currentPage === 1
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: "hover:bg-slate-100 hover:text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-white"
|
||||
}`}>
|
||||
Previous
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{Array.from({ length: totalPages }).map((_, idx) => {
|
||||
const pageNum = idx + 1;
|
||||
const pageLink = `/environments/${environmentId}/people?page=${pageNum}`;
|
||||
|
||||
return (
|
||||
<li key={pageNum} className="hidden sm:block">
|
||||
<a
|
||||
href={pageNum === currentPage ? "#" : pageLink}
|
||||
className={`flex h-8 items-center justify-center px-3 ${
|
||||
pageNum === currentPage ? "bg-blue-50 text-green-500" : "bg-white text-slate-500"
|
||||
} border border-slate-300 hover:bg-slate-100 hover:text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-white`}>
|
||||
{pageNum}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
<li>
|
||||
<a
|
||||
href={nextPageLink}
|
||||
className={`ml-0 flex h-8 items-center justify-center rounded-r-lg border border-slate-300 bg-white px-3 text-slate-500 ${
|
||||
currentPage === totalPages
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: "hover:bg-slate-100 hover:text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-white"
|
||||
}`}>
|
||||
Next
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
|
||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
||||
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
|
||||
import { PageHeader } from "@formbricks/ui/PageHeader";
|
||||
|
||||
@@ -7,7 +7,7 @@ const Loading = () => {
|
||||
<>
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="People">
|
||||
<PeopleSecondaryNavigation activeId="people" loading />
|
||||
<PersonSecondaryNavigation activeId="people" loading />
|
||||
</PageHeader>
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
|
||||
@@ -1,42 +1,20 @@
|
||||
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
|
||||
import { PersonDataView } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonDataView";
|
||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
||||
import { CircleHelpIcon } from "lucide-react";
|
||||
import { ITEMS_PER_PAGE } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getPeople, getPeopleCount } from "@formbricks/lib/person/service";
|
||||
import { TPerson } from "@formbricks/types/people";
|
||||
import { getPersonCount } from "@formbricks/lib/person/service";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { EmptySpaceFiller } from "@formbricks/ui/EmptySpaceFiller";
|
||||
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
|
||||
import { PageHeader } from "@formbricks/ui/PageHeader";
|
||||
import { Pagination } from "@formbricks/ui/Pagination";
|
||||
import { PersonCard } from "./components/PersonCard";
|
||||
|
||||
const Page = async ({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: { environmentId: string };
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
}) => {
|
||||
const pageNumber = searchParams.page ? parseInt(searchParams.page as string) : 1;
|
||||
const [environment, totalPeople] = await Promise.all([
|
||||
getEnvironment(params.environmentId),
|
||||
getPeopleCount(params.environmentId),
|
||||
]);
|
||||
const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
const personCount = await getPersonCount(params.environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
const maxPageNumber = Math.ceil(totalPeople / ITEMS_PER_PAGE);
|
||||
let hidePagination = false;
|
||||
|
||||
let people: TPerson[] = [];
|
||||
|
||||
if (pageNumber < 1 || pageNumber > maxPageNumber) {
|
||||
people = [];
|
||||
hidePagination = true;
|
||||
} else {
|
||||
people = await getPeople(params.environmentId, pageNumber);
|
||||
}
|
||||
|
||||
const HowToAddPeopleButton = (
|
||||
<Button
|
||||
@@ -52,35 +30,9 @@ const Page = async ({
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="People" cta={HowToAddPeopleButton}>
|
||||
<PeopleSecondaryNavigation activeId="people" environmentId={params.environmentId} />
|
||||
<PersonSecondaryNavigation activeId="people" environmentId={params.environmentId} />
|
||||
</PageHeader>
|
||||
{people.length === 0 ? (
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environment={environment}
|
||||
emptyMessage="Your users will appear here as soon as they use your app ⏲️"
|
||||
noWidgetRequired={true}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-7 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-3 pl-6">User</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">User ID</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">Email</div>
|
||||
</div>
|
||||
{people.map((person) => (
|
||||
<PersonCard person={person} key={person.id} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hidePagination ? null : (
|
||||
<Pagination
|
||||
baseUrl={`/environments/${params.environmentId}/people`}
|
||||
currentPage={pageNumber}
|
||||
totalItems={totalPeople}
|
||||
itemsPerPage={ITEMS_PER_PAGE}
|
||||
/>
|
||||
)}
|
||||
<PersonDataView environment={environment} personCount={personCount} itemsPerPage={ITEMS_PER_PAGE} />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
|
||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
||||
import { UsersIcon } from "lucide-react";
|
||||
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
|
||||
import { PageHeader } from "@formbricks/ui/PageHeader";
|
||||
@@ -8,7 +8,7 @@ const Loading = () => {
|
||||
<>
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="People">
|
||||
<PeopleSecondaryNavigation activeId="segments" loading />
|
||||
<PersonSecondaryNavigation activeId="segments" loading />
|
||||
</PageHeader>
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
|
||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
||||
import { BasicCreateSegmentModal } from "@/app/(app)/environments/[environmentId]/(people)/segments/components/BasicCreateSegmentModal";
|
||||
import { SegmentTable } from "@/app/(app)/environments/[environmentId]/(people)/segments/components/SegmentTable";
|
||||
import { CreateSegmentModal } from "@formbricks/ee/advanced-targeting/components/create-segment-modal";
|
||||
@@ -56,7 +56,7 @@ const Page = async ({ params }) => {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="People" cta={renderCreateSegmentButton()}>
|
||||
<PeopleSecondaryNavigation activeId="segments" environmentId={params.environmentId} />
|
||||
<PersonSecondaryNavigation activeId="segments" environmentId={params.environmentId} />
|
||||
</PageHeader>
|
||||
<SegmentTable
|
||||
segments={filteredSegments}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
TYPE_MAPPING,
|
||||
UNSUPPORTED_TYPES_BY_NOTION,
|
||||
} from "@/app/(app)/environments/[environmentId]/integrations/notion/constants";
|
||||
import { questionTypes } from "@/app/lib/questions";
|
||||
import NotionLogo from "@/images/notion.png";
|
||||
import { PlusIcon, XIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
@@ -13,6 +12,7 @@ import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { questionTypes } from "@formbricks/lib/utils/questions";
|
||||
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TIntegrationInput } from "@formbricks/types/integration";
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { ResponseCardModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal";
|
||||
import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell";
|
||||
import { generateColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
|
||||
import { ResponseTableHeader } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableHeader";
|
||||
import { ResponseTableToolbar } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableToolbar";
|
||||
import { TableSettingsModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/TableSettingsModal";
|
||||
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
|
||||
import {
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
@@ -24,6 +21,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { DataTableHeader, DataTableSettingsModal, DataTableToolbar } from "@formbricks/ui/DataTable";
|
||||
import { Skeleton } from "@formbricks/ui/Skeleton";
|
||||
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@formbricks/ui/Table";
|
||||
|
||||
@@ -61,11 +59,44 @@ export const ResponseTable = ({
|
||||
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(false);
|
||||
const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||||
|
||||
// Generate columns
|
||||
const columns = generateColumns(survey, isExpanded, isViewer);
|
||||
const columns = generateResponseTableColumns(survey, isExpanded ?? false, isViewer);
|
||||
|
||||
// Load saved settings from localStorage
|
||||
useEffect(() => {
|
||||
const savedColumnOrder = localStorage.getItem(`${survey.id}-columnOrder`);
|
||||
const savedColumnVisibility = localStorage.getItem(`${survey.id}-columnVisibility`);
|
||||
const savedExpandedSettings = localStorage.getItem(`${survey.id}-rowExpand`);
|
||||
|
||||
if (savedColumnOrder && JSON.parse(savedColumnOrder).length > 0) {
|
||||
setColumnOrder(JSON.parse(savedColumnOrder));
|
||||
} else {
|
||||
setColumnOrder(table.getAllLeafColumns().map((d) => d.id));
|
||||
}
|
||||
|
||||
if (savedColumnVisibility) {
|
||||
setColumnVisibility(JSON.parse(savedColumnVisibility));
|
||||
}
|
||||
if (savedExpandedSettings !== null) {
|
||||
setIsExpanded(JSON.parse(savedExpandedSettings));
|
||||
}
|
||||
}, [survey.id]);
|
||||
|
||||
// Save settings to localStorage when they change
|
||||
useEffect(() => {
|
||||
if (columnOrder.length > 0) {
|
||||
localStorage.setItem(`${survey.id}-columnOrder`, JSON.stringify(columnOrder));
|
||||
}
|
||||
if (Object.keys(columnVisibility).length > 0) {
|
||||
localStorage.setItem(`${survey.id}-columnVisibility`, JSON.stringify(columnVisibility));
|
||||
}
|
||||
if (isExpanded !== null) {
|
||||
localStorage.setItem(`${survey.id}-rowExpand`, JSON.stringify(isExpanded));
|
||||
}
|
||||
}, [columnOrder, columnVisibility, isExpanded, survey.id]);
|
||||
|
||||
// Initialize DnD sensors
|
||||
const sensors = useSensors(
|
||||
@@ -117,14 +148,6 @@ export const ResponseTable = ({
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Set initial column order
|
||||
const setInitialColumnOrder = () => {
|
||||
table.setColumnOrder(table.getAllLeafColumns().map((d) => d.id));
|
||||
};
|
||||
setInitialColumnOrder();
|
||||
}, [table]);
|
||||
|
||||
// Handle column drag end
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
@@ -145,58 +168,65 @@ export const ResponseTable = ({
|
||||
modifiers={[restrictToHorizontalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
sensors={sensors}>
|
||||
<ResponseTableToolbar
|
||||
<DataTableToolbar
|
||||
setIsExpanded={setIsExpanded}
|
||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
||||
isExpanded={isExpanded}
|
||||
isExpanded={isExpanded ?? false}
|
||||
table={table}
|
||||
deleteResponses={deleteResponses}
|
||||
deleteRows={deleteResponses}
|
||||
type="response"
|
||||
/>
|
||||
<div>
|
||||
<Table style={{ width: table.getCenterTotalSize(), tableLayout: "fixed" }}>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
<SortableContext items={columnOrder} strategy={horizontalListSortingStrategy}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<ResponseTableHeader
|
||||
key={header.id}
|
||||
header={header}
|
||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
||||
<div className="w-fit max-w-full overflow-hidden overflow-x-auto rounded-xl border border-slate-300">
|
||||
<div className="w-full overflow-x-auto">
|
||||
<Table
|
||||
style={{
|
||||
width: table.getCenterTotalSize(),
|
||||
tableLayout: "fixed",
|
||||
}}>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
<SortableContext items={columnOrder} strategy={horizontalListSortingStrategy}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<DataTableHeader
|
||||
key={header.id}
|
||||
header={header}
|
||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</tr>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={"group cursor-pointer"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<ResponseTableCell
|
||||
key={cell.id}
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={isExpanded ?? false}
|
||||
setSelectedResponseId={setSelectedResponseId}
|
||||
responses={responses}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</tr>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={"group cursor-pointer"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<ResponseTableCell
|
||||
key={cell.id}
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={isExpanded}
|
||||
setSelectedResponseId={setSelectedResponseId}
|
||||
responses={responses}
|
||||
/>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
{table.getRowModel().rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableRow>
|
||||
))}
|
||||
{table.getRowModel().rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data && hasMore && data.length > 0 && (
|
||||
@@ -207,7 +237,7 @@ export const ResponseTable = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TableSettingsModal
|
||||
<DataTableSettingsModal
|
||||
open={isTableSettingsModalOpen}
|
||||
setOpen={setIsTableSettingsModalOpen}
|
||||
survey={survey}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getCommonPinningStyles } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableHeader";
|
||||
import { Cell, Row, flexRender } from "@tanstack/react-table";
|
||||
import { Maximize2Icon } from "lucide-react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
|
||||
import { getCommonPinningStyles } from "@formbricks/ui/DataTable/lib/utils";
|
||||
import { TableCell } from "@formbricks/ui/Table";
|
||||
|
||||
interface ResponseTableCellProps {
|
||||
@@ -46,8 +46,12 @@ export const ResponseTableCell = ({
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={cn(
|
||||
"border border-slate-300 bg-white shadow-none group-hover:bg-slate-100",
|
||||
row.getIsSelected() && "bg-slate-100"
|
||||
"border-slate-300 bg-white shadow-none group-hover:bg-slate-100",
|
||||
row.getIsSelected() && "bg-slate-100",
|
||||
{
|
||||
"border-r": !cell.column.getIsLastColumn(),
|
||||
"border-l": !cell.column.getIsFirstColumn(),
|
||||
}
|
||||
)}
|
||||
style={cellStyles}
|
||||
onClick={handleCellClick}>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { QUESTIONS_ICON_MAP } from "@/app/lib/questions";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
|
||||
import { processResponseData } from "@formbricks/lib/responses";
|
||||
import { QUESTIONS_ICON_MAP } from "@formbricks/lib/utils/questions";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { Checkbox } from "@formbricks/ui/Checkbox";
|
||||
import { getSelectionColumn } from "@formbricks/ui/DataTable";
|
||||
import { ResponseBadges } from "@formbricks/ui/ResponseBadges";
|
||||
import { RenderResponse } from "@formbricks/ui/SingleResponseCard/components/RenderResponse";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
|
||||
@@ -123,7 +123,7 @@ const getQuestionColumnsData = (
|
||||
}
|
||||
};
|
||||
|
||||
export const generateColumns = (
|
||||
export const generateResponseTableColumns = (
|
||||
survey: TSurvey,
|
||||
isExpanded: boolean,
|
||||
isViewer: boolean
|
||||
@@ -131,30 +131,6 @@ export const generateColumns = (
|
||||
const questionColumns = survey.questions.flatMap((question) =>
|
||||
getQuestionColumnsData(question, survey, isExpanded)
|
||||
);
|
||||
const selectionColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "select",
|
||||
size: 75,
|
||||
enableResizing: false,
|
||||
header: ({ table }) => (
|
||||
<div className="flex w-full items-center justify-center pr-2">
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex w-full items-center justify-center pr-4">
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
className="mx-1"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
const dateColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "createdAt",
|
||||
@@ -299,8 +275,8 @@ export const generateColumns = (
|
||||
};
|
||||
|
||||
// Combine the selection column with the dynamic question columns
|
||||
return [
|
||||
...(isViewer ? [] : [selectionColumn]),
|
||||
|
||||
const baseColumns = [
|
||||
personColumn,
|
||||
dateColumn,
|
||||
statusColumn,
|
||||
@@ -310,4 +286,6 @@ export const generateColumns = (
|
||||
tagsColumn,
|
||||
notesColumn,
|
||||
];
|
||||
|
||||
return isViewer ? baseColumns : [getSelectionColumn(), ...baseColumns];
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { questionTypes } from "@/app/lib/questions";
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import { questionTypes } from "@formbricks/lib/utils/questions";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyQuestionSummary } from "@formbricks/types/surveys/types";
|
||||
|
||||
@@ -110,7 +110,7 @@ export const getPeople = reactCache(
|
||||
)()
|
||||
);
|
||||
|
||||
export const getPeopleCount = reactCache(
|
||||
export const getPersonCount = reactCache(
|
||||
(environmentId: string): Promise<number> =>
|
||||
cache(
|
||||
async () => {
|
||||
@@ -130,7 +130,7 @@ export const getPeopleCount = reactCache(
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getPeopleCount-${environmentId}`],
|
||||
[`getPersonCount-${environmentId}`],
|
||||
{
|
||||
tags: [personCache.tag.byEnvironmentId(environmentId)],
|
||||
}
|
||||
|
||||
@@ -229,12 +229,12 @@ export const questionTypes: TQuestion[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const QUESTIONS_ICON_MAP = questionTypes.reduce(
|
||||
export const QUESTIONS_ICON_MAP: Record<TSurveyQuestionTypeEnum, JSX.Element> = questionTypes.reduce(
|
||||
(prev, curr) => ({
|
||||
...prev,
|
||||
[curr.id]: <curr.icon className="h-4 w-4" />,
|
||||
}),
|
||||
{}
|
||||
{} as Record<TSurveyQuestionTypeEnum, JSX.Element>
|
||||
);
|
||||
|
||||
export const QUESTIONS_NAME_MAP = questionTypes.reduce(
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TTemplate } from "@formbricks/types/templates";
|
||||
import { getLocalizedValue } from "../i18n/utils";
|
||||
import { structuredClone } from "../pollyfills/structuredClone";
|
||||
|
||||
export const replaceQuestionPresetPlaceholders = (
|
||||
question: TSurveyQuestion,
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { ZAttributes } from "./attributes";
|
||||
|
||||
export const ZPerson = z.object({
|
||||
id: z.string().cuid2(),
|
||||
@@ -8,4 +9,13 @@ export const ZPerson = z.object({
|
||||
environmentId: z.string().cuid2(),
|
||||
});
|
||||
|
||||
export const ZPersonTableData = z.object({
|
||||
personId: z.string(),
|
||||
createdAt: z.date(),
|
||||
userId: z.string(),
|
||||
attributes: ZAttributes,
|
||||
});
|
||||
|
||||
export type TPersonTableData = z.infer<typeof ZPersonTableData>;
|
||||
|
||||
export type TPerson = z.infer<typeof ZPerson>;
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
import { Column } from "@tanstack/react-table";
|
||||
import { EllipsisVerticalIcon, EyeOffIcon, SettingsIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { TResponseTableData } from "@formbricks/types/responses";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../DropdownMenu";
|
||||
|
||||
interface ColumnSettingsDropdownProps {
|
||||
column: Column<TResponseTableData>;
|
||||
interface ColumnSettingsDropdownProps<T> {
|
||||
column: Column<T>;
|
||||
setIsTableSettingsModalOpen: (isTableSettingsModalOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export const ColumnSettingsDropdown = ({
|
||||
export const ColumnSettingsDropdown = <T,>({
|
||||
column,
|
||||
setIsTableSettingsModalOpen,
|
||||
}: ColumnSettingsDropdownProps) => {
|
||||
}: ColumnSettingsDropdownProps<T>) => {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -1,28 +1,19 @@
|
||||
import { ColumnSettingsDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ColumnSettingsDropdown";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Column, Header, flexRender } from "@tanstack/react-table";
|
||||
import { Header, flexRender } from "@tanstack/react-table";
|
||||
import { GripVerticalIcon } from "lucide-react";
|
||||
import { CSSProperties } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TableHead } from "@formbricks/ui/Table";
|
||||
import { TableHead } from "../../Table";
|
||||
import { getCommonPinningStyles } from "../lib/utils";
|
||||
import { ColumnSettingsDropdown } from "./ColumnSettingsDropdown";
|
||||
|
||||
interface ResponseTableHeaderProps {
|
||||
header: Header<TResponseTableData, unknown>;
|
||||
interface DataTableHeaderProps<T> {
|
||||
header: Header<T, unknown>;
|
||||
setIsTableSettingsModalOpen: (isTableSettingsModalOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export const getCommonPinningStyles = (column: Column<TResponseTableData>): CSSProperties => {
|
||||
return {
|
||||
left: `${column.getStart("left") - 1}px`,
|
||||
position: "sticky",
|
||||
width: column.getSize(),
|
||||
zIndex: 1,
|
||||
};
|
||||
};
|
||||
|
||||
export const ResponseTableHeader = ({ header, setIsTableSettingsModalOpen }: ResponseTableHeaderProps) => {
|
||||
export const DataTableHeader = <T,>({ header, setIsTableSettingsModalOpen }: DataTableHeaderProps<T>) => {
|
||||
const { attributes, isDragging, listeners, setNodeRef, transform } = useSortable({
|
||||
id: header.column.id,
|
||||
});
|
||||
@@ -35,6 +26,7 @@ export const ResponseTableHeader = ({ header, setIsTableSettingsModalOpen }: Res
|
||||
whiteSpace: "nowrap",
|
||||
width: header.column.getSize(),
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
|
||||
...(header.column.id === "select" ? getCommonPinningStyles(header.column) : {}),
|
||||
};
|
||||
|
||||
@@ -44,7 +36,10 @@ export const ResponseTableHeader = ({ header, setIsTableSettingsModalOpen }: Res
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
key={header.id}
|
||||
className="group relative h-10 border border-slate-300 bg-slate-200 px-2 text-center">
|
||||
className={cn("group relative h-10 border-b border-slate-300 bg-white px-4 text-center", {
|
||||
"border-r": !header.column.getIsLastColumn(),
|
||||
"border-l": !header.column.getIsFirstColumn(),
|
||||
})}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="w-full truncate text-left font-semibold">
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { TableSettingsModalItem } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/TableSettingsModalItem";
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
@@ -10,27 +9,27 @@ import {
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import { TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { Modal } from "@formbricks/ui/Modal";
|
||||
import { Modal } from "../../Modal";
|
||||
import { DataTableSettingsModalItem } from "./DataTableSettingsModalItem";
|
||||
|
||||
interface TableSettingsModalProps {
|
||||
interface DataTableSettingsModalProps<T> {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
survey: TSurvey;
|
||||
table: Table<TResponseTableData>;
|
||||
table: Table<T>;
|
||||
columnOrder: string[];
|
||||
handleDragEnd: (event: DragEndEvent) => void;
|
||||
survey?: TSurvey;
|
||||
}
|
||||
|
||||
export const TableSettingsModal = ({
|
||||
export const DataTableSettingsModal = <T,>({
|
||||
open,
|
||||
setOpen,
|
||||
survey,
|
||||
table,
|
||||
columnOrder,
|
||||
handleDragEnd,
|
||||
}: TableSettingsModalProps) => {
|
||||
survey,
|
||||
}: DataTableSettingsModalProps<T>) => {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
@@ -63,10 +62,10 @@ export const TableSettingsModal = ({
|
||||
collisionDetection={closestCorners}>
|
||||
<SortableContext items={columnOrder} strategy={verticalListSortingStrategy}>
|
||||
{columnOrder.map((columnId) => {
|
||||
if (columnId === "select") return;
|
||||
if (columnId === "select" || columnId === "createdAt") return;
|
||||
const column = table.getAllColumns().find((column) => column.id === columnId);
|
||||
if (!column) return;
|
||||
return <TableSettingsModalItem column={column} key={column?.id} survey={survey} />;
|
||||
if (!column) return null;
|
||||
return <DataTableSettingsModalItem column={column} key={column.id} survey={survey} />;
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
@@ -1,20 +1,19 @@
|
||||
import { QUESTIONS_ICON_MAP } from "@/app/lib/questions";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Column } from "@tanstack/react-table";
|
||||
import { capitalize } from "lodash";
|
||||
import { GripVertical } from "lucide-react";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { TResponseTableData } from "@formbricks/types/responses";
|
||||
import { QUESTIONS_ICON_MAP } from "@formbricks/lib/utils/questions";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { Switch } from "@formbricks/ui/Switch";
|
||||
import { Switch } from "../../Switch";
|
||||
|
||||
interface TableSettingsModalItemProps {
|
||||
column: Column<TResponseTableData, unknown>;
|
||||
survey: TSurvey;
|
||||
interface DataTableSettingsModalItemProps<T> {
|
||||
column: Column<T, unknown>;
|
||||
survey?: TSurvey;
|
||||
}
|
||||
|
||||
export const TableSettingsModalItem = ({ column, survey }: TableSettingsModalItemProps) => {
|
||||
export const DataTableSettingsModalItem = <T,>({ column, survey }: DataTableSettingsModalItemProps<T>) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: column.id,
|
||||
});
|
||||
@@ -40,13 +39,14 @@ export const TableSettingsModalItem = ({ column, survey }: TableSettingsModalIte
|
||||
}
|
||||
};
|
||||
|
||||
const question = survey.questions.find((question) => question.id === column.id);
|
||||
const question = survey?.questions.find((question) => question.id === column.id);
|
||||
|
||||
const style = {
|
||||
transition: transition ?? "transform 100ms ease",
|
||||
transform: CSS.Translate.toString(transform),
|
||||
zIndex: isDragging ? 10 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} id={column.id}>
|
||||
<div {...listeners} {...attributes}>
|
||||
@@ -1,29 +1,30 @@
|
||||
import { SelectedResponseSettings } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/SelectedResponseSettings";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import { MoveVerticalIcon, SettingsIcon } from "lucide-react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TooltipRenderer } from "@formbricks/ui/Tooltip";
|
||||
import { TooltipRenderer } from "../../Tooltip";
|
||||
import { SelectedRowSettings } from "./SelectedRowSettings";
|
||||
|
||||
interface ResponseTableToolbarProps {
|
||||
interface DataTableToolbarProps<T> {
|
||||
setIsTableSettingsModalOpen: (isTableSettingsModalOpen: boolean) => void;
|
||||
setIsExpanded: (isExpanded: boolean) => void;
|
||||
isExpanded: boolean;
|
||||
table: Table<TResponseTableData>;
|
||||
deleteResponses: (responseIds: string[]) => void;
|
||||
table: Table<T>;
|
||||
deleteRows: (rowIds: string[]) => void;
|
||||
type: "person" | "response";
|
||||
}
|
||||
|
||||
export const ResponseTableToolbar = ({
|
||||
export const DataTableToolbar = <T,>({
|
||||
setIsExpanded,
|
||||
setIsTableSettingsModalOpen,
|
||||
isExpanded,
|
||||
table,
|
||||
deleteResponses,
|
||||
}: ResponseTableToolbarProps) => {
|
||||
deleteRows,
|
||||
type,
|
||||
}: DataTableToolbarProps<T>) => {
|
||||
return (
|
||||
<div className="sticky top-12 z-30 my-2 flex w-full items-center justify-between bg-slate-50 py-2">
|
||||
{table.getFilteredSelectedRowModel().rows.length > 0 ? (
|
||||
<SelectedResponseSettings table={table} deleteResponses={deleteResponses} />
|
||||
<SelectedRowSettings table={table} deleteRows={deleteRows} type={type} />
|
||||
) : (
|
||||
<div></div>
|
||||
)}
|
||||
@@ -2,16 +2,18 @@ import { Table } from "@tanstack/react-table";
|
||||
import { Trash2Icon } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TResponseTableData } from "@formbricks/types/responses";
|
||||
import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
|
||||
import { deleteResponseAction } from "@formbricks/ui/SingleResponseCard/actions";
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
|
||||
import { DeleteDialog } from "../../DeleteDialog";
|
||||
import { deleteResponseAction } from "../../SingleResponseCard/actions";
|
||||
import { deletePersonAction } from "../actions";
|
||||
|
||||
interface SelectedResponseSettingsProps {
|
||||
table: Table<TResponseTableData>;
|
||||
deleteResponses: (responseIds: string[]) => void;
|
||||
interface SelectedRowSettingsProps<T> {
|
||||
table: Table<T>;
|
||||
deleteRows: (rowId: string[]) => void;
|
||||
type: "response" | "person";
|
||||
}
|
||||
|
||||
export const SelectedResponseSettings = ({ table, deleteResponses }: SelectedResponseSettingsProps) => {
|
||||
export const SelectedRowSettings = <T,>({ table, deleteRows, type }: SelectedRowSettingsProps<T>) => {
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
@@ -25,17 +27,26 @@ export const SelectedResponseSettings = ({ table, deleteResponses }: SelectedRes
|
||||
[table]
|
||||
);
|
||||
|
||||
// Handle deletion of responses
|
||||
const handleDeleteResponses = async () => {
|
||||
// Handle deletion
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
const rowsToBeDeleted = table.getFilteredSelectedRowModel().rows.map((row) => row.id);
|
||||
await Promise.all(rowsToBeDeleted.map((responseId) => deleteResponseAction({ responseId })));
|
||||
|
||||
deleteResponses(rowsToBeDeleted);
|
||||
toast.success("Responses deleted successfully");
|
||||
if (type === "response") {
|
||||
await Promise.all(rowsToBeDeleted.map((responseId) => deleteResponseAction({ responseId })));
|
||||
} else if (type === "person") {
|
||||
await Promise.all(rowsToBeDeleted.map((personId) => deletePersonAction({ personId })));
|
||||
}
|
||||
|
||||
deleteRows(rowsToBeDeleted);
|
||||
toast.success(`${capitalizeFirstLetter(type)}s deleted successfully`);
|
||||
} catch (error) {
|
||||
toast.error(error.message || "An error occurred while deleting responses");
|
||||
if (error instanceof Error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.error(`An unknown error occurred while deleting ${type}s`);
|
||||
}
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setIsDeleteDialogOpen(false);
|
||||
@@ -54,7 +65,9 @@ export const SelectedResponseSettings = ({ table, deleteResponses }: SelectedRes
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-2 rounded-md bg-slate-900 p-1 px-2 text-xs text-white">
|
||||
<div>{selectedRowCount} responses selected</div>
|
||||
<div>
|
||||
{selectedRowCount} {type}s selected
|
||||
</div>
|
||||
<Separator />
|
||||
<SelectableOption label="Select all" onClick={() => handleToggleAllRowsSelection(true)} />
|
||||
<Separator />
|
||||
@@ -68,8 +81,8 @@ export const SelectedResponseSettings = ({ table, deleteResponses }: SelectedRes
|
||||
<DeleteDialog
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setIsDeleteDialogOpen}
|
||||
deleteWhat="response"
|
||||
onDelete={handleDeleteResponses}
|
||||
deleteWhat={type}
|
||||
onDelete={handleDelete}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
31
packages/ui/DataTable/components/SelectionColumn.tsx
Normal file
31
packages/ui/DataTable/components/SelectionColumn.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Checkbox } from "../../Checkbox";
|
||||
|
||||
export function getSelectionColumn<T extends object>(): ColumnDef<T, unknown> {
|
||||
return {
|
||||
id: "select",
|
||||
accessorKey: "select",
|
||||
size: 60,
|
||||
enableResizing: false,
|
||||
header: ({ table }) => (
|
||||
<div className="flex w-full items-center justify-center pr-4">
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex w-full items-center justify-center pr-4">
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
}
|
||||
6
packages/ui/DataTable/index.tsx
Normal file
6
packages/ui/DataTable/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { DataTableHeader } from "./components/DataTableHeader";
|
||||
import { DataTableSettingsModal } from "./components/DataTableSettingsModal";
|
||||
import { DataTableToolbar } from "./components/DataTableToolbar";
|
||||
import { getSelectionColumn } from "./components/SelectionColumn";
|
||||
|
||||
export { DataTableToolbar, DataTableHeader, DataTableSettingsModal, getSelectionColumn };
|
||||
11
packages/ui/DataTable/lib/utils.ts
Normal file
11
packages/ui/DataTable/lib/utils.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Column } from "@tanstack/react-table";
|
||||
import { CSSProperties } from "react";
|
||||
|
||||
export const getCommonPinningStyles = <T>(column: Column<T>): CSSProperties => {
|
||||
return {
|
||||
left: `${column.getStart("left") - 1}px`,
|
||||
position: "sticky",
|
||||
width: column.getSize(),
|
||||
zIndex: 1,
|
||||
};
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { cn } from "@formbricks/lib/cn";
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto rounded-lg">
|
||||
<div className="relative overflow-auto">
|
||||
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user