mirror of
https://fast.feibisi.com/https://github.com/parcelvoy/platform.git
synced 2025-09-04 12:56:14 +08:00
Adds sticky note to journeys (#675)
This commit is contained in:
parent
2e7a303a55
commit
7cfaa0fe16
16 changed files with 206 additions and 28 deletions
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -23,7 +23,7 @@ const frequencyOptions: FieldOption[] = [
|
|||
},
|
||||
]
|
||||
|
||||
const dayOptions: FieldOption[] = [
|
||||
export const dayOptions: FieldOption[] = [
|
||||
{
|
||||
key: 'MO',
|
||||
label: 'Mon',
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"/>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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
|
||||
/>
|
||||
/>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -248,4 +248,5 @@ export const entranceStep: JourneyStepType<EntranceConfig> = {
|
|||
)
|
||||
},
|
||||
hasDataKey: true,
|
||||
hideTopHandle: true,
|
||||
}
|
||||
|
|
|
@ -56,4 +56,5 @@ export const exitStep: JourneyStepType<ExitConfig> = {
|
|||
/>
|
||||
)
|
||||
},
|
||||
hideBottomHandle: true,
|
||||
}
|
||||
|
|
41
apps/ui/src/views/journey/steps/Sticky.tsx
Normal file
41
apps/ui/src/views/journey/steps/Sticky.tsx
Normal 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,
|
||||
}
|
|
@ -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'
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue