mirror of
https://fast.feibisi.com/https://github.com/parcelvoy/platform.git
synced 2025-09-04 12:56:14 +08:00
Journey Versioning (#674)
This commit is contained in:
parent
a2e3677508
commit
c0ae1bc141
32 changed files with 543 additions and 298 deletions
|
@ -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')
|
||||
})
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)))
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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[]> => {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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>)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'),
|
||||
)
|
||||
|
|
31
apps/platform/src/journey/JourneyUserStep.ts
Normal file
31
apps/platform/src/journey/JourneyUserStep.ts
Normal 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
|
||||
}, {})
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()} />
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
98
apps/ui/src/views/journey/JourneyStepUsers.tsx
Normal file
98
apps/ui/src/views/journey/JourneyStepUsers.tsx
Normal 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>
|
||||
</>
|
||||
}
|
|
@ -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',
|
||||
|
|
|
@ -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} />}
|
||||
|
|
|
@ -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>
|
||||
|
|
54
apps/ui/src/views/users/UserLookup.tsx
Normal file
54
apps/ui/src/views/users/UserLookup.tsx
Normal 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>
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue