From 56757e01c711ce3acfbf4c5fd44181f10213d22e Mon Sep 17 00:00:00 2001 From: Chris Hills <31041837+chrishills@users.noreply.github.com> Date: Wed, 11 Oct 2023 06:13:54 -0500 Subject: [PATCH] add journey step name, journey entrance previewing (#267) --- .../20230919110256_add_journey_step_name.js | 11 ++ apps/platform/src/core/Model.ts | 20 +++ apps/platform/src/journey/Journey.ts | 4 +- .../platform/src/journey/JourneyController.ts | 32 +++- .../platform/src/journey/JourneyRepository.ts | 62 +++++++- apps/platform/src/journey/JourneyStep.ts | 6 + apps/platform/src/users/UserController.ts | 6 + apps/ui/src/api.ts | 16 +- apps/ui/src/index.css | 37 +++++ apps/ui/src/types.ts | 23 ++- apps/ui/src/ui/DataTable.css | 2 +- apps/ui/src/ui/DataTable.tsx | 7 +- apps/ui/src/views/journey/EntranceDetails.tsx | 82 +++++++++++ apps/ui/src/views/journey/JourneyEditor.css | 13 +- apps/ui/src/views/journey/JourneyEditor.tsx | 138 +++++++++++++----- .../views/journey/JourneyUserEntrances.tsx | 26 ++++ apps/ui/src/views/journey/Journeys.tsx | 8 +- apps/ui/src/views/journey/steps/Action.tsx | 33 ++++- apps/ui/src/views/journey/steps/Delay.tsx | 45 +++++- .../ui/src/views/journey/steps/Experiment.tsx | 1 + apps/ui/src/views/journey/steps/Gate.tsx | 17 ++- .../src/views/journey/steps/JourneyLink.tsx | 31 +++- apps/ui/src/views/journey/steps/Update.tsx | 85 +++++------ apps/ui/src/views/router.tsx | 22 +++ apps/ui/src/views/settings/ApiKeys.tsx | 6 +- apps/ui/src/views/users/RuleBuilder.css | 2 +- apps/ui/src/views/users/RuleBuilder.tsx | 118 ++++++++++++++- apps/ui/src/views/users/UserDetail.tsx | 5 + .../ui/src/views/users/UserDetailJourneys.tsx | 39 +++++ package-lock.json | 2 + 30 files changed, 784 insertions(+), 115 deletions(-) create mode 100644 apps/platform/db/migrations/20230919110256_add_journey_step_name.js create mode 100644 apps/ui/src/views/journey/EntranceDetails.tsx create mode 100644 apps/ui/src/views/journey/JourneyUserEntrances.tsx create mode 100644 apps/ui/src/views/users/UserDetailJourneys.tsx diff --git a/apps/platform/db/migrations/20230919110256_add_journey_step_name.js b/apps/platform/db/migrations/20230919110256_add_journey_step_name.js new file mode 100644 index 00000000..cf737f6f --- /dev/null +++ b/apps/platform/db/migrations/20230919110256_add_journey_step_name.js @@ -0,0 +1,11 @@ +exports.up = async function(knex) { + await knex.schema.alterTable('journey_steps', function(table) { + table.string('name', 128) + }) +} + +exports.down = async function(knex) { + await knex.schema.alterTable('journey_steps', function(table) { + table.dropColumn('name') + }) +} diff --git a/apps/platform/src/core/Model.ts b/apps/platform/src/core/Model.ts index 5e9fd59d..6250b6c2 100644 --- a/apps/platform/src/core/Model.ts +++ b/apps/platform/src/core/Model.ts @@ -64,6 +64,12 @@ export default class Model { for (const attribute of this.jsonAttributes) { json[attribute] = JSON.stringify(json[attribute]) } + + // remove any virtual attributes that have been set + for (const attribute of this.virtualAttributes) { + delete (json as any)[attribute] + } + return json } @@ -99,6 +105,20 @@ export default class Model { return this.fromJson(record) } + static async findMap( + this: T, + ids: number[], + db: Database = App.main.db, + ) { + const m = new Map>() + if (!ids.length) return m + const records = await this.all(q => q.whereIn('id', ids), db) + for (const record of records) { + m.set(record.id, record) + } + return m + } + static async all( this: T, query: Query = qb => qb, diff --git a/apps/platform/src/journey/Journey.ts b/apps/platform/src/journey/Journey.ts index ad2686d4..77030043 100644 --- a/apps/platform/src/journey/Journey.ts +++ b/apps/platform/src/journey/Journey.ts @@ -1,6 +1,6 @@ import Model, { ModelParams } from '../core/Model' import { setJourneyStepMap } from './JourneyRepository' -import { JourneyStepMap } from './JourneyStep' +import { JourneyStepMapParams } from './JourneyStep' export default class Journey extends Model { name!: string @@ -14,7 +14,7 @@ export default class Journey extends Model { static jsonAttributes = ['stats'] - static async create(project_id: number, name: string, stepMap: JourneyStepMap) { + static async create(project_id: number, name: string, stepMap: JourneyStepMapParams) { const journey = await this.insertAndFetch({ project_id, name, diff --git a/apps/platform/src/journey/JourneyController.ts b/apps/platform/src/journey/JourneyController.ts index 7cf5e4b2..bf92b178 100644 --- a/apps/platform/src/journey/JourneyController.ts +++ b/apps/platform/src/journey/JourneyController.ts @@ -5,8 +5,9 @@ import { searchParamsSchema } from '../core/searchParams' import { JSONSchemaType, validate } from '../core/validate' import { extractQueryParams } from '../utilities' import Journey, { JourneyParams } from './Journey' -import { createJourney, deleteJourney, getJourneyStepMap, getJourney, pagedJourneys, setJourneyStepMap, updateJourney } from './JourneyRepository' -import { JourneyStepMapParams, journeyStepTypes, toJourneyStepMap } from './JourneyStep' +import { createJourney, deleteJourney, getJourneyStepMap, getJourney, pagedJourneys, setJourneyStepMap, updateJourney, pagedEntrancesByJourney, getEntranceLog } from './JourneyRepository' +import { JourneyStepMapParams, JourneyUserStep, journeyStepTypes, toJourneyStepMap } from './JourneyStep' +import { User } from '../users/User' const router = new Router< ProjectState & { journey?: Journey } @@ -52,6 +53,24 @@ router.post('/', async ctx => { ctx.body = await createJourney(ctx.state.project.id, payload) }) +router.get('/entrances/:entranceId', async ctx => { + const entrance = await JourneyUserStep.first(q => q + .join('journeys', 'journey_user_step.journey_id', '=', 'journeys.id') + .where('journeys.project_id', ctx.state.project.id) + .where('journey_user_step.id', parseInt(ctx.params.entranceId, 10)) + .whereNull('journey_user_step.entrance_id'), + ) + if (!entrance) { + return ctx.throw(404) + } + const [user, journey, userSteps] = await Promise.all([ + User.find(entrance.user_id), + Journey.find(entrance.journey_id), + getEntranceLog(entrance.id), + ]) + ctx.body = { journey, user, userSteps } +}) + router.param('journeyId', async (value, ctx, next) => { ctx.state.journey = await getJourney(parseInt(value), ctx.state.project.id) if (!ctx.state.journey) { @@ -86,6 +105,10 @@ const journeyStepsParamsSchema: JSONSchemaType = { type: 'string', enum: Object.keys(journeyStepTypes), }, + name: { + type: 'string', + nullable: true, + }, data: { type: 'object', // TODO: Could validate further based on sub types nullable: true, @@ -133,4 +156,9 @@ router.put('/:journeyId/steps', async ctx => { ctx.body = await toJourneyStepMap(steps, children) }) +router.get('/:journeyId/entrances', async ctx => { + const params = extractQueryParams(ctx.query, searchParamsSchema) + ctx.body = await pagedEntrancesByJourney(ctx.state.journey!.id, params) +}) + export default router diff --git a/apps/platform/src/journey/JourneyRepository.ts b/apps/platform/src/journey/JourneyRepository.ts index 4b0ecbf9..5d234d94 100644 --- a/apps/platform/src/journey/JourneyRepository.ts +++ b/apps/platform/src/journey/JourneyRepository.ts @@ -5,6 +5,7 @@ import { PageParams } from '../core/searchParams' import Journey, { JourneyParams, UpdateJourneyParams } from './Journey' import { JourneyStep, JourneyEntrance, JourneyUserStep, JourneyStepMap, toJourneyStepMap, JourneyStepChild } from './JourneyStep' import { createTagSubquery, getTags, setTags } from '../tags/TagService' +import { User } from '../users/User' export const pagedJourneys = async (params: PageParams, projectId: number) => { const result = await Journey.search( @@ -109,12 +110,13 @@ export const setJourneyStepMap = async (journeyId: number, stepMap: JourneyStepM ]) // Create or update steps - for (const [external_id, { type, x = 0, y = 0, data = {}, data_key }] of Object.entries(stepMap)) { + for (const [external_id, { type, x = 0, y = 0, data = {}, data_key, name = '' }] of Object.entries(stepMap)) { const idx = steps.findIndex(s => s.external_id === external_id) if (idx === -1) { steps.push(await JourneyStep.insertAndFetch({ journey_id: journeyId, type, + name, external_id, data, data_key, @@ -125,6 +127,7 @@ export const setJourneyStepMap = async (journeyId: number, stepMap: JourneyStepM const step = steps[idx] steps[idx] = await JourneyStep.updateAndFetch(step.id, { type, + name, external_id, data, data_key, @@ -204,3 +207,60 @@ export const getEntranceSubsequentSteps = async (entranceId: number) => { .orderBy('id', 'asc'), ) } + +export const pagedEntrancesByJourney = async (journeyId: number, params: PageParams) => { + const r = await JourneyUserStep.search(params, q => q + .where('journey_id', journeyId) + .whereNull('entrance_id'), + ) + if (r.results?.length) { + const users = await User.findMap(r.results.map(s => s.user_id)) + return { + ...r, + results: r.results.map(s => ({ + id: s.id, + user: users.get(s.user_id), + created_at: s.created_at, + updated_at: s.updated_at, + ended_at: s.ended_at, + })), + } + } + return r +} + +export const pagedEntrancesByUser = async (userId: number, params: PageParams) => { + const r = await JourneyUserStep.search(params, q => q + .where('user_id', userId) + .whereNull('entrance_id'), + ) + if (r.results?.length) { + const journeys = await Journey.findMap(r.results.map(s => s.journey_id)) + return { + ...r, + results: r.results.map(s => ({ + id: s.id, + journey: journeys.get(s.journey_id), + created_at: s.created_at, + updated_at: s.updated_at, + ended_at: s.ended_at, + })), + } + } + return r +} + +export const getEntranceLog = async (entranceId: number) => { + const userSteps = await JourneyUserStep.all(q => q + .where(function() { + return this.where('id', entranceId).orWhere('entrance_id', entranceId) + }) + .orderBy('id', 'asc'), + ) + if (!userSteps.length) return userSteps + const steps = await JourneyStep.findMap(userSteps.map(s => s.step_id)) + for (const userStep of userSteps) { + userStep.step = steps.get(userStep.step_id) + } + return userSteps +} diff --git a/apps/platform/src/journey/JourneyStep.ts b/apps/platform/src/journey/JourneyStep.ts index e608079a..24a84019 100644 --- a/apps/platform/src/journey/JourneyStep.ts +++ b/apps/platform/src/journey/JourneyStep.ts @@ -22,9 +22,12 @@ export class JourneyUserStep extends Model { data?: Record ref?: string + step?: JourneyStep + static tableName = 'journey_user_step' static jsonAttributes = ['data'] + static virtualAttributes = ['step'] static getDataMap(steps: JourneyStep[], userSteps: JourneyUserStep[]) { return userSteps.reduceRight>((a, { data, step_id }) => { @@ -52,6 +55,7 @@ export class JourneyStepChild extends Model { export class JourneyStep extends Model { type!: string journey_id!: number + name?: string data?: Record external_id!: string data_key?: string // make data stored in user steps available in templates @@ -396,6 +400,7 @@ export const journeyStepTypes = [ interface JourneyStepMapItem { type: string + name?: string data?: Record data_key?: string x: number @@ -420,6 +425,7 @@ export async function toJourneyStepMap(steps: JourneyStep[], children: JourneySt for (const step of steps) { editData[step.external_id] = { type: step.type, + name: step.name ?? '', data: step.data ?? {}, data_key: step.data_key, x: step.x ?? 0, diff --git a/apps/platform/src/users/UserController.ts b/apps/platform/src/users/UserController.ts index c59a888f..535b40bd 100644 --- a/apps/platform/src/users/UserController.ts +++ b/apps/platform/src/users/UserController.ts @@ -13,6 +13,7 @@ import { getUserSubscriptions, toggleSubscription } from '../subscriptions/Subsc import { SubscriptionState } from '../subscriptions/Subscription' import { getUserEvents } from './UserEventRepository' import { projectRoleMiddleware } from '../projects/ProjectService' +import { pagedEntrancesByUser } from '../journey/JourneyRepository' const router = new Router< ProjectState & { user?: User } @@ -188,4 +189,9 @@ router.patch('/:userId/subscriptions', async ctx => { ctx.body = await getUser(ctx.state.user!.id) }) +router.get('/:userId/journeys', async ctx => { + const params = extractQueryParams(ctx.query, searchParamsSchema) + ctx.body = await pagedEntrancesByUser(ctx.state.user!.id, params) +}) + export default router diff --git a/apps/ui/src/api.ts b/apps/ui/src/api.ts index e399202d..53963dd7 100644 --- a/apps/ui/src/api.ts +++ b/apps/ui/src/api.ts @@ -1,6 +1,6 @@ import Axios from 'axios' import { env } from './config/env' -import { Admin, AuthMethod, Campaign, CampaignCreateParams, CampaignLaunchParams, CampaignUpdateParams, CampaignUser, Image, Journey, JourneyStepMap, List, ListCreateParams, ListUpdateParams, Locale, Metric, Organization, OrganizationUpdateParams, Project, ProjectAdmin, ProjectAdminInviteParams, ProjectAdminParams, ProjectApiKey, ProjectApiKeyParams, Provider, ProviderCreateParams, ProviderMeta, ProviderUpdateParams, QueueMetric, RuleSuggestions, SearchParams, SearchResult, Subscription, SubscriptionParams, Tag, Template, TemplateCreateParams, TemplatePreviewParams, TemplateProofParams, TemplateUpdateParams, User, UserEvent, UserSubscription } from './types' +import { Admin, AuthMethod, Campaign, CampaignCreateParams, CampaignLaunchParams, CampaignUpdateParams, CampaignUser, Image, Journey, JourneyEntranceDetail, JourneyStepMap, JourneyUserStep, List, ListCreateParams, ListUpdateParams, Locale, Metric, Organization, OrganizationUpdateParams, Project, ProjectAdmin, ProjectAdminInviteParams, ProjectAdminParams, ProjectApiKey, ProjectApiKeyParams, Provider, ProviderCreateParams, ProviderMeta, ProviderUpdateParams, QueueMetric, RuleSuggestions, SearchParams, SearchResult, Subscription, SubscriptionParams, Tag, Template, TemplateCreateParams, TemplatePreviewParams, TemplateProofParams, TemplateUpdateParams, User, UserEvent, UserSubscription } from './types' function appendValue(params: URLSearchParams, name: string, value: unknown) { if (typeof value === 'undefined' || value === null || typeof value === 'function') return @@ -177,6 +177,14 @@ const api = { .put(`/admin/projects/${projectId}/journeys/${journeyId}/steps`, stepData) .then(r => r.data), }, + entrances: { + search: async (projectId: number | string, journeyId: number | string, params: SearchParams) => await client + .get>(`/admin/projects/${projectId}/journeys/${journeyId}/entrances`, { params }) + .then(r => r.data), + log: async (projectId: number | string, entranceId: number | string) => await client + .get(`${projectUrl(projectId)}/journeys/entrances/${entranceId}`) + .then(r => r.data), + }, }, templates: { @@ -199,6 +207,12 @@ const api = { updateSubscriptions: async (projectId: number | string, userId: number | string, subscriptions: SubscriptionParams[]) => await client .patch(`${projectUrl(projectId)}/users/${userId}/subscriptions`, subscriptions) .then(r => r.data), + + journeys: { + search: async (projectId: number | string, userId: number | string, params: SearchParams) => await client + .get>(`${projectUrl(projectId)}/users/${userId}/journeys`, { params }) + .then(r => r.data), + }, }, lists: { diff --git a/apps/ui/src/index.css b/apps/ui/src/index.css index 16076621..a0cfd185 100644 --- a/apps/ui/src/index.css +++ b/apps/ui/src/index.css @@ -195,6 +195,43 @@ label .switch .slider.round:before { height: 16px; } +.icon-box { + align-self: start; + border-radius: 4px; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; +} + +code { + word-wrap: break-word; + background-color: var(--color-grey-soft); + border-radius: 2px; + padding: 2px; +} + +.blue { + background-color: var(--color-blue-soft); + color: var(--color-blue-hard); +} + +.green { + background-color: var(--color-green-soft); + color: var(--color-green-hard); +} + +.red { + background-color: var(--color-red-soft); + color: var(--color-red-hard); +} + +.yellow { + background-color: var(--color-yellow-soft); + color: var(--color-yellow-hard); +} + @media only screen and (max-width: 600px) { .page-content { padding: 0 20px 20px; diff --git a/apps/ui/src/types.ts b/apps/ui/src/types.ts index e3b46c45..9ff7903d 100644 --- a/apps/ui/src/types.ts +++ b/apps/ui/src/types.ts @@ -251,7 +251,7 @@ export interface Journey { export interface JourneyStep { id: number type: string - child_id?: number + name: string data: T x: number y: number @@ -267,6 +267,7 @@ interface JourneyStepMapChild { export interface JourneyStepMap { [external_id: string]: { type: string + name: string data?: Record x: number y: number @@ -293,6 +294,7 @@ export interface JourneyStepType { icon: ReactNode category: 'entrance' | 'delay' | 'flow' | 'action' description: string + Describe?: ComponentType> newData?: () => Promise newEdgeData?: () => Promise Edit?: ComponentType> @@ -304,6 +306,25 @@ export interface JourneyStepType { hasDataKey?: boolean } +export interface JourneyUserStep { + id: number + type: string + delay_until?: string + created_at: string + updated_at: string + ended_at?: string + + user?: User + journey?: Journey + step?: JourneyStep +} + +export interface JourneyEntranceDetail { + journey: Journey + user: User + userSteps: JourneyUserStep[] +} + export type CampaignState = 'draft' | 'pending' | 'scheduled' | 'running' | 'finished' | 'aborted' export interface CampaignDelivery { diff --git a/apps/ui/src/ui/DataTable.css b/apps/ui/src/ui/DataTable.css index af938bba..2b97549e 100644 --- a/apps/ui/src/ui/DataTable.css +++ b/apps/ui/src/ui/DataTable.css @@ -106,6 +106,7 @@ .ui-table .table-cell .multi-cell object, .ui-table .table-cell .multi-cell .placeholder { background-color: var(--color-background-soft); + color: var(--color-grey-hard); grid-area: image; width: 100%; height: 40px; @@ -123,7 +124,6 @@ .ui-table .table-cell .multi-cell .placeholder .icon { width: 24px; height: 24px; - color: var(--color-grey-hard); } .ui-table .table-cell .loader { diff --git a/apps/ui/src/ui/DataTable.tsx b/apps/ui/src/ui/DataTable.tsx index c48c7523..45cdf528 100644 --- a/apps/ui/src/ui/DataTable.tsx +++ b/apps/ui/src/ui/DataTable.tsx @@ -121,11 +121,8 @@ export function DataTable({ let value: any = col.cell ? col.cell(args) : item[col.key as keyof T] - if (!col.cell - && col.key.endsWith('_at') - && value - ) { - value = formatDate(preferences, value) + if ((col.key.endsWith('_at') || col.key.endsWith('_until')) && value) { + value = formatDate(preferences, value, 'Ppp') } if (typeof value === 'boolean') { value = value ? : diff --git a/apps/ui/src/views/journey/EntranceDetails.tsx b/apps/ui/src/views/journey/EntranceDetails.tsx new file mode 100644 index 00000000..2e3cec6e --- /dev/null +++ b/apps/ui/src/views/journey/EntranceDetails.tsx @@ -0,0 +1,82 @@ +import { DataTable, PageContent, Tag } from '../../ui' +import { TagProps } from '../../ui/Tag' +import { camelToTitle, formatDate } from '../../utils' +import { useLoaderData } from 'react-router-dom' +import { JourneyEntranceDetail } from '../../types' +import { useContext } from 'react' +import { PreferencesContext } from '../../ui/PreferencesContext' +import * as stepTypes from './steps' +import clsx from 'clsx' +import { stepCategoryColors } from './JourneyEditor' + +const typeVariants: Record = { + completed: 'success', + error: 'error', + action: 'info', + delay: 'warn', + pending: 'plain', +} + +export default function EntranceDetails() { + + const [preferences] = useContext(PreferencesContext) + + const { journey, user, userSteps } = useLoaderData() as JourneyEntranceDetail + + const entrance = userSteps[0] + const error = userSteps.find(s => s.type === 'error') + + return ( + + + { + error ? 'Error' : entrance.ended_at ? 'Completed' : 'Running' + } + + { + entrance.ended_at && ` at ${formatDate(preferences, entrance.ended_at)}` + } + + } + > + { + + const stepType = stepTypes[item.step!.type as keyof typeof stepTypes] + + return ( +
+
+ {stepType?.icon} +
+
+
{item.step!.name || 'Untitled'}
+
{item.step!.type}
+
+
+ ) + }, + }, + { + key: 'type', + cell: ({ item }) => ( + + {camelToTitle(item.type)} + + ), + }, + { key: 'created_at' }, + { key: 'delay_until' }, + ]} + /> +
+ ) +} diff --git a/apps/ui/src/views/journey/JourneyEditor.css b/apps/ui/src/views/journey/JourneyEditor.css index e3a38e12..a8f58208 100644 --- a/apps/ui/src/views/journey/JourneyEditor.css +++ b/apps/ui/src/views/journey/JourneyEditor.css @@ -15,15 +15,22 @@ .journey-options { max-width: 300px; - border-right: 1px solid var(--color-grey); + border-color: var(--color-grey); + border-style: solid; + border-right-width: 1px; position: relative; overflow: scroll; } +* + .journey-options { + border-right-width: 0; + border-left-width: 1px; + max-width: none; +} + .journey-options h4 { padding: 10px 20px; margin: 0; - border-bottom: 1px solid var(--color-grey); } .journey-options .options-section { @@ -276,7 +283,7 @@ } .journey-step-labelled-sources { - padding-bottom: 20px; + padding-bottom: 36px; } .step-handle-label { diff --git a/apps/ui/src/views/journey/JourneyEditor.tsx b/apps/ui/src/views/journey/JourneyEditor.tsx index 0c83064a..68eca8b2 100644 --- a/apps/ui/src/views/journey/JourneyEditor.tsx +++ b/apps/ui/src/views/journey/JourneyEditor.tsx @@ -57,12 +57,19 @@ const statIcons: Record = { ended: , } +export const stepCategoryColors = { + entrance: 'red', + action: 'blue', + flow: 'green', + delay: 'yellow', +} + function JourneyStepNode({ id, data: { type: typeName, + name, data, - data_key, stats, } = {}, selected, @@ -70,19 +77,7 @@ function JourneyStepNode({ const [project] = useContext(ProjectContext) const [journey] = useContext(JourneyContext) - const { setNodes, getNode, getEdges } = useReactFlow() - - const onDataChange = useCallback((data: any) => { - setNodes(nds => nds.map(n => n.id === id - ? { - ...n, - data: { - ...n.data, - data, - }, - } - : n)) - }, [id, setNodes]) + const { getNode, getEdges } = useReactFlow() const type = getStepType(typeName) @@ -117,10 +112,10 @@ function JourneyStepNode({ )} >
- + {type.icon} -

{type.name}

+

{name || type.name}

{ stats && (
@@ -141,29 +136,16 @@ function JourneyStepNode({ }
{ - type.Edit && ( + type.Describe && (
{ - createElement(type.Edit, { - value: data, - onChange: onDataChange, + createElement(type.Describe, { project, journey, + value: data, + onChange: () => {}, }) } - { - type.hasDataKey && ( - setNodes(nds => nds.map(n => n.id === id ? { ...n, data: { ...n.data, data_key } } : n)) - } - /> - ) - }
) } @@ -318,7 +300,7 @@ function stepsToNodes(stepMap: JourneyStepMap) { const nodes: Node[] = [] const edges: Edge[] = [] - for (const [id, { x, y, type, data, children, stats, stats_at }] of Object.entries(stepMap)) { + for (const [id, { x, y, type, data, name, children, stats, stats_at }] of Object.entries(stepMap)) { nodes.push({ id, position: { @@ -328,6 +310,7 @@ function stepsToNodes(stepMap: JourneyStepMap) { type: 'step', data: { type, + name, data, stats, stats_at, @@ -353,6 +336,7 @@ function nodesToSteps(nodes: Node[], edges: Edge[]) { id, data: { type, + name = '', data = {}, }, position: { @@ -363,6 +347,7 @@ function nodesToSteps(nodes: Node[], edges: Edge[]) { a[id] = { type, data, + name, x, y, children: edges @@ -520,6 +505,78 @@ export default function JourneyEditor() { const selected = nodes.filter(n => n.selected) + let stepEdit: ReactNode = null + if (selected.length === 1) { + const editing = selected[0] + const type = getStepType(editing.data.type) + if (type) { + stepEdit = ( + <> +
+ + {type.icon} + +

{type.name}

+ { + editing.data.stats && ( +
+ + {editing.data.stats.completed ?? 0} + {statIcons.completed} + + { + !!editing.data.stats.delay && ( + + {editing.data.stats.delay ?? 0} + {statIcons.delay} + + ) + } +
+ ) + } +
+
+ setNodes(nds => nds.map(n => n.id === editing.id ? { ...n, data: { ...n.data, name } } : n))} + /> + { + type.hasDataKey && ( + setNodes(nds => nds.map(n => n.id === editing.id ? { ...n, data: { ...n.data, data_key } } : n))} + /> + ) + } + { + type.Edit && createElement(type.Edit, { + value: editing.data.data ?? {}, + onChange: data => setNodes(nds => nds.map(n => n.id === editing.id + ? { + ...editing, + data: { + ...editing.data, + data, + }, + } + : n, + )), + project, + journey, + }) + } +
+ + ) + } + } + return ( - + {type.icon}
{type.name}
@@ -594,7 +651,7 @@ export default function JourneyEditor() { panOnScroll selectNodesOnDrag fitView - maxZoom={1.5} + maxZoom={1} > @@ -614,7 +671,7 @@ export default function JourneyEditor() { }} size="small" > - {`Copy Selected Steps (${selected.length})`} + {`Duplicate Selected Steps (${selected.length})`} ) : ( @@ -624,6 +681,13 @@ export default function JourneyEditor() {
+ { + stepEdit && ( +
+ {stepEdit} +
+ ) + } await api.journeys.entrances.search(projectId, journeyId, params), [projectId, journeyId])) + + return ( + + ) +} diff --git a/apps/ui/src/views/journey/Journeys.tsx b/apps/ui/src/views/journey/Journeys.tsx index 19bfe8db..a29e4f4a 100644 --- a/apps/ui/src/views/journey/Journeys.tsx +++ b/apps/ui/src/views/journey/Journeys.tsx @@ -7,6 +7,7 @@ import PageContent from '../../ui/PageContent' import { SearchTable, useSearchTableQueryState } from '../../ui/SearchTable' import { PlusIcon } from '../../ui/icons' import { JourneyForm } from './JourneyForm' +import { Tag } from '../../ui' export default function Journeys() { const { projectId = '' } = useParams() @@ -30,7 +31,12 @@ export default function Journeys() { key: 'name', }, { - key: 'published', + key: 'status', + cell: ({ item }) => ( + + {item.published ? 'Published' : 'Draft'} + + ), }, { key: 'usage', diff --git a/apps/ui/src/views/journey/steps/Action.tsx b/apps/ui/src/views/journey/steps/Action.tsx index 7415781e..8b10747e 100644 --- a/apps/ui/src/views/journey/steps/Action.tsx +++ b/apps/ui/src/views/journey/steps/Action.tsx @@ -1,9 +1,11 @@ import { useCallback } from 'react' -import api from '../../../api' +import api, { apiUrl } from '../../../api' import { 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' interface ActionConfig { campaign_id: number @@ -14,6 +16,35 @@ export const actionStep: JourneyStepType = { icon: , category: 'action', description: 'Trigger a message (email, sms, push notification, webhook) to be sent.', + Describe({ + project: { id: projectId }, + value: { + campaign_id, + }, + }) { + + const [campaign] = useResolver(useCallback(async () => { + if (campaign_id) { + return await api.campaigns.get(projectId, campaign_id) + } + return null + }, [projectId, campaign_id])) + + if (campaign) { + return ( + <> +
{campaign.name}
+ + + ) + } + + return null + }, newData: async () => ({ campaign_id: 0, }), diff --git a/apps/ui/src/views/journey/steps/Delay.tsx b/apps/ui/src/views/journey/steps/Delay.tsx index c7f0fb3b..c51ead04 100644 --- a/apps/ui/src/views/journey/steps/Delay.tsx +++ b/apps/ui/src/views/journey/steps/Delay.tsx @@ -1,8 +1,11 @@ +import { useContext } from 'react' import { JourneyStepType } from '../../../types' import OptionField from '../../../ui/form/OptionField' import TextInput from '../../../ui/form/TextInput' import { DelayStepIcon } from '../../../ui/icons' -import { snakeToTitle } from '../../../utils' +import { formatDate, formatDuration, snakeToTitle } from '../../../utils' +import { PreferencesContext } from '../../../ui/PreferencesContext' +import { parse, parseISO } from 'date-fns' interface DelayStepConfig { format: 'duration' | 'time' | 'date' @@ -18,6 +21,46 @@ export const delayStep: JourneyStepType = { icon: , category: 'delay', description: 'Add a delay between the previous and next step.', + Describe({ value }) { + const [preferences] = useContext(PreferencesContext) + if (value.format === 'duration') { + return ( + <> + {'Wait '} + + {formatDuration(preferences, { + days: value.days ?? 0, + hours: value.hours ?? 0, + minutes: value.minutes ?? 0, + }) || '--'} + + + ) + } + if (value.format === 'time') { + const parsed = parse(value.time ?? '', 'HH:mm', new Date()) + return ( + <> + {'Wait until '} + + {isNaN(parsed.getTime()) ? '--:--' : formatDate(preferences, parsed, 'p')} + + + ) + } + if (value.format === 'date') { + const parsed = parseISO(value.date ?? '') + return ( + <> + {'Wait until '} + + {isNaN(parsed.getTime()) ? '--' : formatDate(preferences, parsed, 'PP')} + + + ) + } + return null + }, newData: async () => ({ minutes: 0, hours: 0, diff --git a/apps/ui/src/views/journey/steps/Experiment.tsx b/apps/ui/src/views/journey/steps/Experiment.tsx index 47fb2b18..156b4e69 100644 --- a/apps/ui/src/views/journey/steps/Experiment.tsx +++ b/apps/ui/src/views/journey/steps/Experiment.tsx @@ -12,6 +12,7 @@ export const experimentStep: JourneyStepType<{}, ExperimentStepChildConfig> = { icon: , category: 'flow', description: 'Randomly send users down different paths.', + Describe: () => <>{'Proportionally split users between paths.'}, newEdgeData: async () => ({ ratio: 1 }), Edit: () => (
diff --git a/apps/ui/src/views/journey/steps/Gate.tsx b/apps/ui/src/views/journey/steps/Gate.tsx index 1ad55387..d6f1a703 100644 --- a/apps/ui/src/views/journey/steps/Gate.tsx +++ b/apps/ui/src/views/journey/steps/Gate.tsx @@ -1,6 +1,8 @@ +import { useContext } from 'react' import { JourneyStepType, Rule } from '../../../types' import { GateStepIcon } from '../../../ui/icons' -import RuleBuilder, { createWrapperRule } from '../../users/RuleBuilder' +import RuleBuilder, { createWrapperRule, ruleDescription } from '../../users/RuleBuilder' +import { PreferencesContext } from '../../../ui/PreferencesContext' interface GateConfig { rule: Rule @@ -11,6 +13,19 @@ export const gateStep: JourneyStepType = { icon: , category: 'flow', description: 'Proceed on different paths depending on condition results.', + Describe({ + value, + }) { + const [preferences] = useContext(PreferencesContext) + if (value.rule) { + return ( +
+ {ruleDescription(preferences, value.rule)} +
+ ) + } + return null + }, newData: async () => ({ rule: createWrapperRule(), }), diff --git a/apps/ui/src/views/journey/steps/JourneyLink.tsx b/apps/ui/src/views/journey/steps/JourneyLink.tsx index cb36465a..4b674248 100644 --- a/apps/ui/src/views/journey/steps/JourneyLink.tsx +++ b/apps/ui/src/views/journey/steps/JourneyLink.tsx @@ -4,6 +4,7 @@ import { JourneyStepType } from '../../../types' import { EntityIdPicker } from '../../../ui/form/EntityIdPicker' import { LinkStepIcon } from '../../../ui/icons' import { JourneyForm } from '../JourneyForm' +import { useResolver } from '../../../hooks' interface JourneyLinkConfig { target_id: number @@ -15,6 +16,34 @@ export const journeyLinkStep: JourneyStepType = { icon: , category: 'action', description: 'Send users to another journey.', + Describe({ project, journey, value: { target_id } }) { + const [target] = useResolver(useCallback(async () => { + if (target_id === journey.id) { + return journey + } + if (target_id) { + return await api.journeys.get(project.id, target_id) + } + return null + }, [project, journey, target_id])) + if (target === journey) { + return ( + <> + {'Restart '} + {target.name} + + ) + } + if (target) { + return ( + <> + {'Start journey: '} + {target.name} + + ) + } + return null + }, newData: async () => ({ target_id: 0, delay: '1 day', @@ -23,7 +52,6 @@ export const journeyLinkStep: JourneyStepType = { value, onChange, project, - journey, }) { return ( = { subtitle="Send users to this journey when they reach this step." get={useCallback(async id => await api.journeys.get(project.id, id), [project])} search={useCallback(async q => await api.journeys.search(project.id, { q, limit: 50 }), [project])} - optionEnabled={o => o.id !== journey.id} value={value.target_id} onChange={target_id => onChange({ ...value, target_id })} required diff --git a/apps/ui/src/views/journey/steps/Update.tsx b/apps/ui/src/views/journey/steps/Update.tsx index 216abfe7..0420a23b 100644 --- a/apps/ui/src/views/journey/steps/Update.tsx +++ b/apps/ui/src/views/journey/steps/Update.tsx @@ -1,9 +1,9 @@ import { JourneyStepType } from '../../../types' -import { useState } from 'react' +import SourceEditor from '@monaco-editor/react' +import { useContext } from 'react' +import { PreferencesContext } from '../../../ui/PreferencesContext' import { UpdateStepIcon } from '../../../ui/icons' -import Modal from '../../../ui/Modal' -import Button from '../../../ui/Button' -import SourceEditor from '../../../ui/SourceEditor' +import { JsonPreview } from '../../../ui' interface UpdateConfig { template: string // handlebars template for json object @@ -14,58 +14,43 @@ export const updateStep: JourneyStepType = { icon: , category: 'action', description: 'Make updates to a user\'s profile.', + Describe({ value }) { + if (value?.template) { + try { + const parsed = JSON.parse(value.template) + return ( + + ) + } catch { + return ( + <> + {'(click to see updated fields)'} + + ) + } + } + return null + }, newData: async () => ({ template: '{\n\n}\n', }), Edit: ({ onChange, value }) => { - const [open, setOpen] = useState(false) + const [{ mode }] = useContext(PreferencesContext) return ( <> - - - onChange({ ...value, template })} - value={value.template ?? ''} - height={500} - width="100%" - language="json" - /> - +

+ {'Write a Handlebars template that renders JSON that will be shallow merged into the user\'s profile data.'} + {' The user\'s current profile data is available in the '}{'user'} + {' variable, and data collected at other steps are available in '}{'journey[data_key]'}{'.'} +

+ onChange({ ...value, template })} + value={value.template ?? ''} + height={500} + width="400px" + theme={mode === 'dark' ? 'vs-dark' : undefined} + language="handlebars" + /> ) }, diff --git a/apps/ui/src/views/router.tsx b/apps/ui/src/views/router.tsx index e15824dc..dc625e34 100644 --- a/apps/ui/src/views/router.tsx +++ b/apps/ui/src/views/router.tsx @@ -42,6 +42,9 @@ import ProjectSidebar from './project/ProjectSidebar' import Admins from './organization/Admins' import OrganizationSettings from './organization/Settings' import Locales from './settings/Locales' +import JourneyUserEntrances from './journey/JourneyUserEntrances' +import UserDetailJourneys from './users/UserDetailJourneys' +import EntranceDetails from './journey/EntranceDetails' export const useRoute = (includeProject = true) => { const { projectId = '' } = useParams() @@ -266,6 +269,16 @@ export const createRouter = ({ apiPath: api.journeys, context: JourneyContext, element: , + children: [ + { + index: true, + element: , + }, + { + path: 'entrances', + element: , + }, + ], }), createStatefulRoute({ path: 'users', @@ -294,6 +307,10 @@ export const createRouter = ({ path: 'subscriptions', element: , }, + { + path: 'journeys', + element: , + }, ], }), createStatefulRoute({ @@ -307,6 +324,11 @@ export const createRouter = ({ context: ListContext, element: , }), + { + path: 'entrances/:entranceId', + loader: async ({ params }) => await api.journeys.entrances.log(params.projectId!, params.entranceId!), + element: , + }, { path: 'settings', element: , diff --git a/apps/ui/src/views/settings/ApiKeys.tsx b/apps/ui/src/views/settings/ApiKeys.tsx index cd67aa5e..523f3a70 100644 --- a/apps/ui/src/views/settings/ApiKeys.tsx +++ b/apps/ui/src/views/settings/ApiKeys.tsx @@ -96,11 +96,11 @@ export default function ProjectApiKeys() { editing && ( onSubmit={ - async ({ id, name, description, scope }) => { + async ({ id, name, description, scope, role }) => { if (id) { - await api.apiKeys.update(project.id, id, { name, description }) + await api.apiKeys.update(project.id, id, { name, description, role }) } else { - await api.apiKeys.create(project.id, { name, description, scope }) + await api.apiKeys.create(project.id, { name, description, scope, role }) } await state.reload() setEditing(null) diff --git a/apps/ui/src/views/users/RuleBuilder.css b/apps/ui/src/views/users/RuleBuilder.css index 68f25996..6b58ee6e 100644 --- a/apps/ui/src/views/users/RuleBuilder.css +++ b/apps/ui/src/views/users/RuleBuilder.css @@ -109,4 +109,4 @@ .rule-form-title span { font-weight: 500; -} \ No newline at end of file +} diff --git a/apps/ui/src/views/users/RuleBuilder.tsx b/apps/ui/src/views/users/RuleBuilder.tsx index 5272e488..21ade651 100644 --- a/apps/ui/src/views/users/RuleBuilder.tsx +++ b/apps/ui/src/views/users/RuleBuilder.tsx @@ -1,5 +1,5 @@ import { Combobox } from '@headlessui/react' -import { Operator, Rule, RuleSuggestions, RuleType, WrapperRule, ControlledInputProps, FieldProps } from '../../types' +import { Operator, Rule, RuleSuggestions, RuleType, WrapperRule, ControlledInputProps, FieldProps, Preferences } from '../../types' import { FieldPath, FieldValues, useController } from 'react-hook-form' import Button from '../../ui/Button' import ButtonGroup from '../../ui/ButtonGroup' @@ -13,7 +13,7 @@ import { useResolver } from '../../hooks' import api from '../../api' import { highlightSearch, usePopperSelectDropdown } from '../../ui/utils' import clsx from 'clsx' -import { snakeToTitle } from '../../utils' +import { formatDate, snakeToTitle } from '../../utils' export const createWrapperRule = (): WrapperRule => ({ path: '$', @@ -90,9 +90,123 @@ const operatorTypes: Record = { wrapper: [ { key: 'or', label: 'any' }, { key: 'and', label: 'all' }, + { key: 'none', label: 'none' }, + { key: 'xor', label: 'only one' }, ], } +interface GroupedRule extends Omit { + value?: string | string[] +} + +const trimPathDisplay = (path: string = '') => path.startsWith('$.') ? path.substring(2) : path + +export function ruleDescription(preferences: Preferences, rule: Rule | GroupedRule, nodes: ReactNode[] = [], wrapperOperator?: Operator): ReactNode { + const root = nodes.length === 0 + if (rule.type === 'wrapper') { + if (rule.group === 'event' && (rule.path === '$.name' || rule.path === 'name')) { + nodes.push( + 'has user ', + + {rule.value ?? ''} + , + ) + if (rule.children?.length) { + nodes.push(' where ') + } + } else { + nodes.push('user ') + } + if (rule.children?.length) { + const grouped: GroupedRule[] = [] + for (const child of rule.children) { + if (child.type === 'wrapper') { + grouped.push(child) + continue + } + const path = trimPathDisplay(child.path) + const prev = grouped.find(g => trimPathDisplay(g.path) === path && g.operator === child.operator) + if (prev) { + if (Array.isArray(prev.value)) { + prev.value.push(child.value ?? '') + } else { + prev.value = [prev.value ?? '', child.value ?? ''] + } + } else { + grouped.push({ ...child }) // copy so we don't modify original + } + } + grouped.forEach((g, i) => { + if (i > 0) { + nodes.push(', ') + if (wrapperOperator) { + nodes.push(rule.operator === 'and' ? 'and ' : 'or ') + } + } + ruleDescription(preferences, g, nodes, rule.operator) + }) + } + } else { + nodes.push( + + {trimPathDisplay(rule.path)} + , + ) + + nodes.push(' ' + operatorTypes[rule.type]?.find(ot => ot.key === rule.operator)?.label ?? rule.operator) + + if (rule.operator !== 'empty' && rule.operator !== 'is set' && rule.operator !== 'is not set') { + nodes.push(' ') + const values = Array.isArray(rule.value) ? rule.value : [rule.value ?? ''] + values.forEach((value, i, a) => { + if (i > 0) { + nodes.push(', ') + if (i === a.length - 1 && wrapperOperator) { + nodes.push(wrapperOperator === 'and' ? 'and ' : 'or ') + } + } + if (value.includes('{{')) { + nodes.push( + + {value} + , + ) + } else { + value = value.trim() + if (rule.type === 'boolean') value = 'true' + if (rule.type === 'number') { + try { + if (value.includes('.')) { + value = parseFloat(value).toLocaleString() + } else { + value = parseInt(value, 10).toLocaleString() + } + } catch {} + } + if (rule.type === 'date') { + try { + value = formatDate(preferences, value, 'Ppp') + } catch {} + } + nodes.push( + + {value} + , + ) + } + }) + } + } + if (root) { + return ( + + {nodes} + + ) + } + return nodes +} + interface RuleEditProps { rule: Rule setRule: (value: Rule) => void diff --git a/apps/ui/src/views/users/UserDetail.tsx b/apps/ui/src/views/users/UserDetail.tsx index c578d641..e78fc393 100644 --- a/apps/ui/src/views/users/UserDetail.tsx +++ b/apps/ui/src/views/users/UserDetail.tsx @@ -38,6 +38,11 @@ export default function UserDetail() { to: 'subscriptions', children: 'Subscriptions', }, + { + key: 'journeys', + to: 'journeys', + children: 'Journeys', + }, ]} /> diff --git a/apps/ui/src/views/users/UserDetailJourneys.tsx b/apps/ui/src/views/users/UserDetailJourneys.tsx new file mode 100644 index 00000000..459d2d2d --- /dev/null +++ b/apps/ui/src/views/users/UserDetailJourneys.tsx @@ -0,0 +1,39 @@ +import { useCallback, useContext } from 'react' +import { ProjectContext, UserContext } from '../../contexts' +import { SearchTable, useSearchTableQueryState } from '../../ui/SearchTable' +import api from '../../api' +import { Tag } from '../../ui' +import { useNavigate } from 'react-router-dom' + +export default function UserDetailJourneys() { + + const navigate = useNavigate() + + const [project] = useContext(ProjectContext) + const [user] = useContext(UserContext) + + const projectId = project.id + const userId = user.id + + const state = useSearchTableQueryState(useCallback(async params => await api.users.journeys.search(projectId, userId, params), [projectId, userId])) + + return ( + item.journey!.name, + }, + { + key: 'created_at', + }, + { + key: 'ended_at', + cell: ({ item }) => item.ended_at ?? Running, + }, + ]} + onSelectRow={e => navigate(`../../entrances/${e.id}`)} + /> + ) +} diff --git a/package-lock.json b/package-lock.json index 7ff2c739..e75ae63a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ } }, "apps/platform": { + "name": "@parcelvoy/platform", "version": "0.1.0", "dependencies": { "@aws-sdk/client-s3": "^3.171.0", @@ -110,6 +111,7 @@ "dev": true }, "apps/ui": { + "name": "@parcelvoy/ui", "version": "0.1.0", "dependencies": { "@fontsource/inter": "^4.5.14",