♻️ (results) Introduce tRPC and use it for the results
This commit is contained in:
@ -31,7 +31,12 @@
|
||||
"@googleapis/drive": "4.0.0",
|
||||
"@sentry/nextjs": "7.19.0",
|
||||
"@stripe/stripe-js": "1.44.1",
|
||||
"@tanstack/react-query": "^4.16.1",
|
||||
"@tanstack/react-table": "8.5.28",
|
||||
"@trpc/client": "10.0.0-rc.8",
|
||||
"@trpc/next": "10.0.0-rc.8",
|
||||
"@trpc/react-query": "10.0.0-rc.8",
|
||||
"@trpc/server": "10.0.0-rc.8",
|
||||
"@udecode/plate-basic-marks": "18.9.0",
|
||||
"@udecode/plate-common": "^7.0.2",
|
||||
"@udecode/plate-core": "18.9.0",
|
||||
@ -80,6 +85,7 @@
|
||||
"svg-round-corners": "0.4.1",
|
||||
"swr": "1.3.0",
|
||||
"tinycolor2": "1.4.2",
|
||||
"trpc-openapi": "1.0.0-alpha.4",
|
||||
"typebot-js": "workspace:*",
|
||||
"use-debounce": "8.0.4"
|
||||
},
|
||||
@ -110,7 +116,10 @@
|
||||
"eslint-plugin-react": "7.31.10",
|
||||
"models": "workspace:*",
|
||||
"next-transpile-modules": "10.0.0",
|
||||
"superjson": "^1.11.0",
|
||||
"tsconfig": "workspace:*",
|
||||
"typescript": "4.8.4",
|
||||
"utils": "workspace:*"
|
||||
"utils": "workspace:*",
|
||||
"zod": "3.19.1"
|
||||
}
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ export const useAnswersCount = ({
|
||||
{ answersCounts: AnswersCount[] },
|
||||
Error
|
||||
>(
|
||||
typebotId ? `/api/typebots/${typebotId}/results/answers/count` : null,
|
||||
typebotId ? `/api/typebots/${typebotId}/analytics/answersCount` : null,
|
||||
fetcher
|
||||
)
|
||||
if (error) onError(error)
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { BoxProps, Flex } from '@chakra-ui/react'
|
||||
import { BoxProps, Flex, useEventListener } from '@chakra-ui/react'
|
||||
import { useGraph, useGroupsCoordinates } from '../../providers'
|
||||
import { Source } from 'models'
|
||||
import React, { MouseEvent, useEffect, useRef, useState } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
|
||||
export const SourceEndpoint = ({
|
||||
source,
|
||||
@ -15,11 +15,6 @@ export const SourceEndpoint = ({
|
||||
const { groupsCoordinates } = useGroupsCoordinates()
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const handleMouseDown = (e: MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation()
|
||||
setConnectingIds({ source })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (ranOnce || !ref.current || Object.keys(groupsCoordinates).length === 0)
|
||||
return
|
||||
@ -32,6 +27,23 @@ export const SourceEndpoint = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ref.current, groupsCoordinates])
|
||||
|
||||
useEventListener(
|
||||
'pointerdown',
|
||||
(e) => {
|
||||
e.stopPropagation()
|
||||
setConnectingIds({ source })
|
||||
},
|
||||
ref.current
|
||||
)
|
||||
|
||||
useEventListener(
|
||||
'mousedown',
|
||||
(e) => {
|
||||
e.stopPropagation()
|
||||
},
|
||||
ref.current
|
||||
)
|
||||
|
||||
if (!groupsCoordinates) return <></>
|
||||
return (
|
||||
<Flex
|
||||
@ -39,13 +51,10 @@ export const SourceEndpoint = ({
|
||||
data-testid="endpoint"
|
||||
boxSize="32px"
|
||||
rounded="full"
|
||||
onPointerDownCapture={handleMouseDown}
|
||||
onMouseDownCapture={(e) => e.stopPropagation()}
|
||||
cursor="copy"
|
||||
justify="center"
|
||||
align="center"
|
||||
pointerEvents="all"
|
||||
className="prevent-group-drag"
|
||||
{...props}
|
||||
>
|
||||
<Flex
|
||||
@ -54,7 +63,6 @@ export const SourceEndpoint = ({
|
||||
align="center"
|
||||
bgColor="gray.100"
|
||||
rounded="full"
|
||||
pointerEvents="none"
|
||||
>
|
||||
<Flex
|
||||
boxSize="13px"
|
||||
|
@ -4,6 +4,7 @@ import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
useDisclosure,
|
||||
useEventListener,
|
||||
} from '@chakra-ui/react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
@ -152,6 +153,8 @@ export const BlockNode = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [openedBlockId])
|
||||
|
||||
useEventListener('pointerdown', (e) => e.stopPropagation(), blockRef.current)
|
||||
|
||||
return isEditing && isTextBubbleBlock(block) ? (
|
||||
<TextBubbleEditor
|
||||
id={block.id}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import { ResultHeaderCell, ResultWithAnswers } from 'models'
|
||||
import { createContext, ReactNode, useContext, useMemo } from 'react'
|
||||
import { KeyedMutator } from 'swr'
|
||||
import { parseResultHeader } from 'utils'
|
||||
import { useTypebot } from '../editor/providers/TypebotProvider'
|
||||
import { useResultsQuery } from './hooks/useResultsQuery'
|
||||
@ -10,42 +10,37 @@ import { convertResultsToTableData } from './utils'
|
||||
const resultsContext = createContext<{
|
||||
resultsList: { results: ResultWithAnswers[] }[] | undefined
|
||||
flatResults: ResultWithAnswers[]
|
||||
hasMore: boolean
|
||||
hasNextPage: boolean
|
||||
resultHeader: ResultHeaderCell[]
|
||||
totalResults: number
|
||||
tableData: TableData[]
|
||||
onDeleteResults: (totalResultsDeleted: number) => void
|
||||
fetchMore: () => void
|
||||
mutate: KeyedMutator<
|
||||
{
|
||||
results: ResultWithAnswers[]
|
||||
}[]
|
||||
>
|
||||
fetchNextPage: () => void
|
||||
refetchResults: () => void
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
}>({})
|
||||
|
||||
export const ResultsProvider = ({
|
||||
children,
|
||||
workspaceId,
|
||||
typebotId,
|
||||
totalResults,
|
||||
onDeleteResults,
|
||||
}: {
|
||||
children: ReactNode
|
||||
workspaceId: string
|
||||
typebotId: string
|
||||
totalResults: number
|
||||
onDeleteResults: (totalResultsDeleted: number) => void
|
||||
}) => {
|
||||
const { publishedTypebot, linkedTypebots } = useTypebot()
|
||||
const { data, mutate, setSize, hasMore } = useResultsQuery({
|
||||
workspaceId,
|
||||
const { showToast } = useToast()
|
||||
const { data, fetchNextPage, hasNextPage, refetch } = useResultsQuery({
|
||||
typebotId,
|
||||
onError: (error) => {
|
||||
showToast({ description: error })
|
||||
},
|
||||
})
|
||||
|
||||
const fetchMore = () => setSize((state) => state + 1)
|
||||
|
||||
const resultHeader = useMemo(
|
||||
() =>
|
||||
publishedTypebot
|
||||
@ -70,13 +65,13 @@ export const ResultsProvider = ({
|
||||
value={{
|
||||
resultsList: data,
|
||||
flatResults: data?.flatMap((d) => d.results) ?? [],
|
||||
hasMore: hasMore ?? true,
|
||||
hasNextPage: hasNextPage ?? true,
|
||||
tableData,
|
||||
resultHeader,
|
||||
totalResults,
|
||||
onDeleteResults,
|
||||
fetchMore,
|
||||
mutate,
|
||||
fetchNextPage,
|
||||
refetchResults: refetch,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
@ -3,53 +3,51 @@ import { canWriteTypebot } from '@/utils/api/dbRules'
|
||||
import { deleteFiles } from '@/utils/api/storage'
|
||||
import { User, Prisma } from 'db'
|
||||
import { InputBlockType, Typebot } from 'models'
|
||||
import { NextApiResponse } from 'next'
|
||||
import { forbidden } from 'utils/api'
|
||||
|
||||
export const archiveResults =
|
||||
(res: NextApiResponse) =>
|
||||
async ({
|
||||
typebotId,
|
||||
user,
|
||||
resultsFilter,
|
||||
}: {
|
||||
typebotId: string
|
||||
user: User
|
||||
resultsFilter?: Prisma.ResultWhereInput
|
||||
}) => {
|
||||
const typebot = await prisma.typebot.findFirst({
|
||||
where: canWriteTypebot(typebotId, user),
|
||||
select: { groups: true },
|
||||
export const archiveResults = async ({
|
||||
typebotId,
|
||||
user,
|
||||
resultsFilter,
|
||||
}: {
|
||||
typebotId: string
|
||||
user: User
|
||||
resultsFilter?: Prisma.ResultWhereInput
|
||||
}) => {
|
||||
const typebot = await prisma.typebot.findFirst({
|
||||
where: canWriteTypebot(typebotId, user),
|
||||
select: { groups: true },
|
||||
})
|
||||
if (!typebot) return { success: false }
|
||||
const fileUploadBlockIds = (typebot as Typebot).groups
|
||||
.flatMap((g) => g.blocks)
|
||||
.filter((b) => b.type === InputBlockType.FILE)
|
||||
.map((b) => b.id)
|
||||
if (fileUploadBlockIds.length > 0) {
|
||||
const filesToDelete = await prisma.answer.findMany({
|
||||
where: { result: resultsFilter, blockId: { in: fileUploadBlockIds } },
|
||||
})
|
||||
if (!typebot) return forbidden(res)
|
||||
const fileUploadBlockIds = (typebot as Typebot).groups
|
||||
.flatMap((g) => g.blocks)
|
||||
.filter((b) => b.type === InputBlockType.FILE)
|
||||
.map((b) => b.id)
|
||||
if (fileUploadBlockIds.length > 0) {
|
||||
const filesToDelete = await prisma.answer.findMany({
|
||||
where: { result: resultsFilter, blockId: { in: fileUploadBlockIds } },
|
||||
if (filesToDelete.length > 0)
|
||||
await deleteFiles({
|
||||
urls: filesToDelete.flatMap((a) => a.content.split(', ')),
|
||||
})
|
||||
if (filesToDelete.length > 0)
|
||||
await deleteFiles({
|
||||
urls: filesToDelete.flatMap((a) => a.content.split(', ')),
|
||||
})
|
||||
}
|
||||
await prisma.log.deleteMany({
|
||||
where: {
|
||||
result: resultsFilter,
|
||||
},
|
||||
})
|
||||
await prisma.answer.deleteMany({
|
||||
where: {
|
||||
result: resultsFilter,
|
||||
},
|
||||
})
|
||||
await prisma.result.updateMany({
|
||||
where: resultsFilter,
|
||||
data: {
|
||||
isArchived: true,
|
||||
variables: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
await prisma.log.deleteMany({
|
||||
where: {
|
||||
result: resultsFilter,
|
||||
},
|
||||
})
|
||||
await prisma.answer.deleteMany({
|
||||
where: {
|
||||
result: resultsFilter,
|
||||
},
|
||||
})
|
||||
await prisma.result.updateMany({
|
||||
where: resultsFilter,
|
||||
data: {
|
||||
isArchived: true,
|
||||
variables: [],
|
||||
},
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
@ -1 +1,2 @@
|
||||
export * from './archiveResults'
|
||||
export * from './router'
|
||||
|
@ -0,0 +1,39 @@
|
||||
import { canWriteTypebot } from '@/utils/api/dbRules'
|
||||
import { authenticatedProcedure } from '@/utils/server/trpc'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { z } from 'zod'
|
||||
import { archiveResults } from '../archiveResults'
|
||||
|
||||
export const deleteResultsProcedure = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'DELETE',
|
||||
path: '/typebots/{typebotId}/results',
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
typebotId: z.string(),
|
||||
ids: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.output(z.void())
|
||||
.mutation(async ({ input, ctx: { user } }) => {
|
||||
const idsArray = input.ids?.split(',')
|
||||
const { typebotId } = input
|
||||
const { success } = await archiveResults({
|
||||
typebotId,
|
||||
user,
|
||||
resultsFilter: {
|
||||
id: (idsArray?.length ?? 0) > 0 ? { in: idsArray } : undefined,
|
||||
typebot: canWriteTypebot(typebotId, user),
|
||||
},
|
||||
})
|
||||
|
||||
if (!success)
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Typebot not found',
|
||||
})
|
||||
})
|
@ -0,0 +1,30 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import { canReadTypebot } from '@/utils/api/dbRules'
|
||||
import { authenticatedProcedure } from '@/utils/server/trpc'
|
||||
import { logSchema } from 'models'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const getResultLogsProcedure = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/typebots/{typebotId}/results/{resultId}/logs',
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
typebotId: z.string(),
|
||||
resultId: z.string(),
|
||||
})
|
||||
)
|
||||
.output(z.object({ logs: z.array(logSchema) }))
|
||||
.query(async ({ input: { typebotId, resultId }, ctx: { user } }) => {
|
||||
const logs = await prisma.log.findMany({
|
||||
where: {
|
||||
result: { id: resultId, typebot: canReadTypebot(typebotId, user) },
|
||||
},
|
||||
})
|
||||
|
||||
return { logs }
|
||||
})
|
@ -0,0 +1,59 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import { canReadTypebot } from '@/utils/api/dbRules'
|
||||
import { authenticatedProcedure } from '@/utils/server/trpc'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { ResultWithAnswers, resultWithAnswersSchema } from 'models'
|
||||
import { z } from 'zod'
|
||||
|
||||
const maxLimit = 200
|
||||
|
||||
export const getResultsProcedure = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/typebots/{typebotId}/results',
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
typebotId: z.string(),
|
||||
limit: z.string().regex(/^[0-9]{1,3}$/),
|
||||
cursor: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
results: z.array(resultWithAnswersSchema),
|
||||
nextCursor: z.string().nullish(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input, ctx: { user } }) => {
|
||||
const limit = Number(input.limit)
|
||||
if (limit < 1 || limit > maxLimit)
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'limit must be between 1 and 200',
|
||||
})
|
||||
const { cursor } = input
|
||||
const results = (await prisma.result.findMany({
|
||||
take: limit + 1,
|
||||
cursor: cursor ? { id: cursor } : undefined,
|
||||
where: {
|
||||
typebot: canReadTypebot(input.typebotId, user),
|
||||
answers: { some: {} },
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
include: { answers: true },
|
||||
})) as ResultWithAnswers[]
|
||||
|
||||
let nextCursor: typeof cursor | undefined
|
||||
if (results.length > limit) {
|
||||
const nextResult = results.pop()
|
||||
nextCursor = nextResult?.id
|
||||
}
|
||||
|
||||
return { results, nextCursor }
|
||||
})
|
@ -0,0 +1,3 @@
|
||||
export * from './deleteResultsProcedure'
|
||||
export * from './getResultLogsProcedure'
|
||||
export * from './getResultsProcedure'
|
12
apps/builder/src/features/results/api/router.ts
Normal file
12
apps/builder/src/features/results/api/router.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { router } from '@/utils/server/trpc'
|
||||
import {
|
||||
deleteResultsProcedure,
|
||||
getResultLogsProcedure,
|
||||
getResultsProcedure,
|
||||
} from './procedures'
|
||||
|
||||
export const resultsRouter = router({
|
||||
getResults: getResultsProcedure,
|
||||
deleteResults: deleteResultsProcedure,
|
||||
getResultLogs: getResultLogsProcedure,
|
||||
})
|
@ -27,7 +27,7 @@ type Props = {
|
||||
onClose: () => void
|
||||
}
|
||||
export const LogsModal = ({ typebotId, resultId, onClose }: Props) => {
|
||||
const { isLoading, logs } = useLogs(typebotId, resultId ?? undefined)
|
||||
const { isLoading, logs } = useLogs(typebotId, resultId)
|
||||
|
||||
return (
|
||||
<Modal isOpen={isDefined(resultId)} onClose={onClose} size="xl">
|
||||
|
@ -160,7 +160,6 @@ export const ResultsPage = () => {
|
||||
<AnalyticsGraphContainer stats={stats} />
|
||||
) : (
|
||||
<ResultsProvider
|
||||
workspaceId={workspace.id}
|
||||
typebotId={publishedTypebot.typebotId}
|
||||
totalResults={stats?.totalStarts ?? 0}
|
||||
onDeleteResults={handleDeletedResults}
|
||||
|
@ -9,6 +9,7 @@ type Props = {
|
||||
size: number
|
||||
isExpandButtonVisible: boolean
|
||||
cellIndex: number
|
||||
isSelected: boolean
|
||||
onExpandButtonClick: () => void
|
||||
}
|
||||
|
||||
@ -63,5 +64,6 @@ export default memo(
|
||||
Cell,
|
||||
(prev, next) =>
|
||||
prev.size === next.size &&
|
||||
prev.isExpandButtonVisible === next.isExpandButtonVisible
|
||||
prev.isExpandButtonVisible === next.isExpandButtonVisible &&
|
||||
prev.isSelected === next.isSelected
|
||||
)
|
||||
|
@ -13,10 +13,10 @@ import { useTypebot } from '@/features/editor'
|
||||
import { unparse } from 'papaparse'
|
||||
import React, { useState } from 'react'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import { getAllResultsQuery } from '../../queries/getAllResultsQuery'
|
||||
import { convertResultsToTableData } from '../../utils'
|
||||
import { deleteResultsQuery } from '../../queries/deleteResultsQuery'
|
||||
import { useResults } from '../../ResultsProvider'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
|
||||
type ResultsActionButtonsProps = {
|
||||
selectedResultsId: string[]
|
||||
@ -31,10 +31,8 @@ export const ResultsActionButtons = ({
|
||||
const { typebot } = useTypebot()
|
||||
const { showToast } = useToast()
|
||||
const {
|
||||
resultsList: data,
|
||||
flatResults: results,
|
||||
resultHeader,
|
||||
mutate,
|
||||
totalResults,
|
||||
tableData,
|
||||
onDeleteResults,
|
||||
@ -42,14 +40,44 @@ export const ResultsActionButtons = ({
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false)
|
||||
const [isExportLoading, setIsExportLoading] = useState(false)
|
||||
const trpcContext = trpc.useContext()
|
||||
const deleteResultsMutation = trpc.results.deleteResults.useMutation({
|
||||
onMutate: () => {
|
||||
setIsDeleteLoading(true)
|
||||
},
|
||||
onError: (error) => showToast({ description: error.message }),
|
||||
onSuccess: async () => {
|
||||
await trpcContext.results.getResults.invalidate()
|
||||
},
|
||||
onSettled: () => {
|
||||
onDeleteResults(selectedResultsId.length)
|
||||
onClearSelection()
|
||||
setIsDeleteLoading(false)
|
||||
},
|
||||
})
|
||||
|
||||
const workspaceId = typebot?.workspaceId
|
||||
const typebotId = typebot?.id
|
||||
|
||||
const getAllTableData = async () => {
|
||||
if (!workspaceId || !typebotId) return []
|
||||
const results = await getAllResultsQuery(workspaceId, typebotId)
|
||||
return convertResultsToTableData(results, resultHeader)
|
||||
const allResults = []
|
||||
let cursor: string | undefined | null
|
||||
do {
|
||||
try {
|
||||
const { results, nextCursor } =
|
||||
await trpcContext.results.getResults.fetch({
|
||||
typebotId,
|
||||
limit: '200',
|
||||
})
|
||||
allResults.push(...results)
|
||||
cursor = nextCursor
|
||||
} catch (error) {
|
||||
showToast({ description: (error as TRPCError).message })
|
||||
}
|
||||
} while (cursor)
|
||||
|
||||
return convertResultsToTableData(allResults, resultHeader)
|
||||
}
|
||||
|
||||
const totalSelected =
|
||||
@ -59,27 +87,13 @@ export const ResultsActionButtons = ({
|
||||
|
||||
const deleteResults = async () => {
|
||||
if (!workspaceId || !typebotId) return
|
||||
setIsDeleteLoading(true)
|
||||
const { error } = await deleteResultsQuery(
|
||||
workspaceId,
|
||||
deleteResultsMutation.mutate({
|
||||
typebotId,
|
||||
totalSelected === totalResults ? [] : selectedResultsId
|
||||
)
|
||||
if (error) showToast({ description: error.message, title: error.name })
|
||||
else {
|
||||
mutate(
|
||||
ids:
|
||||
totalSelected === totalResults
|
||||
? []
|
||||
: data?.map((d) => ({
|
||||
results: d.results.filter(
|
||||
(r) => !selectedResultsId.includes(r.id)
|
||||
),
|
||||
}))
|
||||
)
|
||||
}
|
||||
onDeleteResults(selectedResultsId.length)
|
||||
onClearSelection()
|
||||
setIsDeleteLoading(false)
|
||||
? undefined
|
||||
: selectedResultsId.join(','),
|
||||
})
|
||||
}
|
||||
|
||||
const exportResultsToCSV = async () => {
|
||||
|
@ -247,9 +247,10 @@ const IndeterminateCheckbox = React.forwardRef(
|
||||
const resolvedRef: any = ref || defaultRef
|
||||
|
||||
return (
|
||||
<Flex justify="center" data-testid="checkbox" {...rest}>
|
||||
<Flex justify="center" data-testid="checkbox">
|
||||
<Checkbox
|
||||
ref={resolvedRef}
|
||||
{...rest}
|
||||
isIndeterminate={indeterminate}
|
||||
isChecked={checked}
|
||||
/>
|
||||
|
@ -10,7 +10,12 @@ type Props = {
|
||||
onExpandButtonClick: () => void
|
||||
}
|
||||
|
||||
export const Row = ({ row, bottomElement, onExpandButtonClick }: Props) => {
|
||||
export const Row = ({
|
||||
row,
|
||||
bottomElement,
|
||||
onExpandButtonClick,
|
||||
isSelected,
|
||||
}: Props) => {
|
||||
const [isExpandButtonVisible, setIsExpandButtonVisible] = useState(false)
|
||||
|
||||
const showExpandButton = () => setIsExpandButtonVisible(true)
|
||||
@ -35,6 +40,7 @@ export const Row = ({ row, bottomElement, onExpandButtonClick }: Props) => {
|
||||
isExpandButtonVisible={isExpandButtonVisible}
|
||||
cellIndex={cellIndex}
|
||||
onExpandButtonClick={onExpandButtonClick}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
|
@ -9,8 +9,8 @@ import { SubmissionsTable } from './ResultsTable'
|
||||
export const ResultsTableContainer = () => {
|
||||
const {
|
||||
flatResults: results,
|
||||
fetchMore,
|
||||
hasMore,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
resultHeader,
|
||||
tableData,
|
||||
} = useResults()
|
||||
@ -60,8 +60,8 @@ export const ResultsTableContainer = () => {
|
||||
preferences={typebot.resultsTablePreferences}
|
||||
resultHeader={resultHeader}
|
||||
data={tableData}
|
||||
onScrollToBottom={fetchMore}
|
||||
hasMore={hasMore}
|
||||
onScrollToBottom={fetchNextPage}
|
||||
hasMore={hasNextPage}
|
||||
onLogOpenIndex={handleLogOpenIndex}
|
||||
onResultExpandIndex={handleResultExpandIndex}
|
||||
/>
|
||||
|
1
apps/builder/src/features/results/components/index.ts
Normal file
1
apps/builder/src/features/results/components/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './ResultsPage'
|
@ -1,17 +1,19 @@
|
||||
import { fetcher } from '@/utils/helpers'
|
||||
import { Log } from 'db'
|
||||
import useSWR from 'swr'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { isDefined } from '@udecode/plate-common'
|
||||
|
||||
export const useLogs = (
|
||||
typebotId: string,
|
||||
resultId?: string,
|
||||
onError?: (e: Error) => void
|
||||
resultId: string | null,
|
||||
onError?: (error: string) => void
|
||||
) => {
|
||||
const { data, error } = useSWR<{ logs: Log[] }>(
|
||||
resultId ? `/api/typebots/${typebotId}/results/${resultId}/logs` : null,
|
||||
fetcher
|
||||
const { data, error } = trpc.results.getResultLogs.useQuery(
|
||||
{
|
||||
resultId: resultId ?? '',
|
||||
typebotId,
|
||||
},
|
||||
{ enabled: isDefined(resultId) }
|
||||
)
|
||||
if (error && onError) onError(error)
|
||||
if (error && onError) onError(error.message)
|
||||
return {
|
||||
logs: data?.logs,
|
||||
isLoading: !error && !data,
|
||||
|
@ -1,64 +1,29 @@
|
||||
import { fetcher } from '@/utils/helpers'
|
||||
import { ResultWithAnswers } from 'models'
|
||||
import { env } from 'utils'
|
||||
import useSWRInfinite from 'swr/infinite'
|
||||
|
||||
const paginationLimit = 50
|
||||
import { trpc } from '@/lib/trpc'
|
||||
|
||||
export const useResultsQuery = ({
|
||||
workspaceId,
|
||||
typebotId,
|
||||
onError,
|
||||
}: {
|
||||
workspaceId: string
|
||||
typebotId: string
|
||||
onError?: (error: Error) => void
|
||||
onError?: (error: string) => void
|
||||
}) => {
|
||||
const { data, error, mutate, setSize, size, isValidating } = useSWRInfinite<
|
||||
{ results: ResultWithAnswers[] },
|
||||
Error
|
||||
>(
|
||||
(
|
||||
pageIndex: number,
|
||||
previousPageData: {
|
||||
results: ResultWithAnswers[]
|
||||
const { data, error, fetchNextPage, hasNextPage, refetch } =
|
||||
trpc.results.getResults.useInfiniteQuery(
|
||||
{
|
||||
typebotId,
|
||||
limit: '50',
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
}
|
||||
) => getKey(workspaceId, typebotId, pageIndex, previousPageData),
|
||||
fetcher,
|
||||
{
|
||||
revalidateAll: true,
|
||||
dedupingInterval: env('E2E_TEST') === 'true' ? 0 : undefined,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
if (error && onError) onError(error)
|
||||
if (error && onError) onError(error.message)
|
||||
return {
|
||||
data,
|
||||
data: data?.pages,
|
||||
isLoading: !error && !data,
|
||||
mutate,
|
||||
setSize,
|
||||
size,
|
||||
hasMore:
|
||||
isValidating ||
|
||||
(data &&
|
||||
data.length > 0 &&
|
||||
data[data.length - 1].results.length > 0 &&
|
||||
data.length === paginationLimit),
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
|
||||
const getKey = (
|
||||
workspaceId: string,
|
||||
typebotId: string,
|
||||
pageIndex: number,
|
||||
previousPageData: {
|
||||
results: ResultWithAnswers[]
|
||||
}
|
||||
) => {
|
||||
if (previousPageData && previousPageData.results.length === 0) return null
|
||||
if (pageIndex === 0)
|
||||
return `/api/typebots/${typebotId}/results?limit=50&workspaceId=${workspaceId}`
|
||||
return `/api/typebots/${typebotId}/results?lastResultId=${
|
||||
previousPageData.results[previousPageData.results.length - 1].id
|
||||
}&limit=${paginationLimit}&workspaceId=${workspaceId}`
|
||||
}
|
||||
|
@ -10,7 +10,7 @@ export const useStats = ({
|
||||
onError: (error: Error) => void
|
||||
}) => {
|
||||
const { data, error, mutate } = useSWR<{ stats: Stats }, Error>(
|
||||
typebotId ? `/api/typebots/${typebotId}/results/stats` : null,
|
||||
typebotId ? `/api/typebots/${typebotId}/analytics/stats` : null,
|
||||
fetcher
|
||||
)
|
||||
if (error) onError(error)
|
||||
|
@ -1 +1 @@
|
||||
export { ResultsPage } from './components/ResultsPage'
|
||||
export * from './components'
|
||||
|
@ -1,19 +0,0 @@
|
||||
import { stringify } from 'qs'
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const deleteResultsQuery = async (
|
||||
workspaceId: string,
|
||||
typebotId: string,
|
||||
ids: string[]
|
||||
) => {
|
||||
const params = stringify({
|
||||
workspaceId,
|
||||
})
|
||||
return sendRequest({
|
||||
url: `/api/typebots/${typebotId}/results?${params}`,
|
||||
method: 'DELETE',
|
||||
body: {
|
||||
ids,
|
||||
},
|
||||
})
|
||||
}
|
@ -1,29 +0,0 @@
|
||||
import { ResultWithAnswers } from 'models'
|
||||
import { stringify } from 'qs'
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const getAllResultsQuery = async (
|
||||
workspaceId: string,
|
||||
typebotId: string
|
||||
) => {
|
||||
const results = []
|
||||
let hasMore = true
|
||||
let lastResultId: string | undefined = undefined
|
||||
do {
|
||||
const query = stringify({ limit: 200, lastResultId, workspaceId })
|
||||
const { data, error } = await sendRequest<{ results: ResultWithAnswers[] }>(
|
||||
{
|
||||
url: `/api/typebots/${typebotId}/results?${query}`,
|
||||
method: 'GET',
|
||||
}
|
||||
)
|
||||
if (error) {
|
||||
console.error(error)
|
||||
break
|
||||
}
|
||||
results.push(...(data?.results ?? []))
|
||||
lastResultId = results[results.length - 1]?.id as string | undefined
|
||||
if (data?.results.length === 0) hasMore = false
|
||||
} while (hasMore)
|
||||
return results
|
||||
}
|
@ -12,17 +12,13 @@ import { HeaderCell, TableData } from './types'
|
||||
import { CodeIcon, CalendarIcon, FileIcon } from '@/components/icons'
|
||||
import { TextLink } from '@/components/TextLink'
|
||||
|
||||
export const parseDateToReadable = (dateStr: string): string => {
|
||||
const date = new Date(dateStr)
|
||||
return (
|
||||
date.toDateString().split(' ').slice(1, 3).join(' ') +
|
||||
', ' +
|
||||
date.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
)
|
||||
}
|
||||
export const parseDateToReadable = (date: Date): string =>
|
||||
date.toDateString().split(' ').slice(1, 3).join(' ') +
|
||||
', ' +
|
||||
date.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
export const parseSubmissionsColumns = (
|
||||
resultHeader: ResultHeaderCell[]
|
||||
|
21
apps/builder/src/lib/trpc.ts
Normal file
21
apps/builder/src/lib/trpc.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { httpBatchLink } from '@trpc/client'
|
||||
import { createTRPCNext } from '@trpc/next'
|
||||
import type { AppRouter } from '../utils/server/routers/_app'
|
||||
import superjson from 'superjson'
|
||||
|
||||
const getBaseUrl = () =>
|
||||
typeof window !== 'undefined' ? '' : process.env.NEXTAUTH_URL
|
||||
|
||||
export const trpc = createTRPCNext<AppRouter>({
|
||||
config() {
|
||||
return {
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
}),
|
||||
],
|
||||
transformer: superjson,
|
||||
}
|
||||
},
|
||||
ssr: true,
|
||||
})
|
@ -17,6 +17,7 @@ import { WorkspaceProvider } from '@/features/workspace'
|
||||
import { toTitleCase } from 'utils'
|
||||
import { Session } from 'next-auth'
|
||||
import { Plan } from 'db'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
|
||||
const { ToastContainer, toast } = createStandaloneToast(customTheme)
|
||||
|
||||
@ -72,4 +73,4 @@ const App = ({
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
export default trpc.withTRPC(App)
|
||||
|
8
apps/builder/src/pages/api/[...trpc].ts
Normal file
8
apps/builder/src/pages/api/[...trpc].ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { createContext } from '@/utils/server/context'
|
||||
import { appRouter } from '@/utils/server/routers/_app'
|
||||
import { createOpenApiNextHandler } from 'trpc-openapi'
|
||||
|
||||
export default createOpenApiNextHandler({
|
||||
router: appRouter,
|
||||
createContext,
|
||||
})
|
8
apps/builder/src/pages/api/trpc/[trpc].ts
Normal file
8
apps/builder/src/pages/api/trpc/[trpc].ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { createContext } from '@/utils/server/context'
|
||||
import { appRouter } from '@/utils/server/routers/_app'
|
||||
import { createNextApiHandler } from '@trpc/server/adapters/next'
|
||||
|
||||
export default createNextApiHandler({
|
||||
router: appRouter,
|
||||
createContext,
|
||||
})
|
@ -39,11 +39,12 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
await archiveResults(res)({
|
||||
const { success } = await archiveResults({
|
||||
typebotId,
|
||||
user,
|
||||
resultsFilter: { typebotId },
|
||||
})
|
||||
if (!success) return res.status(500).send({ success: false })
|
||||
await prisma.publicTypebot.deleteMany({
|
||||
where: { typebot: canWriteTypebot(typebotId, user) },
|
||||
})
|
||||
|
@ -1,67 +0,0 @@
|
||||
import { withSentry } from '@sentry/nextjs'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { canReadTypebot, canWriteTypebot } from '@/utils/api/dbRules'
|
||||
import {
|
||||
badRequest,
|
||||
forbidden,
|
||||
methodNotAllowed,
|
||||
notAuthenticated,
|
||||
} from 'utils/api'
|
||||
import { getAuthenticatedUser } from '@/features/auth'
|
||||
import { archiveResults } from '@/features/results/api'
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const user = await getAuthenticatedUser(req)
|
||||
if (!user) return notAuthenticated(res)
|
||||
const workspaceId = req.query.workspaceId as string | undefined
|
||||
if (!workspaceId) return badRequest(res, 'workspaceId is required')
|
||||
const workspace = await prisma.workspace.findFirst({
|
||||
where:
|
||||
user.email === process.env.ADMIN_EMAIL
|
||||
? undefined
|
||||
: { id: workspaceId, members: { some: { userId: user.id } } },
|
||||
select: { plan: true },
|
||||
})
|
||||
if (!workspace) return forbidden(res)
|
||||
if (req.method === 'GET') {
|
||||
const typebotId = req.query.typebotId as string
|
||||
const lastResultId = req.query.lastResultId?.toString()
|
||||
const take = Number(req.query.limit?.toString())
|
||||
const results = await prisma.result.findMany({
|
||||
take: isNaN(take) ? undefined : take,
|
||||
skip: lastResultId ? 1 : 0,
|
||||
cursor: lastResultId
|
||||
? {
|
||||
id: lastResultId,
|
||||
}
|
||||
: undefined,
|
||||
where: {
|
||||
typebot: canReadTypebot(typebotId, user),
|
||||
answers: { some: {} },
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
include: { answers: true },
|
||||
})
|
||||
return res.status(200).send({ results })
|
||||
}
|
||||
if (req.method === 'DELETE') {
|
||||
const typebotId = req.query.typebotId as string
|
||||
const data = req.body as { ids: string[] }
|
||||
const ids = data.ids
|
||||
await archiveResults(res)({
|
||||
typebotId,
|
||||
user,
|
||||
resultsFilter: {
|
||||
id: ids.length > 0 ? { in: ids } : undefined,
|
||||
typebot: canWriteTypebot(typebotId, user),
|
||||
},
|
||||
})
|
||||
return res.status(200).send({ message: 'done' })
|
||||
}
|
||||
return methodNotAllowed(res)
|
||||
}
|
||||
|
||||
export default withSentry(handler)
|
@ -1,24 +0,0 @@
|
||||
import { withSentry } from '@sentry/nextjs'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { canReadTypebot } from '@/utils/api/dbRules'
|
||||
import { getAuthenticatedUser } from '@/features/auth'
|
||||
import { methodNotAllowed, notAuthenticated } from 'utils/api'
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const user = await getAuthenticatedUser(req)
|
||||
if (!user) return notAuthenticated(res)
|
||||
if (req.method === 'GET') {
|
||||
const typebotId = req.query.typebotId as string
|
||||
const resultId = req.query.resultId as string
|
||||
const logs = await prisma.log.findMany({
|
||||
where: {
|
||||
result: { id: resultId, typebot: canReadTypebot(typebotId, user) },
|
||||
},
|
||||
})
|
||||
return res.send({ logs })
|
||||
}
|
||||
methodNotAllowed(res)
|
||||
}
|
||||
|
||||
export default withSentry(handler)
|
13
apps/builder/src/utils/server/context.ts
Normal file
13
apps/builder/src/utils/server/context.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { getAuthenticatedUser } from '@/features/auth'
|
||||
import { inferAsyncReturnType } from '@trpc/server'
|
||||
import * as trpcNext from '@trpc/server/adapters/next'
|
||||
|
||||
export async function createContext(opts: trpcNext.CreateNextContextOptions) {
|
||||
const user = await getAuthenticatedUser(opts.req)
|
||||
|
||||
return {
|
||||
user,
|
||||
}
|
||||
}
|
||||
|
||||
export type Context = inferAsyncReturnType<typeof createContext>
|
8
apps/builder/src/utils/server/routers/_app.ts
Normal file
8
apps/builder/src/utils/server/routers/_app.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { resultsRouter } from '@/features/results/api'
|
||||
import { router } from '../trpc'
|
||||
|
||||
export const appRouter = router({
|
||||
results: resultsRouter,
|
||||
})
|
||||
|
||||
export type AppRouter = typeof appRouter
|
29
apps/builder/src/utils/server/trpc.ts
Normal file
29
apps/builder/src/utils/server/trpc.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { TRPCError, initTRPC } from '@trpc/server'
|
||||
import { Context } from './context'
|
||||
import { OpenApiMeta } from 'trpc-openapi'
|
||||
import superjson from 'superjson'
|
||||
|
||||
const t = initTRPC.context<Context>().meta<OpenApiMeta>().create({
|
||||
transformer: superjson,
|
||||
})
|
||||
|
||||
const isAuthed = t.middleware(({ next, ctx }) => {
|
||||
if (!ctx.user) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
})
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
user: ctx.user,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
export const middleware = t.middleware
|
||||
|
||||
export const router = t.router
|
||||
|
||||
export const publicProcedure = t.procedure
|
||||
|
||||
export const authenticatedProcedure = t.procedure.use(isAuthed)
|
@ -1,26 +1,11 @@
|
||||
{
|
||||
"extends": "tsconfig/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"incremental": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"composite": true,
|
||||
"downlevelIteration": true
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.json"],
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
Reference in New Issue
Block a user