reopt designreopt design
DocsExploreToolsPricingBuilder
Start
Overview
Start
Next.js 설치
Private install
Core Concepts
아키텍처
Composition Patterns
Accessibility
Keyboard Patterns
Styling
Theme System
Advanced Patterns
Build & Operate
Skills
AI Integration
CLI (opt surface add)
Dependency Graph
Tools
Canvas Catalog
Theme Builder
Form Builder
Templates
Templates
Releases
Release Notes
Oopt-ui
reopt designreopt design

A design system for the AI era

  • Docs
  • Pricing
  • Releases
  • GitHub
  • Terms of Service
  • Privacy Policy

© 2026 reopt-ai. All rights reserved.

SqlWorkspace

surface

SQL 에디터 + 쿼리 결과 테이블. SqlEditor와 QueryResultsTable 조합.

컴포넌트 의존 관계

깊이
▼ USES (3)SqlWorkspacesql-editorquery-results-tableloading-overlay
100%

기본 사용

SQL Workspace

Ctrl+Enter to run

No results

테스트 커버리지

2026년 2월 4일

생성된 테스트 결과를 찾지 못했습니다.

SqlWorkspace 항목이 문서 메타에 연결되어 있지만 현재 생성 파일에는 없습니다.

테스트를 추가한 뒤 `bun run generate:test-results`를 실행하거나 `testDescribe` 매핑을 다시 확인하세요.

SqlWorkspace Props

Prop타입기본값설명
initialQuerystring""초기 SQL 쿼리
onRun(sql: string) => Promise<{ columns: string[]; rows: Record<string, unknown>[] }>—쿼리 실행 핸들러
classNamestring—최외곽 CSS 클래스

Surface 설치

CLI가 공식 배포 채널입니다. 필요한 Surface를 프로젝트로 복사한 뒤 직접 수정할 수 있습니다.

bash
npx @reopt-ai/opt-cli surface add sql-workspace

Consumer target

복사된 파일은 components/surfaces 아래에 저장됩니다.

tsx
import { SqlWorkspace } from "@/components/surfaces/sql-workspace";

Registry metadata

설명
SQL 에디터 + 쿼리 결과 테이블. SqlEditor와 QueryResultsTable 조합.
파일 수
1개
Registry dependencies
없음
Package dependencies
없음
태그
없음
Install notes
없음

포함 파일

  • sql-workspace.tsx→sql-workspace.tsx
Surface 소스 보기
sql-workspace.tsx
"use client";

import * as React from "react";
import {
  SqlEditor,
  QueryResultsTable,
  SurfaceLayout,
  Button,
} from "@reopt-ai/opt-ui";

/** Data shape for `SavedQuery`. */
export interface SavedQuery {
  id: string;
  name: string;
  sql: string;
  createdAt?: string;
}

/** Data shape for `ExampleQuery`. */
export interface ExampleQuery {
  label: string;
  sql: string;
  description?: string;
}

/** Labels for `SqlWorkspace`. */
export interface SqlWorkspaceLabels {
  title?: string;
  emptyTitle?: string;
  emptyDescription?: string;
  savedQueriesLabel?: string;
  exampleQueriesLabel?: string;
  saveButton?: string;
  noSavedQueries?: string;
}

const defaultLabels: Required<SqlWorkspaceLabels> = {
  title: "SQL Workspace",
  emptyTitle: "No results",
  emptyDescription: "Run a query to see results.",
  savedQueriesLabel: "Saved Queries",
  exampleQueriesLabel: "Examples",
  saveButton: "Save",
  noSavedQueries: "No saved queries",
};

/** Props for `SqlWorkspace`. */
export interface SqlWorkspaceProps {
  initialQuery?: string;
  onRun?: (
    sql: string,
  ) => Promise<{ columns: string[]; rows: Array<Record<string, unknown>> }>;
  /** Sidebar content (e.g., saved queries, example queries) */
  sidebar?: React.ReactNode;
  /** Saved query list displayed in auto-generated sidebar */
  savedQueries?: SavedQuery[];
  /** Callback when a saved query is selected */
  onSavedQuerySelect?: (query: { id: string; sql: string }) => void;
  /** Callback to save current query */
  onSaveQuery?: (name: string, sql: string) => void;
  /** Callback to delete a saved query */
  onDeleteQuery?: (id: string) => void;
  /** Example queries displayed in auto-generated sidebar */
  exampleQueries?: ExampleQuery[];
  loading?: boolean;
  header?: React.ReactNode;
  actions?: React.ReactNode;
  labels?: SqlWorkspaceLabels;
  className?: string;
}

/** Renders the `SqlWorkspace` component. */
export function SqlWorkspace({
  initialQuery = "",
  onRun,
  sidebar,
  savedQueries,
  onSavedQuerySelect,
  onSaveQuery,
  onDeleteQuery,
  exampleQueries,
  loading: surfaceLoading = false,
  header,
  actions,
  labels: customLabels,
  className,
}: SqlWorkspaceProps) {
  const labels = { ...defaultLabels, ...customLabels };
  const titleId = React.useId();

  const [sql, setSql] = React.useState(initialQuery);
  const [columns, setColumns] = React.useState<string[]>([]);
  const [rows, setRows] = React.useState<Array<Record<string, unknown>>>([]);
  const [loading, setLoading] = React.useState(false);
  const [error, setError] = React.useState<string>();

  const handleRun = React.useCallback(
    async (query: string) => {
      if (!onRun) return;
      setLoading(true);
      setError(undefined);
      try {
        const result = await onRun(query);
        setColumns(result.columns);
        setRows(result.rows);
      } catch (e) {
        setError(e instanceof Error ? e.message : String(e));
      } finally {
        setLoading(false);
      }
    },
    [onRun],
  );

  // Auto-generate sidebar when savedQueries or exampleQueries are provided
  // and no explicit sidebar prop is given (sidebar takes precedence for backward compat)
  const hasSavedOrExamples =
    (savedQueries && savedQueries.length > 0) ||
    (exampleQueries && exampleQueries.length > 0);
  const showSidebar = sidebar || hasSavedOrExamples;

  const autoSidebar =
    !sidebar && hasSavedOrExamples ? (
      <div className="gap-group flex flex-col">
        {/* Saved Queries section */}
        {savedQueries !== undefined && (
          <div>
            <div className="mb-2 flex items-center justify-between">
              <h3 className="text-text-primary text-sm font-semibold">
                {labels.savedQueriesLabel}
              </h3>
              {onSaveQuery && (
                <Button
                  variant="ghost"
                  size="sm"
                  onClick={() => {
                    const name = `Query ${(savedQueries?.length ?? 0) + 1}`;
                    onSaveQuery(name, sql);
                  }}
                >
                  {labels.saveButton}
                </Button>
              )}
            </div>
            {savedQueries.length === 0 ? (
              <p className="text-text-tertiary text-xs">
                {labels.noSavedQueries}
              </p>
            ) : (
              <ul className="gap-element flex flex-col">
                {savedQueries.map((q) => (
                  <li
                    key={q.id}
                    className="group flex items-center justify-between"
                  >
                    <button
                      type="button"
                      className="text-text-secondary hover:text-text-primary truncate text-left text-sm"
                      onClick={() =>
                        onSavedQuerySelect?.({ id: q.id, sql: q.sql })
                      }
                    >
                      {q.name}
                    </button>
                    {onDeleteQuery && (
                      <button
                        type="button"
                        className="text-text-tertiary hover:text-danger ml-1 shrink-0 text-xs opacity-0 group-hover:opacity-100"
                        onClick={() => onDeleteQuery(q.id)}
                        aria-label={`Delete ${q.name}`}
                      >
                        ✕
                      </button>
                    )}
                  </li>
                ))}
              </ul>
            )}
          </div>
        )}

        {/* Example Queries section */}
        {exampleQueries && exampleQueries.length > 0 && (
          <div>
            <h3 className="text-text-primary mb-2 text-sm font-semibold">
              {labels.exampleQueriesLabel}
            </h3>
            <ul className="gap-element flex flex-col">
              {exampleQueries.map((eq, i) => (
                <li key={i}>
                  <button
                    type="button"
                    className="text-text-secondary hover:text-text-primary w-full text-left text-sm"
                    title={eq.description}
                    onClick={() => {
                      setSql(eq.sql);
                      onSavedQuerySelect?.({ id: `example-${i}`, sql: eq.sql });
                    }}
                  >
                    {eq.label}
                  </button>
                </li>
              ))}
            </ul>
          </div>
        )}
      </div>
    ) : null;

  const sidebarContent = sidebar || autoSidebar;

  return (
    <SurfaceLayout loading={surfaceLoading} className={className}>
      {header || actions ? (
        <div className="flex items-center justify-between">
          {header && <div>{header}</div>}
          {actions && (
            <div className="gap-element flex items-center">{actions}</div>
          )}
        </div>
      ) : (
        <h2 id={titleId} className="text-text-primary text-lg font-semibold">
          {labels.title}
        </h2>
      )}

      {showSidebar ? (
        <div className="gap-section flex">
          <aside className="border-border w-64 shrink-0 overflow-y-auto border-r pr-4">
            {sidebarContent}
          </aside>
          <div className="gap-section flex min-w-0 flex-1 flex-col">
            <SqlEditor value={sql} onChange={setSql} onRun={handleRun} />
            <QueryResultsTable
              columns={columns}
              rows={rows}
              loading={loading}
              error={error}
            />
          </div>
        </div>
      ) : (
        <>
          <SqlEditor value={sql} onChange={setSql} onRun={handleRun} />
          <QueryResultsTable
            columns={columns}
            rows={rows}
            loading={loading}
            error={error}
          />
        </>
      )}
    </SurfaceLayout>
  );
}

SqlWorkspace.displayName = "SqlWorkspace";