Adds journey step type to remove a user from a journey (#501)

This commit is contained in:
Chris Anderson 2024-10-12 15:11:31 -05:00 committed by GitHub
parent e91d8a87c0
commit 95c733795b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 179 additions and 15 deletions

View file

@ -5,7 +5,7 @@ import { searchParamsSchema } from '../core/searchParams'
import { JSONSchemaType, validate } from '../core/validate'
import { extractQueryParams } from '../utilities'
import Journey, { JourneyEntranceTriggerParams, JourneyParams } from './Journey'
import { createJourney, getJourneyStepMap, getJourney, pagedJourneys, setJourneyStepMap, updateJourney, pagedEntrancesByJourney, getEntranceLog, pagedUsersByStep, archiveJourney, deleteJourney } from './JourneyRepository'
import { createJourney, getJourneyStepMap, getJourney, pagedJourneys, setJourneyStepMap, updateJourney, pagedEntrancesByJourney, getEntranceLog, pagedUsersByStep, archiveJourney, deleteJourney, exitUserFromJourney } from './JourneyRepository'
import { JourneyStep, JourneyStepMapParams, JourneyUserStep, journeyStepTypes, toJourneyStepMap } from './JourneyStep'
import { User } from '../users/User'
import { RequestError } from '../core/errors'
@ -179,13 +179,7 @@ router.get('/:journeyId/entrances', async ctx => {
router.delete('/:journeyId/entrances/:entranceId/users/:userId', async ctx => {
const user = await getUserFromContext(ctx)
if (!user) return ctx.throw(404)
const results = await JourneyUserStep.update(
q => q.where('user_id', user.id)
.where('entrance_id', parseInt(ctx.params.entranceId))
.whereNull('ended_at')
.where('journey_id', ctx.state.journey!.id),
{ ended_at: new Date() },
)
const results = await exitUserFromJourney(user.id, parseInt(ctx.params.entranceId), ctx.state.journey!.id)
ctx.body = { exits: results }
})

View file

@ -296,3 +296,25 @@ export const getEntranceLog = async (entranceId: number) => {
}
return userSteps
}
export const getJourneyUserStepByExternalId = async (journeyId: number, userId: number, externalId: string, db?: Database): Promise<JourneyUserStep | undefined> => {
return await JourneyUserStep.first(
qb => qb.leftJoin('journey_steps', 'journey_steps.id', 'journey_user_step.step_id')
.where('journey_user_step.journey_id', journeyId)
.where('journey_steps.external_id', externalId)
.where('journey_user_step.user_id', userId)
.select('journey_user_step.*'),
db,
)
}
export const exitUserFromJourney = async (userId: number, entranceId: number, journeyId: number) => {
console.log('exiting from', userId, entranceId, journeyId)
await JourneyUserStep.update(
q => q.where('user_id', userId)
.where('id', entranceId)
.whereNull('ended_at')
.where('journey_id', journeyId),
{ ended_at: new Date() },
)
}

View file

@ -12,6 +12,7 @@ import App from '../app'
import { RRule } from 'rrule'
import { JourneyState } from './JourneyState'
import { EventPostJob, UserPatchJob } from '../jobs'
import { exitUserFromJourney, getJourneyUserStepByExternalId } from './JourneyRepository'
export class JourneyUserStep extends Model {
user_id!: number
@ -161,6 +162,38 @@ export class JourneyEntrance extends JourneyStep {
}
}
export class JourneyExit extends JourneyStep {
static type = 'exit'
entrance_uuid!: string
event_name!: string
rule?: Rule
parseJson(json: any) {
super.parseJson(json)
this.entrance_uuid = json?.data?.entrance_uuid
this.event_name = json?.data?.event_name
this.rule = json?.data?.rule
}
async process(state: JourneyState, userStep: JourneyUserStep): Promise<void> {
const entrance = await getJourneyUserStepByExternalId(this.journey_id, userStep.user_id, this.entrance_uuid)
if (entrance) await exitUserFromJourney(userStep.user_id, entrance.id, this.journey_id)
super.process(state, userStep)
}
static async create(journeyId: number, db?: Database): Promise<JourneyExit> {
return await JourneyExit.insertAndFetch({
external_id: uuid(),
journey_id: journeyId,
x: 0,
y: 0,
}, db)
}
}
type JourneyDelayFormat = 'duration' | 'time' | 'date'
export class JourneyDelay extends JourneyStep {
static type = 'delay'
@ -556,6 +589,7 @@ export class JourneyEvent extends JourneyStep {
export const journeyStepTypes = [
JourneyEntrance,
JourneyExit,
JourneyDelay,
JourneyAction,
JourneyGate,

View file

@ -118,6 +118,10 @@
"exclusion_lists": "Exclusion Lists",
"existing_team_member": "Existing Team Member",
"exit": "Exit",
"exit_desc": "Remove users from a selected journey flow.",
"exit_step_default": "Exit users from {{name}}",
"exit_entrance_label": "Entrance Flow",
"exit_entrance_desc": "Select the entrance flow to end for the user.",
"experiment": "Experiment",
"experiment_default": "Proportionally split users between paths.",
"experiment_desc": "Randomly send users down different paths.",

View file

@ -118,6 +118,10 @@
"exclusion_lists": "Listas de exclusión",
"existing_team_member": "Miembro del equipo existente",
"exit": "Cerrar",
"exit_desc": "Sacar el usario del camino eligido.",
"exit_step_default": "Sacar el usario del camino {{name}}",
"exit_entrance_label": "Entrada del Camino",
"exit_entrance_desc": "Selectiona la entrada que quieres terminar para el usario.",
"experiment": "Experimento",
"experiment_default": "Divida proporcionalmente a los usuarios entre las rutas.",
"experiment_desc": "Envía aleatoriamente a los usuarios por diferentes rutas.",

View file

@ -1,5 +1,6 @@
import { ComponentType, Dispatch, Key, ReactNode, SetStateAction } from 'react'
import { FieldPath, FieldValues, UseFormReturn } from 'react-hook-form'
import { Node } from 'reactflow'
export type Class<T> = new () => T
@ -312,12 +313,12 @@ export interface JourneyStepTypeEdgeProps<T, E> extends ControlledProps<E> {
export interface JourneyStepType<T = any, E = any> {
name: string
icon: ReactNode
category: 'entrance' | 'delay' | 'flow' | 'action'
category: 'entrance' | 'delay' | 'flow' | 'action' | 'exit'
description: string
Describe?: ComponentType<JourneyStepTypeEditProps<T>>
newData?: () => Promise<T>
newEdgeData?: () => Promise<E>
Edit?: ComponentType<JourneyStepTypeEditProps<T>>
Edit?: ComponentType<JourneyStepTypeEditProps<T> & { nodes: Node[] }>
EditEdge?: ComponentType<JourneyStepTypeEdgeProps<T, E>>
sources?: string[]
multiChildSources?: boolean

View file

@ -84,7 +84,10 @@
height: 16px;
}
.component.entrance .component-handle, .journey-step.entrance .step-header-icon {
.component.entrance .component-handle,
.journey-step.entrance .step-header-icon,
.component.exit .component-handle,
.journey-step.exit .step-header-icon {
background-color: var(--color-red-soft);
color: var(--color-red-hard);
}
@ -108,11 +111,13 @@
color: var(--color-green-hard);
}
.journey-minimap.entrance {
.journey-minimap.entrance,
.journey-minimap.exit {
fill: var(--color-red-soft);
}
.journey-minimap.entrance.selected {
.journey-minimap.entrance.selected,
.journey-minimap.exit.selected {
fill: var(--color-red);
}
@ -182,7 +187,8 @@
opacity: 1;
}
.journey-step.entrance.selected {
.journey-step.entrance.selected,
.journey-step.exit.selected {
border-color: var(--color-red-hard);
}
@ -200,7 +206,7 @@
.journey-step .data-key {
background-color: var(--color-background-soft);
padding: 5px;
padding: 7px 10px;
border-radius: var(--border-radius-inner);
display: flex;
flex-direction: row;

View file

@ -67,6 +67,7 @@ export const stepCategoryColors = {
action: 'blue',
flow: 'green',
delay: 'yellow',
exit: 'red',
}
interface StepUsersProps {
@ -686,6 +687,7 @@ export default function JourneyEditor() {
project,
journey,
stepId: editNode.data.stepId,
nodes,
})
}
</div>

View file

@ -0,0 +1,59 @@
import { JourneyStepType } from '../../../types'
import { CloseIcon } from '../../../ui/icons'
import { useTranslation } from 'react-i18next'
import { SingleSelect } from '../../../ui/form/SingleSelect'
import { snakeToTitle } from '../../../utils'
import { Node, useReactFlow } from 'reactflow'
interface ExitConfig {
entrance_uuid?: string
}
// type StepList = Array<{ id: string, label: string }>
const entranceName = ({ data: { type, name, data_key } }: Node) => {
const stepName = name || snakeToTitle(type)
return data_key
? `${stepName} (${data_key})`
: stepName
}
export const exitStep: JourneyStepType<ExitConfig> = {
name: 'exit',
icon: <CloseIcon />,
category: 'exit',
description: 'exit_desc',
Describe({
value,
}) {
const { t } = useTranslation()
const { getNode } = useReactFlow()
if (!value.entrance_uuid) return <></>
const node = getNode(value.entrance_uuid)
if (!node) return <></>
return (
<div style={{ maxWidth: 300 }}>
{t('exit_step_default', { name: entranceName(node) })}
</div>
)
},
Edit({
onChange,
value,
nodes,
}) {
const { t } = useTranslation()
const steps = nodes
.filter(item => item.data.type === 'entrance')
.map((node) => ({ id: node.id, label: entranceName(node) }))
return (
<SingleSelect
options={steps}
label={t('exit_entrance_label')}
subtitle={t('exit_entrance_desc')}
value={value.entrance_uuid}
onChange={(entrance_uuid) => onChange({ entrance_uuid })}
toValue={x => x.id}
/>
)
},
}

View file

@ -1,5 +1,6 @@
export { entranceStep as entrance } from './Entrance'
export { exitStep as exit } from './Exit'
export { actionStep as action } from './Action'
export { delayStep as delay } from './Delay'
export { gateStep as gate } from './Gate'

View file

@ -0,0 +1,30 @@
---
title: Exit Criteria
sidebar_position: 5
---
# Exit Criteria
## Scenario
You have a long journey for user onboarding that you want to remove a user from if they have performed a certain action. You could achieve this functionality by adding a gate before every single step to check for your exit criteria, but that can be really cumbersome and you are looking to streamline the process.
![Journey Exit Criteria Example](/img/journeys_example_exit.png)
## Steps
1. Create an `Entrance` step:
- Set the name to `Test Entrance`
- Event based
- Listen for any event (e.g. `Enter Test`)
- Multiple entries is not needed
2. Add any set of additional steps to create your journey, for this example, add a delay step of one day.
3. Connect the entrance and delay steps together.
4. Add another `Entrance` step:
- Event based
- Listen for any criteria you want to set as "exit criteria" (e.g. `Exit Test`)
5. Add an exit step
- Select `Test Entrance` as the entrance you want to remove a user from
6. Save
You can now run a user through the flow. When you trigger an event `Enter Test` on the user they will enter into the `Test Entrance` flow of your journey. Next if you trigger an event `Exit Test` the user will be removed from the `Test Entrance` flow with whatever step they are currently at as the latest one.
You can create as many different exit criterias as you want by just creating additional entrances with different criteria and adding additional exits.

View file

@ -9,6 +9,7 @@ sidebar_position: 1
- [**Balancer**](#balancer): Randomly split users across paths and rate limit traffic.
- [**Delay**](#delay): Wait for a duration or until a specific date or time before proceeding.
- [**Entrance**](#entrance): Entry point into the `Journey` that can be triggered by events, schedules, or by other `Journeys` with `Link` steps.
- [**Exit**](#exit): A forced exit from any path a user is in for a selected entrance.
- [**Event**](#event): Trigger an analytic event for the user.
- [**Experiment**](#experiment): Randomly send users down multiple paths for A/B testing purposes.
- [**Gate**](#gate): Split a user between paths depending on the result of a condition.
@ -81,6 +82,12 @@ All users from a provided list will start the journey on a provided interval. Th
- **Interval**: Given the frequency, how often should the entrance happen. A frequency of daily with an interval of two would run every other day.
- **Days**: If set, on what days should the entrance only run on.
## Exit
Sometimes you might wish to remove a user from a given journey. This can be useful for setting up things like exit criteria under which you stop a user from continuing down a journey if they perform a certain action.
#### Parameters
- **Event Name**: Which event should trigger the entrance
- **Entrance Flow**: The entrance for the flow you wish to remove the user from. This can either be the same flow you are currently in, or any flow in the given journey.
## Event
If you have an external analytics provider setup (i.e. Segment) you can trigger external events using this type of step. When triggered, an event will be generated with parameters generated from the template and sent.

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB