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:
chrishills 2023-03-04 20:11:17 -06:00 committed by GitHub
commit 8068d4d586
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 606 additions and 129 deletions

View file

@ -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) => {

View file

@ -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')
}

View file

@ -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;

View file

@ -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
}

View file

@ -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>
)
}

View 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' }}>&nbsp;*</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}
/>
)
}

View file

@ -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' }}>&nbsp;*</span>
)
}
<span>
{label}
{
required && (
<span style={{ color: 'red' }}>&nbsp;*</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,

View file

@ -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

View file

@ -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>
)
}

View file

@ -39,6 +39,7 @@ export default function Journeys() {
]}
onSelectRow={r => navigate(r.id.toString())}
enableSearch
tagEntity='journeys'
/>
<Modal
onClose={() => setOpen(null)}

View file

@ -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 />,
},
],
},
],

View file

@ -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>
</>
)
}

View 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})`}
/>
)
}

View 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>
</>
)
}

View file

@ -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)}
/>
)
},
},