Journey Versioning (#674)

This commit is contained in:
Chris Anderson 2025-06-23 09:44:11 -05:00 committed by GitHub
parent a2e3677508
commit c0ae1bc141
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 543 additions and 298 deletions

View file

@ -0,0 +1,32 @@
exports.up = async function(knex) {
await knex.schema.table('journeys', function(table) {
table.integer('parent_id')
.unsigned()
.nullable()
.references('id')
.inTable('journeys')
.onDelete('CASCADE')
table.string('status')
})
await knex('journeys').update({ status: 'draft' }).where('published', 0)
await knex('journeys').update({ status: 'live' }).where('published', 1)
await knex.schema.table('journeys', function(table) {
table.dropColumn('published')
})
}
exports.down = async function(knex) {
await knex.schema.table('journeys', function(table) {
table.boolean('published')
})
await knex('journeys').update({ published: 0 }).where('status', 'draft')
await knex('journeys').update({ published: 1 }).where('status', 'live')
await knex.schema.table('journeys', function(table) {
table.dropColumn('parent_id')
table.dropColumn('published')
})
}

View file

@ -3,11 +3,15 @@ import { User } from '../users/User'
import { setJourneyStepMap } from './JourneyRepository'
import { JourneyStepMapParams } from './JourneyStep'
type JourneyStatus = 'draft' | 'live' | 'off'
export default class Journey extends Model {
name!: string
project_id!: number
parent_id?: number
draft_id?: number
description?: string
published!: boolean
status!: JourneyStatus
deleted_at?: Date
tags?: string[]
stats?: Record<string, number>
@ -19,14 +23,14 @@ export default class Journey extends Model {
const journey = await this.insertAndFetch({
project_id,
name,
published: true,
status: 'live',
})
const { steps, children } = await setJourneyStepMap(journey, stepMap)
return { journey, steps, children }
}
}
export type JourneyParams = Omit<Journey, ModelParams | 'deleted_at' | 'stats' | 'stats_at'>
export type JourneyParams = Omit<Journey, ModelParams | 'parent_id' | 'draft_id' | 'deleted_at' | 'stats' | 'stats_at'>
export type UpdateJourneyParams = Omit<JourneyParams, 'project_id'>
export interface JourneyEntranceTriggerParams {

View file

@ -5,8 +5,9 @@ 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 } from './JourneyRepository'
import { JourneyStep, JourneyStepMapParams, JourneyUserStep, journeyStepTypes, toJourneyStepMap } from './JourneyStep'
import { createJourney, getJourneyStepMap, getJourney, pagedJourneys, setJourneyStepMap, updateJourney, pagedEntrancesByJourney, getEntranceLog, pagedUsersByStep, archiveJourney, deleteJourney, exitUserFromJourney, publishJourney } from './JourneyRepository'
import { JourneyStep, JourneyStepMapParams, journeyStepTypes, toJourneyStepMap } from './JourneyStep'
import JourneyUserStep from './JourneyUserStep'
import { User } from '../users/User'
import { RequestError } from '../core/errors'
import JourneyError from './JourneyError'
@ -45,8 +46,9 @@ const journeyParams: JSONSchemaType<JourneyParams> = {
},
nullable: true,
},
published: {
type: 'boolean',
status: {
type: 'string',
enum: ['off', 'draft', 'live'],
},
},
additionalProperties: false,
@ -175,6 +177,14 @@ router.post('/:journeyId/duplicate', async ctx => {
ctx.body = await duplicateJourney(ctx.state.journey!)
})
router.post('/:journeyId/version', async ctx => {
ctx.body = await duplicateJourney(ctx.state.journey!, true)
})
router.post('/:journeyId/publish', async ctx => {
ctx.body = await publishJourney(ctx.state.journey!)
})
router.get('/:journeyId/entrances', async ctx => {
const params = extractQueryParams(ctx.query, searchParamsSchema)
ctx.body = await pagedEntrancesByJourney(ctx.state.journey!.id, params)

View file

@ -1,7 +1,7 @@
import { Job } from '../queue'
import App from '../app'
import { chunk } from '../utilities'
import { JourneyUserStep } from './JourneyStep'
import JourneyUserStep from './JourneyUserStep'
import Journey from './Journey'
import JourneyProcessJob from './JourneyProcessJob'
@ -9,15 +9,14 @@ interface JourneyDelayJobParams {
journey_id: number
}
/**
* A job to be run on a schedule to queue up all journeys that need
* to be rechecked
*/
export default class JourneyDelayJob extends Job {
static $name = 'journey_delay_job'
static async enqueueActive(app: App) {
const query = Journey.query(app.db).select('id').where('published', true)
const query = Journey.query(app.db)
.select('id')
.whereNot('status', 'off')
.whereNull('deleted_at')
await chunk<{ id: number }>(query, app.queue.batchSize, async journeys => {
app.queue.enqueueBatch(journeys.map(({ id }) => JourneyDelayJob.from(id)))
})

View file

@ -1,7 +1,7 @@
import { Job } from '../queue'
import Journey from './Journey'
import { JourneyState } from './JourneyState'
import { JourneyUserStep } from './JourneyStep'
import JourneyUserStep from './JourneyUserStep'
interface JourneyProcessParams {
entrance_id: number
@ -17,16 +17,15 @@ export default class JourneyProcessJob extends Job {
static async handler({ entrance_id }: JourneyProcessParams) {
const entrance = await JourneyUserStep.find(entrance_id)
if (!entrance) return
// invalid entrance id
if (!entrance) {
return
}
// make sure journey is still active
if (!await Journey.exists(qb => qb.where('id', entrance.journey_id).where('published', true))) {
return
}
// Make sure journey is still active
const exists = await Journey.exists(
qb => qb.where('id', entrance.journey_id)
.whereNot('status', 'off')
.whereNull('deleted_at'),
)
if (!exists) return
await JourneyState.resume(entrance)
}

View file

@ -3,16 +3,20 @@ import { Database } from '../config/database'
import { RequestError } from '../core/errors'
import { PageParams } from '../core/searchParams'
import Journey, { JourneyParams, UpdateJourneyParams } from './Journey'
import { JourneyStep, JourneyEntrance, JourneyUserStep, JourneyStepMap, toJourneyStepMap, JourneyStepChild } from './JourneyStep'
import { JourneyStep, JourneyEntrance, JourneyStepMap, toJourneyStepMap, JourneyStepChild } from './JourneyStep'
import JourneyUserStep from './JourneyUserStep'
import { createTagSubquery, getTags, setTags } from '../tags/TagService'
import { User } from '../users/User'
import { getProject } from '../projects/ProjectService'
import { duplicateJourney } from './JourneyService'
export const pagedJourneys = async (params: PageParams, projectId: number) => {
const result = await Journey.search(
{ ...params, fields: ['name'] },
qb => {
qb.where({ project_id: projectId }).whereNull('deleted_at')
qb.where({ project_id: projectId })
.whereNull('deleted_at')
.whereNull('parent_id')
if (params.tag?.length) qb.whereIn('id', createTagSubquery(Journey, projectId, params.tag))
return qb
},
@ -27,7 +31,11 @@ export const pagedJourneys = async (params: PageParams, projectId: number) => {
}
export const allJourneys = async (projectId: number): Promise<Journey[]> => {
return await Journey.all(qb => qb.where('project_id', projectId))
return await Journey.all(qb => qb
.where('project_id', projectId)
.whereNull('parent_id')
.whereNull('deleted_at'),
)
}
export const createJourney = async (projectId: number, { tags, ...params }: JourneyParams): Promise<Journey> => {
@ -35,10 +43,11 @@ export const createJourney = async (projectId: number, { tags, ...params }: Jour
const journey = await Journey.insertAndFetch({
...params,
status: 'draft',
project_id: projectId,
}, trx)
// auto-create entrance step
// Auto-create entrance step
await JourneyEntrance.create(journey.id, undefined, trx)
if (tags?.length) {
@ -58,6 +67,7 @@ export const getJourney = async (id: number, projectId: number): Promise<Journey
const journey = await Journey.find(id, qb => qb.where('project_id', projectId))
if (!journey) throw new RequestError('Journey not found', 404)
journey.tags = await getTags(Journey.tableName, [journey.id]).then(m => m.get(journey.id)) ?? []
journey.draft_id = await getDraftJourneyId(journey.id, projectId)
return journey
}
@ -83,7 +93,33 @@ export const deleteJourney = async (id: number, projectId: number): Promise<void
}
export const archiveJourney = async (id: number, projectId: number): Promise<void> => {
await Journey.archive(id, qb => qb.where('project_id', projectId), { published: false })
await Journey.archive(id, qb => qb.where('project_id', projectId), { status: 'off', deleted_at: new Date() })
await Journey.update(qb => qb.where('parent_id', id).where('project_id', projectId), { deleted_at: new Date() })
}
export const publishJourney = async (journey: Journey): Promise<void> => {
// If we are dealing with a draft, utilize it otherwise create
// a new draft for state management
const parent = journey.parent_id
? await getJourney(journey.parent_id, journey.project_id)
: await duplicateJourney(journey, true)
// Set the parent step map to match the draft journey
const steps = await getJourneyStepMap(journey.id)
await setJourneyStepMap(parent, steps)
await Journey.update(qb => qb.whereIn('id', [journey.id, parent.id]), {
status: 'live',
})
}
export const getDraftJourneyId = async (journeyId: number, projectId: number): Promise<number | undefined> => {
const journey = await Journey.first(qb => qb.where('parent_id', journeyId)
.where('project_id', projectId)
.where('status', 'draft')
.orderBy('id', 'desc'),
)
return journey?.id
}
export const getJourneySteps = async (journeyId: number, db?: Database): Promise<JourneyStep[]> => {

View file

@ -1,6 +1,7 @@
import { User } from '../users/User'
import { getEntranceSubsequentSteps, getJourney, getJourneyStepMap, getJourneySteps, setJourneyStepMap } from './JourneyRepository'
import { JourneyEntrance, JourneyStep, JourneyStepMap, JourneyUserStep } from './JourneyStep'
import { JourneyEntrance, JourneyStep, JourneyStepMap } from './JourneyStep'
import JourneyUserStep from './JourneyUserStep'
import { UserEvent } from '../users/UserEvent'
import App from '../app'
import { Rule, RuleTree } from '../rules/Rule'
@ -14,12 +15,17 @@ import { pick, uuid } from '../utilities'
export const enterJourneysFromEvent = async (event: UserEvent, user?: User) => {
// look up all entrances in published journeys
// Look up all entrances in live journeys
const entrances = await JourneyEntrance.all(q => q
.join('journeys', 'journey_steps.journey_id', '=', 'journeys.id')
.where('journeys.project_id', event.project_id)
.where('journeys.published', true)
// Exclude journeys that are not live or the root journey
.where('journeys.status', 'live')
.whereNull('journeys.parent_id')
.whereNull('journeys.deleted_at')
// Filter down the step type to be an entrance
.where('journey_steps.type', JourneyEntrance.type)
.whereJsonPath('journey_steps.data', '$.trigger', '=', 'event')
.whereJsonPath('journey_steps.data', '$.event_name', '=', event.name),
@ -99,20 +105,20 @@ export const loadUserStepDataMap = async (referenceId: number | string) => {
export const triggerEntrance = async (journey: Journey, payload: JourneyEntranceTriggerParams) => {
// look up target entrance step
// Look up target entrance step
const step = await JourneyStep.first(qb => qb
.where('journey_id', journey.id)
.where('id', payload.entrance_id))
// make sure target step is actually an entrance
// Make sure target step is actually an entrance
if (!step || step.type !== JourneyEntrance.type) {
throw new RequestError(JourneyError.JourneyStepDoesNotExist)
}
// extract top-level vs custom properties user fields
// Extract top-level vs custom properties user fields
const { external_id, email, phone, device_token, locale, timezone, ...data } = payload.user
// create the user synchronously if new
// Create the user synchronously if new
const { user, event } = await EventPostJob.from({
project_id: journey.project_id,
event: {
@ -130,7 +136,7 @@ export const triggerEntrance = async (journey: Journey, payload: JourneyEntrance
},
}).handle<{ user: User, event: UserEvent }>()
// create new entrance
// Create new entrance
const entrance_id = await JourneyUserStep.insert({
journey_id: journey.id,
user_id: user.id,
@ -141,31 +147,40 @@ export const triggerEntrance = async (journey: Journey, payload: JourneyEntrance
},
})
// trigger async processing
// Trigger async processing
await JourneyProcessJob.from({ entrance_id }).queue()
}
export const duplicateJourney = async (journey: Journey) => {
export const duplicateJourney = async (journey: Journey, asChild = false) => {
const params: Partial<Journey> = pick(journey, ['project_id', 'name', 'description'])
params.name = `Copy of ${params.name}`
params.published = false
const newJourney = await Journey.insertAndFetch(params)
const newJourney = await Journey.insertAndFetch({
...params,
name: asChild ? params.name : `Copy of ${params.name}`,
status: 'draft',
parent_id: asChild ? journey.parent_id ?? journey.id : undefined,
})
// If there is a parent record, the child steps must match
// UUIDs otherwise remap them for the separate duplicate journey
const steps = await getJourneyStepMap(journey.id)
const newSteps: JourneyStepMap = {}
const stepKeys = Object.keys(steps)
const uuidMap = stepKeys.reduce((acc, curr) => {
acc[curr] = uuid()
return acc
}, {} as Record<string, string>)
for (const key of stepKeys) {
const step = steps[key]
newSteps[uuidMap[key]] = {
...step,
children: step.children?.map(({ external_id, ...rest }) => ({ external_id: uuidMap[external_id], ...rest })),
if (asChild) {
await setJourneyStepMap(newJourney, steps)
} else {
const newSteps: JourneyStepMap = {}
const stepKeys = Object.keys(steps)
const uuidMap = stepKeys.reduce((acc, curr) => {
acc[curr] = uuid()
return acc
}, {} as Record<string, string>)
for (const key of stepKeys) {
const step = steps[key]
newSteps[uuidMap[key]] = {
...step,
children: step.children?.map(({ external_id, ...rest }) => ({ external_id: uuidMap[external_id], ...rest })),
}
}
await setJourneyStepMap(newJourney, newSteps)
}
await setJourneyStepMap(newJourney, newSteps)
return await getJourney(newJourney.id, journey.project_id)
}

View file

@ -8,7 +8,8 @@ import { UserEvent } from '../users/UserEvent'
import { getUserEventsForRules } from '../users/UserRepository'
import { shallowEqual } from '../utilities'
import { getEntranceSubsequentSteps, getJourneyStepChildren, getJourneySteps } from './JourneyRepository'
import { JourneyGate, JourneyStep, JourneyStepChild, JourneyUserStep, journeyStepTypes } from './JourneyStep'
import { JourneyGate, JourneyStep, JourneyStepChild, journeyStepTypes } from './JourneyStep'
import JourneyUserStep from './JourneyUserStep'
type JobOrJobFunc = Job | ((state: JourneyState) => Promise<Job | undefined>)

View file

@ -1,6 +1,7 @@
import { Job } from '../queue'
import Journey from './Journey'
import { JourneyStep, JourneyUserStep } from './JourneyStep'
import { JourneyStep } from './JourneyStep'
import JourneyUserStep from './JourneyUserStep'
interface JourneyStatsParams {
journey_id: number

View file

@ -13,35 +13,7 @@ import { rrulestr } from 'rrule'
import { JourneyState } from './JourneyState'
import { EventPostJob, UserPatchJob } from '../jobs'
import { exitUserFromJourney, getJourneyUserStepByExternalId } from './JourneyRepository'
export class JourneyUserStep extends Model {
user_id!: number
type!: string
journey_id!: number
step_id!: number
delay_until?: Date
entrance_id?: number
ended_at?: Date
data?: Record<string, unknown>
ref?: string
step?: JourneyStep
static tableName = 'journey_user_step'
static jsonAttributes = ['data']
static virtualAttributes = ['step']
static getDataMap(steps: JourneyStep[], userSteps: JourneyUserStep[]) {
return userSteps.reduceRight<Record<string, unknown>>((a, { data, step_id }) => {
const step = steps.find(s => s.id === step_id)
if (data && step && !a[step.dataKey]) {
a[step.dataKey] = data
}
return a
}, {})
}
}
import JourneyUserStep from './JourneyUserStep'
export class JourneyStepChild extends Model {
@ -423,7 +395,7 @@ export class JourneyLink extends JourneyStep {
.join('journeys', 'journey_id', '=', 'journeys.id')
.where('journeys.id', this.target_id)
.where('journeys.project_id', state.user.project_id)
.where('journeys.published', true)
.where('journeys.status', 'live')
.whereNull('journeys.deleted_at')
.where('type', 'entrance'),
)

View file

@ -0,0 +1,31 @@
import Model from '../core/Model'
import { type JourneyStep } from './JourneyStep'
export default class JourneyUserStep extends Model {
user_id!: number
type!: string
journey_id!: number
step_id!: number
delay_until?: Date
entrance_id?: number
ended_at?: Date
data?: Record<string, unknown> | null
ref?: string
step?: JourneyStep
static tableName = 'journey_user_step'
static jsonAttributes = ['data']
static virtualAttributes = ['step']
static getDataMap(steps: JourneyStep[], userSteps: JourneyUserStep[]) {
return userSteps.reduceRight<Record<string, unknown>>((a, { data, step_id }) => {
const step = steps.find(s => s.id === step_id)
if (data && step && !a[step.dataKey]) {
a[step.dataKey] = data
}
return a
}, {})
}
}

View file

@ -1,5 +1,6 @@
import { Job } from '../queue'
import { JourneyEntrance, JourneyUserStep } from './JourneyStep'
import { JourneyEntrance } from './JourneyStep'
import JourneyUserStep from './JourneyUserStep'
import { chunk, Chunker, uuid } from '../utilities'
import App from '../app'
import JourneyProcessJob from './JourneyProcessJob'

View file

@ -10,11 +10,16 @@ export default class ScheduledEntranceOrchestratorJob extends Job {
static async handler() {
// look up all scheduler entrances
// Look up all scheduler entrances
const entrances = await JourneyEntrance.all(q => q
.join('journeys', 'journey_steps.journey_id', '=', 'journeys.id')
.where('journeys.published', true)
// Exclude journeys that are not live or the root journey
.where('journeys.status', 'live')
.whereNull('journeys.parent_id')
.whereNull('journeys.deleted_at')
// Filter down the step type to be an entrance
.where('journey_steps.type', JourneyEntrance.type)
.whereJsonPath('journey_steps.data', '$.trigger', '=', 'schedule')
.whereJsonPath('journey_steps.data', '$.multiple', '=', true)

View file

@ -5,7 +5,8 @@ import { UserEvent } from '../../users/UserEvent'
import Journey from '../Journey'
import { setupProject, setupTestJourney } from './helpers'
import { enterJourneysFromEvent } from '../JourneyService'
import { JourneyStep, JourneyStepMapParams, JourneyUserStep } from '../JourneyStep'
import { JourneyStep, JourneyStepMapParams } from '../JourneyStep'
import JourneyUserStep from '../JourneyUserStep'
import { make } from '../../rules/RuleEngine'
import { uuid } from '../../utilities'
import { JourneyState } from '../JourneyState'

View file

@ -1,5 +1,6 @@
import addMinutes from 'date-fns/addMinutes'
import { JourneyDelay, JourneyUserStep } from '../JourneyStep'
import { JourneyDelay } from '../JourneyStep'
import JourneyUserStep from '../JourneyUserStep'
import { setupTestJourney } from './helpers'
import { JourneyState } from '../JourneyState'

View file

@ -1,6 +1,7 @@
import Project from '../../projects/Project'
import Journey from '../Journey'
import { JourneyEntrance, JourneyUserStep } from '../JourneyStep'
import { JourneyEntrance } from '../JourneyStep'
import JourneyUserStep from '../JourneyUserStep'
import { Frequency, RRule } from 'rrule'
import { addDays } from 'date-fns'
import { User } from '../../users/User'

View file

@ -1,4 +1,4 @@
import { JourneyUserStep } from '../journey/JourneyStep'
import JourneyUserStep from '../journey/JourneyUserStep'
import App from '../app'
import Campaign from '../campaigns/Campaign'
import { getCampaignSend, updateSendState } from '../campaigns/CampaignService'

View file

@ -165,6 +165,12 @@
"invite_to_project": "Invite to Project",
"joined_list_at": "Joined List At",
"journey": "Journey",
"journey_draft_create": "Create Draft",
"journey_draft_edit": "Edit Draft",
"journey_draft_save": "Save Draft",
"journey_add_user_to_entrance": "Add User to Entrance",
"journey_campaign_create_preview": "Create campaign to preview",
"journey_publish_confirmation": "Are you sure you want to publish this draft? It will immediately go live for all users.",
"journey_saved": "Journey Saved!",
"journeys": "Journeys",
"key": "Key",
@ -189,6 +195,7 @@
"list_save": "Save List",
"list": "List",
"lists": "Lists",
"live": "Live",
"load_user": "Load User",
"loading": "Loading",
"locale": "Locale",
@ -217,6 +224,7 @@
"no_providers": "No Providers",
"no_template_alert_body": "There are no templates yet for this campaign. Add a locale above or use the button below to get started.",
"now": "Now",
"off": "Off",
"onboarding_installation_success": "Looks like everything is working with the installation! Now let's get you setup and ready to run some campaigns!",
"onboarding_project_setup_description": "At Parcelvoy, projects represent a single workspace for sending messages. You can use them for creating staging environments, isolating different clients, etc. Let's create your first one to get you started!",
"onboarding_project_setup_title": "Project Setup",

View file

@ -178,6 +178,12 @@ const api = {
duplicate: async (projectId: number | string, journeyId: number | string) => await client
.post<Campaign>(`${projectUrl(projectId)}/journeys/${journeyId}/duplicate`)
.then(r => r.data),
version: async (projectId: number | string, journeyId: number | string) => await client
.post<Journey>(`${projectUrl(projectId)}/journeys/${journeyId}/version`)
.then(r => r.data),
publish: async (projectId: number | string, journeyId: number | string) => await client
.post<Journey>(`${projectUrl(projectId)}/journeys/${journeyId}/publish`)
.then(r => r.data),
steps: {
get: async (projectId: number | string, journeyId: number | string) => await client
.get<JourneyStepMap>(`/admin/projects/${projectId}/journeys/${journeyId}/steps`)
@ -197,6 +203,11 @@ const api = {
.get<JourneyEntranceDetail>(`${projectUrl(projectId)}/journeys/entrances/${entranceId}`)
.then(r => r.data),
},
users: {
trigger: async (projectId: number | string, journeyId: number | string, entranceId: number | string, user: User) => await client
.post<JourneyEntranceDetail>(`${projectUrl(projectId)}/journeys/${journeyId}/trigger`, { entrance_id: entranceId, user: { external_id: user.external_id } })
.then(r => r.data),
},
},
templates: {

View file

@ -274,11 +274,15 @@ export type DynamicList = List & { type: 'dynamic' }
export type ListCreateParams = Pick<List, 'name' | 'rule' | 'type' | 'tags' | 'is_visible'>
export type ListUpdateParams = Pick<List, 'name' | 'rule' | 'tags'> & { published?: boolean }
type JourneyStatus = 'draft' | 'live' | 'off'
export interface Journey {
id: number
parent_id?: number
draft_id?: number
name: string
description?: string
published: boolean
status: JourneyStatus
tags?: string[]
created_at: string
updated_at: string

View file

@ -3,9 +3,11 @@ import { useEffect, useRef } from 'react'
interface IframeProps {
content: string
fullHeight?: boolean
allowScroll?: boolean
width?: string
}
export default function Iframe({ content, fullHeight = false }: IframeProps) {
export default function Iframe({ content, fullHeight = false, allowScroll = true, width }: IframeProps) {
const ref = useRef<HTMLIFrameElement>(null)
const setBody = () => {
@ -26,9 +28,10 @@ export default function Iframe({ content, fullHeight = false }: IframeProps) {
<iframe
src="about:blank"
frameBorder="0"
scrolling={allowScroll ? 'yes' : 'no'}
sandbox="allow-scripts allow-same-origin"
ref={ref}
style={{ width: '100%' }}
style={width ? { width } : {}}
onLoad={() => setBody()} />
)
}

View file

@ -15,6 +15,11 @@
flex-grow: 1;
}
.preview.small {
height: 200px;
display: block;
}
.email-frame {
display: flex;
flex-direction: column;
@ -119,7 +124,8 @@
grid-template-columns: 40px auto;
grid-template-rows: auto auto;
grid-template-areas: "icon header" "icon body";
gap: 10px;
align-items: center;
gap: 0px 10px;
font-size: 13px;
}
@ -149,4 +155,22 @@
.webhook-frame {
padding: 20px;
width: 100%;
}
.preview.small .email-frame iframe {
width: 200%;
height: 200%;
transform: scale(0.5);
transform-origin: top left;
}
.preview.small .phone-frame,
.preview.small .text-frame {
margin: 0;
min-height: 250px;
width: 125%;
border: none;
border-radius: 0;
transform: scale(0.8);
transform-origin: top left;
}

View file

@ -5,12 +5,14 @@ import './Preview.css'
import { ReactNode, useContext } from 'react'
import { ProjectContext } from '../contexts'
import JsonPreview from './JsonPreview'
import clsx from 'clsx'
interface PreviewProps {
template: Pick<Template, 'type' | 'data'>
size?: 'small' | 'large'
}
export default function Preview({ template }: PreviewProps) {
export default function Preview({ template, size = 'large' }: PreviewProps) {
const [project] = useContext(ProjectContext)
const { data, type } = template
@ -26,7 +28,7 @@ export default function Preview({ template }: PreviewProps) {
</div>
)
}
<Iframe content={data.html ?? ''} />
<Iframe content={data.html ?? ''} allowScroll={size !== 'small'} />
</div>
)
} else if (type === 'text') {
@ -63,7 +65,7 @@ export default function Preview({ template }: PreviewProps) {
}
return (
<section className="preview">
<section className={clsx('preview', size)}>
{preview}
</section>
)

View file

@ -1,4 +1,4 @@
import { useContext, useMemo, useState, useEffect, useCallback } from 'react'
import { useContext, useMemo, useState, useEffect } from 'react'
import { CampaignContext, LocaleContext, ProjectContext } from '../../contexts'
import './CampaignPreview.css'
import api from '../../api'
@ -11,59 +11,13 @@ import Alert from '../../ui/Alert'
import Button from '../../ui/Button'
import { Column, Columns } from '../../ui/Columns'
import TextInput from '../../ui/form/TextInput'
import ButtonGroup from '../../ui/ButtonGroup'
import Modal, { ModalProps } from '../../ui/Modal'
import { SearchTable, useSearchTableState } from '../../ui/SearchTable'
import { ChannelType, TemplateProofParams, User } from '../../types'
import { ChannelType, TemplateProofParams } from '../../types'
import FormWrapper from '../../ui/form/FormWrapper'
import SourceEditor from '../../ui/SourceEditor'
import { useTranslation } from 'react-i18next'
import { flattenUser } from '../../ui/utils'
interface UserLookupProps extends Omit<ModalProps, 'title'> {
onSelected: (user: User) => void
}
const UserLookup = ({ open, onClose, onSelected }: UserLookupProps) => {
const [project] = useContext(ProjectContext)
const { t } = useTranslation()
const state = useSearchTableState(useCallback(async params => await api.users.search(project.id, params), [project]))
const [value, setValue] = useState<string>('')
return <Modal
title={t('user_lookup')}
open={open}
onClose={onClose}
size="regular">
<div className="user-lookup">
<ButtonGroup>
<TextInput<string>
name="search"
placeholder={(t('enter_email'))}
hideLabel={true}
value={value}
onChange={setValue} />
<Button
variant="secondary"
onClick={() => state.setParams({
...state.params,
q: value,
})}>{t('search')}</Button>
</ButtonGroup>
<SearchTable
{...state}
columns={[
{ key: 'full_name', title: 'Name' },
{ key: 'email' },
{ key: 'phone' },
]}
onSelectRow={(user) => {
onSelected(user)
onClose(false)
}} />
</div>
</Modal>
}
import { UserLookup } from '../users/UserLookup'
interface SendProofProps extends Omit<ModalProps, 'title'> {
type: ChannelType

View file

@ -286,6 +286,7 @@
background: var(--color-background-soft);
border-radius: var(--border-radius-inner);
padding: 5px 10px;
cursor: pointer;
}
.journey-step-header .step-header-stats:hover .stat {

View file

@ -30,7 +30,7 @@ import ReactFlow, {
useReactFlow,
} from 'reactflow'
import { JourneyContext, ProjectContext } from '../../contexts'
import { camelToTitle, createComparator, createUuid } from '../../utils'
import { createComparator, createUuid } from '../../utils'
import * as journeySteps from './steps/index'
import clsx from 'clsx'
import api from '../../api'
@ -46,10 +46,8 @@ import { JourneyForm } from './JourneyForm'
import { ActionStepIcon, CheckCircleIcon, CloseIcon, CopyIcon, DelayStepIcon, EntranceStepIcon, ForbiddenIcon, KeyIcon } from '../../ui/icons'
import Tag from '../../ui/Tag'
import TextInput from '../../ui/form/TextInput'
import { SearchTable } from '../../ui'
import { useSearchTableState } from '../../ui/SearchTable'
import { typeVariants } from './EntranceDetails'
import { useTranslation } from 'react-i18next'
import { JourneyStepUsers } from './JourneyStepUsers'
const getStepType = (type: string) => (type ? journeySteps[type as keyof typeof journeySteps] as JourneyStepType : null) ?? null
@ -70,71 +68,6 @@ export const stepCategoryColors = {
exit: 'red',
}
interface StepUsersProps {
stepId: number
entrance?: boolean
}
function StepUsers({ entrance, stepId }: StepUsersProps) {
const { t } = useTranslation()
const [{ id: projectId }] = useContext(ProjectContext)
const [{ id: journeyId }] = useContext(JourneyContext)
const state = useSearchTableState(useCallback(async params => await api.journeys.steps.searchUsers(projectId, journeyId, stepId, params), [projectId, journeyId, stepId]), {
limit: 10,
})
return (
<>
<SearchTable
{...state}
columns={[
{
key: 'name',
title: t('name'),
cell: ({ item }) => item.user!.full_name ?? '-',
},
{
key: 'external_id',
title: t('external_id'),
cell: ({ item }) => item.user?.external_id ?? '-',
},
{
key: 'email',
title: t('email'),
cell: ({ item }) => item.user?.email ?? '-',
},
{
key: 'phone',
title: t('phone'),
cell: ({ item }) => item.user?.phone ?? '-',
},
{
key: 'type',
title: t('type'),
cell: ({ item }) => (
<Tag variant={typeVariants[item.type]}>
{camelToTitle(item.type)}
</Tag>
),
},
{
key: 'created_at',
title: t('step_date'),
},
{
key: 'delay_until',
title: t('delay_until'),
cell: ({ item }) => item.delay_until,
},
]}
onSelectRow={entrance ? ({ id }) => window.open(`/projects/${projectId}/entrances/${id}`, '_blank') : undefined}
/>
</>
)
}
function JourneyStepNode({
id,
data: {
@ -354,7 +287,6 @@ const edgeTypes: EdgeTypes = {
}
const DATA_FORMAT = 'application/parcelvoy-journey-step'
const STEP_STYLE = 'smoothstep'
interface CreateEdgeParams {
@ -497,6 +429,8 @@ export default function JourneyEditor() {
const [edges, setEdges, onEdgesChange] = useEdgesState([])
const journeyId = journey.id
const isDraft = journey.status === 'draft'
const draftId = journey.draft_id
const loadSteps = useCallback(async () => {
const steps = await api.journeys.steps.get(project.id, journeyId)
@ -557,6 +491,33 @@ export default function JourneyEditor() {
}
}, [project, journey, nodes, edges])
const createDraft = async () => {
setSaving(true)
try {
const newDraft = await api.journeys.version(project.id, journey.id)
setJourney(newDraft)
editDraft(newDraft.id)
} finally {
setSaving(false)
}
}
const editDraft = (id: number) => {
window.location.href = `/projects/${project.id}/journeys/${id}`
}
const publishJourney = async () => {
if (!confirm(t('journey_publish_confirmation'))) return
setSaving(true)
try {
await api.journeys.publish(project.id, journey.id)
window.location.href = `/projects/${project.id}/journeys/${journey.parent_id ?? journey.id}`
toast.success(t('journey_published'))
} finally {
setSaving(false)
}
}
const onConnect = useCallback(async (connection: Connection) => {
const sourceNode = nodes.find(n => n.id === connection.source)
const data = await getStepType(sourceNode?.data.type)?.newEdgeData?.() ?? {}
@ -614,9 +575,7 @@ export default function JourneyEditor() {
}, [setNodes, flowInstance, project, journey])
const [editOpen, setEditOpen] = useState(false)
const selected = nodes.filter(n => n.selected)
const editNode = nodes.find(n => n.data.editing)
const onNodeDoubleClick = useCallback<NodeMouseHandler>((_, n) => {
@ -730,26 +689,52 @@ export default function JourneyEditor() {
open={true}
onClose={async () => { await navigate('../journeys') }}
actions={
<>
<Tag
variant={journey.published ? 'success' : 'plain'}
size="large">
{journey.published ? t('published') : t('draft')}
</Tag>
<Button
variant="secondary"
onClick={() => setEditOpen(true)}
>
{t('edit_details')}
</Button>
<Button
onClick={saveSteps}
isLoading={saving}
variant="primary"
>
{t('save')}
</Button>
</>
isDraft
? <>
<Button
onClick={publishJourney}
isLoading={saving}
variant="secondary"
>
{t('publish')}
</Button>
<Button
onClick={saveSteps}
isLoading={saving}
variant="primary"
>
{t('journey_draft_save')}
</Button>
</>
: <>
<Tag
variant={journey.status === 'live' ? 'success' : 'plain'}
size="large">
{t(journey.status)}
</Tag>
<Button
variant="secondary"
onClick={() => setEditOpen(true)}
>
{t('edit_details')}
</Button>
{draftId
? <Button
onClick={() => editDraft(draftId)}
isLoading={saving}
variant="primary"
>
{t('journey_draft_edit')}
</Button>
: <Button
onClick={createDraft}
isLoading={saving}
variant="primary"
>
{t('journey_draft_create')}
</Button>
}
</>
}
>
<div className={clsx('journey', editNode && 'editing')}>
@ -770,7 +755,9 @@ export default function JourneyEditor() {
setNodes(nds => nds.map(n => n.data.editing ? { ...n, data: { ...n.data, editing: false } } : n))
}
}}
elementsSelectable
elementsSelectable={isDraft}
nodesDraggable={isDraft}
nodesConnectable={isDraft}
onDragOver={onDragOver}
onDrop={onDrop}
panOnScroll
@ -784,7 +771,7 @@ export default function JourneyEditor() {
{
!editNode && (
<>
<Controls />
<Controls showInteractive={isDraft} />
<MiniMap
nodeClassName={({ data }: Node<JourneyStep>) => `journey-minimap ${getStepType(data.type)?.category ?? 'unknown'}`}
/>
@ -814,7 +801,7 @@ export default function JourneyEditor() {
}
</ReactFlow>
</div>
<div className="journey-options">
{isDraft && <div className="journey-options">
{
stepEdit ?? (
<>
@ -846,7 +833,7 @@ export default function JourneyEditor() {
</>
)
}
</div>
</div>}
</div>
<Modal
open={editOpen}
@ -861,18 +848,12 @@ export default function JourneyEditor() {
}}
/>
</Modal>
<Modal
<JourneyStepUsers
open={!!viewUsersStep}
onClose={() => setViewUsersStep(null)}
title={t('users')}
size="large"
>
{
viewUsersStep && (
<StepUsers {...viewUsersStep} />
)
}
</Modal>
entrance={viewUsersStep?.entrance ?? false}
stepId={viewUsersStep?.stepId ?? 0}
/>
</Modal>
)
}

View file

@ -6,8 +6,8 @@ import { Journey } from '../../types'
import FormWrapper from '../../ui/form/FormWrapper'
import TextInput from '../../ui/form/TextInput'
import { TagPicker } from '../settings/TagPicker'
import SwitchField from '../../ui/form/SwitchField'
import { useTranslation } from 'react-i18next'
import RadioInput from '../../ui/form/RadioInput'
interface JourneyFormProps {
journey?: Journey
@ -17,12 +17,16 @@ interface JourneyFormProps {
export function JourneyForm({ journey, onSaved }: JourneyFormProps) {
const { t } = useTranslation()
const [project] = useContext(ProjectContext)
const statusOptions = [
{ key: 'live', label: t('live') },
{ key: 'off', label: t('off') },
]
return (
<FormWrapper<Journey>
onSubmit={async ({ id, name, description, published = false, tags }) => {
onSubmit={async ({ id, name, description, status, tags }) => {
const saved = id
? await api.journeys.update(project.id, id, { name, description, published, tags })
: await api.journeys.create(project.id, { name, description, published, tags })
? await api.journeys.update(project.id, id, { name, description, status, tags })
: await api.journeys.create(project.id, { name, description, status, tags })
toast.success(t('journey_saved'))
onSaved?.(saved)
}}
@ -49,10 +53,13 @@ export function JourneyForm({ journey, onSaved }: JourneyFormProps) {
name="tags"
label={t('tags')}
/>
<SwitchField
{journey?.status}
<RadioInput.Field
form={form}
name="published"
label={t('published')}
name="status"
label={t('status')}
options={statusOptions}
required
/>
</>
)

View file

@ -0,0 +1,98 @@
import { useCallback, useContext, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { JourneyContext, ProjectContext } from '../../contexts'
import { SearchTable, useSearchTableState } from '../../ui/SearchTable'
import api from '../../api'
import { Button, Modal, Tag } from '../../ui'
import { camelToTitle } from '../../utils'
import { UserLookup } from '../users/UserLookup'
import { typeVariants } from './EntranceDetails'
import { ModalProps } from '../../ui/Modal'
import { User } from '../../types'
interface StepUsersProps extends Omit<ModalProps, 'title'> {
stepId: number
entrance: boolean
}
export function JourneyStepUsers({ open, onClose, entrance, stepId }: StepUsersProps) {
const { t } = useTranslation()
const [{ id: projectId }] = useContext(ProjectContext)
const [{ id: journeyId }] = useContext(JourneyContext)
const [isUserLookupOpen, setIsUserLookupOpen] = useState(false)
const state = useSearchTableState(useCallback(async params => await api.journeys.steps.searchUsers(projectId, journeyId, stepId, params), [projectId, journeyId, stepId]), {
limit: 10,
})
const handleAddUserToEntrance = async (stepId: number, user: User) => {
await api.journeys.users.trigger(projectId, journeyId, stepId, user)
await state.reload()
}
return <>
<Modal
open={open}
onClose={onClose}
title={t('users')}
size="large"
actions={
<Button
size="small"
variant="primary"
onClick={() => setIsUserLookupOpen(true)}
>{t('journey_add_user_to_entrance')}</Button>
}
>
<SearchTable
{...state}
columns={[
{
key: 'name',
title: t('name'),
cell: ({ item }) => item.user!.full_name ?? '-',
},
{
key: 'external_id',
title: t('external_id'),
cell: ({ item }) => item.user?.external_id ?? '-',
},
{
key: 'email',
title: t('email'),
cell: ({ item }) => item.user?.email ?? '-',
},
{
key: 'phone',
title: t('phone'),
cell: ({ item }) => item.user?.phone ?? '-',
},
{
key: 'type',
title: t('type'),
cell: ({ item }) => (
<Tag variant={typeVariants[item.type]}>
{camelToTitle(item.type)}
</Tag>
),
},
{
key: 'created_at',
title: t('step_date'),
},
{
key: 'delay_until',
title: t('delay_until'),
cell: ({ item }) => item.delay_until,
},
]}
onSelectRow={entrance ? ({ id }) => window.open(`/projects/${projectId}/entrances/${id}`, '_blank') : undefined}
/>
<UserLookup
open={isUserLookupOpen}
onClose={setIsUserLookupOpen}
onSelected={async user => await handleAddUserToEntrance(stepId, user)} />
</Modal>
</>
}

View file

@ -10,11 +10,12 @@ import { JourneyForm } from './JourneyForm'
import { Menu, MenuItem, Tag } from '../../ui'
import { ProjectContext } from '../../contexts'
import { useTranslation } from 'react-i18next'
import { Journey } from '../../types'
export const JourneyTag = ({ published }: { published: boolean }) => {
export const JourneyTag = ({ status }: Pick<Journey, 'status'>) => {
const { t } = useTranslation()
const variant = published ? 'success' : 'plain'
const title = published ? t('published') : t('draft')
const variant = status === 'live' ? 'success' : 'plain'
const title = t(status)
return <Tag variant={variant}>{title}</Tag>
}
@ -56,12 +57,7 @@ export default function Journeys() {
{
key: 'status',
title: t('status'),
cell: ({ item }) => <JourneyTag published={item.published} />,
},
{
key: 'usage',
title: t('usage'),
cell: ({ item }) => item.stats?.entrance.toLocaleString(),
cell: ({ item }) => <JourneyTag status={item.status} />,
},
{
key: 'created_at',

View file

@ -1,16 +1,15 @@
import { useCallback, useState } from 'react'
import api, { apiUrl } from '../../../api'
import api from '../../../api'
import { Campaign, JourneyStepType } from '../../../types'
import { EntityIdPicker } from '../../../ui/form/EntityIdPicker'
import { ActionStepIcon } from '../../../ui/icons'
import { CampaignForm } from '../../campaign/CampaignForm'
import { useResolver } from '../../../hooks'
import PreviewImage from '../../../ui/PreviewImage'
import { useTranslation } from 'react-i18next'
import { ChannelIcon } from '../../campaign/ChannelTag'
import Preview from '../../../ui/Preview'
import { SingleSelect } from '../../../ui/form/SingleSelect'
import { Heading } from '../../../ui'
import { Heading, LinkButton } from '../../../ui'
import { locales } from '../../campaign/CampaignDetail'
interface ActionConfig {
@ -27,12 +26,21 @@ const JourneyTemplatePreview = ({ campaign }: { campaign: Campaign }) => {
title={t('preview')}
size="h4"
actions={
<SingleSelect
options={allLocales}
size="small"
value={locale}
onChange={(locale) => setLocale(locale)}
/>
<>
<SingleSelect
options={allLocales}
size="small"
value={locale}
onChange={(locale) => setLocale(locale)}
/>
<LinkButton
to={`/projects/${campaign.project_id}/campaigns/${campaign.id}`}
size="small"
target="_blank"
>
{t('edit_campaign')}
</LinkButton>
</>
}
/>
{template && <Preview template={template} />}
@ -50,7 +58,7 @@ export const actionStep: JourneyStepType<ActionConfig> = {
campaign_id,
},
}) {
const { t } = useTranslation()
const [campaign] = useResolver(useCallback(async () => {
if (campaign_id) {
return await api.campaigns.get(projectId, campaign_id)
@ -68,23 +76,9 @@ export const actionStep: JourneyStepType<ActionConfig> = {
</div>
<div className="journey-step-action-preview">
{ campaign
? (
campaign.channel !== 'webhook'
? (
<PreviewImage
url={apiUrl(projectId, `campaigns/${campaign.id}/preview`)}
width={250}
height={200}
/>
)
: (
<div className="placeholder">
<ChannelIcon channel={campaign.channel} />
</div>
)
)
? <Preview template={campaign.templates[0]} size="small" />
: (
<div className="journey-step-action-preview-placeholder">Create campaign to preview</div>
<div className="journey-step-action-preview-placeholder">{t('journey_campaign_create_preview')}</div>
)}
</div>
</>
@ -123,7 +117,6 @@ export const actionStep: JourneyStepType<ActionConfig> = {
onSave={onCreated}
/>
)}
onEditLink={campaign => window.open(`/projects/${projectId}/campaigns/${campaign.id}`)}
/>
{campaign && <JourneyTemplatePreview campaign={campaign} />}

View file

@ -53,7 +53,7 @@ export default function UserDetailEvents() {
<JsonPreview value={{ name: event.name, ...event.data, created_at: event.created_at }} />
</Column>
<Column>
{event.name === 'email_sent' && event.data?.result?.message?.html && <Iframe content={event.data.result.message.html ?? ''} fullHeight={true} /> }
{event.name === 'email_sent' && event.data?.result?.message?.html && <Iframe content={event.data.result.message.html ?? ''} fullHeight={true} width="100%" /> }
</Column>
</Columns>
</Modal>

View file

@ -0,0 +1,54 @@
import { useCallback, useContext, useState } from 'react'
import { User } from '../../types'
import Modal, { ModalProps } from '../../ui/Modal'
import { ProjectContext } from '../../contexts'
import { useTranslation } from 'react-i18next'
import { SearchTable, useSearchTableState } from '../../ui/SearchTable'
import api from '../../api'
import { Button, ButtonGroup } from '../../ui'
import TextInput from '../../ui/form/TextInput'
interface UserLookupProps extends Omit<ModalProps, 'title'> {
onSelected: (user: User) => void
}
export const UserLookup = ({ open, onClose, onSelected }: UserLookupProps) => {
const [project] = useContext(ProjectContext)
const { t } = useTranslation()
const state = useSearchTableState(useCallback(async params => await api.users.search(project.id, params), [project]))
const [value, setValue] = useState<string>('')
return <Modal
title={t('user_lookup')}
open={open}
onClose={onClose}
size="regular">
<div className="user-lookup">
<ButtonGroup>
<TextInput<string>
name="search"
placeholder={(t('enter_email'))}
hideLabel={true}
value={value}
onChange={setValue} />
<Button
variant="secondary"
onClick={() => state.setParams({
...state.params,
q: value,
})}>{t('search')}</Button>
</ButtonGroup>
<SearchTable
{...state}
columns={[
{ key: 'full_name', title: 'Name' },
{ key: 'email' },
{ key: 'phone' },
]}
onSelectRow={(user) => {
onSelected(user)
onClose(false)
}} />
</div>
</Modal>
}