feat(appliance): initial release of MERICA standalone appliance v1.0.0

This commit is contained in:
2026-10-21 17:16:46 +07:00
commit 851be0cda7
144 changed files with 31135 additions and 0 deletions
+304
View File
@@ -0,0 +1,304 @@
import React, { useState } from 'react';
import CodeMirror from '@uiw/react-codemirror';
import { yaml as yamlLang } from '@codemirror/lang-yaml';
import {
ChevronRight,
ChevronDown,
FileCode2,
AlertTriangle,
AlertCircle,
CheckCircle2,
Copy,
Check,
ExternalLink,
} from 'lucide-react';
export default function BottomDrawer({
isOpen = false,
onToggle,
activeTab = 'yaml', // 'yaml' | 'problems'
onChangeTab,
yamlContent = '',
problems = [],
nodeCount = 0,
edgeCount = 0,
theme = 'dark',
onSelectNode,
}) {
const [copied, setCopied] = useState(false);
const errors = problems.filter((p) => p.severity === 'error');
const warnings = problems.filter((p) => p.severity === 'warning');
const handleCopyYaml = (e) => {
e.stopPropagation();
if (!yamlContent) return;
navigator.clipboard.writeText(yamlContent);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleTabClick = (tab, e) => {
e.stopPropagation();
if (onChangeTab) {
onChangeTab(tab);
}
if (!isOpen && onToggle) {
onToggle();
}
};
const handleSelectProblemNode = (nodeId, e) => {
e.stopPropagation();
if (nodeId && onSelectNode) {
onSelectNode(nodeId);
}
};
return (
<section
id="drawer"
className={isOpen ? 'open' : ''}
style={{
flex: 'none',
background: 'var(--surface)',
borderTop: '1px solid var(--line)',
display: 'flex',
flexDirection: 'column',
zIndex: 15,
transition: 'height 0.15s ease',
}}
>
{/* 36px BOTTOM BAR (#dbar per design-spec.md) */}
<div
id="dbar"
onClick={onToggle}
role="button"
tabIndex={0}
aria-expanded={isOpen}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
height: '36px',
padding: '0 14px',
cursor: 'pointer',
userSelect: 'none',
boxSizing: 'border-box',
fontSize: '12.5px',
}}
>
{/* EXPAND / COLLAPSE CHEVRON */}
<span style={{ display: 'flex', alignItems: 'center', color: 'var(--muted)' }}>
{isOpen ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</span>
{/* TAB 1: YAML PREVIEW */}
<button
type="button"
onClick={(e) => handleTabClick('yaml', e)}
className={`chip ${activeTab === 'yaml' ? 'acc' : ''}`}
style={{
cursor: 'pointer',
padding: '2px 8px',
fontSize: '12px',
border: activeTab === 'yaml' ? '1px solid var(--acc)' : '1px solid transparent',
}}
aria-label="YAML preview tab"
>
<FileCode2 size={13} />
<b>YAML preview</b>
</button>
{/* TAB 2: PROBLEMS LIST */}
<button
type="button"
onClick={(e) => handleTabClick('problems', e)}
className={`chip ${activeTab === 'problems' ? 'acc' : ''}`}
style={{
cursor: 'pointer',
padding: '2px 8px',
fontSize: '12px',
border: activeTab === 'problems' ? '1px solid var(--acc)' : '1px solid transparent',
color: errors.length > 0 ? 'var(--err)' : (warnings.length > 0 ? '#f59e0b' : 'var(--muted)'),
}}
aria-label="Problems list tab"
>
{errors.length > 0 ? (
<AlertCircle size={13} color="var(--err)" />
) : warnings.length > 0 ? (
<AlertTriangle size={13} color="#f59e0b" />
) : (
<CheckCircle2 size={13} color="var(--ok)" />
)}
<span>Problems</span>
<span
style={{
padding: '1px 5px',
borderRadius: '99px',
fontSize: '11px',
background: errors.length > 0 ? 'rgba(210,63,63,0.15)' : (warnings.length > 0 ? 'rgba(245,158,11,0.15)' : 'rgba(31,157,99,0.15)'),
fontWeight: 600,
}}
>
{problems.length}
</span>
</button>
{/* RIGHT METRIC INFO */}
<span className="m" id="cnt" style={{ marginLeft: 'auto', color: 'var(--muted)', fontSize: '11.5px' }}>
{nodeCount} nodes · {edgeCount} dependencies
</span>
</div>
{/* EXPANDED CONTENT BODY (#ybody) */}
{isOpen && (
<div
id="ybody"
style={{
height: '200px',
overflow: 'hidden',
borderTop: '1px solid var(--line)',
background: 'var(--bg)',
display: 'flex',
flexDirection: 'column',
}}
>
{/* TAB 1: YAML VIEW */}
{activeTab === 'yaml' && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px 12px',
background: 'var(--surface)',
borderBottom: '1px solid var(--line)',
fontSize: '11.5px',
color: 'var(--muted)',
}}
>
<span>Generated Apache Airflow 3 DAG Specification</span>
<button
type="button"
className="btn"
onClick={handleCopyYaml}
style={{ padding: '2px 8px', fontSize: '11.5px', gap: '4px' }}
title="Copy YAML to clipboard"
>
{copied ? <Check size={12} color="var(--ok)" /> : <Copy size={12} />}
<span>{copied ? 'Copied!' : 'Copy YAML'}</span>
</button>
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
<CodeMirror
value={yamlContent}
height="100%"
extensions={[yamlLang()]}
theme={theme === 'dark' ? 'dark' : 'light'}
readOnly
/>
</div>
</div>
)}
{/* TAB 2: PROBLEMS VIEW */}
{activeTab === 'problems' && (
<div style={{ flex: 1, overflow: 'auto', padding: '10px 14px' }}>
{problems.length === 0 ? (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
height: '100%',
color: 'var(--ok)',
fontSize: '13px',
fontWeight: 500,
}}
>
<CheckCircle2 size={18} />
<span>No problems detected. Workflow topology and node configurations are valid.</span>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{problems.map((problem) => {
const isErr = problem.severity === 'error';
return (
<div
key={problem.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '6px 10px',
borderRadius: '6px',
background: 'var(--surface)',
border: `1px solid ${isErr ? 'rgba(210,63,63,0.3)' : 'rgba(245,158,11,0.3)'}`,
fontSize: '12.5px',
}}
>
{/* SEVERITY ICON */}
<div style={{ flex: 'none', display: 'flex', alignItems: 'center' }}>
{isErr ? (
<AlertCircle size={15} color="var(--err)" />
) : (
<AlertTriangle size={15} color="#f59e0b" />
)}
</div>
{/* PROBLEM TARGET */}
{problem.nodeTitle && (
<button
type="button"
onClick={(e) => handleSelectProblemNode(problem.nodeId, e)}
className="chip"
style={{
padding: '1px 6px',
fontSize: '11px',
cursor: problem.nodeId ? 'pointer' : 'default',
background: 'var(--bg)',
}}
title={problem.nodeId ? 'Click to select and configure this task' : ''}
>
<span>{problem.nodeTitle}</span>
{problem.nodeId && <ExternalLink size={10} />}
</button>
)}
{/* CATEGORY */}
<span style={{ fontSize: '11px', color: 'var(--muted)', fontWeight: 500 }}>
[{problem.category}]
</span>
{/* MESSAGE */}
<span style={{ flex: 1, color: 'var(--text)' }}>
{problem.message}
</span>
{/* QUICK ACTION */}
{problem.nodeId && (
<button
type="button"
className="btn"
onClick={(e) => handleSelectProblemNode(problem.nodeId, e)}
style={{ padding: '2px 8px', fontSize: '11px' }}
>
Configure
</button>
)}
</div>
);
})}
</div>
)}
</div>
)}
</div>
)}
</section>
);
}
@@ -0,0 +1,82 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import BottomDrawer from './BottomDrawer';
describe('BottomDrawer.jsx', () => {
it('renders collapsed state showing node and dependency counts', () => {
render(
<BottomDrawer
isOpen={false}
nodeCount={5}
edgeCount={4}
problems={[]}
yamlContent="dag_id: test\n"
/>
);
expect(screen.getByText('YAML preview')).toBeInTheDocument();
expect(screen.getByText('5 nodes · 4 dependencies')).toBeInTheDocument();
expect(screen.getByText('Problems')).toBeInTheDocument();
});
it('triggers onToggle when header bar is clicked', () => {
const handleToggle = vi.fn();
render(
<BottomDrawer
isOpen={false}
onToggle={handleToggle}
nodeCount={2}
edgeCount={1}
/>
);
fireEvent.click(screen.getByRole('button', { name: /YAML preview tab/i }));
});
it('renders empty problems state when no problems exist', () => {
render(
<BottomDrawer
isOpen={true}
activeTab="problems"
nodeCount={3}
edgeCount={2}
problems={[]}
/>
);
expect(screen.getByText(/No problems detected/i)).toBeInTheDocument();
});
it('renders problem list and allows clicking to configure node', () => {
const handleSelectNode = vi.fn();
const mockProblems = [
{
id: 'p-1',
nodeId: 'node-db',
nodeTitle: 'Source DB',
category: 'Configuration',
severity: 'error',
message: 'Missing mandatory field "table"',
},
];
render(
<BottomDrawer
isOpen={true}
activeTab="problems"
nodeCount={3}
edgeCount={2}
problems={mockProblems}
onSelectNode={handleSelectNode}
/>
);
expect(screen.getByText('Source DB')).toBeInTheDocument();
expect(screen.getByText(/Missing mandatory field "table"/i)).toBeInTheDocument();
const configBtn = screen.getByRole('button', { name: /Configure/i });
fireEvent.click(configBtn);
expect(handleSelectNode).toHaveBeenCalledWith('node-db');
});
});
+92
View File
@@ -0,0 +1,92 @@
import React, { useEffect, useRef } from 'react';
/**
* ContextMenu Component (Langkah 6)
* Renders fixed context menu at (x, y) with viewport bounds checking,
* keyboard shortcut badges (<kbd>), danger styles, and outside-click dismissal.
*/
export default function ContextMenu({
isOpen,
position = { x: 0, y: 0 },
items = [],
onClose,
}) {
const menuRef = useRef(null);
useEffect(() => {
if (!isOpen) return;
const handlePointerDown = (e) => {
if (menuRef.current && !menuRef.current.contains(e.target)) {
onClose();
}
};
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
onClose();
}
};
window.addEventListener('pointerdown', handlePointerDown, true);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('pointerdown', handlePointerDown, true);
window.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, onClose]);
if (!isOpen || !items || items.length === 0) return null;
// Viewport bounds checking (menu width ~244px, height estimated by item count)
const menuWidth = 244;
const estimatedHeight = items.length * 34 + 16;
const clampedX = Math.max(8, Math.min(position.x, window.innerWidth - menuWidth - 8));
const clampedY = Math.max(8, Math.min(position.y, window.innerHeight - estimatedHeight - 8));
return (
<div
id="ctx"
ref={menuRef}
className="pop open"
style={{
position: 'fixed',
left: `${clampedX}px`,
top: `${clampedY}px`,
width: `${menuWidth}px`,
zIndex: 120,
}}
role="menu"
aria-label="Context menu"
>
{items.map((item, index) => {
if (item === '-' || item.type === 'divider') {
return <hr key={`hr-${index}`} />;
}
if (item.hidden) return null;
return (
<button
key={item.key || `item-${index}`}
type="button"
className={`it ${item.danger ? 'danger' : ''}`}
data-k={item.key}
onClick={(e) => {
e.stopPropagation();
onClose();
if (item.onClick) {
item.onClick();
}
}}
role="menuitem"
>
<span>{item.label}</span>
{item.shortcut && <kbd>{item.shortcut}</kbd>}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,150 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import ContextMenu from './ContextMenu';
describe('ContextMenu Component (Langkah 6)', () => {
it('1. Does not render when isOpen is false', () => {
const { container } = render(
<ContextMenu
isOpen={false}
position={{ x: 100, y: 100 }}
items={[{ key: 'open', label: 'Open settings' }]}
onClose={vi.fn()}
/>
);
expect(container.firstChild).toBeNull();
});
it('2. Renders node context menu items with labels and keyboard badges', () => {
const onClose = vi.fn();
const onOpen = vi.fn();
const onToggle = vi.fn();
const onDup = vi.fn();
const onTidy = vi.fn();
const onDel = vi.fn();
const items = [
{ key: 'open', label: 'Open settings', shortcut: 'Enter', onClick: onOpen },
{ key: 'rename', label: 'Rename', shortcut: 'Space' },
'-',
{ key: 'toggle', label: 'Disable', shortcut: 'D', onClick: onToggle },
{ key: 'dup', label: 'Duplicate', shortcut: 'Ctrl+D', onClick: onDup },
'-',
{ key: 'tidy', label: 'Tidy up workflow', shortcut: 'Shift+Alt+T', onClick: onTidy },
'-',
{ key: 'del', label: 'Delete', shortcut: 'Del', danger: true, onClick: onDel },
];
render(
<ContextMenu
isOpen={true}
position={{ x: 150, y: 200 }}
items={items}
onClose={onClose}
/>
);
// Verify all action buttons and shortcut badges
expect(screen.getByText('Open settings')).toBeInTheDocument();
expect(screen.getByText('Enter')).toBeInTheDocument();
expect(screen.getByText('Rename')).toBeInTheDocument();
expect(screen.getByText('Space')).toBeInTheDocument();
expect(screen.getByText('Disable')).toBeInTheDocument();
expect(screen.getByText('D')).toBeInTheDocument();
expect(screen.getByText('Duplicate')).toBeInTheDocument();
expect(screen.getByText('Ctrl+D')).toBeInTheDocument();
expect(screen.getByText('Tidy up workflow')).toBeInTheDocument();
expect(screen.getByText('Shift+Alt+T')).toBeInTheDocument();
const delBtn = screen.getByText('Delete').closest('button');
expect(delBtn).toHaveClass('danger');
expect(screen.getByText('Del')).toBeInTheDocument();
});
it('3. Triggers item onClick and closes menu on item click', () => {
const onClose = vi.fn();
const onOpen = vi.fn();
const items = [
{ key: 'open', label: 'Open settings', shortcut: 'Enter', onClick: onOpen },
];
render(
<ContextMenu
isOpen={true}
position={{ x: 100, y: 100 }}
items={items}
onClose={onClose}
/>
);
fireEvent.click(screen.getByText('Open settings'));
expect(onOpen).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);
});
it('4. Renders canvas pane context menu items correctly', () => {
const onAdd = vi.fn();
const onNote = vi.fn();
const onTidy = vi.fn();
const onFit = vi.fn();
const canvasItems = [
{ key: 'add', label: 'Add component', onClick: onAdd },
{ key: 'note', label: 'Add note', shortcut: 'Shift+S', onClick: onNote },
{ key: 'tidy', label: 'Tidy up workflow', shortcut: 'Shift+Alt+T', onClick: onTidy },
{ key: 'fit', label: 'Fit to screen', onClick: onFit },
];
render(
<ContextMenu
isOpen={true}
position={{ x: 200, y: 300 }}
items={canvasItems}
onClose={vi.fn()}
/>
);
expect(screen.getByText('Add component')).toBeInTheDocument();
expect(screen.getByText('Add note')).toBeInTheDocument();
expect(screen.getByText('Shift+S')).toBeInTheDocument();
expect(screen.getByText('Tidy up workflow')).toBeInTheDocument();
expect(screen.getByText('Fit to screen')).toBeInTheDocument();
});
it('5. Closes context menu when Escape is pressed', () => {
const onClose = vi.fn();
render(
<ContextMenu
isOpen={true}
position={{ x: 100, y: 100 }}
items={[{ key: 'open', label: 'Open' }]}
onClose={onClose}
/>
);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});
it('6. Closes context menu when clicking outside', () => {
const onClose = vi.fn();
render(
<div>
<div data-testid="outside-element">Outside</div>
<ContextMenu
isOpen={true}
position={{ x: 100, y: 100 }}
items={[{ key: 'open', label: 'Open' }]}
onClose={onClose}
/>
</div>
);
fireEvent.pointerDown(screen.getByTestId('outside-element'));
expect(onClose).toHaveBeenCalledTimes(1);
});
});
+199
View File
@@ -0,0 +1,199 @@
import React, { memo } from 'react';
import { Handle, Position } from 'reactflow';
import {
Database,
FileText,
FolderArchive,
Globe,
Filter,
HardDrive,
Cpu,
Layers,
Terminal,
Code,
ShieldCheck,
Radio,
} from 'lucide-react';
import { isSourceNode } from '../utils/layout';
import { AIRFLOW_WEB_URL } from '../utils/env';
const iconMap = {
// Database / Ingestion
source_db: Database,
db: Database,
source_gws: FileText,
gws: FileText,
source_api: Globe,
source_rest: Globe,
rest: Globe,
source_file: FolderArchive,
file: FolderArchive,
// Transformation
pandas_cleansing: Filter,
pandas_clean: Filter,
pandas: Filter,
dbt_transform: Layers,
dbt: Layers,
duckdb_transform: Database,
duckdb: Database,
pyiceberg_transform: Cpu,
pyiceberg: Cpu,
pyspark_transform: Terminal,
pyspark: Terminal,
bash_operator: Terminal,
bash: Terminal,
python_operator: Code,
python: Code,
// Storage
minio_storage: HardDrive,
minio_target: HardDrive,
minio: HardDrive,
trino_storage: Cpu,
trino_target: Cpu,
trino: Cpu,
// Quality & Notify
ge_quality: ShieldCheck,
ge: ShieldCheck,
sitomat_callback: Radio,
sitomat: Radio,
cb: Radio,
};
function CustomNode({ id, data = {}, selected }) {
const nodeType = data.type || 'source_db';
const IconComponent = iconMap[nodeType] || Database;
const isSource = isSourceNode(nodeType) || data.isSource;
const isDisabled = data.enabled === false || data.disabled === true;
const hasAirflow = data.hasAirflow ?? Boolean(AIRFLOW_WEB_URL && AIRFLOW_WEB_URL.trim() !== '');
// Real status determination
const status = data.status || (data.hasError ? 'error' : data.configured === false ? 'warning' : 'ok');
let statusClass = 'ok';
let statusTitle = 'Configured & Valid';
if (status === 'error' || status === 'err') {
statusClass = 'err';
statusTitle = data.errorMessage || 'Configuration error or missing mandatory fields';
} else if (status === 'warning' || status === 'warn') {
statusClass = 'warn';
statusTitle = data.errorMessage || 'Configuration incomplete';
}
const handleDelete = (e) => {
e.stopPropagation();
if (data.onDeleteNode) {
data.onDeleteNode(id);
}
};
const handleToggleDisable = (e) => {
e.stopPropagation();
if (data.onToggleDisable) {
data.onToggleDisable(id);
}
};
const handleRun = (e) => {
e.stopPropagation();
if (data.onRunNode) {
data.onRunNode(id);
}
};
const handleMore = (e) => {
e.stopPropagation();
if (data.onOpenMenu) {
data.onOpenMenu(id, e);
}
};
const displayTitle = data.label || data.title || nodeType;
const displaySubtitle = isDisabled
? `${data.subtitle || nodeType} (Disabled)`
: data.subtitle || nodeType;
return (
<div
className={`node ${isSource ? 'src' : ''} ${selected ? 'sel' : ''} ${isDisabled ? 'off' : ''}`}
data-id={id}
>
{/* TARGET HANDLE (INPUT PORT - LEFT) - OMITTED FOR SOURCE NODES */}
{!isSource && (
<Handle
type="target"
position={Position.Left}
isConnectable={!isDisabled}
className="port in"
/>
)}
{/* CENTER LINE ICON */}
<div className="ic">
<IconComponent size={42} strokeWidth={1.5} color="var(--acc)" />
</div>
{/* REAL STATUS INDICATOR DOT */}
<i
className={`dot ${statusClass}`}
title={statusTitle}
aria-label={statusTitle}
/>
{/* SOURCE HANDLE (OUTPUT PORT - RIGHT) */}
<Handle
type="source"
position={Position.Right}
isConnectable={!isDisabled}
className="port out"
/>
{/* MINI TOOLBAR (APPEARS ON HOVER OR SELECTION) */}
<div className="ntb">
{hasAirflow && (
<button
type="button"
onClick={handleRun}
title="Run this task"
aria-label="Run this task"
>
▶
</button>
)}
<button
type="button"
onClick={handleToggleDisable}
title={isDisabled ? 'Enable task' : 'Disable task'}
aria-label={isDisabled ? 'Enable task' : 'Disable task'}
>
⏻
</button>
<button
type="button"
onClick={handleDelete}
title="Delete component"
aria-label="Delete component"
>
⌫
</button>
<button
type="button"
onClick={handleMore}
title="More actions"
aria-label="More actions"
>
⋯
</button>
</div>
{/* LABEL & SUBTITLE PLACED BELOW THE TILE (13px & 12px) */}
<div className="t">
<b title={displayTitle}>{displayTitle}</b>
<span title={displaySubtitle}>{displaySubtitle}</span>
</div>
</div>
);
}
export default memo(CustomNode);
+176
View File
@@ -0,0 +1,176 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import CustomNode from './CustomNode';
// Mock reactflow components used by CustomNode
vi.mock('reactflow', () => ({
Handle: ({ type, position, className }) => (
<div data-testid={`handle-${type}-${position}`} className={className} />
),
Position: {
Left: 'left',
Right: 'right',
},
}));
describe('CustomNode - 96x96 Tile, Source & Ports, Status Dot (Langkah 4)', () => {
it('1. Renders source node with rounded left end (.src) and no input target handle', () => {
const { container } = render(
<CustomNode
id="node_db_1"
data={{
type: 'source_db',
label: 'Bronze Database',
subtitle: 'Postgres CDC',
}}
selected={false}
/>
);
const nodeEl = container.querySelector('.node');
expect(nodeEl).toHaveClass('src');
expect(screen.queryByTestId('handle-target-left')).not.toBeInTheDocument();
expect(screen.getByTestId('handle-source-right')).toBeInTheDocument();
expect(screen.getByText('Bronze Database')).toBeInTheDocument();
expect(screen.getByText('Postgres CDC')).toBeInTheDocument();
});
it('2. Renders non-source node with standard border radius and both input & output ports', () => {
const { container } = render(
<CustomNode
id="node_dbt_1"
data={{
type: 'dbt_transform',
label: 'dbt Silver',
subtitle: 'Cleanse',
}}
selected={false}
/>
);
const nodeEl = container.querySelector('.node');
expect(nodeEl).not.toHaveClass('src');
expect(screen.getByTestId('handle-target-left')).toBeInTheDocument();
expect(screen.getByTestId('handle-source-right')).toBeInTheDocument();
});
it('3. Renders selected state with .sel class', () => {
const { container } = render(
<CustomNode
id="node_1"
data={{ type: 'pandas_cleansing', label: 'Pandas Cleanse' }}
selected={true}
/>
);
const nodeEl = container.querySelector('.node');
expect(nodeEl).toHaveClass('sel');
});
it('4. Renders disabled state with .off class and (Disabled) text', () => {
const { container } = render(
<CustomNode
id="node_off"
data={{
type: 'ge_quality',
label: 'Quality Gate',
subtitle: 'Expectations',
enabled: false,
}}
selected={false}
/>
);
const nodeEl = container.querySelector('.node');
expect(nodeEl).toHaveClass('off');
expect(screen.getByText(/Expectations \(Disabled\)/i)).toBeInTheDocument();
});
it('5. Renders real status indicator dot based on node configuration', () => {
const { container: okContainer } = render(
<CustomNode
id="n_ok"
data={{ type: 'source_db', label: 'DB', configured: true }}
/>
);
expect(okContainer.querySelector('.dot.ok')).toBeInTheDocument();
const { container: warnContainer } = render(
<CustomNode
id="n_warn"
data={{ type: 'source_db', label: 'DB', configured: false }}
/>
);
expect(warnContainer.querySelector('.dot.warn')).toBeInTheDocument();
const { container: errContainer } = render(
<CustomNode
id="n_err"
data={{ type: 'source_db', label: 'DB', status: 'error', errorMessage: 'Missing DB connection' }}
/>
);
expect(errContainer.querySelector('.dot.err')).toBeInTheDocument();
});
it('6. Triggers node callbacks on mini toolbar buttons', () => {
const onDelete = vi.fn();
const onToggle = vi.fn();
const onRun = vi.fn();
render(
<CustomNode
id="test_node"
data={{
type: 'source_db',
label: 'Test Node',
onDeleteNode: onDelete,
onToggleDisable: onToggle,
onRunNode: onRun,
}}
/>
);
fireEvent.click(screen.getByTitle('Run this task'));
expect(onRun).toHaveBeenCalledWith('test_node');
fireEvent.click(screen.getByTitle('Disable task'));
expect(onToggle).toHaveBeenCalledWith('test_node');
fireEvent.click(screen.getByTitle('Delete component'));
expect(onDelete).toHaveBeenCalledWith('test_node');
});
it('7. Triggers onOpenMenu when clicking More actions button on mini toolbar', () => {
const onOpenMenu = vi.fn();
render(
<CustomNode
id="test_node_menu"
data={{
type: 'source_db',
label: 'Test Node',
onOpenMenu,
}}
/>
);
fireEvent.click(screen.getByTitle('More actions'));
expect(onOpenMenu).toHaveBeenCalled();
});
it('8. Hides Run button on mini toolbar when Airflow integration is disabled (hasAirflow: false)', () => {
render(
<CustomNode
id="test_node_no_airflow"
data={{
type: 'source_db',
label: 'Test Node',
hasAirflow: false,
}}
/>
);
expect(screen.queryByTitle('Run this task')).not.toBeInTheDocument();
});
});
+322
View File
@@ -0,0 +1,322 @@
import React, { useState, useEffect } from 'react';
import { X, BookOpen, Search, FileText, ChevronRight, HelpCircle, Layers, CheckCircle, Sparkles } from 'lucide-react';
export default function DocsViewerModal({ isOpen, onClose }) {
const [docContent, setDocContent] = useState('');
const [loading, setLoading] = useState(false);
const [docList, setDocList] = useState([]);
const [selectedDoc, setSelectedDoc] = useState('16_user_guide_component_library_merica_studio.md');
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
if (isOpen) {
fetchDocList();
fetchDocContent(selectedDoc);
}
}, [isOpen, selectedDoc]);
const fetchDocList = async () => {
try {
const res = await fetch('/api/v1/docs');
if (res.ok) {
const data = await res.json();
setDocList(data.docs || []);
}
} catch (err) {
console.error('Failed to fetch doc list:', err);
}
};
const fetchDocContent = async (filename) => {
setLoading(true);
try {
const res = await fetch(`/api/v1/docs/${filename}`);
if (res.ok) {
const data = await res.json();
setDocContent(data.content || '');
} else {
setDocContent('### Failed to load document content.');
}
} catch (err) {
console.error('Failed to fetch doc:', err);
setDocContent('### Error connecting to documentation endpoint.');
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
// Render basic markdown into styled JSX blocks
const renderMarkdown = (text) => {
if (!text) return null;
const lines = text.split('\n');
const elements = [];
let inCodeBlock = false;
let codeContent = [];
let codeLang = '';
lines.forEach((line, idx) => {
// Code block start / end
if (line.trim().startsWith('```')) {
if (inCodeBlock) {
elements.push(
<div
key={`code-${idx}`}
style={{
background: '#0D0F14',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '8px',
padding: '12px 16px',
fontFamily: 'monospace',
fontSize: '12px',
color: '#38BDF8',
overflowX: 'auto',
margin: '12px 0',
whiteSpace: 'pre-wrap',
}}
>
{codeContent.join('\n')}
</div>
);
codeContent = [];
inCodeBlock = false;
} else {
inCodeBlock = true;
codeLang = line.trim().replace('```', '');
}
return;
}
if (inCodeBlock) {
codeContent.push(line);
return;
}
// Headings
if (line.startsWith('# ')) {
elements.push(
<h1 key={idx} style={{ fontSize: '22px', fontWeight: '800', color: '#F5F7FA', margin: '20px 0 10px 0', letterSpacing: '-0.4px' }}>
{line.replace('# ', '')}
</h1>
);
} else if (line.startsWith('## ')) {
elements.push(
<h2 key={idx} style={{ fontSize: '17px', fontWeight: '700', color: '#8B7CFF', margin: '18px 0 8px 0', borderBottom: '1px solid rgba(255,255,255,0.08)', paddingBottom: '6px' }}>
{line.replace('## ', '')}
</h2>
);
} else if (line.startsWith('### ')) {
elements.push(
<h3 key={idx} style={{ fontSize: '14px', fontWeight: '600', color: '#38BDF8', margin: '14px 0 6px 0' }}>
{line.replace('### ', '')}
</h3>
);
} else if (line.startsWith('> ')) {
elements.push(
<blockquote
key={idx}
style={{
borderLeft: '4px solid #8B7CFF',
background: 'rgba(139, 124, 255, 0.08)',
padding: '10px 14px',
borderRadius: '0 8px 8px 0',
color: '#F5F7FA',
fontSize: '13px',
margin: '10px 0',
fontStyle: 'italic',
}}
>
{line.replace('> ', '').replace(/\*\*(.*?)\*\*/g, '$1')}
</blockquote>
);
} else if (line.startsWith('* ') || line.startsWith('- ')) {
const bulletText = line.substring(2);
elements.push(
<div key={idx} style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', fontSize: '13px', color: '#A5AAB8', margin: '4px 0', paddingLeft: '8px' }}>
<span style={{ color: '#8B7CFF', marginTop: '2px' }}>•</span>
<span>{parseInlineFormatting(bulletText)}</span>
</div>
);
} else if (line.trim() === '---') {
elements.push(<hr key={idx} style={{ borderColor: 'rgba(255,255,255,0.08)', margin: '16px 0' }} />);
} else if (line.trim().length > 0) {
elements.push(
<p key={idx} style={{ fontSize: '13px', color: '#A5AAB8', lineHeight: '1.6', margin: '6px 0' }}>
{parseInlineFormatting(line)}
</p>
);
}
});
return elements;
};
const parseInlineFormatting = (str) => {
// Basic inline code & bold parsing
const parts = str.split(/(`[^`]+`|\*\*[^*]+\*\*)/g);
return parts.map((part, i) => {
if (part.startsWith('`') && part.endsWith('`')) {
return (
<code key={i} style={{ background: 'rgba(255,255,255,0.1)', color: '#F472B6', padding: '2px 6px', borderRadius: '4px', fontSize: '11px', fontFamily: 'monospace' }}>
{part.slice(1, -1)}
</code>
);
}
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={i} style={{ color: '#F5F7FA', fontWeight: '700' }}>{part.slice(2, -2)}</strong>;
}
return part;
});
};
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(8, 9, 13, 0.85)',
backdropFilter: 'blur(12px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1050,
padding: '24px',
}}
>
<div
style={{
width: '100%',
maxWidth: '1080px',
height: '85vh',
background: 'var(--color-surface, #0E1016)',
border: '1px solid var(--color-border-strong, rgba(255,255,255,0.14))',
borderRadius: 'var(--radius-2xl, 24px)',
boxShadow: '0 25px 70px rgba(0,0,0,0.5)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* HEADER BAR */}
<div
style={{
padding: '16px 24px',
borderBottom: '1px solid var(--color-border-subtle, rgba(255,255,255,0.06))',
background: 'var(--color-surface-raised, #12141B)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ padding: '8px', borderRadius: '10px', background: 'linear-gradient(135deg, #8B7CFF 0%, #6377FF 100%)' }}>
<BookOpen size={20} color="#ffffff" />
</div>
<div>
<h2 style={{ fontSize: '16px', fontWeight: '700', margin: 0, color: '#F5F7FA' }}>
MERICA Studio Documentation & User Guide
</h2>
<p style={{ fontSize: '12px', color: '#707686', margin: 0 }}>
Interactive Component Library & Technical Markdown Documentation Viewer
</p>
</div>
</div>
<button
onClick={onClose}
style={{
background: 'transparent',
border: 'none',
color: '#707686',
cursor: 'pointer',
padding: '6px',
}}
>
<X size={20} />
</button>
</div>
{/* MAIN BODY: SIDEBAR + MARKDOWN CONTENT */}
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{/* SIDEBAR DOCUMENT SELECTOR */}
<div
style={{
width: '280px',
borderRight: '1px solid var(--color-border-subtle, rgba(255,255,255,0.06))',
background: 'var(--color-sidebar-background, #0A0B10)',
padding: '16px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}
>
<div style={{ fontSize: '11px', fontWeight: '700', color: '#707686', letterSpacing: '0.5px' }}>
AVAILABLE DOCUMENTS
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', overflowY: 'auto' }}>
{docList.map((doc) => {
const isActive = selectedDoc === doc;
const docLabel = doc.includes('16_')
? '📖 User Guide & MERICA Widget Library'
: doc.includes('15_')
? '📐 Enhancement Design Specification'
: doc;
return (
<button
key={doc}
onClick={() => setSelectedDoc(doc)}
style={{
width: '100%',
textAlign: 'left',
padding: '10px 12px',
borderRadius: '8px',
border: isActive ? '1px solid #8B7CFF' : '1px solid transparent',
background: isActive ? 'rgba(139, 124, 255, 0.12)' : 'transparent',
color: isActive ? '#ffffff' : '#A5AAB8',
fontSize: '12px',
fontWeight: isActive ? '600' : '400',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<FileText size={14} color={isActive ? '#8B7CFF' : '#707686'} />
<span style={{ flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{docLabel}
</span>
</button>
);
})}
</div>
<div style={{ marginTop: 'auto', padding: '12px', borderRadius: '8px', background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ fontSize: '11px', fontWeight: '700', color: '#8B7CFF', marginBottom: '4px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<Sparkles size={12} /> MERICA Engine
</div>
<p style={{ fontSize: '10px', color: '#707686', margin: 0, lineHeight: 1.4 }}>
Wrapper Apache Airflow untuk eksekusi pipeline terkelola, terintegrasi, dan cloud-ready.
</p>
</div>
</div>
{/* MARKDOWN VIEWER PANEL */}
<div style={{ flex: 1, padding: '24px 32px', overflowY: 'auto', background: 'var(--color-surface, #0E1016)' }}>
{loading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: '#707686', fontSize: '13px' }}>
Loading documentation...
</div>
) : (
<div style={{ maxWidth: '820px', margin: '0 auto' }}>
{renderMarkdown(docContent)}
</div>
)}
</div>
</div>
</div>
</div>
);
}
+434
View File
@@ -0,0 +1,434 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Save,
ShieldCheck,
Rocket,
MoreHorizontal,
Sliders,
FolderOpen,
Plus,
History,
Tag,
ExternalLink,
BookOpen,
Sun,
Moon,
LogOut,
UserCheck,
Eye,
Settings,
Undo,
Redo,
} from 'lucide-react';
import { OPENMETADATA_HOST, AIRFLOW_WEB_URL } from '../utils/env';
export default function HeaderBar({
theme = 'dark',
onToggleTheme,
userRole = 'admin',
currentUser = null,
onLogout,
onOpenSettings,
pipelineId = 'example_pipeline',
setPipelineId,
bundleName = 'dlh-core-ingestion',
setBundleName,
pipelineVersion = 'v1.0.0',
description,
setDescription,
schedule = '@hourly',
setSchedule,
onNewPipeline,
onOpenPipelines,
onOpenDocs,
onSaveDraft,
onValidate,
onDeploy,
onOpenPublish,
onOpenTimeMachine,
onOpenPipelineSettings,
isDirty = false,
canUndo = false,
canRedo = false,
onUndo,
onRedo,
}) {
const isViewer = userRole === 'viewer';
const isAdmin = userRole === 'admin' || currentUser?.is_admin;
const navigate = useNavigate();
const [isMoreOpen, setIsMoreOpen] = useState(false);
const moreRef = useRef(null);
// Close more menu when clicking outside
useEffect(() => {
const handleClickOutside = (e) => {
if (moreRef.current && !moreRef.current.contains(e.target)) {
setIsMoreOpen(false);
}
};
if (isMoreOpen) {
document.addEventListener('pointerdown', handleClickOutside);
}
return () => document.removeEventListener('pointerdown', handleClickOutside);
}, [isMoreOpen]);
const handlePipelineSettingsClick = () => {
setIsMoreOpen(false);
if (onOpenPipelineSettings) {
onOpenPipelineSettings();
}
};
return (
<header
style={{
height: '48px',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '0 14px',
background: 'var(--surface)',
borderBottom: '1px solid var(--line)',
color: 'var(--text)',
boxSizing: 'border-box',
zIndex: 20,
}}
>
{/* EDITABLE PIPELINE NAME */}
<input
id="pname"
value={pipelineId}
onChange={(e) => setPipelineId && setPipelineId(e.target.value)}
aria-label="Pipeline name"
placeholder="pipeline_name"
style={{
border: 0,
background: 'none',
fontWeight: 600,
fontSize: '13.5px',
color: 'var(--text)',
padding: '4px 6px',
borderRadius: '6px',
minWidth: 0,
width: 'min(280px, 24vw)',
textOverflow: 'ellipsis',
outline: 'none',
transition: 'background 0.12s',
}}
onFocus={(e) => (e.target.style.background = 'var(--bg)')}
onBlur={(e) => (e.target.style.background = 'none')}
/>
{/* SCHEDULE CHIP */}
<button
type="button"
className="chip"
id="sched"
onClick={handlePipelineSettingsClick}
title="Pipeline schedule · Click to configure"
aria-label="Pipeline schedule"
>
<span>⏱️</span>
<span>{schedule || '@hourly'}</span>
</button>
{/* DAG BUNDLE CHIP */}
{bundleName && (
<button
type="button"
className="chip"
title={`AIRFLOW 3 DAG BUNDLE: ${bundleName}`}
onClick={handlePipelineSettingsClick}
>
<span>📦</span>
<span>{bundleName}</span>
</button>
)}
{/* UNSAVED CHANGES INDICATOR (design-spec.md & Langkah 8) */}
{isDirty && (
<span
className="chip"
id="unsaved-badge"
style={{
borderColor: '#f59e0b',
color: '#f59e0b',
background: 'rgba(245, 158, 11, 0.1)',
fontSize: '11px',
padding: '2px 7px',
fontWeight: 600,
}}
title="You have unsaved changes in this pipeline draft"
>
<span style={{ fontSize: '9px', lineHeight: 1 }}>●</span>
<span>Unsaved changes</span>
</span>
)}
{/* ROLE BADGE */}
<span
className={`chip ${isViewer ? '' : 'okc'}`}
title={`Role: ${userRole === 'admin' ? 'Admin (Full Access)' : (userRole === 'editor' ? 'Data Engineer (Full Access)' : 'Viewer (Read-Only)')}`}
style={{ fontSize: '11.5px', padding: '1px 8px' }}
>
{isViewer ? <Eye size={12} /> : <UserCheck size={12} />}
<span>{isViewer ? 'Viewer (Read-Only)' : (userRole === 'admin' ? 'Admin (Full Access)' : 'Data Engineer (Full Access)')}</span>
</span>
{/* SPACER */}
<div style={{ flex: 1 }} />
{/* ACTION BUTTONS (CLEAN & MINIMALIST ACCORDING TO SPEC) */}
{/* SAVE DRAFT BUTTON (SECONDARY) */}
{!isViewer && (
<button
type="button"
className="btn"
id="save"
onClick={onSaveDraft}
title="Save draft topology (JSON)"
>
<Save size={14} />
<span>Save draft</span>
</button>
)}
{/* VALIDATE BUTTON (SECONDARY) */}
<button
type="button"
className="btn"
id="val"
onClick={onValidate}
title="Validate workflow graph and configs"
>
<ShieldCheck size={14} />
<span>Validate</span>
</button>
{/* DEPLOY DAG BUTTON (SATU-SATUNYA TOMBOL UTAMA) */}
<button
type="button"
className="btn pri"
id="deploy"
onClick={onDeploy}
disabled={isViewer}
title={isViewer ? 'Viewer role cannot deploy DAG' : 'Deploy DAG to Airflow 3 Engine'}
>
<Rocket size={14} />
<span>Deploy DAG</span>
</button>
{/* MORE MENU BUTTON (⋯) */}
<div style={{ position: 'relative' }} ref={moreRef}>
<button
type="button"
className="btn"
id="mb"
onClick={() => setIsMoreOpen(!isMoreOpen)}
aria-label="More actions"
title="More actions"
style={{ width: '32px', padding: 0 }}
>
<MoreHorizontal size={16} />
</button>
{/* MORE POPUP MENU */}
{isMoreOpen && (
<div
className="pop"
id="more"
style={{
top: '40px',
right: '0',
width: '210px',
display: 'block',
}}
>
<button
type="button"
className="it"
onClick={() => {
setIsMoreOpen(false);
if (onOpenPipelineSettings) onOpenPipelineSettings();
}}
>
<Sliders size={14} />
<span>Pipeline settings</span>
</button>
<button
type="button"
className="it"
onClick={() => {
setIsMoreOpen(false);
if (onOpenPipelines) onOpenPipelines();
}}
>
<FolderOpen size={14} />
<span>Load pipeline</span>
</button>
<button
type="button"
className="it"
onClick={() => {
setIsMoreOpen(false);
if (onNewPipeline) onNewPipeline();
}}
>
<Plus size={14} />
<span>New canvas</span>
</button>
<button
type="button"
className="it"
onClick={() => {
setIsMoreOpen(false);
if (onOpenTimeMachine) onOpenTimeMachine();
}}
>
<History size={14} />
<span>Time Machine</span>
</button>
{!isViewer && onOpenPublish && (
<button
type="button"
className="it"
onClick={() => {
setIsMoreOpen(false);
onOpenPublish();
}}
>
<Tag size={14} />
<span>Publish Version</span>
</button>
)}
<button
type="button"
className="it"
onClick={() => {
setIsMoreOpen(false);
if (onUndo) onUndo();
}}
disabled={!canUndo}
style={{ opacity: canUndo ? 1 : 0.45 }}
>
<Undo size={14} />
<span style={{ flex: 1 }}>Undo</span>
<kbd style={{ fontSize: '10px', color: 'var(--muted)' }}>Ctrl+Z</kbd>
</button>
<button
type="button"
className="it"
onClick={() => {
setIsMoreOpen(false);
if (onRedo) onRedo();
}}
disabled={!canRedo}
style={{ opacity: canRedo ? 1 : 0.45 }}
>
<Redo size={14} />
<span style={{ flex: 1 }}>Redo</span>
<kbd style={{ fontSize: '10px', color: 'var(--muted)' }}>Ctrl+Y</kbd>
</button>
<hr />
{AIRFLOW_WEB_URL && (
<a
href={AIRFLOW_WEB_URL}
target="_blank"
rel="noreferrer"
className="it"
onClick={() => setIsMoreOpen(false)}
style={{ textDecoration: 'none' }}
>
<ExternalLink size={14} />
<span>Open Airflow</span>
</a>
)}
{OPENMETADATA_HOST && (
<a
href={OPENMETADATA_HOST}
target="_blank"
rel="noreferrer"
className="it"
onClick={() => setIsMoreOpen(false)}
style={{ textDecoration: 'none' }}
>
<ExternalLink size={14} />
<span>Open OpenMetadata</span>
</a>
)}
<button
type="button"
className="it"
onClick={() => {
setIsMoreOpen(false);
if (onOpenDocs) onOpenDocs();
else navigate('/docs');
}}
>
<BookOpen size={14} />
<span>Docs</span>
</button>
<hr />
{/* THEME TOGGLE */}
<button
type="button"
className="it"
onClick={() => {
setIsMoreOpen(false);
if (onToggleTheme) onToggleTheme();
}}
title={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
>
{theme === 'dark' ? <Sun size={14} color="#fbbf24" /> : <Moon size={14} color="#8b7cff" />}
<span>{theme === 'dark' ? 'Light mode' : 'Dark mode'}</span>
</button>
{/* SETTINGS (ADMIN ONLY) */}
{isAdmin && onOpenSettings && (
<button
type="button"
className="it"
onClick={() => {
setIsMoreOpen(false);
onOpenSettings();
}}
>
<Settings size={14} />
<span>Access Control (RBAC)</span>
</button>
)}
{/* SIGN OUT */}
<button
type="button"
className="it danger"
onClick={() => {
setIsMoreOpen(false);
if (onLogout) onLogout();
}}
>
<LogOut size={14} />
<span>Sign out</span>
</button>
</div>
)}
</div>
</header>
);
}
File diff suppressed because it is too large Load Diff
+206
View File
@@ -0,0 +1,206 @@
import React, { useState } from 'react';
import { Layers, ShieldCheck, UserCheck, Eye, Lock, ArrowRight, CheckCircle } from 'lucide-react';
export default function LoginModal({ isOpen, onLogin }) {
const [email, setEmail] = useState('admin@dlh.go.id');
const [password, setPassword] = useState('admin123');
const [selectedRole, setSelectedRole] = useState('admin');
if (!isOpen) return null;
const handleQuickFill = (role) => {
setSelectedRole(role);
if (role === 'admin') {
setEmail('admin@dlh.go.id');
setPassword('admin123');
} else {
setEmail('viewer@dlh.go.id');
setPassword('viewer123');
}
};
const handleSubmit = (e) => {
e.preventDefault();
onLogin({
email,
role: selectedRole,
name: selectedRole === 'admin' ? 'Administrator (DLH Lead)' : 'Guest Inspector (Viewer)',
});
};
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(8, 9, 13, 0.85)',
backdropFilter: 'blur(12px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '20px',
}}
>
<div
style={{
width: '100%',
maxWidth: '480px',
background: 'var(--color-surface, #0E1016)',
border: '1px solid var(--color-border-strong, rgba(255,255,255,0.14))',
borderRadius: 'var(--radius-2xl, 24px)',
boxShadow: '0 25px 70px rgba(0,0,0,0.5)',
padding: '32px',
color: 'var(--color-text-primary, #F5F7FA)',
display: 'flex',
flexDirection: 'column',
gap: '24px',
}}
>
{/* BRAND HEADER */}
<div style={{ textAlign: 'center' }}>
<div
style={{
width: '52px',
height: '52px',
borderRadius: '16px',
background: 'linear-gradient(135deg, #8B7CFF 0%, #6377FF 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto 14px auto',
boxShadow: '0 8px 24px rgba(139, 124, 255, 0.35)',
}}
>
<Layers size={28} color="#ffffff" />
</div>
<h2 style={{ fontSize: '20px', fontWeight: '700', margin: '0 0 6px 0', letterSpacing: '-0.3px' }}>
DLH Low-Code Studio Access
</h2>
<p style={{ fontSize: '13px', color: 'var(--color-text-muted, #707686)', margin: 0 }}>
Select user role or quick-autofill credentials to sign in
</p>
</div>
{/* QUICK AUTOFILL ROLE SELECTOR BUTTONS */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<button
type="button"
onClick={() => handleQuickFill('admin')}
style={{
padding: '12px',
borderRadius: '12px',
border: selectedRole === 'admin' ? '2px solid #8B7CFF' : '1px solid var(--color-border-subtle, rgba(255,255,255,0.06))',
background: selectedRole === 'admin' ? 'rgba(139, 124, 255, 0.12)' : 'var(--color-surface-raised, #12141B)',
color: selectedRole === 'admin' ? '#ffffff' : 'var(--color-text-muted, #707686)',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '6px',
transition: 'all 0.2s ease',
}}
>
<UserCheck size={20} color={selectedRole === 'admin' ? '#8B7CFF' : '#707686'} />
<span style={{ fontSize: '12px', fontWeight: '700' }}>👑 Admin Role</span>
<span style={{ fontSize: '10px', opacity: 0.8 }}>Full Edit & Deploy</span>
</button>
<button
type="button"
onClick={() => handleQuickFill('viewer')}
style={{
padding: '12px',
borderRadius: '12px',
border: selectedRole === 'viewer' ? '2px solid #38bdf8' : '1px solid var(--color-border-subtle, rgba(255,255,255,0.06))',
background: selectedRole === 'viewer' ? 'rgba(56, 189, 248, 0.12)' : 'var(--color-surface-raised, #12141B)',
color: selectedRole === 'viewer' ? '#ffffff' : 'var(--color-text-muted, #707686)',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '6px',
transition: 'all 0.2s ease',
}}
>
<Eye size={20} color={selectedRole === 'viewer' ? '#38bdf8' : '#707686'} />
<span style={{ fontSize: '12px', fontWeight: '700' }}>👁️ Viewer Role</span>
<span style={{ fontSize: '10px', opacity: 0.8 }}>Read-Only Canvas</span>
</button>
</div>
{/* LOGIN FORM */}
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: '12px', fontWeight: '600', color: 'var(--color-text-secondary, #A5AAB8)', display: 'block', marginBottom: '6px' }}>
User Email Address
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
background: 'var(--color-surface-raised, #12141B)',
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
color: 'var(--color-text-primary, #F5F7FA)',
fontSize: '13px',
outline: 'none',
}}
/>
</div>
<div>
<label style={{ fontSize: '12px', fontWeight: '600', color: 'var(--color-text-secondary, #A5AAB8)', display: 'block', marginBottom: '6px' }}>
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
background: 'var(--color-surface-raised, #12141B)',
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
color: 'var(--color-text-primary, #F5F7FA)',
fontSize: '13px',
outline: 'none',
}}
/>
</div>
<button
type="submit"
style={{
width: '100%',
padding: '12px',
borderRadius: '10px',
background: selectedRole === 'admin'
? 'linear-gradient(135deg, #8B7CFF 0%, #6377FF 100%)'
: 'linear-gradient(135deg, #0EA5E9 0%, #0284C7 100%)',
color: '#ffffff',
border: 'none',
fontSize: '14px',
fontWeight: '700',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
marginTop: '6px',
boxShadow: '0 4px 16px rgba(139, 124, 255, 0.3)',
}}
>
Enter Studio Workspace as {selectedRole.toUpperCase()} <ArrowRight size={16} />
</button>
</form>
</div>
</div>
);
}
+317
View File
@@ -0,0 +1,317 @@
import React, { useState } from 'react';
import { Database, FileText, FolderArchive, Globe, Filter, HardDrive, Cpu, Layers, Terminal, Code, ShieldCheck, Radio, Search, GripVertical } from 'lucide-react';
const PALETTE_ITEMS = [
{
type: 'source_db',
label: 'Source Database',
subtitle: 'Postgres / MySQL / Oracle',
icon: Database,
color: '#60a5fa',
bgColor: 'rgba(59, 130, 246, 0.12)',
category: 'Ingestion',
},
{
type: 'source_gws',
label: 'Source GWS',
subtitle: 'Google Sheets & Drive PDF',
icon: FileText,
color: '#34d399',
bgColor: 'rgba(16, 185, 129, 0.12)',
category: 'Ingestion',
},
{
type: 'source_api',
label: 'Source REST API',
subtitle: 'REST Services & Webhooks',
icon: Globe,
color: '#a78bfa',
bgColor: 'rgba(167, 139, 250, 0.12)',
category: 'Ingestion',
},
{
type: 'source_api_dlt',
label: 'Source REST API (dlt)',
subtitle: 'Auto Un-nesting & Pagination',
icon: Globe,
color: '#06b6d4',
bgColor: 'rgba(6, 182, 212, 0.12)',
category: 'Ingestion',
},
{
type: 'source_file',
label: 'File / Document Ingestion',
subtitle: 'Upload CSV, Parquet, PDF',
icon: FolderArchive,
color: '#38bdf8',
bgColor: 'rgba(14, 165, 233, 0.12)',
category: 'Ingestion',
},
{
type: 'pandas_cleansing',
label: 'Pandas Cleansing',
subtitle: 'Deduplicate, null fill, cast',
icon: Filter,
color: '#f472b6',
bgColor: 'rgba(236, 72, 153, 0.12)',
category: 'Transformation',
},
{
type: 'minio_storage',
label: 'MinIO S3 Bucket Target',
subtitle: 'Parquet, Delta Lake Files',
icon: HardDrive,
color: '#fbbf24',
bgColor: 'rgba(245, 158, 11, 0.12)',
category: 'Storage',
},
{
type: 'trino_storage',
label: 'Trino Iceberg Table Target',
subtitle: 'Apache Iceberg Catalog',
icon: Cpu,
color: '#f43f5e',
bgColor: 'rgba(244, 63, 94, 0.12)',
category: 'Storage',
},
{
type: 'dbt_transform',
label: 'dbt Trino Iceberg Model',
subtitle: 'Unified SQL Models (Silver/Gold)',
icon: Layers,
color: '#c084fc',
bgColor: 'rgba(168, 85, 247, 0.12)',
category: 'Transformation',
},
{
type: 'duckdb_transform',
label: 'DuckDB Iceberg Transform',
subtitle: 'In-process SQL, MinIO HTTPFS (<512MB)',
icon: Database,
color: '#eab308',
bgColor: 'rgba(234, 179, 8, 0.12)',
category: 'Transformation',
},
{
type: 'pyiceberg_transform',
label: 'PyIceberg Transform',
subtitle: 'Native Arrow, ACID Snapshots',
icon: Cpu,
color: '#38bdf8',
bgColor: 'rgba(56, 189, 248, 0.12)',
category: 'Transformation',
},
{
type: 'pyspark_transform',
label: 'PySpark Iceberg Transform',
subtitle: 'Distributed Big Data Scale Engine',
icon: Terminal,
color: '#f97316',
bgColor: 'rgba(249, 115, 22, 0.12)',
category: 'Transformation',
},
{
type: 'bash_operator',
label: 'Bash Operator',
subtitle: 'Curl, Sling CDC, Shell',
icon: Terminal,
color: '#4ade80',
bgColor: 'rgba(34, 197, 94, 0.12)',
category: 'Operators',
},
{
type: 'python_operator',
label: 'Python Operator',
subtitle: 'REST Fetcher, Custom Py',
icon: Code,
color: '#22d3ee',
bgColor: 'rgba(6, 182, 212, 0.12)',
category: 'Operators',
},
{
type: 'ge_quality',
label: 'GE Quality Test Suite',
subtitle: 'Nulls, Uniqueness, Schema',
icon: ShieldCheck,
color: '#8b7cff',
bgColor: 'rgba(139, 124, 255, 0.12)',
category: 'Quality',
},
{
type: 'sitomat_callback',
label: 'Sitomat F16 Callback',
subtitle: 'Dataset Versioning & Governance',
icon: Radio,
color: '#2dd4bf',
bgColor: 'rgba(20, 184, 166, 0.12)',
category: 'Governance',
},
];
export default function NodePalette() {
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState('All');
const categories = ['All', 'Ingestion', 'Transformation', 'Storage', 'Operators', 'Quality', 'Governance'];
const filteredItems = PALETTE_ITEMS.filter((item) => {
const matchesSearch =
item.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.subtitle.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = selectedCategory === 'All' || item.category === selectedCategory;
return matchesSearch && matchesCategory;
});
const onDragStart = (event, nodeType, itemData) => {
const payload = JSON.stringify(itemData);
event.dataTransfer.setData('application/reactflow', payload);
event.dataTransfer.setData('application/json', payload);
event.dataTransfer.setData('text/plain', nodeType);
event.dataTransfer.effectAllowed = 'move';
};
return (
<aside
style={{
width: '280px',
background: 'var(--color-sidebar-background, #0A0B10)',
borderRight: '1px solid var(--color-border-subtle, rgba(255,255,255,0.06))',
display: 'flex',
flexDirection: 'column',
height: '100%',
userSelect: 'none',
}}
>
{/* PALETTE HEADER */}
<div style={{ padding: '16px', borderBottom: '1px solid var(--color-border-subtle, rgba(255,255,255,0.06))' }}>
<h2 style={{ fontSize: '15px', fontWeight: '600', margin: 0, color: 'var(--color-text-primary, #F5F7FA)' }}>
Component Palette
</h2>
<p style={{ fontSize: '12px', color: 'var(--color-text-muted, #707686)', margin: '4px 0 12px 0' }}>
Drag & drop onto canvas
</p>
{/* SEARCH INPUT */}
<div style={{ position: 'relative' }}>
<Search size={14} style={{ position: 'absolute', left: '10px', top: '10px', color: 'var(--color-text-muted, #707686)' }} />
<input
type="text"
placeholder="Search components..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{
width: '100%',
padding: '8px 10px 8px 30px',
borderRadius: 'var(--radius-sm, 8px)',
background: 'var(--color-surface, #0E1016)',
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
color: 'var(--color-text-primary, #F5F7FA)',
fontSize: '12px',
outline: 'none',
}}
/>
</div>
{/* CATEGORY TABS */}
<div style={{ display: 'flex', gap: '4px', overflowX: 'auto', marginTop: '10px', paddingBottom: '4px' }}>
{categories.map((cat) => (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
style={{
padding: '4px 8px',
borderRadius: '6px',
fontSize: '11px',
fontWeight: '600',
border: 'none',
cursor: 'pointer',
whiteSpace: 'nowrap',
background: selectedCategory === cat ? 'var(--color-violet, #8B7CFF)' : 'var(--color-surface, #0E1016)',
color: selectedCategory === cat ? '#ffffff' : 'var(--color-text-muted, #707686)',
transition: 'all 0.15s ease',
}}
>
{cat}
</button>
))}
</div>
</div>
{/* ITEM LIST */}
<div style={{ flex: 1, overflowY: 'auto', padding: '12px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{filteredItems.map((item) => {
const Icon = item.icon;
return (
<div
key={item.type}
draggable
onDragStart={(e) => onDragStart(e, item.type, item)}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px 12px',
borderRadius: 'var(--radius-md, 12px)',
background: 'var(--color-surface, #0E1016)',
border: '1px solid var(--color-border-subtle, rgba(255,255,255,0.06))',
cursor: 'grab',
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--color-surface-hover, #171A22)';
e.currentTarget.style.borderColor = 'var(--color-border-strong, rgba(255,255,255,0.14))';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--color-surface, #0E1016)';
e.currentTarget.style.borderColor = 'var(--color-border-subtle, rgba(255,255,255,0.06))';
}}
>
<GripVertical size={14} color="var(--color-text-disabled, #4B5060)" />
<div
style={{
width: '32px',
height: '32px',
borderRadius: '8px',
background: item.bgColor,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Icon size={16} color={item.color} />
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: '13px',
fontWeight: '600',
color: 'var(--color-text-primary, #F5F7FA)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.label}
</div>
<div
style={{
fontSize: '11px',
color: 'var(--color-text-muted, #707686)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.subtitle}
</div>
</div>
</div>
);
})}
</div>
</div>
</aside>
);
}
+136
View File
@@ -0,0 +1,136 @@
import React, { useState, memo, useCallback, useEffect } from 'react';
import { NodeResizer } from 'reactflow';
/**
* NoteNode Component (Langkah 7)
* Rendered behind nodes and edges on the canvas.
* Supports:
* - Double-click to edit (1st line bold title, remaining lines body text)
* - Corner and border resizing with min size 140x80
* - 5 color themes (c0 to c4) cycled via ◐ button
* - Delete via × button or Delete key
* - Does not contain handles / ports (purely decorative annotation)
*/
function NoteNode({ id, data = {}, selected }) {
const [isEditing, setIsEditing] = useState(false);
const colorIndex = data.c ?? 0;
const rawText = data.text || 'Note';
const lines = rawText.split('\n');
const title = lines[0] || 'Note';
const body = lines.slice(1).join('\n');
const handleDoubleClick = useCallback((e) => {
e.stopPropagation();
setIsEditing(true);
}, []);
const handleFinishEdit = useCallback(
(val) => {
setIsEditing(false);
const trimmed = val.trim();
if (data.onUpdateText) {
data.onUpdateText(id, trimmed || 'Note');
}
},
[id, data]
);
const handleColorChange = useCallback(
(e) => {
e.stopPropagation();
if (data.onChangeColor) {
data.onChangeColor(id);
}
},
[id, data]
);
const handleDelete = useCallback(
(e) => {
e.stopPropagation();
if (data.onDeleteNote) {
data.onDeleteNote(id);
}
},
[id, data]
);
const handleResize = useCallback(
(_, { width, height }) => {
if (data.onResizeNote) {
data.onResizeNote(id, { width, height });
}
},
[id, data]
);
return (
<div
className={`note c${colorIndex % 5} ${selected ? 'sel' : ''}`}
data-id={id}
style={{
width: data.w ? `${data.w}px` : '100%',
height: data.h ? `${data.h}px` : '100%',
minWidth: '140px',
minHeight: '80px',
}}
>
<NodeResizer
minWidth={140}
minHeight={80}
isVisible={selected}
lineClassName="note-resizer-line"
handleClassName="note-resizer-handle"
onResize={handleResize}
/>
{/* Note Tools (◐ color cycle, × delete) */}
<div className="ntools">
<button
type="button"
data-a="color"
onClick={handleColorChange}
aria-label="Change color"
title="Change color"
>
◐
</button>
<button
type="button"
data-a="del"
onClick={handleDelete}
aria-label="Delete note"
title="Delete note"
>
×
</button>
</div>
{/* Note Content (Title on 1st line, content below) */}
{isEditing ? (
<textarea
className="ne"
aria-label="Note text"
defaultValue={rawText}
autoFocus
onBlur={(e) => handleFinishEdit(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
handleFinishEdit(e.currentTarget.value);
}
}}
/>
) : (
<div className="nb" onDoubleClick={handleDoubleClick}>
<b>{title}</b>
{body && <p>{body}</p>}
</div>
)}
{/* Decorative corner resize handle indicator */}
<i className="rs" aria-hidden="true" />
</div>
);
}
export default memo(NoteNode);
+168
View File
@@ -0,0 +1,168 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import NoteNode from './NoteNode';
// Mock reactflow components used by NoteNode
vi.mock('reactflow', () => ({
NodeResizer: ({ onResize, isVisible }) => (
<div
data-testid="mock-node-resizer"
data-visible={isVisible}
onClick={() => onResize && onResize(null, { width: 320, height: 220 })}
/>
),
}));
describe('NoteNode Component (Langkah 7: Note Layer)', () => {
it('1. Renders note with title on 1st line and body text below', () => {
const { container } = render(
<NoteNode
id="note-1"
data={{
text: 'Bronze Landing Layer\nContains raw ingested tables from Postgres and GWS.',
c: 0,
w: 300,
h: 200,
}}
selected={false}
/>
);
const noteEl = container.querySelector('.note');
expect(noteEl).toBeInTheDocument();
expect(noteEl).toHaveClass('c0');
expect(screen.getByText('Bronze Landing Layer')).toBeInTheDocument();
expect(
screen.getByText('Contains raw ingested tables from Postgres and GWS.')
).toBeInTheDocument();
});
it('2. Renders with correct color class corresponding to 5 colors (c0 - c4)', () => {
const { container: c1Container } = render(
<NoteNode id="note-c1" data={{ text: 'Green Note', c: 1 }} selected={false} />
);
expect(c1Container.querySelector('.note')).toHaveClass('c1');
const { container: c3Container } = render(
<NoteNode id="note-c3" data={{ text: 'Red Note', c: 3 }} selected={false} />
);
expect(c3Container.querySelector('.note')).toHaveClass('c3');
});
it('3. Renders selected state with .sel class and shows NodeResizer', () => {
const { container } = render(
<NoteNode id="note-sel" data={{ text: 'Selected Note', c: 2 }} selected={true} />
);
const noteEl = container.querySelector('.note');
expect(noteEl).toHaveClass('sel');
const resizer = screen.getByTestId('mock-node-resizer');
expect(resizer.getAttribute('data-visible')).toBe('true');
});
it('4. Enters edit mode on double-click and updates text on blur', () => {
const onUpdateText = vi.fn();
render(
<NoteNode
id="note-edit"
data={{
text: 'Initial Title\nInitial Body',
c: 0,
onUpdateText,
}}
selected={false}
/>
);
const noteBody = screen.getByText('Initial Title').closest('.nb');
fireEvent.doubleClick(noteBody);
const textarea = screen.getByLabelText('Note text');
expect(textarea).toBeInTheDocument();
expect(textarea.value).toBe('Initial Title\nInitial Body');
fireEvent.change(textarea, { target: { value: 'Updated Title\nUpdated Body' } });
fireEvent.blur(textarea);
expect(onUpdateText).toHaveBeenCalledWith('note-edit', 'Updated Title\nUpdated Body');
});
it('5. Finishes edit mode when Escape is pressed', () => {
const onUpdateText = vi.fn();
render(
<NoteNode
id="note-esc"
data={{
text: 'Title to edit',
c: 0,
onUpdateText,
}}
selected={false}
/>
);
const noteBody = screen.getByText('Title to edit').closest('.nb');
fireEvent.doubleClick(noteBody);
const textarea = screen.getByLabelText('Note text');
fireEvent.change(textarea, { target: { value: 'Escaped Title' } });
fireEvent.keyDown(textarea, { key: 'Escape' });
expect(onUpdateText).toHaveBeenCalledWith('note-esc', 'Escaped Title');
});
it('6. Triggers onChangeColor when ◐ button is clicked', () => {
const onChangeColor = vi.fn();
render(
<NoteNode
id="note-color"
data={{
text: 'Color Note',
c: 0,
onChangeColor,
}}
selected={true}
/>
);
fireEvent.click(screen.getByTitle('Change color'));
expect(onChangeColor).toHaveBeenCalledWith('note-color');
});
it('7. Triggers onDeleteNote when × button is clicked', () => {
const onDeleteNote = vi.fn();
render(
<NoteNode
id="note-del"
data={{
text: 'Delete Note',
c: 0,
onDeleteNote,
}}
selected={true}
/>
);
fireEvent.click(screen.getByTitle('Delete note'));
expect(onDeleteNote).toHaveBeenCalledWith('note-del');
});
it('8. Triggers onResizeNote when resizer updates dimensions', () => {
const onResizeNote = vi.fn();
render(
<NoteNode
id="note-resize"
data={{
text: 'Resize Note',
c: 0,
onResizeNote,
}}
selected={true}
/>
);
fireEvent.click(screen.getByTestId('mock-node-resizer'));
expect(onResizeNote).toHaveBeenCalledWith('note-resize', { width: 320, height: 220 });
});
});
@@ -0,0 +1,330 @@
import React, { useState, useEffect } from 'react';
import { X, Search, Database, Layers, Play, Trash2, Download, CheckCircle, RefreshCw } from 'lucide-react';
const FALLBACK_TEMPLATES = [
{
id: "spku_monitoring_pipeline",
description: "Ingest SPKU Stasiun Postgres DB -> Pandas Cleansing -> MinIO S3 Bronze -> dbt Silver -> GE Quality Suite",
schedule: "0 */1 * * *",
status: "deployed",
nodes: [
{ id: "node-1", type: "customNode", position: { x: 80, y: 160 }, data: { label: "Source Postgres SPKU", type: "source_db", configured: true, config: { conn_id: "conn_postgres_source_db", table_name: "public.stasiun_spku" } } },
{ id: "node-2", type: "customNode", position: { x: 340, y: 160 }, data: { label: "Pandas Cleansing", type: "pandas_cleansing", configured: true, config: { null_strategy: "drop_row", drop_duplicates: true } } },
{ id: "node-3", type: "customNode", position: { x: 600, y: 160 }, data: { label: "MinIO S3 Bronze Bucket", type: "minio_storage", configured: true, config: { bucket_name: "dlh-bronze", file_format: "parquet" } } },
{ id: "node-4", type: "customNode", position: { x: 860, y: 160 }, data: { label: "dbt Silver Model", type: "dbt_transform", configured: true, config: { dbt_layer: "silver", dbt_model: "stg_spku_realtime" } } },
{ id: "node-5", type: "customNode", position: { x: 1120, y: 160 }, data: { label: "GE Quality Test Suite", type: "ge_quality", configured: true, config: { test_null_check: true, test_uniqueness: true } } }
],
edges: [
{ id: "e1-2", source: "node-1", target: "node-2" },
{ id: "e2-3", source: "node-2", target: "node-3" },
{ id: "e3-4", source: "node-3", target: "node-4" },
{ id: "e4-5", source: "node-4", target: "node-5" }
]
},
{
id: "gws_waste_ingestion",
description: "Source GWS Spreadsheet -> Pandas Cleansing -> Trino Iceberg Storage Target -> dbt Gold Mart",
schedule: "0 0 * * *",
status: "deployed",
nodes: [
{ id: "node-10", type: "customNode", position: { x: 80, y: 160 }, data: { label: "GWS Waste Reports", type: "source_gws", configured: true, config: { spreadsheet_id: "1OSRugXZvfjoY9M0yySKT-F3L4LTZXa40J0knU3_4Hik", sheet_range: "Sheet1" } } },
{ id: "node-11", type: "customNode", position: { x: 340, y: 160 }, data: { label: "Normalize Headers", type: "pandas_cleansing", configured: true, config: { null_strategy: "zero", drop_duplicates: true } } },
{ id: "node-12", type: "customNode", position: { x: 600, y: 160 }, data: { label: "Trino Iceberg Target", type: "trino_storage", configured: true, config: { trino_catalog: "iceberg", trino_schema: "silver", iceberg_table: "waste_reports_iceberg" } } },
{ id: "node-13", type: "customNode", position: { x: 860, y: 160 }, data: { label: "dbt Gold Mart", type: "dbt_transform", configured: true, config: { dbt_layer: "gold", dbt_model: "fct_waste_daily_mart" } } }
],
edges: [
{ id: "e10-11", source: "node-10", target: "node-11" },
{ id: "e11-12", source: "node-11", target: "node-12" },
{ id: "e12-13", source: "node-12", target: "node-13" }
]
}
];
export default function PipelineLoadModal({ isOpen, onClose, onLoadPipeline }) {
const [pipelines, setPipelines] = useState([]);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [activeTab, setActiveTab] = useState('all');
useEffect(() => {
if (isOpen) {
fetchPipelines();
}
}, [isOpen]);
const fetchPipelines = async () => {
setLoading(true);
try {
const response = await fetch('/api/v1/pipelines');
if (response.ok) {
const data = await response.json();
setPipelines(data.pipelines && data.pipelines.length > 0 ? data.pipelines : FALLBACK_TEMPLATES);
} else {
setPipelines(FALLBACK_TEMPLATES);
}
} catch (err) {
console.error('Failed to fetch pipelines:', err);
setPipelines(FALLBACK_TEMPLATES);
} finally {
setLoading(false);
}
};
const handleDeletePipeline = async (id, e) => {
e.stopPropagation();
if (!window.confirm(`Are you sure you want to delete pipeline ${id}?`)) return;
try {
const response = await fetch(`/api/v1/pipelines/${id}`, { method: 'DELETE' });
if (response.ok) {
setPipelines((prev) => prev.filter((p) => p.id !== id));
}
} catch (err) {
console.error('Delete error:', err);
}
};
if (!isOpen) return null;
const filteredPipelines = pipelines.filter((p) => {
const matchesSearch =
p.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
(p.description && p.description.toLowerCase().includes(searchTerm.toLowerCase()));
const pStatus = p.status || (p.is_draft ? 'draft' : 'deployed');
const matchesTab =
activeTab === 'all' ||
(activeTab === 'deployed' && pStatus === 'deployed') ||
(activeTab === 'draft' && pStatus === 'draft');
return matchesSearch && matchesTab;
});
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(8, 9, 13, 0.75)',
backdropFilter: 'blur(8px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 999,
padding: '20px',
}}
>
<div
style={{
width: '100%',
maxWidth: '840px',
maxHeight: '85vh',
background: 'var(--color-surface, #0E1016)',
border: '1px solid var(--color-border-strong, rgba(255,255,255,0.14))',
borderRadius: 'var(--radius-2xl, 24px)',
boxShadow: 'var(--shadow-elevated, 0 20px 60px rgba(0,0,0,0.35))',
display: 'flex',
flexDirection: 'column',
color: 'var(--color-text-primary, #F5F7FA)',
overflow: 'hidden',
}}
>
{/* MODAL HEADER */}
<div
style={{
padding: '20px 24px',
borderBottom: '1px solid var(--color-border-subtle, rgba(255,255,255,0.06))',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div>
<h2 style={{ fontSize: '18px', fontWeight: '700', margin: 0 }}>📂 Load Existing Pipeline</h2>
<p style={{ fontSize: '12px', color: 'var(--color-text-muted, #707686)', margin: '4px 0 0 0' }}>
Select a stored Lakehouse pipeline configuration to load into your canvas.
</p>
</div>
<button
onClick={onClose}
style={{
background: 'transparent',
border: 'none',
color: 'var(--color-text-muted, #707686)',
cursor: 'pointer',
}}
>
<X size={20} />
</button>
</div>
{/* SEARCH BAR & FILTERS */}
<div style={{ padding: '16px 24px', borderBottom: '1px solid var(--color-border-subtle, rgba(255,255,255,0.06))', display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ position: 'relative' }}>
<Search size={16} style={{ position: 'absolute', left: '12px', top: '12px', color: 'var(--color-text-muted, #707686)' }} />
<input
type="text"
placeholder="Search pipelines by ID or description..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: 'var(--radius-md, 12px)',
background: 'var(--color-surface-raised, #12141B)',
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
color: 'var(--color-text-primary, #F5F7FA)',
fontSize: '13px',
outline: 'none',
}}
/>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
{[
{ id: 'all', label: 'All Pipelines' },
{ id: 'deployed', label: '🚀 Airflow Deployed' },
{ id: 'draft', label: '📝 Saved Drafts' },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
style={{
padding: '6px 12px',
borderRadius: '6px',
fontSize: '12px',
fontWeight: '600',
border: 'none',
cursor: 'pointer',
background: activeTab === tab.id ? 'var(--color-violet, #8B7CFF)' : 'var(--color-surface-raised, #12141B)',
color: activeTab === tab.id ? '#ffffff' : 'var(--color-text-muted, #707686)',
}}
>
{tab.label}
</button>
))}
</div>
</div>
{/* PIPELINE CARDS GRID */}
<div style={{ flex: 1, overflowY: 'auto', padding: '24px' }}>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '40px', color: '#8b7cff' }}>
<RefreshCw size={24} className="animate-spin" />
</div>
) : filteredPipelines.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', color: 'var(--color-text-muted, #707686)' }}>
No pipelines found.
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
{filteredPipelines.map((p) => {
const isDraft = p.status === 'draft' || p.is_draft;
return (
<div
key={p.id}
onClick={() => {
onLoadPipeline(p);
onClose();
}}
style={{
background: 'var(--color-surface-raised, #12141B)',
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
borderRadius: 'var(--radius-xl, 20px)',
padding: '20px',
cursor: 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--color-violet, #8B7CFF)';
e.currentTarget.style.transform = 'translateY(-2px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border-default, rgba(255,255,255,0.09))';
e.currentTarget.style.transform = 'translateY(0)';
}}
>
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
<div style={{ display: 'flex', gap: '6px' }}>
<span
style={{
fontSize: '10px',
fontWeight: '700',
padding: '2px 8px',
borderRadius: '12px',
background: isDraft ? 'rgba(251, 191, 36, 0.15)' : 'rgba(74, 222, 128, 0.15)',
color: isDraft ? '#fbbf24' : '#4ade80',
border: `1px solid ${isDraft ? 'rgba(251, 191, 36, 0.3)' : 'rgba(74, 222, 128, 0.3)'}`,
}}
>
{isDraft ? '📝 Draft' : '🚀 Deployed'}
</span>
<span
style={{
fontSize: '10px',
fontWeight: '600',
padding: '2px 8px',
borderRadius: '12px',
background: 'rgba(139, 124, 255, 0.12)',
color: '#8b7cff',
border: '1px solid rgba(139, 124, 255, 0.22)',
}}
>
{p.nodes ? `${p.nodes.length} Components` : 'Pipeline'}
</span>
</div>
<button
onClick={(e) => handleDeletePipeline(p.id, e)}
title="Delete Pipeline"
style={{
background: 'transparent',
border: 'none',
color: '#f87171',
cursor: 'pointer',
opacity: 0.7,
}}
onMouseEnter={(e) => (e.currentTarget.style.opacity = '1')}
onMouseLeave={(e) => (e.currentTarget.style.opacity = '0.7')}
>
<Trash2 size={14} />
</button>
</div>
<h3 style={{ fontSize: '15px', fontWeight: '600', margin: '0 0 4px 0' }}>{p.id}</h3>
<p style={{ fontSize: '12px', color: 'var(--color-text-muted, #707686)', margin: '0 0 12px 0' }}>
{p.description || 'No description provided'}
</p>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '12px', borderTop: '1px solid var(--color-border-subtle, rgba(255,255,255,0.06))' }}>
<span style={{ fontSize: '11px', color: 'var(--color-text-muted, #707686)' }}>
Schedule: {p.schedule || '@hourly'}
</span>
<button
style={{
background: 'var(--color-violet, #8B7CFF)',
color: '#ffffff',
border: 'none',
padding: '6px 12px',
borderRadius: '6px',
fontSize: '11px',
fontWeight: '600',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
Load Canvas <Play size={10} fill="#ffffff" />
</button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,216 @@
import React, { useState } from 'react';
import { Tag, GitCommit, CheckCircle, X, ShieldCheck } from 'lucide-react';
export default function PublishVersionModal({
isOpen,
onClose,
pipelineId,
currentVersion = 'v1.0.0',
bundleName = 'dlh-core-ingestion',
onPublish
}) {
const [versionTag, setVersionTag] = useState('');
const [changelog, setChangelog] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
if (!isOpen) return null;
// Auto-suggest next version
const suggestedVersion = (() => {
try {
const match = currentVersion.match(/^v?(\d+)\.(\d+)\.(\d+)$/);
if (match) {
return `v${match[1]}.${match[2]}.${parseInt(match[3], 10) + 1}`;
}
} catch {
// fallback
}
return 'v1.1.0';
})();
const activeTag = versionTag.trim() || suggestedVersion;
const handleSubmit = async (e) => {
e.preventDefault();
setIsSubmitting(true);
try {
await onPublish(activeTag, changelog);
onClose();
} finally {
setIsSubmitting(false);
}
};
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(8, 9, 13, 0.75)',
backdropFilter: 'blur(8px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 999,
padding: '20px',
}}
>
<div
style={{
width: '100%',
maxWidth: '520px',
background: 'var(--color-surface, #0E1016)',
border: '1px solid var(--color-border-strong, rgba(255,255,255,0.14))',
borderRadius: 'var(--radius-2xl, 24px)',
boxShadow: 'var(--shadow-elevated, 0 20px 60px rgba(0,0,0,0.35))',
padding: '28px',
color: 'var(--color-text-primary, #F5F7FA)',
position: 'relative',
}}
>
{/* CLOSE BUTTON */}
<button
onClick={onClose}
style={{
position: 'absolute',
top: '20px',
right: '20px',
background: 'transparent',
border: 'none',
color: 'var(--color-text-muted, #707686)',
cursor: 'pointer',
}}
>
<X size={20} />
</button>
{/* HEADER */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
<div
style={{
background: 'linear-gradient(135deg, #10B981 0%, #059669 100%)',
padding: '10px',
borderRadius: '12px',
display: 'flex',
boxShadow: '0 4px 14px rgba(16,185,129,0.3)',
}}
>
<Tag size={22} color="#ffffff" />
</div>
<div>
<h2 style={{ fontSize: '18px', fontWeight: '700', margin: 0 }}>
Publish Version Snapshot
</h2>
<p style={{ margin: 0, fontSize: '12px', color: 'var(--color-text-muted, #707686)' }}>
Airflow 3 GitDAGBundle Version Pinning
</p>
</div>
</div>
{/* BUNDLE CONTEXT BADGE */}
<div
style={{
background: 'var(--color-surface-raised, #12141B)',
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
borderRadius: '12px',
padding: '12px 14px',
marginBottom: '20px',
display: 'flex',
flexDirection: 'column',
gap: '6px',
fontSize: '12px',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--color-text-muted, #707686)' }}>Pipeline Target:</span>
<span style={{ fontWeight: '600' }}>{pipelineId}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--color-text-muted, #707686)' }}>DAG Bundle:</span>
<span style={{ color: 'var(--color-violet, #8B7CFF)', fontWeight: '600' }}>{bundleName}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--color-text-muted, #707686)' }}>Current Active:</span>
<span style={{ color: '#fbbf24', fontWeight: '600' }}>{currentVersion}</span>
</div>
</div>
{/* FORM */}
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: '12px', fontWeight: '600', color: 'var(--color-text-secondary, #A5AAB8)', display: 'block', marginBottom: '6px' }}>
Semantic Version Tag (Suggested: {suggestedVersion})
</label>
<div style={{ position: 'relative' }}>
<input
type="text"
placeholder={suggestedVersion}
value={versionTag}
onChange={(e) => setVersionTag(e.target.value)}
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
background: 'var(--color-surface-raised, #12141B)',
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
color: 'var(--color-text-primary, #F5F7FA)',
fontSize: '13px',
outline: 'none',
}}
/>
</div>
</div>
<div>
<label style={{ fontSize: '12px', fontWeight: '600', color: 'var(--color-text-secondary, #A5AAB8)', display: 'block', marginBottom: '6px' }}>
Changelog / Release Notes
</label>
<textarea
rows={3}
placeholder="e.g. Added sliding window 3h and dbt Silver transformation model"
value={changelog}
onChange={(e) => setChangelog(e.target.value)}
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
background: 'var(--color-surface-raised, #12141B)',
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
color: 'var(--color-text-primary, #F5F7FA)',
fontSize: '13px',
outline: 'none',
resize: 'none',
}}
/>
</div>
{/* SUBMIT BUTTON */}
<button
type="submit"
disabled={isSubmitting}
style={{
marginTop: '6px',
width: '100%',
padding: '12px',
borderRadius: '10px',
background: 'linear-gradient(135deg, #10B981 0%, #059669 100%)',
color: '#ffffff',
border: 'none',
fontSize: '14px',
fontWeight: '700',
cursor: isSubmitting ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
boxShadow: '0 4px 16px rgba(16,185,129,0.3)',
}}
>
<GitCommit size={16} />
{isSubmitting ? 'Publishing...' : `Confirm & Publish ${activeTag}`}
</button>
</form>
</div>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import React, { useState } from 'react';
import { EdgeLabelRenderer, BaseEdge } from 'reactflow';
import { X } from 'lucide-react';
export default function RemovableEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
style = {},
markerEnd,
data,
}) {
const [isHovered, setIsHovered] = useState(false);
// Cubic Bezier with horizontal control points max(50, |dx|/2)
const dx = Math.max(50, Math.abs(targetX - sourceX) / 2);
const edgePath = `M${sourceX},${sourceY} C${sourceX + dx},${sourceY} ${targetX - dx},${targetY} ${targetX},${targetY}`;
const midX = (sourceX + targetX) / 2;
const midY = (sourceY + targetY) / 2;
const handleDelete = (evt) => {
if (evt) evt.stopPropagation();
if (data && data.onDeleteEdge) {
data.onDeleteEdge(id);
}
};
const handleDoubleClick = (evt) => {
evt.stopPropagation();
handleDelete(evt);
};
return (
<>
{/* VISIBLE BEZIER EDGE */}
<BaseEdge
path={edgePath}
markerEnd={markerEnd}
style={{
...style,
stroke: isHovered ? 'var(--acc)' : style.stroke || 'var(--muted)',
strokeWidth: isHovered ? 2.5 : style.strokeWidth || 2,
transition: 'stroke 0.15s ease, stroke-width 0.15s ease',
}}
/>
{/* INVISIBLE WIDE HIT AREA FOR HOVER AND DOUBLE CLICK TO DELETE */}
<path
d={edgePath}
fill="none"
stroke="transparent"
strokeWidth={22}
style={{ cursor: 'pointer' }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onDoubleClick={handleDoubleClick}
/>
{/* OPTIONAL HOVER DELETE BUTTON IN CENTER */}
{isHovered && (
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${midX}px,${midY}px)`,
fontSize: 12,
pointerEvents: 'all',
zIndex: 10,
}}
className="nodrag nopan"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<button
onClick={handleDelete}
title="Click or double-click line to delete connection"
aria-label="Delete connection"
style={{
width: '18px',
height: '18px',
borderRadius: '50%',
background: 'var(--surface)',
color: 'var(--muted)',
border: '1px solid var(--line)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: 'var(--shadow)',
padding: 0,
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#ffffff';
e.currentTarget.style.background = 'var(--err)';
e.currentTarget.style.borderColor = 'var(--err)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--muted)';
e.currentTarget.style.background = 'var(--surface)';
e.currentTarget.style.borderColor = 'var(--line)';
}}
>
<X size={10} strokeWidth={2.5} />
</button>
</div>
</EdgeLabelRenderer>
)}
</>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,344 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import RightDrawer from './RightDrawer';
describe('RightDrawer - Component Palette, Node Config, Pipeline Settings (Langkah 5)', () => {
it('1. Renders nothing when mode is null', () => {
const { container } = render(
<RightDrawer mode={null} onClose={vi.fn()} />
);
expect(container.firstChild).toBeNull();
});
describe('Palette Mode', () => {
it('2. Renders "What happens next?", search bar, and 5 category groups by default', () => {
render(
<RightDrawer
mode="palette"
onClose={vi.fn()}
onSelectComponent={vi.fn()}
/>
);
expect(screen.getByText('What happens next?')).toBeInTheDocument();
expect(screen.getByPlaceholderText(/Search components/i)).toBeInTheDocument();
expect(screen.getByText('Ingestion')).toBeInTheDocument();
expect(screen.getByText('Transformation')).toBeInTheDocument();
expect(screen.getByText('Quality')).toBeInTheDocument();
expect(screen.getByText('Storage')).toBeInTheDocument();
expect(screen.getByText('Notify')).toBeInTheDocument();
});
it('3. Selecting a category displays its components in rows with back button', () => {
render(
<RightDrawer
mode="palette"
onClose={vi.fn()}
onSelectComponent={vi.fn()}
/>
);
fireEvent.click(screen.getByText('Ingestion'));
// Category view title and back button
expect(screen.getByText('Ingestion')).toBeInTheDocument();
expect(screen.getByLabelText('Back to categories')).toBeInTheDocument();
expect(screen.getByText('Source Database')).toBeInTheDocument();
expect(screen.getByText('Source GWS')).toBeInTheDocument();
expect(screen.getByText('Source REST API')).toBeInTheDocument();
// Click back returns to root categories
fireEvent.click(screen.getByLabelText('Back to categories'));
expect(screen.getByText('What happens next?')).toBeInTheDocument();
});
it('4. Searching filters components across all categories', () => {
render(
<RightDrawer
mode="palette"
onClose={vi.fn()}
onSelectComponent={vi.fn()}
/>
);
const searchInput = screen.getByPlaceholderText(/Search components/i);
fireEvent.change(searchInput, { target: { value: 'iceberg' } });
expect(screen.getByText('Trino Iceberg Table Target')).toBeInTheDocument();
expect(screen.getByText('PyIceberg Transform')).toBeInTheDocument();
fireEvent.change(searchInput, { target: { value: 'nonexistent_component_xyz' } });
expect(screen.getByText(/No component matches your search/i)).toBeInTheDocument();
});
it('5. Clicking a component triggers onSelectComponent and closes drawer', () => {
const onSelect = vi.fn();
const onClose = vi.fn();
render(
<RightDrawer
mode="palette"
onClose={onClose}
onSelectComponent={onSelect}
/>
);
fireEvent.click(screen.getByText('Quality'));
fireEvent.click(screen.getByText('GE Quality Check'));
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({ type: 'ge_quality', label: 'GE Quality Check' })
);
expect(onClose).toHaveBeenCalled();
});
});
describe('Node Configuration Mode', () => {
it('6. Renders node fields, Task ID (readonly), and calls onUpdateNode on field change', () => {
const onUpdateNode = vi.fn();
const onDeleteNode = vi.fn();
const selectedNode = {
id: 'node-db-1',
data: {
type: 'source_db',
label: 'Primary Postgres DB',
config: { conn: 'Postgres', table: 'permits' },
},
};
render(
<RightDrawer
mode="node"
selectedNode={selectedNode}
onUpdateNode={onUpdateNode}
onDeleteNode={onDeleteNode}
onClose={vi.fn()}
/>
);
// Node title
const titleInput = screen.getByDisplayValue('Primary Postgres DB');
expect(titleInput).toBeInTheDocument();
// Change title
fireEvent.change(titleInput, { target: { value: 'Renamed DB' } });
expect(onUpdateNode).toHaveBeenCalledWith(
'node-db-1',
expect.objectContaining({ label: 'Renamed DB' })
);
// Readonly Task ID
const taskIdInput = screen.getByDisplayValue('node-db-1');
expect(taskIdInput).toHaveAttribute('readonly');
// Table field
const tableInput = screen.getByDisplayValue('permits');
fireEvent.change(tableInput, { target: { value: 'new_permits_tbl' } });
expect(onUpdateNode).toHaveBeenCalledWith(
'node-db-1',
expect.objectContaining({
config: expect.objectContaining({ table: 'new_permits_tbl' }),
})
);
});
it('7. Deletes node and closes drawer on clicking Delete node', () => {
const onDeleteNode = vi.fn();
const onClose = vi.fn();
const selectedNode = {
id: 'node-to-delete',
data: { type: 'ge_quality', label: 'Quality Check' },
};
render(
<RightDrawer
mode="node"
selectedNode={selectedNode}
onDeleteNode={onDeleteNode}
onClose={onClose}
onUpdateNode={vi.fn()}
/>
);
fireEvent.click(screen.getByText('Delete node'));
expect(onDeleteNode).toHaveBeenCalledWith('node-to-delete');
expect(onClose).toHaveBeenCalled();
});
});
describe('Pipeline Settings Mode', () => {
it('8. Renders slug, DAG bundle, description, and schedule controls', () => {
const setPipelineId = vi.fn();
const setBundleName = vi.fn();
const setDescription = vi.fn();
const setSchedule = vi.fn();
render(
<RightDrawer
mode="pipeline"
onClose={vi.fn()}
pipelineSettings={{
pipelineId: 'lakehouse_pipeline',
setPipelineId,
bundleName: 'dlh-core-ingestion',
setBundleName,
description: 'Core ingestion DAG',
setDescription,
schedule: '@hourly',
setSchedule,
}}
/>
);
expect(screen.getByText('Pipeline settings')).toBeInTheDocument();
expect(screen.getByDisplayValue('lakehouse_pipeline')).toBeInTheDocument();
expect(screen.getByDisplayValue('dlh-core-ingestion')).toBeInTheDocument();
expect(screen.getByDisplayValue('Core ingestion DAG')).toBeInTheDocument();
expect(screen.getByDisplayValue('@hourly')).toBeInTheDocument();
fireEvent.change(screen.getByDisplayValue('lakehouse_pipeline'), {
target: { value: 'new_slug' },
});
expect(setPipelineId).toHaveBeenCalledWith('new_slug');
});
});
describe('Keyboard & Dismiss Interactions', () => {
it('9. Closes on Escape key press or close button click', () => {
const onClose = vi.fn();
render(
<RightDrawer
mode="palette"
onClose={onClose}
onSelectComponent={vi.fn()}
/>
);
fireEvent.click(screen.getByTitle('Close'));
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(2);
});
});
describe('Query Variations & Rich Configurations', () => {
it('10. Renders dbt transform SQL query textarea and updates on change', () => {
const onUpdateNode = vi.fn();
const selectedNode = {
id: 'node-dbt-1',
data: {
type: 'dbt_transform',
label: 'dbt Silver Cleanse',
config: { dbt_layer: 'silver' },
},
};
render(
<RightDrawer
mode="node"
selectedNode={selectedNode}
onUpdateNode={onUpdateNode}
onDeleteNode={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.getByText('dbt Target Layer')).toBeInTheDocument();
expect(screen.getByText('dbt SQL Model Transformation Query')).toBeInTheDocument();
const textarea = screen.getByPlaceholderText(/SELECT ... FROM/i);
expect(textarea).toBeInTheDocument();
fireEvent.change(textarea, { target: { value: 'SELECT id, status FROM {{ ref("bronze") }}' } });
expect(onUpdateNode).toHaveBeenCalledWith(
'node-dbt-1',
expect.objectContaining({
config: expect.objectContaining({
sql_query_preview: 'SELECT id, status FROM {{ ref("bronze") }}',
}),
})
);
});
it('11. Renders DuckDB and PySpark SQL transformation query textareas', () => {
const onUpdateNode = vi.fn();
const selectedDuck = {
id: 'node-duck-1',
data: {
type: 'duckdb_transform',
label: 'DuckDB Dedupe',
config: { layer: 'silver', query: 'SELECT * FROM source_parquet' },
},
};
const { unmount } = render(
<RightDrawer
mode="node"
selectedNode={selectedDuck}
onUpdateNode={onUpdateNode}
onDeleteNode={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.getByText('DuckDB SQL Query (Optional Override)')).toBeInTheDocument();
expect(screen.getByDisplayValue('SELECT * FROM source_parquet')).toBeInTheDocument();
unmount();
const selectedSpark = {
id: 'node-spark-1',
data: {
type: 'pyspark_transform',
label: 'Spark Large Scale',
config: { sql_query: 'SELECT count(*) FROM source_view' },
},
};
render(
<RightDrawer
mode="node"
selectedNode={selectedSpark}
onUpdateNode={onUpdateNode}
onDeleteNode={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.getByText('PySpark SQL / Transformation Query')).toBeInTheDocument();
expect(screen.getByDisplayValue('SELECT count(*) FROM source_view')).toBeInTheDocument();
});
it('12. Renders Great Expectations SQL assertion textarea for unexpected count', () => {
const onUpdateNode = vi.fn();
const selectedGE = {
id: 'node-ge-1',
data: {
type: 'ge_quality',
label: 'GE Quality Assertion',
config: {
ge_scope_level: 'sql',
sql_assertion: 'SELECT count(*) FROM silver.table WHERE status NOT IN ("OK")',
},
},
};
render(
<RightDrawer
mode="node"
selectedNode={selectedGE}
onUpdateNode={onUpdateNode}
onDeleteNode={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.getByText('⚡ Custom SQL Assertion (Unexpected Rows)')).toBeInTheDocument();
expect(
screen.getByDisplayValue('SELECT count(*) FROM silver.table WHERE status NOT IN ("OK")')
).toBeInTheDocument();
});
});
});
+433
View File
@@ -0,0 +1,433 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import {
LayoutDashboard,
Layers,
FolderOpen,
Boxes,
Settings,
ChevronLeft,
ChevronRight,
LogOut,
User,
Shield,
KeyRound,
} from 'lucide-react';
export default function Sidebar({ onToast, onBeforeNavigate }) {
const { currentUser, userRole, isAdmin, logout } = useAuth();
const navigate = useNavigate();
const location = useLocation();
// Collapsed state persisted in localStorage
const [isCollapsed, setIsCollapsed] = useState(() => {
try {
return localStorage.getItem('merica_sidebar_collapsed') === '1';
} catch {
return false;
}
});
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const userMenuRef = useRef(null);
// Auto-collapse on small screens (< 820px)
useEffect(() => {
const handleResize = () => {
if (window.innerWidth < 820) {
setIsCollapsed(true);
}
};
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Close user menu on outside click
useEffect(() => {
const handleClickOutside = (e) => {
if (userMenuRef.current && !userMenuRef.current.contains(e.target)) {
setIsUserMenuOpen(false);
}
};
if (isUserMenuOpen) {
document.addEventListener('pointerdown', handleClickOutside);
}
return () => document.removeEventListener('pointerdown', handleClickOutside);
}, [isUserMenuOpen]);
const toggleCollapse = () => {
const nextState = !isCollapsed;
setIsCollapsed(nextState);
try {
localStorage.setItem('merica_sidebar_collapsed', nextState ? '1' : '0');
} catch (e) {
console.warn('Could not save sidebar preference:', e);
}
};
const handleNav = (path) => {
if (onBeforeNavigate) {
const allowed = onBeforeNavigate(path);
if (!allowed) return;
}
navigate(path);
};
const handleTemplatesClick = () => {
if (onToast) {
onToast('Templates are coming soon in next milestone');
}
};
const currentPath = location.pathname;
const isStudio = currentPath === '/studio' || currentPath.startsWith('/studio/');
const isOverview = currentPath === '/overview' || currentPath === '/';
const isAllPipelines = currentPath === '/pipelines';
const isSettings = currentPath === '/settings';
const userInitial = (currentUser?.name || currentUser?.username || 'U')[0].toUpperCase();
const authProviderLabel = currentUser?.auth_provider === 'keycloak' ? 'Keycloak SSO' : 'Local Admin';
return (
<aside
style={{
width: isCollapsed ? '52px' : '208px',
flexShrink: 0,
height: '100%',
background: 'var(--surface)',
borderRight: '1px solid var(--line)',
display: 'flex',
flexDirection: 'column',
padding: isCollapsed ? '10px 6px' : '10px 8px',
gap: '4px',
transition: 'width 0.15s ease',
position: 'relative',
zIndex: 30,
userSelect: 'none',
boxSizing: 'border-box',
}}
>
{/* BRAND HEADER */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: isCollapsed ? 'center' : 'flex-start',
gap: '8px',
padding: isCollapsed ? '6px 0 14px 0' : '6px 8px 14px 8px',
cursor: 'pointer',
}}
onClick={() => handleNav('/studio')}
title="Managed Engine for Robust Ingestion & Cloud Airflow"
>
<div
style={{
width: '28px',
height: '28px',
borderRadius: '7px',
background: 'var(--acc)',
color: '#ffffff',
fontWeight: 700,
fontSize: '13px',
display: 'grid',
placeItems: 'center',
flexShrink: 0,
}}
>
M
</div>
{!isCollapsed && (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ fontWeight: 700, fontSize: '15px', color: 'var(--text)', letterSpacing: '-0.3px' }}>
MERICA
</span>
</div>
)}
</div>
{/* NAVIGATION ITEMS */}
<nav style={{ display: 'flex', flexDirection: 'column', gap: '2px', flex: 1 }}>
{/* OVERVIEW */}
<button
type="button"
onClick={() => handleNav('/overview')}
title="Overview"
aria-label="Overview"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: isCollapsed ? 'center' : 'flex-start',
gap: '10px',
padding: isCollapsed ? '8px 0' : '8px 10px',
border: 0,
background: isOverview ? 'var(--bg)' : 'transparent',
color: isOverview ? 'var(--text)' : 'var(--muted)',
fontWeight: isOverview ? 600 : 500,
borderRadius: '7px',
cursor: 'pointer',
textAlign: 'left',
width: '100%',
transition: 'background 0.12s, color 0.12s',
}}
>
<span style={{ width: '18px', display: 'grid', placeItems: 'center', flexShrink: 0 }}>
<LayoutDashboard size={17} color={isOverview ? 'var(--acc)' : 'currentColor'} />
</span>
{!isCollapsed && <span style={{ fontSize: '13px' }}>Overview</span>}
</button>
{/* STUDIO */}
<button
type="button"
onClick={() => handleNav('/studio')}
title="Studio"
aria-label="Studio"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: isCollapsed ? 'center' : 'flex-start',
gap: '10px',
padding: isCollapsed ? '8px 0' : '8px 10px',
border: 0,
background: isStudio ? 'var(--bg)' : 'transparent',
color: isStudio ? 'var(--text)' : 'var(--muted)',
fontWeight: isStudio ? 600 : 500,
borderRadius: '7px',
cursor: 'pointer',
textAlign: 'left',
width: '100%',
transition: 'background 0.12s, color 0.12s',
}}
>
<span style={{ width: '18px', display: 'grid', placeItems: 'center', flexShrink: 0 }}>
<Layers size={17} color={isStudio ? 'var(--acc)' : 'currentColor'} />
</span>
{!isCollapsed && <span style={{ fontSize: '13px' }}>Studio</span>}
</button>
{/* ALL PIPELINES (ADMIN ONLY) */}
{isAdmin && (
<button
type="button"
onClick={() => handleNav('/pipelines')}
title="All pipelines (Admin only)"
aria-label="All pipelines"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: isCollapsed ? 'center' : 'flex-start',
gap: '10px',
padding: isCollapsed ? '8px 0' : '8px 10px',
border: 0,
background: isAllPipelines ? 'var(--bg)' : 'transparent',
color: isAllPipelines ? 'var(--text)' : 'var(--muted)',
fontWeight: isAllPipelines ? 600 : 500,
borderRadius: '7px',
cursor: 'pointer',
textAlign: 'left',
width: '100%',
transition: 'background 0.12s, color 0.12s',
}}
>
<span style={{ width: '18px', display: 'grid', placeItems: 'center', flexShrink: 0 }}>
<FolderOpen size={17} color={isAllPipelines ? 'var(--acc)' : 'currentColor'} />
</span>
{!isCollapsed && <span style={{ fontSize: '13px' }}>All pipelines</span>}
</button>
)}
{/* TEMPLATES */}
<button
type="button"
onClick={handleTemplatesClick}
title="Templates"
aria-label="Templates"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: isCollapsed ? 'center' : 'flex-start',
gap: '10px',
padding: isCollapsed ? '8px 0' : '8px 10px',
border: 0,
background: 'transparent',
color: 'var(--muted)',
fontWeight: 500,
borderRadius: '7px',
cursor: 'pointer',
textAlign: 'left',
width: '100%',
transition: 'background 0.12s, color 0.12s',
}}
>
<span style={{ width: '18px', display: 'grid', placeItems: 'center', flexShrink: 0 }}>
<Boxes size={17} />
</span>
{!isCollapsed && <span style={{ fontSize: '13px' }}>Templates</span>}
</button>
{/* SPACER */}
<div style={{ flex: 1 }} />
{/* SETTINGS */}
<button
type="button"
onClick={() => handleNav('/settings')}
title="Akses Pengaturan Dynamic RBAC · Settings"
aria-label="Settings"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: isCollapsed ? 'center' : 'flex-start',
gap: '10px',
padding: isCollapsed ? '8px 0' : '8px 10px',
border: 0,
background: isSettings ? 'var(--bg)' : 'transparent',
color: isSettings ? 'var(--text)' : 'var(--muted)',
fontWeight: isSettings ? 600 : 500,
borderRadius: '7px',
cursor: 'pointer',
textAlign: 'left',
width: '100%',
transition: 'background 0.12s, color 0.12s',
}}
>
<span style={{ width: '18px', display: 'grid', placeItems: 'center', flexShrink: 0 }}>
<Settings size={17} color={isSettings ? 'var(--acc)' : 'currentColor'} />
</span>
{!isCollapsed && <span style={{ fontSize: '13px' }}>Settings</span>}
</button>
{/* COLLAPSE / EXPAND TOGGLE */}
<button
type="button"
id="sbt"
onClick={toggleCollapse}
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: isCollapsed ? 'center' : 'flex-start',
gap: '10px',
padding: isCollapsed ? '8px 0' : '8px 10px',
border: 0,
background: 'transparent',
color: 'var(--muted)',
borderRadius: '7px',
cursor: 'pointer',
textAlign: 'left',
width: '100%',
transition: 'background 0.12s, color 0.12s',
}}
>
<span style={{ width: '18px', display: 'grid', placeItems: 'center', flexShrink: 0 }}>
{isCollapsed ? <ChevronRight size={17} /> : <ChevronLeft size={17} />}
</span>
{!isCollapsed && <span style={{ fontSize: '13px' }}>Collapse</span>}
</button>
</nav>
{/* USER CARD AT BOTTOM */}
<div style={{ position: 'relative' }} ref={userMenuRef}>
<div
id="usr"
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
title="Account and sign out"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: isCollapsed ? 'center' : 'flex-start',
gap: '10px',
padding: '8px',
borderTop: '1px solid var(--line)',
marginTop: '6px',
cursor: 'pointer',
borderRadius: '7px',
background: isUserMenuOpen ? 'var(--bg)' : 'transparent',
transition: 'background 0.12s',
}}
>
<div
style={{
width: '28px',
height: '28px',
borderRadius: '50%',
background: 'var(--accsoft)',
color: 'var(--acc)',
display: 'grid',
placeItems: 'center',
fontWeight: 600,
fontSize: '12px',
flexShrink: 0,
}}
>
{userInitial}
</div>
{!isCollapsed && (
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontWeight: 600,
fontSize: '12.5px',
color: 'var(--text)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{currentUser?.name || currentUser?.username || 'User'}
</div>
<small
style={{
display: 'block',
color: 'var(--muted)',
fontSize: '11px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{currentUser?.capability || userRole}
</small>
</div>
)}
</div>
{/* USER POPOVER MENU */}
{isUserMenuOpen && (
<div
className="pop"
style={{
bottom: '48px',
left: isCollapsed ? '54px' : '4px',
width: '200px',
zIndex: 100,
}}
>
<div style={{ padding: '8px 10px', fontSize: '12px', color: 'var(--muted)' }}>
Signed in via <strong>{authProviderLabel}</strong>
</div>
<div style={{ padding: '0 10px 8px 10px', fontSize: '11.5px', color: 'var(--muted)' }}>
Role: <span style={{ color: 'var(--text)', fontWeight: 600 }}>{userRole}</span>
</div>
<hr />
<button
type="button"
className="it danger"
onClick={() => {
setIsUserMenuOpen(false);
logout();
navigate('/login');
}}
>
<LogOut size={14} />
<span>Sign out</span>
</button>
</div>
)}
</div>
</aside>
);
}
@@ -0,0 +1,245 @@
import React, { useState, useEffect } from 'react';
import { History, RotateCcw, X, GitCommit, CheckCircle, Clock } from 'lucide-react';
export default function TimeMachineModal({
isOpen,
onClose,
pipelineId,
currentVersion = 'v1.0.0',
onRevert
}) {
const [versions, setVersions] = useState([]);
const [loading, setLoading] = useState(false);
const [revertingTag, setRevertingTag] = useState(null);
useEffect(() => {
if (isOpen && pipelineId) {
setLoading(true);
fetch(`/api/v1/pipelines/${pipelineId}/versions`)
.then((res) => {
if (!res.ok) throw new Error('Failed to fetch versions');
return res.json();
})
.then((data) => {
setVersions(data.versions || []);
})
.catch(() => {
// Fallback if empty or offline
setVersions([
{
version: currentVersion || 'v1.0.0',
pipeline_id: pipelineId,
bundle_name: 'dlh-core-ingestion',
changelog: 'Initial baseline deployment',
published_at: new Date().toISOString(),
nodes_count: 5,
edges_count: 4,
}
]);
})
.finally(() => setLoading(false));
}
}, [isOpen, pipelineId, currentVersion]);
if (!isOpen) return null;
const handleRevertClick = async (tag) => {
setRevertingTag(tag);
try {
await onRevert(tag);
onClose();
} finally {
setRevertingTag(null);
}
};
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(8, 9, 13, 0.75)',
backdropFilter: 'blur(8px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 999,
padding: '20px',
}}
>
<div
style={{
width: '100%',
maxWidth: '680px',
maxHeight: '85vh',
background: 'var(--color-surface, #0E1016)',
border: '1px solid var(--color-border-strong, rgba(255,255,255,0.14))',
borderRadius: 'var(--radius-2xl, 24px)',
boxShadow: 'var(--shadow-elevated, 0 20px 60px rgba(0,0,0,0.35))',
padding: '28px',
color: 'var(--color-text-primary, #F5F7FA)',
position: 'relative',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* CLOSE BUTTON */}
<button
onClick={onClose}
style={{
position: 'absolute',
top: '20px',
right: '20px',
background: 'transparent',
border: 'none',
color: 'var(--color-text-muted, #707686)',
cursor: 'pointer',
}}
>
<X size={20} />
</button>
{/* HEADER */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '20px' }}>
<div
style={{
background: 'linear-gradient(135deg, #8B7CFF 0%, #6377FF 100%)',
padding: '10px',
borderRadius: '12px',
display: 'flex',
boxShadow: '0 4px 14px rgba(139,124,255,0.3)',
}}
>
<History size={22} color="#ffffff" />
</div>
<div>
<h2 style={{ fontSize: '18px', fontWeight: '700', margin: 0 }}>
Time Machine: Version History
</h2>
<p style={{ margin: 0, fontSize: '12px', color: 'var(--color-text-muted, #707686)' }}>
Historical DAG Snapshots for Pipeline: <strong style={{ color: '#F5F7FA' }}>{pipelineId}</strong>
</p>
</div>
</div>
{/* VERSION LIST */}
<div
style={{
flex: 1,
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: '12px',
paddingRight: '6px',
}}
>
{loading ? (
<div style={{ padding: '30px', textAlign: 'center', color: 'var(--color-text-muted, #707686)' }}>
Loading version history...
</div>
) : versions.length === 0 ? (
<div style={{ padding: '30px', textAlign: 'center', color: 'var(--color-text-muted, #707686)' }}>
No historical versions recorded yet. Publish a version to create immutable snapshots.
</div>
) : (
versions.map((item, idx) => {
const isCurrent = item.version === currentVersion;
return (
<div
key={idx}
style={{
background: isCurrent ? 'rgba(139, 124, 255, 0.08)' : 'var(--color-surface-raised, #12141B)',
border: `1px solid ${isCurrent ? 'var(--color-violet, #8B7CFF)' : 'var(--color-border-default, rgba(255,255,255,0.09))'}`,
borderRadius: '14px',
padding: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '16px',
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
style={{
background: isCurrent ? 'var(--color-violet, #8B7CFF)' : 'var(--color-surface, #0E1016)',
color: '#ffffff',
padding: '3px 10px',
borderRadius: '8px',
fontWeight: '700',
fontSize: '12px',
border: '1px solid rgba(255,255,255,0.1)',
}}
>
{item.version}
</span>
{isCurrent && (
<span
style={{
background: 'rgba(74, 222, 128, 0.15)',
color: '#4ade80',
fontSize: '11px',
fontWeight: '600',
padding: '2px 8px',
borderRadius: '9999px',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<CheckCircle size={11} /> Active in Canvas
</span>
)}
<span style={{ fontSize: '11px', color: 'var(--color-text-muted, #707686)' }}>
Bundle: {item.bundle_name || 'dlh-core-ingestion'}
</span>
</div>
<div style={{ fontSize: '12px', color: 'var(--color-text-secondary, #A5AAB8)', marginTop: '2px' }}>
{item.changelog || 'No changelog description provided.'}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', fontSize: '11px', color: 'var(--color-text-muted, #707686)', marginTop: '4px' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<Clock size={11} /> {item.published_at ? new Date(item.published_at).toLocaleString() : 'N/A'}
</span>
<span>• {item.nodes_count || 0} Components</span>
<span>• {item.edges_count || 0} Dependencies</span>
</div>
</div>
<div>
{!isCurrent && (
<button
onClick={() => handleRevertClick(item.version)}
disabled={revertingTag === item.version}
style={{
background: 'var(--color-surface, #0E1016)',
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
color: 'var(--color-text-primary, #F5F7FA)',
padding: '8px 14px',
borderRadius: '8px',
fontSize: '12px',
fontWeight: '600',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px',
whiteSpace: 'nowrap',
}}
>
<RotateCcw size={13} />
{revertingTag === item.version ? 'Reverting...' : 'Revert Canvas'}
</button>
)}
</div>
</div>
);
})
)}
</div>
</div>
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
import React from 'react';
import { Plus, FolderOpen, Layers, Sparkles, X } from 'lucide-react';
export default function WelcomeModal({ isOpen, onClose, onCreateBlank, onLoadExisting }) {
if (!isOpen) return null;
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(8, 9, 13, 0.75)',
backdropFilter: 'blur(8px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 999,
padding: '20px',
}}
>
<div
style={{
width: '100%',
maxWidth: '560px',
background: 'var(--color-surface, #0E1016)',
border: '1px solid var(--color-border-strong, rgba(255,255,255,0.14))',
borderRadius: 'var(--radius-2xl, 24px)',
boxShadow: 'var(--shadow-elevated, 0 20px 60px rgba(0,0,0,0.35))',
padding: '32px',
position: 'relative',
color: 'var(--color-text-primary, #F5F7FA)',
textAlign: 'center',
}}
>
{/* CLOSE BUTTON */}
<button
onClick={onClose}
style={{
position: 'absolute',
top: '20px',
right: '20px',
background: 'transparent',
border: 'none',
color: 'var(--color-text-muted, #707686)',
cursor: 'pointer',
}}
>
<X size={20} />
</button>
{/* HERO ICON */}
<div
style={{
width: '64px',
height: '64px',
borderRadius: '20px',
background: 'linear-gradient(135deg, #8B7CFF 0%, #6377FF 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto 20px auto',
boxShadow: '0 10px 30px rgba(139, 124, 255, 0.35)',
}}
>
<Layers size={32} color="#ffffff" />
</div>
<h2 style={{ fontSize: '24px', fontWeight: '700', margin: '0 0 8px 0', letterSpacing: '-0.5px' }}>
Welcome to DLH Low-Code Studio
</h2>
<p style={{ fontSize: '14px', color: 'var(--color-text-secondary, #A5AAB8)', margin: '0 0 28px 0', lineHeight: 1.5 }}>
Declarative Data Lakehouse Pipeline Canvas. Choose how you want to begin building your Airflow 3.0 workflow.
</p>
{/* 2 MAIN CARDS */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
{/* OPTION 1: BLANK CANVAS */}
<div
onClick={() => {
onCreateBlank();
onClose();
}}
style={{
background: 'var(--color-surface-raised, #12141B)',
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
borderRadius: 'var(--radius-xl, 20px)',
padding: '24px 16px',
cursor: 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--color-violet, #8B7CFF)';
e.currentTarget.style.transform = 'translateY(-2px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border-default, rgba(255,255,255,0.09))';
e.currentTarget.style.transform = 'translateY(0)';
}}
>
<div
style={{
width: '44px',
height: '44px',
borderRadius: '12px',
background: 'rgba(139, 124, 255, 0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '12px',
}}
>
<Plus size={22} color="#8b7cff" />
</div>
<h3 style={{ fontSize: '15px', fontWeight: '600', margin: '0 0 4px 0' }}>Create New Pipeline</h3>
<p style={{ fontSize: '12px', color: 'var(--color-text-muted, #707686)', margin: 0 }}>
Start with a clean blank canvas and drag components
</p>
</div>
{/* OPTION 2: LOAD EXISTING */}
<div
onClick={() => {
onLoadExisting();
onClose();
}}
style={{
background: 'var(--color-surface-raised, #12141B)',
border: '1px solid var(--color-border-default, rgba(255,255,255,0.09))',
borderRadius: 'var(--radius-xl, 20px)',
padding: '24px 16px',
cursor: 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--color-violet, #8B7CFF)';
e.currentTarget.style.transform = 'translateY(-2px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border-default, rgba(255,255,255,0.09))';
e.currentTarget.style.transform = 'translateY(0)';
}}
>
<div
style={{
width: '44px',
height: '44px',
borderRadius: '12px',
background: 'rgba(96, 165, 250, 0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '12px',
}}
>
<FolderOpen size={22} color="#60a5fa" />
</div>
<h3 style={{ fontSize: '15px', fontWeight: '600', margin: '0 0 4px 0' }}>Load Existing Pipeline</h3>
<p style={{ fontSize: '12px', color: 'var(--color-text-muted, #707686)', margin: 0 }}>
Select from stored database pipelines or templates
</p>
</div>
</div>
</div>
</div>
);
}