mirror of
https://fast.feibisi.com/https://github.com/parcelvoy/platform.git
synced 2025-09-04 12:56:14 +08:00
Merge pull request #73 from parcelvoy/feat/admin-ui-misc
Feat/admin UI misc
This commit is contained in:
commit
e86e0c37bf
25 changed files with 507 additions and 159 deletions
|
@ -83,7 +83,7 @@ export default class Model {
|
|||
}
|
||||
|
||||
static query<T extends typeof Model>(this: T, db: Database = App.main.db): Database.QueryBuilder<InstanceType<T>> {
|
||||
return this.table(db)
|
||||
return this.table(db).clearSelect()
|
||||
}
|
||||
|
||||
static async first<T extends typeof Model>(
|
||||
|
@ -166,7 +166,7 @@ export default class Model {
|
|||
query: Query = qb => qb,
|
||||
db: Database = App.main.db,
|
||||
) {
|
||||
let { page, itemsPerPage, sort, q } = params
|
||||
let { page, itemsPerPage, sort, q, id } = params
|
||||
return await this.search(
|
||||
b => {
|
||||
b = query(b)
|
||||
|
@ -193,6 +193,9 @@ export default class Model {
|
|||
}
|
||||
b.orderBy(sort, desc ? 'desc' : 'asc')
|
||||
}
|
||||
if (id?.length) {
|
||||
b.whereIn('id', id)
|
||||
}
|
||||
return b
|
||||
},
|
||||
page,
|
||||
|
@ -274,7 +277,7 @@ export default class Model {
|
|||
}
|
||||
|
||||
static table(db: Database = App.main.db): Database.QueryBuilder<any> {
|
||||
return db(this.tableName)
|
||||
return db(this.tableName).select(`${this.tableName}.*`)
|
||||
}
|
||||
|
||||
static raw = raw
|
||||
|
|
|
@ -6,6 +6,7 @@ export interface SearchParams {
|
|||
q?: string
|
||||
sort?: string
|
||||
tag?: string[]
|
||||
id?: number[]
|
||||
}
|
||||
|
||||
export const searchParamsSchema: JSONSchemaType<SearchParams> = {
|
||||
|
@ -38,5 +39,13 @@ export const searchParamsSchema: JSONSchemaType<SearchParams> = {
|
|||
},
|
||||
nullable: true,
|
||||
},
|
||||
id: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
},
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -60,7 +60,7 @@ export default class JourneyService {
|
|||
if (await nextStep.hasCompleted(user)) {
|
||||
nextStep = await nextStep.next(user)
|
||||
} else if (await nextStep.condition(user, event)) {
|
||||
await nextStep.complete(user)
|
||||
await nextStep.complete(user, event)
|
||||
nextStep = await nextStep.next(user)
|
||||
} else {
|
||||
nextStep = null
|
||||
|
|
|
@ -1,26 +1,34 @@
|
|||
import Project from '../../projects/Project'
|
||||
import { User } from '../../users/User'
|
||||
import { UserEvent } from '../../users/UserEvent'
|
||||
import Journey from '../Journey'
|
||||
import { lastJourneyStep, setJourneyStepMap } from '../JourneyRepository'
|
||||
import JourneyService from '../JourneyService'
|
||||
import { JourneyEntrance, JourneyUpdate } from '../JourneyStep'
|
||||
|
||||
describe('Run', () => {
|
||||
describe('step progression', () => {
|
||||
test('user should be taken to action 2 or 3', async () => {
|
||||
|
||||
const baseStep = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
data: {},
|
||||
}
|
||||
|
||||
const setup = async () => {
|
||||
const project = await Project.insertAndFetch({
|
||||
name: `Test Project ${Date.now()}`,
|
||||
})
|
||||
|
||||
const journey = await Journey.insertAndFetch({
|
||||
project_id: project.id,
|
||||
name: `Test Journey ${Date.now()}`,
|
||||
})
|
||||
return { project, journey }
|
||||
}
|
||||
|
||||
const baseStep = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
}
|
||||
test('user should be taken to action 2 or 3', async () => {
|
||||
|
||||
const { project, journey } = await setup()
|
||||
|
||||
// entrance -> gate -> (action1 | experiment -> (action2 | action3))
|
||||
const { steps } = await setJourneyStepMap(journey.id, {
|
||||
|
@ -114,5 +122,69 @@ describe('Run', () => {
|
|||
const lastStep = await lastJourneyStep(user.id, journey.id)
|
||||
expect(actionIds).toContain(lastStep?.step_id)
|
||||
})
|
||||
|
||||
test('user update step adds data to profile', async () => {
|
||||
|
||||
const { project, journey } = await setup()
|
||||
|
||||
const { steps } = await setJourneyStepMap(journey.id, {
|
||||
entrance: {
|
||||
...baseStep,
|
||||
type: JourneyEntrance.type,
|
||||
children: [
|
||||
{
|
||||
external_id: 'update',
|
||||
},
|
||||
],
|
||||
},
|
||||
update: {
|
||||
...baseStep,
|
||||
type: JourneyUpdate.type,
|
||||
data: {
|
||||
template: `
|
||||
{
|
||||
"field2": 2,
|
||||
"fromUser": {
|
||||
"prevField2": "{{user.field2}}"
|
||||
},
|
||||
"fromEvent": "{{event.name}}"
|
||||
}
|
||||
`,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const user = await User.insertAndFetch({
|
||||
project_id: project.id,
|
||||
external_id: '2',
|
||||
email: 'test3@twochris.com',
|
||||
data: {
|
||||
field1: 1,
|
||||
field2: 'two',
|
||||
},
|
||||
})
|
||||
|
||||
const event = await UserEvent.insertAndFetch({
|
||||
project_id: project.id,
|
||||
user_id: user.id,
|
||||
name: 'signin',
|
||||
data: {
|
||||
project: 'Parcelvoy',
|
||||
},
|
||||
})
|
||||
|
||||
const service = new JourneyService(journey.id)
|
||||
|
||||
await service.run(user, event)
|
||||
|
||||
const updateStep = steps.find(s => s.external_id === 'update')!
|
||||
const lastStep = await lastJourneyStep(user.id, journey.id)
|
||||
expect(updateStep).toBeDefined()
|
||||
expect(lastStep?.step_id).toBe(updateStep.id)
|
||||
expect(user.data.field1).toBe(1)
|
||||
expect(user.data.field2).toBe(2)
|
||||
expect(user.data.fromUser.prevField2).toBe('two')
|
||||
expect(user.data.fromEvent).toEqual('signin')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -30,7 +30,7 @@
|
|||
"react-popper": "^2.3.0",
|
||||
"react-router-dom": "^6.4.2",
|
||||
"react-scripts": "5.0.1",
|
||||
"reactflow": "^11.3.3",
|
||||
"reactflow": "11.5.6",
|
||||
"uuid": "^9.0.0",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
|
|
|
@ -46,7 +46,7 @@ type OmitFields = 'id' | 'created_at' | 'updated_at' | 'deleted_at'
|
|||
|
||||
export interface EntityApi<T> {
|
||||
basePath: string
|
||||
search: (params: SearchParams) => Promise<SearchResult<T>>
|
||||
search: (params: Partial<SearchParams>) => Promise<SearchResult<T>>
|
||||
create: (params: Omit<T, OmitFields>) => Promise<T>
|
||||
get: (id: number | string) => Promise<T>
|
||||
update: (id: number | string, params: Omit<T, OmitFields>) => Promise<T>
|
||||
|
@ -106,6 +106,12 @@ function createProjectEntityPath<T, C = Omit<T, OmitFields>, U = Omit<T, OmitFie
|
|||
}
|
||||
}
|
||||
|
||||
const cache: {
|
||||
profile: null | Admin
|
||||
} = {
|
||||
profile: null,
|
||||
}
|
||||
|
||||
const api = {
|
||||
|
||||
login() {
|
||||
|
@ -117,7 +123,12 @@ const api = {
|
|||
},
|
||||
|
||||
profile: {
|
||||
get: async () => await client.get<Admin>('/admin/profile').then(r => r.data),
|
||||
get: async () => {
|
||||
if (!cache.profile) {
|
||||
cache.profile = await client.get<Admin>('/admin/profile').then(r => r.data)
|
||||
}
|
||||
return cache.profile!
|
||||
},
|
||||
},
|
||||
|
||||
admins: createEntityPath<Admin>('/admin/admins'),
|
||||
|
|
|
@ -89,11 +89,6 @@ textarea {
|
|||
|
||||
input:hover, textarea:hover {
|
||||
border-color: var(--color-grey-hard);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
form .form-submit {
|
||||
|
|
|
@ -82,6 +82,7 @@ export interface SearchParams {
|
|||
itemsPerPage: number
|
||||
q: string
|
||||
tag?: string[]
|
||||
id?: Array<number | string>
|
||||
}
|
||||
|
||||
export interface SearchResult<T> {
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
import { useMemo, useState, useEffect, Dispatch, PropsWithChildren, createContext, SetStateAction } from 'react'
|
||||
import { Preferences } from '../types'
|
||||
import { localStorageAssign, localStorageSet } from '../utils'
|
||||
import { localStorageGetJson, localStorageSetJson } from '../utils'
|
||||
|
||||
const PREFERENCES = 'preferences'
|
||||
|
||||
const initial = localStorageAssign<Preferences>(PREFERENCES, {
|
||||
const initial: Preferences = {
|
||||
...localStorageGetJson<Preferences>(PREFERENCES) ?? {},
|
||||
lang: window.navigator.language,
|
||||
mode: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
})
|
||||
}
|
||||
|
||||
export const PreferencesContext = createContext<readonly [Preferences, Dispatch<SetStateAction<Preferences>>]>([
|
||||
initial,
|
||||
|
@ -35,7 +36,7 @@ export function PreferencesProvider({ children }: PropsWithChildren<{}>) {
|
|||
|
||||
useEffect(() => {
|
||||
document.body.setAttribute('data-theme', preferences.mode === 'dark' ? 'dark' : 'light')
|
||||
localStorageSet(PREFERENCES, preferences)
|
||||
localStorageSetJson(PREFERENCES, preferences)
|
||||
}, [preferences])
|
||||
|
||||
return (
|
||||
|
|
|
@ -6,6 +6,7 @@ import { TagPicker } from '../views/settings/TagPicker'
|
|||
import { DataTable, DataTableProps } from './DataTable'
|
||||
import TextField from './form/TextField'
|
||||
import Heading from './Heading'
|
||||
import { SearchIcon } from './icons'
|
||||
import Pagination from './Pagination'
|
||||
import Stack from './Stack'
|
||||
|
||||
|
@ -131,6 +132,7 @@ export function SearchTable<T extends Record<string, any>>({
|
|||
value={search}
|
||||
placeholder="Search..."
|
||||
onChange={setSearch}
|
||||
icon={<SearchIcon />}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -2,15 +2,16 @@ import './Sidebar.css'
|
|||
import NavLink from './NavLink'
|
||||
import { ReactComponent as Logo } from '../assets/logo.svg'
|
||||
import { Link, NavLinkProps, useNavigate } from 'react-router-dom'
|
||||
import { PropsWithChildren, ReactNode, useContext } from 'react'
|
||||
import { PropsWithChildren, ReactNode, useCallback, useContext } from 'react'
|
||||
import { AdminContext, ProjectContext } from '../contexts'
|
||||
import api from '../api'
|
||||
import { PreferencesContext } from './PreferencesContext'
|
||||
import { useResolver } from '../hooks'
|
||||
import { SingleSelect } from './form/SingleSelect'
|
||||
import Button, { LinkButton } from './Button'
|
||||
import Button from './Button'
|
||||
import ButtonGroup from './ButtonGroup'
|
||||
import { MoonIcon, PlusIcon, SunIcon } from './icons'
|
||||
import { MoonIcon, SunIcon } from './icons'
|
||||
import { getRecentProjects } from '../utils'
|
||||
|
||||
interface SidebarProps {
|
||||
links?: Array<NavLinkProps & { key: string, icon: ReactNode }>
|
||||
|
@ -21,7 +22,23 @@ export default function Sidebar({ children, links }: PropsWithChildren<SidebarPr
|
|||
const profile = useContext(AdminContext)
|
||||
const [project] = useContext(ProjectContext)
|
||||
const [preferences, setPreferences] = useContext(PreferencesContext)
|
||||
const [projects] = useResolver(api.projects.all)
|
||||
const [recents] = useResolver(useCallback(async () => {
|
||||
const recentIds = getRecentProjects().filter(p => p.id !== project.id).map(p => p.id)
|
||||
return [
|
||||
project,
|
||||
...recentIds.length
|
||||
? await api.projects.search({
|
||||
page: 0,
|
||||
itemsPerPage: recentIds.length,
|
||||
id: recentIds,
|
||||
}).then(r => r.results ?? [])
|
||||
: [],
|
||||
{
|
||||
id: 0,
|
||||
name: 'View All',
|
||||
},
|
||||
]
|
||||
}, [project]))
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -33,8 +50,14 @@ export default function Sidebar({ children, links }: PropsWithChildren<SidebarPr
|
|||
</div>
|
||||
<SingleSelect
|
||||
value={project}
|
||||
onChange={project => navigate(`/projects/${project.id}`)}
|
||||
options={projects ?? [project]}
|
||||
onChange={project => {
|
||||
if (project.id === 0) {
|
||||
navigate('/')
|
||||
} else {
|
||||
navigate(`/projects/${project.id}`)
|
||||
}
|
||||
}}
|
||||
options={recents ?? []}
|
||||
getSelectedOptionDisplay={p => (
|
||||
<>
|
||||
<div className="project-switcher-label">Project</div>
|
||||
|
@ -44,19 +67,6 @@ export default function Sidebar({ children, links }: PropsWithChildren<SidebarPr
|
|||
hideLabel
|
||||
buttonClassName="project-switcher"
|
||||
variant="minimal"
|
||||
optionsFooter={
|
||||
<div
|
||||
style={{
|
||||
borderTop: '1px solid var(--color-grey)',
|
||||
paddingTop: '10px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<LinkButton size="small" variant="primary" to="/projects/new" icon={<PlusIcon />}>
|
||||
{'Create Project'}
|
||||
</LinkButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<nav>
|
||||
{
|
||||
|
|
|
@ -10,6 +10,9 @@
|
|||
padding: 20px;
|
||||
transition: box-shadow ease-in .1s;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ui-tile:hover {
|
||||
|
@ -20,6 +23,23 @@
|
|||
border: 1px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.ui-tile p {
|
||||
margin: 5px 0;
|
||||
.ui-tile img {
|
||||
border-radius: var(--border-radius);
|
||||
width: 100%;
|
||||
max-width: 40px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ui-tile-text {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.ui-tile h5 {
|
||||
margin: 0 0 5px;
|
||||
}
|
||||
|
||||
.ui-tile p {
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
color: var(--color-primary-soft);
|
||||
}
|
||||
|
|
|
@ -1,14 +1,45 @@
|
|||
import clsx from 'clsx'
|
||||
import { PropsWithChildren } from 'react'
|
||||
import { PropsWithChildren, ReactNode } from 'react'
|
||||
import './Tile.css'
|
||||
|
||||
type TileProps = PropsWithChildren<{
|
||||
onClick?: () => void
|
||||
selected?: boolean
|
||||
iconUrl?: string
|
||||
title: ReactNode
|
||||
}> & JSX.IntrinsicElements['div']
|
||||
|
||||
export default function Tile({ onClick, selected = false, children, ...rest }: TileProps) {
|
||||
return <div {...rest} className={clsx(rest.className, 'ui-tile', { selected })} onClick={onClick} tabIndex={0}>{children}</div>
|
||||
export default function Tile({
|
||||
onClick,
|
||||
selected = false,
|
||||
children,
|
||||
className,
|
||||
iconUrl,
|
||||
title,
|
||||
...rest
|
||||
}: TileProps) {
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
className={clsx(className, 'ui-tile', { selected })}
|
||||
onClick={onClick}
|
||||
tabIndex={0}
|
||||
>
|
||||
{
|
||||
iconUrl && (
|
||||
<img
|
||||
src={iconUrl}
|
||||
className="ui-tile-icon"
|
||||
aria-hidden
|
||||
/>
|
||||
)
|
||||
}
|
||||
<div className="ui-tile-text">
|
||||
<h5>{title}</h5>
|
||||
<p>{children}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TileGridProps extends PropsWithChildren {
|
||||
|
|
17
apps/ui/src/ui/form/TextField.css
Normal file
17
apps/ui/src/ui/form/TextField.css
Normal file
|
@ -0,0 +1,17 @@
|
|||
.ui-text-field-icon-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ui-text-field-icon-wrapper input,
|
||||
.ui-text-field-icon-wrapper textarea {
|
||||
padding-left: 36px;
|
||||
}
|
||||
|
||||
.ui-text-field-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 12px;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
transform: translate(0, -50%);
|
||||
}
|
|
@ -1,8 +1,9 @@
|
|||
import clsx from 'clsx'
|
||||
import { useId, Ref } from 'react'
|
||||
import { useId, Ref, ReactNode } from 'react'
|
||||
import { FieldPath, FieldValues } from 'react-hook-form'
|
||||
import { snakeToTitle } from '../../utils'
|
||||
import { FieldProps } from './Field'
|
||||
import './TextField.css'
|
||||
|
||||
export interface TextFieldProps<X extends FieldValues, P extends FieldPath<X>> extends FieldProps<X, P> {
|
||||
type?: 'text' | 'time' | 'date' | 'datetime-local' | 'number'
|
||||
|
@ -16,6 +17,7 @@ export interface TextFieldProps<X extends FieldValues, P extends FieldPath<X>> e
|
|||
inputRef?: Ref<HTMLLabelElement>
|
||||
min?: number
|
||||
max?: number
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
export default function TextField<X extends FieldValues, P extends FieldPath<X>>({
|
||||
|
@ -36,6 +38,7 @@ export default function TextField<X extends FieldValues, P extends FieldPath<X>>
|
|||
onFocus,
|
||||
placeholder,
|
||||
inputRef,
|
||||
icon,
|
||||
}: TextFieldProps<X, P>) {
|
||||
const id = useId()
|
||||
const formParams = form?.register(name, { disabled, required })
|
||||
|
@ -52,40 +55,49 @@ export default function TextField<X extends FieldValues, P extends FieldPath<X>>
|
|||
)
|
||||
}
|
||||
{subtitle && <span className="label-subtitle">{subtitle}</span>}
|
||||
{
|
||||
textarea
|
||||
? (
|
||||
<textarea
|
||||
value={value}
|
||||
{...formParams}
|
||||
onChange={(event) => onChange?.(event?.target.value)}
|
||||
onBlur={async (event) => {
|
||||
await onBlur?.(event)
|
||||
onInputBlur?.(event)
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
id={id}
|
||||
/>
|
||||
<div className={clsx(icon && 'ui-text-field-icon-wrapper')}>
|
||||
{
|
||||
textarea
|
||||
? (
|
||||
<textarea
|
||||
value={value}
|
||||
{...formParams}
|
||||
onChange={(event) => onChange?.(event?.target.value)}
|
||||
onBlur={async (event) => {
|
||||
await onBlur?.(event)
|
||||
onInputBlur?.(event)
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
id={id}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
className={size}
|
||||
placeholder={placeholder}
|
||||
{...formParams}
|
||||
onChange={(event) => onChange?.(event?.target.value)}
|
||||
onBlur={async (event) => {
|
||||
await onBlur?.(event)
|
||||
onInputBlur?.(event)
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
id={id}
|
||||
min={min}
|
||||
max={max}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
icon && (
|
||||
<span className="ui-text-field-icon">
|
||||
{icon}
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
className={size}
|
||||
placeholder={placeholder}
|
||||
{...formParams}
|
||||
onChange={(event) => onChange?.(event?.target.value)}
|
||||
onBlur={async (event) => {
|
||||
await onBlur?.(event)
|
||||
onInputBlur?.(event)
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
id={id}
|
||||
min={min}
|
||||
max={max}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -151,3 +151,9 @@ export const GateStepIcon = () => (
|
|||
<path fillRule="evenodd" d="M6 3.5A1.5 1.5 0 0 1 7.5 2h1A1.5 1.5 0 0 1 10 3.5v1A1.5 1.5 0 0 1 8.5 6v1H11a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-1 0V8h-5v.5a.5.5 0 0 1-1 0v-1A.5.5 0 0 1 5 7h2.5V6A1.5 1.5 0 0 1 6 4.5v-1zm-3 8A1.5 1.5 0 0 1 4.5 10h1A1.5 1.5 0 0 1 7 11.5v1A1.5 1.5 0 0 1 5.5 14h-1A1.5 1.5 0 0 1 3 12.5v-1zm6 0a1.5 1.5 0 0 1 1.5-1.5h1a1.5 1.5 0 0 1 1.5 1.5v1a1.5 1.5 0 0 1-1.5 1.5h-1A1.5 1.5 0 0 1 9 12.5v-1z"/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const SearchIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
|
||||
</svg>
|
||||
)
|
||||
|
|
|
@ -35,19 +35,18 @@ export function combine(...parts: Array<string | number>) {
|
|||
return parts.filter(item => item != null).join(' ')
|
||||
}
|
||||
|
||||
export function localStorageAssign<T extends object>(key: string, o: T) {
|
||||
export function localStorageGetJson<T extends object>(key: string) {
|
||||
try {
|
||||
const stored = localStorage.getItem(key)
|
||||
if (stored) {
|
||||
Object.assign(o, JSON.parse(stored))
|
||||
return JSON.parse(stored) as T
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(err)
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
export function localStorageSet<T extends object>(key: string, o: T) {
|
||||
export function localStorageSetJson<T extends object>(key: string, o: T) {
|
||||
localStorage.setItem(key, JSON.stringify(o))
|
||||
}
|
||||
|
||||
|
@ -118,3 +117,43 @@ export function groupBy<T>(arr: T[], fn: (item: T) => any) {
|
|||
export function groupByKey<T>(arr: T[], key: keyof T) {
|
||||
return groupBy(arr, (item) => item[key])
|
||||
}
|
||||
|
||||
export function arrayMove<T>(arr: T[], currentIndex: number, targetIndex: number) {
|
||||
if (targetIndex >= arr.length) {
|
||||
let k = targetIndex - arr.length + 1
|
||||
while (k--) {
|
||||
(arr as any).push(undefined)
|
||||
}
|
||||
}
|
||||
arr.splice(targetIndex, 0, arr.splice(currentIndex, 1)[0])
|
||||
return arr
|
||||
}
|
||||
|
||||
const RECENT_PROJECTS = 'recent-projects'
|
||||
|
||||
type RecentProjects = Array<{
|
||||
id: number
|
||||
when: number
|
||||
}>
|
||||
|
||||
export function getRecentProjects() {
|
||||
return (localStorageGetJson<RecentProjects>(RECENT_PROJECTS) ?? [])
|
||||
}
|
||||
|
||||
export function pushRecentProject(id: number | string) {
|
||||
const stored = getRecentProjects()
|
||||
const idx = stored.findIndex(p => p.id === id)
|
||||
if (idx !== -1) {
|
||||
arrayMove(stored, idx, 0)
|
||||
} else {
|
||||
stored.unshift({
|
||||
id: typeof id === 'string' ? parseInt(id, 10) : id,
|
||||
when: Date.now(),
|
||||
})
|
||||
}
|
||||
while (stored.length > 3) {
|
||||
stored.pop()
|
||||
}
|
||||
localStorageSetJson(RECENT_PROJECTS, stored)
|
||||
return stored
|
||||
}
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import { useSearchParams } from 'react-router-dom'
|
||||
import { ReactComponent as Logo } from '../../assets/logo.svg'
|
||||
import { env } from '../../config/env'
|
||||
import Button from '../../ui/Button'
|
||||
import './Auth.css'
|
||||
|
||||
export default function Login() {
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const handleRedirect = () => {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
window.location.href = `${env.api.baseURL}/auth/login?r=${urlParams.get('r')}`
|
||||
window.location.href = `${env.api.baseURL}/auth/login?r=${searchParams.get('r') ?? '/'}`
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,11 +1,21 @@
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import ProjectForm from '../project/ProjectForm'
|
||||
|
||||
export default function OnboardingProject() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<>
|
||||
<h1>Project Setup</h1>
|
||||
<p>At Parcelvoy, projects represent a single workspace for sending messages. You can use them for creating staging environments, isolating different clients, etc. Let's create your first one to get you started!</p>
|
||||
<ProjectForm />
|
||||
<p>
|
||||
{
|
||||
`At Parcelvoy, projects represent a single workspace for sending messages.
|
||||
You can use them for creating staging environments, isolating different clients, etc.
|
||||
Let's create your first one to get you started!`
|
||||
}
|
||||
</p>
|
||||
<ProjectForm
|
||||
onSave={({ id }) => navigate('/projects/' + id)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '../../api'
|
||||
import TextField from '../../ui/form/TextField'
|
||||
import { ProjectCreate } from '../../types'
|
||||
import { Project } from '../../types'
|
||||
import FormWrapper from '../../ui/form/FormWrapper'
|
||||
import { SingleSelect } from '../../ui/form/SingleSelect'
|
||||
|
||||
|
@ -11,16 +10,19 @@ export declare namespace Intl {
|
|||
function supportedValuesOf(input: Key): string[]
|
||||
}
|
||||
|
||||
export default function ProjectForm() {
|
||||
const navigate = useNavigate()
|
||||
interface ProjectFormProps {
|
||||
onSave?: (project: Project) => void
|
||||
}
|
||||
|
||||
export default function ProjectForm({ onSave }: ProjectFormProps) {
|
||||
const timeZones = Intl.supportedValuesOf('timeZone')
|
||||
|
||||
return (
|
||||
<FormWrapper<ProjectCreate>
|
||||
onSubmit={async project => {
|
||||
const { id } = await api.projects.create(project)
|
||||
navigate(`/projects/${id}`)
|
||||
<FormWrapper<Project>
|
||||
onSubmit={async ({ id, name, description, locale, timezone }) => {
|
||||
const project = id
|
||||
? await api.projects.update(id, { name, description, locale, timezone })
|
||||
: await api.projects.create({ name, description, locale, timezone })
|
||||
onSave?.(project)
|
||||
}}
|
||||
>
|
||||
{
|
||||
|
|
117
apps/ui/src/views/project/Projects.tsx
Normal file
117
apps/ui/src/views/project/Projects.tsx
Normal file
|
@ -0,0 +1,117 @@
|
|||
import { useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '../../api'
|
||||
import { useResolver } from '../../hooks'
|
||||
import { Project } from '../../types'
|
||||
import Button from '../../ui/Button'
|
||||
import PageContent from '../../ui/PageContent'
|
||||
import { PreferencesContext } from '../../ui/PreferencesContext'
|
||||
import Tile, { TileGrid } from '../../ui/Tile'
|
||||
import { formatDate, getRecentProjects } from '../../utils'
|
||||
import logoUrl from '../../assets/parcelvoylogo.png'
|
||||
import { PlusIcon } from '../../ui/icons'
|
||||
import Modal from '../../ui/Modal'
|
||||
import ProjectForm from './ProjectForm'
|
||||
|
||||
export function Projects() {
|
||||
const navigate = useNavigate()
|
||||
const [preferences] = useContext(PreferencesContext)
|
||||
const [projects] = useResolver(api.projects.all)
|
||||
const recents = useMemo(() => {
|
||||
const recents = getRecentProjects()
|
||||
if (!projects?.length || !recents.length) return []
|
||||
return recents.reduce<Array<{
|
||||
project: Project
|
||||
when: number
|
||||
}>>((a, { id, when }) => {
|
||||
const project = projects.find(p => p.id === id)
|
||||
if (project) {
|
||||
a.push({
|
||||
when,
|
||||
project,
|
||||
})
|
||||
}
|
||||
return a
|
||||
}, [])
|
||||
}, [projects])
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (projects && !projects.length) {
|
||||
navigate('onboarding')
|
||||
}
|
||||
}, [projects, navigate])
|
||||
|
||||
if (!projects) {
|
||||
return (
|
||||
<div>
|
||||
loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContent
|
||||
title="Projects"
|
||||
desc="Projects are isolated workspaces with their own sets of users, events, lists, campaigns, and journeys."
|
||||
actions={
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<PlusIcon />}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{'Create Project'}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{
|
||||
!!recents?.length && (
|
||||
<>
|
||||
<h3>Recently Visited</h3>
|
||||
<TileGrid>
|
||||
{
|
||||
recents.map(({ project, when }) => (
|
||||
<Tile
|
||||
key={project.id}
|
||||
onClick={() => navigate('/projects/' + project.id)}
|
||||
title={project.name || 'Untitled Project'}
|
||||
iconUrl={logoUrl}
|
||||
>
|
||||
{formatDate(preferences, when)}
|
||||
</Tile>
|
||||
))
|
||||
}
|
||||
</TileGrid>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<h3>All Projects</h3>
|
||||
<TileGrid>
|
||||
{
|
||||
projects?.map(project => (
|
||||
<Tile
|
||||
key={project.id}
|
||||
onClick={() => navigate('/projects/' + project.id)}
|
||||
title={project.name}
|
||||
iconUrl={logoUrl}
|
||||
>
|
||||
{formatDate(preferences, project.created_at)}
|
||||
</Tile>
|
||||
))
|
||||
}
|
||||
</TileGrid>
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={setOpen}
|
||||
title="Create Project"
|
||||
>
|
||||
<ProjectForm
|
||||
onSave={project => {
|
||||
setOpen(false)
|
||||
navigate('/projects/' + project.id)
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</PageContent>
|
||||
)
|
||||
}
|
|
@ -2,7 +2,6 @@ import { createBrowserRouter, Outlet, redirect, useNavigate, useParams } from 'r
|
|||
import api from '../api'
|
||||
|
||||
import ErrorPage from './ErrorPage'
|
||||
import ProjectForm from './project/ProjectForm'
|
||||
import Sidebar from '../ui/Sidebar'
|
||||
import { LoaderContextProvider, StatefulLoaderContextProvider } from './LoaderContextProvider'
|
||||
import { AdminContext, CampaignContext, JourneyContext, ListContext, ProjectContext, UserContext } from '../contexts'
|
||||
|
@ -37,6 +36,8 @@ import OnboardingStart from './auth/OnboardingStart'
|
|||
import Onboarding from './auth/Onboarding'
|
||||
import OnboardingProject from './auth/OnboardingProject'
|
||||
import { CampaignsIcon, JourneysIcon, ListsIcon, SettingsIcon, UsersIcon } from '../ui/icons'
|
||||
import { Projects } from './project/Projects'
|
||||
import { pushRecentProject } from '../utils'
|
||||
|
||||
export const useRoute = (includeProject = true) => {
|
||||
const { projectId = '' } = useParams()
|
||||
|
@ -56,20 +57,6 @@ export const router = createBrowserRouter([
|
|||
path: '/login',
|
||||
element: <Login />,
|
||||
},
|
||||
{
|
||||
path: '/onboarding',
|
||||
element: <Onboarding />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <OnboardingStart />,
|
||||
},
|
||||
{
|
||||
path: 'project',
|
||||
element: <OnboardingProject />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
errorElement: <ErrorPage />,
|
||||
|
@ -82,31 +69,29 @@ export const router = createBrowserRouter([
|
|||
children: [
|
||||
{
|
||||
index: true,
|
||||
loader: async () => {
|
||||
const latest = localStorage.getItem('last-project')
|
||||
if (latest) {
|
||||
return redirect(`projects/${latest}`)
|
||||
}
|
||||
const projects = await api.projects.all()
|
||||
if (projects.length) {
|
||||
return redirect(`projects/${projects[0].id}`)
|
||||
}
|
||||
return redirect('projects/new')
|
||||
},
|
||||
element: (
|
||||
<Projects />
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'projects/new',
|
||||
element: (
|
||||
<PageContent title="Create Project">
|
||||
<ProjectForm />
|
||||
</PageContent>
|
||||
),
|
||||
path: 'onboarding',
|
||||
element: <Onboarding />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <OnboardingStart />,
|
||||
},
|
||||
{
|
||||
path: 'project',
|
||||
element: <OnboardingProject />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'projects/:projectId',
|
||||
loader: async ({ params: { projectId = '' } }) => {
|
||||
const project = await api.projects.get(projectId)
|
||||
localStorage.setItem('last-project', projectId)
|
||||
pushRecentProject(project.id)
|
||||
return project
|
||||
},
|
||||
element: (
|
||||
|
|
|
@ -47,14 +47,12 @@ export default function IntegrationModal({ onChange, provider, ...props }: Integ
|
|||
<TileGrid>
|
||||
{options?.map(option => (
|
||||
<Tile
|
||||
className="provider-tile"
|
||||
key={`${option.channel}${option.type}`}
|
||||
onClick={() => setMeta(option)}>
|
||||
{option.icon && <img src={option.icon} />}
|
||||
<div className="tile-text">
|
||||
<h5>{option.name}</h5>
|
||||
<p>{snakeToTitle(option.channel)}</p>
|
||||
</div>
|
||||
title={option.name}
|
||||
onClick={() => setMeta(option)}
|
||||
iconUrl={option.icon}
|
||||
>
|
||||
{snakeToTitle(option.channel)}
|
||||
</Tile>
|
||||
))}
|
||||
</TileGrid>
|
||||
|
|
|
@ -5,35 +5,42 @@ import { Project } from '../../types'
|
|||
import TextField from '../../ui/form/TextField'
|
||||
import FormWrapper from '../../ui/form/FormWrapper'
|
||||
import Heading from '../../ui/Heading'
|
||||
import { toast } from 'react-hot-toast'
|
||||
|
||||
export default function ProjectSettings() {
|
||||
const [project, setProject] = useContext(ProjectContext)
|
||||
|
||||
async function handleSaveProject({ name, description, locale, timezone }: Project) {
|
||||
const value = await api.projects.update(project.id, { name, description, locale, timezone })
|
||||
setProject(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading size="h3" title="General" />
|
||||
<FormWrapper<Project>
|
||||
onSubmit={handleSaveProject}
|
||||
onSubmit={async ({ name, description, locale, timezone }) => {
|
||||
const value = await api.projects.update(project.id, { name, description, locale, timezone })
|
||||
setProject(value)
|
||||
toast.success('Saved project settings')
|
||||
}}
|
||||
defaultValues={project}
|
||||
submitLabel="Save">
|
||||
{form => <>
|
||||
<TextField form={form} name="name" required />
|
||||
<TextField form={form} name="description" textarea />
|
||||
<TextField form={form}
|
||||
name="locale"
|
||||
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 />
|
||||
<TextField form={form}
|
||||
name="timezone"
|
||||
label="Timezone"
|
||||
required />
|
||||
</>}
|
||||
submitLabel="Save"
|
||||
>
|
||||
{
|
||||
form => (
|
||||
<>
|
||||
<TextField form={form} name="name" required />
|
||||
<TextField form={form} name="description" textarea />
|
||||
<TextField form={form}
|
||||
name="locale"
|
||||
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
|
||||
/>
|
||||
<TextField form={form}
|
||||
name="timezone"
|
||||
label="Timezone"
|
||||
required
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</FormWrapper>
|
||||
</>
|
||||
)
|
||||
|
|
3
package-lock.json
generated
3
package-lock.json
generated
|
@ -15,7 +15,6 @@
|
|||
"apps/platform": {
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@apideck/better-ajv-errors": "^0.3.6",
|
||||
"@aws-sdk/client-s3": "^3.171.0",
|
||||
"@aws-sdk/client-ses": "^3.121.0",
|
||||
"@aws-sdk/client-sns": "^3.121.0",
|
||||
|
@ -123,7 +122,7 @@
|
|||
"react-popper": "^2.3.0",
|
||||
"react-router-dom": "^6.4.2",
|
||||
"react-scripts": "5.0.1",
|
||||
"reactflow": "^11.3.3",
|
||||
"reactflow": "11.5.6",
|
||||
"uuid": "^9.0.0",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue