feat: show what links could send users into a journey

This commit is contained in:
Chris Anderson 2025-07-24 14:59:21 -05:00
parent d89c9699a3
commit 36f3652dce
8 changed files with 79 additions and 9 deletions

View file

@ -5,8 +5,8 @@ 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, exitUserFromJourney, publishJourney, skipDelayStep } from './JourneyRepository'
import { JourneyStep, JourneyStepMapParams, journeyStepTypes, toJourneyStepMap } from './JourneyStep'
import { createJourney, getJourney, pagedJourneys, setJourneyStepMap, updateJourney, pagedEntrancesByJourney, getEntranceLog, pagedUsersByStep, archiveJourney, deleteJourney, exitUserFromJourney, publishJourney, skipDelayStep, getJourneyStepMapForUI } from './JourneyRepository'
import { JourneyStep, JourneyStepMapParams, journeyStepTypes } from './JourneyStep'
import JourneyUserStep from './JourneyUserStep'
import { User } from '../users/User'
import { RequestError } from '../core/errors'
@ -165,12 +165,13 @@ const journeyStepsParamsSchema: JSONSchemaType<JourneyStepMapParams> = {
}
router.get('/:journeyId/steps', async ctx => {
ctx.body = await getJourneyStepMap(ctx.state.journey!.id)
console.log('run!')
ctx.body = await getJourneyStepMapForUI(ctx.state.journey!)
})
router.put('/:journeyId/steps', async ctx => {
const { steps, children } = await setJourneyStepMap(ctx.state.journey!, validate(journeyStepsParamsSchema, ctx.request.body))
ctx.body = await toJourneyStepMap(steps, children)
await setJourneyStepMap(ctx.state.journey!, validate(journeyStepsParamsSchema, ctx.request.body))
ctx.body = await getJourneyStepMapForUI(ctx.state.journey!)
})
router.post('/:journeyId/duplicate', async ctx => {

View file

@ -3,7 +3,7 @@ import { Database } from '../config/database'
import { RequestError } from '../core/errors'
import { PageParams } from '../core/searchParams'
import Journey, { JourneyParams, UpdateJourneyParams } from './Journey'
import { JourneyStep, JourneyEntrance, JourneyStepMap, toJourneyStepMap, JourneyStepChild } from './JourneyStep'
import { JourneyStep, JourneyEntrance, JourneyStepMap, toJourneyStepMap, JourneyStepChild, journeyStepTypes } from './JourneyStep'
import JourneyUserStep from './JourneyUserStep'
import { createTagSubquery, getTags, setTags } from '../tags/TagService'
import { User } from '../users/User'
@ -138,6 +138,17 @@ export const getJourneyStepChildren = async (journeyId: number, db?: Database):
)
}
export const getJourneyStepMapForUI = async (journey: Journey) => {
const map = await getJourneyStepMap(journey.id)
for (const key of Object.keys(map)) {
const step = map[key]
const type = journeyStepTypes[step.type]
console.log('hydrate!', step.type)
map[key] = await type.hydrate(step, journey)
}
return map
}
export const getJourneyStepMap = async (journeyId: number) => {
const [steps, children] = await Promise.all([
getJourneySteps(journeyId),

View file

@ -1,7 +1,7 @@
import { add, addDays, addHours, addMinutes, getDay, isEqual, isFuture, isPast, parse } from 'date-fns'
import Model from '../core/Model'
import { getCampaign, getCampaignSend, triggerCampaignSend } from '../campaigns/CampaignService'
import { crossTimezoneCopy, random, snakeCase, uuid } from '../utilities'
import { crossTimezoneCopy, pick, random, snakeCase, uuid } from '../utilities'
import { Database } from '../config/database'
import { compileTemplate } from '../render'
import { logger } from '../config/logger'
@ -14,6 +14,7 @@ import { JourneyState } from './JourneyState'
import { EventPostJob, UserPatchJob } from '../jobs'
import { exitUserFromJourney, getJourneyUserStepByExternalId } from './JourneyRepository'
import JourneyUserStep from './JourneyUserStep'
import Journey from './Journey'
export class JourneyStepChild extends Model {
@ -64,6 +65,11 @@ export class JourneyStep extends Model {
async next(state: JourneyState): Promise<undefined | number> {
return state.childrenOf(this.id)[0]?.child_id
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
static async hydrate(step: JourneyStepMapItem, journey: Journey): Promise<JourneyStepMapItem> {
return Promise.resolve(step)
}
}
export class JourneyEntrance extends JourneyStep {
@ -132,6 +138,24 @@ export class JourneyEntrance extends JourneyStep {
y: 0,
}, db)
}
static async hydrate(step: JourneyStep, journey: Journey) {
if (!step.data) return step
if (step.data.trigger !== 'none') return step
const references = await Journey.all(
qb => qb
.where('journey_steps.type', 'link')
.whereJsonPath('journey_steps.data', '$.target_id', '=', journey.id)
.where('journeys.status', 'live')
.whereNull('journeys.deleted_at')
.whereNull('journeys.parent_id')
.leftJoin('journey_steps', 'journey_steps.journey_id', '=', 'journeys.id')
.select('journeys.name', 'journeys.id'),
)
step.data.references = references.map(item => pick(item, ['id', 'name']))
return step
}
}
export class JourneyExit extends JourneyStep {
@ -598,7 +622,7 @@ export const journeyStepTypes = [
return a
}, {})
interface JourneyStepMapItem {
export interface JourneyStepMapItem {
type: string
name?: string
data?: Record<string, unknown>

View file

@ -71,5 +71,4 @@ export default class ScheduledEntranceJob extends Job {
await App.main.queue.enqueueBatch(items.map(({ id }) => JourneyProcessJob.from({ entrance_id: id })))
})
}
}

View file

@ -127,6 +127,7 @@
"entrance_multiple_entries": "Multiple Entries",
"entrance_multiple_entries_desc": "Should people enter this journey multiple times?",
"entrance_occurs": " occurs",
"entrance_links": "Users can enter this journey by being sent from links in these journeys:",
"entrance_simultaneous_entries": "Simultaneous Entries",
"entrance_simultaneous_entries_desc": "If enabled, user could join this journey multiple times before finishing previous ones.",
"entrance_trigger": "Trigger Entrance",

View file

@ -57,4 +57,10 @@
.ui-tag.tiny .icon {
width: 12px;
height: 12px;
}
.ui-tag-list {
display: flex;
gap: 10px;
flex-wrap: wrap;
}

View file

@ -418,4 +418,14 @@
display: inline-flex;
align-items: center;
justify-content: center;
}
.journey-step.entrance {
max-width: 325px;
}
.journey-step.entrance .references {
display: flex;
gap: 10px;
flex-direction: column;
}

View file

@ -16,6 +16,8 @@ import { env } from '../../../config/env'
import { useTranslation, Trans } from 'react-i18next'
import { createEventRule, isEventWrapper } from '../../users/rules/RuleHelpers'
import { ruleDescription } from '../../users/rules/RuleDescriptions'
import { Tag } from '../../../ui'
import { Link } from 'react-router'
interface EntranceConfig {
trigger: 'none' | 'event' | 'schedule'
@ -29,6 +31,8 @@ interface EntranceConfig {
// schedule based
list_id?: number
schedule?: string
references?: Array<{ id: number, name: string }>
}
const triggerOptions = [
@ -79,6 +83,7 @@ export const entranceStep: JourneyStepType<EntranceConfig> = {
rule,
list_id,
schedule,
references = [],
},
}) {
const { t } = useTranslation()
@ -138,6 +143,19 @@ export const entranceStep: JourneyStepType<EntranceConfig> = {
)
}
if (references.length) {
return <div className="references">
<span>{t('entrance_links')}</span>
<div className="ui-tag-list">
{references.map(journey =>
<Tag variant="plain" key={journey.id}>
<Link to={`../journeys/${journey.id}`}>{journey.name}</Link>
</Tag>,
)}
</div>
</div>
}
return <>{t('entrance_empty')}</>
},
Edit({ onChange, project: { id: projectId }, journey: { id: journeyId }, stepId, value }) {