Compare commits

...

3 Commits

4 changed files with 1237 additions and 212 deletions

View File

@@ -0,0 +1,60 @@
/* Custom scrollbar styles for table edit mode */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: #cbd5e1 transparent;
}
/* Webkit browsers (Chrome, Safari, Edge) */
.custom-scrollbar::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 6px;
margin: 4px 0;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 6px;
border: 2px solid transparent;
background-clip: padding-box;
min-height: 40px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.custom-scrollbar::-webkit-scrollbar-thumb:active {
background: #64748b;
}
/* Dark mode styles */
.dark .custom-scrollbar {
scrollbar-color: #475569 transparent;
}
.dark .custom-scrollbar::-webkit-scrollbar-track {
background: #1e293b;
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
background: #475569;
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb:active {
background: #94a3b8;
}
/* Always show scrollbar when content is scrollable */
.custom-scrollbar:hover::-webkit-scrollbar-thumb,
.custom-scrollbar::-webkit-scrollbar-thumb {
visibility: visible;
}

View File

@@ -0,0 +1,859 @@
import React, {
useState,
useCallback,
useEffect,
useRef,
useMemo,
memo,
} from 'react';
import { Button } from '@/components/button/button';
import { Input } from '@/components/input/input';
import { Plus, Trash2, X } from 'lucide-react';
import type { DBField } from '@/lib/domain/db-field';
import type { DBTable } from '@/lib/domain/db-table';
import { useChartDB } from '@/hooks/use-chartdb';
import { generateId } from '@/lib/utils';
import type { SelectBoxOption } from '@/components/select-box/select-box';
import { SelectBox } from '@/components/select-box/select-box';
import { generateDBFieldSuffix } from '@/lib/domain/db-field';
import { dataTypeDataToDataType } from '@/lib/data/data-types/data-types';
import type { DataTypeData } from '@/lib/data/data-types/data-types';
import { sortedDataTypeMap } from '@/lib/data/data-types/data-types';
import { DatabaseType } from '@/lib/domain/database-type';
import { Checkbox } from '@/components/checkbox/checkbox';
import { cn } from '@/lib/utils';
import './table-edit-mode.css';
interface TableEditModeProps {
table: DBTable;
color?: string;
focusFieldId?: string;
onClose: () => void;
}
interface FieldRowProps {
field: DBField;
dataTypeOptions: SelectBoxOption[];
databaseType: DatabaseType;
onTypeChange: (
fieldId: string,
value: string,
regexMatches?: string[]
) => void;
onNameChange: (
fieldId: string,
e: React.ChangeEvent<HTMLInputElement>
) => void;
onPrimaryKeyChange: (fieldId: string, checked: boolean) => void;
onRemove: (fieldId: string) => void;
inputRef?: (el: HTMLInputElement | null) => void;
}
const FieldRow = memo<FieldRowProps>(
({
field,
dataTypeOptions,
databaseType,
onTypeChange,
onNameChange,
onPrimaryKeyChange,
onRemove,
inputRef,
}) => {
return (
<div className="mb-2 grid grid-cols-[1fr,150px,60px,40px] items-center gap-3 rounded-md p-2 hover:bg-slate-50 dark:hover:bg-slate-800">
<Input
ref={inputRef}
value={field.name}
onChange={(e) => onNameChange(field.id, e)}
className="h-9 text-sm font-medium"
placeholder="Field name"
/>
<SelectBox
className="h-9 min-h-9 w-[150px] text-sm"
popoverClassName="min-w-[350px]"
options={dataTypeOptions}
value={field.type.id}
valueSuffix={generateDBFieldSuffix(field)}
optionSuffix={(option) =>
generateDBFieldSuffix(field, {
databaseType,
forceExtended: true,
typeId: option.value,
})
}
onChange={(value, regexMatches) =>
onTypeChange(
field.id,
value as string,
Array.isArray(regexMatches)
? regexMatches
: undefined
)
}
placeholder="Select type"
emptyPlaceholder="No types found"
/>
<div className="flex justify-center">
<Checkbox
checked={field.primaryKey || false}
onCheckedChange={(checked) =>
onPrimaryKeyChange(field.id, checked as boolean)
}
className="size-5"
/>
</div>
<Button
variant="ghost"
size="sm"
className="size-8 p-0 hover:bg-red-100 dark:hover:bg-red-900"
onClick={() => onRemove(field.id)}
>
<Trash2 className="size-4 text-red-600" />
</Button>
</div>
);
}
);
FieldRow.displayName = 'FieldRow';
// Helper function to generate field regex patterns
const generateFieldRegexPatterns = (
dataType: DataTypeData
): {
regex?: string;
extractRegex?: RegExp;
} => {
if (!dataType.fieldAttributes) {
return { regex: undefined, extractRegex: undefined };
}
const typeName = dataType.name;
const fieldAttributes = dataType.fieldAttributes;
if (fieldAttributes.hasCharMaxLength) {
if (fieldAttributes.hasCharMaxLengthOption) {
return {
regex: `^${typeName}\\((\\d+|[mM][aA][xX])\\)$`,
extractRegex: /\((\d+|max)\)/i,
};
}
return {
regex: `^${typeName}\\(\\d+\\)$`,
extractRegex: /\((\d+)\)/,
};
}
if (fieldAttributes.precision && fieldAttributes.scale) {
return {
regex: `^${typeName}\\s*\\(\\s*\\d+\\s*(?:,\\s*\\d+\\s*)?\\)$`,
extractRegex: new RegExp(
`${typeName}\\s*\\(\\s*(\\d+)\\s*(?:,\\s*(\\d+)\\s*)?\\)`
),
};
}
if (fieldAttributes.precision) {
return {
regex: `^${typeName}\\s*\\(\\s*\\d+\\s*\\)$`,
extractRegex: /\((\d+)\)/,
};
}
return { regex: undefined, extractRegex: undefined };
};
export const TableEditMode: React.FC<TableEditModeProps> = memo(
({ table, color, focusFieldId, onClose }) => {
const { updateTable, databaseType, customTypes } = useChartDB();
const [tableName, setTableName] = useState(() => table.name);
const [localFields, setLocalFields] = useState<DBField[]>(() =>
(table.fields || []).map((field) => ({
...field,
primaryKey: field.primaryKey || false,
}))
);
const [removedFieldIds, setRemovedFieldIds] = useState<string[]>(
() => []
);
const [newlyCreatedFields, setNewlyCreatedFields] = useState<DBField[]>(
() => []
);
const [newFieldId, setNewFieldId] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const tableNameInputRef = useRef<HTMLInputElement>(null);
const fieldInputRefs = useRef<{
[key: string]: HTMLInputElement | null;
}>({});
const hasInitialFocusedRef = useRef(false);
// Use refs to get latest state values in callbacks
const tableNameRef = useRef(tableName);
const localFieldsRef = useRef(localFields);
const removedFieldIdsRef = useRef(removedFieldIds);
const newlyCreatedFieldsRef = useRef(newlyCreatedFields);
useEffect(() => {
tableNameRef.current = tableName;
}, [tableName]);
useEffect(() => {
localFieldsRef.current = localFields;
}, [localFields]);
useEffect(() => {
removedFieldIdsRef.current = removedFieldIds;
}, [removedFieldIds]);
useEffect(() => {
newlyCreatedFieldsRef.current = newlyCreatedFields;
}, [newlyCreatedFields]);
const dataTypes = useMemo(
() =>
sortedDataTypeMap[databaseType] ||
sortedDataTypeMap[DatabaseType.GENERIC],
[databaseType]
);
// Generate options for SelectBox similar to side panel
const dataTypeOptions = useMemo(() => {
const standardTypes: SelectBoxOption[] = dataTypes.map((type) => {
const regexPatterns = generateFieldRegexPatterns(type);
return {
label: type.name,
value: type.id,
regex: regexPatterns.regex,
extractRegex: regexPatterns.extractRegex,
group: customTypes?.length ? 'Standard Types' : undefined,
};
});
if (!customTypes?.length) {
return standardTypes;
}
// Add custom types as options
const customTypeOptions: SelectBoxOption[] = customTypes.map(
(type) => ({
label: type.name,
value: type.name,
description:
type.kind === 'enum'
? `${type.values?.join(' | ')}`
: '',
group: 'Custom Types',
})
);
return [...standardTypes, ...customTypeOptions];
}, [dataTypes, customTypes]);
// Focus on specific field when opened from pencil click, or table name when double-clicked
useEffect(() => {
// Only perform initial focus once
if (hasInitialFocusedRef.current) {
return;
}
hasInitialFocusedRef.current = true;
if (focusFieldId && scrollContainerRef.current) {
// Find the field index to calculate scroll position
const fieldIndex = localFields.findIndex(
(f) => f.id === focusFieldId
);
if (fieldIndex !== -1) {
// Scroll to the field (each field is approximately 56px height)
const scrollPosition = fieldIndex * 56;
scrollContainerRef.current.scrollTo({
top: scrollPosition,
behavior: 'smooth',
});
// Focus and select the field name after scroll
setTimeout(() => {
const input = fieldInputRefs.current[focusFieldId];
if (input) {
input.focus();
input.select();
}
}, 300);
}
} else if (!focusFieldId && tableNameInputRef.current) {
// Only focus table name on initial mount when no specific field is targeted
// This prevents focus jumping when typing in field names
setTimeout(() => {
if (tableNameInputRef.current) {
tableNameInputRef.current.focus();
tableNameInputRef.current.select();
}
}, 100);
}
}, [focusFieldId, localFields]);
// Focus and select text when a new field is added
useEffect(() => {
if (newFieldId) {
// Wait for the next render cycle and for the scroll to complete
const focusTimer = setTimeout(() => {
const input = fieldInputRefs.current[newFieldId];
if (input) {
input.focus();
// Small delay to ensure focus is set before selecting
setTimeout(() => {
input.select();
}, 50);
}
setNewFieldId(null);
}, 100);
return () => clearTimeout(focusTimer);
}
}, [newFieldId]);
// Save all changes when closing the edit mode
const saveAllChanges = useCallback(() => {
const currentTableName = tableNameRef.current;
const currentLocalFields = localFieldsRef.current;
const currentRemovedFieldIds = removedFieldIdsRef.current;
const currentNewlyCreatedFields = newlyCreatedFieldsRef.current;
// Always save to ensure field changes are persisted
// Build the final fields array with all changes
const finalFields: DBField[] = [];
// Process all fields - both existing and new
for (const field of currentLocalFields) {
const isNewField = currentNewlyCreatedFields.some(
(f) => f.id === field.id
);
if (isNewField) {
// For new fields, replace temp ID with a proper ID
finalFields.push({
...field,
id: generateId(), // Generate a proper ID for the new field
primaryKey: field.primaryKey || false,
createdAt: Date.now(),
});
} else if (!currentRemovedFieldIds.includes(field.id)) {
// Existing field that wasn't removed - ensure all properties are included
finalFields.push({
...field,
primaryKey: field.primaryKey || false,
});
}
}
// Build the update object with all changes
const tableUpdates: Partial<DBTable> = {
fields: finalFields,
};
// Add name change if needed
if (currentTableName.trim() && currentTableName !== table.name) {
tableUpdates.name = currentTableName.trim();
}
// Make a single update call with all changes
// Return the promise so we can handle it properly
return updateTable(table.id, tableUpdates);
}, [table, updateTable]);
// Save on unmount if there are changes
useEffect(() => {
return () => {
// Clean up refs
fieldInputRefs.current = {};
// Save any pending changes when component unmounts
// This ensures changes are saved even if the component is unmounted quickly
// Check if any existing fields have been modified
const fieldsModified = localFieldsRef.current.some(
(localField) => {
const originalField = table.fields.find(
(f) => f.id === localField.id
);
if (!originalField) return false; // This is a new field
// Check if any properties have changed
return (
originalField.name !== localField.name ||
(originalField.primaryKey || false) !==
(localField.primaryKey || false) ||
originalField.type.id !== localField.type.id ||
originalField.nullable !== localField.nullable ||
originalField.unique !== localField.unique
);
}
);
const hasChanges =
tableNameRef.current !== table.name ||
localFieldsRef.current.length !== table.fields.length ||
removedFieldIdsRef.current.length > 0 ||
newlyCreatedFieldsRef.current.length > 0 ||
fieldsModified;
if (hasChanges) {
// Use the refs directly since the component is unmounting
const finalFields: DBField[] = [];
for (const field of localFieldsRef.current) {
const isNewField = newlyCreatedFieldsRef.current.some(
(f) => f.id === field.id
);
if (isNewField) {
finalFields.push({
...field,
id: generateId(),
primaryKey: field.primaryKey || false,
createdAt: Date.now(),
});
} else if (
!removedFieldIdsRef.current.includes(field.id)
) {
finalFields.push({
...field,
primaryKey: field.primaryKey || false,
});
}
}
const tableUpdates: Partial<DBTable> = {
fields: finalFields,
};
if (
tableNameRef.current.trim() &&
tableNameRef.current !== table.name
) {
tableUpdates.name = tableNameRef.current.trim();
}
// Fire and forget - component is unmounting
updateTable(table.id, tableUpdates);
}
};
}, [table, updateTable]);
// Handle click outside - using both mousedown and click for better compatibility
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
// Check if click is inside the edit mode container
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
// Check if the click is on a select dropdown portal element
const target = event.target as HTMLElement;
const isSelectPortal =
target.closest('[data-radix-select-viewport]') ||
target.closest('[role="listbox"]') ||
target.closest('[data-radix-popper-content-wrapper]') ||
target.closest('[data-state="open"]') ||
target.closest('[data-radix-select-content]');
// Don't close if clicking on select dropdown
if (isSelectPortal) {
return;
}
event.stopPropagation();
// Save and close - optimized approach
const savePromise = saveAllChanges();
onClose();
// Ensure save completes even after component unmounts
if (savePromise) {
savePromise.catch((error) => {
console.error(
'Failed to save table changes:',
error
);
});
}
}
};
// Prevent wheel events from propagating to the canvas
const handleWheel = (event: WheelEvent) => {
if (
containerRef.current &&
containerRef.current.contains(event.target as Node)
) {
event.stopPropagation();
}
};
// Add event listener after a very small delay to avoid immediate closing
const timer = setTimeout(() => {
// Only use mousedown to handle outside clicks
// Using both mousedown and click can cause issues with dropdowns
document.addEventListener(
'mousedown',
handleClickOutside,
true
);
document.addEventListener('wheel', handleWheel, {
passive: false,
capture: true,
});
}, 50);
return () => {
clearTimeout(timer);
document.removeEventListener(
'mousedown',
handleClickOutside,
true
);
document.removeEventListener('wheel', handleWheel, true);
};
}, [onClose, saveAllChanges]);
const handleTableNameChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setTableName(e.target.value);
},
[]
);
const handleFieldNameChange = useCallback(
(fieldId: string, e: React.ChangeEvent<HTMLInputElement>) => {
const newName = e.target.value;
setLocalFields((prev) => {
const newFields = [...prev];
const index = newFields.findIndex((f) => f.id === fieldId);
if (index !== -1) {
newFields[index] = {
...newFields[index],
name: newName,
};
}
return newFields;
});
},
[]
);
const handleFieldTypeChange = useCallback(
(fieldId: string, typeId: string, regexMatches?: string[]) => {
const field = localFields.find((f) => f.id === fieldId);
if (!field) return;
const dataType = dataTypes.find((v) => v.id === typeId) ?? {
id: typeId,
name: typeId,
};
let characterMaximumLength: string | undefined = undefined;
let precision: number | undefined = undefined;
let scale: number | undefined = undefined;
if (regexMatches?.length) {
if (dataType?.fieldAttributes?.hasCharMaxLength) {
characterMaximumLength = regexMatches[1]?.toLowerCase();
} else if (
dataType?.fieldAttributes?.precision &&
dataType?.fieldAttributes?.scale
) {
precision = parseInt(regexMatches[1]);
scale = regexMatches[2]
? parseInt(regexMatches[2])
: undefined;
} else if (dataType?.fieldAttributes?.precision) {
precision = parseInt(regexMatches[1]);
}
} else {
// Preserve existing values if compatible
if (
dataType?.fieldAttributes?.hasCharMaxLength &&
field.characterMaximumLength
) {
characterMaximumLength = field.characterMaximumLength;
}
if (
dataType?.fieldAttributes?.precision &&
field.precision
) {
precision = field.precision;
}
if (dataType?.fieldAttributes?.scale && field.scale) {
scale = field.scale;
}
}
setLocalFields((prev) =>
prev.map((f) =>
f.id === fieldId
? {
...f,
type: dataTypeDataToDataType(dataType),
characterMaximumLength,
precision,
scale,
increment: undefined,
default: undefined,
}
: f
)
);
},
[dataTypes, localFields]
);
const handleFieldPrimaryKeyChange = useCallback(
(fieldId: string, primaryKey: boolean) => {
setLocalFields((prev) =>
prev.map((field) =>
field.id === fieldId
? { ...field, primaryKey: primaryKey }
: field
)
);
},
[]
);
const handleAddField = useCallback(() => {
// Create a temporary field locally without saving to database
// Default to varchar(100) if available, otherwise use first type
const varcharType = dataTypes.find(
(dt) => dt.id === 'varchar' || dt.id === 'character_varying'
);
const defaultType = varcharType ||
dataTypes[0] || { id: 'text', name: 'text' };
const tempField: DBField = {
id: `temp-${generateId()}`, // Temporary ID
name: `field${localFields.length + 1}`,
type: dataTypeDataToDataType(defaultType),
characterMaximumLength: varcharType ? '100' : undefined,
nullable: true,
unique: false,
primaryKey: false,
createdAt: Date.now(),
};
setLocalFields((prev) => [...prev, tempField]);
setNewlyCreatedFields((prev) => [...prev, tempField]);
setNewFieldId(tempField.id);
// Scroll to bottom after a minimal delay to ensure the new field is rendered
requestAnimationFrame(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
top: scrollContainerRef.current.scrollHeight,
behavior: 'smooth',
});
}
});
}, [localFields.length, dataTypes]);
const handleRemoveField = useCallback(
(fieldId: string) => {
// Check if this field was created in this session
const isNewField = newlyCreatedFields.some(
(f) => f.id === fieldId
);
if (isNewField) {
// Just remove from local state, it was never saved to database
setNewlyCreatedFields((prev) =>
prev.filter((f) => f.id !== fieldId)
);
setLocalFields((prev) =>
prev.filter((field) => field.id !== fieldId)
);
} else {
// Mark existing field for removal on close
setRemovedFieldIds((prev) => [...prev, fieldId]);
setLocalFields((prev) =>
prev.filter((field) => field.id !== fieldId)
);
}
},
[newlyCreatedFields]
);
// Calculate dynamic height based on number of fields
// Max height is 80% of viewport or 600px, whichever is smaller
const maxHeight = Math.min(window.innerHeight * 0.8, 600);
const calculatedHeight = 240 + localFields.length * 56; // header + fields + button + padding
const editModeHeight = Math.min(
maxHeight,
Math.max(320, calculatedHeight)
);
const isScrollable = calculatedHeight > maxHeight;
return (
<>
{/* Invisible overlay to capture clicks in the canvas */}
<div
className="fixed inset-[-9999px] z-40"
style={{ width: '99999px', height: '99999px' }}
onMouseDown={(e) => {
e.stopPropagation();
// Save and close
const savePromise = saveAllChanges();
onClose();
// Ensure save completes
if (savePromise) {
savePromise.catch((error) => {
console.error(
'Failed to save table changes:',
error
);
});
}
}}
/>
<div
ref={containerRef}
// eslint-disable-next-line tailwindcss/no-custom-classname
className="nowheel nopan nodrag absolute z-50 flex min-w-[500px] flex-col rounded-lg border-2 border-blue-500 bg-white shadow-2xl dark:bg-slate-950"
style={{
left: '-50%',
right: '-50%',
top: '50%',
transform: 'translateY(-50%)',
height: `${editModeHeight}px`,
}}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onPointerMove={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
>
{/* Color header bar */}
<div
className="h-2 rounded-t-[6px]"
style={{ backgroundColor: color || '#6b7280' }}
/>
<div className="flex items-center justify-between border-b bg-slate-100 p-4 dark:bg-slate-900">
<Input
ref={tableNameInputRef}
value={tableName}
onChange={handleTableNameChange}
className="mr-3 h-10 flex-1 text-base font-bold"
placeholder="Table name"
/>
<Button
variant="ghost"
size="sm"
onClick={() => {
// Save and close
const savePromise = saveAllChanges();
onClose();
// Ensure save completes
if (savePromise) {
savePromise.catch((error) => {
console.error(
'Failed to save table changes:',
error
);
});
}
}}
className="hover:bg-slate-200 dark:hover:bg-slate-800"
>
<X className="size-5" />
</Button>
</div>
<div className="relative flex flex-1 flex-col overflow-hidden">
<div className="p-4 pb-0">
{localFields.length > 0 && (
<div className="mb-3 grid grid-cols-[1fr,150px,60px,40px] gap-3 px-2 text-sm font-semibold text-slate-700 dark:text-slate-300">
<div>Field Name</div>
<div>Type</div>
<div className="text-center">PK</div>
<div></div>
</div>
)}
</div>
<div
ref={scrollContainerRef}
className={cn(
'flex-1 overflow-y-auto px-4 pr-3 custom-scrollbar relative nowheel',
isScrollable && 'pb-2'
)}
style={{
scrollbarGutter: 'stable',
overflowY:
localFields.length > 0 ? 'auto' : 'hidden',
}}
onWheel={(e) => e.stopPropagation()}
onPointerMove={(e) => e.stopPropagation()}
onMouseMove={(e) => e.stopPropagation()}
>
{localFields.length > 0 ? (
<>
{localFields.map((field) => (
<FieldRow
key={field.id}
field={field}
dataTypeOptions={dataTypeOptions}
databaseType={databaseType}
onNameChange={handleFieldNameChange}
onTypeChange={handleFieldTypeChange}
onPrimaryKeyChange={
handleFieldPrimaryKeyChange
}
onRemove={handleRemoveField}
inputRef={(el) => {
if (el)
fieldInputRefs.current[
field.id
] = el;
}}
/>
))}
</>
) : (
<div className="py-8 text-center text-base text-slate-500 dark:text-slate-400">
No fields yet. Click "Add Field" to create
one.
</div>
)}
</div>
{/* Fade overlay at bottom when scrollable */}
{isScrollable && localFields.length > 5 && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-white to-transparent dark:from-slate-950" />
)}
<div className="relative z-10 flex items-center justify-between gap-4 border-t bg-slate-50 p-4 dark:bg-slate-900">
<Button
variant="outline"
size="default"
className="h-11 max-w-[45%] flex-1 text-base"
onClick={handleAddField}
>
<Plus className="mr-2 size-5" />
Add Field
</Button>
<span className="mr-2 text-sm font-medium text-slate-600 dark:text-slate-400">
{localFields.length}{' '}
{localFields.length === 1
? 'Column'
: 'Columns'}
</span>
</div>
</div>
</div>
</>
);
}
);
TableEditMode.displayName = 'TableEditMode';

View File

@@ -19,7 +19,7 @@ import {
SquareDot,
SquareMinus,
SquarePlus,
Trash2,
Pencil,
} from 'lucide-react';
import { generateDBFieldSuffix, type DBField } from '@/lib/domain/db-field';
import { useChartDB } from '@/hooks/use-chartdb';
@@ -49,6 +49,7 @@ export interface TableNodeFieldProps {
highlighted: boolean;
visible: boolean;
isConnectable: boolean;
onOpenEditMode?: () => void;
}
const arePropsEqual = (
@@ -72,19 +73,23 @@ const arePropsEqual = (
prevProps.highlighted === nextProps.highlighted &&
prevProps.visible === nextProps.visible &&
prevProps.isConnectable === nextProps.isConnectable &&
prevProps.tableNodeId === nextProps.tableNodeId
prevProps.tableNodeId === nextProps.tableNodeId &&
prevProps.onOpenEditMode === nextProps.onOpenEditMode
);
};
export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
({ field, focused, tableNodeId, highlighted, visible, isConnectable }) => {
const {
removeField,
relationships,
readonly,
updateField,
highlightedCustomType,
} = useChartDB();
({
field,
focused,
tableNodeId,
highlighted,
visible,
isConnectable,
onOpenEditMode,
}) => {
const { relationships, readonly, updateField, highlightedCustomType } =
useChartDB();
const [editMode, setEditMode] = useState(false);
const [fieldName, setFieldName] = useState(field.name);
const inputRef = React.useRef<HTMLInputElement>(null);
@@ -514,20 +519,21 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
)}
</span>
</div>
{readonly ? null : (
<div className="hidden flex-row group-hover:flex">
{!readonly && onOpenEditMode ? (
<div className="hidden items-center group-hover:flex">
<Button
variant="ghost"
className="size-6 p-0 hover:bg-primary-foreground"
className="size-6 p-0.5 hover:bg-slate-200 dark:hover:bg-slate-700"
onClick={(e) => {
e.stopPropagation();
removeField(tableNodeId, field.id);
e.preventDefault();
onOpenEditMode();
}}
>
<Trash2 className="size-3.5 text-red-700" />
<Pencil className="size-3.5 text-slate-600 dark:text-slate-400" />
</Button>
</div>
)}
) : null}
</div>
)}
</div>

View File

@@ -6,7 +6,12 @@ import React, {
useEffect,
} from 'react';
import type { NodeProps, Node } from '@xyflow/react';
import { NodeResizer, useConnection, useStore } from '@xyflow/react';
import {
NodeResizer,
useConnection,
useStore,
useReactFlow,
} from '@xyflow/react';
import { Button } from '@/components/button/button';
import {
ChevronsLeftRight,
@@ -47,6 +52,7 @@ import {
} from '@/components/tooltip/tooltip';
import { useDiff } from '@/context/diff-context/use-diff';
import { TableNodeStatus } from './table-node-status/table-node-status';
import { TableEditMode } from './table-edit-mode';
export type TableNodeType = Node<
{
@@ -74,16 +80,40 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
}) => {
const { updateTable, relationships, readonly } = useChartDB();
const edges = useStore((store) => store.edges) as EdgeType[];
const { openTableFromSidebar, selectSidebarSection } = useLayout();
const {
openTableFromSidebar,
selectSidebarSection,
closeAllTablesInSidebar,
} = useLayout();
const [expanded, setExpanded] = useState(table.expanded ?? false);
const { t } = useTranslation();
const [editMode, setEditMode] = useState(false);
const [tableName, setTableName] = useState(table.name);
const inputRef = React.useRef<HTMLInputElement>(null);
const [isHovering, setIsHovering] = useState(false);
const [isTableEditMode, setIsTableEditMode] = useState(false);
const [focusFieldId, setFocusFieldId] = useState<string | undefined>(
undefined
);
const { setNodes } = useReactFlow();
const connection = useConnection();
// Update node draggable state when edit mode changes
useEffect(() => {
setNodes((nodes) =>
nodes.map((node) => {
if (node.id === id) {
return {
...node,
draggable: !isTableEditMode,
};
}
return node;
})
);
}, [isTableEditMode, id, setNodes]);
const isTarget = useMemo(() => {
if (!isHovering) return false;
@@ -337,7 +367,8 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
: '',
isDiffTableRemoved
? 'outline outline-[3px] outline-red-500 dark:outline-red-900 outline-offset-[5px]'
: ''
: '',
isTableEditMode ? 'cursor-default' : ''
),
[
selected,
@@ -350,6 +381,7 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
isDiffNewTable,
isDiffTableRemoved,
isTarget,
isTableEditMode,
]
);
@@ -358,209 +390,277 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
<div
className={tableClassName}
onClick={(e) => {
if (e.detail === 2) {
openTableInEditor();
if (e.detail === 2 && !readonly) {
e.preventDefault();
e.stopPropagation();
setIsTableEditMode(true);
// Close sidebar after a short delay to avoid blocking the UI
setTimeout(() => {
closeAllTablesInSidebar();
}, 50);
}
}}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<NodeResizer
isVisible={focused}
lineClassName="!border-none !w-2"
minWidth={MIN_TABLE_SIZE}
maxWidth={MAX_TABLE_SIZE}
shouldResize={(event) => event.dy === 0}
handleClassName="!hidden"
/>
<TableNodeDependencyIndicator
table={table}
focused={focused}
/>
<TableNodeStatus
status={
isDiffNewTable
? 'new'
: isDiffTableRemoved
? 'removed'
: isDiffTableChanged && !isSummaryOnly
? 'changed'
: 'none'
}
/>
<div
className="h-2 rounded-t-[6px]"
style={{ backgroundColor: tableColor }}
></div>
<div className="group flex h-9 items-center justify-between bg-slate-200 px-2 dark:bg-slate-900">
<div className="flex min-w-0 flex-1 items-center gap-2">
{isDiffNewTable ? (
<Tooltip>
<TooltipTrigger asChild>
<SquarePlus
className="size-3.5 shrink-0 text-green-600"
strokeWidth={2.5}
/>
</TooltipTrigger>
<TooltipContent>New Table</TooltipContent>
</Tooltip>
) : isDiffTableRemoved ? (
<Tooltip>
<TooltipTrigger asChild>
<SquareMinus
className="size-3.5 shrink-0 text-red-600"
strokeWidth={2.5}
/>
</TooltipTrigger>
<TooltipContent>
Table Removed
</TooltipContent>
</Tooltip>
) : isDiffTableChanged && !isSummaryOnly ? (
<Tooltip>
<TooltipTrigger asChild>
<SquareDot
className="size-3.5 shrink-0 text-sky-600"
strokeWidth={2.5}
/>
</TooltipTrigger>
<TooltipContent>
Table Changed
</TooltipContent>
</Tooltip>
) : (
<Table2 className="size-3.5 shrink-0 text-gray-600 dark:text-primary" />
)}
{tableChangedName ? (
<Label className="flex h-5 items-center justify-center truncate rounded-sm bg-sky-200 px-2 py-0.5 text-sm font-normal text-sky-900 dark:bg-sky-800 dark:text-sky-200">
<span className="truncate">
{table.name}
</span>
<span className="mx-1 font-semibold">
</span>
<span className="truncate">
{tableChangedName}
</span>
</Label>
) : isDiffNewTable ? (
<Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-green-200 px-2 py-0.5 text-sm font-normal text-green-900 dark:bg-green-800 dark:text-green-200">
{table.name}
</Label>
) : isDiffTableRemoved ? (
<Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-red-200 px-2 py-0.5 text-sm font-normal text-red-900 dark:bg-red-800 dark:text-red-200">
{table.name}
</Label>
) : isDiffTableChanged && !isSummaryOnly ? (
<Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-sky-200 px-2 py-0.5 text-sm font-normal text-sky-900 dark:bg-sky-800 dark:text-sky-200">
{table.name}
</Label>
) : editMode && !readonly ? (
<>
<Input
ref={inputRef}
onBlur={editTableName}
placeholder={table.name}
autoFocus
type="text"
value={tableName}
onClick={(e) => e.stopPropagation()}
onChange={(e) =>
setTableName(e.target.value)
}
className="h-6 w-full border-[0.5px] border-blue-400 bg-slate-100 focus-visible:ring-0 dark:bg-slate-900"
{/* Keep minimal table structure for connections even in edit mode */}
{isTableEditMode && (
<>
{/* Hidden fields to maintain connection handles */}
<div
style={{
position: 'absolute',
opacity: 0,
pointerEvents: 'none',
}}
>
{table.fields.map((field: DBField) => (
<TableNodeField
key={field.id}
focused={false}
tableNodeId={id}
field={field}
highlighted={false}
visible={false}
isConnectable={!table.isView}
/>
<Button
variant="ghost"
className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
onClick={editTableName}
>
<Check className="size-4" />
</Button>
</>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Label
className="text-editable truncate px-2 py-0.5 text-sm font-bold"
onDoubleClick={enterEditMode}
>
))}
</div>
<TableEditMode
table={table}
color={tableColor}
focusFieldId={focusFieldId}
onClose={() => {
setIsTableEditMode(false);
setFocusFieldId(undefined);
}}
/>
</>
)}
{!isTableEditMode && (
<>
{focused ? (
<NodeResizer
isVisible={focused}
lineClassName="!border-none !w-2"
minWidth={MIN_TABLE_SIZE}
maxWidth={MAX_TABLE_SIZE}
shouldResize={(event) => event.dy === 0}
handleClassName="!hidden"
/>
) : null}
<TableNodeDependencyIndicator
table={table}
focused={focused}
/>
<TableNodeStatus
status={
isDiffNewTable
? 'new'
: isDiffTableRemoved
? 'removed'
: isDiffTableChanged && !isSummaryOnly
? 'changed'
: 'none'
}
/>
<div
className="h-2 rounded-t-[6px]"
style={{ backgroundColor: tableColor }}
></div>
<div className="group flex h-9 items-center justify-between bg-slate-200 px-2 dark:bg-slate-900">
<div className="flex min-w-0 flex-1 items-center gap-2">
{isDiffNewTable ? (
<Tooltip>
<TooltipTrigger asChild>
<SquarePlus
className="size-3.5 shrink-0 text-green-600"
strokeWidth={2.5}
/>
</TooltipTrigger>
<TooltipContent>
New Table
</TooltipContent>
</Tooltip>
) : isDiffTableRemoved ? (
<Tooltip>
<TooltipTrigger asChild>
<SquareMinus
className="size-3.5 shrink-0 text-red-600"
strokeWidth={2.5}
/>
</TooltipTrigger>
<TooltipContent>
Table Removed
</TooltipContent>
</Tooltip>
) : isDiffTableChanged && !isSummaryOnly ? (
<Tooltip>
<TooltipTrigger asChild>
<SquareDot
className="size-3.5 shrink-0 text-sky-600"
strokeWidth={2.5}
/>
</TooltipTrigger>
<TooltipContent>
Table Changed
</TooltipContent>
</Tooltip>
) : (
<Table2 className="size-3.5 shrink-0 text-gray-600 dark:text-primary" />
)}
{tableChangedName ? (
<Label className="flex h-5 items-center justify-center truncate rounded-sm bg-sky-200 px-2 py-0.5 text-sm font-normal text-sky-900 dark:bg-sky-800 dark:text-sky-200">
<span className="truncate">
{table.name}
</span>
<span className="mx-1 font-semibold">
</span>
<span className="truncate">
{tableChangedName}
</span>
</Label>
) : isDiffNewTable ? (
<Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-green-200 px-2 py-0.5 text-sm font-normal text-green-900 dark:bg-green-800 dark:text-green-200">
{table.name}
</Label>
</TooltipTrigger>
<TooltipContent>
{t('tool_tips.double_click_to_edit')}
</TooltipContent>
</Tooltip>
)}
</div>
<div className="hidden shrink-0 flex-row group-hover:flex">
{readonly || editMode ? null : (
<Button
variant="ghost"
className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
onClick={openTableInEditor}
>
<CircleDotDashed className="size-4" />
</Button>
)}
{editMode ? null : (
<Button
variant="ghost"
className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
onClick={
table.width !== MAX_TABLE_SIZE
? expandTable
: shrinkTable
}
>
{table.width !== MAX_TABLE_SIZE ? (
<ChevronsLeftRight className="size-4" />
) : isDiffTableRemoved ? (
<Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-red-200 px-2 py-0.5 text-sm font-normal text-red-900 dark:bg-red-800 dark:text-red-200">
{table.name}
</Label>
) : isDiffTableChanged && !isSummaryOnly ? (
<Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-sky-200 px-2 py-0.5 text-sm font-normal text-sky-900 dark:bg-sky-800 dark:text-sky-200">
{table.name}
</Label>
) : editMode && !readonly ? (
<>
<Input
ref={inputRef}
onBlur={editTableName}
placeholder={table.name}
autoFocus
type="text"
value={tableName}
onClick={(e) =>
e.stopPropagation()
}
onChange={(e) =>
setTableName(e.target.value)
}
className="h-6 w-full border-[0.5px] border-blue-400 bg-slate-100 focus-visible:ring-0 dark:bg-slate-900"
/>
<Button
variant="ghost"
className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
onClick={editTableName}
>
<Check className="size-4" />
</Button>
</>
) : (
<ChevronsRightLeft className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<Label
className="text-editable truncate px-2 py-0.5 text-sm font-bold"
onDoubleClick={
enterEditMode
}
>
{table.name}
</Label>
</TooltipTrigger>
<TooltipContent>
{t(
'tool_tips.double_click_to_edit'
)}
</TooltipContent>
</Tooltip>
)}
</Button>
)}
</div>
</div>
<div
className="transition-[max-height] duration-200 ease-in-out"
style={{
maxHeight: expanded
? `${fields.length * 2}rem` // h-8 per field
: `${TABLE_MINIMIZED_FIELDS * 2}rem`, // h-8 per field
}}
>
{visibleFields.map((field: DBField) => (
<TableNodeField
key={field.id}
focused={focused}
tableNodeId={id}
field={field}
highlighted={highlightedFieldIds.has(field.id)}
visible={true}
isConnectable={!table.isView}
/>
))}
</div>
{fields.length > TABLE_MINIMIZED_FIELDS && (
<div
className="z-10 flex h-8 cursor-pointer items-center justify-center rounded-b-md border-t text-xs text-muted-foreground transition-colors duration-200 hover:bg-slate-100 dark:hover:bg-slate-800"
onClick={toggleExpand}
>
{expanded ? (
<>
<ChevronUp className="mr-1 size-3.5" />
{t('show_less')}
</>
) : (
<>
<ChevronDown className="mr-1 size-3.5" />
{t('show_more')}
</>
)}
</div>
</div>
<div className="hidden shrink-0 flex-row group-hover:flex">
{readonly || editMode || !focused ? null : (
<Button
variant="ghost"
className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
onClick={openTableInEditor}
>
<CircleDotDashed className="size-4" />
</Button>
)}
{editMode || !focused ? null : (
<Button
variant="ghost"
className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
onClick={
table.width !== MAX_TABLE_SIZE
? expandTable
: shrinkTable
}
>
{table.width !== MAX_TABLE_SIZE ? (
<ChevronsLeftRight className="size-4" />
) : (
<ChevronsRightLeft className="size-4" />
)}
</Button>
)}
</div>
</div>
<div className="relative">
<div
className="transition-[max-height] duration-200 ease-in-out"
style={{
maxHeight: expanded
? `${fields.length * 2}rem` // h-8 per field
: `${TABLE_MINIMIZED_FIELDS * 2}rem`, // h-8 per field
}}
>
{visibleFields.map((field: DBField) => (
<TableNodeField
key={field.id}
focused={focused}
tableNodeId={id}
field={field}
highlighted={highlightedFieldIds.has(
field.id
)}
visible={true}
isConnectable={!table.isView}
onOpenEditMode={() => {
if (!readonly) {
setFocusFieldId(field.id);
setIsTableEditMode(true);
setTimeout(() => {
closeAllTablesInSidebar();
}, 50);
}
}}
/>
))}
</div>
{fields.length > TABLE_MINIMIZED_FIELDS && (
<div
className="z-10 flex h-8 cursor-pointer items-center justify-center rounded-b-md border-t text-xs text-muted-foreground transition-colors duration-200 hover:bg-slate-100 dark:hover:bg-slate-800"
onClick={toggleExpand}
>
{expanded ? (
<>
<ChevronUp className="mr-1 size-3.5" />
{t('show_less')}
</>
) : (
<>
<ChevronDown className="mr-1 size-3.5" />
{t('show_more')}
</>
)}
</div>
)}
</div>
</>
)}
</div>
</TableNodeContextMenu>