Adds sticky note to journeys (#675)

This commit is contained in:
Chris Anderson 2025-06-24 09:17:44 -05:00 committed by GitHub
parent 2e7a303a55
commit 7cfaa0fe16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 206 additions and 28 deletions

View file

@ -1,4 +1,4 @@
import { add, addDays, addHours, addMinutes, isEqual, isFuture, isPast, parse } from 'date-fns'
import { add, addDays, addHours, addMinutes, getDay, isEqual, isFuture, isPast, parse } from 'date-fns'
import Model from '../core/Model'
import { getCampaign, getCampaignSend, triggerCampaignSend } from '../campaigns/CampaignService'
import { crossTimezoneCopy, random, snakeCase, uuid } from '../utilities'
@ -176,6 +176,7 @@ export class JourneyDelay extends JourneyStep {
days = 0
time?: string
date?: string
exclusion_days?: number[] // 0 = Sunday, 6 = Saturday
parseJson(json: any) {
super.parseJson(json)
@ -185,18 +186,19 @@ export class JourneyDelay extends JourneyStep {
this.hours = json?.data?.hours
this.days = json?.data?.days
this.time = json?.data?.time
this.exclusion_days = json?.data?.exclusion_days
}
async process(state: JourneyState, userStep: JourneyUserStep) {
// if no delay has been set yet, calculate one
// If no delay has been set yet, calculate one
if (!userStep.delay_until) {
userStep.delay_until = await this.offset(state)
userStep.type = 'delay'
return
}
// if delay has passed, change to completed
// If delay has passed, change to completed
if (!isFuture(userStep.delay_until)) {
userStep.type = 'completed'
}
@ -216,12 +218,16 @@ export class JourneyDelay extends JourneyStep {
minutes: this.minutes,
})
} else if (this.format === 'time' && time) {
const localDate = utcToZonedTime(baseDate, timezone)
let localDate = utcToZonedTime(baseDate, timezone)
const parsedDate = parse(time, 'HH:mm', baseDate)
localDate.setMinutes(parsedDate.getMinutes())
localDate.setHours(parsedDate.getHours())
localDate.setSeconds(0)
if (this.exclusion_days?.length) {
localDate = this.nextNotExcludedDay(localDate)
}
const nextDate = zonedTimeToUtc(localDate, timezone)
// In case things are delayed, allow for up to 30 minutes
@ -243,6 +249,17 @@ export class JourneyDelay extends JourneyStep {
return baseDate
}
private nextNotExcludedDay(date: Date): Date {
for (let i = 0; i < 7; i++) {
if (this.exclusion_days?.includes(getDay(date))) {
date = addDays(date, 1)
} else {
return date
}
}
return date
}
}
export class JourneyAction extends JourneyStep {
@ -333,7 +350,7 @@ export class JourneyGate extends JourneyStep {
}
/**
* randomly distribute users to different branches
* Randomly distribute users to different branches
*/
export class JourneyExperiment extends JourneyStep {
static type = 'experiment'
@ -361,7 +378,7 @@ export class JourneyExperiment extends JourneyStep {
}
/**
* add user to another journey
* Add user to another journey
*/
export class JourneyLink extends JourneyStep {
static type = 'link'
@ -421,6 +438,10 @@ export class JourneyLink extends JourneyStep {
}
}
export class JourneySticky extends JourneyStep {
static type = 'sticky'
}
export class JourneyBalancer extends JourneyStep {
static type = 'balancer'
@ -571,6 +592,7 @@ export const journeyStepTypes = [
JourneyUpdate,
JourneyBalancer,
JourneyEvent,
JourneySticky,
].reduce<Record<string, typeof JourneyStep>>((a, c) => {
a[c.type] = c
return a

View file

@ -66,6 +66,7 @@
"dark_mode": "Use Dark Theme",
"data_key": "Data Key",
"data_key_description": "Makes data stored at this step available in user update and campaign templates.",
"date": "Date",
"day": "Day",
"day_one": "{{count}} Day",
"day_other": "{{count}} Days",
@ -78,6 +79,8 @@
"delay_desc": "Add a delay between the previous and next step.",
"delay_time_desc": "Delay until the specified time in the user's timezone.",
"delay_until": "Delay Until",
"delay_exclusion_dates": "Exclusion Dates",
"delay_exclusion_dates_desc": "Don't release users at the time above if the day of the week is one of the following.",
"delete": "Delete",
"delete_admin_confirmation": "Are you sure you want to delete this admin?",
"delete_integration_confirmation": "Are you sure you want to archive this integration?",
@ -313,6 +316,9 @@
"static": "Static",
"status": "Status",
"step_date": "Step Date",
"sticky": "Sticky Note",
"sticky_desc": "Add a sticky note that will be visible in the journey editor.",
"sticky_text_label": "Text",
"subject": "Subject",
"submit": "Submit",
"subscribed": "Subscribed",

View file

@ -338,7 +338,7 @@ export interface JourneyStepTypeEdgeProps<T, E> extends ControlledProps<E> {
export interface JourneyStepType<T = any, E = any> {
name: string
icon: ReactNode
category: 'entrance' | 'delay' | 'flow' | 'action' | 'exit'
category: 'entrance' | 'delay' | 'flow' | 'action' | 'exit' | 'info'
description: string
Describe?: ComponentType<JourneyStepTypeEditProps<T>>
newData?: () => Promise<T>
@ -348,6 +348,8 @@ export interface JourneyStepType<T = any, E = any> {
sources?: string[]
multiChildSources?: boolean
hasDataKey?: boolean
hideTopHandle?: boolean
hideBottomHandle?: boolean
}
export interface JourneyUserStep {

View file

@ -23,7 +23,7 @@ const frequencyOptions: FieldOption[] = [
},
]
const dayOptions: FieldOption[] = [
export const dayOptions: FieldOption[] = [
{
key: 'MO',
label: 'Mon',

View file

@ -4,6 +4,7 @@ import { FieldOption } from './Field'
interface MultiOptionFieldProps extends ControlledInputProps<any[]> {
options: FieldOption[]
max?: number
}
export function MultiOptionField({
@ -14,8 +15,11 @@ export function MultiOptionField({
subtitle,
required,
value,
max,
}: MultiOptionFieldProps) {
const atLimit = max !== undefined && value.length >= max
return (
<div className="options-group">
{
@ -34,6 +38,7 @@ export function MultiOptionField({
options.map(({ key, label }) => {
const selected = !!value?.includes(key)
const isDisabled = disabled ?? (atLimit && !selected)
return (
<label
@ -48,7 +53,7 @@ export function MultiOptionField({
: value?.filter(v => v !== key) ?? [],
)}
style={{ display: 'none' }}
disabled={disabled}
disabled={isDisabled}
/>
{label}
</label>

View file

@ -154,6 +154,12 @@ export const DelayStepIcon = () => (
</svg>
)
export const StickyStepIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="icon">
<path strokeLinecap="round" strokeLinejoin="round" d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z" />
</svg>
)
export const ExperimentStepIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16" className="icon">
<path d="M7 7V1.414a1 1 0 0 1 2 0V2h5a1 1 0 0 1 .8.4l.975 1.3a.5.5 0 0 1 0 .6L14.8 5.6a1 1 0 0 1-.8.4H9v10H7v-5H2a1 1 0 0 1-.8-.4L.225 9.3a.5.5 0 0 1 0-.6L1.2 7.4A1 1 0 0 1 2 7h5zm1 3V8H2l-.75 1L2 10h6zm0-5h6l.75-1L14 3H8v2z"/>

View file

@ -32,6 +32,10 @@
--color-green-soft: #D1FADF;
--color-green-hard: #039855;
--color-purple: #8729c1;
--color-purple-soft: #F7D8FF;
--color-purple-hard: #4e0c77;
--color-shadow: rgba(0, 0, 0, 0.1);
--color-shadow-soft: rgba(0, 0, 0, 0.05);
@ -59,4 +63,7 @@
--color-shadow: var(--color-grey);
--color-shadow-soft: var(--color-grey-soft);
--color-purple-soft: #4e0c77;
--color-purple-hard: #d1a7ea;
}

View file

@ -111,6 +111,16 @@
color: var(--color-green-hard);
}
.component.info .component-handle {
background-color: var(--color-purple-soft);
color: var(--color-purple-hard);
}
.journey-step.info .step-header-icon {
background-color: var(--color-purple-hard);
color: var(--color-purple-soft);
}
.journey-minimap.entrance,
.journey-minimap.exit {
fill: var(--color-red-soft);
@ -145,6 +155,14 @@
fill: var(--color-green);
}
.journey-minimap.info {
fill: var(--color-purple-soft);
}
.journey-minimap.info.selected {
fill: var(--color-purple);
}
.journey .react-flow__minimap {
background-color: var(--color-background-soft);
}
@ -204,6 +222,23 @@
border-color: var(--color-yellow-hard);
}
.journey-step.info {
background: var(--color-purple-soft);
border-color: var(--color-purple-soft);
}
.journey-step.info.selected {
border-color: var(--color-purple-hard);
}
.journey-step.info .journey-step-header {
border-bottom: none;
}
.journey-step.info .journey-step-body {
padding-top: 0;
}
.journey-step .data-key {
background-color: var(--color-background-soft);
padding: 7px 10px;
@ -302,7 +337,7 @@
}
.journey-step-body {
padding: 20px;
padding: 15px;
}
.journey-step-body-name {

View file

@ -66,6 +66,7 @@ export const stepCategoryColors = {
flow: 'green',
delay: 'yellow',
exit: 'red',
info: 'purple',
}
function JourneyStepNode({
@ -110,7 +111,7 @@ function JourneyStepNode({
return (
<>
{
type.category !== 'entrance' && (
!type.hideTopHandle && (
<Handle type="target" position={Position.Top} id={'t-' + id} />
)
}
@ -128,7 +129,7 @@ function JourneyStepNode({
{type.icon}
</span>
<h4 className="step-header-title">{name || t(type.name)}</h4>
<div className="step-header-stats"
{type.category !== 'info' && <div className="step-header-stats"
onClickCapture={stepId
? () => setViewUsersStep({ stepId, entrance: typeName === 'entrance' })
: undefined
@ -153,7 +154,7 @@ function JourneyStepNode({
</span>
)
}
</div>
</div>}
</div>
<div className="journey-step-body">
{
@ -175,7 +176,7 @@ function JourneyStepNode({
</div>
</div>
{
(
!type.hideBottomHandle && (
Array.isArray(type.sources)
? type.sources
: ['']
@ -431,6 +432,7 @@ export default function JourneyEditor() {
const journeyId = journey.id
const isDraft = journey.status === 'draft'
const draftId = journey.draft_id
const parentId = journey.parent_id
const loadSteps = useCallback(async () => {
const steps = await api.journeys.steps.get(project.id, journeyId)
@ -691,6 +693,12 @@ export default function JourneyEditor() {
actions={
isDraft
? <>
{!parentId && <Button
variant="secondary"
onClick={() => setEditOpen(true)}
>
{t('edit_details')}
</Button>}
<Button
onClick={publishJourney}
isLoading={saving}
@ -764,7 +772,7 @@ export default function JourneyEditor() {
selectNodesOnDrag
fitView
maxZoom={1}
minZoom={0.2}
minZoom={0.1}
zoomOnDoubleClick={false}
>
<Background className="internal-canvas" />

View file

@ -21,6 +21,7 @@ export function JourneyForm({ journey, onSaved }: JourneyFormProps) {
{ key: 'live', label: t('live') },
{ key: 'off', label: t('off') },
]
const isCreated = journey?.id && journey?.status !== 'draft'
return (
<FormWrapper<Journey>
onSubmit={async ({ id, name, description, status, tags }) => {
@ -53,14 +54,13 @@ export function JourneyForm({ journey, onSaved }: JourneyFormProps) {
name="tags"
label={t('tags')}
/>
{journey?.status}
<RadioInput.Field
{isCreated && <RadioInput.Field
form={form}
name="status"
label={t('status')}
options={statusOptions}
required
/>
/>}
</>
)
}

View file

@ -38,7 +38,7 @@ export function JourneyStepUsers({ open, onClose, entrance, stepId }: StepUsersP
title={t('users')}
size="large"
actions={
<Button
entrance && <Button
size="small"
variant="primary"
onClick={() => setIsUserLookupOpen(true)}
@ -84,7 +84,6 @@ export function JourneyStepUsers({ open, onClose, entrance, stepId }: StepUsersP
{
key: 'delay_until',
title: t('delay_until'),
cell: ({ item }) => item.delay_until,
},
]}
onSelectRow={entrance ? ({ id }) => window.open(`/projects/${projectId}/entrances/${id}`, '_blank') : undefined}

View file

@ -7,6 +7,8 @@ import { formatDate, formatDuration, snakeToTitle } from '../../../utils'
import { PreferencesContext } from '../../../ui/PreferencesContext'
import { parse, parseISO } from 'date-fns'
import { useTranslation } from 'react-i18next'
import { MultiOptionField } from '../../../ui/form/MultiOptionField'
import { FieldOption } from '../../../ui/form/Field'
interface DelayStepConfig {
format: 'duration' | 'time' | 'date'
@ -15,8 +17,40 @@ interface DelayStepConfig {
days: number
time?: string
date?: string
exclusion_days?: string[]
}
export const dayOptions: FieldOption[] = [
{
key: 0,
label: 'Sun',
},
{
key: 1,
label: 'Mon',
},
{
key: 2,
label: 'Tue',
},
{
key: 3,
label: 'Wed',
},
{
key: 4,
label: 'Thu',
},
{
key: 5,
label: 'Fri',
},
{
key: 6,
label: 'Sat',
},
]
export const delayStep: JourneyStepType<DelayStepConfig> = {
name: 'delay',
icon: <DelayStepIcon />,
@ -101,14 +135,24 @@ export const delayStep: JourneyStepType<DelayStepConfig> = {
))
}
{ value.format === 'time'
&& <TextInput
name="time"
label={t('time')}
type="time"
subtitle={t('delay_time_desc')}
value={value.time ?? ''}
onChange={time => onChange({ ...value, time })}
/>
&& <div style={{ maxWidth: 400 }}>
<TextInput
name="time"
label={t('time')}
type="time"
subtitle={t('delay_time_desc')}
value={value.time ?? ''}
onChange={time => onChange({ ...value, time })}
/>
<MultiOptionField
options={dayOptions}
value={value.exclusion_days ?? []}
onChange={days => onChange({ ...value, exclusion_days: days })}
label={t('delay_exclusion_dates')}
subtitle={t('delay_exclusion_dates_desc')}
max={6}
/>
</div>
}
{ value.format === 'date'
&& <TextInput

View file

@ -248,4 +248,5 @@ export const entranceStep: JourneyStepType<EntranceConfig> = {
)
},
hasDataKey: true,
hideTopHandle: true,
}

View file

@ -56,4 +56,5 @@ export const exitStep: JourneyStepType<ExitConfig> = {
/>
)
},
hideBottomHandle: true,
}

View file

@ -0,0 +1,41 @@
import { JourneyStepType } from '../../../types'
import { StickyStepIcon } from '../../../ui/icons'
import { useTranslation } from 'react-i18next'
import TextInput from '../../../ui/form/TextInput'
interface StickyConfig {
text?: string
}
export const stickyStep: JourneyStepType<StickyConfig> = {
name: 'sticky',
icon: <StickyStepIcon />,
category: 'info',
description: 'sticky_desc',
Describe({
value,
}) {
return (
<div style={{ maxWidth: 300 }}>
{value.text}
</div>
)
},
Edit({
onChange,
value,
}) {
const { t } = useTranslation()
return (
<TextInput
name="sticky_text"
label={t('sticky_text_label')}
value={value.text ?? ''}
onChange={(text) => onChange({ ...value, text })} // Update the text field
textarea
/>
)
},
hideBottomHandle: true,
hideTopHandle: true,
}

View file

@ -9,3 +9,4 @@ export { journeyLinkStep as link } from './JourneyLink'
export { updateStep as update } from './Update'
export { balancerStep as balancer } from './Balancer'
export { eventStep as event } from './Event'
export { stickyStep as sticky } from './Sticky'