reopt designreopt design
DocsExploreToolsPricingBuilder
시작하기
개요
시작하기
Next.js 설치
Private install
핵심 개념
아키텍처
컴포지션 패턴
접근성
키보드 패턴
스타일링
테마 시스템
고급 패턴
구축·운영
Skills
AI 연동
CLI (opt surface add)
의존 그래프
도구
Canvas 카탈로그
Theme Builder
Form Builder
템플릿
템플릿
릴리즈
릴리즈 노트
Oopt-ui
reopt designreopt design

AI 시대를 위한 디자인 시스템

  • 문서
  • 가격
  • 릴리즈 노트
  • GitHub
  • 서비스 약관
  • 개인정보처리방침

© 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";