mirror of
https://github.com/supabase/supabase.git
synced 2026-10-12 21:05:06 +03:00
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:
1 parent
ad7f30637b
commit
d67f5c15ae
6 files changed
+398
-80
No files matched your search
+81
-80
@@ -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()
|
||||
}
|
||||
+42
@@ -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'
|
||||
+87
@@ -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'
|
||||
+31
@@ -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'
|
||||
+110
@@ -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'
|
||||
+47
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user