Merge pull request #73 from parcelvoy/feat/admin-ui-misc

Feat/admin UI misc
This commit is contained in:
Chris Hills 2023-03-12 16:24:45 -05:00 committed by GitHub
commit e86e0c37bf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 507 additions and 159 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -82,6 +82,7 @@ export interface SearchParams {
itemsPerPage: number
q: string
tag?: string[]
id?: Array<number | string>
}
export interface SearchResult<T> {

View file

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

View file

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

View file

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

View file

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

View file

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

View 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%);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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&apos;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&apos;s create your first one to get you started!`
}
</p>
<ProjectForm
onSave={({ id }) => navigate('/projects/' + id)}
/>
</>
)
}

View file

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

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

View file

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

View file

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

View file

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

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