diff --git a/apps/platform/src/journey/JourneyController.ts b/apps/platform/src/journey/JourneyController.ts index bf92b178..23b5c30c 100644 --- a/apps/platform/src/journey/JourneyController.ts +++ b/apps/platform/src/journey/JourneyController.ts @@ -5,8 +5,8 @@ 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, pagedEntrancesByJourney, getEntranceLog } from './JourneyRepository' -import { JourneyStepMapParams, JourneyUserStep, journeyStepTypes, toJourneyStepMap } from './JourneyStep' +import { createJourney, deleteJourney, getJourneyStepMap, getJourney, pagedJourneys, setJourneyStepMap, updateJourney, pagedEntrancesByJourney, getEntranceLog, pagedUsersByStep } from './JourneyRepository' +import { JourneyStep, JourneyStepMapParams, JourneyUserStep, journeyStepTypes, toJourneyStepMap } from './JourneyStep' import { User } from '../users/User' const router = new Router< @@ -161,4 +161,17 @@ router.get('/:journeyId/entrances', async ctx => { ctx.body = await pagedEntrancesByJourney(ctx.state.journey!.id, params) }) +router.get('/:journeyId/steps/:stepId/users', async ctx => { + const params = extractQueryParams(ctx.query, searchParamsSchema) + const step = await JourneyStep.first(q => q + .where('journey_id', ctx.state.journey!.id) + .where('id', parseInt(ctx.params.stepId)), + ) + if (!step) { + ctx.throw(404) + return + } + ctx.body = await pagedUsersByStep(step.id, params) +}) + export default router diff --git a/apps/platform/src/journey/JourneyRepository.ts b/apps/platform/src/journey/JourneyRepository.ts index 86706315..e55efe48 100644 --- a/apps/platform/src/journey/JourneyRepository.ts +++ b/apps/platform/src/journey/JourneyRepository.ts @@ -250,6 +250,27 @@ export const pagedEntrancesByUser = async (userId: number, params: PageParams) = return r } +export const pagedUsersByStep = async (stepId: number, params: PageParams) => { + const r = await JourneyUserStep.search(params, q => q + .where('step_id', stepId) + .orderBy('id', 'desc'), + ) + 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 getEntranceLog = async (entranceId: number) => { const userSteps = await JourneyUserStep.all(q => q .where(function() { diff --git a/apps/platform/src/journey/JourneyStep.ts b/apps/platform/src/journey/JourneyStep.ts index 4825fca0..ba6a664f 100644 --- a/apps/platform/src/journey/JourneyStep.ts +++ b/apps/platform/src/journey/JourneyStep.ts @@ -452,6 +452,7 @@ export type JourneyStepMap = Record stats_at?: Date next_scheduled_at?: Date + id?: number }> export type JourneyStepMapParams = Record @@ -481,6 +482,8 @@ export async function toJourneyStepMap(steps: JourneyStep[], children: JourneySt return a }, []), stats: step.stats, + next_scheduled_at: step.next_scheduled_at ?? undefined, + id: step.id, } } diff --git a/apps/ui/src/api.ts b/apps/ui/src/api.ts index 53963dd7..4563949f 100644 --- a/apps/ui/src/api.ts +++ b/apps/ui/src/api.ts @@ -176,6 +176,9 @@ const api = { set: async (projectId: number | string, journeyId: number | string, stepData: JourneyStepMap) => await client .put(`/admin/projects/${projectId}/journeys/${journeyId}/steps`, stepData) .then(r => r.data), + searchUsers: async (projectId: number | string, journeyId: number | string, stepId: number | string, params: SearchParams) => await client + .get>(`/admin/projects/${projectId}/journeys/${journeyId}/steps/${stepId}/users`, { params }) + .then(r => r.data), }, entrances: { search: async (projectId: number | string, journeyId: number | string, params: SearchParams) => await client diff --git a/apps/ui/src/types.ts b/apps/ui/src/types.ts index cbcb8c74..5e232108 100644 --- a/apps/ui/src/types.ts +++ b/apps/ui/src/types.ts @@ -277,6 +277,7 @@ export interface JourneyStepMap { children?: JourneyStepMapChild[] stats?: Record stats_at?: Date + id?: number } } diff --git a/apps/ui/src/ui/SearchTable.tsx b/apps/ui/src/ui/SearchTable.tsx index 5005c190..0748e47a 100644 --- a/apps/ui/src/ui/SearchTable.tsx +++ b/apps/ui/src/ui/SearchTable.tsx @@ -73,11 +73,12 @@ export const useTableSearchParams = () => { /** * local state */ -export function useSearchTableState(loader: (params: SearchParams) => Promise | null>) { +export function useSearchTableState(loader: (params: SearchParams) => Promise | null>, initialParams?: Partial) { const [params, setParams] = useState({ limit: 25, q: '', + ...initialParams ?? {}, }) const [results,, reload] = useResolver(useCallback(async () => await loader(params), [loader, params])) diff --git a/apps/ui/src/views/journey/JourneyEditor.tsx b/apps/ui/src/views/journey/JourneyEditor.tsx index fa229c09..b9e38cce 100644 --- a/apps/ui/src/views/journey/JourneyEditor.tsx +++ b/apps/ui/src/views/journey/JourneyEditor.tsx @@ -46,6 +46,8 @@ import { JourneyForm } from './JourneyForm' import { ActionStepIcon, CheckCircleIcon, CloseIcon, CopyIcon, DelayStepIcon, EntranceStepIcon, ForbiddenIcon } from '../../ui/icons' import Tag from '../../ui/Tag' import TextInput from '../../ui/form/TextInput' +import { SearchTable } from '../../ui' +import { useSearchTableState } from '../../ui/SearchTable' const getStepType = (type: string) => (type ? journeySteps[type as keyof typeof journeySteps] as JourneyStepType : null) ?? null @@ -65,6 +67,58 @@ export const stepCategoryColors = { delay: 'yellow', } +interface StepUsersProps { + stepId: number + entrance?: boolean +} + +function StepUsers({ entrance, stepId }: StepUsersProps) { + + 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 ( + <> + item.user!.full_name ?? '-', + }, + { + key: 'external_id', + cell: ({ item }) => item.user?.external_id ?? '-', + }, + { + key: 'email', + cell: ({ item }) => item.user?.email ?? '-', + }, + { + key: 'phone', + cell: ({ item }) => item.user?.phone ?? '-', + }, + { + key: 'created_at', + title: 'Step Date', + cell: ({ item }) => item.created_at, + }, + { + key: 'delay_until', + title: 'Delay Until', + cell: ({ item }) => item.delay_until, + }, + ]} + onSelectRow={entrance ? ({ id }) => window.open(`/projects/${projectId}/entrances/${id}`, '_blank') : undefined} + /> + + ) +} + function JourneyStepNode({ id, data: { @@ -303,7 +357,7 @@ function stepsToNodes(stepMap: JourneyStepMap) { const nodes: Node[] = [] const edges: Edge[] = [] - for (const [id, { x, y, type, data, name, children, stats, stats_at }] of Object.entries(stepMap)) { + for (const [id, { x, y, type, data, name, children, stats, stats_at, id: stepId }] of Object.entries(stepMap)) { nodes.push({ id, position: { @@ -317,6 +371,7 @@ function stepsToNodes(stepMap: JourneyStepMap) { data, stats, stats_at, + stepId, }, }) const stepType = getStepType(type) @@ -510,6 +565,8 @@ export default function JourneyEditor() { const editNode = nodes.find(n => n.data.editing) + const [viewUsersStep, setViewUsersStep] = useState(null) + const onNodeDoubleClick = useCallback((_, n) => { setNodes(nds => nds.map(x => x.id === n.id ? { @@ -539,7 +596,17 @@ export default function JourneyEditor() {

{type.name}

{ editNode.data.stats && ( -
+
setViewUsersStep({ stepId: editNode.data.stepId, entrance: editNode.data.type === 'entrance' }) + : undefined + } + style={{ + cursor: editNode.data.stepId ? 'cursor' : undefined, + }} + > {editNode.data.stats.completed ?? 0} {statIcons.completed} @@ -735,6 +802,18 @@ export default function JourneyEditor() { }} /> + setViewUsersStep(null)} + title="Users" + size="large" + > + { + viewUsersStep && ( + + ) + } + ) }