diff --git a/apps/sim/app/(auth)/auth-layout-client.tsx b/apps/sim/app/(auth)/auth-layout-client.tsx new file mode 100644 index 0000000000..e2b3e9489d --- /dev/null +++ b/apps/sim/app/(auth)/auth-layout-client.tsx @@ -0,0 +1,37 @@ +'use client' + +import { useEffect } from 'react' +import AuthBackground from '@/app/(auth)/components/auth-background' +import Nav from '@/app/(landing)/components/nav/nav' + +function isColorDark(hexColor: string): boolean { + const hex = hexColor.replace('#', '') + const r = Number.parseInt(hex.substr(0, 2), 16) + const g = Number.parseInt(hex.substr(2, 2), 16) + const b = Number.parseInt(hex.substr(4, 2), 16) + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 + return luminance < 0.5 +} + +export default function AuthLayoutClient({ children }: { children: React.ReactNode }) { + useEffect(() => { + const rootStyle = getComputedStyle(document.documentElement) + const brandBackground = rootStyle.getPropertyValue('--brand-background-hex').trim() + + if (brandBackground && isColorDark(brandBackground)) { + document.body.classList.add('auth-dark-bg') + } else { + document.body.classList.remove('auth-dark-bg') + } + }, []) + return ( + +
+
+
+ ) +} diff --git a/apps/sim/app/(auth)/layout.tsx b/apps/sim/app/(auth)/layout.tsx index aaa76e0bf1..d0fdf75a49 100644 --- a/apps/sim/app/(auth)/layout.tsx +++ b/apps/sim/app/(auth)/layout.tsx @@ -1,42 +1,10 @@ -'use client' +import type { Metadata } from 'next' +import AuthLayoutClient from '@/app/(auth)/auth-layout-client' -import { useEffect } from 'react' -import AuthBackground from '@/app/(auth)/components/auth-background' -import Nav from '@/app/(landing)/components/nav/nav' - -// Helper to detect if a color is dark -function isColorDark(hexColor: string): boolean { - const hex = hexColor.replace('#', '') - const r = Number.parseInt(hex.substr(0, 2), 16) - const g = Number.parseInt(hex.substr(2, 2), 16) - const b = Number.parseInt(hex.substr(4, 2), 16) - const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 - return luminance < 0.5 +export const metadata: Metadata = { + robots: { index: false, follow: false }, } export default function AuthLayout({ children }: { children: React.ReactNode }) { - useEffect(() => { - // Check if brand background is dark and add class accordingly - const rootStyle = getComputedStyle(document.documentElement) - const brandBackground = rootStyle.getPropertyValue('--brand-background-hex').trim() - - if (brandBackground && isColorDark(brandBackground)) { - document.body.classList.add('auth-dark-bg') - } else { - document.body.classList.remove('auth-dark-bg') - } - }, []) - return ( - -
- {/* Header - Nav handles all conditional logic */} -
-
- ) + return {children} } diff --git a/apps/sim/app/(auth)/login/page.tsx b/apps/sim/app/(auth)/login/page.tsx index d0173542b8..255b170bfd 100644 --- a/apps/sim/app/(auth)/login/page.tsx +++ b/apps/sim/app/(auth)/login/page.tsx @@ -1,6 +1,11 @@ +import type { Metadata } from 'next' import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker' import LoginForm from '@/app/(auth)/login/login-form' +export const metadata: Metadata = { + title: 'Log In', +} + export const dynamic = 'force-dynamic' export default async function LoginPage() { diff --git a/apps/sim/app/(auth)/reset-password/page.tsx b/apps/sim/app/(auth)/reset-password/page.tsx index 29ef3e425c..cb6470bba0 100644 --- a/apps/sim/app/(auth)/reset-password/page.tsx +++ b/apps/sim/app/(auth)/reset-password/page.tsx @@ -1,117 +1,8 @@ -'use client' +import type { Metadata } from 'next' +import ResetPasswordPage from '@/app/(auth)/reset-password/reset-password-content' -import { Suspense, useEffect, useState } from 'react' -import { createLogger } from '@sim/logger' -import Link from 'next/link' -import { useRouter, useSearchParams } from 'next/navigation' -import { inter } from '@/app/_styles/fonts/inter/inter' -import { soehne } from '@/app/_styles/fonts/soehne/soehne' -import { SetNewPasswordForm } from '@/app/(auth)/reset-password/reset-password-form' - -const logger = createLogger('ResetPasswordPage') - -function ResetPasswordContent() { - const router = useRouter() - const searchParams = useSearchParams() - const token = searchParams.get('token') - - const [isSubmitting, setIsSubmitting] = useState(false) - const [statusMessage, setStatusMessage] = useState<{ - type: 'success' | 'error' | null - text: string - }>({ - type: null, - text: '', - }) - - useEffect(() => { - if (!token) { - setStatusMessage({ - type: 'error', - text: 'Invalid or missing reset token. Please request a new password reset link.', - }) - } - }, [token]) - - const handleResetPassword = async (password: string) => { - try { - setIsSubmitting(true) - setStatusMessage({ type: null, text: '' }) - - const response = await fetch('/api/auth/reset-password', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - token, - newPassword: password, - }), - }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.message || 'Failed to reset password') - } - - setStatusMessage({ - type: 'success', - text: 'Password reset successful! Redirecting to login...', - }) - - setTimeout(() => { - router.push('/login?resetSuccess=true') - }, 1500) - } catch (error) { - logger.error('Error resetting password:', { error }) - setStatusMessage({ - type: 'error', - text: error instanceof Error ? error.message : 'Failed to reset password', - }) - } finally { - setIsSubmitting(false) - } - } - - return ( - <> -
-

- Reset your password -

-

- Enter a new password for your account -

-
- -
- -
- -
- - Back to login - -
- - ) +export const metadata: Metadata = { + title: 'Reset Password', } -export default function ResetPasswordPage() { - return ( - Loading...} - > - - - ) -} +export default ResetPasswordPage diff --git a/apps/sim/app/(auth)/reset-password/reset-password-content.tsx b/apps/sim/app/(auth)/reset-password/reset-password-content.tsx new file mode 100644 index 0000000000..29ef3e425c --- /dev/null +++ b/apps/sim/app/(auth)/reset-password/reset-password-content.tsx @@ -0,0 +1,117 @@ +'use client' + +import { Suspense, useEffect, useState } from 'react' +import { createLogger } from '@sim/logger' +import Link from 'next/link' +import { useRouter, useSearchParams } from 'next/navigation' +import { inter } from '@/app/_styles/fonts/inter/inter' +import { soehne } from '@/app/_styles/fonts/soehne/soehne' +import { SetNewPasswordForm } from '@/app/(auth)/reset-password/reset-password-form' + +const logger = createLogger('ResetPasswordPage') + +function ResetPasswordContent() { + const router = useRouter() + const searchParams = useSearchParams() + const token = searchParams.get('token') + + const [isSubmitting, setIsSubmitting] = useState(false) + const [statusMessage, setStatusMessage] = useState<{ + type: 'success' | 'error' | null + text: string + }>({ + type: null, + text: '', + }) + + useEffect(() => { + if (!token) { + setStatusMessage({ + type: 'error', + text: 'Invalid or missing reset token. Please request a new password reset link.', + }) + } + }, [token]) + + const handleResetPassword = async (password: string) => { + try { + setIsSubmitting(true) + setStatusMessage({ type: null, text: '' }) + + const response = await fetch('/api/auth/reset-password', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + token, + newPassword: password, + }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.message || 'Failed to reset password') + } + + setStatusMessage({ + type: 'success', + text: 'Password reset successful! Redirecting to login...', + }) + + setTimeout(() => { + router.push('/login?resetSuccess=true') + }, 1500) + } catch (error) { + logger.error('Error resetting password:', { error }) + setStatusMessage({ + type: 'error', + text: error instanceof Error ? error.message : 'Failed to reset password', + }) + } finally { + setIsSubmitting(false) + } + } + + return ( + <> +
+

+ Reset your password +

+

+ Enter a new password for your account +

+
+ +
+ +
+ +
+ + Back to login + +
+ + ) +} + +export default function ResetPasswordPage() { + return ( + Loading...} + > + + + ) +} diff --git a/apps/sim/app/(auth)/signup/page.tsx b/apps/sim/app/(auth)/signup/page.tsx index b267716e35..1f01e00464 100644 --- a/apps/sim/app/(auth)/signup/page.tsx +++ b/apps/sim/app/(auth)/signup/page.tsx @@ -1,7 +1,12 @@ +import type { Metadata } from 'next' import { isRegistrationDisabled } from '@/lib/core/config/feature-flags' import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker' import SignupForm from '@/app/(auth)/signup/signup-form' +export const metadata: Metadata = { + title: 'Sign Up', +} + export const dynamic = 'force-dynamic' export default async function SignupPage() { diff --git a/apps/sim/app/(auth)/sso/page.tsx b/apps/sim/app/(auth)/sso/page.tsx index 49bf30f1c6..b49d114dab 100644 --- a/apps/sim/app/(auth)/sso/page.tsx +++ b/apps/sim/app/(auth)/sso/page.tsx @@ -1,7 +1,12 @@ +import type { Metadata } from 'next' import { redirect } from 'next/navigation' import { getEnv, isTruthy } from '@/lib/core/config/env' import SSOForm from '@/ee/sso/components/sso-form' +export const metadata: Metadata = { + title: 'Single Sign-On', +} + export const dynamic = 'force-dynamic' export default async function SSOPage() { diff --git a/apps/sim/app/(auth)/verify/page.tsx b/apps/sim/app/(auth)/verify/page.tsx index 0402e09db6..70c5484144 100644 --- a/apps/sim/app/(auth)/verify/page.tsx +++ b/apps/sim/app/(auth)/verify/page.tsx @@ -1,7 +1,12 @@ +import type { Metadata } from 'next' import { isEmailVerificationEnabled, isProd } from '@/lib/core/config/feature-flags' import { hasEmailService } from '@/lib/messaging/email/mailer' import { VerifyContent } from '@/app/(auth)/verify/verify-content' +export const metadata: Metadata = { + title: 'Verify Email', +} + export const dynamic = 'force-dynamic' export default function VerifyPage() { diff --git a/apps/sim/app/(landing)/layout.tsx b/apps/sim/app/(landing)/layout.tsx index a3ecdf739c..9fef82ff68 100644 --- a/apps/sim/app/(landing)/layout.tsx +++ b/apps/sim/app/(landing)/layout.tsx @@ -4,8 +4,8 @@ export const metadata: Metadata = { metadataBase: new URL('https://sim.ai'), manifest: '/manifest.json', icons: { - icon: '/favicon.ico', - apple: '/apple-icon.png', + icon: [{ url: '/icon.svg', type: 'image/svg+xml', sizes: 'any' }], + apple: '/favicon/apple-touch-icon.png', }, other: { 'msapplication-TileColor': '#000000', diff --git a/apps/sim/app/(landing)/studio/authors/[id]/page.tsx b/apps/sim/app/(landing)/studio/authors/[id]/page.tsx index 720020bbdf..17afbe6cda 100644 --- a/apps/sim/app/(landing)/studio/authors/[id]/page.tsx +++ b/apps/sim/app/(landing)/studio/authors/[id]/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from 'next' import Image from 'next/image' import Link from 'next/link' import { getAllPostMeta } from '@/lib/blog/registry' @@ -5,6 +6,17 @@ import { soehne } from '@/app/_styles/fonts/soehne/soehne' export const revalidate = 3600 +export async function generateMetadata({ + params, +}: { + params: Promise<{ id: string }> +}): Promise { + const { id } = await params + const posts = (await getAllPostMeta()).filter((p) => p.author.id === id) + const author = posts[0]?.author + return { title: author?.name ?? 'Author' } +} + export default async function AuthorPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params const posts = (await getAllPostMeta()).filter((p) => p.author.id === id) diff --git a/apps/sim/app/(landing)/studio/page.tsx b/apps/sim/app/(landing)/studio/page.tsx index fed9fdf1be..f1fe274366 100644 --- a/apps/sim/app/(landing)/studio/page.tsx +++ b/apps/sim/app/(landing)/studio/page.tsx @@ -1,8 +1,14 @@ +import type { Metadata } from 'next' import Link from 'next/link' import { getAllPostMeta } from '@/lib/blog/registry' import { soehne } from '@/app/_styles/fonts/soehne/soehne' import { PostGrid } from '@/app/(landing)/studio/post-grid' +export const metadata: Metadata = { + title: 'Studio', + description: 'Announcements, insights, and guides from the Sim team.', +} + export const revalidate = 3600 export default async function StudioIndex({ diff --git a/apps/sim/app/(landing)/studio/tags/page.tsx b/apps/sim/app/(landing)/studio/tags/page.tsx index 9650b5d1da..7f9c2d0c2c 100644 --- a/apps/sim/app/(landing)/studio/tags/page.tsx +++ b/apps/sim/app/(landing)/studio/tags/page.tsx @@ -1,6 +1,11 @@ +import type { Metadata } from 'next' import Link from 'next/link' import { getAllTags } from '@/lib/blog/registry' +export const metadata: Metadata = { + title: 'Tags', +} + export default async function TagsIndex() { const tags = await getAllTags() return ( diff --git a/apps/sim/app/api/table/[tableId]/columns/route.ts b/apps/sim/app/api/table/[tableId]/columns/route.ts new file mode 100644 index 0000000000..b544ce8d59 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/columns/route.ts @@ -0,0 +1,77 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { addTableColumn } from '@/lib/table' +import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils' + +const logger = createLogger('TableColumnsAPI') + +const CreateColumnSchema = z.object({ + workspaceId: z.string().min(1, 'Workspace ID is required'), + column: z.object({ + name: z.string().min(1, 'Column name is required'), + type: z.enum(['string', 'number', 'boolean', 'date', 'json']), + required: z.boolean().optional(), + unique: z.boolean().optional(), + }), +}) + +interface ColumnsRouteParams { + params: Promise<{ tableId: string }> +} + +/** POST /api/table/[tableId]/columns - Adds a column to the table schema. */ +export async function POST(request: NextRequest, { params }: ColumnsRouteParams) { + const requestId = generateRequestId() + const { tableId } = await params + + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized column creation attempt`) + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const body = await request.json() + const validated = CreateColumnSchema.parse(body) + + const result = await checkAccess(tableId, authResult.userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) + + const { table } = result + + if (table.workspaceId !== validated.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + const updatedTable = await addTableColumn(tableId, validated.column, requestId) + + return NextResponse.json({ + success: true, + data: { + columns: updatedTable.schema.columns.map(normalizeColumn), + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + + if (error instanceof Error) { + if (error.message.includes('already exists') || error.message.includes('maximum column')) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + if (error.message === 'Table not found') { + return NextResponse.json({ error: error.message }, { status: 404 }) + } + } + + logger.error(`[${requestId}] Error adding column to table ${tableId}:`, error) + return NextResponse.json({ error: 'Failed to add column' }, { status: 500 }) + } +} diff --git a/apps/sim/app/chat/[identifier]/page.tsx b/apps/sim/app/chat/[identifier]/page.tsx index 9ba983fc5c..a90238b644 100644 --- a/apps/sim/app/chat/[identifier]/page.tsx +++ b/apps/sim/app/chat/[identifier]/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from 'next' import ChatClient from '@/app/chat/[identifier]/chat' +export const metadata: Metadata = { + title: 'Chat', +} + export default async function ChatPage({ params }: { params: Promise<{ identifier: string }> }) { const { identifier } = await params return diff --git a/apps/sim/app/favicon.ico b/apps/sim/app/favicon.ico deleted file mode 100644 index 9aa82bcf04..0000000000 Binary files a/apps/sim/app/favicon.ico and /dev/null differ diff --git a/apps/sim/app/form/[identifier]/page.tsx b/apps/sim/app/form/[identifier]/page.tsx index ba4004789d..10f5a6e7fc 100644 --- a/apps/sim/app/form/[identifier]/page.tsx +++ b/apps/sim/app/form/[identifier]/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from 'next' import Form from '@/app/form/[identifier]/form' +export const metadata: Metadata = { + title: 'Form', +} + export default async function FormPage({ params }: { params: Promise<{ identifier: string }> }) { const { identifier } = await params return
diff --git a/apps/sim/app/invite/[id]/page.tsx b/apps/sim/app/invite/[id]/page.tsx index 2f22144abc..e04a2ca774 100644 --- a/apps/sim/app/invite/[id]/page.tsx +++ b/apps/sim/app/invite/[id]/page.tsx @@ -1,3 +1,9 @@ +import type { Metadata } from 'next' import Invite from '@/app/invite/[id]/invite' +export const metadata: Metadata = { + title: 'Invite', + robots: { index: false }, +} + export default Invite diff --git a/apps/sim/app/resume/[workflowId]/[executionId]/page.tsx b/apps/sim/app/resume/[workflowId]/[executionId]/page.tsx index bfd2e2fac2..8ad86d3222 100644 --- a/apps/sim/app/resume/[workflowId]/[executionId]/page.tsx +++ b/apps/sim/app/resume/[workflowId]/[executionId]/page.tsx @@ -1,6 +1,12 @@ +import type { Metadata } from 'next' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' import ResumeExecutionPage from '@/app/resume/[workflowId]/[executionId]/resume-page-client' +export const metadata: Metadata = { + title: 'Resume Execution', + robots: { index: false }, +} + export const runtime = 'nodejs' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/templates/page.tsx b/apps/sim/app/templates/page.tsx index c233949a45..c74818acd3 100644 --- a/apps/sim/app/templates/page.tsx +++ b/apps/sim/app/templates/page.tsx @@ -1,11 +1,18 @@ import { db } from '@sim/db' import { permissions, templateCreators, templates, workspace } from '@sim/db/schema' import { and, desc, eq } from 'drizzle-orm' +import type { Metadata } from 'next' import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import type { Template } from '@/app/templates/templates' import Templates from '@/app/templates/templates' +export const metadata: Metadata = { + title: 'Templates', + description: + 'Browse pre-built workflow templates to get started quickly with AI agents, automations, and integrations.', +} + /** * Public templates list page. * Redirects authenticated users to their workspace-scoped templates page. diff --git a/apps/sim/app/unsubscribe/page.tsx b/apps/sim/app/unsubscribe/page.tsx index 8f6e6bf688..d1b3ec2de1 100644 --- a/apps/sim/app/unsubscribe/page.tsx +++ b/apps/sim/app/unsubscribe/page.tsx @@ -1,3 +1,9 @@ +import type { Metadata } from 'next' import Unsubscribe from '@/app/unsubscribe/unsubscribe' +export const metadata: Metadata = { + title: 'Unsubscribe', + robots: { index: false }, +} + export default Unsubscribe diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx index 7fa82cc6fb..4469e50a03 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx @@ -32,7 +32,7 @@ export function ResourceOptionsBar({
{search && (
- + {onFilter && ( )} {onSort && ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index 0020321bdf..272afbc86e 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -137,7 +137,7 @@ export function Resource({ > {col.header} {sort.column === col.id && ( - + )} @@ -210,7 +210,7 @@ function CellContent({ cell, primary }: { cell: ResourceCell; primary?: boolean primary ? 'text-[var(--text-body)]' : 'text-[var(--text-secondary)]' )} > - {cell.icon && {cell.icon}} + {cell.icon && {cell.icon}} {cell.label} ) diff --git a/apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/page.tsx b/apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/page.tsx index 1f78f7e688..5333dfa4b8 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/page.tsx @@ -1,9 +1,15 @@ +import type { Metadata } from 'next' import { redirect, unstable_rethrow } from 'next/navigation' import { getSession } from '@/lib/auth' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' import { FileViewer } from '@/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer' +export const metadata: Metadata = { + title: 'File Viewer', + robots: { index: false }, +} + interface FileViewerPageProps { params: Promise<{ workspaceId: string diff --git a/apps/sim/app/workspace/[workspaceId]/files/page.tsx b/apps/sim/app/workspace/[workspaceId]/files/page.tsx index 68f8a6f835..3120469652 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/page.tsx @@ -1,9 +1,15 @@ +import type { Metadata } from 'next' import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { Files } from './files' +export const metadata: Metadata = { + title: 'Files', + robots: { index: false }, +} + interface FilesPageProps { params: Promise<{ workspaceId: string diff --git a/apps/sim/app/workspace/[workspaceId]/home/page.tsx b/apps/sim/app/workspace/[workspaceId]/home/page.tsx index b354c2c520..659b6e6865 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/page.tsx @@ -1,8 +1,13 @@ +import type { Metadata } from 'next' import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' import { Home } from './home' +export const metadata: Metadata = { + title: 'Home', +} + interface HomePageProps { params: Promise<{ workspaceId: string diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx index 93d484d4d8..be3b3d8530 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from 'next' import { Document } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document' interface DocumentPageProps { @@ -11,6 +12,13 @@ interface DocumentPageProps { }> } +export async function generateMetadata({ searchParams }: DocumentPageProps): Promise { + const { docName, kbName } = await searchParams + const title = docName || 'Document' + const parentName = kbName || 'Knowledge Base' + return { title: `${title} — ${parentName}` } +} + export default async function DocumentChunksPage({ params, searchParams }: DocumentPageProps) { const { id, documentId } = await params const { kbName, docName } = await searchParams diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx index 7bb810feb4..219f94d3e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from 'next' import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base' interface PageProps { @@ -9,6 +10,11 @@ interface PageProps { }> } +export async function generateMetadata({ searchParams }: PageProps): Promise { + const { kbName } = await searchParams + return { title: kbName || 'Knowledge Base' } +} + export default async function KnowledgeBasePage({ params, searchParams }: PageProps) { const { id } = await params const { kbName } = await searchParams diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index c62ee4efac..cd6d4c6211 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -100,10 +100,6 @@ export function Knowledge() { setIsCreateModalOpen(true) }, []) - const handleSort = useCallback(() => {}, []) - - const handleFilter = useCallback(() => {}, []) - const handleUpdateKnowledgeBase = useCallback( async (id: string, name: string, description: string) => { await updateKnowledgeBaseMutation({ @@ -208,8 +204,8 @@ export function Knowledge() { placeholder: 'Search knowledge bases...', }} defaultSort='created' - onSort={handleSort} - onFilter={handleFilter} + onSort={() => {}} + onFilter={() => {}} columns={COLUMNS} rows={rows} onRowClick={handleRowClick} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx index a449539d53..d52602721a 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx @@ -1,9 +1,14 @@ +import type { Metadata } from 'next' import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { Knowledge } from './knowledge' +export const metadata: Metadata = { + title: 'Knowledge Base', +} + interface KnowledgePageProps { params: Promise<{ workspaceId: string diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index e4b06cfd5a..de24323dd8 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -12,7 +12,7 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod -
+
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/page.tsx b/apps/sim/app/workspace/[workspaceId]/logs/page.tsx index ee3d9c0bcf..0b0d49ff33 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/page.tsx @@ -1,3 +1,8 @@ +import type { Metadata } from 'next' import Logs from '@/app/workspace/[workspaceId]/logs/logs' +export const metadata: Metadata = { + title: 'Logs', +} + export default Logs diff --git a/apps/sim/app/workspace/[workspaceId]/schedules/page.tsx b/apps/sim/app/workspace/[workspaceId]/schedules/page.tsx index ba726b9487..5e94455961 100644 --- a/apps/sim/app/workspace/[workspaceId]/schedules/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/schedules/page.tsx @@ -1,8 +1,13 @@ +import type { Metadata } from 'next' import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' import { Schedules } from './schedules' +export const metadata: Metadata = { + title: 'Schedules', +} + interface SchedulesPageProps { params: Promise<{ workspaceId: string diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index 718b3a8b88..8a8573987b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -1,12 +1,40 @@ -'use client' - -import { useParams } from 'next/navigation' +import type { Metadata } from 'next' import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' import { SettingsPage } from './settings' -export default function SettingsSectionPage() { - const params = useParams() - const section = params.section as SettingsSection +const SECTION_TITLES: Record = { + general: 'General', + credentials: 'Secrets', + 'template-profile': 'Template Profile', + 'access-control': 'Access Control', + apikeys: 'Sim Keys', + byok: 'BYOK', + subscription: 'Subscription', + team: 'Team', + sso: 'Single Sign-On', + copilot: 'Copilot Keys', + mcp: 'MCP Tools', + 'custom-tools': 'Custom Tools', + skills: 'Skills', + 'workflow-mcp-servers': 'MCP Servers', + 'credential-sets': 'Email Polling', + debug: 'Debug', +} as const - return +export async function generateMetadata({ + params, +}: { + params: Promise<{ section: string }> +}): Promise { + const { section } = await params + return { title: SECTION_TITLES[section] ?? 'Settings' } +} + +export default async function SettingsSectionPage({ + params, +}: { + params: Promise<{ workspaceId: string; section: string }> +}) { + const { section } = await params + return } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index ef1c007f13..600c6d8c8c 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -5,6 +5,7 @@ import { useParams, useRouter } from 'next/navigation' import { Button, Checkbox, Skeleton } from '@/components/emcn' import { Calendar as CalendarIcon, + Plus, Table as TableIcon, TypeBoolean, TypeJson, @@ -14,17 +15,46 @@ import { import { cn } from '@/lib/core/utils/cn' import type { ColumnDefinition, TableRow as TableRowType } from '@/lib/table' import { ResourceHeader, ResourceOptionsBar } from '@/app/workspace/[workspaceId]/components' -import { useCreateTableRow, useUpdateTableRow } from '@/hooks/queries/tables' -import { STRING_TRUNCATE_LENGTH } from '../../constants' +import { useAddTableColumn, useCreateTableRow, useUpdateTableRow } from '@/hooks/queries/tables' import { useContextMenu, useRowSelection, useTableData } from '../../hooks' -import type { CellViewerData, EditingCell, QueryOptions } from '../../types' +import type { EditingCell, QueryOptions } from '../../types' import { cleanCellValue, formatValueForInput } from '../../utils' -import { CellViewerModal } from '../cell-viewer-modal' import { ContextMenu } from '../context-menu' import { RowModal } from '../row-modal' import { SchemaModal } from '../schema-modal' +type SelectedCell = + | { kind: 'data'; rowId: string; columnName: string } + | { kind: 'placeholder'; index: number; columnName: string } + +interface PendingPlaceholder { + rowId: string | null + data: Record +} + const EMPTY_COLUMNS: never[] = [] +const PLACEHOLDER_ROW_COUNT = 50 +const COL_WIDTH = 160 +const CHECKBOX_COL_WIDTH = 40 +const ADD_COL_WIDTH = 120 + +const CELL = 'border-[var(--border)] border-r border-b px-[8px] py-[7px] align-middle' +const CELL_CHECKBOX = 'border-[var(--border)] border-r border-b px-[12px] py-[7px] align-middle' +const CELL_HEADER = + 'border-[var(--border)] border-r border-b bg-white px-[8px] py-[7px] text-left align-middle dark:bg-[var(--bg)]' +const CELL_HEADER_CHECKBOX = + 'border-[var(--border)] border-r border-b bg-white px-[12px] py-[7px] text-left align-middle dark:bg-[var(--bg)]' +const CELL_CONTENT = 'relative min-h-[20px] min-w-0 overflow-hidden truncate text-[13px]' +const SELECTION_OVERLAY = + 'pointer-events-none absolute -top-px -right-px -bottom-px -left-px border-2 border-[var(--brand-tertiary-2)]' + +const COLUMN_TYPE_ICONS: Record = { + string: TypeText, + number: TypeNumber, + boolean: TypeBoolean, + date: CalendarIcon, + json: TypeJson, +} export function Table() { const params = useParams() @@ -44,15 +74,16 @@ export function Table() { const [deletingRows, setDeletingRows] = useState([]) const [showSchemaModal, setShowSchemaModal] = useState(false) const [editingCell, setEditingCell] = useState(null) + const [selectedCell, setSelectedCell] = useState(null) + const [pendingPlaceholders, setPendingPlaceholders] = useState< + Record + >({}) const [editingEmptyCell, setEditingEmptyCell] = useState<{ rowIndex: number columnName: string } | null>(null) - const [cellViewer, setCellViewer] = useState(null) - const [copied, setCopied] = useState(false) - const { tableData, isLoadingTable, rows, totalCount, totalPages, isLoadingRows } = useTableData({ workspaceId, tableId, @@ -70,17 +101,34 @@ export function Table() { const updateRowMutation = useUpdateTableRow({ workspaceId, tableId }) const createRowMutation = useCreateTableRow({ workspaceId, tableId }) + const addColumnMutation = useAddTableColumn({ workspaceId, tableId }) const columns = useMemo( () => tableData?.schema?.columns || EMPTY_COLUMNS, [tableData?.schema?.columns] ) + + const pendingRowIds = useMemo(() => { + const ids = new Set() + for (const pending of Object.values(pendingPlaceholders)) { + if (pending.rowId) ids.add(pending.rowId) + } + return ids + }, [pendingPlaceholders]) + + const visibleRows = useMemo( + () => rows.filter((r) => !pendingRowIds.has(r.id)), + [rows, pendingRowIds] + ) + + const tableWidth = CHECKBOX_COL_WIDTH + columns.length * COL_WIDTH + ADD_COL_WIDTH const selectedCount = selectedRows.size const hasSelection = selectedCount > 0 - const isAllSelected = rows.length > 0 && selectedCount === rows.length + const isAllSelected = visibleRows.length > 0 && selectedCount === visibleRows.length const columnsRef = useRef(columns) const rowsRef = useRef(rows) + const pendingPlaceholdersRef = useRef(pendingPlaceholders) useEffect(() => { columnsRef.current = columns @@ -88,6 +136,9 @@ export function Table() { useEffect(() => { rowsRef.current = rows }, [rows]) + useEffect(() => { + pendingPlaceholdersRef.current = pendingPlaceholders + }, [pendingPlaceholders]) const handleNavigateBack = useCallback(() => { router.push(`/workspace/${workspaceId}/tables`) @@ -97,10 +148,6 @@ export function Table() { setShowAddModal(true) }, []) - const handleSort = useCallback(() => {}, []) - - const handleFilter = useCallback(() => {}, []) - const handleDeleteSelected = useCallback(() => { setDeletingRows(Array.from(selectedRows)) }, [selectedRows]) @@ -127,30 +174,17 @@ export function Table() { [baseHandleRowContextMenu] ) - const handleCopyCellValue = useCallback(async () => { - if (cellViewer) { - let text: string - if (cellViewer.type === 'json') { - text = JSON.stringify(cellViewer.value, null, 2) - } else if (cellViewer.type === 'date') { - text = String(cellViewer.value) - } else { - text = String(cellViewer.value) - } - await navigator.clipboard.writeText(text) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } - }, [cellViewer]) + const handleSelectCell = useCallback((rowId: string, columnName: string) => { + setSelectedCell({ kind: 'data', rowId, columnName }) + }, []) - const handleCellClick = useCallback( - (columnName: string, value: unknown, type: CellViewerData['type']) => { - setCellViewer({ columnName, value, type }) - }, - [] - ) + const handleSelectPlaceholderCell = useCallback((index: number, columnName: string) => { + setSelectedCell({ kind: 'placeholder', index, columnName }) + }, []) const handleCellDoubleClick = useCallback((rowId: string, columnName: string) => { + setSelectedCell({ kind: 'data', rowId, columnName }) + const column = columnsRef.current.find((c) => c.name === columnName) if (!column) return @@ -160,7 +194,13 @@ export function Table() { return } - if (column.type === 'boolean') return + if (column.type === 'boolean') { + const row = rowsRef.current.find((r) => r.id === rowId) + if (row) { + mutateRef.current({ rowId, data: { [columnName]: !row.data[columnName] } }) + } + return + } setEditingCell({ rowId, columnName }) }, []) @@ -170,6 +210,60 @@ export function Table() { mutateRef.current = updateRowMutation.mutate }, [updateRowMutation.mutate]) + const selectedCellRef = useRef(selectedCell) + useEffect(() => { + selectedCellRef.current = selectedCell + }, [selectedCell]) + + const editingCellRef = useRef(editingCell) + useEffect(() => { + editingCellRef.current = editingCell + }, [editingCell]) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const sel = selectedCellRef.current + if (!sel || sel.kind !== 'data' || editingCellRef.current) return + + if (e.key === 'Delete' || e.key === 'Backspace') { + e.preventDefault() + mutateRef.current({ rowId: sel.rowId, data: { [sel.columnName]: null } }) + } + + if ((e.metaKey || e.ctrlKey) && e.key === 'c') { + e.preventDefault() + const row = rowsRef.current.find((r) => r.id === sel.rowId) + if (row) { + const value = row.data[sel.columnName] + let text = '' + if (value !== null && value !== undefined) { + text = typeof value === 'object' ? JSON.stringify(value) : String(value) + } + navigator.clipboard.writeText(text) + } + } + + if ((e.metaKey || e.ctrlKey) && e.key === 'v') { + e.preventDefault() + navigator.clipboard.readText().then((text) => { + const current = selectedCellRef.current + if (!current || current.kind !== 'data') return + const column = columnsRef.current.find((c) => c.name === current.columnName) + if (!column) return + try { + const cleaned = cleanCellValue(text, column) + mutateRef.current({ rowId: current.rowId, data: { [current.columnName]: cleaned } }) + } catch { + // ignore invalid paste values + } + }) + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, []) + const handleInlineSave = useCallback((rowId: string, columnName: string, value: unknown) => { setEditingCell(null) @@ -187,13 +281,6 @@ export function Table() { setEditingCell(null) }, []) - const handleBooleanToggle = useCallback( - (rowId: string, columnName: string, currentValue: boolean) => { - mutateRef.current({ rowId, data: { [columnName]: !currentValue } }) - }, - [] - ) - const handleEmptyRowDoubleClick = useCallback((rowIndex: number, columnName: string) => { const column = columnsRef.current.find((c) => c.name === columnName) if (!column || column.type === 'json' || column.type === 'boolean') return @@ -205,16 +292,65 @@ export function Table() { createRef.current = createRowMutation.mutate }, [createRowMutation.mutate]) - const handleEmptyRowSave = useCallback((columnName: string, value: unknown) => { + const handleEmptyRowSave = useCallback((rowIndex: number, columnName: string, value: unknown) => { setEditingEmptyCell(null) if (value === null || value === undefined || value === '') return - createRef.current({ [columnName]: value }) + + const existing = pendingPlaceholdersRef.current[rowIndex] + const updatedData = { ...(existing?.data || {}), [columnName]: value } + + if (existing?.rowId) { + setPendingPlaceholders((prev) => ({ + ...prev, + [rowIndex]: { ...prev[rowIndex], data: updatedData }, + })) + mutateRef.current({ rowId: existing.rowId, data: { [columnName]: value } }) + } else { + setPendingPlaceholders((prev) => ({ + ...prev, + [rowIndex]: { rowId: null, data: updatedData }, + })) + createRef.current(updatedData, { + onSuccess: (response: Record) => { + const data = response?.data as Record | undefined + const row = data?.row as Record | undefined + const newRowId = row?.id as string | undefined + if (newRowId) { + setPendingPlaceholders((prev) => { + if (!prev[rowIndex]) return prev + return { + ...prev, + [rowIndex]: { ...prev[rowIndex], rowId: newRowId }, + } + }) + } + }, + onError: () => { + setPendingPlaceholders((prev) => { + const next = { ...prev } + delete next[rowIndex] + return next + }) + }, + }) + } }, []) const handleEmptyRowCancel = useCallback(() => { setEditingEmptyCell(null) }, []) + const handleAddColumn = useCallback(() => { + const existing = columnsRef.current.map((c) => c.name.toLowerCase()) + let name = 'untitled' + let i = 2 + while (existing.includes(name.toLowerCase())) { + name = `untitled_${i}` + i++ + } + addColumnMutation.mutate({ name, type: 'string' }) + }, [addColumnMutation]) + if (isLoadingTable) { return (
@@ -238,34 +374,42 @@ export function Table() { breadcrumbs={[{ label: 'Tables', onClick: handleNavigateBack }, { label: tableData.name }]} /> - + {}} onFilter={() => {}} />
- - - - {columns.map((col) => ( - - ))} - - +
+ + - {columns.map((column) => ( - ))} + @@ -273,7 +417,7 @@ export function Table() { ) : ( <> - {rows.map((row) => ( + {visibleRows.map((row) => ( ))} )} @@ -318,9 +469,7 @@ export function Table() { isOpen={true} onClose={() => setShowAddModal(false)} table={tableData} - onSuccess={() => { - setShowAddModal(false) - }} + onSuccess={() => setShowAddModal(false)} /> )} @@ -331,9 +480,7 @@ export function Table() { onClose={() => setEditingRow(null)} table={tableData} row={editingRow} - onSuccess={() => { - setEditingRow(null) - }} + onSuccess={() => setEditingRow(null)} /> )} @@ -358,13 +505,6 @@ export function Table() { tableName={tableData.name} /> - setCellViewer(null)} - onCopy={handleCopyCellValue} - copied={copied} - /> - + + {columns.map((col) => ( + + ))} + + + ) +} + +function SelectionOverlay() { + return
+} + const DataRow = React.memo(function DataRow({ row, columns, isSelected, editingColumnName, - onCellClick, + selectedColumnName, onDoubleClick, onSave, onCancel, - onBooleanToggle, onContextMenu, onSelectRow, + onSelectCell, }: { row: TableRowType columns: ColumnDefinition[] isSelected: boolean editingColumnName: string | null - onCellClick: (columnName: string, value: unknown, type: CellViewerData['type']) => void + selectedColumnName: string | null onDoubleClick: (rowId: string, columnName: string) => void onSave: (rowId: string, columnName: string, value: unknown) => void onCancel: () => void - onBooleanToggle: (rowId: string, columnName: string, currentValue: boolean) => void onContextMenu: (e: React.MouseEvent, row: TableRowType) => void onSelectRow: (rowId: string) => void + onSelectCell: (rowId: string, columnName: string) => void }) { return (
onContextMenu(e, row)} > - - {columns.map((column) => ( - - ))} + {columns.map((column) => { + const isColumnSelected = selectedColumnName === column.name + return ( + + ) + })} ) }) @@ -437,20 +594,14 @@ function CellContent({ value, column, isEditing, - onCellClick, - onDoubleClick, onSave, onCancel, - onBooleanToggle, }: { value: unknown column: ColumnDefinition isEditing: boolean - onCellClick: (columnName: string, value: unknown, type: CellViewerData['type']) => void - onDoubleClick: () => void onSave: (value: unknown) => void onCancel: () => void - onBooleanToggle: () => void }) { if (isEditing) { return @@ -461,75 +612,25 @@ function CellContent({ if (column.type === 'boolean') { const boolValue = Boolean(value) return ( - - ) - } - - if (isNull) { - return ( - { - e.stopPropagation() - onDoubleClick() - }} - > - — + + {isNull ? '' : boolValue ? 'true' : 'false'} ) } + if (isNull) return null + if (column.type === 'json') { - const jsonStr = JSON.stringify(value) return ( - + + {JSON.stringify(value)} + ) } if (column.type === 'number') { return ( - { - e.stopPropagation() - onDoubleClick() - }} - > - {String(value)} - + {String(value)} ) } @@ -543,66 +644,13 @@ function CellContent({ hour: '2-digit', minute: '2-digit', }) - return ( - { - e.stopPropagation() - onDoubleClick() - }} - > - {formatted} - - ) + return {formatted} } catch { - return ( - { - e.stopPropagation() - onDoubleClick() - }} - > - {String(value)} - - ) + return {String(value)} } } - const strValue = String(value) - if (strValue.length > STRING_TRUNCATE_LENGTH) { - return ( - - ) - } - - return ( - { - e.stopPropagation() - onDoubleClick() - }} - > - {strValue} - - ) + return {String(value)} } function InlineEditor({ @@ -631,10 +679,8 @@ function InlineEditor({ const handleSave = () => { if (doneRef.current) return doneRef.current = true - try { - const cleaned = cleanCellValue(draft, column) - onSave(cleaned) + onSave(cleanCellValue(draft, column)) } catch { onCancel() } @@ -661,7 +707,14 @@ function InlineEditor({ onChange={(e) => setDraft(e.target.value)} onKeyDown={handleKeyDown} onBlur={handleSave} - className='h-full w-full rounded-[2px] border-none bg-transparent px-[4px] py-[2px] text-[13px] text-[var(--text-primary)] outline-none ring-1 ring-[var(--accent)] ring-inset' + className={cn( + 'h-full w-full min-w-0 border-none bg-transparent p-0 outline-none', + column.type === 'number' + ? 'font-mono text-[12px] text-[var(--text-secondary)]' + : column.type === 'date' + ? 'text-[12px] text-[var(--text-secondary)]' + : 'text-[13px] text-[var(--text-primary)]' + )} /> ) } @@ -708,7 +761,7 @@ function TableSkeleton({ columns }: { columns: ColumnDefinition[] }) { <> {Array.from({ length: 25 }).map((_, rowIndex) => ( - {columns.map((col, colIndex) => { @@ -724,14 +777,10 @@ function TableSkeleton({ columns }: { columns: ColumnDefinition[] }) { : col.type === 'date' ? 100 : 120 - const variation = ((rowIndex + colIndex) % 3) * 20 - const width = baseWidth + variation + const width = baseWidth + ((rowIndex + colIndex) % 3) * 20 return ( - ) @@ -742,64 +791,81 @@ function TableSkeleton({ columns }: { columns: ColumnDefinition[] }) { ) } -const PLACEHOLDER_ROW_COUNT = 50 - function PlaceholderRows({ columns, editingEmptyCell, + pendingPlaceholders, + selectedCell, onDoubleClick, onSave, onCancel, + onSelectCell, }: { columns: ColumnDefinition[] editingEmptyCell: { rowIndex: number; columnName: string } | null + pendingPlaceholders: Record + selectedCell: SelectedCell | null onDoubleClick: (rowIndex: number, columnName: string) => void - onSave: (columnName: string, value: unknown) => void + onSave: (rowIndex: number, columnName: string, value: unknown) => void onCancel: () => void + onSelectCell: (index: number, columnName: string) => void }) { return ( <> - {Array.from({ length: PLACEHOLDER_ROW_COUNT }).map((_, i) => ( - - + - ) - })} - - ))} + return ( + + ) + })} + + ) + })} ) } -const COLUMN_TYPE_ICONS: Record = { - string: TypeText, - number: TypeNumber, - boolean: TypeBoolean, - date: CalendarIcon, - json: TypeJson, -} - function ColumnTypeIcon({ type }: { type: string }) { const Icon = COLUMN_TYPE_ICONS[type] ?? TypeText - return + return } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx index 69b92be4f4..8ea13d7f0c 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from 'next' import { Table } from './components' +export const metadata: Metadata = { + title: 'Table', +} + export default function TablePage() { return
+ -
+
+
- + {column.name}
+ +
+ onSelectRow(row.id)} /> -
- onDoubleClick(row.id, column.name)} - onSave={(value) => onSave(row.id, column.name, value)} - onCancel={onCancel} - onBooleanToggle={() => - onBooleanToggle(row.id, column.name, Boolean(row.data[column.name])) - } - /> -
-
onSelectCell(row.id, column.name)} + onDoubleClick={() => onDoubleClick(row.id, column.name)} + > + {isColumnSelected && } +
+ onSave(row.id, column.name, value)} + onCancel={onCancel} + /> +
+
+ +
- {columns.map((col) => { - const isEditing = - editingEmptyCell?.rowIndex === i && editingEmptyCell.columnName === col.name + {Array.from({ length: PLACEHOLDER_ROW_COUNT }).map((_, i) => { + const pending = pendingPlaceholders[i] + return ( +
+ {columns.map((col) => { + const isEditing = + editingEmptyCell?.rowIndex === i && editingEmptyCell.columnName === col.name + const pendingValue = pending?.data[col.name] + const hasPendingValue = pendingValue !== undefined && pendingValue !== null + const isColumnSelected = + selectedCell?.kind === 'placeholder' && + selectedCell.index === i && + selectedCell.columnName === col.name - return ( - onDoubleClick(i, col.name)} - > - {isEditing ? ( - onSave(col.name, value)} - onCancel={onCancel} - /> - ) : ( -
- )} -
onSelectCell(i, col.name)} + onDoubleClick={() => onDoubleClick(i, col.name)} + > + {isColumnSelected && } + {isEditing ? ( + onSave(i, col.name, value)} + onCancel={onCancel} + /> + ) : hasPendingValue ? ( +
+ {}} + onCancel={() => {}} + /> +
+ ) : ( +
+ )} +
} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/create-modal/create-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/create-modal/create-modal.tsx deleted file mode 100644 index 3174057867..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/create-modal/create-modal.tsx +++ /dev/null @@ -1,330 +0,0 @@ -'use client' - -import { useState } from 'react' -import { createLogger } from '@sim/logger' -import { Plus, Trash2 } from 'lucide-react' -import { nanoid } from 'nanoid' -import { useParams } from 'next/navigation' -import { - Button, - Checkbox, - Combobox, - Input, - Label, - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, - Textarea, -} from '@/components/emcn' -import { cn } from '@/lib/core/utils/cn' -import type { ColumnDefinition } from '@/lib/table' -import { useCreateTable } from '@/hooks/queries/tables' - -const logger = createLogger('CreateModal') - -interface CreateModalProps { - isOpen: boolean - onClose: () => void -} - -const COLUMN_TYPE_OPTIONS: Array<{ value: ColumnDefinition['type']; label: string }> = [ - { value: 'string', label: 'String' }, - { value: 'number', label: 'Number' }, - { value: 'boolean', label: 'Boolean' }, - { value: 'date', label: 'Date' }, - { value: 'json', label: 'JSON' }, -] - -interface ColumnWithId extends ColumnDefinition { - id: string -} - -function createEmptyColumn(): ColumnWithId { - return { id: nanoid(), name: '', type: 'string', required: true, unique: false } -} - -export function CreateModal({ isOpen, onClose }: CreateModalProps) { - const params = useParams() - const workspaceId = params.workspaceId as string - - const [tableName, setTableName] = useState('') - const [description, setDescription] = useState('') - const [columns, setColumns] = useState([createEmptyColumn()]) - const [error, setError] = useState(null) - - const createTable = useCreateTable(workspaceId) - - const handleAddColumn = () => { - setColumns([...columns, createEmptyColumn()]) - } - - const handleRemoveColumn = (columnId: string) => { - if (columns.length > 1) { - setColumns(columns.filter((col) => col.id !== columnId)) - } - } - - const handleColumnChange = ( - columnId: string, - field: keyof ColumnDefinition, - value: string | boolean - ) => { - setColumns(columns.map((col) => (col.id === columnId ? { ...col, [field]: value } : col))) - } - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setError(null) - - if (!tableName.trim()) { - setError('Table name is required') - return - } - - // Validate column names - const validColumns = columns.filter((col) => col.name.trim()) - if (validColumns.length === 0) { - setError('At least one column is required') - return - } - - // Check for duplicate column names - const columnNames = validColumns.map((col) => col.name.toLowerCase()) - const uniqueNames = new Set(columnNames) - if (uniqueNames.size !== columnNames.length) { - setError('Duplicate column names found') - return - } - - // Strip internal IDs before sending to API - const columnsForApi = validColumns.map(({ id: _id, ...col }) => col) - - try { - await createTable.mutateAsync({ - name: tableName, - description: description || undefined, - schema: { - columns: columnsForApi, - }, - }) - - // Reset form - resetForm() - onClose() - } catch (err) { - logger.error('Failed to create table:', err) - setError(err instanceof Error ? err.message : 'Failed to create table') - } - } - - const resetForm = () => { - setTableName('') - setDescription('') - setColumns([createEmptyColumn()]) - setError(null) - } - - const handleClose = () => { - resetForm() - onClose() - } - - return ( - - - Create Table - - - -
-
-

- Define your table schema with columns and constraints. -

- - {error && ( -

{error}

- )} - -
- - ) => - setTableName(e.target.value) - } - placeholder='customers, orders, products' - className={cn( - error === 'Table name is required' && 'border-[var(--text-error)]' - )} - required - /> -

- Use lowercase with underscores (e.g., customer_orders) -

-
- -
- -