@@ -0,0 +1,137 @@
|
||||
import prisma from '@typebot.io/lib/prisma'
|
||||
import { authenticatedProcedure } from '@/helpers/server/trpc'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { z } from 'zod'
|
||||
import { canReadTypebots } from '@/helpers/databaseRules'
|
||||
import { totalAnswersSchema } from '@typebot.io/schemas/features/analytics'
|
||||
import { parseGroups } from '@typebot.io/schemas'
|
||||
import { isInputBlock } from '@typebot.io/schemas/helpers'
|
||||
import { defaultTimeFilter, timeFilterValues } from '../constants'
|
||||
import {
|
||||
parseFromDateFromTimeFilter,
|
||||
parseToDateFromTimeFilter,
|
||||
} from '../helpers/parseDateFromTimeFilter'
|
||||
|
||||
export const getInDepthAnalyticsData = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/v1/typebots/{typebotId}/analytics/inDepthData',
|
||||
protect: true,
|
||||
summary:
|
||||
'List total answers in blocks and off-default paths visited edges',
|
||||
tags: ['Analytics'],
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
typebotId: z.string(),
|
||||
timeFilter: z.enum(timeFilterValues).default(defaultTimeFilter),
|
||||
timeZone: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
totalAnswers: z.array(totalAnswersSchema),
|
||||
offDefaultPathVisitedEdges: z.array(
|
||||
z.object({ edgeId: z.string(), total: z.number() })
|
||||
),
|
||||
})
|
||||
)
|
||||
.query(
|
||||
async ({ input: { typebotId, timeFilter, timeZone }, ctx: { user } }) => {
|
||||
const typebot = await prisma.typebot.findFirst({
|
||||
where: canReadTypebots(typebotId, user),
|
||||
select: { publishedTypebot: true },
|
||||
})
|
||||
if (!typebot?.publishedTypebot)
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Published typebot not found',
|
||||
})
|
||||
|
||||
const fromDate = parseFromDateFromTimeFilter(timeFilter, timeZone)
|
||||
const toDate = parseToDateFromTimeFilter(timeFilter, timeZone)
|
||||
|
||||
const totalAnswersPerBlock = await prisma.answer.groupBy({
|
||||
by: ['blockId', 'resultId'],
|
||||
where: {
|
||||
result: {
|
||||
typebotId: typebot.publishedTypebot.typebotId,
|
||||
createdAt: fromDate
|
||||
? {
|
||||
gte: fromDate,
|
||||
lte: toDate ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
blockId: {
|
||||
in: parseGroups(typebot.publishedTypebot.groups, {
|
||||
typebotVersion: typebot.publishedTypebot.version,
|
||||
}).flatMap((group) =>
|
||||
group.blocks.filter(isInputBlock).map((block) => block.id)
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const totalAnswersV2PerBlock = await prisma.answerV2.groupBy({
|
||||
by: ['blockId', 'resultId'],
|
||||
where: {
|
||||
result: {
|
||||
typebotId: typebot.publishedTypebot.typebotId,
|
||||
createdAt: fromDate
|
||||
? {
|
||||
gte: fromDate,
|
||||
lte: toDate ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
blockId: {
|
||||
in: parseGroups(typebot.publishedTypebot.groups, {
|
||||
typebotVersion: typebot.publishedTypebot.version,
|
||||
}).flatMap((group) =>
|
||||
group.blocks.filter(isInputBlock).map((block) => block.id)
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const uniqueCounts = totalAnswersPerBlock
|
||||
.concat(totalAnswersV2PerBlock)
|
||||
.reduce<{
|
||||
[key: string]: Set<string>
|
||||
}>((acc, { blockId, resultId }) => {
|
||||
acc[blockId] = acc[blockId] || new Set()
|
||||
acc[blockId].add(resultId)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const offDefaultPathVisitedEdges = await prisma.visitedEdge.groupBy({
|
||||
by: ['edgeId'],
|
||||
where: {
|
||||
result: {
|
||||
typebotId: typebot.publishedTypebot.typebotId,
|
||||
createdAt: fromDate
|
||||
? {
|
||||
gte: fromDate,
|
||||
lte: toDate ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
_count: { resultId: true },
|
||||
})
|
||||
|
||||
return {
|
||||
totalAnswers: Object.keys(uniqueCounts).map((blockId) => ({
|
||||
blockId,
|
||||
total: uniqueCounts[blockId].size,
|
||||
})),
|
||||
offDefaultPathVisitedEdges: offDefaultPathVisitedEdges.map((e) => ({
|
||||
edgeId: e.edgeId,
|
||||
total: e._count.resultId,
|
||||
})),
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1,78 +0,0 @@
|
||||
import prisma from '@typebot.io/lib/prisma'
|
||||
import { authenticatedProcedure } from '@/helpers/server/trpc'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { z } from 'zod'
|
||||
import { canReadTypebots } from '@/helpers/databaseRules'
|
||||
import { totalAnswersSchema } from '@typebot.io/schemas/features/analytics'
|
||||
import { parseGroups } from '@typebot.io/schemas'
|
||||
import { isInputBlock } from '@typebot.io/schemas/helpers'
|
||||
import { defaultTimeFilter, timeFilterValues } from '../constants'
|
||||
import {
|
||||
parseFromDateFromTimeFilter,
|
||||
parseToDateFromTimeFilter,
|
||||
} from '../helpers/parseDateFromTimeFilter'
|
||||
|
||||
export const getTotalAnswers = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/v1/typebots/{typebotId}/analytics/totalAnswersInBlocks',
|
||||
protect: true,
|
||||
summary: 'List total answers in blocks',
|
||||
tags: ['Analytics'],
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
typebotId: z.string(),
|
||||
timeFilter: z.enum(timeFilterValues).default(defaultTimeFilter),
|
||||
timeZone: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.output(z.object({ totalAnswers: z.array(totalAnswersSchema) }))
|
||||
.query(
|
||||
async ({ input: { typebotId, timeFilter, timeZone }, ctx: { user } }) => {
|
||||
const typebot = await prisma.typebot.findFirst({
|
||||
where: canReadTypebots(typebotId, user),
|
||||
select: { publishedTypebot: true },
|
||||
})
|
||||
if (!typebot?.publishedTypebot)
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Published typebot not found',
|
||||
})
|
||||
|
||||
const fromDate = parseFromDateFromTimeFilter(timeFilter, timeZone)
|
||||
const toDate = parseToDateFromTimeFilter(timeFilter, timeZone)
|
||||
|
||||
const totalAnswersPerBlock = await prisma.answer.groupBy({
|
||||
by: ['blockId'],
|
||||
where: {
|
||||
result: {
|
||||
typebotId: typebot.publishedTypebot.typebotId,
|
||||
createdAt: fromDate
|
||||
? {
|
||||
gte: fromDate,
|
||||
lte: toDate ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
blockId: {
|
||||
in: parseGroups(typebot.publishedTypebot.groups, {
|
||||
typebotVersion: typebot.publishedTypebot.version,
|
||||
}).flatMap((group) =>
|
||||
group.blocks.filter(isInputBlock).map((block) => block.id)
|
||||
),
|
||||
},
|
||||
},
|
||||
_count: { _all: true },
|
||||
})
|
||||
|
||||
return {
|
||||
totalAnswers: totalAnswersPerBlock.map((a) => ({
|
||||
blockId: a.blockId,
|
||||
total: a._count._all,
|
||||
})),
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1,10 +1,8 @@
|
||||
import { router } from '@/helpers/server/trpc'
|
||||
import { getTotalAnswers } from './getTotalAnswers'
|
||||
import { getTotalVisitedEdges } from './getTotalVisitedEdges'
|
||||
import { getStats } from './getStats'
|
||||
import { getInDepthAnalyticsData } from './getInDepthAnalyticsData'
|
||||
|
||||
export const analyticsRouter = router({
|
||||
getTotalAnswers,
|
||||
getTotalVisitedEdges,
|
||||
getInDepthAnalyticsData,
|
||||
getStats,
|
||||
})
|
||||
|
||||
@@ -5,8 +5,14 @@ import {
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react'
|
||||
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
|
||||
import { Stats } from '@typebot.io/schemas'
|
||||
import React from 'react'
|
||||
import {
|
||||
Edge,
|
||||
GroupV6,
|
||||
Stats,
|
||||
TotalAnswers,
|
||||
TotalVisitedEdges,
|
||||
} from '@typebot.io/schemas'
|
||||
import React, { useMemo } from 'react'
|
||||
import { StatsCards } from './StatsCards'
|
||||
import { ChangePlanModal } from '@/features/billing/components/ChangePlanModal'
|
||||
import { Graph } from '@/features/graph/components/Graph'
|
||||
@@ -16,6 +22,7 @@ import { trpc } from '@/lib/trpc'
|
||||
import { isDefined } from '@typebot.io/lib'
|
||||
import { EventsCoordinatesProvider } from '@/features/graph/providers/EventsCoordinateProvider'
|
||||
import { timeFilterValues } from '../constants'
|
||||
import { blockHasItems, isInputBlock } from '@typebot.io/schemas/helpers'
|
||||
|
||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
|
||||
@@ -33,7 +40,7 @@ export const AnalyticsGraphContainer = ({
|
||||
const { t } = useTranslate()
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const { typebot, publishedTypebot } = useTypebot()
|
||||
const { data } = trpc.analytics.getTotalAnswers.useQuery(
|
||||
const { data } = trpc.analytics.getInDepthAnalyticsData.useQuery(
|
||||
{
|
||||
typebotId: typebot?.id as string,
|
||||
timeFilter,
|
||||
@@ -42,14 +49,36 @@ export const AnalyticsGraphContainer = ({
|
||||
{ enabled: isDefined(publishedTypebot) }
|
||||
)
|
||||
|
||||
const { data: edgesData } = trpc.analytics.getTotalVisitedEdges.useQuery(
|
||||
{
|
||||
typebotId: typebot?.id as string,
|
||||
timeFilter,
|
||||
timeZone,
|
||||
},
|
||||
{ enabled: isDefined(publishedTypebot) }
|
||||
)
|
||||
const totalVisitedEdges = useMemo(() => {
|
||||
if (
|
||||
!publishedTypebot?.edges ||
|
||||
!publishedTypebot.groups ||
|
||||
!publishedTypebot.events ||
|
||||
!data?.totalAnswers ||
|
||||
!stats?.totalViews
|
||||
)
|
||||
return
|
||||
const firstEdgeId = publishedTypebot.events[0].outgoingEdgeId
|
||||
if (!firstEdgeId) return
|
||||
return populateEdgesWithVisitData({
|
||||
edgeId: firstEdgeId,
|
||||
edges: publishedTypebot.edges,
|
||||
groups: publishedTypebot.groups,
|
||||
currentTotalUsers: stats.totalViews,
|
||||
totalVisitedEdges: data.offDefaultPathVisitedEdges
|
||||
? [...data.offDefaultPathVisitedEdges]
|
||||
: [],
|
||||
totalAnswers: data.totalAnswers,
|
||||
edgeVisitHistory: [],
|
||||
})
|
||||
}, [
|
||||
data?.offDefaultPathVisitedEdges,
|
||||
data?.totalAnswers,
|
||||
publishedTypebot?.edges,
|
||||
publishedTypebot?.groups,
|
||||
publishedTypebot?.events,
|
||||
stats?.totalViews,
|
||||
])
|
||||
|
||||
return (
|
||||
<Flex
|
||||
@@ -73,7 +102,7 @@ export const AnalyticsGraphContainer = ({
|
||||
typebot={publishedTypebot}
|
||||
onUnlockProPlanClick={onOpen}
|
||||
totalAnswers={data?.totalAnswers}
|
||||
totalVisitedEdges={edgesData?.totalVisitedEdges}
|
||||
totalVisitedEdges={totalVisitedEdges}
|
||||
/>
|
||||
</EventsCoordinatesProvider>
|
||||
</GraphProvider>
|
||||
@@ -102,3 +131,72 @@ export const AnalyticsGraphContainer = ({
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
const populateEdgesWithVisitData = ({
|
||||
edgeId,
|
||||
edges,
|
||||
groups,
|
||||
currentTotalUsers,
|
||||
totalVisitedEdges,
|
||||
totalAnswers,
|
||||
edgeVisitHistory,
|
||||
}: {
|
||||
edgeId: string
|
||||
edges: Edge[]
|
||||
groups: GroupV6[]
|
||||
currentTotalUsers: number
|
||||
totalVisitedEdges: TotalVisitedEdges[]
|
||||
totalAnswers: TotalAnswers[]
|
||||
edgeVisitHistory: string[]
|
||||
}): TotalVisitedEdges[] => {
|
||||
if (edgeVisitHistory.find((e) => e === edgeId)) return totalVisitedEdges
|
||||
totalVisitedEdges.push({
|
||||
edgeId,
|
||||
total: currentTotalUsers,
|
||||
})
|
||||
edgeVisitHistory.push(edgeId)
|
||||
const edge = edges.find((edge) => edge.id === edgeId)
|
||||
if (!edge) return totalVisitedEdges
|
||||
const group = groups.find((group) => edge?.to.groupId === group.id)
|
||||
if (!group) return totalVisitedEdges
|
||||
for (const block of edge.to.blockId
|
||||
? group.blocks.slice(
|
||||
group.blocks.findIndex((b) => b.id === edge.to.blockId)
|
||||
)
|
||||
: group.blocks) {
|
||||
if (blockHasItems(block)) {
|
||||
for (const item of block.items) {
|
||||
if (item.outgoingEdgeId) {
|
||||
totalVisitedEdges = populateEdgesWithVisitData({
|
||||
edgeId: item.outgoingEdgeId,
|
||||
edges,
|
||||
groups,
|
||||
currentTotalUsers:
|
||||
totalVisitedEdges.find(
|
||||
(tve) => tve.edgeId === item.outgoingEdgeId
|
||||
)?.total ?? 0,
|
||||
totalVisitedEdges,
|
||||
totalAnswers,
|
||||
edgeVisitHistory,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (block.outgoingEdgeId) {
|
||||
const totalUsers = isInputBlock(block)
|
||||
? totalAnswers.find((a) => a.blockId === block.id)?.total
|
||||
: currentTotalUsers
|
||||
totalVisitedEdges = populateEdgesWithVisitData({
|
||||
edgeId: block.outgoingEdgeId,
|
||||
edges,
|
||||
groups,
|
||||
currentTotalUsers: totalUsers ?? 0,
|
||||
totalVisitedEdges,
|
||||
totalAnswers,
|
||||
edgeVisitHistory,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return totalVisitedEdges
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ const Expression = ({
|
||||
case 'Result ID':
|
||||
case 'Moment of the day':
|
||||
case 'Environment name':
|
||||
case 'Transcript':
|
||||
case 'Yesterday': {
|
||||
return (
|
||||
<Text as="span">
|
||||
|
||||
@@ -8,11 +8,13 @@ import { WhatsAppLogo } from '@/components/logos/WhatsAppLogo'
|
||||
import {
|
||||
defaultSetVariableOptions,
|
||||
hiddenTypes,
|
||||
sessionOnlySetVariableOptions,
|
||||
valueTypes,
|
||||
} from '@typebot.io/schemas/features/blocks/logic/setVariable/constants'
|
||||
import { TextInput } from '@/components/inputs'
|
||||
import { isDefined } from '@typebot.io/lib'
|
||||
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
|
||||
import { isInputBlock } from '@typebot.io/schemas/helpers'
|
||||
|
||||
type Props = {
|
||||
options: SetVariableBlock['options']
|
||||
@@ -48,6 +50,20 @@ export const SetVariableSettings = ({ options, onOptionsChange }: Props) => {
|
||||
})
|
||||
}
|
||||
|
||||
const isSessionOnly =
|
||||
options?.type &&
|
||||
sessionOnlySetVariableOptions.includes(
|
||||
options.type as (typeof sessionOnlySetVariableOptions)[number]
|
||||
)
|
||||
|
||||
const isLinkedToAnswer =
|
||||
options?.variableId &&
|
||||
typebot?.groups.some((g) =>
|
||||
g.blocks.some(
|
||||
(b) => isInputBlock(b) && b.options?.variableId === options.variableId
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<Stack>
|
||||
@@ -80,7 +96,7 @@ export const SetVariableSettings = ({ options, onOptionsChange }: Props) => {
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{selectedVariable && (
|
||||
{selectedVariable && !isSessionOnly && !isLinkedToAnswer && (
|
||||
<SwitchWithLabel
|
||||
key={selectedVariable.id}
|
||||
label="Save in results?"
|
||||
@@ -244,6 +260,7 @@ const SetVariableValue = ({
|
||||
case 'Today':
|
||||
case 'Result ID':
|
||||
case 'Empty':
|
||||
case 'Transcript':
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ import { importTypebotInDatabase } from '@typebot.io/playwright/databaseActions'
|
||||
import { createId } from '@paralleldrive/cuid2'
|
||||
import { getTestAsset } from '@/test/utils/playwright'
|
||||
|
||||
const typebotId = createId()
|
||||
test.describe.configure({ mode: 'parallel' })
|
||||
|
||||
test.describe('Set variable block', () => {
|
||||
test('its configuration should work', async ({ page }) => {
|
||||
const typebotId = createId()
|
||||
await importTypebotInDatabase(
|
||||
getTestAsset('typebots/logic/setVariable.json'),
|
||||
{
|
||||
@@ -70,4 +71,47 @@ test.describe('Set variable block', () => {
|
||||
page.locator('typebot-standard').locator('text=Addition: 366000')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Transcription variable setting should work in preview', async ({
|
||||
page,
|
||||
}) => {
|
||||
const typebotId = createId()
|
||||
await importTypebotInDatabase(
|
||||
getTestAsset('typebots/logic/setVariable2.json'),
|
||||
{
|
||||
id: typebotId,
|
||||
}
|
||||
)
|
||||
|
||||
await page.goto(`/typebots/${typebotId}/edit`)
|
||||
await page.getByText('Transcription =').click()
|
||||
await expect(page.getByText('Save in results?')).toBeVisible()
|
||||
await page.locator('input[type="text"]').click()
|
||||
await page.getByRole('menuitem', { name: 'Transcript' }).click()
|
||||
await expect(page.getByText('Save in results?')).toBeHidden()
|
||||
await expect(page.getByText('System.Transcript')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Test' }).click()
|
||||
await page.getByRole('button', { name: 'There is a bug 🐛' }).click()
|
||||
await page.getByTestId('textarea').fill('Hello!!')
|
||||
await page.getByTestId('input').getByRole('button').click()
|
||||
await page
|
||||
.locator('typebot-standard')
|
||||
.getByRole('button', { name: 'Restart' })
|
||||
.click()
|
||||
await page.getByRole('button', { name: 'I have a question 💭' }).click()
|
||||
await page.getByTestId('textarea').fill('How are you?')
|
||||
await page.getByTestId('input').getByRole('button').click()
|
||||
await page.getByRole('button', { name: 'Transcription' }).click()
|
||||
|
||||
await expect(
|
||||
page.getByText('Assistant: "Hey friend 👋 How').first()
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Assistant: "https://media0.giphy.com/media/rhgwg4qBu97ISgbfni/giphy-downsized.gif?cid=fe3852a3wimy48e55djt23j44uto7gdlu8ksytylafisvr0q&rid=giphy-downsized.gif&ct=g"'
|
||||
)
|
||||
).toBeVisible()
|
||||
await expect(page.getByText('User: "How are you?"')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,7 +43,7 @@ test('should be configurable', async ({ page }) => {
|
||||
await page.click('[aria-label="Close"]')
|
||||
await page.click('text=Jump to Group #2 in My link typebot 2')
|
||||
await page.getByTestId('selected-item-label').nth(1).click({ force: true })
|
||||
await page.click('button >> text=Start')
|
||||
await page.getByLabel('Clear').click()
|
||||
|
||||
await page.click('text=Test')
|
||||
await page.locator('typebot-standard').locator('input').fill('Hello there!')
|
||||
@@ -53,7 +53,7 @@ test('should be configurable', async ({ page }) => {
|
||||
).toBeVisible()
|
||||
|
||||
await page.click('[aria-label="Close"]')
|
||||
await page.click('text=Jump to Start in My link typebot 2')
|
||||
await page.click('text=Jump in My link typebot 2')
|
||||
await page.waitForTimeout(1000)
|
||||
await page.getByTestId('selected-item-label').first().click({ force: true })
|
||||
await page.click('button >> text=Current typebot')
|
||||
|
||||
@@ -61,58 +61,6 @@ test('Edges connection should work', async ({ page }) => {
|
||||
const total = await page.locator('[data-testid="edge"]').count()
|
||||
expect(total).toBe(1)
|
||||
})
|
||||
test('Drag and drop blocks and items should work', async ({ page }) => {
|
||||
const typebotId = createId()
|
||||
await importTypebotInDatabase(
|
||||
getTestAsset('typebots/editor/buttonsDnd.json'),
|
||||
{
|
||||
id: typebotId,
|
||||
}
|
||||
)
|
||||
|
||||
// Blocks dnd
|
||||
await page.goto(`/typebots/${typebotId}/edit`)
|
||||
await expect(page.locator('[data-testid="block"] >> nth=0')).toHaveText(
|
||||
'Hello!'
|
||||
)
|
||||
await page.dragAndDrop('text=Hello', '[data-testid="block"] >> nth=2', {
|
||||
targetPosition: { x: 100, y: 0 },
|
||||
})
|
||||
await expect(page.locator('[data-testid="block"] >> nth=1')).toHaveText(
|
||||
'Hello!'
|
||||
)
|
||||
await page.dragAndDrop('text=Hello', 'text=Group #2')
|
||||
await expect(page.locator('[data-testid="block"] >> nth=2')).toHaveText(
|
||||
'Hello!'
|
||||
)
|
||||
|
||||
// Items dnd
|
||||
await expect(page.locator('[data-testid="item"] >> nth=0')).toHaveText(
|
||||
'Item 1'
|
||||
)
|
||||
await page.dragAndDrop('text=Item 1', 'text=Item 3')
|
||||
await expect(page.locator('[data-testid="item"] >> nth=2')).toHaveText(
|
||||
'Item 1'
|
||||
)
|
||||
await expect(page.locator('[data-testid="item"] >> nth=1')).toHaveText(
|
||||
'Item 3'
|
||||
)
|
||||
await page.dragAndDrop('text=Item 3', 'text=Item 2-3')
|
||||
await expect(page.locator('[data-testid="item"] >> nth=7')).toHaveText(
|
||||
'Item 3'
|
||||
)
|
||||
|
||||
await expect(page.locator('[data-testid="item"] >> nth=2')).toHaveText(
|
||||
'Name=John'
|
||||
)
|
||||
await page.dragAndDrop(
|
||||
'[data-testid="item"] >> nth=2',
|
||||
'[data-testid="item"] >> nth=3'
|
||||
)
|
||||
await expect(page.locator('[data-testid="item"] >> nth=3')).toHaveText(
|
||||
'Name=John'
|
||||
)
|
||||
})
|
||||
|
||||
test('Rename and icon change should work', async ({ page }) => {
|
||||
const typebotId = createId()
|
||||
|
||||
@@ -110,10 +110,14 @@ export const useUndo = <T extends { updatedAt: Date }>(
|
||||
|
||||
const setUpdateDate = useCallback(
|
||||
(updatedAt: Date) => {
|
||||
set((current) => ({
|
||||
...current,
|
||||
updatedAt,
|
||||
}))
|
||||
set((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
updatedAt,
|
||||
}
|
||||
: current
|
||||
)
|
||||
},
|
||||
[set]
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
TotalVisitedEdges,
|
||||
} from '@typebot.io/schemas/features/analytics'
|
||||
import { computeTotalUsersAtBlock } from '@/features/analytics/helpers/computeTotalUsersAtBlock'
|
||||
import { byId } from '@typebot.io/lib'
|
||||
import { byId, isNotDefined } from '@typebot.io/lib'
|
||||
import { blockHasItems } from '@typebot.io/schemas/helpers'
|
||||
import { groupWidth } from '../../constants'
|
||||
import { getTotalAnswersAtBlock } from '@/features/analytics/helpers/getTotalAnswersAtBlock'
|
||||
@@ -130,7 +130,7 @@ export const DropOffEdge = ({
|
||||
return lastBlock?.id === currentBlockId
|
||||
}, [publishedTypebot, currentBlockId])
|
||||
|
||||
if (!endpointCoordinates) return null
|
||||
if (!endpointCoordinates || isNotDefined(dropOffRate)) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -22,7 +22,7 @@ import { FormEvent, useState } from 'react'
|
||||
import { headerHeight } from '../../editor/constants'
|
||||
import { useDrag } from '@use-gesture/react'
|
||||
import { ResizeHandle } from './ResizeHandle'
|
||||
import { Variable } from '@typebot.io/schemas'
|
||||
import { InputBlock, SetVariableBlock, Variable } from '@typebot.io/schemas'
|
||||
import {
|
||||
CheckIcon,
|
||||
MoreHorizontalIcon,
|
||||
@@ -32,6 +32,9 @@ import {
|
||||
import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel'
|
||||
import { isNotEmpty } from '@typebot.io/lib'
|
||||
import { createId } from '@paralleldrive/cuid2'
|
||||
import { isInputBlock } from '@typebot.io/schemas/helpers'
|
||||
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants'
|
||||
import { sessionOnlySetVariableOptions } from '@typebot.io/schemas/features/blocks/logic/setVariable/constants'
|
||||
|
||||
type Props = {
|
||||
onClose: () => void
|
||||
@@ -70,6 +73,14 @@ export const VariablesDrawer = ({ onClose }: Props) => {
|
||||
})
|
||||
}
|
||||
|
||||
const setVariableAndInputBlocks =
|
||||
typebot?.groups.flatMap(
|
||||
(g) =>
|
||||
g.blocks.filter(
|
||||
(b) => b.type === LogicBlockType.SET_VARIABLE || isInputBlock(b)
|
||||
) as (InputBlock | SetVariableBlock)[]
|
||||
) ?? []
|
||||
|
||||
return (
|
||||
<Flex
|
||||
pos="absolute"
|
||||
@@ -132,6 +143,7 @@ export const VariablesDrawer = ({ onClose }: Props) => {
|
||||
variable={variable}
|
||||
onChange={(changes) => updateVariable(variable.id, changes)}
|
||||
onDelete={() => deleteVariable(variable.id)}
|
||||
setVariableAndInputBlocks={setVariableAndInputBlocks}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -144,58 +156,76 @@ const VariableItem = ({
|
||||
variable,
|
||||
onChange,
|
||||
onDelete,
|
||||
setVariableAndInputBlocks,
|
||||
}: {
|
||||
variable: Variable
|
||||
onChange: (variable: Partial<Variable>) => void
|
||||
onDelete: () => void
|
||||
}) => (
|
||||
<HStack justifyContent="space-between">
|
||||
<Editable
|
||||
defaultValue={variable.name}
|
||||
onSubmit={(name) => onChange({ name })}
|
||||
>
|
||||
<EditablePreview
|
||||
px="2"
|
||||
noOfLines={1}
|
||||
cursor="text"
|
||||
_hover={{
|
||||
bg: useColorModeValue('gray.100', 'gray.700'),
|
||||
}}
|
||||
/>
|
||||
<EditableInput ml="1" pl="1" />
|
||||
</Editable>
|
||||
setVariableAndInputBlocks: (InputBlock | SetVariableBlock)[]
|
||||
}) => {
|
||||
const isSessionOnly = setVariableAndInputBlocks.some(
|
||||
(b) =>
|
||||
b.type === LogicBlockType.SET_VARIABLE &&
|
||||
sessionOnlySetVariableOptions.includes(
|
||||
b.options?.type as (typeof sessionOnlySetVariableOptions)[number]
|
||||
)
|
||||
)
|
||||
|
||||
<HStack>
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<IconButton
|
||||
icon={<MoreHorizontalIcon />}
|
||||
aria-label={'Settings'}
|
||||
size="sm"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverBody>
|
||||
<SwitchWithLabel
|
||||
label="Save in results?"
|
||||
moreInfoContent="Check this option if you want to save the variable value in the typebot Results table."
|
||||
initialValue={!variable.isSessionVariable}
|
||||
onCheckChange={() =>
|
||||
onChange({
|
||||
...variable,
|
||||
isSessionVariable: !variable.isSessionVariable,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
const isLinkedToAnswer = setVariableAndInputBlocks.some(
|
||||
(b) => isInputBlock(b) && b.options?.variableId === variable.id
|
||||
)
|
||||
|
||||
return (
|
||||
<HStack justifyContent="space-between">
|
||||
<Editable
|
||||
defaultValue={variable.name}
|
||||
onSubmit={(name) => onChange({ name })}
|
||||
>
|
||||
<EditablePreview
|
||||
px="2"
|
||||
noOfLines={1}
|
||||
cursor="text"
|
||||
_hover={{
|
||||
bg: useColorModeValue('gray.100', 'gray.700'),
|
||||
}}
|
||||
/>
|
||||
<EditableInput ml="1" pl="1" />
|
||||
</Editable>
|
||||
|
||||
<HStack>
|
||||
{!isSessionOnly && !isLinkedToAnswer && (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<IconButton
|
||||
icon={<MoreHorizontalIcon />}
|
||||
aria-label={'Settings'}
|
||||
size="sm"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverBody>
|
||||
<SwitchWithLabel
|
||||
label="Save in results?"
|
||||
moreInfoContent="Check this option if you want to save the variable value in the typebot Results table."
|
||||
initialValue={!variable.isSessionVariable}
|
||||
onCheckChange={() =>
|
||||
onChange({
|
||||
...variable,
|
||||
isSessionVariable: !variable.isSessionVariable,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<IconButton
|
||||
icon={<TrashIcon />}
|
||||
onClick={onDelete}
|
||||
aria-label="Delete"
|
||||
size="sm"
|
||||
/>
|
||||
</Popover>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</HStack>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WebhookIcon } from '@/components/icons'
|
||||
import { useUser } from '@/features/account/hooks/useUser'
|
||||
import { useEditor } from '@/features/editor/providers/EditorProvider'
|
||||
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
|
||||
import { useGraph } from '@/features/graph/providers/GraphProvider'
|
||||
@@ -7,6 +8,7 @@ import { Standard } from '@typebot.io/nextjs'
|
||||
import { ContinueChatResponse } from '@typebot.io/schemas'
|
||||
|
||||
export const WebPreview = () => {
|
||||
const { user } = useUser()
|
||||
const { typebot } = useTypebot()
|
||||
const { startPreviewAtGroup, startPreviewAtEvent } = useEditor()
|
||||
const { setPreviewingBlock } = useGraph()
|
||||
@@ -40,6 +42,7 @@ export const WebPreview = () => {
|
||||
<Standard
|
||||
key={`web-preview${startPreviewAtGroup ?? ''}`}
|
||||
typebot={typebot}
|
||||
sessionId={user ? `${typebot.id}-${user.id}` : undefined}
|
||||
startFrom={
|
||||
startPreviewAtGroup
|
||||
? { type: 'group', groupId: startPreviewAtGroup }
|
||||
|
||||
@@ -15,6 +15,7 @@ import { parseResultHeader } from '@typebot.io/results/parseResultHeader'
|
||||
import { convertResultsToTableData } from '@typebot.io/results/convertResultsToTableData'
|
||||
import { parseCellContent } from './helpers/parseCellContent'
|
||||
import { timeFilterValues } from '../analytics/constants'
|
||||
import { parseBlockIdVariableIdMap } from '@typebot.io/results/parseBlockIdVariableIdMap'
|
||||
|
||||
const resultsContext = createContext<{
|
||||
resultsList: { results: ResultWithAnswers[] }[] | undefined
|
||||
@@ -97,11 +98,14 @@ export const ResultsProvider = ({
|
||||
const tableData = useMemo(
|
||||
() =>
|
||||
publishedTypebot
|
||||
? convertResultsToTableData(
|
||||
data?.flatMap((d) => d.results) ?? [],
|
||||
resultHeader,
|
||||
parseCellContent
|
||||
)
|
||||
? convertResultsToTableData({
|
||||
results: data?.flatMap((d) => d.results) ?? [],
|
||||
headerCells: resultHeader,
|
||||
cellParser: parseCellContent,
|
||||
blockIdVariableIdMap: parseBlockIdVariableIdMap(
|
||||
publishedTypebot.groups
|
||||
),
|
||||
})
|
||||
: [],
|
||||
[publishedTypebot, data, resultHeader]
|
||||
)
|
||||
|
||||
@@ -71,11 +71,31 @@ export const getResult = authenticatedProcedure
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
include: { answers: true },
|
||||
include: {
|
||||
answers: {
|
||||
select: {
|
||||
blockId: true,
|
||||
content: true,
|
||||
},
|
||||
},
|
||||
answersV2: {
|
||||
select: {
|
||||
blockId: true,
|
||||
content: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (results.length === 0)
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Result not found' })
|
||||
|
||||
return { result: resultWithAnswersSchema.parse(results[0]) }
|
||||
const { answers, answersV2, ...result } = results[0]
|
||||
|
||||
return {
|
||||
result: resultWithAnswersSchema.parse({
|
||||
...result,
|
||||
answers: answers.concat(answersV2),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -104,7 +104,20 @@ export const getResults = authenticatedProcedure
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
include: { answers: true },
|
||||
include: {
|
||||
answers: {
|
||||
select: {
|
||||
blockId: true,
|
||||
content: true,
|
||||
},
|
||||
},
|
||||
answersV2: {
|
||||
select: {
|
||||
blockId: true,
|
||||
content: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let nextCursor: typeof cursor | undefined
|
||||
@@ -114,7 +127,11 @@ export const getResults = authenticatedProcedure
|
||||
}
|
||||
|
||||
return {
|
||||
results: z.array(resultWithAnswersSchema).parse(results),
|
||||
results: z
|
||||
.array(resultWithAnswersSchema)
|
||||
.parse(
|
||||
results.map((r) => ({ ...r, answers: r.answersV2.concat(r.answers) }))
|
||||
),
|
||||
nextCursor,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -27,6 +27,7 @@ import { parseUniqueKey } from '@typebot.io/lib/parseUniqueKey'
|
||||
import { useResults } from '../../ResultsProvider'
|
||||
import { byId, isDefined } from '@typebot.io/lib'
|
||||
import { Typebot } from '@typebot.io/schemas'
|
||||
import { parseBlockIdVariableIdMap } from '@typebot.io/results/parseBlockIdVariableIdMap'
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean
|
||||
@@ -101,7 +102,11 @@ export const ExportAllResultsModal = ({ isOpen, onClose }: Props) => {
|
||||
)
|
||||
: existingResultHeader
|
||||
|
||||
const dataToUnparse = convertResultsToTableData(results, resultHeader)
|
||||
const dataToUnparse = convertResultsToTableData({
|
||||
results,
|
||||
headerCells: resultHeader,
|
||||
blockIdVariableIdMap: parseBlockIdVariableIdMap(typebot?.groups),
|
||||
})
|
||||
|
||||
const headerIds = parseColumnsOrder(
|
||||
typebot?.resultsTablePreferences?.columnsOrder,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
isPublicIdNotAvailable,
|
||||
sanitizeGroups,
|
||||
sanitizeSettings,
|
||||
sanitizeVariables,
|
||||
} from '../helpers/sanitizers'
|
||||
import { createId } from '@paralleldrive/cuid2'
|
||||
import { EventType } from '@typebot.io/schemas/features/events/constants'
|
||||
@@ -92,6 +93,9 @@ export const createTypebot = authenticatedProcedure
|
||||
if (!existingFolder) typebot.folderId = null
|
||||
}
|
||||
|
||||
const groups = (
|
||||
typebot.groups ? await sanitizeGroups(workspaceId)(typebot.groups) : []
|
||||
) as TypebotV6['groups']
|
||||
const newTypebot = await prisma.typebot.create({
|
||||
data: {
|
||||
version: '6',
|
||||
@@ -99,9 +103,7 @@ export const createTypebot = authenticatedProcedure
|
||||
name: typebot.name ?? 'My typebot',
|
||||
icon: typebot.icon,
|
||||
selectedThemeTemplateId: typebot.selectedThemeTemplateId,
|
||||
groups: (typebot.groups
|
||||
? await sanitizeGroups(workspaceId)(typebot.groups)
|
||||
: []) as TypebotV6['groups'],
|
||||
groups,
|
||||
events: typebot.events ?? [
|
||||
{
|
||||
type: EventType.START,
|
||||
@@ -118,7 +120,9 @@ export const createTypebot = authenticatedProcedure
|
||||
}
|
||||
: {},
|
||||
folderId: typebot.folderId,
|
||||
variables: typebot.variables ?? [],
|
||||
variables: typebot.variables
|
||||
? sanitizeVariables({ variables: typebot.variables, groups })
|
||||
: [],
|
||||
edges: typebot.edges ?? [],
|
||||
resultsTablePreferences: typebot.resultsTablePreferences ?? undefined,
|
||||
publicId: typebot.publicId ?? undefined,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
sanitizeFolderId,
|
||||
sanitizeGroups,
|
||||
sanitizeSettings,
|
||||
sanitizeVariables,
|
||||
} from '../helpers/sanitizers'
|
||||
import { preprocessTypebot } from '@typebot.io/schemas/features/typebot/helpers/preprocessTypebot'
|
||||
import { migrateTypebot } from '@typebot.io/migrations/migrateTypebot'
|
||||
@@ -122,6 +123,12 @@ export const importTypebot = authenticatedProcedure
|
||||
|
||||
const migratedTypebot = await migrateImportingTypebot(typebot)
|
||||
|
||||
const groups = (
|
||||
migratedTypebot.groups
|
||||
? await sanitizeGroups(workspaceId)(migratedTypebot.groups)
|
||||
: []
|
||||
) as TypebotV6['groups']
|
||||
|
||||
const newTypebot = await prisma.typebot.create({
|
||||
data: {
|
||||
version: '6',
|
||||
@@ -129,9 +136,7 @@ export const importTypebot = authenticatedProcedure
|
||||
name: migratedTypebot.name,
|
||||
icon: migratedTypebot.icon,
|
||||
selectedThemeTemplateId: migratedTypebot.selectedThemeTemplateId,
|
||||
groups: (migratedTypebot.groups
|
||||
? await sanitizeGroups(workspaceId)(migratedTypebot.groups)
|
||||
: []) as TypebotV6['groups'],
|
||||
groups,
|
||||
events: migratedTypebot.events ?? undefined,
|
||||
theme: migratedTypebot.theme ? migratedTypebot.theme : {},
|
||||
settings: migratedTypebot.settings
|
||||
@@ -147,7 +152,9 @@ export const importTypebot = authenticatedProcedure
|
||||
folderId: migratedTypebot.folderId,
|
||||
workspaceId: workspace.id,
|
||||
}),
|
||||
variables: migratedTypebot.variables ?? [],
|
||||
variables: migratedTypebot.variables
|
||||
? sanitizeVariables({ variables: migratedTypebot.variables, groups })
|
||||
: [],
|
||||
edges: migratedTypebot.edges ?? [],
|
||||
resultsTablePreferences:
|
||||
migratedTypebot.resultsTablePreferences ?? undefined,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
sanitizeCustomDomain,
|
||||
sanitizeGroups,
|
||||
sanitizeSettings,
|
||||
sanitizeVariables,
|
||||
} from '../helpers/sanitizers'
|
||||
import { isWriteTypebotForbidden } from '../helpers/isWriteTypebotForbidden'
|
||||
import { isCloudProdInstance } from '@/helpers/isCloudProdInstance'
|
||||
@@ -156,6 +157,10 @@ export const updateTypebot = authenticatedProcedure
|
||||
})
|
||||
}
|
||||
|
||||
const groups = typebot.groups
|
||||
? await sanitizeGroups(existingTypebot.workspace.id)(typebot.groups)
|
||||
: undefined
|
||||
|
||||
const newTypebot = await prisma.typebot.update({
|
||||
where: {
|
||||
id: existingTypebot.id,
|
||||
@@ -166,9 +171,7 @@ export const updateTypebot = authenticatedProcedure
|
||||
icon: typebot.icon,
|
||||
selectedThemeTemplateId: typebot.selectedThemeTemplateId,
|
||||
events: typebot.events ?? undefined,
|
||||
groups: typebot.groups
|
||||
? await sanitizeGroups(existingTypebot.workspace.id)(typebot.groups)
|
||||
: undefined,
|
||||
groups,
|
||||
theme: typebot.theme ? typebot.theme : undefined,
|
||||
settings: typebot.settings
|
||||
? sanitizeSettings(
|
||||
@@ -178,7 +181,13 @@ export const updateTypebot = authenticatedProcedure
|
||||
)
|
||||
: undefined,
|
||||
folderId: typebot.folderId,
|
||||
variables: typebot.variables,
|
||||
variables:
|
||||
typebot.variables && groups
|
||||
? sanitizeVariables({
|
||||
variables: typebot.variables,
|
||||
groups,
|
||||
})
|
||||
: undefined,
|
||||
edges: typebot.edges,
|
||||
resultsTablePreferences:
|
||||
typebot.resultsTablePreferences === null
|
||||
|
||||
@@ -4,6 +4,9 @@ import { Plan } from '@typebot.io/prisma'
|
||||
import { Block, Typebot } from '@typebot.io/schemas'
|
||||
import { IntegrationBlockType } from '@typebot.io/schemas/features/blocks/integrations/constants'
|
||||
import { defaultSendEmailOptions } from '@typebot.io/schemas/features/blocks/integrations/sendEmail/constants'
|
||||
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants'
|
||||
import { sessionOnlySetVariableOptions } from '@typebot.io/schemas/features/blocks/logic/setVariable/constants'
|
||||
import { isInputBlock } from '@typebot.io/schemas/helpers'
|
||||
|
||||
export const sanitizeSettings = (
|
||||
settings: Typebot['settings'],
|
||||
@@ -160,3 +163,38 @@ export const sanitizeCustomDomain = async ({
|
||||
})
|
||||
return domainCount === 0 ? null : customDomain
|
||||
}
|
||||
|
||||
export const sanitizeVariables = ({
|
||||
variables,
|
||||
groups,
|
||||
}: Pick<Typebot, 'variables' | 'groups'>): Typebot['variables'] => {
|
||||
const blocks = groups
|
||||
.flatMap((group) => group.blocks as Block[])
|
||||
.filter((b) => isInputBlock(b) || b.type === LogicBlockType.SET_VARIABLE)
|
||||
return variables.map((variable) => {
|
||||
if (variable.isSessionVariable) return variable
|
||||
const isVariableLinkedToInputBlock = blocks.some(
|
||||
(block) =>
|
||||
isInputBlock(block) && block.options?.variableId === variable.id
|
||||
)
|
||||
if (isVariableLinkedToInputBlock)
|
||||
return {
|
||||
...variable,
|
||||
isSessionVariable: true,
|
||||
}
|
||||
const isVariableSetToForbiddenResultVar = blocks.some(
|
||||
(block) =>
|
||||
block.type === LogicBlockType.SET_VARIABLE &&
|
||||
block.options?.variableId === variable.id &&
|
||||
sessionOnlySetVariableOptions.includes(
|
||||
block.options.type as (typeof sessionOnlySetVariableOptions)[number]
|
||||
)
|
||||
)
|
||||
if (isVariableSetToForbiddenResultVar)
|
||||
return {
|
||||
...variable,
|
||||
isSessionVariable: true,
|
||||
}
|
||||
return variable
|
||||
})
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ export const startWhatsAppPreview = authenticatedProcedure
|
||||
clientSideActions,
|
||||
logs,
|
||||
visitedEdges,
|
||||
setVariableHistory,
|
||||
} = await startSession({
|
||||
version: 2,
|
||||
message: undefined,
|
||||
@@ -145,6 +146,7 @@ export const startWhatsAppPreview = authenticatedProcedure
|
||||
state: newSessionState,
|
||||
},
|
||||
visitedEdges,
|
||||
setVariableHistory,
|
||||
})
|
||||
} else {
|
||||
await restartSession({
|
||||
|
||||
Reference in New Issue
Block a user