diff --git a/sdk-app/src/App.tsx b/sdk-app/src/App.tsx index 477eb20fe..d046398b7 100644 --- a/sdk-app/src/App.tsx +++ b/sdk-app/src/App.tsx @@ -6,6 +6,7 @@ import { Sidebar } from './Sidebar' import { DemoSettingsPanel } from './DemoSettingsPanel' import { TokenExpiredOverlay } from './TokenExpiredOverlay' import { useEntities } from './useEntities' +import { useEntityCatalog } from './useEntityCatalog' import { useDemoManager } from './useDemoManager' import { useAppMode } from './useAppMode' import { useThemeMode } from './useThemeMode' @@ -16,6 +17,7 @@ export function App() { const [sidebarOpen, setSidebarOpen] = useState(true) const [searchQuery, setSearchQuery] = useState('') const { entities, updateEntity, replaceEntities, resetToDefaults } = useEntities() + const entityCatalog = useEntityCatalog(entities.companyId) const demoManager = useDemoManager() const mode = useAppMode() const themeMode = useThemeMode() @@ -77,6 +79,7 @@ export function App() { proxyMode={demoManager.proxyMode} onCreateNewDemo={handleCreateNewDemo} onRefreshToken={demoManager.refreshToken} + entityCatalog={entityCatalog} /> {demoManager.tokenStatus === 'expired' && ( input, + > .selectWrapper { + flex: 1; + min-width: 0; + } +} + +.readOnly { + background: var(--color-badge-bg); + cursor: default; +} + +.selectWrapper { + position: relative; +} + +.comboboxInputWrapper { + position: relative; + display: flex; + align-items: stretch; + + input { + flex: 1; + padding-right: 2rem; + } +} + +.comboboxChevron { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 2rem; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + cursor: pointer; + color: var(--color-text-muted); + font-size: 0.75rem; + padding: 0; +} + +.helperLine { + margin-top: 0.25rem; + font-size: 0.6875rem; + color: var(--color-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.helperLineHidden { + visibility: hidden; +} + +.selectStatus { + padding: 0.5rem 0.625rem; + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.selectMenu { + position: absolute; + top: calc(100% + 0.25rem); + left: 0; + right: 0; + z-index: 10; + margin: 0; + padding: 0.25rem; + list-style: none; + background: var(--color-bg); + border: 0.0625rem solid var(--color-border); + border-radius: 0.375rem; + box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.12); + max-height: 16rem; + overflow-y: auto; +} + +.selectOption { + width: 100%; + display: flex; + flex-direction: column; + gap: 0.125rem; + padding: 0.5rem 0.625rem; + background: transparent; + border: none; + border-radius: 0.25rem; + cursor: pointer; + text-align: left; + color: var(--color-text); + + &:hover { + background: var(--color-hover-bg); + } +} + +.selectOptionActive { + background: var(--color-hover-bg); +} + +.optionPrimary { + font-size: 0.8125rem; + color: var(--color-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.optionSecondary { + font-size: 0.6875rem; + font-family: monospace; + color: var(--color-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/sdk-app/src/DemoSettingsPanel.tsx b/sdk-app/src/DemoSettingsPanel.tsx index de4623d28..8a6303a0b 100644 --- a/sdk-app/src/DemoSettingsPanel.tsx +++ b/sdk-app/src/DemoSettingsPanel.tsx @@ -1,6 +1,16 @@ -import { useState, useEffect, useRef } from 'react' +import { + useState, + useEffect, + useRef, + useCallback, + useMemo, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, +} from 'react' import type { EntityIds } from './useEntities' import type { TokenStatus } from './useDemoManager' +import type { EntityCatalog } from './useEntityCatalog' +import type { EntityOption } from './entityFormatters' import styles from './DemoSettingsPanel.module.scss' interface DemoSettingsPanelProps { @@ -15,17 +25,316 @@ interface DemoSettingsPanelProps { proxyMode: string onCreateNewDemo: (demoType: string) => Promise onRefreshToken: () => Promise + entityCatalog: EntityCatalog } -const ENTITY_FIELDS: { key: keyof EntityIds; label: string }[] = [ - { key: 'companyId', label: 'Company ID' }, - { key: 'employeeId', label: 'Employee ID' }, - { key: 'contractorId', label: 'Contractor ID' }, - { key: 'payrollId', label: 'Payroll ID' }, +const TEXT_FIELDS: { key: keyof EntityIds; label: string }[] = [ { key: 'requestId', label: 'Request ID' }, { key: 'formId', label: 'Form ID' }, ] +interface EntityComboboxProps { + label: string + value: string + options: EntityOption[] + isLoading: boolean + placeholder: string + useFallback: boolean + onChange: (value: string) => void + trailing?: ReactNode +} + +function filterOptions(options: EntityOption[], query: string): EntityOption[] { + const trimmed = query.trim().toLowerCase() + if (!trimmed) return options + return options.filter( + option => + option.primary.toLowerCase().includes(trimmed) || + option.secondary.toLowerCase().includes(trimmed), + ) +} + +interface CopyIdButtonProps { + value: string + ariaLabel: string +} + +function CopyIdButton({ value, ariaLabel }: CopyIdButtonProps) { + const [status, setStatus] = useState<'idle' | 'copied'>('idle') + const timeoutRef = useRef | null>(null) + + useEffect( + () => () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current) + }, + [], + ) + + const handleClick = useCallback(async () => { + if (!value) return + try { + await navigator.clipboard.writeText(value) + setStatus('copied') + if (timeoutRef.current) clearTimeout(timeoutRef.current) + timeoutRef.current = setTimeout(() => { + setStatus('idle') + }, 1500) + } catch { + // Clipboard unavailable + } + }, [value]) + + return ( + + ) +} + +function EntityCombobox({ + label, + value, + options, + isLoading, + placeholder, + useFallback, + onChange, + trailing, +}: EntityComboboxProps) { + const [isOpen, setIsOpen] = useState(false) + const [query, setQuery] = useState('') + const [activeIndex, setActiveIndex] = useState(0) + const wrapperRef = useRef(null) + const inputRef = useRef(null) + const optionRefs = useRef>([]) + const inputId = `entity-${label.toLowerCase().replace(/\s+/g, '-')}-input` + const optionId = (index: number) => `${inputId}-option-${index}` + + useEffect(() => { + if (!isOpen) return + const handleClick = (e: MouseEvent) => { + if (!wrapperRef.current?.contains(e.target as Node)) { + setIsOpen(false) + } + } + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setIsOpen(false) + inputRef.current?.blur() + } + } + document.addEventListener('mousedown', handleClick) + document.addEventListener('keydown', handleKey) + return () => { + document.removeEventListener('mousedown', handleClick) + document.removeEventListener('keydown', handleKey) + } + }, [isOpen]) + + const matched = useMemo(() => options.find(option => option.value === value), [options, value]) + const filtered = useMemo(() => filterOptions(options, query), [options, query]) + + useEffect(() => { + if (!isOpen) return + const selectedIdx = filtered.findIndex(option => option.value === value) + setActiveIndex(selectedIdx >= 0 ? selectedIdx : 0) + }, [isOpen]) // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + setActiveIndex(idx => { + if (filtered.length === 0) return 0 + return Math.min(Math.max(idx, 0), filtered.length - 1) + }) + }, [filtered.length]) + + useEffect(() => { + if (!isOpen) return + optionRefs.current[activeIndex]?.scrollIntoView({ block: 'nearest' }) + }, [activeIndex, isOpen]) + + if (useFallback) { + return ( +
+ +
+ { + onChange(e.target.value) + }} + placeholder={placeholder} + /> + {trailing} +
+
+ ) + } + + const handleInputChange = (next: string) => { + setQuery(next) + onChange(next) + setActiveIndex(0) + if (!isOpen) setIsOpen(true) + } + + const handleSelect = (option: EntityOption) => { + onChange(option.value) + setQuery('') + setIsOpen(false) + inputRef.current?.blur() + } + + const handleKeyDown = (e: ReactKeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + if (!isOpen) { + setIsOpen(true) + return + } + if (filtered.length === 0) return + setActiveIndex(idx => (idx + 1) % filtered.length) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + if (!isOpen) { + setIsOpen(true) + return + } + if (filtered.length === 0) return + setActiveIndex(idx => (idx - 1 + filtered.length) % filtered.length) + } else if (e.key === 'Enter') { + if (isOpen && filtered[activeIndex]) { + e.preventDefault() + handleSelect(filtered[activeIndex]) + } + } else if (e.key === 'Home' && isOpen) { + e.preventDefault() + setActiveIndex(0) + } else if (e.key === 'End' && isOpen) { + e.preventDefault() + setActiveIndex(Math.max(filtered.length - 1, 0)) + } + } + + return ( +
+ +
+
+
+ { + setQuery('') + }} + onMouseDown={() => { + setIsOpen(open => !open) + }} + onChange={e => { + handleInputChange(e.target.value) + }} + onKeyDown={handleKeyDown} + role="combobox" + aria-expanded={isOpen} + aria-controls={`${inputId}-listbox`} + aria-activedescendant={ + isOpen && filtered[activeIndex] ? optionId(activeIndex) : undefined + } + aria-autocomplete="list" + autoComplete="off" + spellCheck={false} + data-1p-ignore="true" + data-lpignore="true" + /> + +
+ + {isOpen && ( +
    + {isLoading && options.length === 0 ? ( +
  • Loading…
  • + ) : filtered.length === 0 ? ( +
  • + {options.length === 0 ? 'No options available' : 'No matches'} +
  • + ) : ( + filtered.map((option, index) => { + const isSelected = option.value === value + const isActive = index === activeIndex + return ( +
  • { + optionRefs.current[index] = el + }} + role="option" + aria-selected={isSelected} + > + +
  • + ) + }) + )} +
+ )} +
+ {trailing} +
+ {matched && ( +
+ {matched.primary} +
+ )} +
+ ) +} + export function DemoSettingsPanel({ isOpen, onClose, @@ -38,6 +347,7 @@ export function DemoSettingsPanel({ proxyMode, onCreateNewDemo, onRefreshToken, + entityCatalog, }: DemoSettingsPanelProps) { const currentDemoType = import.meta.env.VITE_DEMO_TYPE || 'react_sdk_demo_company_onboarded' const [selectedDemoType, setSelectedDemoType] = useState(currentDemoType) @@ -49,17 +359,20 @@ export function DemoSettingsPanel({ } }, [isOpen, entities]) - const hasChanges = ENTITY_FIELDS.some( - ({ key }) => entities[key] !== confirmedSnapshot.current[key], - ) - const env = typeof __SDK_APP_ENV__ !== 'undefined' ? __SDK_APP_ENV__ : 'demo' const build = typeof __SDK_APP_BUILD__ !== 'undefined' ? __SDK_APP_BUILD__ : 'dev' const mode = typeof __SDK_APP_PROXY_MODE__ !== 'undefined' ? __SDK_APP_PROXY_MODE__ : proxyMode + const isFlowTokenMode = mode === 'flow-token' + + const hasChanges = + entities.employeeId !== confirmedSnapshot.current.employeeId || + entities.contractorId !== confirmedSnapshot.current.contractorId || + entities.payrollId !== confirmedSnapshot.current.payrollId || + TEXT_FIELDS.some(({ key }) => entities[key] !== confirmedSnapshot.current[key]) + if (!isOpen) return null - const isFlowTokenMode = mode === 'flow-token' const displayEnv = env === 'localzp' ? 'local' : env return ( @@ -146,19 +459,80 @@ export function DemoSettingsPanel({

Entity IDs

- {ENTITY_FIELDS.map(({ key, label }) => ( -
- + +
+ +
{ - onUpdateEntity(key, e.target.value) - }} - placeholder={`Enter ${label.toLowerCase()}...`} + value={entities.companyId} + readOnly + className={styles.readOnly} /> +
- ))} +
+ + { + onUpdateEntity('employeeId', value) + }} + trailing={} + /> + + { + onUpdateEntity('contractorId', value) + }} + trailing={} + /> + + { + onUpdateEntity('payrollId', value) + }} + trailing={} + /> + + {TEXT_FIELDS.map(({ key, label }) => { + const inputId = `entity-${key}-input` + return ( +
+ +
+ { + onUpdateEntity(key, e.target.value) + }} + placeholder={`Enter ${label.toLowerCase()}...`} + /> + +
+
+ ) + })}
{hasChanges && ( diff --git a/sdk-app/src/entityFormatters.ts b/sdk-app/src/entityFormatters.ts new file mode 100644 index 000000000..db4b9c014 --- /dev/null +++ b/sdk-app/src/entityFormatters.ts @@ -0,0 +1,47 @@ +export interface RawEmployee { + uuid?: string + first_name?: string | null + last_name?: string | null +} + +export interface RawContractor { + uuid?: string + type?: string + first_name?: string | null + last_name?: string | null + business_name?: string | null +} + +export interface RawPayroll { + uuid?: string + payroll_uuid?: string + check_date?: string + pay_period?: { start_date?: string; end_date?: string } +} + +export interface EntityOption { + value: string + primary: string + secondary: string +} + +export function formatPayPeriod(payroll: RawPayroll): string { + const start = payroll.pay_period?.start_date + const end = payroll.pay_period?.end_date + if (start && end) return `${start} – ${end}` + if (payroll.check_date) return `Check date ${payroll.check_date}` + return 'Payroll' +} + +export function formatContractor(contractor: RawContractor): string { + if (contractor.type === 'Business') { + return contractor.business_name || 'Business contractor' + } + const name = [contractor.first_name, contractor.last_name].filter(Boolean).join(' ').trim() + return name || 'Contractor' +} + +export function formatEmployee(employee: RawEmployee): string { + const name = [employee.first_name, employee.last_name].filter(Boolean).join(' ').trim() + return name || 'Employee' +} diff --git a/sdk-app/src/useEntityCatalog.ts b/sdk-app/src/useEntityCatalog.ts new file mode 100644 index 000000000..22eb03c5a --- /dev/null +++ b/sdk-app/src/useEntityCatalog.ts @@ -0,0 +1,99 @@ +import { useEffect, useState } from 'react' +import { + type EntityOption, + type RawContractor, + type RawEmployee, + type RawPayroll, + formatContractor, + formatEmployee, + formatPayPeriod, +} from './entityFormatters' + +export interface EntityCatalog { + employees: EntityOption[] + contractors: EntityOption[] + payrolls: EntityOption[] + isLoading: boolean +} + +const EMPTY_CATALOG: EntityCatalog = { + employees: [], + contractors: [], + payrolls: [], + isLoading: false, +} + +async function fetchList(path: string, signal: AbortSignal): Promise { + try { + const res = await fetch(path, { signal }) + if (!res.ok) return [] + const data = (await res.json()) as unknown + return Array.isArray(data) ? (data as T[]) : [] + } catch { + return [] + } +} + +export function useEntityCatalog(companyId: string): EntityCatalog { + const [catalog, setCatalog] = useState(EMPTY_CATALOG) + + const proxyMode = typeof __SDK_APP_PROXY_MODE__ !== 'undefined' ? __SDK_APP_PROXY_MODE__ : 'none' + const isFlowTokenMode = proxyMode === 'flow-token' + + useEffect(() => { + if (!isFlowTokenMode || !companyId) { + setCatalog(EMPTY_CATALOG) + return + } + + const controller = new AbortController() + setCatalog(prev => ({ ...prev, isLoading: true })) + + const load = async () => { + const base = `/api/v1/companies/${companyId}` + const [employees, contractors, payrolls] = await Promise.all([ + fetchList(`${base}/employees`, controller.signal), + fetchList(`${base}/contractors`, controller.signal), + fetchList(`${base}/payrolls`, controller.signal), + ]) + + if (controller.signal.aborted) return + + setCatalog({ + employees: employees + .filter(e => !!e.uuid) + .map(e => ({ + value: e.uuid as string, + primary: formatEmployee(e), + secondary: e.uuid as string, + })), + contractors: contractors + .filter(c => !!c.uuid) + .map(c => ({ + value: c.uuid as string, + primary: formatContractor(c), + secondary: c.uuid as string, + })), + payrolls: payrolls + .filter(p => !!(p.payroll_uuid || p.uuid)) + .map(p => { + const id = (p.payroll_uuid || p.uuid) as string + return { + value: id, + primary: formatPayPeriod(p), + secondary: id, + } + }), + isLoading: false, + }) + } + + void load() + + return () => { + controller.abort() + } + }, [companyId, isFlowTokenMode]) + + return catalog +}