feat(appliance): initial release of MERICA standalone appliance v1.0.0
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user