Simplify table quickstart UX by consolidating variants into single view

- Restore simple templates variant with category tabs (no dropdown navigation)
- Add AI variant with direct input field and quick idea chips
- Remove complex multi-view navigation system
- Delete 5 unnecessary component files (Initial/Category/AIInput/Results/Shared)
- Remove ViewMode enum from types
- Consolidate all logic into single TableTemplateSelector component
This commit is contained in:
Sean Oliver committed 2025-10-13 10:43:48 -07:00
1 parent 767e646f68
commit d2c5ccd5ef
8 files changed
+209 -483

No files matched your search

@@ -1,18 +1,17 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { Columns3, Wand2 } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { useAITableGeneration } from './useAITableGeneration'
import { tableTemplates } from './templates'
import { QuickstartVariant, ViewMode } from './types'
import { convertTableSuggestionToTableField } from './utils'
import { InitialView } from './components/InitialView'
import { CategoryView } from './components/CategoryView'
import { AIInputView } from './components/AIInputView'
import { ResultsView } from './components/ResultsView'
import type { TableSuggestion } from './types'
import { Button, Input_Shadcn_ as Input, cn } from 'ui'
import type { TableField } from '../TableEditor.types'
import { AI_QUICK_IDEAS } from './constants'
import { tableTemplates } from './templates'
import type { TableSuggestion } from './types'
import { QuickstartVariant } from './types'
import { useAITableGeneration } from './useAITableGeneration'
import { convertTableSuggestionToTableField } from './utils'
interface TableTemplateSelectorProps {
variant: Exclude<QuickstartVariant, QuickstartVariant.CONTROL>
variant: Exclude<QuickstartVariant, QuickstartVariant.CONTROL>
onSelectTemplate: (tableField: Partial<TableField>) => void
onDismiss?: () => void
disabled?: boolean
@@ -20,45 +19,44 @@ interface TableTemplateSelectorProps {
const SUCCESS_MESSAGE_DURATION_MS = 3000
interface ViewState {
mode: ViewMode
selectedCategory: string | null
selectedTemplate: TableSuggestion | null
generatedTables: TableSuggestion[]
isLoading: boolean
}
const initialViewState: ViewState = {
mode: ViewMode.INITIAL,
selectedCategory: null,
selectedTemplate: null,
generatedTables: [],
isLoading: false,
}
export const TableTemplateSelector = ({
variant,
onSelectTemplate,
onDismiss,
disabled,
}: TableTemplateSelectorProps) => {
const [viewState, setViewState] = useState<ViewState>(initialViewState)
// Templates variant state
const [activeCategory, setActiveCategory] = useState<string | null>(null)
const [selectedTemplate, setSelectedTemplate] = useState<TableSuggestion | null>(null)
// AI variant state
const [aiPrompt, setAiPrompt] = useState('')
const [generatedTables, setGeneratedTables] = useState<TableSuggestion[]>([])
const inputRef = useRef<HTMLInputElement>(null)
const { generateTables, isGenerating, error: apiError } = useAITableGeneration()
// Focus management
const isAI = variant === QuickstartVariant.AI
const categories = useMemo(() => Object.keys(tableTemplates), [])
// Auto-select first category for templates variant
useEffect(() => {
if (viewState.mode === ViewMode.AI_INPUT && inputRef.current) {
if (!isAI && activeCategory === null && categories.length > 0) {
setActiveCategory(categories[0])
}
}, [categories, activeCategory, isAI])
// Focus AI input when in AI mode
useEffect(() => {
if (isAI && inputRef.current) {
inputRef.current.focus()
}
}, [viewState.mode])
}, [isAI])
const handleSelectTemplate = useCallback(
(template: TableSuggestion) => {
const tableField = convertTableSuggestionToTableField(template)
onSelectTemplate(tableField)
setViewState((prev) => ({ ...prev, selectedTemplate: template }))
setSelectedTemplate(template)
toast.success(
`${template.tableName} template applied. You can add or modify the fields below.`,
{
@@ -69,122 +67,200 @@ export const TableTemplateSelector = ({
[onSelectTemplate]
)
const handleGenerateTables = useCallback(async (promptOverride?: string) => {
const promptToUse = promptOverride ?? aiPrompt
if (!promptToUse.trim() || isGenerating) return
const handleGenerateTables = useCallback(
async (promptOverride?: string) => {
const promptToUse = promptOverride ?? aiPrompt
if (!promptToUse.trim() || isGenerating) return
setViewState((prev) => ({ ...prev, isLoading: true }))
try {
const tables = await generateTables(promptToUse)
try {
const tables = await generateTables(promptToUse)
if (tables.length > 0) {
setViewState({
mode: ViewMode.AI_RESULTS,
selectedCategory: null,
selectedTemplate: tables[0],
generatedTables: tables,
isLoading: false,
})
handleSelectTemplate(tables[0])
} else {
setViewState((prev) => ({
...prev,
isLoading: false,
}))
if (tables.length > 0) {
setGeneratedTables(tables)
handleSelectTemplate(tables[0])
}
} catch (error) {
// Error handling is done in the hook
}
} catch (error) {
setViewState((prev) => ({
...prev,
isLoading: false,
}))
}
}, [aiPrompt, generateTables, isGenerating, handleSelectTemplate])
},
[aiPrompt, generateTables, isGenerating, handleSelectTemplate]
)
const handleQuickIdea = useCallback(
(idea: string) => {
setAiPrompt(idea)
setViewState((prev) => ({ ...prev, mode: ViewMode.AI_INPUT }))
handleGenerateTables(idea)
},
[handleGenerateTables]
)
const handleCategorySelect = useCallback((category: string) => {
setViewState((prev) => ({
...prev,
mode: ViewMode.CATEGORY_SELECTED,
selectedCategory: category,
}))
}, [])
const displayedTemplates = useMemo(() => {
if (isAI) {
return generatedTables
}
return activeCategory ? tableTemplates[activeCategory] || [] : []
}, [isAI, generatedTables, activeCategory])
const handleAISelect = useCallback(() => {
setViewState((prev) => ({ ...prev, mode: ViewMode.AI_INPUT }))
}, [])
return (
<div className="rounded-lg border border-default bg-surface-75 p-4">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-medium">
{isAI ? 'Generate tables with AI' : 'Start faster with a table template'}
</h3>
<p className="text-xs text-foreground-lighter mt-1">
{isAI
? "Describe the app you're building and AI will create ready-made table schema."
: 'Save time by starting from a ready-made table schema.'}
</p>
</div>
{onDismiss && (
<Button type="text" size="tiny" onClick={onDismiss}>
Dismiss
</Button>
)}
</div>
const handleBack = useCallback(() => {
setViewState(initialViewState)
setAiPrompt('')
}, [])
{/* AI Variant: Input and Quick Ideas */}
{isAI && (
<div className="space-y-3">
<div className="relative">
<Input
ref={inputRef}
placeholder="Describe your app (e.g., 'recipe sharing app')"
value={aiPrompt}
onChange={(e) => setAiPrompt(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
if (aiPrompt.trim()) {
handleGenerateTables()
}
}
}}
disabled={isGenerating || disabled}
aria-label="Table description for AI generation"
className="pr-24"
/>
<Button
type="default"
size="tiny"
disabled={!aiPrompt.trim() || isGenerating || disabled}
onClick={() => handleGenerateTables()}
loading={isGenerating}
className="absolute right-1 top-1/2 -translate-y-1/2"
>
{isGenerating ? 'Generating...' : 'Generate'}
</Button>
</div>
const { mode, selectedCategory, selectedTemplate, generatedTables } = viewState
{apiError && (
<div className="text-sm text-red-600" role="alert">
{apiError}
</div>
)}
// Initial view
if (mode === ViewMode.INITIAL) {
return (
<InitialView
variant={variant}
disabled={disabled}
onDismiss={onDismiss}
onCategorySelect={handleCategorySelect}
onAISelect={handleAISelect}
onQuickIdea={handleQuickIdea}
/>
)
}
{/* Quick Ideas */}
{generatedTables.length === 0 && (
<div>
<div className="text-xs text-foreground-light mb-2">Quick ideas:</div>
<div className="flex flex-wrap gap-2">
{AI_QUICK_IDEAS.map((idea) => (
<button
key={idea}
onClick={() => handleQuickIdea(idea)}
disabled={isGenerating || disabled}
className={cn(
'px-2 py-1 rounded-md text-xs border border-default',
'hover:border-foreground-muted hover:bg-surface-100',
'disabled:opacity-50 disabled:cursor-not-allowed',
'transition-all'
)}
>
<Wand2 size={12} className="inline mr-1" />
{idea}
</button>
))}
</div>
</div>
)}
</div>
)}
// Category selection view
if (mode === ViewMode.CATEGORY_SELECTED && selectedCategory) {
const templates = tableTemplates[selectedCategory] ?? []
return (
<CategoryView
category={selectedCategory}
templates={templates}
selectedTemplate={selectedTemplate}
onBack={handleBack}
onSelectTemplate={handleSelectTemplate}
/>
)
}
{/* Templates Variant: Category Tabs */}
{!isAI && (
<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>
)}
// AI input view
if (mode === ViewMode.AI_INPUT) {
return (
<AIInputView
prompt={aiPrompt}
error={apiError}
isGenerating={isGenerating}
isLoading={viewState.isLoading}
inputRef={inputRef}
onBack={handleBack}
onPromptChange={setAiPrompt}
onGenerate={handleGenerateTables}
/>
)
}
// AI results view
if (mode === ViewMode.AI_RESULTS && generatedTables.length > 0) {
return (
<ResultsView
tables={generatedTables}
selectedTemplate={selectedTemplate}
title="AI Generated Tables"
onBack={handleBack}
onSelectTemplate={handleSelectTemplate}
/>
)
}
return null
{/* Templates List (both variants use this) */}
{displayedTemplates.length > 0 && (
<div className="grid gap-2">
{isAI && (
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-foreground-light">
Generated {generatedTables.length} table{generatedTables.length !== 1 ? 's' : ''}
</span>
<Button
type="text"
size="tiny"
onClick={() => {
setGeneratedTables([])
setAiPrompt('')
if (inputRef.current) inputRef.current.focus()
}}
disabled={isGenerating}
>
Generate new
</Button>
</div>
)}
{displayedTemplates.map((template) => (
<button
key={`${isAI ? 'ai' : activeCategory}:${template.tableName}`}
onClick={() => handleSelectTemplate(template)}
disabled={disabled}
className={cn(
'text-left p-3 rounded-md border transition-all w-full',
selectedTemplate?.tableName === template.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">{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>
))}
</div>
)}
</div>
)
}
@@ -1,74 +0,0 @@
import { memo } from 'react'
import { Button, Input_Shadcn_ as Input } from 'ui'
import { BackButton } from './SharedComponents'
interface AIInputViewProps {
prompt: string
error: string | null
isGenerating: boolean
isLoading: boolean
inputRef: React.RefObject<HTMLInputElement>
onBack: () => void
onPromptChange: (value: string) => void
onGenerate: () => void
}
export const AIInputView = memo(
({
prompt,
error,
isGenerating,
isLoading,
inputRef,
onBack,
onPromptChange,
onGenerate,
}: AIInputViewProps) => (
<div className="space-y-2">
<div className="flex gap-2">
<BackButton onClick={onBack} disabled={isGenerating} />
</div>
{error && (
<div className="text-sm text-red-600" role="alert">
{error}
</div>
)}
<div className="relative">
<Input
ref={inputRef}
placeholder="Describe your table (e.g., 'user profiles with social features')"
value={prompt}
onChange={(e) => onPromptChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
if (prompt.trim()) {
onGenerate()
}
}
}}
disabled={isGenerating || isLoading}
aria-label="Table description for AI generation"
aria-describedby="ai-prompt-help"
className="pr-24"
/>
<Button
type="default"
size="tiny"
disabled={!prompt.trim() || isGenerating || isLoading}
onClick={onGenerate}
loading={isGenerating || isLoading}
className="absolute right-1 top-1/2 -translate-y-1/2"
aria-label={isGenerating ? 'Generating tables' : 'Generate tables'}
>
{isGenerating || isLoading ? 'Generating...' : 'Generate'}
</Button>
</div>
<span id="ai-prompt-help" className="sr-only">
Enter a description of your tables and press Enter or click Generate
</span>
</div>
)
)
AIInputView.displayName = 'AIInputView'
@@ -1,42 +0,0 @@
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'
@@ -1,87 +0,0 @@
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'
@@ -1,31 +0,0 @@
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'
@@ -1,110 +0,0 @@
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'
@@ -47,13 +47,6 @@ export enum QuickstartVariant {
TEMPLATES = 'templates',
}
export enum ViewMode {
INITIAL = 'initial',
AI_INPUT = 'ai-input',
AI_RESULTS = 'ai-results',
CATEGORY_SELECTED = 'category-selected',
}
export type TableSuggestion = {
tableName: string
fields: TableField[]
+1
View File
@@ -15,6 +15,7 @@ const HOSTED_SUPPORTED_API_URLS = [
'/ai/onboarding/design',
'/ai/feedback/classify',
'/ai/docs',
'/ai/table-quickstart/generate-schemas',
'/get-ip-address',
'/get-utc-time',
'/get-deployment-commit',