update sidebar to use select field, update select field api (#60)

This commit is contained in:
chrishills 2023-03-03 08:33:26 -06:00 committed by GitHub
parent 5b0b0adb5a
commit b091431574
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 297 additions and 236 deletions

View file

@ -28,7 +28,7 @@ export const createJourney = async (projectId: number, params: JourneyParams): P
}, trx)
// auto-create entrance step
await JourneyEntrance.create(journey.id)
await JourneyEntrance.create(journey.id, undefined, trx)
return journey
})

View file

@ -10,6 +10,7 @@ import { random, snakeCase, uuid } from '../utilities'
import App from '../app'
import JourneyProcessJob from './JourneyProcessJob'
import jsonpath from 'jsonpath'
import { Database } from '../config/database'
export class JourneyUserStep extends Model {
user_id!: number
@ -98,7 +99,7 @@ export class JourneyEntrance extends JourneyStep {
this.list_id = json?.data.list_id
}
static async create(journeyId: number, listId?: number): Promise<JourneyEntrance> {
static async create(journeyId: number, listId?: number, db?: Database): Promise<JourneyEntrance> {
return await JourneyEntrance.insertAndFetch({
type: this.type,
external_id: uuid(),
@ -108,7 +109,7 @@ export class JourneyEntrance extends JourneyStep {
},
x: 0,
y: 0,
})
}, db)
}
}

View file

@ -1,4 +1,5 @@
import { ComponentType, Dispatch, ReactNode, SetStateAction } from 'react'
import { ComponentType, Dispatch, Key, ReactNode, SetStateAction } from 'react'
import { FieldPath, FieldValues, UseFormReturn } from 'react-hook-form'
export type Class<T> = new () => T
@ -12,10 +13,26 @@ export interface CommonInputProps {
disabled?: boolean
label?: ReactNode
subtitle?: ReactNode
hideLabel?: boolean
error?: ReactNode
}
export type ControlledInputProps<T> = ControlledProps<T> & CommonInputProps
export interface FieldProps<X extends FieldValues, P extends FieldPath<X>> extends CommonInputProps {
form: UseFormReturn<X>
name: P
}
export type FieldBindingsProps<I extends ControlledInputProps<T>, T, X extends FieldValues, P extends FieldPath<X>> = Omit<I, keyof ControlledProps<T>> & FieldProps<X, P>
export interface OptionsProps<O, V = O> {
options: O[]
toValue?: (option: O) => V
getValueKey?: (option: V) => Key
getOptionDisplay?: (option: O) => ReactNode
}
export type UseStateContext<T> = [T, Dispatch<SetStateAction<T>>]
export interface OAuthResponse {

View file

@ -7,6 +7,7 @@
border-right: 1px solid var(--color-grey);
display: flex;
flex-direction: column;
z-index: 1;
}
.sidebar + * {
@ -28,95 +29,29 @@
}
.sidebar-header .logo svg {
width: 30px;
height: 30px;
flex-shrink: 0;
fill: var(--color-primary);
}
.sidebar-header .project-switcher {
display: flex;
flex-direction: column;
flex-grow: 1;
.sidebar .project-switcher.select-button {
padding: 15px 20px;
border-radius: 0;
border-width: 0 0 1px;
border-color: var(--color-grey);
}
.project-switcher .switcher-button {
border: 0px;
background: var(--color-background);
display: flex;
text-align: left;
align-items: center;
padding: 0;
margin: 0;
cursor: pointer;
}
.project-switcher .switcher-button .switcher-text {
flex-grow: 1;
}
.project-switcher .switcher-button span {
display: block;
}
.project-switcher .switcher-label {
.project-switcher-label {
color: var(--color-primary-soft);
font-size: 12px;
}
.project-switcher .switcher-value {
.project-switcher-value {
color: var(--color-primary);
font-size: 14px;
font-weight: 500;
}
.project-switcher .project-switcher-icon {
color: var(--color-primary);
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
.project-switcher .switcher-options {
position: absolute;
background: var(--color-background);
border-radius: 0 0 var(--border-radius) var(--border-radius);
box-shadow: 0px 10px 20px rgba(0,0,0,0.1);
padding: 0;
width: 100%;
left: 0px;
margin: 0;
top: 100%;
outline: none;
border: 1px solid var(--color-grey);
}
.project-switcher .switcher-options .switcher-option {
display: flex;
border-bottom: 1px solid var(--color-grey);
padding: 15px 20px;
justify-content: space-between;
cursor: pointer;
}
.project-switcher .switcher-options .switcher-option:hover:not(.disabled) {
background: var(--color-background-soft);
}
.project-switcher .switcher-options .switcher-option:last-child {
border-bottom: 0px;
}
.project-switcher .switcher-options .switcher-option svg {
width: 1rem;
height: 1rem;
flex-shrink: 0;
display: none;
}
.project-switcher .switcher-options .switcher-option.selected svg {
display: inline-block;
}
nav {
padding: 20px;
flex-grow: 1;

View file

@ -1,15 +1,15 @@
import './Sidebar.css'
import NavLink from './NavLink'
import { ReactComponent as Logo } from '../assets/logo-icon.svg'
import { ReactComponent as Logo } from '../assets/logo.svg'
import { Link, NavLinkProps, useNavigate } from 'react-router-dom'
import { PropsWithChildren, useCallback, useContext } from 'react'
import { PropsWithChildren, useContext } from 'react'
import { AdminContext, ProjectContext } from '../contexts'
import api from '../api'
import { PreferencesContext } from './PreferencesContext'
import { Listbox } from '@headlessui/react'
import { CheckIcon, ChevronUpDownIcon } from './icons'
import useResolver from '../hooks/useResolver'
import { LinkButton } from './Button'
import { SelectField } from './form/SelectField'
import Button, { LinkButton } from './Button'
import ButtonGroup from './ButtonGroup'
interface SidebarProps {
links?: Array<NavLinkProps & { key: string, icon: string }>
@ -20,7 +20,7 @@ export default function Sidebar({ children, links }: PropsWithChildren<SidebarPr
const profile = useContext(AdminContext)
const [project] = useContext(ProjectContext)
const [preferences, setPreferences] = useContext(PreferencesContext)
const [projects] = useResolver(useCallback(async () => await api.projects.all(), []))
const [projects] = useResolver(api.projects.all)
return (
<>
@ -29,41 +29,34 @@ export default function Sidebar({ children, links }: PropsWithChildren<SidebarPr
<Link className="logo" to='/'>
<Logo />
</Link>
<div className="project-switcher">
<Listbox
value={project}
onChange={(project) => navigate(`/projects/${project.id}`)}>
<Listbox.Button className="switcher-button">
<div className="switcher-text">
<span className="switcher-label">Project</span>
<span className="switcher-value">{project.name}</span>
</div>
<span className="project-switcher-icon">
<ChevronUpDownIcon aria-hidden="true" />
</span>
</Listbox.Button>
<Listbox.Options className="switcher-options">
{projects?.map((project) => (
<Listbox.Option
key={project.id}
value={project}
className={
({ active, selected }) => `switcher-option ${active ? 'active' : ''} ${selected ? 'selected' : ''}` }
>
<span>{project.name}</span>
<span className="option-icon">
<CheckIcon aria-hidden="true" />
</span>
</Listbox.Option>
))}
<div className="switcher-option disabled">
<LinkButton size="small" icon="plus-lg" to="/projects/new">Create New Project</LinkButton>
</div>
</Listbox.Options>
</Listbox>
</div>
</div>
<SelectField
value={project}
onChange={project => navigate(`/projects/${project.id}`)}
options={projects ?? [project]}
getSelectedOptionDisplay={p => (
<>
<div className='project-switcher-label'>Project</div>
<div className='project-switcher-value'>{p.name}</div>
</>
)}
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='plus'>
{'Create Project'}
</LinkButton>
</div>
}
/>
<nav>
{
links?.map(({ key, ...props }) => (
@ -76,13 +69,23 @@ export default function Sidebar({ children, links }: PropsWithChildren<SidebarPr
<div className="sidebar-profile">
<div className="profile-image"></div>
<span className="profile-name">{`${profile.first_name} ${profile.last_name}`}</span>
<span className="profile-role">
<button onClick={async () => await api.logout()}>
Sign Out
</button>
<button onClick={() => {
setPreferences({ ...preferences, mode: preferences.mode === 'dark' ? 'light' : 'dark' })
}}>Toggle Theme</button>
<span className='profile-role'>
<ButtonGroup>
<Button
variant='plain'
size='small'
icon={preferences.mode === 'dark' ? 'moon' : 'sun'}
onClick={() => setPreferences({ ...preferences, mode: preferences.mode === 'dark' ? 'light' : 'dark' })}
/>
<Button
variant='plain'
size='small'
icon=''
onClick={async () => await api.logout()}
>
{'Sign Out'}
</Button>
</ButtonGroup>
</span>
</div>
)

View file

@ -10,10 +10,8 @@
.ui-select .select-button {
background: var(--color-background);
appearance: none;
border: 1px solid var(--color-grey);
box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05);
border: 1px solid transparent;
padding: 12px 15px;
border-radius: var(--border-radius);
width: 100%;
margin: 0;
color: var(--color-primary);
@ -29,11 +27,21 @@
gap: 5px;
}
.ui-select .select-button:hover {
border: 1px solid var(--color-grey-hard);
.ui-select.plain .select-button {
border-color: var(--color-grey);
box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05);
border-radius: var(--border-radius);
}
.ui-select.plain .select-button:hover {
border-color: var(--color-grey-hard);
z-index: 2;
}
.ui-select.minimal .select-button:hover {
background-color: var(--color-background-soft);
}
.ui-select .select-button.small {
padding: 5px 7px;
border-radius: var(--border-radius-inner);
@ -82,7 +90,6 @@
list-style: none;
padding: 5px;
z-index: 999;
overflow: hidden;
max-height: 275px;
overflow: scroll;
}

View file

@ -1,27 +1,43 @@
import { Listbox, Transition } from '@headlessui/react'
import { Fragment, Key, useEffect, useId, useState } from 'react'
import { Fragment, ReactNode } from 'react'
import { CheckIcon, ChevronUpDownIcon } from '../icons'
import { FieldPath, FieldValues, useController } from 'react-hook-form'
import { FieldOption, FieldProps } from './Field'
import './SelectField.css'
import { usePopperSelectDropdown } from '../utils'
import { defaultGetOptionDisplay, defaultGetValueKey, usePopperSelectDropdown } from '../utils'
import { ControlledInputProps, FieldBindingsProps, OptionsProps } from '../../types'
import clsx from 'clsx'
interface OptionFieldProps<X extends FieldValues, P extends FieldPath<X>> extends FieldProps<X, P> {
options: FieldOption[]
value?: Key
onChange?: (value: Key) => void
export interface SelectFieldProps<T, O = T> extends ControlledInputProps<T>, OptionsProps<O, T> {
className?: string
buttonClassName?: string
getSelectedOptionDisplay?: (option: O) => ReactNode
optionsFooter?: ReactNode
size?: 'small' | 'regular'
variant?: 'plain' | 'minimal'
}
export default function SelectField<X extends FieldValues, P extends FieldPath<X>>(props: OptionFieldProps<X, P>) {
const id = useId()
const { form, label, name, options, size = 'regular' } = props
let { value, onChange } = props
if (form) {
const { field } = useController({ name, control: form?.control })
value = field.value
onChange = field.onChange
}
const defaultToValue = (o: any) => o
export function SelectField<T, U = T>({
buttonClassName,
className,
disabled,
error,
getOptionDisplay = defaultGetOptionDisplay,
getSelectedOptionDisplay = getOptionDisplay,
hideLabel,
label,
options,
optionsFooter,
onChange,
required,
size,
subtitle,
toValue = defaultToValue,
getValueKey = defaultGetValueKey,
value,
variant,
}: SelectFieldProps<T, U>) {
const {
setReferenceElement,
@ -30,64 +46,113 @@ export default function SelectField<X extends FieldValues, P extends FieldPath<X
styles,
} = usePopperSelectDropdown()
// Get an internal default value based on options list
const [defaultValue, setDefaultValue] = useState<FieldOption>({ key: id, label: 'Loading...' })
useEffect(() => {
const option = options.find(item => value && item.key === value) ?? options[0]
setDefaultValue(option)
if (!value && options.length > 0) {
onChange?.(option.key)
}
}, [value, options])
const selectedOption = options.find(o => Object.is(getValueKey(toValue(o)), getValueKey(value)))
return (
<div className="ui-select">
<Listbox
value={defaultValue}
onChange={(value) => onChange?.(value.key) }
name={name}>
{label && <Listbox.Label>
<span>
{label}
{props.required && <span style={{ color: 'red' }}>&nbsp;*</span>}
<Listbox
as='div'
className={clsx('ui-select', className, variant ?? 'plain')}
by={(left: T, right: T) => Object.is(getValueKey(left), getValueKey(right))}
disabled={disabled}
value={value}
onChange={onChange}
>
<Listbox.Label style={hideLabel ? { display: 'none' } : undefined}>
{label}
{
required && (
<span style={{ color: 'red' }}>&nbsp;*</span>
)
}
</Listbox.Label>
{
subtitle && (
<span className='label-subtitle'>
{subtitle}
</span>
</Listbox.Label>}
<Listbox.Button className={`select-button ${size}`} ref={setReferenceElement}>
<span className="select-button-label">{defaultValue?.label}</span>
<span className="select-button-icon">
<ChevronUpDownIcon aria-hidden="true" />
)
}
<Listbox.Button className={clsx('select-button', size, buttonClassName)} ref={setReferenceElement}>
<span className="select-button-label">
{
selectedOption === undefined
? ''
: getSelectedOptionDisplay(selectedOption)
}
</span>
<span className="select-button-icon">
<ChevronUpDownIcon aria-hidden="true" />
</span>
</Listbox.Button>
{
(error && !hideLabel) && (
<span className='field-error'>
{error}
</span>
</Listbox.Button>
<Transition
as={Fragment}
leave="transition-leave"
leaveFrom="transition-leave-from"
leaveTo="transition-leave-to"
enter="transition-enter"
enterFrom="transition-enter-from"
enterTo="transition-enter-to"
)
}
<Transition
as={Fragment}
leave="transition-leave"
leaveFrom="transition-leave-from"
leaveTo="transition-leave-to"
enter="transition-enter"
enterFrom="transition-enter-from"
enterTo="transition-enter-to"
>
<Listbox.Options
className="select-options"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<Listbox.Options
className="select-options"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}>
{options.map((option) => (
{options.map((option) => {
const value = toValue(option)
return (
<Listbox.Option
key={option.key}
value={option}
className={
({ active, selected }) => `select-option ${active ? 'active' : ''} ${selected ? 'selected' : ''}` }
key={getValueKey(value)}
value={value}
className={({ active, selected }) => clsx(
'select-option',
active && 'active',
selected && 'selected',
)}
>
<span>{option.label}</span>
<span>{getOptionDisplay(option)}</span>
<span className="option-icon">
<CheckIcon aria-hidden="true" />
</span>
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</Listbox>
</div>
)
})}
{optionsFooter}
</Listbox.Options>
</Transition>
</Listbox>
)
}
SelectField.Field = function SelectFieldField<T, X extends FieldValues, P extends FieldPath<X>>({
form,
name,
required,
...rest
}: FieldBindingsProps<SelectFieldProps<T>, T, X, P>) {
const { field, fieldState } = useController({
control: form.control,
name,
rules: {
required,
},
})
return (
<SelectField
{...rest}
{...field}
required={required}
error={fieldState.error?.message}
/>
)
}

View file

@ -1,4 +1,4 @@
import { useState } from 'react'
import { Key, useState } from 'react'
import { Modifier, usePopper } from 'react-popper'
const modifiers: Array<Partial<Modifier<any, any>>> = [
@ -12,7 +12,7 @@ const modifiers: Array<Partial<Modifier<any, any>>> = [
{
name: 'offset',
options: {
offset: [0, -12],
offset: [0, 4],
},
},
{
@ -47,3 +47,7 @@ export function usePopperSelectDropdown() {
attributes,
}
}
export const defaultGetValueKey = (option: any) => (typeof option === 'object' ? option.id : option) as Key
export const defaultGetOptionDisplay = (option: any) => (typeof option === 'object' ? option.label ?? option.name : option) as string

View file

@ -8,7 +8,7 @@ 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 { SelectField } from '../../ui/form/SelectField'
import { snakeToTitle } from '../../utils'
import OptionField from '../../ui/form/OptionField'
import { SelectionProps } from '../../ui/form/Field'
@ -59,12 +59,15 @@ const SubscriptionSelection = ({ subscriptions, form }: { subscriptions: Subscri
label: item.name,
})), watchChannel)
return <SelectField
form={form}
name="subscription_id"
label="Subscription Group"
options={options}
required />
return (
<SelectField.Field
form={form}
name="subscription_id"
label="Subscription Group"
options={options}
required
/>
)
}
const ProviderSelection = ({ providers, form }: { providers: Provider[], form: UseFormReturn<CampaignCreateParams> }) => {
@ -75,12 +78,15 @@ const ProviderSelection = ({ providers, form }: { providers: Provider[], form: U
label: item.name,
})), watchChannel)
return <SelectField
form={form}
name="provider_id"
label="Provider"
options={options}
required />
return (
<SelectField.Field
form={form}
name="provider_id"
label="Provider"
options={options}
required
/>
)
}
export default function CampaignEditModal({ campaign, open, onClose, onSave }: CampaignEditParams) {

View file

@ -4,7 +4,7 @@ import { LocaleContext } from '../../contexts'
import { Campaign, UseStateContext } from '../../types'
import Button from '../../ui/Button'
import ButtonGroup from '../../ui/ButtonGroup'
import SelectField from '../../ui/form/SelectField'
import { SelectField } from '../../ui/form/SelectField'
import CreateLocaleModal from './CreateLocaleModal'
interface LocaleSelectorParams {
@ -28,21 +28,33 @@ export default function LocaleSelector({ campaignState, openState }: LocaleSelec
return <>
<ButtonGroup>
{ currentLocale && <SelectField
options={allLocales}
name="locale"
size="small"
value={currentLocale}
onChange={(currentLocale) => setLocale({ currentLocale, allLocales })} />}
{ campaign.state !== 'finished' && <Button
size="small"
variant="secondary"
onClick={() => setOpen(true)}>Add Locale</Button>}
{
currentLocale && (
<SelectField
options={allLocales}
size="small"
value={currentLocale}
onChange={(currentLocale) => setLocale({ currentLocale, allLocales })}
/>
)
}
{
campaign.state !== 'finished' && (
<Button
size="small"
variant="secondary"
onClick={() => setOpen(true)}
>
{'Add Locale'}
</Button>
)
}
</ButtonGroup>
<CreateLocaleModal
open={open}
setIsOpen={setOpen}
campaign={campaign}
setCampaign={handleCampaignCreate} />
setCampaign={handleCampaignCreate}
/>
</>
}

View file

@ -1,7 +1,7 @@
import { Operator, Rule, RuleType, WrapperRule } from '../../types'
import Button from '../../ui/Button'
import ButtonGroup from '../../ui/ButtonGroup'
import SelectField from '../../ui/form/SelectField'
import { SelectField } from '../../ui/form/SelectField'
import TextField from '../../ui/form/TextField'
import './RuleBuilder.css'
@ -269,12 +269,16 @@ const OperatorSelector = ({ type, value, onChange }: OperatorParams) => {
const operators = types[type]
return <SelectField
options={operators}
name="operator"
size="small"
value={value}
onChange={(key) => onChange(key as Operator)} />
return (
<SelectField
options={operators}
size="small"
toValue={o => o.key}
value={value}
onChange={onChange}
hideLabel
/>
)
}
interface TypeParams {
@ -283,7 +287,10 @@ interface TypeParams {
}
const TypeOperator = ({ value, onChange }: TypeParams) => {
const types = [
const types: Array<{
key: RuleType
label: string
}> = [
{ key: 'string', label: 'String' },
{ key: 'number', label: 'Number' },
{ key: 'boolean', label: 'Boolean' },
@ -291,12 +298,16 @@ const TypeOperator = ({ value, onChange }: TypeParams) => {
{ key: 'array', label: 'Array' },
]
return <SelectField
options={types}
name="type"
size="small"
value={value}
onChange={(key) => onChange(key as RuleType)} />
return (
<SelectField
options={types}
size="small"
toValue={o => o.key}
value={value}
onChange={onChange}
hideLabel
/>
)
}
interface RuleBuilderParams {