mirror of
https://fast.feibisi.com/https://github.com/parcelvoy/platform.git
synced 2025-08-28 11:46:02 +08:00
added toaster, fixed linter warnings, various ui updates, tagging (#67)
This commit is contained in:
parent
f1e246a86f
commit
2a833e571a
58 changed files with 875 additions and 455 deletions
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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'>
|
||||
|
|
|
@ -30,6 +30,13 @@ const journeyParams: JSONSchemaType<JourneyParams> = {
|
|||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
tags: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
}
|
||||
|
|
|
@ -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> => {
|
||||
|
|
|
@ -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'>
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -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<{
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
40
apps/ui/src/hooks.ts
Normal 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
|
||||
}
|
|
@ -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])
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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]}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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) => (
|
||||
|
|
|
@ -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' }}> *</span>
|
||||
)
|
||||
}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</Listbox.Label>
|
||||
{
|
||||
label && (
|
||||
<Listbox.Label style={hideLabel ? { display: 'none' } : undefined}>
|
||||
{
|
||||
label && (
|
||||
<span>
|
||||
{label}
|
||||
{
|
||||
required && (
|
||||
<span style={{ color: 'red' }}> *</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">
|
||||
|
|
|
@ -92,6 +92,7 @@
|
|||
z-index: 999;
|
||||
max-height: 275px;
|
||||
overflow: scroll;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ui-select .select-option {
|
|
@ -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' }}> *</span>
|
||||
)
|
||||
}
|
||||
</span>
|
||||
</Listbox.Label>
|
||||
{
|
||||
label && (
|
||||
<Listbox.Label style={hideLabel ? { display: 'none' } : undefined}>
|
||||
<span>
|
||||
{label}
|
||||
{
|
||||
required && (
|
||||
<span style={{ color: 'red' }}> *</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}
|
|
@ -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>
|
||||
|
|
|
@ -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}`,
|
||||
}} />
|
||||
|
||||
|
|
|
@ -91,7 +91,10 @@ export default function Campaigns() {
|
|||
),
|
||||
},
|
||||
]}
|
||||
onSelectRow={({ id }) => navigate(id.toString())} />
|
||||
onSelectRow={({ id }) => navigate(id.toString())}
|
||||
enableSearch
|
||||
tagEntity="campaigns"
|
||||
/>
|
||||
</PageContent>
|
||||
|
||||
<CampaignEditModal
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.template-editor.inline {
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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) })}
|
||||
|
|
|
@ -27,7 +27,7 @@ export const entranceStep: JourneyStepType<EntranceConfig> = {
|
|||
return (
|
||||
<>
|
||||
<EntityIdPicker
|
||||
label='List'
|
||||
label="List"
|
||||
required
|
||||
get={getList}
|
||||
search={searchLists}
|
||||
|
|
|
@ -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) })}
|
||||
/>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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) && (
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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']}
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -49,6 +49,8 @@ export default function ListTable({ search, selectedRow, onSelectRow, title }: L
|
|||
]}
|
||||
selectedRow={selectedRow}
|
||||
onSelectRow={({ id }) => handleOnSelectRow(id)}
|
||||
enableSearch
|
||||
tagEntity="lists"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
194
package-lock.json
generated
|
@ -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": {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue