mirror of
https://fast.feibisi.com/https://github.com/parcelvoy/platform.git
synced 2025-08-28 11:46:02 +08:00
Merge pull request #62 from parcelvoy/feat/select-fixes-tags-and-subs-settings
adds tag and sub creation, tag filter to journeys
This commit is contained in:
commit
8068d4d586
15 changed files with 606 additions and 129 deletions
|
@ -5,6 +5,7 @@ import { validate } from '../core/validate'
|
|||
import { extractQueryParams } from '../utilities'
|
||||
import { ProjectState } from '../auth/AuthMiddleware'
|
||||
import { Tag, TagParams } from './Tag'
|
||||
import { getUsedTags } from './TagService'
|
||||
|
||||
const router = new Router<
|
||||
ProjectState & {
|
||||
|
@ -21,7 +22,12 @@ router.get('/', async ctx => {
|
|||
)
|
||||
})
|
||||
|
||||
router.get('/used/:entity', async ctx => {
|
||||
ctx.body = await getUsedTags(ctx.state.project!.id, ctx.params.entity)
|
||||
})
|
||||
|
||||
const tagParams: JSONSchemaType<TagParams> = {
|
||||
$id: 'tagParams',
|
||||
type: 'object',
|
||||
required: ['name'],
|
||||
properties: {
|
||||
|
@ -32,7 +38,10 @@ const tagParams: JSONSchemaType<TagParams> = {
|
|||
}
|
||||
|
||||
router.post('/', async ctx => {
|
||||
ctx.body = await Tag.insertAndFetch(validate(tagParams, ctx.request.body))
|
||||
ctx.body = await Tag.insertAndFetch({
|
||||
project_id: ctx.state.project!.id,
|
||||
...validate(tagParams, ctx.request.body),
|
||||
})
|
||||
})
|
||||
|
||||
router.param('tagId', async (value, ctx, next) => {
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { Database } from 'config/database'
|
||||
import Model from 'core/Model'
|
||||
import { EntityTag, Tag } from './Tag'
|
||||
|
||||
|
@ -65,3 +66,19 @@ export function createTagSubquery<T extends typeof Model>(model: T, project_id:
|
|||
.groupBy('tag.id')
|
||||
.having(model.raw(`count(*) > ${names.length}`))
|
||||
}
|
||||
|
||||
export async function getUsedTags(projectId: number, entity: string, db?: Database): Promise<{
|
||||
id: number
|
||||
name: string
|
||||
count: number
|
||||
}> {
|
||||
return await EntityTag.query(db)
|
||||
.select('tags.id as id')
|
||||
.select('tags.name as name')
|
||||
.countDistinct('entity_id as count')
|
||||
.join('tags', 'tag_id', '=', 'tags.id')
|
||||
.where('entity', entity)
|
||||
.andWhere('tags.project_id', projectId)
|
||||
.groupBy('tag_id')
|
||||
.orderBy('tags.name', 'asc')
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import Axios from 'axios'
|
||||
import { env } from './config/env'
|
||||
import { Admin, Campaign, CampaignCreateParams, CampaignLaunchParams, CampaignUpdateParams, CampaignUser, Image, Journey, JourneyStepMap, JourneyStepStats, List, ListCreateParams, ListUpdateParams, Project, ProjectAdminCreateParams, ProjectApiKey, Provider, ProviderCreateParams, ProviderMeta, ProviderUpdateParams, SearchParams, SearchResult, Subscription, SubscriptionParams, Template, TemplateCreateParams, TemplatePreviewParams, TemplateUpdateParams, User, UserEvent, UserSubscription } from './types'
|
||||
import { Admin, Campaign, CampaignCreateParams, CampaignLaunchParams, CampaignUpdateParams, CampaignUser, Image, Journey, JourneyStepMap, JourneyStepStats, List, ListCreateParams, ListUpdateParams, Project, ProjectAdminCreateParams, ProjectApiKey, Provider, ProviderCreateParams, ProviderMeta, ProviderUpdateParams, SearchParams, SearchResult, Subscription, SubscriptionParams, Tag, Template, TemplateCreateParams, TemplatePreviewParams, TemplateUpdateParams, UsedTag, User, UserEvent, UserSubscription } from './types'
|
||||
|
||||
const client = Axios.create(env.api)
|
||||
|
||||
|
@ -190,6 +190,13 @@ const api = {
|
|||
await client.post(`${projectUrl(projectId)}/images`, formData)
|
||||
},
|
||||
},
|
||||
|
||||
tags: {
|
||||
...createProjectEntityPath<Tag>('tags'),
|
||||
used: async (projectId: number | string, entity: string) => await client
|
||||
.get<UsedTag[]>(`${projectUrl(projectId)}/tags/used/${entity}`)
|
||||
.then(r => r.data),
|
||||
},
|
||||
}
|
||||
|
||||
export default api;
|
||||
|
|
|
@ -81,6 +81,7 @@ export interface SearchParams {
|
|||
page: number
|
||||
itemsPerPage: number
|
||||
q: string
|
||||
tag?: string[]
|
||||
}
|
||||
|
||||
export interface SearchResult<T> {
|
||||
|
@ -393,3 +394,14 @@ export interface Image {
|
|||
alt: string
|
||||
filesize: string
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface UsedTag {
|
||||
id: number
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import { useState, ReactNode, useCallback, useMemo } from 'react'
|
|||
import { useSearchParams } from 'react-router-dom'
|
||||
import useResolver from '../hooks/useResolver'
|
||||
import { SearchParams, SearchResult } from '../types'
|
||||
import { TagPicker } from '../views/settings/TagPicker'
|
||||
import { DataTable, DataTableProps } from './DataTable'
|
||||
import TextField from './form/TextField'
|
||||
import Heading from './Heading'
|
||||
|
@ -15,6 +16,7 @@ export interface SearchTableProps<T extends Record<string, any>> extends Omit<Da
|
|||
params: SearchParams
|
||||
setParams: (params: SearchParams) => void
|
||||
enableSearch?: boolean
|
||||
tagEntity?: 'journeys' | 'lists' | 'users' | 'campaigns' // anything else we want to tag?
|
||||
}
|
||||
|
||||
const DEFAULT_ITEMS_PER_PAGE = 10
|
||||
|
@ -25,6 +27,7 @@ const toTableParams = (searchParams: URLSearchParams) => {
|
|||
page: parseInt(searchParams.get('page') ?? '0'),
|
||||
itemsPerPage: parseInt(searchParams.get('itemsPerPage') ?? '10'),
|
||||
q: searchParams.get('q') ?? '',
|
||||
tag: searchParams.getAll('tag'),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -33,6 +36,7 @@ const fromTableParams = (params: SearchParams) => {
|
|||
page: params.page.toString(),
|
||||
itemsPerPage: params.itemsPerPage.toString(),
|
||||
q: params.q,
|
||||
tag: params.tag ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -43,27 +47,18 @@ export const useTableSearchParams = () => {
|
|||
q: '',
|
||||
})
|
||||
|
||||
const {
|
||||
page,
|
||||
itemsPerPage,
|
||||
q,
|
||||
} = toTableParams(searchParams)
|
||||
|
||||
const setParams = useCallback<(params: SearchParams | ((prev: SearchParams) => SearchParams)) => void>(next => {
|
||||
typeof next === 'function'
|
||||
? setSearchParams(prev => fromTableParams(next(toTableParams(prev))))
|
||||
: setSearchParams(fromTableParams(next))
|
||||
}, [setSearchParams])
|
||||
|
||||
const str = searchParams.toString()
|
||||
|
||||
return useMemo(() => [
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
{
|
||||
page,
|
||||
itemsPerPage,
|
||||
q,
|
||||
} as SearchParams,
|
||||
toTableParams(new URLSearchParams(str)),
|
||||
setParams,
|
||||
] as const, [page, itemsPerPage, q, setParams])
|
||||
] as const, [str, setParams])
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -118,6 +113,7 @@ export function SearchTable<T extends Record<string, any>>({
|
|||
params,
|
||||
results,
|
||||
setParams,
|
||||
tagEntity,
|
||||
title,
|
||||
...rest
|
||||
}: SearchTableProps<T>) {
|
||||
|
@ -131,6 +127,31 @@ export function SearchTable<T extends Record<string, any>>({
|
|||
)
|
||||
}
|
||||
|
||||
const filters: ReactNode[] = []
|
||||
|
||||
if (enableSearch) {
|
||||
filters.push(
|
||||
<TextField
|
||||
key='search'
|
||||
name="search"
|
||||
value={params.q}
|
||||
placeholder="Search..."
|
||||
onChange={(value) => setParams({ ...params, q: value })}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
if (tagEntity) {
|
||||
filters.push(
|
||||
<TagPicker
|
||||
key='tags'
|
||||
entity={tagEntity}
|
||||
value={params.tag ?? []}
|
||||
onChange={tag => setParams({ ...params, tag })}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
|
@ -146,15 +167,9 @@ export function SearchTable<T extends Record<string, any>>({
|
|||
)
|
||||
}
|
||||
{
|
||||
enableSearch && (
|
||||
filters.length > 0 && (
|
||||
<div style={{ paddingBottom: '15px' }}>
|
||||
<TextField
|
||||
name="search"
|
||||
value={params.q}
|
||||
size="small"
|
||||
placeholder="Search..."
|
||||
onChange={(value) => setParams({ ...params, q: value })}
|
||||
/>
|
||||
{filters}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
189
apps/ui/src/ui/form/MultiSelect.tsx
Normal file
189
apps/ui/src/ui/form/MultiSelect.tsx
Normal file
|
@ -0,0 +1,189 @@
|
|||
import { Listbox, Transition } from '@headlessui/react'
|
||||
import { Fragment, ReactNode } from 'react'
|
||||
import { CheckIcon, ChevronUpDownIcon } from '../icons'
|
||||
import { FieldPath, FieldValues, useController } from 'react-hook-form'
|
||||
import { defaultGetOptionDisplay, defaultGetValueKey, defaultToValue, usePopperSelectDropdown } from '../utils'
|
||||
import { ControlledInputProps, FieldBindingsProps, OptionsProps } from '../../types'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export interface MultiSelectProps<T, O = T> extends ControlledInputProps<T[]>, OptionsProps<O, T> {
|
||||
className?: string
|
||||
buttonClassName?: string
|
||||
getSelectedOptionDisplay?: (option: O) => ReactNode
|
||||
optionsFooter?: ReactNode
|
||||
size?: 'small' | 'regular'
|
||||
variant?: 'plain' | 'minimal'
|
||||
}
|
||||
|
||||
export function MultiSelect<T, U = T>({
|
||||
buttonClassName,
|
||||
className,
|
||||
disabled,
|
||||
error,
|
||||
getOptionDisplay = defaultGetOptionDisplay,
|
||||
getSelectedOptionDisplay = getOptionDisplay,
|
||||
hideLabel,
|
||||
label,
|
||||
options,
|
||||
optionsFooter,
|
||||
onChange,
|
||||
required,
|
||||
size,
|
||||
subtitle,
|
||||
toValue = defaultToValue,
|
||||
getValueKey = defaultGetValueKey,
|
||||
value,
|
||||
variant,
|
||||
}: MultiSelectProps<T, U>) {
|
||||
|
||||
const {
|
||||
setReferenceElement,
|
||||
setPopperElement,
|
||||
attributes,
|
||||
styles,
|
||||
} = usePopperSelectDropdown()
|
||||
|
||||
const { valid, invalid } = (value ?? []).reduce((a, v) => {
|
||||
const valueKey = getValueKey(v)
|
||||
const option = options.find(o => getValueKey(toValue(o)) === valueKey)
|
||||
if (option) {
|
||||
a.valid.push(
|
||||
<Fragment key={valueKey}>
|
||||
{getSelectedOptionDisplay(option)}
|
||||
</Fragment>,
|
||||
)
|
||||
} else {
|
||||
a.invalid.push(v)
|
||||
}
|
||||
return a
|
||||
}, {
|
||||
valid: [] as ReactNode[],
|
||||
invalid: [] as T[],
|
||||
})
|
||||
|
||||
return (
|
||||
<Listbox
|
||||
as='div'
|
||||
className={clsx('ui-select', className, variant ?? 'plain')}
|
||||
by={(left: T, right: T) => Object.is(getValueKey(left), getValueKey(right))}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
multiple
|
||||
>
|
||||
<Listbox.Label style={hideLabel ? { display: 'none' } : undefined}>
|
||||
{
|
||||
label && (
|
||||
<span>
|
||||
{label}
|
||||
{
|
||||
required && (
|
||||
<span style={{ color: 'red' }}> *</span>
|
||||
)
|
||||
}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</Listbox.Label>
|
||||
{
|
||||
subtitle && (
|
||||
<span className='label-subtitle'>
|
||||
{subtitle}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
<Listbox.Button className={clsx('select-button', size, buttonClassName)} ref={setReferenceElement}>
|
||||
<span className="select-button-label">
|
||||
{
|
||||
value?.length
|
||||
? (
|
||||
<>
|
||||
{valid}
|
||||
{
|
||||
!!invalid.length && (
|
||||
<span>
|
||||
{`+${invalid.length} Invalid Values`}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
: 'None Selected'
|
||||
}
|
||||
</span>
|
||||
<span className="select-button-icon">
|
||||
<ChevronUpDownIcon aria-hidden="true" />
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
{
|
||||
(error && !hideLabel) && (
|
||||
<span className='field-error'>
|
||||
{error}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition-leave"
|
||||
leaveFrom="transition-leave-from"
|
||||
leaveTo="transition-leave-to"
|
||||
enter="transition-enter"
|
||||
enterFrom="transition-enter-from"
|
||||
enterTo="transition-enter-to"
|
||||
>
|
||||
<Listbox.Options
|
||||
className="select-options"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const value = toValue(option)
|
||||
return (
|
||||
<Listbox.Option
|
||||
key={getValueKey(value)}
|
||||
value={value}
|
||||
className={({ active, selected }) => clsx(
|
||||
'select-option',
|
||||
active && 'active',
|
||||
selected && 'selected',
|
||||
)}
|
||||
>
|
||||
<span>{getOptionDisplay(option)}</span>
|
||||
<span className="option-icon">
|
||||
<CheckIcon aria-hidden="true" />
|
||||
</span>
|
||||
</Listbox.Option>
|
||||
)
|
||||
})}
|
||||
{optionsFooter}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
)
|
||||
}
|
||||
|
||||
MultiSelect.Field = function MultiSelectField<X extends FieldValues, P extends FieldPath<X>>({
|
||||
form,
|
||||
name,
|
||||
required,
|
||||
...rest
|
||||
}: FieldBindingsProps<MultiSelectProps<any>, any, X, P>) {
|
||||
|
||||
const { field, fieldState } = useController({
|
||||
control: form.control,
|
||||
name,
|
||||
rules: {
|
||||
required,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
{...rest}
|
||||
{...field}
|
||||
required={required}
|
||||
error={fieldState.error?.message}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -3,7 +3,7 @@ import { Fragment, ReactNode } from 'react'
|
|||
import { CheckIcon, ChevronUpDownIcon } from '../icons'
|
||||
import { FieldPath, FieldValues, useController } from 'react-hook-form'
|
||||
import './SelectField.css'
|
||||
import { defaultGetOptionDisplay, defaultGetValueKey, usePopperSelectDropdown } from '../utils'
|
||||
import { defaultGetOptionDisplay, defaultGetValueKey, defaultToValue, usePopperSelectDropdown } from '../utils'
|
||||
import { ControlledInputProps, FieldBindingsProps, OptionsProps } from '../../types'
|
||||
import clsx from 'clsx'
|
||||
|
||||
|
@ -16,8 +16,6 @@ export interface SelectFieldProps<T, O = T> extends ControlledInputProps<T>, Opt
|
|||
variant?: 'plain' | 'minimal'
|
||||
}
|
||||
|
||||
const defaultToValue = (o: any) => o
|
||||
|
||||
export function SelectField<T, U = T>({
|
||||
buttonClassName,
|
||||
className,
|
||||
|
@ -58,12 +56,14 @@ export function SelectField<T, U = T>({
|
|||
onChange={onChange}
|
||||
>
|
||||
<Listbox.Label style={hideLabel ? { display: 'none' } : undefined}>
|
||||
{label}
|
||||
{
|
||||
required && (
|
||||
<span style={{ color: 'red' }}> *</span>
|
||||
)
|
||||
}
|
||||
<span>
|
||||
{label}
|
||||
{
|
||||
required && (
|
||||
<span style={{ color: 'red' }}> *</span>
|
||||
)
|
||||
}
|
||||
</span>
|
||||
</Listbox.Label>
|
||||
{
|
||||
subtitle && (
|
||||
|
@ -132,12 +132,12 @@ export function SelectField<T, U = T>({
|
|||
)
|
||||
}
|
||||
|
||||
SelectField.Field = function SelectFieldField<T, X extends FieldValues, P extends FieldPath<X>>({
|
||||
SelectField.Field = function SelectFieldField<T, O, X extends FieldValues, P extends FieldPath<X>>({
|
||||
form,
|
||||
name,
|
||||
required,
|
||||
...rest
|
||||
}: FieldBindingsProps<SelectFieldProps<T>, T, X, P>) {
|
||||
}: FieldBindingsProps<SelectFieldProps<T, O>, T, X, P>) {
|
||||
|
||||
const { field, fieldState } = useController({
|
||||
control: form.control,
|
||||
|
|
|
@ -48,6 +48,8 @@ export function usePopperSelectDropdown() {
|
|||
}
|
||||
}
|
||||
|
||||
export const defaultToValue = (option: any) => option
|
||||
|
||||
export const defaultGetValueKey = (option: any) => (typeof option === 'object' ? option.id : option) as Key
|
||||
|
||||
export const defaultGetOptionDisplay = (option: any) => (typeof option === 'object' ? option.label ?? option.name : option) as string
|
||||
|
|
|
@ -2,7 +2,7 @@ import { useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
|||
import api from '../../api'
|
||||
import { ProjectContext } from '../../contexts'
|
||||
import { Campaign, CampaignCreateParams, Project, Provider, SearchParams, Subscription } from '../../types'
|
||||
import { useController, UseFormReturn } from 'react-hook-form'
|
||||
import { useController, UseFormReturn, useWatch } from 'react-hook-form'
|
||||
import TextField from '../../ui/form/TextField'
|
||||
import FormWrapper from '../../ui/form/FormWrapper'
|
||||
import Heading from '../../ui/Heading'
|
||||
|
@ -25,14 +25,17 @@ interface ListSelectionProps extends SelectionProps<CampaignCreateParams> {
|
|||
}
|
||||
|
||||
const ListSelection = ({ project, name, control }: ListSelectionProps) => {
|
||||
const lists = useCallback(async (params: SearchParams) => await api.lists.search(project.id, params), [api.lists, project])
|
||||
const lists = useCallback(async (params: SearchParams) => await api.lists.search(project.id, params), [project])
|
||||
|
||||
const { field: { value, onChange } } = useController({ name, control, rules: { required: true } })
|
||||
|
||||
return <ListTable
|
||||
search={lists}
|
||||
selectedRow={value}
|
||||
onSelectRow={(id) => onChange(id)} />
|
||||
return (
|
||||
<ListTable
|
||||
search={lists}
|
||||
selectedRow={value}
|
||||
onSelectRow={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChannelSelection = ({ subscriptions, form }: {
|
||||
|
@ -43,48 +46,65 @@ const ChannelSelection = ({ subscriptions, form }: {
|
|||
key: item,
|
||||
label: snakeToTitle(item),
|
||||
}))
|
||||
return <OptionField
|
||||
form={form}
|
||||
name="channel"
|
||||
label="Medium"
|
||||
options={channels}
|
||||
required />
|
||||
}
|
||||
|
||||
const SubscriptionSelection = ({ subscriptions, form }: { subscriptions: Subscription[], form: UseFormReturn<CampaignCreateParams> }) => {
|
||||
const { channel } = form.getValues()
|
||||
const watchChannel = form.watch(['channel'])
|
||||
const options = useMemo(() => subscriptions.filter(item => item.channel === channel).map(item => ({
|
||||
key: item.id,
|
||||
label: item.name,
|
||||
})), watchChannel)
|
||||
|
||||
return (
|
||||
<SelectField.Field
|
||||
<OptionField
|
||||
form={form}
|
||||
name="subscription_id"
|
||||
label="Subscription Group"
|
||||
options={options}
|
||||
name="channel"
|
||||
label="Medium"
|
||||
options={channels}
|
||||
required
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ProviderSelection = ({ providers, form }: { providers: Provider[], form: UseFormReturn<CampaignCreateParams> }) => {
|
||||
const { channel } = form.getValues()
|
||||
const watchChannel = form.watch(['channel'])
|
||||
const options = useMemo(() => providers.filter(item => item.group === channel).map(item => ({
|
||||
key: item.id,
|
||||
label: item.name,
|
||||
})), watchChannel)
|
||||
const SubscriptionSelection = ({ subscriptions, form }: { subscriptions: Subscription[], form: UseFormReturn<CampaignCreateParams> }) => {
|
||||
const channel = useWatch({
|
||||
control: form.control,
|
||||
name: 'channel',
|
||||
})
|
||||
subscriptions = useMemo(() => channel ? subscriptions.filter(s => s.channel === channel) : [], [channel, subscriptions])
|
||||
useEffect(() => {
|
||||
if (channel && subscriptions.length) {
|
||||
const { subscription_id } = form.getValues()
|
||||
if (!subscription_id || !subscriptions.find(s => s.id === subscription_id)) {
|
||||
form.setValue('subscription_id', subscriptions[0].id)
|
||||
}
|
||||
}
|
||||
}, [channel, form, subscriptions])
|
||||
return (
|
||||
<SelectField.Field
|
||||
form={form}
|
||||
name="subscription_id"
|
||||
label="Subscription Group"
|
||||
options={subscriptions}
|
||||
required
|
||||
toValue={x => x.id}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ProviderSelection = ({ providers, form }: { providers: Provider[], form: UseFormReturn<CampaignCreateParams> }) => {
|
||||
const channel = useWatch({
|
||||
control: form.control,
|
||||
name: 'channel',
|
||||
})
|
||||
providers = useMemo(() => channel ? providers.filter(p => p.group === channel) : [], [channel, providers])
|
||||
useEffect(() => {
|
||||
if (channel && providers.length) {
|
||||
const { provider_id } = form.getValues()
|
||||
if (!provider_id || !providers.find(p => p.id === provider_id)) {
|
||||
form.setValue('provider_id', providers[0].id)
|
||||
}
|
||||
}
|
||||
}, [channel, form, providers])
|
||||
return (
|
||||
<SelectField.Field
|
||||
form={form}
|
||||
name="provider_id"
|
||||
label="Provider"
|
||||
options={options}
|
||||
options={providers}
|
||||
required
|
||||
toValue={x => x.id}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -119,52 +139,69 @@ export default function CampaignEditModal({ campaign, open, onClose, onSave }: C
|
|||
onClose(false)
|
||||
}
|
||||
|
||||
return <Modal title={campaign ? 'Edit Campaign' : 'Create Campaign'}
|
||||
open={open}
|
||||
onClose={() => onClose(false)}
|
||||
size="large">
|
||||
<FormWrapper<CampaignCreateParams>
|
||||
onSubmit={async (item) => await handleSave(item)}
|
||||
defaultValues={campaign}
|
||||
submitLabel="Save">
|
||||
{form => <>
|
||||
<TextField form={form}
|
||||
name="name"
|
||||
label="Campaign Name"
|
||||
required />
|
||||
<Heading size="h3" title="List">
|
||||
Who is this campaign going to? Pick a list to send your campaign to.
|
||||
</Heading>
|
||||
<ListSelection
|
||||
project={project}
|
||||
name="list_id"
|
||||
control={form.control} />
|
||||
|
||||
{ campaign
|
||||
? <>
|
||||
<Heading size="h3" title="Channel">
|
||||
This campaign is being sent over the <strong>{campaign.channel}</strong> channel. Set the subscription group this message will be associated to.
|
||||
return (
|
||||
<Modal
|
||||
title={campaign ? 'Edit Campaign' : 'Create Campaign'}
|
||||
open={open}
|
||||
onClose={() => onClose(false)}
|
||||
size="large"
|
||||
>
|
||||
<FormWrapper<CampaignCreateParams>
|
||||
onSubmit={async (item) => await handleSave(item)}
|
||||
defaultValues={campaign}
|
||||
submitLabel="Save"
|
||||
>
|
||||
{form => (
|
||||
<>
|
||||
<TextField form={form}
|
||||
name="name"
|
||||
label="Campaign Name"
|
||||
required
|
||||
/>
|
||||
<Heading size="h3" title="List">
|
||||
Who is this campaign going to? Pick a list to send your campaign to.
|
||||
</Heading>
|
||||
<SubscriptionSelection
|
||||
subscriptions={subscriptions}
|
||||
form={form} />
|
||||
<ListSelection
|
||||
project={project}
|
||||
name="list_id"
|
||||
control={form.control}
|
||||
/>
|
||||
{
|
||||
campaign
|
||||
? (
|
||||
<>
|
||||
<Heading size="h3" title="Channel">
|
||||
This campaign is being sent over the <strong>{campaign.channel}</strong> channel. Set the subscription group this message will be associated to.
|
||||
</Heading>
|
||||
<SubscriptionSelection
|
||||
subscriptions={subscriptions}
|
||||
form={form}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Heading size="h3" title="Channel">
|
||||
Setup the channel this campaign will go out on. The medium is the type of message, provider the sender that will process the message and subscription group the unsubscribe group associated to the campaign.
|
||||
</Heading>
|
||||
<ChannelSelection
|
||||
subscriptions={subscriptions}
|
||||
form={form}
|
||||
/>
|
||||
<ProviderSelection
|
||||
providers={providers}
|
||||
form={form}
|
||||
/>
|
||||
<SubscriptionSelection
|
||||
subscriptions={subscriptions}
|
||||
form={form}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</>
|
||||
: <>
|
||||
<Heading size="h3" title="Channel">
|
||||
Setup the channel this campaign will go out on. The medium is the type of message, provider the sender that will process the message and subscription group the unsubscribe group associated to the campaign.
|
||||
</Heading>
|
||||
<ChannelSelection
|
||||
subscriptions={subscriptions}
|
||||
form={form} />
|
||||
<ProviderSelection
|
||||
providers={providers}
|
||||
form={form} />
|
||||
<SubscriptionSelection
|
||||
subscriptions={subscriptions}
|
||||
form={form} />
|
||||
</>
|
||||
}
|
||||
</>}
|
||||
</FormWrapper>
|
||||
</Modal>
|
||||
)}
|
||||
</FormWrapper>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -39,6 +39,7 @@ export default function Journeys() {
|
|||
]}
|
||||
onSelectRow={r => navigate(r.id.toString())}
|
||||
enableSearch
|
||||
tagEntity='journeys'
|
||||
/>
|
||||
<Modal
|
||||
onClose={() => setOpen(null)}
|
||||
|
|
|
@ -31,6 +31,7 @@ import Journeys from './journey/Journeys'
|
|||
import JourneyEditor from './journey/JourneyEditor'
|
||||
import ProjectSettings from './settings/ProjectSettings'
|
||||
import Integrations from './settings/Integrations'
|
||||
import Tags from './settings/Tags'
|
||||
|
||||
export const route = (path: string, includeProject = true) => {
|
||||
const { projectId = '' } = useParams()
|
||||
|
@ -260,6 +261,11 @@ export const router = createBrowserRouter([
|
|||
to: 'subscriptions',
|
||||
children: 'Subscriptions',
|
||||
},
|
||||
{
|
||||
key: 'tags',
|
||||
to: 'tags',
|
||||
children: 'Tags',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Outlet />
|
||||
|
@ -286,6 +292,10 @@ export const router = createBrowserRouter([
|
|||
path: 'subscriptions',
|
||||
element: <Subscriptions />,
|
||||
},
|
||||
{
|
||||
path: 'tags',
|
||||
element: <Tags />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
@ -1,24 +1,79 @@
|
|||
import { useCallback, useContext } from 'react'
|
||||
import { useCallback, useContext, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '../../api'
|
||||
import { ProjectContext } from '../../contexts'
|
||||
import FormWrapper from '../../ui/form/FormWrapper'
|
||||
import Modal from '../../ui/Modal'
|
||||
import { SearchTable, useSearchTableState } from '../../ui/SearchTable'
|
||||
import { Subscription } from '../../types'
|
||||
import TextField from '../../ui/form/TextField'
|
||||
import { SelectField } from '../../ui/form/SelectField'
|
||||
import Button from '../../ui/Button'
|
||||
|
||||
export default function Subscriptions() {
|
||||
const navigate = useNavigate()
|
||||
const [project] = useContext(ProjectContext)
|
||||
const state = useSearchTableState(useCallback(async params => await api.subscriptions.search(project.id, params), [project]))
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<SearchTable
|
||||
{...state}
|
||||
columns={[
|
||||
{ key: 'name' },
|
||||
{ key: 'channel' },
|
||||
]}
|
||||
itemKey={({ item }) => item.id}
|
||||
onSelectRow={(row) => navigate(`${row.id}`)}
|
||||
title='Subscriptions'
|
||||
/>
|
||||
<>
|
||||
<SearchTable
|
||||
{...state}
|
||||
columns={[
|
||||
{ key: 'name' },
|
||||
{ key: 'channel' },
|
||||
]}
|
||||
itemKey={({ item }) => item.id}
|
||||
onSelectRow={(row) => navigate(`${row.id}`)}
|
||||
title='Subscriptions'
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant='primary'
|
||||
icon='plus'
|
||||
size='small'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{'Create Subscription'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title='Create Subscription'
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
<FormWrapper<Pick<Subscription, 'name' | 'channel'>>
|
||||
onSubmit={async ({ name, channel }) => {
|
||||
await api.subscriptions.create(project.id, { name, channel })
|
||||
await state.reload()
|
||||
setOpen(false)
|
||||
}}
|
||||
defaultValues={{
|
||||
channel: 'email',
|
||||
}}
|
||||
>
|
||||
{
|
||||
form => (
|
||||
<>
|
||||
<TextField
|
||||
form={form}
|
||||
name='name'
|
||||
required
|
||||
label='Name'
|
||||
/>
|
||||
<SelectField.Field
|
||||
form={form}
|
||||
name='channel'
|
||||
options={['email', 'push', 'text', 'webhook']}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</FormWrapper>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
31
apps/ui/src/views/settings/TagPicker.tsx
Normal file
31
apps/ui/src/views/settings/TagPicker.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { useCallback, useContext } from 'react'
|
||||
import api from '../../api'
|
||||
import { ProjectContext } from '../../contexts'
|
||||
import useResolver from '../../hooks/useResolver'
|
||||
import { ControlledProps } from '../../types'
|
||||
import { MultiSelect } from '../../ui/form/MultiSelect'
|
||||
|
||||
export interface TagPickerProps extends ControlledProps<string[]> {
|
||||
entity: 'journeys' | 'campaigns' | 'users' | 'lists'
|
||||
}
|
||||
|
||||
export function TagPicker({
|
||||
entity,
|
||||
onChange,
|
||||
value,
|
||||
}: TagPickerProps) {
|
||||
const [project] = useContext(ProjectContext)
|
||||
const [tags] = useResolver(useCallback(async () => await api.tags.used(project.id, entity), [project, entity]))
|
||||
|
||||
if (!tags) return null
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={tags}
|
||||
toValue={t => t.name}
|
||||
getOptionDisplay={({ name, count }) => `${name} (${count})`}
|
||||
/>
|
||||
)
|
||||
}
|
79
apps/ui/src/views/settings/Tags.tsx
Normal file
79
apps/ui/src/views/settings/Tags.tsx
Normal file
|
@ -0,0 +1,79 @@
|
|||
import { useCallback, useContext, useState } from 'react'
|
||||
import api from '../../api'
|
||||
import { ProjectContext } from '../../contexts'
|
||||
import { Tag } from '../../types'
|
||||
import Button from '../../ui/Button'
|
||||
import FormWrapper from '../../ui/form/FormWrapper'
|
||||
import TextField from '../../ui/form/TextField'
|
||||
import Modal from '../../ui/Modal'
|
||||
import { SearchTable, useSearchTableState } from '../../ui/SearchTable'
|
||||
|
||||
export default function Tags() {
|
||||
|
||||
const [project] = useContext(ProjectContext)
|
||||
const search = useSearchTableState(useCallback(async params => await api.tags.search(project.id, params), [project]))
|
||||
const [editing, setEditing] = useState<null | Tag>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchTable
|
||||
{...search}
|
||||
columns={[
|
||||
{
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
key: 'usage',
|
||||
title: 'Usage',
|
||||
cell: () => 'TODO',
|
||||
},
|
||||
]}
|
||||
title='Tags'
|
||||
description='Use tags to organize and report on your campaigns, journeys, lists, and users.'
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
size='small'
|
||||
variant='primary'
|
||||
onClick={() => setEditing({ id: 0, name: 'New Tag' })}
|
||||
icon='plus'
|
||||
>
|
||||
{'Create Tag'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onSelectRow={setEditing}
|
||||
/>
|
||||
<Modal
|
||||
open={!!editing}
|
||||
onClose={() => setEditing(null)}
|
||||
title={editing?.id ? 'Update Tag' : 'Create Tag'}
|
||||
>
|
||||
{
|
||||
editing && (
|
||||
<FormWrapper<Tag>
|
||||
onSubmit={async ({ id, name }) => {
|
||||
if (id) {
|
||||
await api.tags.update(project.id, id, { name })
|
||||
} else {
|
||||
await api.tags.create(project.id, { name })
|
||||
}
|
||||
await search.reload()
|
||||
setEditing(null)
|
||||
}}
|
||||
defaultValues={editing}
|
||||
>
|
||||
{
|
||||
form => (
|
||||
<>
|
||||
<TextField form={form} name='name' required />
|
||||
</>
|
||||
)
|
||||
}
|
||||
</FormWrapper>
|
||||
)
|
||||
}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -36,9 +36,18 @@ export default function UserDetailSubscriptions() {
|
|||
}
|
||||
|
||||
return <>
|
||||
<Heading size="h3" title="Subscriptions" actions={
|
||||
<Button variant="secondary" onClick={async () => await unsubscribeAll()}>Unsubscribe From All</Button>
|
||||
} />
|
||||
<Heading
|
||||
size="h3"
|
||||
title="Subscriptions"
|
||||
actions={
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={async () => await unsubscribeAll()}
|
||||
>
|
||||
Unsubscribe From All
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<SearchTable
|
||||
results={search}
|
||||
params={params}
|
||||
|
@ -56,7 +65,11 @@ export default function UserDetailSubscriptions() {
|
|||
title: 'Subscribed',
|
||||
cell: ({ item: { subscription_id, state } }) => {
|
||||
return (
|
||||
<SwitchField name="state" checked={state !== 0} onChange={async (checked) => await updateSubscription(subscription_id, checked ? 1 : 0)} />
|
||||
<SwitchField
|
||||
name="state"
|
||||
checked={state !== 0}
|
||||
onChange={async (checked) => await updateSubscription(subscription_id, checked ? 1 : 0)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue