mirror of
https://fast.feibisi.com/https://github.com/parcelvoy/platform.git
synced 2025-09-04 12:56:14 +08:00
Campaign Performance & UI Improvements (#673)
This commit is contained in:
parent
fdeef7bbca
commit
a2e3677508
21 changed files with 206 additions and 81 deletions
|
@ -76,6 +76,11 @@ export const getCampaign = async (id: number, projectId: number): Promise<Campai
|
|||
if (campaign.state === 'loading') {
|
||||
campaign.progress = await campaignPopulationProgress(campaign)
|
||||
}
|
||||
|
||||
logger.warn({
|
||||
query: await recipientClickhouseQuery(campaign),
|
||||
campaignId: campaign.id,
|
||||
}, 'campaign:generate:query')
|
||||
}
|
||||
|
||||
return campaign
|
||||
|
@ -291,12 +296,16 @@ export const generateSendList = async (campaign: SentCampaign) => {
|
|||
const now = Date.now()
|
||||
const cacheKey = CacheKeys.populationProgress(campaign)
|
||||
|
||||
logger.info({ campaignId: campaign.id, elapsed: Date.now() - now }, 'campaign:generate:progress:started')
|
||||
const query = await recipientClickhouseQuery(campaign)
|
||||
|
||||
logger.info({
|
||||
campaignId: campaign.id,
|
||||
elapsed: Date.now() - now,
|
||||
query,
|
||||
}, 'campaign:generate:progress:started')
|
||||
|
||||
// Generate the initial send list
|
||||
const result = await User.clickhouse().query(
|
||||
await recipientClickhouseQuery(campaign),
|
||||
)
|
||||
const result = await User.clickhouse().query(query)
|
||||
|
||||
const chunker = new Chunker<CampaignSendParams>(async items => {
|
||||
await App.main.db.transaction(async (trx) => {
|
||||
|
@ -370,7 +379,7 @@ const recipientClickhouseQuery = async (campaign: Campaign) => {
|
|||
.select('rule')
|
||||
.whereIn('id', ids)
|
||||
for (const list of lists) {
|
||||
queries.push(getRuleQuery(campaign.project_id, list.rule))
|
||||
queries.push('(' + getRuleQuery(campaign.project_id, list.rule) + ')')
|
||||
}
|
||||
return queries.join(' union distinct ')
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import { ProjectState } from '../auth/AuthMiddleware'
|
|||
import parse from '../storage/FileStream'
|
||||
import { projectRoleMiddleware } from '../projects/ProjectService'
|
||||
import ListStatsJob from './ListStatsJob'
|
||||
import { migrateStaticList } from '../utilities/migrate'
|
||||
|
||||
const router = new Router<
|
||||
ProjectState & { list?: List }
|
||||
|
@ -219,4 +220,9 @@ router.post('/:listId/recount', async ctx => {
|
|||
ctx.status = 204
|
||||
})
|
||||
|
||||
router.post('/:listId/migrate', async ctx => {
|
||||
await migrateStaticList(ctx.state.list!)
|
||||
ctx.status = 204
|
||||
})
|
||||
|
||||
export default router
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
export interface Push {
|
||||
tokens: string | string[]
|
||||
topic: string
|
||||
title: string
|
||||
body: string
|
||||
custom: Record<string, string | number>
|
||||
|
|
|
@ -19,14 +19,21 @@ export default class PushChannel {
|
|||
// Find tokens from active devices with push enabled
|
||||
const tokens = variables.user.pushEnabledDevices.map(device => device.token)
|
||||
|
||||
// If no tokens, don't send
|
||||
if (tokens?.length <= 0) return
|
||||
|
||||
const push = {
|
||||
tokens,
|
||||
...template.compile(variables),
|
||||
}
|
||||
|
||||
// If no tokens, don't send
|
||||
if (tokens?.length <= 0) {
|
||||
return {
|
||||
push,
|
||||
success: false,
|
||||
invalidTokens: [],
|
||||
response: 'No active devices with push enabled found.',
|
||||
}
|
||||
}
|
||||
|
||||
return await this.provider.send(push)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -162,7 +162,6 @@ export class TextTemplate extends Template {
|
|||
|
||||
export interface CompiledPush {
|
||||
title: string
|
||||
topic: string
|
||||
body: string
|
||||
custom: Record<string, any>
|
||||
}
|
||||
|
@ -170,7 +169,6 @@ export interface CompiledPush {
|
|||
export class PushTemplate extends Template {
|
||||
declare type: 'push'
|
||||
title!: string
|
||||
topic!: string
|
||||
body!: string
|
||||
url!: string
|
||||
custom!: Record<string, any>
|
||||
|
@ -179,7 +177,6 @@ export class PushTemplate extends Template {
|
|||
super.parseJson(json)
|
||||
|
||||
this.title = json?.data.title
|
||||
this.topic = json?.data.topic
|
||||
this.body = json?.data.body
|
||||
this.url = json?.data.url
|
||||
this.custom = json?.data.custom ?? {}
|
||||
|
@ -194,7 +191,6 @@ export class PushTemplate extends Template {
|
|||
const url = this.compileUrl(variables)
|
||||
|
||||
return {
|
||||
topic: this.topic,
|
||||
title: Render(this.title, variables),
|
||||
body: Render(this.body, variables),
|
||||
custom: { ...custom, url },
|
||||
|
@ -223,16 +219,15 @@ export class PushTemplate extends Template {
|
|||
validate() {
|
||||
return isValid({
|
||||
type: 'object',
|
||||
required: ['title', 'topic', 'body'],
|
||||
required: ['title', 'body'],
|
||||
properties: {
|
||||
title: { type: 'string' },
|
||||
topic: { type: 'string' },
|
||||
body: { type: 'string' },
|
||||
url: { type: 'string', nullable: true },
|
||||
},
|
||||
additionalProperties: true,
|
||||
errorMessage: {
|
||||
required: this.requiredErrors('title', 'topic', 'body'),
|
||||
required: this.requiredErrors('title', 'body'),
|
||||
},
|
||||
}, this.data)
|
||||
}
|
||||
|
|
|
@ -14,6 +14,8 @@ import { getUserFromEmail, getUserFromPhone } from '../users/UserRepository'
|
|||
import { loadWebhookChannel } from '../providers/webhook'
|
||||
import Project from '../projects/Project'
|
||||
import { getProject } from '../projects/ProjectService'
|
||||
import { logger } from '../config/logger'
|
||||
import EventPostJob from '../client/EventPostJob'
|
||||
|
||||
export const pagedTemplates = async (params: PageParams, projectId: number) => {
|
||||
return await Template.search(
|
||||
|
@ -72,7 +74,7 @@ export const screenshotHtml = (template: TemplateType) => {
|
|||
} else if (template.type === 'text') {
|
||||
return template.text
|
||||
} else if (template.type === 'push') {
|
||||
return `${template.title}<br/>${template.body}`
|
||||
return `<html style="font-size:36px;padding:10px">${template.title}<br/>${template.body}</html>`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
@ -106,25 +108,39 @@ export const sendProof = async (template: TemplateType, variables: Variables, re
|
|||
user.data = { ...user?.data, ...variables.user }
|
||||
variables = { user, event, context, project }
|
||||
|
||||
let response: any
|
||||
if (template.type === 'email') {
|
||||
const channel = await loadEmailChannel(campaign.provider_id, project.id)
|
||||
await channel?.send(template, variables)
|
||||
|
||||
response = await channel?.send(template, variables)
|
||||
logger.info(response, 'template:proof:email:result')
|
||||
} else if (template.type === 'text') {
|
||||
const channel = await loadTextChannel(campaign.provider_id, project.id)
|
||||
await channel?.send(template, variables)
|
||||
|
||||
response = await channel?.send(template, variables)
|
||||
logger.info(response, 'template:proof:text:result')
|
||||
} else if (template.type === 'push') {
|
||||
const channel = await loadPushChannel(campaign.provider_id, project.id)
|
||||
if (!user.id) throw new RequestError('Unable to find a user matching the criteria.')
|
||||
await channel?.send(template, variables)
|
||||
|
||||
response = await channel?.send(template, variables)
|
||||
logger.info(response, 'template:proof:push:result')
|
||||
} else if (template.type === 'webhook') {
|
||||
const channel = await loadWebhookChannel(campaign.provider_id, project.id)
|
||||
await channel?.send(template, variables)
|
||||
} else {
|
||||
throw new RequestError('Sending template proofs is only supported for email and text message types as this time.')
|
||||
}
|
||||
|
||||
await EventPostJob.from({
|
||||
project_id: project.id,
|
||||
user_id: user.id,
|
||||
event: {
|
||||
name: 'proof_sent',
|
||||
external_id: user.external_id,
|
||||
data: {
|
||||
context,
|
||||
response,
|
||||
},
|
||||
},
|
||||
}).queue()
|
||||
}
|
||||
|
||||
// Determine what template to send to the user based on the following:
|
||||
|
|
|
@ -67,7 +67,6 @@ describe('Template', () => {
|
|||
type: 'push',
|
||||
data: {
|
||||
title: 'title',
|
||||
topic: 'topic',
|
||||
body: 'body',
|
||||
url,
|
||||
custom: {
|
||||
|
@ -99,7 +98,6 @@ describe('Template', () => {
|
|||
type: 'push',
|
||||
data: {
|
||||
title: 'title',
|
||||
topic: 'topic',
|
||||
body: 'body',
|
||||
url,
|
||||
custom: {
|
||||
|
|
|
@ -26,7 +26,6 @@ Object {
|
|||
"url": "https://google.com",
|
||||
},
|
||||
"title": "title",
|
||||
"topic": "topic",
|
||||
}
|
||||
`;
|
||||
|
||||
|
@ -38,6 +37,5 @@ Object {
|
|||
"url": "https://parcelvoy.com/c?u=M8LRMWZ645&c=M8LRMWZ645&s=1&r=https%253A%252F%252Fgoogle.com",
|
||||
},
|
||||
"title": "title",
|
||||
"topic": "topic",
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -40,6 +40,7 @@ export const whereQuery = <T extends AnyJson | undefined>(path: string, operator
|
|||
}
|
||||
|
||||
if (operator === 'contains') return `${path} LIKE '%${value}%'`
|
||||
if (operator === 'not contain') return `${path} NOT LIKE '%${value}%'`
|
||||
if (operator === 'starts with') return `${path} LIKE '${value}%'`
|
||||
if (operator === 'not start with') return `${path} NOT LIKE '${value}%'`
|
||||
|
||||
|
|
|
@ -63,7 +63,7 @@ export default {
|
|||
|
||||
const ruleValue = compile(rule, item => String(item))
|
||||
|
||||
if (['=', '!=', 'contains', 'any', 'none', 'starts with', 'not start with'].includes(rule.operator)) {
|
||||
if (['=', '!=', 'contains', 'not contain', 'any', 'none', 'starts with', 'not start with'].includes(rule.operator)) {
|
||||
return whereQuery(path, rule.operator, ruleValue, 'String')
|
||||
}
|
||||
|
||||
|
|
|
@ -76,7 +76,7 @@ export const migrateEvents = async (since?: Date) => {
|
|||
|
||||
const size = 1000
|
||||
const chunker = new Chunker<UserEvent>(async events => {
|
||||
await UserEvent.insert(events.map(event => ({ ...event, uuid: randomUUID() })))
|
||||
await UserEvent.clickhouse().insert(events.map(event => ({ ...event, uuid: randomUUID() })))
|
||||
}, size)
|
||||
|
||||
for await (const event of events) {
|
||||
|
@ -93,7 +93,7 @@ export const migrateStaticList = async ({ id, project_id }: List) => {
|
|||
|
||||
const size = 1000
|
||||
const chunker = new Chunker<{ user_id: number, list_id: number, created_at: number, version: number }>(async users => {
|
||||
await UserEvent.insert(
|
||||
await UserEvent.clickhouse().insert(
|
||||
users.map(user => ({
|
||||
name: 'user_imported_to_list',
|
||||
user_id: user.user_id,
|
||||
|
|
|
@ -207,6 +207,7 @@
|
|||
"message": "Message",
|
||||
"message_settings": "Message Settings",
|
||||
"method": "Method",
|
||||
"migrate": "Migrate",
|
||||
"minute": "Minute",
|
||||
"minute_one": "{{count}} Minute",
|
||||
"minute_other": "{{count}} Minutes",
|
||||
|
|
|
@ -243,6 +243,9 @@ const api = {
|
|||
recount: async (projectId: number | string, listId: number | string) => await client
|
||||
.post<List>(`${projectUrl(projectId)}/lists/${listId}/recount`)
|
||||
.then(r => r.data),
|
||||
migrate: async (projectId: number | string, listId: number | string) => await client
|
||||
.post<List>(`${projectUrl(projectId)}/lists/${listId}/migrate`)
|
||||
.then(r => r.data),
|
||||
},
|
||||
|
||||
projectAdmins: {
|
||||
|
|
|
@ -435,7 +435,6 @@ export interface TextTemplateData {
|
|||
|
||||
export interface PushTemplateData {
|
||||
title: string
|
||||
topic: string
|
||||
body: string
|
||||
url: string
|
||||
custom: Record<string, unknown>
|
||||
|
|
|
@ -27,6 +27,15 @@
|
|||
margin-bottom: -5px;
|
||||
}
|
||||
|
||||
.ui-info-table .info-value .tag-list {
|
||||
margin-bottom: -15px;
|
||||
}
|
||||
|
||||
.ui-info-table .info-value .tag-list .ui-tag {
|
||||
margin-top: -5px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ui-info-table .info-row:last-child {
|
||||
border-bottom-width: 0px;
|
||||
}
|
||||
|
|
|
@ -42,7 +42,6 @@ export const localeState = (templates: Template[]) => {
|
|||
}
|
||||
|
||||
export const createLocale = async ({ locale, data }: LocaleParams, campaign: Campaign): Promise<Template> => {
|
||||
// TODO: Get base locale from user preferences
|
||||
const baseLocale = 'en'
|
||||
const template = campaign.templates.find(template => template.locale === baseLocale) ?? campaign.templates[0]
|
||||
return await api.templates.create(campaign.project_id, {
|
||||
|
|
|
@ -14,6 +14,7 @@ import ChannelTag from './ChannelTag'
|
|||
import CodeExample from '../../ui/CodeExample'
|
||||
import { env } from '../../config/env'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Tag } from '../../ui'
|
||||
|
||||
export default function CampaignOverview() {
|
||||
const [project] = useContext(ProjectContext)
|
||||
|
@ -22,12 +23,15 @@ export default function CampaignOverview() {
|
|||
const [campaign, setCampaign] = useContext(CampaignContext)
|
||||
const [isEditOpen, setIsEditOpen] = useState(false)
|
||||
|
||||
const DelimitedLists = ({ lists }: { lists?: List[] }) => {
|
||||
return lists?.map<ReactNode>(
|
||||
list => (
|
||||
<Link to={`/projects/${campaign.project_id}/lists/${list.id}`} key={list.id}>{list.name}</Link>
|
||||
),
|
||||
)?.reduce((prev, curr) => prev ? [prev, ', ', curr] : curr, '') ?? '–'
|
||||
const DelimitedLists = ({ lists, delimiter = ' ' }: { lists?: List[], delimiter?: ReactNode }) => {
|
||||
if (!lists || lists?.length === 0) return <>–</>
|
||||
return <div className="tag-list">
|
||||
{lists?.map<ReactNode>(
|
||||
list => (
|
||||
<Tag variant="plain" key={list.id}><Link to={`/projects/${campaign.project_id}/lists/${list.id}`}>{list.name}</Link></Tag>
|
||||
),
|
||||
)?.reduce((prev, curr) => prev ? [prev, delimiter, curr] : curr, '')}
|
||||
</div>
|
||||
}
|
||||
|
||||
const canEdit = campaign.type === 'trigger' || campaign.state === 'draft' || campaign.state === 'aborted'
|
||||
|
|
|
@ -95,7 +95,6 @@ const PushTable = ({ data }: { data: PushTemplateData }) => {
|
|||
return <InfoTable rows={{
|
||||
[t('title')]: data.title ?? <Tag variant="warn">{t('missing')}</Tag>,
|
||||
[t('body')]: data.body ?? <Tag variant="warn">{t('missing')}</Tag>,
|
||||
[t('topic')]: data.topic ?? <Tag variant="warn">{t('missing')}</Tag>,
|
||||
[t('deeplink')]: data.url,
|
||||
[t('raw_json')]: JSON.stringify(data.custom),
|
||||
}} />
|
||||
|
@ -115,12 +114,6 @@ const PushForm = ({ form }: { form: UseFormReturn<TemplateUpdateParams, any> })
|
|||
label={t('body')}
|
||||
textarea
|
||||
required />
|
||||
<TextInput.Field
|
||||
form={form}
|
||||
name="data.topic"
|
||||
label={t('topic')}
|
||||
textarea
|
||||
required />
|
||||
<TextInput.Field
|
||||
form={form}
|
||||
name="data.url"
|
||||
|
|
|
@ -307,6 +307,9 @@
|
|||
.journey-step-body-name {
|
||||
margin-bottom: 10px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.journey-step-edge {
|
||||
|
@ -347,4 +350,30 @@
|
|||
|
||||
.react-flow__attribution {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.journey-step-action-preview {
|
||||
background: var(--color-background-soft);
|
||||
height: 200px;
|
||||
width: 250px;
|
||||
border-radius: var(--border-radius-inner);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.journey-step-action-preview-placeholder {
|
||||
color: var(--color-grey);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.journey-step-action-type {
|
||||
background: var(--color-background-soft);
|
||||
border-radius: var(--border-radius-inner);
|
||||
padding: 5px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
|
@ -1,17 +1,44 @@
|
|||
import { useCallback } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import api, { apiUrl } from '../../../api'
|
||||
import { JourneyStepType } from '../../../types'
|
||||
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 { locales } from '../../campaign/CampaignDetail'
|
||||
|
||||
interface ActionConfig {
|
||||
campaign_id: number
|
||||
}
|
||||
|
||||
const JourneyTemplatePreview = ({ campaign }: { campaign: Campaign }) => {
|
||||
const { t } = useTranslation()
|
||||
const allLocales = locales(campaign.templates)
|
||||
const [locale, setLocale] = useState(allLocales[0])
|
||||
const template = campaign.templates.find(value => value.locale === locale.key)
|
||||
return <>
|
||||
<Heading
|
||||
title={t('preview')}
|
||||
size="h4"
|
||||
actions={
|
||||
<SingleSelect
|
||||
options={allLocales}
|
||||
size="small"
|
||||
value={locale}
|
||||
onChange={(locale) => setLocale(locale)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{template && <Preview template={template} />}
|
||||
</>
|
||||
}
|
||||
|
||||
export const actionStep: JourneyStepType<ActionConfig> = {
|
||||
name: 'send',
|
||||
icon: <ActionStepIcon />,
|
||||
|
@ -31,24 +58,37 @@ export const actionStep: JourneyStepType<ActionConfig> = {
|
|||
return null
|
||||
}, [projectId, campaign_id]))
|
||||
|
||||
if (campaign) {
|
||||
return (
|
||||
<>
|
||||
<div className="journey-step-body-name">{campaign.name}</div>
|
||||
{
|
||||
campaign.channel !== 'webhook' && (
|
||||
<PreviewImage
|
||||
url={apiUrl(projectId, `campaigns/${campaign.id}/preview`)}
|
||||
width={200}
|
||||
height={200}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<div className="journey-step-body-name">
|
||||
<div className="journey-step-action-type">
|
||||
{campaign && <ChannelIcon channel={campaign.channel} />}
|
||||
</div>
|
||||
{campaign?.name ?? <>–</>}
|
||||
</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>
|
||||
)
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
: (
|
||||
<div className="journey-step-action-preview-placeholder">Create campaign to preview</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
},
|
||||
newData: async () => ({
|
||||
campaign_id: 0,
|
||||
|
@ -58,25 +98,36 @@ export const actionStep: JourneyStepType<ActionConfig> = {
|
|||
onChange,
|
||||
value,
|
||||
}) {
|
||||
const [campaign] = useResolver(useCallback(async () => {
|
||||
if (value) {
|
||||
return await api.campaigns.get(projectId, value.campaign_id)
|
||||
}
|
||||
return null
|
||||
}, [projectId, value]))
|
||||
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<EntityIdPicker
|
||||
label={t('campaign')}
|
||||
subtitle={t('send_campaign_desc')}
|
||||
get={useCallback(async id => await api.campaigns.get(projectId, id), [projectId])}
|
||||
search={useCallback(async q => await api.campaigns.search(projectId, { q, limit: 50, filter: { type: 'trigger' } }), [projectId])}
|
||||
value={value.campaign_id}
|
||||
onChange={campaign_id => onChange({ ...value, campaign_id })}
|
||||
required
|
||||
createModalSize="large"
|
||||
renderCreateForm={onCreated => (
|
||||
<CampaignForm
|
||||
type="trigger"
|
||||
onSave={onCreated}
|
||||
/>
|
||||
)}
|
||||
onEditLink={campaign => window.open(`/projects/${projectId}/campaigns/${campaign.id}`)}
|
||||
/>
|
||||
<>
|
||||
<EntityIdPicker
|
||||
label={t('campaign')}
|
||||
subtitle={t('send_campaign_desc')}
|
||||
get={useCallback(async id => await api.campaigns.get(projectId, id), [projectId])}
|
||||
search={useCallback(async q => await api.campaigns.search(projectId, { q, limit: 50, filter: { type: 'trigger' } }), [projectId])}
|
||||
value={value.campaign_id}
|
||||
onChange={campaign_id => onChange({ ...value, campaign_id })}
|
||||
required
|
||||
createModalSize="large"
|
||||
renderCreateForm={onCreated => (
|
||||
<CampaignForm
|
||||
type="trigger"
|
||||
onSave={onCreated}
|
||||
/>
|
||||
)}
|
||||
onEditLink={campaign => window.open(`/projects/${projectId}/campaigns/${campaign.id}`)}
|
||||
/>
|
||||
|
||||
{campaign && <JourneyTemplatePreview campaign={campaign} />}
|
||||
</>
|
||||
)
|
||||
},
|
||||
hasDataKey: true,
|
||||
|
|
|
@ -120,6 +120,11 @@ export default function ListDetail() {
|
|||
window.location.reload()
|
||||
}
|
||||
|
||||
const handleMigrateList = async () => {
|
||||
await api.lists.migrate(project.id, list.id)
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const handleArchiveList = async () => {
|
||||
await api.lists.delete(project.id, list.id)
|
||||
window.location.href = `/projects/${project.id}/lists`
|
||||
|
@ -155,6 +160,9 @@ export default function ListDetail() {
|
|||
<MenuItem onClick={async () => await handleArchiveList()}>
|
||||
<ArchiveIcon />{t('archive')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={async () => await handleMigrateList()}>
|
||||
<SendIcon />{t('migrate')}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
}>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue