mirror of
https://github.com/chartdb/chartdb.git
synced 2025-10-23 07:11:56 +00:00
Compare commits
3 Commits
c3c646bf7c
...
jf/add_edi
Author | SHA1 | Date | |
---|---|---|---|
|
7382626b92 | ||
|
6f6b59c74f | ||
|
4f1a378762 |
60
src/pages/editor-page/canvas/table-node/table-edit-mode.css
Normal file
60
src/pages/editor-page/canvas/table-node/table-edit-mode.css
Normal 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;
|
||||
}
|
859
src/pages/editor-page/canvas/table-node/table-edit-mode.tsx
Normal file
859
src/pages/editor-page/canvas/table-node/table-edit-mode.tsx
Normal 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';
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
Reference in New Issue
Block a user