mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
* improvement(tables): improve table filtering UX - Replace popover filter with persistent inline panel below toolbar - Add AND/OR toggle between filter rules (shown in Where label slot) - Sync filter panel state from applied filter on open - Show filter button active state when filter is applied or panel is open - Use readable operator labels matching dropdown options - Add Clear filters button (shown only when filter is active) - Close filter panel when last rule is removed via X - Fix empty gap rows appearing in filtered results by skipping position gap rendering when filter is active - Add toggle mode to ResourceOptionsBar for inline panel pattern - Memoize FilterRuleRow for perf, fix filterTags key collision, remove dead filterActiveCount prop * fix(table-filter): use ref to stabilize handleRemove/handleApply callbacks Reading rules via ref instead of closure eliminates rules from useCallback dependency arrays, keeping callbacks stable across rule edits and preserving the memo() benefit on FilterRuleRow. * improvement(tables,kb): remove hacky patterns, fix KB filter popover width - Remove non-TSDoc comment from table-filter (rulesRef pattern is self-evident) - Simplify SearchSection: remove setState-during-render anti-pattern; controlled input binds directly to search.value/onChange (simpler and correct) - Reduce KB filter popover from w-[320px] to w-[200px]; tag filter uses vertical layout so narrow width works; Status-only case is now appropriately compact * feat(knowledge): add sort and filter to KB list page Sort dropdown: name, documents, tokens, created, last updated — pre-sorted externally before passing rows to Resource. Active sort highlights the Sort button; clear resets to default (created desc). Filter popover: filter by connector status (All / With connectors / Without connectors). Active filter shown as a removable tag in the toolbar. * feat(files): add sort and filter to files list page * feat(scheduled-tasks): add sort and filter to scheduled tasks page * fix(table-filter): use explicit close handler instead of toggle * improvement(files,knowledge): replace manual debounce with useDebounce hook and use type guards for file filtering * fix(resource): prevent popover from inheriting anchor min-width * feat(tables): add sort to tables list page * feat(knowledge): add content and owner filters to KB list * feat(scheduled-tasks): add status and health filters * feat(files): add size and uploaded-by filters to files list * feat(tables): add row count, owner, and column type filters * improvement(scheduled-tasks): use combobox filter panel matching logs UI style * improvement(knowledge): use combobox filter panel matching logs UI style * improvement(files): use combobox filter panel matching logs UI style Replaces button-list filters with Combobox-based multi-select sections for file type, size, and uploaded-by filters, aligning the panel with the logs page filter UI. * improvement(tables): use combobox filter panel matching logs UI style * feat(settings): add sort to recently deleted page Add a sort dropdown next to the search bar allowing users to sort by deletion date (default, newest first), name (A–Z), or type (A–Z). * feat(logs): add sort to logs page * improvement(knowledge): upgrade document list filter to combobox style * fix(resources): fix missing imports, memoization, and stale refs across resource pages * improvement(tables): remove column type filter * fix(resources): fix filter/sort correctness issues from audit * fix(chunks): add server-side sort to document chunks API Chunk sort was previously done client-side on a single page of server-paginated data, which only reordered the current page. Now sort params (sortBy, sortOrder) flow through the full stack: types → service → API route → query hook → useDocumentChunks → document.tsx. * perf(resources): memoize filterContent JSX across all resource pages Resource is wrapped in React.memo, so an unstable filterContent reference on every parent re-render defeats the memo. Wrap filterContent in useMemo with correct deps in all 6 pages (files, tables, scheduled-tasks, knowledge, base, document). * fix(resources): add missing sort options for all visible columns Every column visible in a resource table should be sortable. Three pages had visible columns with no sort support: - files.tsx: add 'owner' sort (member name lookup) - scheduled-tasks.tsx: add 'schedule' sort (localeCompare on description) - knowledge.tsx: add 'connectors' (count) and 'owner' (member name) sorts Also add 'members' to processedKBs deps in knowledge.tsx since owner sort now reads member names inside the memo. * whitelabeling updates, sidebar fixes, files bug * increased type safety * pr fixes
349 lines
11 KiB
TypeScript
349 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import { createLogger } from '@sim/logger'
|
|
import { Loader2 } from 'lucide-react'
|
|
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
|
|
import AuthBackground from '@/app/(auth)/components/auth-background'
|
|
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
|
|
import { SupportFooter } from '@/app/(auth)/components/support-footer'
|
|
import Navbar from '@/app/(home)/components/navbar/navbar'
|
|
import {
|
|
FormErrorState,
|
|
FormField,
|
|
FormLoadingState,
|
|
PasswordAuth,
|
|
PoweredBySim,
|
|
ThankYouScreen,
|
|
} from '@/app/form/[identifier]/components'
|
|
|
|
const logger = createLogger('Form')
|
|
|
|
interface FieldConfig {
|
|
name: string
|
|
type: string
|
|
label: string
|
|
description?: string
|
|
required?: boolean
|
|
}
|
|
|
|
interface FormConfig {
|
|
id: string
|
|
title: string
|
|
description?: string
|
|
customizations: {
|
|
primaryColor?: string
|
|
thankYouMessage?: string
|
|
logoUrl?: string
|
|
fieldConfigs?: FieldConfig[]
|
|
}
|
|
authType?: 'public' | 'password' | 'email'
|
|
showBranding?: boolean
|
|
inputSchema?: InputField[]
|
|
}
|
|
|
|
interface InputField {
|
|
name: string
|
|
type?: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files'
|
|
description?: string
|
|
value?: unknown
|
|
required?: boolean
|
|
}
|
|
|
|
export default function Form({ identifier }: { identifier: string }) {
|
|
const [formConfig, setFormConfig] = useState<FormConfig | null>(null)
|
|
const [formData, setFormData] = useState<Record<string, unknown>>({})
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
const [isSubmitted, setIsSubmitted] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [authRequired, setAuthRequired] = useState<'password' | 'email' | null>(null)
|
|
const [thankYouData, setThankYouData] = useState<{
|
|
title: string
|
|
message: string
|
|
} | null>(null)
|
|
|
|
const abortControllerRef = useRef<AbortController | null>(null)
|
|
|
|
const fetchFormConfig = useCallback(
|
|
async (signal?: AbortSignal) => {
|
|
try {
|
|
setIsLoading(true)
|
|
setError(null)
|
|
|
|
const response = await fetch(`/api/form/${identifier}`, { signal })
|
|
if (signal?.aborted) return
|
|
|
|
const data = await response.json()
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 401) {
|
|
const authError = data.error
|
|
if (authError === 'auth_required_password') {
|
|
setAuthRequired('password')
|
|
setFormConfig({
|
|
id: '',
|
|
title: data.title || 'Form',
|
|
customizations: data.customizations || {},
|
|
})
|
|
return
|
|
}
|
|
if (authError === 'auth_required_email') {
|
|
setAuthRequired('email')
|
|
setFormConfig({
|
|
id: '',
|
|
title: data.title || 'Form',
|
|
customizations: data.customizations || {},
|
|
})
|
|
return
|
|
}
|
|
}
|
|
throw new Error(data.error || 'Failed to load form')
|
|
}
|
|
|
|
setFormConfig(data)
|
|
setAuthRequired(null)
|
|
|
|
// Initialize form data from input schema
|
|
const fields = data.inputSchema || []
|
|
if (fields.length > 0) {
|
|
const initialData: Record<string, unknown> = {}
|
|
for (const field of fields) {
|
|
if (field.value !== undefined) {
|
|
initialData[field.name] = field.value
|
|
} else {
|
|
switch (field.type) {
|
|
case 'boolean':
|
|
initialData[field.name] = false
|
|
break
|
|
case 'number':
|
|
initialData[field.name] = ''
|
|
break
|
|
case 'array':
|
|
case 'files':
|
|
initialData[field.name] = []
|
|
break
|
|
case 'object':
|
|
initialData[field.name] = {}
|
|
break
|
|
default:
|
|
initialData[field.name] = ''
|
|
}
|
|
}
|
|
}
|
|
setFormData(initialData)
|
|
}
|
|
} catch (err: unknown) {
|
|
if (err instanceof Error && err.name === 'AbortError') return
|
|
logger.error('Error fetching form config:', err)
|
|
setError(err instanceof Error ? err.message : 'Failed to load form')
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
},
|
|
[identifier]
|
|
)
|
|
|
|
useEffect(() => {
|
|
abortControllerRef.current?.abort()
|
|
const controller = new AbortController()
|
|
abortControllerRef.current = controller
|
|
fetchFormConfig(controller.signal)
|
|
return () => controller.abort()
|
|
}, [fetchFormConfig])
|
|
|
|
const handleFieldChange = useCallback((fieldName: string, value: unknown) => {
|
|
setFormData((prev) => ({ ...prev, [fieldName]: value }))
|
|
}, [])
|
|
|
|
const handleSubmit = useCallback(
|
|
async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!formConfig) return
|
|
|
|
try {
|
|
setIsSubmitting(true)
|
|
setError(null)
|
|
|
|
const response = await fetch(`/api/form/${identifier}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ formData }),
|
|
})
|
|
|
|
const data = await response.json()
|
|
|
|
if (!response.ok) {
|
|
throw new Error(data.error || 'Failed to submit form')
|
|
}
|
|
|
|
setThankYouData({
|
|
title: data.thankYouTitle || 'Thank you!',
|
|
message:
|
|
data.thankYouMessage ||
|
|
formConfig.customizations.thankYouMessage ||
|
|
'Your response has been submitted successfully.',
|
|
})
|
|
setIsSubmitted(true)
|
|
} catch (err: unknown) {
|
|
logger.error('Error submitting form:', err)
|
|
setError(err instanceof Error ? err.message : 'Failed to submit form')
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
},
|
|
[identifier, formConfig, formData]
|
|
)
|
|
|
|
const handlePasswordAuth = useCallback(
|
|
async (password: string) => {
|
|
try {
|
|
setIsLoading(true)
|
|
setError(null)
|
|
|
|
const response = await fetch(`/api/form/${identifier}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password }),
|
|
})
|
|
|
|
const data = await response.json()
|
|
|
|
if (!response.ok) {
|
|
throw new Error(data.error || 'Invalid password')
|
|
}
|
|
|
|
await fetchFormConfig()
|
|
} catch (err: unknown) {
|
|
logger.error('Error authenticating:', err)
|
|
setError(err instanceof Error ? err.message : 'Invalid password')
|
|
setIsLoading(false)
|
|
}
|
|
},
|
|
[identifier, fetchFormConfig]
|
|
)
|
|
|
|
const primaryColor = formConfig?.customizations?.primaryColor || 'var(--brand)'
|
|
|
|
if (isLoading && !authRequired) {
|
|
return <FormLoadingState />
|
|
}
|
|
|
|
if (error && !authRequired) {
|
|
return <FormErrorState error={error} />
|
|
}
|
|
|
|
if (authRequired === 'password') {
|
|
return <PasswordAuth onSubmit={handlePasswordAuth} error={error} />
|
|
}
|
|
|
|
if (isSubmitted && thankYouData) {
|
|
return (
|
|
<AuthBackground className={`${martianMono.variable} dark font-[430] font-season`}>
|
|
<main className='relative flex min-h-full flex-col text-[var(--landing-text)]'>
|
|
<header className='shrink-0 bg-[var(--landing-bg)]'>
|
|
<Navbar logoOnly />
|
|
</header>
|
|
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
|
|
<ThankYouScreen
|
|
title={thankYouData.title}
|
|
message={thankYouData.message}
|
|
primaryColor={formConfig?.customizations?.primaryColor}
|
|
/>
|
|
</div>
|
|
{formConfig?.showBranding !== false ? (
|
|
<PoweredBySim />
|
|
) : (
|
|
<SupportFooter position='absolute' />
|
|
)}
|
|
</main>
|
|
</AuthBackground>
|
|
)
|
|
}
|
|
|
|
if (!formConfig) {
|
|
return <FormErrorState error='Form not found' />
|
|
}
|
|
|
|
// Get fields from input schema
|
|
const fields = formConfig.inputSchema || []
|
|
|
|
// Create a map of field configs for quick lookup
|
|
const fieldConfigMap = new Map(
|
|
(formConfig.customizations?.fieldConfigs || []).map((fc) => [fc.name, fc])
|
|
)
|
|
|
|
return (
|
|
<AuthBackground className={`${martianMono.variable} dark font-[430] font-season`}>
|
|
<main className='relative flex min-h-full flex-col text-[var(--landing-text)]'>
|
|
<header className='shrink-0 bg-[var(--landing-bg)]'>
|
|
<Navbar logoOnly />
|
|
</header>
|
|
<div className='relative z-30 flex flex-1 justify-center px-4 pt-8 pb-24'>
|
|
<div className='w-full max-w-[410px]'>
|
|
{/* Form title */}
|
|
<div className='mb-8 text-center'>
|
|
<h1 className='text-balance font-[430] font-season text-[40px] text-white leading-[110%] tracking-[-0.02em]'>
|
|
{formConfig.title}
|
|
</h1>
|
|
{formConfig.description && (
|
|
<p className='mt-2 font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-lg leading-[125%] tracking-[0.02em]'>
|
|
{formConfig.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className='space-y-6'>
|
|
{fields.length === 0 ? (
|
|
<div className='rounded-[10px] border border-[var(--landing-bg-elevated)] bg-[var(--surface-4)] p-6 text-center text-[var(--landing-text-muted)]'>
|
|
This form has no fields configured.
|
|
</div>
|
|
) : (
|
|
fields.map((field) => {
|
|
const config = fieldConfigMap.get(field.name)
|
|
return (
|
|
<FormField
|
|
key={field.name}
|
|
field={field}
|
|
value={formData[field.name]}
|
|
onChange={(value) => handleFieldChange(field.name, value)}
|
|
primaryColor={primaryColor}
|
|
label={config?.label}
|
|
description={config?.description}
|
|
required={config?.required}
|
|
/>
|
|
)
|
|
})
|
|
)}
|
|
|
|
{error && (
|
|
<div className='rounded-sm border border-[var(--border-1)] bg-[var(--surface-4)] p-3 text-red-500 text-sm'>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{fields.length > 0 && (
|
|
<button type='submit' disabled={isSubmitting} className={AUTH_SUBMIT_BTN}>
|
|
{isSubmitting ? (
|
|
<span className='flex items-center gap-2'>
|
|
<Loader2 className='h-4 w-4 animate-spin' />
|
|
Submitting...
|
|
</span>
|
|
) : (
|
|
'Submit'
|
|
)}
|
|
</button>
|
|
)}
|
|
</form>
|
|
</div>
|
|
</div>
|
|
{formConfig.showBranding !== false ? (
|
|
<PoweredBySim />
|
|
) : (
|
|
<SupportFooter position='absolute' />
|
|
)}
|
|
</main>
|
|
</AuthBackground>
|
|
)
|
|
}
|