added toaster, fixed linter warnings, various ui updates, tagging (#67)

This commit is contained in:
Chris Hills 2023-03-06 20:32:27 -06:00 committed by GitHub
parent f1e246a86f
commit 2a833e571a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 875 additions and 455 deletions

View file

@ -24,6 +24,7 @@ export default class Campaign extends Model {
templates!: Template[]
state!: CampaignState
delivery!: CampaignDelivery
tags?: string[]
send_in_user_timezone?: boolean
send_at?: string | Date

View file

@ -46,6 +46,13 @@ export const campaignCreateParams: JSONSchemaType<CampaignParams> = {
format: 'date-time',
nullable: true,
},
tags: {
type: 'array',
items: {
type: 'string',
},
nullable: true,
},
},
additionalProperties: false,
}
@ -97,6 +104,13 @@ const campaignUpdateParams: JSONSchemaType<Partial<CampaignUpdateParams>> = {
format: 'date-time',
nullable: true,
},
tags: {
type: 'array',
items: {
type: 'string',
},
nullable: true,
},
},
additionalProperties: false,
}

View file

@ -16,19 +16,26 @@ import { utcToZonedTime } from 'date-fns-tz'
import { getSubscription } from '../subscriptions/SubscriptionService'
import { getProvider } from '../channels/ProviderRepository'
import { pick } from '../utilities'
import { createTagSubquery } from '../tags/TagService'
import { createTagSubquery, getTags, setTags } from '../tags/TagService'
import { getProject } from '../projects/ProjectService'
export const pagedCampaigns = async (params: SearchParams, projectId: number) => {
return await Campaign.searchParams(
const result = await Campaign.searchParams(
params,
['name'],
b => {
b.where({ project_id: projectId }).orderBy('id', 'desc')
params?.tags?.length && b.whereIn('id', createTagSubquery(Campaign, projectId, params.tags))
params.tag?.length && b.whereIn('id', createTagSubquery(Campaign, projectId, params.tag))
return b
},
)
if (result.results?.length) {
const tags = await getTags(Campaign.tableName, result.results.map(c => c.id))
for (const campaign of result.results) {
campaign.tags = tags.get(campaign.id)
}
}
return result
}
export const allCampaigns = async (projectId: number): Promise<Campaign[]> => {
@ -43,12 +50,13 @@ export const getCampaign = async (id: number, projectId: number): Promise<Campai
campaign.list = campaign.list_id ? await getList(campaign.list_id, projectId) : undefined
campaign.subscription = await getSubscription(campaign.subscription_id, projectId)
campaign.provider = await getProvider(campaign.provider_id, projectId)
campaign.tags = await getTags(Campaign.tableName, [campaign.id]).then(m => m.get(campaign.id))
}
return campaign
}
export const createCampaign = async (projectId: number, params: CampaignParams): Promise<Campaign> => {
export const createCampaign = async (projectId: number, { tags, ...params }: CampaignParams): Promise<Campaign> => {
const subscription = await Subscription.find(params.subscription_id)
if (!subscription) {
throw new RequestError('Unable to find associated subscription', 404)
@ -67,10 +75,19 @@ export const createCampaign = async (projectId: number, params: CampaignParams):
project_id: projectId,
})
if (tags?.length) {
await setTags({
project_id: projectId,
entity: Campaign.tableName,
entity_id: id,
names: tags,
})
}
return await getCampaign(id, projectId) as Campaign
}
export const updateCampaign = async (id: number, projectId: number, params: Partial<CampaignParams>): Promise<Campaign | undefined> => {
export const updateCampaign = async (id: number, projectId: number, { tags, ...params }: Partial<CampaignParams>): Promise<Campaign | undefined> => {
const data: Partial<Campaign> = { ...params }
@ -85,6 +102,15 @@ export const updateCampaign = async (id: number, projectId: number, params: Part
...data,
})
if (tags) {
await setTags({
project_id: projectId,
entity: Campaign.tableName,
entity_id: id,
names: tags,
})
}
return getCampaign(id, projectId)
}

View file

@ -5,7 +5,7 @@ export interface SearchParams {
itemsPerPage: number
q?: string
sort?: string
tags?: string[]
tag?: string[]
}
export const searchParamsSchema: JSONSchemaType<SearchParams> = {
@ -31,7 +31,7 @@ export const searchParamsSchema: JSONSchemaType<SearchParams> = {
type: 'string',
nullable: true,
},
tags: {
tag: {
type: 'array',
items: {
type: 'string',

View file

@ -5,8 +5,7 @@ export default class Journey extends Model {
project_id!: number
description?: string
deleted_at?: Date
static virtualAttributes: string[] = ['steps']
tags?: string[]
}
export type JourneyParams = Omit<Journey, ModelParams | 'deleted_at'>

View file

@ -30,6 +30,13 @@ const journeyParams: JSONSchemaType<JourneyParams> = {
type: 'string',
nullable: true,
},
tags: {
type: 'array',
items: {
type: 'string',
},
nullable: true,
},
},
additionalProperties: false,
}

View file

@ -6,20 +6,33 @@ import Journey, { JourneyParams, UpdateJourneyParams } from './Journey'
import { JourneyStep, JourneyEntrance, JourneyUserStep, JourneyStepMap, toJourneyStepMap, JourneyStepChild } from './JourneyStep'
import { CampaignDelivery } from 'campaigns/Campaign'
import { raw } from '../core/Model'
import { createTagSubquery, getTags, setTags } from '../tags/TagService'
export const pagedJourneys = async (params: SearchParams, projectId: number) => {
return await Journey.searchParams(
console.log('params', params)
const result = await Journey.searchParams(
params,
['name'],
b => b.where({ project_id: projectId }),
b => {
b = b.where({ project_id: projectId })
params.tag?.length && b.whereIn('id', createTagSubquery(Journey, projectId, params.tag))
return b
},
)
if (result.results?.length) {
const tags = await getTags(Journey.tableName, result.results.map(j => j.id))
for (const journey of result.results) {
journey.tags = tags.get(journey.id) ?? []
}
}
return result
}
export const allJourneys = async (projectId: number): Promise<Journey[]> => {
return await Journey.all(qb => qb.where('project_id', projectId))
}
export const createJourney = async (projectId: number, params: JourneyParams): Promise<Journey> => {
export const createJourney = async (projectId: number, { tags, ...params }: JourneyParams): Promise<Journey> => {
return App.main.db.transaction(async trx => {
const journey = await Journey.insertAndFetch({
@ -30,6 +43,15 @@ export const createJourney = async (projectId: number, params: JourneyParams): P
// auto-create entrance step
await JourneyEntrance.create(journey.id, undefined, trx)
if (tags?.length) {
await setTags({
project_id: projectId,
entity: Journey.tableName,
entity_id: journey.id,
names: tags,
}, trx)
}
return journey
})
}
@ -37,11 +59,23 @@ export const createJourney = async (projectId: number, params: JourneyParams): P
export const getJourney = async (id: number, projectId: number): Promise<Journey> => {
const journey = await Journey.find(id, qb => qb.where('project_id', projectId))
if (!journey) throw new RequestError('Journey not found', 404)
journey.tags = await getTags(Journey.tableName, [journey.id]).then(m => m.get(journey.id)) ?? []
return journey
}
export const updateJourney = async (id: number, params: UpdateJourneyParams): Promise<Journey> => {
return await Journey.updateAndFetch(id, params)
export const updateJourney = async (id: number, { tags, ...params }: UpdateJourneyParams, db = App.main.db): Promise<Journey> => {
return db.transaction(async trx => {
const journey = await Journey.updateAndFetch(id, params, trx)
if (tags) {
await setTags({
project_id: journey.project_id,
entity: Journey.tableName,
entity_id: journey.id,
names: tags,
}, trx)
}
return journey
})
}
export const deleteJourney = async (id: number): Promise<void> => {

View file

@ -12,6 +12,7 @@ export default class List extends Model {
rule?: Rule
version!: number
users_count?: number
tags?: string[]
static jsonAttributes = ['rule']
}
@ -28,5 +29,5 @@ export class UserList extends Model {
static tableName = 'user_list'
}
export type ListUpdateParams = Pick<List, 'name' | 'rule'>
export type ListCreateParams = Pick<List, 'name' | 'type' | 'rule'>
export type ListUpdateParams = Pick<List, 'name' | 'rule' | 'tags'>
export type ListCreateParams = Pick<List, 'name' | 'type' | 'rule' | 'tags'>

View file

@ -60,6 +60,13 @@ const listParams: JSONSchemaType<ListCreateParams> = {
enum: ['dynamic'],
},
rule: ({ $ref: '#/definitions/rule' } as any),
tags: {
type: 'array',
items: {
type: 'string',
},
nullable: true,
},
},
additionalProperties: false,
},
@ -74,6 +81,13 @@ const listParams: JSONSchemaType<ListCreateParams> = {
type: 'string',
enum: ['static'],
},
tags: {
type: 'array',
items: {
type: 'string',
},
nullable: true,
},
},
additionalProperties: false,
}] as any,
@ -109,6 +123,13 @@ const listUpdateParams: JSONSchemaType<ListUpdateParams> = {
type: 'string',
},
rule: ({ $ref: '#/definitions/rule' } as any),
tags: {
type: 'array',
items: {
type: 'string',
},
nullable: true,
},
},
additionalProperties: false,
}

View file

@ -9,21 +9,33 @@ import App from '../app'
import ListPopulateJob from './ListPopulateJob'
import { importUsers } from '../users/UserImport'
import { FileStream } from '../storage/FileStream'
import { createTagSubquery, getTags, setTags } from '../tags/TagService'
export const pagedLists = async (params: SearchParams, projectId: number) => {
return await List.searchParams(
const result = await List.searchParams(
params,
['name'],
b => b.where('project_id', projectId),
b => {
b = b.where('project_id', projectId)
params.tag?.length && b.whereIn('id', createTagSubquery(List, projectId, params.tag))
return b
},
)
}
export const allLists = async (projectId: number) => {
return await List.all(qb => qb.where('project_id', projectId))
if (result.results?.length) {
const tags = await getTags(List.tableName, result.results.map(l => l.id))
for (const list of result.results) {
list.tags = tags.get(list.id)
}
}
return result
}
export const getList = async (id: number, projectId: number) => {
return await List.find(id, qb => qb.where('project_id', projectId))
const list = await List.find(id, qb => qb.where('project_id', projectId))
if (list) {
list.tags = await getTags(List.tableName, [list.id]).then(m => m.get(list.id))
}
return list
}
export const getListUsers = async (id: number, params: SearchParams, projectId: number) => {
@ -48,7 +60,7 @@ export const getUserLists = async (id: number, params: SearchParams, projectId:
)
}
export const createList = async (projectId: number, params: ListCreateParams): Promise<List> => {
export const createList = async (projectId: number, { tags, ...params }: ListCreateParams): Promise<List> => {
const list = await List.insertAndFetch({
...params,
state: 'ready',
@ -56,6 +68,15 @@ export const createList = async (projectId: number, params: ListCreateParams): P
project_id: projectId,
})
if (tags?.length) {
await setTags({
project_id: projectId,
entity: List.tableName,
entity_id: list.id,
names: tags,
})
}
const hasRules = (params.rule?.children?.length ?? 0) > 0
if (list.type === 'dynamic' && hasRules) {
App.main.queue.enqueue(
@ -66,9 +87,18 @@ export const createList = async (projectId: number, params: ListCreateParams): P
return list
}
export const updateList = async (id: number, params: Partial<List>): Promise<List | undefined> => {
export const updateList = async (id: number, { tags, ...params }: Partial<List>): Promise<List | undefined> => {
const list = await List.updateAndFetch(id, params)
if (tags) {
await setTags({
project_id: list.project_id,
entity: List.tableName,
entity_id: list.id,
names: tags,
})
}
if (params.rule && list.type === 'dynamic') {
App.main.queue.enqueue(
ListPopulateJob.from(list.id, list.project_id),

View file

@ -22,6 +22,13 @@ router.get('/', async ctx => {
)
})
router.get('/all', async ctx => {
ctx.body = await Tag.all(q => q
.where('project_id', ctx.state.project!.id)
.orderBy('name', 'asc'),
)
})
router.get('/used/:entity', async ctx => {
ctx.body = await getUsedTags(ctx.state.project!.id, ctx.params.entity)
})

View file

@ -2,54 +2,75 @@ import { Database } from 'config/database'
import Model from 'core/Model'
import { EntityTag, Tag } from './Tag'
// use transaction?
export async function setTags<T extends Model & { project_id: number }>(target: T, names: string[]) {
export async function getTags(entity: string, entityIds: number[], db?: Database) {
return await EntityTag
.query(db)
.select('entity_id as entityId')
.select('tags.name as name')
.join('tags', 'tag_id', '=', 'tags.id')
.whereIn('entity_id', entityIds)
.andWhere('entity', entity)
.orderBy('tags.name', 'asc')
.then((r: Array<{ entityId: number, name: string }>) => r.reduce((a, { entityId, name }) => {
const list = a.get(entityId) ?? a.set(entityId, []).get(entityId)!
list.push(name)
return a
}, new Map<number, string[]>()))
}
// is there a better way to do this?
const tableName = (Object.getPrototypeOf(target) as typeof Model).tableName
const { project_id } = target
interface SetTagsParams {
project_id: number
entity: string
entity_id: number
names: string[]
}
export async function setTags({
project_id,
entity,
entity_id,
names,
}: SetTagsParams, db?: Database) {
// if empty value passed, remove all tag relations
if (!names?.length) {
return await EntityTag.delete(b => b.where({
entity: tableName,
entity_id: target.id,
}))
await EntityTag.delete(b => b.where({
entity,
entity_id,
}), db)
return []
}
// find/create tags in this project by name
const tags = await Tag.all(b => b.where({
project_id,
names,
}))
const tags = await Tag.all(b => b.whereIn('name', names).andWhere('project_id', project_id), db)
for (const name of names) {
if (!tags.find(t => t.name === name)) {
tags.push(await Tag.insertAndFetch({
project_id,
name,
}))
}, db))
}
}
const relations = await EntityTag.all(b => b.where({
entity: tableName,
entity_id: target.id,
}))
entity,
entity_id,
}), db)
for (const tag of tags) {
if (!relations.find(r => r.tag_id === tag.id)) {
await EntityTag.insert({
entity: tableName,
entity_id: target.id,
entity,
entity_id,
tag_id: tag.id,
})
}, db)
}
}
const remove = relations.filter(r => !tags.find(t => t.id === r.tag_id)).map(r => r.id)
if (remove.length) {
await EntityTag.delete(b => b.whereIn('id', remove))
await EntityTag.delete(b => b.whereIn('id', remove), db)
}
return names
@ -57,14 +78,16 @@ export async function setTags<T extends Model & { project_id: number }>(target:
// use with knex: myQuery.whereIn('id', createTagSubquery(MyEntity, 1, ['tag 1', 'tag 2']))
export function createTagSubquery<T extends typeof Model>(model: T, project_id: number, names: string[]) {
return EntityTag.query()
const sq = EntityTag.query()
.select('entity_id')
.join('tags', 'tag_id', 'tag.id')
.whereIn('tag.name', names)
.andWhere('tag.project_id', project_id)
.join('tags', 'tag_id', '=', 'tags.id')
.whereIn('tags.name', names)
.andWhere('tags.project_id', project_id)
.andWhere('entity', model.tableName)
.groupBy('tag.id')
.having(model.raw(`count(*) > ${names.length}`))
.groupBy('entity_id')
.having(model.raw(`count(*) >= ${names.length}`))
console.log(sq.toSQL())
return sq
}
export async function getUsedTags(projectId: number, entity: string, db?: Database): Promise<{

View file

@ -27,6 +27,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.33.0",
"react-hot-toast": "2.4.0",
"react-popper": "^2.3.0",
"react-router-dom": "^6.4.2",
"react-scripts": "5.0.1",

View file

@ -1,3 +1,4 @@
import { Toaster } from 'react-hot-toast'
import { RouterProvider } from 'react-router-dom'
import { PreferencesProvider } from './ui/PreferencesContext'
import { router } from './views/router'
@ -6,6 +7,7 @@ export default function App() {
return (
<PreferencesProvider>
<RouterProvider router={router} />
<Toaster />
</PreferencesProvider>
)
}

View file

@ -1,8 +1,29 @@
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, Tag, Template, TemplateCreateParams, TemplatePreviewParams, TemplateUpdateParams, UsedTag, 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, User, UserEvent, UserSubscription } from './types'
const client = Axios.create(env.api)
function appendValue(params: URLSearchParams, name: string, value: unknown) {
if (typeof value === 'undefined' || value === null || typeof value === 'function') return
if (typeof value === 'object') value = JSON.stringify(value)
params.append(name, value + '')
}
const client = Axios.create({
...env.api,
paramsSerializer: params => {
const s = new URLSearchParams()
for (const [name, value] of Object.entries(params)) {
if (Array.isArray(value)) {
for (const item of value) {
appendValue(s, name, item)
}
} else {
appendValue(s, name, value)
}
}
return s.toString()
},
})
client.interceptors.response.use(
response => response,
@ -194,7 +215,13 @@ const api = {
tags: {
...createProjectEntityPath<Tag>('tags'),
used: async (projectId: number | string, entity: string) => await client
.get<UsedTag[]>(`${projectUrl(projectId)}/tags/used/${entity}`)
.get<Tag[]>(`${projectUrl(projectId)}/tags/used/${entity}`)
.then(r => r.data),
assign: async (projectId: number | string, entity: string, entityId: number, tags: string[]) => await client
.put<string[]>(`${projectUrl(projectId)}/tags/assign`, { entity, entityId, tags })
.then(r => r.data),
all: async (projectId: number | string) => await client
.get<Tag[]>(`${projectUrl(projectId)}/tags/all`)
.then(r => r.data),
},
}

40
apps/ui/src/hooks.ts Normal file
View file

@ -0,0 +1,40 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
export function useResolver<T>(resolver: () => Promise<T>) {
const [value, setValue] = useState<null | T>(null)
const reload = useCallback(async () => await resolver().then(setValue).catch(err => console.error(err)), [resolver])
useEffect(() => {
reload().catch(err => console.error(err))
}, [reload])
return useMemo(() => [value, setValue, reload] as const, [value, reload])
}
export function useDebounceControl<T>(
value: T,
onChange: (value: T) => void,
ms = 400,
) {
const changeRef = useRef(onChange)
changeRef.current = onChange
const valueRef = useRef(value)
valueRef.current = value
const timeoutId = useRef<ReturnType<typeof setTimeout>>()
const synced = useRef(true)
const [temp, setTemp] = useState<T>(value)
useEffect(() => {
clearTimeout(timeoutId.current)
if (valueRef.current !== temp) {
timeoutId.current = setTimeout(() => {
changeRef.current(temp)
synced.current = false
}, ms)
}
}, [temp, ms])
useEffect(() => {
if (!synced.current) {
setTemp(value)
synced.current = true
}
}, [value])
return [temp, setTemp] as const
}

View file

@ -1,10 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
export default function useResolver<T>(resolver: () => Promise<T>) {
const [value, setValue] = useState<null | T>(null)
const reload = useCallback(async () => await resolver().then(setValue).catch(err => console.error(err)), [resolver])
useEffect(() => {
reload().catch(err => console.error(err))
}, [reload])
return useMemo(() => [value, setValue, reload] as const, [value, reload])
}

View file

@ -158,6 +158,7 @@ export type List = {
type: ListType
rule?: WrapperRule
users_count: number
tags?: string[]
created_at: string
updated_at: string
} & (
@ -170,13 +171,14 @@ export type List = {
export type DynamicList = List & { type: 'dynamic' }
export type ListCreateParams = Pick<List, 'name' | 'rule' | 'type'>
export type ListUpdateParams = Pick<List, 'name' | 'rule'>
export type ListCreateParams = Pick<List, 'name' | 'rule' | 'type' | 'tags'>
export type ListUpdateParams = Pick<List, 'name' | 'rule' | 'tags'>
export interface Journey {
id: number
name: string
description?: string
tags?: string[]
created_at: string
updated_at: string
}
@ -254,6 +256,7 @@ export interface Campaign {
templates: Template[]
list_id: number
list?: List
tags?: string[]
send_in_user_timezone: boolean
send_at: string
created_at: string
@ -262,8 +265,8 @@ export interface Campaign {
export type CampaignSendState = 'pending' | 'sent' | 'failed'
export type CampaignUpdateParams = Pick<Campaign, 'name' | 'list_id' | 'subscription_id'>
export type CampaignCreateParams = Pick<Campaign, 'name' | 'list_id' | 'channel' | 'subscription_id' | 'provider_id'>
export type CampaignUpdateParams = Pick<Campaign, 'name' | 'list_id' | 'subscription_id' | 'tags'>
export type CampaignCreateParams = Pick<Campaign, 'name' | 'list_id' | 'channel' | 'subscription_id' | 'provider_id' | 'tags'>
export type CampaignLaunchParams = Pick<Campaign, 'send_at' | 'send_in_user_timezone'>
// export type ListUpdateParams = Pick<List, 'name' | 'rule'>
export type CampaignUser = User & { state: CampaignSendState, send_at: string }
@ -398,10 +401,5 @@ export interface Image {
export interface Tag {
id: number
name: string
}
export interface UsedTag {
id: number
name: string
count: number
count?: number
}

View file

@ -1,9 +1,15 @@
import { PropsWithChildren } from 'react'
import clsx from 'clsx'
import { CSSProperties, PropsWithChildren } from 'react'
import './ButtonGroup.css'
export default function ButtonGroup({ children }: PropsWithChildren) {
type ButtonGroupProps = PropsWithChildren<{
className?: string
style?: CSSProperties
}>
export default function ButtonGroup({ children, className, style }: ButtonGroupProps) {
return (
<div className="ui-button-group">
<div className={clsx('ui-button-group', className)} style={style}>
{children}
</div>
)

View file

@ -30,12 +30,45 @@
max-width: 960px;
}
.modal.fullscreen .modal-inner {
max-height: 100vh;
max-width: 100vw;
height: 100vh;
width: 100vw;
border-radius: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.modal-inner .modal-header {
margin-bottom: 20px;
display: flex;
flex-direction: row;
align-items: center;
}
.modal.fullscreen .modal-header {
margin-bottom: 0;
}
.modal-inner .modal-header > * + * {
margin-left: 15px;
}
.modal.fullscreen .modal-header {
padding: 10px 20px;
border-bottom: 1px solid var(--color-grey);
}
.modal.fullscreen .modal-content {
flex-grow: 1;
}
.modal-inner .modal-header h3 {
margin: 0;
margin-top: 0;
margin-bottom: 0;
flex-grow: 1;
}
.modal-inner .modal-footer {

View file

@ -1,5 +1,6 @@
import { Dialog, Transition } from '@headlessui/react'
import { Fragment, PropsWithChildren, ReactNode } from 'react'
import Button from './Button'
import './Modal.css'
export interface ModalStateProps {
@ -9,12 +10,14 @@ export interface ModalStateProps {
export interface ModalProps extends ModalStateProps {
title: ReactNode
description?: ReactNode
actions?: ReactNode
size?: 'small' | 'regular' | 'large'
size?: 'small' | 'regular' | 'large' | 'fullscreen'
}
export default function Modal({
children,
description,
open,
onClose,
title,
@ -47,12 +50,43 @@ export default function Modal({
>
<Dialog.Panel className="modal-inner">
<div className="modal-header">
{
size === 'fullscreen' && (
<Button
variant="secondary"
onClick={() => onClose(false)}
icon="x-lg"
>
{'Exit'}
</Button>
)
}
<Dialog.Title as="h3">{title}</Dialog.Title>
{
size === 'fullscreen' && actions && (
<div className="modal-fullscreen-actions">
{actions}
</div>
)
}
</div>
{children}
{actions && <div className="modal-footer">
{actions}
</div>}
{
description && (
<Dialog.Description className="modal-description">
{description}
</Dialog.Description>
)
}
<div className="modal-content">
{children}
</div>
{
!!(actions && size !== 'fullscreen') && (
<div className="modal-footer">
{actions}
</div>
)
}
</Dialog.Panel>
</Transition.Child>
</div>

View file

@ -1,12 +1,13 @@
import { useState, ReactNode, useCallback, useMemo } from 'react'
import { useSearchParams } from 'react-router-dom'
import useResolver from '../hooks/useResolver'
import { useDebounceControl, useResolver } from '../hooks'
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'
import Pagination from './Pagination'
import Stack from './Stack'
export interface SearchTableProps<T extends Record<string, any>> extends Omit<DataTableProps<T>, 'items'> {
title?: ReactNode
@ -55,6 +56,8 @@ export const useTableSearchParams = () => {
const str = searchParams.toString()
console.log(str)
return useMemo(() => [
toTableParams(new URLSearchParams(str)),
setParams,
@ -118,6 +121,8 @@ export function SearchTable<T extends Record<string, any>>({
...rest
}: SearchTableProps<T>) {
const [search, setSearch] = useDebounceControl(params.q ?? '', q => setParams({ ...params, q }))
const filters: ReactNode[] = []
if (enableSearch) {
@ -125,9 +130,9 @@ export function SearchTable<T extends Record<string, any>>({
<TextField
key="search"
name="search"
value={params.q}
value={search}
placeholder="Search..."
onChange={(value) => setParams({ ...params, q: value })}
onChange={setSearch}
/>,
)
}
@ -139,6 +144,7 @@ export function SearchTable<T extends Record<string, any>>({
entity={tagEntity}
value={params.tag ?? []}
onChange={tag => setParams({ ...params, tag })}
placeholder="Filter By Tag..."
/>,
)
}
@ -159,9 +165,9 @@ export function SearchTable<T extends Record<string, any>>({
}
{
filters.length > 0 && (
<div style={{ paddingBottom: '15px' }}>
<Stack style={{ paddingBottom: 15 }}>
{filters}
</div>
</Stack>
)
}
<DataTable {...rest} items={results?.results} isLoading={!results} />

View file

@ -6,8 +6,8 @@ import { PropsWithChildren, useContext } from 'react'
import { AdminContext, ProjectContext } from '../contexts'
import api from '../api'
import { PreferencesContext } from './PreferencesContext'
import useResolver from '../hooks/useResolver'
import { SelectField } from './form/SelectField'
import { useResolver } from '../hooks'
import { SingleSelect } from './form/SingleSelect'
import Button, { LinkButton } from './Button'
import ButtonGroup from './ButtonGroup'
@ -30,7 +30,7 @@ export default function Sidebar({ children, links }: PropsWithChildren<SidebarPr
<Logo />
</Link>
</div>
<SelectField
<SingleSelect
value={project}
onChange={project => navigate(`/projects/${project.id}`)}
options={projects ?? [project]}

View file

@ -1,13 +1,16 @@
import clsx from 'clsx'
import { PropsWithChildren } from 'react'
import { CSSProperties, PropsWithChildren } from 'react'
import './Stack.css'
type StackProps = PropsWithChildren<{
className?: string
style?: CSSProperties
vertical?: boolean
}>
export default function Stack({ children, vertical }: StackProps) {
export default function Stack({ children, className, style, vertical }: StackProps) {
return (
<div className={clsx('ui-stack', vertical && 'ui-stack-vertical')}>
<div className={clsx('ui-stack', vertical && 'ui-stack-vertical', className)} style={style}>
{children}
</div>
)

View file

@ -1,6 +1,6 @@
import { Combobox, Transition } from '@headlessui/react'
import { useCallback, useState, Fragment, RefCallback } from 'react'
import useResolver from '../../hooks/useResolver'
import { useResolver } from '../../hooks'
import { ControlledInputProps, SearchResult } from '../../types'
import clsx from 'clsx'
import { CheckIcon, ChevronUpDownIcon } from '../icons'
@ -49,8 +49,8 @@ export function EntityIdPicker<T extends { id: number }>({
return (
<Combobox
as='div'
className='ui-select'
as="div"
className="ui-select"
nullable
value={entity}
onChange={next => onChange(next?.id ?? 0)}
@ -62,7 +62,7 @@ export function EntityIdPicker<T extends { id: number }>({
</span>
{subtitle && <span className="label-subtitle">{subtitle}</span>}
</Combobox.Label>
<div className='ui-button-group'>
<div className="ui-button-group">
<span className={clsx('ui-text-field', size ?? 'regular')}>
<Combobox.Input
displayValue={(value: T) => value && displayValue(value)}
@ -78,8 +78,8 @@ export function EntityIdPicker<T extends { id: number }>({
{
!!(value && !required) && (
<Button
icon='x'
variant='secondary'
icon="x"
variant="secondary"
size={size}
onClick={() => onChange(0)} // set to '0' to clear? or null?
/>
@ -102,7 +102,7 @@ export function EntityIdPicker<T extends { id: number }>({
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
className='select-options'
className="select-options"
>
{
result?.results.map((option) => (

View file

@ -9,6 +9,7 @@ import clsx from 'clsx'
export interface MultiSelectProps<T, O = T> extends ControlledInputProps<T[]>, OptionsProps<O, T> {
className?: string
buttonClassName?: string
placeholder?: ReactNode
getSelectedOptionDisplay?: (option: O) => ReactNode
optionsFooter?: ReactNode
size?: 'small' | 'regular'
@ -19,6 +20,7 @@ export function MultiSelect<T, U = T>({
buttonClassName,
className,
disabled,
placeholder,
error,
getOptionDisplay = defaultGetOptionDisplay,
getSelectedOptionDisplay = getOptionDisplay,
@ -47,6 +49,9 @@ export function MultiSelect<T, U = T>({
const valueKey = getValueKey(v)
const option = options.find(o => getValueKey(toValue(o)) === valueKey)
if (option) {
if (a.valid.length) {
a.valid.push(',')
}
a.valid.push(
<Fragment key={valueKey}>
{getSelectedOptionDisplay(option)}
@ -71,20 +76,24 @@ export function MultiSelect<T, U = T>({
onChange={onChange}
multiple
>
<Listbox.Label style={hideLabel ? { display: 'none' } : undefined}>
{
label && (
<span>
{label}
{
required && (
<span style={{ color: 'red' }}>&nbsp;*</span>
)
}
</span>
)
}
</Listbox.Label>
{
label && (
<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">
@ -108,7 +117,7 @@ export function MultiSelect<T, U = T>({
}
</>
)
: 'None Selected'
: (placeholder ?? 'None Selected')
}
</span>
<span className="select-button-icon">

View file

@ -92,6 +92,7 @@
z-index: 999;
max-height: 275px;
overflow: scroll;
margin: 0;
}
.ui-select .select-option {

View file

@ -2,21 +2,22 @@ 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 './SelectField.css'
import './Select.css'
import { defaultGetOptionDisplay, defaultGetValueKey, defaultToValue, usePopperSelectDropdown } from '../utils'
import { ControlledInputProps, FieldBindingsProps, OptionsProps } from '../../types'
import clsx from 'clsx'
export interface SelectFieldProps<T, O = T> extends ControlledInputProps<T>, OptionsProps<O, T> {
export interface SingleSelectProps<T, O = T> extends ControlledInputProps<T>, OptionsProps<O, T> {
className?: string
buttonClassName?: string
getSelectedOptionDisplay?: (option: O) => ReactNode
onBlur?: () => void
optionsFooter?: ReactNode
size?: 'small' | 'regular'
variant?: 'plain' | 'minimal'
}
export function SelectField<T, U = T>({
export function SingleSelect<T, U = T>({
buttonClassName,
className,
disabled,
@ -27,6 +28,7 @@ export function SelectField<T, U = T>({
label,
options,
optionsFooter,
onBlur,
onChange,
required,
size,
@ -35,7 +37,7 @@ export function SelectField<T, U = T>({
getValueKey = defaultGetValueKey,
value,
variant,
}: SelectFieldProps<T, U>) {
}: SingleSelectProps<T, U>) {
const {
setReferenceElement,
@ -54,17 +56,22 @@ export function SelectField<T, U = T>({
disabled={disabled}
value={value}
onChange={onChange}
onBlur={onBlur}
>
<Listbox.Label style={hideLabel ? { display: 'none' } : undefined}>
<span>
{label}
{
required && (
<span style={{ color: 'red' }}>&nbsp;*</span>
)
}
</span>
</Listbox.Label>
{
label && (
<Listbox.Label style={hideLabel ? { display: 'none' } : undefined}>
<span>
{label}
{
required && (
<span style={{ color: 'red' }}>&nbsp;*</span>
)
}
</span>
</Listbox.Label>
)
}
{
subtitle && (
<span className="label-subtitle">
@ -132,14 +139,14 @@ export function SelectField<T, U = T>({
)
}
SelectField.Field = function SelectFieldField<T, O, X extends FieldValues, P extends FieldPath<X>>({
SingleSelect.Field = function SingleSelectField<T, O, X extends FieldValues, P extends FieldPath<X>>({
form,
name,
required,
...rest
}: FieldBindingsProps<SelectFieldProps<T, O>, T, X, P>) {
}: FieldBindingsProps<SingleSelectProps<T, O>, T, X, P>) {
const { field, fieldState } = useController({
const { field: { ref, ...field }, fieldState } = useController({
control: form.control,
name,
rules: {
@ -148,7 +155,7 @@ SelectField.Field = function SelectFieldField<T, O, X extends FieldValues, P ext
})
return (
<SelectField
<SingleSelect
{...rest}
{...field}
required={required}

View file

@ -8,10 +8,11 @@ import FormWrapper from '../../ui/form/FormWrapper'
import Heading from '../../ui/Heading'
import Modal from '../../ui/Modal'
import ListTable from '../users/ListTable'
import { SelectField } from '../../ui/form/SelectField'
import { SingleSelect } from '../../ui/form/SingleSelect'
import { snakeToTitle } from '../../utils'
import OptionField from '../../ui/form/OptionField'
import { SelectionProps } from '../../ui/form/Field'
import { TagPicker } from '../settings/TagPicker'
interface CampaignEditParams {
campaign?: Campaign
@ -24,10 +25,16 @@ interface ListSelectionProps extends SelectionProps<CampaignCreateParams> {
project: Project
}
const ListSelection = ({ project, name, control }: ListSelectionProps) => {
const ListSelection = ({ project, control }: ListSelectionProps) => {
const lists = useCallback(async (params: SearchParams) => await api.lists.search(project.id, params), [project])
const { field: { value, onChange } } = useController({ name, control, rules: { required: true } })
const { field: { value, onChange } } = useController({
control,
name: 'list_id',
rules: {
required: true,
},
})
return (
<ListTable
@ -72,7 +79,7 @@ const SubscriptionSelection = ({ subscriptions, form }: { subscriptions: Subscri
}
}, [channel, form, subscriptions])
return (
<SelectField.Field
<SingleSelect.Field
form={form}
name="subscription_id"
label="Subscription Group"
@ -98,7 +105,7 @@ const ProviderSelection = ({ providers, form }: { providers: Provider[], form: U
}
}, [channel, form, providers])
return (
<SelectField.Field
<SingleSelect.Field
form={form}
name="provider_id"
label="Provider"
@ -131,10 +138,10 @@ export default function CampaignEditModal({ campaign, open, onClose, onSave }: C
.catch(() => {})
}, [])
async function handleSave({ name, list_id, channel, provider_id, subscription_id }: CampaignCreateParams) {
async function handleSave({ name, list_id, channel, provider_id, subscription_id, tags }: CampaignCreateParams) {
const value = campaign
? await api.campaigns.update(project.id, campaign.id, { name, list_id, subscription_id })
: await api.campaigns.create(project.id, { name, list_id, channel, provider_id, subscription_id })
? await api.campaigns.update(project.id, campaign.id, { name, list_id, subscription_id, tags })
: await api.campaigns.create(project.id, { name, list_id, channel, provider_id, subscription_id, tags })
onSave(value)
onClose(false)
}
@ -199,6 +206,10 @@ export default function CampaignEditModal({ campaign, open, onClose, onSave }: C
</>
)
}
<TagPicker.Field
form={form}
name="tags"
/>
</>
)}
</FormWrapper>

View file

@ -7,7 +7,6 @@ import Heading from '../../ui/Heading'
import { InfoTable } from '../../ui/InfoTable'
import { PreferencesContext } from '../../ui/PreferencesContext'
import { formatDate } from '../../utils'
import { route } from '../router'
import CampaignEditModal from './CampaignEditModal'
import { CampaignTag } from './Campaigns'
import ChannelTag from './ChannelTag'
@ -25,9 +24,19 @@ export default function CampaignOverview() {
return (
<>
<Heading title="Details" size="h3" actions={
<Button size="small" variant="secondary" onClick={() => setIsEditOpen(true)}>Edit Details</Button>
} />
<Heading
title="Details"
size="h3"
actions={
<Button
size="small"
variant="secondary"
onClick={() => setIsEditOpen(true)}
>
Edit Details
</Button>
}
/>
<Heading title="Channel" size="h4" />
<InfoTable rows={{
@ -41,7 +50,7 @@ export default function CampaignOverview() {
state: CampaignTag({ state: campaign.state }),
launched_at: campaign.send_at ? formatDate(preferences, campaign.send_at) : undefined,
in_timezone: campaign.send_in_user_timezone ? 'Yes' : 'No',
list: <Link to={route(`lists/${campaign.list_id}`)}>{campaign.list?.name}</Link>,
list: <Link to={`/projects/${campaign.project_id}/lists/${campaign.list_id}`}>{campaign.list?.name}</Link>,
delivery: `${campaign.delivery?.sent ?? 0} / ${campaign.delivery?.total ?? 0}`,
}} />

View file

@ -91,7 +91,10 @@ export default function Campaigns() {
),
},
]}
onSelectRow={({ id }) => navigate(id.toString())} />
onSelectRow={({ id }) => navigate(id.toString())}
enableSearch
tagEntity="campaigns"
/>
</PageContent>
<CampaignEditModal

View file

@ -9,6 +9,7 @@
right: 0;
top: 0;
bottom: 0;
z-index: 1;
}
.template-editor.inline {

View file

@ -3,18 +3,18 @@ import { CampaignContext, ProjectContext } from '../../contexts'
import SourceEditor from '@monaco-editor/react'
import { editor as Editor } from 'monaco-editor'
import './EmailEditor.css'
import Button, { LinkButton } from '../../ui/Button'
import Button from '../../ui/Button'
import api from '../../api'
import { Image, Template } from '../../types'
import { useSearchParams } from 'react-router-dom'
import { useNavigate, useSearchParams } from 'react-router-dom'
import Preview from '../../ui/Preview'
import { locales } from './CampaignDetail'
import Tabs from '../../ui/Tabs'
import { formatDate } from '../../utils'
import { PreferencesContext } from '../../ui/PreferencesContext'
import { route } from '../router'
import CreateLocaleModal from './CreateLocaleModal'
import ImageGalleryModal from './ImageGalleryModal'
import Modal from '../../ui/Modal'
const HtmlEditor = ({ template, setTemplate }: { template: Template, setTemplate: (template: Template) => void }) => {
@ -130,7 +130,7 @@ const HtmlEditor = ({ template, setTemplate }: { template: Template, setTemplate
}
export default function EmailEditor() {
const navigate = useNavigate()
const [params] = useSearchParams()
const [project] = useContext(ProjectContext)
const [campaign, setCampaign] = useContext(CampaignContext)
@ -161,21 +161,27 @@ export default function EmailEditor() {
return (
<>
<section className="email-editor">
<div className="email-editor-header">
<LinkButton variant="secondary" icon="x-lg" to={route(`campaigns/${campaign.id}/design`)}>Exit</LinkButton>
<h3>{campaign.name}</h3>
</div>
<Modal
size="fullscreen"
title={campaign.name}
open
onClose={() => navigate(`../campaigns/${campaign.id}/design`)}
>
<Tabs
selectedIndex={selectedIndex}
onChange={setSelectedIndex}
tabs={tabs}
append={
<Button size="small"
<Button
size="small"
variant="secondary"
onClick={() => setIsAddLocaleOpen(true)}>Add Locale</Button>
} />
</section>
onClick={() => setIsAddLocaleOpen(true)}
>
{'Add Locale'}
</Button>
}
/>
</Modal>
<CreateLocaleModal
open={isAddLocaleOpen}
setIsOpen={() => setIsAddLocaleOpen(false)}

View file

@ -4,7 +4,7 @@ import { LocaleContext } from '../../contexts'
import { Campaign, UseStateContext } from '../../types'
import Button from '../../ui/Button'
import ButtonGroup from '../../ui/ButtonGroup'
import { SelectField } from '../../ui/form/SelectField'
import { SingleSelect } from '../../ui/form/SingleSelect'
import CreateLocaleModal from './CreateLocaleModal'
interface LocaleSelectorParams {
@ -30,7 +30,7 @@ export default function LocaleSelector({ campaignState, openState }: LocaleSelec
<ButtonGroup>
{
currentLocale && (
<SelectField
<SingleSelect
options={allLocales}
size="small"
value={currentLocale}

View file

@ -1,81 +1,24 @@
.journey {
background: var(--color-background);
display: grid;
grid-template-areas: "header header header" "options builder builder";
grid-template-columns: 300px auto 300px;
grid-template-rows: 60px auto;
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 100vw;
z-index: 80;
padding: 0;
}
.journey-header {
grid-area: header;
border-bottom: 1px solid var(--color-grey);
padding: 0;
display: grid;
grid-template-areas: "close title actions";
grid-template-columns: 60px 1fr auto;
flex-grow: 0;
}
.journey-header h2 {
font-size: 20px;
padding: 10px 20px;
margin: 0;
}
.journey-header .actions {
grid-area: actions;
padding-right: 20px;
display: flex;
flex-direction: row;
align-items: center;
}
.journey-header .publish-details {
padding: 10px;
}
.journey-header .publish-details span {
display: block;
color: var(--color-text-light);
font-size: 14px;
}
.journey-header .journey-header-close {
border-right: 1px solid var(--color-grey);
padding: 10px;
align-items: stretch;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer
}
.journey-header .journey-header-close:hover {
background-color: var(--color-grey-soft);
.journey-actions {
display: flex;
}
.journey-actions .publish-date {
color: var(--color-grey);
}
.journey-builder {
grid-area: builder;
background-size: 30px 30px;
}
.journey-settings {
grid-area: settings;
border-left: 1px solid var(--color-grey);
padding: 20px;
position: relative;
flex-grow: 1;
}
.journey-options {
grid-area: options;
max-width: 300px;
padding: 20px;
border-right: 1px solid var(--color-grey);
position: relative;

View file

@ -1,5 +1,5 @@
import { createElement, DragEventHandler, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { useNavigate } from 'react-router-dom'
import ReactFlow, {
addEdge,
Background,
@ -28,8 +28,7 @@ import ReactFlow, {
useReactFlow,
} from 'reactflow'
import { JourneyContext, ProjectContext } from '../../contexts'
import { PreferencesContext } from '../../ui/PreferencesContext'
import { createComparator, createUuid, formatDate } from '../../utils'
import { createComparator, createUuid } from '../../utils'
import * as journeySteps from './steps/index'
import clsx from 'clsx'
import api from '../../api'
@ -39,6 +38,8 @@ import './JourneyEditor.css'
import 'reactflow/dist/style.css'
import Button from '../../ui/Button'
import Alert from '../../ui/Alert'
import Modal from '../../ui/Modal'
import { toast } from 'react-hot-toast'
const getStepType = (type: string) => (type ? journeySteps[type as keyof typeof journeySteps] as JourneyStepType : null) ?? null
@ -83,7 +84,7 @@ function JourneyStepNode({
if (!type) {
return (
<Alert variant='error' title='Invalid Step Type' />
<Alert variant="error" title="Invalid Step Type" />
)
}
@ -91,7 +92,7 @@ function JourneyStepNode({
<>
{
type.category !== 'entrance' && (
<Handle type='target' position={Position.Top} id={'t-' + id} />
<Handle type="target" position={Position.Top} id={'t-' + id} />
)
}
<div className={clsx('journey-step', type.category, selected && 'selected')}>
@ -104,7 +105,7 @@ function JourneyStepNode({
</div>
{
type.Edit && (
<div className='journey-step-body'>
<div className="journey-step-body">
{
createElement(type.Edit, {
value: data,
@ -118,7 +119,7 @@ function JourneyStepNode({
}
</div>
<Handle
type='source'
type="source"
position={Position.Bottom} id={'s-' + id}
isValidConnection={validateConnection}
/>
@ -271,11 +272,10 @@ function nodesToSteps(nodes: Node[], edges: Edge[]) {
}
export default function JourneyEditor() {
const navigate = useNavigate()
const [flowInstance, setFlowInstance] = useState<null | ReactFlowInstance>(null)
const wrapper = useRef<HTMLDivElement>(null)
const [preferences] = useContext(PreferencesContext)
const [project] = useContext(ProjectContext)
const [journey] = useContext(JourneyContext)
@ -300,8 +300,11 @@ export default function JourneyEditor() {
void loadSteps()
}, [loadSteps])
const [saving, setSaving] = useState(false)
const saveSteps = useCallback(async () => {
setSaving(true)
const stepMap = await api.journeys.steps.set(project.id, journey.id, nodesToSteps(nodes, edges))
const stats = await api.journeys.steps.stats(project.id, journey.id)
@ -310,6 +313,9 @@ export default function JourneyEditor() {
setNodes(refreshed.nodes)
setEdges(refreshed.edges)
setSaving(false)
toast.success('Saved!')
}, [project, journey, nodes, edges])
const onConnect = useCallback(async (connection: Connection) => {
@ -365,67 +371,65 @@ export default function JourneyEditor() {
}, [setNodes, flowInstance, project, journey])
return (
<div className='journey'>
<div className='journey-header'>
<Link to='../journeys' className='journey-header-close'>
<i className="bi-x-lg" />
</Link>
<h2>{journey.name}</h2>
<div className='actions'>
<div className='publish-details'>
<span className="publish-label">Last published at:</span>
<span className="publish-date">{formatDate(preferences, journey.updated_at)}</span>
</div>
<div>
<Button onClick={saveSteps}>
{'Publish'}
</Button>
</div>
<Modal
size="fullscreen"
title={journey.name}
open={true}
onClose={() => navigate('../journeys')}
actions={
<Button
onClick={saveSteps}
isLoading={saving}
variant="primary"
>
{'Save'}
</Button>
}
>
<div className="journey">
<div className="journey-options">
<h3>Components</h3>
{
Object.entries(journeySteps).sort(createComparator(x => x[1].category)).filter(([, type]) => type.category !== 'entrance').map(([key, type]) => (
<div
key={key}
className={clsx('component', type.category)}
draggable
onDragStart={event => {
event.dataTransfer.setData(DATA_FORMAT, key)
event.dataTransfer.effectAllowed = 'move'
}}
>
<i className={clsx('component-handle', type.icon)} />
<div className="component-title">{type.name}</div>
<div className="component-desc">{type.description}</div>
</div>
))
}
</div>
<div className="journey-builder" ref={wrapper}>
<ReactFlow
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onEdgeUpdate={onEdgeUpdate}
onInit={setFlowInstance}
elementsSelectable
onDragOver={onDragOver}
onDrop={onDrop}
>
<Background className="internal-canvas" />
<Controls />
<MiniMap
nodeClassName={({ data }: Node<JourneyStep>) => `journey-minimap ${getStepType(data.type)?.category ?? 'unknown'}`}
/>
</ReactFlow>
</div>
</div>
<div className='journey-options'>
<h3>Components</h3>
{
Object.entries(journeySteps).sort(createComparator(x => x[1].category)).filter(([, type]) => type.category !== 'entrance').map(([key, type]) => (
<div
key={key}
className={clsx('component', type.category)}
draggable
onDragStart={event => {
event.dataTransfer.setData(DATA_FORMAT, key)
event.dataTransfer.effectAllowed = 'move'
}}
>
<i className={clsx('component-handle', type.icon)} />
<div className='component-title'>{type.name}</div>
<div className='component-desc'>{type.description}</div>
</div>
))
}
</div>
<div className='journey-builder' ref={wrapper}>
<ReactFlow
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onEdgeUpdate={onEdgeUpdate}
onInit={setFlowInstance}
elementsSelectable
onDragOver={onDragOver}
onDrop={onDrop}
fitView
>
<Background className='internal-canvas' />
<Controls />
<MiniMap
nodeClassName={({ data }: Node<JourneyStep>) => `journey-minimap ${getStepType(data.type)?.category ?? 'unknown'}`}
/>
</ReactFlow>
</div>
</div>
</Modal>
)
}

View file

@ -8,6 +8,7 @@ import FormWrapper from '../../ui/form/FormWrapper'
import Modal from '../../ui/Modal'
import PageContent from '../../ui/PageContent'
import { SearchTable, useSearchTableQueryState } from '../../ui/SearchTable'
import { TagPicker } from '../settings/TagPicker'
export default function Journeys() {
const { projectId = '' } = useParams()
@ -19,7 +20,7 @@ export default function Journeys() {
<PageContent
title="Journeys"
actions={
<Button icon='plus-lg' onClick={() => setOpen('create')}>
<Button icon="plus-lg" onClick={() => setOpen('create')}>
Create Journey
</Button>
}
@ -39,12 +40,12 @@ export default function Journeys() {
]}
onSelectRow={r => navigate(r.id.toString())}
enableSearch
tagEntity='journeys'
tagEntity="journeys"
/>
<Modal
onClose={() => setOpen(null)}
open={!!open}
title='Create Journey'
title="Create Journey"
>
<FormWrapper<Journey>
onSubmit={async journey => {
@ -58,14 +59,18 @@ export default function Journeys() {
<>
<TextField
form={form}
name='name'
name="name"
required
/>
<TextField
form={form}
name='description'
name="description"
textarea
/>
<TagPicker.Field
form={form}
name="tags"
/>
</>
)
}

View file

@ -23,7 +23,7 @@ export const actionStep: JourneyStepType<ActionConfig> = {
return (
<>
<EntityIdPicker
label='Campaign'
label="Campaign"
get={useCallback(async id => await api.campaigns.get(projectId, id), [projectId])}
search={useCallback(async q => await api.campaigns.search(projectId, { q, page: 0, itemsPerPage: 50 }), [projectId])}
value={value.campaign_id}

View file

@ -30,8 +30,8 @@ export const delayStep: JourneyStepType<DelayStepConfig> = {
key={name}
name={name}
label={snakeToTitle(name)}
type='number'
size='small'
type="number"
size="small"
min={0}
value={value[name as keyof DelayStepConfig] ?? 0}
onChange={n => onChange({ ...value, [name]: parseInt(n) })}

View file

@ -27,7 +27,7 @@ export const entranceStep: JourneyStepType<EntranceConfig> = {
return (
<>
<EntityIdPicker
label='List'
label="List"
required
get={getList}
search={searchLists}

View file

@ -22,11 +22,11 @@ export const experimentStep: JourneyStepType<{}, ExperimentStepChildConfig> = {
const percentage = totalRatio > 0 ? round(ratio / totalRatio * 100, 2) : 0
return (
<TextField
name='ratio'
label='Ratio'
name="ratio"
label="Ratio"
subtitle={`${percentage}% of users will follow this path.`}
type='number'
size='small'
type="number"
size="small"
value={value.ratio ?? 0}
onChange={str => onChange({ ...value, ratio: parseFloat(str) })}
/>

View file

@ -22,8 +22,8 @@ export const journeyLinkStep: JourneyStepType<JourneyLinkConfig> = {
return (
<>
<EntityIdPicker
label='Target Journey'
subtitle='Send users to this journey when they reach this step.'
label="Target Journey"
subtitle="Send users to this journey when they reach this step."
get={useCallback(async id => await api.journeys.get(project.id, id), [project])}
search={useCallback(async q => await api.journeys.search(project.id, { q, page: 0, itemsPerPage: 50 }), [project])}
optionEnabled={o => o.id !== journey.id}

View file

@ -20,11 +20,11 @@ export const mapStep: JourneyStepType<MapConfig, { value: string }> = {
}) {
return (
<TextField
name='attribute'
label='Attribute'
subtitle='Path to value'
type='text'
size='small'
name="attribute"
label="Attribute"
subtitle="Path to value"
type="text"
size="small"
value={value.attribute}
onChange={attribute => onChange({ ...value, attribute })}
/>
@ -38,7 +38,7 @@ export const mapStep: JourneyStepType<MapConfig, { value: string }> = {
}) => {
return (
<TextField
name='value'
name="value"
label={`When ${stepData.attribute} is:`}
subtitle={
siblingData.find(s => s.value === value.value) && (

View file

@ -3,7 +3,7 @@ import api from '../../api'
import TextField from '../../ui/form/TextField'
import { ProjectCreate } from '../../types'
import FormWrapper from '../../ui/form/FormWrapper'
import { SelectField } from '../../ui/form/SelectField'
import { SingleSelect } from '../../ui/form/SingleSelect'
// eslint-disable-next-line @typescript-eslint/no-namespace
export declare namespace Intl {
@ -33,7 +33,7 @@ export default function ProjectForm() {
label="Default Locale"
subtitle="This locale will be used as the default when creating campaigns and when a users locale does not match any available ones."
required />
<SelectField.Field
<SingleSelect.Field
form={form}
options={timeZones}
name="timezone"

View file

@ -37,16 +37,6 @@ import OnboardingStart from './auth/OnboardingStart'
import Onboarding from './auth/Onboarding'
import OnboardingProject from './auth/OnboardingProject'
export const route = (path: string, includeProject = true) => {
const { projectId = '' } = useParams()
const parts = []
if (includeProject) {
parts.push('projects', projectId)
}
parts.push(path)
return '/' + parts.join('/')
}
export const useRoute = (includeProject = true) => {
const { projectId = '' } = useParams()
const navigate = useNavigate()
@ -106,7 +96,7 @@ export const router = createBrowserRouter([
{
path: 'projects/new',
element: (
<PageContent title='Create Project'>
<PageContent title="Create Project">
<ProjectForm />
</PageContent>
),

View file

@ -30,7 +30,7 @@ export default function ProjectApiKeys() {
]}
itemKey={({ item }) => item.id}
onSelectRow={(row: ProjectApiKey) => navigate(`${row.id}`)}
title='API Keys'
title="API Keys"
actions={
<Button icon="plus-lg" size="small" onClick={() => setIsModalOpen(true)}>
Create Key

View file

@ -1,7 +1,7 @@
import { useCallback, useContext, useEffect, useState } from 'react'
import api from '../../api'
import { ProjectContext } from '../../contexts'
import useResolver from '../../hooks/useResolver'
import { useResolver } from '../../hooks'
import { Provider, ProviderCreateParams, ProviderMeta, ProviderUpdateParams } from '../../types'
import Alert from '../../ui/Alert'
import Button from '../../ui/Button'

View file

@ -7,7 +7,7 @@ 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 { SingleSelect } from '../../ui/form/SingleSelect'
import Button from '../../ui/Button'
export default function Subscriptions() {
@ -26,13 +26,13 @@ export default function Subscriptions() {
]}
itemKey={({ item }) => item.id}
onSelectRow={(row) => navigate(`${row.id}`)}
title='Subscriptions'
title="Subscriptions"
actions={
<>
<Button
variant='primary'
icon='plus'
size='small'
variant="primary"
icon="plus"
size="small"
onClick={() => setOpen(true)}
>
{'Create Subscription'}
@ -41,7 +41,7 @@ export default function Subscriptions() {
}
/>
<Modal
title='Create Subscription'
title="Create Subscription"
open={open}
onClose={() => setOpen(false)}
>
@ -60,13 +60,13 @@ export default function Subscriptions() {
<>
<TextField
form={form}
name='name'
name="name"
required
label='Name'
label="Name"
/>
<SelectField.Field
<SingleSelect.Field
form={form}
name='channel'
name="channel"
options={['email', 'push', 'text', 'webhook']}
/>
</>

View file

@ -1,31 +1,68 @@
import { useCallback, useContext } from 'react'
import { ReactNode, useCallback, useContext, useMemo } from 'react'
import { FieldPath, FieldValues, useController } from 'react-hook-form'
import api from '../../api'
import { ProjectContext } from '../../contexts'
import useResolver from '../../hooks/useResolver'
import { ControlledProps } from '../../types'
import { useResolver } from '../../hooks'
import { ControlledInputProps, FieldBindingsProps } from '../../types'
import { MultiSelect } from '../../ui/form/MultiSelect'
import { snakeToTitle } from '../../utils'
export interface TagPickerProps extends ControlledProps<string[]> {
entity: 'journeys' | 'campaigns' | 'users' | 'lists'
export interface TagPickerProps extends ControlledInputProps<string[]> {
entity?: 'journeys' | 'campaigns' | 'users' | 'lists'
placeholder?: ReactNode
}
export function TagPicker({
entity,
onChange,
value,
...rest
}: TagPickerProps) {
const [project] = useContext(ProjectContext)
const [tags] = useResolver(useCallback(async () => await api.tags.used(project.id, entity), [project, entity]))
const [tags] = useResolver(useCallback(async () => {
if (entity) {
return await api.tags.used(project.id, entity)
}
return await api.tags.all(project.id)
}, [project, entity]))
if (!tags) return null
value = useMemo(() => value ?? [], [value])
if (!tags?.length) return null
return (
<MultiSelect
{...rest}
value={value}
onChange={onChange}
options={tags}
toValue={t => t.name}
getOptionDisplay={({ name, count }) => `${name} (${count})`}
getOptionDisplay={({ name, count }) => name + (count !== undefined ? ` (${count})` : '')}
getSelectedOptionDisplay={({ name }) => name}
/>
)
}
TagPicker.Field = function TagPickerField<X extends FieldValues, P extends FieldPath<X>>({
form,
label,
name,
required,
...rest
}: FieldBindingsProps<TagPickerProps, string[], X, P>) {
const { field: { ref, ...field } } = useController({
control: form.control,
name,
rules: {
required,
},
})
return (
<TagPicker
{...rest}
{...field}
required={required}
label={label ?? snakeToTitle(name)}
/>
)
}

View file

@ -28,15 +28,15 @@ export default function Tags() {
cell: () => 'TODO',
},
]}
title='Tags'
description='Use tags to organize and report on your campaigns, journeys, lists, and users.'
title="Tags"
description="Use tags to organize and report on your campaigns, journeys, lists, and users."
actions={
<>
<Button
size='small'
variant='primary'
size="small"
variant="primary"
onClick={() => setEditing({ id: 0, name: 'New Tag' })}
icon='plus'
icon="plus"
>
{'Create Tag'}
</Button>
@ -66,7 +66,7 @@ export default function Tags() {
{
form => (
<>
<TextField form={form} name='name' required />
<TextField form={form} name="name" required />
</>
)
}

View file

@ -16,6 +16,7 @@ import { snakeToTitle } from '../../utils'
import UploadField from '../../ui/form/UploadField'
import { SearchTable, useSearchTableState } from '../../ui/SearchTable'
import { useRoute } from '../router'
import { TagPicker } from '../settings/TagPicker'
const RuleSection = ({ list, onRuleSave }: { list: DynamicList, onRuleSave: (rule: WrapperRule) => void }) => {
const [rule, setRule] = useState<WrapperRule>(list.rule)
@ -99,9 +100,20 @@ export default function ListDetail() {
submitLabel="Save"
defaultValues={{ name: list.name }}
>
{form => <>
<TextField form={form} name="name" label="List Name" required />
</>}
{form => (
<>
<TextField
form={form}
name="name"
label="List Name"
required
/>
<TagPicker.Field
form={form}
name="tags"
/>
</>
)}
</FormWrapper>
</Modal>

View file

@ -49,6 +49,8 @@ export default function ListTable({ search, selectedRow, onSelectRow, title }: L
]}
selectedRow={selectedRow}
onSelectRow={({ id }) => handleOnSelectRow(id)}
enableSearch
tagEntity="lists"
/>
)
}

View file

@ -10,6 +10,7 @@ import Modal from '../../ui/Modal'
import PageContent from '../../ui/PageContent'
import ListTable from './ListTable'
import { createWrapperRule } from './RuleBuilder'
import { TagPicker } from '../settings/TagPicker'
export default function Lists() {
const { projectId = '' } = useParams()
@ -19,13 +20,22 @@ export default function Lists() {
return (
<>
<PageContent title="Lists" actions={
<Button icon="plus-lg" onClick={() => setIsModalOpen(true) }>Create List</Button>
}>
<PageContent
title="Lists"
actions={
<Button
icon="plus-lg"
onClick={() => setIsModalOpen(true) }
>
Create List
</Button>
}
>
<ListTable search={search} />
</PageContent>
<Modal title="Create List"
<Modal
title="Create List"
open={isModalOpen}
onClose={() => setIsModalOpen(false)}
>
@ -43,17 +53,29 @@ export default function Lists() {
defaultValues={{ type: 'dynamic' }}
submitLabel="Save"
>
{form => <>
<TextField form={form} name="name" label="List Name" required />
<OptionField
form={form}
name="type"
label="Type"
options={[
{ key: 'dynamic', label: 'Dynamic' },
{ key: 'static', label: 'Static' },
]}/>
</>}
{form => (
<>
<TextField
form={form}
name="name"
label="List Name"
required
/>
<OptionField
form={form}
name="type"
label="Type"
options={[
{ key: 'dynamic', label: 'Dynamic' },
{ key: 'static', label: 'Static' },
]}
/>
<TagPicker.Field
form={form}
name="tags"
/>
</>
)}
</FormWrapper>
</Modal>
</>

View file

@ -1,7 +1,7 @@
import { Operator, Rule, RuleType, WrapperRule } from '../../types'
import Button from '../../ui/Button'
import ButtonGroup from '../../ui/ButtonGroup'
import { SelectField } from '../../ui/form/SelectField'
import { SingleSelect } from '../../ui/form/SingleSelect'
import TextField from '../../ui/form/TextField'
import './RuleBuilder.css'
@ -270,7 +270,7 @@ const OperatorSelector = ({ type, value, onChange }: OperatorParams) => {
const operators = types[type]
return (
<SelectField
<SingleSelect
options={operators}
size="small"
toValue={o => o.key}
@ -299,7 +299,7 @@ const TypeOperator = ({ value, onChange }: TypeParams) => {
]
return (
<SelectField
<SingleSelect
options={types}
size="small"
toValue={o => o.key}

View file

@ -2,7 +2,7 @@ import { JsonViewer } from '@textea/json-viewer'
import { useCallback, useContext, useState } from 'react'
import api from '../../api'
import { ProjectContext, UserContext } from '../../contexts'
import useResolver from '../../hooks/useResolver'
import { useResolver } from '../../hooks'
import { SearchParams, UserEvent } from '../../types'
import Modal from '../../ui/Modal'
import { SearchTable } from '../../ui/SearchTable'

View file

@ -1,7 +1,7 @@
import { useCallback, useContext } from 'react'
import api from '../../api'
import { ProjectContext, UserContext } from '../../contexts'
import useResolver from '../../hooks/useResolver'
import { useResolver } from '../../hooks'
import { SubscriptionParams, SubscriptionState } from '../../types'
import Button from '../../ui/Button'
import SwitchField from '../../ui/form/SwitchField'

View file

@ -20,6 +20,7 @@ export default function UserTabs() {
{ key: 'created_at' },
{ key: 'updated_at' },
]}
onSelectRow={({ id }) => route(`users/${id}`)} />
onSelectRow={({ id }) => route(`users/${id}`)}
/>
</PageContent>
}

194
package-lock.json generated
View file

@ -9872,6 +9872,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.33.0",
"react-hot-toast": "2.4.0",
"react-popper": "^2.3.0",
"react-router-dom": "^6.4.2",
"react-scripts": "5.0.1",
@ -15487,10 +15488,6 @@
"version": "0.3.8",
"license": "MIT"
},
"apps/ui/node_modules/csstype": {
"version": "3.1.1",
"license": "MIT"
},
"apps/ui/node_modules/d3-color": {
"version": "3.1.0",
"license": "ISC",
@ -18947,16 +18944,6 @@
"version": "4.5.0",
"license": "MIT"
},
"apps/ui/node_modules/loose-envify": {
"version": "1.4.0",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"apps/ui/node_modules/lower-case": {
"version": "2.0.2",
"license": "MIT",
@ -20785,16 +20772,6 @@
"node": ">=0.10.0"
}
},
"apps/ui/node_modules/react": {
"version": "18.2.0",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"apps/ui/node_modules/react-app-polyfill": {
"version": "3.0.0",
"license": "MIT",
@ -20850,17 +20827,6 @@
"node": ">= 12.13.0"
}
},
"apps/ui/node_modules/react-dom": {
"version": "18.2.0",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.0"
},
"peerDependencies": {
"react": "^18.2.0"
}
},
"apps/ui/node_modules/react-error-overlay": {
"version": "6.0.11",
"license": "MIT"
@ -21353,13 +21319,6 @@
"node": ">=10"
}
},
"apps/ui/node_modules/scheduler": {
"version": "0.23.0",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"apps/ui/node_modules/schema-utils": {
"version": "3.1.1",
"license": "MIT",
@ -25851,6 +25810,11 @@
"node": ">= 8"
}
},
"node_modules/csstype": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz",
"integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw=="
},
"node_modules/dargs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/dargs/-/dargs-7.0.0.tgz",
@ -26672,6 +26636,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/goober": {
"version": "2.1.12",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.12.tgz",
"integrity": "sha512-yXHAvO08FU1JgTXX6Zn6sYCUFfB/OJSX8HHjDSgerZHZmFKAb08cykp5LBw5QnmyMcZyPRMqkdyHUSSzge788Q==",
"peerDependencies": {
"csstype": "^3.0.10"
}
},
"node_modules/graceful-fs": {
"version": "4.2.10",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
@ -27573,6 +27545,17 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.14.1.tgz",
@ -29119,6 +29102,44 @@
"node": ">=8"
}
},
"node_modules/react": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.0"
},
"peerDependencies": {
"react": "^18.2.0"
}
},
"node_modules/react-hot-toast": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.0.tgz",
"integrity": "sha512-qnnVbXropKuwUpriVVosgo8QrB+IaPJCpL8oBI6Ov84uvHZ5QQcTp2qg6ku2wNfgJl6rlQXJIQU5q+5lmPOutA==",
"dependencies": {
"goober": "^2.1.10"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/read": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz",
@ -29597,6 +29618,14 @@
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
},
"node_modules/scheduler": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
"integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/semver": {
"version": "7.3.8",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
@ -32707,6 +32736,11 @@
"which": "^2.0.1"
}
},
"csstype": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz",
"integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw=="
},
"dargs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/dargs/-/dargs-7.0.0.tgz",
@ -33330,6 +33364,12 @@
"slash": "^3.0.0"
}
},
"goober": {
"version": "2.1.12",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.12.tgz",
"integrity": "sha512-yXHAvO08FU1JgTXX6Zn6sYCUFfB/OJSX8HHjDSgerZHZmFKAb08cykp5LBw5QnmyMcZyPRMqkdyHUSSzge788Q==",
"requires": {}
},
"graceful-fs": {
"version": "4.2.10",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
@ -34014,6 +34054,14 @@
"is-unicode-supported": "^0.1.0"
}
},
"loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"requires": {
"js-tokens": "^3.0.0 || ^4.0.0"
}
},
"lru-cache": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.14.1.tgz",
@ -41732,6 +41780,31 @@
"integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==",
"dev": true
},
"react": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
"requires": {
"loose-envify": "^1.1.0"
}
},
"react-dom": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
"requires": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.0"
}
},
"react-hot-toast": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.0.tgz",
"integrity": "sha512-qnnVbXropKuwUpriVVosgo8QrB+IaPJCpL8oBI6Ov84uvHZ5QQcTp2qg6ku2wNfgJl6rlQXJIQU5q+5lmPOutA==",
"requires": {
"goober": "^2.1.10"
}
},
"read": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz",
@ -42080,6 +42153,14 @@
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
},
"scheduler": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
"integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
"requires": {
"loose-envify": "^1.1.0"
}
},
"semver": {
"version": "7.3.8",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
@ -42503,6 +42584,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.33.0",
"react-hot-toast": "2.4.0",
"react-popper": "^2.3.0",
"react-router-dom": "^6.4.2",
"react-scripts": "5.0.1",
@ -45809,9 +45891,6 @@
}
}
},
"csstype": {
"version": "3.1.1"
},
"d3-color": {
"version": "3.1.0"
},
@ -47954,12 +48033,6 @@
"lodash.uniq": {
"version": "4.5.0"
},
"loose-envify": {
"version": "1.4.0",
"requires": {
"js-tokens": "^3.0.0 || ^4.0.0"
}
},
"lower-case": {
"version": "2.0.2",
"requires": {
@ -48899,12 +48972,6 @@
}
}
},
"react": {
"version": "18.2.0",
"requires": {
"loose-envify": "^1.1.0"
}
},
"react-app-polyfill": {
"version": "3.0.0",
"requires": {
@ -48950,13 +49017,6 @@
}
}
},
"react-dom": {
"version": "18.2.0",
"requires": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.0"
}
},
"react-error-overlay": {
"version": "6.0.11"
},
@ -49245,12 +49305,6 @@
"xmlchars": "^2.2.0"
}
},
"scheduler": {
"version": "0.23.0",
"requires": {
"loose-envify": "^1.1.0"
}
},
"schema-utils": {
"version": "3.1.1",
"requires": {