Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,6 @@ dataflow/.env
.agents/
.worktrees/

.claude/
.claude/

.github/skills/
6 changes: 6 additions & 0 deletions .impeccable/live/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"files": ["dataflow/index.html"],
"insertBefore": "</body>",
"commentSyntax": "html",
"cspChecked": true
}
24 changes: 24 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ _Avoid_: document table mode
The editor for changing an entire **MongoDB Document** as JSON.
_Avoid_: document table mode, field-level editor

**Workspace Tab**:
A database exploration surface tied to a query or a storage unit.
_Avoid_: browser tab, panel

**Sidebar Focus**:
The sidebar tree item that represents the active **Workspace Tab**'s closest database context.
_Avoid_: hover state, keyboard focus

**Sidebar Reveal**:
The behavior that makes the **Sidebar Focus** visible in the sidebar tree.
_Avoid_: expand all, jump to item

## Relationships

- A **MongoDB Collection** contains zero or more **MongoDB Documents**.
Expand All @@ -58,18 +70,30 @@ _Avoid_: document table mode, field-level editor
- A **Field JSON Editor** accepts any valid JSON value, even when that changes an object or array field into a scalar or `null`.
- A **MongoDB Document** is edited through a **Document JSON Editor**.
- A **Complex Document Field** is not edited through a separate field-level interaction inside the **Document JSON Editor**.
- A **Workspace Tab** has zero or one **Sidebar Focus**.
- A storage-unit **Workspace Tab** focuses its table, view, collection, or Redis key.
- A query **Workspace Tab** focuses the schema, database, or connection, whichever is most specific.
- A **Sidebar Reveal** expands collapsed ancestors of the **Sidebar Focus** and scrolls the focus into view.

## Example Dialogue

> **Dev:** "Should MongoDB open in the table by default?"
> **Domain expert:** "Yes. Open MongoDB collections in the **Collection Table View** by default because users expect a grid for browsing. Keep the **JSON View** available as a switchable document-focused view."
>
> **Dev:** "When users switch between **Workspace Tabs**, should the sidebar keep the last clicked tree item?"
> **Domain expert:** "No. The sidebar should show the **Sidebar Focus** for the active **Workspace Tab**."
>
> **Dev:** "If that focus is hidden under a collapsed folder or outside the visible sidebar area, should we leave the tree as-is?"
> **Domain expert:** "No. Use **Sidebar Reveal** so the focused item is visible without expanding unrelated branches."

## Flagged Ambiguities

### Terminology

- "table view" in MongoDB means **Collection Table View**, not a relational database table.
- The document editor should be a **Document JSON Editor**, not a table view, field list, or field-level editor.
- "focus" in the sidebar means **Sidebar Focus**, not hover state or keyboard focus.
- "auto expand" in the sidebar means **Sidebar Reveal**, not expanding every folder in the tree.
- The **Collection Table View** is the default MongoDB collection view.
- The **Collection Table View** should build its first column set from a limited default sample, not by scanning the full collection.
- The **Collection Table View** supports sorting and filtering on top-level document fields.
Expand Down
33 changes: 33 additions & 0 deletions PRODUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Product

## Register

product

## Users

DataFlow is used by developers, operators, and data-facing teammates who need to inspect and maintain databases from a single workspace. They are usually browsing collections, tables, keys, and query results while keeping enough context to avoid accidental data changes.

## Product Purpose

DataFlow provides a Sealos-native database workspace for browsing records, running SQL, MongoDB, and Redis commands, and turning query results into charts and dashboards. Success means users can move from database structure to data editing or analysis with clear state, predictable controls, and low friction.

## Brand Personality

Lightweight, capable, calm. The interface should feel less heavy than a traditional admin console while still earning trust for database operations.

## Anti-references

Avoid dense enterprise-console clutter, decorative dashboards that slow down the task, and playful visual treatments that make resource mutation feel casual. Avoid calling MongoDB collections tables except when referring specifically to the Collection Table View.

## Design Principles

- Keep the workspace light enough for daily use.
- Make database state and pending mutations visible before action.
- Use familiar product UI patterns for high-frequency controls.
- Preserve MongoDB's document model while offering grid-based browsing.
- Let analysis tools sit close to the data without competing with core browsing.

## Accessibility & Inclusion

Target WCAG AA for contrast, focus visibility, keyboard access, and readable control labels. Respect reduced-motion preferences and avoid using color alone to communicate state.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState } from 'react'
import { Download, Plus, Minus, Undo2, Eye, SendHorizontal, RefreshCw, TerminalSquare, BarChart3, Table2, FileJson } from 'lucide-react'
import { Separator } from '@/components/ui/separator'
import { useCollectionView } from './CollectionViewProvider'
import type { MongoCollectionViewMode } from './types'
import { DataView } from '@/components/database/shared/DataView'
import { Button } from '@/components/ui/Button'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
Expand All @@ -23,8 +24,6 @@ export function CollectionViewToolbar({ connectionId, databaseName, collectionNa
const { state, actions } = useCollectionView()
const openTab = useTabStore((s) => s.openTab)
const [isChartModalOpen, setIsChartModalOpen] = useState(false)
const nextViewMode = state.viewMode === 'table' ? 'json' : 'table'
const ViewSwitchIcon = nextViewMode === 'table' ? Table2 : FileJson

const handleOpenQuery = () => {
openTab({
Expand Down Expand Up @@ -69,29 +68,7 @@ export function CollectionViewToolbar({ connectionId, databaseName, collectionNa
<TooltipContent>{t('common.actions.refresh')}</TooltipContent>
</Tooltip>

<Separator orientation="vertical" className="mx-1 data-[orientation=vertical]:h-4" />

<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => actions.setViewMode(nextViewMode)}
data-testid="mongodb.collection.view-toggle-button"
data-qa-module="mongodb"
data-qa-object="collection-view-mode"
data-qa-action={nextViewMode === 'table' ? 'switch-to-table' : 'switch-to-json'}
data-qa-state={state.viewMode}
>
<ViewSwitchIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{nextViewMode === 'table' ? t('mongodb.view.switchToTable') : t('mongodb.view.switchToJson')}
</TooltipContent>
</Tooltip>

<Separator orientation="vertical" className="mx-1 data-[orientation=vertical]:h-4" />
<Separator orientation="vertical" className="data-[orientation=vertical]:h-4" />

<Tooltip>
<TooltipTrigger asChild>
Expand Down Expand Up @@ -217,6 +194,11 @@ export function CollectionViewToolbar({ connectionId, databaseName, collectionNa
</Tooltip>
</div>
<div className="flex items-center gap-2">
<CollectionViewModeSwitch
currentMode={state.viewMode}
onSelect={actions.setViewMode}
/>
<Separator orientation="vertical" className="mx-1 data-[orientation=vertical]:h-4" />
<DataView.FilterButton
onClick={() => actions.setIsFilterModalOpen(true)}
count={Object.keys(state.activeFilter).length}
Expand Down Expand Up @@ -258,3 +240,88 @@ export function CollectionViewToolbar({ connectionId, databaseName, collectionNa
</div>
)
}

interface CollectionViewModeSwitchProps {
currentMode: MongoCollectionViewMode
onSelect: (mode: MongoCollectionViewMode) => void
}

/** Two-option MongoDB collection view-mode switch. */
function CollectionViewModeSwitch({ currentMode, onSelect }: CollectionViewModeSwitchProps) {
const { t } = useI18n()

return (
<div
className="relative inline-grid h-9 w-[74px] grid-cols-2 items-center overflow-hidden rounded-lg border border-accent bg-accent"
role="group"
aria-label={t('mongodb.view.selectorLabel')}
data-testid="mongodb.collection.view-mode-buttons"
data-qa-module="mongodb"
data-qa-object="collection-view-mode"
data-qa-state={currentMode}
>
<span
aria-hidden="true"
style={{ transform: currentMode === 'json' ? 'translateX(36px)' : 'translateX(0)' }}
className={cn(
'pointer-events-none absolute left-0.5 top-0.5 h-[30px] w-8 rounded-md bg-background shadow-[0_1px_2px_oklch(0.145_0_0_/_0.1),0_3px_8px_oklch(0.145_0_0_/_0.05)] ring-1 ring-border/60 transition-transform duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none',
)}
/>
<CollectionViewModeButton
mode="table"
currentMode={currentMode}
ariaLabel={t('mongodb.view.table')}
tooltip={currentMode === 'table' ? t('mongodb.view.table') : t('mongodb.view.switchToTable')}
onSelect={onSelect}
/>
<CollectionViewModeButton
mode="json"
currentMode={currentMode}
ariaLabel={t('mongodb.view.json')}
tooltip={currentMode === 'json' ? t('mongodb.view.json') : t('mongodb.view.switchToJson')}
onSelect={onSelect}
/>
</div>
)
}

interface CollectionViewModeButtonProps {
mode: MongoCollectionViewMode
currentMode: MongoCollectionViewMode
ariaLabel: string
tooltip: string
onSelect: (mode: MongoCollectionViewMode) => void
}

/** Single option in the MongoDB collection view-mode control. */
function CollectionViewModeButton({ mode, currentMode, ariaLabel, tooltip, onSelect }: CollectionViewModeButtonProps) {
const active = currentMode === mode
const Icon = mode === 'table' ? Table2 : FileJson

return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={ariaLabel}
aria-pressed={active}
onClick={() => onSelect(mode)}
data-testid={`mongodb.collection.view-${mode}-button`}
data-qa-module="mongodb"
data-qa-object="collection-view-mode"
data-qa-action={`switch-to-${mode}`}
data-qa-state={active ? 'active' : 'inactive'}
className={cn(
'relative z-10 h-8 w-9 rounded-md text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground',
active && 'text-primary hover:text-primary',
)}
>
<Icon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
)
}
42 changes: 38 additions & 4 deletions dataflow/src/components/sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useState, useCallback, useReducer } from "react";
import React, { useState, useCallback, useEffect, useMemo, useReducer } from "react";

import { useConnectionStore } from "@/stores/useConnectionStore";
import { useTabStore } from "@/stores/useTabStore";
import { useTabStore, type Tab } from "@/stores/useTabStore";
import { ContextMenu } from "../ui/ContextMenu";
import type { Alert } from "@/components/ui/types";

Expand All @@ -22,6 +22,22 @@ import {
} from "./contextMenuItems";
import { SidebarModals } from "./SidebarModals";
import { useI18n } from "@/i18n/useI18n";
import { getSidebarSelectionForTab } from "./sidebar-selection";

function getSidebarFocusKey(tab: Tab | null): string {
if (!tab) return "no-active-tab";

return JSON.stringify([
tab.id,
tab.type,
tab.connectionId,
tab.databaseName ?? null,
tab.schemaName ?? null,
tab.tableName ?? null,
tab.storageUnitType ?? null,
tab.collectionName ?? null,
]);
}

// ── Modal reducer (inlined from former useSidebarModals) ────────────

Expand Down Expand Up @@ -58,12 +74,12 @@ function modalReducer(_state: ModalState | null, action: Action): ModalState | n

function SidebarInner() {
const { connections, selectedItem, selectItem, systemSchemas, showSystemObjectsFor, toggleSystemObjects, triggerCollectionRefresh } = useConnectionStore();
const { openTab } = useTabStore();
const { tabs, activeTabId, openTab } = useTabStore();
const { t } = useI18n();

const {
expandedItems, treeData, isLoading,
toggleItem, fetchNodeChildren, refreshNode,
toggleItem, fetchNodeChildren, refreshNode, revealNode,
} = useSidebarTree();

// Modal state (inlined from former useSidebarModals)
Expand Down Expand Up @@ -92,6 +108,23 @@ function SidebarInner() {
node: TreeNodeData;
} | null>(null);

const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? null;
const sidebarFocusKey = getSidebarFocusKey(activeTab);
const sidebarSelection = useMemo(
() => getSidebarSelectionForTab(activeTab, connections),
// Ignore title/sqlContent/isDirty changes; they do not affect sidebar focus.
[connections, sidebarFocusKey],
);

useEffect(() => {
selectItem(sidebarSelection);
if (sidebarSelection) {
void revealNode(sidebarSelection).catch((error) => {
console.error("Failed to reveal sidebar selection:", sidebarSelection.id, error);
});
}
}, [revealNode, selectItem, sidebarSelection]);

const handleItemClick = useCallback(
async (node: TreeNodeData) => {
selectItem(node);
Expand Down Expand Up @@ -121,6 +154,7 @@ function SidebarInner() {
databaseName: node.metadata.database,
schemaName: node.metadata.schema,
tableName: node.name,
storageUnitType: node.type,
});
} else if (node.type === "collection") {
const collectionTitle = node.metadata.database
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { createContext, use } from "react";
import React, { createContext, use, useEffect, useRef } from "react";
import {
ChevronRight, ChevronDown, Loader2,
Database, ListTree, Table, Files, Eye, Folder,
Expand Down Expand Up @@ -55,6 +55,7 @@ interface TreeNodeProps {
}

export function TreeNode({ node, depth }: TreeNodeProps) {
const nodeRef = useRef<HTMLDivElement>(null);
const { expandedItems, isLoading: loadingItems, treeData } = useSidebarTree();
const {
selectedItemId,
Expand All @@ -76,9 +77,15 @@ export function TreeNode({ node, depth }: TreeNodeProps) {
: NODE_ICONS[node.type];
const brandIcon = isRoot ? DB_ICONS[connectionDbType] : null;

useEffect(() => {
if (!isSelected) return;
nodeRef.current?.scrollIntoView({ block: "nearest", inline: "nearest" });
}, [isSelected]);

return (
<div>
<div
ref={nodeRef}
data-testid="database.sidebar.tree-node"
data-qa-module="database"
data-qa-object="sidebar-node"
Expand Down
Loading
Loading