feat: add view system and UI polish for table templates (2 of 4)

- Refactor TableTemplateSelector to use view configuration pattern
- Add viewConfig system for managing different UI states
- Create separate view components (InitialView, CategoryView, ResultsView)
- Improve UI polish with better styling and transitions
- Add ViewMode enum and ViewState interface for state management
This commit is contained in:
Sean Oliver committed 2025-10-10 16:10:43 -07:00
1 parent ad7f30637b
commit d67f5c15ae
6 files changed
+398 -80

No files matched your search

@@ -1,9 +1,12 @@
import { useState, useCallback, useEffect, useMemo } from 'react'
import { useState, useCallback, useMemo } from 'react'
import { toast } from 'sonner'
import { Button, cn } from 'ui'
import { tableTemplates } from './templates'
import { QuickstartVariant } from './types'
import { QuickstartVariant, ViewMode } from './types'
import { convertTableSuggestionToTableField } from './utils'
import { InitialView } from './components/InitialView'
import { CategoryView } from './components/CategoryView'
import { ResultsView } from './components/ResultsView'
import { createViewConfig, type ViewKey } from './viewConfig'
import type { TableSuggestion } from './types'
import type { TableField } from '../TableEditor.types'
@@ -16,20 +19,40 @@ interface TableTemplateSelectorProps {
const SUCCESS_MESSAGE_DURATION_MS = 3000
interface ViewState {
mode: ViewMode
selectedCategory: string | null
selectedTemplate: TableSuggestion | null
generatedTables: TableSuggestion[]
error: string | null
isLoading: boolean
}
const initialViewState: ViewState = {
mode: ViewMode.INITIAL,
selectedCategory: null,
selectedTemplate: null,
generatedTables: [],
error: null,
isLoading: false,
}
export const TableTemplateSelector = ({
variant: _variant,
variant,
onSelectTemplate,
onDismiss,
disabled,
}: TableTemplateSelectorProps) => {
const [activeCategory, setActiveCategory] = useState<string | null>(null) // null => All
const [selectedTemplate, setSelectedTemplate] = useState<TableSuggestion | null>(null)
const [viewState, setViewState] = useState<ViewState>(initialViewState)
// Memoize view configuration
const viewConfig = useMemo(() => createViewConfig(), [])
const handleSelectTemplate = useCallback(
(template: TableSuggestion) => {
const tableField = convertTableSuggestionToTableField(template)
onSelectTemplate(tableField)
setSelectedTemplate(template)
setViewState((prev) => ({ ...prev, selectedTemplate: template }))
toast.success(
`${template.tableName} template applied. You can add or modify the fields below.`,
{
@@ -40,82 +63,60 @@ export const TableTemplateSelector = ({
[onSelectTemplate]
)
const categories = useMemo(() => Object.keys(tableTemplates), [])
const handleReset = useCallback(() => {
setViewState(initialViewState)
}, [])
useEffect(() => {
if (activeCategory === null && categories.length > 0) {
setActiveCategory(categories[0])
// Handler collection for views
const handlers = useMemo(
() => ({
onCategorySelect: (category: string) => {
setViewState((prev) => ({
...prev,
mode: ViewMode.CATEGORY_SELECTED,
selectedCategory: category,
}))
},
onBack: handleReset,
onSelectTemplate: handleSelectTemplate,
}),
[handleReset, handleSelectTemplate]
)
// Get the appropriate view component
const getView = (): JSX.Element | null => {
const { mode, selectedCategory } = viewState
// Determine the view key based on mode
let viewKey: ViewKey = mode
// Get the view configuration
const config = viewConfig.get(viewKey)
if (!config) return null
// Check if view should render
if (config.shouldRender && !config.shouldRender(viewState)) {
return null
}
}, [categories, activeCategory])
const displayed = useMemo(
() => (activeCategory ? tableTemplates[activeCategory] || [] : []),
[activeCategory]
)
// Get templates for category view
const templates = selectedCategory ? tableTemplates[selectedCategory] ?? [] : []
return (
<div className="rounded-lg border border-default bg-surface-75 p-4">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-medium">Start faster with a table template</h3>
<p className="text-xs text-foreground-lighter mt-1">
Save time by starting from a ready-made table schema.
</p>
</div>
{onDismiss && (
<Button type="text" size="tiny" onClick={onDismiss}>
Dismiss
</Button>
)}
</div>
// Render the view with all necessary props
return config.render({
// Components
InitialView,
CategoryView,
ResultsView,
// Props
variant,
disabled,
onDismiss,
state: viewState,
templates,
handlers,
})
}
<div className="flex flex-wrap gap-2 mb-3">
{categories.map((category) => (
<button
key={category}
onClick={() => setActiveCategory(category)}
disabled={disabled}
className={cn(
'px-2 py-1 rounded-md text-xs capitalize border',
activeCategory === category
? 'border-foreground bg-surface-200'
: 'border-default hover:border-foreground-muted hover:bg-surface-100',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{category}
</button>
))}
</div>
<div className="grid gap-2">
{displayed.map((t) => (
<button
key={`${activeCategory}:${t.tableName}`}
onClick={() => handleSelectTemplate(t)}
disabled={disabled}
className={cn(
'text-left p-3 rounded-md border transition-all w-full',
selectedTemplate?.tableName === t.tableName
? 'border-foreground bg-surface-200'
: 'border-default hover:border-foreground-muted hover:bg-surface-100',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="text-sm font-medium font-mono">{t.tableName}</div>
{t.rationale && (
<div className="text-sm text-foreground-light mt-1">{t.rationale}</div>
)}
</div>
<div className="flex items-center gap-1 text-sm text-foreground-muted ml-3">
<span>{t.fields.length} columns</span>
</div>
</div>
</button>
))}
</div>
</div>
)
return getView()
}
@@ -0,0 +1,42 @@
import { memo } from 'react'
import type { TableSuggestion } from '../types'
import { BackButton, TemplateList } from './SharedComponents'
interface CategoryViewProps {
category: string
templates: TableSuggestion[]
selectedTemplate: TableSuggestion | null
onBack: () => void
onSelectTemplate: (template: TableSuggestion) => void
}
export const CategoryView = memo(
({ category, templates, selectedTemplate, onBack, onSelectTemplate }: CategoryViewProps) => {
if (templates.length === 0) {
return (
<div className="space-y-2">
<BackButton onClick={onBack} />
<p className="text-sm text-foreground-lighter">
No templates available for this category.
</p>
</div>
)
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<BackButton onClick={onBack} />
<span className="text-xs text-foreground-light">{category}</span>
</div>
<TemplateList
templates={templates}
selectedTemplate={selectedTemplate}
onSelectTemplate={onSelectTemplate}
/>
</div>
)
}
)
CategoryView.displayName = 'CategoryView'
@@ -0,0 +1,87 @@
import { memo } from 'react'
import { Wand2, ChevronDown, Database } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Button,
} from 'ui'
import { QuickstartVariant } from '../types'
import { tableTemplates } from '../templates'
import { AI_QUICK_IDEAS } from '../constants'
import { SelectorHeader } from './SharedComponents'
interface InitialViewProps {
variant: Exclude<QuickstartVariant, QuickstartVariant.CONTROL>
disabled?: boolean
onDismiss?: () => void
onCategorySelect: (category: string) => void
onAISelect: () => void
onQuickIdea: (idea: string) => void
}
export const InitialView = memo(
({
variant,
disabled,
onDismiss,
onCategorySelect,
onAISelect,
onQuickIdea,
}: InitialViewProps) => {
const isAI = variant === QuickstartVariant.AI
const icon = isAI ? <Wand2 size={14} /> : <Database size={14} />
const label = isAI ? 'Generate with AI' : 'Select from templates'
return (
<div className="space-y-2">
<SelectorHeader onDismiss={onDismiss} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="default"
size="small"
disabled={disabled}
className="w-full justify-between"
iconRight={<ChevronDown size={16} />}
aria-label={label}
>
<span className="flex items-center gap-2">
{icon}
{label}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[320px]">
{isAI ? (
<>
<DropdownMenuItem onClick={onAISelect}>
<Wand2 size={14} className="mr-2" aria-hidden="true" />
Generate with AI...
</DropdownMenuItem>
<DropdownMenuSeparator />
<div className="px-2 py-1.5 text-xs text-foreground-light">Quick ideas</div>
{AI_QUICK_IDEAS.map((example) => (
<DropdownMenuItem key={example} onClick={() => onQuickIdea(example)}>
{example}
</DropdownMenuItem>
))}
</>
) : (
Object.keys(tableTemplates).map((category) => (
<DropdownMenuItem key={category} onClick={() => onCategorySelect(category)}>
<Database size={14} className="mr-2" aria-hidden="true" />
{category}
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
)
InitialView.displayName = 'InitialView'
@@ -0,0 +1,31 @@
import { memo } from 'react'
import type { TableSuggestion } from '../types'
import { BackButton, TemplateList } from './SharedComponents'
interface ResultsViewProps {
tables: TableSuggestion[]
selectedTemplate: TableSuggestion | null
title: string
onBack: () => void
onSelectTemplate: (template: TableSuggestion) => void
}
export const ResultsView = memo(
({ tables, selectedTemplate, title, onBack, onSelectTemplate }: ResultsViewProps) => (
<div className="space-y-2">
<div className="flex items-center justify-between">
<BackButton onClick={onBack} label="Generate new" />
<span className="text-xs text-foreground-light">{title}</span>
</div>
<div aria-live="polite">
<TemplateList
templates={tables}
selectedTemplate={selectedTemplate}
onSelectTemplate={onSelectTemplate}
/>
</div>
</div>
)
)
ResultsView.displayName = 'ResultsView'
@@ -0,0 +1,110 @@
import { memo } from 'react'
import { ArrowLeft, Columns3, X } from 'lucide-react'
import { Button, cn } from 'ui'
import type { TableSuggestion } from '../types'
export const BackButton = memo(
({
onClick,
disabled = false,
label = 'Back',
}: {
onClick: () => void
disabled?: boolean
label?: string
}) => (
<Button
type="text"
size="tiny"
icon={<ArrowLeft size={14} />}
onClick={onClick}
disabled={disabled}
aria-label={`${label} to previous view`}
>
{label}
</Button>
)
)
BackButton.displayName = 'BackButton'
export const SelectorHeader = memo(({ onDismiss }: { onDismiss?: () => void }) => (
<div className="flex items-center justify-between">
<label className="text-sm text-foreground-light">Start from template (optional)</label>
{onDismiss && (
<Button
type="text"
size="tiny"
icon={<X size={14} />}
onClick={onDismiss}
className="text-foreground-lighter hover:text-foreground"
aria-label="Dismiss template selector"
>
Dismiss
</Button>
)}
</div>
))
SelectorHeader.displayName = 'SelectorHeader'
export const TemplateItem = memo(
({
template,
isSelected,
onClick,
}: {
template: TableSuggestion
isSelected: boolean
onClick: () => void
}) => (
<button
type="button"
onClick={onClick}
className={cn(
'text-left p-3 rounded-md border transition-all w-full',
isSelected
? 'border-foreground bg-surface-200'
: 'border-default hover:border-foreground-muted hover:bg-surface-100'
)}
data-selected={isSelected}
aria-label={`Select ${template.tableName} table template`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="text-sm font-medium font-mono">{template.tableName}</div>
{template.rationale && (
<div className="text-sm text-foreground-light mt-1">{template.rationale}</div>
)}
</div>
<div className="flex items-center gap-1 text-sm text-foreground-muted ml-3">
<Columns3 size={14} aria-hidden="true" />
<span>{template.fields.length}</span>
</div>
</div>
</button>
)
)
TemplateItem.displayName = 'TemplateItem'
export const TemplateList = memo(
({
templates,
selectedTemplate,
onSelectTemplate,
}: {
templates: TableSuggestion[]
selectedTemplate: TableSuggestion | null
onSelectTemplate: (template: TableSuggestion) => void
}) => (
<div className="grid gap-2" role="list">
{templates.map((template) => (
<TemplateItem
key={template.tableName}
template={template}
isSelected={selectedTemplate?.tableName === template.tableName}
onClick={() => onSelectTemplate(template)}
/>
))}
</div>
)
)
TemplateList.displayName = 'TemplateList'
@@ -0,0 +1,47 @@
import { QuickstartVariant, ViewMode } from './types'
export type ViewConfig = {
variant: QuickstartVariant
mode: ViewMode
render: (props: any) => JSX.Element | null
shouldRender?: (state: any) => boolean
}
export type ViewKey = ViewMode.INITIAL | ViewMode.CATEGORY_SELECTED
// Create a view configuration map for the template system
export const createViewConfig = (): Map<ViewKey, ViewConfig> => {
const config = new Map<ViewKey, ViewConfig>()
// Initial view - category selection
config.set(ViewMode.INITIAL, {
variant: QuickstartVariant.TEMPLATES,
mode: ViewMode.INITIAL,
render: ({ InitialView, variant, disabled, onDismiss, handlers }) => (
<InitialView
variant={variant}
disabled={disabled}
onDismiss={onDismiss}
onCategorySelect={handlers.onCategorySelect}
/>
),
})
// Category selected - show templates
config.set(ViewMode.CATEGORY_SELECTED, {
variant: QuickstartVariant.TEMPLATES,
mode: ViewMode.CATEGORY_SELECTED,
render: ({ CategoryView, state, templates, handlers }) => (
<CategoryView
category={state.selectedCategory!}
templates={templates}
selectedTemplate={state.selectedTemplate}
onBack={handlers.onBack}
onSelectTemplate={handlers.onSelectTemplate}
/>
),
shouldRender: (state) => !!state.selectedCategory,
})
return config
}