Compare commits

..

2 Commits

Author SHA1 Message Date
johnnyfish
4fdc2ccd91 feat: implement reusable click-outside hook for edit fields
- Create custom useClickOutside and useEditClickOutside hooks
- Replace useClickAway with custom hook for better event handling
- Add click-outside behavior to all edit fields:
  - Diagram name in navbar
  - Area names on canvas (with context menu)
  - Table names in side panel
  - Relationship names in side panel
  - Table edit mode panel
- Improve edit mode UX with auto-focus and text selection
- Add pencil icons for visual edit affordance
2025-09-15 19:22:23 +03:00
Guy Ben-Aharon
8954d893bb feat: add quick table mode on canvas (#915)
* fix: add quick table mode on canvas

* fix

* fix

* fix
2025-09-14 20:42:01 +03:00
26 changed files with 1400 additions and 756 deletions

View File

@@ -11,12 +11,14 @@ export interface ColorPickerProps {
color: string;
onChange: (color: string) => void;
disabled?: boolean;
popoverOnMouseDown?: (e: React.MouseEvent) => void;
popoverOnClick?: (e: React.MouseEvent) => void;
}
export const ColorPicker = React.forwardRef<
React.ElementRef<typeof PopoverTrigger>,
ColorPickerProps
>(({ color, onChange, disabled }, ref) => {
>(({ color, onChange, disabled, popoverOnMouseDown, popoverOnClick }, ref) => {
return (
<Popover>
<PopoverTrigger
@@ -37,7 +39,11 @@ export const ColorPicker = React.forwardRef<
}}
/>
</PopoverTrigger>
<PopoverContent className="w-fit">
<PopoverContent
className="w-fit"
onMouseDown={popoverOnMouseDown}
onClick={popoverOnClick}
>
<div className="grid grid-cols-4 gap-2">
{colorOptions.map((option) => (
<div

View File

@@ -56,6 +56,8 @@ export interface SelectBoxProps {
popoverClassName?: string;
readonly?: boolean;
footerButtons?: React.ReactNode;
commandOnMouseDown?: (e: React.MouseEvent) => void;
commandOnClick?: (e: React.MouseEvent) => void;
}
export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
@@ -83,6 +85,8 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
popoverClassName,
readonly,
footerButtons,
commandOnMouseDown,
commandOnClick,
},
ref
) => {
@@ -243,6 +247,8 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
matches?.map((match) => match?.toString())
)
}
onMouseDown={commandOnMouseDown}
onClick={commandOnClick}
>
{multiple && (
<div
@@ -288,7 +294,15 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
</CommandItem>
);
},
[value, multiple, searchTerm, handleSelect, optionSuffix]
[
value,
multiple,
searchTerm,
handleSelect,
optionSuffix,
commandOnClick,
commandOnMouseDown,
]
);
return (
@@ -366,6 +380,8 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
popoverClassName
)}
align="center"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<Command
filter={(value, search, keywords) => {

View File

@@ -14,6 +14,16 @@ export interface CanvasContext {
overlapGraph: Graph<string>;
setShowFilter: React.Dispatch<React.SetStateAction<boolean>>;
showFilter: boolean;
editTableModeTable: {
tableId: string;
fieldId?: string;
} | null;
setEditTableModeTable: React.Dispatch<
React.SetStateAction<{
tableId: string;
fieldId?: string;
} | null>
>;
}
export const canvasContext = createContext<CanvasContext>({
@@ -23,4 +33,6 @@ export const canvasContext = createContext<CanvasContext>({
overlapGraph: createGraph(),
setShowFilter: emptyFn,
showFilter: false,
editTableModeTable: null,
setEditTableModeTable: emptyFn,
});

View File

@@ -33,6 +33,10 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => {
const { fitView } = useReactFlow();
const [overlapGraph, setOverlapGraph] =
useState<Graph<string>>(createGraph());
const [editTableModeTable, setEditTableModeTable] = useState<{
tableId: string;
fieldId?: string;
} | null>(null);
const [showFilter, setShowFilter] = useState(false);
const diagramIdActiveFilterRef = useRef<string>();
@@ -127,6 +131,8 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => {
overlapGraph,
setShowFilter,
showFilter,
editTableModeTable,
setEditTableModeTable,
}}
>
{children}

View File

@@ -57,36 +57,11 @@ export const SQLValidationStatus: React.FC<SQLValidationStatusProps> = ({
// If we have parser errors (errorMessage) after validation
if (errorMessage && !hasErrors) {
// Check if the error is related to parsing issues
const isParsingError =
errorMessage.toLowerCase().includes('error parsing') ||
errorMessage.toLowerCase().includes('unexpected');
return (
<>
<Separator className="mb-1 mt-2" />
<div className="rounded-md border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950">
<div className="space-y-3 p-3 pt-2 text-red-700 dark:text-red-300">
<div className="flex items-start gap-2">
<MessageCircleWarning className="mt-0.5 size-4 shrink-0 text-red-700 dark:text-red-300" />
<div className="flex-1 text-sm text-red-700 dark:text-red-300">
<div className="font-medium">
{isParsingError
? 'SQL Parsing Failed'
: 'SQL Import Error'}
</div>
<div className="mt-1 text-xs">
{errorMessage}
</div>
{isParsingError && (
<div className="mt-2 text-xs opacity-90">
This may indicate incompatible SQL
syntax for the selected database type.
</div>
)}
</div>
</div>
</div>
<div className="mb-1 flex shrink-0 items-center gap-2">
<p className="text-xs text-red-700">{errorMessage}</p>
</div>
</>
);

View File

@@ -0,0 +1,50 @@
import { useEffect, useCallback, type RefObject } from 'react';
/**
* Custom hook that handles click outside detection with capture phase
* to work properly with React Flow canvas and other event-stopping elements
*/
export function useClickOutside(
ref: RefObject<HTMLElement>,
handler: () => void,
isActive = true
) {
useEffect(() => {
if (!isActive) return;
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
handler();
}
};
// Use capture phase to catch events before React Flow or other libraries can stop them
document.addEventListener('mousedown', handleClickOutside, true);
return () => {
document.removeEventListener('mousedown', handleClickOutside, true);
};
}, [ref, handler, isActive]);
}
/**
* Specialized version of useClickOutside for edit mode inputs
* Adds a small delay to prevent race conditions with blur events
*/
export function useEditClickOutside(
inputRef: RefObject<HTMLElement>,
editMode: boolean,
onSave: () => void,
delay = 100
) {
const handleClickOutside = useCallback(() => {
if (editMode) {
// Small delay to ensure any pending state updates are processed
setTimeout(() => {
onSave();
}, delay);
}
}, [editMode, onSave, delay]);
useClickOutside(inputRef, handleClickOutside, editMode);
}

View File

@@ -0,0 +1,320 @@
import { useCallback, useMemo, useState, useEffect } from 'react';
import { useChartDB } from './use-chartdb';
import { useDebounce } from './use-debounce-v2';
import type { DBField, DBTable } from '@/lib/domain';
import type {
SelectBoxOption,
SelectBoxProps,
} from '@/components/select-box/select-box';
import {
dataTypeDataToDataType,
sortedDataTypeMap,
} from '@/lib/data/data-types/data-types';
import { generateDBFieldSuffix } from '@/lib/domain/db-field';
import type { DataTypeData } from '@/lib/data/data-types/data-types';
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 useUpdateTableField = (
table: DBTable,
field: DBField,
customUpdateField?: (attrs: Partial<DBField>) => void
) => {
const {
databaseType,
customTypes,
updateField: chartDBUpdateField,
removeField: chartDBRemoveField,
} = useChartDB();
// Local state for responsive UI
const [localFieldName, setLocalFieldName] = useState(field.name);
const [localNullable, setLocalNullable] = useState(field.nullable);
const [localPrimaryKey, setLocalPrimaryKey] = useState(field.primaryKey);
// Update local state when field properties change externally
useEffect(() => {
setLocalFieldName(field.name);
setLocalNullable(field.nullable);
setLocalPrimaryKey(field.primaryKey);
}, [field.name, field.nullable, field.primaryKey]);
// Use custom updateField if provided, otherwise use the chartDB one
const updateField = useMemo(
() =>
customUpdateField
? (
_tableId: string,
_fieldId: string,
attrs: Partial<DBField>
) => customUpdateField(attrs)
: chartDBUpdateField,
[customUpdateField, chartDBUpdateField]
);
// Calculate primary key fields for validation
const primaryKeyFields = useMemo(() => {
return table.fields.filter((f) => f.primaryKey);
}, [table.fields]);
const primaryKeyCount = useMemo(
() => primaryKeyFields.length,
[primaryKeyFields.length]
);
// Generate data type options for select box
const dataFieldOptions = useMemo(() => {
const standardTypes: SelectBoxOption[] = sortedDataTypeMap[
databaseType
].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];
}, [databaseType, customTypes]);
// Handle data type change
const handleDataTypeChange = useCallback<
NonNullable<SelectBoxProps['onChange']>
>(
(value, regexMatches) => {
const dataType = sortedDataTypeMap[databaseType].find(
(v) => v.id === value
) ?? {
id: value as string,
name: value as string,
};
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 {
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;
}
}
updateField(table.id, field.id, {
characterMaximumLength,
precision,
scale,
increment: undefined,
default: undefined,
type: dataTypeDataToDataType(
dataType ?? {
id: value as string,
name: value as string,
}
),
});
},
[
updateField,
databaseType,
field.characterMaximumLength,
field.precision,
field.scale,
field.id,
table.id,
]
);
// Debounced update for field name
const debouncedNameUpdate = useDebounce(
useCallback(
(value: string) => {
if (value.trim() !== field.name) {
updateField(table.id, field.id, { name: value });
}
},
[updateField, table.id, field.id, field.name]
),
300 // 300ms debounce for text input
);
// Debounced update for nullable toggle
const debouncedNullableUpdate = useDebounce(
useCallback(
(value: boolean) => {
updateField(table.id, field.id, { nullable: value });
},
[updateField, table.id, field.id]
),
100 // 100ms debounce for toggle
);
// Debounced update for primary key toggle
const debouncedPrimaryKeyUpdate = useDebounce(
useCallback(
(value: boolean, primaryKeyCount: number) => {
if (value) {
// When setting as primary key
const updates: Partial<DBField> = {
primaryKey: true,
};
// Only auto-set unique if this will be the only primary key
if (primaryKeyCount === 0) {
updates.unique = true;
}
updateField(table.id, field.id, updates);
} else {
// When removing primary key
updateField(table.id, field.id, {
primaryKey: false,
});
}
},
[updateField, table.id, field.id]
),
100 // 100ms debounce for toggle
);
// Handle primary key toggle with optimistic update
const handlePrimaryKeyToggle = useCallback(
(value: boolean) => {
setLocalPrimaryKey(value);
debouncedPrimaryKeyUpdate(value, primaryKeyCount);
},
[primaryKeyCount, debouncedPrimaryKeyUpdate]
);
// Handle nullable toggle with optimistic update
const handleNullableToggle = useCallback(
(value: boolean) => {
setLocalNullable(value);
debouncedNullableUpdate(value);
},
[debouncedNullableUpdate]
);
// Handle name change with optimistic update
const handleNameChange = useCallback(
(value: string) => {
setLocalFieldName(value);
debouncedNameUpdate(value);
},
[debouncedNameUpdate]
);
// Utility function to generate field suffix for display
const generateFieldSuffix = useCallback(
(typeId?: string) => {
return generateDBFieldSuffix(field, {
databaseType,
forceExtended: true,
typeId,
});
},
[field, databaseType]
);
const removeField = useCallback(() => {
chartDBRemoveField(table.id, field.id);
}, [chartDBRemoveField, table.id, field.id]);
return {
dataFieldOptions,
handleDataTypeChange,
handlePrimaryKeyToggle,
handleNullableToggle,
handleNameChange,
generateFieldSuffix,
primaryKeyCount,
fieldName: localFieldName,
nullable: localNullable,
primaryKey: localPrimaryKey,
removeField,
};
};

View File

@@ -0,0 +1,42 @@
import { useCallback, useState, useEffect } from 'react';
import { useChartDB } from './use-chartdb';
import { useDebounce } from './use-debounce-v2';
import type { DBTable } from '@/lib/domain';
// Hook for updating table properties with debouncing for performance
export const useUpdateTable = (table: DBTable) => {
const { updateTable: chartDBUpdateTable } = useChartDB();
const [localTableName, setLocalTableName] = useState(table.name);
// Debounced update function
const debouncedUpdate = useDebounce(
useCallback(
(value: string) => {
if (value.trim() && value.trim() !== table.name) {
chartDBUpdateTable(table.id, { name: value.trim() });
}
},
[chartDBUpdateTable, table.id, table.name]
),
1000 // 1000ms debounce
);
// Update local state immediately for responsive UI
const handleTableNameChange = useCallback(
(value: string) => {
setLocalTableName(value);
debouncedUpdate(value);
},
[debouncedUpdate]
);
// Update local state when table name changes externally
useEffect(() => {
setLocalTableName(table.name);
}, [table.name]);
return {
tableName: localTableName,
handleTableNameChange,
};
};

View File

@@ -18,4 +18,7 @@
.marker-definitions {
}
.nodrag {
}
}

View File

@@ -1,105 +0,0 @@
/**
* Shared utilities for detecting SQL dialect-specific syntax
* Used across all validators to identify incompatible SQL dialects
*/
import type { ValidationError } from './postgresql-validator';
interface DialectDetectionResult {
detected: boolean;
dialect: string;
lines: number[];
features: string[];
}
/**
* Detect Oracle-specific SQL syntax in the given SQL content
*/
export function detectOracleSQL(lines: string[]): DialectDetectionResult {
const oracleTypeLines: number[] = [];
const detectedFeatures = new Set<string>();
lines.forEach((line, index) => {
const upperLine = line.trim().toUpperCase();
// Check for Oracle-specific data types
if (upperLine.includes('VARCHAR2')) {
detectedFeatures.add('VARCHAR2');
oracleTypeLines.push(index + 1);
}
if (
upperLine.match(/\bNUMBER\s*\(/i) ||
upperLine.match(/\bNUMBER\b(?!\s*\()/i)
) {
detectedFeatures.add('NUMBER');
oracleTypeLines.push(index + 1);
}
// Could add more Oracle-specific features in the future:
// - CLOB, BLOB data types
// - ROWNUM pseudo-column
// - CONNECT BY for hierarchical queries
// - MINUS set operator (vs EXCEPT in other DBs)
});
return {
detected: oracleTypeLines.length > 0,
dialect: 'Oracle',
lines: oracleTypeLines,
features: Array.from(detectedFeatures),
};
}
/**
* Create an Oracle SQL error for the target database type
*/
export function createOracleError(
detection: DialectDetectionResult,
targetDatabase: 'MySQL' | 'PostgreSQL' | 'SQL Server' | 'SQLite'
): ValidationError {
const lineList = detection.lines.slice(0, 5).join(', ');
const moreLines =
detection.lines.length > 5
? ` and ${detection.lines.length - 5} more locations`
: '';
const featuresText = detection.features.join(', ');
// Database-specific conversion suggestions
const conversionMap = {
MySQL: 'VARCHAR2 → VARCHAR, NUMBER → INT/DECIMAL/NUMERIC',
PostgreSQL: 'VARCHAR2 → VARCHAR, NUMBER → NUMERIC/INTEGER',
'SQL Server': 'VARCHAR2 → VARCHAR, NUMBER → INT/DECIMAL/NUMERIC',
SQLite: 'VARCHAR2 → TEXT, NUMBER → INTEGER/REAL',
};
return {
line: detection.lines[0],
message: `Oracle SQL syntax detected (${featuresText} types found on lines: ${lineList}${moreLines})`,
type: 'syntax',
suggestion: `This appears to be Oracle SQL. Please convert to ${targetDatabase} syntax: ${conversionMap[targetDatabase]}`,
};
}
/**
* Detect any foreign SQL dialect in the given content
* Returns null if no foreign dialect is detected
*/
export function detectForeignDialect(
lines: string[],
targetDatabase: 'MySQL' | 'PostgreSQL' | 'SQL Server' | 'SQLite'
): ValidationError | null {
// Check for Oracle SQL
const oracleDetection = detectOracleSQL(lines);
if (oracleDetection.detected) {
return createOracleError(oracleDetection, targetDatabase);
}
// Future: Could add detection for other dialects
// - DB2 specific syntax
// - Teradata specific syntax
// - etc.
return null;
}

View File

@@ -8,7 +8,6 @@ import type {
ValidationError,
ValidationWarning,
} from './postgresql-validator';
import { detectForeignDialect } from './dialect-detection';
/**
* Validates MySQL SQL syntax
@@ -35,16 +34,13 @@ export function validateMySQLDialect(sql: string): ValidationResult {
};
}
// TODO: Implement MySQL-specific validation
// For now, just do basic checks
// Check for common MySQL syntax patterns
const lines = sql.split('\n');
let tableCount = 0;
// Check for foreign SQL dialects
const foreignDialectError = detectForeignDialect(lines, 'MySQL');
if (foreignDialectError) {
errors.push(foreignDialectError);
}
lines.forEach((line, index) => {
const trimmedLine = line.trim();

View File

@@ -3,8 +3,6 @@
* Provides user-friendly error messages for common SQL syntax issues
*/
import { detectForeignDialect } from './dialect-detection';
export interface ValidationResult {
isValid: boolean;
errors: ValidationError[];
@@ -214,13 +212,7 @@ export function validatePostgreSQLDialect(sql: string): ValidationResult {
});
}
// 9. Check for foreign SQL dialects
const foreignDialectError = detectForeignDialect(lines, 'PostgreSQL');
if (foreignDialectError) {
errors.push(foreignDialectError);
}
// 10. Count CREATE TABLE statements
// 9. Count CREATE TABLE statements
let tableCount = 0;
const createTableRegex =
/CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?(?:\s+ONLY)?\s+(?:"?[^"\s.]+?"?\.)?["'`]?[^"'`\s.(]+["'`]?/gi;

View File

@@ -8,7 +8,6 @@ import type {
ValidationError,
ValidationWarning,
} from './postgresql-validator';
import { detectForeignDialect } from './dialect-detection';
/**
* Validates SQLite SQL syntax
@@ -42,12 +41,6 @@ export function validateSQLiteDialect(sql: string): ValidationResult {
const lines = sql.split('\n');
let tableCount = 0;
// Check for foreign SQL dialects
const foreignDialectError = detectForeignDialect(lines, 'SQLite');
if (foreignDialectError) {
errors.push(foreignDialectError);
}
lines.forEach((line, index) => {
const trimmedLine = line.trim();

View File

@@ -8,7 +8,6 @@ import type {
ValidationError,
ValidationWarning,
} from './postgresql-validator';
import { detectForeignDialect } from './dialect-detection';
/**
* Validates SQL Server SQL syntax
@@ -35,16 +34,13 @@ export function validateSQLServerDialect(sql: string): ValidationResult {
};
}
// TODO: Implement SQL Server-specific validation
// For now, just do basic checks
// Check for common SQL Server syntax patterns
const lines = sql.split('\n');
let tableCount = 0;
// Check for foreign SQL dialects
const foreignDialectError = detectForeignDialect(lines, 'SQL Server');
if (foreignDialectError) {
errors.push(foreignDialectError);
}
lines.forEach((line, index) => {
const trimmedLine = line.trim();

View File

@@ -0,0 +1,54 @@
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from '@/components/context-menu/context-menu';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { useChartDB } from '@/hooks/use-chartdb';
import type { Area } from '@/lib/domain/area';
import { Pencil, Trash2 } from 'lucide-react';
import React, { useCallback } from 'react';
export interface AreaNodeContextMenuProps {
area: Area;
onEditName?: () => void;
}
export const AreaNodeContextMenu: React.FC<
React.PropsWithChildren<AreaNodeContextMenuProps>
> = ({ children, area, onEditName }) => {
const { removeArea, readonly } = useChartDB();
const { isMd: isDesktop } = useBreakpoint('md');
const removeAreaHandler = useCallback(() => {
removeArea(area.id);
}, [removeArea, area.id]);
if (!isDesktop || readonly) {
return <>{children}</>;
}
return (
<ContextMenu>
<ContextMenuTrigger>{children}</ContextMenuTrigger>
<ContextMenuContent>
{onEditName && (
<ContextMenuItem
onClick={onEditName}
className="flex justify-between gap-3"
>
<span>Edit Area Name</span>
<Pencil className="size-3.5" />
</ContextMenuItem>
)}
<ContextMenuItem
onClick={removeAreaHandler}
className="flex justify-between gap-3"
>
<span>Delete Area</span>
<Trash2 className="size-3.5 text-red-700" />
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};

View File

@@ -1,10 +1,11 @@
import React, { useCallback, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import type { NodeProps, Node } from '@xyflow/react';
import { NodeResizer } from '@xyflow/react';
import type { Area } from '@/lib/domain/area';
import { useChartDB } from '@/hooks/use-chartdb';
import { Input } from '@/components/input/input';
import { useClickAway, useKeyPressEvent } from 'react-use';
import { useEditClickOutside } from '@/hooks/use-click-outside';
import { useKeyPressEvent } from 'react-use';
import {
Tooltip,
TooltipContent,
@@ -12,9 +13,10 @@ import {
} from '@/components/tooltip/tooltip';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { Check, GripVertical } from 'lucide-react';
import { Check, GripVertical, Pencil } from 'lucide-react';
import { Button } from '@/components/button/button';
import { useLayout } from '@/hooks/use-layout';
import { AreaNodeContextMenu } from './area-node-context-menu';
export type AreaNodeType = Node<
{
@@ -35,12 +37,11 @@ export const AreaNode: React.FC<NodeProps<AreaNodeType>> = React.memo(
const focused = !!selected && !dragging;
const editAreaName = useCallback(() => {
if (!editMode) return;
if (areaName.trim()) {
updateArea(area.id, { name: areaName.trim() });
}
setEditMode(false);
}, [areaName, area.id, updateArea, editMode]);
}, [areaName, area.id, updateArea]);
const abortEdit = useCallback(() => {
setEditMode(false);
@@ -52,89 +53,119 @@ export const AreaNode: React.FC<NodeProps<AreaNodeType>> = React.memo(
openAreaFromSidebar(area.id);
}, [selectSidebarSection, openAreaFromSidebar, area.id]);
useClickAway(inputRef, editAreaName);
// Handle click outside to save and exit edit mode
useEditClickOutside(inputRef, editMode, editAreaName);
useKeyPressEvent('Enter', editAreaName);
useKeyPressEvent('Escape', abortEdit);
const enterEditMode = (e: React.MouseEvent) => {
e.stopPropagation();
setEditMode(true);
};
const enterEditMode = useCallback(
(e?: React.MouseEvent) => {
e?.stopPropagation();
setAreaName(area.name);
setEditMode(true);
},
[area.name]
);
useEffect(() => {
if (editMode) {
// Small delay to ensure the input is rendered
const timeoutId = setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, 50);
return () => clearTimeout(timeoutId);
}
}, [editMode]);
return (
<div
className={cn(
'flex h-full flex-col rounded-md border-2 shadow-sm',
selected ? 'border-pink-600' : 'border-transparent'
)}
style={{
backgroundColor: `${area.color}15`,
borderColor: selected ? undefined : area.color,
}}
onClick={(e) => {
if (e.detail === 2) {
openAreaInEditor();
}
}}
>
{!readonly ? (
<NodeResizer
isVisible={focused}
lineClassName="!border-4 !border-transparent"
handleClassName="!h-[10px] !w-[10px] !rounded-full !bg-pink-600"
minHeight={100}
minWidth={100}
/>
) : null}
<div className="group flex h-8 items-center justify-between rounded-t-md px-2">
<div className="flex w-full items-center gap-1">
<GripVertical className="size-4 shrink-0 text-slate-700 opacity-60 dark:text-slate-300" />
<AreaNodeContextMenu area={area} onEditName={enterEditMode}>
<div
className={cn(
'flex h-full flex-col rounded-md border-2 shadow-sm',
selected ? 'border-pink-600' : 'border-transparent'
)}
style={{
backgroundColor: `${area.color}15`,
borderColor: selected ? undefined : area.color,
}}
onClick={(e) => {
if (e.detail === 2) {
openAreaInEditor();
}
}}
>
{!readonly ? (
<NodeResizer
isVisible={focused}
lineClassName="!border-4 !border-transparent"
handleClassName="!h-[10px] !w-[10px] !rounded-full !bg-pink-600"
minHeight={100}
minWidth={100}
/>
) : null}
<div className="group flex h-8 items-center justify-between rounded-t-md px-2">
<div className="flex w-full items-center gap-1">
<GripVertical className="size-4 shrink-0 text-slate-700 opacity-60 dark:text-slate-300" />
{editMode && !readonly ? (
<div className="flex w-full items-center">
<Input
ref={inputRef}
autoFocus
type="text"
placeholder={area.name}
value={areaName}
onClick={(e) => e.stopPropagation()}
onChange={(e) =>
setAreaName(e.target.value)
}
className="h-6 bg-white/70 focus-visible:ring-0 dark:bg-slate-900/70"
/>
{editMode && !readonly ? (
<div className="flex w-full items-center">
<Input
ref={inputRef}
autoFocus
type="text"
placeholder={area.name}
value={areaName}
onClick={(e) => e.stopPropagation()}
onChange={(e) =>
setAreaName(e.target.value)
}
className="h-6 bg-white/70 focus-visible:ring-0 dark:bg-slate-900/70"
/>
<Button
variant="ghost"
className="ml-1 size-6 p-0 hover:bg-white/20"
onClick={editAreaName}
>
<Check className="size-3.5 text-slate-700 dark:text-slate-300" />
</Button>
</div>
) : !readonly ? (
<Tooltip>
<TooltipTrigger asChild>
<div
className="text-editable truncate px-1 py-0.5 text-base font-semibold text-slate-700 dark:text-slate-300"
onDoubleClick={enterEditMode}
>
{area.name}
</div>
</TooltipTrigger>
<TooltipContent>
{t('tool_tips.double_click_to_edit')}
</TooltipContent>
</Tooltip>
) : (
<div className="truncate px-1 py-0.5 text-base font-semibold text-slate-700 dark:text-slate-300">
{area.name}
</div>
)}
{!editMode && !readonly && (
<Button
variant="ghost"
className="ml-1 size-6 p-0 hover:bg-white/20"
onClick={editAreaName}
className="ml-auto size-5 p-0 opacity-0 transition-opacity hover:bg-white/20 group-hover:opacity-100"
onClick={enterEditMode}
>
<Check className="size-3.5 text-slate-700 dark:text-slate-300" />
<Pencil className="size-3 text-slate-700 dark:text-slate-300" />
</Button>
</div>
) : !readonly ? (
<Tooltip>
<TooltipTrigger asChild>
<div
className="text-editable max-w-[200px] cursor-text truncate px-1 py-0.5 text-base font-semibold text-slate-700 dark:text-slate-300"
onDoubleClick={enterEditMode}
>
{area.name}
</div>
</TooltipTrigger>
<TooltipContent>
{t('tool_tips.double_click_to_edit')}
</TooltipContent>
</Tooltip>
) : (
<div className="truncate px-1 py-0.5 text-base font-semibold text-slate-700 dark:text-slate-300">
{area.name}
</div>
)}
)}
</div>
</div>
<div className="flex-1" />
</div>
<div className="flex-1" />
</div>
</AreaNodeContextMenu>
);
}
);

View File

@@ -89,6 +89,7 @@ import { useDiagramFilter } from '@/context/diagram-filter-context/use-diagram-f
import { filterTable } from '@/lib/domain/diagram-filter/filter';
import { defaultSchemas } from '@/lib/data/default-schemas';
import { useDiff } from '@/context/diff-context/use-diff';
import { useClickAway } from 'react-use';
const HIGHLIGHTED_EDGE_Z_INDEX = 1;
const DEFAULT_EDGE_Z_INDEX = 0;
@@ -244,6 +245,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
overlapGraph,
showFilter,
setShowFilter,
setEditTableModeTable,
} = useCanvas();
const { filter, loading: filterLoading } = useDiagramFilter();
const { checkIfNewTable } = useDiff();
@@ -1213,6 +1215,13 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
setTimeout(() => setHighlightOverlappingTables(false), 600);
}, []);
const containerRef = useRef<HTMLDivElement>(null);
const exitEditTableMode = useCallback(
() => setEditTableModeTable(null),
[setEditTableModeTable]
);
useClickAway(containerRef, exitEditTableMode);
const shiftPressed = useKeyPress('Shift');
const operatingSystem = getOperatingSystem();
@@ -1230,7 +1239,11 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
return (
<CanvasContextMenu>
<div className="relative flex h-full" id="canvas">
<div
className="relative flex h-full"
id="canvas"
ref={containerRef}
>
<ReactFlow
onlyRenderVisibleElements
colorMode={effectiveTheme}
@@ -1255,6 +1268,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
panOnScroll={scrollAction === 'pan'}
snapToGrid={shiftPressed || snapToGridEnabled}
snapGrid={[20, 20]}
onPaneClick={exitEditTableMode}
>
<Controls
position="top-left"

View File

@@ -0,0 +1,180 @@
import React, { useEffect } from 'react';
import { KeyRound, Trash2 } from 'lucide-react';
import { Input } from '@/components/input/input';
import { generateDBFieldSuffix, type DBField } from '@/lib/domain/db-field';
import type { DBTable } from '@/lib/domain';
import { useUpdateTableField } from '@/hooks/use-update-table-field';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/tooltip/tooltip';
import { useTranslation } from 'react-i18next';
import { SelectBox } from '@/components/select-box/select-box';
import { cn } from '@/lib/utils';
import { TableFieldToggle } from './table-field-toggle';
export interface TableEditModeFieldProps {
table: DBTable;
field: DBField;
focused?: boolean;
}
export const TableEditModeField: React.FC<TableEditModeFieldProps> = React.memo(
({ table, field, focused = false }) => {
const { t } = useTranslation();
const [showHighlight, setShowHighlight] = React.useState(false);
const {
dataFieldOptions,
handleDataTypeChange,
handlePrimaryKeyToggle,
handleNullableToggle,
handleNameChange,
generateFieldSuffix,
fieldName,
nullable,
primaryKey,
removeField,
} = useUpdateTableField(table, field);
const inputRef = React.useRef<HTMLInputElement>(null);
// Animate the highlight after mount if focused
useEffect(() => {
if (focused) {
const timer = setTimeout(() => {
setShowHighlight(true);
inputRef.current?.select();
setTimeout(() => {
setShowHighlight(false);
}, 2000);
}, 200); // Small delay for the animation to be noticeable
return () => clearTimeout(timer);
} else {
setShowHighlight(false);
}
}, [focused]);
return (
<div
className={cn(
'flex flex-1 flex-row justify-between gap-2 p-1 transition-colors duration-1000 ease-out',
{
'bg-sky-100 dark:bg-sky-950': showHighlight,
}
)}
>
<div className="flex flex-1 items-center justify-start gap-1 overflow-hidden">
<Tooltip>
<TooltipTrigger asChild>
<span className="min-w-0 flex-1">
<Input
ref={inputRef}
className="h-8 w-full !truncate bg-background focus-visible:ring-0"
type="text"
placeholder={t(
'side_panel.tables_section.table.field_name'
)}
value={fieldName}
onChange={(e) =>
handleNameChange(e.target.value)
}
autoFocus={focused}
/>
</span>
</TooltipTrigger>
<TooltipContent>{fieldName}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
className="flex h-8 min-w-0 flex-1"
asChild
>
<span>
<SelectBox
className="flex h-8 min-h-8 w-full bg-background"
popoverClassName="min-w-[200px]"
options={dataFieldOptions}
placeholder={t(
'side_panel.tables_section.table.field_type'
)}
value={field.type.id}
valueSuffix={generateDBFieldSuffix(field)}
optionSuffix={(option) =>
generateFieldSuffix(option.value)
}
onChange={handleDataTypeChange}
emptyPlaceholder={t(
'side_panel.tables_section.table.no_types_found'
)}
commandOnClick={(e) => e.stopPropagation()}
commandOnMouseDown={(e) =>
e.stopPropagation()
}
/>
</span>
</TooltipTrigger>
<TooltipContent>
{field.type.name}
{field.characterMaximumLength
? `(${field.characterMaximumLength})`
: ''}
</TooltipContent>
</Tooltip>
</div>
<div className="flex shrink-0 items-center justify-end gap-1">
<Tooltip>
<TooltipTrigger asChild>
<span>
<TableFieldToggle
pressed={nullable}
onPressedChange={handleNullableToggle}
>
N
</TableFieldToggle>
</span>
</TooltipTrigger>
<TooltipContent>
{t('side_panel.tables_section.table.nullable')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<TableFieldToggle
pressed={primaryKey}
onPressedChange={handlePrimaryKeyToggle}
>
<KeyRound className="h-3.5" />
</TableFieldToggle>
</span>
</TooltipTrigger>
<TooltipContent>
{t('side_panel.tables_section.table.primary_key')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<TableFieldToggle onPressedChange={removeField}>
<Trash2 className="h-3.5 text-red-700" />
</TableFieldToggle>
</span>
</TooltipTrigger>
<TooltipContent>
{t(
'side_panel.tables_section.table.field_actions.delete_field'
)}
</TooltipContent>
</Tooltip>
</div>
</div>
);
}
);
TableEditModeField.displayName = 'TableEditModeField';

View File

@@ -0,0 +1,205 @@
import { Input } from '@/components/input/input';
import type { DBTable } from '@/lib/domain';
import { FileType2, X } from 'lucide-react';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { TableEditModeField } from './table-edit-mode-field';
import { cn } from '@/lib/utils';
import { ScrollArea } from '@/components/scroll-area/scroll-area';
import { Button } from '@/components/button/button';
import { ColorPicker } from '@/components/color-picker/color-picker';
import { Separator } from '@/components/separator/separator';
import { useChartDB } from '@/hooks/use-chartdb';
import { useUpdateTable } from '@/hooks/use-update-table';
import { useTranslation } from 'react-i18next';
import { useClickOutside } from '@/hooks/use-click-outside';
export interface TableEditModeProps {
table: DBTable;
color: string;
focusFieldId?: string;
onClose: () => void;
}
export const TableEditMode: React.FC<TableEditModeProps> = React.memo(
({ table, color, focusFieldId: focusFieldIdProp, onClose }) => {
const containerRef = useRef<HTMLDivElement>(null);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const fieldRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const [isVisible, setIsVisible] = useState(false);
const { createField, updateTable } = useChartDB();
const { t } = useTranslation();
const { tableName, handleTableNameChange } = useUpdateTable(table);
const [focusFieldId, setFocusFieldId] = useState<string | undefined>(
focusFieldIdProp
);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setFocusFieldId(focusFieldIdProp);
if (!focusFieldIdProp) {
inputRef.current?.select();
}
}, [focusFieldIdProp]);
// Callback to store field refs
const setFieldRef = useCallback((fieldId: string) => {
return (element: HTMLDivElement | null) => {
if (element) {
fieldRefs.current.set(fieldId, element);
} else {
fieldRefs.current.delete(fieldId);
}
};
}, []);
useEffect(() => {
// Trigger animation after mount
requestAnimationFrame(() => {
setIsVisible(true);
});
}, []);
const scrollToFieldId = useCallback((fieldId: string) => {
const fieldElement = fieldRefs.current.get(fieldId);
if (fieldElement) {
fieldElement.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
}, []);
// Scroll to focused field when component mounts
useEffect(() => {
if (focusFieldId) {
scrollToFieldId(focusFieldId);
}
}, [focusFieldId, scrollToFieldId]);
// Handle wheel events: allow zoom to pass through, but handle scroll locally
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
// If Ctrl or Cmd is pressed, it's a zoom gesture - let it pass through to canvas
if (e.ctrlKey || e.metaKey) {
return;
}
// Otherwise, it's a scroll - stop propagation to prevent canvas panning
e.stopPropagation();
};
const scrollArea = scrollAreaRef.current;
if (scrollArea) {
// Use passive: false to allow preventDefault if needed
scrollArea.addEventListener('wheel', handleWheel, {
passive: false,
});
return () => {
scrollArea.removeEventListener('wheel', handleWheel);
};
}
}, []);
const handleAddField = useCallback(async () => {
const field = await createField(table.id);
if (field.id) {
setFocusFieldId(field.id);
}
}, [createField, table.id]);
// Close edit mode when clicking outside
useClickOutside(containerRef, onClose, isVisible);
const handleColorChange = useCallback(
(newColor: string) => {
updateTable(table.id, { color: newColor });
},
[updateTable, table.id]
);
return (
<div
ref={containerRef}
className={cn(
'flex z-50 border-slate-500 dark:border-slate-700 flex-col border-2 bg-slate-50 dark:bg-slate-950 rounded-lg shadow-lg absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-auto transition-all duration-100 ease-out',
{
'opacity-100 scale-100': isVisible,
'opacity-0 scale-95': !isVisible,
}
)}
style={{
minHeight: '300px',
minWidth: '350px',
height: 'max(calc(100% + 48px), 200px)',
width: 'max(calc(100% + 48px), 300px)',
}}
onClick={(e) => e.stopPropagation()}
>
<div
className="h-2 rounded-t-[6px]"
style={{ backgroundColor: color }}
></div>
<div className="group flex h-9 items-center justify-between gap-2 bg-slate-200 px-2 dark:bg-slate-900">
<div className="flex min-w-0 flex-1 items-center gap-2">
<ColorPicker
color={color}
onChange={handleColorChange}
disabled={table.isView}
popoverOnMouseDown={(e) => e.stopPropagation()}
popoverOnClick={(e) => e.stopPropagation()}
/>
<Input
ref={inputRef}
className="h-6 flex-1 rounded-sm border-slate-600 bg-background text-sm"
placeholder="Table name"
value={tableName}
onChange={(e) =>
handleTableNameChange(e.target.value)
}
/>
</div>
<Button
variant="ghost"
size="sm"
className="size-6 p-0 hover:bg-slate-300 dark:hover:bg-slate-700"
onClick={onClose}
>
<X className="size-4" />
</Button>
</div>
<ScrollArea ref={scrollAreaRef} className="nodrag flex-1 p-2">
{table.fields.map((field) => (
<div key={field.id} ref={setFieldRef(field.id)}>
<TableEditModeField
table={table}
field={field}
focused={focusFieldId === field.id}
/>
</div>
))}
</ScrollArea>
<Separator />
<div className="flex items-center justify-between p-2">
<Button
variant="outline"
className="h-8 p-2 text-xs"
onClick={handleAddField}
>
<FileType2 className="mr-1 h-4" />
{t('side_panel.tables_section.table.add_field')}
</Button>
<span className="text-xs font-medium text-muted-foreground">
{table.fields.length}{' '}
{t('side_panel.tables_section.table.fields')}
</span>
</div>
</div>
);
}
);
TableEditMode.displayName = 'TableEditMode';

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { Toggle } from '@/components/toggle/toggle';
export const TableFieldToggle = React.forwardRef<
React.ElementRef<typeof Toggle>,
React.ComponentPropsWithoutRef<typeof Toggle>
>((props, ref) => {
return (
<Toggle
{...props}
ref={ref}
variant="default"
className="h-8 w-[32px] p-2 text-xs text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
/>
);
});
TableFieldToggle.displayName = Toggle.displayName;

View File

@@ -13,13 +13,12 @@ import {
} from '@xyflow/react';
import { Button } from '@/components/button/button';
import {
Check,
KeyRound,
MessageCircleMore,
SquareDot,
SquareMinus,
SquarePlus,
Trash2,
Pencil,
} from 'lucide-react';
import { generateDBFieldSuffix, type DBField } from '@/lib/domain/db-field';
import { useChartDB } from '@/hooks/use-chartdb';
@@ -29,14 +28,14 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/tooltip/tooltip';
import { useClickAway, useKeyPressEvent } from 'react-use';
import { Input } from '@/components/input/input';
import { useDiff } from '@/context/diff-context/use-diff';
import { useLocalConfig } from '@/hooks/use-local-config';
import {
BOTTOM_SOURCE_HANDLE_ID_PREFIX,
TOP_SOURCE_HANDLE_ID_PREFIX,
} from './table-node-dependency-indicator';
import { useCanvas } from '@/hooks/use-canvas';
import { useLayout } from '@/hooks/use-layout';
export const LEFT_HANDLE_ID_PREFIX = 'left_rel_';
export const RIGHT_HANDLE_ID_PREFIX = 'right_rel_';
@@ -78,16 +77,7 @@ const arePropsEqual = (
export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
({ field, focused, tableNodeId, highlighted, visible, isConnectable }) => {
const {
removeField,
relationships,
readonly,
updateField,
highlightedCustomType,
} = useChartDB();
const [editMode, setEditMode] = useState(false);
const [fieldName, setFieldName] = useState(field.name);
const inputRef = React.useRef<HTMLInputElement>(null);
const { relationships, readonly, highlightedCustomType } = useChartDB();
const updateNodeInternals = useUpdateNodeInternals();
const connection = useConnection();
@@ -152,23 +142,6 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
}
}, [tableNodeId, updateNodeInternals, numberOfEdgesToField]);
const editFieldName = useCallback(() => {
if (!editMode) return;
if (fieldName.trim()) {
updateField(tableNodeId, field.id, { name: fieldName.trim() });
}
setEditMode(false);
}, [fieldName, field.id, updateField, editMode, tableNodeId]);
const abortEdit = useCallback(() => {
setEditMode(false);
setFieldName(field.name);
}, [field.name]);
useClickAway(inputRef, editFieldName);
useKeyPressEvent('Enter', editFieldName);
useKeyPressEvent('Escape', abortEdit);
const {
checkIfFieldRemoved,
checkIfNewField,
@@ -272,17 +245,32 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
fieldDiffChangedPrecision,
} = diffState;
const enterEditMode = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setEditMode(true);
}, []);
const isCustomTypeHighlighted = useMemo(() => {
if (!highlightedCustomType) return false;
return field.type.name === highlightedCustomType.name;
}, [highlightedCustomType, field.type.name]);
const { showFieldAttributes } = useLocalConfig();
const { closeAllTablesInSidebar } = useLayout();
const { setEditTableModeTable } = useCanvas();
const openEditTableOnField = useCallback(() => {
if (readonly) {
return;
}
closeAllTablesInSidebar();
setEditTableModeTable({
tableId: tableNodeId,
fieldId: field.id,
});
}, [
setEditTableModeTable,
closeAllTablesInSidebar,
tableNodeId,
field.id,
readonly,
]);
return (
<div
className={cn(
@@ -354,7 +342,6 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
'flex items-center gap-1 min-w-0 flex-1 text-left',
{
'font-semibold': field.primaryKey || field.unique,
'w-full': editMode,
}
)}
>
@@ -365,54 +352,31 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
) : isDiffFieldChanged && !isSummaryOnly ? (
<SquareDot className="size-3.5 shrink-0 text-sky-800 dark:text-sky-200" />
) : null}
{editMode && !readonly ? (
<>
<Input
ref={inputRef}
onBlur={editFieldName}
placeholder={field.name}
autoFocus
type="text"
value={fieldName}
onClick={(e) => e.stopPropagation()}
onChange={(e) => setFieldName(e.target.value)}
className="h-5 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={editFieldName}
>
<Check className="size-4" />
</Button>
</>
) : (
<span
className={cn('truncate min-w-0', {
'text-red-800 font-normal dark:text-red-200':
isDiffFieldRemoved,
'text-green-800 font-normal dark:text-green-200':
isDiffNewField,
'text-sky-800 font-normal dark:text-sky-200':
isDiffFieldChanged &&
!isSummaryOnly &&
!isDiffFieldRemoved &&
!isDiffNewField,
})}
onDoubleClick={enterEditMode}
>
{fieldDiffChangedName ? (
<>
{field.name}{' '}
<span className="font-medium"></span>{' '}
{fieldDiffChangedName}
</>
) : (
field.name
)}
</span>
)}
{field.comments && !editMode ? (
<span
className={cn('truncate min-w-0', {
'text-red-800 font-normal dark:text-red-200':
isDiffFieldRemoved,
'text-green-800 font-normal dark:text-green-200':
isDiffNewField,
'text-sky-800 font-normal dark:text-sky-200':
isDiffFieldChanged &&
!isSummaryOnly &&
!isDiffFieldRemoved &&
!isDiffNewField,
})}
>
{fieldDiffChangedName ? (
<>
{field.name}{' '}
<span className="font-medium"></span>{' '}
{fieldDiffChangedName}
</>
) : (
field.name
)}
</span>
{field.comments ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="shrink-0 cursor-pointer text-muted-foreground">
@@ -423,37 +387,14 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
</Tooltip>
) : null}
</div>
{editMode ? null : (
<div className="ml-2 flex shrink-0 items-center justify-end gap-1.5">
{(field.primaryKey &&
fieldDiffChangedPrimaryKey === null) ||
fieldDiffChangedPrimaryKey ? (
<div
className={cn(
'text-muted-foreground',
!readonly ? 'group-hover:hidden' : '',
isDiffFieldRemoved
? 'text-red-800 dark:text-red-200'
: '',
isDiffNewField
? 'text-green-800 dark:text-green-200'
: '',
isDiffFieldChanged &&
!isSummaryOnly &&
!isDiffFieldRemoved &&
!isDiffNewField
? 'text-sky-800 dark:text-sky-200'
: ''
)}
>
<KeyRound size={14} />
</div>
) : null}
<div className="ml-2 flex shrink-0 items-center justify-end gap-1.5">
{(field.primaryKey &&
fieldDiffChangedPrimaryKey === null) ||
fieldDiffChangedPrimaryKey ? (
<div
className={cn(
'content-center text-right text-xs text-muted-foreground overflow-hidden max-w-[8rem]',
field.primaryKey ? 'min-w-0' : 'min-w-[3rem]',
'text-muted-foreground',
!readonly ? 'group-hover:hidden' : '',
isDiffFieldRemoved
? 'text-red-800 dark:text-red-200'
@@ -462,74 +403,92 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
? 'text-green-800 dark:text-green-200'
: '',
isDiffFieldChanged &&
!isDiffFieldRemoved &&
!isSummaryOnly &&
!isDiffFieldRemoved &&
!isDiffNewField
? 'text-sky-800 dark:text-sky-200'
: ''
)}
>
<span className="block truncate">
{fieldDiffChangedType ? (
<>
<span className="line-through">
{field.type.name.split(' ')[0]}
</span>{' '}
{
fieldDiffChangedType.name.split(
' '
)[0]
}
</>
) : (
`${field.type.name.split(' ')[0]}${
showFieldAttributes
? generateDBFieldSuffix({
...field,
...{
precision:
fieldDiffChangedPrecision ??
field.precision,
scale:
fieldDiffChangedScale ??
field.scale,
characterMaximumLength:
fieldDiffChangedCharacterMaximumLength ??
field.characterMaximumLength,
},
})
: ''
}`
)}
{fieldDiffChangedNullable !== null ? (
fieldDiffChangedNullable ? (
<span className="font-semibold">?</span>
) : (
<span className="line-through">?</span>
)
) : field.nullable ? (
'?'
) : (
''
)}
</span>
<KeyRound size={14} />
</div>
{readonly ? null : (
<div className="hidden flex-row group-hover:flex">
<Button
variant="ghost"
className="size-6 p-0 hover:bg-primary-foreground"
onClick={(e) => {
e.stopPropagation();
removeField(tableNodeId, field.id);
}}
>
<Trash2 className="size-3.5 text-red-700" />
</Button>
</div>
) : null}
<div
className={cn(
'content-center text-right text-xs text-muted-foreground overflow-hidden max-w-[8rem]',
field.primaryKey ? 'min-w-0' : 'min-w-[3rem]',
!readonly ? 'group-hover:hidden' : '',
isDiffFieldRemoved
? 'text-red-800 dark:text-red-200'
: '',
isDiffNewField
? 'text-green-800 dark:text-green-200'
: '',
isDiffFieldChanged &&
!isDiffFieldRemoved &&
!isSummaryOnly &&
!isDiffNewField
? 'text-sky-800 dark:text-sky-200'
: ''
)}
>
<span className="block truncate">
{fieldDiffChangedType ? (
<>
<span className="line-through">
{field.type.name.split(' ')[0]}
</span>{' '}
{fieldDiffChangedType.name.split(' ')[0]}
</>
) : (
`${field.type.name.split(' ')[0]}${
showFieldAttributes
? generateDBFieldSuffix({
...field,
...{
precision:
fieldDiffChangedPrecision ??
field.precision,
scale:
fieldDiffChangedScale ??
field.scale,
characterMaximumLength:
fieldDiffChangedCharacterMaximumLength ??
field.characterMaximumLength,
},
})
: ''
}`
)}
{fieldDiffChangedNullable !== null ? (
fieldDiffChangedNullable ? (
<span className="font-semibold">?</span>
) : (
<span className="line-through">?</span>
)
) : field.nullable ? (
'?'
) : (
''
)}
</span>
</div>
)}
{readonly ? null : (
<div className="hidden flex-row group-hover:flex">
<Button
variant="ghost"
className="size-6 p-0 hover:bg-primary-foreground"
onClick={(e) => {
e.stopPropagation();
openEditTableOnField();
}}
>
<Pencil className="!size-3.5 text-pink-600" />
</Button>
</div>
)}
</div>
</div>
);
},

View File

@@ -14,7 +14,6 @@ import {
Table2,
ChevronDown,
ChevronUp,
Check,
CircleDotDashed,
SquareDot,
SquarePlus,
@@ -38,8 +37,6 @@ import { TableNodeContextMenu } from './table-node-context-menu';
import { cn } from '@/lib/utils';
import { TableNodeDependencyIndicator } from './table-node-dependency-indicator';
import type { EdgeType } from '../canvas';
import { Input } from '@/components/input/input';
import { useClickAway, useKeyPressEvent } from 'react-use';
import {
Tooltip,
TooltipContent,
@@ -47,6 +44,8 @@ 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/table-edit-mode';
import { useCanvas } from '@/hooks/use-canvas';
export type TableNodeType = Node<
{
@@ -74,13 +73,29 @@ 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 { setEditTableModeTable, editTableModeTable } = useCanvas();
// Get edit mode state directly from context
const editTableMode = useMemo(
() => editTableModeTable?.tableId === table.id,
[editTableModeTable, table.id]
);
const editTableModeFieldId = useMemo(
() => (editTableMode ? editTableModeTable?.fieldId : null),
[editTableMode, editTableModeTable]
);
// Store the initial field count when entering edit mode to keep table height fixed
const [editModeInitialFieldCount, setEditModeInitialFieldCount] =
useState<number | null>(null);
const connection = useConnection();
@@ -101,6 +116,17 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
const fields = useMemo(() => table.fields, [table.fields]);
// Effect to manage field count when entering/exiting edit mode
useEffect(() => {
if (editTableMode && editModeInitialFieldCount === null) {
// Entering edit mode - capture current field count
setEditModeInitialFieldCount(fields.length);
} else if (!editTableMode && editModeInitialFieldCount !== null) {
// Exiting edit mode - reset
setEditModeInitialFieldCount(null);
}
}, [editTableMode, fields.length, editModeInitialFieldCount]);
const tableChangedName = useMemo(
() => getTableNewName({ tableId: table.id }),
[getTableNewName, table.id]
@@ -235,14 +261,20 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
}, [relationships]);
const visibleFields = useMemo(() => {
if (expanded || fields.length <= TABLE_MINIMIZED_FIELDS) {
return fields;
// If in edit mode, use the initial field count to keep consistent height
const fieldsToConsider =
editTableMode && editModeInitialFieldCount !== null
? fields.slice(0, editModeInitialFieldCount)
: fields;
if (expanded || fieldsToConsider.length <= TABLE_MINIMIZED_FIELDS) {
return fieldsToConsider;
}
const mustDisplayedFields: DBField[] = [];
const nonMustDisplayedFields: DBField[] = [];
for (const field of fields) {
for (const field of fieldsToConsider) {
if (relatedFieldIds.has(field.id) || field.primaryKey) {
mustDisplayedFields.push(field);
} else {
@@ -269,40 +301,18 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
...visibleMustDisplayedFields,
...visibleNonMustDisplayedFields,
]);
const result = fields.filter((field) =>
const result = fieldsToConsider.filter((field) =>
visibleFieldsSet.has(field)
);
return result;
}, [expanded, fields, relatedFieldIds]);
const editTableName = useCallback(() => {
if (!editMode) return;
if (tableName.trim()) {
updateTable(table.id, { name: tableName.trim() });
}
setEditMode(false);
}, [tableName, table.id, updateTable, editMode]);
const abortEdit = useCallback(() => {
setEditMode(false);
setTableName(table.name);
}, [table.name]);
useClickAway(inputRef, editTableName);
useKeyPressEvent('Enter', editTableName);
useKeyPressEvent('Escape', abortEdit);
const enterEditMode = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setEditMode(true);
}, []);
React.useEffect(() => {
if (table.name.trim()) {
setTableName(table.name.trim());
}
}, [table.name]);
}, [
expanded,
fields,
relatedFieldIds,
editTableMode,
editModeInitialFieldCount,
]);
const tableClassName = useMemo(
() =>
@@ -337,7 +347,9 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
: '',
isDiffTableRemoved
? 'outline outline-[3px] outline-red-500 dark:outline-red-900 outline-offset-[5px]'
: ''
: editTableMode
? 'invisible'
: ''
),
[
selected,
@@ -350,16 +362,45 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
isDiffNewTable,
isDiffTableRemoved,
isTarget,
editTableMode,
]
);
const enterEditTableMode = useCallback(() => {
if (readonly) {
return;
}
closeAllTablesInSidebar();
setEditTableModeTable({ tableId: table.id });
}, [
table.id,
setEditTableModeTable,
closeAllTablesInSidebar,
readonly,
]);
const exitEditTableMode = useCallback(() => {
setEditTableModeTable(null);
}, [setEditTableModeTable]);
return (
<TableNodeContextMenu table={table}>
{editTableMode ? (
<TableEditMode
table={table}
color={tableColor}
focusFieldId={editTableModeFieldId ?? undefined}
onClose={() => {
exitEditTableMode();
}}
/>
) : null}
<div
className={tableClassName}
onClick={(e) => {
if (e.detail === 2) {
openTableInEditor();
if (e.detail === 2 && !readonly) {
enterEditTableMode();
}
}}
onMouseEnter={() => setIsHovering(true)}
@@ -456,47 +497,14 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
<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>
</>
) : (
<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>
<Label className="truncate px-2 py-0.5 text-sm font-bold">
{table.name}
</Label>
)}
</div>
<div className="hidden shrink-0 flex-row group-hover:flex">
{readonly || editMode ? null : (
{readonly ? 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"
@@ -505,30 +513,28 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
<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" />
) : (
<ChevronsRightLeft className="size-4" />
)}
</Button>
)}
<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="transition-[max-height] duration-200 ease-in-out"
style={{
maxHeight: expanded
? `${fields.length * 2}rem` // h-8 per field
? `${(editTableMode && editModeInitialFieldCount !== null ? editModeInitialFieldCount : fields.length) * 2}rem` // h-8 per field
: `${TABLE_MINIMIZED_FIELDS * 2}rem`, // h-8 per field
}}
>
@@ -544,7 +550,9 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
/>
))}
</div>
{fields.length > TABLE_MINIMIZED_FIELDS && (
{(editTableMode && editModeInitialFieldCount !== null
? editModeInitialFieldCount
: 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}

View File

@@ -11,7 +11,8 @@ import type { DBRelationship } from '@/lib/domain/db-relationship';
import { useReactFlow } from '@xyflow/react';
import { useChartDB } from '@/hooks/use-chartdb';
import { useFocusOn } from '@/hooks/use-focus-on';
import { useClickAway, useKeyPressEvent } from 'react-use';
import { useEditClickOutside } from '@/hooks/use-click-outside';
import { useKeyPressEvent } from 'react-use';
import {
DropdownMenu,
DropdownMenuContent,
@@ -42,31 +43,37 @@ export const RelationshipListItemHeader: React.FC<
const inputRef = React.useRef<HTMLInputElement>(null);
const editRelationshipName = useCallback(() => {
if (!editMode) return;
if (relationshipName.trim() && relationshipName !== relationship.name) {
updateRelationship(relationship.id, {
name: relationshipName.trim(),
});
}
setEditMode(false);
}, [
relationshipName,
relationship.id,
updateRelationship,
editMode,
relationship.name,
]);
useClickAway(inputRef, editRelationshipName);
useKeyPressEvent('Enter', editRelationshipName);
const abortEdit = useCallback(() => {
setEditMode(false);
setRelationshipName(relationship.name);
}, [relationship.name]);
const enterEditMode = (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
event.stopPropagation();
setEditMode(true);
};
// Handle click outside to save and exit edit mode
useEditClickOutside(inputRef, editMode, editRelationshipName);
useKeyPressEvent('Enter', editRelationshipName);
useKeyPressEvent('Escape', abortEdit);
const enterEditMode = useCallback(
(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
event.stopPropagation();
setRelationshipName(relationship.name);
setEditMode(true);
},
[relationship.name]
);
const handleFocusOnRelationship = useCallback(
(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {

View File

@@ -1,13 +1,9 @@
import React, { useCallback, useMemo } from 'react';
import React from 'react';
import { GripVertical, KeyRound } from 'lucide-react';
import { Input } from '@/components/input/input';
import { generateDBFieldSuffix, type DBField } from '@/lib/domain/db-field';
import { useChartDB } from '@/hooks/use-chartdb';
import type { DataTypeData } from '@/lib/data/data-types/data-types';
import {
dataTypeDataToDataType,
sortedDataTypeMap,
} from '@/lib/data/data-types/data-types';
import { useUpdateTableField } from '@/hooks/use-update-table-field';
import {
Tooltip,
TooltipContent,
@@ -17,10 +13,6 @@ import { useTranslation } from 'react-i18next';
import { TableFieldToggle } from './table-field-toggle';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type {
SelectBoxOption,
SelectBoxProps,
} from '@/components/select-box/select-box';
import { SelectBox } from '@/components/select-box/select-box';
import { TableFieldPopover } from './table-field-modal/table-field-modal';
import type { DBTable } from '@/lib/domain';
@@ -32,213 +24,35 @@ export interface TableFieldProps {
removeField: () => void;
}
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 TableField: React.FC<TableFieldProps> = ({
table,
field,
updateField,
removeField,
}) => {
const { databaseType, customTypes, readonly } = useChartDB();
const { databaseType, readonly } = useChartDB();
const { t } = useTranslation();
// Only calculate primary key fields, not just count
const primaryKeyFields = useMemo(() => {
return table.fields.filter((f) => f.primaryKey);
}, [table.fields]);
const primaryKeyCount = primaryKeyFields.length;
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: field.id });
const dataFieldOptions = useMemo(() => {
const standardTypes: SelectBoxOption[] = sortedDataTypeMap[
databaseType
].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];
}, [databaseType, customTypes]);
const onChangeDataType = useCallback<
NonNullable<SelectBoxProps['onChange']>
>(
(value, regexMatches) => {
const dataType = sortedDataTypeMap[databaseType].find(
(v) => v.id === value
) ?? {
id: value as string,
name: value as string,
};
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 {
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;
}
}
updateField({
characterMaximumLength,
precision,
scale,
increment: undefined,
default: undefined,
type: dataTypeDataToDataType(
dataType ?? {
id: value as string,
name: value as string,
}
),
});
},
[
updateField,
databaseType,
field.characterMaximumLength,
field.precision,
field.scale,
]
);
const {
dataFieldOptions,
handleDataTypeChange,
handlePrimaryKeyToggle,
handleNullableToggle,
handleNameChange,
generateFieldSuffix,
fieldName,
nullable,
primaryKey,
} = useUpdateTableField(table, field, updateField);
const style = {
transform: CSS.Translate.toString(transform),
transition,
};
const handlePrimaryKeyToggle = useCallback(
(value: boolean) => {
if (value) {
// When setting as primary key
const updates: Partial<DBField> = {
primaryKey: true,
};
// Only auto-set unique if this will be the only primary key
if (primaryKeyCount === 0) {
updates.unique = true;
}
updateField(updates);
} else {
// When removing primary key
updateField({
primaryKey: false,
});
}
},
[primaryKeyCount, updateField]
);
const handleNullableToggle = useCallback(
(value: boolean) => {
updateField({ nullable: value });
},
[updateField]
);
const handleNameChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
updateField({ name: e.target.value });
},
[updateField]
);
return (
<div
className="flex flex-1 touch-none flex-row justify-between gap-2 p-1"
@@ -264,8 +78,10 @@ export const TableField: React.FC<TableFieldProps> = ({
placeholder={t(
'side_panel.tables_section.table.field_name'
)}
value={field.name}
onChange={handleNameChange}
value={fieldName}
onChange={(e) =>
handleNameChange(e.target.value)
}
readOnly={readonly}
/>
</span>
@@ -285,13 +101,9 @@ export const TableField: React.FC<TableFieldProps> = ({
value={field.type.id}
valueSuffix={generateDBFieldSuffix(field)}
optionSuffix={(option) =>
generateDBFieldSuffix(field, {
databaseType,
forceExtended: true,
typeId: option.value,
})
generateFieldSuffix(option.value)
}
onChange={onChangeDataType}
onChange={handleDataTypeChange}
emptyPlaceholder={t(
'side_panel.tables_section.table.no_types_found'
)}
@@ -312,7 +124,7 @@ export const TableField: React.FC<TableFieldProps> = ({
<TooltipTrigger asChild>
<span>
<TableFieldToggle
pressed={field.nullable}
pressed={nullable}
onPressedChange={handleNullableToggle}
disabled={readonly}
>
@@ -328,7 +140,7 @@ export const TableField: React.FC<TableFieldProps> = ({
<TooltipTrigger asChild>
<span>
<TableFieldToggle
pressed={field.primaryKey}
pressed={primaryKey}
onPressedChange={handlePrimaryKeyToggle}
disabled={readonly}
>

View File

@@ -15,7 +15,8 @@ import { ListItemHeaderButton } from '@/pages/editor-page/side-panel/list-item-h
import type { DBTable } from '@/lib/domain/db-table';
import { Input } from '@/components/input/input';
import { useChartDB } from '@/hooks/use-chartdb';
import { useClickAway, useKeyPressEvent } from 'react-use';
import { useEditClickOutside } from '@/hooks/use-click-outside';
import { useKeyPressEvent } from 'react-use';
import { useSortable } from '@dnd-kit/sortable';
import {
DropdownMenu,
@@ -67,27 +68,30 @@ export const TableListItemHeader: React.FC<TableListItemHeaderProps> = ({
const { listeners } = useSortable({ id: table.id });
const editTableName = useCallback(() => {
if (!editMode) return;
if (tableName.trim()) {
updateTable(table.id, { name: tableName.trim() });
}
setEditMode(false);
}, [tableName, table.id, updateTable, editMode]);
}, [tableName, table.id, updateTable]);
const abortEdit = useCallback(() => {
setEditMode(false);
setTableName(table.name);
}, [table.name]);
useClickAway(inputRef, editTableName);
// Handle click outside to save and exit edit mode
useEditClickOutside(inputRef, editMode, editTableName);
useKeyPressEvent('Enter', editTableName);
useKeyPressEvent('Escape', abortEdit);
const enterEditMode = (e: React.MouseEvent) => {
e.stopPropagation();
setEditMode(true);
};
const enterEditMode = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
setTableName(table.name);
setEditMode(true);
},
[table.name]
);
const handleFocusOnTable = useCallback(
(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
@@ -249,6 +253,20 @@ export const TableListItemHeader: React.FC<TableListItemHeaderProps> = ({
}
}, [table.name]);
useEffect(() => {
if (editMode) {
// Small delay to ensure the input is rendered
const timeoutId = setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, 50);
return () => clearTimeout(timeoutId);
}
}, [editMode]);
return (
<div className="group flex h-11 flex-1 items-center justify-between gap-1 overflow-hidden">
{!readonly ? (

View File

@@ -1,9 +1,10 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useEditClickOutside } from '@/hooks/use-click-outside';
import { Button } from '@/components/button/button';
import { Check } from 'lucide-react';
import { Check, Pencil } from 'lucide-react';
import { Input } from '@/components/input/input';
import { useChartDB } from '@/hooks/use-chartdb';
import { useClickAway, useKeyPressEvent } from 'react-use';
import { useKeyPressEvent } from 'react-use';
import { DiagramIcon } from '@/components/diagram-icon/diagram-icon';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
@@ -31,23 +32,45 @@ export const DiagramName: React.FC<DiagramNameProps> = () => {
setEditedDiagramName(diagramName);
}, [diagramName]);
useEffect(() => {
if (editMode) {
// Small delay to ensure the input is rendered
const timeoutId = setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, 50); // Slightly longer delay to ensure DOM is ready
return () => clearTimeout(timeoutId);
}
}, [editMode]);
const editDiagramName = useCallback(() => {
if (!editMode) return;
if (editedDiagramName.trim()) {
updateDiagramName(editedDiagramName.trim());
}
setEditMode(false);
}, [editedDiagramName, updateDiagramName, editMode]);
}, [editedDiagramName, updateDiagramName]);
useClickAway(inputRef, editDiagramName);
const abortEdit = useCallback(() => {
setEditMode(false);
setEditedDiagramName(diagramName);
}, [diagramName]);
// Handle click outside to save and exit edit mode
useEditClickOutside(inputRef, editMode, editDiagramName);
useKeyPressEvent('Enter', editDiagramName);
useKeyPressEvent('Escape', abortEdit);
const enterEditMode = (
event: React.MouseEvent<HTMLHeadingElement, MouseEvent>
) => {
event.stopPropagation();
setEditMode(true);
};
const enterEditMode = useCallback(
(event: React.MouseEvent<HTMLElement, MouseEvent>) => {
event.stopPropagation();
setEditedDiagramName(diagramName);
setEditMode(true);
},
[diagramName]
);
return (
<div className="group">
@@ -81,10 +104,16 @@ export const DiagramName: React.FC<DiagramNameProps> = () => {
setEditedDiagramName(e.target.value)
}
className="ml-1 h-7 focus-visible:ring-0"
style={{
width: `${Math.max(
editedDiagramName.length * 8 + 20,
100
)}px`,
}}
/>
<Button
variant="ghost"
className="flex size-7 p-2 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300"
className="ml-1 flex size-7 p-2 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300"
onClick={editDiagramName}
>
<Check />
@@ -110,6 +139,13 @@ export const DiagramName: React.FC<DiagramNameProps> = () => {
{t('tool_tips.double_click_to_edit')}
</TooltipContent>
</Tooltip>
<Button
variant="ghost"
className="ml-1 size-5 p-0 opacity-0 transition-opacity hover:bg-primary-foreground group-hover:opacity-100"
onClick={enterEditMode}
>
<Pencil className="size-3 text-slate-500 dark:text-slate-400" />
</Button>
</>
)}
</div>